@celilo/cli 1.13.0 → 2.0.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 (80) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +31 -5
  3. package/README.md +0 -2
  4. package/drizzle/0030_drop_module_builds_environment.sql +8 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/public-web-helpers.test.ts +12 -6
  8. package/src/capabilities/public-web-publish.test.ts +42 -13
  9. package/src/capabilities/validation.test.ts +31 -0
  10. package/src/cli/commands/alerts-sweep.ts +3 -0
  11. package/src/cli/commands/console-get-chain.test.ts +96 -0
  12. package/src/cli/commands/console.ts +13 -5
  13. package/src/cli/commands/monitor.ts +15 -2
  14. package/src/cli/commands/notify-config.test.ts +79 -0
  15. package/src/cli/commands/notify-config.ts +13 -2
  16. package/src/cli/commands/system-doctor.test.ts +121 -1
  17. package/src/cli/commands/system-doctor.ts +151 -1
  18. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  19. package/src/cli/completion.ts +10 -2
  20. package/src/cli/index.ts +7 -1
  21. package/src/console/closure.test.ts +76 -0
  22. package/src/console/closure.ts +87 -1
  23. package/src/console/control-plane-boundary.test.ts +82 -4
  24. package/src/console/projection.test.ts +63 -1
  25. package/src/console/projection.ts +39 -2
  26. package/src/db/schema.ts +0 -1
  27. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  28. package/src/hooks/capability-loader.ts +81 -10
  29. package/src/hooks/executor.ts +110 -17
  30. package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
  31. package/src/hooks/hook-jail-unreachability.test.ts +28 -2
  32. package/src/hooks/hook-protocol.ts +44 -0
  33. package/src/hooks/hook-runner-entry.ts +23 -0
  34. package/src/hooks/hook-runner.ts +10 -0
  35. package/src/hooks/hook-trespass.test.ts +9 -3
  36. package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
  37. package/src/hooks/jail.test.ts +92 -0
  38. package/src/hooks/jail.ts +128 -11
  39. package/src/hooks/mount-set.test.ts +28 -6
  40. package/src/hooks/mount-set.ts +34 -20
  41. package/src/hooks/remote-broker.test.ts +350 -0
  42. package/src/hooks/remote-broker.ts +404 -0
  43. package/src/hooks/run-named-hook.ts +2 -0
  44. package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
  45. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
  46. package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
  47. package/src/hooks/unjailed-lint.test.ts +251 -0
  48. package/src/hooks/unjailed-lint.ts +395 -0
  49. package/src/manifest/contracts/v1.ts +22 -1
  50. package/src/manifest/validate.ts +25 -4
  51. package/src/module/web-root.ts +35 -0
  52. package/src/policy/module-business-baseline.ts +27 -3
  53. package/src/policy/module-script-scan.test.ts +22 -0
  54. package/src/policy/module-script-scan.ts +92 -1
  55. package/src/policy/no-hand-built-ssh.test.ts +39 -1
  56. package/src/policy/no-module-business-in-core.test.ts +1 -1
  57. package/src/services/alerting/hook-jail.test.ts +66 -0
  58. package/src/services/alerting/hook-jail.ts +70 -0
  59. package/src/services/alerting/run-monitor.test.ts +62 -0
  60. package/src/services/alerting/run-monitor.ts +12 -0
  61. package/src/services/alerting/sweep-runner.test.ts +1 -0
  62. package/src/services/api-principal-enrolment.test.ts +73 -0
  63. package/src/services/api-principal-enrolment.ts +55 -0
  64. package/src/services/backup-create.ts +36 -7
  65. package/src/services/backup-restore.ts +2 -0
  66. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  67. package/src/services/deploy-ansible.ts +9 -1
  68. package/src/services/fleet-key.test.ts +47 -0
  69. package/src/services/fleet-key.ts +75 -0
  70. package/src/services/health-runner.ts +2 -0
  71. package/src/services/module-build.test.ts +1 -64
  72. package/src/services/module-build.ts +10 -86
  73. package/src/services/module-deploy.ts +20 -0
  74. package/src/services/remote-access.test.ts +139 -0
  75. package/src/services/remote-access.ts +98 -0
  76. package/src/services/restore-from-file.ts +12 -6
  77. package/src/services/static-content-converge.test.ts +338 -0
  78. package/src/services/static-content-converge.ts +299 -0
  79. package/src/services/system-state-stage.test.ts +165 -0
  80. package/src/services/system-state-stage.ts +196 -0
