runcastle 1.6.1 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/bin/runcastle.js +2169 -18
  2. package/index.js +28 -2
  3. package/package.json +1 -1
package/bin/runcastle.js CHANGED
@@ -2061,11 +2061,35 @@ var init_schema = __esm(() => {
2061
2061
  // src/db/client.ts
2062
2062
  var exports_client = {};
2063
2063
  __export(exports_client, {
2064
+ openRawSqlite: () => openRawSqlite,
2064
2065
  createDb: () => createDb,
2065
2066
  BUSY_TIMEOUT_MS: () => BUSY_TIMEOUT_MS
2066
2067
  });
2067
2068
  import { Database } from "bun:sqlite";
2068
2069
  import { drizzle } from "drizzle-orm/bun-sqlite";
2070
+ function openRawSqlite(path, opts) {
2071
+ const sqlite = new Database(path, opts.readonly ? { readonly: true } : { readwrite: true });
2072
+ sqlite.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS};`);
2073
+ return {
2074
+ all: (sql, ...params) => {
2075
+ const statement = sqlite.prepare(sql);
2076
+ try {
2077
+ return statement.all(...params);
2078
+ } finally {
2079
+ statement.finalize();
2080
+ }
2081
+ },
2082
+ run: (sql, ...params) => {
2083
+ const statement = sqlite.prepare(sql);
2084
+ try {
2085
+ statement.run(...params);
2086
+ } finally {
2087
+ statement.finalize();
2088
+ }
2089
+ },
2090
+ close: () => sqlite.close()
2091
+ };
2092
+ }
2069
2093
  function createDb(path) {
2070
2094
  const sqlite = new Database(path, { create: true });
2071
2095
  sqlite.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS};`);
@@ -2133,6 +2157,9 @@ var init_asset_paths = __esm(() => {
2133
2157
  import { readFileSync as readFileSync2, readdirSync } from "fs";
2134
2158
  import { join as join4 } from "path";
2135
2159
  import { sql } from "drizzle-orm";
2160
+ function bundledMigrations(dir = MIGRATIONS_DIR) {
2161
+ return readdirSync(dir).filter((f) => f.endsWith(".sql")).sort();
2162
+ }
2136
2163
  function runMigrations(db, dir = MIGRATIONS_DIR) {
2137
2164
  db.run(sql.raw(`CREATE TABLE IF NOT EXISTS __migrations (
2138
2165
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -2141,8 +2168,7 @@ function runMigrations(db, dir = MIGRATIONS_DIR) {
2141
2168
  )`));
2142
2169
  const appliedRows = db.all(sql.raw("SELECT name FROM __migrations"));
2143
2170
  const applied = new Set(appliedRows.map((r) => r.name));
2144
- const files = readdirSync(dir).filter((f) => f.endsWith(".sql")).sort();
2145
- for (const file of files) {
2171
+ for (const file of bundledMigrations(dir)) {
2146
2172
  if (applied.has(file))
2147
2173
  continue;
2148
2174
  const contents = readFileSync2(join4(dir, file), "utf8");
@@ -75440,6 +75466,2113 @@ var init_setup3 = __esm(() => {
75440
75466
  AGENT_KEYS = { "2": "codex", "3": "both" };
75441
75467
  });
75442
75468
 
75469
+ // src/move/shell.ts
75470
+ import { spawnSync as spawnSync3 } from "child_process";
75471
+ import { lstatSync, readdirSync as readdirSync11, realpathSync as realpathSync3, statfsSync, statSync as statSync12 } from "fs";
75472
+ import { join as join48 } from "path";
75473
+ function run8(command, args2, opts = {}) {
75474
+ const result = spawnSync3(command, args2, {
75475
+ cwd: opts.cwd,
75476
+ input: opts.input,
75477
+ stdio: opts.stdio,
75478
+ env: opts.env,
75479
+ encoding: "utf8",
75480
+ maxBuffer: HUGE_OUTPUT,
75481
+ windowsHide: true
75482
+ });
75483
+ return {
75484
+ ok: result.status === 0,
75485
+ stdout: result.stdout ?? "",
75486
+ stderr: result.stderr ?? (result.error?.message ?? "")
75487
+ };
75488
+ }
75489
+ function git3(cwd, args2) {
75490
+ return run8("git", ["-c", "core.quotepath=off", ...args2], { cwd, env: READ_ONLY_GIT });
75491
+ }
75492
+ function gitOrThrow(cwd, args2) {
75493
+ const result = git3(cwd, args2);
75494
+ if (!result.ok)
75495
+ throw new Error(`git ${args2.join(" ")} failed in ${cwd}: ${result.stderr.trim()}`);
75496
+ return result.stdout;
75497
+ }
75498
+ function nulList(output) {
75499
+ return output.split("\x00").filter((entry) => entry.length > 0);
75500
+ }
75501
+ function systemTar() {
75502
+ if (process.platform !== "win32")
75503
+ return "tar";
75504
+ return join48(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe");
75505
+ }
75506
+ function walkFiles(dir, base = "", strict2 = false) {
75507
+ let entries2;
75508
+ try {
75509
+ entries2 = readdirSync11(dir, { withFileTypes: true });
75510
+ } catch (err) {
75511
+ if (strict2)
75512
+ throw err;
75513
+ return [];
75514
+ }
75515
+ const files = [];
75516
+ for (const entry of entries2) {
75517
+ const rel = base ? `${base}/${entry.name}` : entry.name;
75518
+ if (entry.isDirectory())
75519
+ files.push(...walkFiles(join48(dir, entry.name), rel, strict2));
75520
+ else if (entry.isFile())
75521
+ files.push(rel);
75522
+ }
75523
+ return files;
75524
+ }
75525
+ function walkFollowing(path3, rel, broken, parents = []) {
75526
+ let real2;
75527
+ let isDirectory;
75528
+ try {
75529
+ real2 = realpathSync3(path3);
75530
+ isDirectory = statSync12(path3).isDirectory();
75531
+ } catch {
75532
+ broken.push(rel);
75533
+ return [];
75534
+ }
75535
+ if (!isDirectory)
75536
+ return [rel];
75537
+ if (parents.includes(real2)) {
75538
+ broken.push(rel);
75539
+ return [];
75540
+ }
75541
+ return readdirSync11(path3).filter((name) => name !== "node_modules" && name !== ".git").flatMap((name) => walkFollowing(join48(path3, name), `${rel}/${name}`, broken, [...parents, real2]));
75542
+ }
75543
+ function walkSymlinks(dir, base = "") {
75544
+ let entries2;
75545
+ try {
75546
+ entries2 = readdirSync11(dir, { withFileTypes: true });
75547
+ } catch {
75548
+ return [];
75549
+ }
75550
+ const links = [];
75551
+ for (const entry of entries2) {
75552
+ const rel = base ? `${base}/${entry.name}` : entry.name;
75553
+ if (entry.isSymbolicLink())
75554
+ links.push(rel);
75555
+ else if (entry.isDirectory())
75556
+ links.push(...walkSymlinks(join48(dir, entry.name), rel));
75557
+ }
75558
+ return links;
75559
+ }
75560
+ function isSymlink(path3) {
75561
+ try {
75562
+ return lstatSync(path3).isSymbolicLink();
75563
+ } catch {
75564
+ return false;
75565
+ }
75566
+ }
75567
+ function linkInPath(root, rel) {
75568
+ const segments = rel.split("/");
75569
+ for (let i = 1;i <= segments.length; i++) {
75570
+ const prefix = segments.slice(0, i).join("/");
75571
+ if (isSymlink(join48(root, prefix)))
75572
+ return prefix;
75573
+ }
75574
+ return null;
75575
+ }
75576
+ function pathBytes(path3) {
75577
+ try {
75578
+ const stat3 = lstatSync(path3);
75579
+ if (!stat3.isDirectory())
75580
+ return stat3.isFile() ? stat3.size : 0;
75581
+ } catch {
75582
+ return 0;
75583
+ }
75584
+ return walkFiles(path3).reduce((total, rel) => total + pathBytes(join48(path3, rel)), 0);
75585
+ }
75586
+ function freeBytes(path3) {
75587
+ try {
75588
+ const stats = statfsSync(path3);
75589
+ return Number(stats.bavail) * Number(stats.bsize);
75590
+ } catch {
75591
+ return null;
75592
+ }
75593
+ }
75594
+ function formatBytes(bytes) {
75595
+ if (bytes >= 1024 ** 3)
75596
+ return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
75597
+ if (bytes >= 1024 ** 2)
75598
+ return `${Math.round(bytes / 1024 ** 2)} MB`;
75599
+ return `${Math.round(bytes / 1024)} KB`;
75600
+ }
75601
+ function stamp(now2 = new Date) {
75602
+ const pad = (n) => String(n).padStart(2, "0");
75603
+ return `${now2.getFullYear()}${pad(now2.getMonth() + 1)}${pad(now2.getDate())}-` + `${pad(now2.getHours())}${pad(now2.getMinutes())}${pad(now2.getSeconds())}`;
75604
+ }
75605
+ var HUGE_OUTPUT, READ_ONLY_GIT;
75606
+ var init_shell = __esm(() => {
75607
+ HUGE_OUTPUT = 512 * 1024 * 1024;
75608
+ READ_ONLY_GIT = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
75609
+ });
75610
+
75611
+ // src/move/destination.ts
75612
+ import { existsSync as existsSync41, readdirSync as readdirSync12, readFileSync as readFileSync28 } from "fs";
75613
+ import { isAbsolute as isAbsolute7, join as join49 } from "path";
75614
+ function reposDirOf(env, override) {
75615
+ return override ?? join49(env.home, DEFAULT_REPOS_FOLDER);
75616
+ }
75617
+ function serverPort(env) {
75618
+ const fromEnv2 = Number(process.env.RUNCASTLE_SERVER_PORT);
75619
+ if (Number.isInteger(fromEnv2) && fromEnv2 > 0)
75620
+ return fromEnv2;
75621
+ try {
75622
+ const config2 = JSON.parse(readFileSync28(join49(env.dataDir, "config.json"), "utf8"));
75623
+ return config2.serverPort ?? 4512;
75624
+ } catch {
75625
+ return 4512;
75626
+ }
75627
+ }
75628
+ async function serverRunning(env) {
75629
+ try {
75630
+ const reply = await fetch(`http://127.0.0.1:${serverPort(env)}/health`, {
75631
+ signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS)
75632
+ });
75633
+ const body = await reply.json();
75634
+ return !body.dataDir || sameDataDir(body.dataDir, env.dataDir);
75635
+ } catch (err) {
75636
+ return err instanceof SyntaxError || err.name === "TimeoutError";
75637
+ }
75638
+ }
75639
+ function existingProjects(env, open4) {
75640
+ const path3 = join49(env.dataDir, "runcastle.db");
75641
+ if (!existsSync41(path3))
75642
+ return [];
75643
+ const sql5 = open4(path3, { readonly: true });
75644
+ try {
75645
+ return sql5.all("SELECT name FROM projects").map((row2) => row2.name);
75646
+ } catch {
75647
+ return [];
75648
+ } finally {
75649
+ sql5.close();
75650
+ }
75651
+ }
75652
+ function bytesNeeded(question) {
75653
+ return question.dataBytes * 2 + question.repoBytes * 4;
75654
+ }
75655
+ async function checkDestination(question, env, opts) {
75656
+ const blockers = [];
75657
+ const notes = [];
75658
+ const reposDir = reposDirOf(env, opts.reposDir);
75659
+ if (env.platform === "win32")
75660
+ blockers.push("a Windows machine cannot be the destination of a move");
75661
+ if (!isAbsolute7(reposDir))
75662
+ blockers.push(`--repos-dir must be an absolute path on this machine, not ${reposDir}`);
75663
+ if (await serverRunning(env))
75664
+ blockers.push("this machine's runcastle server is running: stop it first");
75665
+ const known = new Set(bundledMigrations());
75666
+ const unknown2 = question.migrations.filter((name) => !known.has(name));
75667
+ if (unknown2.length > 0) {
75668
+ blockers.push(`the pack comes from a newer runcastle (${question.runcastleVersion}) than this one: update this machine first`);
75669
+ }
75670
+ const projects2 = existingProjects(env, opts.open);
75671
+ if (projects2.length > 0) {
75672
+ const named = `${projects2.length} ${projects2.length === 1 ? "project" : "projects"} (${projects2.join(", ")})`;
75673
+ if (opts.replace)
75674
+ notes.push(`${named} will be set aside, not merged`);
75675
+ else
75676
+ blockers.push(`this machine holds ${named}: add --replace to set them aside`);
75677
+ }
75678
+ for (const folder of question.targetFolders) {
75679
+ const path3 = join49(reposDir, folder);
75680
+ if (!folderHasContent(path3))
75681
+ continue;
75682
+ if (opts.replace)
75683
+ notes.push(`${path3} will be set aside`);
75684
+ else
75685
+ blockers.push(`${path3} already exists: add --replace to set it aside, or choose --repos-dir`);
75686
+ }
75687
+ const free = freeBytes(existsSync41(env.home) ? env.home : "/");
75688
+ const needed = bytesNeeded(question);
75689
+ if (free !== null && free < needed) {
75690
+ blockers.push(`not enough disk: about ${formatBytes(needed)} needed, ${formatBytes(free)} free`);
75691
+ }
75692
+ return { platform: env.platform, blockers, notes, freeBytes: free, reposDir };
75693
+ }
75694
+ function folderHasContent(path3) {
75695
+ try {
75696
+ return readdirSync12(path3).length > 0;
75697
+ } catch {
75698
+ return false;
75699
+ }
75700
+ }
75701
+ var DEFAULT_REPOS_FOLDER = "projects", HEALTH_TIMEOUT_MS = 3000;
75702
+ var init_destination = __esm(() => {
75703
+ init_paths();
75704
+ init_migrate();
75705
+ init_shell();
75706
+ });
75707
+
75708
+ // src/move/paths.ts
75709
+ function canonicalSlashes(path3, sourcePlatform) {
75710
+ const slashed = sourcePlatform === "win32" ? path3.replace(/\\/g, "/") : path3;
75711
+ return slashed.length > 1 ? slashed.replace(/\/+$/, "") : slashed;
75712
+ }
75713
+ function foldForCompare(path3, sourcePlatform) {
75714
+ const canonical = canonicalSlashes(path3, sourcePlatform);
75715
+ return sourcePlatform === "win32" ? canonical.toLowerCase() : canonical;
75716
+ }
75717
+ function samePath2(a, b, sourcePlatform) {
75718
+ return foldForCompare(a, sourcePlatform) === foldForCompare(b, sourcePlatform);
75719
+ }
75720
+ function mapSourcePath(path3, rules, sourcePlatform) {
75721
+ const folded = foldForCompare(path3, sourcePlatform);
75722
+ const canonical = canonicalSlashes(path3, sourcePlatform);
75723
+ const ordered = [...rules].sort((a, b) => b.from.length - a.from.length);
75724
+ for (const rule of ordered) {
75725
+ const from = foldForCompare(rule.from, sourcePlatform);
75726
+ if (folded !== from && !folded.startsWith(`${from}/`))
75727
+ continue;
75728
+ const sep5 = rule.to.includes("\\") ? "\\" : "/";
75729
+ const rest = canonical.slice(from.length).split("/").join(sep5);
75730
+ return rule.to + rest;
75731
+ }
75732
+ return null;
75733
+ }
75734
+ function escapeRegExp(text3) {
75735
+ return text3.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
75736
+ }
75737
+ function rewritePathsInText(text3, rules, sourcePlatform) {
75738
+ const windows = sourcePlatform === "win32";
75739
+ const ordered = [...rules].sort((a, b) => b.from.length - a.from.length);
75740
+ if (ordered.length === 0)
75741
+ return text3;
75742
+ const places = ordered.map((rule) => canonicalSlashes(rule.from, sourcePlatform).split("/").map(escapeRegExp).join(windows ? "[\\\\/]+" : "/"));
75743
+ const pattern = new RegExp(`(?<![\\w.-])(${places.join("|")})(?![\\w.-])((?:[\\\\/][^\\s"'\`<>|&;,()]*)?)`, windows ? "gi" : "g");
75744
+ return text3.replace(pattern, (whole, place, rest) => {
75745
+ const rule = ordered.find((candidate) => samePath2(place, candidate.from, sourcePlatform));
75746
+ if (!rule)
75747
+ return whole;
75748
+ return rule.to + (windows ? rest.replace(/\\+/g, "/") : rest);
75749
+ });
75750
+ }
75751
+ function looksWindowsOnly(text3) {
75752
+ if (/(?<![\w])[A-Za-z]:[\\/]/.test(text3))
75753
+ return true;
75754
+ if (/%[A-Za-z_][A-Za-z0-9_]*%/.test(text3))
75755
+ return true;
75756
+ return /\b(if\s+(not\s+)?exist|del\s+\/|rd\s+\/|rmdir\s+\/|cmd(\.exe)?\s+\/|powershell|pwsh|taskkill)\b|\.(bat|cmd|exe|ps1)\b/i.test(text3);
75757
+ }
75758
+ function sourceBasename(path3, sourcePlatform) {
75759
+ const canonical = canonicalSlashes(path3, sourcePlatform);
75760
+ return canonical.slice(canonical.lastIndexOf("/") + 1);
75761
+ }
75762
+ function claudeHash(text3) {
75763
+ let hash2 = 0;
75764
+ for (let i = 0;i < text3.length; i++)
75765
+ hash2 = (hash2 << 5) - hash2 + text3.charCodeAt(i) | 0;
75766
+ return Math.abs(hash2).toString(36);
75767
+ }
75768
+ function encodeClaudeFolder(path3) {
75769
+ const encoded = path3.replace(/[^a-zA-Z0-9]/g, "-");
75770
+ if (encoded.length <= CLAUDE_FOLDER_MAX)
75771
+ return encoded;
75772
+ return `${encoded.slice(0, CLAUDE_FOLDER_MAX)}-${claudeHash(path3)}`;
75773
+ }
75774
+ function assignTargetFolders(projects2) {
75775
+ const movers = new Map;
75776
+ for (const project3 of projects2) {
75777
+ if (project3.repoMoves)
75778
+ movers.set(project3.folder, [...movers.get(project3.folder) ?? [], project3.id]);
75779
+ }
75780
+ const targets = new Map;
75781
+ for (const project3 of projects2) {
75782
+ const shared = projects2.filter((p) => p.folder === project3.folder).length > 1;
75783
+ const taken = shared && !project3.repoMoves;
75784
+ targets.set(project3.id, taken ? `${project3.folder}-${project3.id}` : project3.folder);
75785
+ }
75786
+ const clashes = [...movers].filter(([, ids3]) => ids3.length > 1).map(([folder]) => folder);
75787
+ return { targets, clashes };
75788
+ }
75789
+ var CLAUDE_FOLDER_MAX = 200;
75790
+
75791
+ // src/move/scan.ts
75792
+ import { existsSync as existsSync42, readdirSync as readdirSync13, readFileSync as readFileSync29, statSync as statSync13 } from "fs";
75793
+ import { join as join50 } from "path";
75794
+ function staysBehind(options) {
75795
+ return [
75796
+ options.withEnv ? "logins" : ".env files and logins",
75797
+ "Codex auth copies",
75798
+ "logs and caches",
75799
+ "the install id",
75800
+ "Claude's file undo checkpoints",
75801
+ ...options.withClaudeSetup ? [] : ["global Claude rules, skills and settings"],
75802
+ "Claude chats outside these repos"
75803
+ ];
75804
+ }
75805
+ function count5(n, noun) {
75806
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
75807
+ }
75808
+ function repoFacts(repoPath) {
75809
+ const refs = {};
75810
+ const listing = git3(repoPath, ["for-each-ref", "--format=%(objectname) %(refname)"]);
75811
+ for (const line2 of listing.stdout.split(`
75812
+ `)) {
75813
+ const space = line2.indexOf(" ");
75814
+ if (space > 0)
75815
+ refs[line2.slice(space + 1).trim()] = line2.slice(0, space);
75816
+ }
75817
+ const remotes = [];
75818
+ for (const name of git3(repoPath, ["remote"]).stdout.split(`
75819
+ `).map((n) => n.trim()).filter(Boolean)) {
75820
+ const url = git3(repoPath, ["remote", "get-url", name]);
75821
+ if (url.ok)
75822
+ remotes.push({ name, url: url.stdout.trim() });
75823
+ }
75824
+ const settings = git3(repoPath, ["config", "--local", "-z", "--get-regexp", "^(branch\\.|user\\.|core\\.hookspath)"]);
75825
+ const config2 = nulList(settings.stdout).map((entry) => {
75826
+ const newline = entry.indexOf(`
75827
+ `);
75828
+ return newline < 0 ? { key: entry, value: "" } : { key: entry.slice(0, newline), value: entry.slice(newline + 1) };
75829
+ });
75830
+ return { refs, remotes, config: config2, ...headOf2(repoPath) };
75831
+ }
75832
+ function headOf2(path3) {
75833
+ const branch = git3(path3, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
75834
+ const sha = git3(path3, ["rev-parse", "--verify", "--quiet", "HEAD"]);
75835
+ return {
75836
+ ...branch.ok ? { headBranch: branch.stdout.trim() } : {},
75837
+ ...sha.ok ? { headSha: sha.stdout.trim() } : {}
75838
+ };
75839
+ }
75840
+ function midOperation(path3) {
75841
+ const gitDirResult = git3(path3, ["rev-parse", "--absolute-git-dir"]);
75842
+ if (!gitDirResult.ok)
75843
+ return null;
75844
+ const gitDir = gitDirResult.stdout.trim();
75845
+ const marks = [
75846
+ ["MERGE_HEAD", "a merge"],
75847
+ ["rebase-merge", "a rebase"],
75848
+ ["rebase-apply", "a rebase"],
75849
+ ["CHERRY_PICK_HEAD", "a cherry-pick"],
75850
+ ["REVERT_HEAD", "a revert"]
75851
+ ];
75852
+ return marks.find(([file]) => existsSync42(join50(gitDir, file)))?.[1] ?? null;
75853
+ }
75854
+ function excludeRules(repoPath) {
75855
+ try {
75856
+ const rules = readFileSync29(join50(repoPath, ".git", "info", "exclude"), "utf8").replace(/\r\n/g, `
75857
+ `);
75858
+ return rules.split(`
75859
+ `).some((line2) => line2.trim() && !line2.startsWith("#")) ? rules : null;
75860
+ } catch {
75861
+ return null;
75862
+ }
75863
+ }
75864
+ function isEnvFile(rel) {
75865
+ const name = rel.slice(rel.lastIndexOf("/") + 1);
75866
+ return /^\.env(\..+)?$/.test(name) && !/\.(example|sample|template)$/.test(name);
75867
+ }
75868
+ function checkoutWip(key, path3) {
75869
+ const { headBranch, headSha: headSha2 } = headOf2(path3);
75870
+ const status3 = gitOrThrow(path3, ["status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=no"]);
75871
+ const changed = nulList(status3).map((entry) => entry.slice(3));
75872
+ const nested = [];
75873
+ const loose = [];
75874
+ for (const rel of nulList(gitOrThrow(path3, ["ls-files", "-o", "--exclude-standard", "-z"]))) {
75875
+ if (isBurnerPath(rel))
75876
+ continue;
75877
+ const bare = rel.replace(/\/$/, "");
75878
+ if (!rel.endsWith("/") || linkInPath(path3, bare)) {
75879
+ loose.push(bare);
75880
+ continue;
75881
+ }
75882
+ nested.push(bare);
75883
+ loose.push(...nulList(gitOrThrow(join50(path3, rel), ["ls-files", "-co", "--exclude-standard", "-z"])).map((inner) => `${rel}${inner}`));
75884
+ }
75885
+ const candidates = headSha2 ? loose : [...changed, ...loose];
75886
+ const links = candidates.map((rel) => linkInPath(path3, rel));
75887
+ const untracked = candidates.filter((_, i) => links[i] === null);
75888
+ return {
75889
+ key,
75890
+ path: path3,
75891
+ ...headBranch ? { branch: headBranch } : {},
75892
+ ...headSha2 ? { sha: headSha2 } : {},
75893
+ tracked: headSha2 ? changed : [],
75894
+ untracked: untracked.filter((rel) => !isEnvFile(rel)),
75895
+ nested,
75896
+ heldBack: untracked.filter(isEnvFile),
75897
+ symlinks: [...new Set(links.filter((link3) => link3 !== null))]
75898
+ };
75899
+ }
75900
+ function wipAttention(project3, wip) {
75901
+ const where = wip.key === "main" ? "" : ` (${wip.key})`;
75902
+ const lines2 = wip.nested.map((rel) => `${project3}${where}: ${rel} is a git repo of its own; its files travel, its history does not`);
75903
+ if (wip.heldBack.length > 0)
75904
+ lines2.push(`${project3}${where}: ${wip.heldBack.join(", ")} stays behind, though git does not ignore it`);
75905
+ if (wip.symlinks.length > 0)
75906
+ lines2.push(`${project3}${where}: untracked symlinks stay behind: ${wip.symlinks.join(", ")}`);
75907
+ if (wip.untracked.length > 0) {
75908
+ const shown2 = wip.untracked.slice(0, 3).join(", ");
75909
+ const more = wip.untracked.length > 3 ? `, and ${wip.untracked.length - 3} more` : "";
75910
+ lines2.push(`${project3}${where}: untracked files travel as they are: ${shown2}${more}`);
75911
+ }
75912
+ return lines2;
75913
+ }
75914
+ function hasWork(wip) {
75915
+ return wip.tracked.length > 0 || wip.untracked.length > 0;
75916
+ }
75917
+ function ignoredEntries(repoPath, carried) {
75918
+ const listing = git3(repoPath, ["ls-files", "-o", "-i", "--exclude-standard", "--directory", "-z"]);
75919
+ return nulList(listing.stdout).map((rel) => rel.replace(/\/$/, "")).filter((rel) => !rel.split("/").includes("node_modules") && !isBurnerPath(rel) && !carried(rel)).sort().map((rel) => {
75920
+ const bytes = pathBytes(join50(repoPath, rel));
75921
+ return bytes >= 1024 * 1024 ? `${rel} (${formatBytes(bytes)})` : rel;
75922
+ });
75923
+ }
75924
+ function ignoredEnvFiles(repoPath) {
75925
+ const listing = git3(repoPath, ["ls-files", "-o", "-i", "--exclude-standard", "-z", "--", ":(glob)**/.env", ":(glob)**/.env.*"]);
75926
+ return nulList(listing.stdout).filter((rel) => {
75927
+ const folders = rel.split("/").slice(0, -1);
75928
+ return isEnvFile(rel) && !folders.some((folder) => folder.startsWith(".") || folder === "node_modules");
75929
+ });
75930
+ }
75931
+ function parseAlso(value) {
75932
+ const colon = value.indexOf(":");
75933
+ if (colon < 1)
75934
+ return null;
75935
+ const path3 = value.slice(colon + 1).replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
75936
+ if (!path3 || path3.startsWith("/") || /^[A-Za-z]:/.test(path3) || path3.split("/").includes(".."))
75937
+ return null;
75938
+ return { project: value.slice(0, colon), path: path3 };
75939
+ }
75940
+ function isBurnerPath(rel) {
75941
+ return rel === ".sandcastle" || rel.startsWith(".sandcastle/");
75942
+ }
75943
+ function commitsOnNoRemote(repoPath) {
75944
+ const count6 = git3(repoPath, ["rev-list", "--count", "--branches", "--not", "--remotes"]);
75945
+ return count6.ok ? Number(count6.stdout.trim()) : 0;
75946
+ }
75947
+ function stashCount(repoPath) {
75948
+ return git3(repoPath, ["stash", "list"]).stdout.split(`
75949
+ `).filter(Boolean).length;
75950
+ }
75951
+ function leftoverWorktrees(repoPath, talkRoot) {
75952
+ const listing = git3(repoPath, ["worktree", "list", "--porcelain"]).stdout;
75953
+ const paths = listing.split(`
75954
+ `).filter((line2) => line2.startsWith("worktree ")).map((line2) => line2.slice("worktree ".length).trim()).slice(1);
75955
+ const talk = talkRoot.replace(/\\/g, "/").toLowerCase();
75956
+ return paths.filter((path3) => {
75957
+ if (path3.replace(/\\/g, "/").toLowerCase().startsWith(talk))
75958
+ return false;
75959
+ return existsSync42(path3) && git3(path3, ["status", "--porcelain"]).stdout.trim().length > 0;
75960
+ });
75961
+ }
75962
+ function localHooks(project3, repoPath) {
75963
+ try {
75964
+ const hooks2 = readdirSync13(join50(repoPath, ".git", "hooks")).filter((name) => !name.endsWith(".sample"));
75965
+ return hooks2.length > 0 ? [`${project3}: local git hooks stay behind: ${hooks2.join(", ")}`] : [];
75966
+ } catch {
75967
+ return [];
75968
+ }
75969
+ }
75970
+ function usesLfsOrSubmodules(repoPath) {
75971
+ const holds = (file, text3) => {
75972
+ try {
75973
+ return readFileSync29(join50(repoPath, file), "utf8").includes(text3);
75974
+ } catch {
75975
+ return false;
75976
+ }
75977
+ };
75978
+ return holds(".gitmodules", "[submodule") || holds(".gitattributes", "filter=lfs");
75979
+ }
75980
+ function repoBytes(repoPath) {
75981
+ return pathBytes(join50(repoPath, ".git", "objects"));
75982
+ }
75983
+ function isRepo(path3) {
75984
+ return existsSync42(join50(path3, ".git")) && git3(path3, ["rev-parse", "--git-dir"]).ok;
75985
+ }
75986
+ function conversationFolders(projectsDir) {
75987
+ const found = new Map;
75988
+ let folders;
75989
+ try {
75990
+ folders = readdirSync13(projectsDir);
75991
+ } catch {
75992
+ return found;
75993
+ }
75994
+ for (const folder of folders) {
75995
+ let names;
75996
+ try {
75997
+ names = readdirSync13(join50(projectsDir, folder));
75998
+ } catch {
75999
+ continue;
76000
+ }
76001
+ for (const name of names) {
76002
+ if (name.endsWith(".jsonl"))
76003
+ found.set(name.slice(0, -".jsonl".length), folder);
76004
+ }
76005
+ }
76006
+ return found;
76007
+ }
76008
+ function findClaudeFolders(env, repoPaths, sessions2) {
76009
+ const projectsDir = join50(env.claudeDir, "projects");
76010
+ const owned = new Map;
76011
+ const claim = (name, cwd) => {
76012
+ if (!owned.has(name))
76013
+ owned.set(name, cwd);
76014
+ };
76015
+ const cwds = new Set([...repoPaths, ...sessions2.map((s) => s.worktree_path)]);
76016
+ const onDisk = new Map;
76017
+ try {
76018
+ for (const name of readdirSync13(projectsDir))
76019
+ onDisk.set(fold(env, name), name);
76020
+ } catch {
76021
+ return { folders: [], conversations: [], missing: 0 };
76022
+ }
76023
+ for (const cwd of cwds) {
76024
+ const name = onDisk.get(fold(env, encodeClaudeFolder(cwd)));
76025
+ if (name)
76026
+ claim(name, cwd);
76027
+ }
76028
+ const onDiskConversations = conversationFolders(projectsDir);
76029
+ const conversations = new Map;
76030
+ const missing = new Set;
76031
+ for (const session of sessions2) {
76032
+ const id3 = session.cc_session_id;
76033
+ if (!id3 || (session.runtime ?? "claude-code") !== "claude-code")
76034
+ continue;
76035
+ const folder = onDiskConversations.get(id3);
76036
+ if (!folder) {
76037
+ missing.add(id3);
76038
+ continue;
76039
+ }
76040
+ claim(folder, session.worktree_path);
76041
+ conversations.set(id3, folder);
76042
+ }
76043
+ return {
76044
+ folders: [...owned].map(([name, cwd]) => ({ name, cwd })),
76045
+ conversations: [...conversations].map(([id3, folder]) => ({ id: id3, folder })),
76046
+ missing: missing.size
76047
+ };
76048
+ }
76049
+ function claudeJsonPath(env) {
76050
+ return env.claudeDir === join50(env.home, ".claude") ? join50(env.home, ".claude.json") : join50(env.claudeDir, ".claude.json");
76051
+ }
76052
+ function readJsonObject(path3) {
76053
+ try {
76054
+ const parsed = JSON.parse(readFileSync29(path3, "utf8"));
76055
+ return parsed && typeof parsed === "object" ? parsed : {};
76056
+ } catch {
76057
+ return {};
76058
+ }
76059
+ }
76060
+ function findClaudeSetup(env) {
76061
+ const broken = [];
76062
+ const files = CLAUDE_SETUP_ENTRIES.filter((entry) => existsSync42(join50(env.claudeDir, entry))).flatMap((entry) => walkFollowing(join50(env.claudeDir, entry), entry, broken));
76063
+ const settings = readJsonObject(join50(env.claudeDir, "settings.json"));
76064
+ const enabled2 = settings.enabledPlugins ?? {};
76065
+ const plugins = Object.keys(enabled2).filter((id3) => enabled2[id3] === true);
76066
+ const known = readJsonObject(join50(env.claudeDir, "plugins", "known_marketplaces.json"));
76067
+ const needed = new Set(plugins.map((id3) => id3.slice(id3.lastIndexOf("@") + 1)));
76068
+ const marketplaces = Object.keys(known).filter((name) => needed.has(name)).flatMap((name) => known[name].source?.repo ?? known[name].source?.url ?? []);
76069
+ const mcpServers = readJsonObject(claudeJsonPath(env)).mcpServers ?? {};
76070
+ return { setup: { files, marketplaces, plugins, mcpServers }, broken };
76071
+ }
76072
+ function fold(env, name) {
76073
+ return env.platform === "win32" ? name.toLowerCase() : name;
76074
+ }
76075
+ function dataSymlinks(env, folders) {
76076
+ const inData = DATA_ENTRIES.flatMap((entry) => {
76077
+ const path3 = join50(env.dataDir, entry);
76078
+ if (isSymlink(path3))
76079
+ return [entry];
76080
+ try {
76081
+ if (!statSync13(path3).isDirectory())
76082
+ return [];
76083
+ } catch {
76084
+ return [];
76085
+ }
76086
+ return walkSymlinks(path3).map((rel) => `${entry}/${rel}`);
76087
+ });
76088
+ const inClaude = folders.flatMap((folder) => walkSymlinks(join50(env.claudeDir, "projects", folder.name)).map((rel) => `claude/${folder.name}/${rel}`));
76089
+ return [...inData, ...inClaude];
76090
+ }
76091
+ function wipBytes(projects2) {
76092
+ let total = 0;
76093
+ for (const project3 of projects2) {
76094
+ for (const wip of project3.wip) {
76095
+ for (const rel of [...wip.tracked, ...wip.untracked])
76096
+ total += pathBytes(join50(wip.path, rel));
76097
+ }
76098
+ for (const rel of project3.carried)
76099
+ total += pathBytes(join50(project3.repoPath, rel));
76100
+ }
76101
+ return total;
76102
+ }
76103
+ function dataBytes(env, folders) {
76104
+ const data = DATA_ENTRIES.reduce((total, entry) => total + pathBytes(join50(env.dataDir, entry)), 0);
76105
+ const claude = folders.reduce((total, folder) => total + pathBytes(join50(env.claudeDir, "projects", folder.name)), 0);
76106
+ return data + claude + pathBytes(join50(env.dataDir, "runcastle.db"));
76107
+ }
76108
+ function scanTalkWorktrees(env, project3, features2) {
76109
+ const root = join50(env.dataDir, "worktrees", project3.id);
76110
+ const wips = [];
76111
+ const stopped = [];
76112
+ let names;
76113
+ try {
76114
+ names = readdirSync13(root);
76115
+ } catch {
76116
+ return { wips, stopped };
76117
+ }
76118
+ for (const name of names) {
76119
+ const status3 = features2.find((f) => f.project_id === project3.id && f.slug === name)?.status;
76120
+ if (status3 === "shipped")
76121
+ continue;
76122
+ const path3 = join50(root, name);
76123
+ if (!git3(path3, ["rev-parse", "--git-dir"]).ok)
76124
+ continue;
76125
+ const operation = midOperation(path3);
76126
+ if (operation)
76127
+ stopped.push(`${project3.name}: the talk worktree ${name} is in the middle of ${operation}. Finish or abort it`);
76128
+ const wip = checkoutWip(name, path3);
76129
+ if (hasWork(wip))
76130
+ wips.push(wip);
76131
+ }
76132
+ return { wips, stopped };
76133
+ }
76134
+ function scanMove(env, sql5, options) {
76135
+ const projectRows = sql5.all("SELECT * FROM projects");
76136
+ const sessionRows = sql5.all("SELECT project_id, feature_id, status, worktree_path, cc_session_id, runtime FROM sessions");
76137
+ const featureRows = sql5.all("SELECT id, project_id, slug, title, branch, status FROM features");
76138
+ const running3 = sql5.all("SELECT feature_id FROM runs WHERE status = 'running'");
76139
+ const blockers = [];
76140
+ const attention = [];
76141
+ const also = (options.also ?? []).flatMap((value) => {
76142
+ const request2 = parseAlso(value);
76143
+ if (request2)
76144
+ return [{ ...request2, used: false }];
76145
+ blockers.push(`--also ${value}: write it as <project>:<path inside its repo>`);
76146
+ return [];
76147
+ });
76148
+ for (const run7 of running3) {
76149
+ const feature = featureRows.find((f) => f.id === run7.feature_id);
76150
+ blockers.push(`a round of ${feature?.slug ?? run7.feature_id} is marked running: let it finish or press Stop, and if the server is already stopped, start it once so it can clean up`);
76151
+ }
76152
+ const placed = projectRows.map((row2) => {
76153
+ const folder = sourceBasename(row2.repo_path, env.platform);
76154
+ const skipped = options.noRepo.includes(row2.name) || options.noRepo.includes(folder);
76155
+ const present = isRepo(row2.repo_path);
76156
+ return { id: row2.id, row: row2, folder, skipped, present, repoMoves: present && !skipped };
76157
+ });
76158
+ const { targets, clashes } = assignTargetFolders(placed);
76159
+ for (const folder of clashes) {
76160
+ const names = placed.filter((p) => p.folder === folder && p.repoMoves).map((p) => p.row.repo_path);
76161
+ blockers.push(`two projects want the folder ${folder}: ${names.join(" and ")}. Leave one out with --no-repo`);
76162
+ }
76163
+ const projects2 = [];
76164
+ const lines2 = [];
76165
+ let totalRepoBytes = 0;
76166
+ for (const { row: row2, folder, skipped, repoMoves } of placed) {
76167
+ const features2 = featureRows.filter((f) => f.project_id === row2.id);
76168
+ const featureIds = new Set(features2.map((f) => f.id));
76169
+ const chats = sessionRows.filter((s) => s.project_id === row2.id || s.feature_id !== null && featureIds.has(s.feature_id));
76170
+ const targetFolder = targets.get(row2.id) ?? folder;
76171
+ const project3 = {
76172
+ id: row2.id,
76173
+ name: row2.name,
76174
+ repoPath: row2.repo_path,
76175
+ targetFolder,
76176
+ repoMoves,
76177
+ refs: {},
76178
+ remotes: [],
76179
+ config: [],
76180
+ wip: [],
76181
+ carried: []
76182
+ };
76183
+ let bytes = 0;
76184
+ if (!repoMoves) {
76185
+ project3.repoLeftReason = skipped ? "--no-repo" : "its repo folder is gone";
76186
+ attention.push(`${row2.name}: repo stays behind (${project3.repoLeftReason}); put it in the repos folder as ${targetFolder} to use the project`);
76187
+ } else {
76188
+ Object.assign(project3, repoFacts(row2.repo_path));
76189
+ const main = checkoutWip("main", row2.repo_path);
76190
+ const talk = scanTalkWorktrees(env, row2, featureRows);
76191
+ project3.wip = [...hasWork(main) ? [main] : [], ...talk.wips];
76192
+ blockers.push(...talk.stopped);
76193
+ const envFiles = options.withEnv ? [...new Set([...ignoredEnvFiles(row2.repo_path), ...main.heldBack])] : [];
76194
+ const requested = also.filter((request2) => request2.project === row2.name || request2.project === folder);
76195
+ const asked = requested.flatMap((request2) => {
76196
+ request2.used = true;
76197
+ const path3 = join50(row2.repo_path, request2.path);
76198
+ if (existsSync42(path3))
76199
+ return statSync13(path3).isDirectory() ? walkFiles(path3, request2.path) : [request2.path];
76200
+ blockers.push(`${row2.name}: --also ${request2.path} is not in the repo folder`);
76201
+ return [];
76202
+ });
76203
+ project3.carried = [...new Set([...envFiles, ...asked])];
76204
+ if (envFiles.length > 0)
76205
+ attention.push(`${row2.name}: .env files travel: ${envFiles.join(", ")}`);
76206
+ if (requested.length > 0) {
76207
+ const paths = requested.map((request2) => request2.path).join(", ");
76208
+ attention.push(`${row2.name}: travels at your request: ${paths}`);
76209
+ }
76210
+ const shownMain = options.withEnv ? { ...main, heldBack: [] } : main;
76211
+ attention.push(...[shownMain, ...talk.wips].flatMap((wip) => wipAttention(row2.name, wip)));
76212
+ const rules = excludeRules(row2.repo_path);
76213
+ if (rules)
76214
+ project3.excludeRules = rules;
76215
+ if (git3(row2.repo_path, ["rev-parse", "--is-shallow-repository"]).stdout.trim() === "true") {
76216
+ blockers.push(`${row2.name}: a shallow clone cannot be packed. Run git fetch --unshallow, or leave it out with --no-repo`);
76217
+ }
76218
+ if (project3.remotes.some((remote) => /^https?:\/\/[^/@]+@/.test(remote.url))) {
76219
+ attention.push(`${row2.name}: a remote URL holds a credential, and it travels with the repo`);
76220
+ }
76221
+ bytes = repoBytes(row2.repo_path);
76222
+ const mid = midOperation(row2.repo_path);
76223
+ if (mid)
76224
+ blockers.push(`${row2.name}: the main checkout is in the middle of ${mid}. Finish or abort it`);
76225
+ const driving = features2.find((f) => f.status === "active" && f.branch === project3.headBranch);
76226
+ if (driving) {
76227
+ blockers.push(`${row2.name}: the main checkout is on ${driving.branch}, which a test drive leaves behind. Stop the drive or check out the base branch`);
76228
+ }
76229
+ if (project3.remotes.length === 0) {
76230
+ attention.push(`${row2.name}: no remote, so the new machine and this one will be its only copies`);
76231
+ } else {
76232
+ const lonely = commitsOnNoRemote(row2.repo_path);
76233
+ if (lonely > 0)
76234
+ attention.push(`${row2.name}: ${count5(lonely, "commit")} on no remote; they travel in the pack, nothing is pushed`);
76235
+ }
76236
+ const carried = new Set(project3.carried);
76237
+ const roots3 = requested.map((request2) => request2.path);
76238
+ const ignored = ignoredEntries(row2.repo_path, (rel) => carried.has(rel) || roots3.some((root) => rel === root || rel.startsWith(`${root}/`)));
76239
+ if (ignored.length > 0)
76240
+ attention.push(`${row2.name}: ignored files stay behind: ${ignored.join(", ")}`);
76241
+ const stashes = stashCount(row2.repo_path);
76242
+ if (stashes > 1)
76243
+ attention.push(`${row2.name}: ${stashes} stashes, only the newest travels`);
76244
+ if (Object.keys(project3.refs).length > 0) {
76245
+ attention.push(...localHooks(row2.name, row2.repo_path));
76246
+ }
76247
+ const leftovers = leftoverWorktrees(row2.repo_path, join50(env.dataDir, "worktrees"));
76248
+ if (leftovers.length > 0)
76249
+ attention.push(`${row2.name}: uncommitted changes in other worktrees stay behind: ${leftovers.join(", ")}`);
76250
+ if (usesLfsOrSubmodules(row2.repo_path)) {
76251
+ attention.push(`${row2.name}: uses LFS or submodules, which the pack does not carry; fetch them on the new machine`);
76252
+ }
76253
+ }
76254
+ projects2.push(project3);
76255
+ totalRepoBytes += bytes;
76256
+ lines2.push({
76257
+ name: row2.name,
76258
+ branches: Object.keys(project3.refs).filter((ref) => ref.startsWith("refs/heads/")).length,
76259
+ uncommitted: project3.wip.reduce((total, wip) => total + wip.tracked.length + wip.untracked.length, 0),
76260
+ features: features2.length,
76261
+ chats: chats.length,
76262
+ repoBytes: bytes,
76263
+ repoMoves,
76264
+ repoPath: row2.repo_path,
76265
+ targetFolder
76266
+ });
76267
+ }
76268
+ for (const request2 of also.filter((item) => !item.used)) {
76269
+ blockers.push(`--also ${request2.project}:${request2.path}: no moving repo is called ${request2.project}`);
76270
+ }
76271
+ const openChats = sessionRows.filter((s) => s.status === "live" || s.status === "launching").length;
76272
+ if (openChats > 0)
76273
+ attention.push(`${count5(openChats, "open chat")} will end at the new machine's first boot, and can be resumed`);
76274
+ const claude = findClaudeFolders(env, projectRows.map((p) => p.repo_path), sessionRows);
76275
+ if (claude.missing > 0) {
76276
+ attention.push(`${count5(claude.missing, "chat")} already gone from this machine: Claude Code removes transcripts after cleanupPeriodDays, 30 by default`);
76277
+ }
76278
+ const links = dataSymlinks(env, claude.folders);
76279
+ if (links.length > 0)
76280
+ attention.push(`symlinks in runcastle's folders stay behind: ${links.join(", ")}`);
76281
+ let claudeSetup;
76282
+ let setupBytes = 0;
76283
+ if (options.withClaudeSetup) {
76284
+ const found = findClaudeSetup(env);
76285
+ claudeSetup = found.setup;
76286
+ setupBytes = found.setup.files.reduce((total, rel) => total + pathBytes(join50(env.claudeDir, rel)), 0);
76287
+ attention.push(`Claude setup travels: ${count5(found.setup.files.length, "file")}, ${count5(found.setup.plugins.length, "plugin")} to install again, ${count5(Object.keys(found.setup.mcpServers).length, "MCP server")}. What the new machine has is set aside, and its login stays`);
76288
+ if (found.broken.length > 0)
76289
+ attention.push(`Claude setup links that lead nowhere stay behind: ${found.broken.join(", ")}`);
76290
+ } else if (readJsonObject(join50(env.claudeDir, "settings.json")).cleanupPeriodDays === undefined) {
76291
+ attention.push("Claude Code on the new machine deletes moved chats older than 30 days unless cleanupPeriodDays is raised; --with-claude-setup does that");
76292
+ }
76293
+ const report = {
76294
+ blockers,
76295
+ projects: lines2,
76296
+ attention,
76297
+ staysBehind: staysBehind(options),
76298
+ dataBytes: dataBytes(env, claude.folders) + wipBytes(projects2) + setupBytes,
76299
+ repoBytes: totalRepoBytes,
76300
+ openChats
76301
+ };
76302
+ return {
76303
+ report,
76304
+ projects: projects2,
76305
+ claudeFolders: claude.folders,
76306
+ conversations: claude.conversations,
76307
+ envCarried: options.withEnv === true,
76308
+ ...claudeSetup ? { claudeSetup } : {}
76309
+ };
76310
+ }
76311
+ function windowsCommandLines(sql5, projects2, env) {
76312
+ const rules = [
76313
+ ...projects2.map((project3) => ({ from: project3.repoPath, to: `/repos/${project3.targetFolder}` })),
76314
+ { from: env.dataDir, to: "/data" },
76315
+ { from: env.home, to: "/home" }
76316
+ ];
76317
+ return sql5.all("SELECT * FROM projects").flatMap((row2) => HOST_COMMANDS.flatMap(({ column, label }) => {
76318
+ const value = row2[column];
76319
+ if (!value || !looksWindowsOnly(rewritePathsInText(value, rules, env.platform)))
76320
+ return [];
76321
+ return [`${row2.name}, ${label}: only works on Windows; change it before the move or it will fail on the new machine`];
76322
+ }));
76323
+ }
76324
+ var DATA_ENTRIES, HOST_COMMANDS, CLAUDE_SETUP_ENTRIES;
76325
+ var init_scan = __esm(() => {
76326
+ init_shell();
76327
+ DATA_ENTRIES = [
76328
+ "config.json",
76329
+ "sessions",
76330
+ "reviews",
76331
+ "annotations",
76332
+ "project-notes",
76333
+ "design-crops",
76334
+ "stories"
76335
+ ];
76336
+ HOST_COMMANDS = [
76337
+ { column: "dev_command", label: "dev command" },
76338
+ { column: "setup_command", label: "setup command" },
76339
+ { column: "verify_commands", label: "verify commands" },
76340
+ { column: "drive_setup_command", label: "drive setup command" },
76341
+ { column: "drive_stop_command", label: "drive stop command" },
76342
+ { column: "db_reset_command", label: "database reset command" },
76343
+ { column: "drive_instructions", label: "drive instructions" }
76344
+ ];
76345
+ CLAUDE_SETUP_ENTRIES = ["CLAUDE.md", "rules", "skills", "agents", "commands", "settings.json"];
76346
+ });
76347
+
76348
+ // src/move/tar.ts
76349
+ import { createHash as createHash5 } from "crypto";
76350
+ import { createReadStream as createReadStream2, createWriteStream, statSync as statSync14 } from "fs";
76351
+ import { once as once5 } from "events";
76352
+ import { createGzip } from "zlib";
76353
+ function writeField(block, offset, length3, value) {
76354
+ block.write(value.slice(0, length3), offset, length3, "utf8");
76355
+ }
76356
+ function octal(value, digits) {
76357
+ return `${Math.trunc(value).toString(8).padStart(digits, "0")}\x00`;
76358
+ }
76359
+ function header(name, size6, mtimeSeconds, type) {
76360
+ const block = Buffer.alloc(BLOCK);
76361
+ writeField(block, 0, 100, name);
76362
+ writeField(block, 100, 8, octal(type === "x" ? 420 : 384, 7));
76363
+ writeField(block, 108, 8, octal(0, 7));
76364
+ writeField(block, 116, 8, octal(0, 7));
76365
+ writeField(block, 124, 12, octal(Math.min(size6, USTAR_MAX_SIZE), 11));
76366
+ writeField(block, 136, 12, octal(mtimeSeconds, 11));
76367
+ block.fill(" ", 148, 156);
76368
+ writeField(block, 156, 1, type);
76369
+ writeField(block, 257, 8, "ustar\x0000");
76370
+ const sum2 = block.reduce((total, byte) => total + byte, 0);
76371
+ writeField(block, 148, 8, `${sum2.toString(8).padStart(6, "0")}\x00 `);
76372
+ return block;
76373
+ }
76374
+ function paxRecord(key, value) {
76375
+ const body = ` ${key}=${value}
76376
+ `;
76377
+ const bodyBytes = Buffer.byteLength(body);
76378
+ let length3 = bodyBytes + 1;
76379
+ while (String(length3).length + bodyBytes !== length3)
76380
+ length3 = String(length3).length + bodyBytes;
76381
+ return `${length3}${body}`;
76382
+ }
76383
+ function needsPaxName(name) {
76384
+ return name.length > 100 || Buffer.byteLength(name) !== name.length;
76385
+ }
76386
+
76387
+ class TarWriter {
76388
+ gzip;
76389
+ out;
76390
+ failure;
76391
+ paxCount = 0;
76392
+ files = [];
76393
+ constructor(path3) {
76394
+ this.gzip = createGzip({ level: 6 });
76395
+ this.out = createWriteStream(path3, { mode: 384 });
76396
+ const fail9 = (err) => {
76397
+ this.failure ??= err;
76398
+ this.gzip.destroy(err);
76399
+ };
76400
+ this.gzip.on("error", fail9);
76401
+ this.out.on("error", fail9);
76402
+ this.gzip.pipe(this.out);
76403
+ }
76404
+ async write(chunk) {
76405
+ if (this.failure)
76406
+ throw this.failure;
76407
+ if (!this.gzip.write(chunk))
76408
+ await once5(this.gzip, "drain");
76409
+ }
76410
+ async writeHeaders(name, size6, mtimeSeconds) {
76411
+ const records = (needsPaxName(name) ? paxRecord("path", name) : "") + (size6 > USTAR_MAX_SIZE ? paxRecord("size", String(size6)) : "");
76412
+ if (records) {
76413
+ const body = Buffer.from(records, "utf8");
76414
+ await this.write(header(`PaxHeader/${this.paxCount++}`, body.length, mtimeSeconds, "x"));
76415
+ await this.write(body);
76416
+ await this.pad(body.length);
76417
+ }
76418
+ await this.write(header(needsPaxName(name) ? `long/${this.paxCount}` : name, size6, mtimeSeconds, "0"));
76419
+ }
76420
+ async pad(size6) {
76421
+ const over = size6 % BLOCK;
76422
+ if (over)
76423
+ await this.write(Buffer.alloc(BLOCK - over));
76424
+ }
76425
+ async addFile(name, path3) {
76426
+ const stat3 = statSync14(path3);
76427
+ await this.writeHeaders(name, stat3.size, stat3.mtimeMs / 1000);
76428
+ const hash2 = createHash5("sha256");
76429
+ let read2 = 0;
76430
+ for await (const chunk of createReadStream2(path3)) {
76431
+ const buffer = chunk;
76432
+ read2 += buffer.length;
76433
+ hash2.update(buffer);
76434
+ await this.write(buffer);
76435
+ }
76436
+ if (read2 !== stat3.size)
76437
+ throw new Error(`${path3} changed while it was being packed`);
76438
+ await this.pad(read2);
76439
+ this.files.push({ path: name, size: read2, sha256: hash2.digest("hex") });
76440
+ }
76441
+ async addBuffer(name, body) {
76442
+ await this.writeHeaders(name, body.length, Date.now() / 1000);
76443
+ await this.write(body);
76444
+ await this.pad(body.length);
76445
+ }
76446
+ abort() {
76447
+ this.gzip.destroy();
76448
+ this.out.destroy();
76449
+ }
76450
+ async finish() {
76451
+ await this.write(Buffer.alloc(BLOCK * 2));
76452
+ this.gzip.end();
76453
+ if (!this.out.closed)
76454
+ await once5(this.out, "close");
76455
+ if (this.failure)
76456
+ throw this.failure;
76457
+ }
76458
+ }
76459
+ async function hashFile(path3) {
76460
+ const hash2 = createHash5("sha256");
76461
+ for await (const chunk of createReadStream2(path3))
76462
+ hash2.update(chunk);
76463
+ return hash2.digest("hex");
76464
+ }
76465
+ var BLOCK = 512, USTAR_MAX_SIZE = 8589934591;
76466
+ var init_tar = () => {};
76467
+
76468
+ // src/move/pack.ts
76469
+ import { closeSync as closeSync6, copyFileSync as copyFileSync4, existsSync as existsSync43, mkdtempSync as mkdtempSync2, openSync as openSync6, rmSync as rmSync11, statSync as statSync15, utimesSync } from "fs";
76470
+ import { spawnSync as spawnSync4 } from "child_process";
76471
+ import { dirname as dirname23, join as join51, resolve as resolve16 } from "path";
76472
+ function isLeftOut(entry) {
76473
+ return /^sessions\/[^/]+\/(system-prompt\.md|settings\.json|mcp\.json|agent-stderr\.log)$/.test(entry) || /^sessions\/[^/]+\/codex-home\/(?!sessions\/)/.test(entry);
76474
+ }
76475
+ async function addTree(tar, dir, prefix, skip) {
76476
+ for (const rel of walkFiles(dir, "", true)) {
76477
+ if (skip?.(rel))
76478
+ continue;
76479
+ await tar.addFile(`${prefix}/${rel}`, join51(dir, rel));
76480
+ }
76481
+ }
76482
+ async function addDataEntries(tar, env) {
76483
+ for (const entry of DATA_ENTRIES) {
76484
+ const path3 = join51(env.dataDir, entry);
76485
+ let isFile3 = false;
76486
+ try {
76487
+ isFile3 = statSync15(path3).isFile();
76488
+ } catch {
76489
+ continue;
76490
+ }
76491
+ if (isFile3)
76492
+ await tar.addFile(`data/${entry}`, path3);
76493
+ else
76494
+ await addTree(tar, path3, `data/${entry}`, (rel) => isLeftOut(`${entry}/${rel}`));
76495
+ }
76496
+ }
76497
+ function snapshotDatabase(env, open4, target) {
76498
+ const source = open4(join51(env.dataDir, "runcastle.db"), { readonly: true });
76499
+ try {
76500
+ source.run(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
76501
+ } finally {
76502
+ source.close();
76503
+ }
76504
+ const copy4 = open4(target, { readonly: true });
76505
+ try {
76506
+ return { rowCounts: countRows(copy4), migrations: copy4.all("SELECT name FROM __migrations ORDER BY name").map((r) => r.name) };
76507
+ } finally {
76508
+ copy4.close();
76509
+ }
76510
+ }
76511
+ function countRows(sql5) {
76512
+ const tables = sql5.all("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'");
76513
+ const counts = {};
76514
+ for (const { name } of tables) {
76515
+ counts[name] = sql5.all(`SELECT COUNT(*) AS n FROM "${name}"`)[0].n;
76516
+ }
76517
+ return counts;
76518
+ }
76519
+ async function addRepo(tar, id3, repoPath, scratch) {
76520
+ const bundle = join51(scratch, `${id3}.bundle`);
76521
+ gitOrThrow(repoPath, ["bundle", "create", "-q", bundle, "--all"]);
76522
+ await tar.addFile(`repos/${id3}.bundle`, bundle);
76523
+ rmSync11(bundle, { force: true });
76524
+ }
76525
+ async function addWip(tar, projectId, wip, scratch) {
76526
+ const prefix = `wip/${projectId}/${wip.key}`;
76527
+ if (wip.sha && wip.tracked.length > 0) {
76528
+ const patch9 = join51(scratch, "changes.patch");
76529
+ const index2 = resolve16(wip.path, gitOrThrow(wip.path, ["rev-parse", "--git-path", "index"]).trim());
76530
+ const indexCopy = join51(scratch, "index");
76531
+ if (existsSync43(index2)) {
76532
+ copyFileSync4(index2, indexCopy);
76533
+ const { atime, mtime } = statSync15(index2);
76534
+ utimesSync(indexCopy, atime, new Date(mtime.getTime() - 1000));
76535
+ }
76536
+ const fd = openSync6(patch9, "w");
76537
+ try {
76538
+ const diff8 = spawnSync4("git", ["-c", "core.quotepath=off", "diff", "--binary", "HEAD"], {
76539
+ cwd: wip.path,
76540
+ stdio: ["ignore", fd, "pipe"],
76541
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_INDEX_FILE: indexCopy },
76542
+ windowsHide: true
76543
+ });
76544
+ if (diff8.status !== 0)
76545
+ throw new Error(`git diff failed in ${wip.path}: ${String(diff8.stderr)}`);
76546
+ } finally {
76547
+ closeSync6(fd);
76548
+ }
76549
+ await tar.addFile(`${prefix}/changes.patch`, patch9);
76550
+ rmSync11(patch9, { force: true });
76551
+ rmSync11(indexCopy, { force: true });
76552
+ }
76553
+ for (const rel of [...wip.tracked, ...wip.untracked]) {
76554
+ const path3 = join51(wip.path, rel);
76555
+ try {
76556
+ if (isSymlink(path3) || !statSync15(path3).isFile())
76557
+ continue;
76558
+ } catch {
76559
+ continue;
76560
+ }
76561
+ await tar.addFile(`${prefix}/files/${rel}`, path3);
76562
+ }
76563
+ }
76564
+ async function packMove(input) {
76565
+ const { env, scan, open: open4, out } = input;
76566
+ const log6 = input.log ?? (() => {});
76567
+ const scratch = mkdtempSync2(join51(dirname23(out), "runcastle-move-"));
76568
+ const tar = new TarWriter(out);
76569
+ try {
76570
+ log6("Copying the database");
76571
+ const dbCopy = join51(scratch, "runcastle.db");
76572
+ const { rowCounts, migrations } = snapshotDatabase(env, open4, dbCopy);
76573
+ await tar.addFile("db/runcastle.db", dbCopy);
76574
+ rmSync11(dbCopy, { force: true });
76575
+ log6("Packing chats, reviews and notes");
76576
+ await addDataEntries(tar, env);
76577
+ for (const folder of scan.claudeFolders) {
76578
+ await addTree(tar, join51(env.claudeDir, "projects", folder.name), `claude/${folder.name}`);
76579
+ }
76580
+ for (const project3 of scan.projects) {
76581
+ if (!project3.repoMoves)
76582
+ continue;
76583
+ log6(`Packing ${project3.name}`);
76584
+ if (Object.keys(project3.refs).length > 0)
76585
+ await addRepo(tar, project3.id, project3.repoPath, scratch);
76586
+ for (const wip of project3.wip)
76587
+ await addWip(tar, project3.id, wip, scratch);
76588
+ for (const rel of project3.carried)
76589
+ await tar.addFile(`carried/${project3.id}/${rel}`, join51(project3.repoPath, rel));
76590
+ }
76591
+ if (scan.claudeSetup) {
76592
+ log6("Packing your Claude setup");
76593
+ for (const rel of scan.claudeSetup.files)
76594
+ await tar.addFile(`claude-setup/${rel}`, join51(env.claudeDir, rel));
76595
+ }
76596
+ const manifest = {
76597
+ formatVersion: 1,
76598
+ runcastleVersion: runcastleVersion(),
76599
+ createdAt: Date.now(),
76600
+ source: env,
76601
+ migrations,
76602
+ projects: scan.projects,
76603
+ claudeFolders: scan.claudeFolders,
76604
+ conversations: scan.conversations,
76605
+ envCarried: scan.envCarried,
76606
+ ...scan.claudeSetup ? { claudeSetup: scan.claudeSetup } : {},
76607
+ reportText: input.reportText ?? "",
76608
+ rowCounts,
76609
+ files: tar.files
76610
+ };
76611
+ await tar.addBuffer("manifest.json", Buffer.from(JSON.stringify(manifest), "utf8"));
76612
+ await tar.finish();
76613
+ return manifest;
76614
+ } catch (err) {
76615
+ tar.abort();
76616
+ rmSync11(out, { force: true });
76617
+ throw err;
76618
+ } finally {
76619
+ rmSync11(scratch, { recursive: true, force: true });
76620
+ }
76621
+ }
76622
+ var init_pack = __esm(() => {
76623
+ init_version();
76624
+ init_scan();
76625
+ init_shell();
76626
+ init_tar();
76627
+ });
76628
+
76629
+ // src/move/remote.ts
76630
+ import { basename as basename8, dirname as dirname24 } from "path";
76631
+ function remoteEnv() {
76632
+ const extra = process.env.RUNCASTLE_MOVE_REMOTE_ENV?.trim();
76633
+ return extra ? `${REMOTE_PATH} ${extra}` : REMOTE_PATH;
76634
+ }
76635
+ function flagWords(flags) {
76636
+ return [
76637
+ ...flags.replace ? ["--replace"] : [],
76638
+ ...flags.reposDir ? ["--repos-dir", `"${flags.reposDir}"`] : []
76639
+ ].join(" ");
76640
+ }
76641
+ function remoteMoveInCommand(packName, flags) {
76642
+ return [remoteEnv(), `runcastle move-in "$HOME/${packName}"`, flagWords(flags)].filter(Boolean).join(" ");
76643
+ }
76644
+ function remoteCheckCommand(flags) {
76645
+ return [remoteEnv(), "runcastle move-in --check", flagWords(flags)].filter(Boolean).join(" ");
76646
+ }
76647
+ function manualSteps(pack2, host, flags) {
76648
+ const target = host ?? "<user@host>";
76649
+ return [
76650
+ `cd "${dirname24(pack2)}"`,
76651
+ `scp "${basename8(pack2)}" ${target}:`,
76652
+ `ssh ${target} '${remoteMoveInCommand(basename8(pack2), flags)}'`
76653
+ ];
76654
+ }
76655
+ function failureMessage2(step4, detail, pack2, host, flags) {
76656
+ return [
76657
+ `${step4} failed: ${detail}`,
76658
+ "",
76659
+ `Nothing was lost. The pack is kept at ${pack2}, and this machine is unchanged.`,
76660
+ "Finish by hand with:",
76661
+ ...manualSteps(pack2, host, flags).map((line2) => ` ${line2}`)
76662
+ ].join(`
76663
+ `);
76664
+ }
76665
+ function askDestination(host, question, flags) {
76666
+ const result = run8("ssh", [host, remoteCheckCommand(flags)], {
76667
+ input: JSON.stringify(question),
76668
+ stdio: ["pipe", "pipe", "pipe"]
76669
+ });
76670
+ const last2 = result.stdout.trim().split(`
76671
+ `).pop() ?? "";
76672
+ try {
76673
+ const parsed = JSON.parse(last2);
76674
+ if (Array.isArray(parsed.blockers))
76675
+ return { answer: parsed, detail: "" };
76676
+ } catch {}
76677
+ const detail = result.stderr.trim() || "it does not have a runcastle with move-in; install this version there";
76678
+ return { answer: null, detail };
76679
+ }
76680
+ function copyPack(pack2, host) {
76681
+ const result = run8("scp", [basename8(pack2), `${host}:`], { cwd: dirname24(pack2), stdio: "inherit" });
76682
+ return { ok: result.ok, detail: result.ok ? "" : `scp to ${host} did not finish` };
76683
+ }
76684
+ function runRemoteMoveIn(pack2, host, flags) {
76685
+ const result = run8("ssh", [host, remoteMoveInCommand(basename8(pack2), flags)], { stdio: "inherit" });
76686
+ return { ok: result.ok, detail: result.ok ? "" : `move-in on ${host} did not finish` };
76687
+ }
76688
+ var REMOTE_PATH = 'PATH="$HOME/.bun/bin:$PATH"';
76689
+ var init_remote = __esm(() => {
76690
+ init_shell();
76691
+ });
76692
+
76693
+ // src/move/report.ts
76694
+ function formatReport2(report, destination, windowsCommands) {
76695
+ const blockers = [
76696
+ ...report.blockers,
76697
+ ...destination?.answer?.blockers ?? [],
76698
+ ...destination && !destination.answer ? [`the destination could not be checked: ${destination.detail}`] : []
76699
+ ];
76700
+ const out = [];
76701
+ if (blockers.length > 0) {
76702
+ out.push("Blocks", ...blockers.map((line2) => ` x ${line2}`), "");
76703
+ }
76704
+ const width = Math.max(10, ...report.projects.map((p) => p.name.length));
76705
+ const reposDir = destination?.answer?.reposDir ?? "<repos folder>";
76706
+ out.push(`Moves${" ".repeat(width - 2)}branches uncommitted features chats repo size`);
76707
+ for (const p of report.projects) {
76708
+ const row2 = `${String(p.branches).padStart(8)} ${String(p.uncommitted).padStart(11)} ${String(p.features).padStart(8)} ${String(p.chats).padStart(5)} ${formatBytes(p.repoBytes).padStart(9)}`;
76709
+ out.push(` + ${p.name.padEnd(width)} ${row2}${p.repoMoves ? "" : " (repo stays)"}`);
76710
+ out.push(` ${p.repoPath} -> ${reposDir}/${p.targetFolder}`);
76711
+ }
76712
+ out.push("");
76713
+ const attention = [...windowsCommands, ...report.attention, ...destination?.answer?.notes ?? []];
76714
+ if (attention.length > 0) {
76715
+ out.push("Worth a look", ...attention.map((line2) => ` ! ${line2}`), "");
76716
+ }
76717
+ out.push(`Stays behind ${report.staysBehind.join(", ")}`, "");
76718
+ const size6 = `Pack holds about ${formatBytes(report.dataBytes + report.repoBytes)} before compression.`;
76719
+ const free = destination?.answer?.freeBytes;
76720
+ const where = destination?.answer ? ` Repos land in ${destination.answer.reposDir}.` : "";
76721
+ const room = free != null ? ` The destination has ${formatBytes(free)} free.` : "";
76722
+ const unchecked = destination ? "" : " The destination was not checked; give a host to check it.";
76723
+ out.push(`${size6}${where}${room}${unchecked}`);
76724
+ return out.join(`
76725
+ `);
76726
+ }
76727
+ function isBlocked(report, destination) {
76728
+ if (report.blockers.length > 0)
76729
+ return true;
76730
+ if (!destination)
76731
+ return false;
76732
+ return !destination.answer || destination.answer.blockers.length > 0;
76733
+ }
76734
+ function formatMoveInResult(result) {
76735
+ const out = ["Moved in.", "", "Repos", ...result.repos.map((r) => ` ${r.name}: ${r.path}`)];
76736
+ if (result.wipApplied.length > 0) {
76737
+ out.push("", "Uncommitted work put back", ...result.wipApplied.map((w) => ` ${w}`));
76738
+ }
76739
+ if (result.wipKept.length > 0) {
76740
+ out.push("", "Uncommitted work that would not apply, kept here", ...result.wipKept.map((w) => ` ${w.project} (${w.key}): ${w.folder}`));
76741
+ }
76742
+ if (result.wipStuck.length > 0) {
76743
+ out.push("", `Uncommitted work that could not be placed. Copy it out of ${result.stagingKept}/wip before anything else`, ...result.wipStuck.map((w) => ` ${w}`));
76744
+ }
76745
+ if (result.commandsWindows.length > 0) {
76746
+ out.push("", "Commands that only work on Windows; change these in the project settings");
76747
+ out.push(...result.commandsWindows.map((c) => ` ${c.project}, ${c.field}: ${c.value}`));
76748
+ }
76749
+ if (result.commandsChanged.length > 0) {
76750
+ out.push("", "Commands with their paths rewritten");
76751
+ out.push(...result.commandsChanged.map((c) => ` ${c.project}, ${c.field}`));
76752
+ }
76753
+ if (result.claudeSetup) {
76754
+ if (result.claudeSetup.chatsKept)
76755
+ out.push("", "Claude Code now keeps chats for 10 years, so it will not delete moved ones.");
76756
+ if (result.claudeSetup.failed.length > 0) {
76757
+ out.push("", "Claude setup that did not go in", ...result.claudeSetup.failed.map((item) => ` ${item}`));
76758
+ }
76759
+ }
76760
+ if (result.setAside.length > 0) {
76761
+ out.push("", "Set aside", ...result.setAside.map((s) => ` ${s.to}`));
76762
+ }
76763
+ if (result.unmappedPaths > 0) {
76764
+ out.push("", `${result.unmappedPaths} stored paths matched no known folder and were left as they were.`);
76765
+ }
76766
+ out.push("", `Full report: ${result.reportPath}`, "", result.envCarried ? "Next: start runcastle here." : "Next: start runcastle here, and recreate the .env files.", "This machine is now the only home. Do not work on the old one.");
76767
+ return out.join(`
76768
+ `);
76769
+ }
76770
+ var init_report2 = __esm(() => {
76771
+ init_shell();
76772
+ });
76773
+
76774
+ // src/move/unpack.ts
76775
+ import {
76776
+ chmodSync,
76777
+ closeSync as closeSync7,
76778
+ copyFileSync as copyFileSync5,
76779
+ cpSync as cpSync3,
76780
+ existsSync as existsSync44,
76781
+ mkdirSync as mkdirSync28,
76782
+ openSync as openSync7,
76783
+ readdirSync as readdirSync14,
76784
+ readFileSync as readFileSync30,
76785
+ readSync as readSync4,
76786
+ renameSync as renameSync2,
76787
+ rmSync as rmSync12,
76788
+ statSync as statSync16,
76789
+ writeFileSync as writeFileSync22
76790
+ } from "fs";
76791
+ import { basename as basename9, dirname as dirname25, join as join52, resolve as resolve17 } from "path";
76792
+ function extract(pack2, into) {
76793
+ const result = run8(systemTar(), ["-xzf", basename9(pack2), "-C", into], { cwd: dirname25(resolve17(pack2)) });
76794
+ if (!result.ok)
76795
+ throw new Error(`could not unpack ${pack2}: ${result.stderr.trim()}`);
76796
+ }
76797
+ async function verifyFiles(staging, manifest) {
76798
+ for (const file of manifest.files) {
76799
+ const path3 = join52(staging, file.path);
76800
+ if (!existsSync44(path3))
76801
+ throw new Error(`the pack is missing ${file.path}`);
76802
+ if (await hashFile(path3) !== file.sha256)
76803
+ throw new Error(`${file.path} did not arrive intact`);
76804
+ }
76805
+ }
76806
+ function pathRules(manifest, env, reposDir) {
76807
+ return [
76808
+ ...manifest.projects.map((p) => ({ from: p.repoPath, to: join52(reposDir, p.targetFolder) })),
76809
+ { from: manifest.source.dataDir, to: env.dataDir }
76810
+ ];
76811
+ }
76812
+ function claudeRenames(manifest, rules) {
76813
+ const platform = manifest.source.platform;
76814
+ const renames = new Map;
76815
+ for (const folder of manifest.claudeFolders) {
76816
+ const mapped = mapSourcePath(folder.cwd, rules, platform);
76817
+ renames.set(folder.name, mapped ? encodeClaudeFolder(mapped) : folder.name);
76818
+ }
76819
+ return renames;
76820
+ }
76821
+ function mapTranscript(path3, manifest, rules, renames, env) {
76822
+ const platform = manifest.source.platform;
76823
+ const projects2 = `${manifest.source.claudeDir}${platform === "win32" ? "\\" : "/"}projects`;
76824
+ const rest = mapSourcePath(path3, [{ from: projects2, to: "" }], platform);
76825
+ if (rest === null)
76826
+ return mapSourcePath(path3, rules, platform);
76827
+ const [, folder, ...tail] = rest.split("/");
76828
+ const key = platform === "win32" ? folder.toLowerCase() : folder;
76829
+ for (const [oldName, newName] of renames) {
76830
+ if ((platform === "win32" ? oldName.toLowerCase() : oldName) === key) {
76831
+ return join52(env.claudeDir, "projects", newName, ...tail);
76832
+ }
76833
+ }
76834
+ return null;
76835
+ }
76836
+ function rewriteCommands(sql5, rules, platform) {
76837
+ const result = { changed: [], windows: [] };
76838
+ const projects2 = sql5.all("SELECT * FROM projects");
76839
+ for (const project3 of projects2) {
76840
+ for (const { column, label } of HOST_COMMANDS) {
76841
+ const value = project3[column];
76842
+ if (!value)
76843
+ continue;
76844
+ const next4 = rewritePathsInText(value, rules, platform);
76845
+ if (next4 !== value) {
76846
+ sql5.run(`UPDATE projects SET ${column} = ? WHERE id = ?`, next4, project3.id);
76847
+ result.changed.push({ project: project3.name, field: label, from: value, to: next4 });
76848
+ }
76849
+ if (looksWindowsOnly(next4))
76850
+ result.windows.push({ project: project3.name, field: label, value: next4 });
76851
+ }
76852
+ }
76853
+ return result;
76854
+ }
76855
+ function rewriteDatabase(sql5, manifest, rules, renames, env) {
76856
+ const platform = manifest.source.platform;
76857
+ let unmapped = 0;
76858
+ let commands;
76859
+ sql5.run("BEGIN");
76860
+ try {
76861
+ for (const row2 of sql5.all("SELECT id, repo_path FROM projects")) {
76862
+ const mapped = mapSourcePath(row2.repo_path, rules, platform);
76863
+ if (mapped)
76864
+ sql5.run("UPDATE projects SET repo_path = ? WHERE id = ?", mapped, row2.id);
76865
+ else
76866
+ unmapped++;
76867
+ }
76868
+ const sessions2 = sql5.all("SELECT id, worktree_path, transcript_path FROM sessions");
76869
+ for (const row2 of sessions2) {
76870
+ const worktree = mapSourcePath(row2.worktree_path, rules, platform);
76871
+ if (worktree)
76872
+ sql5.run("UPDATE sessions SET worktree_path = ? WHERE id = ?", worktree, row2.id);
76873
+ else
76874
+ unmapped++;
76875
+ if (!row2.transcript_path)
76876
+ continue;
76877
+ const transcript = mapTranscript(row2.transcript_path, manifest, rules, renames, env);
76878
+ if (transcript)
76879
+ sql5.run("UPDATE sessions SET transcript_path = ? WHERE id = ?", transcript, row2.id);
76880
+ else
76881
+ unmapped++;
76882
+ }
76883
+ commands = rewriteCommands(sql5, [...rules, { from: manifest.source.home, to: env.home }], platform);
76884
+ if (platform !== env.platform)
76885
+ sql5.run("UPDATE project_findings SET verified_at = NULL, verified_sha = NULL");
76886
+ sql5.run("COMMIT");
76887
+ } catch (err) {
76888
+ sql5.run("ROLLBACK");
76889
+ throw err;
76890
+ }
76891
+ return { unmapped, commands };
76892
+ }
76893
+ function rebuildRepo(project3, staging, path3) {
76894
+ mkdirSync28(path3, { recursive: true });
76895
+ gitOrThrow(path3, ["init", "-q"]);
76896
+ if (Object.keys(project3.refs).length > 0) {
76897
+ const bundle = join52(staging, "repos", `${project3.id}.bundle`);
76898
+ gitOrThrow(path3, ["fetch", "-q", "--no-tags", "--update-head-ok", bundle, "+refs/*:refs/*"]);
76899
+ }
76900
+ for (const remote of project3.remotes)
76901
+ gitOrThrow(path3, ["remote", "add", remote.name, remote.url]);
76902
+ for (const { key, value } of project3.config)
76903
+ gitOrThrow(path3, ["config", "--local", "--add", key, value]);
76904
+ if (project3.headBranch) {
76905
+ gitOrThrow(path3, ["symbolic-ref", "HEAD", `refs/heads/${project3.headBranch}`]);
76906
+ if (project3.headSha)
76907
+ gitOrThrow(path3, ["reset", "-q", "--hard"]);
76908
+ } else if (project3.headSha) {
76909
+ gitOrThrow(path3, ["checkout", "-q", "-f", "--detach", project3.headSha]);
76910
+ }
76911
+ restoreStash(project3, path3);
76912
+ if (project3.excludeRules) {
76913
+ mkdirSync28(join52(path3, ".git", "info"), { recursive: true });
76914
+ writeFileSync22(join52(path3, ".git", "info", "exclude"), project3.excludeRules);
76915
+ }
76916
+ for (const rel of project3.carried) {
76917
+ mkdirSync28(dirname25(join52(path3, rel)), { recursive: true });
76918
+ copyFileSync5(join52(staging, "carried", project3.id, rel), join52(path3, rel));
76919
+ }
76920
+ }
76921
+ function restoreStash(project3, path3) {
76922
+ const sha = project3.refs["refs/stash"];
76923
+ if (!sha)
76924
+ return;
76925
+ gitOrThrow(path3, ["update-ref", "-d", "refs/stash"]);
76926
+ gitOrThrow(path3, ["-c", "user.name=runcastle", "-c", "user.email=runcastle@localhost", "stash", "store", "-m", "moved from the old machine", sha]);
76927
+ }
76928
+ function verifyRefs(project3, path3) {
76929
+ const listing = gitOrThrow(path3, ["for-each-ref", "--format=%(objectname) %(refname)"]);
76930
+ const found = new Map(listing.split(`
76931
+ `).filter(Boolean).map((line2) => {
76932
+ const space = line2.indexOf(" ");
76933
+ return [line2.slice(space + 1).trim(), line2.slice(0, space)];
76934
+ }));
76935
+ for (const [ref, sha] of Object.entries(project3.refs)) {
76936
+ if (found.get(ref) !== sha)
76937
+ throw new Error(`${project3.name}: ${ref} did not arrive at ${sha}`);
76938
+ }
76939
+ }
76940
+ function verifyConversations(staging, manifest) {
76941
+ for (const { id: id3, folder } of manifest.conversations) {
76942
+ if (!existsSync44(join52(staging, "claude", folder, `${id3}.jsonl`))) {
76943
+ throw new Error(`the pack is missing the transcript of conversation ${id3}`);
76944
+ }
76945
+ }
76946
+ }
76947
+ function verifyRowCounts(sql5, manifest) {
76948
+ const counts = countRows(sql5);
76949
+ for (const [table, count6] of Object.entries(manifest.rowCounts)) {
76950
+ if (counts[table] !== count6) {
76951
+ throw new Error(`the database table ${table} has ${counts[table]} rows, the pack says ${count6}`);
76952
+ }
76953
+ }
76954
+ }
76955
+ function mergeSettings(staged, current2) {
76956
+ if (!existsSync44(staged))
76957
+ return;
76958
+ const incoming = JSON.parse(readFileSync30(staged, "utf8"));
76959
+ let own = {};
76960
+ try {
76961
+ own = JSON.parse(readFileSync30(current2, "utf8"));
76962
+ } catch {}
76963
+ for (const key of MACHINE_SETTINGS) {
76964
+ if (key in own)
76965
+ incoming[key] = own[key];
76966
+ else
76967
+ delete incoming[key];
76968
+ }
76969
+ writeFileSync22(staged, `${JSON.stringify(incoming, null, 2)}
76970
+ `);
76971
+ }
76972
+
76973
+ class Swap {
76974
+ made = [];
76975
+ rename(from, to) {
76976
+ mkdirSync28(dirname25(to), { recursive: true });
76977
+ renameSync2(from, to);
76978
+ this.made.push({ from, to });
76979
+ }
76980
+ undo() {
76981
+ for (const { from, to } of this.made.reverse()) {
76982
+ try {
76983
+ renameSync2(to, from);
76984
+ } catch {}
76985
+ }
76986
+ }
76987
+ }
76988
+ function placeClaudeFolder(from, to, swap2, when3) {
76989
+ if (!existsSync44(to)) {
76990
+ swap2.rename(from, to);
76991
+ return;
76992
+ }
76993
+ if (statSync16(from).isDirectory() && statSync16(to).isDirectory()) {
76994
+ for (const entry of readdirSync14(from))
76995
+ placeClaudeFolder(join52(from, entry), join52(to, entry), swap2, when3);
76996
+ return;
76997
+ }
76998
+ if (readFileSync30(from).equals(readFileSync30(to)))
76999
+ return;
77000
+ let aside = `${to}.moved-${when3}`;
77001
+ for (let n = 2;existsSync44(aside); n++)
77002
+ aside = `${to}.moved-${when3}-${n}`;
77003
+ swap2.rename(from, aside);
77004
+ }
77005
+ function restoreScriptBits(dir) {
77006
+ for (const rel of walkFiles(dir)) {
77007
+ const path3 = join52(dir, rel);
77008
+ const start3 = Buffer.alloc(2);
77009
+ const fd = openSync7(path3, "r");
77010
+ try {
77011
+ readSync4(fd, start3, 0, 2, 0);
77012
+ } finally {
77013
+ closeSync7(fd);
77014
+ }
77015
+ if (start3.toString() === "#!")
77016
+ chmodSync(path3, 448);
77017
+ }
77018
+ }
77019
+ function settleClaudeSettings(staged, current2) {
77020
+ if (!existsSync44(staged))
77021
+ return false;
77022
+ const incoming = JSON.parse(readFileSync30(staged, "utf8"));
77023
+ let own = {};
77024
+ try {
77025
+ own = JSON.parse(readFileSync30(current2, "utf8"));
77026
+ } catch {}
77027
+ const keepChats = incoming.cleanupPeriodDays === undefined;
77028
+ const settled = { ...own, ...incoming, ...keepChats ? { cleanupPeriodDays: CHAT_KEEP_DAYS } : {} };
77029
+ writeFileSync22(staged, `${JSON.stringify(settled, null, 2)}
77030
+ `);
77031
+ return keepChats;
77032
+ }
77033
+ function addMcpServers(env, servers2, aside) {
77034
+ if (Object.keys(servers2).length === 0)
77035
+ return true;
77036
+ const path3 = claudeJsonPath(env);
77037
+ let own = {};
77038
+ if (existsSync44(path3)) {
77039
+ try {
77040
+ own = JSON.parse(readFileSync30(path3, "utf8"));
77041
+ } catch {
77042
+ return false;
77043
+ }
77044
+ copyFileSync5(path3, aside);
77045
+ }
77046
+ const mcpServers = { ...own.mcpServers, ...servers2 };
77047
+ writeFileSync22(path3, `${JSON.stringify({ ...own, mcpServers }, null, 2)}
77048
+ `, { mode: 384 });
77049
+ return true;
77050
+ }
77051
+ function installPlugins(setup, runClaude) {
77052
+ const failed = [];
77053
+ for (const marketplace of setup.marketplaces) {
77054
+ if (!runClaude(["plugin", "marketplace", "add", marketplace]))
77055
+ failed.push(`plugin marketplace ${marketplace}`);
77056
+ }
77057
+ for (const plugin of setup.plugins) {
77058
+ if (!runClaude(["plugin", "install", plugin]))
77059
+ failed.push(`plugin ${plugin}`);
77060
+ }
77061
+ return failed;
77062
+ }
77063
+ function claudeOn(env) {
77064
+ const PATH = [join52(env.home, ".local", "bin"), process.env.PATH].filter(Boolean).join(":");
77065
+ return (args2) => run8("claude", args2, { env: { ...process.env, PATH } }).ok;
77066
+ }
77067
+ function applyWip(project3, wip, repoPath, checkout, staging) {
77068
+ const source = join52(staging, "wip", project3.id, wip.key);
77069
+ const patch9 = join52(source, "changes.patch");
77070
+ const applied = !existsSync44(patch9) || run8("git", ["apply", "--whitespace=nowarn", patch9], { cwd: checkout }).ok;
77071
+ if (!applied) {
77072
+ const kept = join52(`${repoPath}.uncommitted`, wip.key);
77073
+ mkdirSync28(dirname25(kept), { recursive: true });
77074
+ cpSync3(source, kept, { recursive: true });
77075
+ return kept;
77076
+ }
77077
+ for (const rel of wip.untracked) {
77078
+ const from = join52(source, "files", rel);
77079
+ if (!existsSync44(from))
77080
+ continue;
77081
+ mkdirSync28(dirname25(join52(checkout, rel)), { recursive: true });
77082
+ copyFileSync5(from, join52(checkout, rel));
77083
+ }
77084
+ return null;
77085
+ }
77086
+ function addTalkWorktree(repoPath, path3, wip) {
77087
+ mkdirSync28(dirname25(path3), { recursive: true });
77088
+ if (wip.branch && run8("git", ["worktree", "add", "-q", path3, wip.branch], { cwd: repoPath }).ok)
77089
+ return;
77090
+ gitOrThrow(repoPath, ["worktree", "add", "-q", "--detach", path3, wip.sha ?? "HEAD"]);
77091
+ }
77092
+ function renderReport(manifest, result) {
77093
+ const lines2 = [
77094
+ "# runcastle move report",
77095
+ "",
77096
+ `Moved ${new Date(manifest.createdAt).toISOString()} from ${manifest.source.platform} (${manifest.source.home}).`,
77097
+ "",
77098
+ "## Repos",
77099
+ ...result.repos.map((r) => `- ${r.name}: ${r.path}`)
77100
+ ];
77101
+ if (result.wipStuck.length > 0) {
77102
+ lines2.push("", `## Uncommitted work still in ${result.stagingKept}/wip`, "");
77103
+ for (const w of result.wipStuck)
77104
+ lines2.push(`- ${w}`);
77105
+ }
77106
+ if (result.commandsWindows.length > 0) {
77107
+ lines2.push("", "## Commands that only work on Windows", "");
77108
+ for (const c of result.commandsWindows)
77109
+ lines2.push(`- ${c.project}, ${c.field}: \`${c.value}\``);
77110
+ }
77111
+ if (result.commandsChanged.length > 0) {
77112
+ lines2.push("", "## Commands with their paths rewritten", "");
77113
+ for (const c of result.commandsChanged)
77114
+ lines2.push(`- ${c.project}, ${c.field}: \`${c.from}\` became \`${c.to}\``);
77115
+ }
77116
+ if (result.claudeSetup && result.claudeSetup.failed.length > 0) {
77117
+ lines2.push("", "## Claude setup that did not go in", "");
77118
+ for (const item of result.claudeSetup.failed)
77119
+ lines2.push(`- ${item}`);
77120
+ }
77121
+ if (result.wipKept.length > 0) {
77122
+ lines2.push("", "## Uncommitted work kept beside the repo", "");
77123
+ for (const w of result.wipKept)
77124
+ lines2.push(`- ${w.project} (${w.key}): ${w.folder}`);
77125
+ }
77126
+ if (result.setAside.length > 0) {
77127
+ lines2.push("", "## Set aside", "");
77128
+ for (const s of result.setAside)
77129
+ lines2.push(`- ${s.from} is now ${s.to}`);
77130
+ }
77131
+ if (result.unmappedPaths > 0) {
77132
+ lines2.push("", `${result.unmappedPaths} stored paths matched no known folder and were left as they were.`);
77133
+ }
77134
+ if (manifest.reportText)
77135
+ lines2.push("", "## The report before the move", "", "```", manifest.reportText, "```");
77136
+ return `${lines2.join(`
77137
+ `)}
77138
+ `;
77139
+ }
77140
+ async function moveIn(input) {
77141
+ const { pack: pack2, env, open: open4, replace } = input;
77142
+ const log6 = input.log ?? (() => {});
77143
+ const reposDir = reposDirOf(env, input.reposDir);
77144
+ const when3 = stamp();
77145
+ const staging = `${env.dataDir}.incoming-${when3}`;
77146
+ const stagedRepos = [];
77147
+ let keepStaging = false;
77148
+ try {
77149
+ if (!existsSync44(pack2))
77150
+ throw new Error(`no pack at ${pack2}`);
77151
+ const free = freeBytes(dirname25(env.dataDir));
77152
+ const unpackBytes = statSync16(pack2).size * 3;
77153
+ if (free !== null && free < unpackBytes) {
77154
+ throw new Error(`not enough disk to unpack: about ${formatBytes(unpackBytes)} needed, ${formatBytes(free)} free`);
77155
+ }
77156
+ mkdirSync28(staging, { recursive: true });
77157
+ log6("Unpacking");
77158
+ extract(pack2, staging);
77159
+ const manifest = JSON.parse(readFileSync30(join52(staging, "manifest.json"), "utf8"));
77160
+ if (manifest.formatVersion !== 1)
77161
+ throw new Error("this pack was made by a newer runcastle");
77162
+ const question = {
77163
+ runcastleVersion: manifest.runcastleVersion,
77164
+ sourcePlatform: manifest.source.platform,
77165
+ migrations: manifest.migrations,
77166
+ targetFolders: manifest.projects.filter((p) => p.repoMoves).map((p) => p.targetFolder),
77167
+ dataBytes: 0,
77168
+ repoBytes: manifest.files.filter((f) => f.path.startsWith("repos/")).reduce((total, f) => total + f.size, 0)
77169
+ };
77170
+ const answer = await checkDestination(question, env, { replace, reposDir, open: open4 });
77171
+ if (answer.blockers.length > 0)
77172
+ throw new Error(answer.blockers.join(`
77173
+ `));
77174
+ log6("Checking every file");
77175
+ await verifyFiles(staging, manifest);
77176
+ verifyConversations(staging, manifest);
77177
+ const rules = pathRules(manifest, env, reposDir);
77178
+ const renames = claudeRenames(manifest, rules);
77179
+ log6("Rewriting paths in the database");
77180
+ const sql5 = open4(join52(staging, "db", "runcastle.db"), { readonly: false });
77181
+ let rewritten;
77182
+ try {
77183
+ verifyRowCounts(sql5, manifest);
77184
+ rewritten = rewriteDatabase(sql5, manifest, rules, renames, env);
77185
+ } finally {
77186
+ sql5.close();
77187
+ }
77188
+ const repoStages = new Map;
77189
+ for (const project3 of manifest.projects) {
77190
+ if (!project3.repoMoves)
77191
+ continue;
77192
+ log6(`Rebuilding ${project3.name}`);
77193
+ const stage = join52(reposDir, `${project3.targetFolder}.incoming-${when3}`);
77194
+ stagedRepos.push(stage);
77195
+ rebuildRepo(project3, staging, stage);
77196
+ verifyRefs(project3, stage);
77197
+ repoStages.set(project3.id, stage);
77198
+ }
77199
+ const data = join52(staging, "data");
77200
+ mkdirSync28(data, { recursive: true });
77201
+ renameSync2(join52(staging, "db", "runcastle.db"), join52(data, "runcastle.db"));
77202
+ mergeSettings(join52(data, "config.json"), join52(env.dataDir, "config.json"));
77203
+ for (const file of KEPT_FILES) {
77204
+ if (existsSync44(join52(env.dataDir, file)))
77205
+ copyFileSync5(join52(env.dataDir, file), join52(data, file));
77206
+ }
77207
+ const stagedSetup = join52(staging, "claude-setup");
77208
+ let chatsKept = false;
77209
+ if (manifest.claudeSetup && existsSync44(stagedSetup)) {
77210
+ restoreScriptBits(stagedSetup);
77211
+ chatsKept = settleClaudeSettings(join52(stagedSetup, "settings.json"), join52(env.claudeDir, "settings.json"));
77212
+ }
77213
+ log6("Swapping into place");
77214
+ const swap2 = new Swap;
77215
+ const setAside = [];
77216
+ const asideOf = (path3) => `${path3}.before-move-${when3}`;
77217
+ try {
77218
+ for (const project3 of manifest.projects) {
77219
+ const stage = repoStages.get(project3.id);
77220
+ if (!stage)
77221
+ continue;
77222
+ const target = join52(reposDir, project3.targetFolder);
77223
+ if (existsSync44(target)) {
77224
+ swap2.rename(target, asideOf(target));
77225
+ setAside.push({ from: target, to: asideOf(target) });
77226
+ }
77227
+ swap2.rename(stage, target);
77228
+ }
77229
+ const claudeProjects = join52(env.claudeDir, "projects");
77230
+ mkdirSync28(claudeProjects, { recursive: true });
77231
+ const asideNames = new Set;
77232
+ for (const folder of manifest.claudeFolders) {
77233
+ const newName = renames.get(folder.name) ?? folder.name;
77234
+ const target = join52(claudeProjects, newName);
77235
+ if (existsSync44(target) && !asideNames.has(newName) && replace) {
77236
+ swap2.rename(target, asideOf(target));
77237
+ setAside.push({ from: target, to: asideOf(target) });
77238
+ }
77239
+ asideNames.add(newName);
77240
+ const staged = join52(staging, "claude", folder.name);
77241
+ if (existsSync44(staged))
77242
+ placeClaudeFolder(staged, target, swap2, when3);
77243
+ }
77244
+ if (manifest.claudeSetup && existsSync44(stagedSetup)) {
77245
+ for (const entry of readdirSync14(stagedSetup)) {
77246
+ const target = join52(env.claudeDir, entry);
77247
+ if (existsSync44(target)) {
77248
+ swap2.rename(target, asideOf(target));
77249
+ setAside.push({ from: target, to: asideOf(target) });
77250
+ }
77251
+ swap2.rename(join52(stagedSetup, entry), target);
77252
+ }
77253
+ }
77254
+ if (existsSync44(env.dataDir)) {
77255
+ swap2.rename(env.dataDir, asideOf(env.dataDir));
77256
+ setAside.push({ from: env.dataDir, to: asideOf(env.dataDir) });
77257
+ }
77258
+ swap2.rename(data, env.dataDir);
77259
+ } catch (err) {
77260
+ swap2.undo();
77261
+ throw err;
77262
+ }
77263
+ log6("Putting uncommitted work back");
77264
+ const wipApplied = [];
77265
+ const wipKept = [];
77266
+ const wipStuck = [];
77267
+ for (const project3 of manifest.projects) {
77268
+ if (!project3.repoMoves)
77269
+ continue;
77270
+ const repoPath = join52(reposDir, project3.targetFolder);
77271
+ for (const wip of project3.wip) {
77272
+ const checkout = wip.key === "main" ? repoPath : join52(env.dataDir, "worktrees", project3.id, wip.key);
77273
+ let kept;
77274
+ try {
77275
+ if (wip.key !== "main")
77276
+ addTalkWorktree(repoPath, checkout, wip);
77277
+ kept = applyWip(project3, wip, repoPath, checkout, staging);
77278
+ } catch {
77279
+ try {
77280
+ kept = join52(`${repoPath}.uncommitted`, wip.key);
77281
+ cpSync3(join52(staging, "wip", project3.id, wip.key), kept, { recursive: true });
77282
+ } catch {
77283
+ keepStaging = true;
77284
+ wipStuck.push(`${project3.name} (${wip.key})`);
77285
+ continue;
77286
+ }
77287
+ }
77288
+ if (kept)
77289
+ wipKept.push({ project: project3.name, key: wip.key, folder: kept });
77290
+ else
77291
+ wipApplied.push(`${project3.name} (${wip.key})`);
77292
+ }
77293
+ }
77294
+ let claudeSetup;
77295
+ if (manifest.claudeSetup) {
77296
+ const failed = [];
77297
+ if (!addMcpServers(env, manifest.claudeSetup.mcpServers, asideOf(claudeJsonPath(env)))) {
77298
+ failed.push(`MCP servers: could not read ${claudeJsonPath(env)}`);
77299
+ }
77300
+ log6("Installing your Claude plugins again");
77301
+ failed.push(...installPlugins(manifest.claudeSetup, input.runClaude ?? claudeOn(env)));
77302
+ claudeSetup = { failed, chatsKept };
77303
+ }
77304
+ const partial = {
77305
+ repos: manifest.projects.filter((p) => p.repoMoves).map((p) => ({ name: p.name, path: join52(reposDir, p.targetFolder) })),
77306
+ commandsChanged: rewritten.commands.changed,
77307
+ commandsWindows: rewritten.commands.windows,
77308
+ envCarried: manifest.envCarried,
77309
+ ...claudeSetup ? { claudeSetup } : {},
77310
+ wipApplied,
77311
+ wipKept,
77312
+ wipStuck,
77313
+ ...keepStaging ? { stagingKept: staging } : {},
77314
+ unmappedPaths: rewritten.unmapped,
77315
+ setAside
77316
+ };
77317
+ const reportPath = join52(env.dataDir, "move-report.md");
77318
+ writeFileSync22(reportPath, renderReport(manifest, partial));
77319
+ if (!keepStaging)
77320
+ rmSync12(pack2, { force: true });
77321
+ return { ...partial, reportPath };
77322
+ } finally {
77323
+ if (!keepStaging)
77324
+ rmSync12(staging, { recursive: true, force: true });
77325
+ for (const stage of stagedRepos)
77326
+ rmSync12(stage, { recursive: true, force: true });
77327
+ }
77328
+ }
77329
+ var MACHINE_SETTINGS, KEPT_FILES, CHAT_KEEP_DAYS = 3650;
77330
+ var init_unpack = __esm(() => {
77331
+ init_destination();
77332
+ init_pack();
77333
+ init_scan();
77334
+ init_shell();
77335
+ init_tar();
77336
+ MACHINE_SETTINGS = ["burnConcurrency", "burnCpus"];
77337
+ KEPT_FILES = [".env", "install-id"];
77338
+ });
77339
+
77340
+ // src/move/cli.ts
77341
+ var exports_cli2 = {};
77342
+ __export(exports_cli2, {
77343
+ runMoveInCli: () => runMoveInCli,
77344
+ runMoveCli: () => runMoveCli,
77345
+ parseMoveInArgs: () => parseMoveInArgs,
77346
+ parseMoveArgs: () => parseMoveArgs,
77347
+ currentEnv: () => currentEnv
77348
+ });
77349
+ import { rmSync as rmSync13 } from "fs";
77350
+ import { homedir as homedir7, tmpdir as tmpdir7 } from "os";
77351
+ import { dirname as dirname26, join as join53 } from "path";
77352
+ import { createInterface as createInterface6 } from "readline/promises";
77353
+ function takeValue(args2, i, flag) {
77354
+ const value = args2[i + 1];
77355
+ if (value === undefined || value.startsWith("--"))
77356
+ throw new Error(`${flag} needs a value`);
77357
+ return value;
77358
+ }
77359
+ function parseMoveArgs(args2) {
77360
+ const parsed = {
77361
+ dryRun: false,
77362
+ yes: false,
77363
+ replace: false,
77364
+ noRepo: [],
77365
+ withEnv: false,
77366
+ withClaudeSetup: false,
77367
+ also: []
77368
+ };
77369
+ for (let i = 0;i < args2.length; i++) {
77370
+ const arg = args2[i];
77371
+ if (arg === "--dry-run")
77372
+ parsed.dryRun = true;
77373
+ else if (arg === "--yes")
77374
+ parsed.yes = true;
77375
+ else if (arg === "--replace")
77376
+ parsed.replace = true;
77377
+ else if (arg === "--with-env")
77378
+ parsed.withEnv = true;
77379
+ else if (arg === "--with-claude-setup")
77380
+ parsed.withClaudeSetup = true;
77381
+ else if (arg === "--also")
77382
+ parsed.also.push(takeValue(args2, i++, arg));
77383
+ else if (arg === "--repos-dir")
77384
+ parsed.reposDir = takeValue(args2, i++, arg);
77385
+ else if (arg === "--no-repo")
77386
+ parsed.noRepo.push(takeValue(args2, i++, arg));
77387
+ else if (arg === "--out")
77388
+ parsed.out = takeValue(args2, i++, arg);
77389
+ else if (arg.startsWith("--"))
77390
+ throw new Error(`unknown flag ${arg}`);
77391
+ else if (parsed.host)
77392
+ throw new Error(`unexpected argument ${arg}`);
77393
+ else
77394
+ parsed.host = arg;
77395
+ }
77396
+ return parsed;
77397
+ }
77398
+ function parseMoveInArgs(args2) {
77399
+ const parsed = { check: false, replace: false };
77400
+ for (let i = 0;i < args2.length; i++) {
77401
+ const arg = args2[i];
77402
+ if (arg === "--check")
77403
+ parsed.check = true;
77404
+ else if (arg === "--replace")
77405
+ parsed.replace = true;
77406
+ else if (arg === "--repos-dir")
77407
+ parsed.reposDir = takeValue(args2, i++, arg);
77408
+ else if (arg.startsWith("--"))
77409
+ throw new Error(`unknown flag ${arg}`);
77410
+ else if (parsed.pack)
77411
+ throw new Error(`unexpected argument ${arg}`);
77412
+ else
77413
+ parsed.pack = arg;
77414
+ }
77415
+ return parsed;
77416
+ }
77417
+ function currentEnv() {
77418
+ return {
77419
+ platform: process.platform,
77420
+ home: homedir7(),
77421
+ dataDir: dataDir(),
77422
+ claudeDir: process.env.CLAUDE_CONFIG_DIR ?? join53(homedir7(), ".claude")
77423
+ };
77424
+ }
77425
+ async function openSql() {
77426
+ const { openRawSqlite: openRawSqlite2 } = await Promise.resolve().then(() => (init_client(), exports_client));
77427
+ return openRawSqlite2;
77428
+ }
77429
+ function questionFor(scan, migrations, env) {
77430
+ return {
77431
+ runcastleVersion: runcastleVersion(),
77432
+ sourcePlatform: env.platform,
77433
+ migrations,
77434
+ targetFolders: scan.projects.filter((p) => p.repoMoves).map((p) => p.targetFolder),
77435
+ dataBytes: scan.report.dataBytes,
77436
+ repoBytes: scan.report.repoBytes
77437
+ };
77438
+ }
77439
+ async function confirmed() {
77440
+ if (!process.stdin.isTTY) {
77441
+ console.error("Run with --yes to go ahead without a terminal to answer in.");
77442
+ return false;
77443
+ }
77444
+ const prompt = createInterface6({ input: process.stdin, output: process.stdout });
77445
+ const answer = await prompt.question(`
77446
+ Go ahead? (y/N) `);
77447
+ prompt.close();
77448
+ return /^y(es)?$/i.test(answer.trim());
77449
+ }
77450
+ async function runMoveCli(argv) {
77451
+ const args2 = parseMoveArgs(argv);
77452
+ const env = currentEnv();
77453
+ const open4 = await openSql();
77454
+ const flags = { replace: args2.replace, ...args2.reposDir ? { reposDir: args2.reposDir } : {} };
77455
+ let sql5;
77456
+ try {
77457
+ sql5 = open4(join53(env.dataDir, "runcastle.db"), { readonly: true });
77458
+ } catch {
77459
+ console.error(`No runcastle database at ${env.dataDir}.`);
77460
+ return 1;
77461
+ }
77462
+ let scan;
77463
+ let migrations;
77464
+ let windowsCommands;
77465
+ try {
77466
+ scan = scanMove(env, sql5, {
77467
+ noRepo: args2.noRepo,
77468
+ withEnv: args2.withEnv,
77469
+ withClaudeSetup: args2.withClaudeSetup,
77470
+ also: args2.also
77471
+ });
77472
+ migrations = sql5.all("SELECT name FROM __migrations").map((r) => r.name);
77473
+ windowsCommands = windowsCommandLines(sql5, scan.projects, env);
77474
+ } finally {
77475
+ sql5.close();
77476
+ }
77477
+ const running3 = await serverRunning(env);
77478
+ if (running3)
77479
+ scan.report.blockers.unshift("this machine's runcastle server is running: stop it for a real move");
77480
+ const destination = args2.host ? askDestination(args2.host, questionFor(scan, migrations, env), flags) : undefined;
77481
+ const pack2 = args2.out ?? join53(tmpdir7(), `runcastle-move-${stamp()}.tar.gz`);
77482
+ const room = freeBytes(dirname26(pack2));
77483
+ const packMax = scan.report.dataBytes + scan.report.repoBytes;
77484
+ if (room !== null && room < packMax) {
77485
+ scan.report.blockers.push(`not enough disk beside the pack: up to ${formatBytes(packMax)} needed, ${formatBytes(room)} free. Choose another place with --out`);
77486
+ }
77487
+ const reportText = formatReport2(scan.report, destination, windowsCommands);
77488
+ console.log(reportText);
77489
+ if (args2.dryRun) {
77490
+ console.log(`
77491
+ Nothing was changed.`);
77492
+ return 0;
77493
+ }
77494
+ if (isBlocked(scan.report, destination)) {
77495
+ console.error(`
77496
+ Nothing was changed. Clear the blocks above, then run this again.`);
77497
+ return 1;
77498
+ }
77499
+ if (!args2.yes && !await confirmed()) {
77500
+ console.log("Nothing was changed.");
77501
+ return 0;
77502
+ }
77503
+ await packMove({ env, scan, open: open4, out: pack2, reportText, log: (line2) => console.log(line2) });
77504
+ console.log(`
77505
+ Packed: ${pack2}`);
77506
+ if (!args2.host) {
77507
+ console.log(`
77508
+ Finish on the new machine with:`);
77509
+ for (const line2 of manualSteps(pack2, undefined, flags))
77510
+ console.log(` ${line2}`);
77511
+ return 0;
77512
+ }
77513
+ const copy4 = copyPack(pack2, args2.host);
77514
+ if (!copy4.ok) {
77515
+ console.error(failureMessage2("The copy", copy4.detail, pack2, args2.host, flags));
77516
+ return 1;
77517
+ }
77518
+ const remote = runRemoteMoveIn(pack2, args2.host, flags);
77519
+ if (!remote.ok) {
77520
+ console.error(failureMessage2("The move on the other machine", remote.detail, pack2, args2.host, flags));
77521
+ return 1;
77522
+ }
77523
+ rmSync13(pack2, { force: true });
77524
+ console.log(`
77525
+ The move is finished. This machine is unchanged and must not be used for work now.`);
77526
+ return 0;
77527
+ }
77528
+ async function readStdin() {
77529
+ let text3 = "";
77530
+ for await (const chunk of process.stdin)
77531
+ text3 += String(chunk);
77532
+ return text3;
77533
+ }
77534
+ async function runMoveInCli(argv) {
77535
+ const args2 = parseMoveInArgs(argv);
77536
+ const env = currentEnv();
77537
+ const open4 = await openSql();
77538
+ if (args2.check) {
77539
+ const question = JSON.parse(await readStdin());
77540
+ const answer = await checkDestination(question, env, {
77541
+ replace: args2.replace,
77542
+ open: open4,
77543
+ ...args2.reposDir ? { reposDir: args2.reposDir } : {}
77544
+ });
77545
+ console.log(JSON.stringify(answer));
77546
+ return 0;
77547
+ }
77548
+ if (!args2.pack) {
77549
+ console.error("Usage: runcastle move-in <pack> [--replace] [--repos-dir <dir>]");
77550
+ return 1;
77551
+ }
77552
+ const result = await moveIn({
77553
+ pack: args2.pack,
77554
+ env,
77555
+ open: open4,
77556
+ replace: args2.replace,
77557
+ log: (line2) => console.log(line2),
77558
+ ...args2.reposDir ? { reposDir: args2.reposDir } : {}
77559
+ });
77560
+ console.log(`
77561
+ ${formatMoveInResult(result)}`);
77562
+ return 0;
77563
+ }
77564
+ var init_cli2 = __esm(() => {
77565
+ init_paths();
77566
+ init_version();
77567
+ init_destination();
77568
+ init_pack();
77569
+ init_remote();
77570
+ init_report2();
77571
+ init_scan();
77572
+ init_shell();
77573
+ init_unpack();
77574
+ });
77575
+
75443
77576
  // src/bin/token.ts
75444
77577
  var exports_token = {};
75445
77578
  __export(exports_token, {
@@ -75461,7 +77594,7 @@ async function readAllStdin() {
75461
77594
  return Buffer.concat(chunks).toString("utf8");
75462
77595
  }
75463
77596
  function readHiddenFromTerminal() {
75464
- return new Promise((resolve16, reject) => {
77597
+ return new Promise((resolve18, reject) => {
75465
77598
  const stdin3 = process.stdin;
75466
77599
  let value = "";
75467
77600
  let timer;
@@ -75487,7 +77620,7 @@ function readHiddenFromTerminal() {
75487
77620
  value += ch;
75488
77621
  }
75489
77622
  if (/[\r\n]/.test(value))
75490
- timer = setTimeout(() => finish(() => resolve16(value)), PASTE_QUIET_MS);
77623
+ timer = setTimeout(() => finish(() => resolve18(value)), PASTE_QUIET_MS);
75491
77624
  };
75492
77625
  stdin3.setRawMode(true);
75493
77626
  stdin3.resume();
@@ -75559,14 +77692,14 @@ __export(exports_service, {
75559
77692
  runService: () => runService,
75560
77693
  SERVICE_USAGE: () => SERVICE_USAGE
75561
77694
  });
75562
- import { spawnSync as spawnSync3 } from "child_process";
75563
- import { existsSync as existsSync41, mkdtempSync as mkdtempSync2, rmSync as rmSync11, writeFileSync as writeFileSync22 } from "fs";
75564
- import { homedir as homedir7, hostname, tmpdir as tmpdir7, userInfo as userInfo3 } from "os";
75565
- import { join as join48 } from "path";
77695
+ import { spawnSync as spawnSync5 } from "child_process";
77696
+ import { existsSync as existsSync45, mkdtempSync as mkdtempSync3, rmSync as rmSync14, writeFileSync as writeFileSync23 } from "fs";
77697
+ import { homedir as homedir8, hostname, tmpdir as tmpdir8, userInfo as userInfo3 } from "os";
77698
+ import { join as join54 } from "path";
75566
77699
  function writeTempFile(text3) {
75567
- const dir = mkdtempSync2(join48(tmpdir7(), "runcastle-service-"));
75568
- const file = join48(dir, "runcastle.service");
75569
- writeFileSync22(file, text3, "utf8");
77700
+ const dir = mkdtempSync3(join54(tmpdir8(), "runcastle-service-"));
77701
+ const file = join54(dir, "runcastle.service");
77702
+ writeFileSync23(file, text3, "utf8");
75570
77703
  return file;
75571
77704
  }
75572
77705
  function runSteps(steps, run7, log6) {
@@ -75586,7 +77719,7 @@ async function runService(argv, deps2 = {}) {
75586
77719
  return 1;
75587
77720
  }
75588
77721
  const platform = deps2.platform ?? process.platform;
75589
- const systemdPresent = deps2.systemdPresent ?? existsSync41("/run/systemd/system");
77722
+ const systemdPresent = deps2.systemdPresent ?? existsSync45("/run/systemd/system");
75590
77723
  if (platform !== "linux" || !systemdPresent) {
75591
77724
  log6("A background service is not supported here: it needs Linux with systemd.");
75592
77725
  return 1;
@@ -75596,7 +77729,7 @@ async function runService(argv, deps2 = {}) {
75596
77729
  return 1;
75597
77730
  }
75598
77731
  const run7 = deps2.runStep ?? inheritStdio;
75599
- const existed = deps2.serviceFileExists ?? existsSync41(SERVICE_FILE_PATH);
77732
+ const existed = deps2.serviceFileExists ?? existsSync45(SERVICE_FILE_PATH);
75600
77733
  if (action === "remove") {
75601
77734
  if (!existed) {
75602
77735
  log6("No runcastle service is installed. Nothing to remove.");
@@ -75612,7 +77745,7 @@ async function runService(argv, deps2 = {}) {
75612
77745
  return ok3 ? 0 : 1;
75613
77746
  }
75614
77747
  const user = deps2.user ?? userInfo3().username;
75615
- const home = deps2.home ?? homedir7();
77748
+ const home = deps2.home ?? homedir8();
75616
77749
  const text3 = renderServiceFile({
75617
77750
  user,
75618
77751
  home,
@@ -75631,14 +77764,14 @@ async function runService(argv, deps2 = {}) {
75631
77764
  return 1;
75632
77765
  } finally {
75633
77766
  if (!deps2.writeTemp)
75634
- rmSync11(join48(tmp, ".."), { recursive: true, force: true });
77767
+ rmSync14(join54(tmp, ".."), { recursive: true, force: true });
75635
77768
  }
75636
77769
  log6(existed ? "Replaced the existing runcastle service." : "Installed the runcastle service.");
75637
77770
  log6("Check it with: systemctl status runcastle");
75638
77771
  log6(`Reach the app from your own machine with: ssh -L 4512:localhost:4512 ${user}@${deps2.host ?? hostname()}`);
75639
77772
  return 0;
75640
77773
  }
75641
- var SERVICE_USAGE, inheritStdio = (command, args2) => spawnSync3(command, args2, { stdio: "inherit" }).status ?? 1;
77774
+ var SERVICE_USAGE, inheritStdio = (command, args2) => spawnSync5(command, args2, { stdio: "inherit" }).status ?? 1;
75642
77775
  var init_service = __esm(() => {
75643
77776
  init_service_file();
75644
77777
  init_resolve_executable();
@@ -75693,7 +77826,7 @@ var init_build_image = __esm(() => {
75693
77826
  // src/bin/runcastle.ts
75694
77827
  init_asset_paths();
75695
77828
  init_version();
75696
- import { dirname as dirname23, resolve as resolve16 } from "path";
77829
+ import { dirname as dirname27, resolve as resolve18 } from "path";
75697
77830
  import { fileURLToPath as fileURLToPath7 } from "url";
75698
77831
  function parseCommand(argv) {
75699
77832
  const [first, ...rest] = argv;
@@ -75707,6 +77840,10 @@ function parseCommand(argv) {
75707
77840
  return { command: "setup", args: rest };
75708
77841
  if (first === "doctor")
75709
77842
  return { command: "doctor", args: rest };
77843
+ if (first === "move")
77844
+ return { command: "move", args: rest };
77845
+ if (first === "move-in")
77846
+ return { command: "move-in", args: rest };
75710
77847
  if (first === "token")
75711
77848
  return { command: "token", args: rest };
75712
77849
  if (first === "service")
@@ -75724,6 +77861,12 @@ Usage:
75724
77861
  runcastle serve The same, spelled out for scripts and services
75725
77862
  runcastle setup List what this machine is missing and install it (--yes, --agent claude|codex|both)
75726
77863
  runcastle doctor Check prerequisites (add --gate for the pre-boot gate)
77864
+ runcastle move [user@host] [--dry-run] [--yes] [--replace] [--with-env]
77865
+ [--with-claude-setup] [--also <project>:<path>]
77866
+ [--repos-dir <dir>] [--no-repo <project>] [--out <file>]
77867
+ Move runcastle to another machine; only reads this one
77868
+ runcastle move-in <pack> [--replace] [--repos-dir <dir>]
77869
+ Bring a move in on this machine
75727
77870
  runcastle token Save the unattended Claude Code token (--stdin to read it from a pipe)
75728
77871
  runcastle service install
75729
77872
  Keep runcastle running on this Linux machine (service remove undoes it)
@@ -75733,7 +77876,7 @@ Usage:
75733
77876
  runcastle --help Show this help
75734
77877
  `;
75735
77878
  async function main(argv) {
75736
- applyInstalledAssetEnv(resolve16(dirname23(fileURLToPath7(import.meta.url)), ".."));
77879
+ applyInstalledAssetEnv(resolve18(dirname27(fileURLToPath7(import.meta.url)), ".."));
75737
77880
  const { command, args: args2 } = parseCommand(argv);
75738
77881
  switch (command) {
75739
77882
  case "version":
@@ -75750,6 +77893,14 @@ async function main(argv) {
75750
77893
  const { runCli: runCli2 } = await Promise.resolve().then(() => (init_cli(), exports_cli));
75751
77894
  return runCli2(args2);
75752
77895
  }
77896
+ case "move": {
77897
+ const { runMoveCli: runMoveCli2 } = await Promise.resolve().then(() => (init_cli2(), exports_cli2));
77898
+ return runMoveCli2(args2);
77899
+ }
77900
+ case "move-in": {
77901
+ const { runMoveInCli: runMoveInCli2 } = await Promise.resolve().then(() => (init_cli2(), exports_cli2));
77902
+ return runMoveInCli2(args2);
77903
+ }
75753
77904
  case "token": {
75754
77905
  const { runToken: runToken2 } = await Promise.resolve().then(() => (init_token(), exports_token));
75755
77906
  return runToken2(args2);