@shardflux/cli 0.11.1 → 0.13.1
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 +64 -1
- package/README.md +83 -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 +285 -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-helper.d.ts +8 -0
- package/dist/git-remote-helper.js +31 -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 +9 -2
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { watch } from 'node:fs';
|
|
3
|
+
import { lstat } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { AgentStateStore } from "./agent-state.js";
|
|
6
|
+
import { projectWorkspacePath, seedAgentRepository } from "./agent-repository.js";
|
|
7
|
+
import { contentIdentity, diffManifests, excludedSyncPath, ignoredProjectPaths, localManifest, MAX_SYNC_FILE_BYTES, parseManifest } from "./project-sync-manifest.js";
|
|
8
|
+
import { applyLocalSyncFile, applyRemoteSyncBatch, readLocalSyncFile, readRemoteSyncFile } from "./project-sync-transfer.js";
|
|
9
|
+
export { projectWorkspacePath } from "./agent-repository.js";
|
|
10
|
+
/** L8 can read these reports without creating a watcher or touching a VM. */
|
|
11
|
+
export function projectSyncConflicts(dir, key) {
|
|
12
|
+
const states = new AgentStateStore(dir).read().project_sync;
|
|
13
|
+
return (key ? states?.[key]?.conflicts ?? [] : Object.values(states ?? {}).flatMap((state) => state.conflicts)).slice();
|
|
14
|
+
}
|
|
15
|
+
/** Core reconciler used by the attach module and by the real Linux harness. */
|
|
16
|
+
export function projectReconciler(options) {
|
|
17
|
+
const { folder, root, cell, agent, state, save, report, signal } = options;
|
|
18
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(agent))
|
|
19
|
+
throw new Error('invalid conflict agent name');
|
|
20
|
+
const cache = new Map();
|
|
21
|
+
let serial = Promise.resolve();
|
|
22
|
+
const run = async () => {
|
|
23
|
+
signal?.throwIfAborted();
|
|
24
|
+
const localRaw = await localManifest(folder, signal, cache, Object.keys(state.common));
|
|
25
|
+
const remoteRaw = await cell.files.manifest(root, { maxFileBytes: MAX_SYNC_FILE_BYTES, knownPaths: [...new Set([...Object.keys(state.common), ...localRaw.entries.map((entry) => entry.path)])], signal });
|
|
26
|
+
const localPaths = new Set(localRaw.entries.map((entry) => entry.path));
|
|
27
|
+
const alreadySkipped = new Set(localRaw.skipped.map((entry) => entry.path));
|
|
28
|
+
for (const path of await ignoredProjectPaths(folder, remoteRaw.entries.map((entry) => entry.path).filter((path) => !localPaths.has(path) && !alreadySkipped.has(path)), signal))
|
|
29
|
+
localRaw.skipped.push({ path, reason: 'ignored' });
|
|
30
|
+
const local = parseManifest(localRaw), remote = parseManifest(remoteRaw);
|
|
31
|
+
const blocked = new Set();
|
|
32
|
+
for (const [side, manifest] of [['local', localRaw], ['remote', remoteRaw]]) {
|
|
33
|
+
for (const skip of manifest.skipped) {
|
|
34
|
+
blocked.add(skip.path);
|
|
35
|
+
report(`Project sync skipped ${side} ${skip.path}: ${skip.reason}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const changes = diffManifests(local, remote, state.common, Date.now(), blocked);
|
|
39
|
+
let batch = new Map();
|
|
40
|
+
let expected = Object.create(null);
|
|
41
|
+
let batchBytes = 0;
|
|
42
|
+
const flush = async () => {
|
|
43
|
+
if (batch.size === 0)
|
|
44
|
+
return;
|
|
45
|
+
await applyRemoteSyncBatch(cell, root, batch, expected, signal);
|
|
46
|
+
batch = new Map();
|
|
47
|
+
expected = Object.create(null);
|
|
48
|
+
batchBytes = 0;
|
|
49
|
+
};
|
|
50
|
+
const upload = async (path, file, previous) => {
|
|
51
|
+
if (batch.size >= 128 || batchBytes + (file?.data.length ?? 0) > 8 << 20)
|
|
52
|
+
await flush();
|
|
53
|
+
batch.set(path, file);
|
|
54
|
+
expected[path] = previous;
|
|
55
|
+
batchBytes += file?.data.length ?? 0;
|
|
56
|
+
};
|
|
57
|
+
for (const change of changes) {
|
|
58
|
+
signal?.throwIfAborted();
|
|
59
|
+
const { path, winner } = change;
|
|
60
|
+
const winnerEntry = winner === 'local' ? local[path] : remote[path];
|
|
61
|
+
const loserEntry = winner === 'local' ? remote[path] : local[path];
|
|
62
|
+
if (change.conflict) {
|
|
63
|
+
let copy = `${path}.shard-conflict-${agent}`;
|
|
64
|
+
// Never overwrite a conflict from an earlier attach, even when Git
|
|
65
|
+
// ignores it. A suffix only appears when the required name is occupied.
|
|
66
|
+
try {
|
|
67
|
+
await lstat(join(folder, copy));
|
|
68
|
+
copy += `-${randomUUID()}`;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code !== 'ENOENT')
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
if (remote[copy])
|
|
75
|
+
copy += `-${randomUUID()}`;
|
|
76
|
+
if (loserEntry) {
|
|
77
|
+
const losing = winner === 'local' ? await readRemoteSyncFile(cell, root, loserEntry) : await readLocalSyncFile(folder, loserEntry);
|
|
78
|
+
const backup = { ...losing, entry: { ...losing.entry, path: copy } };
|
|
79
|
+
await applyLocalSyncFile(folder, copy, backup);
|
|
80
|
+
await upload(copy, backup);
|
|
81
|
+
// Preserve both copies before modifying either original.
|
|
82
|
+
await flush();
|
|
83
|
+
}
|
|
84
|
+
const conflict = { path, copy: loserEntry ? copy : null, losingDeleted: !loserEntry, agent, winner, at: Date.now() };
|
|
85
|
+
state.conflicts.push(conflict);
|
|
86
|
+
save(state);
|
|
87
|
+
report(`Project sync conflict ${path}: ${winner} wins; ${conflict.copy ?? 'the other side deleted the file'}`);
|
|
88
|
+
}
|
|
89
|
+
const file = winnerEntry ? (winner === 'local' ? await readLocalSyncFile(folder, winnerEntry) : await readRemoteSyncFile(cell, root, winnerEntry)) : undefined;
|
|
90
|
+
if (winner === 'local')
|
|
91
|
+
await upload(path, file, remote[path]);
|
|
92
|
+
else
|
|
93
|
+
await applyLocalSyncFile(folder, path, file, local[path]);
|
|
94
|
+
}
|
|
95
|
+
await flush();
|
|
96
|
+
// Only observed equality advances the durable common hash. Changes made
|
|
97
|
+
// during transfer remain relative to the previous common hash on next run.
|
|
98
|
+
const [afterLocal, afterRemote] = changes.length === 0 ? [localRaw, remoteRaw] : await Promise.all([localManifest(folder, signal, cache, Object.keys(state.common)), cell.files.manifest(root, { maxFileBytes: MAX_SYNC_FILE_BYTES, knownPaths: Object.keys(state.common), signal })]);
|
|
99
|
+
const l = parseManifest(afterLocal), r = parseManifest(afterRemote);
|
|
100
|
+
for (const path of new Set([...Object.keys(l), ...Object.keys(r), ...Object.keys(state.common)])) {
|
|
101
|
+
if (blocked.has(path))
|
|
102
|
+
continue;
|
|
103
|
+
if (contentIdentity(l[path]) === contentIdentity(r[path])) {
|
|
104
|
+
if (l[path])
|
|
105
|
+
state.common[path] = contentIdentity(l[path]);
|
|
106
|
+
else
|
|
107
|
+
delete state.common[path];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
save(state);
|
|
111
|
+
};
|
|
112
|
+
return () => { const next = serial.then(run); serial = next.catch(() => undefined); return next; };
|
|
113
|
+
}
|
|
114
|
+
export async function startProjectSync(ctx, ws, project) {
|
|
115
|
+
if (ws.labels.agent && ws.labels.agent !== 'main')
|
|
116
|
+
throw new Error('two-way project sync is only available for the main agent');
|
|
117
|
+
const root = projectWorkspacePath(project);
|
|
118
|
+
const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
|
|
119
|
+
await cell.files.mkdir(root, { parents: true });
|
|
120
|
+
await seedAgentRepository(ctx, ws, project.folder, { root });
|
|
121
|
+
const store = new AgentStateStore(ctx.store.dir);
|
|
122
|
+
const key = `${ws.id}:${project.folder}`;
|
|
123
|
+
const states = store.read().project_sync;
|
|
124
|
+
const state = states?.[key] ?? { common: Object.create(null), conflicts: [] };
|
|
125
|
+
const save = (value) => {
|
|
126
|
+
store.update(doc => {
|
|
127
|
+
const current = (doc.project_sync ?? {});
|
|
128
|
+
current[key] = value;
|
|
129
|
+
doc.project_sync = current;
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
const report = (message) => { ctx.io.stderr.write(`${message}\n`); };
|
|
133
|
+
const reconcile = projectReconciler({ folder: project.folder, root, agent: ws.labels.agent ?? 'main', cell, state, save, report, signal: ctx.signal });
|
|
134
|
+
const abort = new AbortController();
|
|
135
|
+
const signal = ctx.signal ? AbortSignal.any([ctx.signal, abort.signal]) : abort.signal;
|
|
136
|
+
let stopped = false, dirty = false;
|
|
137
|
+
let timer;
|
|
138
|
+
let work = Promise.resolve();
|
|
139
|
+
const failures = [];
|
|
140
|
+
const schedule = () => {
|
|
141
|
+
if (stopped)
|
|
142
|
+
return;
|
|
143
|
+
dirty = true;
|
|
144
|
+
if (timer)
|
|
145
|
+
return;
|
|
146
|
+
timer = setTimeout(() => {
|
|
147
|
+
timer = undefined;
|
|
148
|
+
work = work.then(async () => {
|
|
149
|
+
while (dirty && !stopped) {
|
|
150
|
+
dirty = false;
|
|
151
|
+
await reconcile();
|
|
152
|
+
}
|
|
153
|
+
}).catch((error) => { failures.push(error); report(`Project sync failed: ${error instanceof Error ? error.message : String(error)}`); });
|
|
154
|
+
}, 35);
|
|
155
|
+
};
|
|
156
|
+
const local = watch(project.folder, { recursive: true }, (_event, file) => {
|
|
157
|
+
if (!file || ['.git/index', '.git/info/exclude'].includes(String(file)) || !excludedSyncPath(String(file)))
|
|
158
|
+
schedule();
|
|
159
|
+
});
|
|
160
|
+
local.on('error', (error) => { failures.push(error); report(`Project watch failed: ${error.message}`); });
|
|
161
|
+
let readyResolve;
|
|
162
|
+
let readyReject;
|
|
163
|
+
const ready = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
|
|
164
|
+
const onAbort = () => readyReject(signal.reason ?? new Error('project sync cancelled'));
|
|
165
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
166
|
+
if (signal.aborted)
|
|
167
|
+
onAbort();
|
|
168
|
+
const readyTimeout = setTimeout(() => readyReject(new Error('guest project watch did not become ready within 30 seconds')), 30_000);
|
|
169
|
+
const remote = (async () => {
|
|
170
|
+
let reconnects = 0;
|
|
171
|
+
while (!signal.aborted) {
|
|
172
|
+
try {
|
|
173
|
+
for await (const event of cell.files.watch(root, { signal })) {
|
|
174
|
+
if (event.ready) {
|
|
175
|
+
reconnects = 0;
|
|
176
|
+
readyResolve();
|
|
177
|
+
}
|
|
178
|
+
if (event.ready || event.rescan || event.paths.length > 0)
|
|
179
|
+
schedule();
|
|
180
|
+
}
|
|
181
|
+
if (signal.aborted)
|
|
182
|
+
break;
|
|
183
|
+
throw new Error('guest project watch disconnected');
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (signal.aborted)
|
|
187
|
+
break;
|
|
188
|
+
if (++reconnects > 3) {
|
|
189
|
+
readyReject(error);
|
|
190
|
+
failures.push(error);
|
|
191
|
+
report(`Project watch stopped: ${error instanceof Error ? error.message : String(error)}`);
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
report('Project watch reconnecting; comparing manifests for detached edits');
|
|
195
|
+
await new Promise((resolve) => {
|
|
196
|
+
const done = () => { clearTimeout(t); signal.removeEventListener('abort', done); resolve(); };
|
|
197
|
+
const t = setTimeout(done, reconnects * 100);
|
|
198
|
+
signal.addEventListener('abort', done, { once: true });
|
|
199
|
+
if (signal.aborted)
|
|
200
|
+
done();
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
})();
|
|
205
|
+
try {
|
|
206
|
+
await ready;
|
|
207
|
+
clearTimeout(readyTimeout);
|
|
208
|
+
signal.removeEventListener('abort', onAbort);
|
|
209
|
+
await reconcile();
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
clearTimeout(readyTimeout);
|
|
213
|
+
signal.removeEventListener('abort', onAbort);
|
|
214
|
+
stopped = true;
|
|
215
|
+
local.close();
|
|
216
|
+
abort.abort();
|
|
217
|
+
if (timer)
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
await remote;
|
|
220
|
+
await work;
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
root, conflicts: () => state.conflicts.slice(), reconcile,
|
|
225
|
+
async stop() {
|
|
226
|
+
if (stopped)
|
|
227
|
+
return;
|
|
228
|
+
stopped = true;
|
|
229
|
+
local.close();
|
|
230
|
+
if (timer)
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
abort.abort();
|
|
233
|
+
await remote;
|
|
234
|
+
await work;
|
|
235
|
+
if (!ctx.signal?.aborted && dirty) {
|
|
236
|
+
dirty = false;
|
|
237
|
+
await reconcile();
|
|
238
|
+
}
|
|
239
|
+
if (failures.length)
|
|
240
|
+
throw new AggregateError(failures, 'project sync did not finish cleanly');
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Workspace } from '@shardflux/sdk';
|
|
2
|
+
import type { Ctx, Handler } from './commands.js';
|
|
3
|
+
import { projectIdentity } from './project-identity.js';
|
|
4
|
+
export declare function dependencyHash(folder: string): string;
|
|
5
|
+
export declare function projectTemplateSlug(slug: string, identity: string): string;
|
|
6
|
+
export declare function prepareProjectTemplate(ctx: Ctx, project: ReturnType<typeof projectIdentity>, existing: Workspace | null | undefined): Promise<{
|
|
7
|
+
template: string;
|
|
8
|
+
setupHash: string | undefined;
|
|
9
|
+
labels: {
|
|
10
|
+
shard_deps_hash: string;
|
|
11
|
+
shard_setup: string;
|
|
12
|
+
shard_setup_mode: string;
|
|
13
|
+
shard_template_slug: string;
|
|
14
|
+
shard_repo_remote: string;
|
|
15
|
+
};
|
|
16
|
+
}>;
|
|
17
|
+
export declare const PROJECT_TEMPLATE_HANDLERS: Record<string, Handler>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { savedMachine } from "./filesystem-state.js";
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { lstatSync, readFileSync, readdirSync, readlinkSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
7
|
+
import { projectIdentity, normalizeRemote } from "./project-identity.js";
|
|
8
|
+
// Hash format v2 matches services/guest-agent/internal/setup/hash.go.
|
|
9
|
+
// Include manifests as well as locks: a changed dependency without a regenerated lock needs setup too.
|
|
10
|
+
const dependencyNames = new Set(['package.json', 'package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb',
|
|
11
|
+
'pyproject.toml', 'poetry.lock', 'uv.lock', 'Pipfile', 'Pipfile.lock', 'Cargo.toml', 'Cargo.lock', 'go.mod', 'go.sum',
|
|
12
|
+
'go.work', 'go.work.sum', 'pnpm-workspace.yaml', '.nvmrc', '.node-version', '.python-version', '.go-version', 'rust-toolchain', 'rust-toolchain.toml',
|
|
13
|
+
'Gemfile', 'Gemfile.lock', 'composer.json', 'composer.lock', '.tool-versions', 'mise.toml', '.mise.toml']);
|
|
14
|
+
const skippedDirs = new Set(['.git', 'node_modules', '.venv', 'venv', 'vendor', 'target', '.shard', '__pycache__', 'dist', 'build']);
|
|
15
|
+
const dependencyName = (name) => dependencyNames.has(name) || /^requirements(?:[._-].*)?\.txt$/.test(name) || name.startsWith('Dockerfile') || /^(?:docker-)?compose(?:[._-].*)?\.ya?ml$/.test(name);
|
|
16
|
+
export function dependencyHash(folder) {
|
|
17
|
+
let selected;
|
|
18
|
+
try {
|
|
19
|
+
selected = new Set(execFileSync('git', ['-C', folder, 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 }).split('\0'));
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.status !== 128 && error.code !== 'ENOENT')
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
const files = [];
|
|
26
|
+
const walk = (relative, devcontainer) => {
|
|
27
|
+
for (const name of readdirSync(join(folder, relative)).sort()) {
|
|
28
|
+
const path = relative ? `${relative}/${name}` : name;
|
|
29
|
+
const st = lstatSync(join(folder, path));
|
|
30
|
+
const inside = devcontainer || name === '.devcontainer';
|
|
31
|
+
if (st.isDirectory()) {
|
|
32
|
+
if (!skippedDirs.has(name))
|
|
33
|
+
walk(path, inside);
|
|
34
|
+
}
|
|
35
|
+
else if ((st.isFile() || st.isSymbolicLink()) && (!selected || selected.has(path)) && name !== '.env' && !name.startsWith('.env.') && (inside || path.startsWith('.github/workflows/') || dependencyName(name)))
|
|
36
|
+
files.push(path);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
walk('', false);
|
|
40
|
+
const hash = createHash('sha256').update('shardflux-deps-v2\0');
|
|
41
|
+
for (const path of files.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))) {
|
|
42
|
+
const st = lstatSync(join(folder, path));
|
|
43
|
+
// Hash link targets, never read through a symlink outside the repository.
|
|
44
|
+
const content = st.isSymbolicLink() ? Buffer.from(readlinkSync(join(folder, path))) : readFileSync(join(folder, path));
|
|
45
|
+
hash.update(path).update('\0').update(st.isSymbolicLink() ? 'link\0' : 'file\0').update(String(content.length)).update('\0').update(content);
|
|
46
|
+
}
|
|
47
|
+
return hash.digest('hex');
|
|
48
|
+
}
|
|
49
|
+
export function projectTemplateSlug(slug, identity) {
|
|
50
|
+
if (/^[a-z0-9][a-z0-9-]{0,99}$/.test(slug) && !['coding', 'default', 'python-node-browser'].includes(slug))
|
|
51
|
+
return slug;
|
|
52
|
+
// Distinct dotted/underscored project keys must not collapse onto the same builder slug.
|
|
53
|
+
return `${slug.replace(/[._]/g, '-').slice(0, 89)}-${createHash('sha256').update(identity).digest('hex').slice(0, 10)}`;
|
|
54
|
+
}
|
|
55
|
+
export async function prepareProjectTemplate(ctx, project, existing) {
|
|
56
|
+
const depsHash = dependencyHash(project.folder);
|
|
57
|
+
const remote = project.remote ?? normalizeRemote(`file://${project.folder}`);
|
|
58
|
+
let binding;
|
|
59
|
+
try {
|
|
60
|
+
binding = await ctx.cloud().projectTemplates.get(remote, { signal: ctx.signal });
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (!(err instanceof ShardfluxApiError && err.status === 404))
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
if (binding && (!binding.template_slug || !Number.isInteger(binding.version) || binding.version < 1 || !/^[a-f0-9]{64}$/.test(binding.deps_hash)))
|
|
67
|
+
throw new Error('project template response omitted a valid slug, version or dependency hash');
|
|
68
|
+
const setup = !binding || binding.deps_hash !== depsHash;
|
|
69
|
+
// The builder only accepts lowercase letters, digits and hyphens. Keep L4's workspace identity unchanged.
|
|
70
|
+
const templateSlug = binding?.template_slug ?? projectTemplateSlug(project.slug, remote);
|
|
71
|
+
const machine = await savedMachine(ctx);
|
|
72
|
+
return {
|
|
73
|
+
template: existing?.template.slug ?? (binding ? `${binding.template_slug}@${binding.version}` : machine?.template ? `${machine.template.slug}@${machine.template.version}` : 'coding'),
|
|
74
|
+
setupHash: setup ? depsHash : undefined,
|
|
75
|
+
labels: { shard_deps_hash: depsHash, shard_setup: String(setup), shard_setup_mode: 'assisted-v2', shard_template_slug: templateSlug, shard_repo_remote: remote },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export const PROJECT_TEMPLATE_HANDLERS = {
|
|
79
|
+
async 'projects template'(ctx, _values, [path = './']) {
|
|
80
|
+
const project = projectIdentity(path);
|
|
81
|
+
const remote = project.remote ?? normalizeRemote(`file://${project.folder}`);
|
|
82
|
+
const binding = await ctx.cloud().projectTemplates.get(remote);
|
|
83
|
+
if (ctx.json)
|
|
84
|
+
ctx.io.stdout.write(`${JSON.stringify(binding)}\n`);
|
|
85
|
+
else
|
|
86
|
+
ctx.io.stdout.write(`${binding.template_slug}@${binding.version}\nDependencies: ${binding.deps_hash}\nUpdated: ${binding.updated_at}\n`);
|
|
87
|
+
return 0;
|
|
88
|
+
},
|
|
89
|
+
};
|
package/dist/vault.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SiteCredential } from '@shardflux/sdk';
|
|
2
|
+
export type Vault = 'hsec' | '1password' | 'bitwarden' | 'keychain';
|
|
3
|
+
export declare const VAULT_SECURITY_NOTE = "Sent end to end from your vault to the workspace; Shardflux only relays encrypted bytes.";
|
|
4
|
+
export declare const REMEMBERED_SECURITY_NOTE = "Stored encrypted with AWS KMS and filled straight into the page; agents never see it, and every use is logged.";
|
|
5
|
+
export declare const SECRET_SECURITY_NOTE = "Stored encrypted with AWS KMS; only this project\u2019s workspaces can load it, and every load is logged.";
|
|
6
|
+
export declare function vaultType(value?: string): Vault;
|
|
7
|
+
/** The subprocess's stdout remains in memory. stderr is deliberately discarded:
|
|
8
|
+
* vault errors can echo item content. Names/references are the only argv data. */
|
|
9
|
+
export declare function vaultOutput(vault: Vault, name: string, options?: {
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
keychain?: string;
|
|
12
|
+
env?: NodeJS.ProcessEnv;
|
|
13
|
+
item?: boolean;
|
|
14
|
+
}): Promise<Buffer>;
|
|
15
|
+
export declare function vaultValue(vault: Vault, name: string, options?: Parameters<typeof vaultOutput>[2]): Promise<string>;
|
|
16
|
+
export declare function vaultCredential(vault: Vault, name: string, site: string, options?: Parameters<typeof vaultOutput>[2]): Promise<SiteCredential>;
|
package/dist/vault.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { UsageError } from "./errors.js";
|
|
3
|
+
export const VAULT_SECURITY_NOTE = 'Sent end to end from your vault to the workspace; Shardflux only relays encrypted bytes.';
|
|
4
|
+
export const REMEMBERED_SECURITY_NOTE = 'Stored encrypted with AWS KMS and filled straight into the page; agents never see it, and every use is logged.';
|
|
5
|
+
export const SECRET_SECURITY_NOTE = 'Stored encrypted with AWS KMS; only this project’s workspaces can load it, and every load is logged.';
|
|
6
|
+
export function vaultType(value) {
|
|
7
|
+
if (!['hsec', '1password', 'bitwarden', 'keychain'].includes(value ?? ''))
|
|
8
|
+
throw new UsageError('--from requires hsec, 1password, bitwarden or keychain');
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
/** The subprocess's stdout remains in memory. stderr is deliberately discarded:
|
|
12
|
+
* vault errors can echo item content. Names/references are the only argv data. */
|
|
13
|
+
export function vaultOutput(vault, name, options = {}) {
|
|
14
|
+
if (!name || name.startsWith('-') || name.includes('\0'))
|
|
15
|
+
throw new UsageError('a vault item name or reference is required');
|
|
16
|
+
const command = vault === '1password' ? 'op' : vault === 'bitwarden' ? 'bw' : vault === 'keychain' ? '/usr/bin/security' : 'hsec';
|
|
17
|
+
const args = vault === '1password'
|
|
18
|
+
? options.item
|
|
19
|
+
? ['item', 'get', name, '--format', 'json']
|
|
20
|
+
: name.startsWith('op://')
|
|
21
|
+
? ['read', name]
|
|
22
|
+
: ['item', 'get', name, '--format', 'json']
|
|
23
|
+
: vault === 'bitwarden'
|
|
24
|
+
? ['get', options.item ? 'item' : 'password', name]
|
|
25
|
+
: vault === 'keychain'
|
|
26
|
+
? ['find-generic-password', '-s', name, '-w', ...(options.keychain ? [options.keychain] : [])]
|
|
27
|
+
: ['get', name];
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'ignore'], env: options.env, signal: options.signal });
|
|
30
|
+
const chunks = [];
|
|
31
|
+
let size = 0;
|
|
32
|
+
let failed = false;
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
failed = true;
|
|
35
|
+
child.kill('SIGKILL');
|
|
36
|
+
}, 30_000);
|
|
37
|
+
child.stdout.on('data', (chunk) => {
|
|
38
|
+
size += chunk.length;
|
|
39
|
+
if (size > 65_538) {
|
|
40
|
+
failed = true;
|
|
41
|
+
child.kill('SIGKILL');
|
|
42
|
+
}
|
|
43
|
+
else
|
|
44
|
+
chunks.push(chunk);
|
|
45
|
+
});
|
|
46
|
+
child.on('error', () => {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
for (const b of chunks)
|
|
49
|
+
b.fill(0);
|
|
50
|
+
reject(new UsageError(`Could not read the item from ${vault}. Unlock your vault and retry.`));
|
|
51
|
+
});
|
|
52
|
+
child.on('close', (code) => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
if (code !== 0 || failed) {
|
|
55
|
+
for (const b of chunks)
|
|
56
|
+
b.fill(0);
|
|
57
|
+
reject(new UsageError(`Could not read the item from ${vault}. Unlock your vault and retry.`));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const bytes = Buffer.concat(chunks);
|
|
61
|
+
for (const b of chunks)
|
|
62
|
+
b.fill(0);
|
|
63
|
+
resolve(bytes);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export async function vaultValue(vault, name, options = {}) {
|
|
68
|
+
const bytes = await vaultOutput(vault, name, options);
|
|
69
|
+
try {
|
|
70
|
+
// hsec is raw (including an intentional final newline); the other CLIs add
|
|
71
|
+
// a line ending. Preserve all other whitespace, including password spaces.
|
|
72
|
+
let value = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
73
|
+
if (vault === '1password' && !name.startsWith('op://')) {
|
|
74
|
+
const item = JSON.parse(value);
|
|
75
|
+
const field = item.fields?.find((f) => f.purpose === 'PASSWORD' || f.id === 'password');
|
|
76
|
+
if (typeof field?.value !== 'string')
|
|
77
|
+
throw new Error('use a field reference');
|
|
78
|
+
value = field.value;
|
|
79
|
+
}
|
|
80
|
+
const text = vault === 'hsec' ? value : value.replace(/\r?\n$/, '');
|
|
81
|
+
if (!text || Buffer.byteLength(text) > 65_536 || text.includes('\0'))
|
|
82
|
+
throw new Error('invalid value');
|
|
83
|
+
return text;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new UsageError(`The ${vault} item must contain UTF-8 text, 1–65536 bytes, without NUL.`);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
bytes.fill(0);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export async function vaultCredential(vault, name, site, options = {}) {
|
|
93
|
+
const bytes = await vaultOutput(vault, name, { ...options, item: true });
|
|
94
|
+
try {
|
|
95
|
+
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
96
|
+
const text = vault === 'hsec' ? decoded : decoded.replace(/\r?\n$/, '');
|
|
97
|
+
let raw;
|
|
98
|
+
try {
|
|
99
|
+
raw = JSON.parse(text);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
if (vault === '1password' || vault === 'bitwarden')
|
|
103
|
+
throw new Error('invalid item');
|
|
104
|
+
return { password: text };
|
|
105
|
+
}
|
|
106
|
+
if (!raw || typeof raw !== 'object')
|
|
107
|
+
throw new Error('invalid item');
|
|
108
|
+
const item = raw;
|
|
109
|
+
const knownSites = (item.urls?.map((u) => u.href) ?? item.login?.uris?.map((u) => u.uri) ?? (item.site ? [item.site] : [])).filter((u) => !!u);
|
|
110
|
+
if (knownSites.length &&
|
|
111
|
+
!knownSites.some((u) => {
|
|
112
|
+
try {
|
|
113
|
+
return new URL(u).origin === site;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}))
|
|
119
|
+
throw new Error('site mismatch');
|
|
120
|
+
let username = item.username ?? item.login?.username;
|
|
121
|
+
let password = item.password ?? item.login?.password;
|
|
122
|
+
let seed = item.totp_seed ?? item.login?.totp;
|
|
123
|
+
for (const field of item.fields ?? []) {
|
|
124
|
+
if (field.purpose === 'USERNAME' || field.id === 'username')
|
|
125
|
+
username = field.value;
|
|
126
|
+
if (field.purpose === 'PASSWORD' || field.id === 'password')
|
|
127
|
+
password = field.value;
|
|
128
|
+
if (field.type === 'OTP')
|
|
129
|
+
seed = field.value;
|
|
130
|
+
}
|
|
131
|
+
if (seed?.startsWith('otpauth:')) {
|
|
132
|
+
const u = new URL(seed);
|
|
133
|
+
if (u.searchParams.get('algorithm') && !['SHA1', 'sha1'].includes(u.searchParams.get('algorithm')))
|
|
134
|
+
throw new Error('unsupported totp');
|
|
135
|
+
if (u.searchParams.get('period') && u.searchParams.get('period') !== '30')
|
|
136
|
+
throw new Error('unsupported totp');
|
|
137
|
+
if (u.searchParams.get('digits') && u.searchParams.get('digits') !== '6')
|
|
138
|
+
throw new Error('unsupported totp');
|
|
139
|
+
seed = u.searchParams.get('secret') ?? undefined;
|
|
140
|
+
}
|
|
141
|
+
if (seed && !/^[A-Za-z2-7]+=*$/.test(seed))
|
|
142
|
+
throw new Error('invalid totp');
|
|
143
|
+
if (!password && !username && !seed)
|
|
144
|
+
throw new Error('empty item');
|
|
145
|
+
return { ...(username ? { username } : {}), ...(password ? { password } : {}), ...(seed ? { totp_seed: seed } : {}) };
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
throw new UsageError(`Could not read a matching login from ${vault}. Choose a login item for this site.`);
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
bytes.fill(0);
|
|
152
|
+
}
|
|
153
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shardflux/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork serverless agent workspaces.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -43,16 +43,23 @@
|
|
|
43
43
|
"access": "public"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
+
"classic-level": "3.0.0",
|
|
47
|
+
"smol-toml": "1.9.0",
|
|
48
|
+
"snappyjs": "0.7.0",
|
|
46
49
|
"yaml": "2.9.1",
|
|
47
|
-
"@shardflux/sdk": "^0.
|
|
50
|
+
"@shardflux/sdk": "^0.18.0"
|
|
48
51
|
},
|
|
49
52
|
"devDependencies": {
|
|
50
53
|
"@eslint/js": "10.0.1",
|
|
51
54
|
"@types/node": "24.13.6",
|
|
52
55
|
"eslint": "10.11.0",
|
|
56
|
+
"playwright": "1.62.1",
|
|
53
57
|
"typescript": "5.9.3",
|
|
54
58
|
"typescript-eslint": "8.70.1"
|
|
55
59
|
},
|
|
60
|
+
"optionalDependencies": {
|
|
61
|
+
"@shardflux/menubar-darwin": "^0.1.0"
|
|
62
|
+
},
|
|
56
63
|
"scripts": {
|
|
57
64
|
"build": "node scripts/build.mjs",
|
|
58
65
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|