@skrr-ai/cli 0.1.11 → 0.1.13

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 (27) hide show
  1. package/dist/commands/daemon/index.js +4 -1
  2. package/dist/commands/daemon/install.d.ts +14 -0
  3. package/dist/commands/daemon/install.js +37 -1
  4. package/dist/commands/login.js +24 -1
  5. package/dist/help.d.ts +20 -1
  6. package/dist/help.js +26 -1
  7. package/dist/lib/daemon-installer.d.ts +62 -0
  8. package/dist/lib/daemon-installer.js +247 -0
  9. package/dist/lib/daemon-setup.d.ts +49 -0
  10. package/dist/lib/daemon-setup.js +103 -0
  11. package/dist/lib/daemonHandoff.d.ts +9 -0
  12. package/dist/lib/daemonHandoff.js +9 -2
  13. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +2 -0
  14. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +15 -1
  15. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/releaseKeys.d.ts +87 -0
  16. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/releaseKeys.js +94 -0
  17. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/releaseManifest.d.ts +161 -0
  18. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/releaseManifest.js +235 -0
  19. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +2 -0
  20. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +7 -0
  21. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/releaseKeys.d.ts +87 -0
  22. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/releaseKeys.js +91 -0
  23. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/releaseManifest.d.ts +161 -0
  24. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/releaseManifest.js +227 -0
  25. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  26. package/oclif.manifest.json +23864 -23864
  27. package/package.json +2 -2
@@ -10,7 +10,10 @@ const exec_oversky_1 = require("../../lib/exec-oversky");
10
10
  * quick install check.
11
11
  */
