@shardflux/cli 0.18.1 → 0.19.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 (51) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/README.md +27 -0
  3. package/dist/account-args.js +1 -0
  4. package/dist/agent-commands.d.ts +1 -0
  5. package/dist/agent-commands.js +8 -3
  6. package/dist/agent-config-files.d.ts +16 -2
  7. package/dist/agent-config-files.js +48 -61
  8. package/dist/agent-config-sync.d.ts +12 -1
  9. package/dist/agent-config-sync.js +228 -54
  10. package/dist/agent-repository.d.ts +39 -9
  11. package/dist/agent-repository.js +309 -92
  12. package/dist/agent-state.d.ts +3 -0
  13. package/dist/agent-state.js +13 -0
  14. package/dist/agent-target-commands.d.ts +2 -0
  15. package/dist/agent-target-commands.js +45 -15
  16. package/dist/args.d.ts +1 -1
  17. package/dist/args.js +14 -2
  18. package/dist/conversation-router.js +7 -11
  19. package/dist/conversation-stash.js +64 -18
  20. package/dist/conversation-terminal.d.ts +1 -1
  21. package/dist/conversation-terminal.js +9 -10
  22. package/dist/conversations.d.ts +9 -2
  23. package/dist/conversations.js +23 -6
  24. package/dist/errors.d.ts +1 -1
  25. package/dist/errors.js +1 -1
  26. package/dist/files-mirror.d.ts +1 -9
  27. package/dist/files-mirror.js +19 -3
  28. package/dist/format.js +37 -2
  29. package/dist/handlers.js +3 -1
  30. package/dist/local-sessions.d.ts +60 -0
  31. package/dist/local-sessions.js +445 -0
  32. package/dist/main.js +12 -0
  33. package/dist/move.d.ts +44 -0
  34. package/dist/move.js +248 -0
  35. package/dist/moved-sessions.d.ts +33 -0
  36. package/dist/moved-sessions.js +91 -0
  37. package/dist/onboarding.d.ts +18 -3
  38. package/dist/onboarding.js +72 -46
  39. package/dist/project-sync-manifest.d.ts +31 -3
  40. package/dist/project-sync-manifest.js +192 -108
  41. package/dist/project-sync-transfer.d.ts +50 -4
  42. package/dist/project-sync-transfer.js +404 -106
  43. package/dist/project-sync.js +30 -33
  44. package/dist/seed-phases.d.ts +29 -0
  45. package/dist/seed-phases.js +83 -0
  46. package/dist/t3-mode.d.ts +7 -2
  47. package/dist/t3-mode.js +39 -5
  48. package/dist/thread-on-computer.js +4 -0
  49. package/dist/worktree-twin.d.ts +2 -6
  50. package/dist/worktree-twin.js +16 -51
  51. package/package.json +5 -5
@@ -1,6 +1,4 @@
1
1
  import type { FileManifest, FileManifestEntry } from '@shardflux/sdk';
2
- export declare const MAX_SYNC_FILE_BYTES: number;
3
- export declare const MAX_SYNC_ENTRIES = 20000;
4
2
  export type Manifest = Record<string, FileManifestEntry>;
5
3
  export type Common = Record<string, string>;