@@ -30,6 +30,7 @@ import {
30
30
  refuseIfInFlight,
31
31
  startOperation,
32
32
  } from './module-operations';
33
+ import { remoteAccessPolicy } from './remote-access';
33
34
 
34
35
  export interface RestoreResult {
35
36
  success: boolean;
@@ -288,6 +289,7 @@ export async function restoreModuleBackup(
288
289
  {
289
290
  debug: false,
290
291
  systems: getModuleSystems(backup.moduleId, db),
292
+ remoteAccess: remoteAccessPolicy(backup.moduleId, db),
291
293
  },
292
294
  );
293
295
 
@@ -1,19 +1,3 @@
1
- /**
2
- * Behavior tests for the celilo-mgmt module's on_backup / on_restore
3
- * hooks (Phase 3 of openspec/specs/management-server-backup/spec.md).
4
- *
5
- * We import the hook scripts directly and call their handlers with a
6
- * synthesized HookContext, then assert on the side-effects (files
7
- * written to backup_dir / restore staging dirs). This catches
8
- * regressions in the hook bodies without spinning up the full
9
- * deploy/backup pipeline.
10
- *
11
- * What this DOESN'T test: invokeHook validation, end-to-end backup
12
- * envelope round-trip. Those live in backup-envelope-roundtrip.test.ts
13
- * and (eventually) a fresh-box e2e.
14
- */
15
-
16
- import { Database } from 'bun:sqlite';
17
1
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
18
2
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
19
3
  import { tmpdir } from 'node:os';
@@ -82,13 +66,13 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
82
66
  let dir: string;
83
67
  let backupDir: string;
84
68
  let crossModuleRoot: string;
85
- let dbPath: string;
86
- let keyPath: string;
69
+ let systemStateRoot: string;
87
70
 
88
71
  beforeEach(() => {
89
72
  dir = mkdtempSync(join(tmpdir(), 'celilo-mgmt-backup-test-'));
90
73
  backupDir = join(dir, 'backup-dir');
91
74
  mkdirSync(backupDir, { recursive: true });
75
+
92
76
  crossModuleRoot = join(dir, 'cross-module-root');
93
77
  mkdirSync(join(crossModuleRoot, 'modules', 'caddy', 'terraform'), { recursive: true });
94
78
  writeFileSync(
@@ -104,25 +88,20 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
104
88
  }),
105
89
  );
106
90
 
107
- dbPath = join(dir, 'celilo.db');
108
- // A REAL (minimal) SQLite DB. on_backup snapshots it via bun:sqlite
109
- // serialize(), which requires a valid database file — a plain byte blob
110
- // makes the serializer read a garbage page count and OOM. One table with a
111
- // row is enough to exercise the snapshot path.
112
- const seed = new Database(dbPath);
113
- seed.run('CREATE TABLE backup_probe (id INTEGER PRIMARY KEY, v TEXT)');
114
- seed.run("INSERT INTO backup_probe (v) VALUES ('hello')");
115
- seed.close();
116
- process.env.CELILO_DB_PATH = dbPath;
117
-
118
- keyPath = join(dir, 'master.key');
119
- writeFileSync(keyPath, 'fake-master-key-32-bytes-padding!');
120
- process.env.CELILO_MASTER_KEY_PATH = keyPath;
91
+ // celilo's own state, as the framework stages it before invoking the hook
92
+ // (services/system-state-stage.ts). The hook reads nothing outside this
93
+ // directory and cross_module_root — that is what design D9b bought, and
94
+ // it is why there is no CELILO_DB_PATH or master.key on disk here.
95
+ systemStateRoot = join(dir, 'system-state');
96
+ mkdirSync(join(systemStateRoot, 'ssh'), { recursive: true });
97
+ mkdirSync(join(systemStateRoot, 'module_src', 'caddy'), { recursive: true });
98
+ writeFileSync(join(systemStateRoot, 'celilo.db'), 'SQLite format 3\u0000snapshot');
99
+ writeFileSync(join(systemStateRoot, 'master.key'), 'fake-master-key-32-bytes-padding!');
100
+ writeFileSync(join(systemStateRoot, 'ssh', 'id_ed25519'), 'PRIVATE');
101
+ writeFileSync(join(systemStateRoot, 'module_src', 'caddy', 'manifest.yml'), 'id: caddy');
121
102
  });
122
103
 
