@shardflux/cli 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,12 @@
1
1
  /**
2
- * `shard` tells its user when it is outdated or no longer supported (0.5.0+). The API's
3
- * GET /v1/client-versions names the latest published version of every client and the oldest one it still serves.
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 request per 24 hours per API URL and package: the answer is cached in
6
- * <config dir>/update-check.json (`checked_at`, `latest`, `minimum_supported`, `upgrade_command`). The attempt is
7
- * recorded before the request, so an unreachable API is not asked again for a day either.
8
- * - Started in parallel with the command (the SDK's checkClientVersion: one request, 3 s timeout, never throws) and
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 { checkClientVersion, clientVersionStatus, versionCheckDisabledByEnv } from '@shardflux/sdk';
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 = 24 * 3600 * 1000;
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 { 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) };
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 24 h (unless `force`), else one request. */
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: a request that never finishes (the command ended, the API is unreachable) still
86
- // counts, and the previous answer stays until a new one arrives.
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
- this.#pending = checkClientVersion({ baseUrl: apiUrl, fetch: this.#o.fetch, package: d.package, version: d.version, ecosystem: 'npm', userAgent: this.#o.userAgent }).then((status) => {
89
- const answered = status.latest !== null || status.minimumSupported !== null;
90
- if (!answered)
91
- return { status: cached ? statusFromEntry(cached, d) : status, checkedAt: cached ? cached.checked_at : null };
92
- this.#write(readCache(this.#file), { checked_at: checkedAt, latest: status.latest, minimum_supported: status.minimumSupported, upgrade_command: status.upgradeCommand, release_notes_url: status.releaseNotesUrl });
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 — the package that runs `shard` (the `shardflux` bundle or `@shardflux/cli`),
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 { compareBundleVersions, MenubarManager } from "./menubar.js";
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
- if (!pending.length) {
197
- // The npm package may already carry a newer menu bar app than the one running.
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
- ctx.io.stdout.write(ctx.json ? json({ ...view, updated: false, menubar: refreshed }) : `Shardflux is up to date (${versions}).\n`);
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 || compareBundleVersions(before.version, packaged) >= 0)
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
- const doc = { version: 1, node: runtime.node, script, package: distribution.package, package_version: distribution.version, cli_version: CLI_VERSION };
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "type": "module",
5
5
  "description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork serverless agent workspaces.",
6
6
  "license": "Apache-2.0",
@@ -58,7 +58,7 @@
58
58
  "typescript-eslint": "8.70.1"
59
59
  },
60
60
  "optionalDependencies": {
61
- "@shardflux/menubar-darwin": "^0.3.0"
61
+ "@shardflux/menubar-darwin": "^0.3.2"
62
62
  },
63
63
  "scripts": {
64
64
  "build": "node scripts/build.mjs",