@shardflux/cli 0.13.4 → 0.13.6

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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.6
4
+
5
+ - `shard` makes the macOS menu bar app's executable runnable when it installs or starts the app, so a menu bar copied
6
+ from `@shardflux/menubar-darwin` 0.1.1 or 0.1.2 starts (and an existing copy of those is repaired).
7
+
8
+ ## 0.13.5
9
+
10
+ - `shard update` brings this computer's Shardflux install up to date in one step: it checks shard (the `shardflux`
11
+ package or `@shardflux/cli`), its SDK, the macOS menu bar app and a globally installed `@shardflux/mcp` against the
12
+ latest versions, runs one install with the package manager that installed shard (npm in its own prefix, pnpm, bun
13
+ or Volta), checks the result with the new shard and restarts the menu bar on its new app. `--check` only reports;
14
+ `--json` for both. A prefix npm cannot write to fails with `update_permission_denied` and the exact command to run.
15
+ - The menu bar's Update button runs `shard update`: each `shard` run on macOS records where it runs
16
+ (`install.json` in the config directory), and `shard update` records its outcome (`last-update.json`).
17
+ - `shard` replaces a running menu bar app that is older than the one its npm package carries (after
18
+ `npm i -g shardflux`, the old app used to keep running).
19
+ - The daily update notice and `shard version` say `Update: shard update`.
20
+ - `shard agents list` shows each session's latest message (LAST MESSAGE); `--json` returns it as `last_message`.
21
+
3
22
  ## 0.13.4
4
23
 
5
24
  - Cloud Claude sign-in and reopening the machine editor preserve existing workspace labels while updating their own labels.
@@ -20,7 +39,7 @@
20
39
  - Git history above the first copy's 256 MiB limit fails with `repository_too_large` and its next step in the hint, not `internal_error`.
21
40
  - A `revision_mismatch` suggests `--expected-revision` only for the file commands that take it; elsewhere it says to run the same command again.
22
41
  - Config sync reports project entries keyed by paths on this computer (Codex `config.projects.*`) as one summary line.
23
- - The command center does not show a setup step that `.shardflux/setup.yaml` marks `required: false` as the setup failure while the template saves, and reads skipped steps (coding template 9+).
42
+ - The command center does not show a setup step that `.shardflux/setup.yaml` marks `required: false` as the setup failure while the template saves, and reads skipped steps (coding template 10+).
24
43
 
25
44
  ## 0.13.2
26
45
 
package/README.md CHANGED
@@ -346,12 +346,26 @@ and the session renewals of step-up and password changes).
346
346
  - **Never printed:** the session and the saved key appear only in the file. Output shows key ids; error
347
347
  output redacts anything shaped like a key or session token. `shard env` prints the key on purpose.
348
348
 
349
+ ## Update
350
+
351
+ `shard update` **(0.13.5+)** brings everything Shardflux installed on this computer up to date: shard itself
352
+ (the `shardflux` package or `@shardflux/cli`), its SDK, the macOS menu bar app and a globally installed
353
+ `@shardflux/mcp`. It runs one install of the latest versions with the package manager that installed shard (npm,
354
+ pnpm, bun or Volta) and restarts the menu bar on its new app. The menu bar's **Update** button runs the same command.
355
+
356
+ ```sh
357
+ shard update --check # what is behind, without installing
358
+ shard update
359
+ ```
360
+
361
+ Projects that depend on `@shardflux/sdk` or the Python `shardflux` package keep the versions their lockfiles pin.
362
+
349
363
  ## Version check
350
364
 
351
365
  `shard` tells you when a newer version exists, on stderr after the command's output:
352
366
 
353
367
  ```text
354
- @shardflux/cli 0.5.0 is outdated: 0.6.0 is available. Update: npm i -g shardflux
368
+ @shardflux/cli 0.13.5 is outdated: 0.14.0 is available. Update: shard update
355
369
  ```
356
370
 
