@shardflux/cli 0.20.0 → 0.23.0

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 (124) hide show
  1. package/CHANGELOG.md +312 -1
  2. package/README.md +242 -75
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +19 -15
  6. package/dist/account-pool.js +47 -96
  7. package/dist/account-scope.d.ts +65 -0
  8. package/dist/account-scope.js +246 -0
  9. package/dist/acp-journal.js +58 -6
  10. package/dist/acp-login.d.ts +13 -5
  11. package/dist/acp-login.js +37 -8
  12. package/dist/acp-models.d.ts +6 -1
  13. package/dist/acp-models.js +29 -19
  14. package/dist/acp.js +120 -14
  15. package/dist/agent-commands.d.ts +4 -1
  16. package/dist/agent-commands.js +110 -42
  17. package/dist/agent-config-return.d.ts +46 -2
  18. package/dist/agent-config-return.js +110 -4
  19. package/dist/agent-credentials.js +7 -3
  20. package/dist/agent-repository.d.ts +16 -3
  21. package/dist/agent-repository.js +149 -74
  22. package/dist/agent-setup.d.ts +14 -3
  23. package/dist/agent-setup.js +78 -36
  24. package/dist/agent-target-commands.d.ts +20 -1
  25. package/dist/agent-target-commands.js +73 -31
  26. package/dist/agent-targets.js +6 -1
  27. package/dist/args.d.ts +1 -1
  28. package/dist/args.js +95 -54
  29. package/dist/bin.js +5 -1
  30. package/dist/browser.d.ts +13 -2
  31. package/dist/browser.js +30 -14
  32. package/dist/claude-cloud-login.d.ts +24 -2
  33. package/dist/claude-cloud-login.js +151 -11
  34. package/dist/cli-proxy-hub.d.ts +61 -25
  35. package/dist/cli-proxy-hub.js +202 -54
  36. package/dist/cli-proxy.d.ts +44 -1
  37. package/dist/cli-proxy.js +177 -28
  38. package/dist/client-settings.d.ts +39 -0
  39. package/dist/client-settings.js +91 -0
  40. package/dist/codex-cloud.d.ts +14 -4
  41. package/dist/codex-cloud.js +135 -31
  42. package/dist/codex-linux.d.ts +14 -0
  43. package/dist/codex-linux.js +69 -0
  44. package/dist/command-center-actions.js +18 -5
  45. package/dist/command-center-model.d.ts +2 -0
  46. package/dist/command-center-model.js +6 -1
  47. package/dist/command-center.js +3 -3
  48. package/dist/commands.js +173 -24
  49. package/dist/conversation-commands.d.ts +5 -0
  50. package/dist/conversation-commands.js +8 -5
  51. package/dist/conversation-terminal.d.ts +10 -2
  52. package/dist/conversation-terminal.js +78 -13
  53. package/dist/conversations.d.ts +86 -1
  54. package/dist/conversations.js +306 -23
  55. package/dist/desktop-linux.js +16 -3
  56. package/dist/device-bridge.d.ts +1 -1
  57. package/dist/device-bridge.js +6 -4
  58. package/dist/device-helper.js +43 -22
  59. package/dist/errors.d.ts +29 -2
  60. package/dist/errors.js +50 -7
  61. package/dist/filesystem-commands.d.ts +16 -9
  62. package/dist/filesystem-commands.js +133 -57
  63. package/dist/format.d.ts +2 -1
  64. package/dist/format.js +10 -1
  65. package/dist/handlers.js +3 -1
  66. package/dist/initial-prompt.d.ts +8 -0
  67. package/dist/initial-prompt.js +43 -1
  68. package/dist/large-media.d.ts +14 -0
  69. package/dist/large-media.js +100 -0
  70. package/dist/linux-terminal.d.ts +5 -3
  71. package/dist/linux-terminal.js +6 -20
  72. package/dist/local-sessions.d.ts +26 -2
  73. package/dist/local-sessions.js +113 -24
  74. package/dist/machine-setup.d.ts +11 -1
  75. package/dist/machine-setup.js +69 -22
  76. package/dist/machine-status.d.ts +67 -0
  77. package/dist/machine-status.js +140 -0
  78. package/dist/main.js +11 -13
  79. package/dist/menubar.d.ts +6 -5
  80. package/dist/menubar.js +38 -28
  81. package/dist/move-worktrees-restore.py +144 -0
  82. package/dist/move-worktrees.d.ts +69 -0
  83. package/dist/move-worktrees.js +252 -0
  84. package/dist/move.d.ts +10 -1
  85. package/dist/move.js +111 -16
  86. package/dist/onboarding.d.ts +11 -7
  87. package/dist/onboarding.js +63 -29
  88. package/dist/prepared-repository.d.ts +204 -0
  89. package/dist/prepared-repository.js +860 -0
  90. package/dist/prepared-repository.py +1307 -0
  91. package/dist/project-preparation.d.ts +62 -0
  92. package/dist/project-preparation.js +316 -0
  93. package/dist/project-sync-manifest.d.ts +4 -0
  94. package/dist/project-sync-manifest.js +5 -1
  95. package/dist/project-sync-transfer.js +1 -1
  96. package/dist/project-sync.d.ts +22 -4
  97. package/dist/project-sync.js +34 -8
  98. package/dist/project-template-setup.d.ts +33 -1
  99. package/dist/project-template-setup.js +59 -2
  100. package/dist/secret-ask.d.ts +72 -0
  101. package/dist/secret-ask.js +351 -0
  102. package/dist/seed-phases.d.ts +8 -1
  103. package/dist/seed-phases.js +17 -2
  104. package/dist/setup-cache.d.ts +7 -0
  105. package/dist/setup-cache.js +32 -0
  106. package/dist/t3-launch.d.ts +2 -1
  107. package/dist/t3-launch.js +12 -6
  108. package/dist/t3-mode.d.ts +8 -2
  109. package/dist/t3-mode.js +19 -4
  110. package/dist/t3-phone.d.ts +4 -2
  111. package/dist/t3-phone.js +12 -4
  112. package/dist/t3-proxy.d.ts +81 -0
  113. package/dist/t3-proxy.js +325 -0
  114. package/dist/terminal-apps.d.ts +168 -0
  115. package/dist/terminal-apps.js +574 -0
  116. package/dist/terminal-onboarding.js +6 -1
  117. package/dist/this-computer.d.ts +11 -0
  118. package/dist/this-computer.js +36 -0
  119. package/dist/thread-on-computer.js +4 -1
  120. package/dist/update.d.ts +2 -0
  121. package/dist/update.js +43 -9
  122. package/dist/worktree-twin.d.ts +10 -2
  123. package/dist/worktree-twin.js +27 -9
  124. package/package.json +5 -5
