@celilo/cli 1.14.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 (57) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +25 -3
  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-publish.test.ts +18 -0
  8. package/src/cli/commands/alerts-sweep.ts +3 -0
  9. package/src/cli/commands/monitor.ts +15 -2
  10. package/src/cli/commands/system-doctor.test.ts +121 -1
  11. package/src/cli/commands/system-doctor.ts +151 -1
  12. package/src/cli/completion.ts +9 -2
  13. package/src/cli/index.ts +1 -1
  14. package/src/console/control-plane-boundary.test.ts +82 -4
  15. package/src/db/schema.ts +0 -1
  16. package/src/hooks/capability-loader.ts +14 -0
  17. package/src/hooks/executor.ts +110 -17
  18. package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
  19. package/src/hooks/hook-jail-unreachability.test.ts +28 -2
  20. package/src/hooks/hook-protocol.ts +44 -0
  21. package/src/hooks/hook-runner-entry.ts +23 -0
  22. package/src/hooks/hook-runner.ts +10 -0
  23. package/src/hooks/hook-trespass.test.ts +9 -3
  24. package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
  25. package/src/hooks/jail.test.ts +92 -0
  26. package/src/hooks/jail.ts +128 -11
  27. package/src/hooks/mount-set.test.ts +28 -6
  28. package/src/hooks/mount-set.ts +34 -20
  29. package/src/hooks/remote-broker.test.ts +350 -0
  30. package/src/hooks/remote-broker.ts +404 -0
  31. package/src/hooks/run-named-hook.ts +2 -0
  32. package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
  33. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
  34. package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
  35. package/src/hooks/unjailed-lint.test.ts +251 -0
  36. package/src/hooks/unjailed-lint.ts +395 -0
  37. package/src/policy/module-business-baseline.ts +13 -1
  38. package/src/policy/module-script-scan.ts +60 -1
  39. package/src/policy/no-hand-built-ssh.test.ts +39 -1
  40. package/src/policy/no-module-business-in-core.test.ts +1 -1
  41. package/src/services/alerting/hook-jail.test.ts +66 -0
  42. package/src/services/alerting/hook-jail.ts +70 -0
  43. package/src/services/alerting/run-monitor.test.ts +62 -0
  44. package/src/services/alerting/run-monitor.ts +12 -0
  45. package/src/services/alerting/sweep-runner.test.ts +1 -0
  46. package/src/services/backup-create.ts +3 -0
  47. package/src/services/backup-restore.ts +2 -0
  48. package/src/services/deploy-ansible.ts +9 -1
  49. package/src/services/health-runner.ts +2 -0
  50. package/src/services/module-build.test.ts +1 -64
  51. package/src/services/module-build.ts +10 -86
  52. package/src/services/module-deploy.ts +20 -0
  53. package/src/services/remote-access.test.ts +139 -0
  54. package/src/services/remote-access.ts +98 -0
  55. package/src/services/restore-from-file.ts +6 -1
  56. package/src/services/static-content-converge.test.ts +338 -0
  57. package/src/services/static-content-converge.ts +299 -0
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Hook-jail regression — a host that used to jail its hooks and has stopped.
3
+ *
4
+ * Design D8's three states want three different things (see
5
+ * openspec/changes/hook-process-boundary/design.md): jailed is normal and
6
+ * raises nothing; a host that never jailed (a Mac) is a configuration fact
7
+ * that `celilo system doctor` reports and is NOT an event; a host that ran
8
+ * hooks jailed yesterday and runs them unjailed today is a kernel upgrade, an
9
+ * AppArmor policy change, or a changed container profile, and is exactly the
10
+ * failure that looks like nothing. Only the third state raises here.
11
+ *
12
+ * The state this reads is `hook-jail-mode.json`, written by `recordJailMode`
13
+ * on every real module-hook run. The transition survives the record being
14
+ * overwritten because an unjailed record carries `lastJailed` — the jailed
15
+ * record it replaced on the same host.
16
+ *
17
+ * The monitor scheduling this check is a SELF-monitor: `celilo monitor add
18
+ * hook_jail` creates it `suppressible: false`, so per
19
+ * openspec/specs/alerting/spec.md no ancestor alert and no deploy window can
20
+ * silence it. Losing confinement during a broad outage is precisely when it
21
+ * must still page.
22
+ *
23
+ * Deliberately cheap: one local file read, no remote contact, safe on every
24
+ * sweep.
25
+ */
26
+
27
+ import type { AlertSeverity } from '../../db/schema';
28
+ import type { JailModeRecord } from '../../hooks/jail';
29
+ import { type FailingKey, builtinAlertKey } from './keys';
30
+
31
+ export const HOOK_JAIL_CHECK = 'hook_jail';
32
+
33
+ export interface HookJailState {
34
+ /** The recorded mode, or undefined when no hook has recorded one yet. */
35
+ record: JailModeRecord | undefined;
36
+ /** The host this check runs on, compared against the record's own. */
37
+ host: string;
38
+ }
39
+
40
+ /**
41
+ * One failing key when this host used to jail and has stopped; empty
42
+ * otherwise. The message names the host and the reason, which is what the
43
+ * security-model spec requires the alert to carry.
44
+ *
45
+ * A record written by another host is ignored rather than compared: a database
46
+ * or data directory restored onto a new box is a move, not a regression, and
47
+ * the new box has its own history to make.
48
+ */
49
+ export function hookJailFailingKeys(state: HookJailState, severity: AlertSeverity): FailingKey[] {
50
+ const { record, host } = state;
51
+ if (!record || record.host !== host) return [];
52
+ if (record.mode !== 'unjailed' || !record.lastJailed) return [];
53
+
54
+ return [
55
+ {
56
+ key: builtinAlertKey(HOOK_JAIL_CHECK, 'host', host),
57
+ severity,
58
+ message:
59
+ `Hooks on ${host} ran jailed (${record.lastJailed.backend}) until ` +
60
+ `${record.lastJailed.recordedAt} and now run unjailed: ` +
61
+ `${record.reason ?? 'no reason was recorded'}`,
62
+ details:
63
+ 'A host that quietly stops confining hooks is a failure that otherwise\n' +
64
+ 'looks like nothing — the usual causes are a kernel upgrade, an AppArmor\n' +
65
+ 'policy change, or a changed container profile. `celilo system doctor`\n' +
66
+ 'reports what the host can do right now. The alert resolves when a hook\n' +
67
+ 'runs jailed on this host again.',
68
+ },
69
+ ];
70
+ }
@@ -7,6 +7,7 @@ import type { DbClient } from '../../db/client';
7
7
  import { type Monitor, alerts, monitorRuns, monitors } from '../../db/schema';
