@shardflux/cli 0.24.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +119 -0
- package/README.md +227 -1
- package/dist/account-args.js +5 -0
- package/dist/account-pool.js +5 -1
- package/dist/acp-permission-elsewhere.d.ts +38 -0
- package/dist/acp-permission-elsewhere.js +85 -0
- package/dist/acp-rpc.d.ts +2 -1
- package/dist/acp-rpc.js +11 -2
- package/dist/acp.js +95 -22
- package/dist/agent-credentials.d.ts +3 -0
- package/dist/agent-credentials.js +7 -0
- package/dist/args.d.ts +1 -1
- package/dist/args.js +143 -8
- package/dist/board-args.d.ts +6 -0
- package/dist/board-args.js +155 -0
- package/dist/board-commands.d.ts +62 -0
- package/dist/board-commands.js +395 -0
- package/dist/codex-cloud.d.ts +30 -3
- package/dist/codex-cloud.js +86 -12
- package/dist/conversation-commands.d.ts +30 -0
- package/dist/conversation-commands.js +312 -9
- package/dist/conversation-transcript.d.ts +44 -0
- package/dist/conversation-transcript.js +206 -0
- package/dist/conversations.d.ts +5 -2
- package/dist/conversations.js +20 -10
- package/dist/device-approvals.js +4 -3
- package/dist/device-bridge.d.ts +42 -1
- package/dist/device-bridge.js +223 -12
- package/dist/device-folders.d.ts +58 -0
- package/dist/device-folders.js +228 -0
- package/dist/device-helper.d.ts +6 -0
- package/dist/device-helper.js +87 -14
- package/dist/drive-commands.d.ts +25 -0
- package/dist/drive-commands.js +201 -0
- package/dist/handlers.js +6 -1
- package/dist/main.js +3 -0
- package/dist/move-files.d.ts +39 -0
- package/dist/move-files.js +167 -0
- package/dist/move.js +12 -11
- package/dist/onboarding.d.ts +6 -2
- package/dist/onboarding.js +24 -6
- package/dist/project-git.d.ts +7 -1
- package/dist/project-git.js +16 -3
- package/dist/project-preparation.d.ts +7 -0
- package/dist/project-preparation.js +14 -0
- package/dist/project-template-setup.js +32 -11
- package/dist/router-commands.d.ts +22 -0
- package/dist/router-commands.js +179 -0
- package/package.json +2 -2
package/dist/move.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { createHash } from 'node:crypto';
|
|
16
16
|
import { execFileSync } from 'node:child_process';
|
|
17
17
|
import { existsSync, realpathSync } from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
18
19
|
import { dirname, relative, resolve } from 'node:path';
|
|
19
20
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
20
21
|
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
@@ -31,13 +32,12 @@ import { conversationTerminal } from "./conversation-terminal.js";
|
|
|
31
32
|
import { openInChoices } from "./t3-mode.js";
|
|
32
33
|
import { openClaudeDesktopSession, remoteControlUrl } from "./agent-target-commands.js";
|
|
33
34
|
import { openUrl } from "./browser.js";
|
|
34
|
-
import { SYNC_CHUNK_BYTES } from "./project-sync-transfer.js";
|
|
35
35
|
import { completeRecords, continuedAfter, findLocalSession, homeRelativeTranscript, listLocalSessions, readTranscript, recordCwd, rewriteSessionPaths, sessionState } from "./local-sessions.js";
|
|
36
36
|
import { installDivergenceNotice, movedKey, movedSession, movedSessions, rememberMoved } from "./moved-sessions.js";
|
|
37
37
|
import { captureWorktreeMigration, inspectWorktreeMigration, migrationScope, preserveWorktreeMigration, referencedWorktrees, rewriteWorktreeTranscript } from "./move-worktrees.js";
|
|
38
38
|
import { interruptLocalTurn, MOVE_CONTINUATION, stopsLocalTurn } from "./move-interrupt.js";
|
|
39
39
|
import { seedPhase } from "./seed-phases.js";
|
|
40
|
-
|
|
40
|
+
import { homeSpelling, placeSessionFiles, sessionFiles, VM_HOME, writeChunks } from "./move-files.js";
|
|
41
41
|
const SPACE = '/shardflux/project';
|
|
42
42
|
/** A transcript above this moves with the repository's history bound. */
|
|
43
43
|
const TRANSCRIPT_LIMIT = 256 << 20;
|
|
@@ -86,14 +86,6 @@ export async function waitForTurnEnd(ctx, s, env, pollMs = 1000) {
|
|
|
86
86
|
await delay(pollMs, undefined, { signal: ctx.signal });
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
-
async function writeChunks(cell, path, bytes) {
|
|
90
|
-
if (!bytes.length) {
|
|
91
|
-
await cell.files.write(path, '', { mode: '0600' });
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
for (let at = 0; at < bytes.length; at += SYNC_CHUNK_BYTES)
|
|
95
|
-
await cell.files.write(path, bytes.subarray(at, at + SYNC_CHUNK_BYTES), { mode: '0600', append: at > 0 });
|
|
96
|
-
}
|
|
97
89
|
/**
|
|
98
90
|
* The session's transcript in the conversation's VM: at its native path (where the harness resumes it) and, when the
|
|
99
91
|
* project space is mounted, as the conversation's durable copy with the same checkpoint metadata the guest writes
|
|
@@ -252,6 +244,13 @@ export async function moveSession(ctx, values) {
|
|
|
252
244
|
if (elsewhere.length && !ctx.json)
|
|
253
245
|
ctx.io.stderr.write(`This session also worked in ${elsewhere.length === 1 ? 'another worktree' : `${elsewhere.length} other worktrees`} of this repository (${elsewhere.join(', ')}); ${elsewhere.length === 1 ? 'it stays' : 'they stay'} on this computer. shard move --preserve-all-worktrees copies every worktree.\n`);
|
|
254
246
|
}
|
|
247
|
+
// Files it used outside the project (an @-mentioned ~/Downloads/handoff.md) go along at the same paths (0.25.0+).
|
|
248
|
+
const home = env.HOME || homedir();
|
|
249
|
+
const files = sessionFiles(bytes, s.harness, home, [folder, ...(plan?.trees.map(t => t.source) ?? []), ...elsewhere]);
|
|
250
|
+
if (!ctx.json && files.files.length)
|
|
251
|
+
ctx.io.stderr.write(`Bringing ${files.files.length === 1 ? 'a file' : `${files.files.length} files`} it used outside the project: ${files.files.map(f => homeSpelling(home, f.path)).join(', ')}\n`);
|
|
252
|
+
if (!ctx.json && files.left.length)
|
|
253
|
+
ctx.io.stderr.write(`Staying on this computer (past the move’s 64 files, 64 MiB): ${files.left.map(p => homeSpelling(home, p)).join(', ')}\n`);
|
|
255
254
|
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
256
255
|
if (!ctx.json)
|
|
257
256
|
ctx.io.stderr.write(`Moving ${s.harness === 'claude' ? 'Claude Code' : 'Codex'} session “${s.title}” to the cloud…\n`);
|
|
@@ -270,6 +269,7 @@ export async function moveSession(ctx, values) {
|
|
|
270
269
|
// The workspace's cell handle is shared with the seed that follows: it stays open.
|
|
271
270
|
beforeSeed: async (ws, c) => {
|
|
272
271
|
placed = await placeTranscript(ws.cell(), c, s, bytes, folder, ctx.signal, values['preserve-all-worktrees'] === true && plan ? plan : undefined);
|
|
272
|
+
await placeSessionFiles(ws.cell(), files, home, ctx.signal);
|
|
273
273
|
},
|
|
274
274
|
});
|
|
275
275
|
}
|
|
@@ -303,7 +303,8 @@ export async function moveSession(ctx, values) {
|
|
|
303
303
|
if (!attach) {
|
|
304
304
|
const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
|
|
305
305
|
await conversationTerminal(quiet, ws, c, place, { ...terminalValues, detach: true });
|
|
306
|
-
out(ctx, `Moved to the cloud as ${c.key}: the same session, with its history. Open it: shard agents open ${c.key}\n`, { moved, conversation: c, workspace: ws.data, ...(placed ? { transcript: placed } : {}), ...(preserved ? { preserved } : {}), ...(elsewhere.length ? { other_worktrees: elsewhere } : {})
|
|
306
|
+
out(ctx, `Moved to the cloud as ${c.key}: the same session, with its history. Open it: shard agents open ${c.key}\n`, { moved, conversation: c, workspace: ws.data, ...(placed ? { transcript: placed } : {}), ...(preserved ? { preserved } : {}), ...(elsewhere.length ? { other_worktrees: elsewhere } : {}),
|
|
307
|
+
...(files.files.length ? { files: files.files.map(f => f.path) } : {}), ...(files.left.length ? { files_left: files.left } : {}) });
|
|
307
308
|
return 0;
|
|
308
309
|
}
|
|
309
310
|
const where = openInChoices(ctx, project.slug)[s.harness];
|
package/dist/onboarding.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ type CredentialRow = {
|
|
|
22
22
|
app: string;
|
|
23
23
|
status?: string;
|
|
24
24
|
scope?: string;
|
|
25
|
+
fingerprint?: string | null;
|
|
25
26
|
access?: {
|
|
26
27
|
verdict: 'usable' | 'refused' | 'unknown';
|
|
27
28
|
checked_at: string;
|
|
@@ -75,8 +76,11 @@ export interface T3SignInRow {
|
|
|
75
76
|
export declare function t3Connection(ctx: Ctx, signedIn: boolean, appDirs?: string[], platform?: NodeJS.Platform, startSignIn?: boolean, scope?: Values): Promise<Connection | null>;
|
|
76
77
|
/** Claude: the account login is ready and Claude itself accepts it right now. */
|
|
77
78
|
export declare function claudeConnection(row: CredentialRow | undefined): Omit<Connection, 'id' | 'picked' | 'found'>;
|
|
78
|
-
/**
|
|
79
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Codex: this computer's Codex login is copied and the account's Codex remote device (one executor) is connected.
|
|
81
|
+
* `renewed`: this computer has a Codex login other than the one in the cloud (a new sign-in not copied yet).
|
|
82
|
+
*/
|
|
83
|
+
export declare function codexConnection(local: boolean, synced: boolean, connector: CodexConnectorView | null, health: CodexHealth, renewed?: boolean): Omit<Connection, 'id' | 'picked' | 'found'>;
|
|
80
84
|
/**
|
|
81
85
|
* The onboarding report, for an agent setting itself up from chat and for the menu bar app. It verifies every
|
|
82
86
|
* connection live: the Shardflux account, Claude (Claude itself accepts the login) and Codex (the login is copied and
|
package/dist/onboarding.js
CHANGED
|
@@ -7,9 +7,9 @@ import { AgentStateStore } from "./agent-state.js";
|
|
|
7
7
|
import { describeError } from "./errors.js";
|
|
8
8
|
import { t3PhoneLink } from "./t3-phone.js";
|
|
9
9
|
import { t3NeedsProject, t3SignInProject } from "./account-scope.js";
|
|
10
|
-
import { readAgentCredentials, syncAgentCredentials } from "./agent-credentials.js";
|
|
10
|
+
import { credentialFingerprint, readAgentCredentials, syncAgentCredentials } from "./agent-credentials.js";
|
|
11
11
|
import { getConversation } from "./conversations.js";
|
|
12
|
-
import { codexHealth, readCodexConnector, restartCodexConnector } from "./codex-cloud.js";
|
|
12
|
+
import { CODEX_SIGN_IN_LINE, codexHealth, readCodexConnector, restartCodexConnector } from "./codex-cloud.js";
|
|
13
13
|
import { applicationDirs } from "./agent-targets.js";
|
|
14
14
|
import { configureT3Provider } from "./t3-provider.js";
|
|
15
15
|
import { t3Install } from "./t3-mode.js";
|
|
@@ -198,8 +198,11 @@ export function claudeConnection(row) {
|
|
|
198
198
|
return { state: 'action', fact: 'Claude no longer accepts this sign-in.', action: { ...signIn, title: 'Sign in to Claude again' } };
|
|
199
199
|
return { state: 'verified', fact: 'Signed in. Works in every conversation, on web and phone.' };
|
|
200
200
|
}
|
|
201
|
-
/**
|
|
202
|
-
|
|
201
|
+
/**
|
|
202
|
+
* Codex: this computer's Codex login is copied and the account's Codex remote device (one executor) is connected.
|
|
203
|
+
* `renewed`: this computer has a Codex login other than the one in the cloud (a new sign-in not copied yet).
|
|
204
|
+
*/
|
|
205
|
+
export function codexConnection(local, synced, connector, health, renewed = false) {
|
|
203
206
|
const enable = { title: 'Connect Codex', command: shard(['agents', 'codex', 'enable']) };
|
|
204
207
|
if (!synced) {
|
|
205
208
|
return local ? { state: 'action', fact: 'Available on web and phone once connected.', action: enable }
|
|
@@ -215,6 +218,11 @@ export function codexConnection(local, synced, connector, health) {
|
|
|
215
218
|
case 'failed': return { state: 'action', fact: 'Codex’s remote device didn’t start.', action: retry };
|
|
216
219
|
case 'stuck': return { state: 'action', fact: 'Codex’s remote device is taking too long to start.', action: retry };
|
|
217
220
|
case 'attention': return { state: 'action', fact: connector?.last_error ? `Codex needs attention: ${connector.last_error}` : 'Codex needs attention.', action: retry };
|
|
221
|
+
// Codex Cloud refused the copied ChatGPT login (0.25.0+): sign in to Codex again here, then copy that sign-in, which
|
|
222
|
+
// starts the device again by itself. Never Retry: a restart cannot fix a sign-in.
|
|
223
|
+
case 'sign_in': return renewed
|
|
224
|
+
? { state: 'action', fact: 'Your new Codex sign-in is ready to copy to the cloud.', action: { title: 'Copy sign-in', command: shard(['agents', 'credentials', 'sync']) } }
|
|
225
|
+
: { state: 'action', fact: CODEX_SIGN_IN_LINE, action: { title: 'Sign in to Codex again', command: ['codex', 'login'] } };
|
|
218
226
|
}
|
|
219
227
|
}
|
|
220
228
|
/**
|
|
@@ -237,7 +245,10 @@ export async function onboardingReport(ctx, values, platform = process.platform)
|
|
|
237
245
|
let connector = null;
|
|
238
246
|
let health = 'missing';
|
|
239
247
|
const home = ctx.io.env.HOME ?? ctx.io.env.USERPROFILE;
|
|
240
|
-
const
|
|
248
|
+
const localLogin = home ? (await readAgentCredentials({ home, redact: value => ctx.addSecret(value) })).find(v => v.app === 'codex') : undefined;
|
|
249
|
+
const localCodex = localLogin !== undefined;
|
|
250
|
+
/** Whether this computer's Codex login differs from the copy in the cloud. */
|
|
251
|
+
const renewed = (rows) => localLogin !== undefined && rows.find(row => row.app === 'codex')?.fingerprint !== credentialFingerprint(localLogin.files);
|
|
241
252
|
try {
|
|
242
253
|
ctx.apiKey();
|
|
243
254
|
const principal = await ctx.cloud().me();
|
|
@@ -284,6 +295,13 @@ export async function onboardingReport(ctx, values, platform = process.platform)
|
|
|
284
295
|
read = { connector: await ctx.cloud().codex.enable({ signal: ctx.signal }), workspaceState: null };
|
|
285
296
|
health = codexHealth(read.connector, null);
|
|
286
297
|
}
|
|
298
|
+
else if (connect && synced && health === 'sign_in' && renewed(credentials) && home) {
|
|
299
|
+
// A new Codex sign-in on this computer: copy it, and the device starts again with it (never a restart).
|
|
300
|
+
await syncAgentCredentials(ctx.cloud(), { home, redact: value => ctx.addSecret(value) });
|
|
301
|
+
credentials = await listCredentials();
|
|
302
|
+
read = { connector: await ctx.cloud().codex.enable({ signal: ctx.signal }), workspaceState: null };
|
|
303
|
+
health = codexHealth(read.connector, null);
|
|
304
|
+
}
|
|
287
305
|
else if (connect && synced && values['retry-codex'] === true && (health === 'failed' || health === 'stuck' || health === 'attention')) {
|
|
288
306
|
read = { connector: await restartCodexConnector(ctx), workspaceState: null };
|
|
289
307
|
health = codexHealth(read.connector, null);
|
|
@@ -304,7 +322,7 @@ export async function onboardingReport(ctx, values, platform = process.platform)
|
|
|
304
322
|
...(!connected ? signIn : codexError instanceof ShardfluxApiError && codexError.reason === 'codex_chatgpt_login_required'
|
|
305
323
|
? { state: 'action', fact: codexError.message, action: { title: 'Sign in with ChatGPT', command: ['codex', 'login'] } }
|
|
306
324
|
: (credentialError ?? codexError) !== undefined ? failure(credentialError ?? codexError)
|
|
307
|
-
: codexConnection(localCodex, credentials.some(row => row.app === 'codex'), connector, health)) };
|
|
325
|
+
: codexConnection(localCodex, credentials.some(row => row.app === 'codex'), connector, health, renewed(credentials))) };
|
|
308
326
|
const t3 = await t3Connection(ctx, connected || ctx.sessionSource() !== null, undefined, undefined, values['t3-connect'] === true, values);
|
|
309
327
|
const connections = [account, claude, codex, ...(t3 ? [t3] : [])];
|
|
310
328
|
// The cloud machine (0.20.2+), the same row `shard machine setup --status` reports: whether what is installed carries
|
package/dist/project-git.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
import type { Ctx } from './commands.js';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Prepare only the selected project root; never a nested repository. A folder without Git becomes a repository whose
|
|
4
|
+
* first commit is its files (.gitignore respected; large untracked media stay untracked, as a twin snapshot leaves
|
|
5
|
+
* them): a conversation then starts from the same commit as the folder and its work fast-forwards back, as in a
|
|
6
|
+
* repository the person made (0.25.0+; before, an empty first commit left every file to a snapshot, and landings
|
|
7
|
+
* conflicted). A repository of the person's own keeps its staged and untracked files as they are.
|
|
8
|
+
*/
|
|
3
9
|
export declare function ensureProjectGit(ctx: Ctx, folder: string): Promise<void>;
|
package/dist/project-git.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { existsSync, realpathSync } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { gitRunner } from "./agent-git.js";
|
|
4
5
|
import { GitError } from "./errors.js";
|
|
5
|
-
|
|
6
|
+
import { largeUntrackedMedia, outsideSpecs } from "./large-media.js";
|
|
7
|
+
/**
|
|
8
|
+
* Prepare only the selected project root; never a nested repository. A folder without Git becomes a repository whose
|
|
9
|
+
* first commit is its files (.gitignore respected; large untracked media stay untracked, as a twin snapshot leaves
|
|
10
|
+
* them): a conversation then starts from the same commit as the folder and its work fast-forwards back, as in a
|
|
11
|
+
* repository the person made (0.25.0+; before, an empty first commit left every file to a snapshot, and landings
|
|
12
|
+
* conflicted). A repository of the person's own keeps its staged and untracked files as they are.
|
|
13
|
+
*/
|
|
6
14
|
export async function ensureProjectGit(ctx, folder) {
|
|
7
15
|
const git = gitRunner(folder, { ...process.env, ...ctx.io.env, LC_ALL: 'C' }, ctx.signal, false);
|
|
8
16
|
let initialized = false;
|
|
@@ -37,7 +45,12 @@ export async function ensureProjectGit(ctx, folder) {
|
|
|
37
45
|
throw error;
|
|
38
46
|
}
|
|
39
47
|
// --only with no paths makes an empty commit even when the user already has staged files.
|
|
40
|
-
|
|
48
|
+
let commit = ['-c', 'core.hooksPath=/dev/null', 'commit', '--allow-empty', '--only', '-m', 'Initial commit'];
|
|
49
|
+
if (initialized) {
|
|
50
|
+
const media = await largeUntrackedMedia(folder, ctx.store.dir, `init-${createHash('sha256').update(folder).digest('hex').slice(0, 16)}`, ctx.signal);
|
|
51
|
+
await git(['add', '-A', '--', '.', ...outsideSpecs(media)]);
|
|
52
|
+
commit = ['-c', 'core.hooksPath=/dev/null', 'commit', '--allow-empty', '-m', 'Initial commit'];
|
|
53
|
+
}
|
|
41
54
|
try {
|
|
42
55
|
await git(commit);
|
|
43
56
|
}
|
|
@@ -47,6 +60,6 @@ export async function ensureProjectGit(ctx, folder) {
|
|
|
47
60
|
await git(['-c', 'user.name=Shardflux', '-c', 'user.email=noreply@shardflux.dev', ...commit]);
|
|
48
61
|
}
|
|
49
62
|
ctx.io.stderr.write(initialized
|
|
50
|
-
? `Initialized a git repository in ${folder} (shard tracks your project with git).\n`
|
|
63
|
+
? `Initialized a git repository in ${folder} with its files as the first commit (shard tracks your project with git).\n`
|
|
51
64
|
: `Created an initial empty commit in ${folder}.\n`);
|
|
52
65
|
}
|
|
@@ -38,6 +38,13 @@ export interface SetupStatus {
|
|
|
38
38
|
}[];
|
|
39
39
|
}
|
|
40
40
|
export declare function setupFailure(status: SetupStatus): CodedError;
|
|
41
|
+
/**
|
|
42
|
+
* A setup that failed only because the project has no build or test command (the guest's `.:checks` step, unresolved
|
|
43
|
+
* at detection) has nothing left to prepare: its files are in the cache and every other step succeeded. That is a
|
|
44
|
+
* clean ready, never an error that waits for an edit (0.25.0+; before, a plain folder from `projects link` stayed
|
|
45
|
+
* flagged).
|
|
46
|
+
*/
|
|
47
|
+
export declare function nothingToCheck(status: SetupStatus): boolean;
|
|
41
48
|
/** New content past this, or a dependency change, makes a quiet project worth preparing again. */
|
|
42
49
|
export declare const PREPARE_AGAIN_BYTES: number;
|
|
43
50
|
/** Quiet this long after the last edit before preparing again. */
|
|
@@ -40,6 +40,18 @@ export function setupFailure(status) {
|
|
|
40
40
|
const fix = `${first}${step ? `, or set run for ${step.name} under steps in .shardflux/setup.yaml` : ''}; the project is prepared again after the change`;
|
|
41
41
|
return new SetupFailed('project_setup_failed', message, fix, step ? { step: step.name, ...(step.error?.code ? { error_code: step.error.code } : {}) } : undefined);
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* A setup that failed only because the project has no build or test command (the guest's `.:checks` step, unresolved
|
|
45
|
+
* at detection) has nothing left to prepare: its files are in the cache and every other step succeeded. That is a
|
|
46
|
+
* clean ready, never an error that waits for an edit (0.25.0+; before, a plain folder from `projects link` stayed
|
|
47
|
+
* flagged).
|
|
48
|
+
*/
|
|
49
|
+
export function nothingToCheck(status) {
|
|
50
|
+
const failed = status.tasks?.filter(t => t.state === 'failed') ?? [];
|
|
51
|
+
return status.state === 'failed' && failed.length > 0
|
|
52
|
+
&& failed.every(t => t.error?.code === 'setup_detection_unresolved' && /(^|:)checks$/.test(t.name))
|
|
53
|
+
&& (status.tasks ?? []).every(t => t.state === 'failed' ? failed.includes(t) : ['succeeded', 'skipped'].includes(t.state));
|
|
54
|
+
}
|
|
43
55
|
/** New content past this, or a dependency change, makes a quiet project worth preparing again. */
|
|
44
56
|
export const PREPARE_AGAIN_BYTES = 8 << 20;
|
|
45
57
|
/** Quiet this long after the last edit before preparing again. */
|
|
@@ -158,6 +170,8 @@ export async function prepareSelectedProject(ctx, selected) {
|
|
|
158
170
|
if (status.exitCode !== 0)
|
|
159
171
|
throw new Error(`Project setup status failed: ${status.stderr.trim()}`);
|
|
160
172
|
const state = JSON.parse(status.stdout);
|
|
173
|
+
if (nothingToCheck(state))
|
|
174
|
+
break;
|
|
161
175
|
if (state.state === 'failed')
|
|
162
176
|
throw setupFailure(state);
|
|
163
177
|
if (Date.now() > deadline)
|
|
@@ -23,20 +23,41 @@ export function dependencyHash(folder) {
|
|
|
23
23
|
throw error;
|
|
24
24
|
}
|
|
25
25
|
const files = [];
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
const wanted = (path, name, inside) => name !== '.env' && !name.startsWith('.env.') && (inside || path.startsWith('.github/workflows/') || dependencyName(name));
|
|
27
|
+
if (selected) {
|
|
28
|
+
// Git's list is the whole input: walking the folder visited every ignored and untracked tree too (a folder of
|
|
29
|
+
// agent worktrees held 1.2 million entries and cost 29 s of every start).
|
|
30
|
+
for (const path of selected) {
|
|
31
|
+
const parts = path.split('/'), name = parts.at(-1);
|
|
32
|
+
if (!name || parts.slice(0, -1).some(dir => skippedDirs.has(dir)) || !wanted(path, name, parts.includes('.devcontainer')))
|
|
33
|
+
continue;
|
|
34
|
+
let st;
|
|
35
|
+
try {
|
|
36
|
+
st = lstatSync(join(folder, path));
|
|
34
37
|
}
|
|
35
|
-
|
|
38
|
+
catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (st.isFile() || st.isSymbolicLink())
|
|
36
42
|
files.push(path);
|
|
37
43
|
}
|
|
38
|
-
}
|
|
39
|
-
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
const walk = (relative, devcontainer) => {
|
|
47
|
+
for (const name of readdirSync(join(folder, relative)).sort()) {
|
|
48
|
+
const path = relative ? `${relative}/${name}` : name;
|
|
49
|
+
const st = lstatSync(join(folder, path));
|
|
50
|
+
const inside = devcontainer || name === '.devcontainer';
|
|
51
|
+
if (st.isDirectory()) {
|
|
52
|
+
if (!skippedDirs.has(name))
|
|
53
|
+
walk(path, inside);
|
|
54
|
+
}
|
|
55
|
+
else if ((st.isFile() || st.isSymbolicLink()) && wanted(path, name, inside))
|
|
56
|
+
files.push(path);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
walk('', false);
|
|
60
|
+
}
|
|
40
61
|
const hash = createHash('sha256').update('shardflux-deps-v2\0');
|
|
41
62
|
for (const path of files.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))) {
|
|
42
63
|
const st = lstatSync(join(folder, path));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RouterResult, Shardflux, ShardfluxAccount } from '@shardflux/sdk';
|
|
2
|
+
import type { Ctx, Handler } from './commands.js';
|
|
3
|
+
/** The API's bound on one input or question (characters). */
|
|
4
|
+
export declare const BOARD_TEXT_MAX = 4000;
|
|
5
|
+
interface BoardClient {
|
|
6
|
+
client: Shardflux | ShardfluxAccount;
|
|
7
|
+
/** The signed-in session: the organization's whole board. */
|
|
8
|
+
session: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** SHARDFLUX_API_KEY in the environment wins; else the signed-in session; else the saved key (its error names both). */
|
|
11
|
+
export declare function boardClient(ctx: Ctx): BoardClient;
|
|
12
|
+
/**
|
|
13
|
+
* The readable form of a RouterResult: the kind and where it goes, then each part (a split's parts numbered, with
|
|
14
|
+
* their titles).
|
|
15
|
+
*
|
|
16
|
+
* 2 tasks in site
|
|
17
|
+
* 1. Fix the landing page hero image
|
|
18
|
+
* fix the hero image on the landing page
|
|
19
|
+
*/
|
|
20
|
+
export declare function routeText(r: RouterResult, names: ReadonlyMap<string, string>): string;
|
|
21
|
+
export declare const ROUTER_HANDLERS: Record<string, Handler>;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shard route` and `shard ask` (0.25.0+): the board's input bar from a terminal, through @shardflux/sdk's
|
|
3
|
+
* `router.route()` and `questions.stream()` / `questions.ask()`. The signed-in session reads every project of an
|
|
4
|
+
* organization; SHARDFLUX_API_KEY in the environment (or a saved key without a session) reads that key's project.
|
|
5
|
+
*/
|
|
6
|
+
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
7
|
+
import { UUID } from "./args.js";
|
|
8
|
+
import { findOrg, resolveOrg, resolveProject } from "./account-scope.js";
|
|
9
|
+
import { out, str } from "./commands.js";
|
|
10
|
+
import { EXIT, UsageError } from "./errors.js";
|
|
11
|
+
/** The API's bound on one input or question (characters). */
|
|
12
|
+
export const BOARD_TEXT_MAX = 4000;
|
|
13
|
+
const TASK_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
14
|
+
/** SHARDFLUX_API_KEY in the environment wins; else the signed-in session; else the saved key (its error names both). */
|
|
15
|
+
export function boardClient(ctx) {
|
|
16
|
+
if (!ctx.io.env.SHARDFLUX_API_KEY?.trim() && ctx.sessionSource() !== null)
|
|
17
|
+
return { client: ctx.account(), session: true };
|
|
18
|
+
return { client: ctx.cloud(), session: false };
|
|
19
|
+
}
|
|
20
|
+
/** --org / --project as ids (slugs resolved with the session); with neither, the saved default organization. */
|
|
21
|
+
async function scopeOf(ctx, values, b) {
|
|
22
|
+
const org = str(values.org), project = str(values.project);
|
|
23
|
+
if (!b.session) {
|
|
24
|
+
for (const [flag, v] of [['--org', org], ['--project', project]]) {
|
|
25
|
+
if (v !== undefined && !UUID.test(v))
|
|
26
|
+
throw new UsageError(`${flag} must be an id (UUID) with a project API key`, 'or sign in (shard auth login) to name it by slug');
|
|
27
|
+
}
|
|
28
|
+
return { scope: { ...(org === undefined ? {} : { organizationId: org }), ...(project === undefined ? {} : { projectId: project }) }, defaulted: false };
|
|
29
|
+
}
|
|
30
|
+
const account = b.client;
|
|
31
|
+
const scope = {};
|
|
32
|
+
if (org !== undefined)
|
|
33
|
+
scope.organizationId = UUID.test(org) ? org : (await findOrg(account, org)).id;
|
|
34
|
+
if (project !== undefined)
|
|
35
|
+
scope.projectId = UUID.test(project) ? project : (await resolveProject(ctx, account, values)).id;
|
|
36
|
+
if (org !== undefined || project !== undefined)
|
|
37
|
+
return { scope, defaulted: false };
|
|
38
|
+
const saved = ctx.store.profile(ctx.apiUrl).organization_id;
|
|
39
|
+
return { scope: saved ? { organizationId: saved } : {}, defaulted: true };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Runs `call` in the board's scope. With the session and no --org/--project: the saved default organization, else the
|
|
43
|
+
* only one; when the person is in several and none is saved (422), or the saved one is gone (404), the error lists
|
|
44
|
+
* theirs and names the fix (resolveOrg), and an organization that resolves is used.
|
|
45
|
+
*/
|
|
46
|
+
async function inScope(ctx, values, call) {
|
|
47
|
+
const b = boardClient(ctx);
|
|
48
|
+
const { scope, defaulted } = await scopeOf(ctx, values, b);
|
|
49
|
+
try {
|
|
50
|
+
return await call(b.client, scope);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (!b.session || !defaulted || !(err instanceof ShardfluxApiError))
|
|
54
|
+
throw err;
|
|
55
|
+
const several = err.status === 422 && scope.organizationId === undefined && /organization/i.test(err.message);
|
|
56
|
+
const gone = err.status === 404 && scope.organizationId !== undefined;
|
|
57
|
+
if (!several && !gone)
|
|
58
|
+
throw err;
|
|
59
|
+
const org = await resolveOrg(ctx, b.client, {});
|
|
60
|
+
if (org.id === scope.organizationId)
|
|
61
|
+
throw err;
|
|
62
|
+
return call(b.client, { organizationId: org.id });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function textOf(words, what, command) {
|
|
66
|
+
const text = words.join(' ').trim();
|
|
67
|
+
if (!text)
|
|
68
|
+
throw new UsageError(`missing <${what}> for "shard ${command}"`);
|
|
69
|
+
if (text.length > BOARD_TEXT_MAX)
|
|
70
|
+
throw new UsageError(`the ${what} is ${text.length} characters; the limit is ${BOARD_TEXT_MAX}`);
|
|
71
|
+
return text;
|
|
72
|
+
}
|
|
73
|
+
function taskOf(values) {
|
|
74
|
+
const task = str(values.task);
|
|
75
|
+
if (task !== undefined && !TASK_ID.test(task))
|
|
76
|
+
throw new UsageError('--task must be a task id (task_…)');
|
|
77
|
+
return task;
|
|
78
|
+
}
|
|
79
|
+
/** Project names for the readable form (GET /v1/projects/{id}); an id that cannot be read prints as itself. */
|
|
80
|
+
async function projectNames(client, parts, signal) {
|
|
81
|
+
const ids = [...new Set(parts.filter((p) => !p.home && p.project_id).map((p) => p.project_id))];
|
|
82
|
+
const names = await Promise.all(ids.map((id) => client.request('GET', `/v1/projects/${encodeURIComponent(id)}`, { signal }).then((p) => p.name, () => id)));
|
|
83
|
+
return new Map(ids.map((id, i) => [id, names[i]]));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The readable form of a RouterResult: the kind and where it goes, then each part (a split's parts numbered, with
|
|
87
|
+
* their titles).
|
|
88
|
+
*
|
|
89
|
+
* 2 tasks in site
|
|
90
|
+
* 1. Fix the landing page hero image
|
|
91
|
+
* fix the hero image on the landing page
|
|
92
|
+
*/
|
|
93
|
+
export function routeText(r, names) {
|
|
94
|
+
const where = (p) => (p.home ? 'on your home' : p.project_id ? `in ${names.get(p.project_id) ?? p.project_id}` : '');
|
|
95
|
+
const parts = r.parts;
|
|
96
|
+
const same = parts.every((p) => where(p) === where(parts[0]));
|
|
97
|
+
let head = r.kind === 'task' && parts.length > 1 ? `${parts.length} tasks` : r.kind;
|
|
98
|
+
if (r.task_ref)
|
|
99
|
+
head += r.kind === 'steer' ? ` ${r.task_ref}` : ` about ${r.task_ref}`;
|
|
100
|
+
if (same && where(parts[0]))
|
|
101
|
+
head += ` ${where(parts[0])}`;
|
|
102
|
+
const notes = [r.forced === 'question' ? 'asked as a question' : '', r.uncertain ? 'project uncertain' : ''].filter(Boolean);
|
|
103
|
+
if (notes.length)
|
|
104
|
+
head += ` (${notes.join(', ')})`;
|
|
105
|
+
const lines = [head];
|
|
106
|
+
if (parts.length === 1) {
|
|
107
|
+
const p = parts[0];
|
|
108
|
+
if (p.title)
|
|
109
|
+
lines.push(` ${p.title}`);
|
|
110
|
+
lines.push(` ${p.text}${same ? '' : ` (${where(p)})`}`);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
parts.forEach((p, i) => {
|
|
114
|
+
lines.push(` ${i + 1}. ${p.title ?? p.text}${same ? '' : ` (${where(p)})`}`);
|
|
115
|
+
if (p.title)
|
|
116
|
+
lines.push(` ${p.text}`);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return `${lines.join('\n')}\n`;
|
|
120
|
+
}
|
|
121
|
+
const interrupted = (ctx, err) => {
|
|
122
|
+
if (ctx.signal.aborted)
|
|
123
|
+
return EXIT.interrupted;
|
|
124
|
+
throw err;
|
|
125
|
+
};
|
|
126
|
+
export const ROUTER_HANDLERS = {
|
|
127
|
+
async route(ctx, values, words) {
|
|
128
|
+
const text = textOf(words, 'text', 'route');
|
|
129
|
+
const selectedTaskId = taskOf(values);
|
|
130
|
+
try {
|
|
131
|
+
const { result, client } = await inScope(ctx, values, async (client, scope) => ({
|
|
132
|
+
client,
|
|
133
|
+
result: await client.router.route({ text, ...(selectedTaskId === undefined ? {} : { selectedTaskId }), ...scope, signal: ctx.signal }),
|
|
134
|
+
}));
|
|
135
|
+
if (ctx.json) {
|
|
136
|
+
out(ctx, '', result);
|
|
137
|
+
return EXIT.ok;
|
|
138
|
+
}
|
|
139
|
+
ctx.io.stdout.write(routeText(result, await projectNames(client, result.parts, ctx.signal)));
|
|
140
|
+
return EXIT.ok;
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
return interrupted(ctx, err);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
async ask(ctx, values, words) {
|
|
147
|
+
const text = textOf(words, 'question', 'ask');
|
|
148
|
+
const taskId = taskOf(values);
|
|
149
|
+
const params = { text, ...(taskId === undefined ? {} : { taskId }), signal: ctx.signal };
|
|
150
|
+
try {
|
|
151
|
+
if (ctx.json) {
|
|
152
|
+
out(ctx, '', await inScope(ctx, values, (client, scope) => client.questions.ask({ ...params, ...scope })));
|
|
153
|
+
return EXIT.ok;
|
|
154
|
+
}
|
|
155
|
+
// Standard output carries the answer as it is written; the place in line (while it waits) goes to stderr.
|
|
156
|
+
await inScope(ctx, values, async (client, scope) => {
|
|
157
|
+
let written = '';
|
|
158
|
+
for await (const e of client.questions.stream({ ...params, ...scope })) {
|
|
159
|
+
if (e.type === 'queued')
|
|
160
|
+
ctx.io.stderr.write(`Queued: position ${e.position}, about ${Math.max(1, Math.round(e.expectedWaitMs / 1000))} s\n`);
|
|
161
|
+
else if (e.type === 'delta') {
|
|
162
|
+
ctx.io.stdout.write(e.text);
|
|
163
|
+
written += e.text;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
if (!written)
|
|
167
|
+
ctx.io.stdout.write((written = e.answer.text));
|
|
168
|
+
if (!written.endsWith('\n'))
|
|
169
|
+
ctx.io.stdout.write('\n');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
return EXIT.ok;
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
return interrupted(ctx, err);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shardflux/cli",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork serverless agent workspaces.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"snappyjs": "0.7.0",
|
|
49
49
|
"ws": "8.22.0",
|
|
50
50
|
"yaml": "2.9.1",
|
|
51
|
-
"@shardflux/sdk": "^0.27.
|
|
51
|
+
"@shardflux/sdk": "^0.27.3"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@eslint/js": "10.0.1",
|