@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +50 -3
  3. package/dist/acp-models.js +5 -1
  4. package/dist/agent-commands.d.ts +8 -2
  5. package/dist/agent-commands.js +23 -11
  6. package/dist/agent-target-commands.d.ts +7 -2
  7. package/dist/agent-target-commands.js +12 -7
  8. package/dist/agent-targets.d.ts +3 -0
  9. package/dist/agent-targets.js +4 -1
  10. package/dist/agent-terminal.d.ts +8 -0
  11. package/dist/agent-terminal.js +35 -2
  12. package/dist/args.d.ts +1 -1
  13. package/dist/args.js +29 -3
  14. package/dist/codex-cloud.d.ts +2 -0
  15. package/dist/codex-cloud.js +2 -0
  16. package/dist/command-center-actions.js +9 -0
  17. package/dist/commands.d.ts +23 -1
  18. package/dist/commands.js +54 -14
  19. package/dist/conversation-commands.js +6 -0
  20. package/dist/conversation-terminal.js +10 -1
  21. package/dist/conversations.d.ts +13 -1
  22. package/dist/conversations.js +41 -5
  23. package/dist/device-helper.js +9 -2
  24. package/dist/errors.d.ts +5 -0
  25. package/dist/errors.js +11 -1
  26. package/dist/files-mirror.d.ts +164 -0
  27. package/dist/files-mirror.js +761 -0
  28. package/dist/handlers.js +3 -1
  29. package/dist/main.js +39 -14
  30. package/dist/menubar.d.ts +57 -0
  31. package/dist/menubar.js +264 -45
  32. package/dist/registry.d.ts +8 -0
  33. package/dist/registry.js +18 -0
  34. package/dist/thread-on-computer.d.ts +35 -0
  35. package/dist/thread-on-computer.js +200 -0
  36. package/dist/update-check.d.ts +9 -1
  37. package/dist/update-check.js +55 -17
  38. package/dist/update.d.ts +2 -0
  39. package/dist/update.js +26 -8
  40. package/dist/wait-status.d.ts +22 -0
  41. package/dist/wait-status.js +59 -0
  42. package/dist/worktree-twin-engine.d.ts +95 -0
  43. package/dist/worktree-twin-engine.js +402 -0
  44. package/dist/worktree-twin-land.py +331 -0
  45. package/dist/worktree-twin.d.ts +296 -0
  46. package/dist/worktree-twin.js +718 -0
  47. package/package.json +3 -3
package/dist/handlers.js CHANGED
@@ -15,5 +15,7 @@ import { MENUBAR_HANDLERS } from "./menubar.js";
15
15
  import { UPDATE_HANDLERS } from "./update.js";
16
16
  import { FILESYSTEM_HANDLERS } from "./filesystem-commands.js";
17
17
  import { CONVERSATION_HANDLERS as LOCAL_CONVERSATION_HANDLERS } from "./conversation-local.js";
18
+ import { TWIN_HANDLERS } from "./worktree-twin-engine.js";
18
19
  import { CODEX_CLOUD_HANDLERS } from "./codex-cloud.js";