8
8
  import { setupTestDatabaseAt } from '../../test-utils/database';
9
9
  import type { HealthCheckResult } from '../health-runner';
10
+ import { HOOK_JAIL_CHECK, type HookJailState } from './hook-jail';
10
11
  import { moduleAlertKey, moduleCheckAlertKey } from './keys';
11
12
  import { type MonitorRunDeps, runOneMonitor } from './run-monitor';
12
13
  import { loadLiveAlerts, markUnsuppressed } from './store';
@@ -40,6 +41,7 @@ describe('runOneMonitor', () => {
40
41
  runModuleCheck: async () => result,
41
42
  runBuiltinCheck: async () => [],
42
43
  loadModuleCoverage: () => [],
44
+ loadJailState: () => ({ record: undefined, host: 'test-host' }),
43
45
  now: () => now,
44
46
  graceMs: 60_000,
45
47
  };
@@ -182,4 +184,64 @@ describe('runOneMonitor', () => {
182
184
  expect(summary.outcome).toBe('success');
183
185
  expect(liveKeys()).toEqual([]);
184
186
  });
187
+
188
+ describe('the hook-jail self-monitor (hook-process-boundary task 4.4)', () => {
189
+ const HOST = 'celilo-mgr';
190
+ const regressed: HookJailState = {
191
+ host: HOST,
192
+ record: {
193
+ mode: 'unjailed',
194
+ backend: 'none',
195
+ reason: 'bubblewrap is installed but could not build a namespace',
196
+ host: HOST,
197
+ recordedAt: NOW.toISOString(),
198
+ lastJailed: { backend: 'bubblewrap', recordedAt: '2026-07-27T03:00:00.000Z' },
199
+ },
200
+ };
201
+ const healed: HookJailState = {
202
+ host: HOST,
203
+ record: {
204
+ mode: 'jailed',
205
+ backend: 'bubblewrap',
206
+ host: HOST,
207
+ recordedAt: LATER.toISOString(),
208
+ },
209
+ };
210
+
211
+ let jailMonitor: Monitor;
212
+
213
+ beforeEach(() => {
214
+ db.insert(monitors)
215
+ .values({
216
+ id: 'mon-jail',
217
+ kind: 'builtin_check',
218
+ target: HOOK_JAIL_CHECK,
219
+ intervalMinutes: 15,
220
+ severity: 'critical',
221
+ suppressible: false,
222
+ })
223
+ .run();
224
+ jailMonitor = db.select().from(monitors).where(eq(monitors.id, 'mon-jail')).get() as Monitor;
225
+ });
226
+
227
+ const jailDeps = (state: HookJailState, now: Date = NOW): MonitorRunDeps => ({
228
+ ...deps(healthy, now),
229
+ loadJailState: () => state,
230
+ });
231
+
232
+ test('a jailed-to-unjailed transition fires an alert naming the host', async () => {
233
+ const summary = await runOneMonitor(db, jailMonitor, jailDeps(regressed));
234
+ expect(summary.outcome).toBe('success');
235
+ const live = loadLiveAlerts(db, jailMonitor.id);
236
+ expect(live.map((a) => a.key)).toEqual([`builtin:hook_jail/host:${HOST}`]);
237
+ const row = db.select().from(alerts).where(eq(alerts.monitorId, jailMonitor.id)).get();
238
+ expect(row?.message).toContain(HOST);
239
+ });
240
+
241
+ test('re-jailing resolves it by set difference', async () => {
242
+ await runOneMonitor(db, jailMonitor, jailDeps(regressed));
243
+ await runOneMonitor(db, jailMonitor, jailDeps(healed, LATER));
244
+ expect(loadLiveAlerts(db, jailMonitor.id)).toEqual([]);
245
+ });
246
+ });
185
247
  });