123
104
  afterEach(() => {
124
- process.env.CELILO_DB_PATH = undefined;
125
- process.env.CELILO_MASTER_KEY_PATH = undefined;
126
105
  try {
127
106
  rmSync(dir, { recursive: true, force: true });
128
107
  } catch {
@@ -130,16 +109,20 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
130
109
  }
131
110
  });
132
111
 
133
- it('writes celilo.db, master.key, machine-pool.json, and cross_module_state into backup_dir', async () => {
112
+ it('lays the staged celilo state into backup_dir alongside the cross-module state', async () => {
134
113
  const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
135
- const ctx = buildContext({
136
- backup_dir: backupDir,
137
- cross_module_root: crossModuleRoot,
138
- });
139
- const result = (await hook(ctx)) as BackupHookOutput;
114
+ const result = (await hook(
115
+ buildContext({
116
+ backup_dir: backupDir,
117
+ cross_module_root: crossModuleRoot,
118
+ system_state_root: systemStateRoot,
119
+ }),
120
+ )) as BackupHookOutput;
140
121
 
141
122
  expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
142
123
  expect(existsSync(join(backupDir, 'master.key'))).toBe(true);
124
+ expect(existsSync(join(backupDir, 'ssh', 'id_ed25519'))).toBe(true);
125
+ expect(existsSync(join(backupDir, 'module_src', 'caddy', 'manifest.yml'))).toBe(true);
143
126
  expect(existsSync(join(backupDir, 'machine-pool.json'))).toBe(true);
144
127
  expect(
145
128
  existsSync(
@@ -153,39 +136,27 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
153
136
  expect(result.size_bytes).toBeGreaterThan(0);
154
137
  });
155
138
 
156
- it('captures LEAN module source (excludes generated/, node_modules/, large build artifacts)', async () => {
157
- // stateDir = dirname(db_path) = dir; on_backup reads dir/modules/<id>.
158
- const modSrc = join(dir, 'modules', 'caddy');
159
- mkdirSync(join(modSrc, 'scripts', 'node_modules', '@celilo'), { recursive: true });
160
- mkdirSync(join(modSrc, 'generated', 'terraform'), { recursive: true });
161
- mkdirSync(join(modSrc, 'ansible', 'files'), { recursive: true });
162
- writeFileSync(join(modSrc, 'manifest.yml'), 'id: caddy');
163
- writeFileSync(join(modSrc, 'scripts', 'hook.ts'), '// hook');
164
- writeFileSync(join(modSrc, 'generated', 'terraform', 'main.tf'), 'resource {}');
165
- writeFileSync(join(modSrc, 'scripts', 'node_modules', '@celilo', 'dep.js'), '// vendored');
166
- // A >2MB "compiled binary" sitting in source — must be skipped by size.
167
- writeFileSync(join(modSrc, 'ansible', 'files', 'server-bin'), Buffer.alloc(3 * 1024 * 1024));
168
-
139
+ it('refuses when the framework did not stage celilo state', async () => {
169
140
  const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
170
- await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
171
141
 
172
- // Source files captured...
173
- expect(existsSync(join(backupDir, 'module_src', 'caddy', 'manifest.yml'))).toBe(true);
174
- expect(existsSync(join(backupDir, 'module_src', 'caddy', 'scripts', 'hook.ts'))).toBe(true);
175
- // ...but build/vendored content excluded (keeps the backup small enough to
176
- // not OOM the in-memory tar+encrypt — turnip's full dirs were ~1.6GB).
177
- expect(existsSync(join(backupDir, 'module_src', 'caddy', 'generated'))).toBe(false);
178
- expect(existsSync(join(backupDir, 'module_src', 'caddy', 'scripts', 'node_modules'))).toBe(
179
- false,
180
- );
142
+ // Silence is the dangerous outcome here. Without the DB and the master
143
+ // key an envelope restores to nothing, and a hook that shrugged and
144
+ // returned success would report artifact_count > 0 for the machine-pool
145
+ // and cross-module files alone. Found at restore, which is too late.
181
146
  expect(
182
- existsSync(join(backupDir, 'module_src', 'caddy', 'ansible', 'files', 'server-bin')),
183
- ).toBe(false);
147
+ hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot })),
148
+ ).rejects.toThrow(/system_state_root was not provided/);
184
149
  });
185
150
 
186
151
  it('machine-pool.json is valid JSON (array)', async () => {
187
152
  const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
188
- await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
153
+ await hook(
154
+ buildContext({
155
+ backup_dir: backupDir,
156
+ cross_module_root: crossModuleRoot,
157
+ system_state_root: systemStateRoot,
158
+ }),
159
+ );
189
160
 
190
161
  const machinePool = JSON.parse(readFileSync(join(backupDir, 'machine-pool.json'), 'utf-8'));
191
162
  expect(Array.isArray(machinePool)).toBe(true);
@@ -194,25 +165,13 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
194
165
  it('proceeds (with a warning) when cross_module_root is missing', async () => {
195
166
  const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
196
167
  const result = (await hook(
197
- buildContext({ backup_dir: backupDir }), // no cross_module_root
168
+ buildContext({ backup_dir: backupDir, system_state_root: systemStateRoot }),
198
169
  )) as BackupHookOutput;
199
170
 
200
171
  expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
201
172
  expect(existsSync(join(backupDir, 'cross_module_state'))).toBe(false);
202
173
  expect(result.schema_version).toBe('1.1');
203
174
  });
204
-
205
- it('proceeds (with a warning) when master.key is missing on disk', async () => {
206
- rmSync(keyPath);
207
- const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
208
- const result = (await hook(
209
- buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }),
210
- )) as BackupHookOutput;
211
-
212
- expect(existsSync(join(backupDir, 'master.key'))).toBe(false);
213
- expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
214
- expect(result.artifact_count).toBeGreaterThan(0);
215
- });
216
175
  });
