@skrr-ai/cli 0.1.11 → 0.1.12

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.
@@ -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
  }
@@ -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>;
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.offerDaemonSetup = offerDaemonSetup;
4
+ /**
5
+ * daemon-setup.ts — offer to set this machine up, at the moment we can.
6
+ *
7
+ * ## Why login and not `npm install`
8
+ *
9
+ * The obvious way to remove a step is a `postinstall` that installs the daemon
10
+ * when the CLI is installed. It cannot work, and the reason is specific rather
11
+ * than aesthetic: installing the daemon BINDS it to a deployment and profile,
12
+ * taken from the CLI's session (`bindToCliSession`, OSK-279 — a daemon on a
13
+ * different deployment than its CLI is the failure that binding exists to
14
+ * prevent). At `npm install` time there is no session.
15
+ *
16
+ * It would not even fail loudly. With no config on disk `loadConfig()` returns
17
+ * DEFAULT_CONFIG, whose baseURL is the DEV deployment — so a postinstall would
18
+ * quietly bind the machine to dev, and the first `skrr login` against
19
+ * production would produce exactly the split. That is the default path on a
20
+ * clean machine, not an edge case.
21
+ *
22
+ * Login is the first moment the answer exists. It is also where the credential
23
+ * handoff already runs, so this is not a new path through the program; it is
24
+ * the branch where that path used to give up.
25
+ *
26
+ * ## Why it asks
27
+ *
28
+ * Signing in and "run a process on this machine that can execute bash, read and
29
+ * write files, and drive a browser" are not the same size of decision. Doing
30
+ * the second as a silent consequence of the first is the class of surprise this
31
+ * whole area has been removing. So: asked when there is someone to ask, skipped
32
+ * with an instruction when there is not, and never on the opt-out.
33
+ */
34
+ const prompt_1 = require("./prompt");
35
+ const daemon_binding_1 = require("./daemon-binding");
36
+ const exec_oversky_1 = require("./exec-oversky");
37
+ const daemon_installer_1 = require("./daemon-installer");
38
+ const ssh_detect_1 = require("./ssh-detect");
39
+ /**
40
+ * Ask, then install. Returns without touching anything when there is nobody to
41
+ * ask or the operator has opted out.
42
+ */
43
+ async function offerDaemonSetup(opts = {}) {
44
+ const env = opts.env ?? process.env;
45
+ const log = opts.log ?? ((line) => console.log(line));
46
+ // The same switch that turns the credential handoff off. An operator who has
47
+ // said "do not touch my daemon" has not asked a narrower question, and a
48
+ // second variable for the same intent is a second thing to remember.
49
+ if (env.OVERSKY_SKIP_DAEMON_HANDOFF === '1' || env.SKRR_SKIP_DAEMON_HANDOFF === '1') {
50
+ return { status: 'skipped', detail: 'skipped by OVERSKY_SKIP_DAEMON_HANDOFF' };
51
+ }
52
+ const interactive = opts.interactive ?? !(0, ssh_detect_1.isNonInteractive)();
53
+ if (!interactive) {
54
+ // A prompt with no terminal hangs forever, which is worse than the extra
55
+ // step. Say which command does it instead.
56
+ return {
57
+ status: 'skipped',
58
+ detail: 'no terminal to ask — run `skrr daemon install` to set this machine up',
59
+ };
60
+ }
61
+ const confirmImpl = opts.confirmImpl ?? prompt_1.confirm;
62
+ log('');
63
+ log(' This machine has no skrr runtime. It is what lets your agents work here —');
64
+ log(' running commands, editing files, and driving a browser on this machine.');
65
+ const yes = await confirmImpl(' Set it up now?', false);
66
+ if (!yes) {
67
+ return {
68
+ status: 'declined',
69
+ detail: 'run `skrr daemon install` when you want it',
70
+ };
71
+ }
72
+ // Fetch the signed binary only when there is none. When the binary is already
73
+ // present and only the service is missing, this step is skipped entirely —
74
+ // downloading over a working runtime is not what "set it up" asked for.
75
+ const binaryPresent = opts.binaryPresent ?? Boolean((0, exec_oversky_1.findOverskyBinary)());
76
+ if (!binaryPresent) {
77
+ log(' Fetching the signed release...');
78
+ const fetched = await (0, daemon_installer_1.installDaemon)({ env });
79
+ if (!fetched.ok) {
80
+ return { status: 'failed', detail: fetched.error };
81
+ }
82
+ log(` Installed skrrd ${fetched.version} (${fetched.platformKey})`);
83
+ }
84
+ // Register the OS service, bound to the deployment and profile of the session
85
+ // that just authenticated — which is the whole reason this runs here and not
86
+ // at install time.
87
+ try {
88
+ const code = await (0, exec_oversky_1.execOversky)(['install', ...(0, daemon_binding_1.bindToCliSession)([])]);
89
+ if (code !== 0) {
90
+ return { status: 'failed', detail: `\`skrrd install\` exited ${code}` };
91
+ }
92
+ }
93
+ catch (err) {
94
+ if (err.message === 'oversky-not-installed') {
95
+ return {
96
+ status: 'failed',
97
+ detail: 'the runtime vanished between fetching and installing it',
98
+ };
99
+ }
100
+ return { status: 'failed', detail: err.message };
101
+ }
102
+ return { status: 'installed', detail: 'this machine is set up' };
103
+ }
@@ -32,6 +32,15 @@
32
32
  export interface DaemonHandoffOutcome {
33
33
  status: 'delivered' | 'skipped' | 'failed';
34
34
  detail: string;
35
+ /**
36
+ * Why it skipped, for a caller that must act on the difference.
37
+ *
38
+ * `detail` is a sentence for a human and is expected to be reworded; the
39
+ * caller deciding whether to OFFER TO INSTALL a daemon cannot key off that.
40
+ * The two absent cases are separated because they need different work — one
41
+ * needs a binary fetched, the other only needs the service registered.
42
+ */
43
+ reason?: 'opted-out' | 'brokered' | 'no-binary' | 'unsupported-daemon' | 'no-service';
35
44
  }
