@curia-sh/cli 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +259 -0
- package/bin/curia.mjs +15 -0
- package/package.json +36 -0
- package/src/acquire.mjs +188 -0
- package/src/archive.mjs +154 -0
- package/src/atomic.mjs +38 -0
- package/src/bundle.mjs +119 -0
- package/src/cli.mjs +73 -0
- package/src/commands.mjs +45 -0
- package/src/compose.mjs +137 -0
- package/src/config.mjs +289 -0
- package/src/doctor.mjs +392 -0
- package/src/exit.mjs +32 -0
- package/src/install.mjs +194 -0
- package/src/launcher.mjs +53 -0
- package/src/layout.mjs +95 -0
- package/src/lock.mjs +74 -0
- package/src/manifest.mjs +571 -0
- package/src/preflight.mjs +593 -0
- package/src/purge.mjs +230 -0
- package/src/resources.mjs +146 -0
- package/src/rollback.mjs +131 -0
- package/src/root.mjs +196 -0
- package/src/secrets.mjs +149 -0
- package/src/stable.mjs +314 -0
- package/src/stage.mjs +141 -0
- package/src/steps.mjs +25 -0
- package/src/switch.mjs +199 -0
- package/src/tailscale.mjs +156 -0
- package/src/uninstall.mjs +190 -0
- package/src/update.mjs +174 -0
- package/stable-index.pub +3 -0
package/src/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
|
+
}
|
package/src/install.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/launcher.mjs
ADDED
|
@@ -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
|
+
}
|