diff --git a/CHANGELOG.md b/CHANGELOG.md index 82bea9c3..dd37d2a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 ` 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. diff --git a/docs/commands.md b/docs/commands.md index e31c5a79..54b9560c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -54,6 +54,7 @@ Omitting the flag preserves existing main-app behavior. - A claude.ai link runs `claude --teleport -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. diff --git a/packages/cli/src/core/session-import/api.ts b/packages/cli/src/core/session-import/api.ts index 89fda85c..f10bf712 100644 --- a/packages/cli/src/core/session-import/api.ts +++ b/packages/cli/src/core/session-import/api.ts @@ -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"; @@ -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."; } @@ -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 { @@ -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) { diff --git a/packages/cli/tests/cli/sessions_import.spec.ts b/packages/cli/tests/cli/sessions_import.spec.ts index 6a9641b7..5442e36d 100644 --- a/packages/cli/tests/cli/sessions_import.spec.ts +++ b/packages/cli/tests/cli/sessions_import.spec.ts @@ -32,6 +32,7 @@ const claudeLines = (cwd: string, text: string) => interface Upload { authorization?: string; + idempotencyKey?: string; body: string; } @@ -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); @@ -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", @@ -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"));