@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/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
+ }
@@ -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
+ }