12
12
  class Daemon extends core_1.Command {
13
- static description = 'Manage the local skrr runtime. Wraps the `skrrd` binary.';
13
+ // "Wraps the `skrrd` binary" read, cold, as though you must already HAVE one —
14
+ // the opposite of what `skrr daemon install` does now that it fetches the
15
+ // signed release. Dogfooding 0.1.12 hit exactly that reading.
16
+ static description = 'Manage the local skrr runtime — install it, sign it in, and check on it.';
14
17
  static examples = [
15
18
  '<%= config.bin %> daemon install',
16
19
  '<%= config.bin %> daemon status',
@@ -10,6 +10,20 @@ import { Command } from '@oclif/core';
10
10
  * A thin proxy, like every other verb here: the service-management logic stays
11
11
  * in the runtime, which is the only component that knows its own service label
12
12
  * on each platform.
13
+ *
14
+ * WITH ONE ADDITION: it fetches the runtime first when there isn't one.
15
+ *
16
+ * Before that, this command refused with "install it with brew" whenever
17
+ * `skrrd` was absent — so the command whose entire job is to set the machine up
18
+ * required the machine to already be set up, and onboarding's first step was a
19
+ * second install from a second distribution channel, before the user had signed
20
+ * in or seen anything work. npm is the front door: `npm i -g @skrr-ai/cli`
21
+ * gives you `skrr`, and `skrr daemon install` gets the daemon.
22
+ *
23
+ * The download is verified against the same pinned ed25519 keys the daemon's
24
+ * own self-updater uses — see `daemon-installer.ts`. It is deliberately NOT a
25
+ * silent side effect: the fetch is announced, because a command that installs
26
+ * an executable should say so.
13
27
  */
14
28
  export default class DaemonInstall extends Command {
15
29
  static aliases: string[];
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const core_1 = require("@oclif/core");
4
4
  const exec_oversky_1 = require("../../lib/exec-oversky");
5
5
  const daemon_binding_1 = require("../../lib/daemon-binding");
6
+ const daemon_installer_1 = require("../../lib/daemon-installer");
6
7
  /**
7
8
  * `skrr daemon install` — install the local runtime's OS service.
8
9
  *
@@ -14,6 +15,20 @@ const daemon_binding_1 = require("../../lib/daemon-binding");
14
15
  * A thin proxy, like every other verb here: the service-management logic stays
15
16
  * in the runtime, which is the only component that knows its own service label
16
17
  * on each platform.
18
+ *
19
+ * WITH ONE ADDITION: it fetches the runtime first when there isn't one.
20
+ *
21
+ * Before that, this command refused with "install it with brew" whenever
22
+ * `skrrd` was absent — so the command whose entire job is to set the machine up
23
+ * required the machine to already be set up, and onboarding's first step was a
24
+ * second install from a second distribution channel, before the user had signed
25
+ * in or seen anything work. npm is the front door: `npm i -g @skrr-ai/cli`
26
+ * gives you `skrr`, and `skrr daemon install` gets the daemon.
27
+ *
28
+ * The download is verified against the same pinned ed25519 keys the daemon's
29
+ * own self-updater uses — see `daemon-installer.ts`. It is deliberately NOT a
30
+ * silent side effect: the fetch is announced, because a command that installs
31
+ * an executable should say so.
17
32
  */
18
33
  class DaemonInstall extends core_1.Command {
19
34
  // `skrr daemons <sub>` — the plural is advertised as an alias for the
@@ -34,6 +49,24 @@ class DaemonInstall extends core_1.Command {
34
49
  // own comment records `skrr code run -m <model>` dying the same way. Same
35
50
  // mistake, one directory over.
36
51
  const argv = this.argv;
52
+ // Bootstrap only when there is nothing to forward to. An existing runtime —
53
+ // brew, the script installer, a dev checkout — is left exactly alone; this
54
+ // is a floor under the missing case, not a second update channel competing
55
+ // with the daemon's own.
56
+ if (!(0, exec_oversky_1.findOverskyBinary)()) {
57
+ this.log('No local skrr runtime found. Fetching the signed release...');
58
+ const outcome = await (0, daemon_installer_1.installDaemon)();
59
+ if (!outcome.ok) {
60
+ this.log('');
61
+ this.log(` Could not install the runtime: ${outcome.error}`);
62
+ this.log('');
63
+ this.log(' You can install it directly instead:');
64
+ this.log(' macOS: brew install dush1023/oversky/oversky');
65
+ this.log(' Linux: curl -fsSL https://raw.githubusercontent.com/dush1023/OverSky/main/scripts/install-daemon.sh | bash');
66
+ this.exit(1);
67
+ }
68
+ this.log(` Installed skrrd ${outcome.version} (${outcome.platformKey}) at ${outcome.binaryPath}`);
69
+ }
37
70
  try {
38
71
  // Bind the new daemon to the deployment and profile the CLI is on,
39
72
  // for whichever of those the user did not state. Installing is the
@@ -45,7 +78,10 @@ class DaemonInstall extends core_1.Command {
45
78
  }
46
79
  catch (err) {
47
80
  if (err.message === 'oversky-not-installed') {
48
- this.log((0, exec_oversky_1.notInstalledMessage)());
81
+ // Reachable only if the runtime vanished between the bootstrap above
82
+ // and this line. Saying so beats repeating install instructions for
83
+ // something that was just installed.
84
+ this.log('The skrr runtime disappeared between installing it and running it.');
49
85
  this.exit(127);
50
86
  }
51
87
  throw err;
@@ -5,6 +5,7 @@ const data_provider_1 = require("@skrr-ai/data-provider");
5
5
  const auth_core_1 = require("@skrr-ai/auth-core");
6
6
  const base_command_1 = require("../base-command");
7
7
  const daemonHandoff_1 = require("../lib/daemonHandoff");
8
+ const daemon_setup_1 = require("../lib/daemon-setup");
8
9
  const config_1 = require("../lib/config");
9
10
  const keychain_1 = require("../lib/keychain");
10
11
  const login_1 = require("../lib/login");
@@ -179,7 +180,29 @@ class Login extends base_command_1.BaseCommand {
179
180
  // and a failure here must not fail it or discard what the user just earned.
180
181
  // It degrades to exactly the old behaviour, with a hint naming the command
181
182
  // that finishes the job.
182
- const handoff = await (0, daemonHandoff_1.handOffToLocalDaemon)(process.env, { loginFlow: result.flow });
183
+ let handoff = await (0, daemonHandoff_1.handOffToLocalDaemon)(process.env, { loginFlow: result.flow });
184
+ // Nothing to hand a credential TO. This is the first moment we can fix
185
+ // that correctly — installing the daemon binds it to a deployment and a
186
+ // profile, and until this login there was no session to take them from.
187
+ // Keyed off `reason` rather than the sentence in `detail`, which is
188
+ // written for a person and expected to be reworded.
189
+ if (handoff.reason === 'no-binary' || handoff.reason === 'no-service') {
190
+ const setup = await (0, daemon_setup_1.offerDaemonSetup)({
191
+ binaryPresent: handoff.reason === 'no-service',
192
+ log: (line) => this.log(line),
193
+ });
194
+ if (setup.status === 'installed') {
195
+ // Re-run rather than duplicating the mint here: it is the same act
196
+ // it always was, and now it has somewhere to land.
197
+ handoff = await (0, daemonHandoff_1.handOffToLocalDaemon)(process.env, { loginFlow: result.flow });
198
+ }
199
+ else if (setup.status === 'failed') {
200
+ this.log(`Could not set up the runtime: ${setup.detail}.`);
201
+ }
202
+ else {
203
+ this.log(`This machine is not set up — ${setup.detail}.`);
204
+ }
205
+ }
183
206
  if (handoff.status === 'delivered') {
184
207
  this.log(`The local daemon was signed in too — ${handoff.detail}.`);
185
208
  }
package/dist/help.d.ts CHANGED
@@ -20,8 +20,27 @@ import { Help } from '@oclif/core';
20
20
  * before running it. The audience most likely to need the good message was the
21
21
  * audience reliably denied it.
22
22
  *
23
- * Only the not-found path is overridden. Real help output is untouched.
23
+ * It also puts a GETTING STARTED block at the top of `skrr --help`.
24
+ *
25
+ * Dogfooding 0.1.12 found the root help teaching neither of the two commands
26
+ * the release exists to deliver: ~50 topics in alphabetical order, with `login`
27
+ * between `list-tokens` and `logout`, and nothing saying a runtime is needed or
28
+ * that `skrr` can fetch one. The shortest path to a working machine was
29
+ * undiscoverable from inside the tool that provides it — findable only by
30
+ * someone who read the docs, which is what the two-command flow exists to make
31
+ * unnecessary.
32
+ *
33
+ * Printed BEFORE the standard output rather than appended, because the thing it
34
+ * competes with is a fifty-line topic list. A signpost below that is a signpost
35
+ * for people who already knew.
24
36
  */
25
37
  export default class SkrrHelp extends Help {
38
+ /**
39
+ * The two commands, before the topic list rather than after it.
40
+ *
41
+ * Deliberately not a tutorial: two lines, both runnable, in the order a new
42
+ * machine needs them.
43
+ */
44
+ protected showRootHelp(): Promise<void>;
26
45
  showHelp(argv: string[]): Promise<void>;
27
46
  }
package/dist/help.js CHANGED
@@ -23,9 +23,34 @@ const command_miss_1 = require("./lib/command-miss");
23
23
  * before running it. The audience most likely to need the good message was the
24
24
  * audience reliably denied it.
25
25
  *
26
- * Only the not-found path is overridden. Real help output is untouched.
26
+ * It also puts a GETTING STARTED block at the top of `skrr --help`.
27
+ *
28
+ * Dogfooding 0.1.12 found the root help teaching neither of the two commands
29
+ * the release exists to deliver: ~50 topics in alphabetical order, with `login`
30
+ * between `list-tokens` and `logout`, and nothing saying a runtime is needed or
31
+ * that `skrr` can fetch one. The shortest path to a working machine was
32
+ * undiscoverable from inside the tool that provides it — findable only by
33
+ * someone who read the docs, which is what the two-command flow exists to make
34
+ * unnecessary.
35
+ *
36
+ * Printed BEFORE the standard output rather than appended, because the thing it
37
+ * competes with is a fifty-line topic list. A signpost below that is a signpost
38
+ * for people who already knew.
27
39
  */
28
40
  class SkrrHelp extends core_1.Help {
41
+ /**
42
+ * The two commands, before the topic list rather than after it.
43
+ *
44
+ * Deliberately not a tutorial: two lines, both runnable, in the order a new
45
+ * machine needs them.
46
+ */
47
+ async showRootHelp() {
48
+ this.log('GETTING STARTED');
49
+ this.log(' skrr login Sign in. Offers to set this machine up if it has no runtime.');
50
+ this.log(' skrr daemon install Install the runtime by hand (fetches the signed release).');
51
+ this.log('');
52
+ return super.showRootHelp();
53
+ }
29
54
  async showHelp(argv) {
30
55
  // Mirror oclif's own id resolution: the topic separator is a space here, so
31
56
  // the id is the leading non-flag tokens joined with `:`.
@@ -0,0 +1,62 @@
1
+ import { type DaemonVersionManifest, type ReleaseKey } from '@skrr-ai/auth-core';
2
+ /** Public CloudFront feed. Mirrors the daemon's own `CLOUDFRONT_FEED_BASE`. */
3
+ export declare const DEFAULT_DAEMON_FEED_BASE = "https://updates.oversky.ai/daemon";
4
+ /** Same override the daemon honours, so one env var steers both. */
5
+ export declare const DAEMON_FEED_BASE_ENV = "OVERSKY_DAEMON_UPDATE_FEED_BASE";
6
+ export type DaemonInstallFailureCode = 'unsupported_platform' | 'manifest_unreachable' | 'manifest_malformed' | 'manifest_unsigned' | 'platform_missing' | 'untrusted_artifact_origin' | 'download_failed' | 'checksum_mismatch' | 'artifact_unsigned' | 'write_failed';
7
+ export type DaemonInstallResult = {
8
+ ok: true;
9
+ version: string;
10
+ platformKey: string;
11
+ binaryPath: string;
12
+ } | {
13
+ ok: false;
14
+ code: DaemonInstallFailureCode;
15
+ error: string;
16
+ };
17
+ export interface DaemonInstallOptions {
18
+ env?: NodeJS.ProcessEnv;
19
+ /** Override the feed root. Tests point this at a local fixture server. */
20
+ feedBase?: string;
21
+ /** Injected for tests; defaults to global fetch. */
22
+ fetchImpl?: typeof fetch;
23
+ /**
24
+ * Trust anchors. Defaults to the pinned production set; tests substitute a
25
+ * generated keypair, exactly as the sky-code installer does. Passing an EMPTY
26
+ * set does not disable verification here — every non-`ok` verdict is a
27
+ * refusal, so an unconfigured trust root turns installs off rather than
28
+ * turning checking off.
29
+ */
30
+ keys?: readonly ReleaseKey[];
31
+ }
32
+ export declare function daemonFeedBase(env?: NodeJS.ProcessEnv): string;
33
+ /**
34
+ * Where the fetched binary lands. `exec-oversky.ts` already looks here, so a
35
+ * daemon installed by this path is found by every `skrr daemon *` command
36
+ * without the user touching PATH.
37
+ */
38
+ export declare function daemonBinaryPath(env?: NodeJS.ProcessEnv): string;
39
+ /**
40
+ * `<platform>-<arch>`, the manifest's `platforms` key. Returns null for a
41
+ * combination the release does not build, which is a clearer refusal than a
42
+ * missing-key lookup one step later.
43
+ */
44
+ export declare function daemonPlatformKey(platform?: string, arch?: string): string | null;
45
+ /**
46
+ * True when `candidate` may serve an artifact for a feed rooted at `feedBase`.
47
+ * Same rule as the daemon's `isTrustedArtifactOrigin`: a signed manifest still
48
+ * does not get to point the download at another origin.
49
+ */
50
+ export declare function isTrustedArtifactOrigin(candidate: string, feedBase: string): boolean;
51
+ /** Fetch `<feedBase>/latest/daemon-version.json` and verify its signature. */
52
+ export declare function fetchVerifiedDaemonManifest(opts?: DaemonInstallOptions): Promise<{
53
+ ok: true;
54
+ manifest: DaemonVersionManifest;
55
+ } | (DaemonInstallResult & {
56
+ ok: false;
57
+ })>;
58
+ /**
59
+ * Download, verify and install the daemon binary. Returns a tagged result; it
60
+ * never throws, so the caller can render one refusal for every failure mode.
61
+ */
62
+ export declare function installDaemon(opts?: DaemonInstallOptions): Promise<DaemonInstallResult>;
@@ -0,0 +1,247 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DAEMON_FEED_BASE_ENV = exports.DEFAULT_DAEMON_FEED_BASE = void 0;
7
+ exports.daemonFeedBase = daemonFeedBase;
8
+ exports.daemonBinaryPath = daemonBinaryPath;
9
+ exports.daemonPlatformKey = daemonPlatformKey;
10
+ exports.isTrustedArtifactOrigin = isTrustedArtifactOrigin;
11
+ exports.fetchVerifiedDaemonManifest = fetchVerifiedDaemonManifest;
12
+ exports.installDaemon = installDaemon;
13
+ /**
14
+ * daemon-installer.ts — fetch, verify and place the signed `skrrd` binary.
15
+ *
16
+ * ## Why this exists
17
+ *
18
+ * `skrr daemon install` forwarded to `skrrd install`, and `findOverskyBinary()`
19
+ * refused with "install it with brew" when there was no `skrrd` to forward to.
20
+ * So the one command whose job is to set the machine up required the machine to
21
+ * already be set up, and onboarding's first step was a second install from a
22
+ * second distribution channel — before the user had signed in or seen anything
23
+ * work.
24
+ *
25
+ * npm is the front door now. `npm i -g @skrr-ai/cli` gives you `skrr`, and
26
+ * `skrr daemon install` gets the daemon itself.
27
+ *
28
+ * ## Why it downloads a binary instead of shipping one
29
+ *
30
+ * The daemon is a compiled per-platform executable that is codesigned,
31
+ * notarized, and auto-updates itself in the field from a signed CloudFront feed
32
+ * with rollout cohorts. Vendoring five platform binaries into an npm tarball
33
+ * would bloat every install to serve one platform, and — worse — would freeze a
34
+ * copy that the daemon's own updater cannot replace, because that updater swaps
35
+ * a binary in its release slot, not a file inside a node_modules tree. Fetching
36
+ * from the same feed the updater uses keeps ONE distribution channel for the
37
+ * daemon and leaves its update story intact.
38
+ *
39
+ * ## Trust
40
+ *
41
+ * This is the CLI downloading an executable and putting it on disk, so every
42
+ * gate the daemon's own self-updater applies is applied here too, against the
43
+ * SAME pinned keys (`@skrr-ai/auth-core`, moved there for this reason — a
44
+ * mirrored trust root is a second array that can be emptied):
45
+ *
46
+ * 1. the manifest's ed25519 signature, fail-closed;
47
+ * 2. the artifact URL must share the feed's origin. The signature already
48
+ * covers the URL, so this is not about forgery — it bounds where a
49
+ * LEGITIMATELY signed manifest can send us, which is what protects against
50
+ * operator error and against a signer coerced into signing a redirect;
51
+ * 3. sha256 of the received bytes;
52
+ * 4. the artifact's own ed25519 signature over (platform, version, sha256, url).
53
+ *
54
+ * TLS alone is explicitly not enough: the sha256 travels in the same document
55
+ * over the same channel, so it defends against corruption in transit, not
56
+ * against a spoofed feed. (3) without (4) would be the same mistake.
57
+ *
58
+ * The write is staged then renamed, so an interrupted download can never leave
59
+ * a half-written executable at the path the CLI will later exec.
60
+ */
61
+ const node_crypto_1 = require("node:crypto");
62
+ const node_fs_1 = require("node:fs");
63
+ const node_os_1 = __importDefault(require("node:os"));
64
+ const node_path_1 = __importDefault(require("node:path"));
65
+ const auth_core_1 = require("@skrr-ai/auth-core");
66
+ /** Public CloudFront feed. Mirrors the daemon's own `CLOUDFRONT_FEED_BASE`. */
67
+ exports.DEFAULT_DAEMON_FEED_BASE = 'https://updates.oversky.ai/daemon';
68
+ /** Same override the daemon honours, so one env var steers both. */
69
+ exports.DAEMON_FEED_BASE_ENV = 'OVERSKY_DAEMON_UPDATE_FEED_BASE';
70
+ /** Refuse a manifest larger than this. It is a small JSON document. */
71
+ const MAX_MANIFEST_BYTES = 1_024 * 1_024;
72
+ /** Refuse an artifact larger than this regardless of what the manifest claims. */
73
+ const MAX_ARTIFACT_BYTES = 256 * 1_024 * 1_024;
74
+ const MANIFEST_TIMEOUT_MS = 30_000;
75
+ const ARTIFACT_TIMEOUT_MS = 10 * 60_000;
76
+ function daemonFeedBase(env = process.env) {
77
+ return (env[exports.DAEMON_FEED_BASE_ENV]?.trim() || exports.DEFAULT_DAEMON_FEED_BASE).replace(/\/+$/, '');
78
+ }
79
+ /**
80
+ * Where the fetched binary lands. `exec-oversky.ts` already looks here, so a
81
+ * daemon installed by this path is found by every `skrr daemon *` command
82
+ * without the user touching PATH.
83
+ */
84
+ function daemonBinaryPath(env = process.env) {
85
+ const home = env.HOME || env.USERPROFILE || node_os_1.default.homedir();
86
+ const exe = process.platform === 'win32' ? 'skrrd.exe' : 'skrrd';
87
+ return node_path_1.default.join(home, '.skrr', 'bin', exe);
88
+ }
89
+ /**
90
+ * `<platform>-<arch>`, the manifest's `platforms` key. Returns null for a
91
+ * combination the release does not build, which is a clearer refusal than a
92
+ * missing-key lookup one step later.
93
+ */
94
+ function daemonPlatformKey(platform = process.platform, arch = process.arch) {
95
+ const supported = new Set([
96
+ 'darwin-arm64',
97
+ 'darwin-x64',
98
+ 'linux-arm64',
99
+ 'linux-x64',
100
+ 'win32-x64',
101
+ ]);
102
+ const key = `${platform}-${arch}`;
103
+ return supported.has(key) ? key : null;
104
+ }
105
+ /**
106
+ * True when `candidate` may serve an artifact for a feed rooted at `feedBase`.
107
+ * Same rule as the daemon's `isTrustedArtifactOrigin`: a signed manifest still
108
+ * does not get to point the download at another origin.
109
+ */
110
+ function isTrustedArtifactOrigin(candidate, feedBase) {
111
+ try {
112
+ return new URL(candidate).origin === new URL(feedBase).origin;
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ }
118
+ function fail(code, error) {
119
+ return { ok: false, code, error };
120
+ }
121
+ async function getWithTimeout(url, timeoutMs, fetchImpl) {
122
+ const controller = new AbortController();
123
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
124
+ try {
125
+ return await fetchImpl(url, {
126
+ signal: controller.signal,
127
+ headers: { 'User-Agent': 'skrr-cli (daemon install)' },
128
+ });
129
+ }
130
+ finally {
131
+ clearTimeout(timer);
132
+ }
133
+ }
134
+ /** Fetch `<feedBase>/latest/daemon-version.json` and verify its signature. */
135
+ async function fetchVerifiedDaemonManifest(opts = {}) {
136
+ const env = opts.env ?? process.env;
137
+ const feedBase = opts.feedBase ?? daemonFeedBase(env);
138
+ const fetchImpl = opts.fetchImpl ?? fetch;
139
+ const keys = opts.keys ?? auth_core_1.OVERSKY_RELEASE_PUBLIC_KEYS;
140
+ const url = `${feedBase}/latest/daemon-version.json`;
141
+ let res;
142
+ try {
143
+ res = await getWithTimeout(url, MANIFEST_TIMEOUT_MS, fetchImpl);
144
+ }
145
+ catch (err) {
146
+ return fail('manifest_unreachable', `${url}: ${err.message}`);
147
+ }
148
+ if (!res.ok) {
149
+ return fail('manifest_unreachable', `${url} returned HTTP ${res.status}`);
150
+ }
151
+ const text = await res.text();
152
+ if (text.length > MAX_MANIFEST_BYTES) {
153
+ return fail('manifest_malformed', 'release manifest is implausibly large');
154
+ }
155
+ let manifest;
156
+ try {
157
+ manifest = JSON.parse(text);
158
+ }
159
+ catch {
160
+ return fail('manifest_malformed', `${url} did not return JSON`);
161
+ }
162
+ if (!manifest || typeof manifest.version !== 'string' || !manifest.platforms) {
163
+ return fail('manifest_malformed', 'release manifest is missing version/platforms');
164
+ }
165
+ // Fail-closed by construction: the pinned key set is non-empty, so `disabled`
166
+ // cannot be reached here. Treating it as a refusal anyway means emptying that
167
+ // array turns installs OFF rather than turning verification off silently.
168
+ const verdict = (0, auth_core_1.verifyManifest)(manifest, keys);
169
+ if (!('ok' in verdict) || verdict.ok !== true) {
170
+ const reason = 'reason' in verdict ? verdict.reason : 'no release signing key is pinned';
171
+ return fail('manifest_unsigned', `release manifest failed verification: ${reason}`);
172
+ }
173
+ return { ok: true, manifest };
174
+ }
175
+ /**
176
+ * Download, verify and install the daemon binary. Returns a tagged result; it
177
+ * never throws, so the caller can render one refusal for every failure mode.
178
+ */
179
+ async function installDaemon(opts = {}) {
180
+ const env = opts.env ?? process.env;
181
+ const feedBase = opts.feedBase ?? daemonFeedBase(env);
182
+ const fetchImpl = opts.fetchImpl ?? fetch;
183
+ const keys = opts.keys ?? auth_core_1.OVERSKY_RELEASE_PUBLIC_KEYS;
184
+ const platformKey = daemonPlatformKey();
185
+ if (!platformKey) {
186
+ return fail('unsupported_platform', `no daemon build for ${process.platform}-${process.arch}`);
187
+ }
188
+ const fetched = await fetchVerifiedDaemonManifest({ env, feedBase, fetchImpl, keys });
189
+ if (!('manifest' in fetched))
190
+ return fetched;
191
+ const { manifest } = fetched;
192
+ const artifact = manifest.platforms[platformKey];
193
+ if (!artifact?.url || !artifact.sha256) {
194
+ return fail('platform_missing', `release ${manifest.version} publishes no artifact for ${platformKey}`);
195
+ }
196
+ if (!isTrustedArtifactOrigin(artifact.url, feedBase)) {
197
+ return fail('untrusted_artifact_origin', `manifest points ${platformKey} at ${artifact.url}, which is not on the feed's origin`);
198
+ }
199
+ const artifactVerdict = (0, auth_core_1.verifyArtifact)(platformKey, manifest.version, artifact, keys);
200
+ if (!('ok' in artifactVerdict) || artifactVerdict.ok !== true) {
201
+ const reason = 'reason' in artifactVerdict ? artifactVerdict.reason : 'no key pinned';
202
+ return fail('artifact_unsigned', `${platformKey} artifact failed verification: ${reason}`);
203
+ }
204
+ if (typeof artifact.size === 'number' && artifact.size > MAX_ARTIFACT_BYTES) {
205
+ return fail('download_failed', `manifest declares an implausible size (${artifact.size} bytes)`);
206
+ }
207
+ let bytes;
208
+ try {
209
+ const res = await getWithTimeout(artifact.url, ARTIFACT_TIMEOUT_MS, fetchImpl);
210
+ if (!res.ok) {
211
+ return fail('download_failed', `${artifact.url} returned HTTP ${res.status}`);
212
+ }
213
+ const buf = Buffer.from(await res.arrayBuffer());
214
+ if (buf.byteLength > MAX_ARTIFACT_BYTES) {
215
+ return fail('download_failed', 'downloaded artifact exceeds the size ceiling');
216
+ }
217
+ bytes = buf;
218
+ }
219
+ catch (err) {
220
+ return fail('download_failed', `${artifact.url}: ${err.message}`);
221
+ }
222
+ const actual = (0, node_crypto_1.createHash)('sha256').update(bytes).digest('hex');
223
+ if (actual !== artifact.sha256.toLowerCase()) {
224
+ return fail('checksum_mismatch', `sha256 mismatch for ${platformKey}: expected ${artifact.sha256}, received ${actual}`);
225
+ }
226
+ // Staged then renamed. A rename within one directory is atomic, so the path
227
+ // the CLI later execs is either the previous binary or the fully-written new
228
+ // one — never a truncated download wearing the executable bit.
229
+ const binaryPath = daemonBinaryPath(env);
230
+ const stagingPath = `${binaryPath}.staging-${process.pid}`;
231
+ try {
232
+ (0, node_fs_1.mkdirSync)(node_path_1.default.dirname(binaryPath), { recursive: true });
233
+ (0, node_fs_1.writeFileSync)(stagingPath, bytes, { mode: 0o755 });
234
+ (0, node_fs_1.chmodSync)(stagingPath, 0o755);
235
+ (0, node_fs_1.renameSync)(stagingPath, binaryPath);
236
+ }
237
+ catch (err) {
238
+ try {
239
+ (0, node_fs_1.rmSync)(stagingPath, { force: true });
240
+ }
241
+ catch {
242
+ /* best-effort staging cleanup */
243
+ }
244
+ return fail('write_failed', `could not install to ${binaryPath}: ${err.message}`);
245
+ }
246
+ return { ok: true, version: manifest.version, platformKey, binaryPath };
247
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * daemon-setup.ts — offer to set this machine up, at the moment we can.
3
+ *
4
+ * ## Why login and not `npm install`
5
+ *
6
+ * The obvious way to remove a step is a `postinstall` that installs the daemon
7
+ * when the CLI is installed. It cannot work, and the reason is specific rather
8
+ * than aesthetic: installing the daemon BINDS it to a deployment and profile,
9
+ * taken from the CLI's session (`bindToCliSession`, OSK-279 — a daemon on a
10
+ * different deployment than its CLI is the failure that binding exists to
11
+ * prevent). At `npm install` time there is no session.
12
+ *
13
+ * It would not even fail loudly. With no config on disk `loadConfig()` returns
14
+ * DEFAULT_CONFIG, whose baseURL is the DEV deployment — so a postinstall would
15
+ * quietly bind the machine to dev, and the first `skrr login` against
16
+ * production would produce exactly the split. That is the default path on a
17
+ * clean machine, not an edge case.
18
+ *
19
+ * Login is the first moment the answer exists. It is also where the credential
20
+ * handoff already runs, so this is not a new path through the program; it is
21
+ * the branch where that path used to give up.
22
+ *
23
+ * ## Why it asks
24
+ *
25
+ * Signing in and "run a process on this machine that can execute bash, read and
26
+ * write files, and drive a browser" are not the same size of decision. Doing
27
+ * the second as a silent consequence of the first is the class of surprise this
28
+ * whole area has been removing. So: asked when there is someone to ask, skipped
29
+ * with an instruction when there is not, and never on the opt-out.
30
+ */
31
+ import { confirm } from './prompt';
32
+ export interface DaemonSetupOutcome {
33
+ status: 'installed' | 'declined' | 'skipped' | 'failed';
34
+ detail: string;
35
+ }
36
+ export interface DaemonSetupOptions {
37
+ env?: NodeJS.ProcessEnv;
38
+ /** True when only the OS service is missing and the binary is already here. */
39
+ binaryPresent?: boolean;
40
+ /** Injected by tests. */
41
+ confirmImpl?: typeof confirm;
42
+ interactive?: boolean;
43
+ log?: (line: string) => void;
44
+ }
45
+ /**
46
+ * Ask, then install. Returns without touching anything when there is nobody to
47
+ * ask or the operator has opted out.
48
+ */
49
+ export declare function offerDaemonSetup(opts?: DaemonSetupOptions): Promise<DaemonSetupOutcome>;