@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.
- package/dist/commands/daemon/install.d.ts +14 -0
- package/dist/commands/daemon/install.js +37 -1
- package/dist/commands/login.js +24 -1
- package/dist/lib/daemon-installer.d.ts +62 -0
- package/dist/lib/daemon-installer.js +247 -0
- package/dist/lib/daemon-setup.d.ts +49 -0
- package/dist/lib/daemon-setup.js +103 -0
- package/dist/lib/daemonHandoff.d.ts +9 -0
- package/dist/lib/daemonHandoff.js +9 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +2 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +15 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/releaseKeys.d.ts +87 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/releaseKeys.js +94 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/releaseManifest.d.ts +161 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/releaseManifest.js +235 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +2 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +7 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/releaseKeys.d.ts +87 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/releaseKeys.js +91 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/releaseManifest.d.ts +161 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/releaseManifest.js +227 -0
- package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
- package/oclif.manifest.json +16337 -16337
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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;
|
package/dist/commands/login.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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';
|