@shardflux/cli 0.14.1 → 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 +7 -0
- package/README.md +3 -0
- 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.js +2 -2
- package/dist/args.d.ts +1 -1
- package/dist/args.js +1 -1
- package/dist/conversation-stash.js +4 -0
- package/dist/conversation-terminal.d.ts +6 -1
- package/dist/conversation-terminal.js +8 -4
- package/dist/conversations.js +3 -1
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +18 -0
- 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 +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.2
|
|
4
|
+
|
|
5
|
+
- `shard agents open` accepts conversation keys, VM keys and conversation ids, reattaching the existing Claude Code or Codex session in its conversation worktree. The menu bar's Open action uses the same path; legacy agents retain their existing behavior.
|
|
6
|
+
- Start local projects without Git history: initialize a repository on `main` when needed and create one empty initial commit, preserving existing files and staged work. Use a command-local Shardflux identity when Git identity is missing, and report actionable `git_error` failures.
|
|
7
|
+
|
|
3
8
|
## 0.14.1
|
|
4
9
|
|
|
5
10
|
- `shard ssh-proxy` connects faster: host trust (host key pin and device key) runs alongside the tunnel start, with no guest byte reaching OpenSSH before the host key is pinned, and a reconnect checks its device key with one read.
|
|
6
11
|
|
|
7
12
|
## 0.14.0
|
|
8
13
|
|
|
14
|
+
- Mirror native conversation transcripts when the guest includes its empty append-lock file.
|
|
15
|
+
|
|
9
16
|
- Ask for device approval before preparing the VM-backed local worktree; report a failed preparation as the request's result and bound preparation to 30 seconds.
|
|
10
17
|
- Keep desktop programs running after a command returns with detached `setsid -f` guidance.
|
|
11
18
|
- Allocate one VM per new ACP conversation, with cached initial model choices and live updates from that same VM.
|
package/README.md
CHANGED
|
@@ -1153,6 +1153,9 @@ Set `--reachable 30m` when opening a workspace to choose how long waiting agents
|
|
|
1153
1153
|
## Cloud agents (0.13.2+)
|
|
1154
1154
|
|
|
1155
1155
|
Run `shard ./` in your project to start Claude Code or Codex, usable immediately while setup runs in the background.
|
|
1156
|
+
|
|
1157
|
+
New project folders are ready to use: `shard ./` and `shard agents new` initialize Git on `main` when needed and create an empty initial commit (CLI 0.14.2+). Existing files and staged work stay yours; the initial commit contains no files. Repositories without commits keep their current branch. If Git identity is missing, that one commit uses `Shardflux <noreply@shardflux.dev>` without changing your Git configuration.
|
|
1158
|
+
|
|
1156
1159
|
Your **machine** carries credentials, tools, dotfiles, logins and agent config; the project's **template** carries
|
|
1157
1160
|
toolchains, dependencies, services and project secrets. Both are private filesystem templates and include secrets
|
|
1158
1161
|
as ordinary files. Browser logins and Claude/Codex config belong to the machine. Set up tools and secrets normally inside the VM;
|
package/dist/agent-commands.js
CHANGED
|
@@ -8,6 +8,7 @@ import { AgentStateStore } from "./agent-state.js";
|
|
|
8
8
|
import { attachTerminal, cellConnector } from "./agent-terminal.js";
|
|
9
9
|
import { prepareProjectTemplate } from "./project-template-setup.js";
|
|
10
10
|
import { projectIdentity, projectLabels } from "./project-identity.js";
|
|
11
|
+
import { ensureProjectGit } from "./project-git.js";
|
|
11
12
|
import { json } from "./format.js";
|
|
12
13
|
import { resolveWorkspace } from "./commands.js";
|
|
13
14
|
import { savedMachine } from "./filesystem-state.js";
|
|
@@ -21,6 +22,8 @@ import { rememberLaunch } from "./agent-live.js";
|
|
|
21
22
|
import { prepareAgentMachine } from "./agent-machine.js";
|
|
22
23
|
import { installedTargets } from "./agent-targets.js";
|
|
23
24
|
import { openDesktopWorkspace, queueT3Prewarm } from "./agent-target-commands.js";
|
|
25
|
+
import { conversationWorkspace, conversationsUnavailable, getConversation, listConversations, resumeConversation } from "./conversations.js";
|
|
26
|
+
import { conversationTerminal } from "./conversation-terminal.js";
|
|
24
27
|
const active = (session) => session.state === 'running' || session.state === 'starting';
|
|
25
28
|
/** `<slug>-<6 hex of sha256(identity)>`: the same repository gets the same key on every machine. */
|
|
26
29
|
export function disambiguatedSlug(slug, identity) {
|
|
@@ -169,6 +172,10 @@ export const launchProject = async (ctx, values, [path = './', explicitApp]) =>
|
|
|
169
172
|
if (agent !== 'main')
|
|
170
173
|
agentName(agent);
|
|
171
174
|
const labels = projectLabels(project, agent, app);
|
|
175
|
+
const local = like ? projectIdentity(path) : project;
|
|
176
|
+
// A cloud-only --like launch must not initialize an unrelated local checkout.
|
|
177
|
+
if (!like?.labels.repo_remote || like.labels.repo_remote === (local.remote ?? `file://${local.folder}`))
|
|
178
|
+
await ensureProjectGit(ctx, local.folder);
|
|
172
179
|
const cloud = ctx.cloud();
|
|
173
180
|
let existing = await cloud.workspaces.findByKey(`${project.slug}/${agent}`, { includeDeleted: false, signal: ctx.signal });
|
|
174
181
|
const owner = agent === 'main' ? existing : like ?? await cloud.workspaces.findByKey(`${project.slug}/main`, { includeDeleted: false, signal: ctx.signal });
|
|
@@ -237,18 +244,52 @@ export const AGENT_HANDLERS = {
|
|
|
237
244
|
}
|
|
238
245
|
await syncCredentials(ctx);
|
|
239
246
|
let ws;
|
|
240
|
-
|
|
241
|
-
|
|
247
|
+
let conversation;
|
|
248
|
+
if (/^conv_[a-z0-9]{26}$/.test(agent)) {
|
|
249
|
+
conversation = await resumeConversation(ctx, await getConversation(ctx, agent));
|
|
250
|
+
ws = await conversationWorkspace(ctx, conversation);
|
|
251
|
+
}
|
|
242
252
|
else {
|
|
243
|
-
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
253
|
+
let key = agent;
|
|
254
|
+
if (!agent.includes('/')) {
|
|
255
|
+
const project = projectIdentity('./');
|
|
256
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(agent))
|
|
257
|
+
throw new UsageError('agent must be a workspace key or an agent name');
|
|
258
|
+
key = `${project.slug}/${agent}`;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
ws = await ctx.cloud().workspace(key, { create: false }).open();
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (!missing(error))
|
|
265
|
+
throw error;
|
|
266
|
+
// A conversation key differs from its VM key (and survives VM moves).
|
|
267
|
+
let matches;
|
|
268
|
+
try {
|
|
269
|
+
matches = (await listConversations(ctx)).filter(c => c.key === key);
|
|
270
|
+
}
|
|
271
|
+
catch (e) {
|
|
272
|
+
if (conversationsUnavailable(e))
|
|
273
|
+
throw error;
|
|
274
|
+
throw e;
|
|
275
|
+
}
|
|
276
|
+
if (!matches.length)
|
|
277
|
+
throw error;
|
|
278
|
+
if (matches.length !== 1)
|
|
279
|
+
throw new UsageError('Use the conversation id.');
|
|
280
|
+
conversation = await resumeConversation(ctx, matches[0]);
|
|
281
|
+
ws = await conversationWorkspace(ctx, conversation);
|
|
282
|
+
}
|
|
283
|
+
// The menu bar supplies workspace_key from agents list, not the conversation key.
|
|
284
|
+
if (!conversation && ws.labels.shard_conversation) {
|
|
285
|
+
conversation = await resumeConversation(ctx, await getConversation(ctx, ws.labels.shard_conversation));
|
|
286
|
+
ws = await conversationWorkspace(ctx, conversation);
|
|
287
|
+
}
|
|
247
288
|
}
|
|
248
289
|
if (values.reachable !== undefined)
|
|
249
290
|
ws = await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
|
|
250
291
|
const projectKey = typeof values.project === 'string' ? values.project : ws.labels.repo_remote || ws.labels.shard_project || ws.key;
|
|
251
|
-
const app = values.app ?? new AgentStateStore(ctx.store.dir).app(projectKey, ws.labels.app === 'codex' ? 'codex' : 'claude');
|
|
292
|
+
const app = conversation?.harness ?? values.app ?? new AgentStateStore(ctx.store.dir).app(projectKey, ws.labels.app === 'codex' ? 'codex' : 'claude');
|
|
252
293
|
if (app === 'claude' && !await cloudClaudeLogin(ctx)) {
|
|
253
294
|
progress(ctx, 'Signing in to cloud Claude (once per account)…');
|
|
254
295
|
await loginClaudeInWorkspace(ctx, ws);
|
|
@@ -258,6 +299,8 @@ export const AGENT_HANDLERS = {
|
|
|
258
299
|
if (ws.labels.shard_layer !== 'machine' && !(await savedMachine(ctx))?.template)
|
|
259
300
|
await syncAgentConfig(ctx, ws);
|
|
260
301
|
await prepareAgentMachine(ctx, ws);
|
|
302
|
+
if (conversation)
|
|
303
|
+
return conversationTerminal(ctx, ws, conversation, projectKey, values);
|
|
261
304
|
return terminal(ctx, ws, app, (ws.labels.agent ?? agent).split('/').at(-1), projectKey, values);
|
|
262
305
|
},
|
|
263
306
|
};
|
package/dist/agent-git.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface GitRepository {
|
|
|
16
16
|
}
|
|
17
17
|
export type GitRepositories = Record<string, GitRepository>;
|
|
18
18
|
export type GitRun = (args: string[]) => Promise<string>;
|
|
19
|
-
export declare function gitRunner(root: string, env?: NodeJS.ProcessEnv, signal?: AbortSignal): GitRun;
|
|
19
|
+
export declare function gitRunner(root: string, env?: NodeJS.ProcessEnv, signal?: AbortSignal, remoteHelper?: boolean): GitRun;
|
|
20
20
|
export declare function agentName(name: string): string;
|
|
21
21
|
export declare class AgentGit {
|
|
22
22
|
readonly root: string;
|
package/dist/agent-git.js
CHANGED
|
@@ -2,18 +2,25 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
|
-
import { UsageError } from "./errors.js";
|
|
5
|
+
import { GitError, UsageError } from "./errors.js";
|
|
6
6
|
import { AgentStateStore } from "./agent-state.js";
|
|
7
7
|
import { gitPath } from "./git-remote-helper.js";
|
|
8
8
|
const execute = promisify(execFile);
|
|
9
9
|
const DAY = 86_400_000;
|
|
10
|
-
export function gitRunner(root, env = process.env, signal) {
|
|
10
|
+
export function gitRunner(root, env = process.env, signal, remoteHelper = true) {
|
|
11
11
|
return async (args) => {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
try {
|
|
13
|
+
const result = await execute('git', ['--no-pager', '-C', root, ...args], {
|
|
14
|
+
env: { ...env, PATH: remoteHelper ? gitPath(env) : env.PATH, GIT_TERMINAL_PROMPT: '0', GIT_MERGE_AUTOEDIT: 'no' },
|
|
15
|
+
timeout: 120_000, maxBuffer: 4 * 1024 * 1024, signal,
|
|
16
|
+
});
|
|
17
|
+
return result.stdout.trim();
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (signal?.aborted)
|
|
21
|
+
throw error;
|
|
22
|
+
throw new GitError(root, error);
|
|
23
|
+
}
|
|
17
24
|
};
|
|
18
25
|
}
|
|
19
26
|
export function agentName(name) {
|
package/dist/agent-repository.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
|
3
3
|
import { homedir, tmpdir } from 'node:os';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
|
-
import { CodedError } from "./errors.js";
|
|
5
|
+
import { CodedError, UsageError } from "./errors.js";
|
|
6
6
|
import { AgentStateStore } from "./agent-state.js";
|
|
7
7
|
import { projectIdentity } from "./project-identity.js";
|
|
8
8
|
import { localManifest, projectGit } from "./project-sync-manifest.js";
|
|
@@ -135,7 +135,7 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
|
|
|
135
135
|
return;
|
|
136
136
|
}
|
|
137
137
|
if (top !== folder)
|
|
138
|
-
throw new
|
|
138
|
+
throw new UsageError('project sync requires the Git repository root; run shard there');
|
|
139
139
|
const existing = await cell.exec.run(['git', '-C', root, 'rev-parse', '--verify', 'HEAD'], { timeoutMs: 5000, signal });
|
|
140
140
|
if (existing.exitCode !== 0 && existing.exitCode !== 128)
|
|
141
141
|
throw new Error('could not check workspace Git history');
|
package/dist/args.d.ts
CHANGED
package/dist/args.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { parseArgs } from 'node:util';
|
|
13
13
|
import { ACCOUNT_COMMANDS } from "./account-args.js";
|
|
14
14
|
import { UsageError } from "./errors.js";
|
|
15
|
-
export const CLI_VERSION = '0.14.
|
|
15
|
+
export const CLI_VERSION = '0.14.2';
|
|
16
16
|
export const GLOBAL_OPTIONS = {
|
|
17
17
|
'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
|
|
18
18
|
json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
|
|
@@ -62,6 +62,10 @@ export async function mirrorConversation(ctx, record, source) {
|
|
|
62
62
|
throw new Error('Project transcript copy includes files outside the mirror bound.');
|
|
63
63
|
let count = 0;
|
|
64
64
|
for (const entry of manifest.entries) {
|
|
65
|
+
// Flush holds this empty regular file while appending the transcript.
|
|
66
|
+
// It is synchronization metadata, not native history to mirror.
|
|
67
|
+
if (entry.path === '.lock' && !entry.symlink && entry.size === 0 && entry.sha256 === 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
|
|
68
|
+
continue;
|
|
65
69
|
if (entry.symlink || entry.path.includes('/') || !/^[a-zA-Z0-9_.-]+\.(jsonl|json)$/.test(entry.path))
|
|
66
70
|
throw new Error('Project transcript copy has an unsafe entry.');
|
|
67
71
|
const file = join(dir, entry.path), expected = existsSync(file) ? createHash('sha256').update(readFileSync(file)).digest('hex') : '';
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Workspace } from '@shardflux/sdk';
|
|
2
|
+
import type { Ctx, Handler } from './commands.js';
|
|
3
|
+
import type { Values } from './args.js';
|
|
4
|
+
import type { Conversation } from './conversations.js';
|
|
5
|
+
/** Every terminal entry point uses the durable conversation's native harness, worktree and session. */
|
|
6
|
+
export declare function conversationTerminal(ctx: Ctx, ws: Workspace, c: Conversation, project: string, values: Values): Promise<number>;
|
|
2
7
|
/** Select a durable conversation, then use the harness's native terminal for all interaction. */
|
|
3
8
|
export declare const launchConversationProject: Handler;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { AgentStateStore } from "./agent-state.js";
|
|
3
3
|
import { projectIdentity, projectLabels } from "./project-identity.js";
|
|
4
|
-
import { createConversation, conversationWorkspace, conversationsUnavailable, listConversations, localConversations, rememberConversation, resumeConversation, restoreConversationBinding } from "./conversations.js";
|
|
4
|
+
import { createConversation, conversationName, conversationWorkspace, conversationsUnavailable, listConversations, localConversations, rememberConversation, resumeConversation, restoreConversationBinding } from "./conversations.js";
|
|
5
5
|
import { terminal } from "./agent-commands.js";
|
|
6
6
|
import { cloudClaudeLogin, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
|
|
7
7
|
import { syncAgentCredentials } from "./agent-credentials.js";
|
|
@@ -13,6 +13,11 @@ import { ensureMenubar, MenubarManager } from "./menubar.js";
|
|
|
13
13
|
import { UsageError } from "./errors.js";
|
|
14
14
|
import { localResumeTerminal } from "./conversation-local-resume.js";
|
|
15
15
|
import { agentReachability } from "./agent-reachability.js";
|
|
16
|
+
/** Every terminal entry point uses the durable conversation's native harness, worktree and session. */
|
|
17
|
+
export async function conversationTerminal(ctx, ws, c, project, values) {
|
|
18
|
+
await restoreConversationBinding(ctx, c, ws);
|
|
19
|
+
return terminal(ctx, ws, c.harness, conversationName(c), project, values, undefined, c.worktree_path, { id: c.id, session: c.harness_session_id });
|
|
20
|
+
}
|
|
16
21
|
/** Select a durable conversation, then use the harness's native terminal for all interaction. */
|
|
17
22
|
export const launchConversationProject = async (ctx, values, [path = './', explicit]) => {
|
|
18
23
|
const project = projectIdentity(path), store = new AgentStateStore(ctx.store.dir);
|
|
@@ -35,8 +40,7 @@ export const launchConversationProject = async (ctx, values, [path = './', expli
|
|
|
35
40
|
const next = await resumeConversation(ctx, c);
|
|
36
41
|
rememberConversation(ctx, next, project.folder);
|
|
37
42
|
const ws = await conversationWorkspace(ctx, next);
|
|
38
|
-
|
|
39
|
-
return terminal(ctx, ws, app, next.key.split('/').at(-1), project.remote ?? project.folder, values, undefined, next.worktree_path, { id: next.id, session: next.harness_session_id });
|
|
43
|
+
return conversationTerminal(ctx, ws, next, project.remote ?? project.folder, values);
|
|
40
44
|
}
|
|
41
45
|
await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
|
|
42
46
|
const signedIn = app === 'claude' ? await cloudClaudeLogin(ctx) : true;
|
|
@@ -70,5 +74,5 @@ export const launchConversationProject = async (ctx, values, [path = './', expli
|
|
|
70
74
|
if (!(await savedMachine(ctx))?.template)
|
|
71
75
|
await syncAgentConfig(ctx, ws);
|
|
72
76
|
await ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
|
|
73
|
-
return
|
|
77
|
+
return conversationTerminal(ctx, ws, c, project.remote ?? project.folder, values);
|
|
74
78
|
};
|
package/dist/conversations.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ShardfluxApiError } from '@shardflux/sdk';
|
|
|
3
3
|
import { AgentStateStore } from "./agent-state.js";
|
|
4
4
|
import { gitRunner } from "./agent-git.js";
|
|
5
5
|
import { projectIdentity } from "./project-identity.js";
|
|
6
|
+
import { ensureProjectGit } from "./project-git.js";
|
|
6
7
|
import { seedAgentRepository } from "./agent-repository.js";
|
|
7
8
|
import { UsageError } from "./errors.js";
|
|
8
9
|
import { conversationClient } from "./conversation-commands.js";
|
|
@@ -87,7 +88,8 @@ export async function restoreConversationBinding(ctx, c, ws) {
|
|
|
87
88
|
}
|
|
88
89
|
export async function createConversationRecord(ctx, folder, harness, route, name, allocated, idempotencyKey = randomUUID()) {
|
|
89
90
|
const project = projectIdentity(folder);
|
|
90
|
-
|
|
91
|
+
await ensureProjectGit(ctx, project.folder);
|
|
92
|
+
// Both the conversation API and its legacy workspace fallback need a usable local HEAD.
|
|
91
93
|
await conversationClient(ctx).conversations.list({ limit: 1 });
|
|
92
94
|
const git = gitRunner(project.folder, { ...process.env, ...ctx.io.env }, ctx.signal);
|
|
93
95
|
const commit = await git(['rev-parse', 'HEAD']);
|
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) {
|
|
@@ -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",
|