@celilo/cli 1.5.0 → 1.6.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 (50) hide show
  1. package/CELILO_SUBSYSTEMS.md +16 -2
  2. package/MODULE_PRIMITIVES.md +19 -6
  3. package/drizzle/0026_module_integrity_version.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +2 -2
  6. package/src/cli/commands/module-audit.ts +5 -2
  7. package/src/cli/commands/module-update.test.ts +90 -2
  8. package/src/cli/commands/module-update.ts +112 -6
  9. package/src/cli/commands/module-verify.ts +77 -13
  10. package/src/cli/commands/system-audit.ts +17 -0
  11. package/src/cli/commands/system-doctor.ts +78 -2
  12. package/src/cli/commands/system-update.ts +33 -3
  13. package/src/cli/index.ts +2 -2
  14. package/src/cli/tui/audit-state.ts +11 -3
  15. package/src/cli/tui/audit-tui.tsx +10 -4
  16. package/src/cli/tui/icons.ts +9 -2
  17. package/src/cli/tui/modals/analyzing.tsx +3 -0
  18. package/src/db/schema.ts +5 -0
  19. package/src/manifest/json-schema-roundtrip.test.ts +12 -4
  20. package/src/manifest/schema.ts +23 -0
  21. package/src/module/import.ts +36 -35
  22. package/src/module/packaging/audit.ts +103 -28
  23. package/src/module/packaging/build.ts +12 -53
  24. package/src/module/packaging/classify-module-path.test.ts +104 -0
  25. package/src/module/packaging/extract.ts +31 -3
  26. package/src/module/packaging/generated-plane.test.ts +79 -0
  27. package/src/module/packaging/generated-plane.ts +134 -0
  28. package/src/module/packaging/host-plane.test.ts +132 -0
  29. package/src/module/packaging/host-plane.ts +135 -0
  30. package/src/module/packaging/package-rules.ts +62 -0
  31. package/src/services/audit/cli-version.test.ts +6 -2
  32. package/src/services/audit/cli-version.ts +20 -6
  33. package/src/services/audit/detect-without-converge.test.ts +91 -0
  34. package/src/services/audit/detect-without-converge.ts +81 -0
  35. package/src/services/audit/disk-space.test.ts +5 -2
  36. package/src/services/audit/disk-space.ts +5 -3
  37. package/src/services/audit/health.test.ts +39 -0
  38. package/src/services/audit/index.test.ts +7 -1
  39. package/src/services/audit/index.ts +12 -0
  40. package/src/services/audit/module-integrity.test.ts +146 -0
  41. package/src/services/audit/module-integrity.ts +113 -0
  42. package/src/services/audit/module-versions.ts +4 -1
  43. package/src/services/audit/schema.test.ts +7 -2
  44. package/src/services/audit/schema.ts +19 -1
  45. package/src/services/audit/terraform-plan.ts +17 -2
  46. package/src/services/audit/types.test.ts +29 -0
  47. package/src/services/audit/types.ts +30 -4
  48. package/src/services/module-deploy.ts +21 -0
  49. package/src/services/restore-from-file.ts +4 -0
  50. package/src/services/update/orchestrator.test.ts +2 -0
@@ -96,7 +96,8 @@ Module hooks reach a remote box ONLY through these typed primitives
96
96
  (exported from `packages/capabilities/src/index.ts`).
97
97
 
98
98
  - **`remoteExec`** — the ONE ssh seam (`ssh <user>@<target> <cmd>`); everything else builds on it. `user`/`port`/`identityFile` on the target default to `root`/22/the agent key, so a fleet call is unchanged; an OFF-FLEET account (`external_web`) sets them.