6
4
  export interface SyncChange {
@@ -13,9 +11,25 @@ export declare function excludedSyncPath(path: string): boolean;
13
11
  export declare function contentIdentity(entry?: FileManifestEntry): string;
14
12
  /** Deletions have an observation timestamp: a detached filesystem has no deletion clock. Ties favor the laptop. */
15
13
  export declare function diffManifests(local: Manifest, remote: Manifest, common: Common, observedAt?: number, blocked?: Set<string>): SyncChange[];
14
+ /**
15
+ * Validates a manifest's wire shape. Any number of entries and any file size are accepted; an entry whose path this
16
+ * client cannot represent is moved to `manifest.skipped` (reason `unsupported_path`) instead of failing the pass.
17
+ */
16
18
  export declare function parseManifest(manifest: FileManifest): Manifest;
17
19
  export declare function projectGit(folder: string, args: string[], signal?: AbortSignal, input?: string): Promise<string>;
18
- /** Uses the actual Git implementation rather than an approximation of gitignore. */
20
+ /**
21
+ * Streams Git's NUL-separated path output into `each`: no output buffer and no time bound, so a tree of any size is
22
+ * listed in full (the signal still cancels it). Exit codes in `ok` count as success (check-ignore exits 1 for none).
23
+ */
24
+ export declare function projectGitPaths(folder: string, args: string[], each: (path: string) => void, options?: {
25
+ signal?: AbortSignal;
26
+ input?: string;
27
+ ok?: number[];
28
+ }): Promise<void>;
29
+ /**
30
+ * Uses the actual Git implementation rather than an approximation of gitignore. Any number of paths; a path this
31
+ * client cannot represent stays in the list and is reported as skipped by `localEntry`.
32
+ */
19
33
  export declare function selectedProjectPaths(folder: string, signal?: AbortSignal): Promise<string[]>;
20
34
  export declare function ignoredProjectPaths(folder: string, paths: string[], signal?: AbortSignal): Promise<string[]>;
21
35
  /** Refuse parent symlinks, including a tracked file beneath a submodule/link. */
@@ -24,4 +38,18 @@ export type LocalHashCache = Map<string, {
24
38
  metadata: string;
25
39
  entry: FileManifestEntry;
26
40
  }>;
41
+ export type LocalEntry = FileManifestEntry | {
42
+ path: string;
43
+ reason: string;
44
+ } | undefined;
45
+ /**
46
+ * One project file's manifest entry: undefined when it does not exist (ENOENT), `{ path, reason }` when it is skipped
47
+ * (`unsupported_type`: a socket, device or FIFO; `unsupported_path`: a name the sync cannot carry, such as one with a
48
+ * backslash; `nested_repository`: a submodule or nested Git repository). Any size is hashed.
49
+ */
50
+ export declare function localEntry(folder: string, path: string, signal?: AbortSignal, cache?: LocalHashCache): Promise<FileManifestEntry | {
51
+ path: string;
52
+ reason: string;
53
+ } | undefined>;
54
+ /** The cache holds one entry per project path: entries for paths that left the project are dropped after each pass. */
27
55
  export declare function localManifest(folder: string, signal?: AbortSignal, cache?: LocalHashCache, known?: string[]): Promise<FileManifest>;
@@ -1,11 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { execFile } from 'node:child_process';
2
+ import { execFile, spawn } from 'node:child_process';
3
3
  import { constants } from 'node:fs';
4
4
  import { lstat, open, readdir, readlink, realpath } from 'node:fs/promises';
5
5
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
6
6
  import { GitError } from "./errors.js";
7
- export const MAX_SYNC_FILE_BYTES = 64 * 1024 * 1024;
8
- export const MAX_SYNC_ENTRIES = 20_000;
9
7
  export function validSyncPath(path) {
10
8
  return path.length > 0 && Buffer.byteLength(path) <= 4096 && !path.includes('\0') && !path.includes('\\') && !path.startsWith('/') && !path.split('/').some((part) => !part || part === '.' || part === '..' || part === '.git' || part === '.shard');
11
9
  }
@@ -31,23 +29,33 @@ export function diffManifests(local, remote, common, observedAt = Date.now(), bl
31
29
  }
32
30
  return changes;
33
31
  }
