@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.
- package/dist/commands/daemon/index.js +4 -1
- 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/help.d.ts +20 -1
- package/dist/help.js +26 -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 +23864 -23864
- 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
|
-
|
|
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
|
-
|
|
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
|
}
|
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
|
-
*
|
|
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
|
-
*
|
|
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>;
|