99
- - **`probe`** — read-only health checks: `systemd` / `http` / `command`.
99
+ - **`probe`** — read-only health checks over SSH: `systemd` / `command`. It carried a third `http` kind that SSHed in and ran `curl`; the production `ubuntu-22.04-standard` LXC ships none, and a missing binary was indistinguishable from a dead service. Deleted — `probeHttp` replaces it (`openspec/changes/probe-http-from-management/`).
100
+ - **`probeHttp`** — HTTP health check run FROM the management server over `fetch`; nothing on the target. Takes `port` + `path`, never a URL, and builds the address from the target's `ipv4_address` — so a check cannot name `localhost` and silently probe celilo-mgr's own port. Redirects observed, not followed (caddy's 308 is a healthy answer). Typed failure: `'unreachable'` vs `'status'`.
100
101
  - **`serviceCtl`** — systemctl start/stop/restart/reload/enable/disable.
101
102
  - **`runAppCommand` / `runAppCommandWithSecret`** — escape-hatch on-box command; the secret variant feeds the secret on **stdin** (`$SECRET`), never argv.
102
103
  - **`streamBackup` / `streamRestore` / `fetchFile` / `pushFile`** — binary-safe streaming via local shell redirect/pipe.
@@ -226,13 +227,26 @@ to look, which is how a forgotten pause actually gets found.
226
227
 
227
228
  ## Packaging, registry & publish
228
229
 
229
- - **Module packaging** — `apps/celilo/src/module/packaging/` — `build.ts` (`buildModule`), `extract.ts`, `checksum.ts`, `signature.ts` (`signChecksums`/`verifySignature`), `release-metadata.ts`, `audit.ts`.
230
+ - **Module packaging** — `apps/celilo/src/module/packaging/` — `build.ts` (`buildModule`), `extract.ts`, `checksum.ts`, `signature.ts` (`signChecksums`/`verifySignature`), `release-metadata.ts`, `audit.ts`, `package-rules.ts`, `generated-plane.ts`, `host-plane.ts` (see **Module integrity** below).
230
231
  - **Publish driver** — `scripts/publish.ts` shims to `apps/celilo/src/cli/commands/publish/` (workspace npm packages via `bun publish` + module registry; preflight stale-version/stale-manifest gates).
231
232
  - **Registry publish-token admin (append-safe)** — `apps/celilo/src/cli/commands/registry-token.ts` — `celilo registry token add/rm <token>` read-modify-write the celilo-registry `publish_tokens` secret (newline-separated bootstrap/admin list). Avoids the `module secret set` full-overwrite that clobbered other holders. Runs on-mgr where the master key lives; runtime-minted scoped tokens are handled separately by the registry-server.
232
233
  - **Contributor identity tokens (idp-issued, per-user)** — `apps/celilo/src/cli/commands/token.ts` — `celilo token obtain|list|revoke` mints/lists/revokes per-user API tokens via the `idp` capability (`create_token`/`list_tokens`/`revoke_token` on authentik). A module contributor authenticates publishes AS THEMSELVES (SECURE_MODULE_PUBLISH.md §6) — no admin/shared token on their machine; the token feeds `celilo author init`. Shown once at mint; the idp stores it hashed, celilo persists nothing. Runs on-mgr where the idp provider lives. Distinct from `registry token add/rm` (raw bootstrap list, different trust model).
233
234
  - **Registry token verification (opaque + idp introspection)** — `packages/registry-server/src/auth.ts` (`TokenAuth` — opaque SHA-256 publish tokens, admin/per-package scope) + `packages/registry-server/src/introspection.ts` (`IntrospectionVerifier` — RFC 7662 verify-bridge, SECURE_MODULE_PUBLISH.md §5[D-A]). `authorizePackage()` in `server.ts` tries the opaque set first (unchanged), then, for a token unknown to it, `identify()`s it via the idp introspection endpoint using the registry's confidential OIDC client creds (`OIDC_INTROSPECTION_ENDPOINT`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, provisioned on install — ce-7aa), reading `{active, sub, groups, exp}`. Fails CLOSED on any introspection error; never logs tokens/secrets. Instant revocation: revoke at the idp → next publish sees `active:false` → 401.
234
235
  - **Registry module-owner table (hybrid group + owner authz — ce-1ch, D-C)** — `packages/registry-server/src/module-owner-store.ts` (`ModuleOwnerStore` — JSON-persisted `{moduleName, ownerSub, claimedAt, sourceGroup}`, `REGISTRY_OWNERS_FILE`/`dataDir/module-owners.json`). The verified `groups` claim gates *whether* an identity may publish (`REGISTRY_ADMIN_GROUP`→publish/reassign anything; `REGISTRY_PUBLISHER_GROUP`, default `celilo-authors`, configurable→claim+publish owned); the owner table gates *which names*. First-publish-claims: the first verified publisher of an unclaimed name owns it; a *different* publisher is then DENIED (confused-deputy defense — Author-A cannot publish Author-B's module). Admin HTTP endpoints `GET /api/v1/modules/owners`, `GET|POST /api/v1/modules/owners/{name}` (reassign). Operator front door: `celilo registry owner list|show|set` (`apps/celilo/src/cli/commands/registry-owner.ts`), admin token resolved from the local `publish_tokens` bootstrap list.
235
236
 
237
+ ## Module integrity (the four places a module exists)
238
+
239
+ A module exists in four places at once, and celilo verifies the correspondence
240
+ between them. Design: `openspec/changes/module-integrity-rigor/design.md`.
241
+
242
+ - **The one classifier** — `apps/celilo/src/module/packaging/package-rules.ts` — `classifyModulePath(relPath)` returns `package` / `derived` / `unknown` and is the SINGLE answer to what belongs to a module. `build.ts#shouldExclude`, `extract.ts#scanDirectory`, `import.ts#copyModuleFiles`, `module-update.ts` and `audit.ts` all route through it. Composes with, and does not restate, `includeNodeModulesPath` (ISS-0046).
243
+ - **The versioned baseline** — `moduleIntegrity` in `apps/celilo/src/db/schema.ts` — checksums PLUS the `version` they describe. Upserted by `module import` and written by `module-update.ts#updateOne`, so it tracks the installed version instead of freezing at first import. `version` is nullable: NULL means "written before celilo stamped versions", which verify reports rather than papers over.
244
+ - **Plane 1, installed tree vs baseline** — `apps/celilo/src/module/packaging/audit.ts` — `auditModule(moduleId, db, { deep })`. The entry point for `module verify`.
245
+ - **Plane 2, generated project vs installed tree** — `apps/celilo/src/module/packaging/generated-plane.ts` — `compareVerbatimRoleAssets` (pure), `readVerbatimRoleAssets`, `refuseIfGeneratedIsStale`. Only `ansible/roles/<role>/files/**` has a meaningful expected digest; Ansible templates the rest. Wired into `module verify` and into `module-deploy.ts` as a pre-flight that refuses BEFORE contacting any system (celilo#925).
246
+ - **Plane 3, host vs generated project** — `apps/celilo/src/module/packaging/host-plane.ts` — `verifyModuleOnHosts` runs the generated playbook through `executeAnsible(..., { check: true })` and classifies each `PLAY RECAP` line as `converged` / `drift` / `unmeasured`. One SSH per system, so `--deep` only. A module opts out with `verify: { deep: false, reason: … }`; the reason is required and is printed.
247
+ - **Surfaces** — `celilo module verify <id> [--deep] [--json]` (`apps/celilo/src/cli/commands/module-verify.ts`), the `module_integrity` audit category (`apps/celilo/src/services/audit/module-integrity.ts`), and `system doctor`'s fleet section. Same implementation behind all three.
248
+ - **Dead convergence machinery** — `apps/celilo/src/services/audit/detect-without-converge.ts` — the `detect_without_converge` category reports a module declaring a `reconcile_*` / `refresh_registrations` / `reassert_dhcp_dns` hook that no subscription ever fires (celilo#934).
249
+
236
250
  ## Alerting & notifications
237
251
 
238
252
  Observation and delivery: monitors run checks on a schedule, alerts hold what
@@ -64,12 +64,25 @@ that, not a primitive. The primitives are for the module's *own* box.
64
64
  ### `probe(target, check, runner?, opts?) → { healthy, detail }`
65
65
  Read-only health check. Never mutates. `check` is one of:
66
66
  - `{ kind: 'systemd', unit }` — unit is `active`.
67
- - `{ kind: 'http', url, expectStatus?, headers? }` — curl runs **on the box**, so
68
- it doesn't depend on Caddy/public DNS. `expectStatus` defaults to 200; pass a
69
- list (e.g. `[200, 308]`) or `headers: { Host: 'app.example.com' }` for a vhost.
70
67
  - `{ kind: 'command', command, expectStdoutIncludes? }`.
68
+
69
+ For HTTP use `probeHttp`, not `probe` — see below.
70
+ ```ts
71
+ probe(sys, { kind: 'systemd', unit: 'caddy' }, run);
72
+ ```
73
+
74
+ ### `probeHttp(target, { port, path?, expectStatus?, headers?, timeoutMs? }) → Promise<{ healthy, detail, failure? }>`
75
+ HTTP health check, run **from the management server** over `fetch`. Nothing runs
76
+ on the target — no SSH, no curl (the production LXC image ships none, and a
77
+ missing binary used to be indistinguishable from a dead service). There is
78
+ deliberately **no `url`**: you give a port and a path, and the primitive builds
79
+ the address from the target's `ipv4_address`, so a check cannot name `localhost`
80
+ and end up probing celilo-mgr itself. `expectStatus` defaults to 200; pass a
81
+ list (e.g. `[200, 308]`) or `headers: { Host: 'app.example.com' }` for a vhost.
82
+ Redirects are **observed, not followed**. On failure, `failure` is
83
+ `'unreachable'` (could not connect) or `'status'` (answered with the wrong code).
71
84
  ```ts
72
- probe(sys, { kind: 'http', url: 'http://localhost', headers: { Host: fqdn }, expectStatus: [200, 308] }, run);
85
+ await probeHttp(sys, { port: 80, headers: { Host: fqdn }, expectStatus: [200, 308] });
73
86
  ```
74
87
 
75
88
  ### `serviceCtl(target, unit, action, runner?, opts?) → RunResult`
@@ -97,11 +110,11 @@ applyRenderedConfig({
97
110
 
98
111
  ### `waitFor(predicate, { attempts?, intervalMs?, onAttempt? }) → Promise<boolean>`
99
112
  Poll an async predicate until true or exhausted (default 30 × 5s). A combinator —
100
- it never touches the remote; wrap a `probe`/`runAppCommand` thunk. Use
113
+ it never touches the remote; wrap a `probe`/`probeHttp`/`runAppCommand` thunk. Use
101
114
  `onAttempt` to emit a heartbeat so a long wait doesn't trip the hook idle-timeout.
102
115
  Never `sleep`.
103
116
  ```ts
104
- await waitFor(() => probe(sys, { kind: 'http', url }, run).healthy,
117
+ await waitFor(async () => (await probeHttp(sys, { port: 3000 })).healthy,
105
118
  { onAttempt: (n) => logger.info(`waiting… ${n * 5}s`) });
106
119
  ```
107
120
 
@@ -0,0 +1,20 @@
1
+ -- The integrity baseline records WHICH VERSION its checksums describe
2
+ -- (openspec/changes/module-integrity-rigor, D1).
3
+ --
4
+ -- Without it `module verify` cannot distinguish "the files are old" from "the
5
+ -- checksums are old", and it reported the same violations either way. The row
6
+ -- was written once, by a plain INSERT on a UNIQUE column inside a try/catch
7
+ -- that only warned, and `module update` never touched the table at all — so a
8
+ -- module imported once and updated ten times still carried its first import's
9
+ -- checksums, and every file that had legitimately changed since read as
10
+ -- [MODIFIED]. That was the whole of the manifest.yml / README.md / scripts/*
11
+ -- block in the fleet's 47- and 25-violation outputs.
12
+ --
13
+ -- NULLABLE on purpose, and this is the Rule 3.4 exception rather than a missing
14
+ -- default: NULL means "written before celilo stamped versions", which is a
15
+ -- fact verify must be able to state. A default of '' or of the module's current
16
+ -- version would fabricate a correspondence nobody measured — exactly the
17
+ -- failure this change exists to remove. Existing rows keep NULL until the next
18
+ -- `module import` or `module update` rewrites them.
19
+
20
+ ALTER TABLE `module_integrity` ADD `version` text;
@@ -183,6 +183,13 @@
183
183
  "when": 1784100000000,
184
184
  "tag": "0025_port_forward_owner",
185
185
  "breakpoints": true
186
+ },
187
+ {
188
+ "idx": 26,
189
+ "version": "6",
190
+ "when": 1784200000000,
191
+ "tag": "0026_module_integrity_version",
192
+ "breakpoints": true
186
193
  }
187
194
  ]
188
- }
195
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,7 +58,7 @@
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1109.0",
60
60
  "@aws-sdk/lib-storage": "^3.1101.0",
61
- "@celilo/capabilities": "^2.2.0",
61
+ "@celilo/capabilities": "^2.3.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
63
  "@celilo/core": "^0.9.0",
64
64
  "@celilo/event-bus": "^0.6.0",
@@ -13,9 +13,12 @@
13
13
  import type { CommandResult } from '../types';
14
14
  import { moduleVerify } from './module-verify';
15
15
 
16
- export async function moduleAudit(args: string[]): Promise<CommandResult> {
16
+ export async function moduleAudit(
17
+ args: string[],
18
+ flags: Record<string, string | boolean> = {},
19
+ ): Promise<CommandResult> {
17
20
  process.stderr.write(
18
21
  'warning: `celilo module audit` is deprecated; use `celilo module verify` instead.\n',
19
22
  );
20
- return moduleVerify(args);
23
+ return moduleVerify(args, flags);
21
24
  }
@@ -5,9 +5,9 @@
5
5
  */
6
6
 
7
7
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
8
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
- import { join } from 'node:path';
10
+ import { join, relative } from 'node:path';
11
11
  import { eq } from 'drizzle-orm';
12
12
  import { type DbClient, getDb } from '../../db/client';
13
13
  import { modules } from '../../db/schema';
@@ -321,3 +321,91 @@ describe('registry sweep — an unanswered breaking update is not a decline', ()
321
321
  expect(getDb().select().from(modules).all()[0].version).toBe('1.0.2+9');
322
322
  });
323
323
  });
324
+
325
+ /**
326
+ * Every file under `root`, keyed by relative path, valued by its exact bytes.
327
+ * Compared whole so a TRUNCATION shows up — an existence check would not.
328
+ */
329
+ function snapshot(root: string, dir = root): Record<string, string> {
330
+ const files: Record<string, string> = {};
331
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
332
+ const full = join(dir, entry.name);
333
+ if (entry.isDirectory()) Object.assign(files, snapshot(root, full));
334
+ else if (entry.isFile()) files[relative(root, full)] = readFileSync(full, 'utf-8');
335
+ }
336
+ return files;
337
+ }
338
+
339
+ describe("updateOne — pointed at the module's own installed path", () => {
340
+ let tempDir: string;
341
+ let installedDir: string;
342
+ let db: DbClient;
343
+
344
+ beforeEach(() => {
345
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-selfpath-'));
346
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
347
+ process.env.CELILO_ORIGINAL_CWD = tempDir;
348
+
349
+ installedDir = join(tempDir, 'installed', 'selfmod');
350
+ mkdirSync(installedDir, { recursive: true });
351
+ writeFileSync(
352
+ join(installedDir, 'manifest.yml'),
353
+ `celilo_contract: "1.0"
354
+ id: selfmod
355
+ name: Self Module
356
+ version: 1.0.0
357
+ description: fixture
358
+ `,
359
+ );
360
+
361
+ db = getDb();
362
+ db.insert(modules)
363
+ .values({
364
+ id: 'selfmod',
365
+ name: 'Self Module',
366
+ sourcePath: installedDir,
367
+ version: '1.0.0',
368
+ manifestData: {
369
+ celilo_contract: '1.0',
370
+ id: 'selfmod',
371
+ name: 'Self Module',
372
+ version: '1.0.0',
373
+ },
374
+ })
375
+ .run();
376
+ });
377
+
378
+ afterEach(() => {
379
+ rmSync(tempDir, { recursive: true, force: true });
380
+ process.env.CELILO_DB_PATH = undefined;
381
+ process.env.CELILO_ORIGINAL_CWD = undefined;
382
+ });
383
+
384
+ test('refuses by name, and writes NOTHING', async () => {
385
+ // A data-loss guard, not a UX nicety. On celilo-mgr on 2026-08-19,
386
+ // `celilo module update /var/celilo/modules/wireguard-manager` threw
387
+ // `EINVAL: copy_file_range` and TRUNCATED
388
+ // `ansible/roles/wireguard-manager/handlers/main.yml` in the module source
389
+ // on the way down. The crash was mid-copy, so the tree was left damaged
390
+ // rather than untouched. It stayed invisible for nine hours because
391
+ // `generated/` still held a good copy; the next generate propagated the
392
+ // truncation, and the deploy after that died with "The requested handler
393
+ // 'Restart wireguard-manager' was not found". App health, ingress and
394
+ // tunnel peers were all green throughout — none of them can see a
395
+ // truncated Ansible handler.
396
+ //
397
+ // So this compares the whole tree byte for byte rather than checking that
398
+ // a file still exists. "It threw" was already true of the behaviour that
399
+ // caused the damage, and an existence check cannot see a truncation.
400
+ const before = snapshot(installedDir);
401
+
402
+ const result = await updateOne(installedDir, db, {}, { quiet: true });
403
+
404
+ expect(result.status).toBe('failed');
405
+ if (result.status !== 'failed') return;
406
+ expect(result.error).toContain('IS the installed copy of selfmod');
407
+ expect(result.error).not.toContain('EINVAL');
408
+
409
+ expect(snapshot(installedDir)).toEqual(before);
410
+ });
411
+ });
@@ -9,18 +9,20 @@
9
9
  * The module ID is read from the manifest at the given path.
10
10
  */
11
11
 
12
- import { cpSync, existsSync, readFileSync, readdirSync } from 'node:fs';
12
+ import { cpSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
13
13
  import { unlink } from 'node:fs/promises';
14
14
  import { tmpdir } from 'node:os';
15
- import { join, resolve } from 'node:path';
15
+ import { join, relative, resolve } from 'node:path';
16
16
  import { eq } from 'drizzle-orm';
17
17
  import { parse as parseYaml } from 'yaml';
18
18
  import { registerModuleCapabilities } from '../../capabilities/registration';
19
19
  import { getDb } from '../../db/client';
20
- import { capabilities, modules } from '../../db/schema';
20
+ import { capabilities, moduleIntegrity, modules } from '../../db/schema';
21
21
  import { ModuleManifestSchema } from '../../manifest/schema';
22
22
  import type { ModuleManifest } from '../../manifest/schema';
23
+ import { computeChecksums } from '../../module/packaging/build';
23
24
  import { cleanupTempDir, extractPackage } from '../../module/packaging/extract';
25
+ import { classifyModulePath } from '../../module/packaging/package-rules';
24
26
  import { RegistryClient } from '../../registry/client';
25
27
  import { askConfirm, withInterviewSession } from '../../services/bus-interview';
26
28
  import { InterviewAbandonedError, InterviewUnansweredError } from '../../services/interview-errors';
@@ -152,6 +154,30 @@ export async function fetchAndUpdate(
152
154
  /**
153
155
  * Upgrade a single module from a source path
154
156
  */
157
+ /**
158
+ * Every file under an installed module that an update is entitled to remove:
159
+ * `package` (the new version decides whether it survives) and `unknown` (no
160
+ * version ever shipped it, so it self-heals a tree an older update polluted).
161
+ *
162
+ * `derived` is neither walked nor removed. It is celilo's or the operator's —
163
+ * `generated/`, the hook runtime closure, `screenshots/`, `cookies.json` — and
164
+ * `generated/` alone carries terraform state and provider binaries.
165
+ */
166
+ function listPrunableFiles(root: string, dir = root): string[] {
167
+ const found: string[] = [];
168
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
169
+ const full = join(dir, entry.name);
170
+ const rel = relative(root, full);
171
+ if (classifyModulePath(rel) === 'derived') continue;
172
+ if (entry.isDirectory()) {
173
+ found.push(...listPrunableFiles(root, full));
174
+ } else if (entry.isFile()) {
175
+ found.push(rel);
176
+ }
177
+ }
178
+ return found;
179
+ }
180
+
155
181
  export async function updateOne(
156
182
  sourcePath: string,
157
183
  db: ReturnType<typeof getDb>,
@@ -241,6 +267,20 @@ export async function updateOne(
241
267
  };
242
268
  }
243
269
 
270
+ // Pointed at the module's OWN install, `updateOne` used to copy every file
271
+ // onto itself and die inside `cpSync` with `EINVAL: copy_file_range` — an
272
+ // error that names a syscall and not the mistake. Worse since D1: the copy
273
+ // dies partway, after the prune has already run. Refuse by name instead
274
+ // (D11).
275
+ if (resolve(actualPath) === resolve(module.sourcePath)) {
276
+ if (tempDir) await cleanupTempDir(tempDir);
277
+ return {
278
+ status: 'failed',
279
+ moduleId,
280
+ error: `'${sourcePath}' IS the installed copy of ${moduleId}. There is nothing to update it from. Point 'module update' at the module's source tree or a .netapp, or run 'celilo module upgrade ${moduleId}' to take the registry's version.`,
281
+ };
282
+ }
283
+
244
284
  // Old version comes from the DB so we capture whatever was last
245
285
  // recorded (which IS the registry-versioned form, e.g. "1.0.0+5",
246
286
  // for registry-driven installs/upgrades).
@@ -257,18 +297,84 @@ export async function updateOne(
257
297
  const installedPath = module.sourcePath;
258
298
  const preserveDirs = new Set(['generated', 'screenshots', 'cookies.json']);
259
299
 
260
- const skipDirs = new Set(['.git', 'node_modules', '.next', '.cache']);
300
+ // Route the copy through the one classifier, the way `module import` does.
301
+ // `updateOne` used to copy the source tree wholesale, so updating from a
302
+ // directory planted `e2e/`, `*.test.ts` and `scripts/tsconfig.json` in the
303
+ // install — files no package ships and no target runs. Import never did,
304
+ // because a directory import goes through the packager; update is the path
305
+ // that skipped it.
261
306
  const entries = readdirSync(actualPath);
262
307
  for (const entry of entries) {
263
- if (preserveDirs.has(entry) || skipDirs.has(entry)) continue;
308
+ if (preserveDirs.has(entry)) continue;
264
309
  const src = join(actualPath, entry);
265
310
  const dest = join(installedPath, entry);
266
- cpSync(src, dest, { recursive: true, force: true });
311
+ cpSync(src, dest, {
312
+ recursive: true,
313
+ force: true,
314
+ filter: (from) => classifyModulePath(relative(actualPath, from)) !== 'unknown',
315
+ });
267
316
  }
268
317
 
318
+ // The integrity baseline for the version just installed. Prefer the package's
319
+ // own signed `checksums.json`; a directory update has none, so compute over
320
+ // the source we just copied. Read before the temp dir goes away.
321
+ const packagedChecksumsPath = join(actualPath, 'checksums.json');
322
+ const packagedSignaturePath = join(actualPath, 'signature.sig');
323
+ let baselineChecksums: Record<string, string>;
324
+ if (existsSync(packagedChecksumsPath)) {
325
+ const parsed = JSON.parse(readFileSync(packagedChecksumsPath, 'utf-8')) as {
326
+ files?: Record<string, string>;
327
+ };
328
+ baselineChecksums = parsed.files ?? {};
329
+ } else {
330
+ baselineChecksums = (await computeChecksums(actualPath)).files;
331
+ }
332
+ const packagedSignature = existsSync(packagedSignaturePath)
333
+ ? readFileSync(packagedSignaturePath, 'utf-8').trim()
334
+ : null;
335
+
269
336
  // Clean up temp dir if we extracted a .netapp
270
337
  if (tempDir) await cleanupTempDir(tempDir);
271
338
 
339
+ // Remove what the new version dropped. `updateOne` only ever overlaid files,
340
+ // so a hook script deleted in 1.1.0 stayed on the box and stayed runnable —
341
+ // code celilo no longer believes it has installed, which is the same class of
342
+ // lie as celilo#925 pointing the other way. Only `package`-class paths are
343
+ // pruned: `generated/`, the hook runtime closure, `screenshots/` and
344
+ // `cookies.json` are celilo's or the operator's, and survive an update by
345
+ // design.
346
+ const survivingPaths = new Set(
347
+ Object.keys(baselineChecksums).filter((p) => classifyModulePath(p) === 'package'),
348
+ );
349
+ for (const relPath of listPrunableFiles(installedPath)) {
350
+ if (!survivingPaths.has(relPath)) {
351
+ rmSync(join(installedPath, relPath));
352
+ }
353
+ }
354
+
355
+ // Record it. `updateOne` never touched this table, so the baseline stayed
356
+ // frozen at the module's FIRST import no matter how many times it was
357
+ // updated. That is why `module verify` reported the same violations whether
358
+ // the files were old or the checksums were old, and could not answer the one
359
+ // question that matters (celilo#925).
360
+ db.insert(moduleIntegrity)
361
+ .values({
362
+ moduleId,
363
+ checksums: baselineChecksums,
364
+ version: newVersion,
365
+ signature: packagedSignature,
366
+ })
367
+ .onConflictDoUpdate({
368
+ target: moduleIntegrity.moduleId,
369
+ set: {
370
+ checksums: baselineChecksums,
371
+ version: newVersion,
372
+ signature: packagedSignature,
373
+ updatedAt: new Date(),
374
+ },
375
+ })
376
+ .run();
377
+
272
378
  // Update manifest in database. We persist the display version (with
273
379
  // +N when known) so subsequent `module list` / `module update` calls
274
380
  // see the same version string the registry reported.
@@ -1,53 +1,117 @@
1
1
  import { auditModule } from '../../module/packaging/audit';
2
+ import type { IntegrityViolation } from '../../module/packaging/extract';
3
+ import { hasFlag } from '../parser';
2
4
  import type { CommandResult } from '../types';
3
5
 
4
6
  /**
5
- * Verify module integrity (signature + checksums).
7
+ * Verify module integrity across the three planes a module lives in.
6
8
  *
7
- * Usage: celilo module verify <module-id>
9
+ * Usage: celilo module verify <module-id> [--deep] [--json]
10
+ *
11
+ * installed tree vs baseline did anything change the files since install?
12
+ * generated project vs installed is what we would deploy built from it?
13
+ * host vs generated project is what is running what we generated? (--deep)
14
+ *
15
+ * The first two are local and take milliseconds. `--deep` is one SSH per
16
+ * system, so it is opt-in (openspec/changes/module-integrity-rigor, D3).
8
17
  *
9
18
  * Renamed from `module audit` per CELILO_UPDATE D11 — `audit` is now
10
19
  * reserved for system-level drift detection (`celilo system audit`).
11
20
  * The legacy `module audit` continues to work via a deprecation alias
12
21
  * (see `module-audit.ts`).
13
22
  *
14
- * Returns a CommandResult so the dispatcher controls process exit
15
- * behavior.
23
+ * Returns a CommandResult so the dispatcher controls process exit behavior.
16
24
  */
17
- export async function moduleVerify(args: string[]): Promise<CommandResult> {
25
+ export async function moduleVerify(
26
+ args: string[],
27
+ flags: Record<string, string | boolean> = {},
28
+ ): Promise<CommandResult> {
18
29
  if (args.length === 0) {
19
30
  return {
20
31
  success: false,
21
- error: 'Module ID is required\n\nUsage: celilo module verify <module-id>',
32
+ error: 'Module ID is required\n\nUsage: celilo module verify <module-id> [--deep] [--json]',
22
33
  };
23
34
  }
24
35
 
25
36
  const moduleId = args[0];
37
+ const deep = hasFlag(flags, 'deep');
38
+ const json = hasFlag(flags, 'json');
39
+
40
+ const result = await auditModule(moduleId, undefined, { deep });
26
41
 
27
- const result = await auditModule(moduleId);
42
+ if (json) {
43
+ // The whole point of D9: one call answers "is the installed tree the
44
+ // version celilo thinks it is", with the digests on both sides, so nobody
45
+ // needs a shell on celilo-mgr to settle a celilo#925-shaped question.
46
+ const payload = {
47
+ moduleId,
48
+ deep,
49
+ ok: result.success && !result.error,
50
+ error: result.error ?? null,
51
+ moduleVersion: result.moduleVersion ?? null,
52
+ baselineVersion: result.baselineVersion ?? null,
53
+ violations: result.violations.map((v) => ({
54
+ type: v.type,
55
+ path: v.path,
56
+ message: v.message,
57
+ expectedDigest: v.expectedDigest ?? null,
58
+ actualDigest: v.actualDigest ?? null,
59
+ })),
60
+ hosts: result.hostPlane?.findings ?? [],
61
+ deepOptOut: result.hostPlane?.optedOut ?? null,
62
+ };
63
+ const text = JSON.stringify(payload, null, 2);
64
+ return payload.ok ? { success: true, message: text } : { success: false, error: text };
65
+ }
28
66
 
29
67
  if (result.error) {
30
68
  return { success: false, error: result.error };
31
69
  }
32
70
 
71
+ const lines: string[] = [];
72
+
73
+ // An opt-out nobody sees is a check that quietly disappeared, so it prints
74
+ // whether the module is clean or not (task 6.3).
75
+ if (result.hostPlane?.optedOut) {
76
+ lines.push(
77
+ ` ⚠ [DEEP SKIPPED] This module opts out of the host check: ${result.hostPlane.optedOut.reason}`,
78
+ );
79
+ }
80
+ for (const finding of result.hostPlane?.findings ?? []) {
81
+ if (finding.state === 'converged') {
82
+ lines.push(` ✓ [HOST] ${finding.hostname}: running what celilo generated`);
83
+ } else {
84
+ const tag = finding.state === 'drift' ? 'HOST-DRIFT' : 'HOST-UNMEASURED';
85
+ lines.push(
86
+ ` ${finding.state === 'drift' ? '✗' : '⚠'} [${tag}] ${finding.hostname}: ${finding.detail}`,
87
+ );
88
+ }
89
+ }
90
+
33
91
  if (result.success) {
34
92
  return {
35
93
  success: true,
36
- message: `Module '${moduleId}' passed integrity check\n No violations found.`,
94
+ message: [`Module '${moduleId}' passed integrity check`, ...lines, ' No violations found.']
95
+ .join('\n')
96
+ .trimEnd(),
37
97
  };
38
98
  }
39
99
 
40
- const violationLines = result.violations.map((v) => {
41
- const icon = v.type === 'missing' ? '⚠' : v.type === 'modified' ? '✗' : '!';
42
- return ` ${icon} [${v.type.toUpperCase()}] ${v.message}`;
43
- });
100
+ const ICONS: Record<IntegrityViolation['type'], string> = {
101
+ missing: '⚠',
102
+ modified: '✗',
103
+ extra: '!',
104
+ 'stale-baseline': '⚠',
105
+ 'stale-generated': '✗',
106
+ };
44
107
 
45
108
  return {
46
109
  success: false,
47
110
  error: [
48
111
  `Module '${moduleId}' failed integrity check`,
49
112
  ` Found ${result.violations.length} violation(s):`,
50
- ...violationLines,
113
+ ...result.violations.map((v) => ` ${ICONS[v.type]} [${v.type.toUpperCase()}] ${v.message}`),
114
+ ...lines,
51
115
  ].join('\n'),
52
116
  };
53
117
  }
@@ -211,6 +211,13 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
211
211
 
212
212
  const healthResults = await runAllHealthChecks(db, { onProgress });
213
213
 
214
+ // Module integrity. Shallow by default: the installed tree against its
215
+ // baseline, and the generated project against the installed tree. Both are
216
+ // local and take milliseconds. The host plane is one SSH per system and is
217
+ // reached through `module verify --deep`, not from here.
218
+ const { auditModule } = await import('../../module/packaging/audit');
219
+ const integrityResults = await Promise.all(installed.map((m) => auditModule(m.id, db)));
220
+
214
221
  // Services-credentials: try decrypting each container service's
215
222
  // credential envelope. Failures (missing master key, wrong
216
223
  // provider shape, corrupt envelope) become BLOCKED audit findings.
@@ -369,6 +376,14 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
369
376
  fetcher: makeRegistryFetcher(registryClient),
370
377
  },
371
378
  moduleConfigs: { modules: installedConfigs },
379
+ moduleIntegrity: { results: integrityResults },
380
+ detectWithoutConverge: {
381
+ modules: deployedModules.map((m) => ({
382
+ id: m.id,
383
+ state: m.state,
384
+ manifest: m.manifestData as ModuleManifest,
385
+ })),
386
+ },
372
387
  health: { results: healthResults },
373
388
  backups: { modules: installedBackupInfo },
374
389
  abandonedOperations: { records: loadAbandonedOperations(db) },
@@ -419,11 +434,13 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
419
434
 
420
435
  const VERDICT_ICON: Record<string, string> = {
421
436
  READY: '●',
437
+ UNKNOWN: '?',
422
438
  DRIFT: '⚠',
423
439
  BLOCKED: '✗',
424
440
  };
425
441
 
426
442
  const SEVERITY_ICON: Record<string, string> = {
443
+ unmeasured: '?',
427
444
  drift: '⚠',
428
445
  blocked: '✗',
429
446
  };