32
+ /**
33
+ * Validates a manifest's wire shape. Any number of entries and any file size are accepted; an entry whose path this
34
+ * client cannot represent is moved to `manifest.skipped` (reason `unsupported_path`) instead of failing the pass.
35
+ */
34
36
  export function parseManifest(manifest) {
35
- if (!manifest || !Array.isArray(manifest.entries) || !Array.isArray(manifest.skipped) || manifest.entries.length + manifest.skipped.length > MAX_SYNC_ENTRIES)
37
+ if (!manifest || !Array.isArray(manifest.entries) || !Array.isArray(manifest.skipped))
36
38
  throw new Error('invalid project manifest');
37
39
  const entries = Object.create(null);
38
40
  for (const entry of manifest.entries) {
39
- if (!validSyncPath(entry.path) || !/^[a-f0-9]{64}$/.test(entry.sha256) || !Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > MAX_SYNC_FILE_BYTES || !Number.isInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777 || !Number.isFinite(entry.mtime_unix_ms) || typeof entry.symlink !== 'boolean' || typeof entry.link_target !== 'string' || entries[entry.path])
40
- throw new Error(`invalid project manifest entry: ${entry.path}`);
41
+ if (typeof entry?.path !== 'string' || !/^[a-f0-9]{64}$/.test(entry.sha256) || !Number.isSafeInteger(entry.size) || entry.size < 0 || !Number.isInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777 || !Number.isFinite(entry.mtime_unix_ms) || typeof entry.symlink !== 'boolean' || typeof entry.link_target !== 'string' || entries[entry.path])
42
+ throw new Error(`invalid project manifest entry: ${String(entry?.path)}`);
43
+ if (!validSyncPath(entry.path)) {
44
+ manifest.skipped.push({ path: entry.path, reason: 'unsupported_path' });
45
+ continue;
46
+ }
41
47
  entries[entry.path] = entry;
42
48
  }
43
49
  for (const skip of manifest.skipped)
44
- if (!validSyncPath(skip.path) || typeof skip.reason !== 'string' || !skip.reason)
50
+ if (typeof skip?.path !== 'string' || !skip.path || typeof skip.reason !== 'string' || !skip.reason)
45
51
  throw new Error('invalid skipped project path');
46
52
  return entries;
47
53
  }
