@sibylline-labs/eris 0.0.0-stage → 0.3.0
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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +146 -2
- package/bin/eris.ts +42 -0
- package/docs/ARCHITECTURE.md +188 -0
- package/docs/COMPLETION.md +106 -0
- package/docs/ERRORS.md +632 -0
- package/docs/OUTPUT-CONTRACT.md +219 -0
- package/docs/README.md +30 -0
- package/docs/UPDATE.md +159 -0
- package/docs/YAK.md +395 -0
- package/docs/dev/ADDING-A-COMMAND.md +79 -0
- package/docs/dev/DEVELOPMENT.md +88 -0
- package/docs/dev/LAYOUT.md +74 -0
- package/docs/dev/PLUGINS.md +382 -0
- package/docs/dev/RELEASING.md +157 -0
- package/docs/dev/TESTING.md +84 -0
- package/docs/dev/surfaces/secrets.md +95 -0
- package/package.json +55 -4
- package/packages/yak-contract/package.json +28 -0
- package/packages/yak-contract/schema/yak-v2.schema.json +416 -0
- package/packages/yak-contract/src/index.ts +1 -0
- package/packages/yak-contract/src/v2.ts +375 -0
- package/plugins/index.ts +12 -0
- package/plugins/yak/handlers.ts +128 -0
- package/plugins/yak/index.ts +51 -0
- package/plugins/yak/login.ts +300 -0
- package/plugins/yak/yak-context.ts +280 -0
- package/plugins/yak/yak-identity.ts +399 -0
- package/plugins/yak/yak-redact.ts +686 -0
- package/plugins/yak/yak-setup.ts +133 -0
- package/plugins/yak/yak-submit.ts +1166 -0
- package/plugins/yak/yak.ts +485 -0
- package/src/build.ts +248 -0
- package/src/cli/args.ts +434 -0
- package/src/cli/commands/completion/bash.ts +24 -0
- package/src/cli/commands/completion/domain.ts +203 -0
- package/src/cli/commands/completion/emit.ts +246 -0
- package/src/cli/commands/completion/shell.ts +24 -0
- package/src/cli/commands/completion/zsh.ts +24 -0
- package/src/cli/commands/completion.ts +48 -0
- package/src/cli/commands/update/github.ts +328 -0
- package/src/cli/commands/update/opt-in.ts +32 -0
- package/src/cli/commands/update/refusal.ts +21 -0
- package/src/cli/commands/update/release.ts +351 -0
- package/src/cli/commands/update/replace.ts +143 -0
- package/src/cli/commands/update/semver.ts +48 -0
- package/src/cli/commands/update.ts +47 -0
- package/src/cli/commands/usage.ts +68 -0
- package/src/cli/commands/version.ts +29 -0
- package/src/cli/compose.ts +273 -0
- package/src/cli/output.ts +81 -0
- package/src/cli/plugin.ts +85 -0
- package/src/cli/registry.ts +180 -0
- package/src/cli/router.ts +31 -0
- package/src/cli/ts-verb.ts +126 -0
- package/src/main.ts +223 -0
- package/src/runtime/env.ts +16 -0
- package/src/runtime/eris-assets.d.ts +5 -0
- package/src/runtime/home.ts +306 -0
- package/src/runtime/logging.ts +43 -0
- package/src/runtime/process.ts +388 -0
- package/src/runtime/settings.ts +191 -0
- package/src/sdk.ts +62 -0
- package/testing/build-child.ts +55 -0
- package/testing/fake-runner.ts +23 -0
- package/testing/index.ts +16 -0
- package/ts/lib/eris-fail.ts +180 -0
- package/ts/lib/pool.ts +63 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* release.ts — `eris update [--check] [--to <tag>]` for a distribution that
|
|
3
|
+
* publishes compiled binaries to GitHub releases: replace this binary with a
|
|
4
|
+
* release of the repository it was built from (`releaseRepo()`, baked in by
|
|
5
|
+
* `build()`), so a distribution's binary never takes another's for its own.
|
|
6
|
+
* Core's own `update` only prints the npm commands (../update.ts); a
|
|
7
|
+
* distribution opts into this verb with `RELEASE_UPDATE` (./opt-in.ts).
|
|
8
|
+
*
|
|
9
|
+
* 1. Refuse, before any request, what cannot be updated in place: a source
|
|
10
|
+
* checkout, a platform with no release binary, a bad `ERIS_UPDATE_API`, a
|
|
11
|
+
* directory that cannot be written.
|
|
12
|
+
* 2. Ask the releases API for the latest release (or `--to`'s tag). A
|
|
13
|
+
* prerelease is installed only when named; `--to` may also go backwards.
|
|
14
|
+
* 3. Read the release's `SHA256SUMS`, download this platform's `eris-<os>-<arch>`
|
|
15
|
+
* into a temp file beside the binary, hash it as it streams, and refuse a
|
|
16
|
+
* mismatch.
|
|
17
|
+
* 4. Rename it over the binary (keeping `<binary>.old`), run it once
|
|
18
|
+
* (`--output json --version` must report the release's tag) and restore the
|
|
19
|
+
* old one if it does not. Success removes `.old`.
|
|
20
|
+
*
|
|
21
|
+
* Exit codes: 0 done or already current; 1 refused (a failure block). With
|
|
22
|
+
* `--check`, 0 current, 10 an update is available, 1 could not tell. The record
|
|
23
|
+
* under `--output json` is `{current, latest, update_available, updated}`.
|
|
24
|
+
*
|
|
25
|
+
* The network and the file work are in ./update/. Everything the verb learns
|
|
26
|
+
* about its surroundings comes from an `UpdateHost`, so a test runs the real
|
|
27
|
+
* thing against a loopback server and a stand-in binary.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
31
|
+
import { realpathSync } from "node:fs";
|
|
32
|
+
import { renderFailureBlock } from "../../../../ts/lib/eris-fail";
|
|
33
|
+
import type { VerbContext, VerbHandler } from "../../../main";
|
|
34
|
+
import { isCompiled, releaseRepo, VERSION } from "../../../runtime/home";
|
|
35
|
+
import { readEnv } from "../../../runtime/env";
|
|
36
|
+
import { info, ok } from "../../../runtime/logging";
|
|
37
|
+
import { nodeRunner, type Runner } from "../../../runtime/process";
|
|
38
|
+
import { readRowArgs, usageLine } from "../../args";
|
|
39
|
+
import { sanitizeDisplay } from "../../output";
|
|
40
|
+
import { GitHub, MAX_BINARY_BYTES, notFound, parseApiBase, sendsToken, TAG } from "./github";
|
|
41
|
+
import { checkWritable, discard, forgetOld, oldPath, onInterrupt, replace, restore, stage } from "./replace";
|
|
42
|
+
import { Refusal } from "./refusal";
|
|
43
|
+
import { RELEASE_UPDATE_ROW } from "./opt-in";
|
|
44
|
+
import { compareVersions } from "./semver";
|
|
45
|
+
|
|
46
|
+
export { allowedRedirect, parseApiBase } from "./github";
|
|
47
|
+
export { compareVersions } from "./semver";
|
|
48
|
+
|
|
49
|
+
const USAGE = `Usage: eris update [--check] [--to <tag>]
|
|
50
|
+
|
|
51
|
+
Replace this eris binary with a release of the repository it was built from.
|
|
52
|
+
With no flag, install the latest release when it is newer than this build.
|
|
53
|
+
|
|
54
|
+
Flags:
|
|
55
|
+
--check Say whether an update is available and change nothing.
|
|
56
|
+
Exit 0: up to date. Exit 10: an update is available.
|
|
57
|
+
Exit 1: could not tell.
|
|
58
|
+
--to <tag> Install that release instead, a prerelease or an older one
|
|
59
|
+
included.
|
|
60
|
+
--help, -h Print this usage text
|
|
61
|
+
|
|
62
|
+
The binary is checked against the release's SHA256SUMS, run once before the old
|
|
63
|
+
one is dropped, and the old one is put back if the new one does not answer.
|
|
64
|
+
A source checkout is refused: update it with git pull.
|
|
65
|
+
|
|
66
|
+
Environment:
|
|
67
|
+
GH_TOKEN, GITHUB_TOKEN A GitHub token (GH_TOKEN first). Needed for a private
|
|
68
|
+
repository; raises the anonymous rate limit. Sent to
|
|
69
|
+
api.github.com only (or a loopback test server),
|
|
70
|
+
never to the download host.
|
|
71
|
+
ERIS_UPDATE_API Another API base, for a GitHub Enterprise host or a
|
|
72
|
+
test server: https, or http on a loopback host. A
|
|
73
|
+
host other than api.github.com or loopback is sent
|
|
74
|
+
no token.
|
|
75
|
+
|
|
76
|
+
--output json prints {current, latest, update_available, updated}.
|
|
77
|
+
Refusals are explained in docs/ERRORS.md.
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
/** The platforms releases publish a binary for. */
|
|
81
|
+
const SUPPORTED = new Set(["darwin-arm64", "linux-x64"]);
|
|
82
|
+
|
|
83
|
+
/** How long the new binary has to answer `--version`. */
|
|
84
|
+
const SMOKE_TIMEOUT_MS = 20_000;
|
|
85
|
+
|
|
86
|
+
export interface UpdateHost {
|
|
87
|
+
/** Whether this is a binary `build()` compiled, as opposed to a source checkout. */
|
|
88
|
+
readonly compiled: boolean;
|
|
89
|
+
/** The running binary's path. */
|
|
90
|
+
readonly execPath: string;
|
|
91
|
+
readonly platform: string;
|
|
92
|
+
readonly arch: string;
|
|
93
|
+
/** This build's tag (`dev` when it has none). */
|
|
94
|
+
readonly version: string;
|
|
95
|
+
/** `owner/name` of the repository its releases are published to. */
|
|
96
|
+
readonly releaseRepo: string;
|
|
97
|
+
/** Runs the new binary. */
|
|
98
|
+
readonly runner: Runner;
|
|
99
|
+
/** How long the API may stay silent, in ms. */
|
|
100
|
+
readonly apiTimeoutMs: number;
|
|
101
|
+
/** How long a download may stay silent between two chunks, in ms. */
|
|
102
|
+
readonly downloadTimeoutMs: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** This process. */
|
|
106
|
+
export function defaultHost(): UpdateHost {
|
|
107
|
+
return {
|
|
108
|
+
compiled: isCompiled,
|
|
109
|
+
execPath: process.execPath,
|
|
110
|
+
platform: process.platform,
|
|
111
|
+
arch: process.arch,
|
|
112
|
+
version: VERSION,
|
|
113
|
+
releaseRepo: releaseRepo(),
|
|
114
|
+
runner: nodeRunner,
|
|
115
|
+
apiTimeoutMs: 30_000,
|
|
116
|
+
downloadTimeoutMs: 60_000,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The checksum SHA256SUMS lists for `name` (`<hex> <name>`, or `<hex> *<name>`), lower-case. */
|
|
121
|
+
function checksumOf(sums: string, name: string): string | undefined {
|
|
122
|
+
for (const line of sums.split("\n")) {
|
|
123
|
+
const m = /^([0-9a-fA-F]{64}) [ *](.+?)\r?$/.exec(line);
|
|
124
|
+
if (m !== null && m[2] === name) return m[1]!.toLowerCase();
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The token from `GH_TOKEN`, else `GITHUB_TOKEN`: trimmed, and a blank one is skipped.
|
|
131
|
+
* A token holds visible ASCII only; anything else is refused here, by variable name,
|
|
132
|
+
* because a header value with a line break makes `fetch` throw an error that quotes
|
|
133
|
+
* the whole value, and the failure block would print the secret.
|
|
134
|
+
*/
|
|
135
|
+
function tokenFrom(env: VerbContext["env"]): string | undefined {
|
|
136
|
+
for (const name of ["GH_TOKEN", "GITHUB_TOKEN"]) {
|
|
137
|
+
const value = readEnv(env, name)?.trim();
|
|
138
|
+
if (value === undefined || value === "") continue;
|
|
139
|
+
if (!/^[\x21-\x7e]+$/.test(value)) {
|
|
140
|
+
throw new Refusal({
|
|
141
|
+
slug: "update-token-invalid",
|
|
142
|
+
what: `${name} is not a usable token: it holds a space, a line break or another character a token cannot (its value is not shown)`,
|
|
143
|
+
fix: `set ${name} to the token alone, or unset it`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `ERIS_UPDATE_API` as it may be shown: scheme and host only, so a credential in it is never echoed. */
|
|
152
|
+
function shownApi(raw: string | undefined): string {
|
|
153
|
+
try {
|
|
154
|
+
const url = new URL(raw ?? "");
|
|
155
|
+
return `${url.protocol}//${url.host}`;
|
|
156
|
+
} catch {
|
|
157
|
+
return "(not a URL)";
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface Target {
|
|
162
|
+
readonly path: string;
|
|
163
|
+
readonly api: URL;
|
|
164
|
+
readonly token: string | undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Everything that can be refused without a request. */
|
|
168
|
+
function preflight(host: UpdateHost, ctx: VerbContext, check: boolean): Target {
|
|
169
|
+
if (!host.compiled) {
|
|
170
|
+
throw new Refusal({
|
|
171
|
+
slug: "update-not-compiled",
|
|
172
|
+
what: "this eris runs from a source checkout, which eris update does not replace",
|
|
173
|
+
fix: `update the checkout instead: cd ${sanitizeDisplay(ctx.erisDir)} && git pull && bun install --frozen-lockfile`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const platform = `${host.platform}-${host.arch}`;
|
|
177
|
+
if (!SUPPORTED.has(platform)) {
|
|
178
|
+
throw new Refusal({
|
|
179
|
+
slug: "update-unsupported-platform",
|
|
180
|
+
what: `releases carry no eris binary for ${sanitizeDisplay(platform)} (darwin-arm64 and linux-x64 only)`,
|
|
181
|
+
fix: "build eris from source for this platform: see 'From source' in the README",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
let path = host.execPath;
|
|
185
|
+
try {
|
|
186
|
+
path = realpathSync(host.execPath);
|
|
187
|
+
} catch {
|
|
188
|
+
// Keep the path as given; writing beside it fails with its own reason below.
|
|
189
|
+
}
|
|
190
|
+
const raw = readEnv(ctx.env, "ERIS_UPDATE_API");
|
|
191
|
+
const api = parseApiBase(raw);
|
|
192
|
+
if (api === null) {
|
|
193
|
+
throw new Refusal({
|
|
194
|
+
slug: "update-api-invalid",
|
|
195
|
+
what: `ERIS_UPDATE_API is not an API base eris will use: ${shownApi(raw)}`,
|
|
196
|
+
fix: "set it to an https URL with no credentials, query or fragment (http only for a loopback host), or unset it to use https://api.github.com",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
// Another host gets no token: the variable may be stray or planted.
|
|
200
|
+
const token = sendsToken(api) ? tokenFrom(ctx.env) : undefined;
|
|
201
|
+
if (!check) checkWritable(path);
|
|
202
|
+
return { path, api, token };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Why the new binary is not good, or null when it answered with the release's tag. */
|
|
206
|
+
async function smoke(host: UpdateHost, path: string, tag: string, env: VerbContext["env"]): Promise<string | null> {
|
|
207
|
+
// The new binary needs no token; it is not handed one.
|
|
208
|
+
const { GH_TOKEN: _gh, GITHUB_TOKEN: _github, ...rest } = env;
|
|
209
|
+
const r = await host.runner.run([path, "--output", "json", "--version"], { timeoutMs: SMOKE_TIMEOUT_MS, env: rest });
|
|
210
|
+
if (r.kind === "spawn-failed") return `it did not start (${sanitizeDisplay(r.errno)})`;
|
|
211
|
+
if (r.kind === "timeout") return `it did not answer within ${SMOKE_TIMEOUT_MS / 1000}s`;
|
|
212
|
+
if (r.kind === "signal") return `it was killed by ${sanitizeDisplay(r.signal)}`;
|
|
213
|
+
if (r.kind === "exit") {
|
|
214
|
+
const said = sanitizeDisplay(r.stderr.toString("utf8").split("\n")[0] ?? "").slice(0, 120);
|
|
215
|
+
return `it exited ${r.code}${said !== "" ? `: ${said}` : ""}`;
|
|
216
|
+
}
|
|
217
|
+
let reported: unknown;
|
|
218
|
+
try {
|
|
219
|
+
reported = (JSON.parse(r.stdout.toString("utf8")) as { version?: unknown }).version;
|
|
220
|
+
} catch {
|
|
221
|
+
return "it did not print a version record";
|
|
222
|
+
}
|
|
223
|
+
if (reported !== tag) return `it reports ${typeof reported === "string" ? sanitizeDisplay(reported).slice(0, 60) : "no version"}, not ${tag}`;
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface UpdateRecord {
|
|
228
|
+
current: string;
|
|
229
|
+
latest: string;
|
|
230
|
+
update_available: boolean;
|
|
231
|
+
updated: boolean;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function update(host: UpdateHost, ctx: VerbContext, check: boolean, to: string | undefined): Promise<number> {
|
|
235
|
+
const target = preflight(host, ctx, check);
|
|
236
|
+
const repo = host.releaseRepo;
|
|
237
|
+
const gh = new GitHub({
|
|
238
|
+
base: target.api,
|
|
239
|
+
token: target.token,
|
|
240
|
+
repo,
|
|
241
|
+
userAgent: `eris-update/${host.version}`,
|
|
242
|
+
apiTimeoutMs: host.apiTimeoutMs,
|
|
243
|
+
downloadTimeoutMs: host.downloadTimeoutMs,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (!check) info(`checking ${repo} for ${to ?? "the latest release"}`, ctx.stderr);
|
|
247
|
+
const release = await gh.release(to);
|
|
248
|
+
if (release.draft || (release.prerelease && to === undefined)) {
|
|
249
|
+
throw notFound(repo, to ?? release.tag, release.draft ? ` (${release.tag} is a draft)` : ` (${release.tag} is a prerelease; name it with --to)`);
|
|
250
|
+
}
|
|
251
|
+
const order = compareVersions(release.tag, host.version);
|
|
252
|
+
const newer = order === null ? release.tag !== host.version : order > 0;
|
|
253
|
+
const emit = (updated: boolean) => {
|
|
254
|
+
const record: UpdateRecord = { current: host.version, latest: release.tag, update_available: newer, updated };
|
|
255
|
+
if (ctx.format === "json") ctx.stdout.write(`${JSON.stringify(record)}\n`);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
if (check) {
|
|
259
|
+
if (ctx.format !== "json") ctx.stdout.write(newer ? `update available: ${host.version} -> ${release.tag}\n` : `up to date: ${host.version}\n`);
|
|
260
|
+
emit(false);
|
|
261
|
+
return newer ? 10 : 0;
|
|
262
|
+
}
|
|
263
|
+
if (to === undefined ? !newer : release.tag === host.version) {
|
|
264
|
+
ok(`eris ${host.version} is up to date`, ctx.stderr);
|
|
265
|
+
emit(false);
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const name = `eris-${host.platform}-${host.arch}`;
|
|
270
|
+
const missing = (what: string) =>
|
|
271
|
+
new Refusal({ slug: "update-asset-missing", what, fix: `choose another release with eris update --to <tag>: see https://github.com/${repo}/releases` });
|
|
272
|
+
const binary = release.assets.find((a) => a.name === name);
|
|
273
|
+
const sumsAsset = release.assets.find((a) => a.name === "SHA256SUMS");
|
|
274
|
+
if (binary === undefined) throw missing(`${repo}'s release ${release.tag} has no ${name}`);
|
|
275
|
+
if (sumsAsset === undefined) throw missing(`${repo}'s release ${release.tag} has no SHA256SUMS`);
|
|
276
|
+
|
|
277
|
+
info(`downloading ${name} ${release.tag}`, ctx.stderr);
|
|
278
|
+
const expected = checksumOf(await gh.downloadText(sumsAsset), name);
|
|
279
|
+
if (expected === undefined) throw missing(`SHA256SUMS in ${repo}'s release ${release.tag} lists no checksum for ${name}`);
|
|
280
|
+
const hash = createHash("sha256");
|
|
281
|
+
// One guard from the first byte staged until the new binary has answered, so
|
|
282
|
+
// no signal lands between two guards with no listener and kills by default:
|
|
283
|
+
// before the rename an interrupt removes the staged file, after it puts the
|
|
284
|
+
// old binary back. (stage() also removes a partial file on its own.)
|
|
285
|
+
let staged: string | undefined;
|
|
286
|
+
let replaced = false;
|
|
287
|
+
const stand = onInterrupt(() => {
|
|
288
|
+
if (replaced) void restore(target.path);
|
|
289
|
+
else if (staged !== undefined) discard(staged);
|
|
290
|
+
});
|
|
291
|
+
let why: string | null;
|
|
292
|
+
try {
|
|
293
|
+
staged = await stage(target.path, (write) =>
|
|
294
|
+
gh.download(binary, MAX_BINARY_BYTES, (chunk) => {
|
|
295
|
+
hash.update(chunk);
|
|
296
|
+
write(chunk);
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
const got = hash.digest("hex");
|
|
300
|
+
if (got !== expected) {
|
|
301
|
+
discard(staged);
|
|
302
|
+
throw new Refusal({
|
|
303
|
+
slug: "update-checksum-mismatch",
|
|
304
|
+
what: `${name} does not match its checksum in SHA256SUMS (expected ${expected}, got ${got})`,
|
|
305
|
+
fix: "nothing was changed\nretry; if it repeats, the release is corrupt or has been tampered with, so do not install it",
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
replace(target.path, staged);
|
|
309
|
+
replaced = true;
|
|
310
|
+
why = await smoke(host, target.path, release.tag, ctx.env);
|
|
311
|
+
} finally {
|
|
312
|
+
stand();
|
|
313
|
+
}
|
|
314
|
+
if (why !== null) {
|
|
315
|
+
const stuck = restore(target.path);
|
|
316
|
+
throw new Refusal({
|
|
317
|
+
slug: "update-smoke-failed",
|
|
318
|
+
what: `the new eris ${release.tag} failed its check: ${why}; ${stuck === null ? `the previous eris ${host.version} is restored` : `the previous eris could not be put back (${stuck})`}`,
|
|
319
|
+
fix:
|
|
320
|
+
stuck === null
|
|
321
|
+
? `the release may be broken: retry later, or install another with eris update --to <tag> (see https://github.com/${repo}/releases)`
|
|
322
|
+
: `put the previous eris back by hand: mv ${oldPath(target.path)} ${target.path}`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
forgetOld(target.path);
|
|
326
|
+
ok(`updated eris ${host.version} -> ${release.tag} (${target.path})`, ctx.stderr);
|
|
327
|
+
emit(true);
|
|
328
|
+
return 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The verb on a given host. */
|
|
332
|
+
export function createUpdateHandler(host: UpdateHost): VerbHandler {
|
|
333
|
+
return async (args, ctx) => {
|
|
334
|
+
const a = readRowArgs("update", RELEASE_UPDATE_ROW, args, ctx, { help: USAGE });
|
|
335
|
+
if (a === null) return 0;
|
|
336
|
+
const to = a.flags["--to"];
|
|
337
|
+
if (to !== undefined && !TAG.test(to)) {
|
|
338
|
+
ctx.stderr.write(`ERROR --to takes a release tag such as v0.2.1, got '${sanitizeDisplay(to)}' (usage: ${usageLine("update", RELEASE_UPDATE_ROW)})\n`);
|
|
339
|
+
return 1;
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
return await update(host, ctx, a.flags["--check"], to);
|
|
343
|
+
} catch (e) {
|
|
344
|
+
if (!(e instanceof Refusal)) throw e;
|
|
345
|
+
ctx.stderr.write(renderFailureBlock(e.input, ctx.erisDir));
|
|
346
|
+
return 1;
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export const runReleaseUpdate: VerbHandler = createUpdateHandler(defaultHost());
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* replace.ts — put a new binary where the running one is, and take it back if
|
|
3
|
+
* the new one does not run.
|
|
4
|
+
*
|
|
5
|
+
* Every file it makes sits in the target's own directory, never a system temp
|
|
6
|
+
* dir: a rename is only atomic inside one filesystem, and a binary that is
|
|
7
|
+
* about to be executed should not be staged in a directory anyone can write.
|
|
8
|
+
*
|
|
9
|
+
* 1. `stage`: the download streams into `.eris-update-<pid>-<random>` beside the
|
|
10
|
+
* target, with the target's mode, and is removed if anything fails.
|
|
11
|
+
* 2. `replace`: hard-links the target to `<target>.old` (a copy where the
|
|
12
|
+
* filesystem has no links), then renames the staged file over the target.
|
|
13
|
+
* The rename is atomic, so the target path names a whole binary at every
|
|
14
|
+
* moment, and replacing the file a process is running from is safe on
|
|
15
|
+
* macOS and Linux (the running image keeps its inode).
|
|
16
|
+
* 3. The caller runs the new binary (`smoke`). If it fails, `<target>.old` is
|
|
17
|
+
* renamed back; if it passes, `.old` is removed.
|
|
18
|
+
*
|
|
19
|
+
* An interrupt (Ctrl-C, a TERM, a hangup) in step 1 removes the partial file, and
|
|
20
|
+
* in steps 2-3 puts the old binary back (`onInterrupt`), so a killed update never
|
|
21
|
+
* leaves a stray file or a binary nobody has run.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { accessSync, chmodSync, closeSync, constants, copyFileSync, linkSync, openSync, renameSync, rmSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
|
+
import { randomBytes } from "node:crypto";
|
|
27
|
+
import { Refusal, reasonOf } from "./refusal";
|
|
28
|
+
|
|
29
|
+
export const oldPath = (target: string): string => `${target}.old`;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run `cleanup` if this process is interrupted, then die by that signal as it
|
|
33
|
+
* would have without us. Returns the function that stands the guard down.
|
|
34
|
+
*/
|
|
35
|
+
export function onInterrupt(cleanup: () => void): () => void {
|
|
36
|
+
const handlers = new Map<NodeJS.Signals, () => void>();
|
|
37
|
+
const dispose = () => {
|
|
38
|
+
for (const [sig, handler] of handlers) process.removeListener(sig, handler);
|
|
39
|
+
handlers.clear();
|
|
40
|
+
};
|
|
41
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
|
42
|
+
const handler = () => {
|
|
43
|
+
try {
|
|
44
|
+
cleanup();
|
|
45
|
+
} finally {
|
|
46
|
+
dispose();
|
|
47
|
+
process.kill(process.pid, sig);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
handlers.set(sig, handler);
|
|
51
|
+
process.on(sig, handler);
|
|
52
|
+
}
|
|
53
|
+
return dispose;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function unwritable(dir: string, e: unknown): Refusal {
|
|
57
|
+
return new Refusal({
|
|
58
|
+
slug: "update-target-unwritable",
|
|
59
|
+
what: `eris cannot write to ${dir}: ${reasonOf(e)}`,
|
|
60
|
+
fix: `run eris update as a user who can write to ${dir}\nor reinstall eris into a directory you own`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Refuse now, before any download, when the target's directory cannot be written. */
|
|
65
|
+
export function checkWritable(target: string): void {
|
|
66
|
+
const dir = dirname(target);
|
|
67
|
+
try {
|
|
68
|
+
accessSync(dir, constants.W_OK);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
throw unwritable(dir, e);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The staged file's path, once `fill` has written all of it through `write`. */
|
|
75
|
+
export async function stage(target: string, fill: (write: (chunk: Uint8Array) => void) => Promise<void>): Promise<string> {
|
|
76
|
+
const dir = dirname(target);
|
|
77
|
+
const path = join(dir, `.eris-update-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
78
|
+
let fd: number;
|
|
79
|
+
try {
|
|
80
|
+
fd = openSync(path, "wx", 0o700);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
throw unwritable(dir, e);
|
|
83
|
+
}
|
|
84
|
+
const stand = onInterrupt(() => rmSync(path, { force: true }));
|
|
85
|
+
try {
|
|
86
|
+
try {
|
|
87
|
+
await fill((chunk) => {
|
|
88
|
+
for (let at = 0; at < chunk.length; ) at += writeSync(fd, chunk, at);
|
|
89
|
+
});
|
|
90
|
+
} finally {
|
|
91
|
+
closeSync(fd);
|
|
92
|
+
}
|
|
93
|
+
chmodSync(path, statSync(target).mode & 0o777);
|
|
94
|
+
return path;
|
|
95
|
+
} catch (e) {
|
|
96
|
+
rmSync(path, { force: true });
|
|
97
|
+
if (e instanceof Refusal) throw e;
|
|
98
|
+
throw unwritable(dir, e);
|
|
99
|
+
} finally {
|
|
100
|
+
stand();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Remove a staged file that will not be used. */
|
|
105
|
+
export function discard(path: string): void {
|
|
106
|
+
rmSync(path, { force: true });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Keep the running binary as `<target>.old`, and rename `staged` over `target`. */
|
|
110
|
+
export function replace(target: string, staged: string): void {
|
|
111
|
+
const old = oldPath(target);
|
|
112
|
+
try {
|
|
113
|
+
rmSync(old, { force: true });
|
|
114
|
+
try {
|
|
115
|
+
linkSync(target, old);
|
|
116
|
+
} catch {
|
|
117
|
+
copyFileSync(target, old);
|
|
118
|
+
}
|
|
119
|
+
renameSync(staged, target);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
rmSync(old, { force: true });
|
|
122
|
+
throw unwritable(dirname(target), e);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Remove `<target>.old` once the new binary has answered. */
|
|
127
|
+
export function forgetOld(target: string): void {
|
|
128
|
+
try {
|
|
129
|
+
unlinkSync(oldPath(target));
|
|
130
|
+
} catch {
|
|
131
|
+
// Nothing to remove, or it cannot be: the update itself succeeded.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Put `<target>.old` back. Returns the reason it could not, else null. */
|
|
136
|
+
export function restore(target: string): string | null {
|
|
137
|
+
try {
|
|
138
|
+
renameSync(oldPath(target), target);
|
|
139
|
+
return null;
|
|
140
|
+
} catch (e) {
|
|
141
|
+
return reasonOf(e);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* semver.ts — the order `eris update` puts release tags in. A tag is
|
|
3
|
+
* `[v]MAJOR.MINOR.PATCH[-prerelease]`, ordered as semver.org defines: the
|
|
4
|
+
* numbers first, a prerelease before its release, prerelease identifiers
|
|
5
|
+
* compared one by one (numbers numerically, below words).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const SEMVER = /^v?(\d{1,9})\.(\d{1,9})\.(\d{1,9})(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A release tag: `vMAJOR.MINOR.PATCH[-prerelease]`, exactly the tags
|
|
12
|
+
* `compareVersions` orders, with the `v`. The release workflow's tag check
|
|
13
|
+
* (.github/workflows/release.yml) holds the same grammar;
|
|
14
|
+
* tests/workflows.test.ts runs both on one table.
|
|
15
|
+
*/
|
|
16
|
+
export const RELEASE_TAG = /^v[0-9]{1,9}\.[0-9]{1,9}\.[0-9]{1,9}(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/;
|
|
17
|
+
|
|
18
|
+
const sign = (n: number): -1 | 0 | 1 => (n < 0 ? -1 : n > 0 ? 1 : 0);
|
|
19
|
+
|
|
20
|
+
/** -1, 0 or 1 for `a` before, equal to or after `b`; null when either is not a version (`dev`, a typo). */
|
|
21
|
+
export function compareVersions(a: string, b: string): -1 | 0 | 1 | null {
|
|
22
|
+
const pa = SEMVER.exec(a);
|
|
23
|
+
const pb = SEMVER.exec(b);
|
|
24
|
+
if (pa === null || pb === null) return null;
|
|
25
|
+
for (const i of [1, 2, 3]) {
|
|
26
|
+
const d = sign(Number(pa[i]) - Number(pb[i]));
|
|
27
|
+
if (d !== 0) return d;
|
|
28
|
+
}
|
|
29
|
+
const ra = pa[4];
|
|
30
|
+
const rb = pb[4];
|
|
31
|
+
if (ra === undefined || rb === undefined) return ra === rb ? 0 : ra === undefined ? 1 : -1;
|
|
32
|
+
const ia = ra.split(".");
|
|
33
|
+
const ib = rb.split(".");
|
|
34
|
+
for (let i = 0; i < Math.max(ia.length, ib.length); i++) {
|
|
35
|
+
const x = ia[i];
|
|
36
|
+
const y = ib[i];
|
|
37
|
+
if (x === undefined) return -1;
|
|
38
|
+
if (y === undefined) return 1;
|
|
39
|
+
const nx = /^\d+$/.test(x);
|
|
40
|
+
const ny = /^\d+$/.test(y);
|
|
41
|
+
if (nx && ny) {
|
|
42
|
+
const d = sign(Number(x) - Number(y));
|
|
43
|
+
if (d !== 0) return d;
|
|
44
|
+
} else if (nx !== ny) return nx ? -1 : 1;
|
|
45
|
+
else if (x !== y) return x < y ? -1 : 1;
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* update.ts — `eris update`: say how to get the latest release. eris ships as
|
|
3
|
+
* an npm package, run by bun (docs/UPDATE.md), so there is nothing to replace in
|
|
4
|
+
* place: the package manager that installed it is what updates it. The verb
|
|
5
|
+
* reads nothing and fetches nothing; it prints the commands and exits 0.
|
|
6
|
+
*
|
|
7
|
+
* `--output json` prints `{current, commands}`: this build's version (`dev` in
|
|
8
|
+
* a checkout) and the same commands, in the same order.
|
|
9
|
+
*
|
|
10
|
+
* A distribution that publishes compiled binaries swaps in the in-place verb,
|
|
11
|
+
* `RELEASE_UPDATE` (./update/opt-in.ts).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { VerbHandler } from "../../main";
|
|
15
|
+
import { VERSION } from "../../runtime/home";
|
|
16
|
+
import { readVerbArgs } from "../args";
|
|
17
|
+
|
|
18
|
+
/** The package's npm name: what every command below installs or runs. */
|
|
19
|
+
export const PACKAGE = "@sibylline-labs/eris";
|
|
20
|
+
|
|
21
|
+
/** How to get the latest release, in the order they are printed. */
|
|
22
|
+
export const UPDATE_COMMANDS: readonly string[] = [`npx ${PACKAGE}@latest <command>`, `bunx ${PACKAGE}@latest <command>`, `bun add -g ${PACKAGE}@latest`];
|
|
23
|
+
|
|
24
|
+
const USAGE = `Usage: eris update
|
|
25
|
+
|
|
26
|
+
Print how to get the latest eris release. eris is an npm package run by bun;
|
|
27
|
+
the package manager that installed it updates it, so this command changes
|
|
28
|
+
nothing.
|
|
29
|
+
|
|
30
|
+
Flags:
|
|
31
|
+
--help, -h Print this usage text
|
|
32
|
+
|
|
33
|
+
--output json prints {current, commands}.
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
export const runUpdate: VerbHandler = async (args, ctx) => {
|
|
37
|
+
if (readVerbArgs("update", args, ctx, { help: USAGE }) === null) return 0;
|
|
38
|
+
if (ctx.format === "json") {
|
|
39
|
+
ctx.stdout.write(`${JSON.stringify({ current: VERSION, commands: UPDATE_COMMANDS })}\n`);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
const [npx, bunx, global] = UPDATE_COMMANDS;
|
|
43
|
+
ctx.stdout.write(
|
|
44
|
+
`eris ${VERSION}. Run the latest release with:\n ${npx}\n ${bunx}\nor install it globally (bun runs it, so bun must be on PATH):\n ${global}\n`,
|
|
45
|
+
);
|
|
46
|
+
return 0;
|
|
47
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usage.ts — bare `eris`, `eris -h` and `eris --help`: the top-level usage (the
|
|
3
|
+
* former Bash entrypoint's `_eris_usage`).
|
|
4
|
+
*
|
|
5
|
+
* A composed build lists each plugin group after the built-in commands, a
|
|
6
|
+
* locked one marked, every summary in one column (src/cli/compose.ts). The `--output` paragraph names every
|
|
7
|
+
* json surface of the composed CLI, plugin rows included;
|
|
8
|
+
* tests/cli-router.test.ts holds it against the `json` rows. A build whose bare
|
|
9
|
+
* program is a distribution's menu says so in the last line.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { CLI, type CliContext } from "../../main";
|
|
13
|
+
import { type Cli, commandLines, NO_ENTITLEMENTS, type Session } from "../compose";
|
|
14
|
+
|
|
15
|
+
const HEADER = `Usage: eris <command> [args]
|
|
16
|
+
|
|
17
|
+
Commands:
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
const FLAG_COLUMN = " ".repeat(23);
|
|
21
|
+
/** The widest line of the `--output` paragraph. */
|
|
22
|
+
const WIDTH = 78;
|
|
23
|
+
|
|
24
|
+
/** Words filled greedily into lines of at most `WIDTH`; a word never breaks. */
|
|
25
|
+
function fill(words: readonly string[]): string {
|
|
26
|
+
const lines: string[] = [];
|
|
27
|
+
let line = " --output text|json ";
|
|
28
|
+
let fresh = true;
|
|
29
|
+
for (const w of words) {
|
|
30
|
+
if (!fresh && line.length + 1 + w.length > WIDTH) {
|
|
31
|
+
lines.push(line);
|
|
32
|
+
line = FLAG_COLUMN;
|
|
33
|
+
fresh = true;
|
|
34
|
+
}
|
|
35
|
+
line += fresh ? w : ` ${w}`;
|
|
36
|
+
fresh = false;
|
|
37
|
+
}
|
|
38
|
+
return [...lines, line].map((l) => `${l}\n`).join("");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The global flags, the `--output` paragraph naming each json surface `cli` composes. */
|
|
42
|
+
function globals(cli: Cli): string {
|
|
43
|
+
const surfaces = Object.entries(cli.jsonSurfaces).map(([g, s]) => (s === true ? g : `${g} ${s.join("|")}`));
|
|
44
|
+
const words = [
|
|
45
|
+
..."Output format. 'json' is the machine rendering, accepted by:".split(" "),
|
|
46
|
+
...surfaces.map((s, i) => `${s}${i === surfaces.length - 1 ? "." : ","}`),
|
|
47
|
+
..."Any other verb refuses the flag (docs/OUTPUT-CONTRACT.md). Default: text.".split(" "),
|
|
48
|
+
];
|
|
49
|
+
return `
|
|
50
|
+
Global flags:
|
|
51
|
+
${fill(words)} --version Print this build's version and release repository.
|
|
52
|
+
|
|
53
|
+
Run 'eris <command> --help' for command-specific usage.
|
|
54
|
+
${cli.hasMenu ? "Run 'eris' without arguments for interactive mode.\n" : ""}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The top-level usage of one session's composed CLI. */
|
|
58
|
+
export function usageText(session: Session): string {
|
|
59
|
+
return `${HEADER}${commandLines(session)}${globals(session.cli)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** This build's top-level usage (core plus plugins/index.ts), default entitlements. */
|
|
63
|
+
export const ERIS_USAGE = usageText({ cli: CLI, entitled: NO_ENTITLEMENTS });
|
|
64
|
+
|
|
65
|
+
export async function runUsage(_args: string[], ctx: CliContext & { session?: Session }): Promise<number> {
|
|
66
|
+
ctx.stdout.write(ctx.session === undefined ? ERIS_USAGE : usageText(ctx.session));
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* version.ts — `eris --version`: what this build is.
|
|
3
|
+
*
|
|
4
|
+
* eris v0.2.0 (sibylline-labs/eris)
|
|
5
|
+
* <the line a distribution added, if any>
|
|
6
|
+
*
|
|
7
|
+
* `--output json` prints the same facts as one record:
|
|
8
|
+
* `{version, release_repo, build_id, extra}`. `version` is the tag the build was
|
|
9
|
+
* given (`dev` for an untagged build and for a checkout), `release_repo` the
|
|
10
|
+
* repository `eris update` reads releases from, `build_id` the compiled
|
|
11
|
+
* binary's id (null in a checkout) and `extra` the distribution's line (null
|
|
12
|
+
* when it adds none). All four are baked at build time (src/build.ts); a run
|
|
13
|
+
* from source reports the identity its entry handed `runProgram` (`identity()`,
|
|
14
|
+
* src/runtime/home.ts). Nothing here reads a file, the network or the
|
|
15
|
+
* environment, so it answers in a binary whose asset home cannot be written.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { VerbHandler } from "../../main";
|
|
19
|
+
import { BUILD_ID, identity, VERSION } from "../../runtime/home";
|
|
20
|
+
|
|
21
|
+
export const runVersion: VerbHandler = async (_args, ctx) => {
|
|
22
|
+
const { releaseRepo, versionExtra } = identity();
|
|
23
|
+
if (ctx.format === "json") {
|
|
24
|
+
ctx.stdout.write(`${JSON.stringify({ version: VERSION, release_repo: releaseRepo, build_id: BUILD_ID, extra: versionExtra })}\n`);
|
|
25
|
+
} else {
|
|
26
|
+
ctx.stdout.write(`eris ${VERSION} (${releaseRepo})\n${versionExtra !== null ? `${versionExtra}\n` : ""}`);
|
|
27
|
+
}
|
|
28
|
+
return 0;
|
|
29
|
+
};
|