36
45
  export declare function handOffToLocalDaemon(env?: NodeJS.ProcessEnv, opts?: {
37
46
  loginFlow?: string;
@@ -173,7 +173,11 @@ async function handOffToLocalDaemon(env = process.env, opts = {}) {
173
173
  // deliberately running a daemon against a different deployment than their CLI is
174
174
  // doing a legitimate thing, and this must not overrule it.
175
175
  if (env.OVERSKY_SKIP_DAEMON_HANDOFF === '1' || env.SKRR_SKIP_DAEMON_HANDOFF === '1') {
176
- return { status: 'skipped', detail: 'skipped by OVERSKY_SKIP_DAEMON_HANDOFF' };
176
+ return {
177
+ status: 'skipped',
178
+ reason: 'opted-out',
179
+ detail: 'skipped by OVERSKY_SKIP_DAEMON_HANDOFF',
180
+ };
177
181
  }
178
182
  // The daemon brokered this login, which means it just used its OWN credential
179
183
  // against this server and that credential works. There is nothing to fix.
@@ -186,12 +190,13 @@ async function handOffToLocalDaemon(env = process.env, opts = {}) {
186
190
  if (opts.loginFlow === 'daemon-broker') {
187
191
  return {
188
192
  status: 'skipped',
193
+ reason: 'brokered',
189
194
  detail: 'the local daemon brokered this login, so it already has a working credential',
190
195
  };
191
196
  }
192
197
  const binary = (0, exec_oversky_1.findOverskyBinary)();
193
198
  if (!binary) {
194
- return { status: 'skipped', detail: 'no local daemon installed' };
199
+ return { status: 'skipped', reason: 'no-binary', detail: 'no local daemon installed' };
195
200
  }
196
201
  // An older daemon has no `accept-handoff`, and spawning it would fail with
197
202
  // commander's "unknown command" rather than anything a reader could act on. A
@@ -201,6 +206,7 @@ async function handOffToLocalDaemon(env = process.env, opts = {}) {
201
206
  if (supported === false) {
202
207
  return {
203
208
  status: 'skipped',
209
+ reason: 'unsupported-daemon',
204
210
  detail: 'the installed daemon predates `accept-handoff`; run `skrr daemon login` once',
205
211
  };
206
212
  }
@@ -233,6 +239,7 @@ async function handOffToLocalDaemon(env = process.env, opts = {}) {
233
239
  if (idSource === 'cwd') {
234
240
  return {
235
241
  status: 'skipped',
242
+ reason: 'no-service',
236
243
  detail: 'no daemon service is installed on this machine',
237
244
  };
238
245
  }
@@ -36,4 +36,6 @@ export { sanitizeSpawnEnv, SENSITIVE_ENV_VARS, SENSITIVE_ENV_PREFIXES, SENSITIVE
36
36
  export { CONFIG_DIR_NAME, LEGACY_CONFIG_DIR_NAMES, NATIVE_ID_PREFIX, LEGACY_NATIVE_ID_PREFIXES, DEFAULT_BINARY_NAME, } from './localIdentity.js';
37
37
  export { findLegacyLocalState, describeLegacyState, type LegacyStateFinding, type LegacyStateReport, } from './legacyStatePreflight.js';
38
38
  export { DEFAULT_SKY_CODE_CHANNEL, DEFAULT_SKY_CODE_FEED_BASE, SKY_CODE_CHANNELS, SKY_CODE_CHANNEL_ENV, SKY_CODE_MANIFEST_FILE, isSkyCodeChannel, resolveChannelManifestUrl, resolveSkyCodeChannel, skyCodeChannelPrefix, skyCodeFeedBase, SKY_CODE_FEED_BASE_ENV, type SkyCodeChannel, } from './skyCodeChannels.js';
39
+ export { OVERSKY_RELEASE_PUBLIC_KEYS, type ReleaseKey } from './releaseKeys.js';
40
+ export { MAX_SIGS, buildArtifactMessage, buildManifestMessage, getDeltaFor, verifyArtifact, verifyManifest, type DaemonVersionManifest, type DeltaDescriptor, type ManifestSignature, type PlatformArtifact, type VerifyResult, } from './releaseManifest.js';
39
41
  export { resolveConfigRoot, configRootPath, isConfigRootOverridden } from './configRoot.js';