357
371
  or that the API no longer supports it (`... is no longer supported by the Shardflux API (minimum
@@ -8,6 +8,7 @@ interface GuestSession {
8
8
  since_unix_ms: number;
9
9
  title: string;
10
10
  remote_control_url: string;
11
+ last_message?: string;
11
12
  }
12
13
  /** Parses `sf-agent list` stdout; anything malformed yields no sessions rather than a wrong row. */
13
14
  export declare function guestSessions(stdout: string): GuestSession[];
@@ -61,7 +61,9 @@ export async function withLiveAgents(ctx, agents, filter, now = Date.now()) {
61
61
  return [];
62
62
  return guestSessions(result.stdout).map(s => ({
63
63
  id: `${w.id}:${s.session_id}`, workspace_id: w.id, workspace_key: w.workspace_key, app: s.app, state: s.state,
64
- since: new Date(s.since_unix_ms).toISOString(), title: s.title, remote_control_url: s.remote_control_url, labels: w.labels,
64
+ since: new Date(s.since_unix_ms).toISOString(), title: s.title, remote_control_url: s.remote_control_url,
65
+ // A guest from before the latest message (coding templates before it) lists none.
66
+ last_message: typeof s.last_message === 'string' ? s.last_message : '', labels: w.labels,
65
67
  }));
66
68
  }
67
69
  catch (error) {
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.13.4";
1
+ export declare const CLI_VERSION = "0.13.6";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
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.4';
15
+ export const CLI_VERSION = '0.13.6';
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).' },
@@ -161,6 +161,19 @@ const KEY_COMMANDS = [
161
161
  options: {},
162
162
  },
163
163
  { path: ['whoami'], summary: 'Show the principal behind the project API key (SHARDFLUX_API_KEY, else the saved one)', positionals: [], options: {} },
164
+ {
165
+ path: ['update'],
166
+ summary: 'Update shard, the menu bar app and a global MCP server to their latest versions',
167
+ description: [
168
+ 'Checks every local Shardflux piece against GET /v1/client-versions: shard (the shardflux package or @shardflux/cli),',
169
+ 'its SDK, the macOS menu bar app and a globally installed @shardflux/mcp. When one is behind, runs one install of',
170
+ 'the latest versions with the package manager that installed shard (npm, pnpm, bun or Volta), then restarts the',
171
+ 'menu bar on its new app. --check only reports. Projects that depend on @shardflux/sdk or shardflux (Python) keep',
172
+ 'their own versions.',
173
+ ].join('\n'),
174
+ positionals: [],
175
+ options: { check: { type: 'boolean', description: 'Report what is behind without installing anything.' } },
176
+ },
164
177
  {
165
178
  path: ['version'],
166
179
  summary: 'Print the CLI and SDK versions and whether a newer shard is available',
@@ -1540,7 +1553,7 @@ export const HELP_SECTIONS = [
1540
1553
  { title: 'Account (a user session: shard signup or shard auth login)', groups: ['signup', 'verify', 'auth', 'setup', 'env', 'orgs', 'projects', 'api-keys', 'members', 'invitations', 'billing', 'audit', 'account'] },
1541
1554
  { title: 'Workspaces (a project API key: SHARDFLUX_API_KEY, else the one shard setup saved)', groups: ['login', 'whoami', 'status', 'usage', 'machine', 'template', 'agents', 'ssh-proxy', 'workspaces', 'files', 'operations', 'executions', 'secrets', 'credentials', 'volumes', 'egress'] },
1542
1555
  { title: 'Templates (publish and archive use the session; the rest the API key)', groups: ['templates'] },
1543
- { title: 'Other', groups: ['menubar', 'feedback', 'version'] },
1556
+ { title: 'Other', groups: ['menubar', 'update', 'feedback', 'version'] },
1544
1557
  ];
1545
1558
  export function topHelp() {
1546
1559
  const w = Math.max(...COMMANDS.map((c) => usageLine(c).length));
@@ -64,6 +64,10 @@ export interface Ctx {
64
64
  input: Input;
65
65
  /** The package that runs the CLI (the version check's identity). */
66
66
  distribution: Distribution;
67
+ /** The Node.js binary and script of this process; null when run in-process (tests, embedders). */
68
+ runtime: import('./update.js').Runtime | null;
69
+ /** GET /v1/client-versions (unauthenticated): the latest version of every client. */
70
+ clientVersions(): Promise<unknown>;
67
71
  /** This machine's name (default API key name of `shard setup`). */
68
72
  hostname(): string;
69
73
  }
package/dist/commands.js CHANGED
@@ -818,7 +818,9 @@ export const KEY_HANDLERS = {
818
818
  // A complete listing also shows sessions the projection has not picked up yet (agent-live.ts).
819
819
  if (!next && !str(values.cursor))
820
820
  rows.splice(0, rows.length, ...await withLiveAgents(ctx, rows, { ...(state ? { state } : {}), labels }));
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 });
821
+ // LAST MESSAGE (0.13.4+): the session's latest message, from an API that reports it.
822
+ const message = (a) => !a.last_message ? '-' : a.last_message.length > 60 ? `${a.last_message.slice(0, 59)}…` : a.last_message;
823
+ out(ctx, rows.length ? table(['WORKSPACE', 'APP', 'STATE', 'TITLE', 'LAST MESSAGE'], rows.map(a => [a.workspace_key, a.app, a.state, a.title, message(a)])) : 'No agent sessions.\n', { data: rows, next_cursor: next });
822
824
  if (!ctx.json && next)
823
825
  ctx.io.stderr.write(`more: shard agents list --cursor ${next}\n`);
824
826
  return 0;
@@ -2374,9 +2376,9 @@ function updateLine(u, d) {
2374
2376
  case 'current':
2375
2377
  return `up to date${pkg}: latest ${u.latest ?? '-'}${when}\n`;
2376
2378
  case 'outdated':
2377
- return `update available${pkg}: ${u.latest} (you have ${u.current}). Update: ${u.upgrade_command ?? '-'}\n`;
2379
+ return `update available${pkg}: ${u.latest} (you have ${u.current}). Update: shard update\n`;
2378
2380
  case 'unsupported':
2379
- return `no longer supported${pkg}: the API needs ${u.minimum_supported} or later (latest ${u.latest ?? '-'}). Update: ${u.upgrade_command ?? '-'}\n`;
2381
+ return `no longer supported${pkg}: the API needs ${u.minimum_supported} or later (latest ${u.latest ?? '-'}). Update: shard update\n`;
2380
2382
  default:
2381
2383
  return `update status unknown${pkg}${when} (the API could not be asked, or has no release of ${u.package} yet)\n`;
2382
2384
  }
package/dist/handlers.js CHANGED
@@ -11,5 +11,6 @@ import { TARGET_HANDLERS } from "./agent-target-commands.js";
11
11
  import { CENTER_HANDLERS } from "./command-center-actions.js";
12
12
  import { AGENT_GIT_HANDLERS } from "./agent-git-commands.js";
13
13
  import { MENUBAR_HANDLERS } from "./menubar.js";
14
+ import { UPDATE_HANDLERS } from "./update.js";
14
15
  import { FILESYSTEM_HANDLERS } from "./filesystem-commands.js";
15
- export const HANDLERS = { ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS };
16
+ export const HANDLERS = { ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
package/dist/main.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Io } from './commands.js';
2
+ import type { Runtime } from './update.js';
2
3
  import type { Distribution } from './update-check.js';
3
4
  /** A sleep that ends early when the signal aborts (so Ctrl-C never waits out a poll interval). */
4
5
  export declare function abortableSleep(signal: AbortSignal): (ms: number) => Promise<void>;
@@ -6,6 +7,8 @@ export interface RunOptions {
6
7
  signal?: AbortSignal;
7
8
  /** The package that runs the CLI, for the version check (default @shardflux/cli at CLI_VERSION). */
8
9
  distribution?: Distribution;
10
+ /** The Node.js binary and script of this `shard` process (main() sets them; `shard update` needs them). */
11
+ runtime?: Runtime;
9
12
  }
10
13
  export declare function run(argv: readonly string[], io: Io, opts?: RunOptions): Promise<number>;
11
14
  export interface MainOptions {
package/dist/main.js CHANGED
@@ -17,6 +17,7 @@ import { CLI_VERSION, CENTER_COMMAND, PROJECT_COMMAND, commandHelp, parseCommand
17
17
  import { commandCenter } from "./command-center.js";
18
18
  import { housekeeping, startupHousekeeping } from "./agent-git-commands.js";
19
19
  import { CredentialStore, configDir } from "./credentials.js";
20
+ import { recordInstall } from "./update.js";
20
21
  import { AuthConfigError, EXIT, ExecutionPendingError, InterruptedError, UsageError, describeError, exitCodeFor, ownHint, redact } from "./errors.js";
21
22
  import { errorText, feedbackCommand, json, time } from "./format.js";
22
23
  import { HANDLERS } from "./handlers.js";
@@ -528,6 +529,15 @@ export async function run(argv, io, opts = {}) {
528
529
  store,
529
530
  input: new Input(io.stdin, io.stderr),
530
531
  distribution,
532
+ runtime: opts.runtime ?? null,
533
+ async clientVersions() {
534
+ const res = await base(new URL('/v1/client-versions', `${apiUrl.replace(/\/+$/, '')}/`), {
535
+ headers: { accept: 'application/json', 'user-agent': userAgent }, signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
536
+ });
537
+ if (!res.ok)
538
+ throw new UsageError(`Could not read the latest versions from ${apiUrl} (HTTP ${res.status}).`);
539
+ return res.json();
540
+ },
531
541
  hostname: () => {
532
542
  try {
533
543
  return hostname();
@@ -616,7 +626,7 @@ export async function run(argv, io, opts = {}) {
616
626
  },
617
627
  };
618
628
  // The update check runs next to the command (not for `version`, which reports it itself).
619
- if (!updateCheckDisabled(io.env) && !['version', 'ssh-proxy'].includes(parsed.command.path[0])) {
629
+ if (!updateCheckDisabled(io.env) && !['version', 'update', 'ssh-proxy'].includes(parsed.command.path[0])) {
620
630
  try {
621
631
  updates = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent });
622
632
  updates.start();
@@ -684,7 +694,11 @@ export async function main(opts = {}) {
684
694
  controller.abort(new Error('interrupted'));
685
695
  });
686
696
  const flush = (stream) => new Promise((resolve) => stream.write('', () => resolve()));
687
- const code = await run(process.argv.slice(2), { stdout: process.stdout, stderr: process.stderr, stdin: process.stdin, env: process.env }, { signal: controller.signal, ...(opts.distribution ? { distribution: opts.distribution } : {}) });
697
+ const runtime = { node: process.execPath, script: process.argv[1] ?? '' };
698
+ // The menu bar's update button runs this shard; it finds it here (macOS only, written when it changes).
699
+ if (process.platform === 'darwin' && runtime.script)
700
+ recordInstall(configDir(process.env), runtime, opts.distribution ?? CLI_DISTRIBUTION);
701
+ const code = await run(process.argv.slice(2), { stdout: process.stdout, stderr: process.stderr, stdin: process.stdin, env: process.env }, { signal: controller.signal, runtime, ...(opts.distribution ? { distribution: opts.distribution } : {}) });
688
702
  await Promise.all([flush(process.stdout), flush(process.stderr)]);
689
703
  process.exit(code);
690
704
  }
package/dist/menubar.d.ts CHANGED
@@ -10,6 +10,11 @@ export interface MenubarStatus {
10
10
  type NativeStatus = Omit<MenubarStatus, 'installed' | 'version'>;
11
11
  export declare function compareBundleVersions(left: string, right: string): number;
12
12
  export declare function parseNativeStatus(text: string): NativeStatus;
13
+ /**
14
+ * The app's executables keep their execute permission (0.13.6+). @shardflux/menubar-darwin 0.1.1 and 0.1.2 reached npm
15
+ * without it (pnpm's packer drops it), so an app copied from them could not run; this also repairs such an install.
16
+ */
17
+ export declare function makeExecutable(app: string): void;
13
18
  /** Native tools use argument arrays, bounded deadlines, and the installed app's own process/login projection. */
14
19
  export declare class MenubarManager {
15
20
  #private;
@@ -21,6 +26,9 @@ export declare class MenubarManager {
21
26
  packageApp?: () => string;
22
27
  signal?: AbortSignal;
23
28
  });
29
+ get platform(): NodeJS.Platform;
30
+ /** The version of the app this CLI's npm package carries (what `start` installs). */
31
+ packagedVersion(): Promise<string>;
24
32
  status(): Promise<MenubarStatus>;
25
33
  stop(): Promise<MenubarStatus>;
26
34
  login(value: 'on' | 'off'): Promise<MenubarStatus>;
package/dist/menubar.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, renameSync, rmSync } from 'node:fs';
2
+ import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import { homedir } from 'node:os';
5
5
  import { dirname, join } from 'node:path';
@@ -30,6 +30,25 @@ export function parseNativeStatus(text) {
30
30
  }
31
31
  return row;
32
32
  }
33
+ /**
34
+ * The app's executables keep their execute permission (0.13.6+). @shardflux/menubar-darwin 0.1.1 and 0.1.2 reached npm
35
+ * without it (pnpm's packer drops it), so an app copied from them could not run; this also repairs such an install.
36
+ */
37
+ export function makeExecutable(app) {
38
+ const dir = join(app, 'Contents', 'MacOS');
39
+ let names;
40
+ try {
41
+ names = readdirSync(dir);
42
+ }
43
+ catch {
44
+ return;
45
+ }
46
+ for (const name of names) {
47
+ const file = join(dir, name), stat = lstatSync(file);
48
+ if (stat.isFile() && (stat.mode & 0o111) !== 0o111)
49
+ chmodSync(file, 0o755);
50
+ }
51
+ }
33
52
  /** Native tools use argument arrays, bounded deadlines, and the installed app's own process/login projection. */
34
53
  export class MenubarManager {
35
54
  app;
@@ -49,8 +68,11 @@ export class MenubarManager {
49
68
  }
50
69
  });
51
70
  }
71
+ get platform() { return this.#platform; }
52
72
  #requireMac() { if (this.#platform !== 'darwin')
53
73
  throw new UsageError('shard menubar requires macOS 14 or later.'); }
74
+ /** The version of the app this CLI's npm package carries (what `start` installs). */
75
+ async packagedVersion() { this.#requireMac(); return this.#version(this.#packageApp()); }
54
76
  async #version(app) {
55
77
  const file = join(app, 'Contents', 'Info.plist');
56
78
  const id = (await this.#run('/usr/bin/plutil', ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', file])).trim();
@@ -61,6 +83,7 @@ export class MenubarManager {
61
83
  return version;
62
84
  }
63
85
  async #control(action, ...args) {
86
+ makeExecutable(this.app);
64
87
  return parseNativeStatus(await this.#run(join(this.app, 'Contents', 'MacOS', 'ShardfluxMenuBar'), ['--control', action, ...args]));
65
88
  }
66
89
  async status() {
@@ -111,6 +134,7 @@ export class MenubarManager {
111
134
  await this.stop();
112
135
  if (current.installed)
113
136
  renameSync(this.app, backup);
137
+ makeExecutable(staged);
114
138
  try {
115
139
  renameSync(staged, this.app);
116
140
  committed = true;
@@ -158,7 +182,8 @@ export async function ensureMenubar(dir, apiUrl, manager = new MenubarManager(),
158
182
  return undefined;
159
183
  try {
160
184
  const status = await manager.status();
161
- if (status.running)
185
+ // A running app stays unless this CLI carries a newer one (after `npm i -g shardflux`): then it is replaced.
186
+ if (status.running && !(status.version && compareBundleVersions(status.version, await manager.packagedVersion()) < 0))
162
187
  return undefined;
163
188
  const started = await manager.start(dir, apiUrl);
164
189
  if (!started.running)
@@ -59,6 +59,7 @@ export declare class UpdateCheck {
59
59
  checkedAt: string | null;
60
60
  } | null>;
61
61
  }
62
- /** The one-line notice for stderr, or null when there is nothing to say. */
62
+ /** The one-line notice for stderr, or null when there is nothing to say. It names `shard update` (0.13.5+), which
63
+ * updates with the package manager that installed shard, rather than the catalog's npm command. */
63
64
  export declare function noticeOf(s: ClientVersionStatus | undefined | null): string | null;
64
65
  export {};
@@ -120,9 +120,10 @@ export class UpdateCheck {
120
120
  }
121
121
  }
122
122
  }
123
- /** The one-line notice for stderr, or null when there is nothing to say. */
123
+ /** The one-line notice for stderr, or null when there is nothing to say. It names `shard update` (0.13.5+), which
124
+ * updates with the package manager that installed shard, rather than the catalog's npm command. */
124
125
  export function noticeOf(s) {
125
126
  if (!s || (s.status !== 'outdated' && s.status !== 'unsupported') || !s.message)
126
127
  return null;
127
- return `${s.message}\n`;
128
+ return `${s.message.replace(/ Update: .*$/, ' Update: shard update')}\n`;
128
129
  }
@@ -0,0 +1,61 @@
1
+ import type { ClientVersionStatusKind } from '@shardflux/sdk';
2
+ import type { Ctx, Handler } from './commands.js';
3
+ import { MenubarManager } from './menubar.js';
4
+ export declare const INSTALL_FILE = "install.json";
5
+ export declare const LAST_UPDATE_FILE = "last-update.json";
6
+ /** How this `shard` process runs: the Node.js binary and the script it was started with. */
7
+ export interface Runtime {
8
+ node: string;
9
+ script: string;
10
+ }
11
+ export type Manager = 'npm' | 'pnpm' | 'bun' | 'volta' | 'npx' | 'unmanaged';
12
+ export interface Install {
13
+ manager: Manager;
14
+ /** The installed package directory of the distribution, when found. */
15
+ packageDir: string | null;
16
+ /** The node_modules directory that holds it (the global root for a global install). */
17
+ root: string | null;
18
+ /** npm's global prefix (`<prefix>/lib/node_modules` is the root). */
19
+ prefix: string | null;
20
+ }
21
+ export interface Component {
22
+ package: string;
23
+ label: string;
24
+ current: string;
25
+ latest: string | null;
26
+ status: ClientVersionStatusKind;
27
+ }
28
+ /** Finds the package directory of `pkg` above `script` and how it was installed. */
29
+ export declare function detectInstall(script: string, pkg: string, platform?: NodeJS.Platform): Install;
30
+ /** The packages one install updates: the distribution, plus `@shardflux/mcp` when it is installed next to it. */
31
+ export declare function installSpecs(install: Install, pkg: string): string[];
32
+ /** The install command as argv (npm keeps the prefix it was installed in), or null when shard cannot run it. */
33
+ export declare function installArgv(install: Install, specs: string[], npm?: string): string[] | null;
34
+ /** The command a person types for the same update. */
35
+ export declare function displayCommand(install: Install, specs: string[]): string;
36
+ /** Every local piece and its status against the catalog. */
37
+ export declare function components(ctx: Ctx, install: Install, catalog: unknown, menubar?: MenubarManager): Promise<Component[]>;
38
+ /** One line per change: `shard 0.13.4 → 0.13.5 · menu bar 0.1.1 → 0.1.2`. */
39
+ export declare function changesText(list: {
40
+ label: string;
41
+ from: string;
42
+ to: string | null;
43
+ }[]): string;
44
+ export interface Exec {
45
+ code: number;
46
+ output: string;
47
+ }
48
+ export type Runner = (argv: string[], env: Record<string, string | undefined>, signal: AbortSignal) => Promise<Exec>;
49
+ /** Runs argv without a shell, keeping the last 64 KiB of output; never throws for a non-zero exit. */
50
+ export declare const runProcess: Runner;
51
+ export interface UpdateDeps {
52
+ run?: Runner;
53
+ menubar?: MenubarManager;
54
+ }
55
+ export declare function update(ctx: Ctx, values: Record<string, unknown>, deps?: UpdateDeps): Promise<number>;
56
+ /** Records where `shard` runs, for the menu bar's update button. Written on macOS, only when it changed. */
57
+ export declare function recordInstall(dir: string, runtime: Runtime, distribution: {
58
+ package: string;
59
+ version: string;
60
+ }): void;
61
+ export declare const UPDATE_HANDLERS: Record<string, Handler>;
package/dist/update.js ADDED
@@ -0,0 +1,298 @@
1
+ /**
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
5
+ * of the latest versions with the package manager that installed `shard`, checks the result with the new `shard`,
6
+ * and restarts the menu bar on its new app. `--check` only reports.
7
+ *
8
+ * The menu bar app runs the same command: it finds `shard` through install.json (written by each `shard` run on
9
+ * macOS) and shows the outcome from last-update.json, also when the update replaced the app that started it.
10
+ */
11
+ import { spawn } from 'node:child_process';
12
+ import { closeSync, existsSync, openSync, readFileSync, realpathSync, rmSync, writeSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { basename, dirname, join, sep } from 'node:path';
15
+ import { clientVersionStatus, SDK_VERSION } from '@shardflux/sdk';
16
+ import { CLI_VERSION } from "./args.js";
17
+ import { AgentStateStore } from "./agent-state.js";
18
+ import { writePrivateFile } from "./credentials.js";
19
+ import { CodedError } from "./errors.js";
20
+ import { json } from "./format.js";
21
+ import { compareBundleVersions, MenubarManager } from "./menubar.js";
22
+ export const INSTALL_FILE = 'install.json';
23
+ export const LAST_UPDATE_FILE = 'last-update.json';
24
+ const LOCK_FILE = 'update.lock';
25
+ const MCP = '@shardflux/mcp';
26
+ const MENUBAR = '@shardflux/menubar-darwin';
27
+ /** Finds the package directory of `pkg` above `script` and how it was installed. */
28
+ export function detectInstall(script, pkg, platform = process.platform) {
29
+ let real = script;
30
+ try {
31
+ real = realpathSync(script);
32
+ }
33
+ catch { /* a missing script keeps its path */ }
34
+ let packageDir = null;
35
+ for (let dir = dirname(real); dir !== dirname(dir); dir = dirname(dir)) {
36
+ try {
37
+ if (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name === pkg) {
38
+ packageDir = dir;
39
+ break;
40
+ }
41
+ }
42
+ catch { /* no package.json here */ }
43
+ }
44
+ const none = { manager: 'unmanaged', packageDir, root: null, prefix: null };
45
+ if (!packageDir)
46
+ return none;
47
+ const root = pkg.startsWith('@') ? dirname(dirname(packageDir)) : dirname(packageDir);
48
+ if (basename(root) !== 'node_modules')
49
+ return none;
50
+ const has = (part) => root.split(sep).join('/').includes(part);
51
+ if (has('/_npx/'))
52
+ return { manager: 'npx', packageDir, root, prefix: null };
53
+ if (has('/.volta/'))
54
+ return { manager: 'volta', packageDir, root, prefix: null };
55
+ if (has('/.bun/install/global/'))
56
+ return { manager: 'bun', packageDir, root, prefix: null };
57
+ if (has('/pnpm/global/') || has('/.pnpm/'))
58
+ return { manager: 'pnpm', packageDir, root, prefix: null };
59
+ if (basename(dirname(root)) === 'lib')
60
+ return { manager: 'npm', packageDir, root, prefix: dirname(dirname(root)) };
61
+ if (platform === 'win32' && basename(dirname(root)) === 'npm')
62
+ return { manager: 'npm', packageDir, root, prefix: dirname(root) };
63
+ return none;
64
+ }
65
+ function versionAt(dir) {
66
+ try {
67
+ const v = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).version;
68
+ return typeof v === 'string' ? v : null;
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ /** The packages one install updates: the distribution, plus `@shardflux/mcp` when it is installed next to it. */
75
+ export function installSpecs(install, pkg) {
76
+ const specs = [`${pkg}@latest`];
77
+ if (install.root && versionAt(join(install.root, MCP)))
78
+ specs.push(`${MCP}@latest`);
79
+ return specs;
80
+ }
81
+ /** The install command as argv (npm keeps the prefix it was installed in), or null when shard cannot run it. */
82
+ export function installArgv(install, specs, npm = 'npm') {
83
+ switch (install.manager) {
84
+ case 'npm': return [npm, 'install', '--global', ...(install.prefix ? ['--prefix', install.prefix] : []), ...specs, '--no-fund', '--no-audit'];
85
+ case 'pnpm': return ['pnpm', 'add', '--global', ...specs];
86
+ case 'bun': return ['bun', 'add', '--global', ...specs];
87
+ case 'volta': return ['volta', 'install', ...specs];
88
+ default: return null;
89
+ }
90
+ }
91
+ /** The command a person types for the same update. */
92
+ export function displayCommand(install, specs) {
93
+ switch (install.manager) {
94
+ case 'pnpm': return `pnpm add -g ${specs.join(' ')}`;
95
+ case 'bun': return `bun add -g ${specs.join(' ')}`;
96
+ case 'volta': return `volta install ${specs.join(' ')}`;
97
+ case 'npx': return `npx ${specs[0]}`;
98
+ default: return `npm install -g ${specs.join(' ')}`;
99
+ }
100
+ }
101
+ const LABELS = { shardflux: 'shardflux', '@shardflux/cli': 'shard', '@shardflux/sdk': 'SDK', [MENUBAR]: 'menu bar', [MCP]: 'MCP server' };
102
+ /** Every local piece and its status against the catalog. */
103
+ export async function components(ctx, install, catalog, menubar = menubarManager(ctx)) {
104
+ const local = [[ctx.distribution.package, ctx.distribution.version]];
105
+ if (ctx.distribution.package !== '@shardflux/cli')
106
+ local.push(['@shardflux/cli', CLI_VERSION]);
107
+ local.push(['@shardflux/sdk', SDK_VERSION]);
108
+ if (menubar.platform === 'darwin') {
109
+ const status = await menubar.status().catch(() => null);
110
+ if (status?.installed && status.version)
111
+ local.push([MENUBAR, status.version]);
112
+ }
113
+ if (install.root)
114
+ local.push([MCP, versionAt(join(install.root, MCP))]);
115
+ return local.filter((entry) => entry[1] !== null).map(([pkg, version]) => {
116
+ const s = clientVersionStatus(catalog, { package: pkg, version });
117
+ return { package: pkg, label: LABELS[pkg] ?? pkg, current: version, latest: s.latest, status: s.status };
118
+ });
119
+ }
120
+ const outdated = (c) => c.status === 'outdated' || c.status === 'unsupported';
121
+ function menubarManager(ctx) {
122
+ return new MenubarManager({ home: ctx.io.env.HOME || homedir(), signal: ctx.signal });
123
+ }
124
+ /** One line per change: `shard 0.13.4 → 0.13.5 · menu bar 0.1.1 → 0.1.2`. */
125
+ export function changesText(list) {
126
+ return list.map(c => `${c.label} ${c.from} → ${c.to ?? '?'}`).join(' · ');
127
+ }
128
+ /** Runs argv without a shell, keeping the last 64 KiB of output; never throws for a non-zero exit. */
129
+ export const runProcess = (argv, env, signal) => new Promise((resolve, reject) => {
130
+ const child = spawn(argv[0], argv.slice(1), { env: env, stdio: ['ignore', 'pipe', 'pipe'], signal });
131
+ let output = '';
132
+ const keep = (chunk) => { output = (output + chunk.toString('utf8')).slice(-65536); };
133
+ child.stdout.on('data', keep);
134
+ child.stderr.on('data', keep);
135
+ child.on('error', reject);
136
+ child.on('close', code => resolve({ code: code ?? 1, output }));
137
+ });
138
+ function writeLastUpdate(dir, doc) {
139
+ try {
140
+ writePrivateFile(dir, join(dir, LAST_UPDATE_FILE), `${JSON.stringify({ version: 1, finished_at: new Date().toISOString(), ...doc }, null, 2)}\n`);
141
+ }
142
+ catch { /* the outcome is also printed; the menu bar then shows no result */ }
143
+ }
144
+ /** One update at a time per config directory; a lock left by a process that is gone is taken over. */
145
+ function lock(dir) {
146
+ const file = join(dir, LOCK_FILE);
147
+ for (let attempt = 0; attempt < 2; attempt++) {
148
+ try {
149
+ const fd = openSync(file, 'wx', 0o600);
150
+ writeSync(fd, String(process.pid));
151
+ closeSync(fd);
152
+ return () => rmSync(file, { force: true });
153
+ }
154
+ catch (error) {
155
+ if (error.code !== 'EEXIST')
156
+ throw error;
157
+ const pid = Number(readFileSync(file, 'utf8'));
158
+ let alive = false;
159
+ try {
160
+ if (Number.isInteger(pid) && pid > 0) {
161
+ process.kill(pid, 0);
162
+ alive = true;
163
+ }
164
+ }
165
+ catch { /* gone */ }
166
+ if (alive)
167
+ throw new CodedError('update_in_progress', 'A Shardflux update is already running.', 'wait for it to finish, then run shard update --check');
168
+ rmSync(file, { force: true });
169
+ }
170
+ }
171
+ throw new CodedError('update_in_progress', 'A Shardflux update is already running.', 'wait for it to finish, then run shard update --check');
172
+ }
173
+ /** npm next to the running Node.js (the same installation), else npm on PATH. */
174
+ function npmFor(node) {
175
+ const sibling = join(dirname(node), process.platform === 'win32' ? 'npm.cmd' : 'npm');
176
+ return existsSync(sibling) ? sibling : 'npm';
177
+ }
178
+ export async function update(ctx, values, deps = {}) {
179
+ const run = deps.run ?? runProcess;
180
+ const menubar = deps.menubar ?? menubarManager(ctx);
181
+ const runtime = ctx.runtime;
182
+ const install = runtime ? detectInstall(runtime.script, ctx.distribution.package) : { manager: 'unmanaged', packageDir: null, root: null, prefix: null };
183
+ const catalog = await ctx.clientVersions();
184
+ const list = await components(ctx, install, catalog, menubar);
185
+ const specs = installSpecs(install, ctx.distribution.package);
186
+ const command = displayCommand(install, specs);
187
+ const pending = list.filter(outdated);
188
+ const view = { update_available: pending.length > 0, components: list, install: { manager: install.manager, command } };
189
+ const versions = list.map(c => `${c.label} ${c.current}`).join(' · ');
190
+ if (values.check === true) {
191
+ ctx.io.stdout.write(ctx.json ? json(view) : pending.length
192
+ ? `Update available: ${changesText(pending.map(c => ({ label: c.label, from: c.current, to: c.latest })))}\nRun: shard update\n`
193
+ : `Shardflux is up to date (${versions}).\n`);
194
+ return 0;
195
+ }
196
+ if (!pending.length) {
197
+ // The npm package may already carry a newer menu bar app than the one running.
198
+ 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`);
200
+ return 0;
201
+ }
202
+ const argv = installArgv(install, specs, runtime ? npmFor(runtime.node) : 'npm');
203
+ if (!argv || !runtime) {
204
+ const where = install.packageDir ?? runtime?.script ?? 'this process';
205
+ const message = install.manager === 'npx'
206
+ ? 'npx runs the latest version the next time you start it.'
207
+ : `shard update does not manage the install at ${where}.`;
208
+ writeLastUpdate(ctx.store.dir, { status: 'failed', changes: [], message, command });
209
+ throw new CodedError('update_unmanaged', message, `run: ${command}`);
210
+ }
211
+ const unlock = lock(ctx.store.dir);
212
+ try {
213
+ if (!ctx.json)
214
+ ctx.io.stderr.write(`Updating ${changesText(pending.map(c => ({ label: c.label, from: c.current, to: c.latest })))} with ${install.manager}…\n`);
215
+ const env = { ...process.env, ...ctx.io.env, PATH: [dirname(runtime.node), ctx.io.env.PATH ?? process.env.PATH ?? ''].join(process.platform === 'win32' ? ';' : ':') };
216
+ const result = await run(argv, env, ctx.signal);
217
+ if (result.code !== 0) {
218
+ const denied = /\bEACCES\b|\bEPERM\b/.test(result.output);
219
+ const fix = denied ? `sudo ${command}` : command;
220
+ const message = denied
221
+ ? `${install.manager} cannot write to ${install.prefix ?? install.root}.`
222
+ : `${install.manager} failed: ${result.output.trim().split('\n').slice(-3).join(' ').slice(0, 400)}`;
223
+ writeLastUpdate(ctx.store.dir, { status: 'failed', changes: [], message, command: fix });
224
+ throw new CodedError(denied ? 'update_permission_denied' : 'update_failed', message, `run: ${fix}`);
225
+ }
226
+ // The new shard reports what is installed now.
227
+ const after = await run([runtime.node, runtime.script, 'version', '--json'], { ...env, SHARDFLUX_NO_UPDATE_CHECK: '1' }, ctx.signal);
228
+ let now = {};
229
+ try {
230
+ now = JSON.parse(after.output.slice(after.output.indexOf('{')));
231
+ }
232
+ catch { /* reported as unknown below */ }
233
+ const installed = {
234
+ [ctx.distribution.package]: install.packageDir ? versionAt(install.packageDir) : null,
235
+ '@shardflux/cli': now.cli ?? null, '@shardflux/sdk': now.sdk ?? null,
236
+ [MCP]: install.root ? versionAt(join(install.root, MCP)) : null,
237
+ };
238
+ const restart = await refreshMenubar(ctx, menubar, runtime, run, env);
239
+ if (restart.version)
240
+ installed[MENUBAR] = restart.version;
241
+ const changes = list.filter(c => (installed[c.package] ?? c.current) !== c.current)
242
+ .map(c => ({ package: c.package, label: c.label, from: c.current, to: installed[c.package] ?? null }));
243
+ const message = changes.length ? `Updated ${changesText(changes)}` : 'Shardflux is up to date';
244
+ writeLastUpdate(ctx.store.dir, { status: 'ok', changes, message, command });
245
+ ctx.io.stdout.write(ctx.json ? json({ ...view, updated: true, changes, menubar: restart.status }) : `${message}.\n`);
246
+ return 0;
247
+ }
248
+ finally {
249
+ unlock();
250
+ }
251
+ }
252
+ /**
253
+ * Restarts the menu bar on the app its npm package carries now, when that is newer than the installed one and the
254
+ * person has not turned the menu bar off. With `runtime`, the freshly installed shard does it (this process still
255
+ * runs the code from before the update).
256
+ */
257
+ async function refreshMenubar(ctx, menubar, runtime, run, env) {
258
+ if (menubar.platform !== 'darwin')
259
+ return { status: 'off' };
260
+ if (new AgentStateStore(ctx.store.dir).read().menubar_stopped === true)
261
+ return { status: 'off' };
262
+ const before = await menubar.status().catch(() => null);
263
+ if (!before?.installed)
264
+ return { status: 'off' };
265
+ if (runtime && run && env) {
266
+ const r = await run([runtime.node, runtime.script, '--api-url', ctx.apiUrl, 'menubar', 'start', '--json'], { ...env, SHARDFLUX_NO_UPDATE_CHECK: '1', SHARDFLUX_CONFIG_DIR: ctx.store.dir }, ctx.signal);
267
+ if (r.code !== 0)
268
+ return { status: 'failed' };
269
+ }
270
+ else {
271
+ const packaged = await menubar.packagedVersion().catch(() => null);
272
+ if (!packaged || !before.version || compareBundleVersions(before.version, packaged) >= 0)
273
+ return { status: 'current' };
274
+ await menubar.start(ctx.store.dir, ctx.apiUrl);
275
+ }
276
+ const after = await menubar.status().catch(() => null);
277
+ return after?.version && after.version !== before.version ? { status: 'restarted', version: after.version } : { status: 'current' };
278
+ }
279
+ /** Records where `shard` runs, for the menu bar's update button. Written on macOS, only when it changed. */
280
+ export function recordInstall(dir, runtime, distribution) {
281
+ let script = runtime.script;
282
+ try {
283
+ script = realpathSync(script);
284
+ }
285
+ catch { /* keep the path */ }
286
+ const doc = { version: 1, node: runtime.node, script, package: distribution.package, package_version: distribution.version, cli_version: CLI_VERSION };
287
+ const text = `${JSON.stringify(doc, null, 2)}\n`;
288
+ try {
289
+ if (readFileSync(join(dir, INSTALL_FILE), 'utf8') === text)
290
+ return;
291
+ }
292
+ catch { /* first run */ }
293
+ try {
294
+ writePrivateFile(dir, join(dir, INSTALL_FILE), text);
295
+ }
296
+ catch { /* the menu bar then offers the terminal instead */ }
297
+ }
298
+ export const UPDATE_HANDLERS = { update: (ctx, values) => update(ctx, values) };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.13.4",
3
+ "version": "0.13.6",
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.2"
50
+ "@shardflux/sdk": "^0.18.3"
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.1"
61
+ "@shardflux/menubar-darwin": "^0.1.3"
62
62
  },
63
63
  "scripts": {
64
64
  "build": "node scripts/build.mjs",