217
176
 
218
177
  describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_restore', () => {
@@ -138,7 +138,7 @@ function parseAnsibleLine(line: string): string | null {
138
138
  */
139
139
  export async function executeAnsible(
140
140
  generatedPath: string,
141
- options?: { noInteractive?: boolean; check?: boolean },
141
+ options?: { noInteractive?: boolean; check?: boolean; tags?: string[] },
142
142
  ): Promise<AnsibleResult> {
143
143
  const ansibleDir = join(generatedPath, 'ansible');
144
144
  const inventoryPath = join(ansibleDir, 'inventory', 'hosts.ini');
@@ -166,6 +166,14 @@ export async function executeAnsible(
166
166
  // stored record of the host (celilo#902 design D6). One argument, not a
167
167
  // second execution path — everything else about the run is identical.
168
168
  ...(options?.check ? ['--check'] : []),
169
+ // `--tags` scopes the run to the tagged tasks. The static-content
170
+ // converge uses it (capability-owned-tables D10): the full playbook
171
+ // re-templates the bootstrap Caddyfile, which would overwrite the
172
+ // REAL config the provider's reconcile wrote to disk and reload caddy
173
+ // into serving nothing. A publish converge must touch /srv/www and
174
+ // nothing else. An unscoped run (the deploy) still executes every
175
+ // untagged task alongside the tagged ones.
176
+ ...(options?.tags?.length ? ['--tags', options.tags.join(',')] : []),
169
177
  shellEscape(playbookPath),
170
178
  ],
171
179
  cwd: ansibleDir,
@@ -0,0 +1,47 @@
1
+ /**
2
+ * celilo's fleet SSH keypair, minted by the framework rather than by
3
+ * celilo-mgmt's on_install (design D9b of
4
+ * openspec/changes/hook-process-boundary).
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
8
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { ensureFleetKey, getFleetSshDir } from './fleet-key';
12
+
13
+ describe('ensureFleetKey', () => {
14
+ let dataDir: string;
15
+
16
+ beforeEach(() => {
17
+ dataDir = mkdtempSync(join(tmpdir(), 'celilo-fleet-key-'));
18
+ process.env.CELILO_DB_PATH = join(dataDir, 'celilo.db');
19
+ });
20
+
21
+ afterEach(() => {
22
+ process.env.CELILO_DB_PATH = undefined;
23
+ rmSync(dataDir, { recursive: true, force: true });
24
+ });
25
+
26
+ it('mints an ed25519 keypair next to the DB and returns the public half', () => {
27
+ const key = ensureFleetKey();
28
+
29
+ expect(key.created).toBe(true);
30
+ expect(key.publicKey.startsWith('ssh-ed25519 ')).toBe(true);
31
+ expect(existsSync(join(dataDir, '.ssh', 'id_ed25519'))).toBe(true);
32
+ expect(readFileSync(join(dataDir, '.ssh', 'id_ed25519.pub'), 'utf-8').trim()).toBe(
33
+ key.publicKey,
34
+ );
35
+ expect(getFleetSshDir()).toBe(join(dataDir, '.ssh'));
36
+ });
37
+
38
+ it('reuses an existing key instead of re-keying', () => {
39
+ const first = ensureFleetKey();
40
+ const second = ensureFleetKey();
41
+
42
+ // Re-keying would strand every machine whose authorized_keys holds the
43
+ // old public half, and a redeploy calls this every time.
44
+ expect(second.created).toBe(false);
45
+ expect(second.publicKey).toBe(first.publicKey);
46
+ });
47
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * celilo's fleet SSH keypair — the key celilo authenticates to managed
3
+ * machines with. The DB carries only the public half (`ssh.public_key`);
4
+ * the private half lives on disk and never leaves the management box.
5
+ *
6
+ * It used to be minted by celilo-mgmt's `on_install`, which derived
7
+ * `dirname(config.db_path)` and wrote into celilo's data directory from
8
+ * inside a module hook. That is a write into the one directory the hook
9
+ * jail exists to keep out of the mount set
10
+ * (openspec/changes/hook-process-boundary, design D9b): staging covers
11
+ * copies OUT of celilo's state, and nothing covers writes IN.
12
+ *
13
+ * So minting moved here. celilo owns the key's lifecycle, and "create it
14
+ * if absent" wants to be idempotent and tested once rather than in each
15
+ * module that reaches for it.
16
+ */
17
+
18
+ import { execFileSync } from 'node:child_process';
19
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+ import { getDbPath } from '../config/paths';
22
+
23
+ /**
24
+ * Where the fleet keypair lives.
25
+ *
26
+ * Next to the DB, not under `getDataDir()`. Those are the same directory
27
+ * on a deb install (`CELILO_DATA_DIR=/var/celilo`, db at
28
+ * `/var/celilo/celilo.db`) and differ only when `CELILO_DB_PATH` points
29
+ * somewhere custom. Following the DB is what `on_install` did, so it is
30
+ * where every existing box's key already sits, and restore has followed
31
+ * the same rule since it was written (`applyStagedSystemFiles`).
32
+ *
33
+ * One exported helper rather than two hand-derived joins is the point:
34
+ * mint and restore can no longer drift to different directories.
35
+ */
36
+ export function getFleetSshDir(): string {
37
+ return join(dirname(getDbPath()), '.ssh');
38
+ }
39
+
40
+ export interface FleetKey {
41
+ /** The public half, as it goes into `ssh.public_key` and authorized_keys. */
42
+ publicKey: string;
43
+ /** True when this call minted the key; false when it was already there. */
44
+ created: boolean;
45
+ }
46
+
47
+ /**
48
+ * Ensure the fleet keypair exists and return its public half.
49
+ *
50
+ * Idempotent: an existing key is reused, never regenerated. Re-keying
51
+ * would silently strand every machine whose authorized_keys holds the old
52
+ * public half, and a redeploy must not do that.
53
+ *
54
+ * Permissions are left to `mkdirSync`'s mode and to ssh-keygen, which
55
+ * `fchmod`s the private half to 0600 itself. An explicit chmod pass was
56
+ * written here first and then removed: umask only ever REMOVES mode bits, so
57
+ * neither the directory nor the key can come out wider than asked for, and no
58
+ * test could be made to fail without it.
59
+ */
60
+ export function ensureFleetKey(): FleetKey {
61
+ const sshDir = getFleetSshDir();
62
+ const keyPath = join(sshDir, 'id_ed25519');
63
+ const publicKeyPath = `${keyPath}.pub`;
64
+
65
+ if (existsSync(publicKeyPath)) {
66
+ return { publicKey: readFileSync(publicKeyPath, 'utf-8').trim(), created: false };
67
+ }
68
+
69
+ mkdirSync(sshDir, { recursive: true, mode: 0o700 });
70
+ execFileSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', keyPath, '-C', 'celilo-fleet'], {
71
+ stdio: 'pipe',
72
+ });
73
+
74
+ return { publicKey: readFileSync(publicKeyPath, 'utf-8').trim(), created: true };
75
+ }
@@ -18,6 +18,7 @@ import type { ModuleManifest } from '../manifest/schema';
18
18
  import { decryptSecret } from '../secrets/encryption';
