@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.
Files changed (264) hide show
  1. package/README.md +573 -0
  2. package/dist/config.d.ts +144 -0
  3. package/dist/config.js +106 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/core/agent-runner.d.ts +169 -0
  6. package/dist/core/agent-runner.js +23 -0
  7. package/dist/core/agent-runner.js.map +1 -0
  8. package/dist/core/backend-detect.d.ts +16 -0
  9. package/dist/core/backend-detect.js +118 -0
  10. package/dist/core/backend-detect.js.map +1 -0
  11. package/dist/core/claude-cli-runner.d.ts +56 -0
  12. package/dist/core/claude-cli-runner.js +416 -0
  13. package/dist/core/claude-cli-runner.js.map +1 -0
  14. package/dist/core/claude-ui-mapper.d.ts +71 -0
  15. package/dist/core/claude-ui-mapper.js +567 -0
  16. package/dist/core/claude-ui-mapper.js.map +1 -0
  17. package/dist/core/codex-app-server-runner.d.ts +29 -0
  18. package/dist/core/codex-app-server-runner.js +489 -0
  19. package/dist/core/codex-app-server-runner.js.map +1 -0
  20. package/dist/core/codex-ui-mapper.d.ts +63 -0
  21. package/dist/core/codex-ui-mapper.js +491 -0
  22. package/dist/core/codex-ui-mapper.js.map +1 -0
  23. package/dist/core/ndjson.d.ts +6 -0
  24. package/dist/core/ndjson.js +23 -0
  25. package/dist/core/ndjson.js.map +1 -0
  26. package/dist/core/opencode-server-runner.d.ts +30 -0
  27. package/dist/core/opencode-server-runner.js +488 -0
  28. package/dist/core/opencode-server-runner.js.map +1 -0
  29. package/dist/core/opencode-ui-mapper.d.ts +105 -0
  30. package/dist/core/opencode-ui-mapper.js +588 -0
  31. package/dist/core/opencode-ui-mapper.js.map +1 -0
  32. package/dist/core/process-usage.d.ts +63 -0
  33. package/dist/core/process-usage.js +181 -0
  34. package/dist/core/process-usage.js.map +1 -0
  35. package/dist/core/runner-factory.d.ts +8 -0
  36. package/dist/core/runner-factory.js +22 -0
  37. package/dist/core/runner-factory.js.map +1 -0
  38. package/dist/core/tool-display.d.ts +30 -0
  39. package/dist/core/tool-display.js +156 -0
  40. package/dist/core/tool-display.js.map +1 -0
  41. package/dist/core/ui-events.d.ts +308 -0
  42. package/dist/core/ui-events.js +25 -0
  43. package/dist/core/ui-events.js.map +1 -0
  44. package/dist/core/usage.d.ts +11 -0
  45. package/dist/core/usage.js +15 -0
  46. package/dist/core/usage.js.map +1 -0
  47. package/dist/git-worktree.d.ts +88 -0
  48. package/dist/git-worktree.js +297 -0
  49. package/dist/git-worktree.js.map +1 -0
  50. package/dist/handoff.d.ts +62 -0
  51. package/dist/handoff.js +147 -0
  52. package/dist/handoff.js.map +1 -0
  53. package/dist/index.d.ts +2 -0
  54. package/dist/index.js +466 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/install-as-command.d.ts +39 -0
  57. package/dist/install-as-command.js +51 -0
  58. package/dist/install-as-command.js.map +1 -0
  59. package/dist/pack-check.d.ts +17 -0
  60. package/dist/pack-check.js +27 -0
  61. package/dist/pack-check.js.map +1 -0
  62. package/dist/paths.d.ts +23 -0
  63. package/dist/paths.js +34 -0
  64. package/dist/paths.js.map +1 -0
  65. package/dist/planner.d.ts +17 -0
  66. package/dist/planner.js +248 -0
  67. package/dist/planner.js.map +1 -0
  68. package/dist/release/snapshot.d.ts +64 -0
  69. package/dist/release/snapshot.js +75 -0
  70. package/dist/release/snapshot.js.map +1 -0
  71. package/dist/release/stable.d.ts +46 -0
  72. package/dist/release/stable.js +57 -0
  73. package/dist/release/stable.js.map +1 -0
  74. package/dist/runs/auto-name.d.ts +75 -0
  75. package/dist/runs/auto-name.js +168 -0
  76. package/dist/runs/auto-name.js.map +1 -0
  77. package/dist/runs/retention.d.ts +60 -0
  78. package/dist/runs/retention.js +86 -0
  79. package/dist/runs/retention.js.map +1 -0
  80. package/dist/runs/review-gate.d.ts +18 -0
  81. package/dist/runs/review-gate.js +21 -0
  82. package/dist/runs/review-gate.js.map +1 -0
  83. package/dist/runs/store.d.ts +408 -0
  84. package/dist/runs/store.js +526 -0
  85. package/dist/runs/store.js.map +1 -0
  86. package/dist/runs/task-refs.d.ts +24 -0
  87. package/dist/runs/task-refs.js +74 -0
  88. package/dist/runs/task-refs.js.map +1 -0
  89. package/dist/runs/ui-event-sink.d.ts +81 -0
  90. package/dist/runs/ui-event-sink.js +210 -0
  91. package/dist/runs/ui-event-sink.js.map +1 -0
  92. package/dist/server/capabilities.d.ts +33 -0
  93. package/dist/server/capabilities.js +43 -0
  94. package/dist/server/capabilities.js.map +1 -0
  95. package/dist/server/forge/github.d.ts +70 -0
  96. package/dist/server/forge/github.js +375 -0
  97. package/dist/server/forge/github.js.map +1 -0
  98. package/dist/server/forge/index.d.ts +24 -0
  99. package/dist/server/forge/index.js +45 -0
  100. package/dist/server/forge/index.js.map +1 -0
  101. package/dist/server/forge/types.d.ts +80 -0
  102. package/dist/server/forge/types.js +2 -0
  103. package/dist/server/forge/types.js.map +1 -0
  104. package/dist/server/git-changes.d.ts +195 -0
  105. package/dist/server/git-changes.js +497 -0
  106. package/dist/server/git-changes.js.map +1 -0
  107. package/dist/server/git.d.ts +26 -0
  108. package/dist/server/git.js +103 -0
  109. package/dist/server/git.js.map +1 -0
  110. package/dist/server/github.d.ts +8 -0
  111. package/dist/server/github.js +8 -0
  112. package/dist/server/github.js.map +1 -0
  113. package/dist/server/launch-key.d.ts +7 -0
  114. package/dist/server/launch-key.js +33 -0
  115. package/dist/server/launch-key.js.map +1 -0
  116. package/dist/server/open-in-app.d.ts +51 -0
  117. package/dist/server/open-in-app.js +189 -0
  118. package/dist/server/open-in-app.js.map +1 -0
  119. package/dist/server/open-in-terminal.d.ts +25 -0
  120. package/dist/server/open-in-terminal.js +120 -0
  121. package/dist/server/open-in-terminal.js.map +1 -0
  122. package/dist/server/pr.d.ts +7 -0
  123. package/dist/server/pr.js +7 -0
  124. package/dist/server/pr.js.map +1 -0
  125. package/dist/server/server.d.ts +41 -0
  126. package/dist/server/server.js +1390 -0
  127. package/dist/server/server.js.map +1 -0
  128. package/dist/server/static-ui.d.ts +49 -0
  129. package/dist/server/static-ui.js +92 -0
  130. package/dist/server/static-ui.js.map +1 -0
  131. package/dist/server/wsl.d.ts +25 -0
  132. package/dist/server/wsl.js +92 -0
  133. package/dist/server/wsl.js.map +1 -0
  134. package/dist/server-install/engine.d.ts +33 -0
  135. package/dist/server-install/engine.js +314 -0
  136. package/dist/server-install/engine.js.map +1 -0
  137. package/dist/server-install/platforms/macosx-ngrok.d.ts +6 -0
  138. package/dist/server-install/platforms/macosx-ngrok.js +345 -0
  139. package/dist/server-install/platforms/macosx-ngrok.js.map +1 -0
  140. package/dist/server-install/platforms/ubuntu-vps.d.ts +39 -0
  141. package/dist/server-install/platforms/ubuntu-vps.js +746 -0
  142. package/dist/server-install/platforms/ubuntu-vps.js.map +1 -0
  143. package/dist/server-install/state.d.ts +26 -0
  144. package/dist/server-install/state.js +125 -0
  145. package/dist/server-install/state.js.map +1 -0
  146. package/dist/server-install/steps.d.ts +109 -0
  147. package/dist/server-install/steps.js +330 -0
  148. package/dist/server-install/steps.js.map +1 -0
  149. package/dist/server-install/strategies.d.ts +4 -0
  150. package/dist/server-install/strategies.js +20 -0
  151. package/dist/server-install/strategies.js.map +1 -0
  152. package/dist/server-install/types.d.ts +597 -0
  153. package/dist/server-install/types.js +98 -0
  154. package/dist/server-install/types.js.map +1 -0
  155. package/dist/server-install/ui.d.ts +42 -0
  156. package/dist/server-install/ui.js +130 -0
  157. package/dist/server-install/ui.js.map +1 -0
  158. package/dist/skills-banner.d.ts +21 -0
  159. package/dist/skills-banner.js +40 -0
  160. package/dist/skills-banner.js.map +1 -0
  161. package/dist/skills-remote.d.ts +35 -0
  162. package/dist/skills-remote.js +266 -0
  163. package/dist/skills-remote.js.map +1 -0
  164. package/dist/skills.d.ts +45 -0
  165. package/dist/skills.js +182 -0
  166. package/dist/skills.js.map +1 -0
  167. package/dist/todos.d.ts +63 -0
  168. package/dist/todos.js +168 -0
  169. package/dist/todos.js.map +1 -0
  170. package/dist/ui-state.d.ts +12 -0
  171. package/dist/ui-state.js +25 -0
  172. package/dist/ui-state.js.map +1 -0
  173. package/dist/update-check.d.ts +12 -0
  174. package/dist/update-check.js +41 -0
  175. package/dist/update-check.js.map +1 -0
  176. package/dist/workflows/load.d.ts +15 -0
  177. package/dist/workflows/load.js +56 -0
  178. package/dist/workflows/load.js.map +1 -0
  179. package/dist/workflows/run.d.ts +281 -0
  180. package/dist/workflows/run.js +1511 -0
  181. package/dist/workflows/run.js.map +1 -0
  182. package/dist/workflows/types.d.ts +307 -0
  183. package/dist/workflows/types.js +171 -0
  184. package/dist/workflows/types.js.map +1 -0
  185. package/package.json +90 -0
  186. package/scripts/check-pack.mjs +46 -0
  187. package/scripts/dev.mjs +74 -0
  188. package/scripts/install-as-command.mjs +120 -0
  189. package/scripts/mock-claude.mjs +310 -0
  190. package/scripts/release-snapshot.mjs +130 -0
  191. package/scripts/release.mjs +119 -0
  192. package/scripts/test-process-usage.mjs +47 -0
  193. package/web/dist/assets/arrow-left-BE957rJs.js +1 -0
  194. package/web/dist/assets/bundle-mjs-DLPpRFyl.js +1 -0
  195. package/web/dist/assets/centered-state-BddhEEtO.js +43 -0
  196. package/web/dist/assets/chunk-BO2N2NFS-f5igTtpB.js +131 -0
  197. package/web/dist/assets/command-CjhFbABo.js +1 -0
  198. package/web/dist/assets/compare-variants-Cs52IAEG.js +1 -0
  199. package/web/dist/assets/core-BuMqAF_1.js +12 -0
  200. package/web/dist/assets/css-BsVw1vtW.js +1 -0
  201. package/web/dist/assets/dialog-DFvXTkFv.js +1 -0
  202. package/web/dist/assets/diff-view-C9b8AITq.js +4 -0
  203. package/web/dist/assets/diff-woXpYk--.js +1 -0
  204. package/web/dist/assets/dist-FBs4wmIY.js +1 -0
  205. package/web/dist/assets/dist-o_kVMXjg.js +1 -0
  206. package/web/dist/assets/engine-javascript-FQvzRocB.js +141 -0
  207. package/web/dist/assets/file-BVuztAOd.js +1 -0
  208. package/web/dist/assets/folder-0pJ1NZ_S.js +1 -0
  209. package/web/dist/assets/git-pull-request-DJkAMT0f.js +1 -0
  210. package/web/dist/assets/git-toolbar-CwhQdx3J.js +1 -0
  211. package/web/dist/assets/github-D0rAX4-r.js +1 -0
  212. package/web/dist/assets/go-rLFTqkRN.js +1 -0
  213. package/web/dist/assets/highlighted-body-OFNGDK62-DGHHie_q.js +1 -0
  214. package/web/dist/assets/highlighter-BKVEsACy.js +3 -0
  215. package/web/dist/assets/html-BY76lXLH.js +1 -0
  216. package/web/dist/assets/index-CEkI7QxN.css +2 -0
  217. package/web/dist/assets/index-CO2lH2et.js +11 -0
  218. package/web/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  219. package/web/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  220. package/web/dist/assets/javascript-BgS3c2Ky.js +1 -0
  221. package/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  222. package/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  223. package/web/dist/assets/json-qhed-kSA.js +1 -0
  224. package/web/dist/assets/jsonc-CYpm1nAK.js +1 -0
  225. package/web/dist/assets/jsx-Bz0zcwM4.js +1 -0
  226. package/web/dist/assets/lib-CNGa3Dig.js +1 -0
  227. package/web/dist/assets/loader-circle-BV3DO9mE.js +1 -0
  228. package/web/dist/assets/markdown-BYOwaDjH.js +1 -0
  229. package/web/dist/assets/markdown-D4-ADgH9.js +1 -0
  230. package/web/dist/assets/mermaid-GHXKKRXX-DM2VsGBG.js +1 -0
  231. package/web/dist/assets/open-mercato-toBr6SOa.svg +11 -0
  232. package/web/dist/assets/prompt-templates-C7ptKvDd.js +15 -0
  233. package/web/dist/assets/python-gzcpVVnB.js +1 -0
  234. package/web/dist/assets/react-dom-Ddtik4qM.js +1 -0
  235. package/web/dist/assets/refresh-cw-DCa-Rlwz.js +1 -0
  236. package/web/dist/assets/repo-git-DIhryGDx.js +1 -0
  237. package/web/dist/assets/run-diff-Dyow8B1q.js +3 -0
  238. package/web/dist/assets/run-header-jx9-18b8.js +1 -0
  239. package/web/dist/assets/rust-Cfkwpbl8.js +1 -0
  240. package/web/dist/assets/search-x-CoCyl7zT.js +1 -0
  241. package/web/dist/assets/shellscript-CLZ0U2zV.js +1 -0
  242. package/web/dist/assets/skill-detail-DB7x_FQp.js +1 -0
  243. package/web/dist/assets/skills-98vCroU8.js +1 -0
  244. package/web/dist/assets/skills-BKIoqD6K.js +1 -0
  245. package/web/dist/assets/sql-BsFa4tDR.js +1 -0
  246. package/web/dist/assets/square-terminal-B6PZX4Qp.js +1 -0
  247. package/web/dist/assets/tab-link-C5--YMQd.js +1 -0
  248. package/web/dist/assets/task-changes-ejxPGVJw.js +1 -0
  249. package/web/dist/assets/task-commits-C6GO5qT-.js +1 -0
  250. package/web/dist/assets/task-files-CatpCBXa.js +2 -0
  251. package/web/dist/assets/task-thread-BMbXDM3d.js +5 -0
  252. package/web/dist/assets/tasks-table-dgezlCcg.js +1 -0
  253. package/web/dist/assets/trash-2-CDtxmmc8.js +1 -0
  254. package/web/dist/assets/triangle-alert-6su4Js5O.js +1 -0
  255. package/web/dist/assets/tsx-udAQXfEw.js +1 -0
  256. package/web/dist/assets/typescript-bsJCZSQ-.js +1 -0
  257. package/web/dist/assets/upload-Bp7hQxll.js +1 -0
  258. package/web/dist/assets/use-desktop-CvTwojOJ.js +3 -0
  259. package/web/dist/assets/utils-rQGbOrwc.js +1 -0
  260. package/web/dist/assets/workflows-Dy2c6790.js +11 -0
  261. package/web/dist/assets/x-Br_jIDBw.js +1 -0
  262. package/web/dist/assets/yaml-rwi0_p6S.js +1 -0
  263. package/web/dist/index.html +70 -0
  264. 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