@shardflux/cli 0.15.1 → 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.
- package/CHANGELOG.md +13 -0
- package/dist/args.d.ts +1 -1
- package/dist/args.js +4 -1
- package/dist/commands.d.ts +1 -1
- package/dist/main.js +30 -12
- package/dist/menubar.d.ts +57 -0
- package/dist/menubar.js +264 -45
- package/dist/registry.d.ts +48 -0
- package/dist/registry.js +102 -0
- package/dist/update-check.d.ts +9 -1
- package/dist/update-check.js +55 -17
- package/dist/update.d.ts +2 -0
- package/dist/update.js +26 -8
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.15.2
|
|
4
|
+
|
|
5
|
+
- Updates show up the moment they are published: the latest version comes from npm's `latest` tag (what
|
|
6
|
+
`npm i -g shardflux` installs), checked at most every 12 hours with its ETag. The API still says which versions it
|
|
7
|
+
supports, and can hold back a release. Offline, nothing is printed. `shard update --check` and the menu bar's update
|
|
8
|
+
check use the same versions.
|
|
9
|
+
- The menu bar keeps up with npm by itself: after `npm i -g shardflux`, every `shard` command compares the installed app
|
|
10
|
+
with the one the package carries (two Info.plist reads, no network) and, when that one is newer, brings it in in the
|
|
11
|
+
background: a stopped app is replaced on disk, a running one is left to update itself (menu bar 0.3.2+) or, for
|
|
12
|
+
earlier apps, restarted on the new version when none of its windows is open. `shard update` swaps in an app the
|
|
13
|
+
package already carries without another install. App versions compare as semver (0.3.10 after 0.3.9).
|
|
14
|
+
- Installs menu bar 0.3.2 (`@shardflux/menubar-darwin ^0.3.2`).
|
|
15
|
+
|
|
3
16
|
## 0.15.1
|
|
4
17
|
|
|
5
18
|
- `shard setup`, `shard signup` and `shard api-keys create` make keys with every workspace tool, `computer` included, so the saved key can watch and screenshot a workspace's desktop (`shard ws computer stream`, `screenshot`), as the first-run demo does. `shard setup` replaces a saved key from an earlier setup (every tool but `computer`) with one that has every tool; the old key keeps working until revoked. `--tool` lists `computer`.
|
package/dist/args.d.ts
CHANGED
package/dist/args.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { parseArgs } from 'node:util';
|
|
13
13
|
import { ACCOUNT_COMMANDS } from "./account-args.js";
|
|
14
14
|
import { UsageError } from "./errors.js";
|
|
15
|
-
export const CLI_VERSION = '0.15.
|
|
15
|
+
export const CLI_VERSION = '0.15.2';
|
|
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).' },
|
|
@@ -1407,6 +1407,9 @@ export function parseCommandLine(argv, options = {}) {
|
|
|
1407
1407
|
// `acp --login` is the ACP terminal sign-in an editor runs (ACP appends the method's args to `shard acp`).
|
|
1408
1408
|
if (argv.length === 2 && argv[0] === 'acp' && argv[1] === '--login')
|
|
1409
1409
|
return { kind: 'run', command: { path: ['acp'], summary: '', positionals: [], options: {} }, values: { login: true }, positionals: [], global: { apiUrl: undefined, json: false, agentLabel: undefined, wake: undefined, wakeTimeout: undefined } };
|
|
1410
|
+
// `menubar refresh` (0.15.2+, not in help): the background step each command may start (menubar.ts).
|
|
1411
|
+
if (argv.length === 2 && argv[0] === 'menubar' && argv[1] === 'refresh')
|
|
1412
|
+
return { kind: 'run', command: { path: ['menubar', 'refresh'], summary: '', positionals: [], options: {} }, values: {}, positionals: [], global: { apiUrl: undefined, json: false, agentLabel: undefined, wake: undefined, wakeTimeout: undefined } };
|
|
1410
1413
|
if (argv.length === 1 && (argv[0] === 'acp' || argv[0] === 'device-helper'))
|
|
1411
1414
|
return { kind: 'run', command: { path: [argv[0]], summary: '', positionals: [], options: {} }, values: {}, positionals: [], global: { apiUrl: undefined, json: false, agentLabel: undefined, wake: undefined, wakeTimeout: undefined } };
|
|
1412
1415
|
const terminator = argv.indexOf('--');
|
package/dist/commands.d.ts
CHANGED
|
@@ -66,7 +66,7 @@ export interface Ctx {
|
|
|
66
66
|
distribution: Distribution;
|
|
67
67
|
/** The Node.js binary and script of this process; null when run in-process (tests, embedders). */
|
|
68
68
|
runtime: import('./update.js').Runtime | null;
|
|
69
|
-
/** GET /v1/client-versions (
|
|
69
|
+
/** Every client's versions: GET /v1/client-versions (minimum_supported, hold) with `latest` from npm (0.15.2+). */
|
|
70
70
|
clientVersions(): Promise<unknown>;
|
|
71
71
|
/** This machine's name (default API key name of `shard setup`). */
|
|
72
72
|
hostname(): string;
|
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,6 +23,8 @@ 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";
|
|
@@ -540,12 +542,20 @@ export async function run(argv, io, opts = {}) {
|
|
|
540
542
|
distribution,
|
|
541
543
|
runtime: opts.runtime ?? null,
|
|
542
544
|
async clientVersions() {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
545
|
+
// The latest versions come from npm (0.15.2+); the API adds minimum_supported and hold. Either one is enough.
|
|
546
|
+
const catalog = (async () => {
|
|
547
|
+
const res = await base(new URL('/v1/client-versions', `${apiUrl.replace(/\/+$/, '')}/`), {
|
|
548
|
+
headers: { accept: 'application/json', 'user-agent': userAgent }, signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
|
549
|
+
});
|
|
550
|
+
if (!res.ok)
|
|
551
|
+
throw new UsageError(`Could not read the latest versions from ${apiUrl} (HTTP ${res.status}).`);
|
|
552
|
+
const body = await res.json();
|
|
553
|
+
return body;
|
|
554
|
+
})();
|
|
555
|
+
const [api, latest] = await Promise.all([catalog.then((body) => ({ body }), (error) => ({ error })), npmLatestAll(NPM_CLIENTS, { fetch: base, registry: npmRegistry(io.env), userAgent, signal })]);
|
|
556
|
+
if ('error' in api && Object.keys(latest).length === 0)
|
|
557
|
+
throw api.error;
|
|
558
|
+
return withRegistryLatest('body' in api ? api.body : null, latest);
|
|
549
559
|
},
|
|
550
560
|
hostname: () => {
|
|
551
561
|
try {
|
|
@@ -624,7 +634,7 @@ export async function run(argv, io, opts = {}) {
|
|
|
624
634
|
secrets.push(value);
|
|
625
635
|
},
|
|
626
636
|
async versionStatus(force) {
|
|
627
|
-
const check = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent });
|
|
637
|
+
const check = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent, registry: npmRegistry(io.env) });
|
|
628
638
|
if (!force && updateCheckDisabled(io.env)) {
|
|
629
639
|
const cached = check.peek();
|
|
630
640
|
return cached ? updateView(cached.status, cached.checkedAt) : null;
|
|
@@ -634,10 +644,18 @@ export async function run(argv, io, opts = {}) {
|
|
|
634
644
|
return r ? updateView(r.status, r.checkedAt) : null;
|
|
635
645
|
},
|
|
636
646
|
};
|
|
647
|
+
// After `npm i -g shardflux` the package may carry a newer menu bar app: brought in by a detached process (no
|
|
648
|
+
// network; an Info.plist compare decides whether to start it).
|
|
649
|
+
// `shard update` brings the app in itself; `shard update --check` (the menu bar of an app before 0.3.2 runs it every
|
|
650
|
+
// 6 hours) is how such an app learns of a newer one with no action from the person.
|
|
651
|
+
const updating = command === 'update' && parsed.values.check !== true;
|
|
652
|
+
if (opts.runtime && !updating && !['menubar', 'device-helper'].includes(parsed.command.path[0])) {
|
|
653
|
+
refreshMenubarInBackground({ dir: store.dir, home: io.env.HOME || homedir(), runtime: opts.runtime, env: { ...process.env, ...io.env } });
|
|
654
|
+
}
|
|
637
655
|
// 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])) {
|
|
656
|
+
if (!updateCheckDisabled(io.env) && !['version', 'update', 'ssh-proxy', 'acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh') {
|
|
639
657
|
try {
|
|
640
|
-
updates = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent });
|
|
658
|
+
updates = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent, registry: npmRegistry(io.env) });
|
|
641
659
|
updates.start();
|
|
642
660
|
}
|
|
643
661
|
catch {
|
|
@@ -648,10 +666,10 @@ export async function run(argv, io, opts = {}) {
|
|
|
648
666
|
catch (err) {
|
|
649
667
|
return report(err);
|
|
650
668
|
}
|
|
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(' ')];
|
|
669
|
+
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
670
|
if (!handler)
|
|
653
671
|
return report(new UsageError(`unknown command "${parsed.command.path.join(' ')}"`));
|
|
654
|
-
if (!['acp', 'device-helper'].includes(parsed.command.path[0]))
|
|
672
|
+
if (!['acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh')
|
|
655
673
|
await housekeeping(ctx);
|
|
656
674
|
const running = handler(ctx, parsed.values, parsed.positionals).then((code) => ({ code }), (err) => ({ err }));
|
|
657
675
|
// 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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
194
|
-
|
|
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
|
-
|
|
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,48 @@
|
|
|
1
|
+
export declare const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
2
|
+
/** Each registry request's timeout (ms). */
|
|
3
|
+
export declare const REGISTRY_TIMEOUT_MS = 3000;
|
|
4
|
+
/** The npm packages whose versions `shard update` compares. */
|
|
5
|
+
export declare const NPM_CLIENTS: readonly ["shardflux", "@shardflux/cli", "@shardflux/sdk", "@shardflux/mcp", "@shardflux/menubar-darwin"];
|
|
6
|
+
export interface RegistryAnswer {
|
|
7
|
+
latest: string | null;
|
|
8
|
+
/** The registry's validator for the next request (npm's dist-tags endpoint sends none today). */
|
|
9
|
+
etag: string | null;
|
|
10
|
+
/** 304: what the caller cached under `etag` is still current. */
|
|
11
|
+
notModified: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface RegistryOptions {
|
|
14
|
+
fetch: typeof fetch;
|
|
15
|
+
/** Default https://registry.npmjs.org (SHARDFLUX_NPM_REGISTRY overrides it for the CLI). */
|
|
16
|
+
registry?: string;
|
|
17
|
+
etag?: string | null;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
userAgent?: string;
|
|
21
|
+
}
|
|
22
|
+
/** The registry to ask: SHARDFLUX_NPM_REGISTRY, else npm's public registry. */
|
|
23
|
+
export declare function npmRegistry(env: Record<string, string | undefined>): string;
|
|
24
|
+
/** `latest` of an npm package from `<registry>/-/package/<name>/dist-tags`, or null when the registry did not answer. */
|
|
25
|
+
export declare function npmLatest(pkg: string, opts: RegistryOptions): Promise<RegistryAnswer | null>;
|
|
26
|
+
/** `latest` of every package in `packages`, asked in parallel; a package the registry did not answer is absent. */
|
|
27
|
+
export declare function npmLatestAll(packages: readonly string[], opts: RegistryOptions): Promise<Record<string, string>>;
|
|
28
|
+
export interface CatalogEntry {
|
|
29
|
+
package: string;
|
|
30
|
+
ecosystem: string;
|
|
31
|
+
latest: string | null;
|
|
32
|
+
minimum_supported: string | null;
|
|
33
|
+
upgrade_command: string | null;
|
|
34
|
+
release_notes_url: string | null;
|
|
35
|
+
hold?: string | null;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A GET /v1/client-versions body whose npm entries carry the registry's `latest` (`registry`: package -> version; an
|
|
40
|
+
* absent package keeps the catalog's). Works with every catalog shape the API has served: no `hold` field, `latest`
|
|
41
|
+
* null, a package missing from the list, or no catalog at all (`catalog` null: the API did not answer).
|
|
42
|
+
*
|
|
43
|
+
* `hold`: when the registry's latest is that version, it is not recommended; the catalog's own `latest` is, when it is
|
|
44
|
+
* older, else `minimum_supported` (so "no longer supported" still works and nobody is told to update).
|
|
45
|
+
*/
|
|
46
|
+
export declare function withRegistryLatest(catalog: unknown, registry: Record<string, string | null | undefined>): {
|
|
47
|
+
clients: CatalogEntry[];
|
|
48
|
+
};
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The latest version of each Shardflux client comes from its registry (0.15.2+), so a release is known the moment it
|
|
3
|
+
* is published: npm's `latest` dist-tag, which is what `npm i -g shardflux` and `shard update` install. (`stable` is an
|
|
4
|
+
* opt-in tag for installs behind a release-age gate, docs/progress/packaging.md; it never drives the update notice.)
|
|
5
|
+
*
|
|
6
|
+
* The API catalog (GET /v1/client-versions) still says which versions it serves (`minimum_supported`) and may name a
|
|
7
|
+
* version to stop recommending (`hold`, an emergency brake). Its `latest` is only the fallback when the registry does
|
|
8
|
+
* not answer. Every request here is bounded and never throws: offline means "no answer".
|
|
9
|
+
*/
|
|
10
|
+
import { compareVersions } from '@shardflux/sdk';
|
|
11
|
+
export const NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
12
|
+
/** Each registry request's timeout (ms). */
|
|
13
|
+
export const REGISTRY_TIMEOUT_MS = 3_000;
|
|
14
|
+
/** The npm packages whose versions `shard update` compares. */
|
|
15
|
+
export const NPM_CLIENTS = ['shardflux', '@shardflux/cli', '@shardflux/sdk', '@shardflux/mcp', '@shardflux/menubar-darwin'];
|
|
16
|
+
const str = (v) => (typeof v === 'string' && v.length > 0 ? v : null);
|
|
17
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
18
|
+
/** The registry to ask: SHARDFLUX_NPM_REGISTRY, else npm's public registry. */
|
|
19
|
+
export function npmRegistry(env) {
|
|
20
|
+
const own = env.SHARDFLUX_NPM_REGISTRY?.trim();
|
|
21
|
+
return own ? own.replace(/\/+$/, '') : NPM_REGISTRY;
|
|
22
|
+
}
|
|
23
|
+
/** `latest` of an npm package from `<registry>/-/package/<name>/dist-tags`, or null when the registry did not answer. */
|
|
24
|
+
export async function npmLatest(pkg, opts) {
|
|
25
|
+
try {
|
|
26
|
+
const timeout = AbortSignal.timeout(opts.timeoutMs ?? REGISTRY_TIMEOUT_MS);
|
|
27
|
+
const headers = { accept: 'application/json' };
|
|
28
|
+
if (opts.userAgent)
|
|
29
|
+
headers['user-agent'] = opts.userAgent;
|
|
30
|
+
if (opts.etag)
|
|
31
|
+
headers['if-none-match'] = opts.etag;
|
|
32
|
+
const res = await opts.fetch(`${(opts.registry ?? NPM_REGISTRY).replace(/\/+$/, '')}/-/package/${encodeURIComponent(pkg)}/dist-tags`, {
|
|
33
|
+
method: 'GET',
|
|
34
|
+
headers,
|
|
35
|
+
signal: opts.signal ? AbortSignal.any([timeout, opts.signal]) : timeout,
|
|
36
|
+
});
|
|
37
|
+
const etag = res.headers.get('etag');
|
|
38
|
+
if (res.status === 304) {
|
|
39
|
+
await res.body?.cancel().catch(() => undefined);
|
|
40
|
+
return { latest: null, etag: etag ?? opts.etag ?? null, notModified: true };
|
|
41
|
+
}
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
await res.body?.cancel().catch(() => undefined);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const body = JSON.parse(await res.text());
|
|
47
|
+
const latest = isObj(body) ? str(body.latest) : null;
|
|
48
|
+
// A tag that is not a version (a broken registry mirror) is no answer.
|
|
49
|
+
if (latest === null || compareVersions(latest, latest) === null)
|
|
50
|
+
return null;
|
|
51
|
+
return { latest, etag, notModified: false };
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** `latest` of every package in `packages`, asked in parallel; a package the registry did not answer is absent. */
|
|
58
|
+
export async function npmLatestAll(packages, opts) {
|
|
59
|
+
const answers = await Promise.all(packages.map(async (pkg) => [pkg, await npmLatest(pkg, { ...opts, etag: null })]));
|
|
60
|
+
return Object.fromEntries(answers.flatMap(([pkg, a]) => (a?.latest ? [[pkg, a.latest]] : [])));
|
|
61
|
+
}
|
|
62
|
+
const DEFAULT_UPGRADE = {
|
|
63
|
+
shardflux: 'npm i -g shardflux',
|
|
64
|
+
'@shardflux/cli': 'npm i -g shardflux',
|
|
65
|
+
'@shardflux/menubar-darwin': 'shard update',
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* A GET /v1/client-versions body whose npm entries carry the registry's `latest` (`registry`: package -> version; an
|
|
69
|
+
* absent package keeps the catalog's). Works with every catalog shape the API has served: no `hold` field, `latest`
|
|
70
|
+
* null, a package missing from the list, or no catalog at all (`catalog` null: the API did not answer).
|
|
71
|
+
*
|
|
72
|
+
* `hold`: when the registry's latest is that version, it is not recommended; the catalog's own `latest` is, when it is
|
|
73
|
+
* older, else `minimum_supported` (so "no longer supported" still works and nobody is told to update).
|
|
74
|
+
*/
|
|
75
|
+
export function withRegistryLatest(catalog, registry) {
|
|
76
|
+
const listed = isObj(catalog) && Array.isArray(catalog.clients) ? catalog.clients.filter(isObj) : [];
|
|
77
|
+
const entries = listed.map((c) => ({
|
|
78
|
+
...c,
|
|
79
|
+
package: String(c.package),
|
|
80
|
+
ecosystem: String(c.ecosystem),
|
|
81
|
+
latest: str(c.latest),
|
|
82
|
+
minimum_supported: str(c.minimum_supported),
|
|
83
|
+
upgrade_command: str(c.upgrade_command),
|
|
84
|
+
release_notes_url: str(c.release_notes_url),
|
|
85
|
+
hold: str(c.hold),
|
|
86
|
+
}));
|
|
87
|
+
for (const pkg of Object.keys(registry)) {
|
|
88
|
+
if (!entries.some((e) => e.ecosystem === 'npm' && e.package === pkg)) {
|
|
89
|
+
entries.push({ package: pkg, ecosystem: 'npm', latest: null, minimum_supported: null, upgrade_command: DEFAULT_UPGRADE[pkg] ?? null, release_notes_url: `https://www.npmjs.com/package/${pkg}?activeTab=versions`, hold: null });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
clients: entries.map((e) => {
|
|
94
|
+
const fromRegistry = e.ecosystem === 'npm' ? str(registry[e.package]) : null;
|
|
95
|
+
let latest = fromRegistry ?? e.latest;
|
|
96
|
+
if (latest !== null && e.hold && compareVersions(latest, e.hold) === 0) {
|
|
97
|
+
latest = e.latest !== null && compareVersions(e.latest, e.hold) === -1 ? e.latest : e.minimum_supported;
|
|
98
|
+
}
|
|
99
|
+
return { ...e, latest };
|
|
100
|
+
}),
|
|
101
|
+
};
|
|
102
|
+
}
|
package/dist/update-check.d.ts
CHANGED
|
@@ -11,10 +11,16 @@ export interface Distribution {
|
|
|
11
11
|
export declare const CLI_DISTRIBUTION: Distribution;
|
|
12
12
|
interface CacheEntry {
|
|
13
13
|
checked_at: string;
|
|
14
|
+
/** What the notice compares with: the registry's latest, unless the catalog holds it (registry.ts). */
|
|
14
15
|
latest: string | null;
|
|
15
16
|
minimum_supported: string | null;
|
|
16
17
|
upgrade_command: string | null;
|
|
17
18
|
release_notes_url: string | null;
|
|
19
|
+
/** The catalog's own `latest` and `hold`, and the registry's answer with its ETag (0.15.2+). */
|
|
20
|
+
catalog_latest?: string | null;
|
|
21
|
+
hold?: string | null;
|
|
22
|
+
registry_latest?: string | null;
|
|
23
|
+
registry_etag?: string | null;
|
|
18
24
|
}
|
|
19
25
|
/** The `update` object of `shard version --json`. */
|
|
20
26
|
export interface UpdateView {
|
|
@@ -37,6 +43,8 @@ export interface UpdateCheckOptions {
|
|
|
37
43
|
distribution: Distribution;
|
|
38
44
|
fetch: typeof fetch;
|
|
39
45
|
userAgent: string;
|
|
46
|
+
/** The npm registry (default https://registry.npmjs.org). */
|
|
47
|
+
registry?: string;
|
|
40
48
|
now?: () => number;
|
|
41
49
|
}
|
|
42
50
|
/**
|
|
@@ -51,7 +59,7 @@ export declare class UpdateCheck {
|
|
|
51
59
|
status: ClientVersionStatus;
|
|
52
60
|
checkedAt: string | null;
|
|
53
61
|
} | null;
|
|
54
|
-
/** Starts the check: from the cache when it is younger than
|
|
62
|
+
/** Starts the check: from the cache when it is younger than 12 h (unless `force`), else the registry and the API. */
|
|
55
63
|
start(force?: boolean): void;
|
|
56
64
|
/** The status (with when it was fetched), or null when not started or still running after `waitMs`. */
|
|
57
65
|
result(waitMs: number): Promise<{
|
package/dist/update-check.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `shard` tells its user when it is outdated or no longer supported (0.5.0+). The
|
|
3
|
-
*
|
|
2
|
+
* `shard` tells its user when it is outdated or no longer supported (0.5.0+). The latest version comes from the
|
|
3
|
+
* registry (0.15.2+: npm's `latest` dist-tag, see registry.ts); the API's GET /v1/client-versions names the oldest
|
|
4
|
+
* version it still serves and, optionally, a version to stop recommending (`hold`).
|
|
4
5
|
*
|
|
5
|
-
* - At most one
|
|
6
|
-
*
|
|
7
|
-
* recorded before the
|
|
8
|
-
* - Started in parallel with the command (
|
|
6
|
+
* - At most one check per 12 hours per API URL and package: the answer is cached in <config dir>/update-check.json
|
|
7
|
+
* (`checked_at`, `latest`, `minimum_supported`, `upgrade_command`, and what each source said, with the registry's
|
|
8
|
+
* ETag). The attempt is recorded before the requests, so an unreachable network is not asked again for 12 hours.
|
|
9
|
+
* - Started in parallel with the command (two requests in parallel, 3 s timeout each, never throws) and
|
|
9
10
|
* printed on stderr after it. When the command ends first, `shard` waits at most ~300 ms and otherwise skips the
|
|
10
11
|
* notice (the next run uses what this one cached). Never changes the exit code; never printed after a command that
|
|
11
12
|
* failed with --json (the error document stays the only stderr content).
|
|
@@ -14,13 +15,16 @@
|
|
|
14
15
|
* - Off with SHARDFLUX_NO_UPDATE_CHECK=1 (also true/yes/on) or NO_UPDATE_NOTIFIER (the SDK's rules);
|
|
15
16
|
* `shard version --check` still asks.
|
|
16
17
|
*/
|
|
17
|
-
import {
|
|
18
|
+
import { clientVersionStatus, versionCheckDisabledByEnv } from '@shardflux/sdk';
|
|
18
19
|
import { readFileSync } from 'node:fs';
|
|
19
20
|
import { join } from 'node:path';
|
|
20
21
|
import { CLI_VERSION } from "./args.js";
|
|
21
22
|
import { writePrivateFile } from "./credentials.js";
|
|
23
|
+
import { NPM_REGISTRY, npmLatest, withRegistryLatest } from "./registry.js";
|
|
22
24
|
export const UPDATE_CHECK_FILE = 'update-check.json';
|
|
23
|
-
export const CHECK_INTERVAL_MS =
|
|
25
|
+
export const CHECK_INTERVAL_MS = 12 * 3600 * 1000;
|
|
26
|
+
/** The catalog request's timeout (ms). */
|
|
27
|
+
const CATALOG_TIMEOUT_MS = 3_000;
|
|
24
28
|
/** How long the end of a command waits for a check that is still running. */
|
|
25
29
|
export const NOTICE_WAIT_MS = 300;
|
|
26
30
|
export const CLI_DISTRIBUTION = { package: '@shardflux/cli', version: CLI_VERSION };
|
|
@@ -44,7 +48,26 @@ function entryOf(doc, apiUrl, pkg) {
|
|
|
44
48
|
const e = doc.checks[apiUrl]?.[pkg];
|
|
45
49
|
if (!isObj(e) || typeof e.checked_at !== 'string' || Number.isNaN(Date.parse(e.checked_at)))
|
|
46
50
|
return null;
|
|
47
|
-
return {
|
|
51
|
+
return {
|
|
52
|
+
checked_at: e.checked_at, latest: strOrNull(e.latest), minimum_supported: strOrNull(e.minimum_supported), upgrade_command: strOrNull(e.upgrade_command), release_notes_url: strOrNull(e.release_notes_url),
|
|
53
|
+
// An entry written before 0.15.2 has no sources: its `latest` came from the catalog.
|
|
54
|
+
catalog_latest: 'catalog_latest' in e ? strOrNull(e.catalog_latest) : strOrNull(e.latest), hold: strOrNull(e.hold), registry_latest: strOrNull(e.registry_latest), registry_etag: strOrNull(e.registry_etag),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** GET /v1/client-versions, or null when the API did not answer with JSON (never throws). */
|
|
58
|
+
async function fetchCatalog(apiUrl, f, userAgent) {
|
|
59
|
+
try {
|
|
60
|
+
const res = await f(new URL('/v1/client-versions', `${apiUrl.replace(/\/+$/, '')}/`), { method: 'GET', headers: { accept: 'application/json', 'user-agent': userAgent }, signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS) });
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
await res.body?.cancel().catch(() => undefined);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const body = JSON.parse(await res.text());
|
|
66
|
+
return isObj(body) && Array.isArray(body.clients) ? body : null;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
48
71
|
}
|
|
49
72
|
/** The status the cached answer gives this distribution (the same rules as the SDK; no request). */
|
|
50
73
|
export function statusFromEntry(entry, d) {
|
|
@@ -71,7 +94,7 @@ export class UpdateCheck {
|
|
|
71
94
|
const cached = entryOf(readCache(this.#file), this.#o.apiUrl, this.#o.distribution.package);
|
|
72
95
|
return cached ? { status: statusFromEntry(cached, this.#o.distribution), checkedAt: cached.checked_at } : null;
|
|
73
96
|
}
|
|
74
|
-
/** Starts the check: from the cache when it is younger than
|
|
97
|
+
/** Starts the check: from the cache when it is younger than 12 h (unless `force`), else the registry and the API. */
|
|
75
98
|
start(force = false) {
|
|
76
99
|
const { apiUrl, distribution: d } = this.#o;
|
|
77
100
|
const now = (this.#o.now ?? Date.now)();
|
|
@@ -82,14 +105,29 @@ export class UpdateCheck {
|
|
|
82
105
|
return;
|
|
83
106
|
}
|
|
84
107
|
const checkedAt = new Date(now).toISOString();
|
|
85
|
-
// Record the attempt first:
|
|
86
|
-
//
|
|
108
|
+
// Record the attempt first: requests that never finish (the command ended, the network is down) still count,
|
|
109
|
+
// and the previous answer stays until a new one arrives.
|
|
87
110
|
this.#write(doc, { ...(cached ?? { latest: null, minimum_supported: null, upgrade_command: null, release_notes_url: null }), checked_at: checkedAt });
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
111
|
+
const f = this.#o.fetch;
|
|
112
|
+
this.#pending = Promise.all([
|
|
113
|
+
fetchCatalog(apiUrl, f, this.#o.userAgent),
|
|
114
|
+
npmLatest(d.package, { fetch: f, registry: this.#o.registry ?? NPM_REGISTRY, etag: cached?.registry_latest ? cached.registry_etag : null, userAgent: this.#o.userAgent }),
|
|
115
|
+
]).then(([catalog, registry]) => {
|
|
116
|
+
const registryLatest = registry?.notModified ? (cached?.registry_latest ?? null) : (registry?.latest ?? null);
|
|
117
|
+
if (catalog === null && registryLatest === null) {
|
|
118
|
+
// Nobody answered: the last answer stands.
|
|
119
|
+
return { status: cached ? statusFromEntry(cached, d) : statusFromEntry(null, d), checkedAt: cached ? cached.checked_at : null };
|
|
120
|
+
}
|
|
121
|
+
// A catalog that did not answer this time: what it said last time (minimum_supported, hold) still applies.
|
|
122
|
+
const base = catalog ?? (cached ? { clients: [{ package: d.package, ecosystem: 'npm', latest: cached.catalog_latest ?? null, minimum_supported: cached.minimum_supported, upgrade_command: cached.upgrade_command, release_notes_url: cached.release_notes_url, hold: cached.hold ?? null }] } : null);
|
|
123
|
+
const merged = withRegistryLatest(base, registryLatest ? { [d.package]: registryLatest } : {});
|
|
124
|
+
const own = merged.clients.find((c) => c.ecosystem === 'npm' && c.package === d.package);
|
|
125
|
+
const status = clientVersionStatus(merged, { package: d.package, version: d.version, ecosystem: 'npm' });
|
|
126
|
+
const raw = base?.clients.find((c) => c.ecosystem === 'npm' && c.package === d.package);
|
|
127
|
+
this.#write(readCache(this.#file), {
|
|
128
|
+
checked_at: checkedAt, latest: own?.latest ?? null, minimum_supported: own?.minimum_supported ?? null, upgrade_command: own?.upgrade_command ?? null, release_notes_url: own?.release_notes_url ?? null,
|
|
129
|
+
catalog_latest: strOrNull(raw?.latest), hold: own?.hold ?? null, registry_latest: registryLatest, registry_etag: registry?.etag ?? cached?.registry_etag ?? null,
|
|
130
|
+
});
|
|
93
131
|
return { status, checkedAt };
|
|
94
132
|
});
|
|
95
133
|
}
|
package/dist/update.d.ts
CHANGED
|
@@ -52,6 +52,8 @@ export interface UpdateDeps {
|
|
|
52
52
|
run?: Runner;
|
|
53
53
|
menubar?: MenubarManager;
|
|
54
54
|
}
|
|
55
|
+
/** True when nothing is behind but the menu bar app, and the app the npm package carries is already that version. */
|
|
56
|
+
export declare function onlyAppToSwap(pending: Component[], packagedApp: string | null): boolean;
|
|
55
57
|
export declare function update(ctx: Ctx, values: Record<string, unknown>, deps?: UpdateDeps): Promise<number>;
|
|
56
58
|
/** Records where `shard` runs, for the menu bar's update button. Written on macOS, only when it changed. */
|
|
57
59
|
export declare function recordInstall(dir: string, runtime: Runtime, distribution: {
|
package/dist/update.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `shard update` (0.13.5+): brings this computer's Shardflux install up to date in one step. It checks every local
|
|
3
|
-
* piece against GET /v1/client-versions
|
|
4
|
-
* the CLI and SDK inside it, the macOS menu bar app and a globally installed `@shardflux/mcp` — then runs one install
|
|
3
|
+
* piece against the latest versions on npm (0.15.2+; GET /v1/client-versions adds minimum_supported and hold) — the
|
|
4
|
+
* package that runs `shard` (the `shardflux` bundle or `@shardflux/cli`), the CLI and SDK inside it, the macOS menu bar app and a globally installed `@shardflux/mcp` — then runs one install
|
|
5
5
|
* of the latest versions with the package manager that installed `shard`, checks the result with the new `shard`,
|
|
6
6
|
* and restarts the menu bar on its new app. `--check` only reports.
|
|
7
7
|
*
|
|
@@ -18,7 +18,7 @@ import { AgentStateStore } from "./agent-state.js";
|
|
|
18
18
|
import { writePrivateFile } from "./credentials.js";
|
|
19
19
|
import { CodedError } from "./errors.js";
|
|
20
20
|
import { json } from "./format.js";
|
|
21
|
-
import {
|
|
21
|
+
import { compareAppVersions, MenubarManager, packagedAppPath } from "./menubar.js";
|
|
22
22
|
export const INSTALL_FILE = 'install.json';
|
|
23
23
|
export const LAST_UPDATE_FILE = 'last-update.json';
|
|
24
24
|
const LOCK_FILE = 'update.lock';
|
|
@@ -175,6 +175,10 @@ function npmFor(node) {
|
|
|
175
175
|
const sibling = join(dirname(node), process.platform === 'win32' ? 'npm.cmd' : 'npm');
|
|
176
176
|
return existsSync(sibling) ? sibling : 'npm';
|
|
177
177
|
}
|
|
178
|
+
/** True when nothing is behind but the menu bar app, and the app the npm package carries is already that version. */
|
|
179
|
+
export function onlyAppToSwap(pending, packagedApp) {
|
|
180
|
+
return pending.every(c => c.package === MENUBAR && packagedApp !== null && c.latest !== null && compareAppVersions(packagedApp, c.latest) >= 0);
|
|
181
|
+
}
|
|
178
182
|
export async function update(ctx, values, deps = {}) {
|
|
179
183
|
const run = deps.run ?? runProcess;
|
|
180
184
|
const menubar = deps.menubar ?? menubarManager(ctx);
|
|
@@ -193,10 +197,15 @@ export async function update(ctx, values, deps = {}) {
|
|
|
193
197
|
: `Shardflux is up to date (${versions}).\n`);
|
|
194
198
|
return 0;
|
|
195
199
|
}
|
|
196
|
-
|
|
197
|
-
|
|
200
|
+
// The npm package may already carry the latest menu bar app (installed with npm, not yet swapped in): no install.
|
|
201
|
+
const packagedApp = pending.some(c => c.package === MENUBAR) ? await menubar.packagedVersion().catch(() => null) : null;
|
|
202
|
+
if (onlyAppToSwap(pending, packagedApp)) {
|
|
198
203
|
const refreshed = await refreshMenubar(ctx, menubar);
|
|
199
|
-
|
|
204
|
+
const changes = refreshed.status === 'restarted' ? pending.map(c => ({ package: c.package, label: c.label, from: c.current, to: refreshed.version ?? null })) : [];
|
|
205
|
+
if (changes.length)
|
|
206
|
+
writeLastUpdate(ctx.store.dir, { status: 'ok', changes, message: `Updated ${changesText(changes)}`, command });
|
|
207
|
+
ctx.io.stdout.write(ctx.json ? json({ ...view, updated: changes.length > 0, ...(changes.length ? { changes } : {}), menubar: refreshed.status })
|
|
208
|
+
: changes.length ? `Updated ${changesText(changes)}.\n` : `Shardflux is up to date (${versions}).\n`);
|
|
200
209
|
return 0;
|
|
201
210
|
}
|
|
202
211
|
const argv = installArgv(install, specs, runtime ? npmFor(runtime.node) : 'npm');
|
|
@@ -269,7 +278,7 @@ async function refreshMenubar(ctx, menubar, runtime, run, env) {
|
|
|
269
278
|
}
|
|
270
279
|
else {
|
|
271
280
|
const packaged = await menubar.packagedVersion().catch(() => null);
|
|
272
|
-
if (!packaged || !before.version ||
|
|
281
|
+
if (!packaged || !before.version || compareAppVersions(before.version, packaged) >= 0)
|
|
273
282
|
return { status: 'current' };
|
|
274
283
|
await menubar.start(ctx.store.dir, ctx.apiUrl);
|
|
275
284
|
}
|
|
@@ -283,7 +292,16 @@ export function recordInstall(dir, runtime, distribution) {
|
|
|
283
292
|
script = realpathSync(script);
|
|
284
293
|
}
|
|
285
294
|
catch { /* keep the path */ }
|
|
286
|
-
|
|
295
|
+
// The app this CLI's npm package carries (0.15.2+): the menu bar compares itself with it and swaps itself in.
|
|
296
|
+
let menubarApp = packagedAppPath();
|
|
297
|
+
try {
|
|
298
|
+
if (menubarApp)
|
|
299
|
+
menubarApp = realpathSync(menubarApp);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
menubarApp = null;
|
|
303
|
+
}
|
|
304
|
+
const doc = { version: 1, node: runtime.node, script, package: distribution.package, package_version: distribution.version, cli_version: CLI_VERSION, ...(menubarApp ? { menubar_app: menubarApp } : {}) };
|
|
287
305
|
const text = `${JSON.stringify(doc, null, 2)}\n`;
|
|
288
306
|
try {
|
|
289
307
|
if (readFileSync(join(dir, INSTALL_FILE), 'utf8') === text)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shardflux/cli",
|
|
3
|
-
"version": "0.15.
|
|
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.
|
|
61
|
+
"@shardflux/menubar-darwin": "^0.3.2"
|
|
62
62
|
},
|
|
63
63
|
"scripts": {
|
|
64
64
|
"build": "node scripts/build.mjs",
|