@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/purge.mjs ADDED
@@ -0,0 +1,230 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { createInterface } from 'node:readline'
4
+
5
+ import { EXIT, Refusal, UsageError } from './exit.mjs'
6
+ import { dockerRunner } from './compose.mjs'
7
+ import { launcherPath } from './launcher.mjs'
8
+ import { withLifecycleLock } from './lock.mjs'
9
+ import { removeInstallationResources, removeReleaseImages } from './resources.mjs'
10
+ import { openRoot, recordPath } from './root.mjs'
11
+ import { namedSteps } from './steps.mjs'
12
+ import { readTailscaleRecord, tailscaleRunner, withdrawServeRoutes } from './tailscale.mjs'
13
+ import { externalChecklist, purgeCommand } from './uninstall.mjs'
14
+
15
+ // `curia purge` (#887, implementing #855): the confirmed purge. It removes
16
+ // every local trace of one installation after one explicit confirmation, as
17
+ // six named steps.
18
+ //
19
+ // preflight the root boundary (`openRoot`, which must find an
20
+ // installation), then the warning: the exact root and what
21
+ // goes with it. Nothing changes.
22
+ // confirm the one confirmation. On a terminal the operator types the
23
+ // exact installation root; without one, `--confirm <root>` on
24
+ // the command line says the same thing. Anything else stops the
25
+ // command with nothing changed. There is no second question, no
26
+ // scan for unpublished work, and no salvage.
27
+ // docker under the lifecycle lock from here to the end: every
28
+ // container, network, and volume that carries this
29
+ // installation's label, the same teardown as uninstall.
30
+ // routes the Tailscale Serve routes Curia created, the same as uninstall.
31
+ // images the release images nothing on the host uses any more. Found
32
+ // by their exact repositories, never by a name prefix, and
33
+ // removed only when Docker lists no container over them and
34
+ // accepts the removal without force. An image another
35
+ // installation runs stays. The agent image the service built is
36
+ // not a release image and stays.
37
+ // root the launcher when it names this root, then the installation
38
+ // root itself, last, with the installation record as the last
39
+ // file inside it, so a rerun over a half-removed root still
40
+ // finds the installation.
41
+ //
42
+ // The installation ID and the external identifiers are read before anything
43
+ // is removed, because the files that hold them go with the root. Every step
44
+ // reads before it removes, so a rerun over a partial purge finishes it. The
45
+ // bootstrap's `--purge` runs this command from a temporary stage with
46
+ // `CURIA_ROOT` only: no launcher, no stage, and possibly a root whose `run/`
47
+ // is already gone, so the lock's directory is created when it is missing.
48
+
49
+ export const PURGE_STEPS = Object.freeze(['preflight', 'confirm', 'docker', 'routes', 'images', 'root'])
50
+
51
+ export const CONFIRM_OPTION = '--confirm'
52
+ const PROMPT = 'Type the installation root to confirm, or anything else to stop: '
53
+
54
+ // The command's seam. `context` is what `runCli` hands a command. `deps` are
55
+ // the boundaries a test replaces: the Docker runner, the `tailscale` runner,
56
+ // the question on the terminal, and whether there is a terminal to ask on.
57
+ export async function runPurge(
58
+ { env, args = [], stdout, uid, root },
59
+ { docker = dockerRunner, tailscale = tailscaleRunner, prompt = askOnTerminal, isTerminal = () => Boolean(process.stdin.isTTY) } = {},
60
+ ) {
61
+ const confirmed = confirmationFromArgs(args)
62
+ const launcher = launcherPath(env)
63
+ const bootstrap = purgeCommand({ env, root })
64
+ const steps = namedSteps({
65
+ steps: PURGE_STEPS,
66
+ stdout,
67
+ rerun: (step) => (existsSync(launcher)
68
+ ? `Run '${launcher} purge' to run ${step} again; the completed steps are kept.`
69
+ : `Fix the cause, then run 'curia purge' again from the bootstrap (${bootstrap}); the completed steps are kept.`),
70
+ })
71
+ const say = (text) => stdout.write(`${text}\n`)
72
+
73
+ try {
74
+ // 1. preflight
75
+ steps.begin('preflight')
76
+ const opened = openRoot(root, { uid })
77
+ if (opened.status !== 'installed') {
78
+ throw new Refusal(`${root} holds no installation, so there is nothing to purge. Nothing changed.`)
79
+ }
80
+ const { installationId, activeVersion } = opened.record
81
+ const external = externalChecklist(root, { uid })
82
+ const recordedRoutes = readTailscaleRecord(join(root, 'state')).serve
83
+ say(`This purges the Curia installation at ${root} (installation ${installationId}, version ${activeVersion}).`)
84
+ say('It deletes, with no way back:')
85
+ say(' - your configuration (config/), credentials (secrets/), history (state/), and unfinished work (work/)')
86
+ say(' - the installed versions, caches, and runtime files (versions/, cache/, run/) and the launcher')
87
+ say(" - the installation's containers, networks, and volumes, and the release images nothing else uses")
88
+ say(' - the Tailscale Serve routes Curia created')
89
+ say('It does not delete the GitHub App, the Discord bot and channel, the Tailscale node, or any model-provider login; those stay yours to remove.')
90
+
91
+ // 2. confirm
92
+ steps.begin('confirm')
93
+ if (confirmed !== null) {
94
+ if (confirmed !== root) throw new Refusal(`${CONFIRM_OPTION} names ${confirmed}, not the installation root ${root}. Nothing changed.`)
95
+ } else if (!isTerminal()) {
96
+ throw new Refusal(`no terminal to confirm on. Run 'curia purge ${CONFIRM_OPTION} ${root}' to confirm without a prompt. Nothing changed.`)
97
+ } else if ((await prompt(PROMPT)) !== root) {
98
+ throw new Refusal('the answer did not match the installation root. Nothing changed.')
99
+ }
100
+ say(`confirmed: purging ${root}`)
101
+
102
+ // The lock lives under run/, which a partial purge or the bootstrap's
103
+ // stage case may find missing.
104
+ const runDir = join(root, 'run')
105
+ if (!existsSync(runDir)) mkdirSync(runDir, { mode: 0o700 })
106
+
107
+ return await withLifecycleLock(root, async () => {
108
+ // 3. docker
109
+ steps.begin('docker')
110
+ const removed = await removeInstallationResources(installationId, { docker, stdout })
111
+ const count = removed.containers.length + removed.networks.length + removed.volumes.length
112
+ say(count === 0
113
+ ? `no container, network, or volume carries the label of installation ${installationId}`
114
+ : `removed every container, network, and volume of installation ${installationId}`)
115
+
116
+ // 4. routes
117
+ steps.begin('routes')
118
+ const routes = await withdrawServeRoutes({ stateDir: join(root, 'state'), stdout }, { tailscale })
119
+ if (routes.recorded.length === 0) say('no Serve route is recorded for this installation')
120
+ else if (routes.withdrawn.length === 0 && routes.absent.length > 0 && !routes.unreachable) say('no recorded Serve route is standing; nothing to withdraw')
121
+
122
+ // 5. images
123
+ steps.begin('images')
124
+ const images = await removeReleaseImages({ docker, stdout })
125
+ if (images.found.length === 0) say('no release image is on this host')
126
+ else say(`release images: ${images.removed.length} removed, ${images.kept.length} kept`)
127
+
128
+ // 6. root
129
+ steps.begin('root')
130
+ const launcherFate = removeLauncher(launcher, root)
131
+ say(launcherFate === 'removed' ? `removed the launcher ${launcher}`
132
+ : launcherFate === 'foreign' ? `kept the launcher ${launcher}: it belongs to another installation root`
133
+ : `no launcher at ${launcher}`)
134
+ removeRoot(root)
135
+ say(`removed ${root}`)
136
+
137
+ say('')
138
+ say(`Curia is purged. The installation root ${root} is removed.`)
139
+ say(` removed: the root with config/, secrets/, state/, and work/, the launcher, the installation's containers, networks, and volumes, and ${images.removed.length === 1 ? '1 release image' : `${images.removed.length} release images`}`)
140
+ if (images.kept.length > 0) say(` kept: ${images.kept.length === 1 ? '1 release image' : `${images.kept.length} release images`} still in use on this host (listed under [5/6] images)`)
141
+ say(` reinstall: a later install starts a new installation with a new ID; the removed one cannot come back`)
142
+ say('')
143
+ say('External resources Curia never deletes. Deleting the local secret files revokes nothing: each credential stays valid until you remove it where it was issued.')
144
+ for (const line of external) say(` ${line}`)
145
+ for (const route of recordedRoutes) {
146
+ const withdrawn = routes.withdrawn.some((r) => r.https === route.https && r.target === route.target)
147
+ const absent = routes.absent.some((r) => r.https === route.https && r.target === route.target) && !routes.unreachable
148
+ say(` Serve route https://:${route.https} -> ${route.target}: ${withdrawn ? 'withdrawn' : absent ? 'was not standing' : `still recorded when the root was removed; run 'tailscale serve --https=${route.https} off' on the node`}`)
149
+ }
150
+ say(' Model-provider logins (Anthropic, OpenAI): revoke them in each provider\'s account settings if you won\'t reinstall; only the local copies were deleted.')
151
+ return EXIT.ok
152
+ })
153
+ } catch (e) {
154
+ throw steps.wrap(e)
155
+ }
156
+ }
157
+
158
+ // `--confirm <root>` or `--confirm=<root>`; anything else on the line is a
159
+ // usage error before the command runs.
160
+ export function confirmationFromArgs(args) {
161
+ let value = null
162
+ for (let i = 0; i < args.length; i++) {
163
+ const arg = args[i]
164
+ if (arg === CONFIRM_OPTION) {
165
+ if (i + 1 >= args.length || args[i + 1].startsWith('-')) throw new UsageError(`${CONFIRM_OPTION} needs the installation root as its value`)
166
+ value = args[++i]
167
+ } else if (arg.startsWith(`${CONFIRM_OPTION}=`)) {
168
+ value = arg.slice(CONFIRM_OPTION.length + 1)
169
+ if (value === '') throw new UsageError(`${CONFIRM_OPTION} needs the installation root as its value`)
170
+ } else if (arg.startsWith('-')) {
171
+ throw new UsageError(`unknown option: ${arg}`)
172
+ } else {
173
+ throw new UsageError(`unexpected argument: ${arg}`)
174
+ }
175
+ }
176
+ return value
177
+ }
178
+
179
+ // The question on the operator's terminal, answered once.
180
+ function askOnTerminal(question) {
181
+ return new Promise((resolve) => {
182
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
183
+ rl.question(question, (answer) => {
184
+ rl.close()
185
+ resolve(answer)
186
+ })
187
+ })
188
+ }
189
+
190
+ // Removes the launcher when it names this root. A launcher of another root
191
+ // is another installation's and stays. The same rule as uninstall.
192
+ function removeLauncher(launcher, root) {
193
+ let text
194
+ try {
195
+ text = readFileSync(launcher, 'utf8')
196
+ } catch (e) {
197
+ if (e.code === 'ENOENT') return 'absent'
198
+ throw e
199
+ }
200
+ if (!text.includes(`CURIA_ROOT='${root}'`)) return 'foreign'
201
+ unlinkSync(launcher)
202
+ return 'removed'
203
+ }
204
+
205
+ // Removes the root with the installation record last: everything beside
206
+ // `state/` first, then everything in `state/` beside the record, then the
207
+ // record with the rest. A failure anywhere before the end leaves a root that
208
+ // `openRoot` still recognizes, so the rerun is a purge and not a refusal of
209
+ // an unknown directory.
210
+ function removeRoot(root) {
211
+ const record = recordPath(root)
212
+ const stateDir = join(root, 'state')
213
+ for (const entry of readdirSync(root)) {
214
+ if (entry === 'state') continue
215
+ rmSync(join(root, entry), { recursive: true, force: true })
216
+ }
217
+ if (existsSync(stateDir)) {
218
+ for (const entry of readdirSync(stateDir)) {
219
+ const file = join(stateDir, entry)
220
+ if (file === record) continue
221
+ rmSync(file, { recursive: true, force: true })
222
+ }
223
+ }
224
+ rmSync(root, { recursive: true, force: true })
225
+ }
226
+
227
+ // Used by the command table.
228
+ export function purgeCommandRun(context, deps) {
229
+ return runPurge(context, deps)
230
+ }
@@ -0,0 +1,146 @@
1
+ import { IMAGE_REGISTRY, INSTALLATION_LABEL, RELEASE_IMAGES } from './bundle.mjs'
2
+ import { dockerRunner } from './compose.mjs'
3
+
4
+ // The Docker resources of one installation, found by label and by nothing
5
+ // else (#886 and #887, implementing #855).
6
+ //
7
+ // Every container, network, and volume Curia creates for an installation
8
+ // carries `sh.curia.installation=<installation ID>`: the Compose bundle puts
9
+ // it on the five services, the project network, and the tmux socket volume,
10
+ // and the service puts it on every agent container and the agent cache
11
+ // volumes it creates. So one filter names what belongs to an installation on
12
+ // a host, whatever the Compose project's files are in, and a name prefix is
13
+ // never read as ownership. A container of another installation, an
14
+ // operator's own container, and a probe container without the label are
15
+ // never listed and never touched.
16
+ //
17
+ // Uninstall stops and removes them; purge does the same and then removes
18
+ // the release images. Both read what is there before they remove it, so a
19
+ // rerun over a host where nothing is left lists nothing and removes nothing.
20
+
21
+ export class DockerError extends Error {
22
+ constructor(message) {
23
+ super(message)
24
+ this.name = 'DockerError'
25
+ }
26
+ }
27
+
28
+ const filter = (installationId) => ['--filter', `label=${INSTALLATION_LABEL}=${installationId}`]
29
+
30
+ // The labelled resources: `{ containers: [{ id, name, running }], networks:
31
+ // [{ id, name }], volumes: [{ name }] }`.
32
+ export async function installationResources(installationId, { docker = dockerRunner } = {}) {
33
+ const containers = (await run(docker, ['ps', '--all', '--no-trunc', ...filter(installationId), '--format', '{{.ID}}\t{{.Names}}\t{{.State}}']))
34
+ .map((line) => {
35
+ const [id, name, state] = line.split('\t')
36
+ return { id, name, running: state === 'running' }
37
+ })
38
+ const networks = (await run(docker, ['network', 'ls', '--no-trunc', ...filter(installationId), '--format', '{{.ID}}\t{{.Name}}']))
39
+ .map((line) => {
40
+ const [id, name] = line.split('\t')
41
+ return { id, name }
42
+ })
43
+ const volumes = (await run(docker, ['volume', 'ls', ...filter(installationId), '--format', '{{.Name}}']))
44
+ .map((name) => ({ name }))
45
+ return { containers, networks, volumes }
46
+ }
47
+
48
+ // Stops every running labelled container, then removes every labelled
49
+ // container, network, and volume, in that order, so the network and the
50
+ // volumes are free when they are removed. Returns what was removed. A stop
51
+ // gives each process its termination signal and the usual grace before the
52
+ // kill, which is what lets the service close its journal; nothing waits for
53
+ // a session to finish, and nothing drains one.
54
+ export async function removeInstallationResources(installationId, { docker = dockerRunner, stdout } = {}) {
55
+ const found = await installationResources(installationId, { docker })
56
+ const running = found.containers.filter((c) => c.running)
57
+ if (running.length > 0) {
58
+ stdout?.write(`stopping ${describe(running.map((c) => c.name), 'container')}\n`)
59
+ await run(docker, ['stop', ...running.map((c) => c.id)])
60
+ }
61
+ if (found.containers.length > 0) {
62
+ stdout?.write(`removing ${describe(found.containers.map((c) => c.name), 'container')}\n`)
63
+ await run(docker, ['rm', '--force', '--volumes', ...found.containers.map((c) => c.id)])
64
+ }
65
+ if (found.networks.length > 0) {
66
+ stdout?.write(`removing ${describe(found.networks.map((n) => n.name), 'network')}\n`)
67
+ await run(docker, ['network', 'rm', ...found.networks.map((n) => n.id)])
68
+ }
69
+ if (found.volumes.length > 0) {
70
+ stdout?.write(`removing ${describe(found.volumes.map((v) => v.name), 'volume')}\n`)
71
+ await run(docker, ['volume', 'rm', '--force', ...found.volumes.map((v) => v.name)])
72
+ }
73
+ return found
74
+ }
75
+
76
+ // The release images on the host: every image under one of the four exact
77
+ // repositories `RELEASE_IMAGES` names, whatever release it belongs to, as
78
+ // `{ id, reference }` with the reference `<repository>@<digest>` (or
79
+ // `<repository>:<tag>` for one pulled by tag). Images are not labelled with
80
+ // an installation, because two installations on one host share a pulled
81
+ // image, so the repository is the identity; a name that merely starts with
82
+ // `curia-` is never read as a release image.
83
+ export async function releaseImages({ docker = dockerRunner } = {}) {
84
+ const seen = new Map()
85
+ for (const name of Object.values(RELEASE_IMAGES)) {
86
+ const repository = `${IMAGE_REGISTRY}/${name}`
87
+ const rows = await run(docker, ['image', 'ls', '--no-trunc', '--digests', '--filter', `reference=${repository}`, '--format', '{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Digest}}'])
88
+ for (const line of rows) {
89
+ const [id, repo, tag, digest] = line.split('\t')
90
+ const reference = digest && digest !== '<none>' ? `${repo}@${digest}` : `${repo}:${tag}`
91
+ if (!seen.has(id)) seen.set(id, { id, reference })
92
+ }
93
+ }
94
+ return [...seen.values()]
95
+ }
96
+
97
+ // Removes every release image that Docker proves unused, and keeps the rest
98
+ // with the reason. Unused means: `docker ps --all --filter ancestor=<id>`
99
+ // lists no container, of this installation or any other, and Docker itself
100
+ // accepts the removal without `--force`. An image Docker refuses (a container
101
+ // created between the two calls, a dependent image) is kept and reported,
102
+ // not a failure: images are shared host resources, and keeping one leaves
103
+ // nothing of the installation behind. Returns `{ found, removed, kept }`
104
+ // with `kept` as `[{ reference, reason }]`.
105
+ export async function removeReleaseImages({ docker = dockerRunner, stdout } = {}) {
106
+ const found = await releaseImages({ docker })
107
+ const removed = []
108
+ const kept = []
109
+ for (const image of found) {
110
+ const users = (await run(docker, ['ps', '--all', '--no-trunc', '--filter', `ancestor=${image.id}`, '--format', '{{.ID}}\t{{.Names}}']))
111
+ .map((line) => line.split('\t')[1])
112
+ if (users.length > 0) {
113
+ const reason = `in use by ${describe(users, 'container')}`
114
+ stdout?.write(`kept the image ${image.reference}: ${reason}\n`)
115
+ kept.push({ reference: image.reference, reason })
116
+ continue
117
+ }
118
+ const result = await docker(['image', 'rm', image.id])
119
+ if (!result.ok) {
120
+ const reason = `docker refused: ${lastLines(result)}`
121
+ stdout?.write(`kept the image ${image.reference}: ${reason}\n`)
122
+ kept.push({ reference: image.reference, reason })
123
+ continue
124
+ }
125
+ stdout?.write(`removed the image ${image.reference}\n`)
126
+ removed.push(image)
127
+ }
128
+ return { found, removed, kept }
129
+ }
130
+
131
+ function describe(names, noun) {
132
+ return `${names.length} ${noun}${names.length === 1 ? '' : 's'}: ${names.join(', ')}`
133
+ }
134
+
135
+ // One `docker` invocation whose output is read line by line.
136
+ async function run(docker, args) {
137
+ const result = await docker(args)
138
+ if (!result.ok) {
139
+ throw new DockerError(`docker ${args.join(' ')} failed:\n${lastLines(result)}`)
140
+ }
141
+ return String(result.stdout ?? '').split('\n').map((l) => l.trim()).filter((l) => l !== '')
142
+ }
143
+
144
+ function lastLines(result) {
145
+ return result.missing ? 'docker is not on the path' : (result.stderr || result.stdout || `exit ${result.code}`).trim().split('\n').slice(-5).join('\n')
146
+ }
@@ -0,0 +1,131 @@
1
+ import { readdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { EXIT, Refusal } from './exit.mjs'
5
+ import { dockerRunner } from './compose.mjs'
6
+ import { operatorConfigPath } from './config.mjs'
7
+ import { launcherPath } from './launcher.mjs'
8
+ import { withLifecycleLock } from './lock.mjs'
9
+ import { hostProbes, preflight } from './preflight.mjs'
10
+ import { openRoot, versionPaths } from './root.mjs'
11
+ import { IncompatibleRelease, isCompleteStage, validateWithRelease } from './stage.mjs'
12
+ import { namedSteps } from './steps.mjs'
13
+ import { switchRelease } from './switch.mjs'
14
+
15
+ // `curia rollback` (#885, implementing #854): the switch of `curia update`
16
+ // with the versions swapped, from the active release to the one rollback
17
+ // release, as four named steps.
18
+ //
19
+ // preflight the root boundary (`openRoot`, which must find an
20
+ // installation) and the host preflight. Nothing changes.
21
+ // select under the lifecycle lock from here to the end: the rollback
22
+ // release is the one complete release under versions/ that is
23
+ // not the active one. Curia keeps exactly one after a
24
+ // successful switch, so none means there was no update to roll
25
+ // back, and two means a failed update left its staged target
26
+ // beside the rollback release, and the operator says which one
27
+ // goes. Both are refusals; nothing is guessed.
28
+ // validate the rollback release validates the current operator
29
+ // configuration with its own reader (`validateWithRelease`,
30
+ // the door `curia update` validates through). A configuration
31
+ // the rollback release refuses is the blocking incompatibility
32
+ // the contract names: the rollback is refused, exit `refused`,
33
+ // and the running release is not touched.
34
+ // switch `switchRelease`, the same door as the update's `switch`
35
+ // step: the core services are recreated from the rollback
36
+ // release's bundle while tmux, ttyd, and the agent containers
37
+ // keep running, every service must be healthy, the service and
38
+ // the app must report the rollback release, and every live
39
+ // session must be adopted back; then the record names the
40
+ // rollback release and the release that was active is kept as
41
+ // the new rollback release. A failure switches back once to
42
+ // the release that was running and leaves the record alone.
43
+ //
44
+ // `config/`, `secrets/`, `state/`, and `work/` are never read for writing
45
+ // here: the record is the one file the rollback changes, and the switch
46
+ // changes it only after acceptance.
47
+
48
+ export const ROLLBACK_STEPS = Object.freeze(['preflight', 'select', 'validate', 'switch'])
49
+
50
+ // The command's seam. `context` is what `runCli` hands a command. `deps` are
51
+ // the boundaries a test replaces: the host probes, the rollback release's
52
+ // configuration validator, the Docker runner, the loopback `fetch`, and the
53
+ // clock the waits use.
54
+ export async function runRollback(
55
+ { env, args = [], stdout, uid, gid, root },
56
+ { hostProbes: host = hostProbes, validateTarget = validateWithRelease, docker = dockerRunner, fetch: fetchImpl = globalThis.fetch, sleep, now } = {},
57
+ ) {
58
+ const command = [launcherPath(env), 'rollback', ...args].join(' ')
59
+ const steps = namedSteps({ steps: ROLLBACK_STEPS, stdout, rerun: (step) => `Run '${command}' to run ${step} again; the completed steps are kept.` })
60
+ const say = (text) => stdout.write(`${text}\n`)
61
+
62
+ try {
63
+ // 1. preflight
64
+ steps.begin('preflight')
65
+ const opened = openRoot(root, { uid })
66
+ if (opened.status !== 'installed') {
67
+ throw new Refusal(`${root} holds no installation, so there is nothing to roll back. Run the bootstrap to install Curia there.`)
68
+ }
69
+ const record = opened.record
70
+ const active = record.activeVersion
71
+ const hostReport = await preflight({ uid, root, stdout }, host)
72
+ if (!hostReport.ok) throw hostReport.refusal
73
+ const dockerGid = hostReport.facts.docker.group.gid
74
+
75
+ return await withLifecycleLock(root, async () => {
76
+ // 2. select
77
+ steps.begin('select')
78
+ const { version: previous, candidates } = rollbackRelease(root, active)
79
+ if (previous === null) {
80
+ if (candidates.length === 0) {
81
+ throw new Refusal(`versions/ holds no release beside the active one, ${active}, so there is nothing to roll back to. Curia keeps the release you updated from after a successful 'curia update', and only that one.`)
82
+ }
83
+ throw new Refusal(`versions/ holds ${candidates.length === 2 ? 'two' : candidates.length} releases beside the active one, ${active}: ${candidates.join(' and ')}. One of them is the release you updated from and the other is the staged target of an update that did not switch. Finish the update with 'curia update', or remove the staged release you don't want from ${join(root, 'versions')}, then run 'curia rollback' again.`)
84
+ }
85
+ say(`rolling back from ${active} to ${previous}`)
86
+
87
+ // 3. validate
88
+ steps.begin('validate')
89
+ try {
90
+ await validateTarget({ root, version: previous, dir: versionPaths(root, previous).dir })
91
+ } catch (e) {
92
+ if (!(e instanceof IncompatibleRelease)) throw e
93
+ const action = e.reason === 'configuration'
94
+ ? `Fix the file so that both releases accept it, or stay on ${active}.`
95
+ : `${previous} is older than the operator configuration contract, so Curia cannot prove it reads this installation.`
96
+ throw new Refusal(`${e.message} ${action} The active version is unchanged.`)
97
+ }
98
+ say(`${previous} accepts the current operator configuration at ${operatorConfigPath(root)}`)
99
+
100
+ // 4. switch
101
+ steps.begin('switch')
102
+ await switchRelease(
103
+ { root, from: active, to: previous, record, environment: { uid, gid, dockerGid, installationId: record.installationId }, stdout },
104
+ { docker, fetch: fetchImpl, sleep, now },
105
+ )
106
+ say('')
107
+ say(`Curia ${previous} is running. Open the Curia app as before; nothing in integration setup has to be repeated.`)
108
+ say(`${active} is now the rollback release: 'curia rollback' switches forward to it, and 'curia update' replaces it.`)
109
+ return EXIT.ok
110
+ })
111
+ } catch (e) {
112
+ throw steps.wrap(e)
113
+ }
114
+ }
115
+
116
+ // The rollback release under `root`: the one complete release under
117
+ // versions/ that is not `active`. Returns `{ version, candidates }`, with
118
+ // `version` null when there is none or more than one. A dot directory is a
119
+ // staging leftover and never a candidate; an incomplete directory is not one
120
+ // either (the switch removes both).
121
+ export function rollbackRelease(root, active, { complete = isCompleteStage } = {}) {
122
+ const candidates = readdirSync(join(root, 'versions'))
123
+ .filter((name) => !name.startsWith('.') && name !== active && complete(join(root, 'versions', name)))
124
+ .sort()
125
+ return { version: candidates.length === 1 ? candidates[0] : null, candidates }
126
+ }
127
+
128
+ // Used by the command table.
129
+ export function rollbackCommand(context, deps) {
130
+ return runRollback(context, deps)
131
+ }