19
- export const HANDLERS = { ...CODEX_CLOUD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_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 };
20
+ import { THREAD_HANDLERS } from "./thread-on-computer.js";
21
+ export const HANDLERS = { ...CODEX_CLOUD_HANDLERS, ...THREAD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...TWIN_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.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * (step-up, password change, TOTP change) is saved at once; a saved session the API rejects (401) is removed.
11
11
  * Every credential the command loaded is redacted from what it prints on failure.
12
12
  */
13
- import { hostname } from 'node:os';
13
+ import { homedir, hostname } from 'node:os';
14
14
  import { statSync } from 'node:fs';
15
15
  import { SDK_VERSION, Shardflux, ShardfluxAccount, isSessionToken } from '@shardflux/sdk';
16
16
  import { CLI_VERSION, CENTER_COMMAND, PROJECT_COMMAND, commandHelp, parseCommandLine, resolveApiUrl, resolveWake, resolveWakeTimeout } from "./args.js";
@@ -23,12 +23,15 @@ import { runDeviceHelper } from "./device-helper.js";
23
23
  import { housekeeping, startupHousekeeping } from "./agent-git-commands.js";
24
24
  import { CredentialStore, configDir } from "./credentials.js";
25
25
  import { recordInstall } from "./update.js";
26
+ import { refreshMenubarInBackground, runMenubarRefresh } from "./menubar.js";
27
+ import { NPM_CLIENTS, npmLatestAll, npmRegistry, withRegistryLatest } from "./registry.js";
26
28
  import { AuthConfigError, EXIT, ExecutionPendingError, InterruptedError, UsageError, describeError, exitCodeFor, ownHint, redact } from "./errors.js";
27
29
  import { errorText, feedbackCommand, json, time } from "./format.js";
28
30
  import { HANDLERS } from "./handlers.js";
29
31
  import { makeFetch } from "./http.js";
30
32
  import { Input } from "./input.js";
31
33
  import { TimingRecorder, notices, timingJson, timingText } from "./timing.js";
34
+ import { waitStatusListener } from "./wait-status.js";
32
35
  import { CLI_DISTRIBUTION, NOTICE_WAIT_MS, UpdateCheck, noticeOf, updateCheckDisabled, updateView } from "./update-check.js";
33
36
  const KEY_SHAPE = /^sfk_[a-z2-7]{16}_[A-Za-z0-9]+$/;
34
37
  // Printable (no C0 controls or DEL), as the API's AgentLabel pattern.
@@ -124,7 +127,7 @@ function hintFor(info, cred, file, command = '') {
124
127
  if (reason === 'edit_not_text')
125
128
  return 'the file is not UTF-8 text; use files write for binary data';
126
129
  if (reason === 'host_capacity')
127
- return 'the workspace cannot be woken right now and the call did not run; retry in a few seconds';
130
+ return "the workspace's host is making room to wake it and the call did not run (it was already retried); run the command again";
128
131
  if (reason === 'host_feature_unavailable')
129
132
  return hostFeatureHint(info.details?.feature);
130
133
  // Inbound ports (0.9.0): the next step names the command.
@@ -532,6 +535,7 @@ export async function run(argv, io, opts = {}) {
532
535
  agentLabel,
533
536
  wake,
534
537
  wakeTimeoutMs,
538
+ wakeTimeoutExplicit: parsed.global.wakeTimeout !== undefined || Boolean(io.env.SHARDFLUX_WAKE_TIMEOUT_MS?.trim()),
535
539
  signal,
536
540
  timing: showTiming,
537
541
  traces,
@@ -540,12 +544,20 @@ export async function run(argv, io, opts = {}) {
540
544
  distribution,
541
545
  runtime: opts.runtime ?? null,
542
546
  async clientVersions() {
543
- const res = await base(new URL('/v1/client-versions', `${apiUrl.replace(/\/+$/, '')}/`), {
544
- headers: { accept: 'application/json', 'user-agent': userAgent }, signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
545
- });
546
- if (!res.ok)
547
- throw new UsageError(`Could not read the latest versions from ${apiUrl} (HTTP ${res.status}).`);
548
- return res.json();
547
+ // The latest versions come from npm (0.15.2+); the API adds minimum_supported and hold. Either one is enough.
548
+ const catalog = (async () => {
549
+ const res = await base(new URL('/v1/client-versions', `${apiUrl.replace(/\/+$/, '')}/`), {
550
+ headers: { accept: 'application/json', 'user-agent': userAgent }, signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
551
+ });
552
+ if (!res.ok)
553
+ throw new UsageError(`Could not read the latest versions from ${apiUrl} (HTTP ${res.status}).`);
554
+ const body = await res.json();
555
+ return body;
556
+ })();
557
+ const [api, latest] = await Promise.all([catalog.then((body) => ({ body }), (error) => ({ error })), npmLatestAll(NPM_CLIENTS, { fetch: base, registry: npmRegistry(io.env), userAgent, signal })]);
558
+ if ('error' in api && Object.keys(latest).length === 0)
559
+ throw api.error;
560
+ return withRegistryLatest('body' in api ? api.body : null, latest);
549
561
  },
550
562
  hostname: () => {
551
563
  try {
@@ -559,13 +571,18 @@ export async function run(argv, io, opts = {}) {
559
571
  if (cloud)
560
572
  return cloud;
561
573
  const key = apiKey();
574
+ // Timings, and (0.16.0+) where a wait stands: a queued start's position, a call waiting for room (stderr).
575
+ const waitStatus = waitStatusListener(io.stderr, parsed.global.json);
562
576
  cloud = new Shardflux({
563
577
  apiKey: key.token,
564
578
  baseUrl: apiUrl,
565
579
  userAgent,
566
580
  fetch: watchedFetch,
567
581
  sleep,
568
- onProgress: traces.listener,
582
+ onProgress: (e) => {
583
+ traces.listener(e);
584
+ waitStatus(e);
585
+ },
569
586
  versionCheck: false,
570
587
  });
571
588
  return cloud;
@@ -624,7 +641,7 @@ export async function run(argv, io, opts = {}) {
624
641
  secrets.push(value);
625
642
  },
626
643
  async versionStatus(force) {
627
- const check = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent });
644
+ const check = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent, registry: npmRegistry(io.env) });
628
645
  if (!force && updateCheckDisabled(io.env)) {
629
646
  const cached = check.peek();
630
647
  return cached ? updateView(cached.status, cached.checkedAt) : null;
@@ -634,10 +651,18 @@ export async function run(argv, io, opts = {}) {
634
651
  return r ? updateView(r.status, r.checkedAt) : null;
635
652
  },
636
653
  };
654
+ // After `npm i -g shardflux` the package may carry a newer menu bar app: brought in by a detached process (no
655
+ // network; an Info.plist compare decides whether to start it).
656
+ // `shard update` brings the app in itself; `shard update --check` (the menu bar of an app before 0.3.2 runs it every
657
+ // 6 hours) is how such an app learns of a newer one with no action from the person.
658
+ const updating = command === 'update' && parsed.values.check !== true;
659
+ if (opts.runtime && !updating && !['menubar', 'device-helper'].includes(parsed.command.path[0])) {
660
+ refreshMenubarInBackground({ dir: store.dir, home: io.env.HOME || homedir(), runtime: opts.runtime, env: { ...process.env, ...io.env } });
661
+ }
637
662
  // The update check runs next to the command (not for `version`, which reports it itself).
638
- if (!updateCheckDisabled(io.env) && !['version', 'update', 'ssh-proxy', 'acp', 'device-helper'].includes(parsed.command.path[0])) {
663
+ if (!updateCheckDisabled(io.env) && !['version', 'update', 'ssh-proxy', 'acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh') {
639
664
  try {
640
- updates = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent });
665
+ updates = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent, registry: npmRegistry(io.env) });
641
666
  updates.start();
642
667
  }
643
668
  catch {
@@ -648,10 +673,10 @@ export async function run(argv, io, opts = {}) {
648
673
  catch (err) {
649
674
  return report(err);
650
675
  }
651
- const handler = parsed.command.path[0] === 'acp' ? (ctx) => parsed.values.login === true ? runAcpLogin(ctx) : runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? runDeviceHelper : parsed.command === PROJECT_COMMAND ? launchConversationProject : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
676
+ const handler = parsed.command.path[0] === 'acp' ? (ctx) => parsed.values.login === true ? runAcpLogin(ctx) : runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? runDeviceHelper : command === 'menubar refresh' ? runMenubarRefresh : parsed.command === PROJECT_COMMAND ? launchConversationProject : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
652
677
  if (!handler)
653
678
  return report(new UsageError(`unknown command "${parsed.command.path.join(' ')}"`));
654
- if (!['acp', 'device-helper'].includes(parsed.command.path[0]))
679
+ if (!['acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh')
655
680
  await housekeeping(ctx);
656
681
  const running = handler(ctx, parsed.values, parsed.positionals).then((code) => ({ code }), (err) => ({ err }));
657
682
  // On Ctrl-C give the command a moment to clean up (exec sends a cancel), then stop regardless.
package/dist/menubar.d.ts CHANGED
@@ -17,6 +17,24 @@ export interface MenubarStatus {
17
17
  }
18
18
  type NativeStatus = Omit<MenubarStatus, 'installed' | 'version'>;
19
19
  export declare function compareBundleVersions(left: string, right: string): number;
20
+ /**
21
+ * Marketing versions (CFBundleShortVersionString) compare as semver: 0.3.10 > 0.3.9, 0.4.0-rc.1 < 0.4.0. A version that
22
+ * is not semver compares as a numeric bundle version.
23
+ */
24
+ export declare function compareAppVersions(left: string, right: string): number;
25
+ /**
26
+ * The first app that swaps itself in when the npm package next to the CLI carries a newer one (it knows when no window
27
+ * is open and nothing is in progress). The CLI leaves a running app of this version or later to do that itself.
28
+ */
29
+ export declare const SELF_UPDATING_APP = "0.3.2";
30
+ /** CFBundleShortVersionString of a Shardflux app from its XML Info.plist, read without any tool (null: not readable). */
31
+ export declare function plistVersion(app: string): string | null;
32
+ /**
33
+ * Whether any of these processes shows a window: the menu, the launcher, Settings, onboarding, an approval. The status
34
+ * item itself (a strip at the top of the screen on the status layer, owned by the app before macOS 26) does not count.
35
+ * Null when the probe could not answer: then nothing may be replaced.
36
+ */
37
+ export declare function windowsOpen(text: string): boolean | null;
20
38
  export declare function parseNativeStatus(text: string): NativeStatus;
21
39
  /**
22
40
  * The app's executables keep their execute permission (0.13.6+). @shardflux/menubar-darwin 0.1.1 and 0.1.2 reached npm
@@ -37,13 +55,52 @@ export declare class MenubarManager {
37
55
  get platform(): NodeJS.Platform;
38
56
  /** The version of the app this CLI's npm package carries (what `start` installs). */
39
57
  packagedVersion(): Promise<string>;
58
+ /** Whether the app shows any window (see windowsOpen); null when that could not be read. */
59
+ windowsOpen(pids: number[]): Promise<boolean | null>;
40
60
  status(): Promise<MenubarStatus>;
41
61
  stop(): Promise<MenubarStatus>;
42
62
  login(value: 'on' | 'off'): Promise<MenubarStatus>;
43
63
  start(configDir: string, apiUrl: string): Promise<MenubarStatus>;
64
+ /**
65
+ * Brings an installed app up to the one this CLI's npm package carries, without disturbing the person: nothing when
66
+ * the versions are equal (each swap of an ad-hoc signed app costs its privacy grants), the copy on disk alone when it
67
+ * is not running, and a running app only when it shows no window and no `shard update` runs. A running app that
68
+ * swaps itself in (SELF_UPDATING_APP or later) is left to do so: it also knows when it is in the middle of something.
69
+ */
70
+ refresh(configDir: string, apiUrl: string): Promise<{
71
+ result: 'current' | 'not_installed' | 'installed' | 'restarted' | 'deferred' | 'busy';
72
+ from: string | null;
73
+ to: string | null;
74
+ }>;
44
75
  }
45
76
  /** On macOS, `shard` brings the menu bar up by itself (with launch at login) unless the user ran `shard menubar stop`.
46
77
  * It never fails the command center: a missing optional package or a refused start just leaves it off. */
47
78
  export declare function ensureMenubar(dir: string, apiUrl: string, manager?: MenubarManager, platform?: NodeJS.Platform): Promise<string | undefined>;
79
+ /** How often a command may start a background refresh while the app is busy (a window open). */
80
+ export declare const REFRESH_RETRY_MS: number;
81
+ /** Where this CLI's npm package keeps the app it carries, or null when the optional package is missing. */
82
+ export declare function packagedAppPath(): string | null;
83
+ export interface BackgroundRefreshOptions {
84
+ dir: string;
85
+ home: string;
86
+ /** This `shard` process: the refresh runs the same Node.js and script. */
87
+ runtime: {
88
+ node: string;
89
+ script: string;
90
+ };
91
+ env: Record<string, string | undefined>;
92
+ platform?: NodeJS.Platform;
93
+ packageApp?: () => string | null;
94
+ now?: () => number;
95
+ spawn?: (node: string, args: string[], env: Record<string, string | undefined>) => void;
96
+ }
97
+ /**
98
+ * Every `shard` command (0.15.2+): when the npm package carries a newer app than the installed one (two Info.plist
99
+ * reads, no network, no tool), `shard menubar refresh` runs detached, so the command never waits for it. It is started
100
+ * at most every 5 minutes while the app is busy, and not at all after `shard menubar stop`. Returns whether it started.
101
+ */
102
+ export declare function refreshMenubarInBackground(o: BackgroundRefreshOptions): boolean;
103
+ /** `shard menubar refresh` (not listed in help, so not in HANDLERS): what refreshMenubarInBackground starts. */
104
+ export declare const runMenubarRefresh: Handler;
48
105
  export declare const MENUBAR_HANDLERS: Record<string, Handler>;
49
106
  export {};
package/dist/menubar.js CHANGED
@@ -1,9 +1,10 @@
1
- import { execFile } from 'node:child_process';
2
- import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync } from 'node:fs';
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { chmodSync, closeSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeSync } 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';
6
6
  import { promisify } from 'node:util';
7
+ import { compareVersions } from '@shardflux/sdk';
7
8
  import { AgentStateStore } from "./agent-state.js";
8
9
  import { UsageError } from "./errors.js";
9
10
  import { json } from "./format.js";
@@ -27,6 +28,61 @@ export function compareBundleVersions(left, right) {
27
28
  }
28
29
  return 0;
29
30
  }
31
+ /**
32
+ * Marketing versions (CFBundleShortVersionString) compare as semver: 0.3.10 > 0.3.9, 0.4.0-rc.1 < 0.4.0. A version that
33
+ * is not semver compares as a numeric bundle version.
34
+ */
35
+ export function compareAppVersions(left, right) {
36
+ return compareVersions(left, right) ?? compareBundleVersions(left, right);
37
+ }
38
+ /**
39
+ * The first app that swaps itself in when the npm package next to the CLI carries a newer one (it knows when no window
40
+ * is open and nothing is in progress). The CLI leaves a running app of this version or later to do that itself.
41
+ */
42
+ export const SELF_UPDATING_APP = '0.3.2';
43
+ /** CFBundleShortVersionString of a Shardflux app from its XML Info.plist, read without any tool (null: not readable). */
44
+ export function plistVersion(app) {
45
+ try {
46
+ const text = readFileSync(join(app, 'Contents', 'Info.plist'), 'utf8');
47
+ const value = (key) => new RegExp(`<key>${key}</key>\\s*<string>([^<]+)</string>`).exec(text)?.[1]?.trim() ?? null;
48
+ if (value('CFBundleIdentifier') !== BUNDLE_ID)
49
+ return null;
50
+ const version = value('CFBundleShortVersionString') ?? value('CFBundleVersion');
51
+ return version && compareVersions(version, version) !== null ? version : null;
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ const WINDOW_PROBE = `ObjC.import('CoreGraphics');
58
+ function run(argv) {
59
+ const pids = argv.map(Number);
60
+ const list = ObjC.deepUnwrap(ObjC.castRefToObject($.CGWindowListCopyWindowInfo($.kCGWindowListOptionOnScreenOnly, $.kCGNullWindowID))) || [];
61
+ return JSON.stringify(list.filter(w => pids.includes(w.kCGWindowOwnerPID)).map(w => ({ pid: w.kCGWindowOwnerPID, layer: w.kCGWindowLayer,
62
+ x: w.kCGWindowBounds.X, y: w.kCGWindowBounds.Y, width: w.kCGWindowBounds.Width, height: w.kCGWindowBounds.Height, alpha: w.kCGWindowAlpha })));
63
+ }`;
64
+ /**
65
+ * Whether any of these processes shows a window: the menu, the launcher, Settings, onboarding, an approval. The status
66
+ * item itself (a strip at the top of the screen on the status layer, owned by the app before macOS 26) does not count.
67
+ * Null when the probe could not answer: then nothing may be replaced.
68
+ */
69
+ export function windowsOpen(text) {
70
+ let rows;
71
+ try {
72
+ rows = JSON.parse(text);
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ if (!Array.isArray(rows))
78
+ return null;
79
+ const windows = rows;
80
+ if (!windows.every(w => w && typeof w === 'object' && ['layer', 'y', 'width', 'height'].every(k => typeof w[k] === 'number')))
81
+ return null;
82
+ // A status item is a strip on the status layer at the top of the screen (39 pt high on a notched display).
83
+ const statusItem = (w) => w.layer === 25 && w.y <= 0 && w.height <= 50;
84
+ return windows.some(w => w.layer >= 0 && !statusItem(w) && (w.alpha ?? 1) > 0 && w.width > 1 && w.height > 1);
85
+ }
30
86
  export function parseNativeStatus(text) {
31
87
  const value = JSON.parse(text);
32
88
  if (!value || typeof value !== 'object')
@@ -68,12 +124,10 @@ export class MenubarManager {
68
124
  this.#platform = options.platform ?? process.platform;
69
125
  this.#run = options.run ?? ((tool, args) => nativeTools.run(tool, args, options.signal));
70
126
  this.#packageApp = options.packageApp ?? (() => {
71
- try {
72
- return join(dirname(createRequire(import.meta.url).resolve('@shardflux/menubar-darwin/package.json')), 'Shardflux.app');
73
- }
74
- catch {
127
+ const app = packagedAppPath();
128
+ if (!app)
75
129
  throw new UsageError('The macOS app package is missing. Reinstall shard with optional dependencies enabled, then run shard menubar start.');
76
- }
130
+ return app;
77
131
  });
78
132
  }
79
133
  get platform() { return this.#platform; }
@@ -86,10 +140,25 @@ export class MenubarManager {
86
140
  const id = (await this.#run('/usr/bin/plutil', ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', file])).trim();
87
141
  if (id !== BUNDLE_ID || lstatSync(app).isSymbolicLink())
88
142
  throw new UsageError(`Refusing to replace an unrelated app at ${app}.`);
143
+ // The marketing version (0.15.2+), compared as semver; an app without one has its numeric bundle version.
144
+ const marketing = await this.#run('/usr/bin/plutil', ['-extract', 'CFBundleShortVersionString', 'raw', '-o', '-', file]).then(v => v.trim(), () => '');
145
+ if (marketing && compareVersions(marketing, marketing) !== null)
146
+ return marketing;
89
147
  const version = (await this.#run('/usr/bin/plutil', ['-extract', 'CFBundleVersion', 'raw', '-o', '-', file])).trim();
90
148
  compareBundleVersions(version, version);
91
149
  return version;
92
150
  }
151
+ /** Whether the app shows any window (see windowsOpen); null when that could not be read. */
152
+ async windowsOpen(pids) {
153
+ if (!pids.length)
154
+ return false;
155
+ try {
156
+ return windowsOpen(await this.#run('/usr/bin/osascript', ['-l', 'JavaScript', '-e', WINDOW_PROBE, ...pids.map(String)]));
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
93
162
  async #control(action, ...args) {
94
163
  makeExecutable(this.app);
95
164
  return parseNativeStatus(await this.#run(join(this.app, 'Contents', 'MacOS', 'ShardfluxMenuBar'), ['--control', action, ...args]));
@@ -125,46 +194,49 @@ export class MenubarManager {
125
194
  await this.#control('login', value);
126
195
  return this.status();
127
196
  }
128
- async start(configDir, apiUrl) {
129
- this.#requireMac();
130
- const source = this.#packageApp(), sourceVersion = await this.#version(source);
131
- const current = await this.status();
132
- if (!current.installed || compareBundleVersions(current.version, sourceVersion) < 0) {
133
- // Verify the source before stopping or replacing any existing app.
134
- await this.#run('/usr/bin/codesign', ['--verify', '--deep', '--strict', source]);
135
- mkdirSync(dirname(this.app), { recursive: true });
136
- const stage = mkdtempSync(join(dirname(this.app), '.Shardflux-install-'));
137
- const staged = join(stage, 'Shardflux.app'), backup = join(stage, 'previous.app');
138
- let committed = false, restored = false;
197
+ /** Replaces the installed app with `source` (verified first), stopping it while it is swapped. */
198
+ async #install(source, current) {
199
+ // Verify the source before stopping or replacing any existing app.
200
+ await this.#run('/usr/bin/codesign', ['--verify', '--deep', '--strict', source]);
201
+ mkdirSync(dirname(this.app), { recursive: true });
202
+ const stage = mkdtempSync(join(dirname(this.app), '.Shardflux-install-'));
203
+ const staged = join(stage, 'Shardflux.app'), backup = join(stage, 'previous.app');
204
+ let committed = false, restored = false;
205
+ try {
206
+ cpSync(source, staged, { recursive: true, dereference: false });
207
+ if (current.running)
208
+ await this.stop();
209
+ if (current.installed)
210
+ renameSync(this.app, backup);
211
+ makeExecutable(staged);
139
212
  try {
140
- cpSync(source, staged, { recursive: true, dereference: false });
141
- if (current.running)
142
- await this.stop();
143
- if (current.installed)
144
- renameSync(this.app, backup);
145
- makeExecutable(staged);
146
- try {
147
- renameSync(staged, this.app);
148
- committed = true;
149
- }
150
- catch (error) {
151
- if (existsSync(backup)) {
152
- try {
153
- renameSync(backup, this.app);
154
- restored = true;
155
- }
156
- catch {
157
- throw new UsageError(`Could not restore the previous app. It is preserved at ${backup}.`);
158
- }
213
+ renameSync(staged, this.app);
214
+ committed = true;
215
+ }
216
+ catch (error) {
217
+ if (existsSync(backup)) {
218
+ try {
219
+ renameSync(backup, this.app);
220
+ restored = true;
221
+ }
222
+ catch {
223
+ throw new UsageError(`Could not restore the previous app. It is preserved at ${backup}.`);
159
224
  }
160
- throw error;
161
225
  }
226
+ throw error;
162
227
  }
163
- finally {
164
- if (committed || restored || !existsSync(backup))
165
- rmSync(stage, { recursive: true, force: true });
166
- }
167
228
  }
229
+ finally {
230
+ if (committed || restored || !existsSync(backup))
231
+ rmSync(stage, { recursive: true, force: true });
232
+ }
233
+ }
234
+ async start(configDir, apiUrl) {
235
+ this.#requireMac();
236
+ const source = this.#packageApp(), sourceVersion = await this.#version(source);
237
+ const current = await this.status();
238
+ if (!current.installed || compareAppVersions(current.version, sourceVersion) < 0)
239
+ await this.#install(source, current);
168
240
  await this.#stripQuarantine();
169
241
  if (!(await this.status()).running) {
170
242
  mkdirSync(configDir, { recursive: true, mode: 0o700 });
@@ -179,6 +251,45 @@ export class MenubarManager {
179
251
  }
180
252
  throw new UsageError(`Shardflux did not start. Check ${join(configDir, 'menubar.log')}.`);
181
253
  }
254
+ /**
255
+ * Brings an installed app up to the one this CLI's npm package carries, without disturbing the person: nothing when
256
+ * the versions are equal (each swap of an ad-hoc signed app costs its privacy grants), the copy on disk alone when it
257
+ * is not running, and a running app only when it shows no window and no `shard update` runs. A running app that
258
+ * swaps itself in (SELF_UPDATING_APP or later) is left to do so: it also knows when it is in the middle of something.
259
+ */
260
+ async refresh(configDir, apiUrl) {
261
+ this.#requireMac();
262
+ const current = await this.status();
263
+ if (!current.installed || !current.version)
264
+ return { result: 'not_installed', from: null, to: null };
265
+ const to = await this.packagedVersion(), from = current.version;
266
+ if (compareAppVersions(from, to) >= 0)
267
+ return { result: 'current', from, to };
268
+ if (current.running) {
269
+ if (compareAppVersions(from, SELF_UPDATING_APP) >= 0)
270
+ return { result: 'deferred', from, to };
271
+ if (updateRunning(configDir) || (await this.windowsOpen(current.pids)) !== false)
272
+ return { result: 'busy', from, to };
273
+ await this.start(configDir, apiUrl);
274
+ return { result: 'restarted', from, to };
275
+ }
276
+ await this.#install(this.#packageApp(), current);
277
+ await this.#stripQuarantine();
278
+ return { result: 'installed', from, to };
279
+ }
280
+ }
281
+ /** A `shard update` holds update.lock while it runs (update.ts); a lock of a process that is gone does not count. */
282
+ function updateRunning(dir) {
283
+ try {
284
+ const pid = Number(readFileSync(join(dir, 'update.lock'), 'utf8'));
285
+ if (!Number.isInteger(pid) || pid <= 0)
286
+ return false;
287
+ process.kill(pid, 0);
288
+ return true;
289
+ }
290
+ catch {
291
+ return false;
292
+ }
182
293
  }
183
294
  /** On macOS, `shard` brings the menu bar up by itself (with launch at login) unless the user ran `shard menubar stop`.
184
295
  * It never fails the command center: a missing optional package or a refused start just leaves it off. */
@@ -190,8 +301,9 @@ export async function ensureMenubar(dir, apiUrl, manager = new MenubarManager(),
190
301
  return undefined;
191
302
  try {
192
303
  const status = await manager.status();
193
- // A running app stays unless this CLI carries a newer one (after `npm i -g shardflux`): then it is replaced.
194
- if (status.running && !(status.version && compareBundleVersions(status.version, await manager.packagedVersion()) < 0))
304
+ // A running app is brought up to the one this CLI carries (after `npm i -g shardflux`) by the background refresh
305
+ // every command starts (refreshMenubarInBackground), at a moment when it shows no window.
306
+ if (status.running)
195
307
  return undefined;
196
308
  const started = await manager.start(dir, apiUrl);
197
309
  if (!started.running)
@@ -204,7 +316,114 @@ export async function ensureMenubar(dir, apiUrl, manager = new MenubarManager(),
204
316
  return undefined;
205
317
  }
206
318
  }
207
- export const MENUBAR_HANDLERS = Object.fromEntries(['start', 'stop', 'status'].map(action => {
319
+ const REFRESH_MARK = 'menubar-refresh.json';
320
+ const REFRESH_LOCK = 'menubar-refresh.lock';
321
+ /** How often a command may start a background refresh while the app is busy (a window open). */
322
+ export const REFRESH_RETRY_MS = 5 * 60_000;
323
+ /** Where this CLI's npm package keeps the app it carries, or null when the optional package is missing. */
324
+ export function packagedAppPath() {
325
+ try {
326
+ return join(dirname(createRequire(import.meta.url).resolve('@shardflux/menubar-darwin/package.json')), 'Shardflux.app');
327
+ }
328
+ catch {
329
+ return null;
330
+ }
331
+ }
332
+ /**
333
+ * Every `shard` command (0.15.2+): when the npm package carries a newer app than the installed one (two Info.plist
334
+ * reads, no network, no tool), `shard menubar refresh` runs detached, so the command never waits for it. It is started
335
+ * at most every 5 minutes while the app is busy, and not at all after `shard menubar stop`. Returns whether it started.
336
+ */
337
+ export function refreshMenubarInBackground(o) {
338
+ try {
339
+ if ((o.platform ?? process.platform) !== 'darwin')
340
+ return false;
341
+ const installed = plistVersion(join(o.home, 'Applications', 'Shardflux.app'));
342
+ const source = (o.packageApp ?? packagedAppPath)();
343
+ const packaged = source ? plistVersion(source) : null;
344
+ if (!installed || !packaged || compareAppVersions(installed, packaged) >= 0)
345
+ return false;
346
+ if (new AgentStateStore(o.dir).read().menubar_stopped === true)
347
+ return false;
348
+ const now = (o.now ?? Date.now)(), mark = join(o.dir, REFRESH_MARK);
349
+ try {
350
+ const last = JSON.parse(readFileSync(mark, 'utf8'));
351
+ if (last.to === packaged && typeof last.at === 'number' && now - last.at < REFRESH_RETRY_MS && last.at <= now)
352
+ return false;
353
+ }
354
+ catch { /* never started */ }
355
+ mkdirSync(o.dir, { recursive: true, mode: 0o700 });
356
+ const fd = openSync(mark, 'w', 0o600);
357
+ try {
358
+ writeSync(fd, `${JSON.stringify({ at: now, from: installed, to: packaged })}\n`);
359
+ }
360
+ finally {
361
+ closeSync(fd);
362
+ }
363
+ const env = { ...o.env, SHARDFLUX_CONFIG_DIR: o.dir, SHARDFLUX_NO_UPDATE_CHECK: '1' };
364
+ (o.spawn ?? spawnDetached)(o.runtime.node, [o.runtime.script, 'menubar', 'refresh'], env);
365
+ return true;
366
+ }
367
+ catch {
368
+ return false;
369
+ }
370
+ }
371
+ function spawnDetached(node, args, env) {
372
+ const child = spawn(node, args, { detached: true, stdio: 'ignore', env });
373
+ child.on('error', () => undefined);
374
+ child.unref();
375
+ }
376
+ /** One refresh at a time per config directory; the lock of a process that is gone is taken over. */
377
+ function refreshLock(dir) {
378
+ const file = join(dir, REFRESH_LOCK);
379
+ for (let attempt = 0; attempt < 2; attempt++) {
380
+ try {
381
+ const fd = openSync(file, 'wx', 0o600);
382
+ writeSync(fd, String(process.pid));
383
+ closeSync(fd);
384
+ return () => rmSync(file, { force: true });
385
+ }
386
+ catch (error) {
387
+ if (error.code !== 'EEXIST')
388
+ return null;
389
+ let alive = false;
390
+ try {
391
+ const pid = Number(readFileSync(file, 'utf8'));
392
+ if (Number.isInteger(pid) && pid > 0) {
393
+ process.kill(pid, 0);
394
+ alive = true;
395
+ }
396
+ }
397
+ catch { /* gone */ }
398
+ if (alive)
399
+ return null;
400
+ rmSync(file, { force: true });
401
+ }
402
+ }
403
+ return null;
404
+ }
405
+ /** `shard menubar refresh` (not listed in help, so not in HANDLERS): what refreshMenubarInBackground starts. */
406
+ export const runMenubarRefresh = async (ctx) => {
407
+ const manager = new MenubarManager({ home: ctx.io.env.HOME || homedir(), signal: ctx.signal });
408
+ let outcome;
409
+ if (new AgentStateStore(ctx.store.dir).read().menubar_stopped === true)
410
+ outcome = { result: 'off', from: null, to: null };
411
+ else {
412
+ const unlock = refreshLock(ctx.store.dir);
413
+ if (!unlock)
414
+ outcome = { result: 'locked', from: null, to: null };
415
+ else
416
+ try {
417
+ outcome = await manager.refresh(ctx.store.dir, ctx.apiUrl);
418
+ }
419
+ finally {
420
+ unlock();
421
+ }
422
+ }
423
+ ctx.io.stdout.write(ctx.json ? json(outcome) : `Menu bar: ${outcome.result}${outcome.from ? ` · app ${outcome.from}${outcome.to && outcome.to !== outcome.from ? ` → ${outcome.to}` : ''}` : ''}\n`);
424
+ return 0;
425
+ };
426
+ export const MENUBAR_HANDLERS = Object.fromEntries(['start', 'stop', 'status'].map((action) => {
208
427
  const handler = async (ctx, values) => {
209
428
  if (values.login !== undefined && !['on', 'off'].includes(String(values.login)))
210
429
  throw new UsageError('--login must be on or off.');
@@ -0,0 +1,8 @@
1
+ import type { RegistryOptions } from '@shardflux/sdk';
2
+ export { NPM_REGISTRY, npmLatest, withRegistryLatest } from '@shardflux/sdk';
3
+ /** The npm packages whose versions `shard update` compares. */
4
+ export declare const NPM_CLIENTS: readonly ["shardflux", "@shardflux/cli", "@shardflux/sdk", "@shardflux/mcp", "@shardflux/menubar-darwin"];
5
+ /** The registry to ask: SHARDFLUX_NPM_REGISTRY, else npm's public registry. */
6
+ export declare function npmRegistry(env: Record<string, string | undefined>): string;
7
+ /** `latest` of every package in `packages`, asked in parallel; a package the registry did not answer is absent. */
8
+ export declare function npmLatestAll(packages: readonly string[], opts: RegistryOptions): Promise<Record<string, string>>;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The latest version of each Shardflux client comes from npm (0.15.2+; the SDK's registry helpers): its `latest`
3
+ * dist-tag is what `npm i -g shardflux` and `shard update` install. The API catalog adds `minimum_supported` and
4
+ * `hold` (see withRegistryLatest).
5
+ */
6
+ import { npmLatest, registryFromEnv } from '@shardflux/sdk';
7
+ export { NPM_REGISTRY, npmLatest, withRegistryLatest } from '@shardflux/sdk';
8
+ /** The npm packages whose versions `shard update` compares. */
9
+ export const NPM_CLIENTS = ['shardflux', '@shardflux/cli', '@shardflux/sdk', '@shardflux/mcp', '@shardflux/menubar-darwin'];
10
+ /** The registry to ask: SHARDFLUX_NPM_REGISTRY, else npm's public registry. */
11
+ export function npmRegistry(env) {
12
+ return registryFromEnv(env, 'npm');
13
+ }
14
+ /** `latest` of every package in `packages`, asked in parallel; a package the registry did not answer is absent. */
15
+ export async function npmLatestAll(packages, opts) {
16
+ const answers = await Promise.all(packages.map(async (pkg) => [pkg, await npmLatest(pkg, { ...opts, etag: null })]));
17
+ return Object.fromEntries(answers.flatMap(([pkg, a]) => (a?.latest ? [[pkg, a.latest]] : [])));
18
+ }