@shardflux/cli 0.11.0 → 0.13.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 +65 -1
- package/README.md +85 -15
- package/dist/account-args.js +2 -0
- package/dist/account-commands.js +39 -0
- package/dist/agent-commands.d.ts +8 -0
- package/dist/agent-commands.js +223 -0
- package/dist/agent-config-files.d.ts +23 -0
- package/dist/agent-config-files.js +203 -0
- package/dist/agent-config-mcp.d.ts +36 -0
- package/dist/agent-config-mcp.js +225 -0
- package/dist/agent-config-sync.d.ts +58 -0
- package/dist/agent-config-sync.js +366 -0
- package/dist/agent-credentials.d.ts +14 -0
- package/dist/agent-credentials.js +93 -0
- package/dist/agent-git-commands.d.ts +10 -0
- package/dist/agent-git-commands.js +294 -0
- package/dist/agent-git.d.ts +47 -0
- package/dist/agent-git.js +284 -0
- package/dist/agent-reachability.d.ts +4 -0
- package/dist/agent-reachability.js +13 -0
- package/dist/agent-repository.d.ts +13 -0
- package/dist/agent-repository.js +182 -0
- package/dist/agent-state.d.ts +16 -0
- package/dist/agent-state.js +63 -0
- package/dist/agent-target-commands.d.ts +10 -0
- package/dist/agent-target-commands.js +171 -0
- package/dist/agent-targets.d.ts +33 -0
- package/dist/agent-targets.js +267 -0
- package/dist/agent-terminal.d.ts +35 -0
- package/dist/agent-terminal.js +209 -0
- package/dist/args.d.ts +8 -4
- package/dist/args.js +120 -10
- package/dist/assisted-setup.d.ts +24 -0
- package/dist/assisted-setup.js +77 -0
- package/dist/browser-commands.d.ts +3 -0
- package/dist/browser-commands.js +83 -0
- package/dist/browser-profile-copy.d.ts +2 -0
- package/dist/browser-profile-copy.js +2 -0
- package/dist/browser-readers.d.ts +13 -0
- package/dist/browser-readers.js +249 -0
- package/dist/browser-sites.d.ts +1 -0
- package/dist/browser-sites.js +13 -0
- package/dist/claude-cloud-login.d.ts +9 -0
- package/dist/claude-cloud-login.js +143 -0
- package/dist/command-center-actions.d.ts +6 -0
- package/dist/command-center-actions.js +132 -0
- package/dist/command-center-keys.d.ts +6 -0
- package/dist/command-center-keys.js +38 -0
- package/dist/command-center-model.d.ts +29 -0
- package/dist/command-center-model.js +90 -0
- package/dist/command-center-renderer.d.ts +25 -0
- package/dist/command-center-renderer.js +100 -0
- package/dist/command-center.d.ts +18 -0
- package/dist/command-center.js +320 -0
- package/dist/commands.d.ts +8 -2
- package/dist/commands.js +123 -8
- package/dist/credential-prompts.d.ts +22 -0
- package/dist/credential-prompts.js +193 -0
- package/dist/filesystem-commands.d.ts +2 -0
- package/dist/filesystem-commands.js +63 -0
- package/dist/filesystem-state.d.ts +3 -0
- package/dist/filesystem-state.js +12 -0
- package/dist/git-remote-bin.d.ts +2 -0
- package/dist/git-remote-bin.js +37 -0
- package/dist/git-remote.d.ts +14 -0
- package/dist/git-remote.js +136 -0
- package/dist/handlers.js +9 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/input.d.ts +5 -0
- package/dist/main.js +21 -5
- package/dist/menubar.d.ts +32 -0
- package/dist/menubar.js +180 -0
- package/dist/project-identity.d.ts +14 -0
- package/dist/project-identity.js +61 -0
- package/dist/project-sync-manifest.d.ts +27 -0
- package/dist/project-sync-manifest.js +225 -0
- package/dist/project-sync-transfer.d.ts +14 -0
- package/dist/project-sync-transfer.js +240 -0
- package/dist/project-sync.d.ts +38 -0
- package/dist/project-sync.js +243 -0
- package/dist/project-template-setup.d.ts +17 -0
- package/dist/project-template-setup.js +89 -0
- package/dist/vault.d.ts +16 -0
- package/dist/vault.js +153 -0
- package/package.json +11 -3
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { lstat, open, readdir, readlink, realpath } from 'node:fs/promises';
|
|
5
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
export const MAX_SYNC_FILE_BYTES = 64 * 1024 * 1024;
|
|
7
|
+
export const MAX_SYNC_ENTRIES = 20_000;
|
|
8
|
+
export function validSyncPath(path) {
|
|
9
|
+
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');
|
|
10
|
+
}
|
|
11
|
+
export function excludedSyncPath(path) { return path.split('/').some((part) => part === '.git' || part === '.shard' || part.includes('.shard-tmp-')); }
|
|
12
|
+
export function contentIdentity(entry) {
|
|
13
|
+
return entry ? `${entry.sha256}:${entry.mode}:${entry.symlink ? 'link' : 'file'}` : 'absent';
|
|
14
|
+
}
|
|
15
|
+
/** Deletions have an observation timestamp: a detached filesystem has no deletion clock. Ties favor the laptop. */
|
|
16
|
+
export function diffManifests(local, remote, common, observedAt = Date.now(), blocked = new Set()) {
|
|
17
|
+
const changes = [];
|
|
18
|
+
for (const path of [...new Set([...Object.keys(local), ...Object.keys(remote), ...Object.keys(common)])].sort()) {
|
|
19
|
+
if (blocked.has(path))
|
|
20
|
+
continue;
|
|
21
|
+
const localEntry = Object.hasOwn(local, path) ? local[path] : undefined;
|
|
22
|
+
const remoteEntry = Object.hasOwn(remote, path) ? remote[path] : undefined;
|
|
23
|
+
const l = contentIdentity(localEntry), r = contentIdentity(remoteEntry);
|
|
24
|
+
if (l === r)
|
|
25
|
+
continue;
|
|
26
|
+
const base = Object.hasOwn(common, path) ? common[path] : 'absent';
|
|
27
|
+
const lc = l !== base, rc = r !== base;
|
|
28
|
+
const winner = lc && !rc ? 'local' : rc && !lc ? 'remote' : (localEntry?.mtime_unix_ms ?? observedAt) >= (remoteEntry?.mtime_unix_ms ?? observedAt) ? 'local' : 'remote';
|
|
29
|
+
changes.push({ path, winner, conflict: lc && rc });
|
|
30
|
+
}
|
|
31
|
+
return changes;
|
|
32
|
+
}
|
|
33
|
+
export function parseManifest(manifest) {
|
|
34
|
+
if (!manifest || !Array.isArray(manifest.entries) || !Array.isArray(manifest.skipped) || manifest.entries.length + manifest.skipped.length > MAX_SYNC_ENTRIES)
|
|
35
|
+
throw new Error('invalid project manifest');
|
|
36
|
+
const entries = Object.create(null);
|
|
37
|
+
for (const entry of manifest.entries) {
|
|
38
|
+
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])
|
|
39
|
+
throw new Error(`invalid project manifest entry: ${entry.path}`);
|
|
40
|
+
entries[entry.path] = entry;
|
|
41
|
+
}
|
|
42
|
+
for (const skip of manifest.skipped)
|
|
43
|
+
if (!validSyncPath(skip.path) || typeof skip.reason !== 'string' || !skip.reason)
|
|
44
|
+
throw new Error('invalid skipped project path');
|
|
45
|
+
return entries;
|
|
46
|
+
}
|
|
47
|
+
export async function projectGit(folder, args, signal, input = '') {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
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) => {
|
|
50
|
+
if (error) {
|
|
51
|
+
const failure = Object.assign(new Error(error.message, { cause: error }), { code: error.code, stdout });
|
|
52
|
+
reject(failure);
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
resolve(stdout);
|
|
56
|
+
});
|
|
57
|
+
child.stdin?.on('error', () => undefined); // Early Git refusal is reported by the callback.
|
|
58
|
+
child.stdin?.end(input);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** Uses the actual Git implementation rather than an approximation of gitignore. */
|
|
62
|
+
export async function selectedProjectPaths(folder, signal) {
|
|
63
|
+
let git = false;
|
|
64
|
+
try {
|
|
65
|
+
git = (await projectGit(folder, ['rev-parse', '--is-inside-work-tree'], signal)).trim() === 'true';
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (signal?.aborted || error.code === 'ENOENT')
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
const paths = new Set();
|
|
72
|
+
if (git) {
|
|
73
|
+
for (const path of (await projectGit(folder, ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '.'], signal)).split('\0'))
|
|
74
|
+
if (path && !excludedSyncPath(path))
|
|
75
|
+
paths.add(path);
|
|
76
|
+
for (const path of (await projectGit(folder, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--', '.env*', '**/.env*'], signal)).split('\0')) {
|
|
77
|
+
if (path && basename(path).startsWith('.env') && !excludedSyncPath(path))
|
|
78
|
+
paths.add(path);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const walk = async (dir) => {
|
|
83
|
+
signal?.throwIfAborted();
|
|
84
|
+
for (const entry of await readdir(join(folder, dir), { withFileTypes: true })) {
|
|
85
|
+
const path = dir ? `${dir}/${entry.name}` : entry.name;
|
|
86
|
+
if (excludedSyncPath(path))
|
|
87
|
+
continue;
|
|
88
|
+
if (entry.isDirectory())
|
|
89
|
+
await walk(path);
|
|
90
|
+
else
|
|
91
|
+
paths.add(path);
|
|
92
|
+
if (paths.size > MAX_SYNC_ENTRIES)
|
|
93
|
+
throw new Error(`project exceeds ${MAX_SYNC_ENTRIES} sync entries`);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
await walk('');
|
|
97
|
+
}
|
|
98
|
+
if (paths.size > MAX_SYNC_ENTRIES)
|
|
99
|
+
throw new Error(`project exceeds ${MAX_SYNC_ENTRIES} sync entries`);
|
|
100
|
+
for (const path of paths)
|
|
101
|
+
if (!validSyncPath(path))
|
|
102
|
+
throw new Error(`unsupported project path: ${JSON.stringify(path)}`);
|
|
103
|
+
return [...paths].sort();
|
|
104
|
+
}
|
|
105
|
+
export async function ignoredProjectPaths(folder, paths, signal) {
|
|
106
|
+
paths = paths.filter((path) => !(basename(path).startsWith('.env')));
|
|
107
|
+
if (paths.length === 0)
|
|
108
|
+
return [];
|
|
109
|
+
try {
|
|
110
|
+
await projectGit(folder, ['rev-parse', '--is-inside-work-tree'], signal);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (signal?.aborted)
|
|
114
|
+
throw error;
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
const ignored = [];
|
|
118
|
+
for (let at = 0; at < paths.length; at += 32) {
|
|
119
|
+
let output;
|
|
120
|
+
try {
|
|
121
|
+
output = await projectGit(folder, ['check-ignore', '-z', '--stdin'], signal, paths.slice(at, at + 32).join('\0') + '\0');
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
const failure = error;
|
|
125
|
+
if (failure.code !== 1)
|
|
126
|
+
throw error;
|
|
127
|
+
output = failure.stdout ?? '';
|
|
128
|
+
}
|
|
129
|
+
ignored.push(...output.split('\0').filter(Boolean));
|
|
130
|
+
}
|
|
131
|
+
return ignored;
|
|
132
|
+
}
|
|
133
|
+
/** Refuse parent symlinks, including a tracked file beneath a submodule/link. */
|
|
134
|
+
export async function projectFilePath(folder, path) {
|
|
135
|
+
if (!validSyncPath(path))
|
|
136
|
+
throw new Error(`unsafe project path: ${JSON.stringify(path)}`);
|
|
137
|
+
folder = await realpath(folder);
|
|
138
|
+
const target = resolve(folder, path);
|
|
139
|
+
const parent = await realpath(dirname(target));
|
|
140
|
+
const rel = relative(folder, parent);
|
|
141
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || resolve(folder, dirname(path)) !== parent)
|
|
142
|
+
throw new Error(`project path traverses a symbolic link: ${path}`);
|
|
143
|
+
return target;
|
|
144
|
+
}
|
|
145
|
+
export async function localManifest(folder, signal, cache, known = []) {
|
|
146
|
+
const paths = await selectedProjectPaths(folder, signal);
|
|
147
|
+
const result = { entries: [], skipped: [] };
|
|
148
|
+
let next = 0;
|
|
149
|
+
await Promise.all(Array.from({ length: Math.min(16, paths.length) }, async () => {
|
|
150
|
+
for (;;) {
|
|
151
|
+
const path = paths[next++];
|
|
152
|
+
if (path === undefined)
|
|
153
|
+
break;
|
|
154
|
+
signal?.throwIfAborted();
|
|
155
|
+
let target;
|
|
156
|
+
let st;
|
|
157
|
+
try {
|
|
158
|
+
target = await projectFilePath(folder, path);
|
|
159
|
+
st = await lstat(target);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (error.code === 'ENOENT')
|
|
163
|
+
continue;
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
const metadata = `${st.dev}:${st.ino}:${st.size}:${st.mode}:${st.mtimeMs}:${st.ctimeMs}`;
|
|
167
|
+
const cached = cache?.get(path);
|
|
168
|
+
if (cached?.metadata === metadata && st.size <= MAX_SYNC_FILE_BYTES) {
|
|
169
|
+
result.entries.push(cached.entry);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
let sha256, link_target = '';
|
|
173
|
+
if (st.isSymbolicLink()) {
|
|
174
|
+
link_target = await readlink(target);
|
|
175
|
+
sha256 = createHash('sha256').update(`symlink\0${link_target}`).digest('hex');
|
|
176
|
+
}
|
|
177
|
+
else if (st.isFile()) {
|
|
178
|
+
if (st.size > MAX_SYNC_FILE_BYTES) {
|
|
179
|
+
result.skipped.push({ path, reason: 'max_file_bytes' });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
183
|
+
try {
|
|
184
|
+
const hash = createHash('sha256');
|
|
185
|
+
let bytes = 0;
|
|
186
|
+
for await (const raw of file.createReadStream({ autoClose: false })) {
|
|
187
|
+
const chunk = raw;
|
|
188
|
+
signal?.throwIfAborted();
|
|
189
|
+
bytes += chunk.length;
|
|
190
|
+
if (bytes > MAX_SYNC_FILE_BYTES)
|
|
191
|
+
throw new Error(`${path} grew beyond the sync limit`);
|
|
192
|
+
hash.update(chunk);
|
|
193
|
+
}
|
|
194
|
+
const after = await file.stat();
|
|
195
|
+
if (bytes !== st.size || after.ino !== st.ino || after.mtimeMs !== st.mtimeMs || after.ctimeMs !== st.ctimeMs)
|
|
196
|
+
throw new Error(`${path} changed while hashing; retry project sync`);
|
|
197
|
+
sha256 = hash.digest('hex');
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
await file.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
result.skipped.push({ path, reason: 'unsupported_type' });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const entry = { path, sha256, size: st.size, mode: st.mode & 0o777, mtime_unix_ms: st.mtimeMs, symlink: st.isSymbolicLink(), link_target };
|
|
208
|
+
result.entries.push(entry);
|
|
209
|
+
if (cache) {
|
|
210
|
+
if (cache.size >= MAX_SYNC_ENTRIES)
|
|
211
|
+
cache.clear();
|
|
212
|
+
cache.set(path, { metadata, entry });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}));
|
|
216
|
+
const selected = new Set(paths);
|
|
217
|
+
for (const path of known)
|
|
218
|
+
if (!validSyncPath(path))
|
|
219
|
+
throw new Error('invalid common sync path');
|
|
220
|
+
for (const path of await ignoredProjectPaths(folder, known.filter((path) => !selected.has(path)), signal))
|
|
221
|
+
result.skipped.push({ path, reason: 'ignored' });
|
|
222
|
+
result.entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
223
|
+
result.skipped.sort((a, b) => a.path.localeCompare(b.path));
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CellClient, FileManifestEntry } from '@shardflux/sdk';
|
|
2
|
+
export type SyncCell = Pick<CellClient, 'files' | 'exec'>;
|
|
3
|
+
export interface SyncFile {
|
|
4
|
+
entry: FileManifestEntry;
|
|
5
|
+
data: Uint8Array;
|
|
6
|
+
}
|
|
7
|
+
export declare const SYNC_CHUNK_BYTES: number;
|
|
8
|
+
export declare function verifySyncBytes(entry: FileManifestEntry, data: Uint8Array): void;
|
|
9
|
+
export declare function readLocalSyncFile(folder: string, entry: FileManifestEntry): Promise<SyncFile>;
|
|
10
|
+
export declare function readRemoteSyncFile(cell: SyncCell, root: string, entry: FileManifestEntry): Promise<SyncFile>;
|
|
11
|
+
export declare function applyLocalSyncFile(folder: string, path: string, file: SyncFile | undefined, expected?: FileManifestEntry): Promise<void>;
|
|
12
|
+
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>;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { chmod, lstat, mkdir, open, readlink, rename, rm, symlink, utimes } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { tarEnd, tarHeader, tarPadding } from '@shardflux/sdk';
|
|
6
|
+
import { contentIdentity, projectFilePath, validSyncPath } from "./project-sync-manifest.js";
|
|
7
|
+
export const SYNC_CHUNK_BYTES = 8 << 20;
|
|
8
|
+
export function verifySyncBytes(entry, data) {
|
|
9
|
+
const hash = createHash('sha256').update(entry.symlink ? `symlink\0${entry.link_target}` : data).digest('hex');
|
|
10
|
+
if (hash !== entry.sha256 || (!entry.symlink && data.byteLength !== entry.size))
|
|
11
|
+
throw new Error(`${entry.path} changed during sync; compare manifests again`);
|
|
12
|
+
}
|
|
13
|
+
export async function readLocalSyncFile(folder, entry) {
|
|
14
|
+
const path = await projectFilePath(folder, entry.path);
|
|
15
|
+
const st = await lstat(path);
|
|
16
|
+
let data = new Uint8Array();
|
|
17
|
+
if (entry.symlink) {
|
|
18
|
+
if (!st.isSymbolicLink() || await readlink(path) !== entry.link_target)
|
|
19
|
+
throw new Error(`${entry.path} changed during sync`);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
23
|
+
try {
|
|
24
|
+
data = new Uint8Array(entry.size);
|
|
25
|
+
let offset = 0;
|
|
26
|
+
while (offset < data.length) {
|
|
27
|
+
const { bytesRead } = await file.read(data, offset, data.length - offset, offset);
|
|
28
|
+
if (!bytesRead)
|
|
29
|
+
throw new Error(`${entry.path} changed during sync`);
|
|
30
|
+
offset += bytesRead;
|
|
31
|
+
}
|
|
32
|
+
if ((await file.stat()).size !== entry.size)
|
|
33
|
+
throw new Error(`${entry.path} changed during sync`);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
await file.close();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if ((st.mode & 0o777) !== entry.mode)
|
|
40
|
+
throw new Error(`${entry.path} mode changed during sync`);
|
|
41
|
+
verifySyncBytes(entry, data);
|
|
42
|
+
return { entry, data };
|
|
43
|
+
}
|
|
44
|
+
export async function readRemoteSyncFile(cell, root, entry) {
|
|
45
|
+
const data = new Uint8Array(entry.symlink ? 0 : entry.size);
|
|
46
|
+
for (let offset = 0; offset < data.length;) {
|
|
47
|
+
const chunk = await cell.files.read(`${root}/${entry.path}`, { offset, length: Math.min(SYNC_CHUNK_BYTES, data.length - offset) });
|
|
48
|
+
if (chunk.length === 0 || chunk.length > data.length - offset)
|
|
49
|
+
throw new Error(`${entry.path} changed during sync`);
|
|
50
|
+
data.set(chunk, offset);
|
|
51
|
+
offset += chunk.length;
|
|
52
|
+
}
|
|
53
|
+
verifySyncBytes(entry, data);
|
|
54
|
+
return { entry, data };
|
|
55
|
+
}
|
|
56
|
+
async function checkLocal(folder, path, expected) {
|
|
57
|
+
if (expected) {
|
|
58
|
+
await readLocalSyncFile(folder, expected);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
await lstat(await projectFilePath(folder, path));
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code === 'ENOENT')
|
|
66
|
+
return;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`${path} was created during sync`);
|
|
70
|
+
}
|
|
71
|
+
export async function applyLocalSyncFile(folder, path, file, expected) {
|
|
72
|
+
if (!validSyncPath(path))
|
|
73
|
+
throw new Error(`unsafe sync path: ${path}`);
|
|
74
|
+
// Create each parent separately and refuse links before descending.
|
|
75
|
+
let parent = folder;
|
|
76
|
+
for (const part of dirname(path).split('/').filter((part) => part !== '.')) {
|
|
77
|
+
parent = join(parent, part);
|
|
78
|
+
try {
|
|
79
|
+
await mkdir(parent);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error.code !== 'EEXIST')
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
const st = await lstat(parent);
|
|
86
|
+
if (!st.isDirectory() || st.isSymbolicLink())
|
|
87
|
+
throw new Error(`sync parent is not a directory: ${parent}`);
|
|
88
|
+
}
|
|
89
|
+
const target = await projectFilePath(folder, path);
|
|
90
|
+
await checkLocal(folder, path, expected);
|
|
91
|
+
if (!file) {
|
|
92
|
+
await rm(target, { force: true });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
verifySyncBytes(file.entry, file.data);
|
|
96
|
+
const tmp = `${target}.shard-tmp-${randomUUID()}`;
|
|
97
|
+
try {
|
|
98
|
+
if (file.entry.symlink)
|
|
99
|
+
await symlink(file.entry.link_target, tmp);
|
|
100
|
+
else {
|
|
101
|
+
const fd = await open(tmp, 'wx', file.entry.mode);
|
|
102
|
+
try {
|
|
103
|
+
await fd.writeFile(file.data);
|
|
104
|
+
await fd.sync();
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await fd.close();
|
|
108
|
+
}
|
|
109
|
+
await chmod(tmp, file.entry.mode);
|
|
110
|
+
await utimes(tmp, file.entry.mtime_unix_ms / 1000, file.entry.mtime_unix_ms / 1000);
|
|
111
|
+
}
|
|
112
|
+
// The read above is deliberately just before replacement; concurrent
|
|
113
|
+
// changes detected here fail the pass instead of advancing its baseline.
|
|
114
|
+
await checkLocal(folder, path, expected);
|
|
115
|
+
await rename(tmp, target);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
await rm(tmp, { force: true });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Extract our existing SDK tar format with dirfd + O_NOFOLLOW, so an agent's
|
|
122
|
+
// parent symlink cannot redirect a batch out of the project. Compare previous
|
|
123
|
+
// hashes at the mutation boundary, retain mtimes, and fsync every replacement.
|
|
124
|
+
export const APPLY_SYNC_TAR = String.raw `
|
|
125
|
+
import os,sys,json,hashlib,tarfile,uuid,stat
|
|
126
|
+
root,archive,changes=sys.argv[1:]
|
|
127
|
+
changes=json.loads(changes)
|
|
128
|
+
rootfd=os.open(root,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW)
|
|
129
|
+
def parent(p):
|
|
130
|
+
parts=p.split('/')
|
|
131
|
+
if not p or any(x in ('','..','.','.git','.shard') for x in parts) or '\\' in p: raise ValueError('unsafe project path')
|
|
132
|
+
fd=os.dup(rootfd)
|
|
133
|
+
try:
|
|
134
|
+
for x in parts[:-1]:
|
|
135
|
+
try: os.mkdir(x,dir_fd=fd)
|
|
136
|
+
except FileExistsError: pass
|
|
137
|
+
child=os.open(x,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=fd)
|
|
138
|
+
os.close(fd);fd=child
|
|
139
|
+
return fd,parts[-1]
|
|
140
|
+
except: os.close(fd);raise
|
|
141
|
+
def identity(fd,name):
|
|
142
|
+
try: st=os.stat(name,dir_fd=fd,follow_symlinks=False)
|
|
143
|
+
except FileNotFoundError: return 'absent'
|
|
144
|
+
if stat.S_ISLNK(st.st_mode):
|
|
145
|
+
digest=hashlib.sha256(('symlink\0'+os.readlink(name,dir_fd=fd)).encode()).hexdigest();kind='link'
|
|
146
|
+
elif stat.S_ISREG(st.st_mode):
|
|
147
|
+
if st.st_size>67108864: raise ValueError('destination grew beyond sync limit')
|
|
148
|
+
f=os.open(name,os.O_RDONLY|os.O_NOFOLLOW,dir_fd=fd)
|
|
149
|
+
with os.fdopen(f,'rb') as reader: digest=hashlib.file_digest(reader,'sha256').hexdigest()
|
|
150
|
+
kind='file'
|
|
151
|
+
else: raise ValueError('destination is not a file')
|
|
152
|
+
return digest+':'+str(stat.S_IMODE(st.st_mode)&511)+':'+kind
|
|
153
|
+
try:
|
|
154
|
+
members={}
|
|
155
|
+
tar=None
|
|
156
|
+
if archive:
|
|
157
|
+
tar=tarfile.open(archive,'r:')
|
|
158
|
+
for member in tar:
|
|
159
|
+
if member.name in members or member.name not in changes or not(member.isfile() or member.issym()) or member.size>67108864: raise ValueError('invalid sync archive')
|
|
160
|
+
members[member.name]=member
|
|
161
|
+
for p,change in changes.items():
|
|
162
|
+
fd,name=parent(p)
|
|
163
|
+
tmp=name+'.shard-tmp-'+uuid.uuid4().hex
|
|
164
|
+
try:
|
|
165
|
+
if identity(fd,name)!=change['previous']: raise ValueError(p+' changed during sync')
|
|
166
|
+
entry=change['next']
|
|
167
|
+
if entry is None:
|
|
168
|
+
try: os.unlink(name,dir_fd=fd)
|
|
169
|
+
except FileNotFoundError: pass
|
|
170
|
+
else:
|
|
171
|
+
member=members[p]
|
|
172
|
+
if entry['symlink']:
|
|
173
|
+
if not member.issym() or member.linkname!=entry['link_target']: raise ValueError('link mismatch')
|
|
174
|
+
digest=hashlib.sha256(('symlink\0'+member.linkname).encode()).hexdigest()
|
|
175
|
+
os.symlink(member.linkname,tmp,dir_fd=fd)
|
|
176
|
+
else:
|
|
177
|
+
if not member.isfile() or member.size!=entry['size']: raise ValueError('file mismatch')
|
|
178
|
+
dst=os.open(tmp,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,entry['mode'],dir_fd=fd)
|
|
179
|
+
with os.fdopen(dst,'wb') as writer,tar.extractfile(member) as reader:
|
|
180
|
+
digest=hashlib.sha256()
|
|
181
|
+
while True:
|
|
182
|
+
b=reader.read(1048576)
|
|
183
|
+
if not b: break
|
|
184
|
+
digest.update(b);writer.write(b)
|
|
185
|
+
writer.flush();os.fchmod(writer.fileno(),entry['mode']);os.fsync(writer.fileno())
|
|
186
|
+
digest=digest.hexdigest()
|
|
187
|
+
os.utime(tmp,ns=(int(entry['mtime_unix_ms']*1000000),)*2,dir_fd=fd,follow_symlinks=False)
|
|
188
|
+
if digest!=entry['sha256']: raise ValueError('content mismatch')
|
|
189
|
+
if identity(fd,name)!=change['previous']: raise ValueError(p+' changed during sync')
|
|
190
|
+
os.replace(tmp,name,src_dir_fd=fd,dst_dir_fd=fd)
|
|
191
|
+
os.fsync(fd)
|
|
192
|
+
finally:
|
|
193
|
+
try: os.unlink(tmp,dir_fd=fd)
|
|
194
|
+
except FileNotFoundError: pass
|
|
195
|
+
os.close(fd)
|
|
196
|
+
finally:
|
|
197
|
+
if tar: tar.close()
|
|
198
|
+
os.close(rootfd)
|
|
199
|
+
`;
|
|
200
|
+
export async function writeSyncBlob(cell, path, data) {
|
|
201
|
+
if (data.byteLength === 0)
|
|
202
|
+
await cell.files.write(path, data, { mode: '0600' });
|
|
203
|
+
for (let offset = 0; offset < data.byteLength; offset += SYNC_CHUNK_BYTES)
|
|
204
|
+
await cell.files.write(path, data.subarray(offset, offset + SYNC_CHUNK_BYTES), { mode: '0600', append: offset > 0 });
|
|
205
|
+
}
|
|
206
|
+
export async function applyRemoteSyncBatch(cell, root, files, expected, signal) {
|
|
207
|
+
const parts = [];
|
|
208
|
+
const changes = Object.create(null);
|
|
209
|
+
let size = 0;
|
|
210
|
+
for (const [path, file] of files) {
|
|
211
|
+
if (!validSyncPath(path))
|
|
212
|
+
throw new Error(`unsafe sync path: ${path}`);
|
|
213
|
+
changes[path] = { previous: contentIdentity(expected[path]), next: file?.entry ?? null };
|
|
214
|
+
if (file) {
|
|
215
|
+
verifySyncBytes(file.entry, file.data);
|
|
216
|
+
const header = tarHeader({ path, type: file.entry.symlink ? 'symlink' : 'file', size: file.data.length, mode: file.entry.mode, linkname: file.entry.link_target, mtime: Math.floor(file.entry.mtime_unix_ms / 1000) });
|
|
217
|
+
const padding = tarPadding(file.data.length);
|
|
218
|
+
parts.push(header, file.data, padding);
|
|
219
|
+
size += header.length + file.data.length + padding.length;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const end = tarEnd(size);
|
|
223
|
+
const archive = new Uint8Array(size + end.length);
|
|
224
|
+
let at = 0;
|
|
225
|
+
for (const part of parts) {
|
|
226
|
+
archive.set(part, at);
|
|
227
|
+
at += part.length;
|
|
228
|
+
}
|
|
229
|
+
archive.set(end, at);
|
|
230
|
+
const temp = `/tmp/shard-sync-${randomUUID()}.tar`;
|
|
231
|
+
try {
|
|
232
|
+
await writeSyncBlob(cell, temp, archive);
|
|
233
|
+
const result = await cell.exec.run(['python3', '-c', APPLY_SYNC_TAR, root, temp, JSON.stringify(changes)], { timeoutMs: 60_000, maxOutputBytes: 4096, signal });
|
|
234
|
+
if (result.exitCode !== 0)
|
|
235
|
+
throw new Error(`project sync batch failed: ${result.stderr.trim()}`);
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
await cell.files.remove(temp).catch(() => undefined);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Workspace } from '@shardflux/sdk';
|
|
2
|
+
import type { Ctx } from './commands.js';
|
|
3
|
+
import type { projectIdentity } from './project-identity.js';
|
|
4
|
+
import type { Common } from './project-sync-manifest.js';
|
|
5
|
+
import type { SyncCell } from './project-sync-transfer.js';
|
|
6
|
+
export interface ProjectSyncConflict {
|
|
7
|
+
path: string;
|
|
8
|
+
copy: string | null;
|
|
9
|
+
losingDeleted: boolean;
|
|
10
|
+
agent: string;
|
|
11
|
+
winner: 'local' | 'remote';
|
|
12
|
+
at: number;
|
|
13
|
+
}
|
|
14
|
+
export interface ProjectSyncState {
|
|
15
|
+
common: Common;
|
|
16
|
+
conflicts: ProjectSyncConflict[];
|
|
17
|
+
}
|
|
18
|
+
export interface ProjectSync {
|
|
19
|
+
root: string;
|
|
20
|
+
conflicts(): readonly ProjectSyncConflict[];
|
|
21
|
+
reconcile(): Promise<void>;
|
|
22
|
+
stop(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export { projectWorkspacePath } from './agent-repository.js';
|
|
25
|
+
/** L8 can read these reports without creating a watcher or touching a VM. */
|
|
26
|
+
export declare function projectSyncConflicts(dir: string, key?: string): ProjectSyncConflict[];
|
|
27
|
+
/** Core reconciler used by the attach module and by the real Linux harness. */
|
|
28
|
+
export declare function projectReconciler(options: {
|
|
29
|
+
folder: string;
|
|
30
|
+
root: string;
|
|
31
|
+
agent: string;
|
|
32
|
+
cell: SyncCell;
|
|
33
|
+
state: ProjectSyncState;
|
|
34
|
+
save: (state: ProjectSyncState) => void;
|
|
35
|
+
report: (message: string) => void;
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
}): () => Promise<void>;
|
|
38
|
+
export declare function startProjectSync(ctx: Ctx, ws: Workspace, project: ReturnType<typeof projectIdentity>): Promise<ProjectSync>;
|