package/dist/menubar.js CHANGED
@@ -16,7 +16,7 @@ const execute = promisify(execFile);
16
16
  * whether or not a built Shardflux.app is in the package.
17
17
  */
18
18
  export const nativeTools = {
19
- run: async (tool, args, signal) => (await execute(tool, args, { timeout: 15000, maxBuffer: 1024 * 1024, signal })).stdout,
19
+ run: async (tool, args, signal, env) => (await execute(tool, args, { timeout: 15000, maxBuffer: 1024 * 1024, signal, ...(env ? { env: { ...process.env, ...env } } : {}) })).stdout,
20
20
  };
21
21
  const BUNDLE_ID = 'dev.shardflux.menubar';
22
22
  export function compareBundleVersions(left, right) {
@@ -123,7 +123,7 @@ export class MenubarManager {
123
123
  constructor(options = {}) {
124
124
  this.app = join(options.home ?? homedir(), 'Applications', 'Shardflux.app');
125
125
  this.#platform = options.platform ?? process.platform;
126
- this.#run = options.run ?? ((tool, args) => nativeTools.run(tool, args, options.signal));
126
+ this.#run = options.run ?? ((tool, args, env) => nativeTools.run(tool, args, options.signal, env));
127
127
  this.#packageApp = options.packageApp ?? (() => {
128
128
  const app = packagedAppPath();
129
129
  if (!app)
@@ -160,43 +160,51 @@ export class MenubarManager {
160
160
  return null;
161
161
  }
162
162
  }
163
- async #control(action, ...args) {
163
+ /**
164
+ * The app's --control for `configDir`: its pids are the app running for that config directory, wherever it runs from
165
+ * (menu bar 0.8.4+, one app per config directory), and any app running from the installed bundle.
166
+ */
167
+ async #control(configDir, action, ...args) {
164
168
  makeExecutable(this.app);
165
- return parseNativeStatus(await this.#run(join(this.app, 'Contents', 'MacOS', 'ShardfluxMenuBar'), ['--control', action, ...args]));
169
+ const env = configDir ? { SHARDFLUX_CONFIG_DIR: configDir } : undefined;
170
+ return parseNativeStatus(await this.#run(join(this.app, 'Contents', 'MacOS', 'ShardfluxMenuBar'), ['--control', action, ...args], env));
166
171
  }
167
- async status() {
172
+ async status(configDir) {
168
173
  this.#requireMac();
169
174
  if (!existsSync(this.app))
170
175
  return { installed: false, version: null, running: false, pids: [], login: 'off' };
171
176
  const version = await this.#version(this.app);
172
- return { installed: true, version, ...await this.#control('status') };
177
+ return { installed: true, version, ...await this.#control(configDir, 'status') };
173
178
  }
174
179
  async #stripQuarantine() {
175
180
  const attributes = await this.#run('/usr/bin/xattr', ['-lr', this.app]);
176
181
  if (attributes.includes('com.apple.quarantine'))
177
182
  await this.#run('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', this.app]);
178
183
  }
179
- async stop() {
180
- const current = await this.status();
184
+ async stop(configDir) {
185
+ const current = await this.status(configDir);
181
186
  if (!current.installed || !current.running)
182
187
  return current;
183
- await this.#control('stop');
188
+ await this.#control(configDir, 'stop');
184
189
  for (let i = 0; i < 20; i++) {
185
- const status = await this.status();
190
+ const status = await this.status(configDir);
186
191
  if (!status.running)
187
192
  return status;
188
193
  await new Promise(resolve => setTimeout(resolve, 100));
189
194
  }
190
195
  throw new UsageError('Shardflux is still stopping. Run shard menubar status before retrying.');
191
196
  }
192
- async login(value) {
193
- if (!(await this.status()).installed)
197
+ async login(value, configDir) {
198
+ if (!(await this.status(configDir)).installed)
194
199
  throw new UsageError('Run shard menubar start before changing launch at login.');
195
- await this.#control('login', value);
196
- return this.status();
200
+ await this.#control(configDir, 'login', value);
201
+ return this.status(configDir);
197
202
  }
198
- /** Replaces the installed app with `source` (verified first), stopping it while it is swapped. */
199
- async #install(source, current) {
203
+ /**
204
+ * Replaces the installed app with `source` (verified first), stopping it while it is swapped: the app running from
205
+ * the bundle and the one running for `configDir` from elsewhere, so no older app stays behind.
206
+ */
207
+ async #install(source, current, configDir) {
200
208
  // Verify the source before stopping or replacing any existing app.
201
209
  await this.#run('/usr/bin/codesign', ['--verify', '--deep', '--strict', source]);
202
210
  mkdirSync(dirname(this.app), { recursive: true });
@@ -206,7 +214,7 @@ export class MenubarManager {
206
214
  try {
207
215
  cpSync(source, staged, { recursive: true, dereference: false });
208
216
  if (current.running)
209
- await this.stop();
217
+ await this.stop(configDir);
210
218
  if (current.installed)
211
219
  renameSync(this.app, backup);
212
220
  makeExecutable(staged);
@@ -235,17 +243,19 @@ export class MenubarManager {
235
243
  async start(configDir, apiUrl) {
236
244
  this.#requireMac();
237
245
  const source = this.#packageApp(), sourceVersion = await this.#version(source);
238
- const current = await this.status();
246
+ const current = await this.status(configDir);
239
247
  if (!current.installed || compareAppVersions(current.version, sourceVersion) < 0)
240
- await this.#install(source, current);
248
+ await this.#install(source, current, configDir);
241
249
  await this.#stripQuarantine();
242
- if (!(await this.status()).running) {
250
+ if (!(await this.status(configDir)).running) {
243
251
  mkdirSync(configDir, { recursive: true, mode: 0o700 });
244
252
  const log = join(configDir, 'menubar.log');
245
- await this.#run('/usr/bin/open', ['-g', this.app, '--env', `SHARDFLUX_CONFIG_DIR=${configDir}`, '--env', `SHARDFLUX_API_URL=${apiUrl}`, '--stdout', log, '--stderr', log]);
253
+ // A start that races another one for this config directory hands over to it without opening the launcher.
254
+ await this.#run('/usr/bin/open', ['-g', this.app, '--env', `SHARDFLUX_CONFIG_DIR=${configDir}`, '--env', `SHARDFLUX_API_URL=${apiUrl}`,
255
+ '--env', 'SHARDFLUX_MENUBAR_LAUNCH=background', '--stdout', log, '--stderr', log]);
246
256
  }
247
257
  for (let i = 0; i < 20; i++) {
248
- const status = await this.status();
258
+ const status = await this.status(configDir);
249
259
  if (status.running)
250
260
  return status;
251
261
  await new Promise(resolve => setTimeout(resolve, 100));
@@ -260,7 +270,7 @@ export class MenubarManager {
260
270
  */
261
271
  async refresh(configDir, apiUrl) {
262
272
  this.#requireMac();
263
- const current = await this.status();
273
+ const current = await this.status(configDir);
264
274
  if (!current.installed || !current.version)
265
275
  return { result: 'not_installed', from: null, to: null };
266
276
  const to = await this.packagedVersion(), from = current.version;
@@ -274,7 +284,7 @@ export class MenubarManager {
274
284
  await this.start(configDir, apiUrl);
275
285
  return { result: 'restarted', from, to };
276
286
  }
277
- await this.#install(this.#packageApp(), current);
287
+ await this.#install(this.#packageApp(), current, configDir);
278
288
  await this.#stripQuarantine();
279
289
  return { result: 'installed', from, to };
280
290
  }
@@ -301,7 +311,7 @@ export async function ensureMenubar(dir, apiUrl, manager = new MenubarManager(),
301
311
  if (store.read().menubar_stopped === true)
302
312
  return undefined;
303
313
  try {
304
- const status = await manager.status();
314
+ const status = await manager.status(dir);
305
315
  // A running app is brought up to the one this CLI carries (after `npm i -g shardflux`) by the background refresh
306
316
  // every command starts (refreshMenubarInBackground), at a moment when it shows no window.
307
317
  if (status.running)
@@ -310,7 +320,7 @@ export async function ensureMenubar(dir, apiUrl, manager = new MenubarManager(),
310
320
  if (!started.running)
311
321
  return undefined;
312
322
  if (started.login !== 'on')
313
- await manager.login('on').catch(() => undefined);
323
+ await manager.login('on', dir).catch(() => undefined);
314
324
  return 'Menu bar started · shard menubar stop to turn it off';
315
325
  }
316
326
  catch {
@@ -468,7 +478,7 @@ export const MENUBAR_HANDLERS = Object.fromEntries(['start', 'stop', 'status'].m
468
478
  if (values.login !== undefined && !['on', 'off'].includes(String(values.login)))
469
479
  throw new UsageError('--login must be on or off.');
470
480
  const manager = new MenubarManager({ home: ctx.io.env.HOME || homedir(), signal: ctx.signal });
471
- let status = action === 'start' ? await manager.start(ctx.store.dir, ctx.apiUrl) : action === 'stop' ? await manager.stop() : await manager.status();
481
+ let status = action === 'start' ? await manager.start(ctx.store.dir, ctx.apiUrl) : action === 'stop' ? await manager.stop(ctx.store.dir) : await manager.status(ctx.store.dir);
472
482
  // An explicit stop keeps `shard` from starting the menu bar again; an explicit start re-enables it.
473
483
  if (action !== 'status')
474
484
  new AgentStateStore(ctx.store.dir).update(doc => { if (action === 'stop')
@@ -476,7 +486,7 @@ export const MENUBAR_HANDLERS = Object.fromEntries(['start', 'stop', 'status'].m
476
486
  else
477
487
  delete doc.menubar_stopped; });
478
488
  if (values.login === 'on' || values.login === 'off')
479
- status = await manager.login(values.login);
489
+ status = await manager.login(values.login, ctx.store.dir);
480
490
  ctx.io.stdout.write(ctx.json ? json(status) : `Menu bar: ${status.running ? 'running' : 'stopped'}${status.installed ? ` · app ${status.version}` : ' · not installed'} · login ${status.login}\n`);
481
491
  return 0;
482
492
  };
@@ -0,0 +1,144 @@
1
+ """Restore an owned migration into an EMPTY folder; never overwrite continued work.
2
+
3
+ The immutable archives retain original Git metadata. Only location pointers in the
4
+ restored copy change. Can be run again with a new empty root for recovery.
5
+ """
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from pathlib import Path, PurePosixPath
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tarfile
14
+
15
+ manifest = Path(sys.argv[1]).resolve()
16
+ plan = json.loads(manifest.read_text())
17
+ root = Path(sys.argv[2]).absolute()
18
+ if root.is_symlink() or (root.exists() and any(root.iterdir())):
19
+ raise ValueError("Restore requires a new empty folder; existing work was not replaced")
20
+ root.mkdir(parents=True, exist_ok=True)
21
+
22
+ def destination(target):
23
+ parts = PurePosixPath(target).parts
24
+ if target != "." and (target.startswith("/") or any(p in ("", "..") for p in parts)):
25
+ raise ValueError("Unsafe worktree destination")
26
+ return root if target == "." else root.joinpath(*parts)
27
+
28
+ targets = {t["source"]: destination(t["target"]) for t in plan["trees"]}
29
+ expected = plan["bytes"] + plan["files"] * 1024
30
+ if shutil.disk_usage(root).free < expected:
31
+ raise ValueError(f"Restore requires at least {expected} free bytes on the target filesystem")
32
+
33
+ def parents_safe(path, base):
34
+ parent = path.parent
35
+ while parent != base:
36
+ if parent.is_symlink():
37
+ raise ValueError("Archive parent is a symlink")
38
+ if parent == parent.parent:
39
+ raise ValueError("Archive escaped its root")
40
+ parent = parent.parent
41
+ if base.is_symlink():
42
+ raise ValueError("Archive root is a symlink")
43
+
44
+ for i, tree in enumerate(plan["trees"]):
45
+ base = targets[tree["source"]]
46
+ base.mkdir(parents=True, exist_ok=True)
47
+ directories = []
48
+ with tarfile.open(manifest.parent / f"{i}.tar", "r:") as archive:
49
+ for member in archive:
50
+ parts = PurePosixPath(member.name).parts
51
+ if member.name in (".", "./"):
52
+ continue
53
+ if member.name.startswith("/") or any(p in ("", "..") for p in parts):
54
+ raise ValueError("Unsafe archive member")
55
+ path = base.joinpath(*parts)
56
+ parents_safe(path, base)
57
+ if member.isdir():
58
+ if path.is_symlink():
59
+ raise ValueError("Archive directory is a symlink")
60
+ path.mkdir(parents=True, exist_ok=True)
61
+ directories.append((path, member.mode & 0o777, member.mtime))
62
+ elif member.issym():
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ path.symlink_to(member.linkname)
65
+ elif member.isreg() or member.islnk():
66
+ # Expand hard links: Git index/content is preserved, no link may escape.
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ with archive.extractfile(member) as src, path.open("xb") as out:
69
+ shutil.copyfileobj(src, out, 1024 * 1024)
70
+ path.chmod(member.mode & 0o777)
71
+ os.utime(path, (member.mtime, member.mtime))
72
+ else:
73
+ raise ValueError("Special archive member is not supported")
74
+ # Read-only directory modes must be applied after writing their contents.
75
+ for path, mode, mtime in reversed(directories):
76
+ path.chmod(mode)
77
+ os.utime(path, (mtime, mtime))
78
+ for entry in tree["files"]:
79
+ path = base / entry["path"]
80
+ if "link" in entry:
81
+ if not path.is_symlink() or os.readlink(path) != entry["link"]:
82
+ raise ValueError(f"Symlink verification failed: {path}")
83
+ digest = hashlib.sha256(("symlink\0" + entry["link"]).encode()).hexdigest()
84
+ else:
85
+ if path.is_symlink() or not path.is_file() or path.stat().st_size != entry["size"]:
86
+ raise ValueError(f"File size/type verification failed: {path}")
87
+ sha = hashlib.sha256()
88
+ with path.open("rb") as src:
89
+ for chunk in iter(lambda: src.read(1024 * 1024), b""):
90
+ sha.update(chunk)
91
+ digest = sha.hexdigest()
92
+ if path.stat().st_mode & 0o777 != entry["mode"]:
93
+ raise ValueError(f"File mode verification failed: {path}")
94
+ if digest != entry["sha256"]:
95
+ raise ValueError(f"File hash verification failed: {path}")
96
+
97
+ common = targets[plan["main"]] / ".git"
98
+ env = {**os.environ, "GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0"}
99
+ def git(tree, *args):
100
+ return subprocess.run(["git", "-C", str(tree), "-c", "core.hooksPath=/dev/null", *args],
101
+ env=env, check=True, stdout=subprocess.PIPE).stdout
102
+
103
+ # Repair forward/back pointers without running checkout/reset or refreshing an index.
104
+ for tree in plan["trees"]:
105
+ dest = targets[tree["source"]]
106
+ if tree["gitDir"] != plan["commonDir"]:
107
+ gitdir = common / Path(tree["gitDir"]).relative_to(plan["commonDir"])
108
+ (dest / ".git").write_text(f"gitdir: {gitdir}\n")
109
+ (gitdir / "gitdir").write_text(f"{dest / '.git'}\n")
110
+ (gitdir / "commondir").write_text(os.path.relpath(common, gitdir) + "\n")
111
+ worktree_config = gitdir / "config.worktree"
112
+ if worktree_config.exists():
113
+ subprocess.run(["git", "config", "--file", str(worktree_config), "core.worktree", str(dest)], check=True)
114
+ git(targets[plan["main"]], "config", "core.worktree", str(targets[plan["main"]]))
115
+ # External registered worktrees live in this private recovery area, outside the
116
+ # selected checkout's tracked content. Hide only that explicit location.
117
+ exclude = common / "info/exclude"
118
+ exclude.parent.mkdir(parents=True, exist_ok=True)
119
+ with exclude.open("a") as out:
120
+ out.write("\n/.shard/preserved-worktrees/\n")
121
+
122
+ selected = targets[plan["selected"]]
123
+ refs = git(selected, "for-each-ref", "--format=%(objectname) %(refname)").decode()
124
+ if refs != plan["refs"]:
125
+ raise ValueError("Local refs did not survive migration")
126
+ for tree in plan["trees"]:
127
+ dest = targets[tree["source"]]
128
+ if git(dest, "rev-parse", "HEAD").decode().strip() != tree["head"]:
129
+ raise ValueError(f"HEAD did not survive migration: {dest}")
130
+ status = git(dest, "status", "--porcelain=v1", "-z", "--untracked-files=all").decode()
131
+ if status != tree["status"]:
132
+ raise ValueError(f"Git status changed during migration: {dest}")
133
+
134
+ # The platform tracks this conversation by its own branch; the original branch
135
+ # and raw HEAD remain in refs/archives. Index and working bytes stay untouched.
136
+ branch = plan["branch"]
137
+ git(selected, "check-ref-format", branch)
138
+ if any(line.endswith(" " + branch) for line in refs.splitlines()):
139
+ raise ValueError("Conversation branch collides with a preserved local ref")
140
+ if "--defer-conversation-branch" not in sys.argv[3:]:
141
+ git(selected, "update-ref", branch, git(selected, "rev-parse", "HEAD").decode().strip())
142
+ git(selected, "symbolic-ref", "HEAD", branch)
143
+ print(json.dumps({"worktrees": len(plan["trees"]), "files": plan["files"], "bytes": plan["bytes"],
144
+ "root": str(root), "refs": len(refs.splitlines()), "status": "matched"}))
@@ -0,0 +1,69 @@
1
+ import type { SyncCell } from './project-sync-transfer.js';
2
+ export interface MigrationFile {
3
+ path: string;
4
+ size: number;
5
+ mode: number;
6
+ sha256: string;
7
+ link?: string;
8
+ }
9
+ export interface MigrationTree {
10
+ source: string;
11
+ spellings: string[];
12
+ target: string;
13
+ head: string;
14
+ branch: string;
15
+ gitDir: string;
16
+ status: string;
17
+ files: MigrationFile[];
18
+ exclude: string[];
19
+ }
20
+ export interface WorktreeMigration {
21
+ version: 1;
22
+ selected: string;
23
+ commonDir: string;
24
+ main: string;
25
+ refs: string;
26
+ trees: MigrationTree[];
27
+ bytes: number;
28
+ files: number;
29
+ referenced: string[];
30
+ /** Registrations Git reports as prunable (their folder or its .git is gone): nothing to copy, never a refusal. */
31
+ stale: string[];
32
+ }
33
+ /**
34
+ * Other registered worktrees of this repository that the session's transcript works in. A normal move leaves them on
35
+ * this computer; the CLI says so. Best effort: an empty list when Git cannot answer.
36
+ */
37
+ export declare function referencedWorktrees(folder: string, transcript: Buffer, signal?: AbortSignal): Promise<string[]>;
38
+ /** Git is the source of membership; sibling folders and symlink targets never expand the upload scope. */
39
+ export declare function inspectWorktreeMigration(folder: string, transcript: Buffer, signal?: AbortSignal): Promise<WorktreeMigration | null>;
40
+ /** Size/hash pass only after preserve-all is selected (ignored caches may be large). */
41
+ export declare function captureWorktreeMigration(plan: WorktreeMigration, signal?: AbortSignal): Promise<void>;
42
+ export declare function migrationScope(plan: WorktreeMigration): {
43
+ local_refs: string[];
44
+ bytes: number;
45
+ files: number;
46
+ scope: string;
47
+ size_basis: string;
48
+ stale_registrations?: string[] | undefined;
49
+ worktrees: {
50
+ path: string;
51
+ branch: string;
52
+ target: string;
53
+ }[];
54
+ referenced: string[];
55
+ };
56
+ /** Longest paths first, before replacing a parent. Message text and signed thinking keep the existing rewrite contract. */
57
+ export declare function rewriteWorktreeTranscript(bytes: Buffer, plan: WorktreeMigration, root: string): Buffer;
58
+ export interface MigrationResult {
59
+ snapshot: string;
60
+ bytes: number;
61
+ worktrees: number;
62
+ files: number;
63
+ }
64
+ /** Called instead of regular seeding: the guest starts only after its refs, index, bytes and statuses are checked. */
65
+ export declare function preserveWorktreeMigration(cell: SyncCell, plan: WorktreeMigration, c: {
66
+ id: string;
67
+ branch: string;
68
+ worktree_path: string;
69
+ }, signal?: AbortSignal): Promise<MigrationResult>;
@@ -0,0 +1,252 @@
1
+ /** An explicit, recoverable cold copy of a repository and its registered worktrees. Never writes to local Git. */
2
+ import { createHash } from 'node:crypto';
3
+ import { spawn } from 'node:child_process';
4
+ import { createReadStream } from 'node:fs';
5
+ import { lstat, readFile, readdir, readlink, realpath } from 'node:fs/promises';
6
+ import { basename, dirname, join, relative } from 'node:path';
7
+ import { SYNC_CHUNK_BYTES } from "./project-sync-transfer.js";
8
+ import { projectGit } from "./project-sync-manifest.js";
9
+ import { UsageError } from "./errors.js";
10
+ import { rewriteSessionPaths } from "./local-sessions.js";
11
+ const inside = (root, path) => path === root || path.startsWith(`${root}/`);
12
+ /** `git worktree list --porcelain -z`: each registration's path and whether Git calls it prunable. */
13
+ async function registeredWorktrees(folder, signal) {
14
+ const out = [];
15
+ for (const field of (await git(folder, ['worktree', 'list', '--porcelain', '-z'], signal)).split('\0')) {
16
+ if (field.startsWith('worktree '))
17
+ out.push({ path: field.slice(9), prunable: false });
18
+ else if ((field === 'prunable' || field.startsWith('prunable ')) && out.length)
19
+ out.at(-1).prunable = true;
20
+ }
21
+ return out;
22
+ }
23
+ /**
24
+ * Whether the transcript names `path` itself (or a file below it), not just a longer registered worktree path that
25
+ * starts with it (the main checkout is a prefix of every worktree nested inside it). JSON-escaped spellings count.
26
+ */
27
+ function names(text, path, registered) {
28
+ const longer = registered.filter(p => p.length > path.length && p.startsWith(path));
29
+ for (const spelled of new Set([path, JSON.stringify(path).slice(1, -1)])) {
30
+ for (let at = text.indexOf(spelled); at >= 0; at = text.indexOf(spelled, at + 1)) {
31
+ const next = text[at + spelled.length];
32
+ if (next !== undefined && /[\w.-]/.test(next))
33
+ continue; // a sibling folder: /repo-old for /repo
34
+ if (longer.some(p => text.startsWith(p, at) || text.startsWith(JSON.stringify(p).slice(1, -1), at)))
35
+ continue;
36
+ return true;
37
+ }
38
+ }
39
+ return false;
40
+ }
41
+ /**
42
+ * Other registered worktrees of this repository that the session's transcript works in. A normal move leaves them on
43
+ * this computer; the CLI says so. Best effort: an empty list when Git cannot answer.
44
+ */
45
+ export async function referencedWorktrees(folder, transcript, signal) {
46
+ try {
47
+ const selected = await realpath(folder), text = transcript.toString('utf8');
48
+ const live = (await registeredWorktrees(folder, signal)).filter(w => !w.prunable).map(w => w.path);
49
+ const all = [...new Set([...live, selected])];
50
+ const others = [];
51
+ for (const path of live) {
52
+ const real = await realpath(path).catch(() => '');
53
+ if (!real || real === selected)
54
+ continue;
55
+ if (names(text, path, all) || (real !== path && names(text, real, all)))
56
+ others.push(real);
57
+ }
58
+ return others;
59
+ }
60
+ catch (e) {
61
+ if (signal?.aborted)
62
+ throw e;
63
+ return [];
64
+ }
65
+ }
66
+ const git = (folder, args, signal) => projectGit(folder, args, signal);
67
+ const hash = async (file, signal) => {
68
+ const sha = createHash('sha256');
69
+ for await (const chunk of createReadStream(file, { signal }))
70
+ sha.update(chunk);
71
+ return sha.digest('hex');
72
+ };
73
+ /** Git is the source of membership; sibling folders and symlink targets never expand the upload scope. */
74
+ export async function inspectWorktreeMigration(folder, transcript, signal) {
75
+ let commonDir;
76
+ try {
77
+ commonDir = (await git(folder, ['rev-parse', '--path-format=absolute', '--git-common-dir'], signal)).trim();
78
+ }
79
+ catch (e) {
80
+ if (signal?.aborted || e.code !== 128)
81
+ throw e;
82
+ return null;
83
+ }
84
+ folder = await realpath(folder);
85
+ commonDir = await realpath(commonDir);
86
+ try {
87
+ await git(folder, ['rev-parse', '--verify', 'HEAD'], signal);
88
+ }
89
+ catch (e) {
90
+ if (signal?.aborted || e.code !== 128)
91
+ throw e;
92
+ return null;
93
+ }
94
+ const main = dirname(commonDir);
95
+ if (basename(commonDir) !== '.git' || !(await lstat(commonDir)).isDirectory())
96
+ throw new UsageError('Preserve-all needs a repository with its own .git directory; separate Git directories are not supported.');
97
+ if ((await git(main, ['rev-parse', '--show-toplevel'], signal)).trim() !== main)
98
+ throw new UsageError('The main checkout is unavailable; restore it before moving all worktrees.');
99
+ // External alternate stores cannot be represented by this bounded copy.
100
+ const alternates = join(commonDir, 'objects/info/alternates');
101
+ if (await readFile(alternates, 'utf8').then(s => !!s.trim(), (e) => { if (e.code !== 'ENOENT')
102
+ throw e; return false; }))
103
+ throw new UsageError('This repository borrows an external Git object store. Make it self-contained before preserving all worktrees.');
104
+ // A registration whose folder (or its .git) is gone has nothing to copy: Git itself calls it prunable. It is
105
+ // reported, never a reason to refuse; its branch is a local ref and moves with the others.
106
+ const registered = [], sources = [], stale = [];
107
+ for (const w of await registeredWorktrees(folder, signal)) {
108
+ const real = w.prunable ? '' : await realpath(w.path).catch(() => '');
109
+ if (!real) {
110
+ stale.push(w.path);
111
+ continue;
112
+ }
113
+ registered.push(w.path);
114
+ sources.push(real);
115
+ }
116
+ if (!sources.includes(folder) || !sources.includes(main))
117
+ throw new UsageError('Git did not list the selected and main worktrees.');
118
+ const trees = [];
119
+ for (const [i, source] of sources.entries()) {
120
+ // Worktrees inside the selected folder keep their relative paths, including ignored .worktrees.
121
+ const target = source === folder ? '.' : inside(folder, source) ? relative(folder, source) : `.shard/preserved-worktrees/${i}-${basename(source)}`;
122
+ const gitDir = (await git(source, ['rev-parse', '--absolute-git-dir'], signal)).trim();
123
+ if (!inside(commonDir, gitDir))
124
+ throw new UsageError(`Worktree ${source} uses an unrelated Git store.`);
125
+ const branch = await git(source, ['symbolic-ref', '-q', 'HEAD'], signal).then(s => s.trim(), (e) => { if (e.code !== 1)
126
+ throw e; return ''; });
127
+ const spellings = [...new Set([source, registered[i]])];
128
+ trees.push({ source, spellings, target, gitDir, branch, head: (await git(source, ['rev-parse', 'HEAD'], signal)).trim(),
129
+ status: await git(source, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], signal), files: [],
130
+ exclude: sources.filter(p => p !== source && inside(source, p)).map(p => relative(source, p)) });
131
+ }
132
+ const text = transcript.toString('utf8'), all = trees.flatMap(t => t.spellings);
133
+ const referenced = trees.filter(t => t.source !== folder && t.spellings.some(p => names(text, p, all))).map(t => t.source);
134
+ return { version: 1, selected: folder, commonDir, main, trees, referenced, stale,
135
+ refs: await git(folder, ['for-each-ref', '--format=%(objectname) %(refname)'], signal), bytes: 0, files: 0 };
136
+ }
137
+ /** Size/hash pass only after preserve-all is selected (ignored caches may be large). */
138
+ export async function captureWorktreeMigration(plan, signal) {
139
+ plan.bytes = 0;
140
+ plan.files = 0;
141
+ for (const tree of plan.trees) {
142
+ tree.files = [];
143
+ const walk = async (path) => {
144
+ signal?.throwIfAborted();
145
+ if (tree.exclude.some(p => inside(p, path)))
146
+ return;
147
+ if (path.endsWith('/.git'))
148
+ throw new UsageError(`Nested repository ${join(tree.source, path)} is not a registered worktree. Migrate it separately before preserving all worktrees.`);
149
+ const file = join(tree.source, path), st = await lstat(file);
150
+ if (st.isDirectory()) {
151
+ for (const name of (await readdir(file)).sort())
152
+ await walk(path ? `${path}/${name}` : name);
153
+ return;
154
+ }
155
+ if (!st.isFile() && !st.isSymbolicLink())
156
+ throw new UsageError(`Cannot preserve special file ${file}. Remove it from this migration scope before retrying.`);
157
+ if (path.endsWith('.lock') && inside('.git', path))
158
+ throw new UsageError(`Git is writing ${file}; finish the Git operation before migrating.`);
159
+ const link = st.isSymbolicLink() ? await readlink(file) : undefined;
160
+ const sha256 = link === undefined ? await hash(file, signal) : createHash('sha256').update(`symlink\0${link}`).digest('hex');
161
+ tree.files.push({ path, size: st.size, mode: st.mode & 0o777, sha256, ...(link !== undefined ? { link } : {}) });
162
+ plan.bytes += st.size;
163
+ plan.files++;
164
+ };
165
+ await walk('');
166
+ }
167
+ }
168
+ export function migrationScope(plan) {
169
+ return { worktrees: plan.trees.map(t => ({ path: t.source, branch: t.branch || '(detached)', target: t.target })), referenced: plan.referenced,
170
+ ...(plan.stale.length ? { stale_registrations: plan.stale } : {}),
171
+ local_refs: plan.refs.trim().split('\n').filter(Boolean).map(line => line.slice(line.indexOf(' ') + 1)),
172
+ bytes: plan.bytes, files: plan.files, scope: 'All files, including ignored files and Git metadata, inside these registered worktrees. Symlink targets and unrelated sibling folders are not uploaded.',
173
+ size_basis: 'File bytes before tar headers; archive bytes and free-space requirement are larger. Ignored dependency/build caches are included.' };
174
+ }
175
+ /** Longest paths first, before replacing a parent. Message text and signed thinking keep the existing rewrite contract. */
176
+ export function rewriteWorktreeTranscript(bytes, plan, root) {
177
+ const mappings = plan.trees.flatMap(tree => tree.spellings.map(from => ({ from, to: tree.target === '.' ? root : `${root}/${tree.target}` })));
178
+ for (const { from, to } of mappings.sort((a, b) => b.from.length - a.from.length))
179
+ bytes = rewriteSessionPaths(bytes, from, to);
180
+ return bytes;
181
+ }
182
+ async function uploadTree(cell, tree, dest, signal) {
183
+ const child = spawn('tar', ['-cf', '-', ...tree.exclude.map(p => `--exclude=./${p}`), '-C', tree.source, '.'], { stdio: ['ignore', 'pipe', 'pipe'], signal, env: { ...process.env, COPYFILE_DISABLE: '1' } });
184
+ let stderr = '';
185
+ child.stderr.on('data', (b) => { stderr = (stderr + b.toString()).slice(-2000); });
186
+ const exited = new Promise((resolve, reject) => { child.on('error', reject); child.on('close', code => resolve(code ?? 1)); });
187
+ // Attach a rejection handler while reading the stream.
188
+ void exited.catch(() => undefined);
189
+ let total = 0, size = 0, pending = [];
190
+ const flush = async () => { if (!size)
191
+ return; const bytes = Buffer.concat(pending, size); await cell.files.write(dest, bytes, { mode: '0600', append: total > 0 }); total += size; size = 0; pending = []; };
192
+ try {
193
+ for await (const chunk of child.stdout) {
194
+ let b = chunk;
195
+ while (b.length) {
196
+ const n = Math.min(b.length, SYNC_CHUNK_BYTES - size);
197
+ pending.push(b.subarray(0, n));
198
+ size += n;
199
+ b = b.subarray(n);
200
+ if (size === SYNC_CHUNK_BYTES)
201
+ await flush();
202
+ }
203
+ }
204
+ await flush();
205
+ if (await exited !== 0)
206
+ throw new Error(`Worktree archive failed: ${stderr}`);
207
+ return total;
208
+ }
209
+ catch (e) {
210
+ child.kill();
211
+ await exited.catch(() => undefined);
212
+ throw e;
213
+ }
214
+ }
215
+ /** Called instead of regular seeding: the guest starts only after its refs, index, bytes and statuses are checked. */
216
+ export async function preserveWorktreeMigration(cell, plan, c, signal) {
217
+ if (!/^conv_[a-z0-9]{26}$/.test(c.id))
218
+ throw new Error('Invalid migration identity');
219
+ const snapshot = `/shardflux/project/migrations/${c.id}`;
220
+ const mounted = await cell.exec.run(['test', '-d', '/shardflux/project/git/hub.git'], { timeoutMs: 10_000, signal });
221
+ if (mounted.exitCode !== 0)
222
+ throw new Error('The durable project space is unavailable; preserve-all did not upload or resume the session.');
223
+ await cell.files.mkdir(snapshot, { parents: true });
224
+ const space = await cell.exec.run(['python3', '-c', 'import os,shutil,sys; a,b,n=sys.argv[1:]; os.makedirs(b,exist_ok=True); need=int(n); assert shutil.disk_usage(a).free>=need and shutil.disk_usage(b).free>=need, "Migration needs more free disk space for archives and restored files"', snapshot, c.worktree_path,
225
+ String(2 * (plan.bytes + plan.files * 1024))], { timeoutMs: 10_000, signal });
226
+ if (space.exitCode !== 0)
227
+ throw new UsageError(space.stderr.trim());
228
+ let bytes = 0;
229
+ for (const [i, tree] of plan.trees.entries())
230
+ bytes += await uploadTree(cell, tree, `${snapshot}/${i}.tar`, signal);
231
+ const script = await readFile(new URL('./move-worktrees-restore.py', import.meta.url), 'utf8');
232
+ await cell.files.write(`${snapshot}/restore.py`, script, { mode: '0600' });
233
+ const branch = c.branch.startsWith('refs/heads/') ? c.branch : `refs/heads/${c.branch}`;
234
+ await cell.files.write(`${snapshot}/manifest.json`, JSON.stringify({ ...plan, branch, root: c.worktree_path }), { mode: '0600' });
235
+ const applied = await cell.exec.run(['python3', `${snapshot}/restore.py`, `${snapshot}/manifest.json`, c.worktree_path, '--defer-conversation-branch'], { timeoutMs: 600_000, maxOutputBytes: 8192, signal });
236
+ if (applied.exitCode !== 0)
237
+ throw new Error(`Preserve-all verification failed; the local source is unchanged: ${applied.stderr.trim()}`);
238
+ // No move is recorded or resumed if another local process changed a tree during upload.
239
+ const source = (await inspectWorktreeMigration(plan.selected, Buffer.alloc(0), signal));
240
+ await captureWorktreeMigration(source, signal);
241
+ if (source.refs !== plan.refs || JSON.stringify(source.trees.map(t => [t.source, t.head, t.branch, t.status, t.files])) !== JSON.stringify(plan.trees.map(t => [t.source, t.head, t.branch, t.status, t.files]))) {
242
+ throw new UsageError('A local worktree changed during migration; no session was resumed. Finish its work and retry. The captured recovery archive is retained.');
243
+ }
244
+ await cell.files.write(`${snapshot}/ready.json`, JSON.stringify({ bytes, worktrees: plan.trees.length, files: plan.files }), { mode: '0600' });
245
+ // MovedInReady in the guest observes this symbolic branch. It is the last
246
+ // readiness transition, after all copies and both local/remote verification.
247
+ const ready = await cell.exec.run(['sh', '-c', 'set -e; git -C "$1" -c core.hooksPath=/dev/null update-ref "$2" HEAD; git -C "$1" symbolic-ref HEAD "$2"',
248
+ 'shard-migration-ready', c.worktree_path, branch], { timeoutMs: 10_000, signal });
249
+ if (ready.exitCode !== 0)
250
+ throw new Error(`Migration branch could not be activated: ${ready.stderr.trim()}`);
251
+ return { snapshot, bytes, worktrees: plan.trees.length, files: plan.files };
252
+ }