@shardflux/cli 0.15.1 → 0.16.0
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 +83 -0
- package/README.md +50 -3
- package/dist/acp-models.js +5 -1
- package/dist/agent-commands.d.ts +8 -2
- package/dist/agent-commands.js +23 -11
- package/dist/agent-target-commands.d.ts +7 -2
- package/dist/agent-target-commands.js +12 -7
- package/dist/agent-targets.d.ts +3 -0
- package/dist/agent-targets.js +4 -1
- package/dist/agent-terminal.d.ts +8 -0
- package/dist/agent-terminal.js +35 -2
- package/dist/args.d.ts +1 -1
- package/dist/args.js +29 -3
- package/dist/codex-cloud.d.ts +2 -0
- package/dist/codex-cloud.js +2 -0
- package/dist/command-center-actions.js +9 -0
- package/dist/commands.d.ts +23 -1
- package/dist/commands.js +54 -14
- package/dist/conversation-commands.js +6 -0
- package/dist/conversation-terminal.js +10 -1
- package/dist/conversations.d.ts +13 -1
- package/dist/conversations.js +41 -5
- package/dist/device-helper.js +9 -2
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +11 -1
- package/dist/files-mirror.d.ts +164 -0
- package/dist/files-mirror.js +761 -0
- package/dist/handlers.js +3 -1
- package/dist/main.js +39 -14
- package/dist/menubar.d.ts +57 -0
- package/dist/menubar.js +264 -45
- package/dist/registry.d.ts +8 -0
- package/dist/registry.js +18 -0
- package/dist/thread-on-computer.d.ts +35 -0
- package/dist/thread-on-computer.js +200 -0
- package/dist/update-check.d.ts +9 -1
- package/dist/update-check.js +55 -17
- package/dist/update.d.ts +2 -0
- package/dist/update.js +26 -8
- package/dist/wait-status.d.ts +22 -0
- package/dist/wait-status.js +59 -0
- package/dist/worktree-twin-engine.d.ts +95 -0
- package/dist/worktree-twin-engine.js +402 -0
- package/dist/worktree-twin-land.py +331 -0
- package/dist/worktree-twin.d.ts +296 -0
- package/dist/worktree-twin.js +718 -0
- package/package.json +3 -3
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Ctx, Handler } from './commands.js';
|
|
2
|
+
import type { Values } from './args.js';
|
|
3
|
+
import type { Conversation, Harness } from './conversations.js';
|
|
4
|
+
/** A conversation still bound to its computer: not done, archived or ended. */
|
|
5
|
+
export declare const activeConversation: (c: Pick<Conversation, "state" | "ended_at">) => boolean;
|
|
6
|
+
export interface ComputerThread {
|
|
7
|
+
id: string;
|
|
8
|
+
key: string;
|
|
9
|
+
title: string;
|
|
10
|
+
harness: Harness;
|
|
11
|
+
state: Conversation['state'];
|
|
12
|
+
route: Conversation['route'];
|
|
13
|
+
role: string;
|
|
14
|
+
join_mode: string | null;
|
|
15
|
+
branch: string;
|
|
16
|
+
}
|
|
17
|
+
export interface Computer {
|
|
18
|
+
workspace_id: string;
|
|
19
|
+
workspace_key: string;
|
|
20
|
+
state: 'running' | 'suspended';
|
|
21
|
+
last_activity_at: string | null;
|
|
22
|
+
conversations: ComputerThread[];
|
|
23
|
+
}
|
|
24
|
+
/** The project's conversations: its repository's, or (for a folder without a remote) the ones started from it. */
|
|
25
|
+
export declare function projectConversations(ctx: Pick<Ctx, 'store' | 'apiUrl'>, all: Conversation[], folder: string): Conversation[];
|
|
26
|
+
/** Computers a new thread can start on: each live VM (running or parked, never deleted) of the project's active conversations. */
|
|
27
|
+
export declare function listComputers(ctx: Ctx, folder: string): Promise<Computer[]>;
|
|
28
|
+
/**
|
|
29
|
+
* The conversation whose computer `--on` names: a conversation id, key or name, or the VM's workspace key or id (then
|
|
30
|
+
* the VM's own conversation, else its oldest live thread).
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveOn(ctx: Ctx, ref: string, folder: string): Promise<Conversation>;
|
|
33
|
+
/** Starts the thread on `--on`'s computer and opens it like a new conversation of that app. */
|
|
34
|
+
export declare function startThreadOn(ctx: Ctx, values: Values, path: string, explicitApp?: string): Promise<number>;
|
|
35
|
+
export declare const THREAD_HANDLERS: Record<string, Handler>;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// A new Claude or Codex thread on an existing conversation's computer (0.16.0+): `shard agents new --on <ref>`,
|
|
2
|
+
// `shard ./ --on <ref>` and `shard agents computers`. The thread joins that VM as a worker (its own branch and
|
|
3
|
+
// worktree, sharing the VM's packages, caches, services, ports and files) or, with --review, as a read-only Claude
|
|
4
|
+
// reviewer. Codex threads are genuine Codex Cloud threads. docs/progress/thread-on-existing-vm.md.
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
7
|
+
import { out, str } from "./commands.js";
|
|
8
|
+
import { UsageError } from "./errors.js";
|
|
9
|
+
import { table } from "./format.js";
|
|
10
|
+
import { AgentStateStore } from "./agent-state.js";
|
|
11
|
+
import { normalizeRemote, projectIdentity } from "./project-identity.js";
|
|
12
|
+
import { progress } from "./progress.js";
|
|
13
|
+
import { conversationClient } from "./conversation-commands.js";
|
|
14
|
+
import { conversationName, conversationWorkspace, getConversation, listConversations, localConversations, rememberConversation, validateConversation, waitThreadReady } from "./conversations.js";
|
|
15
|
+
import { syncAgentCredentials } from "./agent-credentials.js";
|
|
16
|
+
import { cloudClaudeLogin, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
|
|
17
|
+
import { promptWithImages, readInitialImages, readInitialPrompt, uploadInitialImages } from "./initial-prompt.js";
|
|
18
|
+
import { recordSeedSnapshot } from "./files-mirror.js";
|
|
19
|
+
import { ensureCodexCloud, printCloudThread, submitCloudInitialPrompt } from "./codex-cloud.js";
|
|
20
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21
|
+
const SHA = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
|
|
22
|
+
/** A conversation still bound to its computer: not done, archived or ended. */
|
|
23
|
+
export const activeConversation = (c) => c.state !== 'done' && c.state !== 'archived' && !c.ended_at;
|
|
24
|
+
/** The project's conversations: its repository's, or (for a folder without a remote) the ones started from it. */
|
|
25
|
+
export function projectConversations(ctx, all, folder) {
|
|
26
|
+
const project = projectIdentity(folder), remote = project.remote ?? `file://${project.folder}`, records = localConversations(ctx);
|
|
27
|
+
const same = (r) => { try {
|
|
28
|
+
return normalizeRemote(r, project.folder) === remote;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
} };
|
|
33
|
+
return all.filter(c => c.repo_remote ? same(c.repo_remote) : records[c.id]?.folder === project.folder);
|
|
34
|
+
}
|
|
35
|
+
/** The VM's own conversation first (its label), then the oldest thread on it. */
|
|
36
|
+
function host(bound, label) {
|
|
37
|
+
return bound.find(c => c.id === label) ?? [...bound].sort((a, b) => a.created_at.localeCompare(b.created_at))[0];
|
|
38
|
+
}
|
|
39
|
+
const thread = (c) => ({ id: c.id, key: c.key, title: c.title, harness: c.harness, state: c.state, route: c.route,
|
|
40
|
+
role: c.role ?? 'main', join_mode: c.join_mode ?? null, branch: c.branch });
|
|
41
|
+
/** Computers a new thread can start on: each live VM (running or parked, never deleted) of the project's active conversations. */
|
|
42
|
+
export async function listComputers(ctx, folder) {
|
|
43
|
+
const client = conversationClient(ctx);
|
|
44
|
+
const groups = new Map();
|
|
45
|
+
for (const c of projectConversations(ctx, await listConversations(ctx), folder).filter(activeConversation))
|
|
46
|
+
groups.set(c.workspace_id, [...groups.get(c.workspace_id) ?? [], c]);
|
|
47
|
+
const computers = await Promise.all([...groups].map(async ([id, bound]) => {
|
|
48
|
+
let ws;
|
|
49
|
+
try {
|
|
50
|
+
ws = await client.workspaces.get(id);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error instanceof ShardfluxApiError && error.status === 404)
|
|
54
|
+
return null;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
const observed = String(ws.data.observed_state ?? '');
|
|
58
|
+
if (ws.data.deleted_at || ['failed', 'deleted', 'deleting'].includes(observed))
|
|
59
|
+
return null;
|
|
60
|
+
const own = host(bound, ws.labels.shard_conversation);
|
|
61
|
+
const ordered = [own, ...bound.filter(c => c !== own).sort((a, b) => a.created_at.localeCompare(b.created_at))];
|
|
62
|
+
const last = bound.map(c => c.last_activity_at ?? c.created_at).sort().at(-1) ?? null;
|
|
63
|
+
return { workspace_id: ws.id, workspace_key: ws.key, state: observed === 'suspended' ? 'suspended' : 'running', last_activity_at: last, conversations: ordered.map(thread) };
|
|
64
|
+
}));
|
|
65
|
+
return computers.filter((c) => c !== null).sort((a, b) => (b.last_activity_at ?? '').localeCompare(a.last_activity_at ?? ''));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The conversation whose computer `--on` names: a conversation id, key or name, or the VM's workspace key or id (then
|
|
69
|
+
* the VM's own conversation, else its oldest live thread).
|
|
70
|
+
*/
|
|
71
|
+
export async function resolveOn(ctx, ref, folder) {
|
|
72
|
+
if (!ref.trim())
|
|
73
|
+
throw new UsageError('--on needs a conversation or computer; list them with shard agents computers.');
|
|
74
|
+
let target;
|
|
75
|
+
if (/^conv_[a-z0-9]{26}$/.test(ref))
|
|
76
|
+
target = await getConversation(ctx, ref);
|
|
77
|
+
else {
|
|
78
|
+
const all = await listConversations(ctx);
|
|
79
|
+
const keyed = all.filter(c => c.key === ref);
|
|
80
|
+
if (keyed.length === 1)
|
|
81
|
+
target = keyed[0];
|
|
82
|
+
else if (UUID.test(ref) || ref.includes('/')) {
|
|
83
|
+
const client = conversationClient(ctx);
|
|
84
|
+
let ws = null;
|
|
85
|
+
try {
|
|
86
|
+
ws = UUID.test(ref) ? await client.workspaces.get(ref) : await client.workspaces.findByKey(ref, { includeDeleted: false, signal: ctx.signal });
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (!(error instanceof ShardfluxApiError && error.status === 404))
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
if (ws)
|
|
93
|
+
target = host(all.filter(c => c.workspace_id === ws.id && activeConversation(c)), ws.labels.shard_conversation);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const named = projectConversations(ctx, all, folder).filter(c => conversationName(c) === ref);
|
|
97
|
+
const live = named.filter(activeConversation);
|
|
98
|
+
if ((live.length || named.length) > 1)
|
|
99
|
+
throw new UsageError(`Several conversations are named ${ref}; use its key or id (shard agents computers lists them).`);
|
|
100
|
+
target = live[0] ?? named[0];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (!target)
|
|
104
|
+
throw new UsageError(`No conversation or computer "${ref}" in this project. List them with shard agents computers.`);
|
|
105
|
+
if (!activeConversation(target))
|
|
106
|
+
throw new UsageError(`${target.title || target.key} has ended, so its computer is not available. Start the thread on a new computer, or continue that conversation with shard agents open ${target.id}.`);
|
|
107
|
+
return target;
|
|
108
|
+
}
|
|
109
|
+
/** `git rev-parse HEAD` of the target's worktree in its VM: the new thread starts from its committed work. */
|
|
110
|
+
async function targetHead(ws, target, signal) {
|
|
111
|
+
const head = await ws.cell().exec.run(['git', '-C', target.worktree_path, 'rev-parse', 'HEAD'], { timeoutMs: 30_000, maxOutputBytes: 4096, signal });
|
|
112
|
+
const sha = head.stdout.trim();
|
|
113
|
+
return head.exitCode === 0 && SHA.test(sha) ? sha : target.base_commit;
|
|
114
|
+
}
|
|
115
|
+
/** Records a thread that could never be admitted as discarded, so it never holds the shared computer. */
|
|
116
|
+
async function abandon(ctx, c) {
|
|
117
|
+
try {
|
|
118
|
+
await conversationClient(ctx).conversations.land(c.id, 'discard', { idempotencyKey: `abandon-${c.id}`, signal: ctx.signal });
|
|
119
|
+
}
|
|
120
|
+
catch { /* the API refuses it again on the next try */ }
|
|
121
|
+
}
|
|
122
|
+
/** Starts the thread on `--on`'s computer and opens it like a new conversation of that app. */
|
|
123
|
+
export async function startThreadOn(ctx, values, path, explicitApp) {
|
|
124
|
+
const ref = str(values.on) ?? '';
|
|
125
|
+
for (const flag of ['like', 'in', 'resume', 'new'])
|
|
126
|
+
if (values[flag] !== undefined && !(flag === 'in' && ['terminal', 'codex-app'].includes(String(values.in))))
|
|
127
|
+
throw new UsageError(`--${flag} cannot be combined with --on.`);
|
|
128
|
+
if (values.computer === true)
|
|
129
|
+
throw new UsageError('--computer is a setting of a new computer; a thread with --on uses its computer as it is.');
|
|
130
|
+
const text = await readInitialPrompt(ctx, values);
|
|
131
|
+
const images = await readInitialImages(ctx, values, text);
|
|
132
|
+
const initialPrompt = text === undefined ? undefined : promptWithImages(text, images);
|
|
133
|
+
const project = projectIdentity(path), projectKey = project.remote ?? project.folder, store = new AgentStateStore(ctx.store.dir);
|
|
134
|
+
const chosen = explicitApp ?? str(values.app);
|
|
135
|
+
if (chosen !== undefined && chosen !== 'claude' && chosen !== 'codex')
|
|
136
|
+
throw new UsageError('--app must be claude or codex');
|
|
137
|
+
const app = chosen ?? store.app(projectKey);
|
|
138
|
+
const review = values.review === true;
|
|
139
|
+
if (review && app === 'codex')
|
|
140
|
+
throw new UsageError('--review starts a read-only Claude reviewer. A Codex thread on a computer works on its own branch; omit --review.');
|
|
141
|
+
const name = str(values.name);
|
|
142
|
+
if (name !== undefined && !/^[a-z0-9][a-z0-9-]{0,47}$/.test(name))
|
|
143
|
+
throw new UsageError('--name must match ^[a-z0-9][a-z0-9-]{0,47}$');
|
|
144
|
+
const target = await resolveOn(ctx, ref, path);
|
|
145
|
+
let signedIn = true;
|
|
146
|
+
if (app === 'claude') {
|
|
147
|
+
await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
|
|
148
|
+
signedIn = Boolean(await cloudClaudeLogin(ctx));
|
|
149
|
+
if (!signedIn)
|
|
150
|
+
requireClaudeTerminal(ctx);
|
|
151
|
+
if (!ctx.json && values.detach !== true && (!ctx.io.stdin.isTTY || !ctx.io.stdin.setRawMode))
|
|
152
|
+
throw new UsageError('agent terminal requires a TTY; add --json to start it detached');
|
|
153
|
+
}
|
|
154
|
+
else
|
|
155
|
+
await ensureCodexCloud(ctx);
|
|
156
|
+
progress(ctx, `Starting ${app === 'claude' ? 'Claude' : 'Codex'} on the computer with ${target.title || target.key}…`);
|
|
157
|
+
const ws = await conversationWorkspace(ctx, target);
|
|
158
|
+
const commit = await targetHead(ws, target, ctx.signal);
|
|
159
|
+
const baseBranch = target.base_branch ?? undefined;
|
|
160
|
+
await uploadInitialImages(ws.cell(), images, ctx.signal);
|
|
161
|
+
const response = await conversationClient(ctx).conversations.create({ projectId: target.project_id, repoRemote: target.repo_remote ?? ws.labels.repo_remote ?? '',
|
|
162
|
+
harness: app, route: app === 'codex' ? 'codex-cloud' : 'terminal', mode: 'join', parentId: target.id, ...(review ? { joinMode: 'reviewer' } : {}),
|
|
163
|
+
base: { commit, ...(baseBranch ? { branch: baseBranch } : {}) }, ...(name ? { name } : {}) }, { idempotencyKey: randomUUID(), signal: ctx.signal });
|
|
164
|
+
let c = validateConversation({ ...response.conversation, restore: response.restore });
|
|
165
|
+
rememberConversation(ctx, c, project.folder, false, baseBranch);
|
|
166
|
+
store.save(projectKey, app);
|
|
167
|
+
if (app === 'codex') {
|
|
168
|
+
try {
|
|
169
|
+
await ctx.cloud().codex.admit(c.id, { idempotencyKey: `codex-${c.id}`, signal: ctx.signal });
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
await abandon(ctx, c);
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
const task = await ctx.cloud().codex.waitTask(c.id, { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
|
|
176
|
+
// The worktree exists once admission prepared it: record where the thread started for the files shelf.
|
|
177
|
+
await recordSeedSnapshot(ws, c, ctx.signal);
|
|
178
|
+
if (initialPrompt !== undefined)
|
|
179
|
+
await submitCloudInitialPrompt(ctx, c, initialPrompt, ws.key);
|
|
180
|
+
await printCloudThread(ctx, c, task, initialPrompt !== undefined);
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
// The computer's guest prepares the worktree and starts the harness; the terminal attaches to that session.
|
|
184
|
+
c = await waitThreadReady(ctx, c);
|
|
185
|
+
if (!review)
|
|
186
|
+
await recordSeedSnapshot(ws, c, ctx.signal);
|
|
187
|
+
if (!signedIn)
|
|
188
|
+
await loginClaudeInWorkspace(ctx, ws);
|
|
189
|
+
const { conversationTerminal } = await import("./conversation-terminal.js");
|
|
190
|
+
return conversationTerminal(ctx, ws, c, projectKey, { ...values, ...(initialPrompt === undefined ? {} : { initialPrompt }) });
|
|
191
|
+
}
|
|
192
|
+
export const THREAD_HANDLERS = {
|
|
193
|
+
async 'agents computers'(ctx, values) {
|
|
194
|
+
const computers = await listComputers(ctx, str(values.path) ?? './');
|
|
195
|
+
const label = (t) => `${t.title || t.key} (${t.harness}, ${t.state.replace('_', ' ')})`;
|
|
196
|
+
out(ctx, computers.length ? table(['COMPUTER', 'STATE', 'THREADS'], computers.map(c => [c.workspace_key, c.state, c.conversations.map(label).join('; ')]))
|
|
197
|
+
: 'No computers to start a thread on. Start a conversation with shard ./ first.\n', { computers });
|
|
198
|
+
return 0;
|
|
199
|
+
},
|
|
200
|
+
};
|
package/dist/update-check.d.ts
CHANGED
|
@@ -11,10 +11,16 @@ export interface Distribution {
|
|
|
11
11
|
export declare const CLI_DISTRIBUTION: Distribution;
|
|
12
12
|
interface CacheEntry {
|
|
13
13
|
checked_at: string;
|
|
14
|
+
/** What the notice compares with: the registry's latest, unless the catalog holds it (registry.ts). */
|
|
14
15
|
latest: string | null;
|
|
15
16
|
minimum_supported: string | null;
|
|
16
17
|
upgrade_command: string | null;
|
|
17
18
|
release_notes_url: string | null;
|
|
19
|
+
/** The catalog's own `latest` and `hold`, and the registry's answer with its ETag (0.15.2+). */
|
|
20
|
+
catalog_latest?: string | null;
|
|
21
|
+
hold?: string | null;
|
|
22
|
+
registry_latest?: string | null;
|
|
23
|
+
registry_etag?: string | null;
|
|
18
24
|
}
|
|
19
25
|
/** The `update` object of `shard version --json`. */
|
|
20
26
|
export interface UpdateView {
|
|
@@ -37,6 +43,8 @@ export interface UpdateCheckOptions {
|
|
|
37
43
|
distribution: Distribution;
|
|
38
44
|
fetch: typeof fetch;
|
|
39
45
|
userAgent: string;
|
|
46
|
+
/** The npm registry (default https://registry.npmjs.org). */
|
|
47
|
+
registry?: string;
|
|
40
48
|
now?: () => number;
|
|
41
49
|
}
|
|
42
50
|
/**
|
|
@@ -51,7 +59,7 @@ export declare class UpdateCheck {
|
|
|
51
59
|
status: ClientVersionStatus;
|
|
52
60
|
checkedAt: string | null;
|
|
53
61
|
} | null;
|
|
54
|
-
/** Starts the check: from the cache when it is younger than
|
|
62
|
+
/** Starts the check: from the cache when it is younger than 12 h (unless `force`), else the registry and the API. */
|
|
55
63
|
start(force?: boolean): void;
|
|
56
64
|
/** The status (with when it was fetched), or null when not started or still running after `waitMs`. */
|
|
57
65
|
result(waitMs: number): Promise<{
|
package/dist/update-check.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `shard` tells its user when it is outdated or no longer supported (0.5.0+). The
|
|
3
|
-
*
|
|
2
|
+
* `shard` tells its user when it is outdated or no longer supported (0.5.0+). The latest version comes from the
|
|
3
|
+
* registry (0.15.2+: npm's `latest` dist-tag, see registry.ts); the API's GET /v1/client-versions names the oldest
|
|
4
|
+
* version it still serves and, optionally, a version to stop recommending (`hold`).
|
|
4
5
|
*
|
|
5
|
-
* - At most one
|
|
6
|
-
*
|
|
7
|
-
* recorded before the
|
|
8
|
-
* - Started in parallel with the command (
|
|
6
|
+
* - At most one check per 12 hours per API URL and package: the answer is cached in <config dir>/update-check.json
|
|
7
|
+
* (`checked_at`, `latest`, `minimum_supported`, `upgrade_command`, and what each source said, with the registry's
|
|
8
|
+
* ETag). The attempt is recorded before the requests, so an unreachable network is not asked again for 12 hours.
|
|
9
|
+
* - Started in parallel with the command (two requests in parallel, 3 s timeout each, never throws) and
|
|
9
10
|
* printed on stderr after it. When the command ends first, `shard` waits at most ~300 ms and otherwise skips the
|
|
10
11
|
* notice (the next run uses what this one cached). Never changes the exit code; never printed after a command that
|
|
11
12
|
* failed with --json (the error document stays the only stderr content).
|
|
@@ -14,13 +15,16 @@
|
|
|
14
15
|
* - Off with SHARDFLUX_NO_UPDATE_CHECK=1 (also true/yes/on) or NO_UPDATE_NOTIFIER (the SDK's rules);
|
|
15
16
|
* `shard version --check` still asks.
|
|
16
17
|
*/
|
|
17
|
-
import {
|
|
18
|
+
import { clientVersionStatus, versionCheckDisabledByEnv } from '@shardflux/sdk';
|
|
18
19
|
import { readFileSync } from 'node:fs';
|
|
19
20
|
import { join } from 'node:path';
|
|
20
21
|
import { CLI_VERSION } from "./args.js";
|
|
21
22
|
import { writePrivateFile } from "./credentials.js";
|
|
23
|
+
import { NPM_REGISTRY, npmLatest, withRegistryLatest } from "./registry.js";
|
|
22
24
|
export const UPDATE_CHECK_FILE = 'update-check.json';
|
|
23
|
-
export const CHECK_INTERVAL_MS =
|
|
25
|
+
export const CHECK_INTERVAL_MS = 12 * 3600 * 1000;
|
|
26
|
+
/** The catalog request's timeout (ms). */
|
|
27
|
+
const CATALOG_TIMEOUT_MS = 3_000;
|
|
24
28
|
/** How long the end of a command waits for a check that is still running. */
|
|
25
29
|
export const NOTICE_WAIT_MS = 300;
|
|
26
30
|
export const CLI_DISTRIBUTION = { package: '@shardflux/cli', version: CLI_VERSION };
|
|
@@ -44,7 +48,26 @@ function entryOf(doc, apiUrl, pkg) {
|
|
|
44
48
|
const e = doc.checks[apiUrl]?.[pkg];
|
|
45
49
|
if (!isObj(e) || typeof e.checked_at !== 'string' || Number.isNaN(Date.parse(e.checked_at)))
|
|
46
50
|
return null;
|
|
47
|
-
return {
|
|
51
|
+
return {
|
|
52
|
+
checked_at: e.checked_at, latest: strOrNull(e.latest), minimum_supported: strOrNull(e.minimum_supported), upgrade_command: strOrNull(e.upgrade_command), release_notes_url: strOrNull(e.release_notes_url),
|
|
53
|
+
// An entry written before 0.15.2 has no sources: its `latest` came from the catalog.
|
|
54
|
+
catalog_latest: 'catalog_latest' in e ? strOrNull(e.catalog_latest) : strOrNull(e.latest), hold: strOrNull(e.hold), registry_latest: strOrNull(e.registry_latest), registry_etag: strOrNull(e.registry_etag),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** GET /v1/client-versions, or null when the API did not answer with JSON (never throws). */
|
|
58
|
+
async function fetchCatalog(apiUrl, f, userAgent) {
|
|
59
|
+
try {
|
|
60
|
+
const res = await f(new URL('/v1/client-versions', `${apiUrl.replace(/\/+$/, '')}/`), { method: 'GET', headers: { accept: 'application/json', 'user-agent': userAgent }, signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS) });
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
await res.body?.cancel().catch(() => undefined);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const body = JSON.parse(await res.text());
|
|
66
|
+
return isObj(body) && Array.isArray(body.clients) ? body : null;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
48
71
|
}
|
|
49
72
|
/** The status the cached answer gives this distribution (the same rules as the SDK; no request). */
|
|
50
73
|
export function statusFromEntry(entry, d) {
|
|
@@ -71,7 +94,7 @@ export class UpdateCheck {
|
|
|
71
94
|
const cached = entryOf(readCache(this.#file), this.#o.apiUrl, this.#o.distribution.package);
|
|
72
95
|
return cached ? { status: statusFromEntry(cached, this.#o.distribution), checkedAt: cached.checked_at } : null;
|
|
73
96
|
}
|
|
74
|
-
/** Starts the check: from the cache when it is younger than
|
|
97
|
+
/** Starts the check: from the cache when it is younger than 12 h (unless `force`), else the registry and the API. */
|
|
75
98
|
start(force = false) {
|
|
76
99
|
const { apiUrl, distribution: d } = this.#o;
|
|
77
100
|
const now = (this.#o.now ?? Date.now)();
|
|
@@ -82,14 +105,29 @@ export class UpdateCheck {
|
|
|
82
105
|
return;
|
|
83
106
|
}
|
|
84
107
|
const checkedAt = new Date(now).toISOString();
|
|
85
|
-
// Record the attempt first:
|
|
86
|
-
//
|
|
108
|
+
// Record the attempt first: requests that never finish (the command ended, the network is down) still count,
|
|
109
|
+
// and the previous answer stays until a new one arrives.
|
|
87
110
|
this.#write(doc, { ...(cached ?? { latest: null, minimum_supported: null, upgrade_command: null, release_notes_url: null }), checked_at: checkedAt });
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
111
|
+
const f = this.#o.fetch;
|
|
112
|
+
this.#pending = Promise.all([
|
|
113
|
+
fetchCatalog(apiUrl, f, this.#o.userAgent),
|
|
114
|
+
npmLatest(d.package, { fetch: f, registry: this.#o.registry ?? NPM_REGISTRY, etag: cached?.registry_latest ? cached.registry_etag : null, userAgent: this.#o.userAgent }),
|
|
115
|
+
]).then(([catalog, registry]) => {
|
|
116
|
+
const registryLatest = registry?.notModified ? (cached?.registry_latest ?? null) : (registry?.latest ?? null);
|
|
117
|
+
if (catalog === null && registryLatest === null) {
|
|
118
|
+
// Nobody answered: the last answer stands.
|
|
119
|
+
return { status: cached ? statusFromEntry(cached, d) : statusFromEntry(null, d), checkedAt: cached ? cached.checked_at : null };
|
|
120
|
+
}
|
|
121
|
+
// A catalog that did not answer this time: what it said last time (minimum_supported, hold) still applies.
|
|
122
|
+
const base = catalog ?? (cached ? { clients: [{ package: d.package, ecosystem: 'npm', latest: cached.catalog_latest ?? null, minimum_supported: cached.minimum_supported, upgrade_command: cached.upgrade_command, release_notes_url: cached.release_notes_url, hold: cached.hold ?? null }] } : null);
|
|
123
|
+
const merged = withRegistryLatest(base, registryLatest ? { [d.package]: registryLatest } : {});
|
|
124
|
+
const own = merged.clients.find((c) => c.ecosystem === 'npm' && c.package === d.package);
|
|
125
|
+
const status = clientVersionStatus(merged, { package: d.package, version: d.version, ecosystem: 'npm' });
|
|
126
|
+
const raw = base?.clients.find((c) => c.ecosystem === 'npm' && c.package === d.package);
|
|
127
|
+
this.#write(readCache(this.#file), {
|
|
128
|
+
checked_at: checkedAt, latest: own?.latest ?? null, minimum_supported: own?.minimum_supported ?? null, upgrade_command: own?.upgrade_command ?? null, release_notes_url: own?.release_notes_url ?? null,
|
|
129
|
+
catalog_latest: strOrNull(raw?.latest), hold: own?.hold ?? null, registry_latest: registryLatest, registry_etag: registry?.etag ?? cached?.registry_etag ?? null,
|
|
130
|
+
});
|
|
93
131
|
return { status, checkedAt };
|
|
94
132
|
});
|
|
95
133
|
}
|
package/dist/update.d.ts
CHANGED
|
@@ -52,6 +52,8 @@ export interface UpdateDeps {
|
|
|
52
52
|
run?: Runner;
|
|
53
53
|
menubar?: MenubarManager;
|
|
54
54
|
}
|
|
55
|
+
/** True when nothing is behind but the menu bar app, and the app the npm package carries is already that version. */
|
|
56
|
+
export declare function onlyAppToSwap(pending: Component[], packagedApp: string | null): boolean;
|
|
55
57
|
export declare function update(ctx: Ctx, values: Record<string, unknown>, deps?: UpdateDeps): Promise<number>;
|
|
56
58
|
/** Records where `shard` runs, for the menu bar's update button. Written on macOS, only when it changed. */
|
|
57
59
|
export declare function recordInstall(dir: string, runtime: Runtime, distribution: {
|
package/dist/update.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `shard update` (0.13.5+): brings this computer's Shardflux install up to date in one step. It checks every local
|
|
3
|
-
* piece against GET /v1/client-versions
|
|
4
|
-
* the CLI and SDK inside it, the macOS menu bar app and a globally installed `@shardflux/mcp` — then runs one install
|
|
3
|
+
* piece against the latest versions on npm (0.15.2+; GET /v1/client-versions adds minimum_supported and hold) — the
|
|
4
|
+
* package that runs `shard` (the `shardflux` bundle or `@shardflux/cli`), the CLI and SDK inside it, the macOS menu bar app and a globally installed `@shardflux/mcp` — then runs one install
|
|
5
5
|
* of the latest versions with the package manager that installed `shard`, checks the result with the new `shard`,
|
|
6
6
|
* and restarts the menu bar on its new app. `--check` only reports.
|
|
7
7
|
*
|
|
@@ -18,7 +18,7 @@ import { AgentStateStore } from "./agent-state.js";
|
|
|
18
18
|
import { writePrivateFile } from "./credentials.js";
|
|
19
19
|
import { CodedError } from "./errors.js";
|
|
20
20
|
import { json } from "./format.js";
|
|
21
|
-
import {
|
|
21
|
+
import { compareAppVersions, MenubarManager, packagedAppPath } from "./menubar.js";
|
|
22
22
|
export const INSTALL_FILE = 'install.json';
|
|
23
23
|
export const LAST_UPDATE_FILE = 'last-update.json';
|
|
24
24
|
const LOCK_FILE = 'update.lock';
|
|
@@ -175,6 +175,10 @@ function npmFor(node) {
|
|
|
175
175
|
const sibling = join(dirname(node), process.platform === 'win32' ? 'npm.cmd' : 'npm');
|
|
176
176
|
return existsSync(sibling) ? sibling : 'npm';
|
|
177
177
|
}
|
|
178
|
+
/** True when nothing is behind but the menu bar app, and the app the npm package carries is already that version. */
|
|
179
|
+
export function onlyAppToSwap(pending, packagedApp) {
|
|
180
|
+
return pending.every(c => c.package === MENUBAR && packagedApp !== null && c.latest !== null && compareAppVersions(packagedApp, c.latest) >= 0);
|
|
181
|
+
}
|
|
178
182
|
export async function update(ctx, values, deps = {}) {
|
|
179
183
|
const run = deps.run ?? runProcess;
|
|
180
184
|
const menubar = deps.menubar ?? menubarManager(ctx);
|
|
@@ -193,10 +197,15 @@ export async function update(ctx, values, deps = {}) {
|
|
|
193
197
|
: `Shardflux is up to date (${versions}).\n`);
|
|
194
198
|
return 0;
|
|
195
199
|
}
|
|
196
|
-
|
|
197
|
-
|
|
200
|
+
// The npm package may already carry the latest menu bar app (installed with npm, not yet swapped in): no install.
|
|
201
|
+
const packagedApp = pending.some(c => c.package === MENUBAR) ? await menubar.packagedVersion().catch(() => null) : null;
|
|
202
|
+
if (onlyAppToSwap(pending, packagedApp)) {
|
|
198
203
|
const refreshed = await refreshMenubar(ctx, menubar);
|
|
199
|
-
|
|
204
|
+
const changes = refreshed.status === 'restarted' ? pending.map(c => ({ package: c.package, label: c.label, from: c.current, to: refreshed.version ?? null })) : [];
|
|
205
|
+
if (changes.length)
|
|
206
|
+
writeLastUpdate(ctx.store.dir, { status: 'ok', changes, message: `Updated ${changesText(changes)}`, command });
|
|
207
|
+
ctx.io.stdout.write(ctx.json ? json({ ...view, updated: changes.length > 0, ...(changes.length ? { changes } : {}), menubar: refreshed.status })
|
|
208
|
+
: changes.length ? `Updated ${changesText(changes)}.\n` : `Shardflux is up to date (${versions}).\n`);
|
|
200
209
|
return 0;
|
|
201
210
|
}
|
|
202
211
|
const argv = installArgv(install, specs, runtime ? npmFor(runtime.node) : 'npm');
|
|
@@ -269,7 +278,7 @@ async function refreshMenubar(ctx, menubar, runtime, run, env) {
|
|
|
269
278
|
}
|
|
270
279
|
else {
|
|
271
280
|
const packaged = await menubar.packagedVersion().catch(() => null);
|
|
272
|
-
if (!packaged || !before.version ||
|
|
281
|
+
if (!packaged || !before.version || compareAppVersions(before.version, packaged) >= 0)
|
|
273
282
|
return { status: 'current' };
|
|
274
283
|
await menubar.start(ctx.store.dir, ctx.apiUrl);
|
|
275
284
|
}
|
|
@@ -283,7 +292,16 @@ export function recordInstall(dir, runtime, distribution) {
|
|
|
283
292
|
script = realpathSync(script);
|
|
284
293
|
}
|
|
285
294
|
catch { /* keep the path */ }
|
|
286
|
-
|
|
295
|
+
// The app this CLI's npm package carries (0.15.2+): the menu bar compares itself with it and swaps itself in.
|
|
296
|
+
let menubarApp = packagedAppPath();
|
|
297
|
+
try {
|
|
298
|
+
if (menubarApp)
|
|
299
|
+
menubarApp = realpathSync(menubarApp);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
menubarApp = null;
|
|
303
|
+
}
|
|
304
|
+
const doc = { version: 1, node: runtime.node, script, package: distribution.package, package_version: distribution.version, cli_version: CLI_VERSION, ...(menubarApp ? { menubar_app: menubarApp } : {}) };
|
|
287
305
|
const text = `${JSON.stringify(doc, null, 2)}\n`;
|
|
288
306
|
try {
|
|
289
307
|
if (readFileSync(join(dir, INSTALL_FILE), 'utf8') === text)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a wait stands (0.16.0+): while a command waits on an operation whose start is queued for capacity, or a call
|
|
3
|
+
* waits for room (the workspace's host making room to wake it, or a free working slot of the plan), one line on stderr
|
|
4
|
+
* says so: the queue position, what the pool is doing (a new host starting, when it is expected) and until when the
|
|
5
|
+
* start holds its place. A line is printed only when it changes; dim on a terminal, plain when piped; never with
|
|
6
|
+
* --json, where stdout stays one document and stderr is left to the error.
|
|
7
|
+
*/
|
|
8
|
+
import type { ProgressEvent, ProgressListener, RetryRecord } from '@shardflux/sdk';
|
|
9
|
+
import type { Writer } from './commands.js';
|
|
10
|
+
/** `2026-10-09T21:40:12Z` as `21:40Z` (UTC); the input when it is not a timestamp. */
|
|
11
|
+
export declare function clockZ(iso: string): string;
|
|
12
|
+
/** A queued start in words: `queued for capacity (position 3): a new host is starting, ready by 21:40Z; it holds its place until 21:51Z`. */
|
|
13
|
+
export declare function queueLine(e: Pick<Extract<ProgressEvent, {
|
|
14
|
+
type: 'phase';
|
|
15
|
+
}>, 'queuePosition' | 'scaleOut' | 'expectedReadyBy' | 'deadlineAt'>): string;
|
|
16
|
+
/** A call waiting to be admitted, in words. */
|
|
17
|
+
export declare function admissionLine(r: Pick<RetryRecord, 'reason' | 'queuePosition'>): string;
|
|
18
|
+
/**
|
|
19
|
+
* The client listener that prints where a wait stands. Silent with `json`. A phase other than capacity_pending ends
|
|
20
|
+
* the queue, so a later queue prints again.
|
|
21
|
+
*/
|
|
22
|
+
export declare function waitStatusListener(stderr: Writer, json: boolean): ProgressListener;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** `2026-10-09T21:40:12Z` as `21:40Z` (UTC); the input when it is not a timestamp. */
|
|
2
|
+
export function clockZ(iso) {
|
|
3
|
+
const t = Date.parse(iso);
|
|
4
|
+
return Number.isFinite(t) ? `${new Date(t).toISOString().slice(11, 16)}Z` : iso;
|
|
5
|
+
}
|
|
6
|
+
/** A queued start in words: `queued for capacity (position 3): a new host is starting, ready by 21:40Z; it holds its place until 21:51Z`. */
|
|
7
|
+
export function queueLine(e) {
|
|
8
|
+
const position = e.queuePosition ? ` (position ${e.queuePosition})` : '';
|
|
9
|
+
const pool = e.scaleOut === 'requested'
|
|
10
|
+
? `: a new host is starting${e.expectedReadyBy ? `, ready by ${clockZ(e.expectedReadyBy)}` : ''}`
|
|
11
|
+
: e.scaleOut === 'clamped'
|
|
12
|
+
? ': it starts as soon as a host has room'
|
|
13
|
+
: '';
|
|
14
|
+
const until = e.deadlineAt ? `; it holds its place until ${clockZ(e.deadlineAt)}` : '';
|
|
15
|
+
return `queued for capacity${position}${pool}${until}`;
|
|
16
|
+
}
|
|
17
|
+
/** A call waiting to be admitted, in words. */
|
|
18
|
+
export function admissionLine(r) {
|
|
19
|
+
const position = r.queuePosition ? ` (position ${r.queuePosition})` : '';
|
|
20
|
+
if (r.reason === 'host_capacity')
|
|
21
|
+
return `waiting for room on the workspace's host${position}`;
|
|
22
|
+
if (r.reason === 'concurrent_workspaces')
|
|
23
|
+
return 'waiting for a free working slot: every workspace the plan lets work at once is busy';
|
|
24
|
+
return `waiting to be admitted${r.reason ? ` (${r.reason})` : ''}${position}`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The client listener that prints where a wait stands. Silent with `json`. A phase other than capacity_pending ends
|
|
28
|
+
* the queue, so a later queue prints again.
|
|
29
|
+
*/
|
|
30
|
+
export function waitStatusListener(stderr, json) {
|
|
31
|
+
let last = null;
|
|
32
|
+
return (e) => {
|
|
33
|
+
if (json)
|
|
34
|
+
return;
|
|
35
|
+
let line;
|
|
36
|
+
if (e.type === 'retry') {
|
|
37
|
+
if (!e.retry.admission)
|
|
38
|
+
return;
|
|
39
|
+
line = admissionLine(e.retry);
|
|
40
|
+
}
|
|
41
|
+
else if (e.type === 'phase') {
|
|
42
|
+
if (e.phase !== 'capacity_pending') {
|
|
43
|
+
if (e.action !== 'tool')
|
|
44
|
+
last = null;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
line = queueLine(e);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
if (e.action !== 'token' && e.action !== 'tool')
|
|
51
|
+
last = null;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (line === last)
|
|
55
|
+
return;
|
|
56
|
+
last = line;
|
|
57
|
+
stderr.write(stderr.isTTY ? `\x1b[2m· ${line}\x1b[0m\n` : `${line}\n`);
|
|
58
|
+
};
|
|
59
|
+
}
|