@@ -19,6 +19,7 @@ import type { HealthCheckResult } from '../health-runner';
19
19
  import { failingKeysFromFindings } from './builtin-monitors';
20
20
  import { HEALTH_COVERAGE_CHECK, healthCoverageFailingKeys } from './health-coverage';
21
21
  import type { ModuleCoverageInput } from './health-coverage';
22
+ import { HOOK_JAIL_CHECK, type HookJailState, hookJailFailingKeys } from './hook-jail';
22
23
  import {
23
24
  type FailingKey,
24
25
  builtinMonitorKey,
@@ -35,6 +36,8 @@ export interface MonitorRunDeps {
35
36
  runBuiltinCheck(category: DriftCategory): Promise<DriftFinding[]>;
36
37
  /** Module roster for the health-coverage check. */
37
38
  loadModuleCoverage(): ModuleCoverageInput[];
39
+ /** Recorded jail mode and current host, for the hook-jail self-monitor. */
40
+ loadJailState(): HookJailState;
38
41
  now(): Date;
39
42
  /** Grace window before a new alert may notify. */
40
43
  graceMs: number;
@@ -95,6 +98,15 @@ async function runCheck(
95
98
  };
96
99
  }
97
100
 
101
+ // Same shape: one local file read. This is the self-monitor for D8's third
102
+ // state — a host that used to jail its hooks and has stopped.
103
+ if (monitor.target === HOOK_JAIL_CHECK) {
104
+ return {
105
+ outcome: 'success',
106
+ failingKeys: hookJailFailingKeys(deps.loadJailState(), severity),
107
+ };
108
+ }
109
+
98
110
  const category = monitor.target as DriftCategory;
99
111
  try {
100
112
  const findings = await deps.runBuiltinCheck(category);
@@ -42,6 +42,7 @@ describe('runSweep', () => {
42
42
  runModuleCheck: async () => result,
43
43
  runBuiltinCheck: async () => [],
44
44
  loadModuleCoverage: () => [],
45
+ loadJailState: () => ({ record: undefined, host: 'test-host' }),
45
46
  now: () => now,
46
47
  graceMs: 60_000,
47
48
  };
@@ -41,6 +41,7 @@ import {
41
41
  refuseIfInFlight,
42
42
  startOperation,
43
43
  } from './module-operations';
44
+ import { remoteAccessPolicy } from './remote-access';
44
45
  import { stageSystemState } from './system-state-stage';
45
46
 
46
47
  export interface BackupCreateOptions {
@@ -399,6 +400,7 @@ export async function createModuleBackup(
399
400
  {
400
401
  debug: false,
401
402
  systems: getModuleSystems(moduleId, db),
403
+ remoteAccess: remoteAccessPolicy(moduleId, db),
402
404
  },
403
405
  );
404
406
 
@@ -563,6 +565,7 @@ export async function importModuleBackup(
563
565
  {
564
566
  debug: false,
565
567
  systems: getModuleSystems(moduleId, db),
568
+ remoteAccess: remoteAccessPolicy(moduleId, db),
566
569
  },
567
570
  );
568
571
 
@@ -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
 
@@ -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,
@@ -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,
@@ -46,6 +46,7 @@ import {
46
46
  runProviderBackfill,
47
47
  } from './provider-arrival';
48
48
  import { checkProxmoxReachable, formatProxmoxUnreachableError } from './proxmox-preflight';
49
+ import { remoteAccessPolicy } from './remote-access';
49
50
  import { LOCAL_MACHINE_IP, deleteTemporarySshKey, writeTemporarySshKey } from './ssh-key-manager';
50
51
  import { buildTerraformEnvForService } from './terraform-env';
51
52
 
@@ -720,6 +721,7 @@ async function deployModuleImpl(
720
721
  capabilities: capabilityFunctions,
721
722
  requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
722
723
  systems: getModuleSystems(moduleId, db),
724
+ remoteAccess: remoteAccessPolicy(moduleId, db),
723
725
  },
724
726
  );
725
727
 
@@ -858,6 +860,7 @@ async function deployModuleImpl(
858
860
  capabilities: capFns,
859
861
  requiredCapabilities: (manifest.requires?.capabilities ?? []).map((c) => c.name),
860
862
  systems: getModuleSystems(moduleId, db),
863
+ remoteAccess: remoteAccessPolicy(moduleId, db),
861
864
  },
862
865
  };
863
866
  },
@@ -1159,6 +1162,7 @@ async function deployModuleImpl(
1159
1162
  capabilities: capFunctions,
1160
1163
  requiredCapabilities: providerManifest.requires.capabilities.map((c) => c.name),
1161
1164
  systems: getModuleSystems(capRecord.moduleId, db),
1165
+ remoteAccess: remoteAccessPolicy(capRecord.moduleId, db),
1162
1166
  },
1163
1167
  );
