From 8b057255e11e1b0142410750098df2a6a0901d60 Mon Sep 17 00:00:00 2001 From: imrik Date: Sun, 25 Oct 2026 15:53:37 +0200 Subject: [PATCH 1/5] feat(import): continue a Claude Code or Codex session in Base Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `base44 import` uploads one session file to POST /api/apps/{app_id}/imported/session-imports and prints the new branch's link. - `--current` finds the running session: CLAUDE_CODE_SESSION_ID, then CODEX_THREAD_ID, then the newest Codex rollout recorded in this folder. - A claude.ai session link runs `claude --teleport` in a temp clone of this checkout's origin, drops the teleport exchange, and cleans up. - `--file` uploads a file as is; no argument opens a picker. - `--token ` sends the one-time key through its own client, so the stored login is never used, refreshed or cleared. - Errors are plain messages an agent can relay, carry no request body, and the link and key never reach telemetry. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/commands.md | 10 + packages/cli/src/cli/commands/import.ts | 157 +++++++ packages/cli/src/cli/program.ts | 4 + packages/cli/src/core/project/schema.ts | 1 + packages/cli/src/core/session-import/api.ts | 142 +++++++ .../cli/src/core/session-import/sessions.ts | 285 +++++++++++++ packages/cli/src/core/utils/git.ts | 16 + packages/cli/tests/cli/import.spec.ts | 395 ++++++++++++++++++ packages/cli/tests/cli/testkit/CLITestkit.ts | 6 + packages/cli/tests/cli/testkit/index.ts | 4 + 11 files changed, 1021 insertions(+) create mode 100644 packages/cli/src/cli/commands/import.ts create mode 100644 packages/cli/src/core/session-import/api.ts create mode 100644 packages/cli/src/core/session-import/sessions.ts create mode 100644 packages/cli/tests/cli/import.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f523079c5..3427c1eb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- `base44 import` continues a Claude Code or Codex session in a new Base Code branch and prints its 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. - `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 ff7cf2c9f..76f84a8b9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -46,6 +46,16 @@ Omitting the flag preserves existing main-app behavior. - `fetch` reads stdin only for `--body @-`. An agent harness can hold stdin open forever, so reading it unasked would hang the call. - A failed `fetch` reports to telemetry without the request or response body, `--body`, `--form`, or the query string: any endpoint can carry secrets. +## Session import (`base44 import`) + +`src/cli/commands/import.ts` uploads one Claude Code or Codex session file to `POST /api/apps/{app_id}/imported/session-imports`; the logic is in `src/core/session-import/`. The backend parses the file and creates the branch, so the CLI sends it unchanged. + +- `--current` finds the running session: `CLAUDE_CODE_SESSION_ID` names `~/.claude/projects/*/.jsonl`; otherwise `CODEX_THREAD_ID` (set by Codex; the rollout file name ends with it), else the newest `~/.codex/sessions/*/*/*/rollout-*.jsonl` whose `session_meta` cwd is this folder. +- 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. +- **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. +- Inside claude.ai (`CLAUDE_CODE_REMOTE`), a failed connection or a non-JSON 403 means the environment's network allowlist blocks Base44; the message says how to allow it. + ## Command File Template ```typescript diff --git a/packages/cli/src/cli/commands/import.ts b/packages/cli/src/cli/commands/import.ts new file mode 100644 index 000000000..39ab1c264 --- /dev/null +++ b/packages/cli/src/cli/commands/import.ts @@ -0,0 +1,157 @@ +import { cancel, isCancel, select } from "@clack/prompts"; +import type { Command } from "commander"; +import ms from "ms"; +import { CLIExitError } from "@/cli/errors.js"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { + Base44Command, + ensureAuth, + requireApp, + toJsonStdout, +} from "@/cli/utils/index.js"; +import { getBase44ApiUrl } from "@/core/config.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { MAX_SESSION_BYTES, uploadSession } from "@/core/session-import/api.js"; +import { + findCurrentSession, + type LocalSession, + listSessions, + parseSessionLink, + teleportSession, +} from "@/core/session-import/sessions.js"; +import { readFile } from "@/core/utils/fs.js"; +import { hasUnpushedWork } from "@/core/utils/git.js"; + +interface ImportOptions { + current?: boolean; + file?: string; + token?: string; +} + +const TOOL_LABELS: Record = { + claude_code: "Claude Code", + codex: "Codex", +}; + +async function pickSession(cwd: string): Promise { + const sessions = await listSessions(cwd); + if (sessions.length === 0) { + throw new InvalidInputError( + "No Claude Code or Codex sessions found in this folder. Pass --file .", + ); + } + const choice = await select({ + message: "Which session do you want to continue in Base Code?", + options: sessions.map((session) => ({ + value: session.path, + label: session.firstMessage.replace(/\s+/g, " ").slice(0, 70), + hint: `${TOOL_LABELS[session.tool]}, ${ms(Date.now() - session.updatedAt.getTime())} ago`, + })), + }); + if (isCancel(choice)) { + cancel("Operation cancelled."); + throw new CLIExitError(0); + } + return choice; +} + +async function readSession( + ctx: CLIContext, + link: string | undefined, + options: ImportOptions, +): Promise { + const cwd = process.cwd(); + if (options.file) return readFile(options.file); + if (link) { + const sessionId = parseSessionLink(link); + const content = await ctx.runTask("Downloading the claude.ai session", () => + teleportSession(sessionId, cwd), + ); + return Buffer.from(content); + } + if (options.current) { + const path = await findCurrentSession(cwd); + if (!path) { + throw new InvalidInputError( + "Couldn't find the current session. Run this from inside Claude Code or Codex, or pass --file .", + { hints: [] }, + ); + } + return readFile(path); + } + if (ctx.isNonInteractive || ctx.jsonMode) { + throw new InvalidInputError( + "Choose a session: pass --current, a claude.ai session link, or --file .", + ); + } + return readFile(await pickSession(cwd)); +} + +async function importAction( + ctx: CLIContext, + link: string | undefined, + options: ImportOptions, + command: Command, +): Promise { + // First: the transcript link and the key must never reach error reporting. + ctx.errorReporter.setContext({ + command: { + name: "import", + args: link ? ["[not reported]"] : [], + options: { + ...command.opts(), + ...(options.token ? { token: "[not reported]" } : {}), + }, + }, + }); + const chosen = [link, options.current, options.file].filter(Boolean).length; + if (chosen > 1) { + throw new InvalidInputError( + "Pass only one of: a session link, --current, or --file.", + ); + } + + const appId = requireApp(ctx).id; + const content = await readSession(ctx, link, options); + if (content.length > MAX_SESSION_BYTES) { + throw new InvalidInputError( + "This session is over 25 MB and can't be imported.", + { hints: [] }, + ); + } + if (await hasUnpushedWork(process.cwd())) { + ctx.log.warn( + "Base Code only sees what's on GitHub. Push your changes so the new branch has them.", + ); + } + + if (!options.token) await ensureAuth(ctx); + + ctx.log.info("This uploads the full conversation to Base Code."); + const imported = await ctx.runTask("Importing session", () => + uploadSession(appId, content, options.token), + ); + const url = `${getBase44ApiUrl()}${imported.branchUrl}`; + return { + outroMessage: `✓ Imported ${imported.turnCount} messages from ${TOOL_LABELS[imported.source]} into branch "${imported.branchName}".\nOpen it: ${url}`, + stdout: ctx.jsonMode ? toJsonStdout({ ...imported, url }) : undefined, + }; +} + +export function getImportCommand(): Command { + return new Base44Command("import", { requireAuth: false }) + .description( + "Continue a Claude Code or Codex session in a new Base Code branch", + ) + .argument( + "[session-link]", + "A claude.ai session address (https://claude.ai/code/session_…)", + ) + .option("--current", "Import the session this command runs inside") + .option("--file ", "Import this session file as is") + .option( + "--token ", + "One-time import key from Base Code (no login needed)", + ) + .action(importAction); +} diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 468a6fd94..a6006b8db 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -33,6 +33,7 @@ import { versionsApiEnabled } from "@/core/version/gate.js"; import packageJson from "../../package.json"; import { getDevCommand } from "./commands/dev.js"; import { getExecCommand } from "./commands/exec.js"; +import { getImportCommand } from "./commands/import.js"; import { getEjectCommand } from "./commands/project/eject.js"; import type { CLIContext } from "./types.js"; @@ -144,5 +145,8 @@ export function createProgram(context: CLIContext): Command { // Register logs command program.addCommand(getLogsCommand()); + // Register Base Code session import + program.addCommand(getImportCommand()); + return program; } diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 76080d5dd..862a6f0ca 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -122,6 +122,7 @@ export const TestOverridesSchema = z.object({ }) .optional(), latestVersion: z.string().nullable().optional(), + claudeBin: z.string().optional(), }); export type TestOverrides = z.infer; diff --git a/packages/cli/src/core/session-import/api.ts b/packages/cli/src/core/session-import/api.ts new file mode 100644 index 000000000..817508014 --- /dev/null +++ b/packages/cli/src/core/session-import/api.ts @@ -0,0 +1,142 @@ +import ky, { HTTPError, type KyResponse } from "ky"; +import { z } from "zod"; +import { getAppClient } from "@/core/clients/index.js"; +import { getBase44ApiUrl } from "@/core/config.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; + +export const MAX_SESSION_BYTES = 25 * 1024 * 1024; + +const SessionImportResponseSchema = z + .object({ + branch_id: z.string(), + branch_name: z.string(), + source: z.enum(["claude_code", "codex"]), + turn_count: z.number(), + branch_url: z.string(), + }) + .transform((data) => ({ + branchId: data.branch_id, + branchName: data.branch_name, + source: data.source, + turnCount: data.turn_count, + branchUrl: data.branch_url, + })); + +type SessionImport = z.infer; + +const ErrorEnvelopeSchema = z.object({ + error: z.object({ code: z.string().optional() }), +}); + +const NETWORK_BLOCKED = + "claude.ai is blocking Base44. In this session's environment settings, open Network access, add `app.base44.com` and `base44.app` to Allowed domains, save, and try again."; + +function messageFor( + status: number, + code: string | undefined, + viaToken: boolean, +): string | undefined { + if (status === 401 && viaToken) { + return "This import key expired or was already used. In Base Code, open Import session and copy a new prompt."; + } + if (status === 403) { + return "You don't have edit access to this app, or this key was made for a different app."; + } + if (status === 404) return "Session import isn't available for this app."; + if (status === 413) + return "This session is over 25 MB and can't be imported."; + if (code === "unsupported_file") { + return "That file isn't a Claude Code or Codex session."; + } + if (code === "repository_mismatch") { + return "This session is from a different repository than the app."; + } + if (status === 429) return "Too many imports. Wait a minute and try again."; + return undefined; +} + +/** + * A fresh error that carries no request or response body: the body is the + * user's conversation, and error reporting sends ApiError fields to PostHog. + */ +async function toImportError( + error: unknown, + viaToken: boolean, +): Promise { + if (!(error instanceof HTTPError)) { + // No HTTP response: the claude.ai sandbox refuses the connection. + const reason = error instanceof Error ? error.message : String(error); + return new ApiError( + process.env.CLAUDE_CODE_REMOTE + ? NETWORK_BLOCKED + : `Couldn't reach Base44: ${reason}`, + { hints: [] }, + ); + } + const { status } = error.response; + const fallback = await ApiError.fromHttpError(error, "importing session"); + const envelope = ErrorEnvelopeSchema.safeParse(fallback.responseBody); + const code = envelope.success ? envelope.data.error.code : undefined; + // The claude.ai egress proxy answers a blocked host with its own non-JSON 403. + const blocked = + process.env.CLAUDE_CODE_REMOTE && + status === 403 && + typeof fallback.responseBody !== "object"; + const known = blocked ? NETWORK_BLOCKED : messageFor(status, code, viaToken); + return new ApiError(known ?? fallback.message, { + statusCode: status, + requestUrl: error.request.url, + requestMethod: error.request.method, + requestId: fallback.requestId, + // The CLI's default 401 hint says `base44 login`, which a key user doesn't need. + hints: known ? [] : fallback.hints, + }); +} + +/** + * Uploads a session file. With an import key it goes through its own client: + * base44Client would replace the Authorization header with the stored login and, + * on a 401, refresh and retry as that user. + */ +export async function uploadSession( + appId: string, + content: Uint8Array, + token?: string, +): Promise { + const form = new FormData(); + form.append("file", new Blob([content]), "session.jsonl"); + + let response: KyResponse; + try { + response = token + ? await ky.post( + new URL( + `/api/apps/${appId}/imported/session-imports`, + getBase44ApiUrl(), + ), + { + body: form, + headers: { + Authorization: `Bearer ${token}`, + "User-Agent": "Base44 CLI", + }, + timeout: 180_000, + }, + ) + : await getAppClient().post("imported/session-imports", { + body: form, + timeout: 180_000, + }); + } catch (error) { + throw await toImportError(error, Boolean(token)); + } + + const result = SessionImportResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid session import response from server", + result.error, + ); + } + return result.data; +} diff --git a/packages/cli/src/core/session-import/sessions.ts b/packages/cli/src/core/session-import/sessions.ts new file mode 100644 index 000000000..cc60ed9af --- /dev/null +++ b/packages/cli/src/core/session-import/sessions.ts @@ -0,0 +1,285 @@ +import { createReadStream } from "node:fs"; +import { realpath, rm, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { createInterface } from "node:readline"; +import { execa } from "execa"; +import { globby } from "globby"; +import { dir } from "tmp-promise"; +import { getTestOverrides } from "@/core/config.js"; +import { DependencyNotFoundError, InvalidInputError } from "@/core/errors.js"; +import { readFile } from "@/core/utils/fs.js"; + +export type SessionTool = "claude_code" | "codex"; + +export interface LocalSession { + tool: SessionTool; + path: string; + updatedAt: Date; + firstMessage: string; +} + +const claudeDir = () => + process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); +const codexDir = () => process.env.CODEX_HOME || join(homedir(), ".codex"); + +/** Reads JSONL lines until `pick` returns a value. Session files can be large. */ +async function scanLines( + path: string, + pick: (entry: Record) => T | undefined, + maxLines = Number.POSITIVE_INFINITY, +): Promise { + const lines = createInterface({ input: createReadStream(path) }); + let count = 0; + try { + for await (const line of lines) { + if (++count > maxLines) return undefined; + try { + const found = pick(JSON.parse(line)); + if (found !== undefined) return found; + } catch { + // A torn line (the file is being written) is normal. + } + } + } finally { + lines.close(); + } + return undefined; +} + +const asRecord = (value: unknown): Record => + value && typeof value === "object" ? (value as Record) : {}; + +const codexCwd = (entry: Record) => + entry.type === "session_meta" + ? String(asRecord(entry.payload).cwd ?? "") + : undefined; + +async function newest(paths: string[]): Promise { + let best: { path: string; mtime: number } | undefined; + for (const path of paths) { + const { mtimeMs } = await stat(path); + if (!best || mtimeMs > best.mtime) best = { path, mtime: mtimeMs }; + } + return best?.path; +} + +async function sameDir(a: string, b: string): Promise { + if (a === b) return true; + const [ra, rb] = await Promise.all([ + realpath(a).catch(() => a), + realpath(b).catch(() => b), + ]); + return ra === rb; +} + +const codexRollouts = () => + globby("sessions/*/*/*/rollout-*.jsonl", { + cwd: codexDir(), + absolute: true, + }); + +async function codexRolloutsIn(cwd: string): Promise { + const matches: string[] = []; + for (const path of await codexRollouts()) { + const recorded = await scanLines(path, codexCwd, 1); + if (recorded && (await sameDir(recorded, cwd))) matches.push(path); + } + return matches; +} + +/** The file of the Claude Code or Codex session this process runs inside. */ +export async function findCurrentSession( + cwd: string, +): Promise { + const claudeId = process.env.CLAUDE_CODE_SESSION_ID; + if (claudeId) { + const [path] = await globby(`projects/*/${claudeId}.jsonl`, { + cwd: claudeDir(), + absolute: true, + }); + return path; + } + // Codex sets CODEX_THREAD_ID for the commands it runs, and names the rollout + // after it. Without it (older Codex), assume the newest rollout recorded in + // this folder is the running one. + const codexId = process.env.CODEX_THREAD_ID; + if (codexId) { + const rollouts = await codexRollouts(); + return rollouts.find((path) => path.endsWith(`-${codexId}.jsonl`)); + } + return newest(await codexRolloutsIn(cwd)); +} + +function claudeUserText(entry: Record): string | undefined { + if (entry.type !== "user" || entry.isMeta || entry.isSidechain) return; + const content = asRecord(entry.message).content; + // Command and caveat messages Claude Code injects start with a tag. + return typeof content === "string" && !content.startsWith("<") + ? content + : undefined; +} + +function codexUserText(entry: Record): string | undefined { + const payload = asRecord(entry.payload); + return entry.type === "event_msg" && payload.type === "user_message" + ? String(payload.message ?? "") + : undefined; +} + +const PICKER_LIMIT = 20; + +/** This folder's Claude Code and Codex sessions, newest first. */ +export async function listSessions(cwd: string): Promise { + // ponytail: Claude Code names the folder by replacing every + // non-alphanumeric character of the cwd with "-". + const claudeFolder = cwd.replace(/[^a-zA-Z0-9]/g, "-"); + const claude = await globby(`projects/${claudeFolder}/*.jsonl`, { + cwd: claudeDir(), + absolute: true, + }); + const candidates = [ + ...claude.map((path) => ({ tool: "claude_code" as const, path })), + ...(await codexRolloutsIn(cwd)).map((path) => ({ + tool: "codex" as const, + path, + })), + ]; + const dated = await Promise.all( + candidates.map(async (c) => ({ + ...c, + updatedAt: (await stat(c.path)).mtime, + })), + ); + dated.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + const sessions: LocalSession[] = []; + for (const candidate of dated.slice(0, PICKER_LIMIT)) { + const firstMessage = await scanLines( + candidate.path, + candidate.tool === "claude_code" ? claudeUserText : codexUserText, + ); + if (firstMessage) sessions.push({ ...candidate, firstMessage }); + } + return sessions; +} + +const SESSION_ID = /session_[A-Za-z0-9]+/; + +/** The `session_…` id in a claude.ai address, a bare id, or a `claude --teleport` command. */ +export function parseSessionLink(link: string): string { + if (/cshk_/.test(link) || !SESSION_ID.test(link)) { + throw new InvalidInputError( + `That isn't a claude.ai session address. Copy it from your browser's address bar (not "Copy link").`, + { hints: [] }, + ); + } + return (link.match(SESSION_ID) as RegExpMatchArray)[0]; +} + +const TELEPORT_PROMPT = "Reply with OK."; +const TELEPORT_NOTE = "This session is being continued from another machine"; + +function parseLine(line: string): Record { + try { + return asRecord(JSON.parse(line)); + } catch { + return {}; + } +} + +/** + * Drops what the teleport added: the queued TELEPORT_PROMPT (written as a + * `queue-operation` line), the continued-from-another-machine note (a meta user + * line), and the prompt's turn with everything after it, when it ran. + */ +function stripTeleportExchange(content: string): string { + const lines = content.split("\n"); + const entries = lines.map(parseLine); + const userText = (entry: Record) => + entry.type === "user" ? asRecord(entry.message).content : undefined; + + let end = lines.length; + for (let i = lines.length - 1; i >= 0; i--) { + if (userText(entries[i] ?? {}) === TELEPORT_PROMPT) { + end = i; + break; + } + } + return lines + .slice(0, end) + .filter((_, i) => { + const entry = entries[i] ?? {}; + const text = userText(entry); + const queued = + entry.type === "queue-operation" && entry.content === TELEPORT_PROMPT; + const note = typeof text === "string" && text.startsWith(TELEPORT_NOTE); + return !queued && !note; + }) + .join("\n"); +} + +const NEEDS_CLAUDE = + "Claude Code must be installed and signed in to the same claude.ai account to import a claude.ai session."; + +/** + * Pulls a claude.ai cloud session down with `claude --teleport` and returns its + * file content. Teleport checks out the session's branch, so it runs in a temp + * clone of this checkout's origin; the clone and its Claude project folder are + * deleted afterwards. + */ +export async function teleportSession( + sessionId: string, + cwd: string, +): Promise { + const origin = await execa("git", ["remote", "get-url", "origin"], { + cwd, + reject: false, + }); + if (origin.exitCode !== 0 || !origin.stdout) { + throw new InvalidInputError( + "Run this from a folder with the app's GitHub repository, so the session can be checked out.", + ); + } + + const tmp = await dir({ unsafeCleanup: true }); + const repo = join(tmp.path, "repo"); + let projectFolder: string | undefined; + try { + await execa("git", ["clone", "--quiet", origin.stdout, repo]); + const repoReal = await realpath(repo); + const startedAt = Date.now(); + const claudeBin = getTestOverrides()?.claudeBin ?? "claude"; + try { + await execa(claudeBin, ["--teleport", sessionId, "-p", TELEPORT_PROMPT], { + cwd: repo, + stdin: "ignore", + }); + } catch { + // Not chained: execa's message holds the command line and session id. + throw new DependencyNotFoundError(NEEDS_CLAUDE, { hints: [] }); + } + + const fresh: string[] = []; + for (const path of await globby("projects/*/*.jsonl", { + cwd: claudeDir(), + absolute: true, + })) { + if ((await stat(path)).mtimeMs < startedAt) continue; + const sessionCwd = await scanLines( + path, + (entry) => (typeof entry.cwd === "string" ? entry.cwd : undefined), + 50, + ); + if (sessionCwd === repo || sessionCwd === repoReal) fresh.push(path); + } + const file = await newest(fresh); + if (!file) throw new DependencyNotFoundError(NEEDS_CLAUDE, { hints: [] }); + projectFolder = dirname(file); + return stripTeleportExchange((await readFile(file)).toString("utf8")); + } finally { + // Only the temp clone writes to this folder, so all of it goes. + if (projectFolder) + await rm(projectFolder, { recursive: true, force: true }); + await tmp.cleanup(); + } +} diff --git a/packages/cli/src/core/utils/git.ts b/packages/cli/src/core/utils/git.ts index f3a5be13b..a1fde7114 100644 --- a/packages/cli/src/core/utils/git.ts +++ b/packages/cli/src/core/utils/git.ts @@ -1,6 +1,22 @@ +import { execa } from "execa"; + /** Abbreviated or full commit hash — the same pattern the server validates. */ const GIT_HASH_PATTERN = /^[a-fA-F0-9]{7,64}$/; export function isGitCommitHash(value: string): boolean { return GIT_HASH_PATTERN.test(value); } + +/** True when the checkout at `cwd` has uncommitted changes or commits its remote lacks. */ +export async function hasUnpushedWork(cwd: string): Promise { + const status = await execa("git", ["status", "--porcelain", "--branch"], { + cwd, + reject: false, + }); + if (status.exitCode !== 0) return false; + const [branch = "", ...changes] = status.stdout.split("\n").filter(Boolean); + // "## main...origin/main [ahead 1]"; a branch with no upstream has no "...". + return ( + changes.length > 0 || !branch.includes("...") || branch.includes("[ahead") + ); +} diff --git a/packages/cli/tests/cli/import.spec.ts b/packages/cli/tests/cli/import.spec.ts new file mode 100644 index 000000000..683a6223a --- /dev/null +++ b/packages/cli/tests/cli/import.spec.ts @@ -0,0 +1,395 @@ +import { chmod, mkdir, readdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { execa } from "execa"; +import { beforeEach, describe, expect, it } from "vitest"; +import { setupCLITests } from "./testkit/index.js"; + +const APP_ID = "import-app"; +const ROUTE = `/api/apps/${APP_ID}/imported/session-imports`; +const TOKEN = "b44imp_secret-key"; + +const SUCCESS = { + branch_id: "b1", + branch_name: "add-coupon-codes", + source: "claude_code", + turn_count: 14, + branch_url: `/apps/${APP_ID}/editor/preview?branch=add-coupon-codes`, +}; + +const claudeLines = (cwd: string, text: string) => + `${[ + { type: "user", cwd, message: { role: "user", content: text } }, + { + type: "assistant", + cwd, + message: { role: "assistant", content: [{ type: "text", text: "Sure" }] }, + }, + ] + .map((line) => JSON.stringify(line)) + .join("\n")}\n`; + +interface Upload { + authorization?: string; + body: string; +} + +describe("import command", () => { + const t = setupCLITests(); + let uploads: Upload[]; + + const home = () => t.getTempDir(); + + async function plant(relativePath: string, content: string) { + const path = join(home(), relativePath); + await mkdir(join(path, ".."), { recursive: true }); + await writeFile(path, content); + return path; + } + + function mockUpload(status = 200, body: unknown = SUCCESS) { + t.api.mockRoute("POST", ROUTE, (req, res) => { + uploads.push({ + authorization: req.headers.authorization, + body: (req.body as Buffer).toString("utf8"), + }); + res.status(status).json(body); + }); + } + + beforeEach(() => { + uploads = []; + // execa inherits the developer's env, which may itself be a Claude Code session. + t.givenEnv({ + CLAUDE_CODE_SESSION_ID: "", + CLAUDE_CODE_REMOTE: "", + CLAUDE_CONFIG_DIR: "", + CODEX_HOME: "", + CODEX_THREAD_ID: "", + }); + }); + + it("imports the current Claude Code session with an import key, without a login", async () => { + await plant( + ".claude/projects/-some-folder/abc-123.jsonl", + claudeLines("/some/folder", "Add coupon codes"), + ); + await plant( + ".claude/projects/-other/zzz.jsonl", + claudeLines("/other", "Not this one"), + ); + t.givenEnv({ CLAUDE_CODE_SESSION_ID: "abc-123" }); + mockUpload(); + + const result = await t.run( + "import", + "--current", + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain( + 'Imported 14 messages from Claude Code into branch "add-coupon-codes"', + ); + t.expectResult(result).toContain( + `/apps/${APP_ID}/editor/preview?branch=add-coupon-codes`, + ); + t.expectResult(result).toContain("This uploads the full conversation"); + expect(uploads).toHaveLength(1); + expect(uploads[0]?.authorization).toBe(`Bearer ${TOKEN}`); + expect(uploads[0]?.body).toContain("Add coupon codes"); + expect(uploads[0]?.body).not.toContain("Not this one"); + expect(`${result.stdout}${result.stderr}`).not.toContain(TOKEN); + }); + + it("imports the newest Codex session recorded in this folder", async () => { + const meta = (cwd: string) => + `${JSON.stringify({ type: "session_meta", payload: { cwd } })}\n`; + await plant( + ".codex/sessions/2026/10/20/rollout-old.jsonl", + `${meta(home())}{"note":"older"}\n`, + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + await plant( + ".codex/sessions/2026/10/21/rollout-new.jsonl", + `${meta(home())}{"note":"newest"}\n`, + ); + await plant( + ".codex/sessions/2026/10/22/rollout-elsewhere.jsonl", + `${meta("/elsewhere")}{"note":"other folder"}\n`, + ); + mockUpload(200, { ...SUCCESS, source: "codex" }); + + const result = await t.run( + "import", + "--current", + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("from Codex"); + expect(uploads[0]?.body).toContain("newest"); + }); + + it("imports the Codex session named by CODEX_THREAD_ID", async () => { + await plant( + ".codex/sessions/2026/10/25/rollout-2026-10-25T15-41-21-thread-a.jsonl", + '{"note":"this thread"}\n', + ); + await plant( + ".codex/sessions/2026/10/25/rollout-2026-10-25T15-42-00-thread-b.jsonl", + '{"note":"other thread"}\n', + ); + t.givenEnv({ CODEX_THREAD_ID: "thread-a" }); + mockUpload(200, { ...SUCCESS, source: "codex" }); + + const result = await t.run( + "import", + "--current", + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toSucceed(); + expect(uploads[0]?.body).toContain("this thread"); + }); + + it("explains how to proceed when there is no current session", async () => { + const result = await t.run("import", "--current", "--app-id", APP_ID); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "Couldn't find the current session. Run this from inside Claude Code or Codex, or pass --file .", + ); + expect(uploads).toHaveLength(0); + }); + + it("returns the import as JSON with the full link", async () => { + const file = await plant("s.jsonl", claudeLines("/x", "hi")); + mockUpload(); + + const result = await t.run( + "import", + "--file", + file, + "--app-id", + APP_ID, + "--token", + TOKEN, + "--json", + ); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toMatchObject({ + branchName: "add-coupon-codes", + turnCount: 14, + source: "claude_code", + url: expect.stringMatching( + new RegExp(`/apps/${APP_ID}/editor/preview\\?branch=add-coupon-codes$`), + ), + }); + }); + + it("tells a reused key to copy a new prompt, and leaves the stored login alone", async () => { + await t.givenLoggedIn({ email: "me@example.com", name: "Me" }); + const authBefore = await t.readAuthFile(); + const file = await plant("s.jsonl", claudeLines("/x", "hi")); + mockUpload(401, { + detail: "This import key is invalid, expired or already used.", + }); + + const result = await t.run( + "import", + "--file", + file, + "--app-id", + APP_ID, + "--token", + TOKEN, + "--json", + ); + + t.expectResult(result).toFail(); + const envelope = JSON.parse(result.stdout); + expect(envelope.error).toBe( + "This import key expired or was already used. In Base Code, open Import session and copy a new prompt.", + ); + expect(envelope.hints).toBeUndefined(); + expect(uploads).toHaveLength(1); + expect(await t.readAuthFile()).toEqual(authBefore); + }); + + it.each([ + [403, "forbidden", "You don't have edit access to this app"], + [404, "not_found", "Session import isn't available for this app."], + [ + 422, + "unsupported_file", + "That file isn't a Claude Code or Codex session.", + ], + [ + 422, + "repository_mismatch", + "This session is from a different repository than the app.", + ], + [429, "rate_limited", "Too many imports. Wait a minute and try again."], + ])("maps HTTP %i %s to a plain message", async (status, code, message) => { + const file = await plant("s.jsonl", claudeLines("/x", "hi")); + mockUpload(status, { error: { code, message: "server words" } }); + + const result = await t.run( + "import", + "--file", + file, + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain(message); + }); + + 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")); + mockUpload(); + + const result = await t.run("import", "--file", file, "--app-id", APP_ID); + + t.expectResult(result).toSucceed(); + expect(uploads[0]?.authorization).toMatch(/^Bearer /); + expect(uploads[0]?.authorization).not.toBe(`Bearer ${TOKEN}`); + }); + + it("refuses a file over 25 MB before uploading", async () => { + const file = await plant("big.jsonl", "x".repeat(25 * 1024 * 1024 + 1)); + + const result = await t.run( + "import", + "--file", + file, + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "This session is over 25 MB and can't be imported.", + ); + expect(uploads).toHaveLength(0); + }); + + it("rejects a claude.ai 'Copy link' address", async () => { + const result = await t.run( + "import", + "https://claude.ai/code/cshk_abc123", + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "That isn't a claude.ai session address. Copy it from your browser's address bar", + ); + }); + + it("teleports a claude.ai session, drops the teleport exchange, and cleans up", async () => { + // The run's folder is a checkout whose origin is a local bare repo. + await execa("git", ["init", "--quiet", "--bare", "origin.git"], { + cwd: home(), + }); + await execa("git", ["init", "--quiet"], { cwd: home() }); + await execa( + "git", + ["remote", "add", "origin", join(home(), "origin.git")], + { + cwd: home(), + }, + ); + // A stand-in for `claude --teleport -p "Reply with OK."`. + const fakeClaude = await plant( + "fake-claude.mjs", + `#!/usr/bin/env node +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +const [flag, id] = process.argv.slice(2); +if (flag !== "--teleport" || id !== "session_01AbC") process.exit(1); +const cwd = process.cwd(); +const dir = join(process.env.HOME, ".claude", "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-")); +mkdirSync(dir, { recursive: true }); +const lines = [ + { type: "queue-operation", operation: "enqueue", content: "Reply with OK." }, + { type: "user", cwd, message: { role: "user", content: "Cloud question" } }, + { type: "assistant", cwd, message: { role: "assistant", content: [{ type: "text", text: "Cloud answer" }] } }, + { type: "user", isMeta: true, cwd, message: { role: "user", content: "This session is being continued from another machine. Application state may have changed." } }, + { type: "user", cwd, message: { role: "user", content: "Reply with OK." } }, + { type: "assistant", cwd, message: { role: "assistant", content: [{ type: "text", text: "OK" }] } }, +]; +writeFileSync(join(dir, "local-id.jsonl"), lines.map((l) => JSON.stringify(l)).join("\\n") + "\\n"); +`, + ); + await chmod(fakeClaude, 0o755); + t.givenClaudeBin(fakeClaude); + mockUpload(); + + const result = await t.run( + "import", + "https://claude.ai/code/session_01AbC?from=cli&m=0", + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toSucceed(); + const body = uploads[0]?.body ?? ""; + expect(body).toContain("Cloud question"); + expect(body).toContain("Cloud answer"); + expect(body).not.toContain("Reply with OK."); + expect(body).not.toContain("continued from another machine"); + // The teleported project folder is gone. + expect(await readdir(join(home(), ".claude", "projects"))).toEqual([]); + }); + + it("explains that Claude Code is needed when teleport fails", async () => { + await execa("git", ["init", "--quiet", "--bare", "origin.git"], { + cwd: home(), + }); + await execa("git", ["init", "--quiet"], { cwd: home() }); + await execa( + "git", + ["remote", "add", "origin", join(home(), "origin.git")], + { + cwd: home(), + }, + ); + t.givenClaudeBin(join(home(), "no-such-claude")); + + const result = await t.run( + "import", + "claude --teleport session_01AbC", + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "Claude Code must be installed and signed in to the same claude.ai account", + ); + }); +}); diff --git a/packages/cli/tests/cli/testkit/CLITestkit.ts b/packages/cli/tests/cli/testkit/CLITestkit.ts index 7b24d4027..d28659ac3 100644 --- a/packages/cli/tests/cli/testkit/CLITestkit.ts +++ b/packages/cli/tests/cli/testkit/CLITestkit.ts @@ -49,6 +49,7 @@ export interface RunLiveHandle { interface TestOverrides { appConfig?: { id: string; projectRoot: string }; latestVersion?: string | null; + claudeBin?: string; } export class CLITestkit { @@ -140,6 +141,11 @@ export class CLITestkit { this.testOverrides.latestVersion = version; } + /** Run this executable in place of `claude` (session teleport) */ + givenClaudeBin(path: string): void { + this.testOverrides.claudeBin = path; + } + /** Simulate piped stdin for the next run() call */ givenStdin(content: string): void { this.stdinContent = content; diff --git a/packages/cli/tests/cli/testkit/index.ts b/packages/cli/tests/cli/testkit/index.ts index 227601fe3..f7a3d4ded 100644 --- a/packages/cli/tests/cli/testkit/index.ts +++ b/packages/cli/tests/cli/testkit/index.ts @@ -38,6 +38,9 @@ export interface TestContext { givenLatestVersion: (version: string | null | undefined) => void; + /** Run this executable in place of `claude` (session teleport) */ + givenClaudeBin: (path: string) => void; + /** Simulate piped stdin for the next run() call */ givenStdin: (content: string) => void; @@ -131,6 +134,7 @@ export function setupCLITests(): TestContext { await getKit().givenProject(fixturePath); }, givenLatestVersion: (version) => getKit().givenLatestVersion(version), + givenClaudeBin: (path) => getKit().givenClaudeBin(path), givenStdin: (content) => getKit().givenStdin(content), givenEnv: (vars) => getKit().givenEnv(vars), From b21d1062856f4b40a8cafd1d49062b47b68f966d Mon Sep 17 00:00:00 2001 From: imrik Date: Sun, 25 Oct 2026 16:26:45 +0200 Subject: [PATCH 2/5] feat(import): stop when the session's work isn't on GitHub, send source_branch Base Code starts the new branch from GitHub, so a session imported with local-only work lands without it. For --current and the picker, the command now stops before uploading (the one-time key stays valid) when the checkout has uncommitted changes, unpushed commits, or a branch with no upstream or a deleted one. --skip-push-check imports anyway and says the local changes aren't included. A link (its changes live in the cloud session), --file, and runs outside a git checkout skip the check. The current branch goes with the upload as source_branch, left out when HEAD is detached; the backend starts the new branch from it when it's on GitHub. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/commands.md | 2 + packages/cli/src/cli/commands/import.ts | 36 ++++- packages/cli/src/core/session-import/api.ts | 4 +- packages/cli/src/core/utils/git.ts | 31 +++-- packages/cli/tests/cli/import.spec.ts | 142 ++++++++++++++++++++ 6 files changed, 200 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3427c1eb8..f354458b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Added -- `base44 import` continues a Claude Code or Codex session in a new Base Code branch and prints its 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. +- `base44 import` continues a Claude Code or Codex session in a new Base Code branch and prints its 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 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 76f84a8b9..c9ebbf108 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -52,6 +52,8 @@ Omitting the flag preserves existing main-app behavior. - `--current` finds the running session: `CLAUDE_CODE_SESSION_ID` names `~/.claude/projects/*/.jsonl`; otherwise `CODEX_THREAD_ID` (set by Codex; the rollout file name ends with it), else the newest `~/.codex/sessions/*/*/*/rollout-*.jsonl` whose `session_meta` cwd is this folder. - 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. - **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. - Inside claude.ai (`CLAUDE_CODE_REMOTE`), a failed connection or a non-JSON 403 means the environment's network allowlist blocks Base44; the message says how to allow it. diff --git a/packages/cli/src/cli/commands/import.ts b/packages/cli/src/cli/commands/import.ts index 39ab1c264..28d1cbd13 100644 --- a/packages/cli/src/cli/commands/import.ts +++ b/packages/cli/src/cli/commands/import.ts @@ -20,12 +20,13 @@ import { teleportSession, } from "@/core/session-import/sessions.js"; import { readFile } from "@/core/utils/fs.js"; -import { hasUnpushedWork } from "@/core/utils/git.js"; +import { readGitState } from "@/core/utils/git.js"; interface ImportOptions { current?: boolean; file?: string; token?: string; + skipPushCheck?: boolean; } const TOOL_LABELS: Record = { @@ -119,22 +120,39 @@ async function importAction( { hints: [] }, ); } - if (await hasUnpushedWork(process.cwd())) { - ctx.log.warn( - "Base Code only sees what's on GitHub. Push your changes so the new branch has them.", + // A link's changes live in the cloud session and --file may come from + // anywhere, so only a session from this checkout is held to its git state. + const git = + link || options.file ? undefined : await readGitState(process.cwd()); + if (git?.unpushed && !options.skipPushCheck) { + throw new InvalidInputError( + "Your changes aren't on GitHub yet. Ask the user whether to commit and push them (to a new branch if on main or master), then run this again. If they don't want to, run it again with --skip-push-check.", + { hints: [] }, ); } + const note = git?.unpushed + ? "Base Code started from what's on GitHub; your local changes aren't included." + : undefined; if (!options.token) await ensureAuth(ctx); ctx.log.info("This uploads the full conversation to Base Code."); const imported = await ctx.runTask("Importing session", () => - uploadSession(appId, content, options.token), + uploadSession(appId, content, { + token: options.token, + sourceBranch: git?.branch, + }), ); const url = `${getBase44ApiUrl()}${imported.branchUrl}`; return { - outroMessage: `✓ Imported ${imported.turnCount} messages from ${TOOL_LABELS[imported.source]} into branch "${imported.branchName}".\nOpen it: ${url}`, - stdout: ctx.jsonMode ? toJsonStdout({ ...imported, url }) : undefined, + outroMessage: [ + `✓ Imported ${imported.turnCount} messages from ${TOOL_LABELS[imported.source]} into branch "${imported.branchName}".`, + ...(note ? [note] : []), + `Open it: ${url}`, + ].join("\n"), + stdout: ctx.jsonMode + ? toJsonStdout({ ...imported, url, ...(note ? { note } : {}) }) + : undefined, }; } @@ -149,6 +167,10 @@ export function getImportCommand(): Command { ) .option("--current", "Import the session this command runs inside") .option("--file ", "Import this session file as is") + .option( + "--skip-push-check", + "Import even though local changes aren't on GitHub (they won't be included)", + ) .option( "--token ", "One-time import key from Base Code (no login needed)", diff --git a/packages/cli/src/core/session-import/api.ts b/packages/cli/src/core/session-import/api.ts index 817508014..580cc4801 100644 --- a/packages/cli/src/core/session-import/api.ts +++ b/packages/cli/src/core/session-import/api.ts @@ -101,10 +101,12 @@ async function toImportError( export async function uploadSession( appId: string, content: Uint8Array, - token?: string, + { token, sourceBranch }: { token?: string; sourceBranch?: string } = {}, ): Promise { const form = new FormData(); 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); let response: KyResponse; try { diff --git a/packages/cli/src/core/utils/git.ts b/packages/cli/src/core/utils/git.ts index a1fde7114..18d09b5cc 100644 --- a/packages/cli/src/core/utils/git.ts +++ b/packages/cli/src/core/utils/git.ts @@ -7,16 +7,31 @@ export function isGitCommitHash(value: string): boolean { return GIT_HASH_PATTERN.test(value); } -/** True when the checkout at `cwd` has uncommitted changes or commits its remote lacks. */ -export async function hasUnpushedWork(cwd: string): Promise { +interface GitState { + /** The checked-out branch; undefined when HEAD is detached. */ + branch?: string; + /** Uncommitted changes, or a branch whose commits aren't all on its remote. */ + unpushed: boolean; +} + +/** The checkout's branch and whether it has work its remote lacks; undefined outside a git checkout. */ +export async function readGitState(cwd: string): Promise { const status = await execa("git", ["status", "--porcelain", "--branch"], { cwd, reject: false, }); - if (status.exitCode !== 0) return false; - const [branch = "", ...changes] = status.stdout.split("\n").filter(Boolean); - // "## main...origin/main [ahead 1]"; a branch with no upstream has no "...". - return ( - changes.length > 0 || !branch.includes("...") || branch.includes("[ahead") - ); + if (status.exitCode !== 0) return undefined; + const [header = "", ...changes] = status.stdout.split("\n").filter(Boolean); + const head = await execa("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + cwd, + reject: false, + }); + const branch = + head.exitCode === 0 && head.stdout !== "HEAD" ? head.stdout : undefined; + // "## main...origin/main [ahead 1]". No "..." means no upstream; "[gone]" + // means the upstream was deleted. Both leave the branch off the remote. + const offRemote = + branch !== undefined && + (!header.includes("...") || /\[(ahead|gone)/.test(header)); + return { branch, unpushed: changes.length > 0 || offRemote }; } diff --git a/packages/cli/tests/cli/import.spec.ts b/packages/cli/tests/cli/import.spec.ts index 683a6223a..7899ea16b 100644 --- a/packages/cli/tests/cli/import.spec.ts +++ b/packages/cli/tests/cli/import.spec.ts @@ -306,6 +306,146 @@ describe("import command", () => { ); }); + describe("git state of the checkout", () => { + const git = (...args: string[]) => + execa( + "git", + [ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "-c", + "commit.gpgsign=false", + ...args, + ], + { cwd: home() }, + ); + const SOURCE_BRANCH = /name="source_branch"\r\n\r\nfeature\r\n/; + + /** HOME is the checkout; ignoring everything keeps the planted sessions out of git status. */ + async function givenPushedBranch() { + await git("init", "--quiet", "--bare", "origin.git"); + await git("init", "--quiet", "-b", "feature"); + await writeFile(join(home(), ".gitignore"), "*\n"); + await git("commit", "--quiet", "--allow-empty", "-m", "init"); + await git("remote", "add", "origin", join(home(), "origin.git")); + await git("push", "--quiet", "-u", "origin", "feature"); + } + + async function runCurrent(...extra: string[]) { + await plant( + ".claude/projects/-f/abc-123.jsonl", + claudeLines("/f", "Add coupon codes"), + ); + t.givenEnv({ CLAUDE_CODE_SESSION_ID: "abc-123" }); + return t.run( + "import", + "--current", + "--app-id", + APP_ID, + "--token", + TOKEN, + ...extra, + ); + } + + it("sends the pushed branch to start from", async () => { + await givenPushedBranch(); + mockUpload(); + + const result = await runCurrent(); + + t.expectResult(result).toSucceed(); + expect(uploads[0]?.body).toMatch(SOURCE_BRANCH); + t.expectResult(result).toNotContain("local changes aren't included"); + }); + + it("stops before uploading when there are uncommitted changes", async () => { + await givenPushedBranch(); + await writeFile(join(home(), "dirty.txt"), "x"); + await git("add", "-f", "dirty.txt"); + mockUpload(); + + const result = await runCurrent(); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "Your changes aren't on GitHub yet. Ask the user whether to commit and push them", + ); + t.expectResult(result).toContain("--skip-push-check"); + expect(uploads).toHaveLength(0); + }); + + it("stops when the branch was never pushed", async () => { + await givenPushedBranch(); + await git("checkout", "--quiet", "-b", "local-only"); + mockUpload(); + + const result = await runCurrent(); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Your changes aren't on GitHub yet."); + expect(uploads).toHaveLength(0); + }); + + it("stops when the branch has commits GitHub lacks", async () => { + await givenPushedBranch(); + await git("commit", "--quiet", "--allow-empty", "-m", "local"); + mockUpload(); + + const result = await runCurrent(); + + t.expectResult(result).toFail(); + expect(uploads).toHaveLength(0); + }); + + it("imports without the local changes under --skip-push-check, and says so", async () => { + await givenPushedBranch(); + await git("commit", "--quiet", "--allow-empty", "-m", "local"); + mockUpload(); + + const result = await runCurrent("--skip-push-check", "--json"); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout).note).toBe( + "Base Code started from what's on GitHub; your local changes aren't included.", + ); + expect(uploads[0]?.body).toMatch(SOURCE_BRANCH); + }); + + it("leaves out the branch when HEAD is detached", async () => { + await givenPushedBranch(); + await git("checkout", "--quiet", "--detach"); + mockUpload(); + + const result = await runCurrent(); + + t.expectResult(result).toSucceed(); + expect(uploads[0]?.body).not.toContain('name="source_branch"'); + }); + + it("doesn't check git for --file", async () => { + await givenPushedBranch(); + await git("commit", "--quiet", "--allow-empty", "-m", "local"); + const file = await plant("s.jsonl", claudeLines("/x", "hi")); + mockUpload(); + + const result = await t.run( + "import", + "--file", + file, + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toSucceed(); + expect(uploads[0]?.body).not.toContain('name="source_branch"'); + }); + }); + it("teleports a claude.ai session, drops the teleport exchange, and cleans up", async () => { // The run's folder is a checkout whose origin is a local bare repo. await execa("git", ["init", "--quiet", "--bare", "origin.git"], { @@ -360,6 +500,8 @@ writeFileSync(join(dir, "local-id.jsonl"), lines.map((l) => JSON.stringify(l)).j expect(body).toContain("Cloud answer"); expect(body).not.toContain("Reply with OK."); expect(body).not.toContain("continued from another machine"); + // The checkout here is dirty, but a link's changes live in the cloud. + expect(body).not.toContain('name="source_branch"'); // The teleported project folder is gone. expect(await readdir(join(home(), ".claude", "projects"))).toEqual([]); }); From 0bf073e047d9a04e268b23970806c17f7c54d556 Mon Sep 17 00:00:00 2001 From: imrik Date: Mon, 26 Oct 2026 12:20:07 +0200 Subject: [PATCH 3/5] fix(import): only app.base44.com needs allowing in claude.ai Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/core/session-import/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/core/session-import/api.ts b/packages/cli/src/core/session-import/api.ts index 580cc4801..5190c1f39 100644 --- a/packages/cli/src/core/session-import/api.ts +++ b/packages/cli/src/core/session-import/api.ts @@ -29,7 +29,7 @@ const ErrorEnvelopeSchema = z.object({ }); const NETWORK_BLOCKED = - "claude.ai is blocking Base44. In this session's environment settings, open Network access, add `app.base44.com` and `base44.app` to Allowed domains, save, and try again."; + "claude.ai is blocking Base44. In this session's environment settings, open Network access, add `app.base44.com` to Allowed domains, save, and try again."; function messageFor( status: number, From fd22d2200e55de65477832a0e981f0c8020c571e Mon Sep 17 00:00:00 2001 From: imrik Date: Mon, 26 Oct 2026 15:25:58 +0200 Subject: [PATCH 4/5] fix(import): match the server's branch_exists, continued, and Codex cloud cases - 409 branch_exists prints the existing branch's link from details.branch_url; 409 repository_not_connected says to connect it. - A success with continued: true reads "Continued on ". - CODEX_THREAD_ID with no rollout file is Codex cloud, which keeps the conversation on OpenAI's side: say it isn't supported. - The network message names the Claude cloud environment. Co-Authored-By: Claude Opus 5.5 --- docs/commands.md | 2 + packages/cli/src/cli/commands/import.ts | 4 +- packages/cli/src/core/session-import/api.ts | 29 ++++++- .../cli/src/core/session-import/sessions.ts | 10 ++- packages/cli/tests/cli/import.spec.ts | 77 +++++++++++++++++++ 5 files changed, 116 insertions(+), 6 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index c9ebbf108..745e4cb2e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -56,6 +56,8 @@ Omitting the flag preserves existing main-app behavior. - **`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. - **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. +- Codex cloud sets `CODEX_THREAD_ID` but keeps the conversation on OpenAI's side, so a thread id with no rollout file means Codex cloud, which can't be imported. - Inside claude.ai (`CLAUDE_CODE_REMOTE`), a failed connection or a non-JSON 403 means the environment's network allowlist blocks Base44; the message says how to allow it. ## Command File Template diff --git a/packages/cli/src/cli/commands/import.ts b/packages/cli/src/cli/commands/import.ts index 28d1cbd13..c91690190 100644 --- a/packages/cli/src/cli/commands/import.ts +++ b/packages/cli/src/cli/commands/import.ts @@ -146,7 +146,9 @@ async function importAction( const url = `${getBase44ApiUrl()}${imported.branchUrl}`; return { outroMessage: [ - `✓ Imported ${imported.turnCount} messages from ${TOOL_LABELS[imported.source]} into branch "${imported.branchName}".`, + imported.continued + ? `✓ Imported ${imported.turnCount} messages from ${TOOL_LABELS[imported.source]}. Continued on "${imported.branchName}".` + : `✓ Imported ${imported.turnCount} messages from ${TOOL_LABELS[imported.source]} into branch "${imported.branchName}".`, ...(note ? [note] : []), `Open it: ${url}`, ].join("\n"), diff --git a/packages/cli/src/core/session-import/api.ts b/packages/cli/src/core/session-import/api.ts index 5190c1f39..89fda85c7 100644 --- a/packages/cli/src/core/session-import/api.ts +++ b/packages/cli/src/core/session-import/api.ts @@ -13,6 +13,8 @@ const SessionImportResponseSchema = z source: z.enum(["claude_code", "codex"]), turn_count: z.number(), branch_url: z.string(), + continued: z.boolean(), + source_ref: z.string(), }) .transform((data) => ({ branchId: data.branch_id, @@ -20,22 +22,39 @@ const SessionImportResponseSchema = z source: data.source, turnCount: data.turn_count, branchUrl: data.branch_url, + continued: data.continued, + sourceRef: data.source_ref, })); type SessionImport = z.infer; const ErrorEnvelopeSchema = z.object({ - error: z.object({ code: z.string().optional() }), + error: z.object({ + code: z.string().optional(), + details: z + .object({ branch_name: z.string(), branch_url: z.string() }) + .partial() + .optional(), + }), }); +type ErrorDetails = z.infer["error"]["details"]; + const NETWORK_BLOCKED = - "claude.ai is blocking Base44. In this session's environment settings, open Network access, add `app.base44.com` to Allowed domains, save, and try again."; + "Your Claude cloud environment can't reach Base44 yet. In the environment's settings, open Network access, add `app.base44.com` to Allowed domains, and try again."; function messageFor( status: number, code: string | undefined, + details: ErrorDetails, viaToken: boolean, ): string | undefined { + 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 === "repository_not_connected") { + return "Connect this app to its GitHub repository in Base Code first."; + } if (status === 401 && viaToken) { return "This import key expired or was already used. In Base Code, open Import session and copy a new prompt."; } @@ -76,13 +95,15 @@ async function toImportError( const { status } = error.response; const fallback = await ApiError.fromHttpError(error, "importing session"); const envelope = ErrorEnvelopeSchema.safeParse(fallback.responseBody); - const code = envelope.success ? envelope.data.error.code : undefined; + const { code, details } = envelope.success ? envelope.data.error : {}; // The claude.ai egress proxy answers a blocked host with its own non-JSON 403. const blocked = process.env.CLAUDE_CODE_REMOTE && status === 403 && typeof fallback.responseBody !== "object"; - const known = blocked ? NETWORK_BLOCKED : messageFor(status, code, viaToken); + const known = blocked + ? NETWORK_BLOCKED + : messageFor(status, code, details, viaToken); return new ApiError(known ?? fallback.message, { statusCode: status, requestUrl: error.request.url, diff --git a/packages/cli/src/core/session-import/sessions.ts b/packages/cli/src/core/session-import/sessions.ts index cc60ed9af..4d2ba1562 100644 --- a/packages/cli/src/core/session-import/sessions.ts +++ b/packages/cli/src/core/session-import/sessions.ts @@ -106,7 +106,15 @@ export async function findCurrentSession( const codexId = process.env.CODEX_THREAD_ID; if (codexId) { const rollouts = await codexRollouts(); - return rollouts.find((path) => path.endsWith(`-${codexId}.jsonl`)); + const rollout = rollouts.find((path) => path.endsWith(`-${codexId}.jsonl`)); + // Codex cloud sets the id but keeps the conversation on OpenAI's side. + if (!rollout) { + throw new InvalidInputError( + "Codex cloud sessions can't be imported yet. Run this from the Codex app or CLI on your computer.", + { hints: [] }, + ); + } + return rollout; } return newest(await codexRolloutsIn(cwd)); } diff --git a/packages/cli/tests/cli/import.spec.ts b/packages/cli/tests/cli/import.spec.ts index 7899ea16b..2e8ba1078 100644 --- a/packages/cli/tests/cli/import.spec.ts +++ b/packages/cli/tests/cli/import.spec.ts @@ -14,6 +14,8 @@ const SUCCESS = { source: "claude_code", turn_count: 14, branch_url: `/apps/${APP_ID}/editor/preview?branch=add-coupon-codes`, + continued: false, + source_ref: "main", }; const claudeLines = (cwd: string, text: string) => @@ -240,6 +242,11 @@ describe("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, + "repository_not_connected", + "Connect this app to its GitHub repository in Base Code first.", + ], ])("maps HTTP %i %s to a plain message", async (status, code, message) => { const file = await plant("s.jsonl", claudeLines("/x", "hi")); mockUpload(status, { error: { code, message: "server words" } }); @@ -258,6 +265,76 @@ describe("import command", () => { t.expectResult(result).toContain(message); }); + it("links to the existing branch when Base Code already has one", async () => { + const file = await plant("s.jsonl", claudeLines("/x", "hi")); + mockUpload(409, { + error: { + code: "branch_exists", + message: "Base Code already has a branch for feature/x.", + details: { + branch_name: "feature/x", + branch_url: `/apps/${APP_ID}/editor/preview?branch=feature%2Fx`, + }, + }, + }); + + const result = await t.run( + "import", + "--file", + file, + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "Base Code already has feature/x. Open it: http", + ); + t.expectResult(result).toContain( + `/apps/${APP_ID}/editor/preview?branch=feature%2Fx`, + ); + }); + + it("says it continued on the session's branch when Base Code adopted it", async () => { + const file = await plant("s.jsonl", claudeLines("/x", "hi")); + mockUpload(200, { + ...SUCCESS, + branch_name: "feature/x", + continued: true, + source_ref: "feature/x", + }); + + const result = await t.run( + "import", + "--file", + file, + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain( + 'Imported 14 messages from Claude Code. Continued on "feature/x".', + ); + }); + + it("explains that Codex cloud sessions can't be imported", async () => { + // Codex cloud sets the thread id but writes no rollout file. + t.givenEnv({ CODEX_THREAD_ID: "cloud-thread" }); + + const result = await t.run("import", "--current", "--app-id", APP_ID); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "Codex cloud sessions can't be imported yet. Run this from the Codex app or CLI on your computer.", + ); + expect(uploads).toHaveLength(0); + }); + 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")); From 12185af6b2566a2d8a48b93f0bf22f9d1d944732 Mon Sep 17 00:00:00 2001 From: imrik Date: Wed, 28 Oct 2026 11:24:57 +0200 Subject: [PATCH 5/5] refactor(sessions): rename `base44 import` to `base44 sessions import` A bare top-level `import` reads as importing a project, which is what "Import" means in Base Code, and every other group is a plural noun. The help line drops "in a new branch": the import now continues on the session's own pushed branch when it can. No alias, since `import` never shipped. base44-dev/apper#34732's dialog prompt already uses the new name. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/commands.md | 4 +- .../src/cli/commands/{ => sessions}/import.ts | 8 ++-- .../cli/src/cli/commands/sessions/index.ts | 8 ++++ packages/cli/src/cli/program.ts | 4 +- ...import.spec.ts => sessions_import.spec.ts} | 41 +++++++++++++++++-- 6 files changed, 53 insertions(+), 14 deletions(-) rename packages/cli/src/cli/commands/{ => sessions}/import.ts (97%) create mode 100644 packages/cli/src/cli/commands/sessions/index.ts rename packages/cli/tests/cli/{import.spec.ts => sessions_import.spec.ts} (96%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f354458b8..0ef471067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Added -- `base44 import` continues a Claude Code or Codex session in a new Base Code branch and prints its 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. - `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 745e4cb2e..e31c5a795 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -46,9 +46,9 @@ Omitting the flag preserves existing main-app behavior. - `fetch` reads stdin only for `--body @-`. An agent harness can hold stdin open forever, so reading it unasked would hang the call. - A failed `fetch` reports to telemetry without the request or response body, `--body`, `--form`, or the query string: any endpoint can carry secrets. -## Session import (`base44 import`) +## Session import (`base44 sessions import`) -`src/cli/commands/import.ts` uploads one Claude Code or Codex session file to `POST /api/apps/{app_id}/imported/session-imports`; the logic is in `src/core/session-import/`. The backend parses the file and creates the branch, so the CLI sends it unchanged. +`src/cli/commands/sessions/import.ts` uploads one Claude Code or Codex session file to `POST /api/apps/{app_id}/imported/session-imports`; the logic is in `src/core/session-import/`. The backend parses the file and creates or continues the branch, so the CLI sends it unchanged. - `--current` finds the running session: `CLAUDE_CODE_SESSION_ID` names `~/.claude/projects/*/.jsonl`; otherwise `CODEX_THREAD_ID` (set by Codex; the rollout file name ends with it), else the newest `~/.codex/sessions/*/*/*/rollout-*.jsonl` whose `session_meta` cwd is this folder. - 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. diff --git a/packages/cli/src/cli/commands/import.ts b/packages/cli/src/cli/commands/sessions/import.ts similarity index 97% rename from packages/cli/src/cli/commands/import.ts rename to packages/cli/src/cli/commands/sessions/import.ts index c91690190..65e1b8b75 100644 --- a/packages/cli/src/cli/commands/import.ts +++ b/packages/cli/src/cli/commands/sessions/import.ts @@ -97,7 +97,7 @@ async function importAction( // First: the transcript link and the key must never reach error reporting. ctx.errorReporter.setContext({ command: { - name: "import", + name: "sessions import", args: link ? ["[not reported]"] : [], options: { ...command.opts(), @@ -158,11 +158,9 @@ async function importAction( }; } -export function getImportCommand(): Command { +export function getSessionsImportCommand(): Command { return new Base44Command("import", { requireAuth: false }) - .description( - "Continue a Claude Code or Codex session in a new Base Code branch", - ) + .description("Continue a Claude Code or Codex session in Base Code") .argument( "[session-link]", "A claude.ai session address (https://claude.ai/code/session_…)", diff --git a/packages/cli/src/cli/commands/sessions/index.ts b/packages/cli/src/cli/commands/sessions/index.ts new file mode 100644 index 000000000..5342a060d --- /dev/null +++ b/packages/cli/src/cli/commands/sessions/index.ts @@ -0,0 +1,8 @@ +import { Command } from "commander"; +import { getSessionsImportCommand } from "./import.js"; + +export function getSessionsCommand(): Command { + return new Command("sessions") + .description("Move Claude Code and Codex sessions to Base Code") + .addCommand(getSessionsImportCommand()); +} diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index a6006b8db..fd34b96ff 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -22,6 +22,7 @@ import { getVisibilityCommand } from "@/cli/commands/project/visibility.js"; import { getPublishCommand } from "@/cli/commands/publish.js"; import { getSandboxCommand } from "@/cli/commands/sandbox/index.js"; import { getSecretsCommand } from "@/cli/commands/secrets/index.js"; +import { getSessionsCommand } from "@/cli/commands/sessions/index.js"; import { getSiteCommand } from "@/cli/commands/site/index.js"; import { getTypesCommand } from "@/cli/commands/types/index.js"; import { getVersionsCommand } from "@/cli/commands/versions/index.js"; @@ -33,7 +34,6 @@ import { versionsApiEnabled } from "@/core/version/gate.js"; import packageJson from "../../package.json"; import { getDevCommand } from "./commands/dev.js"; import { getExecCommand } from "./commands/exec.js"; -import { getImportCommand } from "./commands/import.js"; import { getEjectCommand } from "./commands/project/eject.js"; import type { CLIContext } from "./types.js"; @@ -146,7 +146,7 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getLogsCommand()); // Register Base Code session import - program.addCommand(getImportCommand()); + program.addCommand(getSessionsCommand()); return program; } diff --git a/packages/cli/tests/cli/import.spec.ts b/packages/cli/tests/cli/sessions_import.spec.ts similarity index 96% rename from packages/cli/tests/cli/import.spec.ts rename to packages/cli/tests/cli/sessions_import.spec.ts index 2e8ba1078..6a9641b78 100644 --- a/packages/cli/tests/cli/import.spec.ts +++ b/packages/cli/tests/cli/sessions_import.spec.ts @@ -35,7 +35,7 @@ interface Upload { body: string; } -describe("import command", () => { +describe("sessions import command", () => { const t = setupCLITests(); let uploads: Upload[]; @@ -83,6 +83,7 @@ describe("import command", () => { mockUpload(); const result = await t.run( + "sessions", "import", "--current", "--app-id", @@ -125,6 +126,7 @@ describe("import command", () => { mockUpload(200, { ...SUCCESS, source: "codex" }); const result = await t.run( + "sessions", "import", "--current", "--app-id", @@ -151,6 +153,7 @@ describe("import command", () => { mockUpload(200, { ...SUCCESS, source: "codex" }); const result = await t.run( + "sessions", "import", "--current", "--app-id", @@ -164,7 +167,13 @@ describe("import command", () => { }); it("explains how to proceed when there is no current session", async () => { - const result = await t.run("import", "--current", "--app-id", APP_ID); + const result = await t.run( + "sessions", + "import", + "--current", + "--app-id", + APP_ID, + ); t.expectResult(result).toFail(); t.expectResult(result).toContain( @@ -178,6 +187,7 @@ describe("import command", () => { mockUpload(); const result = await t.run( + "sessions", "import", "--file", file, @@ -208,6 +218,7 @@ describe("import command", () => { }); const result = await t.run( + "sessions", "import", "--file", file, @@ -252,6 +263,7 @@ describe("import command", () => { mockUpload(status, { error: { code, message: "server words" } }); const result = await t.run( + "sessions", "import", "--file", file, @@ -279,6 +291,7 @@ describe("import command", () => { }); const result = await t.run( + "sessions", "import", "--file", file, @@ -307,6 +320,7 @@ describe("import command", () => { }); const result = await t.run( + "sessions", "import", "--file", file, @@ -326,7 +340,13 @@ describe("import command", () => { // Codex cloud sets the thread id but writes no rollout file. t.givenEnv({ CODEX_THREAD_ID: "cloud-thread" }); - const result = await t.run("import", "--current", "--app-id", APP_ID); + const result = await t.run( + "sessions", + "import", + "--current", + "--app-id", + APP_ID, + ); t.expectResult(result).toFail(); t.expectResult(result).toContain( @@ -340,7 +360,14 @@ describe("import command", () => { const file = await plant("s.jsonl", claudeLines("/x", "hi")); mockUpload(); - const result = await t.run("import", "--file", file, "--app-id", APP_ID); + const result = await t.run( + "sessions", + "import", + "--file", + file, + "--app-id", + APP_ID, + ); t.expectResult(result).toSucceed(); expect(uploads[0]?.authorization).toMatch(/^Bearer /); @@ -351,6 +378,7 @@ describe("import command", () => { const file = await plant("big.jsonl", "x".repeat(25 * 1024 * 1024 + 1)); const result = await t.run( + "sessions", "import", "--file", file, @@ -369,6 +397,7 @@ describe("import command", () => { it("rejects a claude.ai 'Copy link' address", async () => { const result = await t.run( + "sessions", "import", "https://claude.ai/code/cshk_abc123", "--app-id", @@ -417,6 +446,7 @@ describe("import command", () => { ); t.givenEnv({ CLAUDE_CODE_SESSION_ID: "abc-123" }); return t.run( + "sessions", "import", "--current", "--app-id", @@ -509,6 +539,7 @@ describe("import command", () => { mockUpload(); const result = await t.run( + "sessions", "import", "--file", file, @@ -563,6 +594,7 @@ writeFileSync(join(dir, "local-id.jsonl"), lines.map((l) => JSON.stringify(l)).j mockUpload(); const result = await t.run( + "sessions", "import", "https://claude.ai/code/session_01AbC?from=cli&m=0", "--app-id", @@ -598,6 +630,7 @@ writeFileSync(join(dir, "local-id.jsonl"), lines.map((l) => JSON.stringify(l)).j t.givenClaudeBin(join(home(), "no-such-claude")); const result = await t.run( + "sessions", "import", "claude --teleport session_01AbC", "--app-id",