@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/exit.mjs ADDED
@@ -0,0 +1,32 @@
1
+ // The four exit codes every lifecycle command and the launcher use.
2
+ //
3
+ // ok the command did what it said.
4
+ // failed the command started the operation and the operation failed.
5
+ // The installation may have changed. The message says what to do next.
6
+ // usage the command line was wrong. Nothing ran.
7
+ // refused Curia refused to start the operation. Nothing changed. The message
8
+ // names the condition and one corrective action.
9
+ //
10
+ // A script can branch on these numbers. The launcher exits `refused` when the
11
+ // active version is incomplete, so the same code means the same thing whether
12
+ // the refusal came from the shell script or from the lifecycle interface.
13
+ export const EXIT = Object.freeze({ ok: 0, failed: 1, usage: 2, refused: 3 })
14
+
15
+ // A refusal a command raises before it changes anything. `runCli` turns it into
16
+ // `EXIT.refused` and prints the message on stderr, so a command states the
17
+ // condition once and never touches the exit code itself.
18
+ export class Refusal extends Error {
19
+ constructor(message) {
20
+ super(message)
21
+ this.name = 'Refusal'
22
+ }
23
+ }
24
+
25
+ // A command line the command cannot act on. `runCli` turns it into
26
+ // `EXIT.usage` and points at `curia help`; nothing ran.
27
+ export class UsageError extends Error {
28
+ constructor(message) {
29
+ super(message)
30
+ this.name = 'UsageError'
31
+ }
32
+ }
@@ -0,0 +1,194 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import { EXIT, Refusal } from './exit.mjs'
5
+ import { BOOTSTRAP_COMMAND } from './acquire.mjs'
6
+ import { writeAtomically } from './atomic.mjs'
7
+ import { composeProject, dockerRunner, startProject, waitForHealth, writeComposeEnvironment } from './compose.mjs'
8
+ import { initialOperatorConfig, operatorConfigPath, writeOperatorConfig } from './config.mjs'
9
+ import { launcherPath, renderLauncher } from './launcher.mjs'
10
+ import { serviceLayout } from './layout.mjs'
11
+ import { withLifecycleLock } from './lock.mjs'
12
+ import { releaseProbes } from './manifest.mjs'
13
+ import { hostProbes, preflight } from './preflight.mjs'
14
+ import { createInstallationRecord, ensureLayout, openRoot, versionPaths, writeInstallationRecord } from './root.mjs'
15
+ import { isCompleteStage, placeVersion, stagedVersion, verifyRetained } from './stage.mjs'
16
+
17
+ // `curia install` and `curia reinstall` (#873, implementing #851, #854, #857,
18
+ // and #862): from a verified stage to a healthy packaged Curia and a reachable
19
+ // app, as one linear sequence of six named steps.
20
+ //
21
+ // preflight the root boundary (`openRoot`) and the host preflight. Nothing
22
+ // on disk changes. A refusal here creates no root.
23
+ // root the root and its seven boundaries exist, the lifecycle lock is
24
+ // held from here to the end, the installation record names the
25
+ // version, and a fresh root gets the initial operator
26
+ // configuration. A recognized root keeps its installation ID,
27
+ // config/, secrets/, state/, and work/: that is a reinstall.
28
+ // stage the staged artifacts are verified against the release manifest
29
+ // and land under versions/<version>/ as one complete version,
30
+ // read-only, replacing that directory if it was there.
31
+ // activate the record names the version and the launcher is written.
32
+ // Any other directory under versions/ is removed, so one release
33
+ // is installed.
34
+ // start the Compose environment and the mount sources exist, the
35
+ // images are pulled by digest, and the project is up.
36
+ // health every declared service reports healthy.
37
+ //
38
+ // Every step is idempotent by inspection, not by a persisted operation record:
39
+ // a rerun repeats the cheap steps, finds the expensive ones done, and so lands
40
+ // at the step that failed. A failure names the current step and the command
41
+ // that reruns it. There is no operation engine, no repair mode, and no retry
42
+ // loop beyond the health wait.
43
+ //
44
+ // The stage comes from the bootstrap as CURIA_STAGE, holding `node/`, `cli/`,
45
+ // `cli.tgz`, `bundle.tar.gz`, and `bundle.tar.gz.sha256`. The bootstrap removes
46
+ // it when this command returns, so the stage is copied, never moved. Without
47
+ // CURIA_STAGE, which is how the installed launcher reruns the command, the
48
+ // version already under versions/<version>/ is verified and reused.
49
+ //
50
+ // The version installed is always this interface's own version: the bootstrap
51
+ // runs the staged package, and the launcher runs the installed one. Installing
52
+ // another version is `curia update` (#883), which stages through the same
53
+ // `placeVersion` in stage.mjs.
54
+
55
+ export const INSTALL_STEPS = Object.freeze(['preflight', 'root', 'stage', 'activate', 'start', 'health'])
56
+
57
+ // The Tailscale Serve port of the Curia app, `dashboard.serve_port` in
58
+ // config/curia.yaml. daemon/test/preflightports.test.mjs keeps them equal.
59
+ export const APP_SERVE_PORT = 8445
60
+
61
+ export const packageVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
62
+
63
+ // The command's seam. `context` is what `runCli` hands a command plus `gid`
64
+ // and `mode` (`install` or `reinstall`). `deps` are the boundaries a test
65
+ // replaces: the host probes, the release probes, the Docker runner, and the
66
+ // clock the health wait uses.
67
+ export async function runInstall(
68
+ { env, stdout, uid, gid, root, mode = 'install' },
69
+ { hostProbes: host = hostProbes, releaseProbes: release = releaseProbes, docker = dockerRunner, sleep, now } = {},
70
+ ) {
71
+ const version = packageVersion
72
+ const launcher = launcherPath(env)
73
+ const steps = sequence({ stdout, launcher, mode })
74
+ const say = (text) => stdout.write(`${text}\n`)
75
+
76
+ try {
77
+ // 1. preflight
78
+ steps.begin('preflight')
79
+ const opened = openRoot(root, { uid })
80
+ const hostReport = await preflight({ uid, root, stdout }, host)
81
+ if (!hostReport.ok) throw hostReport.refusal
82
+ const dockerGid = hostReport.facts.docker.group.gid
83
+ const appHost = hostReport.facts.tailscale.certDomains[0]
84
+
85
+ // 2. root
86
+ steps.begin('root')
87
+ if (mode === 'reinstall' && opened.status !== 'installed') {
88
+ throw new Refusal(`${root} holds no installation, so there is nothing to reinstall. Run the bootstrap to install Curia there.`)
89
+ }
90
+ const reinstalling = opened.status === 'installed'
91
+ say(reinstalling ? `reinstalling ${version} over the installation at ${root} (installation ${opened.record.installationId})` : `creating the installation root at ${root}`)
92
+ ensureLayout(root, { uid })
93
+
94
+ return await withLifecycleLock(root, async () => {
95
+ const record = opened.record ?? createInstallationRecord(version)
96
+ if (!opened.record) writeInstallationRecord(root, record)
97
+ const configPath = operatorConfigPath(root)
98
+ if (!existsSync(configPath)) {
99
+ writeOperatorConfig(configPath, initialOperatorConfig())
100
+ say(`wrote the initial operator configuration to ${configPath}`)
101
+ } else {
102
+ say(`keeping ${configPath}`)
103
+ }
104
+
105
+ // 3. stage
106
+ steps.begin('stage')
107
+ const paths = versionPaths(root, version)
108
+ const stage = env.CURIA_STAGE
109
+ if (stage) {
110
+ if (!isCompleteStage(stage)) {
111
+ throw new Refusal(`the stage ${stage} is incomplete. Run the bootstrap again; it downloads a complete stage.`)
112
+ }
113
+ const staged = stagedVersion(stage)
114
+ if (staged !== version) {
115
+ throw new Refusal(`the stage holds @curia-sh/cli ${staged}, but this lifecycle interface is ${version}, and an installation is always its own version. Run the bootstrap again so it installs one version end to end.`)
116
+ }
117
+ await placeVersion({ root, version, stage, stdout }, release)
118
+ } else if (isCompleteStage(paths.dir)) {
119
+ say(`${version} is already installed under ${paths.dir}; verifying the retained artifacts`)
120
+ await verifyRetained({ version, dir: paths.dir, stdout }, release)
121
+ } else {
122
+ throw new Refusal(`no release to install: CURIA_STAGE is not set and ${paths.dir} holds no complete version. Run the bootstrap: ${BOOTSTRAP_COMMAND}`)
123
+ }
124
+
125
+ // 4. activate
126
+ steps.begin('activate')
127
+ writeInstallationRecord(root, { ...record, activeVersion: version })
128
+ mkdirSync(dirname(launcher), { recursive: true })
129
+ writeAtomically(launcher, renderLauncher({ root }), { mode: 0o755 })
130
+ for (const other of readdirSync(join(root, 'versions'))) {
131
+ if (other !== version) rmSync(join(root, 'versions', other), { recursive: true, force: true })
132
+ }
133
+ say(`${version} is the active version; the launcher is ${launcher}`)
134
+
135
+ // 5. start
136
+ steps.begin('start')
137
+ const project = composeProject({ root, version })
138
+ writeComposeEnvironment(project, { uid, gid, dockerGid, installationId: record.installationId })
139
+ const layout = serviceLayout(root)
140
+ for (const dir of [layout.home, layout.overseerRepos, layout.overseerTokens, layout.overseerConfigDir]) {
141
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
142
+ chmodSync(dir, 0o700)
143
+ }
144
+ await startProject(project, { docker, stdout })
145
+
146
+ // 6. health
147
+ steps.begin('health')
148
+ await waitForHealth(project, { docker, sleep, now, stdout })
149
+
150
+ const app = `https://${appHost}:${APP_SERVE_PORT}/`
151
+ say('')
152
+ say(`Curia ${version} is installed and running.`)
153
+ say(` installation root: ${root}`)
154
+ say(` launcher: ${launcher}`)
155
+ say(` Curia app: ${app}`)
156
+ say('')
157
+ say(`Next: open the Curia app at ${app} from a device on your tailnet and start integration setup. It connects GitHub, Discord, Tailscale, and one model provider, then runs the Full loop.`)
158
+ return EXIT.ok
159
+ })
160
+ } catch (e) {
161
+ throw steps.wrap(e)
162
+ }
163
+ }
164
+
165
+ // The step sequence: prints each header, remembers the current step, and
166
+ // turns an error into one that names the step and the command that reruns
167
+ // it. Before the launcher exists the rerun is the bootstrap.
168
+ function sequence({ stdout, launcher, mode }) {
169
+ let current = null
170
+ let index = 0
171
+ return {
172
+ begin(name) {
173
+ current = name
174
+ index = INSTALL_STEPS.indexOf(name) + 1
175
+ stdout.write(`[${index}/${INSTALL_STEPS.length}] ${name}\n`)
176
+ },
177
+ wrap(e) {
178
+ if (current === null) return e
179
+ if (e instanceof Refusal) return new Refusal(`${current}: ${e.message}`)
180
+ const rerun = existsSync(launcher)
181
+ ? `Run '${launcher} ${mode}' to run ${current} again; the completed steps are kept.`
182
+ : `Fix the cause and run the bootstrap again; it resumes at ${current}.`
183
+ const wrapped = new Error(`${current} failed: ${e.message}\n${rerun}`)
184
+ wrapped.cause = e
185
+ return wrapped
186
+ },
187
+ }
188
+ }
189
+
190
+ // Used by the command table: the two commands are one sequence with one
191
+ // difference, whether a root that holds no installation is acceptable.
192
+ export function installCommand(mode) {
193
+ return (context, deps) => runInstall({ ...context, mode }, deps)
194
+ }
@@ -0,0 +1,53 @@
1
+ import { join } from 'node:path'
2
+
3
+ // The stable host launcher: `~/.local/bin/curia`.
4
+ //
5
+ // The bootstrap writes it once per installation and no update rewrites it. It
6
+ // is one POSIX shell script with the installation root written into it, so a
7
+ // nondefault root stays explicit without the operator typing it. On every run
8
+ // it reads `state/installation.json`, picks the version the record names, and
9
+ // execs that version's pinned Node runtime on that version's entry point with
10
+ // CURIA_ROOT exported. It carries no other logic: the lifecycle interface under
11
+ // the active version owns everything else, which is what lets an update change
12
+ // the interface without touching the launcher.
13
+ //
14
+ // It exits `refused` (3) when the record is missing or the active version is
15
+ // incomplete, and says which file is missing. That is the launcher's one
16
+ // refusal, and it matches the lifecycle interface's own refused code.
17
+
18
+ export function launcherPath(env) {
19
+ return join(env.HOME ?? '', '.local', 'bin', 'curia')
20
+ }
21
+
22
+ export function renderLauncher({ root }) {
23
+ if (root.includes("'")) throw new Error(`the installation root must not contain a single quote: ${root}`)
24
+ return `#!/bin/sh
25
+ # Curia launcher. Written by the Curia bootstrap; an update never rewrites it.
26
+ # It runs the lifecycle interface of the active installed version.
27
+ CURIA_ROOT='${root}'
28
+ export CURIA_ROOT
29
+
30
+ record="$CURIA_ROOT/state/installation.json"
31
+ if [ ! -r "$record" ]; then
32
+ echo "curia: no installation record at $record. Run the bootstrap again to reinstall, or delete the launcher if Curia was purged." >&2
33
+ exit 3
34
+ fi
35
+
36
+ version=$(sed -n 's/^[[:space:]]*"activeVersion"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*$/\\1/p' "$record" | head -n 1)
37
+ if [ -z "$version" ]; then
38
+ echo "curia: $record names no active version. Run the bootstrap again to reinstall." >&2
39
+ exit 3
40
+ fi
41
+
42
+ node="$CURIA_ROOT/versions/$version/node/bin/node"
43
+ entry="$CURIA_ROOT/versions/$version/cli/bin/curia.mjs"
44
+ for required in "$node" "$entry"; do
45
+ if [ ! -f "$required" ]; then
46
+ echo "curia: the active version $version is incomplete: $required is missing. Run the bootstrap again to reinstall the active version." >&2
47
+ exit 3
48
+ fi
49
+ done
50
+
51
+ exec "$node" "$entry" "$@"
52
+ `
53
+ }
package/src/layout.mjs ADDED
@@ -0,0 +1,95 @@
1
+ import { isAbsolute, join } from 'node:path'
2
+
3
+ // Where the service data of an installation lives, inside the seven boundaries
4
+ // of the installation root (#867, implementing #851).
5
+ //
6
+ // One function answers the question for every process: the lifecycle interface
7
+ // that writes the Compose project, the daemon that writes the paths, the
8
+ // overseer that reads them, and the tests that inspect the Compose bundle. Each
9
+ // path sits in the boundary whose lifecycle class it has, so the survival
10
+ // contract of the root (`config/`, `secrets/`, `state/`, and `work/` preserved;
11
+ // `versions/`, `cache/`, and `run/` replaceable) applies to it with no extra
12
+ // rule.
13
+ //
14
+ // state/ the journal, attachments, results, backups, the
15
+ // preview registry, and the daemon's own token stores
16
+ // work/ worktrees, review checkouts, and the per-session
17
+ // config directories, which the daemon calls its
18
+ // workspace root
19
+ // work/cfg/curia-overseer the overseer's config directory and native sessions
20
+ // cache/home HOME in every service container: tool caches and
21
+ // nothing Curia has to keep
22
+ // cache/overseer-repos the overseer's mirrors of origin
23
+ // run/overseer-tokens the overseer's renewable installation tokens, one
24
+ // file per owner, rewritten by the daemon
25
+ //
26
+ // The secret files themselves are the catalogue in `secrets.mjs`.
27
+ export function serviceLayout(root) {
28
+ if (typeof root !== 'string' || !isAbsolute(root)) {
29
+ throw new Error(`the installation root must be an absolute path, got ${root}`)
30
+ }
31
+ const at = (...parts) => join(root, ...parts)
32
+ return Object.freeze({
33
+ root,
34
+ config: at('config'),
35
+ secrets: at('secrets'),
36
+ state: at('state'),
37
+ work: at('work'),
38
+ cache: at('cache'),
39
+ run: at('run'),
40
+ versions: at('versions'),
41
+ overseerConfigDir: at('work', 'cfg', 'curia-overseer'),
42
+ home: at('cache', 'home'),
43
+ overseerRepos: at('cache', 'overseer-repos'),
44
+ overseerTokens: at('run', 'overseer-tokens'),
45
+ })
46
+ }
47
+
48
+ // The five long-running services of an installation, in Compose order.
49
+ export const SERVICES = Object.freeze(['daemon', 'tmux', 'ttyd', 'dashboard', 'overseer'])
50
+
51
+ // What each container may see of the installation root, as layout paths and
52
+ // modes. This is the container-access contract of #851, and the Compose bundle
53
+ // is inspected against it:
54
+ //
55
+ // - the service reads `config/` and writes its `config.yaml` for the app's
56
+ // settings screen, owns every secret file, writes the narrow state,
57
+ // work, cache, and runtime paths it uses, and reads `versions/` so its
58
+ // daily update check (#883) verifies the stable-release index with the
59
+ // key the active version's package carries;
60
+ // - the tmux runtime holds the panes that run `docker run` against host
61
+ // paths, so it sees the work tree and the shared home and nothing else;
62
+ // - the attach surface sees nothing of the root, only the tmux socket;
63
+ // - the app sees nothing of the root and reaches configuration through the
64
+ // service;
65
+ // - the overseer sees its own config directory, its mirrors, and its
66
+ // renewable tokens read-only. Its model credential reaches it as a copy in
67
+ // its config directory, written by the service, so no secret file is
68
+ // mounted into the container that holds a shell.
69
+ export const SERVICE_MOUNTS = Object.freeze({
70
+ daemon: Object.freeze([
71
+ Object.freeze({ path: 'config', mode: 'rw' }),
72
+ Object.freeze({ path: 'secrets', mode: 'rw' }),
73
+ Object.freeze({ path: 'state', mode: 'rw' }),
74
+ Object.freeze({ path: 'work', mode: 'rw' }),
75
+ Object.freeze({ path: 'cache', mode: 'rw' }),
76
+ Object.freeze({ path: 'run', mode: 'rw' }),
77
+ Object.freeze({ path: 'versions', mode: 'ro' }),
78
+ ]),
79
+ tmux: Object.freeze([
80
+ Object.freeze({ path: 'work', mode: 'rw' }),
81
+ Object.freeze({ path: 'home', mode: 'rw' }),
82
+ ]),
83
+ ttyd: Object.freeze([]),
84
+ dashboard: Object.freeze([]),
85
+ overseer: Object.freeze([
86
+ Object.freeze({ path: 'overseerConfigDir', mode: 'rw' }),
87
+ Object.freeze({ path: 'overseerRepos', mode: 'rw' }),
88
+ Object.freeze({ path: 'overseerTokens', mode: 'ro' }),
89
+ ]),
90
+ })
91
+
92
+ // The containers that may reach the Docker socket: the service, which runs
93
+ // agent containers as siblings, and the tmux runtime, whose panes run
94
+ // `docker run`. The app, the attach surface, and the overseer never do.
95
+ export const DOCKER_SOCKET_SERVICES = Object.freeze(['daemon', 'tmux'])
package/src/lock.mjs ADDED
@@ -0,0 +1,74 @@
1
+ import { closeSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { Refusal } from './exit.mjs'
5
+
6
+ // The lifecycle-operation lock: `run/lifecycle.lock` inside the installation
7
+ // root. One lifecycle operation runs at a time per installation. The lock file
8
+ // is created exclusively and holds the owning process id, so a second `curia`
9
+ // invocation refuses with that id instead of racing an install, update, or
10
+ // uninstall that is half done.
11
+ //
12
+ // `run/` is restart-disposable, and a crash can leave the file behind. A lock
13
+ // whose process no longer exists is taken over: the stale file is moved aside
14
+ // first, so two takers cannot both unlink a fresh lock a third process just
15
+ // created, and the exclusive create decides who wins.
16
+ export function lockPath(root) {
17
+ return join(root, 'run', 'lifecycle.lock')
18
+ }
19
+
20
+ export async function withLifecycleLock(root, operation) {
21
+ const path = lockPath(root)
22
+ acquire(path)
23
+ try {
24
+ return await operation()
25
+ } finally {
26
+ try { unlinkSync(path) } catch {}
27
+ }
28
+ }
29
+
30
+ function acquire(path) {
31
+ for (let attempt = 0; attempt < 2; attempt++) {
32
+ let fd
33
+ try {
34
+ fd = openSync(path, 'wx', 0o600)
35
+ } catch (e) {
36
+ if (e.code !== 'EEXIST') throw e
37
+ const holder = holderOf(path)
38
+ if (holder !== null) {
39
+ throw new Refusal(`another lifecycle operation is running: process ${holder} holds ${path}. Wait for it to finish, then run the command again.`)
40
+ }
41
+ // Stale: no live process owns it. Move it aside, then try the create again.
42
+ try { renameSync(path, `${path}.stale.${process.pid}`) } catch {}
43
+ try { unlinkSync(`${path}.stale.${process.pid}`) } catch {}
44
+ continue
45
+ }
46
+ try {
47
+ writeSync(fd, `${process.pid}\n`)
48
+ } finally {
49
+ closeSync(fd)
50
+ }
51
+ return
52
+ }
53
+ throw new Refusal(`another lifecycle operation is running and holds ${path}. Wait for it to finish, then run the command again.`)
54
+ }
55
+
56
+ // The live process named in the lock file, or null when the file names none.
57
+ function holderOf(path) {
58
+ let text
59
+ try {
60
+ text = readFileSync(path, 'utf8')
61
+ } catch (e) {
62
+ if (e.code === 'ENOENT') return null
63
+ throw e
64
+ }
65
+ const pid = Number.parseInt(text, 10)
66
+ if (!Number.isInteger(pid) || pid <= 0) return null
67
+ try {
68
+ process.kill(pid, 0)
69
+ return pid
70
+ } catch (e) {
71
+ // EPERM means the process exists and belongs to someone else: it's alive.
72
+ return e.code === 'EPERM' ? pid : null
73
+ }
74
+ }