@open-mercato/cezar 0.1.5-pr506.357
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 +573 -0
- package/dist/config.d.ts +144 -0
- package/dist/config.js +106 -0
- package/dist/config.js.map +1 -0
- package/dist/core/agent-runner.d.ts +169 -0
- package/dist/core/agent-runner.js +23 -0
- package/dist/core/agent-runner.js.map +1 -0
- package/dist/core/backend-detect.d.ts +16 -0
- package/dist/core/backend-detect.js +118 -0
- package/dist/core/backend-detect.js.map +1 -0
- package/dist/core/claude-cli-runner.d.ts +56 -0
- package/dist/core/claude-cli-runner.js +416 -0
- package/dist/core/claude-cli-runner.js.map +1 -0
- package/dist/core/claude-ui-mapper.d.ts +71 -0
- package/dist/core/claude-ui-mapper.js +567 -0
- package/dist/core/claude-ui-mapper.js.map +1 -0
- package/dist/core/codex-app-server-runner.d.ts +29 -0
- package/dist/core/codex-app-server-runner.js +489 -0
- package/dist/core/codex-app-server-runner.js.map +1 -0
- package/dist/core/codex-ui-mapper.d.ts +63 -0
- package/dist/core/codex-ui-mapper.js +491 -0
- package/dist/core/codex-ui-mapper.js.map +1 -0
- package/dist/core/ndjson.d.ts +6 -0
- package/dist/core/ndjson.js +23 -0
- package/dist/core/ndjson.js.map +1 -0
- package/dist/core/opencode-server-runner.d.ts +30 -0
- package/dist/core/opencode-server-runner.js +488 -0
- package/dist/core/opencode-server-runner.js.map +1 -0
- package/dist/core/opencode-ui-mapper.d.ts +105 -0
- package/dist/core/opencode-ui-mapper.js +588 -0
- package/dist/core/opencode-ui-mapper.js.map +1 -0
- package/dist/core/process-usage.d.ts +63 -0
- package/dist/core/process-usage.js +181 -0
- package/dist/core/process-usage.js.map +1 -0
- package/dist/core/runner-factory.d.ts +8 -0
- package/dist/core/runner-factory.js +22 -0
- package/dist/core/runner-factory.js.map +1 -0
- package/dist/core/tool-display.d.ts +30 -0
- package/dist/core/tool-display.js +156 -0
- package/dist/core/tool-display.js.map +1 -0
- package/dist/core/ui-events.d.ts +308 -0
- package/dist/core/ui-events.js +25 -0
- package/dist/core/ui-events.js.map +1 -0
- package/dist/core/usage.d.ts +11 -0
- package/dist/core/usage.js +15 -0
- package/dist/core/usage.js.map +1 -0
- package/dist/git-worktree.d.ts +88 -0
- package/dist/git-worktree.js +297 -0
- package/dist/git-worktree.js.map +1 -0
- package/dist/handoff.d.ts +62 -0
- package/dist/handoff.js +147 -0
- package/dist/handoff.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +466 -0
- package/dist/index.js.map +1 -0
- package/dist/install-as-command.d.ts +39 -0
- package/dist/install-as-command.js +51 -0
- package/dist/install-as-command.js.map +1 -0
- package/dist/pack-check.d.ts +17 -0
- package/dist/pack-check.js +27 -0
- package/dist/pack-check.js.map +1 -0
- package/dist/paths.d.ts +23 -0
- package/dist/paths.js +34 -0
- package/dist/paths.js.map +1 -0
- package/dist/planner.d.ts +17 -0
- package/dist/planner.js +248 -0
- package/dist/planner.js.map +1 -0
- package/dist/release/snapshot.d.ts +64 -0
- package/dist/release/snapshot.js +75 -0
- package/dist/release/snapshot.js.map +1 -0
- package/dist/release/stable.d.ts +46 -0
- package/dist/release/stable.js +57 -0
- package/dist/release/stable.js.map +1 -0
- package/dist/runs/auto-name.d.ts +75 -0
- package/dist/runs/auto-name.js +168 -0
- package/dist/runs/auto-name.js.map +1 -0
- package/dist/runs/retention.d.ts +60 -0
- package/dist/runs/retention.js +86 -0
- package/dist/runs/retention.js.map +1 -0
- package/dist/runs/review-gate.d.ts +18 -0
- package/dist/runs/review-gate.js +21 -0
- package/dist/runs/review-gate.js.map +1 -0
- package/dist/runs/store.d.ts +408 -0
- package/dist/runs/store.js +526 -0
- package/dist/runs/store.js.map +1 -0
- package/dist/runs/task-refs.d.ts +24 -0
- package/dist/runs/task-refs.js +74 -0
- package/dist/runs/task-refs.js.map +1 -0
- package/dist/runs/ui-event-sink.d.ts +81 -0
- package/dist/runs/ui-event-sink.js +210 -0
- package/dist/runs/ui-event-sink.js.map +1 -0
- package/dist/server/capabilities.d.ts +33 -0
- package/dist/server/capabilities.js +43 -0
- package/dist/server/capabilities.js.map +1 -0
- package/dist/server/forge/github.d.ts +70 -0
- package/dist/server/forge/github.js +375 -0
- package/dist/server/forge/github.js.map +1 -0
- package/dist/server/forge/index.d.ts +24 -0
- package/dist/server/forge/index.js +45 -0
- package/dist/server/forge/index.js.map +1 -0
- package/dist/server/forge/types.d.ts +80 -0
- package/dist/server/forge/types.js +2 -0
- package/dist/server/forge/types.js.map +1 -0
- package/dist/server/git-changes.d.ts +195 -0
- package/dist/server/git-changes.js +497 -0
- package/dist/server/git-changes.js.map +1 -0
- package/dist/server/git.d.ts +26 -0
- package/dist/server/git.js +103 -0
- package/dist/server/git.js.map +1 -0
- package/dist/server/github.d.ts +8 -0
- package/dist/server/github.js +8 -0
- package/dist/server/github.js.map +1 -0
- package/dist/server/launch-key.d.ts +7 -0
- package/dist/server/launch-key.js +33 -0
- package/dist/server/launch-key.js.map +1 -0
- package/dist/server/open-in-app.d.ts +51 -0
- package/dist/server/open-in-app.js +189 -0
- package/dist/server/open-in-app.js.map +1 -0
- package/dist/server/open-in-terminal.d.ts +25 -0
- package/dist/server/open-in-terminal.js +120 -0
- package/dist/server/open-in-terminal.js.map +1 -0
- package/dist/server/pr.d.ts +7 -0
- package/dist/server/pr.js +7 -0
- package/dist/server/pr.js.map +1 -0
- package/dist/server/server.d.ts +41 -0
- package/dist/server/server.js +1390 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/static-ui.d.ts +49 -0
- package/dist/server/static-ui.js +92 -0
- package/dist/server/static-ui.js.map +1 -0
- package/dist/server/wsl.d.ts +25 -0
- package/dist/server/wsl.js +92 -0
- package/dist/server/wsl.js.map +1 -0
- package/dist/server-install/engine.d.ts +33 -0
- package/dist/server-install/engine.js +314 -0
- package/dist/server-install/engine.js.map +1 -0
- package/dist/server-install/platforms/macosx-ngrok.d.ts +6 -0
- package/dist/server-install/platforms/macosx-ngrok.js +345 -0
- package/dist/server-install/platforms/macosx-ngrok.js.map +1 -0
- package/dist/server-install/platforms/ubuntu-vps.d.ts +39 -0
- package/dist/server-install/platforms/ubuntu-vps.js +746 -0
- package/dist/server-install/platforms/ubuntu-vps.js.map +1 -0
- package/dist/server-install/state.d.ts +26 -0
- package/dist/server-install/state.js +125 -0
- package/dist/server-install/state.js.map +1 -0
- package/dist/server-install/steps.d.ts +109 -0
- package/dist/server-install/steps.js +330 -0
- package/dist/server-install/steps.js.map +1 -0
- package/dist/server-install/strategies.d.ts +4 -0
- package/dist/server-install/strategies.js +20 -0
- package/dist/server-install/strategies.js.map +1 -0
- package/dist/server-install/types.d.ts +597 -0
- package/dist/server-install/types.js +98 -0
- package/dist/server-install/types.js.map +1 -0
- package/dist/server-install/ui.d.ts +42 -0
- package/dist/server-install/ui.js +130 -0
- package/dist/server-install/ui.js.map +1 -0
- package/dist/skills-banner.d.ts +21 -0
- package/dist/skills-banner.js +40 -0
- package/dist/skills-banner.js.map +1 -0
- package/dist/skills-remote.d.ts +35 -0
- package/dist/skills-remote.js +266 -0
- package/dist/skills-remote.js.map +1 -0
- package/dist/skills.d.ts +45 -0
- package/dist/skills.js +182 -0
- package/dist/skills.js.map +1 -0
- package/dist/todos.d.ts +63 -0
- package/dist/todos.js +168 -0
- package/dist/todos.js.map +1 -0
- package/dist/ui-state.d.ts +12 -0
- package/dist/ui-state.js +25 -0
- package/dist/ui-state.js.map +1 -0
- package/dist/update-check.d.ts +12 -0
- package/dist/update-check.js +41 -0
- package/dist/update-check.js.map +1 -0
- package/dist/workflows/load.d.ts +15 -0
- package/dist/workflows/load.js +56 -0
- package/dist/workflows/load.js.map +1 -0
- package/dist/workflows/run.d.ts +281 -0
- package/dist/workflows/run.js +1511 -0
- package/dist/workflows/run.js.map +1 -0
- package/dist/workflows/types.d.ts +307 -0
- package/dist/workflows/types.js +171 -0
- package/dist/workflows/types.js.map +1 -0
- package/package.json +90 -0
- package/scripts/check-pack.mjs +46 -0
- package/scripts/dev.mjs +74 -0
- package/scripts/install-as-command.mjs +120 -0
- package/scripts/mock-claude.mjs +310 -0
- package/scripts/release-snapshot.mjs +130 -0
- package/scripts/release.mjs +119 -0
- package/scripts/test-process-usage.mjs +47 -0
- package/web/dist/assets/arrow-left-BE957rJs.js +1 -0
- package/web/dist/assets/bundle-mjs-DLPpRFyl.js +1 -0
- package/web/dist/assets/centered-state-BddhEEtO.js +43 -0
- package/web/dist/assets/chunk-BO2N2NFS-f5igTtpB.js +131 -0
- package/web/dist/assets/command-CjhFbABo.js +1 -0
- package/web/dist/assets/compare-variants-Cs52IAEG.js +1 -0
- package/web/dist/assets/core-BuMqAF_1.js +12 -0
- package/web/dist/assets/css-BsVw1vtW.js +1 -0
- package/web/dist/assets/dialog-DFvXTkFv.js +1 -0
- package/web/dist/assets/diff-view-C9b8AITq.js +4 -0
- package/web/dist/assets/diff-woXpYk--.js +1 -0
- package/web/dist/assets/dist-FBs4wmIY.js +1 -0
- package/web/dist/assets/dist-o_kVMXjg.js +1 -0
- package/web/dist/assets/engine-javascript-FQvzRocB.js +141 -0
- package/web/dist/assets/file-BVuztAOd.js +1 -0
- package/web/dist/assets/folder-0pJ1NZ_S.js +1 -0
- package/web/dist/assets/git-pull-request-DJkAMT0f.js +1 -0
- package/web/dist/assets/git-toolbar-CwhQdx3J.js +1 -0
- package/web/dist/assets/github-D0rAX4-r.js +1 -0
- package/web/dist/assets/go-rLFTqkRN.js +1 -0
- package/web/dist/assets/highlighted-body-OFNGDK62-DGHHie_q.js +1 -0
- package/web/dist/assets/highlighter-BKVEsACy.js +3 -0
- package/web/dist/assets/html-BY76lXLH.js +1 -0
- package/web/dist/assets/index-CEkI7QxN.css +2 -0
- package/web/dist/assets/index-CO2lH2et.js +11 -0
- package/web/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/web/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/web/dist/assets/javascript-BgS3c2Ky.js +1 -0
- package/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/web/dist/assets/json-qhed-kSA.js +1 -0
- package/web/dist/assets/jsonc-CYpm1nAK.js +1 -0
- package/web/dist/assets/jsx-Bz0zcwM4.js +1 -0
- package/web/dist/assets/lib-CNGa3Dig.js +1 -0
- package/web/dist/assets/loader-circle-BV3DO9mE.js +1 -0
- package/web/dist/assets/markdown-BYOwaDjH.js +1 -0
- package/web/dist/assets/markdown-D4-ADgH9.js +1 -0
- package/web/dist/assets/mermaid-GHXKKRXX-DM2VsGBG.js +1 -0
- package/web/dist/assets/open-mercato-toBr6SOa.svg +11 -0
- package/web/dist/assets/prompt-templates-C7ptKvDd.js +15 -0
- package/web/dist/assets/python-gzcpVVnB.js +1 -0
- package/web/dist/assets/react-dom-Ddtik4qM.js +1 -0
- package/web/dist/assets/refresh-cw-DCa-Rlwz.js +1 -0
- package/web/dist/assets/repo-git-DIhryGDx.js +1 -0
- package/web/dist/assets/run-diff-Dyow8B1q.js +3 -0
- package/web/dist/assets/run-header-jx9-18b8.js +1 -0
- package/web/dist/assets/rust-Cfkwpbl8.js +1 -0
- package/web/dist/assets/search-x-CoCyl7zT.js +1 -0
- package/web/dist/assets/shellscript-CLZ0U2zV.js +1 -0
- package/web/dist/assets/skill-detail-DB7x_FQp.js +1 -0
- package/web/dist/assets/skills-98vCroU8.js +1 -0
- package/web/dist/assets/skills-BKIoqD6K.js +1 -0
- package/web/dist/assets/sql-BsFa4tDR.js +1 -0
- package/web/dist/assets/square-terminal-B6PZX4Qp.js +1 -0
- package/web/dist/assets/tab-link-C5--YMQd.js +1 -0
- package/web/dist/assets/task-changes-ejxPGVJw.js +1 -0
- package/web/dist/assets/task-commits-C6GO5qT-.js +1 -0
- package/web/dist/assets/task-files-CatpCBXa.js +2 -0
- package/web/dist/assets/task-thread-BMbXDM3d.js +5 -0
- package/web/dist/assets/tasks-table-dgezlCcg.js +1 -0
- package/web/dist/assets/trash-2-CDtxmmc8.js +1 -0
- package/web/dist/assets/triangle-alert-6su4Js5O.js +1 -0
- package/web/dist/assets/tsx-udAQXfEw.js +1 -0
- package/web/dist/assets/typescript-bsJCZSQ-.js +1 -0
- package/web/dist/assets/upload-Bp7hQxll.js +1 -0
- package/web/dist/assets/use-desktop-CvTwojOJ.js +3 -0
- package/web/dist/assets/utils-rQGbOrwc.js +1 -0
- package/web/dist/assets/workflows-Dy2c6790.js +11 -0
- package/web/dist/assets/x-Br_jIDBw.js +1 -0
- package/web/dist/assets/yaml-rwi0_p6S.js +1 -0
- package/web/dist/index.html +70 -0
- package/web/open-mercato.svg +11 -0
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir, userInfo } from 'node:os';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { CANCEL, PreflightError } from '../types.js';
|
|
6
|
+
import { depCheckStep, generatePassword, owned, shared, shquote, StepAborted, StepCancelled, StepSkipped, sudoStep, verifyCommand } from '../steps.js';
|
|
7
|
+
/**
|
|
8
|
+
* The `ubuntu-vps` strategy: stand up an authenticated, proxied cezar on a bare
|
|
9
|
+
* Ubuntu/Debian VPS. cezar itself stays loopback-bound; nginx is the single
|
|
10
|
+
* public surface (TLS + htpasswd identity) forwarding to 127.0.0.1:<port>.
|
|
11
|
+
*
|
|
12
|
+
* Phase 1 ships deps → nginx+htpasswd → identity-verify (and their `undo`s).
|
|
13
|
+
* Phase 2 appends the optional SSL and autostart steps to `steps()`.
|
|
14
|
+
*/
|
|
15
|
+
const VHOST_AVAILABLE = '/etc/nginx/sites-available/cezar';
|
|
16
|
+
const VHOST_ENABLED = '/etc/nginx/sites-enabled/cezar';
|
|
17
|
+
const HTPASSWD = '/etc/cezar/htpasswd';
|
|
18
|
+
/** Best-effort current OS username, suggested as the default cockpit login. */
|
|
19
|
+
function currentUsername() {
|
|
20
|
+
try {
|
|
21
|
+
return userInfo().username || 'ops';
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return 'ops';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* True when ufw is installed and enabled. `ufw status` needs root and the
|
|
29
|
+
* installer is mandatorily non-root, so it always failed — instead read
|
|
30
|
+
* `/etc/ufw/ufw.conf` (world-readable), whose `ENABLED=yes` is the same truth
|
|
31
|
+
* `ufw enable` maintains.
|
|
32
|
+
*/
|
|
33
|
+
async function ufwIsActive(ctx) {
|
|
34
|
+
const r = await ctx.runner.capture('sh', [
|
|
35
|
+
'-c',
|
|
36
|
+
'grep -qi "^ENABLED=yes" /etc/ufw/ufw.conf 2>/dev/null && echo ufw-enabled || true',
|
|
37
|
+
]);
|
|
38
|
+
return r.stdout.includes('ufw-enabled');
|
|
39
|
+
}
|
|
40
|
+
/** The HTTP status curl saw for a request ("000" = could not connect / no response). */
|
|
41
|
+
async function curlCode(ctx, args, opts) {
|
|
42
|
+
const r = await ctx.runner.capture('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', ...args], opts);
|
|
43
|
+
return r.stdout.trim() || '000';
|
|
44
|
+
}
|
|
45
|
+
/** True once cezar answers on 127.0.0.1:<port> (any HTTP status = the process is up). */
|
|
46
|
+
async function isCezarUp(ctx, port) {
|
|
47
|
+
const code = await curlCode(ctx, [`http://127.0.0.1:${port}/`]);
|
|
48
|
+
return code !== '000';
|
|
49
|
+
}
|
|
50
|
+
/** Poll the loopback upstream until cezar responds or the attempts run out. */
|
|
51
|
+
async function waitForCezar(ctx, port, attempts = 15) {
|
|
52
|
+
for (let i = 0; i < attempts; i++) {
|
|
53
|
+
if (await isCezarUp(ctx, port))
|
|
54
|
+
return true;
|
|
55
|
+
// `sleep 1` via the runner keeps this testable (no real timers in unit tests).
|
|
56
|
+
if (i < attempts - 1)
|
|
57
|
+
await ctx.runner.capture('sh', ['-c', 'sleep 1']);
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* After a service is enabled, wait for cezar to actually answer on the loopback
|
|
63
|
+
* port. A running unit that crash-loops (bad WorkingDirectory, missing build)
|
|
64
|
+
* would otherwise leave nginx proxying to nothing (502) while the installer
|
|
65
|
+
* claims success. On failure, point the operator at the service logs.
|
|
66
|
+
*/
|
|
67
|
+
async function confirmCezarRunning(ctx, statusCmd, logsCmd) {
|
|
68
|
+
if (ctx.dryRun) {
|
|
69
|
+
ctx.ui.info(`DRY RUN — would wait for cezar on 127.0.0.1:${ctx.state.primaryPort}.`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const sp = ctx.ui.spinner();
|
|
73
|
+
sp.start(`Waiting for cezar to start on 127.0.0.1:${ctx.state.primaryPort}…`);
|
|
74
|
+
const up = await waitForCezar(ctx, ctx.state.primaryPort);
|
|
75
|
+
sp.stop(up ? 'cezar is running.' : 'cezar did not come up.');
|
|
76
|
+
if (!up) {
|
|
77
|
+
ctx.ui.warn(`cezar is not answering on 127.0.0.1:${ctx.state.primaryPort} yet — nginx will return 502 until it is.\n` +
|
|
78
|
+
`Check the service:\n • ${statusCmd}\n • ${logsCmd}\n` +
|
|
79
|
+
'Common causes: the WorkingDirectory has no built cezar, or the port is wrong.');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The nginx server block: auth_basic identity + SSE-safe proxy to loopback.
|
|
84
|
+
* `serverName` defaults to the catch-all `_`; the SSL step rewrites it to the
|
|
85
|
+
* real domain so the `certbot --nginx` plugin can find this vhost to edit.
|
|
86
|
+
*/
|
|
87
|
+
export function nginxVhost(port, serverName = '_') {
|
|
88
|
+
return `# Managed by cezar server-install — do not edit by hand.
|
|
89
|
+
server {
|
|
90
|
+
listen 80;
|
|
91
|
+
listen [::]:80;
|
|
92
|
+
server_name ${serverName};
|
|
93
|
+
|
|
94
|
+
auth_basic "cezar";
|
|
95
|
+
auth_basic_user_file ${HTPASSWD};
|
|
96
|
+
|
|
97
|
+
location / {
|
|
98
|
+
proxy_pass http://127.0.0.1:${port};
|
|
99
|
+
proxy_http_version 1.1;
|
|
100
|
+
proxy_set_header Host $host;
|
|
101
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
102
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
103
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
104
|
+
|
|
105
|
+
# cezar streams SSE (run events). Never buffer it, or the cockpit goes mute.
|
|
106
|
+
proxy_buffering off;
|
|
107
|
+
proxy_read_timeout 3600s;
|
|
108
|
+
proxy_set_header Connection '';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
/** Build a privileged command that writes file content atomically (base64 → no quoting hell). */
|
|
114
|
+
function writeRootFileCmd(path, content, extra = '') {
|
|
115
|
+
const b64 = Buffer.from(content, 'utf8').toString('base64');
|
|
116
|
+
const dir = path.slice(0, path.lastIndexOf('/')) || '/';
|
|
117
|
+
return `install -d -m 0755 ${shquote(dir)} && printf %s ${shquote(b64)} | base64 --decode > ${shquote(path)}${extra ? ` && ${extra}` : ''}`;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* A sudoStep that writes a root-owned file. The command carries the content as
|
|
121
|
+
* base64 (so quoting/newlines survive a copy-paste), which is unreadable — so we
|
|
122
|
+
* always show the DECODED file content in a note first, so the operator can see
|
|
123
|
+
* exactly what will land on disk and knows the base64 is not doing anything
|
|
124
|
+
* hidden.
|
|
125
|
+
*/
|
|
126
|
+
function writeFileStep(ctx, opts) {
|
|
127
|
+
return sudoStep(ctx, {
|
|
128
|
+
description: opts.description,
|
|
129
|
+
note: `This writes ${opts.path} with exactly this content:\n\n${opts.content}`,
|
|
130
|
+
command: writeRootFileCmd(opts.path, opts.content, opts.extra),
|
|
131
|
+
verify: opts.verify,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const nginxProxyStep = {
|
|
135
|
+
id: 'nginx-proxy',
|
|
136
|
+
title: 'Reverse proxy (nginx + htpasswd)',
|
|
137
|
+
async check(ctx) {
|
|
138
|
+
if (ctx.dryRun)
|
|
139
|
+
return false;
|
|
140
|
+
const nginxOk = await verifyCommand(ctx, 'nginx', ['-v']);
|
|
141
|
+
const vhostOk = await verifyCommand(ctx, 'test', ['-f', VHOST_ENABLED]);
|
|
142
|
+
return nginxOk && vhostOk;
|
|
143
|
+
},
|
|
144
|
+
async run(ctx) {
|
|
145
|
+
// 1) nginx. Record the package as a `shared` artifact only when this run
|
|
146
|
+
// installed it, so uninstall can list it for manual removal without
|
|
147
|
+
// claiming a pre-existing nginx.
|
|
148
|
+
const nginxWasPresent = await verifyCommand(ctx, 'nginx', ['-v']);
|
|
149
|
+
await sudoStep(ctx, {
|
|
150
|
+
description: 'Install nginx (the public TLS + auth front for cezar).',
|
|
151
|
+
command: 'apt-get update && apt-get install -y nginx',
|
|
152
|
+
verify: (c) => verifyCommand(c, 'nginx', ['-v']),
|
|
153
|
+
});
|
|
154
|
+
// 2) identity credentials. This is the HTTP Basic-Auth login nginx will
|
|
155
|
+
// challenge for over HTTPS — i.e. what you type in the browser to reach
|
|
156
|
+
// the cockpit. Suggest the current OS user as a sensible default.
|
|
157
|
+
const suggestedUser = currentUsername();
|
|
158
|
+
const userAnswer = await ctx.ui.text({
|
|
159
|
+
message: 'Cockpit login username (HTTPS Basic-Auth — you type this in the browser to reach the cockpit)',
|
|
160
|
+
placeholder: suggestedUser,
|
|
161
|
+
initialValue: suggestedUser,
|
|
162
|
+
// htpasswd's line format is `user:hash` — a ":" in the username would
|
|
163
|
+
// silently corrupt it into a login that can never succeed.
|
|
164
|
+
validate: (v) => {
|
|
165
|
+
if (!v.trim())
|
|
166
|
+
return 'username is required';
|
|
167
|
+
if (/[:\s]/.test(v.trim()))
|
|
168
|
+
return 'no ":" or whitespace — htpasswd uses ":" as its separator';
|
|
169
|
+
return undefined;
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
if (userAnswer === CANCEL)
|
|
173
|
+
throw new StepCancelled();
|
|
174
|
+
const user = String(userAnswer).trim();
|
|
175
|
+
// Set the cockpit password. Interactive operators may auto-generate a strong
|
|
176
|
+
// one (shown once) or type their own. Under `--yes` (no human) there is no
|
|
177
|
+
// menu — the password comes straight from the UI, and the length backstop
|
|
178
|
+
// below refuses an empty one rather than standing up an open cockpit (H1).
|
|
179
|
+
let password;
|
|
180
|
+
if (ctx.assumeYes) {
|
|
181
|
+
const typed = await ctx.ui.password({
|
|
182
|
+
message: `Set the HTTPS cockpit password for "${user}"`,
|
|
183
|
+
validate: (v) => (v.length >= 6 ? undefined : 'use at least 6 characters'),
|
|
184
|
+
});
|
|
185
|
+
if (typed === CANCEL)
|
|
186
|
+
throw new StepCancelled();
|
|
187
|
+
password = String(typed);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
const how = await ctx.ui.select({
|
|
191
|
+
message: `Cockpit password for "${user}" (used with the username to log in over HTTPS)`,
|
|
192
|
+
options: [
|
|
193
|
+
{ value: 'generate', label: 'Generate a strong password for me', hint: 'shown once — save it now' },
|
|
194
|
+
{ value: 'manual', label: 'Type my own password' },
|
|
195
|
+
],
|
|
196
|
+
initialValue: 'generate',
|
|
197
|
+
});
|
|
198
|
+
if (how === CANCEL)
|
|
199
|
+
throw new StepCancelled();
|
|
200
|
+
if (how === 'generate') {
|
|
201
|
+
password = generatePassword();
|
|
202
|
+
ctx.ui.note(`Username: ${user}\nPassword: ${password}\n\nSave these now — this is your cockpit login. cezar stores only a hash; the plaintext is not written anywhere and cannot be recovered.`, 'Generated cockpit credentials');
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
const typed = await ctx.ui.password({
|
|
206
|
+
message: `Set the HTTPS cockpit password for "${user}"`,
|
|
207
|
+
validate: (v) => (v.length >= 6 ? undefined : 'use at least 6 characters'),
|
|
208
|
+
});
|
|
209
|
+
if (typed === CANCEL)
|
|
210
|
+
throw new StepCancelled();
|
|
211
|
+
password = String(typed);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// The non-interactive UI (`--yes`) cannot invent a password and does not run
|
|
215
|
+
// validators — refuse rather than write an empty-password htpasswd (a public
|
|
216
|
+
// cockpit anyone can open). A real install must set a password interactively.
|
|
217
|
+
if (!ctx.dryRun && String(password).length < 6) {
|
|
218
|
+
throw new StepAborted('a cockpit password (≥6 chars) is required — run server-install without --yes to set one');
|
|
219
|
+
}
|
|
220
|
+
// Keep the credentials in memory (never persisted) so the final verify step
|
|
221
|
+
// can prove an authenticated request actually reaches cezar over the proxy.
|
|
222
|
+
ctx.prefs.cockpit = { user, password };
|
|
223
|
+
// 3) htpasswd file. The hash (not the plaintext) is embedded in the write
|
|
224
|
+
// command; the plaintext is fed to openssl via stdin so it never appears
|
|
225
|
+
// in the process argv (visible to other users via `ps`). apr1 is what
|
|
226
|
+
// nginx's auth_basic expects.
|
|
227
|
+
let hash = '<dry-run-hash>';
|
|
228
|
+
if (!ctx.dryRun) {
|
|
229
|
+
const out = await ctx.runner.capture('openssl', ['passwd', '-apr1', '-stdin'], { input: `${String(password)}\n` });
|
|
230
|
+
hash = out.stdout.trim();
|
|
231
|
+
if (out.code !== 0 || !hash)
|
|
232
|
+
throw new StepAborted('failed to hash the password (openssl) — cannot write htpasswd');
|
|
233
|
+
}
|
|
234
|
+
// nginx workers run as www-data and read auth_basic_user_file per request,
|
|
235
|
+
// so the file must be group-readable by www-data — 0640 root:root would make
|
|
236
|
+
// every request 500. 0640 root:www-data: readable by nginx, not by others.
|
|
237
|
+
// The credential line travels on STDIN (`cat > file`), never in argv — the
|
|
238
|
+
// apr1 hash is offline-crackable, and argv is world-readable via `ps`.
|
|
239
|
+
await sudoStep(ctx, {
|
|
240
|
+
description: 'Write the htpasswd identity file that nginx checks on every request.',
|
|
241
|
+
note: `${HTPASSWD}\n\n${user}:<apr1 hash of your password>`,
|
|
242
|
+
command: `install -d -m 0755 /etc/cezar && cat > ${HTPASSWD} && chown root:www-data ${HTPASSWD} && chmod 0640 ${HTPASSWD}`,
|
|
243
|
+
input: `${user}:${hash}\n`,
|
|
244
|
+
inputLabel: 'credential line (username:hash)',
|
|
245
|
+
verify: (c) => verifyCommand(c, 'test', ['-f', HTPASSWD]),
|
|
246
|
+
});
|
|
247
|
+
// Record whether the distro's default site was enabled *before* we disable
|
|
248
|
+
// it, so undo only re-enables it if we were the one who removed it.
|
|
249
|
+
const defaultWasEnabled = await verifyCommand(ctx, 'test', ['-L', '/etc/nginx/sites-enabled/default']);
|
|
250
|
+
// 4) vhost + enable + reload
|
|
251
|
+
const vhost = nginxVhost(ctx.state.primaryPort);
|
|
252
|
+
await writeFileStep(ctx, {
|
|
253
|
+
description: 'Write the cezar nginx site, enable it, and reload nginx.',
|
|
254
|
+
path: VHOST_AVAILABLE,
|
|
255
|
+
content: vhost,
|
|
256
|
+
extra: `ln -sf ${VHOST_AVAILABLE} ${VHOST_ENABLED} && rm -f /etc/nginx/sites-enabled/default && nginx -t && systemctl reload nginx`,
|
|
257
|
+
verify: (c) => verifyCommand(c, 'test', ['-f', VHOST_ENABLED]),
|
|
258
|
+
});
|
|
259
|
+
// 5) firewall: if ufw is active, the cockpit is unreachable until 80/443 are
|
|
260
|
+
// allowed — a common "installed fine but nothing loads" cause. This is a
|
|
261
|
+
// best-effort sub-step of the (required) proxy step: skipping it must not
|
|
262
|
+
// fail the whole install, so a StepSkipped is swallowed here.
|
|
263
|
+
if (!ctx.dryRun && (await ufwIsActive(ctx))) {
|
|
264
|
+
try {
|
|
265
|
+
await sudoStep(ctx, {
|
|
266
|
+
description: 'ufw is active — allow HTTP/HTTPS so the cockpit is reachable.',
|
|
267
|
+
// Self-verifying: the trailing grep makes the (root) command itself
|
|
268
|
+
// fail when the rule did not land — needed because rule listing also
|
|
269
|
+
// requires root, so the unprivileged verify below can't always check.
|
|
270
|
+
command: `ufw allow 'Nginx Full' && ufw status | grep -qi 'Nginx Full'`,
|
|
271
|
+
skippable: true,
|
|
272
|
+
skipHint: 'open ports 80 and 443 yourself, or via a cloud firewall',
|
|
273
|
+
verify: async (c) => {
|
|
274
|
+
if ((await c.runner.capture('sudo', ['-n', 'true'])).code === 0) {
|
|
275
|
+
return ((await c.runner.capture('sudo', ['-n', 'bash', '-lc', "ufw status | grep -qi 'Nginx Full'"])).code === 0);
|
|
276
|
+
}
|
|
277
|
+
// No root and no human in the loop (`--yes`): nobody ran the
|
|
278
|
+
// command, so it cannot have "verified itself" — fail, which the
|
|
279
|
+
// skippable path converts into the loud firewall warning below.
|
|
280
|
+
if (c.assumeYes)
|
|
281
|
+
return false;
|
|
282
|
+
// Interactive delegate: the operator confirmed running the
|
|
283
|
+
// self-verifying command (the trailing grep fails their paste if
|
|
284
|
+
// the rule is missing); we cannot re-check without root.
|
|
285
|
+
c.ui.warn('Cannot list ufw rules without root — trusting the self-verifying command you just ran. ' +
|
|
286
|
+
'Double-check ports 80/443 are reachable.');
|
|
287
|
+
return true;
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
if (!(err instanceof StepSkipped))
|
|
293
|
+
throw err;
|
|
294
|
+
ctx.ui.warn('Firewall left unchanged — make sure ports 80 and 443 are reachable, or the cockpit will not load.');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const artifacts = [
|
|
298
|
+
owned('file', { path: VHOST_AVAILABLE }),
|
|
299
|
+
owned('symlink', { path: VHOST_ENABLED }),
|
|
300
|
+
owned('htpasswd', { path: HTPASSWD, name: user }),
|
|
301
|
+
];
|
|
302
|
+
if (defaultWasEnabled)
|
|
303
|
+
artifacts.push(owned('nginx-default', { path: '/etc/nginx/sites-enabled/default' }));
|
|
304
|
+
if (!nginxWasPresent) {
|
|
305
|
+
artifacts.push(shared('package', { name: 'nginx', removeHint: 'sudo apt-get remove -y nginx' }));
|
|
306
|
+
}
|
|
307
|
+
return { artifacts };
|
|
308
|
+
},
|
|
309
|
+
async undo(ctx, created) {
|
|
310
|
+
// Remove the *known* cezar-owned paths (constants), so uninstall works even
|
|
311
|
+
// if server.json was lost and the step was re-recorded with created=null.
|
|
312
|
+
// Only re-enable the default site if the run recorded that it disabled it.
|
|
313
|
+
const restoreDefault = (created?.artifacts ?? []).some((a) => a.type === 'nginx-default');
|
|
314
|
+
const restoreClause = restoreDefault
|
|
315
|
+
? ` && { [ -e /etc/nginx/sites-available/default ] && ln -sf /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default || true; }`
|
|
316
|
+
: '';
|
|
317
|
+
// `shared` packages (nginx, when this install added it) are listed, never
|
|
318
|
+
// auto-removed — the operator may have started depending on them.
|
|
319
|
+
const sharedPkgs = (created?.artifacts ?? []).filter((a) => a.kind === 'shared');
|
|
320
|
+
if (sharedPkgs.length > 0) {
|
|
321
|
+
ctx.ui.note(sharedPkgs.map((a) => a.removeHint ?? a.name ?? '').filter(Boolean).join('\n'), 'Installed for cezar but possibly used elsewhere — remove manually if unwanted');
|
|
322
|
+
}
|
|
323
|
+
await sudoStep(ctx, {
|
|
324
|
+
description: 'Remove the cezar nginx site + htpasswd, reload nginx.',
|
|
325
|
+
command: `rm -f ${VHOST_ENABLED} ${VHOST_AVAILABLE} ${HTPASSWD}` +
|
|
326
|
+
restoreClause +
|
|
327
|
+
` && { rmdir /etc/cezar 2>/dev/null || true; }` +
|
|
328
|
+
` && { nginx -t && systemctl reload nginx || true; }`,
|
|
329
|
+
verify: (c) => verifyCommand(c, 'sh', ['-c', `! test -f ${VHOST_ENABLED}`]),
|
|
330
|
+
});
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
const HOSTNAME_RE = /^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/i;
|
|
334
|
+
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
335
|
+
const sslStep = {
|
|
336
|
+
id: 'ssl',
|
|
337
|
+
title: 'Domain + SSL (Let’s Encrypt)',
|
|
338
|
+
optional: true,
|
|
339
|
+
async check() {
|
|
340
|
+
return false; // optional — the engine gates it with a confirm; certbot is idempotent
|
|
341
|
+
},
|
|
342
|
+
async run(ctx) {
|
|
343
|
+
const domain = await ctx.ui.text({
|
|
344
|
+
message: 'Domain pointing at this server (an A/AAAA record must resolve here)',
|
|
345
|
+
placeholder: 'cezar.example.com',
|
|
346
|
+
validate: (v) => (HOSTNAME_RE.test(v.trim()) ? undefined : 'enter a valid domain'),
|
|
347
|
+
});
|
|
348
|
+
if (domain === CANCEL)
|
|
349
|
+
throw new StepCancelled();
|
|
350
|
+
const email = await ctx.ui.text({
|
|
351
|
+
message: 'Email for Let’s Encrypt renewal notices',
|
|
352
|
+
placeholder: 'you@example.com',
|
|
353
|
+
validate: (v) => (EMAIL_RE.test(v.trim()) ? undefined : 'enter a valid email'),
|
|
354
|
+
});
|
|
355
|
+
if (email === CANCEL)
|
|
356
|
+
throw new StepCancelled();
|
|
357
|
+
const certbotWasPresent = await verifyCommand(ctx, 'certbot', ['--version']);
|
|
358
|
+
await sudoStep(ctx, {
|
|
359
|
+
description: 'Install certbot + its nginx plugin.',
|
|
360
|
+
command: 'apt-get install -y certbot python3-certbot-nginx',
|
|
361
|
+
verify: (c) => verifyCommand(c, 'certbot', ['--version']),
|
|
362
|
+
});
|
|
363
|
+
// Point the nginx site's server_name at the domain BEFORE running certbot.
|
|
364
|
+
// The `certbot --nginx` plugin locates the vhost by matching `server_name`
|
|
365
|
+
// against `-d <domain>`; with the catch-all `server_name _;` it fails with
|
|
366
|
+
// "Unable to find a VirtualHost". Doing this first also means that if the
|
|
367
|
+
// operator skips certbot now, a later manual `certbot --nginx -d <domain>`
|
|
368
|
+
// just works — no "could not find a matching server" (issue #8).
|
|
369
|
+
//
|
|
370
|
+
// On a RE-RUN (--reconfigure ssl / --reinstall / resume) the vhost may
|
|
371
|
+
// already carry certbot's TLS edits (`listen 443 ssl`, `ssl_certificate`).
|
|
372
|
+
// Rewriting it with the plain-HTTP template would take working HTTPS down
|
|
373
|
+
// BEFORE certbot runs again — and leave it down if certbot then fails. So
|
|
374
|
+
// when TLS config is present, only the `server_name` lines are updated
|
|
375
|
+
// in place; the hostname regex bars every sed/shell metacharacter.
|
|
376
|
+
const trimmedDomain = String(domain).trim();
|
|
377
|
+
const vhostHasTls = await verifyCommand(ctx, 'sh', ['-c', `grep -qs ssl_certificate ${VHOST_AVAILABLE}`]);
|
|
378
|
+
if (vhostHasTls) {
|
|
379
|
+
await sudoStep(ctx, {
|
|
380
|
+
description: `Update server_name to ${trimmedDomain} in the existing TLS-enabled nginx site (certbot config preserved).`,
|
|
381
|
+
command: `sed -i 's/^\\([[:space:]]*\\)server_name .*;/\\1server_name ${trimmedDomain};/' ${VHOST_AVAILABLE} && nginx -t && systemctl reload nginx`,
|
|
382
|
+
verify: (c) => verifyCommand(c, 'sh', ['-c', `grep -qF ${shquote(`server_name ${trimmedDomain}`)} ${VHOST_AVAILABLE}`]),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
const domainVhost = nginxVhost(ctx.state.primaryPort, trimmedDomain);
|
|
387
|
+
await writeFileStep(ctx, {
|
|
388
|
+
description: `Point the nginx site at ${trimmedDomain} so certbot can configure TLS for it.`,
|
|
389
|
+
path: VHOST_AVAILABLE,
|
|
390
|
+
content: domainVhost,
|
|
391
|
+
extra: `ln -sf ${VHOST_AVAILABLE} ${VHOST_ENABLED} && nginx -t && systemctl reload nginx`,
|
|
392
|
+
verify: (c) => verifyCommand(c, 'sh', ['-c', `grep -qF ${shquote(`server_name ${trimmedDomain}`)} ${VHOST_AVAILABLE}`]),
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
// certbot can legitimately fail on external state (DNS not pointed yet, LE
|
|
396
|
+
// rate limit). Route it through sudoStep so it honors the sudo/delegate
|
|
397
|
+
// choice (issue #7) and offers Skip on repeated failure (issue #8) — on
|
|
398
|
+
// skip, nginx is already configured for a later manual certbot run.
|
|
399
|
+
await sudoStep(ctx, {
|
|
400
|
+
description: `Obtain and install a Let’s Encrypt certificate for ${String(domain).trim()} (adds HTTPS + redirect).`,
|
|
401
|
+
command: `certbot --nginx -d ${shquote(String(domain).trim())} --non-interactive --agree-tos -m ${shquote(String(email).trim())} --redirect`,
|
|
402
|
+
skippable: true,
|
|
403
|
+
skipHint: `run later: sudo certbot --nginx -d ${String(domain).trim()}`,
|
|
404
|
+
// Verify via the nginx vhost, NOT /etc/letsencrypt/live — that dir is 0700
|
|
405
|
+
// root, so a non-root `test -d` (the operator ran certbot themselves) fails
|
|
406
|
+
// with permission-denied and reports a false failure even when the cert was
|
|
407
|
+
// issued. certbot --nginx writes `ssl_certificate …` into the vhost, which
|
|
408
|
+
// is world-readable, so grepping it works without root.
|
|
409
|
+
verify: (c) => verifyCommand(c, 'sh', ['-c', `grep -qs ssl_certificate ${VHOST_AVAILABLE} ${VHOST_ENABLED}`]),
|
|
410
|
+
});
|
|
411
|
+
ctx.state.publicUrl = `https://${trimmedDomain}`;
|
|
412
|
+
// The cert + its auto-renewal timer are `shared`: uninstall lists them, it
|
|
413
|
+
// does not delete them (removing a cert can break other vhosts). Same for
|
|
414
|
+
// the certbot packages when this run installed them.
|
|
415
|
+
const artifacts = [
|
|
416
|
+
shared('cert', { name: trimmedDomain, removeHint: `sudo certbot delete --cert-name ${trimmedDomain}` }),
|
|
417
|
+
];
|
|
418
|
+
if (!certbotWasPresent) {
|
|
419
|
+
artifacts.push(shared('package', {
|
|
420
|
+
name: 'certbot + python3-certbot-nginx',
|
|
421
|
+
removeHint: 'sudo apt-get remove -y certbot python3-certbot-nginx',
|
|
422
|
+
}));
|
|
423
|
+
}
|
|
424
|
+
return { artifacts };
|
|
425
|
+
},
|
|
426
|
+
async undo(ctx, created) {
|
|
427
|
+
const cert = (created?.artifacts ?? []).find((a) => a.type === 'cert');
|
|
428
|
+
if (cert) {
|
|
429
|
+
ctx.ui.note(`The TLS certificate for ${cert.name ?? 'your domain'} and its auto-renewal timer were left in place.\nRemove them yourself if you want them gone:\n${cert.removeHint ?? ''}`, 'SSL');
|
|
430
|
+
}
|
|
431
|
+
const pkgs = (created?.artifacts ?? []).filter((a) => a.type === 'package');
|
|
432
|
+
if (pkgs.length > 0) {
|
|
433
|
+
ctx.ui.note(pkgs.map((a) => a.removeHint ?? a.name ?? '').filter(Boolean).join('\n'), 'Installed for cezar but possibly used elsewhere — remove manually if unwanted');
|
|
434
|
+
}
|
|
435
|
+
// The vhost (with certbot’s edits) is removed by the nginx-proxy step’s undo.
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
const UNIT_NAME = 'cezar.service';
|
|
439
|
+
/**
|
|
440
|
+
* systemd unit that runs cezar loopback-bound with CEZ_REMOTE=1.
|
|
441
|
+
*
|
|
442
|
+
* `execStart` must be an ABSOLUTE command — systemd resolves the ExecStart
|
|
443
|
+
* executable against its OWN compiled-in PATH (/usr/local/bin:/usr/bin:…), NOT
|
|
444
|
+
* the unit's `Environment=PATH`, so a bare `cezar` gives status=203/EXEC
|
|
445
|
+
* ("Unable to locate executable"). `resolveExecStart` therefore returns an
|
|
446
|
+
* absolute `"<node> <entry.js>"`. We still set `Environment=PATH` (with the
|
|
447
|
+
* installer's node dir) for any child process the app spawns.
|
|
448
|
+
*/
|
|
449
|
+
export function systemdUnit(repoRoot, port, scope, execStart) {
|
|
450
|
+
const userLine = scope === 'system' ? `User=${userInfo().username}\n` : '';
|
|
451
|
+
const installTarget = scope === 'system' ? 'multi-user.target' : 'default.target';
|
|
452
|
+
// Give the service the operator's own PATH (node dir + the login PATH the CLI
|
|
453
|
+
// merged in, e.g. ~/.local/bin and nvm) so cezar can spawn claude / gh / codex
|
|
454
|
+
// at runtime — systemd's default PATH has none of those.
|
|
455
|
+
const pathDirs = [dirname(process.execPath), ...(process.env.PATH ?? '').split(':'), '/usr/local/bin', '/usr/bin', '/bin']
|
|
456
|
+
.filter((d, i, a) => d && d !== '.' && a.indexOf(d) === i);
|
|
457
|
+
// systemd expands `%` specifiers in Environment/ExecStart values — a literal
|
|
458
|
+
// `%` (possible in an nvm dir name) must be doubled or the unit fails to load.
|
|
459
|
+
const sysd = (s) => s.replace(/%/g, '%%');
|
|
460
|
+
return `# Managed by cezar server-install — do not edit by hand.
|
|
461
|
+
[Unit]
|
|
462
|
+
Description=cezar cockpit
|
|
463
|
+
After=network-online.target
|
|
464
|
+
Wants=network-online.target
|
|
465
|
+
|
|
466
|
+
[Service]
|
|
467
|
+
Type=simple
|
|
468
|
+
${userLine}WorkingDirectory=${repoRoot}
|
|
469
|
+
Environment=CEZ_REMOTE=1
|
|
470
|
+
Environment=PATH=${sysd(pathDirs.join(':'))}
|
|
471
|
+
ExecStart=${sysd(execStart)} serve --no-open --port ${port}
|
|
472
|
+
Restart=on-failure
|
|
473
|
+
RestartSec=5
|
|
474
|
+
|
|
475
|
+
[Install]
|
|
476
|
+
WantedBy=${installTarget}
|
|
477
|
+
`;
|
|
478
|
+
}
|
|
479
|
+
/** Our official npm package/alias — what `npx <this>` reinstalls the CLI as. */
|
|
480
|
+
const OFFICIAL_CLI_PKG = 'cezar-cli';
|
|
481
|
+
/**
|
|
482
|
+
* Decide the absolute ExecStart command for the service, mirroring how the
|
|
483
|
+
* installer itself was launched so the box keeps running the same cezar:
|
|
484
|
+
*
|
|
485
|
+
* - Launched via `npx <alias>` (the CLI package lives in npm's ephemeral
|
|
486
|
+
* `_npx` cache, which gets cleaned): the service can't point there, so it
|
|
487
|
+
* reinstalls-and-runs the same way — `<abs npx> --yes cezar-cli` (bare `npx`
|
|
488
|
+
* would 203/EXEC, so npx is made absolute).
|
|
489
|
+
* - A stable install (a checkout, or a global `cezar-cli`/`cezar`): run the
|
|
490
|
+
* CLI's own built entry `<node> <pkg>/dist/index.js`, or a resolved global
|
|
491
|
+
* bin. Absolute node + absolute script → systemd never resolves off PATH.
|
|
492
|
+
*/
|
|
493
|
+
export function serviceExecStart(opts) {
|
|
494
|
+
if (/[/\\]_npx[/\\]/.test(opts.pkgRoot))
|
|
495
|
+
return `${opts.npxPath} --yes ${OFFICIAL_CLI_PKG}`;
|
|
496
|
+
if (opts.entryExists)
|
|
497
|
+
return `${opts.node} ${opts.entry}`;
|
|
498
|
+
if (opts.globalBin)
|
|
499
|
+
return `${opts.node} ${opts.globalBin}`;
|
|
500
|
+
return `${opts.node} ${opts.entry}`;
|
|
501
|
+
}
|
|
502
|
+
async function resolveExecStart(ctx) {
|
|
503
|
+
const node = process.execPath; // absolute node running this installer
|
|
504
|
+
// The CLI's OWN package root (…/dist/server-install/platforms/ → up 3), NOT the
|
|
505
|
+
// repo being operated on — `--repo` / cwd can differ from where cezar lives.
|
|
506
|
+
const pkgRoot = resolve(fileURLToPath(new URL('../../..', import.meta.url)));
|
|
507
|
+
const entry = join(pkgRoot, 'dist', 'index.js');
|
|
508
|
+
let npxPath = join(dirname(node), 'npx');
|
|
509
|
+
if (ctx.dryRun)
|
|
510
|
+
return serviceExecStart({ node, pkgRoot, entry, entryExists: true, npxPath });
|
|
511
|
+
// A standalone node binary has no adjacent npx — fall back to the login
|
|
512
|
+
// shell's, or ExecStart would 203/EXEC (the exact bug 9fea263 fixed).
|
|
513
|
+
if (!existsSync(npxPath)) {
|
|
514
|
+
const found = (await ctx.runner.capture('bash', ['-lc', 'command -v npx'])).stdout.trim().split('\n').pop()?.trim();
|
|
515
|
+
if (found)
|
|
516
|
+
npxPath = found;
|
|
517
|
+
}
|
|
518
|
+
let globalBin;
|
|
519
|
+
if (!existsSync(entry) && !/[/\\]_npx[/\\]/.test(pkgRoot)) {
|
|
520
|
+
const out = (await ctx.runner.capture('bash', ['-lc', `command -v ${OFFICIAL_CLI_PKG} || command -v cezar`])).stdout.trim();
|
|
521
|
+
globalBin = out.split('\n').map((s) => s.trim()).filter(Boolean).pop();
|
|
522
|
+
if (!globalBin) {
|
|
523
|
+
ctx.ui.warn(`Could not locate a built cezar to run (${entry} missing, no global ${OFFICIAL_CLI_PKG}).\n` +
|
|
524
|
+
`Install it (npm i -g ${OFFICIAL_CLI_PKG}) or build the checkout, then re-run with --reconfigure autostart.`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return serviceExecStart({ node, pkgRoot, entry, entryExists: existsSync(entry), npxPath, globalBin });
|
|
528
|
+
}
|
|
529
|
+
const autostartStep = {
|
|
530
|
+
id: 'autostart',
|
|
531
|
+
// Required: after install the cockpit must actually be serving, so cezar runs
|
|
532
|
+
// as a systemd service — started now AND enabled on boot. (id kept as
|
|
533
|
+
// `autostart` for state compatibility.)
|
|
534
|
+
title: 'Run cezar as a service (systemd — starts now + on boot)',
|
|
535
|
+
async check() {
|
|
536
|
+
return false; // always (re)assert the service is installed and running
|
|
537
|
+
},
|
|
538
|
+
async run(ctx) {
|
|
539
|
+
const execStart = await resolveExecStart(ctx);
|
|
540
|
+
// Prefer a rootless `systemd --user` service + linger; fall back to a system
|
|
541
|
+
// unit (via sudoStep) when the user bus is not reachable. `show-environment`
|
|
542
|
+
// exits 0 iff the user manager is up — on a headless SSH box with no session
|
|
543
|
+
// it fails (exit 1, not 127), which the old `!== 127` test misread as "up".
|
|
544
|
+
const userBus = ctx.dryRun ? true : (await ctx.runner.capture('systemctl', ['--user', 'show-environment'])).code === 0;
|
|
545
|
+
if (userBus) {
|
|
546
|
+
const unitPath = join(homedir(), '.config', 'systemd', 'user', UNIT_NAME);
|
|
547
|
+
if (ctx.dryRun) {
|
|
548
|
+
ctx.ui.info(`DRY RUN — would write ${unitPath} and enable it (systemctl --user enable --now cezar).`);
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
mkdirSync(join(homedir(), '.config', 'systemd', 'user'), { recursive: true });
|
|
552
|
+
writeFileSync(unitPath, systemdUnit(ctx.repoRoot, ctx.state.primaryPort, 'user', execStart), 'utf8');
|
|
553
|
+
await ctx.runner.interactive('systemctl', ['--user', 'daemon-reload']);
|
|
554
|
+
await ctx.runner.interactive('systemctl', ['--user', 'enable', '--now', UNIT_NAME]);
|
|
555
|
+
if ((await ctx.runner.capture('systemctl', ['--user', 'is-enabled', UNIT_NAME])).code !== 0) {
|
|
556
|
+
ctx.ui.warn('The user service did not enable cleanly — check `systemctl --user status cezar`.');
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// Linger lets the user service survive logout / start at boot — usually
|
|
560
|
+
// needs root. Record it as owned only when THIS run turned it on, so
|
|
561
|
+
// uninstall reverses exactly what install changed (a pre-existing linger
|
|
562
|
+
// may serve other user services).
|
|
563
|
+
const osUser = userInfo().username;
|
|
564
|
+
const lingerWasOn = await verifyCommand(ctx, 'loginctl', ['show-user', osUser, '-p', 'Linger'], (r) => r.stdout.includes('Linger=yes'));
|
|
565
|
+
await sudoStep(ctx, {
|
|
566
|
+
description: 'Enable linger so the cockpit starts at boot without a login session.',
|
|
567
|
+
command: `loginctl enable-linger ${shquote(osUser)}`,
|
|
568
|
+
verify: (c) => verifyCommand(c, 'loginctl', ['show-user', osUser, '-p', 'Linger'], (r) => r.stdout.includes('Linger=yes')),
|
|
569
|
+
});
|
|
570
|
+
await confirmCezarRunning(ctx, 'systemctl --user status cezar', 'journalctl --user -u cezar -n 50 --no-pager');
|
|
571
|
+
const artifacts = [owned('service', { name: UNIT_NAME, scope: 'user', path: unitPath })];
|
|
572
|
+
if (!lingerWasOn)
|
|
573
|
+
artifacts.push(owned('linger', { name: osUser }));
|
|
574
|
+
return { artifacts };
|
|
575
|
+
}
|
|
576
|
+
// System unit fallback.
|
|
577
|
+
await writeFileStep(ctx, {
|
|
578
|
+
description: 'Install the cezar systemd unit, start it now, and enable it at boot.',
|
|
579
|
+
path: `/etc/systemd/system/${UNIT_NAME}`,
|
|
580
|
+
content: systemdUnit(ctx.repoRoot, ctx.state.primaryPort, 'system', execStart),
|
|
581
|
+
extra: `systemctl daemon-reload && systemctl enable --now ${UNIT_NAME}`,
|
|
582
|
+
verify: (c) => verifyCommand(c, 'systemctl', ['is-enabled', UNIT_NAME]),
|
|
583
|
+
});
|
|
584
|
+
await confirmCezarRunning(ctx, 'sudo systemctl status cezar', 'sudo journalctl -u cezar -n 50 --no-pager');
|
|
585
|
+
return { artifacts: [owned('service', { name: UNIT_NAME, scope: 'system', path: `/etc/systemd/system/${UNIT_NAME}` })] };
|
|
586
|
+
},
|
|
587
|
+
async undo(ctx, created) {
|
|
588
|
+
const svc = (created?.artifacts ?? []).find((a) => a.type === 'service');
|
|
589
|
+
// Reverse linger only when install recorded that it enabled it.
|
|
590
|
+
const linger = (created?.artifacts ?? []).find((a) => a.type === 'linger');
|
|
591
|
+
if (svc?.scope === 'user') {
|
|
592
|
+
if (!ctx.dryRun) {
|
|
593
|
+
await ctx.runner.interactive('systemctl', ['--user', 'disable', '--now', UNIT_NAME]);
|
|
594
|
+
if (svc.path)
|
|
595
|
+
rmSync(svc.path, { force: true });
|
|
596
|
+
await ctx.runner.interactive('systemctl', ['--user', 'daemon-reload']);
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
ctx.ui.info('DRY RUN — would disable and remove the user service.');
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
else if (svc) {
|
|
603
|
+
await sudoStep(ctx, {
|
|
604
|
+
description: 'Disable and remove the cezar systemd unit.',
|
|
605
|
+
command: `systemctl disable --now ${UNIT_NAME}; rm -f /etc/systemd/system/${UNIT_NAME} && systemctl daemon-reload`,
|
|
606
|
+
verify: (c) => verifyCommand(c, 'sh', ['-c', `! systemctl is-enabled ${UNIT_NAME}`]),
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
if (linger?.name) {
|
|
610
|
+
await sudoStep(ctx, {
|
|
611
|
+
description: 'Disable linger (this install enabled it; nothing else needs it).',
|
|
612
|
+
command: `loginctl disable-linger ${shquote(linger.name)}`,
|
|
613
|
+
verify: (c) => verifyCommand(c, 'loginctl', ['show-user', linger.name ?? '', '-p', 'Linger'], (r) => r.stdout.includes('Linger=no')),
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
};
|
|
618
|
+
const identityStep = {
|
|
619
|
+
id: 'identity',
|
|
620
|
+
title: 'Verify the cockpit end-to-end (auth + HTTPS reach cezar)',
|
|
621
|
+
async check() {
|
|
622
|
+
return false; // always re-verify; it creates nothing
|
|
623
|
+
},
|
|
624
|
+
async run(ctx) {
|
|
625
|
+
if (ctx.dryRun) {
|
|
626
|
+
ctx.ui.info('DRY RUN — would verify auth (401 for anon) AND that an authenticated request reaches cezar.');
|
|
627
|
+
return { artifacts: [] };
|
|
628
|
+
}
|
|
629
|
+
const port = ctx.state.primaryPort;
|
|
630
|
+
const https = ctx.state.publicUrl?.startsWith('https://') ?? false;
|
|
631
|
+
const base = https ? 'https://127.0.0.1/' : 'http://127.0.0.1/';
|
|
632
|
+
const tls = https ? ['-k'] : [];
|
|
633
|
+
// 1) Is cezar actually listening on the loopback upstream? nginx challenges
|
|
634
|
+
// auth *before* proxying, so an anonymous 401 alone does NOT prove the
|
|
635
|
+
// backend is up — check the upstream directly.
|
|
636
|
+
const upstreamUp = await isCezarUp(ctx, port);
|
|
637
|
+
// 2) An anonymous request through nginx must be challenged (auth is active).
|
|
638
|
+
const anonCode = await curlCode(ctx, [...tls, base]);
|
|
639
|
+
const authEnforced = anonCode === '401';
|
|
640
|
+
// 3) The real proof: an AUTHENTICATED request reaches cezar (2xx/3xx — not
|
|
641
|
+
// 401/403 = bad creds, not 502/504 = upstream down). Credentials are read
|
|
642
|
+
// from stdin (curl -K -) so they never land in argv. `null` = not testable
|
|
643
|
+
// (a resume where the plaintext password is no longer in memory).
|
|
644
|
+
let authedOk = null;
|
|
645
|
+
const cred = ctx.prefs.cockpit;
|
|
646
|
+
if (cred) {
|
|
647
|
+
// curl's config format requires `\` and `"` escaped inside the quoted
|
|
648
|
+
// value — both are legal password characters; unescaped they break the
|
|
649
|
+
// config parse and fail a WORKING install with "bad credentials".
|
|
650
|
+
const curlCfgQuote = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
651
|
+
const code = await curlCode(ctx, [...tls, '-K', '-', base], {
|
|
652
|
+
input: `user = "${curlCfgQuote(cred.user)}:${curlCfgQuote(cred.password)}"\n`,
|
|
653
|
+
});
|
|
654
|
+
authedOk = /^[23]\d\d$/.test(code);
|
|
655
|
+
}
|
|
656
|
+
const url = ctx.state.publicUrl ?? `http://<this-server>`;
|
|
657
|
+
const coreOk = upstreamUp && authEnforced && authedOk !== false;
|
|
658
|
+
if (coreOk) {
|
|
659
|
+
ctx.ui.success(`Cockpit is live at ${url} — ${authedOk ? 'an authenticated request reached cezar' : 'auth is enforced and cezar is up'}. ` +
|
|
660
|
+
'Log in with the username and password you set.');
|
|
661
|
+
if (!https) {
|
|
662
|
+
ctx.ui.warn('This cockpit is HTTP-only (no domain/SSL configured). To serve it over HTTPS with this same auth, ' +
|
|
663
|
+
're-run: cezar server-install --platform ubuntu-vps --reconfigure ssl');
|
|
664
|
+
}
|
|
665
|
+
return { artifacts: [] };
|
|
666
|
+
}
|
|
667
|
+
const problems = [];
|
|
668
|
+
if (!upstreamUp)
|
|
669
|
+
problems.push(`cezar is not listening on 127.0.0.1:${port} — the service is down, so nginx returns 502`);
|
|
670
|
+
if (!authEnforced)
|
|
671
|
+
problems.push(`nginx did not challenge an anonymous request (got "${anonCode}") — basic auth may not be active`);
|
|
672
|
+
if (authedOk === false)
|
|
673
|
+
problems.push('an authenticated request did not reach cezar (bad credentials, or the upstream is down)');
|
|
674
|
+
ctx.ui.error(`The cockpit is NOT fully working yet:\n` +
|
|
675
|
+
problems.map((p) => ` • ${p}`).join('\n') +
|
|
676
|
+
`\n\nDiagnostics on the server:\n` +
|
|
677
|
+
` • systemctl --user status cezar (or: sudo systemctl status cezar)\n` +
|
|
678
|
+
` • sudo systemctl status nginx && sudo nginx -t\n` +
|
|
679
|
+
` • sudo ss -ltnp | grep -E ':80|:443|:${port}'\n` +
|
|
680
|
+
` • ports 80/443 open in ufw AND any cloud firewall (Hetzner/AWS)`);
|
|
681
|
+
// Fail the run so `installed` stays false and the exit code is non-zero —
|
|
682
|
+
// "complete" must mean the cockpit actually works. Re-run to resume (the
|
|
683
|
+
// service/proxy steps are idempotent).
|
|
684
|
+
throw new StepAborted('cockpit verification failed — see the diagnostics above');
|
|
685
|
+
},
|
|
686
|
+
async undo() {
|
|
687
|
+
// nothing created
|
|
688
|
+
},
|
|
689
|
+
};
|
|
690
|
+
export const ubuntuVps = {
|
|
691
|
+
id: 'ubuntu-vps',
|
|
692
|
+
label: 'Ubuntu/Debian VPS',
|
|
693
|
+
async preflight(ctx) {
|
|
694
|
+
if (ctx.dryRun) {
|
|
695
|
+
ctx.ui.info('DRY RUN — skipping OS/privilege preflight.');
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const uname = await ctx.runner.capture('uname', ['-s']);
|
|
699
|
+
if (!uname.stdout.includes('Linux')) {
|
|
700
|
+
throw new PreflightError('ubuntu-vps requires Linux. On macOS use --platform macosx-ngrok.');
|
|
701
|
+
}
|
|
702
|
+
if ((await ctx.runner.capture('apt-get', ['--version'])).code !== 0) {
|
|
703
|
+
throw new PreflightError('ubuntu-vps requires apt (Debian/Ubuntu).');
|
|
704
|
+
}
|
|
705
|
+
if ((await ctx.runner.capture('id', ['-u'])).stdout.trim() === '0') {
|
|
706
|
+
throw new PreflightError('run server-install as a normal sudo-capable user, not root.');
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
steps() {
|
|
710
|
+
// deps → proxy → (optional) ssl → (optional) autostart → identity.
|
|
711
|
+
return [depCheckStep(), nginxProxyStep, sslStep, autostartStep, identityStep];
|
|
712
|
+
},
|
|
713
|
+
async redeploy(ctx) {
|
|
714
|
+
// Restart the systemd service so it picks up the new cezar (a fresh local
|
|
715
|
+
// build the unit runs from, or a newly published cezar-cli via npx), then
|
|
716
|
+
// re-run the same end-to-end verify install uses.
|
|
717
|
+
const svc = (ctx.state.steps['autostart']?.created?.artifacts ?? []).find((a) => a.type === 'service');
|
|
718
|
+
// No recorded artifact (older record, or the step was check()-satisfied):
|
|
719
|
+
// prefer the scope whose unit file actually exists — the user unit is the
|
|
720
|
+
// install default, so defaulting to `system` would sudo-restart a unit
|
|
721
|
+
// that was never installed.
|
|
722
|
+
const userUnitExists = existsSync(join(homedir(), '.config', 'systemd', 'user', UNIT_NAME));
|
|
723
|
+
const scope = svc?.scope === 'user' || svc?.scope === 'system' ? svc.scope : userUnitExists ? 'user' : 'system';
|
|
724
|
+
if (ctx.dryRun) {
|
|
725
|
+
ctx.ui.info(`DRY RUN — would reload+restart the cezar ${scope} service and re-verify the cockpit.`);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
ctx.ui.info(`Redeploying — restarting the cezar ${scope} service to pick up the new version.`);
|
|
729
|
+
if (scope === 'user') {
|
|
730
|
+
await ctx.runner.interactive('systemctl', ['--user', 'daemon-reload']);
|
|
731
|
+
const code = await ctx.runner.interactive('systemctl', ['--user', 'restart', UNIT_NAME]);
|
|
732
|
+
if (code !== 0)
|
|
733
|
+
ctx.ui.warn('systemctl --user restart returned non-zero — check `systemctl --user status cezar`.');
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
await sudoStep(ctx, {
|
|
737
|
+
description: 'Reload systemd and restart the cezar service.',
|
|
738
|
+
command: `systemctl daemon-reload && systemctl restart ${UNIT_NAME}`,
|
|
739
|
+
verify: (c) => verifyCommand(c, 'systemctl', ['is-active', UNIT_NAME]),
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
await confirmCezarRunning(ctx, scope === 'user' ? 'systemctl --user status cezar' : 'sudo systemctl status cezar', scope === 'user' ? 'journalctl --user -u cezar -n 50 --no-pager' : 'sudo journalctl -u cezar -n 50 --no-pager');
|
|
743
|
+
await identityStep.run(ctx); // throws StepAborted if the cockpit isn't fully working
|
|
744
|
+
},
|
|
745
|
+
};
|
|
746
|
+
//# sourceMappingURL=ubuntu-vps.js.map
|