@celilo/cli 0.1.0

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 (267) hide show
  1. package/README.md +1566 -0
  2. package/bin/celilo +16 -0
  3. package/drizzle/0000_complex_puma.sql +179 -0
  4. package/drizzle/0001_dizzy_wolfpack.sql +2 -0
  5. package/drizzle/0002_web_routes.sql +16 -0
  6. package/drizzle/0003_backup_storage.sql +32 -0
  7. package/drizzle/meta/0000_snapshot.json +1151 -0
  8. package/drizzle/meta/0001_snapshot.json +1167 -0
  9. package/drizzle/meta/0002_snapshot.json +1257 -0
  10. package/drizzle/meta/_journal.json +27 -0
  11. package/package.json +64 -0
  12. package/schemas/system_config.json +106 -0
  13. package/src/__integration__/container-services-cli.integration.test.ts +246 -0
  14. package/src/ansible/dependencies.test.ts +309 -0
  15. package/src/ansible/dependencies.ts +896 -0
  16. package/src/ansible/inventory.test.ts +463 -0
  17. package/src/ansible/inventory.ts +445 -0
  18. package/src/ansible/secrets.ts +222 -0
  19. package/src/ansible/validation.test.ts +92 -0
  20. package/src/ansible/validation.ts +272 -0
  21. package/src/api-clients/digitalocean.ts +94 -0
  22. package/src/api-clients/proxmox.ts +655 -0
  23. package/src/capabilities/logging-wrapper.test.ts +217 -0
  24. package/src/capabilities/lookup.test.ts +149 -0
  25. package/src/capabilities/lookup.ts +89 -0
  26. package/src/capabilities/public-web-helpers.test.ts +198 -0
  27. package/src/capabilities/public-web-publish.test.ts +458 -0
  28. package/src/capabilities/registration.test.ts +395 -0
  29. package/src/capabilities/registration.ts +200 -0
  30. package/src/capabilities/route-validation.test.ts +121 -0
  31. package/src/capabilities/route-validation.ts +96 -0
  32. package/src/capabilities/secret-ref.test.ts +313 -0
  33. package/src/capabilities/secret-validation.ts +157 -0
  34. package/src/capabilities/secrets.test.ts +750 -0
  35. package/src/capabilities/secrets.ts +244 -0
  36. package/src/capabilities/validation.test.ts +613 -0
  37. package/src/capabilities/validation.ts +160 -0
  38. package/src/capabilities/well-known.test.ts +238 -0
  39. package/src/capabilities/well-known.ts +222 -0
  40. package/src/cli/cli.test.ts +654 -0
  41. package/src/cli/command-registry.ts +742 -0
  42. package/src/cli/command-tree-parser.test.ts +180 -0
  43. package/src/cli/command-tree-parser.ts +193 -0
  44. package/src/cli/commands/backup-create.ts +137 -0
  45. package/src/cli/commands/backup-delete.ts +74 -0
  46. package/src/cli/commands/backup-import.ts +97 -0
  47. package/src/cli/commands/backup-list.ts +132 -0
  48. package/src/cli/commands/backup-name.ts +73 -0
  49. package/src/cli/commands/backup-prune.ts +98 -0
  50. package/src/cli/commands/backup-restore.ts +122 -0
  51. package/src/cli/commands/capability-info.ts +121 -0
  52. package/src/cli/commands/capability-list.ts +47 -0
  53. package/src/cli/commands/completion.ts +87 -0
  54. package/src/cli/commands/hook-run.ts +176 -0
  55. package/src/cli/commands/ipam.ts +607 -0
  56. package/src/cli/commands/machine-add.ts +235 -0
  57. package/src/cli/commands/machine-earmark.ts +82 -0
  58. package/src/cli/commands/machine-list.ts +77 -0
  59. package/src/cli/commands/machine-remove.ts +90 -0
  60. package/src/cli/commands/machine-status.ts +131 -0
  61. package/src/cli/commands/module-audit.ts +51 -0
  62. package/src/cli/commands/module-build.ts +60 -0
  63. package/src/cli/commands/module-config.ts +170 -0
  64. package/src/cli/commands/module-deploy.ts +71 -0
  65. package/src/cli/commands/module-generate.ts +236 -0
  66. package/src/cli/commands/module-health.ts +108 -0
  67. package/src/cli/commands/module-import.ts +80 -0
  68. package/src/cli/commands/module-list.ts +43 -0
  69. package/src/cli/commands/module-logs.ts +73 -0
  70. package/src/cli/commands/module-remove.ts +162 -0
  71. package/src/cli/commands/module-show.ts +208 -0
  72. package/src/cli/commands/module-status.ts +131 -0
  73. package/src/cli/commands/module-types.ts +189 -0
  74. package/src/cli/commands/module-upgrade.ts +192 -0
  75. package/src/cli/commands/package.ts +68 -0
  76. package/src/cli/commands/secret-list.ts +99 -0
  77. package/src/cli/commands/secret-set.ts +134 -0
  78. package/src/cli/commands/service-add-digitalocean.ts +133 -0
  79. package/src/cli/commands/service-add-proxmox.ts +342 -0
  80. package/src/cli/commands/service-config-get.ts +83 -0
  81. package/src/cli/commands/service-config-set.ts +145 -0
  82. package/src/cli/commands/service-list.ts +74 -0
  83. package/src/cli/commands/service-reconfigure.ts +230 -0
  84. package/src/cli/commands/service-remove.ts +103 -0
  85. package/src/cli/commands/service-verify.ts +240 -0
  86. package/src/cli/commands/status.ts +216 -0
  87. package/src/cli/commands/storage-add-local.ts +106 -0
  88. package/src/cli/commands/storage-add-s3.ts +114 -0
  89. package/src/cli/commands/storage-list.ts +72 -0
  90. package/src/cli/commands/storage-remove.ts +54 -0
  91. package/src/cli/commands/storage-set-default.ts +44 -0
  92. package/src/cli/commands/storage-verify.ts +54 -0
  93. package/src/cli/commands/system-config.ts +168 -0
  94. package/src/cli/commands/system-init.ts +314 -0
  95. package/src/cli/commands/system-secret-get.ts +98 -0
  96. package/src/cli/commands/system-secret-set.ts +76 -0
  97. package/src/cli/commands/system-vault-password.ts +34 -0
  98. package/src/cli/completion.test.ts +37 -0
  99. package/src/cli/completion.ts +482 -0
  100. package/src/cli/fuel-gauge.test.ts +208 -0
  101. package/src/cli/fuel-gauge.ts +405 -0
  102. package/src/cli/generate-zsh-completion.test.ts +95 -0
  103. package/src/cli/generate-zsh-completion.ts +497 -0
  104. package/src/cli/index.ts +1583 -0
  105. package/src/cli/interactive-config.test.ts +201 -0
  106. package/src/cli/interactive-config.ts +62 -0
  107. package/src/cli/parser.test.ts +227 -0
  108. package/src/cli/parser.ts +244 -0
  109. package/src/cli/prompts.test.ts +33 -0
  110. package/src/cli/prompts.ts +121 -0
  111. package/src/cli/types.ts +38 -0
  112. package/src/cli/validators.test.ts +235 -0
  113. package/src/cli/validators.ts +188 -0
  114. package/src/config/env.ts +41 -0
  115. package/src/config/paths.test.ts +172 -0
  116. package/src/config/paths.ts +108 -0
  117. package/src/db/client.ts +190 -0
  118. package/src/db/migrate.ts +30 -0
  119. package/src/db/schema.test.ts +221 -0
  120. package/src/db/schema.ts +434 -0
  121. package/src/hooks/capability-loader-firewall.test.ts +246 -0
  122. package/src/hooks/capability-loader.test.ts +100 -0
  123. package/src/hooks/capability-loader.ts +520 -0
  124. package/src/hooks/define-hook.test.ts +488 -0
  125. package/src/hooks/executor.test.ts +462 -0
  126. package/src/hooks/executor.ts +469 -0
  127. package/src/hooks/logger.test.ts +54 -0
  128. package/src/hooks/logger.ts +95 -0
  129. package/src/hooks/test-fixtures/failing-hook.ts +13 -0
  130. package/src/hooks/test-fixtures/no-default-hook.ts +6 -0
  131. package/src/hooks/test-fixtures/success-hook.ts +20 -0
  132. package/src/hooks/test-fixtures/unbranded-hook.ts +11 -0
  133. package/src/hooks/test-fixtures/void-hook.ts +13 -0
  134. package/src/hooks/types.ts +89 -0
  135. package/src/infrastructure/property-extractor.test.ts +194 -0
  136. package/src/infrastructure/property-extractor.ts +151 -0
  137. package/src/ipam/allocator.test.ts +442 -0
  138. package/src/ipam/allocator.ts +369 -0
  139. package/src/ipam/auto-allocator.test.ts +247 -0
  140. package/src/ipam/auto-allocator.ts +270 -0
  141. package/src/ipam/subnet-parser.test.ts +107 -0
  142. package/src/ipam/subnet-parser.ts +136 -0
  143. package/src/manifest/contracts/index.ts +61 -0
  144. package/src/manifest/contracts/v1.ts +118 -0
  145. package/src/manifest/json-schema-roundtrip.test.ts +99 -0
  146. package/src/manifest/schema.ts +367 -0
  147. package/src/manifest/template-validator.test.ts +231 -0
  148. package/src/manifest/template-validator.ts +322 -0
  149. package/src/manifest/validate.test.ts +1180 -0
  150. package/src/manifest/validate.ts +415 -0
  151. package/src/module/import.test.ts +355 -0
  152. package/src/module/import.ts +676 -0
  153. package/src/module/packaging/audit.ts +169 -0
  154. package/src/module/packaging/build.ts +228 -0
  155. package/src/module/packaging/checksum.ts +41 -0
  156. package/src/module/packaging/extract.ts +234 -0
  157. package/src/module/packaging/signature.ts +47 -0
  158. package/src/secrets/encryption.test.ts +284 -0
  159. package/src/secrets/encryption.ts +162 -0
  160. package/src/secrets/generators.test.ts +112 -0
  161. package/src/secrets/generators.ts +127 -0
  162. package/src/secrets/master-key.test.ts +159 -0
  163. package/src/secrets/master-key.ts +114 -0
  164. package/src/secrets/storage.test.ts +115 -0
  165. package/src/secrets/storage.ts +106 -0
  166. package/src/secrets/vault.test.ts +35 -0
  167. package/src/secrets/vault.ts +42 -0
  168. package/src/services/backup-create.ts +532 -0
  169. package/src/services/backup-metadata.ts +198 -0
  170. package/src/services/backup-restore.ts +229 -0
  171. package/src/services/backup-retention.ts +84 -0
  172. package/src/services/backup-storage.ts +281 -0
  173. package/src/services/build-stream.test.ts +122 -0
  174. package/src/services/build-stream.ts +201 -0
  175. package/src/services/config-interview.ts +694 -0
  176. package/src/services/container-service.test.ts +298 -0
  177. package/src/services/container-service.ts +401 -0
  178. package/src/services/cross-module-data-manager.test.ts +405 -0
  179. package/src/services/cross-module-data-manager.ts +412 -0
  180. package/src/services/deploy-ansible.ts +88 -0
  181. package/src/services/deploy-planner.ts +153 -0
  182. package/src/services/deploy-preflight.ts +274 -0
  183. package/src/services/deploy-ssh.ts +131 -0
  184. package/src/services/deploy-terraform.test.ts +55 -0
  185. package/src/services/deploy-terraform.ts +445 -0
  186. package/src/services/deploy-validation.ts +311 -0
  187. package/src/services/dns-auto-register.ts +211 -0
  188. package/src/services/health-runner.ts +184 -0
  189. package/src/services/infrastructure-selector.test.ts +485 -0
  190. package/src/services/infrastructure-selector.ts +245 -0
  191. package/src/services/infrastructure-variable-resolver.test.ts +751 -0
  192. package/src/services/infrastructure-variable-resolver.ts +234 -0
  193. package/src/services/machine-detector.ts +328 -0
  194. package/src/services/machine-pool.test.ts +405 -0
  195. package/src/services/machine-pool.ts +316 -0
  196. package/src/services/manifest-validation.ts +120 -0
  197. package/src/services/module-build.test.ts +290 -0
  198. package/src/services/module-build.ts +431 -0
  199. package/src/services/module-config.test.ts +237 -0
  200. package/src/services/module-config.ts +298 -0
  201. package/src/services/module-deploy.ts +862 -0
  202. package/src/services/module-types-drift.test.ts +73 -0
  203. package/src/services/module-types-generator.test.ts +288 -0
  204. package/src/services/module-types-generator.ts +189 -0
  205. package/src/services/proxmox-state-recovery.ts +140 -0
  206. package/src/services/schema-validation.ts +155 -0
  207. package/src/services/secret-schema-loader.test.ts +311 -0
  208. package/src/services/secret-schema-loader.ts +239 -0
  209. package/src/services/ssh-key-manager.test.ts +283 -0
  210. package/src/services/ssh-key-manager.ts +193 -0
  211. package/src/services/storage-providers/local.ts +105 -0
  212. package/src/services/storage-providers/s3.ts +182 -0
  213. package/src/services/storage-providers/types.ts +24 -0
  214. package/src/services/system-config-schema-types.ts +25 -0
  215. package/src/services/system-config-validator.test.ts +160 -0
  216. package/src/services/system-config-validator.ts +74 -0
  217. package/src/services/system-init.test.ts +153 -0
  218. package/src/services/system-init.ts +253 -0
  219. package/src/services/terraform-safety.ts +174 -0
  220. package/src/services/zone-detector.test.ts +110 -0
  221. package/src/services/zone-detector.ts +102 -0
  222. package/src/services/zone-policy.test.ts +97 -0
  223. package/src/services/zone-policy.ts +126 -0
  224. package/src/templates/generator.test.ts +645 -0
  225. package/src/templates/generator.ts +1119 -0
  226. package/src/templates/types.ts +62 -0
  227. package/src/test-utils/INTERACTIVE_PROMPTS.md +167 -0
  228. package/src/test-utils/cli-context-interactive.test.ts +152 -0
  229. package/src/test-utils/cli-context-server.test.ts +66 -0
  230. package/src/test-utils/cli-context.test.ts +273 -0
  231. package/src/test-utils/cli-context.ts +677 -0
  232. package/src/test-utils/cli-result.test.ts +282 -0
  233. package/src/test-utils/cli-result.ts +241 -0
  234. package/src/test-utils/cli.ts +55 -0
  235. package/src/test-utils/completion-harness.test.ts +126 -0
  236. package/src/test-utils/completion-harness.ts +82 -0
  237. package/src/test-utils/database.test.ts +182 -0
  238. package/src/test-utils/database.ts +126 -0
  239. package/src/test-utils/filesystem.test.ts +208 -0
  240. package/src/test-utils/filesystem.ts +142 -0
  241. package/src/test-utils/fixtures.test.ts +123 -0
  242. package/src/test-utils/fixtures.ts +160 -0
  243. package/src/test-utils/golden-diff.ts +197 -0
  244. package/src/test-utils/index.ts +77 -0
  245. package/src/test-utils/integration.ts +81 -0
  246. package/src/test-utils/module-fixtures.ts +468 -0
  247. package/src/test-utils/modules.test.ts +144 -0
  248. package/src/test-utils/modules.ts +183 -0
  249. package/src/test-utils/setup-test-db.ts +90 -0
  250. package/src/test-utils/value-extractor.test.ts +231 -0
  251. package/src/test-utils/value-extractor.ts +228 -0
  252. package/src/types/infrastructure.ts +157 -0
  253. package/src/utils/shell.test.ts +365 -0
  254. package/src/utils/shell.ts +159 -0
  255. package/src/validation/schemas.ts +166 -0
  256. package/src/variables/ansible-resolver.test.ts +142 -0
  257. package/src/variables/ansible-resolver.ts +69 -0
  258. package/src/variables/capability-self-ref.test.ts +220 -0
  259. package/src/variables/context.test.ts +1265 -0
  260. package/src/variables/context.ts +624 -0
  261. package/src/variables/declarative-derivation.test.ts +743 -0
  262. package/src/variables/declarative-derivation.ts +200 -0
  263. package/src/variables/parser.test.ts +231 -0
  264. package/src/variables/parser.ts +76 -0
  265. package/src/variables/resolver.test.ts +458 -0
  266. package/src/variables/resolver.ts +282 -0
  267. package/src/variables/types.ts +59 -0
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Integration test for `publishStaticSite`'s clientConfig injection.
3
+ *
4
+ * The factory itself opens SSH connections and shells out to tar — we
5
+ * stub `child_process.execSync` so the test exercises only the parts
6
+ * that matter for D4: config.js generation, file write into sourceDir,
7
+ * and the composition order (register_route → write file → upload).
8
+ *
9
+ * Why `spyOn` instead of `mock.module`: bun:test's `mock.module()` is
10
+ * a runtime-global replacement that persists across every test file in
11
+ * the suite. Mocking `node:child_process` that way breaks unrelated
12
+ * tests (e.g. CommandTreeParser, deriveSecret) because their own
13
+ * `execSync` calls hit our stub. `spyOn` is auto-restored after each
14
+ * test and stays scoped to this file.
15
+ */
16
+
17
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
18
+ import * as childProcess from 'node:child_process';
19
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { createPublicWeb } from '@celilo/capabilities';
23
+ import type { HookLogger, RouteOps, WebRoute } from '@celilo/capabilities';
24
+ import { createCapturingLogger } from '../hooks/logger';
25
+
26
+ let execSyncSpy: ReturnType<typeof spyOn>;
27
+
28
+ const noopLogger: HookLogger = {
29
+ info() {},
30
+ warn() {},
31
+ error() {},
32
+ success() {},
33
+ };
34
+
35
+ function makeRouteOps(): { ops: RouteOps; routes: WebRoute[] } {
36
+ const routes: WebRoute[] = [];
37
+ let nextId = 1;
38
+ const ops: RouteOps = {
39
+ getRoutes(moduleId) {
40
+ return routes.filter((r) => r.moduleId === moduleId);
41
+ },
42
+ getAllRoutes() {
43
+ return [...routes];
44
+ },
45
+ upsertRoute(route) {
46
+ const existing = routes.findIndex(
47
+ (r) => r.path === route.path && r.moduleId === route.moduleId,
48
+ );
49
+ const now = new Date();
50
+ const stored: WebRoute = {
51
+ id: existing >= 0 ? routes[existing].id : nextId++,
52
+ slug: route.slug,
53
+ moduleId: route.moduleId,
54
+ type: route.type,
55
+ path: route.path,
56
+ targetHost: route.targetHost ?? null,
57
+ targetPort: route.targetPort ?? null,
58
+ subdomain: route.subdomain ?? null,
59
+ websocket: route.websocket ?? false,
60
+ contentHash: route.contentHash ?? null,
61
+ createdAt: existing >= 0 ? routes[existing].createdAt : now,
62
+ updatedAt: now,
63
+ };
64
+ if (existing >= 0) {
65
+ routes[existing] = stored;
66
+ } else {
67
+ routes.push(stored);
68
+ }
69
+ },
70
+ deleteRoute(moduleId, path) {
71
+ const i = routes.findIndex((r) => r.moduleId === moduleId && r.path === path);
72
+ if (i >= 0) routes.splice(i, 1);
73
+ },
74
+ deleteRoutesBySlug(slug) {
75
+ for (let i = routes.length - 1; i >= 0; i--) {
76
+ if (routes[i].slug === slug) routes.splice(i, 1);
77
+ }
78
+ },
79
+ deleteRoutesByModule(moduleId) {
80
+ for (let i = routes.length - 1; i >= 0; i--) {
81
+ if (routes[i].moduleId === moduleId) routes.splice(i, 1);
82
+ }
83
+ },
84
+ };
85
+ return { ops, routes };
86
+ }
87
+
88
+ describe('publishStaticSite — clientConfig injection', () => {
89
+ let sourceDir: string;
90
+
91
+ beforeEach(() => {
92
+ // Stub execSync to a no-op for the duration of this test only.
93
+ // spyOn auto-restores when the test finishes, so other test files
94
+ // in the same suite get the real implementation.
95
+ execSyncSpy = spyOn(childProcess, 'execSync').mockReturnValue(Buffer.from(''));
96
+ sourceDir = mkdtempSync(join(tmpdir(), 'publish-static-site-'));
97
+ // Drop a fake build artifact so the upload "has something to do".
98
+ writeFileSync(join(sourceDir, 'index.html'), '<html></html>', 'utf-8');
99
+ });
100
+
101
+ afterEach(() => {
102
+ execSyncSpy.mockRestore();
103
+ rmSync(sourceDir, { recursive: true, force: true });
104
+ });
105
+
106
+ test('writes config.js into sourceDir before upload when clientConfig is provided', async () => {
107
+ const { ops } = makeRouteOps();
108
+ const cap = createPublicWeb({
109
+ moduleId: 'lunacycle',
110
+ logger: noopLogger,
111
+ config: {
112
+ container_ip: '10.0.10.20/24',
113
+ hostname: 'www',
114
+ primary_domain: 'example.com',
115
+ email: 'admin@example.com',
116
+ },
117
+ secrets: {},
118
+ routeOps: ops,
119
+ });
120
+
121
+ const result = await cap.publishStaticSite({
122
+ path: '/lunacycle',
123
+ sourceDir,
124
+ clientConfig: {
125
+ AUTHENTIK_URL: 'https://auth.example.com/application/o',
126
+ CLIENT_ID: 'lunacycle-web',
127
+ REDIRECT_URI: 'https://www.example.com/lunacycle/auth/callback',
128
+ },
129
+ });
130
+
131
+ expect(result.success).toBe(true);
132
+ expect(result.path).toBe('/lunacycle');
133
+
134
+ const configPath = join(sourceDir, 'config.js');
135
+ expect(existsSync(configPath)).toBe(true);
136
+
137
+ const configContents = readFileSync(configPath, 'utf-8');
138
+ expect(configContents).toContain('window.__MODULE_CONFIG__ =');
139
+ expect(configContents).toContain('"AUTHENTIK_URL":"https://auth.example.com/application/o"');
140
+ expect(configContents).toContain('"CLIENT_ID":"lunacycle-web"');
141
+ expect(configContents).toContain(
142
+ '"REDIRECT_URI":"https://www.example.com/lunacycle/auth/callback"',
143
+ );
144
+ expect(configContents.startsWith('// Generated by Celilo')).toBe(true);
145
+ });
146
+
147
+ test('does not write config.js when clientConfig is omitted', async () => {
148
+ const { ops } = makeRouteOps();
149
+ const cap = createPublicWeb({
150
+ moduleId: 'lunacycle',
151
+ logger: noopLogger,
152
+ config: {
153
+ container_ip: '10.0.10.20/24',
154
+ hostname: 'www',
155
+ primary_domain: 'example.com',
156
+ email: 'admin@example.com',
157
+ },
158
+ secrets: {},
159
+ routeOps: ops,
160
+ });
161
+
162
+ await cap.publishStaticSite({
163
+ path: '/lunacycle',
164
+ sourceDir,
165
+ // no clientConfig
166
+ });
167
+
168
+ expect(existsSync(join(sourceDir, 'config.js'))).toBe(false);
169
+ });
170
+
171
+ test('registers the route as static and records moduleId/path', async () => {
172
+ const { ops, routes } = makeRouteOps();
173
+ const cap = createPublicWeb({
174
+ moduleId: 'lunacycle',
175
+ logger: noopLogger,
176
+ config: {
177
+ container_ip: '10.0.10.20/24',
178
+ hostname: 'www',
179
+ primary_domain: 'example.com',
180
+ email: 'admin@example.com',
181
+ },
182
+ secrets: {},
183
+ routeOps: ops,
184
+ });
185
+
186
+ await cap.publishStaticSite({
187
+ path: '/lunacycle',
188
+ sourceDir,
189
+ });
190
+
191
+ const stored = routes.find((r) => r.path === '/lunacycle');
192
+ expect(stored).toBeDefined();
193
+ expect(stored?.type).toBe('static');
194
+ expect(stored?.moduleId).toBe('lunacycle');
195
+ expect(stored?.slug).toBe('lunacycle');
196
+ });
197
+
198
+ test('fails fast when sourceDir does not exist', async () => {
199
+ const { ops } = makeRouteOps();
200
+ const cap = createPublicWeb({
201
+ moduleId: 'lunacycle',
202
+ logger: noopLogger,
203
+ config: {
204
+ container_ip: '10.0.10.20/24',
205
+ hostname: 'www',
206
+ primary_domain: 'example.com',
207
+ email: 'admin@example.com',
208
+ },
209
+ secrets: {},
210
+ routeOps: ops,
211
+ });
212
+
213
+ await expect(
214
+ cap.publishStaticSite({
215
+ path: '/lunacycle',
216
+ sourceDir: '/nonexistent/path/that/should/not/exist',
217
+ clientConfig: { FOO: 'bar' },
218
+ }),
219
+ ).rejects.toThrow('sourceDir does not exist');
220
+ });
221
+
222
+ test('fails validation before touching the filesystem', async () => {
223
+ const { ops } = makeRouteOps();
224
+ const cap = createPublicWeb({
225
+ moduleId: 'lunacycle',
226
+ logger: noopLogger,
227
+ config: {
228
+ container_ip: '10.0.10.20/24',
229
+ hostname: 'www',
230
+ primary_domain: 'example.com',
231
+ email: 'admin@example.com',
232
+ },
233
+ secrets: {},
234
+ routeOps: ops,
235
+ });
236
+
237
+ await expect(
238
+ cap.publishStaticSite({
239
+ path: '', // bad — caught by validator before any side effects
240
+ sourceDir,
241
+ }),
242
+ ).rejects.toThrow('Invalid publishStaticSite request');
243
+
244
+ // The doomed call must not have written config.js or registered a route.
245
+ expect(existsSync(join(sourceDir, 'config.js'))).toBe(false);
246
+ });
247
+ });
248
+
249
+ describe('registerReverseProxy', () => {
250
+ beforeEach(() => {
251
+ execSyncSpy = spyOn(childProcess, 'execSync').mockReturnValue(Buffer.from(''));
252
+ });
253
+
254
+ afterEach(() => {
255
+ execSyncSpy.mockRestore();
256
+ });
257
+
258
+ test('registers a reverse_proxy route and returns the path', async () => {
259
+ const { ops, routes } = makeRouteOps();
260
+ const cap = createPublicWeb({
261
+ moduleId: 'lunacycle',
262
+ logger: noopLogger,
263
+ config: {
264
+ container_ip: '10.0.10.20/24',
265
+ hostname: 'www',
266
+ primary_domain: 'example.com',
267
+ email: 'admin@example.com',
268
+ },
269
+ secrets: {},
270
+ routeOps: ops,
271
+ });
272
+
273
+ const result = await cap.registerReverseProxy({
274
+ path: '/lunacycle/api',
275
+ targetHost: '10.0.20.42',
276
+ targetPort: 8080,
277
+ websocket: true,
278
+ });
279
+
280
+ expect(result.success).toBe(true);
281
+ expect(result.path).toBe('/lunacycle/api');
282
+
283
+ const stored = routes.find((r) => r.path === '/lunacycle/api');
284
+ expect(stored).toBeDefined();
285
+ expect(stored?.type).toBe('reverse_proxy');
286
+ expect(stored?.targetHost).toBe('10.0.20.42');
287
+ expect(stored?.targetPort).toBe(8080);
288
+ expect(stored?.websocket).toBe(true);
289
+ });
290
+
291
+ test('rejects invalid request before touching routeOps', async () => {
292
+ const { ops, routes } = makeRouteOps();
293
+ const cap = createPublicWeb({
294
+ moduleId: 'lunacycle',
295
+ logger: noopLogger,
296
+ config: {
297
+ container_ip: '10.0.10.20/24',
298
+ hostname: 'www',
299
+ primary_domain: 'example.com',
300
+ email: 'admin@example.com',
301
+ },
302
+ secrets: {},
303
+ routeOps: ops,
304
+ });
305
+
306
+ await expect(
307
+ cap.registerReverseProxy({
308
+ path: '/lunacycle/api',
309
+ targetHost: '',
310
+ targetPort: 8080,
311
+ }),
312
+ ).rejects.toThrow('Invalid registerReverseProxy request');
313
+
314
+ expect(routes).toHaveLength(0);
315
+ });
316
+ });
317
+
318
+ describe('auto-logging — end-to-end through createPublicWeb', () => {
319
+ let sourceDir: string;
320
+
321
+ beforeEach(() => {
322
+ execSyncSpy = spyOn(childProcess, 'execSync').mockReturnValue(Buffer.from(''));
323
+ sourceDir = mkdtempSync(join(tmpdir(), 'autolog-publish-'));
324
+ writeFileSync(join(sourceDir, 'index.html'), '<html></html>', 'utf-8');
325
+ });
326
+
327
+ afterEach(() => {
328
+ execSyncSpy.mockRestore();
329
+ rmSync(sourceDir, { recursive: true, force: true });
330
+ });
331
+
332
+ test('successful primitive call emits → and ✓ markers via the captured logger', async () => {
333
+ const { logger, messages } = createCapturingLogger();
334
+ const { ops } = makeRouteOps();
335
+
336
+ const cap = createPublicWeb({
337
+ moduleId: 'lunacycle',
338
+ logger,
339
+ config: {
340
+ container_ip: '10.0.10.20/24',
341
+ hostname: 'www',
342
+ primary_domain: 'example.com',
343
+ email: 'admin@example.com',
344
+ },
345
+ secrets: {},
346
+ routeOps: ops,
347
+ });
348
+
349
+ await cap.register_route({
350
+ type: 'reverse_proxy',
351
+ path: '/lunacycle/api',
352
+ targetHost: '10.0.20.42',
353
+ targetPort: 8080,
354
+ });
355
+
356
+ const messageTexts = messages.map((m) => m.message);
357
+ expect(messageTexts).toContain('→ public_web.register_route');
358
+ expect(messageTexts).toContain('✓ public_web.register_route');
359
+ // The error marker must NOT appear on a successful call.
360
+ expect(messageTexts.some((m) => m.startsWith('✗'))).toBe(false);
361
+ });
362
+
363
+ test('failed primitive call emits ✗ marker and re-throws', async () => {
364
+ const { logger, messages } = createCapturingLogger();
365
+ const { ops } = makeRouteOps();
366
+
367
+ const cap = createPublicWeb({
368
+ moduleId: 'lunacycle',
369
+ logger,
370
+ config: {
371
+ container_ip: '10.0.10.20/24',
372
+ hostname: 'www',
373
+ primary_domain: 'example.com',
374
+ email: 'admin@example.com',
375
+ },
376
+ secrets: {},
377
+ routeOps: ops,
378
+ });
379
+
380
+ // Bad path triggers the validator inside register_route, which
381
+ // throws — the wrapper should catch, log, and re-throw.
382
+ await expect(
383
+ cap.register_route({
384
+ type: 'static',
385
+ path: 'no-leading-slash', // invalid
386
+ }),
387
+ ).rejects.toThrow('Invalid route');
388
+
389
+ const errorMessage = messages.find((m) => m.level === 'error');
390
+ expect(errorMessage).toBeDefined();
391
+ expect(errorMessage?.message).toContain('✗ public_web.register_route');
392
+ expect(errorMessage?.message).toContain('Invalid route');
393
+ });
394
+
395
+ test('high-level call composes its primitives and logs each step', async () => {
396
+ const { logger, messages } = createCapturingLogger();
397
+ const { ops } = makeRouteOps();
398
+
399
+ const cap = createPublicWeb({
400
+ moduleId: 'lunacycle',
401
+ logger,
402
+ config: {
403
+ container_ip: '10.0.10.20/24',
404
+ hostname: 'www',
405
+ primary_domain: 'example.com',
406
+ email: 'admin@example.com',
407
+ },
408
+ secrets: {},
409
+ routeOps: ops,
410
+ });
411
+
412
+ await cap.publishStaticSite({ path: '/lunacycle', sourceDir });
413
+
414
+ const messageTexts = messages.map((m) => m.message);
415
+ // The high-level call itself logs.
416
+ expect(messageTexts).toContain('→ public_web.publishStaticSite');
417
+ expect(messageTexts).toContain('✓ public_web.publishStaticSite');
418
+ // It also composes register_route + upload_static_assets internally.
419
+ // Those run via the unwrapped reference (`capability.<method>` from
420
+ // inside the factory closure), so they do NOT produce extra arrow
421
+ // markers — only the outer publishStaticSite call is logged.
422
+ // This keeps high-level calls a single semantic step in logs.
423
+ expect(messageTexts.filter((m) => m.startsWith('→'))).toHaveLength(1);
424
+ expect(messageTexts.filter((m) => m.startsWith('✓'))).toHaveLength(1);
425
+ });
426
+
427
+ test('does not log payloads or result values', async () => {
428
+ const { logger, messages } = createCapturingLogger();
429
+ const { ops } = makeRouteOps();
430
+
431
+ const cap = createPublicWeb({
432
+ moduleId: 'lunacycle',
433
+ logger,
434
+ config: {
435
+ container_ip: '10.0.10.20/24',
436
+ hostname: 'www',
437
+ primary_domain: 'example.com',
438
+ email: 'admin@example.com',
439
+ },
440
+ secrets: {},
441
+ routeOps: ops,
442
+ });
443
+
444
+ await cap.register_route({
445
+ type: 'reverse_proxy',
446
+ path: '/lunacycle/api',
447
+ targetHost: '10.0.20.42',
448
+ targetPort: 8080,
449
+ });
450
+
451
+ // None of the request fields should appear in the log lines.
452
+ for (const m of messages) {
453
+ expect(m.message).not.toContain('10.0.20.42');
454
+ expect(m.message).not.toContain('8080');
455
+ expect(m.message).not.toContain('lunacycle/api');
456
+ }
457
+ });
458
+ });