19
19
  import { getOrCreateMasterKey } from '../secrets/master-key';
20
20
  import { getModuleSystems } from './deployed-systems';
21
+ import { remoteAccessPolicy } from './remote-access';
21
22
 
22
23
  export interface HealthCheckItem {
23
24
  name: string;
@@ -196,6 +197,7 @@ export async function runModuleHealthCheck(
196
197
  capabilities: capabilityFunctions,
197
198
  requiredCapabilities,
198
199
  systems: getModuleSystems(moduleId, db),
200
+ remoteAccess: remoteAccessPolicy(moduleId, db),
199
201
  },
200
202
  );
201
203
  presentation.finish(hookResult.success);
@@ -1,8 +1,7 @@
1
- import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { mkdir, rm, writeFile } from 'node:fs/promises';
4
4
  import { eq } from 'drizzle-orm';
5
- import { log } from '../cli/prompts';
6
5
  import { type DbClient, createDbClient } from '../db/client';
7
6
  import { moduleBuilds, modules } from '../db/schema';
8
7
  import { skipIntegration } from '../test-utils/integration-guard';
@@ -40,7 +39,6 @@ describe('Module Build Service', () => {
40
39
  version TEXT NOT NULL,
41
40
  built_at INTEGER NOT NULL DEFAULT (unixepoch()),
42
41
  artifacts TEXT NOT NULL,
43
- environment TEXT,
44
42
  status TEXT NOT NULL,
45
43
  build_log TEXT,
46
44
  FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE
@@ -98,65 +96,6 @@ describe('Module Build Service', () => {
98
96
  expect(result.error).toContain('does not have a build section');
99
97
  });
100
98
 
101
- test('should detect Nix environment from flake.nix', async () => {
102
- // Create test module directory with flake.nix
103
- await mkdir(TEST_MODULE_DIR, { recursive: true });
104
- await mkdir(`${TEST_MODULE_DIR}/build`, { recursive: true });
105
- await writeFile(`${TEST_MODULE_DIR}/flake.nix`, '# Nix flake');
106
- await writeFile(
107
- `${TEST_MODULE_DIR}/build/playbook.yml`,
108
- `---
109
- - name: Test build
110
- hosts: localhost
111
- gather_facts: false
112
- tasks:
113
- - name: Debug message
114
- ansible.builtin.debug:
115
- msg: "Nix build test"
116
- `,
117
- );
118
-
119
- db.insert(modules)
120
- .values({
121
- id: 'nix-module',
122
- name: 'Nix Module',
123
- version: '1.0.0',
124
- sourcePath: TEST_MODULE_DIR,
125
- manifestData: {
126
- id: 'nix-module',
127
- name: 'Nix Module',
128
- version: '1.0.0',
129
- build: {
130
- script: 'build/playbook.yml',
131
- },
132
- },
133
- })
134
- .run();
135
-
136
- // Mock log.info / log.warn to capture output (module-build.ts now
137
- // routes through these instead of console.log so output flows through
138
- // the active ProgressDisplay during a real deploy).
139
- const logInfoSpy = spyOn(log, 'info').mockImplementation(() => {});
140
- const logWarnSpy = spyOn(log, 'warn').mockImplementation(() => {});
141
-
142
- const _result = await buildModuleFromSource('nix-module', db);
143
-
144
- // Verify Nix detection or fallback message was logged
145
- // If nix is available: "Entering Nix environment"
146
- // If nix not available: "flake.nix detected but nix command not available"
147
- const calls = [...logInfoSpy.mock.calls, ...logWarnSpy.mock.calls].map((c) => c[0]);
148
- const hasNixMessage = calls.some(
149
- (msg) => msg.includes('Nix environment') || msg.includes('flake.nix detected'),
150
- );
151
- expect(hasNixMessage).toBe(true);
152
-
153
- logInfoSpy.mockRestore();
154
- logWarnSpy.mockRestore();
155
-
156
- // Build result depends on whether Nix is installed
157
- // We're just testing that detection happens (message logged)
158
- }, 10000); // 10 second timeout
159
-
160
99
  test.skipIf(skipIntegration({ tools: ['ansible'] }))(
161
100
  'should record build metadata in database',
162
101
  async () => {
@@ -208,7 +147,6 @@ describe('Module Build Service', () => {
208
147
  expect(buildRecord?.moduleId).toBe('record-test');
209
148
  expect(buildRecord?.version).toBe('1.0.0');
210
149
  expect(buildRecord?.status).toBe('success');
211
- expect(buildRecord?.environment).toBe('system'); // No flake.nix
212
150
  },
213
151
  10000,
214
152
  ); // 10 second timeout for ansible execution
@@ -282,7 +220,6 @@ describe('Module Build Service', () => {
282
220
  artifacts: ['/path/to/artifact'],
283
221
  status: 'success',
284
222
  buildLog: 'Build completed',
285
- environment: 'nix',
286
223
  })
287
224
  .run();
288
225
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Module Build Service
3
3
  *
4
- * Executes module build scripts (Ansible playbooks) in Nix environments
4
+ * Executes module build scripts (Ansible playbooks)
5
5
  * to compile custom software binaries before deployment.
6
6
  *
7
7
  * Example: Caddy with RFC2136 DNS provider
@@ -40,65 +40,26 @@ export interface BuildStatusRecord {
40
40
  buildLog?: string;
41
41
  }
42
42
 
43
- /**
44
- * Detect if module uses Nix environment
45
- * Policy function - checks for flake.nix existence
46
- *
47
- * @param modulePath - Path to module directory
48
- * @returns True if flake.nix exists
49
- */
50
- function detectNixEnvironment(modulePath: string): boolean {
51
- const flakePath = `${modulePath}/flake.nix`;
52
- return existsSync(flakePath);
53
- }
54
-
55
- /**
56
- * Check if nix command is available
57
- * Policy function - validates nix availability
58
- *
59
- * @returns True if nix command can be executed
60
- */
61
- async function isNixAvailable(): Promise<boolean> {
62
- const { spawn } = await import('node:child_process');
63
-
64
- return new Promise((resolve) => {
65
- const child = spawn('nix', ['--version'], {
66
- stdio: 'ignore',
67
- });
68
-
69
- child.on('close', (code) => {
70
- resolve(code === 0);
71
- });
72
-
73
- child.on('error', () => {
74
- resolve(false);
75
- });
76
- });
77
- }
78
-
79
43
  /**
80
44
  * Execute build playbook with streaming progress
81
45
  * Execution function - runs ansible-playbook with fuel-gauge progress indicator
82
46
  *
83
47
  * @param modulePath - Path to module directory
84
- * @param playbookPath - Path to build playbook (relative to module)
85
- * @param useNix - Whether to run inside Nix environment
48
+ * @param commandStr - Build command to execute
86
49
  * @returns Build result with output
87
50
  */
88
51
  async function executeBuildCommand(
89
52
  modulePath: string,
90
53
  commandStr: string,
91
- useNix: boolean,
92
54
  ): Promise<{ success: boolean; output: string; error?: string }> {
93
55
  const { executeBuildWithProgress } = await import('./build-stream');
94
- const { shellEscape } = await import('../utils/shell');
95
56
 
96
57
  // build-stream passes through `shell: true`, so spawn re-wraps everything in
97
58
  // `/bin/sh -c`. If we sent ['bash', '-c', commandStr] as command+args, sh
98
59
  // would re-parse the join — `&&` outside the inner quotes splits the chain
99
60
  // and `bun install` runs in modulePath instead of the cd'd subdir. Pass the
100
61
  // full command as one string so sh -c gets a single, unsplit shell command.
101
- const command = useNix ? `nix develop --command bash -c ${shellEscape(commandStr)}` : commandStr;
62
+ const command = commandStr;
102
63
 
103
64
  const result = await executeBuildWithProgress({
104
65
  command,
@@ -117,7 +78,6 @@ async function executeBuildCommand(
117
78
  async function executeBuildScript(
118
79
  modulePath: string,
119
80
  scriptPath: string,
120
- useNix: boolean,
121
81
  ): Promise<{ success: boolean; output: string; error?: string }> {
122
82
  const fullScriptPath = `${modulePath}/${scriptPath}`;
123
83
 
@@ -132,11 +92,7 @@ async function executeBuildScript(
132
92
  let command: string;
133
93
  let args: string[];
134
94
 
135
- if (useNix) {
136
- const innerCommand = isShellScript ? 'bash' : 'ansible-playbook';
137
- command = 'nix';
138
- args = ['develop', '--command', innerCommand, scriptPath];
139
- } else if (isShellScript) {
95
+ if (isShellScript) {
140
96
  command = 'bash';
141
97
  args = [scriptPath];
142
98
  } else {
@@ -207,7 +163,6 @@ function formatBytes(bytes: number): string {
207
163
  * @param artifacts - List of artifact paths
208
164
  * @param status - Build status
209
165
  * @param buildLog - Build output log
210
- * @param environment - Build environment (nix or system)
211
166
  * @param db - Database client
212
167
  */
213
168
  async function recordBuildMetadata(
@@ -216,7 +171,6 @@ async function recordBuildMetadata(
216
171
  artifacts: string[],
217
172
  status: BuildStatus,
218
173
  buildLog: string,
219
- environment: 'nix' | 'system' | null,
220
174
  db: DbClient,
221
175
  ): Promise<void> {
222
176
  await db.insert(moduleBuilds).values({
@@ -225,7 +179,6 @@ async function recordBuildMetadata(
225
179
  artifacts,
226
180
  status,
227
181
  buildLog,
228
- environment,
229
182
  });
230
183
  }
231
184
 
@@ -268,38 +221,18 @@ export async function buildModuleFromSource(moduleId: string, db: DbClient): Pro
268
221
  };
269
222
  }
270
223
 
271
- // Detect Nix environment and verify availability
272
- const nixDetected = detectNixEnvironment(modulePath);
273
- const nixAvailable = nixDetected ? await isNixAvailable() : false;
274
- const useNix = nixDetected && nixAvailable;
275
- const environment: 'nix' | 'system' = useNix ? 'nix' : 'system';
276
-
277
- if (useNix) {
278
- log.info('Entering Nix environment (flake.nix detected)...');
279
- } else if (nixDetected && !nixAvailable) {
280
- log.warn('flake.nix detected but nix command not available - using system Ansible');
281
- }
282
-
283
224
  try {
284
225
  // Execute build script with streaming progress
285
226
  const buildResult = command
286
- ? await executeBuildCommand(modulePath, command, useNix)
287
- : await executeBuildScript(modulePath, script as string, useNix);
227
+ ? await executeBuildCommand(modulePath, command)
228
+ : await executeBuildScript(modulePath, script as string);
288
229
 
289
230
  // Check if build command failed
290
231
  if (!buildResult.success) {
291
232
  const errorMsg = buildResult.error || 'Build command failed';
292
233
 
293
234
  // Record failed build
294
- await recordBuildMetadata(
295
- moduleId,
296
- manifest.version,
297
- [],
298
- 'failed',
299
- buildResult.output,
300
- environment,
301
- db,
302
- );
235
+ await recordBuildMetadata(moduleId, manifest.version, [], 'failed', buildResult.output, db);
303
236
 
304
237
  return {
305
238
  success: false,
@@ -327,7 +260,6 @@ export async function buildModuleFromSource(moduleId: string, db: DbClient): Pro
327
260
  [],
328
261
  'failed',
329
262
  `${buildLog}\n\n${errorMsg}`,
330
- environment,
331
263
  db,
332
264
  );
333
265
 
@@ -346,15 +278,7 @@ export async function buildModuleFromSource(moduleId: string, db: DbClient): Pro
346
278
 
347
279
  // Record successful build
348
280
  const artifactPaths = verification.map((v) => v.path);
349
- await recordBuildMetadata(
350
- moduleId,
351
- manifest.version,
352
- artifactPaths,
353
- 'success',
354
- buildLog,
355
- environment,
356
- db,
357
- );
281
+ await recordBuildMetadata(moduleId, manifest.version, artifactPaths, 'success', buildLog, db);
358
282
 
359
283
  return {
360
284
  success: true,
@@ -364,7 +288,7 @@ export async function buildModuleFromSource(moduleId: string, db: DbClient): Pro
364
288
  }
365
289
 
366
290
  // No artifacts defined, just record success
367
- await recordBuildMetadata(moduleId, manifest.version, [], 'success', buildLog, environment, db);
291
+ await recordBuildMetadata(moduleId, manifest.version, [], 'success', buildLog, db);
368
292
 
369
293
  return {
370
294
  success: true,
@@ -376,7 +300,7 @@ export async function buildModuleFromSource(moduleId: string, db: DbClient): Pro
376
300
  const buildLog = `Build failed: ${errorMsg}`;
377
301
 
378
302
  // Record failed build
379
- await recordBuildMetadata(moduleId, manifest.version, [], 'failed', buildLog, environment, db);
303
+ await recordBuildMetadata(moduleId, manifest.version, [], 'failed', buildLog, db);
380
304
 
381
305
  return {
382
306
  success: false,