Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

### Added

- `base44 sessions import` continues a Claude Code or Codex session in Base Code and prints the branch link. `--current` picks the session the command runs inside (what Base Code's Import session prompt runs), a claude.ai session address pulls a cloud session down with `claude --teleport`, `--file` uploads a file as is, and no argument opens a picker. `--token <b44imp_…>` uses the one-time key from Base Code instead of the CLI login. Before uploading the current session it stops if the work isn't on GitHub yet (Base Code starts from there) — `--skip-push-check` imports without it — and it sends the current branch as `source_branch` for the new branch to start from.
- `base44 sessions import` continues a Claude Code or Codex session in Base Code and prints the branch link. `--current` picks the session the command runs inside (what Base Code's Import session prompt runs), a claude.ai session address pulls a cloud session down with `claude --teleport`, `--file` uploads a file as is, and no argument opens a picker. `--token <b44imp_…>` uses the one-time key from Base Code instead of the CLI login. Before uploading the current session it stops if the work isn't on GitHub yet (Base Code starts from there) — `--skip-push-check` imports without it — and it sends the current branch as `source_branch` for the new branch to start from. Each upload carries an `Idempotency-Key`, so a retry never creates a second branch.
- `base44 site install` runs the site's `installCommand` and nothing else, so a machine that only needs a project's dependencies no longer has to go through `create`, `scaffold` or `eject`. Local only: no login and no app id required.
- `base44 site dev` runs the site's `serveCommand` exactly as written, with no local backend and the frontend reaching its backend same-origin — the shape a hosted sandbox needs, where `base44 dev` (local backend) and `dev --remote` (the published app) are the developer-machine paths. It appends nothing of its own — anything after `--` is appended as given, so `npm run dev` needs a second `--` to reach the script (`base44 site dev -- -- --config wrapper.mjs`) — and where the dev server binds is the command's own business, and in a sandbox `@base44/vite-plugin` binds Base44 apps to `0.0.0.0:5173`. Serving is its whole job, so it falls back to `npm run dev` when the block names none.

Expand Down
1 change: 1 addition & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Omitting the flag preserves existing main-app behavior.
- A claude.ai link runs `claude --teleport <session_id> -p "Reply with OK."` in a temp clone of this checkout's `origin`, strips that exchange, then deletes the clone and its `~/.claude/projects` folder. Tests swap the binary with the `claudeBin` test override.
- **Push check.** For `--current` and the picker, the checkout's work must be on GitHub, because Base Code starts the branch from there. Uncommitted changes, unpushed commits, a branch with no upstream or a deleted one (`[gone]`) stop the command before the upload, so the one-time key stays valid for the re-run. `--skip-push-check` imports anyway and says the local changes aren't included. Outside a git checkout there is no check. A link (the changes live in the cloud session) and `--file` skip it.
- **`source_branch`.** The current branch (`git rev-parse --abbrev-ref HEAD`, left out when detached) goes with the upload as a multipart field. The backend starts the new branch from it when it's on GitHub, otherwise from the branch recorded in the file, then the repo default.
- **`Idempotency-Key`.** Each run sends one fresh UUID on the upload, shared by any retry within the run, so a retried upload gets the first response back instead of creating a second branch. 409 `idempotency_key_in_use` means that first upload is still running.
- **Per-command key auth.** `--token` is a one-time `b44imp_` key. The command is `requireAuth: false` and calls `ensureAuth` only without a key. The key goes through a bare `ky` call, never `base44Client`: its auth hook would replace the header, and its 401 handler would refresh and retry as the stored login.
- Errors are rebuilt as plain messages an agent can relay, with no request or response body (the body is the conversation). The action replaces the reported command context so the link and `--token` never reach telemetry.
- When the session's pushed branch already exists in Base Code, the upload answers 409 `branch_exists` with `details.branch_url`, and the command prints that link. A success with `continued: true` means Base Code continued on the session's own branch rather than creating one.
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/core/session-import/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import ky, { HTTPError, type KyResponse } from "ky";
import { z } from "zod";
import { getAppClient } from "@/core/clients/index.js";
Expand Down Expand Up @@ -52,6 +53,9 @@ function messageFor(
if (code === "branch_exists" && details?.branch_url) {
return `Base Code already has ${details.branch_name ?? "a branch for this session"}. Open it: ${new URL(details.branch_url, getBase44ApiUrl())}`;
}
if (code === "idempotency_key_in_use") {
return "An import of this session is still running. Wait a moment and try again.";
}
if (code === "repository_not_connected") {
return "Connect this app to its GitHub repository in Base Code first.";
}
Expand Down Expand Up @@ -128,6 +132,9 @@ export async function uploadSession(
form.append("file", new Blob([content]), "session.jsonl");
// The backend starts the new branch from this one when it's on GitHub.
if (sourceBranch) form.append("source_branch", sourceBranch);
// One key per run, shared by any retry of it: a retried upload gets the first
// response back instead of creating a second branch.
const idempotencyKey = randomUUID();

let response: KyResponse;
try {
Expand All @@ -142,12 +149,14 @@ export async function uploadSession(
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": "Base44 CLI",
"Idempotency-Key": idempotencyKey,
},
timeout: 180_000,
},
)
: await getAppClient().post("imported/session-imports", {
body: form,
headers: { "Idempotency-Key": idempotencyKey },
timeout: 180_000,
});
} catch (error) {
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/tests/cli/sessions_import.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const claudeLines = (cwd: string, text: string) =>

interface Upload {
authorization?: string;
idempotencyKey?: string;
body: string;
}

Expand All @@ -52,6 +53,7 @@ describe("sessions import command", () => {
t.api.mockRoute("POST", ROUTE, (req, res) => {
uploads.push({
authorization: req.headers.authorization,
idempotencyKey: req.headers["idempotency-key"] as string | undefined,
body: (req.body as Buffer).toString("utf8"),
});
res.status(status).json(body);
Expand Down Expand Up @@ -253,6 +255,11 @@ describe("sessions import command", () => {
"This session is from a different repository than the app.",
],
[429, "rate_limited", "Too many imports. Wait a minute and try again."],
[
409,
"idempotency_key_in_use",
"An import of this session is still running. Wait a moment and try again.",
],
[
409,
"repository_not_connected",
Expand Down Expand Up @@ -355,6 +362,21 @@ describe("sessions import command", () => {
expect(uploads).toHaveLength(0);
});

it("sends a fresh Idempotency-Key on each run", async () => {
const file = await plant("s.jsonl", claudeLines("/x", "hi"));
mockUpload();
const args = ["sessions", "import", "--file", file, "--app-id", APP_ID];

await t.run(...args, "--token", TOKEN);
await t.givenLoggedIn({ email: "me@example.com", name: "Me" });
await t.run(...args);

const [first, second] = uploads.map((upload) => upload.idempotencyKey);
expect(first).toMatch(/^[0-9a-f-]{36}$/);
expect(second).toMatch(/^[0-9a-f-]{36}$/);
expect(first).not.toBe(second);
});

it("uses the stored login when no key is given", async () => {
await t.givenLoggedIn({ email: "me@example.com", name: "Me" });
const file = await plant("s.jsonl", claudeLines("/x", "hi"));
Expand Down
Loading