@shardflux/cli 0.13.2 → 0.13.4
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 +22 -0
- package/README.md +19 -16
- package/dist/account-commands.js +6 -3
- package/dist/agent-commands.d.ts +1 -1
- package/dist/agent-commands.js +29 -8
- package/dist/agent-config-files.js +2 -1
- package/dist/agent-config-report.d.ts +8 -0
- package/dist/agent-config-report.js +21 -0
- package/dist/agent-config-sync.js +3 -2
- package/dist/agent-credentials.js +9 -2
- package/dist/agent-live.d.ts +29 -0
- package/dist/agent-live.js +82 -0
- package/dist/agent-repository.d.ts +12 -0
- package/dist/agent-repository.js +92 -16
- package/dist/agent-target-commands.js +31 -2
- package/dist/args.d.ts +1 -1
- package/dist/args.js +7 -4
- package/dist/assisted-setup.d.ts +3 -1
- package/dist/assisted-setup.js +4 -1
- package/dist/claude-cloud-login.d.ts +7 -1
- package/dist/claude-cloud-login.js +36 -14
- package/dist/command-center-actions.js +4 -0
- package/dist/command-center.js +3 -0
- package/dist/commands.d.ts +1 -1
- package/dist/commands.js +6 -2
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +25 -2
- package/dist/filesystem-commands.d.ts +5 -0
- package/dist/filesystem-commands.js +51 -11
- package/dist/main.js +13 -2
- package/dist/progress.d.ts +6 -0
- package/dist/progress.js +8 -0
- package/dist/setup-cache.d.ts +5 -0
- package/dist/setup-cache.js +11 -0
- package/package.json +3 -3
|
@@ -5,6 +5,7 @@ import { Readable, Duplex } from 'node:stream';
|
|
|
5
5
|
import { ShardfluxApiError, WorkspaceTunnels } from '@shardflux/sdk';
|
|
6
6
|
import { out, resolveWorkspace } from "./commands.js";
|
|
7
7
|
import { AgentTargets, installedTargets } from "./agent-targets.js";
|
|
8
|
+
import { join, relative } from 'node:path';
|
|
8
9
|
import { projectIdentity } from "./project-identity.js";
|
|
9
10
|
import { projectWorkspacePath } from "./agent-repository.js";
|
|
10
11
|
import { UsageError } from "./errors.js";
|
|
@@ -134,14 +135,42 @@ const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
|
|
|
134
135
|
out(ctx, `${instructions}\n`, { target, workspace: ws.data, host: entry.host, instructions });
|
|
135
136
|
return 0;
|
|
136
137
|
};
|
|
138
|
+
const tilde = (targets, file) => file.startsWith(`${targets.home}/`) ? `~/${relative(targets.home, file)}` : file;
|
|
139
|
+
const targetLabel = (e) => `[SF] ${e.project} · ${e.agent}`;
|
|
140
|
+
/** Where each managed connection of an entry lives: the SSH host block (and its Include), then each desktop app's file. */
|
|
141
|
+
function configuredTargets(targets, e) {
|
|
142
|
+
return [
|
|
143
|
+
{ target: 'ssh', label: 'SSH', file: targets.sshFile, name: e.host, detail: `(included from ${tilde(targets, join(targets.home, '.ssh', 'config'))}): ssh ${e.host}` },
|
|
144
|
+
...(e.targets.includes('claude-desktop') ? [{ target: 'claude-desktop', label: 'Claude Desktop', file: targets.claudeFile, name: targetLabel(e), detail: `(sshConfigs: ${targetLabel(e)}); restart Claude Desktop, then pick it in Code's environment list` }] : []),
|
|
145
|
+
...(e.targets.includes('codex-app') ? [{ target: 'codex-app', label: 'Codex app', file: targets.codexFile, name: targetLabel(e), detail: `(remoteConnections: ${targetLabel(e)}); restart Codex to import it` }] : []),
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
function targetFiles(targets, entries) {
|
|
149
|
+
return [...new Set(entries.flatMap(e => configuredTargets(targets, e).map(c => c.file)))];
|
|
150
|
+
}
|
|
137
151
|
export const TARGET_HANDLERS = {
|
|
138
152
|
'agents targets'(ctx, values, [ref]) {
|
|
139
153
|
const detected = installedTargets(ctx.io.env);
|
|
140
154
|
const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
|
|
155
|
+
const key = ref ? keyOf(ref) : undefined;
|
|
156
|
+
const before = values.remove === true ? targets.configure(undefined, ctx.apiUrl, []) : [];
|
|
141
157
|
const entries = ref || values.remove === true
|
|
142
|
-
? targets.configure(
|
|
158
|
+
? targets.configure(key, ctx.apiUrl, detected.targets.filter((t) => t === 'claude-desktop' || t === 'codex-app'), values.remove === true)
|
|
143
159
|
: [];
|
|
144
|
-
|
|
160
|
+
const detection = `${detected.clients.join(', ') || 'No installed agent clients'}\nOpen targets: ${detected.targets.join(', ')}\n`;
|
|
161
|
+
if (values.remove === true) {
|
|
162
|
+
const removed = before.filter(e => !entries.some(n => n.host === e.host));
|
|
163
|
+
out(ctx, removed.length ? `Removed ${removed.map(e => `${e.workspace} (${e.host})`).join(', ')} from ${targetFiles(targets, removed).map(f => tilde(targets, f)).join(', ')}\n` : 'No managed targets to remove.\n', { ...detected, entries, removed });
|
|
164
|
+
return Promise.resolve(0);
|
|
165
|
+
}
|
|
166
|
+
const entry = key ? entries.find(e => e.workspace === key) : undefined;
|
|
167
|
+
if (!entry) {
|
|
168
|
+
out(ctx, detection, { ...detected, entries });
|
|
169
|
+
return Promise.resolve(0);
|
|
170
|
+
}
|
|
171
|
+
const configured = configuredTargets(targets, entry);
|
|
172
|
+
out(ctx, `Configured ${entry.workspace} as SSH host ${entry.host}:\n${configured.map(c => ` ${c.label.padEnd(15)} ${tilde(targets, c.file)}${c.detail ? ` ${c.detail}` : ''}`).join('\n')}\n` +
|
|
173
|
+
['claude-desktop', 'codex-app'].filter(t => !entry.targets.includes(t)).map(t => ` ${(t === 'claude-desktop' ? 'Claude Desktop' : 'Codex app').padEnd(15)} not installed on this computer\n`).join(''), { ...detected, entries, configured: configured.map(({ target, file, name }) => ({ target, file, name })) });
|
|
145
174
|
return Promise.resolve(0);
|
|
146
175
|
},
|
|
147
176
|
'agents open claude-desktop': openTarget('claude-desktop'),
|
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.13.
|
|
15
|
+
export const CLI_VERSION = '0.13.4';
|
|
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).' },
|
|
@@ -142,7 +142,10 @@ const SECRET_PERMISSIONS = {
|
|
|
142
142
|
const KEY_COMMANDS = [
|
|
143
143
|
...['machine', 'template'].flatMap((level) => [
|
|
144
144
|
{ path: [level], summary: level === 'machine' ? 'Open your machine agent, or show its saved filesystem with --json' : 'Show this project’s template filesystem', positionals: [], options: {} },
|
|
145
|
-
{ path: [level, 'save'], summary: `Save the ${level} filesystem as a new private version`, positionals: [], options: {
|
|
145
|
+
{ path: [level, 'save'], summary: `Save the ${level} filesystem as a new private version`, positionals: [], options: {
|
|
146
|
+
wait: { type: 'boolean', description: 'Return when the new version is ready (or failed), printing progress on stderr.' },
|
|
147
|
+
timeout: { type: 'string', value: '<duration>', description: 'With --wait: give up waiting after this long (default 10m). The save continues server side.' },
|
|
148
|
+
} },
|
|
146
149
|
{ path: [level, 'rollback'], summary: `Restore a saved ${level} version`, positionals: [], options: { to: { type: 'string', value: '<version>', description: 'Version to restore (default previous saved version).' } } },
|
|
147
150
|
]),
|
|
148
151
|
{ path: ['agents', 'list'], summary: 'List coding agent sessions.', positionals: [], options: {
|
|
@@ -1136,8 +1139,8 @@ const AGENT_OPTIONS = {
|
|
|
1136
1139
|
approvals: { type: 'boolean', description: 'Keep the agent’s permission prompts.' },
|
|
1137
1140
|
};
|
|
1138
1141
|
export const PROJECT_COMMAND = {
|
|
1139
|
-
path: ['<path>'], summary: 'Open the project’s cloud agent command center',
|
|
1140
|
-
description: '
|
|
1142
|
+
path: ['<path>'], summary: 'Open the project’s cloud agent command center, or start its agent directly with claude|codex',
|
|
1143
|
+
description: 'Naming the app starts (or reattaches) that agent at once; with --json it starts detached and prints the session. Detach with Ctrl-] then d; /exit ends the agent session.',
|
|
1141
1144
|
positionals: [{ name: 'path', required: true }, { name: 'claude|codex' }], options: AGENT_OPTIONS,
|
|
1142
1145
|
};
|
|
1143
1146
|
export const CENTER_COMMAND = { path: ['<center>'], summary: 'Open the cloud agent command center', positionals: [], options: {} };
|
package/dist/assisted-setup.d.ts
CHANGED
|
@@ -4,10 +4,12 @@ export interface SetupProgress {
|
|
|
4
4
|
id: string;
|
|
5
5
|
deps_hash: string;
|
|
6
6
|
state: 'queued' | 'running' | 'checking' | 'saving' | 'ready' | 'failed';
|
|
7
|
+
/** required is false for a step the repository marked `required: false` in .shardflux/setup.yaml. */
|
|
7
8
|
tasks: {
|
|
8
9
|
name: string;
|
|
9
|
-
state: 'queued' | 'running' | 'ready' | 'failed';
|
|
10
|
+
state: 'queued' | 'running' | 'ready' | 'failed' | 'skipped';
|
|
10
11
|
attempts: number;
|
|
12
|
+
required?: boolean;
|
|
11
13
|
error?: {
|
|
12
14
|
code: string;
|
|
13
15
|
message: string;
|
package/dist/assisted-setup.js
CHANGED
|
@@ -67,7 +67,10 @@ export async function retrySetupStep(ctx, workspace, step) {
|
|
|
67
67
|
export function setupLine(project, setup) {
|
|
68
68
|
if (setup.state === 'ready')
|
|
69
69
|
return undefined;
|
|
70
|
-
const
|
|
70
|
+
const failures = setup.tasks.filter(task => task.state === 'failed' && task.error?.code !== 'setup_dependency_failed');
|
|
71
|
+
// A step marked required: false does not hold back the save; it is the
|
|
72
|
+
// failure shown only when the job failed and no required step did.
|
|
73
|
+
const failed = failures.find(task => task.required !== false) ?? (setup.state === 'failed' ? failures[0] : undefined);
|
|
71
74
|
if (failed)
|
|
72
75
|
return `Setting up ${project}: ${failed.name} failed: ${failed.error?.message ?? 'No error receipt'}${failed.error?.retryable ? ' · r retries once' : ''}`;
|
|
73
76
|
if (setup.state === 'saving')
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import type { AgentCredentialMetadata, Workspace } from '@shardflux/sdk';
|
|
1
|
+
import type { AgentCredentialMetadata, CellClient, Workspace } from '@shardflux/sdk';
|
|
2
2
|
import type { Ctx } from './commands.js';
|
|
3
3
|
export declare function requireClaudeTerminal(ctx: Ctx): void;
|
|
4
4
|
export declare function cloudClaudeLogin(ctx: Ctx): Promise<AgentCredentialMetadata | undefined>;
|
|
5
5
|
/** Parse only complete provider authorize links, including the native OSC-8 link. */
|
|
6
6
|
export declare function claudeLoginUrls(output: string): string[];
|
|
7
|
+
/**
|
|
8
|
+
* Put the signed-in account into the workspace's ~/.claude.json, keeping every other key. The platform's credential
|
|
9
|
+
* delivery writes the same file right after the upload, so a revision conflict re-reads and merges again; a file that
|
|
10
|
+
* already holds this account is left as it is.
|
|
11
|
+
*/
|
|
12
|
+
export declare function mergeClaudeAccount(cell: Pick<CellClient, 'files'>, oauthAccount: unknown, signal?: AbortSignal): Promise<void>;
|
|
7
13
|
/** A transient Linux profile holds the initial pair; it is removed before an agent starts. */
|
|
8
14
|
export declare function loginClaudeInWorkspace(ctx: Ctx, ws: Workspace): Promise<AgentCredentialMetadata>;
|
|
9
15
|
export declare function ensureCloudClaude(ctx: Ctx, ws: Workspace): Promise<void>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
1
2
|
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
2
3
|
import { UsageError } from "./errors.js";
|
|
3
4
|
import { openUrl } from "./browser.js";
|
|
@@ -42,6 +43,39 @@ function credentialFile(path, value, ctx) {
|
|
|
42
43
|
ctx.addSecret(content_b64);
|
|
43
44
|
return { path, mode: '0600', content_b64 };
|
|
44
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Put the signed-in account into the workspace's ~/.claude.json, keeping every other key. The platform's credential
|
|
48
|
+
* delivery writes the same file right after the upload, so a revision conflict re-reads and merges again; a file that
|
|
49
|
+
* already holds this account is left as it is.
|
|
50
|
+
*/
|
|
51
|
+
export async function mergeClaudeAccount(cell, oauthAccount, signal) {
|
|
52
|
+
const path = '/home/user/.claude.json';
|
|
53
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
54
|
+
let document = {}, revision = 'absent';
|
|
55
|
+
try {
|
|
56
|
+
const current = await cell.files.readWithInfo(path);
|
|
57
|
+
document = JSON.parse(Buffer.from(current.data).toString());
|
|
58
|
+
if (!current.revision)
|
|
59
|
+
throw new UsageError('Claude account configuration has no safe content revision.');
|
|
60
|
+
revision = current.revision;
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (!(error instanceof Error && 'status' in error && error.status === 404))
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
if (isDeepStrictEqual(document.oauthAccount, oauthAccount))
|
|
67
|
+
return;
|
|
68
|
+
try {
|
|
69
|
+
await cell.files.patch({ path, content: JSON.stringify({ ...document, oauthAccount }), expectedRevision: revision, mode: '0600', createParents: true }, { signal });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (!(error instanceof ShardfluxApiError && error.status === 409 && error.details?.reason === 'revision_mismatch'))
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
throw new UsageError('Claude account configuration kept changing in the workspace. Run shard agents credentials login claude again.');
|
|
78
|
+
}
|
|
45
79
|
/** A transient Linux profile holds the initial pair; it is removed before an agent starts. */
|
|
46
80
|
export async function loginClaudeInWorkspace(ctx, ws) {
|
|
47
81
|
requireClaudeTerminal(ctx);
|
|
@@ -112,22 +146,10 @@ export async function loginClaudeInWorkspace(ctx, ws) {
|
|
|
112
146
|
// persistence. An upload error requires a new cloud login, never guest refresh.
|
|
113
147
|
await cell.files.remove(root, { recursive: true });
|
|
114
148
|
const metadata = await api.put('claude', files, { loginId: claim.login_id });
|
|
149
|
+
complete = true; // stored: the login must not be canceled by a later local write failure
|
|
115
150
|
const access = Object.fromEntries(['accessToken', 'expiresAt', 'scopes', 'subscriptionType', 'rateLimitTier'].filter(key => key in credential.claudeAiOauth).map(key => [key, credential.claudeAiOauth[key]]));
|
|
116
151
|
await cell.files.write('/home/user/.claude/.credentials.json', JSON.stringify({ claudeAiOauth: access }), { mode: '0600', createParents: true });
|
|
117
|
-
|
|
118
|
-
try {
|
|
119
|
-
const current = await cell.files.readWithInfo('/home/user/.claude.json');
|
|
120
|
-
document = JSON.parse(Buffer.from(current.data).toString());
|
|
121
|
-
if (!current.revision)
|
|
122
|
-
throw new UsageError('Claude account configuration has no safe content revision.');
|
|
123
|
-
revision = current.revision;
|
|
124
|
-
}
|
|
125
|
-
catch (error) {
|
|
126
|
-
if (!(error instanceof Error && 'status' in error && error.status === 404))
|
|
127
|
-
throw error;
|
|
128
|
-
}
|
|
129
|
-
await cell.files.patch({ path: '/home/user/.claude.json', content: JSON.stringify({ ...document, oauthAccount: config.oauthAccount }), expectedRevision: revision, mode: '0600', createParents: true }, { signal: ctx.signal });
|
|
130
|
-
complete = true;
|
|
152
|
+
await mergeClaudeAccount(cell, config.oauthAccount, ctx.signal);
|
|
131
153
|
return metadata;
|
|
132
154
|
}
|
|
133
155
|
finally {
|
|
@@ -6,6 +6,7 @@ import { json } from "./format.js";
|
|
|
6
6
|
import { UsageError } from "./errors.js";
|
|
7
7
|
import { cellConnector, PtyOutputCursor } from "./agent-terminal.js";
|
|
8
8
|
import { rememberGitAgent } from "./agent-git-commands.js";
|
|
9
|
+
import { rememberLaunch } from "./agent-live.js";
|
|
9
10
|
import { agentName as gitAgentName } from "./agent-git.js";
|
|
10
11
|
/** L9 can register target-specific handlers after detecting the installed applications. */
|
|
11
12
|
export async function availableTargets(ctx) {
|
|
@@ -43,6 +44,7 @@ export const CENTER_HANDLERS = {
|
|
|
43
44
|
const labels = { ...source.labels, shard_project: source.labels.shard_project || source.key.split('/')[0], agent: name,
|
|
44
45
|
agent_pty_name: source.labels.agent_pty_name || agentName(source.key, source.labels) };
|
|
45
46
|
const caps = workspaceCaps(values);
|
|
47
|
+
const started = Date.now();
|
|
46
48
|
const result = await ctx.cloud().workspaces.fork(source.id, { key, labels, ...(caps ? { caps } : {}) }, {
|
|
47
49
|
agentLabel: ctx.agentLabel, wait: values.wait === false ? false : { timeoutMs: waitTimeout(values), signal: ctx.signal },
|
|
48
50
|
});
|
|
@@ -54,6 +56,8 @@ export const CENTER_HANDLERS = {
|
|
|
54
56
|
throw new Error(`Fork ${key} has no independent agent session receipt`);
|
|
55
57
|
}
|
|
56
58
|
await ctx.cloud().workspaces.setRetention(result.workspace.id, { delete_after_idle_days: 7 });
|
|
59
|
+
if (agent)
|
|
60
|
+
rememberLaunch(ctx, result.workspace.id, started);
|
|
57
61
|
await rememberGitAgent(ctx, result.workspace, './');
|
|
58
62
|
out(ctx, `Forked ${key}: operation ${result.operation.id} ${result.operation.state}\n`, { workspace: result.workspace.data, operation: result.operation, ...(agent ? { agent } : {}) });
|
|
59
63
|
return 0;
|
package/dist/command-center.js
CHANGED
|
@@ -308,6 +308,9 @@ export async function showCenter(ctx, scope, options = {}) {
|
|
|
308
308
|
}
|
|
309
309
|
}
|
|
310
310
|
export const commandCenter = async (ctx, values, [path, app]) => {
|
|
311
|
+
// `shard <path> claude|codex` names the agent: start (or reattach) it without the picker; --json starts it detached.
|
|
312
|
+
if (path && (app === 'claude' || app === 'codex'))
|
|
313
|
+
return agentAction(ctx, 'new', { ...values, path, app }, []);
|
|
311
314
|
const scope = path ? projectIdentity(path) : undefined;
|
|
312
315
|
if (ctx.json) {
|
|
313
316
|
const data = await loadCenter(ctx);
|
package/dist/commands.d.ts
CHANGED
|
@@ -79,7 +79,7 @@ export declare function openVolumes(values: readonly string[]): import('@shardfl
|
|
|
79
79
|
export declare function workspaceCaps(values: Values): Caps | undefined;
|
|
80
80
|
/** `--when-idle <duration>`: whole seconds from 0 (as soon as it is idle; 0.6.1) to 3600 (the API's after_seconds bounds). */
|
|
81
81
|
export declare function whenIdleSeconds(text: string): number;
|
|
82
|
-
export declare function waitTimeout(values: Values): number;
|
|
82
|
+
export declare function waitTimeout(values: Values, fallbackMs?: number): number;
|
|
83
83
|
/**
|
|
84
84
|
* `<id|key>`: a UUID is tried as an id first; anything else (or an unknown UUID) as an exact workspace key, across every
|
|
85
85
|
* lifetime and purpose (sessions, drafts and test instances are hidden from the default list), preferring the live
|
package/dist/commands.js
CHANGED
|
@@ -17,6 +17,7 @@ import { parse as parseYaml } from 'yaml';
|
|
|
17
17
|
import { CLI_VERSION, SECRET_NAME, UUID, parseDuration, parseEnvPairs, parsePositiveInt } from "./args.js";
|
|
18
18
|
import { ExecSessionRunningError, ExecutionFailedError, ExecutionPendingError, InterruptedError, NotFoundError, UsageError } from "./errors.js";
|
|
19
19
|
import { updateCheckDisabled } from "./update-check.js";
|
|
20
|
+
import { withLiveAgents } from "./agent-live.js";
|
|
20
21
|
import { initTemplateYaml, recipeYaml } from "./template-yaml.js";
|
|
21
22
|
import { BuildTimer, buildTimingJson, buildTimingText, progressLine } from "./timing.js";
|
|
22
23
|
import { timingJson } from "./timing.js";
|
|
@@ -174,9 +175,9 @@ export function whenIdleSeconds(text) {
|
|
|
174
175
|
throw new UsageError(`--when-idle must be between 0s and 1h (got "${text}")`);
|
|
175
176
|
return s;
|
|
176
177
|
}
|
|
177
|
-
export function waitTimeout(values) {
|
|
178
|
+
export function waitTimeout(values, fallbackMs = DEFAULT_WAIT_MS) {
|
|
178
179
|
const t = str(values.timeout);
|
|
179
|
-
return t === undefined ?
|
|
180
|
+
return t === undefined ? fallbackMs : parseDuration(t, 'timeout');
|
|
180
181
|
}
|
|
181
182
|
/**
|
|
182
183
|
* `<id|key>`: a UUID is tried as an id first; anything else (or an unknown UUID) as an exact workspace key, across every
|
|
@@ -814,6 +815,9 @@ export const KEY_HANDLERS = {
|
|
|
814
815
|
next = page.nextCursor;
|
|
815
816
|
cursor = next ?? undefined;
|
|
816
817
|
} while (values.all === true && cursor);
|
|
818
|
+
// A complete listing also shows sessions the projection has not picked up yet (agent-live.ts).
|
|
819
|
+
if (!next && !str(values.cursor))
|
|
820
|
+
rows.splice(0, rows.length, ...await withLiveAgents(ctx, rows, { ...(state ? { state } : {}), labels }));
|
|
817
821
|
out(ctx, rows.length ? table(['WORKSPACE', 'APP', 'STATE', 'TITLE'], rows.map(a => [a.workspace_key, a.app, a.state, a.title])) : 'No agent sessions.\n', { data: rows, next_cursor: next });
|
|
818
822
|
if (!ctx.json && next)
|
|
819
823
|
ctx.io.stderr.write(`more: shard agents list --cursor ${next}\n`);
|
package/dist/errors.d.ts
CHANGED
|
@@ -28,6 +28,16 @@ declare class HintedError extends Error {
|
|
|
28
28
|
export declare class UsageError extends HintedError {
|
|
29
29
|
name: string;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* A refusal `shard` detects itself, with a stable code, its facts in `details` and the next step as `hint` (exit 1),
|
|
33
|
+
* e.g. `repository_too_large` (0.13.3+) for Git history over the initial sync limit.
|
|
34
|
+
*/
|
|
35
|
+
export declare class CodedError extends HintedError {
|
|
36
|
+
name: string;
|
|
37
|
+
readonly code: string;
|
|
38
|
+
readonly details: Record<string, unknown> | undefined;
|
|
39
|
+
constructor(code: string, message: string, hint?: string, details?: Record<string, unknown>);
|
|
40
|
+
}
|
|
31
41
|
/** No usable credential: SHARDFLUX_API_KEY / the saved key, or the session, missing or malformed (never carries it). */
|
|
32
42
|
export declare class AuthConfigError extends HintedError {
|
|
33
43
|
name: string;
|
package/dist/errors.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* stderr with the API's error code, message and request id; the process exit
|
|
4
4
|
* code tells scripts what kind of failure it was (README "Exit codes").
|
|
5
5
|
*/
|
|
6
|
-
import { CheckoutTimeoutError, DurabilityLostError, ExecStartError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TemplateBuildTimeoutError, TemplateFileError, TemplateUploadError } from '@shardflux/sdk';
|
|
6
|
+
import { CheckoutTimeoutError, DurabilityLostError, ExecStartError, FilesystemSaveFailedError, FilesystemSaveTimeoutError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TemplateBuildTimeoutError, TemplateFileError, TemplateUploadError } from '@shardflux/sdk';
|
|
7
7
|
export const EXIT = {
|
|
8
8
|
ok: 0,
|
|
9
9
|
/** Any other failure: API refusal (conflict, quota, ...), network, protocol. */
|
|
@@ -36,6 +36,20 @@ class HintedError extends Error {
|
|
|
36
36
|
export class UsageError extends HintedError {
|
|
37
37
|
name = 'UsageError';
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* A refusal `shard` detects itself, with a stable code, its facts in `details` and the next step as `hint` (exit 1),
|
|
41
|
+
* e.g. `repository_too_large` (0.13.3+) for Git history over the initial sync limit.
|
|
42
|
+
*/
|
|
43
|
+
export class CodedError extends HintedError {
|
|
44
|
+
name = 'CodedError';
|
|
45
|
+
code;
|
|
46
|
+
details;
|
|
47
|
+
constructor(code, message, hint, details) {
|
|
48
|
+
super(message, hint);
|
|
49
|
+
this.code = code;
|
|
50
|
+
this.details = details;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
39
53
|
/** No usable credential: SHARDFLUX_API_KEY / the saved key, or the session, missing or malformed (never carries it). */
|
|
40
54
|
export class AuthConfigError extends HintedError {
|
|
41
55
|
name = 'AuthConfigError';
|
|
@@ -180,6 +194,8 @@ export function describeError(err) {
|
|
|
180
194
|
return { code: 'timeout', message: err.message, last_state: err.session.state, details: { session_id: err.session.session_id } };
|
|
181
195
|
if (err instanceof UsageError)
|
|
182
196
|
return { code: 'usage', message: err.message };
|
|
197
|
+
if (err instanceof CodedError)
|
|
198
|
+
return { code: err.code, message: err.message, retryable: false, ...(err.details ? { details: err.details } : {}) };
|
|
183
199
|
if (err instanceof CheckoutTimeoutError) {
|
|
184
200
|
return { code: 'timeout', message: `${err.message}; the checkout stays open (pay at its URL, then check with: shard billing status)`, last_state: err.checkout.status, details: { checkout_id: err.checkout.id, status: err.checkout.status, url: err.checkout.url } };
|
|
185
201
|
}
|
|
@@ -190,6 +206,11 @@ export function describeError(err) {
|
|
|
190
206
|
return { code: 'usage', message: err.message };
|
|
191
207
|
if (err instanceof TemplateBuildTimeoutError)
|
|
192
208
|
return { code: 'timeout', message: `${err.message}; follow it with: shard templates builds get ${err.build.id}`, last_state: err.build.state };
|
|
209
|
+
// machine/template save --wait (0.13.3+): the save ended without its version, or is still building at the deadline.
|
|
210
|
+
if (err instanceof FilesystemSaveFailedError)
|
|
211
|
+
return { code: 'filesystem_save_failed', message: err.message, retryable: true, details: { level: err.state.level, version: err.version } };
|
|
212
|
+
if (err instanceof FilesystemSaveTimeoutError)
|
|
213
|
+
return { code: 'timeout', message: `${err.message}; check with shard ${err.state.level}`, last_state: err.state.state, details: { level: err.state.level, version: err.version } };
|
|
193
214
|
if (err instanceof TemplateUploadError)
|
|
194
215
|
return { code: 'upload_failed', message: err.message, ...(err.status ? { status: err.status } : {}), details: { sha256: err.sha256, ...(err.code ? { storage_code: err.code } : {}) } };
|
|
195
216
|
if (err instanceof AuthConfigError)
|
|
@@ -221,8 +242,10 @@ export function exitCodeFor(err, interrupted = false) {
|
|
|
221
242
|
return EXIT.usage;
|
|
222
243
|
if (err instanceof AuthConfigError || err instanceof MfaPendingError)
|
|
223
244
|
return EXIT.auth;
|
|
224
|
-
if (err instanceof TemplateBuildTimeoutError)
|
|
245
|
+
if (err instanceof TemplateBuildTimeoutError || err instanceof FilesystemSaveTimeoutError)
|
|
225
246
|
return EXIT.timeout;
|
|
247
|
+
if (err instanceof FilesystemSaveFailedError)
|
|
248
|
+
return EXIT.operationFailed;
|
|
226
249
|
if (err instanceof NotFoundError)
|
|
227
250
|
return EXIT.notFound;
|
|
228
251
|
if (err instanceof OperationTimeoutError || err instanceof CheckoutTimeoutError || isNamed(err, 'TimeoutError'))
|
|
@@ -1,2 +1,7 @@
|
|
|
1
|
+
import type { AgentFilesystem } from '@shardflux/sdk';
|
|
1
2
|
import type { Handler } from './commands.js';
|
|
3
|
+
/** One line per status: what is in use and what is saving or failed, its platform base and setup cache. */
|
|
4
|
+
export declare function filesystemLine(state: AgentFilesystem & {
|
|
5
|
+
cache_scope?: string;
|
|
6
|
+
}): string;
|
|
2
7
|
export declare const FILESYSTEM_HANDLERS: Record<string, Handler>;
|
|
@@ -1,16 +1,43 @@
|
|
|
1
|
+
import { waitTimeout } from "./commands.js";
|
|
1
2
|
import { UsageError } from "./errors.js";
|
|
2
3
|
import { projectIdentity, normalizeRemote } from "./project-identity.js";
|
|
3
4
|
import { AGENT_HANDLERS } from "./agent-commands.js";
|
|
5
|
+
import { readSetupProgress } from "./assisted-setup.js";
|
|
4
6
|
import { syncAgentConfig } from "./agent-config-sync.js";
|
|
7
|
+
import { progress } from "./progress.js";
|
|
5
8
|
function remote() {
|
|
6
9
|
const project = projectIdentity('./');
|
|
7
10
|
return { project, repoRemote: project.remote ?? normalizeRemote(`file://${project.folder}`) };
|
|
8
11
|
}
|
|
12
|
+
/** One line per status: what is in use and what is saving or failed, its platform base and setup cache. */
|
|
13
|
+
export function filesystemLine(state) {
|
|
14
|
+
const base = state.platform_base ? `; platform ${state.platform_base.slug}@${state.platform_base.version}${state.rebase_pending ? ', rebase pending' : ''}` : '';
|
|
15
|
+
return usageLine(state) + base + (state.cache_scope ? `; cache: ${state.cache_scope}` : '');
|
|
16
|
+
}
|
|
17
|
+
function usageLine(state) {
|
|
18
|
+
const current = state.version > 0 ? `version ${state.version}` : '';
|
|
19
|
+
if (state.state === 'building' && state.pending)
|
|
20
|
+
return `${state.level}: saving version ${state.pending.version}${current ? ` (${current} stays in use until it is ready)` : ''}`;
|
|
21
|
+
if (state.state === 'failed' && state.failure)
|
|
22
|
+
return `${state.level}: version ${state.failure.version} was not saved: ${state.failure.message}${current ? ` (${current} stays in use)` : ''}`;
|
|
23
|
+
if (current)
|
|
24
|
+
return `${state.level}: ${current} is ready`;
|
|
25
|
+
return state.level === 'machine' ? 'machine: nothing saved yet; change it in shard machine, then run shard machine save' : 'template: nothing saved yet; setup saves it after shard ./, or run shard template save';
|
|
26
|
+
}
|
|
9
27
|
function output(ctx, state) {
|
|
10
|
-
ctx.io.stdout.write(ctx.json ? JSON.stringify(state) + '\n' : `${state
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
28
|
+
ctx.io.stdout.write(ctx.json ? JSON.stringify(state) + '\n' : `${filesystemLine(state)}\n`);
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
/** Save output: one line naming the version being saved, or with --wait the saved version (progress on stderr). */
|
|
32
|
+
async function saved(ctx, values, save, level) {
|
|
33
|
+
if (values.wait !== true && values.timeout !== undefined)
|
|
34
|
+
throw new UsageError('--timeout needs --wait');
|
|
35
|
+
const wait = values.wait === true ? { timeoutMs: waitTimeout(values, 600_000), onChange: (state) => { if (state.state === 'building' && state.pending)
|
|
36
|
+
progress(ctx, `Saving ${level} version ${state.pending.version}…`); } } : undefined;
|
|
37
|
+
const state = await save({ signal: ctx.signal, ...(wait ? { wait } : {}) });
|
|
38
|
+
if (ctx.json)
|
|
39
|
+
return output(ctx, state);
|
|
40
|
+
ctx.io.stdout.write(wait ? `${level}: version ${state.version} saved\n` : state.pending ? `${filesystemLine(state)}; shard ${level} shows when it is ready\n` : `${filesystemLine(state)}\n`);
|
|
14
41
|
return 0;
|
|
15
42
|
}
|
|
16
43
|
const rollbackTo = (value) => {
|
|
@@ -28,34 +55,47 @@ export const FILESYSTEM_HANDLERS = {
|
|
|
28
55
|
if (ctx.json || !ctx.io.stdin.isTTY)
|
|
29
56
|
return output(ctx, state);
|
|
30
57
|
const key = `machine/${state.organization_id}/v${state.version}`;
|
|
58
|
+
const existing = await cloud.workspaces.findByKey(key, { includeDeleted: false, signal: ctx.signal });
|
|
59
|
+
if (!existing)
|
|
60
|
+
progress(ctx, `Creating your machine VM (${key}); the first open takes about a minute…`);
|
|
61
|
+
else if (existing.data.observed_state !== 'running')
|
|
62
|
+
progress(ctx, `Resuming your machine VM (${key})…`);
|
|
31
63
|
const ws = await cloud.workspaces.open({ key, template: state.template ? `${state.template.slug}@${state.template.version}` : 'coding',
|
|
32
|
-
labels: { shard_layer: 'machine', shard_machine: state.organization_id, repo_path: '/home/user', app: 'claude', agent: 'machine' },
|
|
64
|
+
labels: { ...existing?.labels, shard_layer: 'machine', shard_machine: state.organization_id, repo_path: '/home/user', app: 'claude', agent: 'machine' },
|
|
33
65
|
wait: { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal } });
|
|
34
|
-
if (state.version === 0)
|
|
66
|
+
if (state.version === 0) {
|
|
67
|
+
progress(ctx, 'Syncing your Claude and Codex config into the machine…');
|
|
35
68
|
await syncAgentConfig(ctx, ws);
|
|
69
|
+
}
|
|
36
70
|
return AGENT_HANDLERS['agents open'](ctx, {}, [ws.key]);
|
|
37
71
|
},
|
|
38
|
-
async 'machine save'(ctx) {
|
|
72
|
+
async 'machine save'(ctx, values) {
|
|
39
73
|
const cloud = ctx.cloud();
|
|
40
74
|
const state = await cloud.machine.get({ signal: ctx.signal });
|
|
41
75
|
const ws = await cloud.workspaces.findByKey(`machine/${state.organization_id}/v${state.version}`, { signal: ctx.signal }) ??
|
|
42
76
|
(state.workspace_id ? await cloud.workspaces.get(state.workspace_id) : null);
|
|
43
77
|
if (!ws)
|
|
44
78
|
throw new UsageError('Open shard machine, change it, then run shard machine save');
|
|
45
|
-
return
|
|
79
|
+
return saved(ctx, values, options => cloud.machine.save({ workspaceId: ws.id }, options), 'machine');
|
|
46
80
|
},
|
|
47
81
|
async 'machine rollback'(ctx, values) {
|
|
48
82
|
return output(ctx, await ctx.cloud().machine.rollback({ to: rollbackTo(values.to) }, { signal: ctx.signal }));
|
|
49
83
|
},
|
|
50
84
|
async template(ctx) {
|
|
51
|
-
|
|
85
|
+
const { project, repoRemote } = remote();
|
|
86
|
+
const state = await ctx.cloud().templates.getProject(repoRemote, { signal: ctx.signal });
|
|
87
|
+
const ws = await ctx.cloud().workspaces.findByKey(`${project.slug}/main`, { signal: ctx.signal });
|
|
88
|
+
const progress = ws ? await readSetupProgress(ctx, ws.data) : undefined;
|
|
89
|
+
const attachments = ws ? await ctx.cloud().volumes.listAttached(ws.id) : undefined;
|
|
90
|
+
const mounted = attachments?.data.some((a) => a.mount_path === '/mnt/shardflux-cache' && a.mounted && a.volume.name === 'shardflux-setup-cache' && a.volume.org_shared);
|
|
91
|
+
return output(ctx, { ...state, cache_scope: progress?.cache_scope ?? (mounted ? 'account' : 'workspace') });
|
|
52
92
|
},
|
|
53
|
-
async 'template save'(ctx) {
|
|
93
|
+
async 'template save'(ctx, values) {
|
|
54
94
|
const { project, repoRemote } = remote();
|
|
55
95
|
const ws = await ctx.cloud().workspaces.findByKey(`${project.slug}/main`, { signal: ctx.signal });
|
|
56
96
|
if (!ws)
|
|
57
97
|
throw new UsageError('Run shard ./ for this project before saving its template');
|
|
58
|
-
return
|
|
98
|
+
return saved(ctx, values, options => ctx.cloud().templates.saveProject({ repoRemote, workspaceId: ws.id }, options), 'template');
|
|
59
99
|
},
|
|
60
100
|
async 'template rollback'(ctx, values) {
|
|
61
101
|
return output(ctx, await ctx.cloud().templates.rollbackProject({ repoRemote: remote().repoRemote, to: rollbackTo(values.to) }, { signal: ctx.signal }));
|
package/dist/main.js
CHANGED
|
@@ -46,7 +46,7 @@ export function abortableSleep(signal) {
|
|
|
46
46
|
function reasonOf(info) {
|
|
47
47
|
return typeof info.details?.reason === 'string' ? info.details.reason : undefined;
|
|
48
48
|
}
|
|
49
|
-
function hintFor(info, cred, file) {
|
|
49
|
+
function hintFor(info, cred, file, command = '') {
|
|
50
50
|
if (info.code === 'timeout' && info.operation_id)
|
|
51
51
|
return `the operation continues server side; keep waiting with: shard operations wait ${info.operation_id}`;
|
|
52
52
|
if (info.code === 'operation_failed' && info.retryable) {
|
|
@@ -56,6 +56,10 @@ function hintFor(info, cred, file) {
|
|
|
56
56
|
return `keep waiting with: shard operations wait ${info.operation_id}`;
|
|
57
57
|
if (info.code === 'usage')
|
|
58
58
|
return 'run "shard --help" or "shard <command> --help"';
|
|
59
|
+
if (info.code === 'filesystem_save_failed') {
|
|
60
|
+
const level = info.details?.level === 'template' ? 'template' : 'machine';
|
|
61
|
+
return `the version in use is unchanged; fix what the message names in ${level === 'machine' ? 'shard machine' : 'the project agent'}, then run shard ${level} save --wait again`;
|
|
62
|
+
}
|
|
59
63
|
if (info.source === 'cell' && info.code === 'workspace_not_running')
|
|
60
64
|
return 'open or resume the workspace first: shard workspaces open <key> --template <slug>';
|
|
61
65
|
if (info.status === 401 && info.code === 'unauthenticated') {
|
|
@@ -67,6 +71,10 @@ function hintFor(info, cred, file) {
|
|
|
67
71
|
return `the API key saved in ${file} was revoked or expired; save a new one: shard setup --new-key`;
|
|
68
72
|
return undefined;
|
|
69
73
|
}
|
|
74
|
+
// The edge firewall answers before the API: the contract body (edge_refused), or a bare 403 from an edge without it.
|
|
75
|
+
if (reasonOf(info) === 'edge_refused' || (info.code === 'protocol_error' && info.status === 403)) {
|
|
76
|
+
return `the edge firewall refused the request before it reached Shardflux; send the data through a command's stdin instead, for example: shard ws exec <workspace> --stdin <local-file> -- sh -c 'cat > "$1"' sh <path>`;
|
|
77
|
+
}
|
|
70
78
|
if (info.code === 'step_up_required')
|
|
71
79
|
return STEP_UP_HINT;
|
|
72
80
|
if (info.code === 'mfa_required')
|
|
@@ -94,6 +102,9 @@ function hintFor(info, cred, file) {
|
|
|
94
102
|
const reason = info.details?.reason;
|
|
95
103
|
const index = typeof info.details?.index === 'number' ? ` #${info.details.index + 1}` : '';
|
|
96
104
|
if (reason === 'revision_mismatch') {
|
|
105
|
+
// Only file commands take --expected-revision; elsewhere (a sign-in, config sync) shard chose the revision itself.
|
|
106
|
+
if (!command.startsWith('files '))
|
|
107
|
+
return 'the file changed while shard was updating it; run the same command again';
|
|
97
108
|
const current = typeof info.details?.current_revision === 'string' ? info.details.current_revision : 'unknown';
|
|
98
109
|
return `the file changed since that revision (current revision: ${current}); read it again, then retry with --expected-revision ${current}`;
|
|
99
110
|
}
|
|
@@ -387,7 +398,7 @@ export async function run(argv, io, opts = {}) {
|
|
|
387
398
|
}
|
|
388
399
|
// An error that knows the command to continue with (an interrupted or still running execution) brings its own hint.
|
|
389
400
|
const own = ownHint(err) ?? ((err instanceof InterruptedError || err instanceof ExecutionPendingError) && err.hint ? err.hint : undefined);
|
|
390
|
-
const hint = command === 'feedback' ? feedbackHint(info, own ?? hintFor(info, cred, store.file)) : (own ?? hintFor(info, cred, store.file));
|
|
401
|
+
const hint = command === 'feedback' ? feedbackHint(info, own ?? hintFor(info, cred, store.file, command)) : (own ?? hintFor(info, cred, store.file, command));
|
|
391
402
|
// --timing on a failure: the failed call's timing (err.timing, else the last recorded), before the error; with
|
|
392
403
|
// --json a `timing` field next to `error`.
|
|
393
404
|
const timing = showTiming ? traces.forError(err) : null;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Ctx } from './commands.js';
|
|
2
|
+
/**
|
|
3
|
+
* One progress line on stderr before a step that can take a while (creating a VM, syncing config, signing in,
|
|
4
|
+
* starting an agent), so a long first run never looks hung. Silent with --json: stdout stays one document.
|
|
5
|
+
*/
|
|
6
|
+
export declare function progress(ctx: Pick<Ctx, 'json' | 'io'>, line: string): void;
|
package/dist/progress.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One progress line on stderr before a step that can take a while (creating a VM, syncing config, signing in,
|
|
3
|
+
* starting an agent), so a long first run never looks hung. Silent with --json: stdout stays one document.
|
|
4
|
+
*/
|
|
5
|
+
export function progress(ctx, line) {
|
|
6
|
+
if (!ctx.json)
|
|
7
|
+
ctx.io.stderr.write(`${line}\n`);
|
|
8
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Workspace } from '@shardflux/sdk';
|
|
2
|
+
import type { Ctx } from './commands.js';
|
|
3
|
+
export declare const SETUP_CACHE_PATH = "/mnt/shardflux-cache";
|
|
4
|
+
/** Uses ordinary fenced shared-volume operations, after the managed PTY starts. */
|
|
5
|
+
export declare function prepareSetupCache(ctx: Ctx, workspace: Workspace): Promise<void>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const name = 'shardflux-setup-cache';
|
|
2
|
+
export const SETUP_CACHE_PATH = '/mnt/shardflux-cache';
|
|
3
|
+
/** Uses ordinary fenced shared-volume operations, after the managed PTY starts. */
|
|
4
|
+
export async function prepareSetupCache(ctx, workspace) {
|
|
5
|
+
const cloud = ctx.cloud();
|
|
6
|
+
const wait = { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal };
|
|
7
|
+
const { volume } = await cloud.volumes.open({ name, projectId: workspace.data.project_id, wait });
|
|
8
|
+
if (volume.state !== 'available' || !volume.org_shared || volume.quota_gib !== 1)
|
|
9
|
+
throw new Error('account setup cache is not available');
|
|
10
|
+
await cloud.volumes.attach(workspace.id, { volumeId: volume.id, mountPath: SETUP_CACHE_PATH, wait });
|
|
11
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shardflux/cli",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork serverless agent workspaces.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"smol-toml": "1.9.0",
|
|
48
48
|
"snappyjs": "0.7.0",
|
|
49
49
|
"yaml": "2.9.1",
|
|
50
|
-
"@shardflux/sdk": "^0.18.
|
|
50
|
+
"@shardflux/sdk": "^0.18.2"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@eslint/js": "10.0.1",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"typescript-eslint": "8.70.1"
|
|
59
59
|
},
|
|
60
60
|
"optionalDependencies": {
|
|
61
|
-
"@shardflux/menubar-darwin": "^0.1.
|
|
61
|
+
"@shardflux/menubar-darwin": "^0.1.1"
|
|
62
62
|
},
|
|
63
63
|
"scripts": {
|
|
64
64
|
"build": "node scripts/build.mjs",
|