@curia-sh/cli 0.4.1
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/README.md +259 -0
- package/bin/curia.mjs +15 -0
- package/package.json +36 -0
- package/src/acquire.mjs +188 -0
- package/src/archive.mjs +154 -0
- package/src/atomic.mjs +38 -0
- package/src/bundle.mjs +119 -0
- package/src/cli.mjs +73 -0
- package/src/commands.mjs +45 -0
- package/src/compose.mjs +137 -0
- package/src/config.mjs +289 -0
- package/src/doctor.mjs +392 -0
- package/src/exit.mjs +32 -0
- package/src/install.mjs +194 -0
- package/src/launcher.mjs +53 -0
- package/src/layout.mjs +95 -0
- package/src/lock.mjs +74 -0
- package/src/manifest.mjs +571 -0
- package/src/preflight.mjs +593 -0
- package/src/purge.mjs +230 -0
- package/src/resources.mjs +146 -0
- package/src/rollback.mjs +131 -0
- package/src/root.mjs +196 -0
- package/src/secrets.mjs +149 -0
- package/src/stable.mjs +314 -0
- package/src/stage.mjs +141 -0
- package/src/steps.mjs +25 -0
- package/src/switch.mjs +199 -0
- package/src/tailscale.mjs +156 -0
- package/src/uninstall.mjs +190 -0
- package/src/update.mjs +174 -0
- package/stable-index.pub +3 -0
package/src/root.mjs
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
3
|
+
import { isAbsolute, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { Refusal } from './exit.mjs'
|
|
6
|
+
import { writeAtomically } from './atomic.mjs'
|
|
7
|
+
|
|
8
|
+
// The installation root the lifecycle interface acts on.
|
|
9
|
+
//
|
|
10
|
+
// The launcher exports `CURIA_ROOT` for the root it was installed for, so a
|
|
11
|
+
// nondefault root stays explicit in the launcher and never has to be typed.
|
|
12
|
+
// Without it, the default root follows the XDG base directory rules.
|
|
13
|
+
export function installationRoot(env) {
|
|
14
|
+
if (env.CURIA_ROOT) return env.CURIA_ROOT
|
|
15
|
+
if (env.XDG_DATA_HOME) return join(env.XDG_DATA_HOME, 'curia')
|
|
16
|
+
return join(env.HOME ?? '', '.local', 'share', 'curia')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The seven boundaries of an installation root, in the order the operator
|
|
20
|
+
// documentation lists them. Each is one directory, mode 0700, owned by the
|
|
21
|
+
// operator. Lifecycle commands act on them as whole units.
|
|
22
|
+
export const BOUNDARIES = Object.freeze(['config', 'secrets', 'state', 'work', 'versions', 'cache', 'run'])
|
|
23
|
+
|
|
24
|
+
const OWNER_ONLY_DIR = 0o700
|
|
25
|
+
const OWNER_ONLY_FILE = 0o600
|
|
26
|
+
|
|
27
|
+
// The one safe way into an installation root. Every lifecycle command calls it
|
|
28
|
+
// before it touches anything, and it refuses (exit 3, nothing changed) when:
|
|
29
|
+
//
|
|
30
|
+
// - the command runs as root;
|
|
31
|
+
// - the root is not an absolute path;
|
|
32
|
+
// - the root, a boundary directory, or the installation record is a
|
|
33
|
+
// symbolic link;
|
|
34
|
+
// - the root or a boundary is owned by another user or is reachable by the
|
|
35
|
+
// group or by others;
|
|
36
|
+
// - the root is nonempty and holds no installation record.
|
|
37
|
+
//
|
|
38
|
+
// It returns the root's status so the caller decides what the operation may
|
|
39
|
+
// do: `absent` (nothing there), `empty` (an empty directory), or `installed`
|
|
40
|
+
// (a record is present, and it is returned). A record that is present but
|
|
41
|
+
// malformed is a failure, not a refusal, so a damaged installation never reads
|
|
42
|
+
// as a fresh one.
|
|
43
|
+
export function openRoot(root, { uid }) {
|
|
44
|
+
if (uid === 0) {
|
|
45
|
+
throw new Refusal('this command runs as root. Curia runs unprivileged: run it as the operator that owns the installation.')
|
|
46
|
+
}
|
|
47
|
+
if (!isAbsolute(root)) {
|
|
48
|
+
throw new Refusal(`the installation root must be an absolute path, got ${root}. Set CURIA_ROOT to an absolute path or run the installed launcher.`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const rootStat = ownerOnlyDirectory(root, { uid, what: 'the installation root' })
|
|
52
|
+
if (rootStat === null) return { root, status: 'absent', record: null }
|
|
53
|
+
|
|
54
|
+
for (const name of BOUNDARIES) {
|
|
55
|
+
ownerOnlyDirectory(join(root, name), { uid, what: `${name}/ in the installation root` })
|
|
56
|
+
}
|
|
57
|
+
const record = readInstallationRecord(root)
|
|
58
|
+
if (record) return { root, status: 'installed', record }
|
|
59
|
+
|
|
60
|
+
if (readdirSync(root).length === 0) return { root, status: 'empty', record: null }
|
|
61
|
+
throw new Refusal(`${root} is not empty and holds no installation record, so it is not a Curia installation. Choose an empty or absent directory, or move what is there out of the way.`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Creates the root and the seven boundaries with owner-only permissions. The
|
|
65
|
+
// modes are set explicitly, not through the umask. It is idempotent: it adds a
|
|
66
|
+
// missing boundary to an existing root and leaves the rest alone. It never
|
|
67
|
+
// widens or narrows a directory that exists, so a boundary with broad
|
|
68
|
+
// permissions is refused here as in `openRoot`.
|
|
69
|
+
export function ensureLayout(root, { uid }) {
|
|
70
|
+
if (ownerOnlyDirectory(root, { uid, what: 'the installation root' }) === null) {
|
|
71
|
+
mkdirSync(root, { recursive: true })
|
|
72
|
+
chmodSync(root, OWNER_ONLY_DIR)
|
|
73
|
+
}
|
|
74
|
+
for (const name of BOUNDARIES) {
|
|
75
|
+
const dir = join(root, name)
|
|
76
|
+
if (ownerOnlyDirectory(dir, { uid, what: `${name}/ in the installation root` }) === null) {
|
|
77
|
+
mkdirSync(dir, { mode: OWNER_ONLY_DIR })
|
|
78
|
+
chmodSync(dir, OWNER_ONLY_DIR)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The lstat of a directory that must be owned by `uid`, mode 0700, and not a
|
|
84
|
+
// symbolic link. Returns null when the path does not exist. Every other
|
|
85
|
+
// deviation is a refusal that names the path and the corrective action.
|
|
86
|
+
function ownerOnlyDirectory(path, { uid, what }) {
|
|
87
|
+
let stat
|
|
88
|
+
try {
|
|
89
|
+
stat = lstatSync(path)
|
|
90
|
+
} catch (e) {
|
|
91
|
+
if (e.code === 'ENOENT') return null
|
|
92
|
+
throw e
|
|
93
|
+
}
|
|
94
|
+
if (stat.isSymbolicLink()) {
|
|
95
|
+
throw new Refusal(`${what} is a symbolic link: ${path}. Curia does not follow links there. Replace the link with a real directory.`)
|
|
96
|
+
}
|
|
97
|
+
if (!stat.isDirectory()) {
|
|
98
|
+
throw new Refusal(`${what} is not a directory: ${path}. Move the file out of the way or choose another root.`)
|
|
99
|
+
}
|
|
100
|
+
if (stat.uid !== uid) {
|
|
101
|
+
throw new Refusal(`${what} is owned by user ${stat.uid}, not by you (user ${uid}): ${path}. Run the command as the owner, or choose another root.`)
|
|
102
|
+
}
|
|
103
|
+
const mode = stat.mode & 0o777
|
|
104
|
+
if ((mode & 0o077) !== 0) {
|
|
105
|
+
throw new Refusal(`${what} has mode ${octal(mode)}, which lets other users reach it: ${path}. Run 'chmod 0700 ${path}' and try again.`)
|
|
106
|
+
}
|
|
107
|
+
return stat
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function octal(mode) {
|
|
111
|
+
return `0${mode.toString(8).padStart(3, '0')}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The installation record: `state/installation.json`, the one file that says
|
|
115
|
+
// which version is active. It holds only the record format, a random
|
|
116
|
+
// installation ID, and the active version. The launcher reads the same file
|
|
117
|
+
// with `sed`, so its shape stays flat: one JSON object, one key per line,
|
|
118
|
+
// `activeVersion` a string.
|
|
119
|
+
export const RECORD_FORMAT = 1
|
|
120
|
+
const RECORD_KEYS = Object.freeze(['format', 'installationId', 'activeVersion'])
|
|
121
|
+
|
|
122
|
+
export function recordPath(root) {
|
|
123
|
+
return join(root, 'state', 'installation.json')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function createInstallationRecord(activeVersion) {
|
|
127
|
+
return { format: RECORD_FORMAT, installationId: randomBytes(16).toString('hex'), activeVersion }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Returns the record, or `null` when the root holds none. A record that is
|
|
131
|
+
// present but unreadable as a record is an error: a half-written or foreign
|
|
132
|
+
// file must not read as "no installation". A record that is a symbolic link
|
|
133
|
+
// is refused, because the file is security-sensitive and must live in state/.
|
|
134
|
+
export function readInstallationRecord(root) {
|
|
135
|
+
const path = recordPath(root)
|
|
136
|
+
let text
|
|
137
|
+
try {
|
|
138
|
+
if (lstatSync(path).isSymbolicLink()) {
|
|
139
|
+
throw new Refusal(`the installation record ${path} is a symbolic link. Replace the link with the real file or remove it.`)
|
|
140
|
+
}
|
|
141
|
+
text = readFileSync(path, 'utf8')
|
|
142
|
+
} catch (e) {
|
|
143
|
+
if (e.code === 'ENOENT') return null
|
|
144
|
+
throw e
|
|
145
|
+
}
|
|
146
|
+
let record
|
|
147
|
+
try {
|
|
148
|
+
record = JSON.parse(text)
|
|
149
|
+
} catch {
|
|
150
|
+
record = null
|
|
151
|
+
}
|
|
152
|
+
if (!isRecord(record)) {
|
|
153
|
+
throw new Error(`${path} is not a Curia installation record`)
|
|
154
|
+
}
|
|
155
|
+
return record
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isRecord(record) {
|
|
159
|
+
return record !== null
|
|
160
|
+
&& typeof record === 'object'
|
|
161
|
+
&& record.format === RECORD_FORMAT
|
|
162
|
+
&& typeof record.activeVersion === 'string'
|
|
163
|
+
&& typeof record.installationId === 'string'
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Writes the record atomically, owner-only. The record must hold exactly the
|
|
167
|
+
// three documented keys, so nothing operator-specific or generated slips in.
|
|
168
|
+
export function writeInstallationRecord(root, record) {
|
|
169
|
+
const foreign = Object.keys(record).filter((k) => !RECORD_KEYS.includes(k))
|
|
170
|
+
if (foreign.length > 0 || !isRecord(record)) {
|
|
171
|
+
throw new Error(`the installation record holds only ${RECORD_KEYS.join(', ')}; refusing to write ${JSON.stringify(record)}${foreign.length ? ` (unexpected: ${foreign.join(', ')})` : ''}`)
|
|
172
|
+
}
|
|
173
|
+
const ordered = Object.fromEntries(RECORD_KEYS.map((k) => [k, record[k]]))
|
|
174
|
+
writeAtomically(recordPath(root), JSON.stringify(ordered, null, 2) + '\n', { mode: OWNER_ONLY_FILE })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The paths of an installed version. The launcher reads the first two: the
|
|
178
|
+
// pinned Node runtime and the lifecycle interface's entry point, where
|
|
179
|
+
// `versions/<version>/cli/` holds the unpacked `@curia-sh/cli` package (the
|
|
180
|
+
// tarball's `package/` directory). The rest are what the release manifest
|
|
181
|
+
// (`cli/src/manifest.mjs`) verifies: the manifest the package embeds, the
|
|
182
|
+
// retained package tarball and bundle archive exactly as downloaded, and the
|
|
183
|
+
// unpacked Compose bundle the lifecycle interface starts.
|
|
184
|
+
export function versionPaths(root, version) {
|
|
185
|
+
const dir = join(root, 'versions', version)
|
|
186
|
+
return {
|
|
187
|
+
dir,
|
|
188
|
+
node: join(dir, 'node', 'bin', 'node'),
|
|
189
|
+
cli: join(dir, 'cli', 'bin', 'curia.mjs'),
|
|
190
|
+
manifest: join(dir, 'cli', 'manifest.json'),
|
|
191
|
+
package: join(dir, 'cli.tgz'),
|
|
192
|
+
bundleArchive: join(dir, 'bundle.tar.gz'),
|
|
193
|
+
bundleChecksum: join(dir, 'bundle.tar.gz.sha256'),
|
|
194
|
+
bundle: join(dir, 'bundle', 'compose.yaml'),
|
|
195
|
+
}
|
|
196
|
+
}
|
package/src/secrets.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { lstatSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { writeAtomically } from './atomic.mjs'
|
|
5
|
+
|
|
6
|
+
// The long-lived credentials Curia owns, one owner-only file each under
|
|
7
|
+
// `secrets/` in the installation root (#867, implementing #851 and #852).
|
|
8
|
+
//
|
|
9
|
+
// A long-lived credential reaches a consumer through a file and nothing else:
|
|
10
|
+
// never an environment variable, a Compose interpolation, a command argument,
|
|
11
|
+
// a log line, a diagnostic, or a browser response. The service is the one
|
|
12
|
+
// process that reads and replaces these files. Every other consumer gets a
|
|
13
|
+
// copy of the one credential it needs, written by the service into that
|
|
14
|
+
// consumer's own directory, or a renewable token derived from it.
|
|
15
|
+
//
|
|
16
|
+
// Renewable tokens (GitHub App installation tokens, agent tokens) and
|
|
17
|
+
// session-bound capabilities are not on this list. They live under `run/` and
|
|
18
|
+
// `work/` and are recreated or bound to a session.
|
|
19
|
+
export const SECRET_FILES = Object.freeze([
|
|
20
|
+
Object.freeze({
|
|
21
|
+
name: 'discord-bot-token',
|
|
22
|
+
holds: 'the Discord bot token, one line',
|
|
23
|
+
writer: 'the Discord integration step of setup',
|
|
24
|
+
}),
|
|
25
|
+
Object.freeze({
|
|
26
|
+
name: 'github-app.json',
|
|
27
|
+
holds: 'the GitHub App: `{ "id": "<app id>", "pem": "<private key>" }`',
|
|
28
|
+
writer: 'the GitHub integration step of setup, from the manifest conversion',
|
|
29
|
+
}),
|
|
30
|
+
Object.freeze({
|
|
31
|
+
name: 'anthropic.json',
|
|
32
|
+
holds: 'the Anthropic subscription credential the service adopted',
|
|
33
|
+
writer: 'the Anthropic integration step of setup, or `reauth anthropic`',
|
|
34
|
+
}),
|
|
35
|
+
Object.freeze({
|
|
36
|
+
name: 'codex-auth.json',
|
|
37
|
+
holds: 'the OpenAI Codex credential, refreshed by the service',
|
|
38
|
+
writer: 'the OpenAI integration step of setup, or `reauth codex`, and the service on refresh',
|
|
39
|
+
}),
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
export const SECRET_NAMES = Object.freeze(SECRET_FILES.map((s) => s.name))
|
|
43
|
+
|
|
44
|
+
export const SECRET_MODE = 0o600
|
|
45
|
+
|
|
46
|
+
// A secret boundary that is not met. It is not a `Refusal`: the process that
|
|
47
|
+
// meets one decides what it means. The service refuses to boot on it, and
|
|
48
|
+
// `curia doctor` reports it. The message never carries a secret value.
|
|
49
|
+
export class SecretError extends Error {
|
|
50
|
+
constructor(message) {
|
|
51
|
+
super(message)
|
|
52
|
+
this.name = 'SecretError'
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function secretPath(root, name) {
|
|
57
|
+
if (!SECRET_NAMES.includes(name)) {
|
|
58
|
+
throw new SecretError(`${name} is not a secret Curia knows. The secret files are ${SECRET_NAMES.join(', ')}.`)
|
|
59
|
+
}
|
|
60
|
+
return join(root, 'secrets', name)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The secret's text, or `null` when the file is absent. A file that is a
|
|
64
|
+
// symbolic link, that another user owns, or that the group or others can read
|
|
65
|
+
// is refused, because a secret that reaches past its owner is no longer a
|
|
66
|
+
// secret and the fix is the operator's.
|
|
67
|
+
export function readSecret(root, name, { uid = process.getuid?.() } = {}) {
|
|
68
|
+
const file = secretPath(root, name)
|
|
69
|
+
const fault = inspect(file, { uid })
|
|
70
|
+
if (fault === 'absent') return null
|
|
71
|
+
if (fault) throw new SecretError(fault)
|
|
72
|
+
return readFileSync(file, 'utf8')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Writes the secret atomically at mode 0600. A symbolic link at the target is
|
|
76
|
+
// replaced by the file, never followed. The parent `secrets/` exists because
|
|
77
|
+
// `ensureLayout` created it.
|
|
78
|
+
export function writeSecret(root, name, text) {
|
|
79
|
+
const file = secretPath(root, name)
|
|
80
|
+
if (typeof text !== 'string' || text.trim() === '') {
|
|
81
|
+
throw new SecretError(`refusing to write an empty ${name}`)
|
|
82
|
+
}
|
|
83
|
+
writeAtomically(file, text, { mode: SECRET_MODE })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Presence and refusals by name, for a diagnostic. No value is read.
|
|
87
|
+
export function secretsStatus(root, { uid = process.getuid?.() } = {}) {
|
|
88
|
+
const status = {}
|
|
89
|
+
for (const name of SECRET_NAMES) {
|
|
90
|
+
const fault = inspect(secretPath(root, name), { uid })
|
|
91
|
+
if (fault === 'absent') status[name] = { state: 'absent' }
|
|
92
|
+
else if (fault) status[name] = { state: 'refused', why: fault }
|
|
93
|
+
else status[name] = { state: 'present' }
|
|
94
|
+
}
|
|
95
|
+
return status
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function inspect(file, { uid }) {
|
|
99
|
+
let stat
|
|
100
|
+
try {
|
|
101
|
+
stat = lstatSync(file)
|
|
102
|
+
} catch (e) {
|
|
103
|
+
if (e.code === 'ENOENT') return 'absent'
|
|
104
|
+
throw e
|
|
105
|
+
}
|
|
106
|
+
if (stat.isSymbolicLink()) {
|
|
107
|
+
return `${file} is a symbolic link. Curia does not follow links in secrets/. Replace the link with the real file.`
|
|
108
|
+
}
|
|
109
|
+
if (!stat.isFile()) {
|
|
110
|
+
return `${file} is not a regular file. Move it out of the way and write the secret again.`
|
|
111
|
+
}
|
|
112
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
113
|
+
return `${file} is owned by user ${stat.uid}, not by you (user ${uid}). Run the service as the owner, or write the secret again as yourself.`
|
|
114
|
+
}
|
|
115
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
116
|
+
return `${file} has mode 0${(stat.mode & 0o777).toString(8).padStart(3, '0')}, which lets other users read it. Run 'chmod 0600 ${file}' and try again.`
|
|
117
|
+
}
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The environment keys that used to carry a long-lived credential into the
|
|
122
|
+
// service, or would if an operator set them. A service running from an
|
|
123
|
+
// installation root refuses to boot while any of them is set, naming the key
|
|
124
|
+
// and the secret file to use instead, so a credential never enters a process
|
|
125
|
+
// environment by habit.
|
|
126
|
+
export const CREDENTIAL_ENV_KEYS = Object.freeze([
|
|
127
|
+
'DISCORD_BOT_TOKEN',
|
|
128
|
+
'CURIA_GH_APP_ID',
|
|
129
|
+
'CURIA_GH_APP_KEY_FILE',
|
|
130
|
+
'CLAUDE_CODE_OAUTH_TOKEN',
|
|
131
|
+
'ANTHROPIC_API_KEY',
|
|
132
|
+
'GH_TOKEN',
|
|
133
|
+
'GITHUB_TOKEN',
|
|
134
|
+
])
|
|
135
|
+
|
|
136
|
+
export function credentialsInEnvironment(env) {
|
|
137
|
+
return CREDENTIAL_ENV_KEYS.filter((key) => typeof env[key] === 'string' && env[key] !== '')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Every occurrence of every given value replaced, for text that may carry a
|
|
141
|
+
// secret on its way to a log or a response.
|
|
142
|
+
export function redact(text, values) {
|
|
143
|
+
let out = String(text)
|
|
144
|
+
for (const value of values) {
|
|
145
|
+
if (typeof value !== 'string' || value === '') continue
|
|
146
|
+
out = out.split(value).join('[redacted]')
|
|
147
|
+
}
|
|
148
|
+
return out
|
|
149
|
+
}
|
package/src/stable.mjs
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
import { Refusal } from './exit.mjs'
|
|
5
|
+
import { RELEASE_REPOSITORY, isReleaseVersion } from './manifest.mjs'
|
|
6
|
+
|
|
7
|
+
// The stable-release index and release selection (#871, implementing #849
|
|
8
|
+
// and #854).
|
|
9
|
+
//
|
|
10
|
+
// Every published Curia release is immutable and stays published. Which one
|
|
11
|
+
// an installation should run is a separate, mutable fact, and this module is
|
|
12
|
+
// the one place that says how that fact is published, proven, and read:
|
|
13
|
+
//
|
|
14
|
+
// - The index is one small signed file, `release/stable.json` on the `main`
|
|
15
|
+
// branch of alp82/curia, served raw from GitHub. It names the recommended
|
|
16
|
+
// stable release and the versions that were withdrawn. It never restates
|
|
17
|
+
// a manifest: a version is a name here, and the release manifest is what
|
|
18
|
+
// says what the version is made of.
|
|
19
|
+
// - The index is signed with one Ed25519 key. The private key lives only in
|
|
20
|
+
// the repository secret the promotion workflow reads; the public key ships
|
|
21
|
+
// inside this package as `stable-index.pub`, so an installed version
|
|
22
|
+
// trusts the key that the package it verified carries, and nothing else.
|
|
23
|
+
// A host needs no `gh` login and no extra tool to verify it.
|
|
24
|
+
// - `promote` and `withdraw` are the only two transitions. Each one changes
|
|
25
|
+
// selection metadata and nothing else: no artifact is rebuilt, replaced,
|
|
26
|
+
// or deleted. The sequence number rises on every change, so a consumer
|
|
27
|
+
// that remembers the last sequence it accepted can refuse an older index.
|
|
28
|
+
// - `selectRelease` is the one selection rule. With nothing requested it
|
|
29
|
+
// picks the stable release. An exact version is honored as asked. A
|
|
30
|
+
// prerelease is selected only with the explicit `--prerelease` request,
|
|
31
|
+
// and a withdrawn version is never selected.
|
|
32
|
+
//
|
|
33
|
+
// `curia update` (#883) calls `fetchStableIndex` and `selectRelease`; the
|
|
34
|
+
// service's daily check and the Curia app read the same index through the
|
|
35
|
+
// same functions; the bootstrap (#872) reads the same file. Every read of
|
|
36
|
+
// the network goes through `stableProbes`, so a test hands in a fake.
|
|
37
|
+
|
|
38
|
+
export class StableIndexError extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message)
|
|
41
|
+
this.name = 'StableIndexError'
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const STABLE_INDEX_FORMAT = 1
|
|
46
|
+
export const STABLE_INDEX_PATH = 'release/stable.json'
|
|
47
|
+
export const STABLE_INDEX_URL = `https://raw.githubusercontent.com/${RELEASE_REPOSITORY}/main/${STABLE_INDEX_PATH}`
|
|
48
|
+
|
|
49
|
+
// The public key's file name inside the package, beside package.json.
|
|
50
|
+
export const STABLE_INDEX_KEY_FILE = 'stable-index.pub'
|
|
51
|
+
|
|
52
|
+
const SIGNATURE_ALGORITHM = 'ed25519'
|
|
53
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/
|
|
54
|
+
|
|
55
|
+
export function isPrerelease(version) {
|
|
56
|
+
return isReleaseVersion(version) && version.includes('-')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function releaseNotesUrl(version) {
|
|
60
|
+
return `https://github.com/${RELEASE_REPOSITORY}/releases/tag/v${version}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// The index: create, render, parse.
|
|
65
|
+
|
|
66
|
+
export function createStableIndex({ sequence = 0, updated, stable = null, withdrawn = [] } = {}) {
|
|
67
|
+
return validate({ format: STABLE_INDEX_FORMAT, sequence, updated, stable, withdrawn })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The one text form: keys in contract order, the withdrawn list sorted, two-
|
|
71
|
+
// space indentation, one trailing newline. This is what the signature covers.
|
|
72
|
+
export function renderStableIndex(index) {
|
|
73
|
+
return `${JSON.stringify(validate(index), null, 2)}\n`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseStableIndex(text) {
|
|
77
|
+
return validate(json(text, 'the index'))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function json(text, what) {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(text)
|
|
83
|
+
} catch (e) {
|
|
84
|
+
throw new StableIndexError(`${what} is not JSON: ${e.message}`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validate(i) {
|
|
89
|
+
if (i === null || typeof i !== 'object' || Array.isArray(i)) throw new StableIndexError('the index must be a JSON object')
|
|
90
|
+
if (i.format !== STABLE_INDEX_FORMAT) throw new StableIndexError(`format must be ${STABLE_INDEX_FORMAT}, got ${JSON.stringify(i.format)}`)
|
|
91
|
+
onlyKeys(i, ['format', 'sequence', 'updated', 'stable', 'withdrawn'], 'the index')
|
|
92
|
+
if (!Number.isInteger(i.sequence) || i.sequence < 0) throw new StableIndexError(`sequence must be a nonnegative integer, got ${JSON.stringify(i.sequence)}`)
|
|
93
|
+
if (typeof i.updated !== 'string' || !TIMESTAMP.test(i.updated) || Number.isNaN(Date.parse(i.updated))) {
|
|
94
|
+
throw new StableIndexError(`updated must be an ISO 8601 UTC timestamp like 2026-09-02T10:00:00Z, got ${JSON.stringify(i.updated)}`)
|
|
95
|
+
}
|
|
96
|
+
if (!Array.isArray(i.withdrawn)) throw new StableIndexError(`withdrawn must be an array of versions, got ${JSON.stringify(i.withdrawn)}`)
|
|
97
|
+
i.withdrawn.forEach((v, n) => {
|
|
98
|
+
if (!isReleaseVersion(v)) throw new StableIndexError(`withdrawn[${n}] must be a release version like 1.2.3, got ${JSON.stringify(v)}`)
|
|
99
|
+
})
|
|
100
|
+
const withdrawn = [...new Set(i.withdrawn)].sort(compareVersions)
|
|
101
|
+
if (i.stable !== null) {
|
|
102
|
+
if (!isReleaseVersion(i.stable)) throw new StableIndexError(`stable must be a release version like 1.2.3 or null, got ${JSON.stringify(i.stable)}`)
|
|
103
|
+
if (isPrerelease(i.stable)) throw new StableIndexError(`stable must not be a prerelease, got ${i.stable}`)
|
|
104
|
+
if (withdrawn.includes(i.stable)) throw new StableIndexError(`stable ${i.stable} is withdrawn, and a withdrawn version is never the stable release`)
|
|
105
|
+
}
|
|
106
|
+
return { format: STABLE_INDEX_FORMAT, sequence: i.sequence, updated: i.updated, stable: i.stable, withdrawn }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function onlyKeys(value, allowed, what) {
|
|
110
|
+
for (const key of allowed) if (!(key in value)) throw new StableIndexError(`${key} is missing from ${what}`)
|
|
111
|
+
for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new StableIndexError(`${key} is not part of ${what}`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Numeric order on the three parts, then a prerelease before its release,
|
|
115
|
+
// then the prerelease suffixes as text. Enough to keep the list readable.
|
|
116
|
+
function compareVersions(a, b) {
|
|
117
|
+
const [ac, as = null] = a.split('-', 2)
|
|
118
|
+
const [bc, bs = null] = b.split('-', 2)
|
|
119
|
+
const an = ac.split('.').map(Number)
|
|
120
|
+
const bn = bc.split('.').map(Number)
|
|
121
|
+
for (let n = 0; n < 3; n += 1) if (an[n] !== bn[n]) return an[n] - bn[n]
|
|
122
|
+
if (as === bs) return 0
|
|
123
|
+
if (as === null) return 1
|
|
124
|
+
if (bs === null) return -1
|
|
125
|
+
return as < bs ? -1 : 1
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// The key and the signature.
|
|
130
|
+
|
|
131
|
+
// The pinned key's short name: the first 16 hex characters of the SHA-256 of
|
|
132
|
+
// the public key's DER form. A signature names the key it was made with, so
|
|
133
|
+
// a mismatch says which key each side holds instead of only "invalid".
|
|
134
|
+
export function keyFingerprint(publicKey) {
|
|
135
|
+
const key = typeof publicKey === 'object' && publicKey?.type === 'public' ? publicKey : createPublicKey(publicKey)
|
|
136
|
+
const der = key.export({ type: 'spki', format: 'der' })
|
|
137
|
+
return createHash('sha256').update(der).digest('hex').slice(0, 16)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// One Ed25519 pair in PEM form. `deploy/release/keygen.mjs` calls this once,
|
|
141
|
+
// commits the public key into the package, and hands the private key to the
|
|
142
|
+
// repository secret without writing it anywhere else.
|
|
143
|
+
export function generateStableIndexKeys() {
|
|
144
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
|
|
145
|
+
const publicPem = publicKey.export({ type: 'spki', format: 'pem' })
|
|
146
|
+
return { publicKey: publicPem, privateKey: privateKey.export({ type: 'pkcs8', format: 'pem' }), fingerprint: keyFingerprint(publicPem) }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The signed file: the index and its signature over the canonical index
|
|
150
|
+
// text. Deterministic, so signing the same index twice writes the same file.
|
|
151
|
+
export function signStableIndex(index, privateKey) {
|
|
152
|
+
const canonical = renderStableIndex(index)
|
|
153
|
+
const key = createPrivateKey(privateKey)
|
|
154
|
+
if (key.asymmetricKeyType !== SIGNATURE_ALGORITHM) throw new StableIndexError(`the signing key must be ${SIGNATURE_ALGORITHM}, got ${key.asymmetricKeyType}`)
|
|
155
|
+
const value = sign(null, Buffer.from(canonical), key).toString('base64')
|
|
156
|
+
const envelope = { index: JSON.parse(canonical), signature: { algorithm: SIGNATURE_ALGORITHM, key: keyFingerprint(createPublicKey(key)), value } }
|
|
157
|
+
return `${JSON.stringify(envelope, null, 2)}\n`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Returns the index when the file's signature verifies against `publicKey`,
|
|
161
|
+
// and throws a `StableIndexError` that says why otherwise. Nothing passes by
|
|
162
|
+
// absence: no pinned key, no signature, another key, or a changed byte all
|
|
163
|
+
// fail.
|
|
164
|
+
export function verifyStableIndex(text, { publicKey }) {
|
|
165
|
+
if (!publicKey) throw new StableIndexError('no stable-index public key is pinned in this version, so no index can be trusted')
|
|
166
|
+
const envelope = json(text, 'the stable-release index')
|
|
167
|
+
if (envelope === null || typeof envelope !== 'object' || Array.isArray(envelope)) throw new StableIndexError('the stable-release index must be a JSON object')
|
|
168
|
+
if (!('index' in envelope)) throw new StableIndexError('the stable-release index signature is missing: the file is a bare index, not a signed one')
|
|
169
|
+
const index = validate(envelope.index)
|
|
170
|
+
const signature = envelope.signature
|
|
171
|
+
if (signature === null || signature === undefined) throw new StableIndexError('the stable-release index signature is missing')
|
|
172
|
+
if (typeof signature !== 'object' || Array.isArray(signature)) throw new StableIndexError('the stable-release index signature must be an object')
|
|
173
|
+
if (signature.algorithm !== SIGNATURE_ALGORITHM) throw new StableIndexError(`signature.algorithm must be ${SIGNATURE_ALGORITHM}, got ${JSON.stringify(signature.algorithm)}`)
|
|
174
|
+
const pinned = keyFingerprint(publicKey)
|
|
175
|
+
if (signature.key !== pinned) throw new StableIndexError(`the stable-release index is signed with key ${signature.key}, and this version pins key ${pinned}. Update the lifecycle interface to a version that pins the current key.`)
|
|
176
|
+
if (typeof signature.value !== 'string') throw new StableIndexError('signature.value must be a base64 string')
|
|
177
|
+
const ok = verify(null, Buffer.from(renderStableIndex(index)), createPublicKey(publicKey), Buffer.from(signature.value, 'base64'))
|
|
178
|
+
if (!ok) throw new StableIndexError(`the stable-release index signature does not verify against key ${pinned}. The file was changed after it was signed: do not trust it, and report it at https://github.com/${RELEASE_REPOSITORY}/issues.`)
|
|
179
|
+
return index
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The public key this package ships, or null when the file is absent or empty.
|
|
183
|
+
export function pinnedPublicKey(file = new URL(`../${STABLE_INDEX_KEY_FILE}`, import.meta.url)) {
|
|
184
|
+
try {
|
|
185
|
+
const text = readFileSync(file, 'utf8').trim()
|
|
186
|
+
return text ? `${text}\n` : null
|
|
187
|
+
} catch (e) {
|
|
188
|
+
if (e.code === 'ENOENT') return null
|
|
189
|
+
throw e
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// The two transitions. Pure: index in, index out, the input untouched.
|
|
195
|
+
|
|
196
|
+
function releaseVersion(version) {
|
|
197
|
+
if (!isReleaseVersion(version)) throw new Refusal(`${version} is not a release version like 1.2.3.`)
|
|
198
|
+
return version
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function next(index, changes, { updated }) {
|
|
202
|
+
return validate({ ...index, ...changes, sequence: index.sequence + 1, updated })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function promote(index, version, { updated }) {
|
|
206
|
+
const current = validate(index)
|
|
207
|
+
releaseVersion(version)
|
|
208
|
+
if (isPrerelease(version)) throw new Refusal(`${version} is a prerelease, and a prerelease is never the stable release. Publish it as a release version first.`)
|
|
209
|
+
if (current.withdrawn.includes(version)) throw new Refusal(`${version} is withdrawn. A withdrawn version is never promoted; publish a fixed release and promote that.`)
|
|
210
|
+
if (current.stable === version) return current
|
|
211
|
+
return next(current, { stable: version }, { updated })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function withdraw(index, version, { updated }) {
|
|
215
|
+
const current = validate(index)
|
|
216
|
+
releaseVersion(version)
|
|
217
|
+
if (current.withdrawn.includes(version)) return current
|
|
218
|
+
return next(current, {
|
|
219
|
+
stable: current.stable === version ? null : current.stable,
|
|
220
|
+
withdrawn: [...current.withdrawn, version],
|
|
221
|
+
}, { updated })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Selection.
|
|
226
|
+
|
|
227
|
+
// The one rule. Returns `{ version, selection }` with `selection` one of
|
|
228
|
+
// `stable`, `exact`, or `prerelease`, or throws a `Refusal` that names the
|
|
229
|
+
// condition. It decides from the index alone; whether the version's
|
|
230
|
+
// artifacts exist and verify is the release verification's question, asked
|
|
231
|
+
// after selection.
|
|
232
|
+
export function selectRelease(index, { requested = null, prerelease = false } = {}) {
|
|
233
|
+
const current = validate(index)
|
|
234
|
+
if (requested === null) {
|
|
235
|
+
if (prerelease) throw new Refusal('--prerelease needs an exact version, such as 1.3.0-rc.1. Without it, Curia selects the stable release.')
|
|
236
|
+
if (current.stable === null) throw new Refusal('no stable release is recommended right now. Ask for an exact version, or wait for the next stable release.')
|
|
237
|
+
return { version: current.stable, selection: 'stable' }
|
|
238
|
+
}
|
|
239
|
+
if (!isReleaseVersion(requested)) throw new Refusal(`${requested} is not a release version like 1.2.3.`)
|
|
240
|
+
if (current.withdrawn.includes(requested)) {
|
|
241
|
+
throw new Refusal(`${requested} is withdrawn: the release notes at ${releaseNotesUrl(requested)} say why. Choose another version, or run the command without a version for the stable release.`)
|
|
242
|
+
}
|
|
243
|
+
if (isPrerelease(requested)) {
|
|
244
|
+
if (!prerelease) throw new Refusal(`${requested} is a prerelease. A prerelease is never selected by accident: to install it anyway, add --prerelease.`)
|
|
245
|
+
return { version: requested, selection: 'prerelease' }
|
|
246
|
+
}
|
|
247
|
+
if (prerelease) throw new Refusal(`${requested} is not a prerelease. Run the command without --prerelease.`)
|
|
248
|
+
return { version: requested, selection: 'exact' }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// The command-line shape `curia update` gives selection: one optional exact
|
|
252
|
+
// version and one optional `--prerelease`. A `StableIndexError` here is a
|
|
253
|
+
// usage error for the caller to report as such.
|
|
254
|
+
export function selectionFromArgs(args) {
|
|
255
|
+
let requested = null
|
|
256
|
+
let prerelease = false
|
|
257
|
+
for (const arg of args) {
|
|
258
|
+
if (arg === '--prerelease') prerelease = true
|
|
259
|
+
else if (arg.startsWith('-')) throw new StableIndexError(`unknown option: ${arg}`)
|
|
260
|
+
else if (requested !== null) throw new StableIndexError(`one version at most, got ${requested} and ${arg}`)
|
|
261
|
+
else requested = arg
|
|
262
|
+
}
|
|
263
|
+
return { requested, prerelease }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const SELECTION_WORDS = {
|
|
267
|
+
stable: 'the stable release',
|
|
268
|
+
exact: 'the exact version requested',
|
|
269
|
+
prerelease: 'the exact prerelease requested',
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function renderSelection({ version, selection }) {
|
|
273
|
+
return `selected ${version}, ${SELECTION_WORDS[selection]}\n`
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// Fetching.
|
|
278
|
+
|
|
279
|
+
// The one network boundary: the raw file on the main branch. Null when it
|
|
280
|
+
// does not download, for whatever reason; the caller reports that as a failed
|
|
281
|
+
// check and never as an empty index.
|
|
282
|
+
export const stableProbes = Object.freeze({
|
|
283
|
+
stableIndex: async () => {
|
|
284
|
+
try {
|
|
285
|
+
const response = await fetch(STABLE_INDEX_URL, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(30_000) })
|
|
286
|
+
if (!response.ok) return null
|
|
287
|
+
return await response.text()
|
|
288
|
+
} catch {
|
|
289
|
+
return null
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
function renderIndexLine(index) {
|
|
295
|
+
const withdrawn = index.withdrawn.length ? `withdrawn ${index.withdrawn.join(', ')}` : 'nothing withdrawn'
|
|
296
|
+
return `stable-release index: sequence ${index.sequence}, stable ${index.stable ?? 'none'}, ${withdrawn}\n`
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Downloads the index, verifies it against the pinned key, prints one line,
|
|
300
|
+
// and returns `{ ok, index, error }`. A failed fetch carries the reason and no
|
|
301
|
+
// index, so a caller cannot select from a file that did not verify.
|
|
302
|
+
export async function fetchStableIndex({ stdout, publicKey = pinnedPublicKey() }, probes = stableProbes) {
|
|
303
|
+
let result
|
|
304
|
+
try {
|
|
305
|
+
const text = await probes.stableIndex()
|
|
306
|
+
if (text === null || text === undefined) throw new StableIndexError(`the stable-release index could not be downloaded from ${STABLE_INDEX_URL}. Check outbound access to raw.githubusercontent.com and run the command again.`)
|
|
307
|
+
result = { ok: true, index: verifyStableIndex(text, { publicKey }), error: null }
|
|
308
|
+
} catch (e) {
|
|
309
|
+
if (!(e instanceof StableIndexError)) throw e
|
|
310
|
+
result = { ok: false, index: null, error: e.message }
|
|
311
|
+
}
|
|
312
|
+
stdout.write(result.ok ? renderIndexLine(result.index) : `stable-release index: failed. ${result.error}\n`)
|
|
313
|
+
return result
|
|
314
|
+
}
|