54
+ const GIT_CONFIG = ['-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-c', 'core.hooksPath=/dev/null'];
55
+ const gitEnv = () => ({ ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' });
48
56
  export async function projectGit(folder, args, signal, input = '') {
49
57
  return new Promise((resolve, reject) => {
50
- const child = execFile('git', ['-C', folder, '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-c', 'core.hooksPath=/dev/null', ...args], { encoding: 'utf8', timeout: 30_000, maxBuffer: 4 << 20, signal, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' } }, (error, stdout, stderr) => {
58
+ const child = execFile('git', ['-C', folder, ...GIT_CONFIG, ...args], { encoding: 'utf8', timeout: 30_000, maxBuffer: 4 << 20, signal, env: gitEnv() }, (error, stdout, stderr) => {
51
59
  if (!error) {
52
60
  resolve(stdout);
53
61
  return;
@@ -65,7 +73,64 @@ export async function projectGit(folder, args, signal, input = '') {
65
73
  child.stdin?.end(input);
66
74
  });
67
75
  }
68
- /** Uses the actual Git implementation rather than an approximation of gitignore. */
76
+ /**
77
+ * Streams Git's NUL-separated path output into `each`: no output buffer and no time bound, so a tree of any size is
78
+ * listed in full (the signal still cancels it). Exit codes in `ok` count as success (check-ignore exits 1 for none).
79
+ */
80
+ export async function projectGitPaths(folder, args, each, options = {}) {
81
+ const { signal, input } = options;
82
+ await new Promise((resolve, reject) => {
83
+ const child = spawn('git', ['-C', folder, ...GIT_CONFIG, ...args], { stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], signal, env: gitEnv() });
84
+ let pending, stderr = '', failed;
85
+ const { stdout, stderr: errors } = child;
86
+ if (!stdout || !errors)
87
+ throw new Error('git started without output pipes');
88
+ stdout.on('data', (chunk) => {
89
+ let at = 0;
90
+ for (let nul = chunk.indexOf(0); nul >= 0; nul = chunk.indexOf(0, at)) {
91
+ const part = pending ? Buffer.concat([pending, chunk.subarray(at, nul)]) : chunk.subarray(at, nul);
92
+ pending = undefined;
93
+ at = nul + 1;
94
+ if (part.length)
95
+ try {
96
+ each(part.toString('utf8'));
97
+ }
98
+ catch (error) {
99
+ failed ??= error instanceof Error ? error : new Error(String(error));
100
+ child.kill();
101
+ }
102
+ }
103
+ if (at < chunk.length)
104
+ pending = pending ? Buffer.concat([pending, chunk.subarray(at)]) : Buffer.from(chunk.subarray(at));
105
+ });
106
+ errors.on('data', (chunk) => { if (stderr.length < 64 << 10)
107
+ stderr += chunk.toString('utf8'); });
108
+ child.stdin?.on('error', () => undefined);
109
+ child.stdin?.end(input);
110
+ child.once('error', (error) => { reject(signal?.aborted ? error : new GitError(folder, Object.assign(error, { stderr }))); });
111
+ child.once('close', (code) => {
112
+ if (failed) {
113
+ reject(failed);
114
+ return;
115
+ }
116
+ if (signal?.aborted) {
117
+ reject(signal.reason);
118
+ return;
119
+ }
120
+ if ((options.ok ?? [0]).includes(code ?? -1)) {
121
+ if (pending?.length)
122
+ each(pending.toString('utf8'));
123
+ resolve();
124
+ return;
125
+ }
126
+ reject(new GitError(folder, Object.assign(new Error(`git ${args[0] ?? ''} exited with ${code}`), { code: code ?? undefined, stderr })));
127
+ });
128
+ });
129
+ }
130
+ /**
131
+ * Uses the actual Git implementation rather than an approximation of gitignore. Any number of paths; a path this
132
+ * client cannot represent stays in the list and is reported as skipped by `localEntry`.
133
+ */
69
134
  export async function selectedProjectPaths(folder, signal) {
70
135
  let git = false;
71
136
  try {
@@ -77,13 +142,12 @@ export async function selectedProjectPaths(folder, signal) {
77
142
  }
78
143
  const paths = new Set();
79
144
  if (git) {
80
- for (const path of (await projectGit(folder, ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '.'], signal)).split('\0'))
81
- if (path && !excludedSyncPath(path))
82
- paths.add(path);
83
- for (const path of (await projectGit(folder, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--', '.env*', '**/.env*'], signal)).split('\0')) {
84
- if (path && basename(path).startsWith('.env') && !excludedSyncPath(path))
145
+ await projectGitPaths(folder, ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '.'], (path) => { if (!excludedSyncPath(path))
146
+ paths.add(path); }, { signal });
147
+ await projectGitPaths(folder, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--', '.env*', '**/.env*'], (path) => {
148
+ if (basename(path).startsWith('.env') && !excludedSyncPath(path))
85
149
  paths.add(path);
86
- }
150
+ }, { signal });
87
151
  }
88
152
  else {
89
153
  const walk = async (dir) => {
@@ -96,17 +160,10 @@ export async function selectedProjectPaths(folder, signal) {
96
160
  await walk(path);
97
161
  else
98
162
  paths.add(path);
99
- if (paths.size > MAX_SYNC_ENTRIES)
100
- throw new Error(`project exceeds ${MAX_SYNC_ENTRIES} sync entries`);
101
163
  }
102
164
  };
103
165
  await walk('');
104
166
  }
105
- if (paths.size > MAX_SYNC_ENTRIES)
106
- throw new Error(`project exceeds ${MAX_SYNC_ENTRIES} sync entries`);
107
- for (const path of paths)
108
- if (!validSyncPath(path))
109
- throw new Error(`unsupported project path: ${JSON.stringify(path)}`);
110
167
  return [...paths].sort();
111
168
  }
112
169
  export async function ignoredProjectPaths(folder, paths, signal) {
@@ -122,18 +179,9 @@ export async function ignoredProjectPaths(folder, paths, signal) {
122
179
  return [];
123
180
  }
124
181
  const ignored = [];
125
- for (let at = 0; at < paths.length; at += 32) {
126
- let output;
127
- try {
128
- output = await projectGit(folder, ['check-ignore', '-z', '--stdin'], signal, paths.slice(at, at + 32).join('\0') + '\0');
129
- }
130
- catch (error) {
131
- const failure = error;
132
- if (failure.code !== 1)
133
- throw error;
134
- output = failure.stdout ?? '';
135
- }
136
- ignored.push(...output.split('\0').filter(Boolean));
182
+ // Streamed both ways, so the batch only bounds one stdin string; any number of paths is checked.
183
+ for (let at = 0; at < paths.length; at += 4096) {
184
+ await projectGitPaths(folder, ['check-ignore', '-z', '--stdin'], (path) => { ignored.push(path); }, { signal, input: paths.slice(at, at + 4096).join('\0') + '\0', ok: [0, 1] });
137
185
  }
138
186
  return ignored;
139
187
  }
@@ -141,92 +189,128 @@ export async function ignoredProjectPaths(folder, paths, signal) {
141
189
  export async function projectFilePath(folder, path) {
142
190
  if (!validSyncPath(path))
143
191
  throw new Error(`unsafe project path: ${JSON.stringify(path)}`);
144
- folder = await realpath(folder);
145
- const target = resolve(folder, path);
146
- const parent = await realpath(dirname(target));
147
- const rel = relative(folder, parent);
148
- if (rel === '..' || rel.startsWith(`..${sep}`) || resolve(folder, dirname(path)) !== parent)
149
- throw new Error(`project path traverses a symbolic link: ${path}`);
192
+ return await checkedProjectPath(await realpath(folder), path);
193
+ }
194
+ /** `folder` is already a real path; `parents` memoizes each directory's check within one pass. */
195
+ async function checkedProjectPath(folder, path, parents) {
196
+ const target = resolve(folder, path), dir = dirname(path);
197
+ let check = parents?.get(dir);
198
+ if (!check) {
199
+ check = (async () => {
200
+ const parent = await realpath(dirname(target));
201
+ const rel = relative(folder, parent);
202
+ if (rel === '..' || rel.startsWith(`..${sep}`) || resolve(folder, dir) !== parent)
203
+ throw new Error(`project path traverses a symbolic link: ${path}`);
204
+ })();
205
+ parents?.set(dir, check);
206
+ }
207
+ await check;
150
208
  return target;
151
209
  }
152
- export async function localManifest(folder, signal, cache, known = []) {
153
- const paths = await selectedProjectPaths(folder, signal);
154
- const result = { entries: [], skipped: [] };
155
- let next = 0;
156
- await Promise.all(Array.from({ length: Math.min(16, paths.length) }, async () => {
157
- for (;;) {
158
- const path = paths[next++];
159
- if (path === undefined)
160
- break;
161
- signal?.throwIfAborted();
162
- let target;
163
- let st;
164
- try {
165
- target = await projectFilePath(folder, path);
166
- st = await lstat(target);
167
- }
168
- catch (error) {
169
- if (error.code === 'ENOENT')
170
- continue;
171
- throw error;
172
- }
173
- const metadata = `${st.dev}:${st.ino}:${st.size}:${st.mode}:${st.mtimeMs}:${st.ctimeMs}`;
174
- const cached = cache?.get(path);
175
- if (cached?.metadata === metadata && st.size <= MAX_SYNC_FILE_BYTES) {
176
- result.entries.push(cached.entry);
177
- continue;
178
- }
179
- let sha256, link_target = '';
180
- if (st.isSymbolicLink()) {
181
- link_target = await readlink(target);
182
- sha256 = createHash('sha256').update(`symlink\0${link_target}`).digest('hex');
183
- }
184
- else if (st.isFile()) {
185
- if (st.size > MAX_SYNC_FILE_BYTES) {
186
- result.skipped.push({ path, reason: 'max_file_bytes' });
187
- continue;
188
- }
189
- const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
190
- try {
191
- const hash = createHash('sha256');
192
- let bytes = 0;
193
- for await (const raw of file.createReadStream({ autoClose: false })) {
194
- const chunk = raw;
195
- signal?.throwIfAborted();
196
- bytes += chunk.length;
197
- if (bytes > MAX_SYNC_FILE_BYTES)
198
- throw new Error(`${path} grew beyond the sync limit`);
199
- hash.update(chunk);
200
- }
201
- const after = await file.stat();
202
- if (bytes !== st.size || after.ino !== st.ino || after.mtimeMs !== st.mtimeMs || after.ctimeMs !== st.ctimeMs)
203
- throw new Error(`${path} changed while hashing; retry project sync`);
204
- sha256 = hash.digest('hex');
205
- }
206
- finally {
207
- await file.close();
208
- }
210
+ /** Files at most this size are hashed from one read; larger ones stream through a fixed buffer. */
211
+ const ONE_READ_BYTES = 1 << 20;
212
+ async function entryAt(folder, path, signal, cache, parents) {
213
+ // `git ls-files --others` lists an untracked nested repository as "dir/"; its files belong to that repository.
214
+ if (path.endsWith('/') && validSyncPath(path.slice(0, -1)))
215
+ return { path: path.slice(0, -1), reason: 'nested_repository' };
216
+ if (!validSyncPath(path))
217
+ return { path, reason: 'unsupported_path' };
218
+ signal?.throwIfAborted();
219
+ let target;
220
+ let st;
221
+ try {
222
+ target = await checkedProjectPath(folder, path, parents);
223
+ st = await lstat(target);
224
+ }
225
+ catch (error) {
226
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR')
227
+ return undefined;
228
+ throw error;
229
+ }
230
+ const metadata = `${st.dev}:${st.ino}:${st.size}:${st.mode}:${st.mtimeMs}:${st.ctimeMs}`;
231
+ const cached = cache?.get(path);
232
+ if (cached?.metadata === metadata)
233
+ return cached.entry;
234
+ let sha256, link_target = '';
235
+ if (st.isSymbolicLink()) {
236
+ link_target = await readlink(target);
237
+ sha256 = createHash('sha256').update(`symlink\0${link_target}`).digest('hex');
238
+ }
239
+ else if (st.isFile()) {
240
+ // Any size: hashed through a bounded buffer, never held whole.
241
+ const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
242
+ try {
243
+ const hash = createHash('sha256');
244
+ let bytes = 0;
245
+ if (st.size <= ONE_READ_BYTES) {
246
+ const data = await file.readFile();
247
+ bytes = data.length;
248
+ hash.update(data);
209
249
  }
210
250
  else {
211
- result.skipped.push({ path, reason: 'unsupported_type' });
212
- continue;
213
- }
214
- const entry = { path, sha256, size: st.size, mode: st.mode & 0o777, mtime_unix_ms: st.mtimeMs, symlink: st.isSymbolicLink(), link_target };
215
- result.entries.push(entry);
216
- if (cache) {
217
- if (cache.size >= MAX_SYNC_ENTRIES)
218
- cache.clear();
219
- cache.set(path, { metadata, entry });
251
+ const buffer = Buffer.allocUnsafe(ONE_READ_BYTES);
252
+ for (;;) {
253
+ signal?.throwIfAborted();
254
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, bytes);
255
+ if (!bytesRead)
256
+ break;
257
+ hash.update(buffer.subarray(0, bytesRead));
258
+ bytes += bytesRead;
259
+ }
220
260
  }
261
+ const after = await file.stat();
262
+ if (bytes !== st.size || after.ino !== st.ino || after.mtimeMs !== st.mtimeMs || after.ctimeMs !== st.ctimeMs)
263
+ throw new Error(`${path} changed while hashing; retry project sync`);
264
+ sha256 = hash.digest('hex');
221
265
  }
266
+ finally {
267
+ await file.close();
268
+ }
269
+ }
270
+ else
271
+ return { path, reason: st.isDirectory() ? 'nested_repository' : 'unsupported_type' };
272
+ const entry = { path, sha256, size: st.size, mode: st.mode & 0o777, mtime_unix_ms: st.mtimeMs, symlink: st.isSymbolicLink(), link_target };
273
+ cache?.set(path, { metadata, entry });
274
+ return entry;
275
+ }
276
+ /**
277
+ * One project file's manifest entry: undefined when it does not exist (ENOENT), `{ path, reason }` when it is skipped
278
+ * (`unsupported_type`: a socket, device or FIFO; `unsupported_path`: a name the sync cannot carry, such as one with a
279
+ * backslash; `nested_repository`: a submodule or nested Git repository). Any size is hashed.
280
+ */
281
+ export async function localEntry(folder, path, signal, cache) {
282
+ return await entryAt(await realpath(folder), path, signal, cache);
283
+ }
284
+ /** The cache holds one entry per project path: entries for paths that left the project are dropped after each pass. */
285
+ export async function localManifest(folder, signal, cache, known = []) {
286
+ const paths = await selectedProjectPaths(folder, signal);
287
+ const real = await realpath(folder);
288
+ const parents = new Map();
289
+ const found = new Array(paths.length);
290
+ let next = 0;
291
+ await Promise.all(Array.from({ length: Math.min(16, paths.length) }, async () => {
292
+ for (let at = next++; at < paths.length; at = next++)
293
+ found[at] = await entryAt(real, paths[at], signal, cache, parents);
222
294
  }));
295
+ const result = { entries: [], skipped: [] };
296
+ for (const item of found) {
297
+ if (!item)
298
+ continue;
299
+ if ('sha256' in item)
300
+ result.entries.push(item);
301
+ else
302
+ result.skipped.push(item);
303
+ }
223
304
  const selected = new Set(paths);
305
+ if (cache)
306
+ for (const path of cache.keys())
307
+ if (!selected.has(path))
308
+ cache.delete(path);
224
309
  for (const path of known)
225
310
  if (!validSyncPath(path))
226
311
  throw new Error('invalid common sync path');
227
312
  for (const path of await ignoredProjectPaths(folder, known.filter((path) => !selected.has(path)), signal))
228
313
  result.skipped.push({ path, reason: 'ignored' });
229
- result.entries.sort((a, b) => a.path.localeCompare(b.path));
230
- result.skipped.sort((a, b) => a.path.localeCompare(b.path));
314
+ result.skipped.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
231
315
  return result;
232
316
  }
@@ -1,14 +1,60 @@
1
- import type { CellClient, FileManifestEntry } from '@shardflux/sdk';
1
+ import type { CellClient, FileManifest, FileManifestEntry } from '@shardflux/sdk';
2
2
  export type SyncCell = Pick<CellClient, 'files' | 'exec'>;
3
+ /** What blob transfers need: appending writes, ranged reads. */
4
+ type BlobWriteCell = {
5
+ files: Pick<CellClient['files'], 'write'>;
6
+ };
7
+ type BlobReadCell = {
8
+ files: Pick<CellClient['files'], 'read'>;
9
+ };
3
10
  export interface SyncFile {
4
11
  entry: FileManifestEntry;
5
12
  data: Uint8Array;
6
13
  }
14
+ /** One request's worth of file data. A file larger than this streams through its own archive, one chunk at a time. */
7
15
  export declare const SYNC_CHUNK_BYTES: number;
16
+ /** Small files per batch archive (and at most SYNC_CHUNK_BYTES of their data). */
17
+ export declare const SYNC_BATCH_FILES = 128;
8
18
  export declare function verifySyncBytes(entry: FileManifestEntry, data: Uint8Array): void;
19
+ /** A small file's verified bytes (whole in memory: callers stream anything above SYNC_CHUNK_BYTES). */
9
20
  export declare function readLocalSyncFile(folder: string, entry: FileManifestEntry): Promise<SyncFile>;
10
- export declare function readRemoteSyncFile(cell: SyncCell, root: string, entry: FileManifestEntry): Promise<SyncFile>;
21
+ /** Applies a file already in memory (or deletes `path` when `file` is undefined). `expected` is the local version it replaces. */
11
22
  export declare function applyLocalSyncFile(folder: string, path: string, file: SyncFile | undefined, expected?: FileManifestEntry): Promise<void>;
23
+ /**
24
+ * Applies the VM's version of one file here (or deletes it when `entry` is undefined), streaming it chunk by chunk
25
+ * into a temporary file next to the target: any size, never whole in memory. The bytes must hash to `entry`.
26
+ * `from` reads another VM path (a conflict copy); `expected` is the local version it replaces.
27
+ */
28
+ export declare function pullRemoteFile(cell: SyncCell, root: string, folder: string, path: string, entry: FileManifestEntry | undefined, options?: {
29
+ expected?: FileManifestEntry;
30
+ from?: string;
31
+ signal?: AbortSignal;
32
+ }): Promise<void>;
33
+ /** Copies a local file to another project path (a conflict copy), verified against `entry`; any size. */
34
+ export declare function copyLocalSyncFile(folder: string, entry: FileManifestEntry, to: string): Promise<void>;
12
35
  export declare const APPLY_SYNC_TAR: string;
13
- export declare function writeSyncBlob(cell: SyncCell, path: string, data: Uint8Array): Promise<void>;
14
- export declare function applyRemoteSyncBatch(cell: SyncCell, root: string, files: Map<string, SyncFile | undefined>, expected: Record<string, FileManifestEntry | undefined>, signal?: AbortSignal): Promise<void>;
36
+ export declare function writeSyncBlob(cell: BlobWriteCell, path: string, data: Uint8Array): Promise<void>;
37
+ /** Uploads a local file of any size to a VM path, SYNC_CHUNK_BYTES at a time straight from disk. */
38
+ export declare function writeSyncFileFrom(cell: BlobWriteCell, path: string, local: string, signal?: AbortSignal): Promise<void>;
39
+ /** Downloads a VM file of `size` bytes to a new local file, SYNC_CHUNK_BYTES at a time. */
40
+ export declare function readSyncFileTo(cell: BlobReadCell, path: string, size: number, local: string, signal?: AbortSignal): Promise<void>;
41
+ /** Copies a file of `size` bytes from one VM to another, SYNC_CHUNK_BYTES at a time. */
42
+ export declare function copySyncBlob(from: BlobReadCell, path: string, size: number, to: BlobWriteCell, target: string, signal?: AbortSignal): Promise<void>;
43
+ /**
44
+ * Writes local files into the VM project at `root`: `deletions` first, then `files` (manifest entries of `folder`),
45
+ * small ones batched (at most SYNC_BATCH_FILES and SYNC_CHUNK_BYTES per archive), larger ones streamed from disk one
46
+ * chunk at a time. Every file is verified against its entry. Without `overwrite`, each VM path must still be the
47
+ * `expected` version (absent when missing) or the pass fails as changed; `overwrite: true` replaces whatever is there.
48
+ * A deletion removes the directories it empties, so a directory can become a file.
49
+ */
50
+ export declare function pushLocalFiles(cell: SyncCell, root: string, folder: string, files: FileManifestEntry[], deletions: string[], options?: {
51
+ signal?: AbortSignal;
52
+ overwrite?: boolean;
53
+ expected?: Record<string, FileManifestEntry | undefined>;
54
+ }): Promise<void>;
55
+ /**
56
+ * The VM project's manifest with every file hashed, whatever its size: no `maxFileBytes` is sent, and files an
57
+ * older guest still skips for size (`max_file_bytes`) are hashed in the VM and returned as entries.
58
+ */
59
+ export declare function remoteManifest(cell: SyncCell, root: string, knownPaths: string[] | undefined, signal?: AbortSignal): Promise<FileManifest>;
60
+ export {};