@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.
@@ -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
- /** Resolve every existing ancestor, including symlinks. Missing leaf files do not hide an outside parent. */
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
- const canonical = resolve(realpathSync(ancestor), relative(ancestor, target));
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), ws = await conversationWorkspace(ctx, c);
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 root = await ensureConversationWorktree(ctx, c, record.folder, ws);
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(root, requested), grants = new DeviceGrants(ctx.store.dir);
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(root, requested).path !== target.path)
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
- const cell = ws.cell();
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) {
@@ -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
- return cell.exec;
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) {
@@ -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. */
@@ -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 session = await exec.start({ argv: [service, path], stdin_open: true, timeout_ms: 600_000 }, combined);
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,3 @@
1
+ import type { Ctx } from './commands.js';
2
+ /** Prepare only the selected project root. Never stage files or initialize a nested repository. */
3
+ export declare function ensureProjectGit(ctx: Ctx, folder: string): Promise<void>;
@@ -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.0",
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.0"
50
+ "@shardflux/sdk": "^0.19.1"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@eslint/js": "10.0.1",