@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.
@@ -0,0 +1,190 @@
1
+ import { existsSync, readFileSync, readdirSync, rmSync, unlinkSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { BOOTSTRAP_COMMAND } from './acquire.mjs'
5
+ import { EXIT, Refusal } from './exit.mjs'
6
+ import { dockerRunner } from './compose.mjs'
7
+ import { launcherPath } from './launcher.mjs'
8
+ import { lockPath, withLifecycleLock } from './lock.mjs'
9
+ import { removeInstallationResources } from './resources.mjs'
10
+ import { installationRoot, openRoot } from './root.mjs'
11
+ import { readSecret } from './secrets.mjs'
12
+ import { namedSteps } from './steps.mjs'
13
+ import { tailscaleRunner, withdrawServeRoutes } from './tailscale.mjs'
14
+
15
+ // `curia uninstall` (#886, implementing #855): the ordinary uninstall. It
16
+ // removes Curia's runnable footprint from the host and keeps the
17
+ // installation, as four named steps.
18
+ //
19
+ // preflight the root boundary (`openRoot`, which must find an
20
+ // installation). Nothing changes.
21
+ // docker under the lifecycle lock from here to the end: every
22
+ // container, network, and volume that carries this
23
+ // installation's label is stopped and removed. That is the
24
+ // five services of the Compose project, its network and tmux
25
+ // socket volume, and every agent container and agent cache
26
+ // volume the service created. Nothing without the label is
27
+ // read or touched, and the release images stay for purge.
28
+ // routes the Tailscale Serve routes Curia created, as
29
+ // `state/tailscale.json` records them, are turned off. No
30
+ // other route, and nothing else of the node.
31
+ // files the contents of versions/, cache/, and run/ are removed, and
32
+ // then the launcher, when it is this installation's.
33
+ //
34
+ // What stays is the installation: `config/`, `secrets/`, `state/`, and
35
+ // `work/`, with the installation record and its ID. Nothing here reads a
36
+ // session, drains one, changes a claim, or judges what under `work/` is
37
+ // worth keeping: the preserved directories are the recovery mechanism, and
38
+ // the bootstrap's `curia install` over the root is the reinstall.
39
+ //
40
+ // Every step reads what is there before it removes it, so a rerun over a
41
+ // partial cleanup does the rest and a rerun over a finished one does
42
+ // nothing. There is no persisted operation record and no repair mode. A
43
+ // failed step names itself and the command that reruns it.
44
+
45
+ export const UNINSTALL_STEPS = Object.freeze(['preflight', 'docker', 'routes', 'files'])
46
+
47
+ // The directories whose contents go, in the order they are removed. The
48
+ // directories themselves stay, so the root keeps its seven boundaries and the
49
+ // lock has a place to live on a rerun.
50
+ export const REMOVED_BOUNDARIES = Object.freeze(['versions', 'cache', 'run'])
51
+ export const PRESERVED_BOUNDARIES = Object.freeze(['config', 'secrets', 'state', 'work'])
52
+
53
+ // The command's seam. `context` is what `runCli` hands a command. `deps` are
54
+ // the boundaries a test replaces: the Docker runner and the `tailscale`
55
+ // runner.
56
+ export async function runUninstall(
57
+ { env, stdout, uid, root },
58
+ { docker = dockerRunner, tailscale = tailscaleRunner } = {},
59
+ ) {
60
+ const launcher = launcherPath(env)
61
+ const steps = namedSteps({
62
+ steps: UNINSTALL_STEPS,
63
+ stdout,
64
+ rerun: (step) => (existsSync(launcher)
65
+ ? `Run '${launcher} uninstall' to run ${step} again; the completed steps are kept.`
66
+ : `Fix the cause, then run 'curia uninstall' again from the bootstrap's reinstall (${reinstallCommand({ env, root })}), or remove the rest by hand: ${REMOVED_BOUNDARIES.map((b) => join(root, b)).join(', ')}.`),
67
+ })
68
+ const say = (text) => stdout.write(`${text}\n`)
69
+
70
+ try {
71
+ // 1. preflight
72
+ steps.begin('preflight')
73
+ const opened = openRoot(root, { uid })
74
+ if (opened.status !== 'installed') {
75
+ throw new Refusal(`${root} holds no installation, so there is nothing to uninstall. Nothing changed.`)
76
+ }
77
+ const { installationId, activeVersion } = opened.record
78
+ say(`uninstalling Curia ${activeVersion} from ${root} (installation ${installationId}); config/, secrets/, state/, and work/ are kept`)
79
+
80
+ return await withLifecycleLock(root, async () => {
81
+ // 2. docker
82
+ steps.begin('docker')
83
+ const removed = await removeInstallationResources(installationId, { docker, stdout })
84
+ const count = removed.containers.length + removed.networks.length + removed.volumes.length
85
+ say(count === 0
86
+ ? `no container, network, or volume carries the label of installation ${installationId}`
87
+ : `removed every container, network, and volume of installation ${installationId}; the release images are kept for 'curia purge'`)
88
+
89
+ // 3. routes
90
+ steps.begin('routes')
91
+ const routes = await withdrawServeRoutes({ stateDir: join(root, 'state'), stdout }, { tailscale })
92
+ if (routes.recorded.length === 0) say('no Serve route is recorded for this installation')
93
+ else if (routes.withdrawn.length === 0) say(`no recorded Serve route is standing; nothing to withdraw`)
94
+
95
+ // 4. files
96
+ steps.begin('files')
97
+ const lock = lockPath(root)
98
+ for (const name of REMOVED_BOUNDARIES) {
99
+ const dir = join(root, name)
100
+ if (!existsSync(dir)) continue
101
+ for (const entry of readdirSync(dir)) {
102
+ const file = join(dir, entry)
103
+ if (file === lock) continue
104
+ rmSync(file, { recursive: true, force: true })
105
+ }
106
+ say(`emptied ${dir}`)
107
+ }
108
+ const launcherFate = removeLauncher(launcher, root)
109
+ say(launcherFate === 'removed' ? `removed the launcher ${launcher}`
110
+ : launcherFate === 'foreign' ? `kept the launcher ${launcher}: it belongs to another installation root`
111
+ : `no launcher at ${launcher}`)
112
+
113
+ say('')
114
+ say(`Curia is uninstalled. The installation at ${root} is preserved.`)
115
+ say(` kept: ${PRESERVED_BOUNDARIES.map((b) => `${b}/`).join(', ')} (installation ${installationId}: configuration, secrets, history, and resumable work)`)
116
+ say(` removed: the launcher, ${REMOVED_BOUNDARIES.map((b) => `${b}/`).join(', ')}, and the installation's containers, networks, volumes, and Serve routes`)
117
+ say(` images: kept; 'curia purge' removes them`)
118
+ say(` reinstall: ${reinstallCommand({ env, root })}`)
119
+ say(` purge: ${purgeCommand({ env, root })}`)
120
+ const external = externalChecklist(root, { uid })
121
+ if (external.length > 0) {
122
+ say('')
123
+ say("External resources Curia never deletes. Remove them yourself if you won't reinstall:")
124
+ for (const line of external) say(` ${line}`)
125
+ }
126
+ return EXIT.ok
127
+ })
128
+ } catch (e) {
129
+ throw steps.wrap(e)
130
+ }
131
+ }
132
+
133
+ // The bootstrap installs over a preserved root and recognizes the record, so
134
+ // the reinstall command is the install command, with the root named when it
135
+ // is not the default one.
136
+ export function reinstallCommand({ env, root }) {
137
+ return `${BOOTSTRAP_COMMAND}${rootOption({ env, root })}`
138
+ }
139
+
140
+ export function purgeCommand({ env, root }) {
141
+ return `${BOOTSTRAP_COMMAND} --purge${rootOption({ env, root })}`
142
+ }
143
+
144
+ function rootOption({ env, root }) {
145
+ const byDefault = installationRoot({ HOME: env.HOME, XDG_DATA_HOME: env.XDG_DATA_HOME })
146
+ return root === byDefault ? '' : ` --root ${root}`
147
+ }
148
+
149
+ // Removes the launcher when it names this root. A launcher of another root
150
+ // is another installation's and stays.
151
+ function removeLauncher(launcher, root) {
152
+ let text
153
+ try {
154
+ text = readFileSync(launcher, 'utf8')
155
+ } catch (e) {
156
+ if (e.code === 'ENOENT') return 'absent'
157
+ throw e
158
+ }
159
+ if (!text.includes(`CURIA_ROOT='${root}'`)) return 'foreign'
160
+ unlinkSync(launcher)
161
+ return 'removed'
162
+ }
163
+
164
+ // The external resources Curia knows the identifiers of, from the files it
165
+ // keeps. Identifiers only, never a token or a key, and a file that cannot be
166
+ // read contributes nothing: the checklist is a courtesy, not a check.
167
+ export function externalChecklist(root, { uid }) {
168
+ const lines = []
169
+ const github = parse(() => readSecret(root, 'github-app.json', { uid }))
170
+ if (github?.id) lines.push(`GitHub App ${github.id} and its installations: https://github.com/settings/apps`)
171
+ const discord = parse(() => readFileSync(join(root, 'state', 'discord.json'), 'utf8'))
172
+ if (discord?.guild_id) lines.push(`Discord bot, server ${discord.guild_id}${discord.channel ? `, channel ${discord.channel}` : ''}: https://discord.com/developers/applications`)
173
+ const tailscale = parse(() => readFileSync(join(root, 'state', 'tailscale.json'), 'utf8'))
174
+ if (tailscale?.machine_name) lines.push(`Tailscale node ${tailscale.machine_name}: https://login.tailscale.com/admin/machines`)
175
+ return lines
176
+ }
177
+
178
+ function parse(read) {
179
+ try {
180
+ const text = read()
181
+ return text ? JSON.parse(text) : null
182
+ } catch {
183
+ return null
184
+ }
185
+ }
186
+
187
+ // Used by the command table.
188
+ export function uninstallCommand(context, deps) {
189
+ return runUninstall(context, deps)
190
+ }
package/src/update.mjs ADDED
@@ -0,0 +1,174 @@
1
+ import { mkdirSync, readFileSync, rmSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { EXIT, Refusal, UsageError } from './exit.mjs'
5
+ import { acquireProbes, acquireRelease } from './acquire.mjs'
6
+ import { operatorConfigPath } from './config.mjs'
7
+ import { launcherPath } from './launcher.mjs'
8
+ import { withLifecycleLock } from './lock.mjs'
9
+ import { releaseProbes } from './manifest.mjs'
10
+ import { hostProbes, preflight } from './preflight.mjs'
11
+ import { openRoot, versionPaths } from './root.mjs'
12
+ import { StableIndexError, fetchStableIndex, pinnedPublicKey, releaseNotesUrl, renderSelection, selectRelease, selectionFromArgs, stableProbes } from './stable.mjs'
13
+ import { IncompatibleRelease, isCompleteStage, placeVersion, validateWithRelease, verifyRetained } from './stage.mjs'
14
+ import { namedSteps } from './steps.mjs'
15
+ import { switchRelease } from './switch.mjs'
16
+ import { dockerRunner } from './compose.mjs'
17
+
18
+ // `curia update` (#883, implementing #854): from the signed stable-release
19
+ // index to a verified, validated target release staged beside the active
20
+ // one, as one linear sequence of named steps.
21
+ //
22
+ // preflight the root boundary (`openRoot`, which must find an installation)
23
+ // and the host preflight. Nothing on disk changes.
24
+ // select the stable-release index is downloaded and proven against the
25
+ // pinned key, and one version is selected from it: the stable
26
+ // release by default, an exact version when asked, an exact
27
+ // prerelease only with `--prerelease`. A withdrawn version is
28
+ // never selected. When the selected version is the active one,
29
+ // there is nothing to do and the command stops here, ok.
30
+ // acquire under the lifecycle lock from here to the end: every artifact
31
+ // of the target (package, pinned runtime, bundle, checksum,
32
+ // manifest) is downloaded into cache/update/ and proven, the
33
+ // bootstrap's own steps in this package's code (acquire.mjs). A
34
+ // target already complete under versions/<target>/ is verified
35
+ // and reused instead, so a rerun downloads nothing.
36
+ // stage the target passes the release door (`verifyStagedRelease`)
37
+ // and lands read-only as versions/<target>/ through one rename,
38
+ // beside the active version, which does not change.
39
+ // validate the target release validates the current operator
40
+ // configuration with its own reader (`readOperatorConfig` of
41
+ // the staged package), so a configuration the target refuses
42
+ // stops the update before anything switches.
43
+ // switch the core services (service, app, overseer) are recreated
44
+ // from the target's bundle while tmux, ttyd, and the live agent
45
+ // containers keep running; every service reports healthy, the
46
+ // service and the app report the target version, and the live
47
+ // sessions are re-adopted; then the record names the target
48
+ // and the release that was active is kept as the one rollback
49
+ // release, every other version removed. A failure switches
50
+ // the core services back once and leaves the record alone.
51
+ // The sequence is `switchRelease` in switch.mjs, which `curia
52
+ // rollback` shares.
53
+ //
54
+ // Discovery is the same `fetchStableIndex` and `selectRelease` the service's
55
+ // daily check and the Curia app use. A failed discovery refuses before the
56
+ // lock, so it never touches the running installation. The command never
57
+ // rewrites the launcher: it reads the record, and the record is what the
58
+ // switch writes.
59
+
60
+ export const UPDATE_STEPS = Object.freeze(['preflight', 'select', 'acquire', 'stage', 'validate', 'switch'])
61
+
62
+ const packageVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
63
+
64
+ // The command's seam. `context` is what `runCli` hands a command. `deps` are
65
+ // the boundaries a test replaces: the host probes, the stable-index probe
66
+ // and the pinned key, the acquisition probes, the release probes, the
67
+ // target's configuration validator, the Docker runner, the loopback `fetch`,
68
+ // and the clock the waits use.
69
+ export async function runUpdate(
70
+ { env, args = [], stdout, uid, gid, root },
71
+ { hostProbes: host = hostProbes, stableProbes: stable = stableProbes, publicKey = pinnedPublicKey(), acquireProbes: acquire = acquireProbes, releaseProbes: release = releaseProbes, validateTarget = validateWithRelease, docker = dockerRunner, fetch: fetchImpl = globalThis.fetch, sleep, now } = {},
72
+ ) {
73
+ let selection
74
+ try {
75
+ selection = selectionFromArgs(args)
76
+ } catch (e) {
77
+ if (e instanceof StableIndexError) throw new UsageError(e.message)
78
+ throw e
79
+ }
80
+ const command = [launcherPath(env), 'update', ...args].join(' ')
81
+ const steps = namedSteps({ steps: UPDATE_STEPS, stdout, rerun: (step) => `Run '${command}' to run ${step} again; the completed steps are kept.` })
82
+ const say = (text) => stdout.write(`${text}\n`)
83
+
84
+ try {
85
+ // 1. preflight
86
+ steps.begin('preflight')
87
+ const opened = openRoot(root, { uid })
88
+ if (opened.status !== 'installed') {
89
+ throw new Refusal(`${root} holds no installation, so there is nothing to update. Run the bootstrap to install Curia there.`)
90
+ }
91
+ const record = opened.record
92
+ const active = record.activeVersion
93
+ const hostReport = await preflight({ uid, root, stdout }, host)
94
+ if (!hostReport.ok) throw hostReport.refusal
95
+ const dockerGid = hostReport.facts.docker.group.gid
96
+
97
+ // 2. select
98
+ steps.begin('select')
99
+ const fetched = await fetchStableIndex({ stdout, publicKey }, stable)
100
+ if (!fetched.ok) throw new Refusal(`${fetched.error} The running installation is not affected.`)
101
+ const { index } = fetched
102
+ if (index.withdrawn.includes(active)) {
103
+ say(`warning: the active version ${active} is withdrawn. The release notes at ${releaseNotesUrl(active)} say why.`)
104
+ }
105
+ const { version: target, selection: how } = selectRelease(index, selection)
106
+ stdout.write(renderSelection({ version: target, selection: how }))
107
+ if (target === active) {
108
+ say(`${active} is the active version. Nothing to update.`)
109
+ return EXIT.ok
110
+ }
111
+ say(`updating ${active} to ${target} (release notes: ${releaseNotesUrl(target)})`)
112
+
113
+ return await withLifecycleLock(root, async () => {
114
+ // 3. acquire
115
+ steps.begin('acquire')
116
+ const paths = versionPaths(root, target)
117
+ let stage = null
118
+ if (isCompleteStage(paths.dir)) {
119
+ say(`${target} is already staged under ${paths.dir}; verifying the retained artifacts`)
120
+ await verifyRetained({ version: target, dir: paths.dir, stdout }, release)
121
+ } else {
122
+ stage = join(root, 'cache', 'update', `${target}.${process.pid}`)
123
+ rmSync(stage, { recursive: true, force: true })
124
+ mkdirSync(stage, { recursive: true, mode: 0o700 })
125
+ try {
126
+ await acquireRelease({ version: target, stage, stdout }, acquire)
127
+ } catch (e) {
128
+ rmSync(stage, { recursive: true, force: true })
129
+ throw e
130
+ }
131
+ }
132
+
133
+ // 4. stage
134
+ steps.begin('stage')
135
+ if (stage) {
136
+ try {
137
+ await placeVersion({ root, version: target, stage, stdout }, release)
138
+ } finally {
139
+ rmSync(stage, { recursive: true, force: true })
140
+ }
141
+ } else {
142
+ say(`${target} is staged under ${paths.dir}`)
143
+ }
144
+
145
+ // 5. validate
146
+ steps.begin('validate')
147
+ try {
148
+ await validateTarget({ root, version: target, dir: paths.dir })
149
+ } catch (e) {
150
+ if (!(e instanceof IncompatibleRelease)) throw e
151
+ throw new Error(e.reason === 'configuration' ? `${e.message} Fix the file, or choose another version. The active version is unchanged.` : `${e.message} Choose a version that does.`)
152
+ }
153
+ say(`${target} accepts the current operator configuration at ${operatorConfigPath(root)}`)
154
+
155
+ // 6. switch
156
+ steps.begin('switch')
157
+ await switchRelease(
158
+ { root, from: active, to: target, record, environment: { uid, gid, dockerGid, installationId: record.installationId }, stdout },
159
+ { docker, fetch: fetchImpl, sleep, now },
160
+ )
161
+ say('')
162
+ say(`Curia ${target} is running. Open the Curia app as before; nothing in integration setup has to be repeated.`)
163
+ say(`If ${target} misbehaves, 'curia rollback' switches back to ${active}.`)
164
+ return EXIT.ok
165
+ })
166
+ } catch (e) {
167
+ throw steps.wrap(e)
168
+ }
169
+ }
170
+
171
+ // Used by the command table.
172
+ export function updateCommand(context, deps) {
173
+ return runUpdate(context, deps)
174
+ }
@@ -0,0 +1,3 @@
1
+ -----BEGIN PUBLIC KEY-----
2
+ MCowBQYDK2VwAyEAxCKSmZVadSpZM0t24JceAmtafXwAOgRV0eNbwkyOzrY=
3
+ -----END PUBLIC KEY-----