@shardflux/cli 0.14.0 → 0.14.2
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 +21 -0
- package/README.md +12 -5
- package/dist/acp-catalog.d.ts +7 -152
- package/dist/acp-catalog.js +39 -286
- package/dist/acp-models.d.ts +36 -0
- package/dist/acp-models.js +161 -0
- package/dist/acp.d.ts +5 -3
- package/dist/acp.js +216 -42
- package/dist/agent-commands.js +50 -7
- package/dist/agent-git.d.ts +1 -1
- package/dist/agent-git.js +14 -7
- package/dist/agent-repository.d.ts +2 -0
- package/dist/agent-repository.js +43 -32
- package/dist/agent-target-commands.d.ts +1 -1
- package/dist/agent-target-commands.js +51 -23
- package/dist/args.d.ts +1 -1
- package/dist/args.js +2 -2
- package/dist/conversation-router.js +3 -0
- package/dist/conversation-stash.js +5 -1
- package/dist/conversation-terminal.d.ts +6 -1
- package/dist/conversation-terminal.js +8 -4
- package/dist/conversations.d.ts +2 -2
- package/dist/conversations.js +6 -3
- package/dist/device-bridge.d.ts +1 -1
- package/dist/device-bridge.js +27 -16
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +18 -0
- package/dist/git-remote-bin.js +3 -1
- package/dist/git-remote.d.ts +3 -1
- package/dist/git-remote.js +2 -1
- package/dist/project-git.d.ts +3 -0
- package/dist/project-git.js +52 -0
- package/dist/project-sync-manifest.js +13 -6
- package/package.json +2 -2
package/dist/device-bridge.js
CHANGED
|
@@ -18,11 +18,7 @@ const LIMIT = 256 << 20, OUTPUT = (192 << 10) - 128;
|
|
|
18
18
|
const inside = (root, target) => target === root || target.startsWith(root + sep);
|
|
19
19
|
const safeId = (value) => { if (!/^[a-zA-Z0-9_-]{1,96}$/.test(value))
|
|
20
20
|
throw new Error('Invalid device request identity'); return value; };
|
|
21
|
-
|
|
22
|
-
export function devicePath(root, requested = '.') {
|
|
23
|
-
if (/[\0\r\n]/.test(requested))
|
|
24
|
-
throw new UsageError('Device path contains invalid characters.');
|
|
25
|
-
const base = realpathSync(root), target = isAbsolute(requested) ? resolve(requested) : resolve(base, requested);
|
|
21
|
+
function canonicalDevicePath(target) {
|
|
26
22
|
let ancestor = target;
|
|
27
23
|
while (!existsSync(ancestor)) {
|
|
28
24
|
const parent = dirname(ancestor);
|
|
@@ -30,7 +26,14 @@ export function devicePath(root, requested = '.') {
|
|
|
30
26
|
throw new UsageError('Device path is unavailable.');
|
|
31
27
|
ancestor = parent;
|
|
32
28
|
}
|
|
33
|
-
|
|
29
|
+
return resolve(realpathSync(ancestor), relative(ancestor, target));
|
|
30
|
+
}
|
|
31
|
+
/** Resolve existing ancestors even before the conversation worktree is created. */
|
|
32
|
+
export function devicePath(root, requested = '.') {
|
|
33
|
+
if (/[\0\r\n]/.test(requested))
|
|
34
|
+
throw new UsageError('Device path contains invalid characters.');
|
|
35
|
+
const base = canonicalDevicePath(resolve(root)), target = isAbsolute(requested) ? resolve(requested) : resolve(base, requested);
|
|
36
|
+
const canonical = canonicalDevicePath(target);
|
|
34
37
|
return { path: canonical, outside: !inside(base, canonical) };
|
|
35
38
|
}
|
|
36
39
|
export class DeviceGrants {
|
|
@@ -162,12 +165,13 @@ export class DeviceExecutor {
|
|
|
162
165
|
const record = records[r.conversation_id] ?? (folders.size === 1 ? matching[0] : undefined);
|
|
163
166
|
if (!record || record.project_id !== r.project_id)
|
|
164
167
|
return; // No inferred local path for unknown projects.
|
|
165
|
-
let c = await getConversation(ctx, r.conversation_id)
|
|
168
|
+
let c = await getConversation(ctx, r.conversation_id);
|
|
166
169
|
if (c.project_id !== r.project_id)
|
|
167
170
|
throw new Error('Device request project changed.');
|
|
168
|
-
const
|
|
171
|
+
const planned = devicePath(record.folder, join('.shard', 'worktrees', conversationName(c)));
|
|
169
172
|
const requested = r.exec?.cwd_relative ?? r.files?.local_path_relative ?? '.';
|
|
170
|
-
const target = devicePath(
|
|
173
|
+
const target = devicePath(planned.path, requested), grants = new DeviceGrants(ctx.store.dir);
|
|
174
|
+
target.outside ||= planned.outside;
|
|
171
175
|
const choice = grants.allows(ctx.apiUrl, r.project_id, record.folder, r.kind, target.outside) ? 'once' : await this.#ask({ type: 'approval', request_id: r.id, project_id: r.project_id, conversation_id: c.id, summary: deviceSummary(r), outside: target.outside });
|
|
172
176
|
if (choice === 'deny') {
|
|
173
177
|
await this.result(r, { state: 'denied' });
|
|
@@ -180,20 +184,27 @@ export class DeviceExecutor {
|
|
|
180
184
|
if (choice === 'always')
|
|
181
185
|
await grants.save(ctx.apiUrl, r.project_id, record.folder, r.kind);
|
|
182
186
|
// Re-read after the approval, before claiming or touching data.
|
|
183
|
-
if (devicePath(
|
|
187
|
+
if (devicePath(record.folder, join('.shard', 'worktrees', conversationName(c))).path !== planned.path || devicePath(planned.path, requested).path !== target.path)
|
|
184
188
|
throw new Error('Device path changed during approval.');
|
|
185
189
|
const current = await getConversation(ctx, r.conversation_id);
|
|
186
190
|
if (current.project_id !== c.project_id || current.branch !== c.branch || current.state === 'done' || current.state === 'archived')
|
|
187
191
|
throw new Error('The conversation changed or ended during device approval.');
|
|
188
|
-
if (current.workspace_id !== c.workspace_id) {
|
|
189
|
-
ws.cell().close();
|
|
190
|
-
ws = await conversationWorkspace(ctx, current);
|
|
191
|
-
}
|
|
192
192
|
c = current;
|
|
193
193
|
await this.result(r, { state: 'approved' });
|
|
194
194
|
await this.result(r, { state: 'running' });
|
|
195
|
-
|
|
195
|
+
let ws;
|
|
196
196
|
try {
|
|
197
|
+
// Approval is independent of VM/Git availability. Bound setup and report its
|
|
198
|
+
// failure through the existing running -> failed transition.
|
|
199
|
+
const preparation = { ...ctx, signal: AbortSignal.any([ctx.signal, AbortSignal.timeout(30_000)]) };
|
|
200
|
+
ws = await conversationWorkspace(preparation, c);
|
|
201
|
+
const root = await ensureConversationWorktree(preparation, c, record.folder, ws);
|
|
202
|
+
if (root !== planned.path || devicePath(root, requested).path !== target.path)
|
|
203
|
+
throw new Error('Device path changed during preparation.');
|
|
204
|
+
const prepared = await getConversation(ctx, c.id);
|
|
205
|
+
if (prepared.project_id !== c.project_id || prepared.workspace_id !== c.workspace_id || prepared.branch !== c.branch || prepared.state === 'done' || prepared.state === 'archived')
|
|
206
|
+
throw new Error('The conversation changed or ended during device preparation.');
|
|
207
|
+
const cell = ws.cell();
|
|
197
208
|
let result;
|
|
198
209
|
if (r.kind === 'try_locally' || tryLocallyAction(r)) {
|
|
199
210
|
const action = tryLocallyAction(r);
|
|
@@ -260,7 +271,7 @@ export class DeviceExecutor {
|
|
|
260
271
|
}
|
|
261
272
|
finally {
|
|
262
273
|
if (!this.#handles.has(r.id))
|
|
263
|
-
cell.close();
|
|
274
|
+
ws?.cell().close();
|
|
264
275
|
}
|
|
265
276
|
}
|
|
266
277
|
async reap() {
|
package/dist/errors.d.ts
CHANGED
|
@@ -28,6 +28,13 @@ declare class HintedError extends Error {
|
|
|
28
28
|
export declare class UsageError extends HintedError {
|
|
29
29
|
name: string;
|
|
30
30
|
}
|
|
31
|
+
/** Keep Git exit statuses for optional probes while reporting actionable CLI errors. */
|
|
32
|
+
export declare class GitError extends HintedError {
|
|
33
|
+
name: string;
|
|
34
|
+
readonly code: string | number | undefined;
|
|
35
|
+
readonly stderr: string;
|
|
36
|
+
constructor(folder: string, cause: unknown);
|
|
37
|
+
}
|
|
31
38
|
/**
|
|
32
39
|
* A refusal `shard` detects itself, with a stable code, its facts in `details` and the next step as `hint` (exit 1),
|
|
33
40
|
* e.g. `repository_too_large` (0.13.3+) for Git history over the initial sync limit.
|
package/dist/errors.js
CHANGED
|
@@ -36,6 +36,22 @@ class HintedError extends Error {
|
|
|
36
36
|
export class UsageError extends HintedError {
|
|
37
37
|
name = 'UsageError';
|
|
38
38
|
}
|
|
39
|
+
/** Keep Git exit statuses for optional probes while reporting actionable CLI errors. */
|
|
40
|
+
export class GitError extends HintedError {
|
|
41
|
+
name = 'GitError';
|
|
42
|
+
code;
|
|
43
|
+
stderr;
|
|
44
|
+
constructor(folder, cause) {
|
|
45
|
+
const failure = cause;
|
|
46
|
+
const detail = failure.stderr?.trim() || failure.message || 'Git command failed';
|
|
47
|
+
super(`Git failed in ${folder}: ${detail}\n${failure.code === 'ENOENT' ? 'Install Git and make sure git is on PATH, then run shard again.' : 'Resolve this Git error, then run shard again.'}`, failure.code === 'ENOENT'
|
|
48
|
+
? 'Install Git and make sure git is on PATH, then run shard again.'
|
|
49
|
+
: `Check Git permissions, configuration and repository state with git -C ${JSON.stringify(folder)} status, resolve the error above, then run shard again.`);
|
|
50
|
+
this.code = failure.code;
|
|
51
|
+
this.stderr = failure.stderr ?? '';
|
|
52
|
+
this.cause = cause;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
39
55
|
/**
|
|
40
56
|
* A refusal `shard` detects itself, with a stable code, its facts in `details` and the next step as `hint` (exit 1),
|
|
41
57
|
* e.g. `repository_too_large` (0.13.3+) for Git history over the initial sync limit.
|
|
@@ -199,6 +215,8 @@ export function describeError(err) {
|
|
|
199
215
|
return { code: 'timeout', message: err.message, last_state: err.session.state, details: { session_id: err.session.session_id } };
|
|
200
216
|
if (err instanceof UsageError)
|
|
201
217
|
return { code: 'usage', message: err.message };
|
|
218
|
+
if (err instanceof GitError)
|
|
219
|
+
return { code: 'git_error', message: err.message, retryable: false };
|
|
202
220
|
if (err instanceof CodedError)
|
|
203
221
|
return { code: err.code, message: err.message, retryable: false, ...(err.details ? { details: err.details } : {}) };
|
|
204
222
|
if (err instanceof CheckoutTimeoutError) {
|
package/dist/git-remote-bin.js
CHANGED
|
@@ -22,7 +22,9 @@ try {
|
|
|
22
22
|
if (!ws)
|
|
23
23
|
throw new Error(`workspace ${remote.key} does not exist`);
|
|
24
24
|
cell = ws.cell({ agentLabel: 'git-remote-shard', transitionTimeoutMs: 120_000 });
|
|
25
|
-
|
|
25
|
+
const capability = await cell.exec.run(['/usr/local/bin/sf-agent', 'git-bridge-info'], { timeoutMs: 10_000, maxOutputBytes: 4096, signal: controller.signal });
|
|
26
|
+
const platformGitBridge = capability.exitCode === 0 && JSON.parse(capability.stdout).version === 1;
|
|
27
|
+
return { ...cell.exec, platformGitBridge };
|
|
26
28
|
}, controller.signal);
|
|
27
29
|
}
|
|
28
30
|
catch (error) {
|
package/dist/git-remote.d.ts
CHANGED
|
@@ -7,7 +7,9 @@ export interface ShardRemote {
|
|
|
7
7
|
/** Keys have two segments (K9). Everything after the agent is an absolute guest path. */
|
|
8
8
|
export declare function parseShardRemote(url: string): ShardRemote;
|
|
9
9
|
export declare function shardRemote(key: string, path: string): string;
|
|
10
|
-
export type GitExec = Pick<CellClient['exec'], 'start' | 'input' | 'output' | 'cancel'
|
|
10
|
+
export type GitExec = Pick<CellClient['exec'], 'start' | 'input' | 'output' | 'cancel'> & {
|
|
11
|
+
platformGitBridge?: boolean;
|
|
12
|
+
};
|
|
11
13
|
/** No text decoding of the pack stream; offsets are acknowledged bytes, including partial stdin writes. */
|
|
12
14
|
export declare function bridgeGit(exec: GitExec, service: string, path: string, input: AsyncIterable<Uint8Array>, stdout: Writable, stderr: Writable, signal: AbortSignal): Promise<number>;
|
|
13
15
|
/** Git's `connect` capability switches from newline commands to a full-duplex smart protocol. */
|
package/dist/git-remote.js
CHANGED
|
@@ -28,7 +28,8 @@ export async function bridgeGit(exec, service, path, input, stdout, stderr, sign
|
|
|
28
28
|
throw new UsageError('unsupported Git service');
|
|
29
29
|
const controller = new AbortController();
|
|
30
30
|
const combined = AbortSignal.any([signal, controller.signal]);
|
|
31
|
-
const
|
|
31
|
+
const argv = exec.platformGitBridge ? ['/usr/local/bin/sf-agent', 'git-bridge', '--service', service, '--path', path] : [service, path];
|
|
32
|
+
const session = await exec.start({ argv, stdin_open: true, timeout_ms: 600_000 }, combined);
|
|
32
33
|
let ended = false, offset = 0;
|
|
33
34
|
const send = async () => {
|
|
34
35
|
for await (const chunk of input) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { gitRunner } from "./agent-git.js";
|
|
4
|
+
import { GitError } from "./errors.js";
|
|
5
|
+
/** Prepare only the selected project root. Never stage files or initialize a nested repository. */
|
|
6
|
+
export async function ensureProjectGit(ctx, folder) {
|
|
7
|
+
const git = gitRunner(folder, { ...process.env, ...ctx.io.env, LC_ALL: 'C' }, ctx.signal, false);
|
|
8
|
+
let initialized = false;
|
|
9
|
+
try {
|
|
10
|
+
const top = await git(['rev-parse', '--show-toplevel']);
|
|
11
|
+
if (realpathSync(top) !== realpathSync(folder))
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (!(error instanceof GitError) || error.code !== 128 || !error.stderr.includes('not a git repository'))
|
|
16
|
+
throw error;
|
|
17
|
+
if (existsSync(join(folder, '.git')))
|
|
18
|
+
throw error;
|
|
19
|
+
await git(['init', '-b', 'main']);
|
|
20
|
+
initialized = true;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
await git(['rev-parse', '--verify', '--quiet', 'HEAD^{commit}']);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (!(error instanceof GitError) || error.code !== 1)
|
|
28
|
+
throw error;
|
|
29
|
+
// An absent branch is unborn; a broken existing ref is a Git failure, not permission to replace it.
|
|
30
|
+
const branch = await git(['symbolic-ref', 'HEAD']);
|
|
31
|
+
const absent = await git(['show-ref', '--verify', '--quiet', branch]).then(() => false, (refError) => {
|
|
32
|
+
if (!(refError instanceof GitError) || refError.code !== 1)
|
|
33
|
+
throw refError;
|
|
34
|
+
return true;
|
|
35
|
+
});
|
|
36
|
+
if (!absent)
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
// --only with no paths makes an empty commit even when the user already has staged files.
|
|
40
|
+
const commit = ['-c', 'core.hooksPath=/dev/null', 'commit', '--allow-empty', '--only', '-m', 'Initial commit'];
|
|
41
|
+
try {
|
|
42
|
+
await git(commit);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (!(error instanceof GitError) || !/Author identity unknown|Committer identity unknown|unable to auto-detect email address|empty ident name|no (?:name|email) was given/.test(error.stderr))
|
|
46
|
+
throw error;
|
|
47
|
+
await git(['-c', 'user.name=Shardflux', '-c', 'user.email=noreply@shardflux.dev', ...commit]);
|
|
48
|
+
}
|
|
49
|
+
ctx.io.stderr.write(initialized
|
|
50
|
+
? `Initialized a git repository in ${folder} (shard tracks your project with git).\n`
|
|
51
|
+
: `Created an initial empty commit in ${folder}.\n`);
|
|
52
|
+
}
|
|
@@ -3,6 +3,7 @@ import { execFile } 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
|
+
import { GitError } from "./errors.js";
|
|
6
7
|
export const MAX_SYNC_FILE_BYTES = 64 * 1024 * 1024;
|
|
7
8
|
export const MAX_SYNC_ENTRIES = 20_000;
|
|
8
9
|
export function validSyncPath(path) {
|
|
@@ -46,13 +47,19 @@ export function parseManifest(manifest) {
|
|
|
46
47
|
}
|
|
47
48
|
export async function projectGit(folder, args, signal, input = '') {
|
|
48
49
|
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
|
|
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) => {
|
|
51
|
+
if (!error) {
|
|
55
52
|
resolve(stdout);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (signal?.aborted) {
|
|
56
|
+
const abortError = error;
|
|
57
|
+
reject(abortError);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const failure = new GitError(folder, Object.assign(error, { stderr }));
|
|
61
|
+
Object.assign(failure, { stdout });
|
|
62
|
+
reject(failure);
|
|
56
63
|
});
|
|
57
64
|
child.stdin?.on('error', () => undefined); // Early Git refusal is reported by the callback.
|
|
58
65
|
child.stdin?.end(input);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shardflux/cli",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
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",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"smol-toml": "1.9.0",
|
|
48
48
|
"snappyjs": "0.7.0",
|
|
49
49
|
"yaml": "2.9.1",
|
|
50
|
-
"@shardflux/sdk": "^0.19.
|
|
50
|
+
"@shardflux/sdk": "^0.19.1"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@eslint/js": "10.0.1",
|