1164
1168
 
@@ -1308,6 +1312,21 @@ async function deployModuleImpl(
1308
1312
  }
1309
1313
  }
1310
1314
 
1315
+ // The provider's own deploy runs the static-content converge alongside
1316
+ // everything else (design D10, task 4.5): the release set is written into
1317
+ // the generated inventory, the playbook's static_content-tagged tasks
1318
+ // converge /srv/www, and a rebuilt host recovers with no consumer
1319
+ // involvement. Same plan + writer the publish-time converge uses — one
1320
+ // code path, two callers.
1321
+ if (manifest.provides?.capabilities?.some((cap) => cap.name === 'public_web')) {
1322
+ const { writeStaticContentVars } = await import('./static-content-converge');
1323
+ const staticVars = await writeStaticContentVars(db, moduleId, generatedPath);
1324
+ if (!staticVars.success) {
1325
+ return { success: false, error: staticVars.error, phases };
1326
+ }
1327
+ log.success('Static-content release set written to the inventory');
1328
+ }
1329
+
1311
1330
  try {
1312
1331
  const ansibleResult = await executeAnsible(generatedPath, {
1313
1332
  noInteractive: !process.stdout.isTTY,
@@ -1412,6 +1431,7 @@ async function deployModuleImpl(
1412
1431
  capabilities: capFns,
1413
1432
  requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
1414
1433
  systems: getModuleSystems(moduleId, db),
1434
+ remoteAccess: remoteAccessPolicy(moduleId, db),
1415
1435
  },
1416
1436
  };
1417
1437
  },