@shardflux/cli 0.14.1 → 0.14.3
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 +12 -0
- package/README.md +4 -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.d.ts +12 -2
- package/dist/conversation-stash.js +124 -43
- package/dist/conversation-terminal.d.ts +6 -1
- package/dist/conversation-terminal.js +8 -4
- package/dist/conversations.js +3 -1
- package/dist/device-helper.js +27 -3
- 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,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.3
|
|
4
|
+
|
|
5
|
+
- Poll device requests and deliver approvals independently of transcript mirroring, so a slow or failed copy cannot delay file-push approvals. Isolate mirror failures by conversation, retry with exponential backoff, and log each failure once per conversation revision with the affected entry name.
|
|
6
|
+
- Skip unknown, nested, symlink and oversized transcript entries while mirroring safe top-level JSONL and JSON files. Log each skipped name once per conversation, preserve the empty append-lock exception, and refuse symlink destinations or ancestors inside the stash.
|
|
7
|
+
|
|
8
|
+
## 0.14.2
|
|
9
|
+
|
|
10
|
+
- `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.
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
3
13
|
## 0.14.1
|
|
4
14
|
|
|
5
15
|
- `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
16
|
|
|
7
17
|
## 0.14.0
|
|
8
18
|
|
|
19
|
+
- Mirror native conversation transcripts when the guest includes its empty append-lock file.
|
|
20
|
+
|
|
9
21
|
- 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
22
|
- Keep desktop programs running after a command returns with detached `setsid -f` guidance.
|
|
11
23
|
- Allocate one VM per new ACP conversation, with cached initial model choices and live updates from that same VM.
|
package/README.md
CHANGED
|
@@ -81,6 +81,7 @@ Your branch opens in `.shard/worktrees/<name>`, syncs both ways, and its dev ser
|
|
|
81
81
|
Ctrl-C stops local sync. Your main checkout stays available for your own work.
|
|
82
82
|
Device tools ask Allow once or Always for this project on your Mac before preparing the conversation’s local worktree. Live transcripts stay in
|
|
83
83
|
`~/.config/shardflux/conversations/`; ended sessions enter Claude Code’s or Codex’s native history. Transcript files up to 64 MiB are supported (0.14.0+).
|
|
84
|
+
From 0.14.3, transcript copying runs independently of device approvals. Unknown entries are skipped, and failed copies retry with backoff; diagnostics name the conversation and affected entry.
|
|
84
85
|
|
|
85
86
|
For application workspace commands, `shard signup` creates an account and saves a project key, or set
|
|
86
87
|
`SHARDFLUX_API_KEY` to a key from the console and run `shard login`. `shard --help` lists every command.
|
|
@@ -1153,6 +1154,9 @@ Set `--reachable 30m` when opening a workspace to choose how long waiting agents
|
|
|
1153
1154
|
## Cloud agents (0.13.2+)
|
|
1154
1155
|
|
|
1155
1156
|
Run `shard ./` in your project to start Claude Code or Codex, usable immediately while setup runs in the background.
|
|
1157
|
+
|
|
1158
|
+
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.
|
|
1159
|
+
|
|
1156
1160
|
Your **machine** carries credentials, tools, dotfiles, logins and agent config; the project's **template** carries
|
|
1157
1161
|
toolchains, dependencies, services and project secrets. Both are private filesystem templates and include secrets
|
|
1158
1162
|
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.3';
|
|
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).' },
|
|
@@ -4,8 +4,18 @@ import type { Conversation, LocalConversation } from './conversations.js';
|
|
|
4
4
|
export declare function rewriteTranscript(bytes: Uint8Array, remoteCwd: string, localCwd: string): Uint8Array;
|
|
5
5
|
export declare function transcriptDestination(c: Conversation, folder: string, filename: string, env: Record<string, string | undefined>): string;
|
|
6
6
|
/** Keep growing copies away from native session discovery until the control plane ends the conversation. */
|
|
7
|
-
export declare function mirrorConversation(ctx: Ctx, record: LocalConversation, source: Workspace): Promise<void>;
|
|
7
|
+
export declare function mirrorConversation(ctx: Ctx, record: LocalConversation, source: Workspace, skipped?: Set<string>): Promise<void>;
|
|
8
8
|
export declare function handoffConversation(ctx: Pick<Ctx, 'io' | 'store' | 'apiUrl' | 'signal'>, c: Conversation, folder: string, dir: string, source?: Pick<Workspace, 'key'> | {
|
|
9
9
|
hub: string;
|
|
10
10
|
}): Promise<void>;
|
|
11
|
-
export declare
|
|
11
|
+
export declare class ConversationMirrorState {
|
|
12
|
+
readonly mirrored: Map<string, string>;
|
|
13
|
+
readonly skipped: Map<string, Set<string>>;
|
|
14
|
+
readonly retries: Map<string, {
|
|
15
|
+
revision: string;
|
|
16
|
+
failures: number;
|
|
17
|
+
retryAt: number;
|
|
18
|
+
reported: Set<string>;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
export declare function mirrorProjectConversations(ctx: Ctx, state?: ConversationMirrorState): Promise<void>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { conversationName, listConversations, localConversations, rememberConversation } from "./conversations.js";
|
|
@@ -45,31 +45,79 @@ function privateDirectory(path) {
|
|
|
45
45
|
at = parent;
|
|
46
46
|
}
|
|
47
47
|
if (existsSync(at) && lstatSync(at).isSymbolicLink())
|
|
48
|
-
throw new Error(
|
|
48
|
+
throw new Error(`Transcript directory must not be a symlink: ${at}`);
|
|
49
49
|
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
50
50
|
if (lstatSync(path).isSymbolicLink())
|
|
51
|
-
throw new Error(
|
|
51
|
+
throw new Error(`Transcript directory must not be a symlink: ${path}`);
|
|
52
|
+
}
|
|
53
|
+
function readStashFile(file) {
|
|
54
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
55
|
+
try {
|
|
56
|
+
if (!fstatSync(fd).isFile())
|
|
57
|
+
throw new Error(`Stash entry is not a regular file: ${file}`);
|
|
58
|
+
return readFileSync(fd);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
closeSync(fd);
|
|
62
|
+
}
|
|
52
63
|
}
|
|
53
64
|
/** Keep growing copies away from native session discovery until the control plane ends the conversation. */
|
|
54
|
-
export async function mirrorConversation(ctx, record, source) {
|
|
65
|
+
export async function mirrorConversation(ctx, record, source, skipped = new Set()) {
|
|
55
66
|
const c = record.conversation;
|
|
56
|
-
|
|
67
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(c.project_id) || !/^conv_[a-z0-9]{26}$/.test(c.id))
|
|
68
|
+
throw new Error('Transcript stash identity is unsafe.');
|
|
69
|
+
let dir = ctx.store.dir;
|
|
70
|
+
// Check each stash component before creating children; no ancestor inside the stash may be a symlink.
|
|
57
71
|
privateDirectory(dir);
|
|
72
|
+
for (const part of ['conversations', c.project_id, c.id]) {
|
|
73
|
+
dir = join(dir, part);
|
|
74
|
+
privateDirectory(dir);
|
|
75
|
+
}
|
|
76
|
+
const root = `/shardflux/project/transcripts/${c.id}`;
|
|
77
|
+
const skip = (name, reason) => {
|
|
78
|
+
if (skipped.has(name))
|
|
79
|
+
return;
|
|
80
|
+
skipped.add(name);
|
|
81
|
+
ctx.io.stderr.write(`Transcript mirror ${c.id}: skipped entry ${JSON.stringify(name)} (${reason}).\n`);
|
|
82
|
+
};
|
|
58
83
|
const cell = source.cell({ wake: null });
|
|
84
|
+
const close = () => cell.close();
|
|
85
|
+
ctx.signal.addEventListener('abort', close, { once: true });
|
|
59
86
|
try {
|
|
87
|
+
ctx.signal.throwIfAborted();
|
|
60
88
|
const manifest = await cell.files.manifest(root, { maxFileBytes: MAX, signal: ctx.signal });
|
|
61
|
-
|
|
62
|
-
|
|
89
|
+
for (const entry of manifest.skipped)
|
|
90
|
+
skip(entry.path, entry.reason);
|
|
63
91
|
let count = 0;
|
|
64
92
|
for (const entry of manifest.entries) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
93
|
+
// Flush holds this empty regular file while appending the transcript.
|
|
94
|
+
// It is synchronization metadata, not native history to mirror.
|
|
95
|
+
if (entry.path === '.lock' && !entry.symlink && entry.size === 0 && entry.sha256 === 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
|
|
96
|
+
continue;
|
|
97
|
+
if (entry.symlink || entry.path.includes('..') || entry.path.includes('/') || !/^[a-zA-Z0-9_.-]+\.(jsonl|json)$/.test(entry.path)) {
|
|
98
|
+
skip(entry.path, 'not a safe top-level JSONL or JSON file');
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (!Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > MAX) {
|
|
102
|
+
skip(entry.path, 'outside the 64 MiB mirror bound');
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const file = join(dir, entry.path), existing = lstatSync(file, { throwIfNoEntry: false });
|
|
107
|
+
if (existing && !existing.isFile()) {
|
|
108
|
+
skip(entry.path, 'stash destination is not a regular file');
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const expected = existing ? createHash('sha256').update(readStashFile(file)).digest('hex') : '';
|
|
112
|
+
if (expected !== entry.sha256) {
|
|
113
|
+
const bytes = await cell.files.read(`${root}/${entry.path}`, { length: entry.size + 1 });
|
|
114
|
+
if (bytes.length !== entry.size || createHash('sha256').update(bytes).digest('hex') !== entry.sha256)
|
|
115
|
+
throw new Error('Transcript changed during copying; retaining the previous stash.');
|
|
116
|
+
writePrivateFile(dir, file, new TextDecoder('utf8', { fatal: true }).decode(bytes));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
throw new Error(`Entry ${JSON.stringify(entry.path)}: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
73
121
|
}
|
|
74
122
|
if (entry.path.endsWith('.jsonl'))
|
|
75
123
|
count++;
|
|
@@ -80,26 +128,27 @@ export async function mirrorConversation(ctx, record, source) {
|
|
|
80
128
|
const binding = await conversationClient(ctx).workspaces.get(c.workspace_id);
|
|
81
129
|
if (!binding.data.deleted_at)
|
|
82
130
|
return;
|
|
83
|
-
const meta = JSON.parse(
|
|
84
|
-
if (!c.harness_session_id || meta.harness !== c.harness || meta.harness_session_id !== c.harness_session_id || !/^[a-zA-Z0-9_.-]+\.jsonl$/.test(meta.file))
|
|
85
|
-
throw new Error('
|
|
86
|
-
const bytes =
|
|
131
|
+
const meta = JSON.parse(readStashFile(join(dir, 'meta.json')).toString('utf8'));
|
|
132
|
+
if (!meta || !c.harness_session_id || meta.harness !== c.harness || meta.harness_session_id !== c.harness_session_id || typeof meta.file !== 'string' || meta.file.includes('..') || !/^[a-zA-Z0-9_.-]+\.jsonl$/.test(meta.file))
|
|
133
|
+
throw new Error('Entry "meta.json": transcript handoff metadata does not match the ended conversation.');
|
|
134
|
+
const bytes = readStashFile(join(dir, meta.file));
|
|
87
135
|
if (bytes.length !== meta.bytes || createHash('sha256').update(bytes).digest('hex') !== meta.sha256)
|
|
88
|
-
throw new Error(
|
|
136
|
+
throw new Error(`Entry ${JSON.stringify(meta.file)}: transcript handoff checkpoint differs from its durable metadata.`);
|
|
89
137
|
await handoffConversation(ctx, { ...c, worktree_path: meta.cwd }, record.folder, dir, source);
|
|
90
138
|
}
|
|
91
139
|
}
|
|
92
140
|
finally {
|
|
141
|
+
ctx.signal.removeEventListener('abort', close);
|
|
93
142
|
cell.close();
|
|
94
143
|
}
|
|
95
144
|
}
|
|
96
145
|
export async function handoffConversation(ctx, c, folder, dir, source) {
|
|
97
146
|
if (c.state !== 'done' && c.state !== 'archived')
|
|
98
147
|
throw new Error('Only an ended conversation can enter native history.');
|
|
99
|
-
const files = readdirSync(dir).filter(f => f.
|
|
148
|
+
const files = readdirSync(dir).filter(f => !f.includes('..') && /^[a-zA-Z0-9_.-]+\.jsonl$/.test(f) && lstatSync(join(dir, f)).isFile());
|
|
100
149
|
if (!files.length)
|
|
101
150
|
throw new Error('Conversation has no transcript to hand off.');
|
|
102
|
-
const writes = files.map(name => ({ dest: transcriptDestination(c, folder, name, ctx.io.env), bytes: rewriteTranscript(
|
|
151
|
+
const writes = files.map(name => ({ dest: transcriptDestination(c, folder, name, ctx.io.env), bytes: rewriteTranscript(readStashFile(join(dir, name)), c.worktree_path, folder) }));
|
|
103
152
|
// Check every destination before the first write. Preserve local native history on any collision.
|
|
104
153
|
for (const { dest, bytes } of writes) {
|
|
105
154
|
if (existsSync(dest) && (lstatSync(dest).isSymbolicLink() || !readFileSync(dest).equals(Buffer.from(bytes))))
|
|
@@ -119,7 +168,12 @@ export async function handoffConversation(ctx, c, folder, dir, source) {
|
|
|
119
168
|
rememberConversation(ctx, c, folder, true);
|
|
120
169
|
writePrivateFile(dir, join(dir, 'handoff.json'), JSON.stringify({ conversation_id: c.id, branch: c.branch, destinations: writes.map(w => w.dest), at: new Date().toISOString() }) + '\n');
|
|
121
170
|
}
|
|
122
|
-
export
|
|
171
|
+
export class ConversationMirrorState {
|
|
172
|
+
mirrored = new Map();
|
|
173
|
+
skipped = new Map();
|
|
174
|
+
retries = new Map();
|
|
175
|
+
}
|
|
176
|
+
export async function mirrorProjectConversations(ctx, state = new ConversationMirrorState()) {
|
|
123
177
|
const records = localConversations(ctx);
|
|
124
178
|
if (!Object.keys(records).length)
|
|
125
179
|
return;
|
|
@@ -129,30 +183,57 @@ export async function mirrorProjectConversations(ctx, mirrored = new Map()) {
|
|
|
129
183
|
if (!c || record.handed_off || !c.harness_session_id)
|
|
130
184
|
continue;
|
|
131
185
|
const revision = JSON.stringify([c.workspace_id, c.harness_session_id, c.state, c.durable, c.last_activity_at, c.summary]);
|
|
132
|
-
if (mirrored.get(c.id) === revision)
|
|
186
|
+
if (state.mirrored.get(c.id) === revision)
|
|
133
187
|
continue;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
188
|
+
let retry = state.retries.get(c.id);
|
|
189
|
+
if (!retry || retry.revision !== revision) {
|
|
190
|
+
retry = { revision, failures: 0, retryAt: 0, reported: new Set() };
|
|
191
|
+
state.retries.set(c.id, retry);
|
|
192
|
+
}
|
|
193
|
+
if (Date.now() < retry.retryAt)
|
|
194
|
+
continue;
|
|
195
|
+
try {
|
|
196
|
+
rememberConversation(ctx, c, record.folder);
|
|
197
|
+
let source;
|
|
198
|
+
for (const candidate of [c, ...all.filter(other => other.project_id === c.project_id && other.id !== c.id)]) {
|
|
199
|
+
try {
|
|
200
|
+
const ws = await conversationClient(ctx).workspaces.get(candidate.workspace_id);
|
|
201
|
+
if (!ws.data.deleted_at && ws.ready) {
|
|
202
|
+
source = ws;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
142
205
|
}
|
|
206
|
+
catch { /* Another binding may be available. */ }
|
|
207
|
+
}
|
|
208
|
+
if (!source) {
|
|
209
|
+
const client = conversationClient(ctx);
|
|
210
|
+
const reader = await client.conversations.spaceReader(c.project_id, { signal: ctx.signal });
|
|
211
|
+
source = await client.workspaces.get(reader.workspace.id);
|
|
212
|
+
if (!source.ready)
|
|
213
|
+
await source.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
|
|
214
|
+
}
|
|
215
|
+
let skipped = state.skipped.get(c.id);
|
|
216
|
+
if (!skipped) {
|
|
217
|
+
skipped = new Set();
|
|
218
|
+
state.skipped.set(c.id, skipped);
|
|
143
219
|
}
|
|
144
|
-
|
|
220
|
+
await mirrorConversation(ctx, { ...record, conversation: c }, source, skipped);
|
|
221
|
+
retry.failures = 0;
|
|
222
|
+
retry.retryAt = 0;
|
|
223
|
+
// An ended record may precede its VM tombstone; retry until the handoff receipt is written.
|
|
224
|
+
if (c.state !== 'done' && c.state !== 'archived')
|
|
225
|
+
state.mirrored.set(c.id, revision);
|
|
145
226
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
227
|
+
catch (e) {
|
|
228
|
+
if (ctx.signal.aborted)
|
|
229
|
+
return;
|
|
230
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
231
|
+
const backoff = Math.min(300_000, 5000 * 2 ** Math.min(retry.failures++, 6));
|
|
232
|
+
retry.retryAt = Date.now() + backoff;
|
|
233
|
+
if (!retry.reported.has(message)) {
|
|
234
|
+
retry.reported.add(message);
|
|
235
|
+
ctx.io.stderr.write(`Transcript mirror ${c.id}: ${message} (retrying in ${backoff / 1000}s).\n`);
|
|
236
|
+
}
|
|
152
237
|
}
|
|
153
|
-
await mirrorConversation(ctx, { ...record, conversation: c }, source);
|
|
154
|
-
// An ended record may precede its VM tombstone; retry until the handoff receipt is written.
|
|
155
|
-
if (c.state !== 'done' && c.state !== 'archived')
|
|
156
|
-
mirrored.set(c.id, revision);
|
|
157
238
|
}
|
|
158
239
|
}
|
|
@@ -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/device-helper.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
2
|
import { DeviceExecutor, askConnectedAcp } from "./device-bridge.js";
|
|
3
|
-
import { mirrorProjectConversations } from "./conversation-stash.js";
|
|
3
|
+
import { ConversationMirrorState, mirrorProjectConversations } from "./conversation-stash.js";
|
|
4
4
|
import { withTargetLock } from "./target-lock.js";
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { conversationsUnavailable } from "./conversations.js";
|
|
@@ -49,7 +49,7 @@ export async function runDeviceHelper(ctx) {
|
|
|
49
49
|
});
|
|
50
50
|
};
|
|
51
51
|
const working = new Map();
|
|
52
|
-
const mirrored = new
|
|
52
|
+
const mirrored = new ConversationMirrorState();
|
|
53
53
|
const deviceFile = join(ctx.store.dir, 'device-id');
|
|
54
54
|
let deviceId;
|
|
55
55
|
try {
|
|
@@ -61,8 +61,32 @@ export async function runDeviceHelper(ctx) {
|
|
|
61
61
|
}
|
|
62
62
|
const executor = new DeviceExecutor(ctx, ask, deviceId);
|
|
63
63
|
const report = (e) => { ctx.io.stderr.write(`Device bridge: ${e instanceof Error ? e.message : String(e)}\n`); };
|
|
64
|
+
let mirroring;
|
|
64
65
|
try {
|
|
65
66
|
return await withTargetLock(join(ctx.store.dir, 'device-helper-lock'), async () => {
|
|
67
|
+
// A slow or failed transcript copy must not delay heartbeats, request polling or approvals.
|
|
68
|
+
mirroring = (async () => {
|
|
69
|
+
let failures = 0;
|
|
70
|
+
const reported = new Set();
|
|
71
|
+
while (!signal.aborted) {
|
|
72
|
+
let wait = 1000;
|
|
73
|
+
try {
|
|
74
|
+
await mirrorProjectConversations(ctx, mirrored);
|
|
75
|
+
failures = 0;
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
if (signal.aborted)
|
|
79
|
+
break;
|
|
80
|
+
wait = Math.min(300_000, 5000 * 2 ** Math.min(failures++, 6));
|
|
81
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
82
|
+
if (!conversationsUnavailable(e) && !reported.has(message)) {
|
|
83
|
+
reported.add(message);
|
|
84
|
+
ctx.io.stderr.write(`Transcript mirror discovery: ${message} (retrying in ${wait / 1000}s).\n`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
await delay(wait, undefined, { signal }).catch(() => undefined);
|
|
88
|
+
}
|
|
89
|
+
})();
|
|
66
90
|
while (!signal.aborted) {
|
|
67
91
|
try {
|
|
68
92
|
for (const projectId of new Set(Object.values(localConversations(ctx)).map(r => r.project_id))) {
|
|
@@ -80,7 +104,6 @@ export async function runDeviceHelper(ctx) {
|
|
|
80
104
|
} while (cursor && !signal.aborted);
|
|
81
105
|
}
|
|
82
106
|
await executor.reap();
|
|
83
|
-
await mirrorProjectConversations(ctx, mirrored);
|
|
84
107
|
}
|
|
85
108
|
catch (e) {
|
|
86
109
|
if (!signal.aborted && !conversationsUnavailable(e))
|
|
@@ -99,6 +122,7 @@ export async function runDeviceHelper(ctx) {
|
|
|
99
122
|
pending.clear();
|
|
100
123
|
await executor.close();
|
|
101
124
|
await Promise.all([...working.values()]);
|
|
125
|
+
await mirroring;
|
|
102
126
|
await input.catch(() => undefined);
|
|
103
127
|
}
|
|
104
128
|
}
|
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.3",
|
|
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",
|