@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.
- package/CHANGELOG.md +81 -1
- package/README.md +27 -0
- package/dist/account-args.js +1 -0
- package/dist/agent-commands.d.ts +1 -0
- package/dist/agent-commands.js +8 -3
- package/dist/agent-config-files.d.ts +16 -2
- package/dist/agent-config-files.js +48 -61
- package/dist/agent-config-sync.d.ts +12 -1
- package/dist/agent-config-sync.js +228 -54
- package/dist/agent-repository.d.ts +39 -9
- package/dist/agent-repository.js +309 -92
- package/dist/agent-state.d.ts +3 -0
- package/dist/agent-state.js +13 -0
- package/dist/agent-target-commands.d.ts +2 -0
- package/dist/agent-target-commands.js +45 -15
- package/dist/args.d.ts +1 -1
- package/dist/args.js +14 -2
- package/dist/conversation-router.js +7 -11
- package/dist/conversation-stash.js +64 -18
- package/dist/conversation-terminal.d.ts +1 -1
- package/dist/conversation-terminal.js +9 -10
- package/dist/conversations.d.ts +9 -2
- package/dist/conversations.js +23 -6
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -1
- package/dist/files-mirror.d.ts +1 -9
- package/dist/files-mirror.js +19 -3
- package/dist/format.js +37 -2
- package/dist/handlers.js +3 -1
- package/dist/local-sessions.d.ts +60 -0
- package/dist/local-sessions.js +445 -0
- package/dist/main.js +12 -0
- package/dist/move.d.ts +44 -0
- package/dist/move.js +248 -0
- package/dist/moved-sessions.d.ts +33 -0
- package/dist/moved-sessions.js +91 -0
- package/dist/onboarding.d.ts +18 -3
- package/dist/onboarding.js +72 -46
- package/dist/project-sync-manifest.d.ts +31 -3
- package/dist/project-sync-manifest.js +192 -108
- package/dist/project-sync-transfer.d.ts +50 -4
- package/dist/project-sync-transfer.js +404 -106
- package/dist/project-sync.js +30 -33
- package/dist/seed-phases.d.ts +29 -0
- package/dist/seed-phases.js +83 -0
- package/dist/t3-mode.d.ts +7 -2
- package/dist/t3-mode.js +39 -5
- package/dist/thread-on-computer.js +4 -0
- package/dist/worktree-twin.d.ts +2 -6
- package/dist/worktree-twin.js +16 -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
|
-
/**
|
|
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)
|
|
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 (
|
|
40
|
-
throw new Error(`invalid project manifest entry: ${entry
|
|
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 (!
|
|
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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
14
|
-
|
|
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 {};
|