diff --git a/src/__tests__/migrate-guard.test.ts b/src/__tests__/migrate-guard.test.ts index d5102f140..2d3a77644 100644 --- a/src/__tests__/migrate-guard.test.ts +++ b/src/__tests__/migrate-guard.test.ts @@ -11,7 +11,7 @@ import { describe, expect, it, beforeAll, afterAll } from 'vitest'; import os from 'node:os'; import type { AgentAdapter, SessionMeta } from '../session-flow/adapters/base.js'; import { ADAPTER_REGISTRY, type AdapterFactory } from '../session-flow/adapters/index.js'; -import { fidelityFromSession } from '../session-flow/migrate.js'; +import { fidelityFromSession, previewMigration } from '../session-flow/migrate.js'; import { AgentSession } from '../session-flow/agent-session.js'; import type { Session } from '../session-flow/ir.js'; @@ -217,3 +217,21 @@ describe('fidelityFromSession — 真实统计', () => { }); }); }); + +describe('previewMigration', () => { + it('returns the source, target, session metadata, and computed fidelity', () => { + const session = makeSession(); + const sourcePlatform = session.platform; + const targetPlatform = 'cursor'; + + expect(previewMigration(session, sourcePlatform, targetPlatform)).toEqual({ + sourcePlatform, + targetPlatform, + sessionId: session.sessionId, + sessionTitle: session.title, + cwd: session.cwd, + messageCount: session.messages.length, + fidelity: fidelityFromSession(session, targetPlatform), + }); + }); +}); diff --git a/src/__tests__/session-cmd.test.ts b/src/__tests__/session-cmd.test.ts index 3dc19bf57..7d885e0e0 100644 --- a/src/__tests__/session-cmd.test.ts +++ b/src/__tests__/session-cmd.test.ts @@ -32,6 +32,7 @@ const mocks = vi.hoisted(() => ({ adaptersByPlatform: {} as Record, /** migrate.js mock 的 preview 返回值(Error 时抛出);agent-session.js mock 的 copy 返回值 */ previewResult: null as unknown, + previewCalls: [] as Array<{ session: unknown; source: string; target: string }>, copyResult: null as unknown, /** The OS the commands' `noAdapter` checks judge by; this machine's when unset. */ os: undefined as NodeJS.Platform | undefined, @@ -119,14 +120,9 @@ vi.mock('../session-flow/adapters/index.js', () => ({ })); vi.mock('../session-flow/migrate.js', () => ({ - MigrationEngine: class { - constructor( - public readonly source: string, - public readonly target: string, - ) {} - preview(): unknown { - return mocks.previewResult; - } + previewMigration: (session: unknown, source: string, target: string): unknown => { + mocks.previewCalls.push({ session, source, target }); + return mocks.previewResult; }, resolveTargetCwd: (explicit: string | undefined, sourceCwd: string | undefined) => explicit ?? sourceCwd, })); @@ -186,6 +182,7 @@ beforeEach(() => { mocks.gitCalls.length = 0; mocks.adaptersByPlatform = {}; mocks.previewResult = null; + mocks.previewCalls.length = 0; mocks.copyResult = null; mocks.os = undefined; mocks.heldArchiveLock = ''; @@ -714,16 +711,13 @@ describe('session migrate interactive picker', () => { filePath: '/tmp/tgt.jsonl', fidelity: { score: 1 }, }; - const { MigrationEngine } = await import('../session-flow/migrate.js'); - const previewSpy = vi.spyOn(MigrationEngine.prototype, 'preview'); - feedAnswers(['n', '11']); await runSession('migrate', '-s', 'fakeplat', '-t', 'tgtplat', '--cwd', '/run/dir'); // The picker is on page 2 by the time the number is entered, and the // selected id is the 11th newest -- unreachable before paging existed. expect(out.join('\n')).toContain('page 2/2'); - expect(previewSpy).toHaveBeenCalledWith(sessions[1]); + expect(mocks.previewCalls).toContainEqual({ session: sessions[1], source: 'fakeplat', target: 'tgtplat' }); }); it('cancels on an empty answer instead of paging forever', async () => { diff --git a/src/session-flow/migrate.ts b/src/session-flow/migrate.ts index bd56df362..ea7b612d6 100644 --- a/src/session-flow/migrate.ts +++ b/src/session-flow/migrate.ts @@ -258,30 +258,21 @@ export function resolveTargetCwd( return fallback; } -// --------------------------------------------------------------------------- -// MigrationEngine -// --------------------------------------------------------------------------- - -export class MigrationEngine { - constructor( - private sourcePlatform: string, - private targetPlatform: string, - ) {} - - /** What migrating `session`, read from the source platform, would carry over. */ - preview(session: Session): MigrationPreview { - const fidelity = fidelityFromSession(session, this.targetPlatform); - - return { - sourcePlatform: this.sourcePlatform, - targetPlatform: this.targetPlatform, - sessionId: session.sessionId, - sessionTitle: session.title, - cwd: session.cwd, - messageCount: session.messages.length, - fidelity, - }; - } +/** What migrating `session`, read from `sourcePlatform`, would carry over. */ +export function previewMigration( + session: Session, + sourcePlatform: string, + targetPlatform: string, +): MigrationPreview { + return { + sourcePlatform, + targetPlatform, + sessionId: session.sessionId, + sessionTitle: session.title, + cwd: session.cwd, + messageCount: session.messages.length, + fidelity: fidelityFromSession(session, targetPlatform), + }; } export { getAdapter, listAvailablePlatforms, listInstalledPlatforms }; diff --git a/src/session-flow/session-cmd-shared.ts b/src/session-flow/session-cmd-shared.ts new file mode 100644 index 000000000..affe22386 --- /dev/null +++ b/src/session-flow/session-cmd-shared.ts @@ -0,0 +1,338 @@ +import readline from 'node:readline'; +import * as path from 'node:path'; +import { getAdapter } from './adapters/index.js'; +import { + AgentSession, + noAdapter, + recordedToolRoots, + type AgentSessionListed, +} from './agent-session.js'; +import { + SyncManager, + getRepoIdentity, + getGitAuthor, + withSessionIndexLock, +} from './sync.js'; +import { isInteractive } from '../utils/prompt.js'; +import type { WorkflowEnvelope } from '../workflow/types.js'; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / 1024 / 1024).toFixed(1)}MB`; +} + +/** ` (12 msgs)` or ` (12 msgs, 4.0KB)`; nothing when the tool's listing does not count (OpenCode). */ +export function listedCounts(m: AgentSessionListed, { includeSize = false } = {}): string { + if (m.messageCount === undefined) return ''; + if (!includeSize || m.sizeBytes === undefined) return ` (${m.messageCount} msgs)`; + return ` (${m.messageCount} msgs, ${formatBytes(m.sizeBytes)})`; +} + +/** + * Strip terminal control sequences before printing untrusted text. + * + * Session titles, author names, repo identities, and search snippets all come + * from the team repo — anything a member pushes lands in someone else's + * terminal. ANSI/OSC sequences can move the cursor, clear the screen, change + * the title, or even disguise the output as another command's result (the + * same class of overwrite as `\r`). Always strip control characters before display. + */ +// oxlint-disable-next-line no-control-regex +const CONTROL_SEQ_RE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b\[[0-9;?]*[ -/]*[@-~]/g; + +export function safeText(value: string): string { + return String(value ?? '').replace(CONTROL_SEQ_RE, ' '); +} + +/** + * Prefetch every stdin line into a queue; ask consumes from the queue. + * + * Do not wait on rl.question one call at a time: with piped batch input, + * multiple question callbacks race (after the previous question shifts its + * callback and before the next callback is registered, intervening input + * lines are dropped). A queue reader is stable on both a TTY and a pipe. + */ +let lineQueue: string[] = []; +let lineResolver: ((line: string) => void) | null = null; +let lineReaderStarted = false; +let sharedRl: readline.Interface | null = null; + +function startLineReader(): void { + if (lineReaderStarted) return; + lineReaderStarted = true; + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + sharedRl = rl; + rl.on('line', (line) => { + if (lineResolver) { + const r = lineResolver; + lineResolver = null; + r(line.trim()); + } else { + lineQueue.push(line.trim()); + } + }); +} + +/** + * Close the readline interface and release the event loop. + * Must be called after all interactive input, or the process hangs and never + * exits (the terminal does not return a prompt). + */ +export function closeStdin(): void { + if (sharedRl) { + sharedRl.close(); + sharedRl = null; + } + lineResolver = null; +} + +/** + * Read one line of user input (interactive). Prefer the prefetched queue; + * otherwise wait for the next line. + */ +export function ask(question: string): Promise { + startLineReader(); + return new Promise((resolve) => { + if (lineQueue.length > 0) { + resolve(lineQueue.shift() as string); + return; + } + // When stdin closes (EOF / pipe end), 'line' never fires and the promise + // hangs until the event loop drains, then the process exits silently — + // a scripted call gets exit 0 and no output, which looks like success. + // Resolve an empty string explicitly so callers take their own "Cancelled." branch. + const onEnd = () => { + if (lineResolver) { + const r = lineResolver; + lineResolver = null; + r(''); + } + }; + sharedRl?.once?.('close', onEnd); + lineResolver = (line) => { + sharedRl?.off?.('close', onEnd); + resolve(line); + }; + process.stdout.write(question); + }); +} + +type ArchiveConsent = 'ok' | 'declined' | 'refused'; + +/** + * Unredacted transcripts are team-readable. Interactive runs ask first. + * `-y` is the explicit yes for scripts (it also skips the large-batch prompt). + * `--scrub` redacts before anything is written. A non-interactive run with + * neither flag used to archive anyway and only warn afterwards. + */ +export async function confirmUnredactedArchive( + count: number, + sourceLabel: string, + opts: { scrub?: boolean; yes?: boolean }, +): Promise { + if (opts.scrub || opts.yes) return 'ok'; + if (!isInteractive()) return 'refused'; + console.log(`\n ⚠ About to archive ${count} unredacted session(s) from ${sourceLabel}.`); + console.log( + ' Archived sessions are team-readable: full prompts, tool output, file paths and any secret in them.', + ); + console.log(' Re-run with --scrub to redact secret-shaped values first, or -y to archive as-is.'); + const ans = await ask('Archive as-is? (y/N): '); + if (!/^y(es)?$/i.test(ans.trim())) return 'declined'; + return 'ok'; +} + +/** + * Look up an adapter safely; an invalid platform gets a friendly message + * instead of a stack trace. Its store follows the tool roots recorded for `cwd`. + */ +export async function safeGetAdapter(platform: string, cwd: string | undefined) { + const unknown = noAdapter(platform, 'read'); + if (unknown) { + console.error(`Error: ${unknown.message}`); + process.exit(1); + } + return getAdapter(platform, await recordedToolRoots(cwd)); +} + +/** + * Archive author: prefer the git identity of the session's own workspace. + * + * `push --all` archives sessions from several workspaces (possibly different + * repos and different local identities) together. Signing them all with the + * identity of the directory the CLI happens to run in attributes other + * people's sessions to the current user and makes `--author` filtering lie. + * When that identity is unavailable, fall back to the run directory's identity. + */ +export function authorForCwd(cwd: string | undefined, fallback: string): string { + if (!cwd || !path.isAbsolute(cwd)) return fallback; + try { + const author = getGitAuthor(cwd); + return author && author !== 'unknown' ? author : fallback; + } catch { + return fallback; + } +} + +/** + * Resolve the current cwd's repoIdentity (canonical git remote). + * A non-git directory returns null (falls back to _unattributed) without an error. + */ +export function resolveRepoIdentity(cwd?: string): string | null { + return getRepoIdentity(cwd ?? process.cwd()); +} + +/** + * Derive the archive key (repoIdentity) from the session's **native cwd**. + * Key invariant: the archive key comes from the session's own working + * directory, never from the directory the CLI happens to be running in + * (design doc P3). + * + * Each adapter's readSession already tries to recover the native cwd: + * - codex / workbuddy: storage includes the real path + * - claude-code / codebuddy / cursor: recovered from the cwd field recorded in JSONL + * - codebuddy-ide: the workspace directory is an irreversible md5(cwd). When + * the real path cannot be recovered, session.cwd is an `md5:` + * placeholder, the session goes under `_unattributed`, and an English warning is printed + */ +export function deriveArchiveIdentity(session: { cwd: string }, platform: string): string | null { + const nativeCwd = session.cwd; + if (nativeCwd && path.isAbsolute(nativeCwd)) { + return getRepoIdentity(nativeCwd); + } + console.warn( + ` ⚠ native cwd unknowable for ${platform} session, archived under _unattributed`, + ); + return null; +} + +/** + * Resolve the team-repo root. + * Prefer --repo-root; otherwise use cwd (assuming cwd is the team-repo clone). + */ +export async function resolveRepoRoot( + repoRoot?: string, + options?: { materialize?: boolean }, +): Promise { + if (repoRoot?.trim()) return repoRoot.trim(); + const { detectProjectConfig } = await import('../config.js'); + const { getReportsDir, isSelfMode } = await import('../types.js'); + const { ensureReportsWorktree } = await import('../utils/reports-branch.js'); + const cfg = await detectProjectConfig(); + if (!cfg || cfg.repo.kind === 'http' || !cfg.repo.localPath?.trim()) { + throw new Error( + 'No teamai project config found. Run `teamai init` or pass --repo-root. Session sync will not use the current working directory.', + ); + } + if (isSelfMode(cfg)) { + // Readers and --dry-run must not publish a brand-new teamai-reports branch. + // The default ensure() pushes the orphan branch as soon as it creates it. + if (options?.materialize !== false) { + await ensureReportsWorktree(cfg, { pushIfCreated: false }); + } + return getReportsDir(cfg); + } + return cfg.repo.localPath; +} + +/** + * Push the team repo to the remote; on failure, degrade to a warning instead of crashing. + * + * By the time we get here, the local saveSession + gitCommit have already + * succeeded — the session data is not lost. Remote failures are often + * unrelated to the data (no upstream, read-only HTTP mode, network). Blowing + * up with a stack trace would disguise a successful archive as a total + * failure, and running it again would create duplicate commits. + */ +export async function pushToRemote(syncMgr: SyncManager, repoRoot: string): Promise { + try { + const pushed = syncMgr.gitPush(); + if (pushed.status === 'indexes-conflicted') { + const merged = await withSessionIndexLock(repoRoot, async () => syncMgr.commitRebuiltIndexes(pushed.indexes)); + if (merged.status === 'busy') throw new Error(mergeBusyMessage(repoRoot, merged.lockPath)); + if (syncMgr.gitPush().status !== 'pushed') { + throw new Error(`The remote moved on again and its merge stopped on the session indexes; run \`teamai session pull\` to finish it.`); + } + } + return true; + } catch (err) { + // The local commit is kept, so nothing is lost -- but this is still a + // failure and must not be printed as "✓ Pushed". + console.error(` ✗ Remote push failed (local commit kept): ${gitFailureReason(err)}`); + return false; + } +} + +/** The first line ("Command failed: git ...") only repeats the command; git's fatal/error line is the actual reason. */ +function gitFailureReason(err: unknown): string { + const msg = err instanceof Error ? err.message : String(err); + const fatal = msg.split('\n').find((l) => /^(fatal|error):/i.test(l.trim())); + return safeText(fatal?.trim() ?? msg.split('\n')[0]); +} + +/** + * Wrap gitCommit / gitPull: a git-layer failure (not a git directory, no + * remote, index.lock race, and so on) prints a single English error line and + * exits 1, instead of letting execFileSync's exception hit the user as a raw + * stack trace (leaked internal paths plus a fake sense of a crash). + */ +export function runGitStep(step: () => T, repoRoot: string, what: string): T { + try { + return step(); + } catch (err) { + console.error(`Error: ${what} failed in ${repoRoot}: ${gitFailureReason(err)}`); + console.error(`Check that ${repoRoot} is a git repository with a configured remote, then retry.`); + process.exit(1); + } +} + +export function archiveBusyMessage(repoRoot: string, lockPath: string): string { + return `another teamai command is writing the session archive in ${repoRoot} (lock: ${lockPath}).`; +} + +/** A pull merged the remote, but the session indexes it left conflicted wait for the lock another command holds. */ +export function mergeBusyMessage(repoRoot: string, lockPath: string): string { + return `${archiveBusyMessage(repoRoot, lockPath)} The pull is left mid-merge on the session indexes; run \`teamai session pull\` once it has finished.`; +} + +/** Another command held the session archive lock for the whole wait: this write did not happen, so fail the run. */ +export function reportArchiveBusy(repoRoot: string, lockPath: string): void { + console.error(`Error: ${archiveBusyMessage(repoRoot, lockPath)}`); + console.error('Run this command again once it has finished.'); + process.exitCode = 1; +} + +/** `session migrate --json` prints the same `{ data } | { error }` envelope as the workflow commands. */ +export function printEnvelope(envelope: WorkflowEnvelope): void { + process.stdout.write(`${JSON.stringify(envelope)}\n`); + if ('error' in envelope) process.exitCode = 1; +} + +/** + * The sessions of `platform` in `cwd`, or in every workspace without it. When the platform could + * not be listed, its failure is printed (as the envelope under --json), the run fails, and the + * result is undefined. + */ +export async function listSource({ + platform, + cwd, + asEnvelope = false, +}: { + platform: string; + cwd?: string; + asEnvelope?: boolean; +}): Promise { + const { + sessions, + failures: [failure], + } = await AgentSession.list({ platforms: [platform], cwd }); + if (!failure) return sessions; + if (asEnvelope) { + printEnvelope({ error: { code: failure.code, message: failure.message } }); + } else { + console.error(`Error: ${failure.message}`); + process.exitCode = 1; + } + return undefined; +} diff --git a/src/session-flow/session-cmd.ts b/src/session-flow/session-cmd.ts index 4a04dbcf1..733cb3a9a 100644 --- a/src/session-flow/session-cmd.ts +++ b/src/session-flow/session-cmd.ts @@ -19,395 +19,36 @@ */ import type { Command } from 'commander'; -import readline from 'node:readline'; -import * as path from 'node:path'; -import { getAdapter, listAvailablePlatforms, listInstalledPlatforms } from './adapters/index.js'; -import { MigrationEngine, resolveTargetCwd } from './migrate.js'; +import { listAvailablePlatforms, listInstalledPlatforms } from './adapters/index.js'; import { AgentSession, noAdapter, recordedToolRoots, toolRootsLookup, writeCopyOnce, - type AgentSessionCopy, - type AgentSessionListed, } from './agent-session.js'; -import { SyncManager, getRepoIdentity, getGitAuthor, defaultSyncMeta, withSessionIndexLock } from './sync.js'; +import { SyncManager, getGitAuthor, defaultSyncMeta, withSessionIndexLock } from './sync.js'; import { SessionSearchEngine, type LoadedSession } from './search.js'; import { isSafeSessionFileId } from './ids.js'; import { isInteractive } from '../utils/prompt.js'; -import { setStderrOnly } from '../utils/logger.js'; -import type { WorkflowEnvelope } from '../workflow/types.js'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes}B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; - return `${(bytes / 1024 / 1024).toFixed(1)}MB`; -} - -/** ` (12 msgs)` or ` (12 msgs, 4.0KB)`; nothing when the tool's listing does not count (OpenCode). */ -function listedCounts(m: AgentSessionListed, { includeSize = false } = {}): string { - if (m.messageCount === undefined) return ''; - if (!includeSize || m.sizeBytes === undefined) return ` (${m.messageCount} msgs)`; - return ` (${m.messageCount} msgs, ${formatBytes(m.sizeBytes)})`; -} - -/** - * Strip terminal control sequences before printing untrusted text. - * - * Session titles, author names, repo identities, and search snippets all come - * from the team repo — anything a member pushes lands in someone else's - * terminal. ANSI/OSC sequences can move the cursor, clear the screen, change - * the title, or even disguise the output as another command's result (the - * same class of overwrite as `\r`). Always strip control characters before display. - */ -// oxlint-disable-next-line no-control-regex -const CONTROL_SEQ_RE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b\[[0-9;?]*[ -/]*[@-~]/g; - -function safeText(value: string): string { - return String(value ?? '').replace(CONTROL_SEQ_RE, ' '); -} - -/** - * Prefetch every stdin line into a queue; ask consumes from the queue. - * - * Do not wait on rl.question one call at a time: with piped batch input, - * multiple question callbacks race (after the previous question shifts its - * callback and before the next callback is registered, intervening input - * lines are dropped). A queue reader is stable on both a TTY and a pipe. - */ -let lineQueue: string[] = []; -let lineResolver: ((line: string) => void) | null = null; -let lineReaderStarted = false; - -let sharedRl: readline.Interface | null = null; -/** During --all batch migration, list the sessions and require confirmation above this count (-y skips it). */ -const BATCH_CONFIRM_THRESHOLD = 10; - -/** How many sessions to show per page in the interactive picker (n/p pages). */ -const SESSION_PICKER_PAGE_SIZE = 10; - - -function startLineReader(): void { - if (lineReaderStarted) return; - lineReaderStarted = true; - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - sharedRl = rl; - rl.on('line', (line) => { - if (lineResolver) { - const r = lineResolver; - lineResolver = null; - r(line.trim()); - } else { - lineQueue.push(line.trim()); - } - }); -} - -/** - * Close the readline interface and release the event loop. - * Must be called after all interactive input, or the process hangs and never - * exits (the terminal does not return a prompt). - */ -function closeStdin(): void { - if (sharedRl) { - sharedRl.close(); - sharedRl = null; - } - lineResolver = null; -} - -/** - * Read one line of user input (interactive). Prefer the prefetched queue; - * otherwise wait for the next line. - */ -function ask(question: string): Promise { - startLineReader(); - return new Promise((resolve) => { - if (lineQueue.length > 0) { - resolve(lineQueue.shift() as string); - return; - } - // When stdin closes (EOF / pipe end), 'line' never fires and the promise - // hangs until the event loop drains, then the process exits silently — - // a scripted call gets exit 0 and no output, which looks like success. - // Resolve an empty string explicitly so callers take their own "Cancelled." branch. - const onEnd = () => { - if (lineResolver) { - const r = lineResolver; - lineResolver = null; - r(''); - } - }; - sharedRl?.once?.('close', onEnd); - lineResolver = (line) => { - sharedRl?.off?.('close', onEnd); - resolve(line); - }; - process.stdout.write(question); - }); -} - -type ArchiveConsent = 'ok' | 'declined' | 'refused'; - -/** - * Unredacted transcripts are team-readable. Interactive runs ask first. - * `-y` is the explicit yes for scripts (it also skips the large-batch prompt). - * `--scrub` redacts before anything is written. A non-interactive run with - * neither flag used to archive anyway and only warn afterwards. - */ -async function confirmUnredactedArchive( - count: number, - sourceLabel: string, - opts: { scrub?: boolean; yes?: boolean }, -): Promise { - if (opts.scrub || opts.yes) return 'ok'; - if (!isInteractive()) return 'refused'; - console.log(`\n ⚠ About to archive ${count} unredacted session(s) from ${sourceLabel}.`); - console.log( - ' Archived sessions are team-readable: full prompts, tool output, file paths and any secret in them.', - ); - console.log(' Re-run with --scrub to redact secret-shaped values first, or -y to archive as-is.'); - const ans = await ask('Archive as-is? (y/N): '); - if (!/^y(es)?$/i.test(ans.trim())) return 'declined'; - return 'ok'; -} - -/** - * Show a numbered menu and let the user pick one item. - * A number or a name is accepted; returns the selected string. - */ -async function promptSelect(question: string, options: string[]): Promise { - console.log(question); - for (let i = 0; i < options.length; i++) { - console.log(` [${i + 1}] ${options[i]}`); - } - const ans = await ask('Select (number or name): '); - if (!ans) throw new Error('Selection cancelled'); - const num = parseInt(ans, 10); - if (!Number.isNaN(num) && num >= 1 && num <= options.length) { - return options[num - 1]; - } - const lower = ans.toLowerCase(); - const byName = options.find((o) => o.toLowerCase() === lower); - if (byName) return byName; - throw new Error(`Invalid selection: ${ans}`); -} - -/** - * Look up an adapter safely; an invalid platform gets a friendly message - * instead of a stack trace. Its store follows the tool roots recorded for `cwd`. - */ -async function safeGetAdapter(platform: string, cwd: string | undefined) { - const unknown = noAdapter(platform, 'read'); - if (unknown) { - console.error(`Error: ${unknown.message}`); - process.exit(1); - } - return getAdapter(platform, await recordedToolRoots(cwd)); -} - -/** - * Archive author: prefer the git identity of the session's own workspace. - * - * `push --all` archives sessions from several workspaces (possibly different - * repos and different local identities) together. Signing them all with the - * identity of the directory the CLI happens to run in attributes other - * people's sessions to the current user and makes `--author` filtering lie. - * When that identity is unavailable, fall back to the run directory's identity. - */ -function authorForCwd(cwd: string | undefined, fallback: string): string { - if (!cwd || !path.isAbsolute(cwd)) return fallback; - try { - const author = getGitAuthor(cwd); - return author && author !== 'unknown' ? author : fallback; - } catch { - return fallback; - } -} - -/** - * Resolve the current cwd's repoIdentity (canonical git remote). - * A non-git directory returns null (falls back to _unattributed) without an error. - */ -function resolveRepoIdentity(cwd?: string): string | null { - return getRepoIdentity(cwd ?? process.cwd()); -} - -/** - * Derive the archive key (repoIdentity) from the session's **native cwd**. - * Key invariant: the archive key comes from the session's own working - * directory, never from the directory the CLI happens to be running in - * (design doc P3). - * - * Each adapter's readSession already tries to recover the native cwd: - * - codex / workbuddy: storage includes the real path - * - claude-code / codebuddy / cursor: recovered from the cwd field recorded in JSONL - * - codebuddy-ide: the workspace directory is an irreversible md5(cwd). When - * the real path cannot be recovered, session.cwd is an `md5:` - * placeholder, the session goes under `_unattributed`, and an English warning is printed - */ -function deriveArchiveIdentity(session: { cwd: string }, platform: string): string | null { - const nativeCwd = session.cwd; - if (nativeCwd && path.isAbsolute(nativeCwd)) { - return getRepoIdentity(nativeCwd); - } - console.warn( - ` ⚠ native cwd unknowable for ${platform} session, archived under _unattributed`, - ); - return null; -} - -/** - * Resolve the team-repo root. - * Prefer --repo-root; otherwise use cwd (assuming cwd is the team-repo clone). - */ -async function resolveRepoRoot( - repoRoot?: string, - options?: { materialize?: boolean }, -): Promise { - if (repoRoot?.trim()) return repoRoot.trim(); - const { detectProjectConfig } = await import('../config.js'); - const { getReportsDir, isSelfMode } = await import('../types.js'); - const { ensureReportsWorktree } = await import('../utils/reports-branch.js'); - const cfg = await detectProjectConfig(); - if (!cfg || cfg.repo.kind === 'http' || !cfg.repo.localPath?.trim()) { - throw new Error( - 'No teamai project config found. Run `teamai init` or pass --repo-root. Session sync will not use the current working directory.', - ); - } - if (isSelfMode(cfg)) { - // Readers and --dry-run must not publish a brand-new teamai-reports branch. - // The default ensure() pushes the orphan branch as soon as it creates it. - if (options?.materialize !== false) { - await ensureReportsWorktree(cfg, { pushIfCreated: false }); - } - return getReportsDir(cfg); - } - return cfg.repo.localPath; -} - -/** - * Push the team repo to the remote; on failure, degrade to a warning instead of crashing. - * - * By the time we get here, the local saveSession + gitCommit have already - * succeeded — the session data is not lost. Remote failures are often - * unrelated to the data (no upstream, read-only HTTP mode, network). Blowing - * up with a stack trace would disguise a successful archive as a total - * failure, and running it again would create duplicate commits. - */ -async function pushToRemote(syncMgr: SyncManager, repoRoot: string): Promise { - try { - const pushed = syncMgr.gitPush(); - if (pushed.status === 'indexes-conflicted') { - const merged = await withSessionIndexLock(repoRoot, async () => syncMgr.commitRebuiltIndexes(pushed.indexes)); - if (merged.status === 'busy') throw new Error(mergeBusyMessage(repoRoot, merged.lockPath)); - if (syncMgr.gitPush().status !== 'pushed') { - throw new Error(`The remote moved on again and its merge stopped on the session indexes; run \`teamai session pull\` to finish it.`); - } - } - return true; - } catch (err) { - // The local commit is kept, so nothing is lost -- but this is still a - // failure and must not be printed as "✓ Pushed". - console.error(` ✗ Remote push failed (local commit kept): ${gitFailureReason(err)}`); - return false; - } -} - -/** The first line ("Command failed: git ...") only repeats the command; git's fatal/error line is the actual reason. */ -function gitFailureReason(err: unknown): string { - const msg = err instanceof Error ? err.message : String(err); - const fatal = msg.split('\n').find((l) => /^(fatal|error):/i.test(l.trim())); - return safeText(fatal?.trim() ?? msg.split('\n')[0]); -} - -/** - * Wrap gitCommit / gitPull: a git-layer failure (not a git directory, no - * remote, index.lock race, and so on) prints a single English error line and - * exits 1, instead of letting execFileSync's exception hit the user as a raw - * stack trace (leaked internal paths plus a fake sense of a crash). - */ -function runGitStep(step: () => T, repoRoot: string, what: string): T { - try { - return step(); - } catch (err) { - console.error(`Error: ${what} failed in ${repoRoot}: ${gitFailureReason(err)}`); - console.error(`Check that ${repoRoot} is a git repository with a configured remote, then retry.`); - process.exit(1); - } -} - -function archiveBusyMessage(repoRoot: string, lockPath: string): string { - return `another teamai command is writing the session archive in ${repoRoot} (lock: ${lockPath}).`; -} - -/** A pull merged the remote, but the session indexes it left conflicted wait for the lock another command holds. */ -function mergeBusyMessage(repoRoot: string, lockPath: string): string { - return `${archiveBusyMessage(repoRoot, lockPath)} The pull is left mid-merge on the session indexes; run \`teamai session pull\` once it has finished.`; -} - -/** Another command held the session archive lock for the whole wait: this write did not happen, so fail the run. */ -function reportArchiveBusy(repoRoot: string, lockPath: string): void { - console.error(`Error: ${archiveBusyMessage(repoRoot, lockPath)}`); - console.error('Run this command again once it has finished.'); - process.exitCode = 1; -} - -/** What `session migrate --json` reports for one copy: the copy result without its status. */ -type MigrateJsonData = Omit, 'status'>; - -/** `session migrate --json` prints the same `{ data } | { error }` envelope as the workflow commands. */ -function printEnvelope(envelope: WorkflowEnvelope): void { - process.stdout.write(`${JSON.stringify(envelope)}\n`); - if ('error' in envelope) process.exitCode = 1; -} - -/** - * The sessions of `platform` in `cwd`, or in every workspace without it. When the platform could - * not be listed, its failure is printed (as the envelope under --json), the run fails, and the - * result is undefined. - */ -async function listSource({ - platform, - cwd, - asEnvelope = false, -}: { - platform: string; - cwd?: string; - asEnvelope?: boolean; -}): Promise { - const { - sessions, - failures: [failure], - } = await AgentSession.list({ platforms: [platform], cwd }); - if (!failure) return sessions; - if (asEnvelope) { - printEnvelope({ error: { code: failure.code, message: failure.message } }); - } else { - console.error(`Error: ${failure.message}`); - process.exitCode = 1; - } - return undefined; -} - -/** - * Under --json, stdout carries only the envelope: every progress line, from this command or a - * logger it reaches, goes to stderr until the returned restore runs. - */ -function reserveStdoutForEnvelope(): () => void { - const log = console.log; - console.log = console.error; - const wasStderrOnly = setStderrOnly(true); - return () => { - console.log = log; - setStderrOnly(wasStderrOnly); - }; -} +import { registerMigrationCommand } from './session-migrate-cmd.js'; +import { + ask, + authorForCwd, + closeStdin, + confirmUnredactedArchive, + deriveArchiveIdentity, + listedCounts, + listSource, + mergeBusyMessage, + pushToRemote, + reportArchiveBusy, + resolveRepoIdentity, + resolveRepoRoot, + runGitStep, + safeGetAdapter, + safeText, +} from './session-cmd-shared.js'; // --------------------------------------------------------------------------- // Command registration @@ -447,479 +88,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { } }); - // ── session migrate ──────────────────────────────────────── - sessionCmd - .command('migrate') - .description('Migrate a session from one platform to another (or archive to same platform)') - .argument('[sessionId]', 'Session ID to migrate') - .option('-s, --source ', 'Source platform (e.g. claude-code, codebuddy)') - .option('-t, --target ', 'Target platform') - .option('--cwd ', 'Working directory (defaults to current directory)') - .option('--target-cwd ', 'Override cwd for the target session') - .option('--push', 'Also push the migrated session to the team repo') - .option('--repo-root ', 'Team repo root (for --push)') - .option('--scrub', 'Redact secrets (tokens/keys/passwords) from the session before writing it') - .option('--all', 'Migrate every session from source (not just the 5 most recent)') - .option('--limit ', 'Max sessions to migrate (only caps --all; ignored otherwise)') - .option('-y, --yes', 'Skip confirmation prompts, including archiving unredacted transcripts') - .option('--json', 'Print one {data}|{error} JSON envelope on stdout; never prompts (needs a session id, -s and -t)') - .action(async (sessionId, opts) => { - const json = Boolean(opts.json); - const restoreStdout = json ? reserveStdoutForEnvelope() : undefined; - try { - // A driver never answers a prompt: refuse up front, before anything is copied. - if (json) { - const refuse = (message: string) => printEnvelope({ error: { code: 'prompt_refused', message } }); - if (opts.all) { - return printEnvelope({ - error: { code: 'unsupported_option', message: '--json migrates one session: pass its id instead of --all.' }, - }); - } - if (!opts.source || !opts.target) return refuse('--json never prompts: pass -s and -t .'); - if (!sessionId) return refuse('--json never prompts: pass the session id to migrate.'); - if (opts.push && !opts.scrub && !opts.yes) { - return refuse('--json never prompts: pass -y to archive the transcript as-is, or --scrub to redact it first.'); - } - } - let source = opts.source; - let target = opts.target; - - // Interactive: when source/target is missing, guide the user to pick - if (!source) { - source = await promptSelect('Select source platform:', listAvailablePlatforms()); - } - if (!target) { - const others = listAvailablePlatforms().filter((p) => p !== source); - target = await promptSelect('Select target platform:', others); - } - - // A target no copy can be written to fails once here, not once per session after its preview. - const refusedTarget = noAdapter(target, 'write'); - if (refusedTarget) { - if (json) return printEnvelope({ error: { code: refusedTarget.code, message: refusedTarget.message } }); - console.error(`Error: ${refusedTarget.message}`); - process.exitCode = 1; - return; - } - - let workCwd = opts.cwd ?? process.cwd(); - let metas = await listSource({ platform: source, cwd: workCwd, asEnvelope: json }); - if (!metas) return; - let crossDirExpanded = false; - // --all means "every session from this source": with no --cwd, enumerate - // across all workspaces, rather than only the current directory (that - // would silently turn --all into "everything in the current directory"). - if (opts.all && !opts.cwd && !sessionId) { - const allMetas = await listSource({ platform: source }); - if (!allMetas) return; - if (allMetas.length > 0) { - metas = allMetas; - crossDirExpanded = true; - } - } - - - // When the current cwd has no sessions, interactively offer to list sessions from every directory. - // After expansion those sessions **do not belong to workCwd**; source lookup must pass - // undefined so the adapter searches globally by id (every adapter's findSessionFile has that fallback). - // Previously workCwd was still passed to the source adapter, and claude-code - // only looked in the single encodeCwdClaude(workCwd) directory, so this path failed 100% of the time. - if (metas.length === 0 && !opts.cwd && !sessionId) { - const allMetas = await listSource({ platform: source }); - if (!allMetas) return; - if (allMetas.length > 0) { - console.log(`\nNo sessions found in current directory: ${workCwd}`); - console.log(`But ${allMetas.length} session(s) found across all directories on ${source}.`); - console.log(`Tip: pass --cwd to migrate from a specific workspace (non-interactive runs need it).`); - const ans = await ask('List all? (y/N): '); - if (ans.toLowerCase() === 'y' || ans.toLowerCase() === 'yes') { - metas = allMetas; - crossDirExpanded = true; - } - } - } - /** Workspace used for source lookup: when expanded across directories, sessions do not belong to workCwd, so the adapter searches globally. */ - const sourceProjectPath = crossDirExpanded ? undefined : workCwd; - const printNotFound = () => - printEnvelope({ error: { code: 'not_found', message: `No ${source} session ${sessionId} in ${workCwd}.` } }); - - if (metas.length === 0) { - if (json) return printNotFound(); - console.log('No sessions found on source platform.'); - return; - } - - // Select sessions - let targets: typeof metas; - if (opts.all) { - // --all means what it says: migrate every session, no longer silently truncated to 5. - // Previously slice(0, 5) with no notice, so users thought everything had been migrated. - // Use --limit when a cap is needed (same semantics as session push). - const limitRaw = parseInt(opts.limit, 10); - const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 0; - const sorted = metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); - targets = limit > 0 ? sorted.slice(0, limit) : sorted; - - // Large-batch confirmation: --all now migrates everything (no longer truncated to 5). - // When there are many sessions, list them and require confirmation; -y skips it. - // Avoid accidentally migrating dozens or hundreds at once, which is expensive to roll back. - if (targets.length > BATCH_CONFIRM_THRESHOLD && !opts.yes) { - console.log(`\nAbout to migrate ${targets.length} session(s) from ${source}:`); - for (const m of targets) { - const title = safeText(m.title); - console.log(` ${m.sessionId.slice(0, 8)} ${title.length > 50 ? title.slice(0, 50) + '...' : title}${listedCounts(m)}`); - } - // Nobody can answer in a non-interactive run. Cancelling with exit 0 - // there would tell a script that every session was migrated; refuse - // instead and let the caller opt in explicitly. - if (!isInteractive()) { - console.error(`\nRefusing to migrate ${targets.length} session(s) without confirmation.`); - console.error('Pass -y to confirm, or --limit to migrate fewer.'); - process.exit(1); - } - const ans = await ask('\nMigrate all of the above? (y/N): '); - if (ans.toLowerCase() !== 'y' && ans.toLowerCase() !== 'yes') { - console.log('Cancelled.'); - return; - } - } - } else if (sessionId) { - const matches = metas.filter( - (m) => m.sessionId === sessionId || m.sessionId.startsWith(sessionId), - ); - if (matches.length === 0) { - if (json) return printNotFound(); - console.error(`Session not found: ${sessionId}`); - process.exit(1); - } - // Ambiguous prefix: taking every match silently migrates sessions the - // user never named (and rollback then has to undo all of them). - if (matches.length > 1) { - if (json) { - const ids = matches.slice(0, 10).map((m) => m.sessionId).join(', '); - return printEnvelope({ - error: { - code: 'ambiguous_id', - message: `Session id "${sessionId}" matches ${matches.length} sessions (${ids}). Pass the full id.`, - }, - }); - } - console.error(`Ambiguous session id "${sessionId}": matches ${matches.length} sessions.`); - for (const m of matches.slice(0, 10)) { - console.error(` ${m.sessionId} ${safeText(m.title).slice(0, 60)}`); - } - console.error('Pass more characters of the id, or use --all / no argument to pick from a list.'); - process.exit(1); - } - targets = matches; - } else { - // Interactive paging: 10 per screen, n/p to change page. Numbers are global indexes (still selectable after paging). - // Showing only the first 10 with no way to page makes users think there are only 10 sessions once the list grows. - const sorted = metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); - const pageSize = SESSION_PICKER_PAGE_SIZE; - const pages = Math.max(1, Math.ceil(sorted.length / pageSize)); - let page = 0; - - for (;;) { - const start = page * pageSize; - const slice = sorted.slice(start, start + pageSize); - console.log( - `\nSessions on ${source} — ${pages > 1 ? `page ${page + 1}/${pages}, ` : ''}${sorted.length} total:`, - ); - for (let i = 0; i < slice.length; i++) { - const m = slice[i]; - const title = safeText(m.title); - const titleShown = title.length > 50 ? title.slice(0, 50) + '...' : title; - console.log( - ` [${start + i + 1}] ${m.sessionId.slice(0, 8)} ${titleShown}${listedCounts(m, { includeSize: true })}`, - ); - } - - const ans = ( - await ask( - pages > 1 - ? '\nSelect a number, n = next page, p = previous page, Enter = cancel: ' - : '\nSelect session (number) or Enter to cancel: ', - ) - ) - .trim() - .toLowerCase(); - // Enter (and EOF, which reads as an empty answer) still cancels: - // a non-interactive run must never sit in a paging loop. - if (!ans || ans === 'q' || ans === 'quit' || ans === 'exit') { - console.log('Cancelled.'); - return; - } - if (pages > 1 && (ans === 'n' || ans === 'next' || ans === 'd')) { - page = (page + 1) % pages; - continue; - } - if (pages > 1 && (ans === 'p' || ans === 'prev' || ans === 'previous' || ans === 'u')) { - page = (page - 1 + pages) % pages; - continue; - } - const num = parseInt(ans, 10); - if (!Number.isNaN(num) && num >= 1 && num <= sorted.length) { - targets = [sorted[num - 1]]; - break; - } - console.log( - `Enter a number between 1 and ${sorted.length}${pages > 1 ? ', or n/p to change page' : ''}.`, - ); - } - } - - const engine = new MigrationEngine(source, target); - const toolRootsFor = toolRootsLookup(); - let migrated = 0; - let reused = 0; - let failed = 0; - // Record the target session ID and real fidelity of each successful migration, and of each - // copy that already existed (a retried --push archives it). - // --push then reads those IDs back exactly (not "the N most recent on the target platform", - // which would push the wrong sessions; see P4/P7). - const migratedTargets: { sessionId: string; fidelityScore: number; cwd?: string }[] = []; - /** --json: the one copy this run made or found. */ - let copied: MigrateJsonData | undefined; - - for (const m of targets) { - // The preview shows what is stored; a --scrub run redacts in the copy itself. - const found = await AgentSession.find( - { platform: source, sessionId: m.sessionId, cwd: sourceProjectPath, filePath: m.filePath }, - { scrub: false, toolRootsFor }, - ); - if (found.status === 'failed') { - if (json) return printEnvelope({ error: { code: found.failure.code, message: found.failure.message } }); - console.error(`\n ✗ Migration failed: ${found.failure.message}`); - failed++; - continue; - } - const preview = engine.preview(found.session); - - console.log(`\n Migration Preview`); - console.log(` ─────────────────────────────────`); - console.log(` Source: ${preview.sourcePlatform}`); - console.log(` Target: ${preview.targetPlatform}`); - console.log(` Session: ${safeText(preview.sessionTitle)} (${preview.sessionId.slice(0, 8)}...)`); - console.log(` CWD: ${preview.cwd}`); - // Target workspace defaults to the source session's workspace; only --target-cwd moves it - console.log(` Target CWD:${opts.targetCwd ? ' ' + opts.targetCwd : ' (same as source)'}`); - console.log(` Messages: ${preview.messageCount}`); - console.log(` ─────────────────────────────────`); - console.log(` Fidelity: ${(preview.fidelity.score * 100).toFixed(1)}% (Mode ${preview.fidelity.mode})`); - console.log(` Preserved: ${preview.fidelity.preservedBlocks}/${preview.fidelity.totalBlocks} blocks`); - if (preview.fidelity.degradedBlocks > 0) { - console.log(` Degraded: ${preview.fidelity.degradedBlocks} blocks`); - } - for (const d of preview.fidelity.degradations) { - console.log(` ⚠ ${d}`); - } - for (const w of preview.fidelity.warnings) { - console.log(` ⚠ ${w}`); - } - - // --dry-run: stop once the preview is printed. - // Migration has no confirmation step (it runs immediately after printing the preview). - // Without honoring the global --dry-run, the only way to see fidelity and warnings - // is to migrate for real and roll back if unhappy. - if (isDryRun()) { - if (json) return printEnvelope({ data: { dryRun: true, fidelity: preview.fidelity } }); - console.log(`\n · --dry-run: preview only, not migrated: ${m.sessionId.slice(0, 8)}...\n`); - continue; - } - - // Target workspace default = source session workspace (keep the directory the same); - // only an explicit --target-cwd moves the session to another workspace. An encoded or - // placeholder source cwd (e.g. `-Users-foo-project`) falls back to --cwd, and copy - // refuses anything that is still not an existing absolute directory. - const targetCwd = resolveTargetCwd(opts.targetCwd, preview.cwd, sourceProjectPath) ?? preview.cwd; - const result = await AgentSession.copy({ - from: { platform: source, sessionId: m.sessionId, cwd: sourceProjectPath, filePath: m.filePath }, - to: { platform: target, cwd: targetCwd }, - scrub: Boolean(opts.scrub), - toolRootsFor, - }); - if (result.status === 'failed') { - if (json) { - printEnvelope({ error: { code: result.failure.code, message: result.failure.message } }); - return; - } - console.error(`\n ✗ Migration failed: ${result.failure.message}`); - failed++; - continue; - } - if (result.reused) { - // Never overwritten: the member may have kept talking in the copy. - console.log(`\n ✓ Session already migrated as ${result.sessionId}, nothing written.`); - console.log(` Target CWD: ${result.cwd}`); - console.log( - ` To refresh it, delete that copy and migrate again: teamai session rollback ${result.sessionId} --platform ${target} --cwd "${result.cwd}"`, - ); - reused++; - } else { - console.log(`\n ✓ Migration successful`); - console.log(` Target session ID: ${result.sessionId}`); - console.log(` Target CWD: ${result.cwd}`); - if (opts.scrub) { - console.log( - ` Redacted: ${result.redactedCount ?? 0} secret-looking value(s) (best-effort; review before archiving)`, - ); - } - if (result.filePath) { - console.log(` Target file: ${result.filePath}`); - } - if (result.notice) { - console.error(` ⚠ ${result.notice}`); - } - console.log(` Fidelity: ${(result.fidelity.score * 100).toFixed(1)}%`); - migrated++; - } - migratedTargets.push({ sessionId: result.sessionId, fidelityScore: result.fidelity.score, cwd: result.cwd }); - const { status: _status, ...copy } = result; - copied = copy; - } - // Scripted semantics: any single failure exits non-zero (--all batch does not stop the remaining sessions). - if (failed > 0) { - process.exitCode = 1; - } - - // --push: push to the team repo - if (opts.push && migratedTargets.length > 0) { - const consent = await confirmUnredactedArchive(migratedTargets.length, `${source} → ${target}`, { - scrub: Boolean(opts.scrub), - yes: Boolean(opts.yes), - }); - if (consent !== 'ok') { - console.error(`\n ${migratedTargets.length} session(s) migrated locally, not archived.`); - if (consent === 'refused') { - console.error('Pass --scrub to redact the archive, or -y to archive as-is.'); - process.exitCode = 1; - } else { - console.log('Archive cancelled. Re-run with --scrub to archive a redacted copy.'); - } - return; - } - const repoRoot = await resolveRepoRoot(opts.repoRoot, { materialize: !isDryRun() }); - // When expanded across directories, sessions do not belong to workCwd; author should come from the repo the session actually lives in - const authorCwd = migratedTargets.find((t) => t.cwd)?.cwd ?? workCwd; - const author = getGitAuthor(authorCwd); - - const syncMgr = new SyncManager(repoRoot); - /** --json: the copy stays on this machine whatever happens to its archive, so say where it is. */ - const archiveFailed = (code: string, reason: string) => - printEnvelope({ error: { code, message: `Copied as ${copied?.sessionId} in ${copied?.cwd}, but not archived: ${reason}` } }); - let saved = 0; - let unarchived = 0; - for (const t of migratedTargets) { - // Migration keeps image paths so the target can still open the file. - // The copy that lands in the team repo must not: re-read the target - // and scrub again with the archive default, which drops filePath. - const readBack = await AgentSession.find( - { platform: target, sessionId: t.sessionId, cwd: t.cwd ?? opts.targetCwd ?? workCwd }, - { scrub: Boolean(opts.scrub), toolRootsFor }, - ); - if (readBack.status === 'failed') { - if (json) return archiveFailed(readBack.failure.code, readBack.failure.message); - console.error(`\n ✗ Not archived: ${readBack.failure.message}`); - unarchived++; - continue; - } - const session = readBack.session; - // P3: the archive key is derived from the session's native cwd, not the directory migrate is running in (see deriveArchiveIdentity) - const meta = defaultSyncMeta( - { - platform: target, - author: authorForCwd(session.cwd ?? t.cwd, author), - cwd: session.cwd || opts.targetCwd || workCwd, - sessionId: t.sessionId, - repoIdentity: deriveArchiveIdentity(session, target), - title: session.title, - }, - session.createdAt, - ); - meta.migration.migratedAt = new Date().toISOString(); - meta.migration.sourcePlatform = source; - meta.migration.targetPlatform = target; - meta.migration.fidelityScore = t.fidelityScore; - try { - const write = await withSessionIndexLock(repoRoot, async () => syncMgr.saveSession(session, meta)); - if (write.status === 'busy') { - if (json) return archiveFailed('busy', `${archiveBusyMessage(repoRoot, write.lockPath)} Run it again once that has finished.`); - reportArchiveBusy(repoRoot, write.lockPath); - return; - } - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - if (json) return archiveFailed('archive_failed', reason); - console.error(`Error: ${reason}`); - process.exitCode = 1; - return; - } - saved++; - } - if (unarchived > 0) { - console.error(`\n ✗ ${unarchived} migrated session(s) could not be read back and were not archived.`); - process.exitCode = 1; - } - // [fixed] a gitCommit failure (not a git directory / index.lock race) used to crash with a raw stack trace - // Under --json a throw reaches the action's catch, which prints the envelope; runGitStep would exit without one. - const commitArchive = () => syncMgr.gitCommit(`sync: migrate ${saved} session(s) ${source}→${target}`); - const commit = json ? commitArchive() : runGitStep(commitArchive, repoRoot, 'git commit'); - if (commit.status === 'committed') { - const pushed = await pushToRemote(syncMgr, repoRoot); - if (pushed) { - console.log(`\n ✓ Pushed ${saved} session(s) to team repo`); - } else { - console.log(`\n · ${saved} session(s) committed locally but not pushed`); - process.exitCode = 1; - if (json) { - return archiveFailed( - 'archive_failed', - `committed in ${repoRoot} as ${commit.commit.slice(0, 8)}, but the push to the remote failed (reason on stderr).`, - ); - } - } - console.log(` commit: ${commit.commit.slice(0, 8)}`); - } else if (commit.status === 'failed') { - // Hooks / gpg / missing identity: the archive files are still - // staged. Say so, and fail -- "No changes to push" here would hide - // a broken archive. - console.error(`\n ✗ Commit failed, ${saved} session(s) left staged in ${repoRoot}: ${safeText(commit.reason)}`); - process.exitCode = 1; - if (json) return archiveFailed('archive_failed', `the commit failed, files left staged in ${repoRoot}: ${safeText(commit.reason)}`); - } else { - console.log(`\n · No changes to push\n`); - } - } - - if (json && copied) { - printEnvelope({ data: copied }); - return; - } - const failedNote = failed > 0 ? `, ${failed} failed` : ''; - console.log( - isDryRun() - ? `\n ${targets.length - failed} session(s) would be migrated${failedNote} (--dry-run, no changes made).\n` - : `\n ${migrated} session(s) migrated${reused > 0 ? `, ${reused} already migrated` : ''}${failedNote}.\n`, - ); - } catch (err) { - // Interactive cancel (promptSelect throws 'Selection cancelled'; on EOF, ask returns an empty string - // and hits that path) is not a failure. Exit quietly, and do not turn it into an unhandled-rejection stack. - const msg = err instanceof Error ? err.message : String(err); - // --json never prompts, so nothing here is a cancel. Listing, reading and copying report their - // failures as values, so a throw that reaches here has no known cause. - if (json) return printEnvelope({ error: { code: 'unexpected_error', message: msg } }); - if (/cancel/i.test(msg)) { - console.log('Cancelled.'); - return; - } - console.error(`Error: ${msg}`); - process.exit(1); - } finally { - restoreStdout?.(); - closeStdin(); - } - }); + registerMigrationCommand(sessionCmd, isDryRun); // ── session push ─────────────────────────────────────────── sessionCmd diff --git a/src/session-flow/session-migrate-cmd.ts b/src/session-flow/session-migrate-cmd.ts new file mode 100644 index 000000000..f945e73c8 --- /dev/null +++ b/src/session-flow/session-migrate-cmd.ts @@ -0,0 +1,550 @@ +import type { Command } from 'commander'; +import { listAvailablePlatforms } from './adapters/index.js'; +import { previewMigration, resolveTargetCwd } from './migrate.js'; +import { + AgentSession, + noAdapter, + toolRootsLookup, + type AgentSessionCopy, +} from './agent-session.js'; +import { + SyncManager, + getGitAuthor, + defaultSyncMeta, + withSessionIndexLock, +} from './sync.js'; +import { isInteractive } from '../utils/prompt.js'; +import { setStderrOnly } from '../utils/logger.js'; +import { + archiveBusyMessage, + ask, + authorForCwd, + closeStdin, + confirmUnredactedArchive, + deriveArchiveIdentity, + listedCounts, + listSource, + printEnvelope, + pushToRemote, + reportArchiveBusy, + resolveRepoRoot, + runGitStep, + safeText, +} from './session-cmd-shared.js'; + +const BATCH_CONFIRM_THRESHOLD = 10; +const SESSION_PICKER_PAGE_SIZE = 10; + +/** What `session migrate --json` reports for one copy: the copy result without its status. */ +type MigrateJsonData = Omit, 'status'>; + +/** + * Show a numbered menu and let the user pick one item. + * A number or a name is accepted; returns the selected string. + */ +async function promptSelect(question: string, options: string[]): Promise { + console.log(question); + for (let i = 0; i < options.length; i++) { + console.log(` [${i + 1}] ${options[i]}`); + } + const ans = await ask('Select (number or name): '); + if (!ans) throw new Error('Selection cancelled'); + const num = parseInt(ans, 10); + if (!Number.isNaN(num) && num >= 1 && num <= options.length) { + return options[num - 1]; + } + const lower = ans.toLowerCase(); + const byName = options.find((o) => o.toLowerCase() === lower); + if (byName) return byName; + throw new Error(`Invalid selection: ${ans}`); +} + +/** + * Under --json, stdout carries only the envelope: every progress line, from this command or a + * logger it reaches, goes to stderr until the returned restore runs. + */ +function reserveStdoutForEnvelope(): () => void { + const log = console.log; + console.log = console.error; + const wasStderrOnly = setStderrOnly(true); + return () => { + console.log = log; + setStderrOnly(wasStderrOnly); + }; +} + +/** Register the migration command and keep its orchestration isolated from the other session commands. */ +export function registerMigrationCommand(sessionCmd: Command, isDryRun: () => boolean): void { + // ── session migrate ──────────────────────────────────────── + sessionCmd + .command('migrate') + .description('Migrate a session from one platform to another (or archive to same platform)') + .argument('[sessionId]', 'Session ID to migrate') + .option('-s, --source ', 'Source platform (e.g. claude-code, codebuddy)') + .option('-t, --target ', 'Target platform') + .option('--cwd ', 'Working directory (defaults to current directory)') + .option('--target-cwd ', 'Override cwd for the target session') + .option('--push', 'Also push the migrated session to the team repo') + .option('--repo-root ', 'Team repo root (for --push)') + .option('--scrub', 'Redact secrets (tokens/keys/passwords) from the session before writing it') + .option('--all', 'Migrate every session from source (not just the 5 most recent)') + .option('--limit ', 'Max sessions to migrate (only caps --all; ignored otherwise)') + .option('-y, --yes', 'Skip confirmation prompts, including archiving unredacted transcripts') + .option('--json', 'Print one {data}|{error} JSON envelope on stdout; never prompts (needs a session id, -s and -t)') + .action(async (sessionId, opts) => { + const json = Boolean(opts.json); + const restoreStdout = json ? reserveStdoutForEnvelope() : undefined; + try { + // A driver never answers a prompt: refuse up front, before anything is copied. + if (json) { + const refuse = (message: string) => printEnvelope({ error: { code: 'prompt_refused', message } }); + if (opts.all) { + return printEnvelope({ + error: { code: 'unsupported_option', message: '--json migrates one session: pass its id instead of --all.' }, + }); + } + if (!opts.source || !opts.target) return refuse('--json never prompts: pass -s and -t .'); + if (!sessionId) return refuse('--json never prompts: pass the session id to migrate.'); + if (opts.push && !opts.scrub && !opts.yes) { + return refuse('--json never prompts: pass -y to archive the transcript as-is, or --scrub to redact it first.'); + } + } + let source = opts.source; + let target = opts.target; + + // Interactive: when source/target is missing, guide the user to pick + if (!source) { + source = await promptSelect('Select source platform:', listAvailablePlatforms()); + } + if (!target) { + const others = listAvailablePlatforms().filter((p) => p !== source); + target = await promptSelect('Select target platform:', others); + } + + // A target no copy can be written to fails once here, not once per session after its preview. + const refusedTarget = noAdapter(target, 'write'); + if (refusedTarget) { + if (json) return printEnvelope({ error: { code: refusedTarget.code, message: refusedTarget.message } }); + console.error(`Error: ${refusedTarget.message}`); + process.exitCode = 1; + return; + } + + let workCwd = opts.cwd ?? process.cwd(); + let metas = await listSource({ platform: source, cwd: workCwd, asEnvelope: json }); + if (!metas) return; + let crossDirExpanded = false; + // --all means "every session from this source": with no --cwd, enumerate + // across all workspaces, rather than only the current directory (that + // would silently turn --all into "everything in the current directory"). + if (opts.all && !opts.cwd && !sessionId) { + const allMetas = await listSource({ platform: source }); + if (!allMetas) return; + if (allMetas.length > 0) { + metas = allMetas; + crossDirExpanded = true; + } + } + + + // When the current cwd has no sessions, interactively offer to list sessions from every directory. + // After expansion those sessions **do not belong to workCwd**; source lookup must pass + // undefined so the adapter searches globally by id (every adapter's findSessionFile has that fallback). + // Previously workCwd was still passed to the source adapter, and claude-code + // only looked in the single encodeCwdClaude(workCwd) directory, so this path failed 100% of the time. + if (metas.length === 0 && !opts.cwd && !sessionId) { + const allMetas = await listSource({ platform: source }); + if (!allMetas) return; + if (allMetas.length > 0) { + console.log(`\nNo sessions found in current directory: ${workCwd}`); + console.log(`But ${allMetas.length} session(s) found across all directories on ${source}.`); + console.log(`Tip: pass --cwd to migrate from a specific workspace (non-interactive runs need it).`); + const ans = await ask('List all? (y/N): '); + if (ans.toLowerCase() === 'y' || ans.toLowerCase() === 'yes') { + metas = allMetas; + crossDirExpanded = true; + } + } + } + /** Workspace used for source lookup: when expanded across directories, sessions do not belong to workCwd, so the adapter searches globally. */ + const sourceProjectPath = crossDirExpanded ? undefined : workCwd; + const printNotFound = () => + printEnvelope({ error: { code: 'not_found', message: `No ${source} session ${sessionId} in ${workCwd}.` } }); + + if (metas.length === 0) { + if (json) return printNotFound(); + console.log('No sessions found on source platform.'); + return; + } + + // Select sessions + let targets: typeof metas; + if (opts.all) { + // --all means what it says: migrate every session, no longer silently truncated to 5. + // Previously slice(0, 5) with no notice, so users thought everything had been migrated. + // Use --limit when a cap is needed (same semantics as session push). + const limitRaw = parseInt(opts.limit, 10); + const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 0; + const sorted = metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + targets = limit > 0 ? sorted.slice(0, limit) : sorted; + + // Large-batch confirmation: --all now migrates everything (no longer truncated to 5). + // When there are many sessions, list them and require confirmation; -y skips it. + // Avoid accidentally migrating dozens or hundreds at once, which is expensive to roll back. + if (targets.length > BATCH_CONFIRM_THRESHOLD && !opts.yes) { + console.log(`\nAbout to migrate ${targets.length} session(s) from ${source}:`); + for (const m of targets) { + const title = safeText(m.title); + console.log(` ${m.sessionId.slice(0, 8)} ${title.length > 50 ? title.slice(0, 50) + '...' : title}${listedCounts(m)}`); + } + // Nobody can answer in a non-interactive run. Cancelling with exit 0 + // there would tell a script that every session was migrated; refuse + // instead and let the caller opt in explicitly. + if (!isInteractive()) { + console.error(`\nRefusing to migrate ${targets.length} session(s) without confirmation.`); + console.error('Pass -y to confirm, or --limit to migrate fewer.'); + process.exit(1); + } + const ans = await ask('\nMigrate all of the above? (y/N): '); + if (ans.toLowerCase() !== 'y' && ans.toLowerCase() !== 'yes') { + console.log('Cancelled.'); + return; + } + } + } else if (sessionId) { + const matches = metas.filter( + (m) => m.sessionId === sessionId || m.sessionId.startsWith(sessionId), + ); + if (matches.length === 0) { + if (json) return printNotFound(); + console.error(`Session not found: ${sessionId}`); + process.exit(1); + } + // Ambiguous prefix: taking every match silently migrates sessions the + // user never named (and rollback then has to undo all of them). + if (matches.length > 1) { + if (json) { + const ids = matches.slice(0, 10).map((m) => m.sessionId).join(', '); + return printEnvelope({ + error: { + code: 'ambiguous_id', + message: `Session id "${sessionId}" matches ${matches.length} sessions (${ids}). Pass the full id.`, + }, + }); + } + console.error(`Ambiguous session id "${sessionId}": matches ${matches.length} sessions.`); + for (const m of matches.slice(0, 10)) { + console.error(` ${m.sessionId} ${safeText(m.title).slice(0, 60)}`); + } + console.error('Pass more characters of the id, or use --all / no argument to pick from a list.'); + process.exit(1); + } + targets = matches; + } else { + // Interactive paging: 10 per screen, n/p to change page. Numbers are global indexes (still selectable after paging). + // Showing only the first 10 with no way to page makes users think there are only 10 sessions once the list grows. + const sorted = metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + const pageSize = SESSION_PICKER_PAGE_SIZE; + const pages = Math.max(1, Math.ceil(sorted.length / pageSize)); + let page = 0; + + for (;;) { + const start = page * pageSize; + const slice = sorted.slice(start, start + pageSize); + console.log( + `\nSessions on ${source} — ${pages > 1 ? `page ${page + 1}/${pages}, ` : ''}${sorted.length} total:`, + ); + for (let i = 0; i < slice.length; i++) { + const m = slice[i]; + const title = safeText(m.title); + const titleShown = title.length > 50 ? title.slice(0, 50) + '...' : title; + console.log( + ` [${start + i + 1}] ${m.sessionId.slice(0, 8)} ${titleShown}${listedCounts(m, { includeSize: true })}`, + ); + } + + const ans = ( + await ask( + pages > 1 + ? '\nSelect a number, n = next page, p = previous page, Enter = cancel: ' + : '\nSelect session (number) or Enter to cancel: ', + ) + ) + .trim() + .toLowerCase(); + // Enter (and EOF, which reads as an empty answer) still cancels: + // a non-interactive run must never sit in a paging loop. + if (!ans || ans === 'q' || ans === 'quit' || ans === 'exit') { + console.log('Cancelled.'); + return; + } + if (pages > 1 && (ans === 'n' || ans === 'next' || ans === 'd')) { + page = (page + 1) % pages; + continue; + } + if (pages > 1 && (ans === 'p' || ans === 'prev' || ans === 'previous' || ans === 'u')) { + page = (page - 1 + pages) % pages; + continue; + } + const num = parseInt(ans, 10); + if (!Number.isNaN(num) && num >= 1 && num <= sorted.length) { + targets = [sorted[num - 1]]; + break; + } + console.log( + `Enter a number between 1 and ${sorted.length}${pages > 1 ? ', or n/p to change page' : ''}.`, + ); + } + } + + const toolRootsFor = toolRootsLookup(); + let migrated = 0; + let reused = 0; + let failed = 0; + // Record the target session ID and real fidelity of each successful migration, and of each + // copy that already existed (a retried --push archives it). + // --push then reads those IDs back exactly (not "the N most recent on the target platform", + // which would push the wrong sessions; see P4/P7). + const migratedTargets: { sessionId: string; fidelityScore: number; cwd?: string }[] = []; + /** --json: the one copy this run made or found. */ + let copied: MigrateJsonData | undefined; + + for (const m of targets) { + // The preview shows what is stored; a --scrub run redacts in the copy itself. + const found = await AgentSession.find( + { platform: source, sessionId: m.sessionId, cwd: sourceProjectPath, filePath: m.filePath }, + { scrub: false, toolRootsFor }, + ); + if (found.status === 'failed') { + if (json) return printEnvelope({ error: { code: found.failure.code, message: found.failure.message } }); + console.error(`\n ✗ Migration failed: ${found.failure.message}`); + failed++; + continue; + } + const preview = previewMigration(found.session, source, target); + + console.log(`\n Migration Preview`); + console.log(` ─────────────────────────────────`); + console.log(` Source: ${preview.sourcePlatform}`); + console.log(` Target: ${preview.targetPlatform}`); + console.log(` Session: ${safeText(preview.sessionTitle)} (${preview.sessionId.slice(0, 8)}...)`); + console.log(` CWD: ${preview.cwd}`); + // Target workspace defaults to the source session's workspace; only --target-cwd moves it + console.log(` Target CWD:${opts.targetCwd ? ' ' + opts.targetCwd : ' (same as source)'}`); + console.log(` Messages: ${preview.messageCount}`); + console.log(` ─────────────────────────────────`); + console.log(` Fidelity: ${(preview.fidelity.score * 100).toFixed(1)}% (Mode ${preview.fidelity.mode})`); + console.log(` Preserved: ${preview.fidelity.preservedBlocks}/${preview.fidelity.totalBlocks} blocks`); + if (preview.fidelity.degradedBlocks > 0) { + console.log(` Degraded: ${preview.fidelity.degradedBlocks} blocks`); + } + for (const d of preview.fidelity.degradations) { + console.log(` ⚠ ${d}`); + } + for (const w of preview.fidelity.warnings) { + console.log(` ⚠ ${w}`); + } + + // --dry-run: stop once the preview is printed. + // Migration has no confirmation step (it runs immediately after printing the preview). + // Without honoring the global --dry-run, the only way to see fidelity and warnings + // is to migrate for real and roll back if unhappy. + if (isDryRun()) { + if (json) return printEnvelope({ data: { dryRun: true, fidelity: preview.fidelity } }); + console.log(`\n · --dry-run: preview only, not migrated: ${m.sessionId.slice(0, 8)}...\n`); + continue; + } + + // Target workspace default = source session workspace (keep the directory the same); + // only an explicit --target-cwd moves the session to another workspace. An encoded or + // placeholder source cwd (e.g. `-Users-foo-project`) falls back to --cwd, and copy + // refuses anything that is still not an existing absolute directory. + const targetCwd = resolveTargetCwd(opts.targetCwd, preview.cwd, sourceProjectPath) ?? preview.cwd; + const result = await AgentSession.copy({ + from: { platform: source, sessionId: m.sessionId, cwd: sourceProjectPath, filePath: m.filePath }, + to: { platform: target, cwd: targetCwd }, + scrub: Boolean(opts.scrub), + toolRootsFor, + }); + if (result.status === 'failed') { + if (json) { + printEnvelope({ error: { code: result.failure.code, message: result.failure.message } }); + return; + } + console.error(`\n ✗ Migration failed: ${result.failure.message}`); + failed++; + continue; + } + if (result.reused) { + // Never overwritten: the member may have kept talking in the copy. + console.log(`\n ✓ Session already migrated as ${result.sessionId}, nothing written.`); + console.log(` Target CWD: ${result.cwd}`); + console.log( + ` To refresh it, delete that copy and migrate again: teamai session rollback ${result.sessionId} --platform ${target} --cwd "${result.cwd}"`, + ); + reused++; + } else { + console.log(`\n ✓ Migration successful`); + console.log(` Target session ID: ${result.sessionId}`); + console.log(` Target CWD: ${result.cwd}`); + if (opts.scrub) { + console.log( + ` Redacted: ${result.redactedCount ?? 0} secret-looking value(s) (best-effort; review before archiving)`, + ); + } + if (result.filePath) { + console.log(` Target file: ${result.filePath}`); + } + if (result.notice) { + console.error(` ⚠ ${result.notice}`); + } + console.log(` Fidelity: ${(result.fidelity.score * 100).toFixed(1)}%`); + migrated++; + } + migratedTargets.push({ sessionId: result.sessionId, fidelityScore: result.fidelity.score, cwd: result.cwd }); + const { status: _status, ...copy } = result; + copied = copy; + } + // Scripted semantics: any single failure exits non-zero (--all batch does not stop the remaining sessions). + if (failed > 0) { + process.exitCode = 1; + } + + // --push: push to the team repo + if (opts.push && migratedTargets.length > 0) { + const consent = await confirmUnredactedArchive(migratedTargets.length, `${source} → ${target}`, { + scrub: Boolean(opts.scrub), + yes: Boolean(opts.yes), + }); + if (consent !== 'ok') { + console.error(`\n ${migratedTargets.length} session(s) migrated locally, not archived.`); + if (consent === 'refused') { + console.error('Pass --scrub to redact the archive, or -y to archive as-is.'); + process.exitCode = 1; + } else { + console.log('Archive cancelled. Re-run with --scrub to archive a redacted copy.'); + } + return; + } + const repoRoot = await resolveRepoRoot(opts.repoRoot, { materialize: !isDryRun() }); + // When expanded across directories, sessions do not belong to workCwd; author should come from the repo the session actually lives in + const authorCwd = migratedTargets.find((t) => t.cwd)?.cwd ?? workCwd; + const author = getGitAuthor(authorCwd); + + const syncMgr = new SyncManager(repoRoot); + /** --json: the copy stays on this machine whatever happens to its archive, so say where it is. */ + const archiveFailed = (code: string, reason: string) => + printEnvelope({ error: { code, message: `Copied as ${copied?.sessionId} in ${copied?.cwd}, but not archived: ${reason}` } }); + let saved = 0; + let unarchived = 0; + for (const t of migratedTargets) { + // Migration keeps image paths so the target can still open the file. + // The copy that lands in the team repo must not: re-read the target + // and scrub again with the archive default, which drops filePath. + const readBack = await AgentSession.find( + { platform: target, sessionId: t.sessionId, cwd: t.cwd ?? opts.targetCwd ?? workCwd }, + { scrub: Boolean(opts.scrub), toolRootsFor }, + ); + if (readBack.status === 'failed') { + if (json) return archiveFailed(readBack.failure.code, readBack.failure.message); + console.error(`\n ✗ Not archived: ${readBack.failure.message}`); + unarchived++; + continue; + } + const session = readBack.session; + // P3: the archive key is derived from the session's native cwd, not the directory migrate is running in (see deriveArchiveIdentity) + const meta = defaultSyncMeta( + { + platform: target, + author: authorForCwd(session.cwd ?? t.cwd, author), + cwd: session.cwd || opts.targetCwd || workCwd, + sessionId: t.sessionId, + repoIdentity: deriveArchiveIdentity(session, target), + title: session.title, + }, + session.createdAt, + ); + meta.migration.migratedAt = new Date().toISOString(); + meta.migration.sourcePlatform = source; + meta.migration.targetPlatform = target; + meta.migration.fidelityScore = t.fidelityScore; + try { + const write = await withSessionIndexLock(repoRoot, async () => syncMgr.saveSession(session, meta)); + if (write.status === 'busy') { + if (json) return archiveFailed('busy', `${archiveBusyMessage(repoRoot, write.lockPath)} Run it again once that has finished.`); + reportArchiveBusy(repoRoot, write.lockPath); + return; + } + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + if (json) return archiveFailed('archive_failed', reason); + console.error(`Error: ${reason}`); + process.exitCode = 1; + return; + } + saved++; + } + if (unarchived > 0) { + console.error(`\n ✗ ${unarchived} migrated session(s) could not be read back and were not archived.`); + process.exitCode = 1; + } + // [fixed] a gitCommit failure (not a git directory / index.lock race) used to crash with a raw stack trace + // Under --json a throw reaches the action's catch, which prints the envelope; runGitStep would exit without one. + const commitArchive = () => syncMgr.gitCommit(`sync: migrate ${saved} session(s) ${source}→${target}`); + const commit = json ? commitArchive() : runGitStep(commitArchive, repoRoot, 'git commit'); + if (commit.status === 'committed') { + const pushed = await pushToRemote(syncMgr, repoRoot); + if (pushed) { + console.log(`\n ✓ Pushed ${saved} session(s) to team repo`); + } else { + console.log(`\n · ${saved} session(s) committed locally but not pushed`); + process.exitCode = 1; + if (json) { + return archiveFailed( + 'archive_failed', + `committed in ${repoRoot} as ${commit.commit.slice(0, 8)}, but the push to the remote failed (reason on stderr).`, + ); + } + } + console.log(` commit: ${commit.commit.slice(0, 8)}`); + } else if (commit.status === 'failed') { + // Hooks / gpg / missing identity: the archive files are still + // staged. Say so, and fail -- "No changes to push" here would hide + // a broken archive. + console.error(`\n ✗ Commit failed, ${saved} session(s) left staged in ${repoRoot}: ${safeText(commit.reason)}`); + process.exitCode = 1; + if (json) return archiveFailed('archive_failed', `the commit failed, files left staged in ${repoRoot}: ${safeText(commit.reason)}`); + } else { + console.log(`\n · No changes to push\n`); + } + } + + if (json && copied) { + printEnvelope({ data: copied }); + return; + } + const failedNote = failed > 0 ? `, ${failed} failed` : ''; + console.log( + isDryRun() + ? `\n ${targets.length - failed} session(s) would be migrated${failedNote} (--dry-run, no changes made).\n` + : `\n ${migrated} session(s) migrated${reused > 0 ? `, ${reused} already migrated` : ''}${failedNote}.\n`, + ); + } catch (err) { + // Interactive cancel (promptSelect throws 'Selection cancelled'; on EOF, ask returns an empty string + // and hits that path) is not a failure. Exit quietly, and do not turn it into an unhandled-rejection stack. + const msg = err instanceof Error ? err.message : String(err); + // --json never prompts, so nothing here is a cancel. Listing, reading and copying report their + // failures as values, so a throw that reaches here has no known cause. + if (json) return printEnvelope({ error: { code: 'unexpected_error', message: msg } }); + if (/cancel/i.test(msg)) { + console.log('Cancelled.'); + return; + } + console.error(`Error: ${msg}`); + process.exit(1); + } finally { + restoreStdout?.(); + closeStdin(); + } + }); +}