@celilo/cli 1.4.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 (55) hide show
  1. package/CELILO_SUBSYSTEMS.md +18 -4
  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 +41 -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/policy/module-script-scan.test.ts +164 -0
  32. package/src/policy/module-script-scan.ts +143 -0
  33. package/src/policy/no-hand-built-ssh.test.ts +22 -62
  34. package/src/services/audit/cli-version.test.ts +6 -2
  35. package/src/services/audit/cli-version.ts +20 -6
  36. package/src/services/audit/detect-without-converge.test.ts +91 -0
  37. package/src/services/audit/detect-without-converge.ts +81 -0
  38. package/src/services/audit/disk-space.test.ts +5 -2
  39. package/src/services/audit/disk-space.ts +5 -3
  40. package/src/services/audit/health.test.ts +39 -0
  41. package/src/services/audit/index.test.ts +7 -1
  42. package/src/services/audit/index.ts +12 -0
  43. package/src/services/audit/module-integrity.test.ts +146 -0
  44. package/src/services/audit/module-integrity.ts +113 -0
  45. package/src/services/audit/module-versions.ts +4 -1
  46. package/src/services/audit/schema.test.ts +7 -2
  47. package/src/services/audit/schema.ts +19 -1
  48. package/src/services/audit/terraform-plan.ts +17 -2
  49. package/src/services/audit/types.test.ts +29 -0
  50. package/src/services/audit/types.ts +30 -4
  51. package/src/services/module-deploy.ts +21 -0
  52. package/src/services/restore-from-file.ts +4 -0
  53. package/src/services/update/orchestrator.test.ts +2 -0
  54. package/src/templates/copy-role-files.test.ts +69 -0
  55. package/src/templates/generator.ts +23 -1
@@ -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
  };
@@ -472,7 +472,67 @@ async function renderAspectCoverage(opts: { fix: boolean }): Promise<{
472
472
  return { lines, failCount: missing.length, warnCount: unknown.length };
473
473
  }
474
474
 
475
- async function renderFleetSection(opts: { forced: boolean; fix: boolean }): Promise<{
475
+ /**
476
+ * The fleet-section line for module integrity, one row per module.
477
+ *
478
+ * `module verify` is the detailed surface and `system audit` is the machine
479
+ * one; doctor gets the summary, because doctor is where an operator looks
480
+ * first. Same implementation behind all three.
481
+ */
482
+ async function renderModuleIntegrity(opts: {
483
+ db: ReturnType<typeof getDb>;
484
+ deep: boolean;
485
+ }): Promise<{ lines: string[]; failCount: number; warnCount: number }> {
486
+ const { auditModule } = await import('../../module/packaging/audit');
487
+ const { auditModuleIntegrity } = await import('../../services/audit/module-integrity');
488
+ const { modules: modulesTable } = await import('../../db/schema');
489
+
490
+ const installed = opts.db.select().from(modulesTable).all();
491
+ if (installed.length === 0) return { lines: [], failCount: 0, warnCount: 0 };
492
+
493
+ const results = await Promise.all(
494
+ installed.map((m) => auditModule(m.id, opts.db, { deep: opts.deep })),
495
+ );
496
+ const findings = auditModuleIntegrity({ results });
497
+
498
+ const drifted = findings.filter((f) => f.severity === 'drift');
499
+ const unmeasured = findings.filter((f) => f.severity === 'unmeasured');
500
+
501
+ if (findings.length === 0) {
502
+ const scope = opts.deep
503
+ ? 'including what is running on their hosts'
504
+ : 'installed and generated';
505
+ return {
506
+ lines: [
507
+ ` ${ANSI.green}✔${ANSI.reset} Module integrity ${ANSI.dim}— ${installed.length} module(s) match their recorded version (${scope})${ANSI.reset}`,
508
+ ],
509
+ failCount: 0,
510
+ warnCount: 0,
511
+ };
512
+ }
513
+
514
+ const lines: string[] = [
515
+ ` ${drifted.length > 0 ? `${ANSI.red}✗${ANSI.reset}` : `${ANSI.yellow}?${ANSI.reset}`} Module integrity ${ANSI.dim}— ${drifted.length} drifted, ${unmeasured.length} unmeasured, of ${installed.length} module(s)${ANSI.reset}`,
516
+ ];
517
+ // Every finding, never just the first. celilo#951 printed `drift[0]` of 19,
518
+ // so a real finding would have arrived at position 19 and never been seen.
519
+ for (const f of findings) {
520
+ lines.push(` ${ANSI.dim}${f.message}${ANSI.reset}`);
521
+ if (f.remediation) lines.push(` ${ANSI.dim}→ ${f.remediation}${ANSI.reset}`);
522
+ }
523
+ if (!opts.deep) {
524
+ lines.push(
525
+ ` ${ANSI.dim}Run \`celilo system doctor --deep\` to also ask each host what it is running.${ANSI.reset}`,
526
+ );
527
+ }
528
+ return { lines, failCount: drifted.length, warnCount: unmeasured.length };
529
+ }
530
+
531
+ async function renderFleetSection(opts: {
532
+ forced: boolean;
533
+ fix: boolean;
534
+ deep: boolean;
535
+ }): Promise<{
476
536
  lines: string[];
477
537
  failCount: number;
478
538
  warnCount: number;
@@ -518,6 +578,18 @@ async function renderFleetSection(opts: { forced: boolean; fix: boolean }): Prom
518
578
  if (f.status === 'fail') failCount++;
519
579
  else if (f.status === 'warn') warnCount++;
520
580
  }
581
+
582
+ // Module integrity: is the code on each box the code celilo thinks it is?
583
+ // Shallow here by design — the installed tree against its baseline and the
584
+ // generated project against the installed tree are both local and take
585
+ // milliseconds. The host plane is one SSH per system and is reached with
586
+ // `--deep`, alongside aspect coverage, for the same reason
587
+ // (openspec/changes/module-integrity-rigor, D8).
588
+ const integrity = await renderModuleIntegrity({ db, deep: opts.deep });
589
+ lines.push(...integrity.lines);
590
+ failCount += integrity.failCount;
591
+ warnCount += integrity.warnCount;
592
+
521
593
  return { lines, failCount, warnCount };
522
594
  } finally {
523
595
  bus.close();
@@ -647,7 +719,11 @@ export async function handleSystemDoctor(
647
719
 
648
720
  // Fleet-runtime section (state-aware; only renders on a management
649
721
  // plane with a celilo DB, or when --fleet forces it).
650
- const fleet = await renderFleetSection({ forced: flags.fleet === true, fix });
722
+ const fleet = await renderFleetSection({
723
+ forced: flags.fleet === true,
724
+ fix,
725
+ deep: flags.deep === true,
726
+ });
651
727
  if (fleet.lines.length > 0) {
652
728
  lines.push(...fleet.lines);
653
729
  lines.push('');
@@ -495,6 +495,11 @@ export async function handleSystemUpdate(
495
495
  const migrationsFolder = findMigrationsFolderSafe();
496
496
  const healthResults = await runAllHealthChecks(db);
497
497
 
498
+ // Module integrity, shallow. Local and milliseconds; the host plane is one
499
+ // SSH per system and belongs to `module verify --deep`.
500
+ const { auditModule } = await import('../../module/packaging/audit');
501
+ const integrityResults = await Promise.all(upgradableModules.map((m) => auditModule(m.id, db)));
502
+
498
503
  const latestBackupByModule = new Map<string, number>();
499
504
  try {
500
505
  const successfulBackups = db
@@ -563,6 +568,14 @@ export async function handleSystemUpdate(
563
568
  configs: configsByModule.get(m.id) ?? {},
564
569
  })),
565
570
  },
571
+ moduleIntegrity: { results: integrityResults },
572
+ detectWithoutConverge: {
573
+ modules: upgradableModules.map((m) => ({
574
+ id: m.id,
575
+ state: m.state,
576
+ manifest: m.manifestData as ModuleManifest,
577
+ })),
578
+ },
566
579
  health: { results: healthResults },
567
580
  backups: {
568
581
  modules: upgradableModules.map((m) => ({
@@ -714,7 +727,7 @@ export async function handleSystemUpdate(
714
727
  // orchestrator was reacting to).
715
728
  const successfulModuleSteps = result.modules.filter((m) => m.step === 'done');
716
729
  if (result.ok && successfulModuleSteps.length > 0) {
717
- const refreshedAudit = await runAudit(rebuildAuditDepsForRerun(auditDeps, db));
730
+ const refreshedAudit = await runAudit(await rebuildAuditDepsForRerun(auditDeps, db));
718
731
  result.audit = refreshedAudit;
719
732
  }
720
733
 
@@ -747,10 +760,10 @@ type AuditDeps = Parameters<typeof runAudit>[0];
747
760
  * reflect post-upgrade reality (e.g., a module_versions drift
748
761
  * finding for a module we just upgraded is no longer reported).
749
762
  */
750
- export function rebuildAuditDepsForRerun(
763
+ export async function rebuildAuditDepsForRerun(
751
764
  original: AuditDeps,
752
765
  db: ReturnType<typeof getDb>,
753
- ): AuditDeps {
766
+ ): Promise<AuditDeps> {
754
767
  const installed = db.select().from(modules).all();
755
768
  const upgradeEligibleStates = new Set(['INSTALLED', 'VERIFIED', 'IMPORTED']);
756
769
  const upgradable = installed.filter((m) => upgradeEligibleStates.has(m.state));
@@ -763,6 +776,8 @@ export function rebuildAuditDepsForRerun(
763
776
  configsByModule.set(c.moduleId, m);
764
777
  }
765
778
 
779
+ const { auditModule } = await import('../../module/packaging/audit');
780
+
766
781
  // Backup recency is unchanged across an orchestrator run (only
767
782
  // celilo-DB snapshots happen, not per-module backup writes), so
768
783
  // we look up each module's prior lastSuccessfulBackupAt by id
@@ -793,6 +808,21 @@ export function rebuildAuditDepsForRerun(
793
808
  configs: configsByModule.get(m.id) ?? {},
794
809
  })),
795
810
  },
811
+ // Re-MEASURED, not carried over. An upgrade rewrites the installed tree and
812
+ // the baseline, so the pre-upgrade result describes files that are no
813
+ // longer on disk. Reusing it would be reporting a stored claim about a
814
+ // state that has since changed, which is the exact mistake this whole
815
+ // change exists to remove.
816
+ moduleIntegrity: {
817
+ results: await Promise.all(upgradable.map((m) => auditModule(m.id, db))),
818
+ },
819
+ detectWithoutConverge: {
820
+ modules: upgradable.map((m) => ({
821
+ id: m.id,
822
+ state: m.state,
823
+ manifest: m.manifestData as ModuleManifest,
824
+ })),
825
+ },
796
826
  // Unchanged across an orchestrator run — an upgrade does not reclaim
797
827
  // abandoned operations, so re-reading them would be the same rows.
798
828
  abandonedOperations: original.abandonedOperations,
package/src/cli/index.ts CHANGED
@@ -1577,9 +1577,9 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1577
1577
  case 'changeset':
1578
1578
  return handleModuleChangeset(parsed.args, parsed.flags);
1579
1579
  case 'audit':
1580
- return moduleAudit(parsed.args);
1580
+ return moduleAudit(parsed.args, parsed.flags);
1581
1581
  case 'verify':
1582
- return moduleVerify(parsed.args);
1582
+ return moduleVerify(parsed.args, parsed.flags);
1583
1583
  case 'config': {
1584
1584
  // Config requires additional subcommand (set/get)
1585
1585
  const configSubcommand = parsed.args[0];
@@ -56,12 +56,12 @@ export type ModalState =
56
56
  /**
57
57
  * Per-category progress state — drives the analyzing modal's
58
58
  * fuel-gauges. `done` carries the verdict so the row can show the
59
- * right icon (✓ clean / ▲ drift / × blocked).
59
+ * right icon (✓ clean / ? unmeasured / ▲ drift / × blocked).
60
60
  */
61
61
  export type CategoryStatus =
62
62
  | 'pending'
63
63
  | 'running'
64
- | { kind: 'done'; verdict: 'clean' | 'drift' | 'blocked' };
64
+ | { kind: 'done'; verdict: 'clean' | 'unmeasured' | 'drift' | 'blocked' };
65
65
 
66
66
  /**
67
67
  * The full set of categories the audit emits — used to seed the
@@ -90,9 +90,13 @@ export const ALL_CATEGORIES: readonly DriftCategory[] = [
90
90
  'transport_reads',
91
91
  'trusted_sources',
92
92
  'interface_classification',
93
+ 'module_integrity',
94
+ 'detect_without_converge',
93
95
  ];
94
96
 
95
97
  export const CATEGORY_LABELS: Record<DriftCategory, string> = {
98
+ module_integrity: 'Module integrity',
99
+ detect_without_converge: 'Drift without converge',
96
100
  interface_classification: 'Firewall interfaces',
97
101
  cli_version: 'CLI version',
98
102
  schema: 'Schema migrations',
@@ -181,7 +185,11 @@ export type AuditTuiAction =
181
185
  | { type: 'select-finding'; index: number }
182
186
  // Per-category audit-progress lifecycle.
183
187
  | { type: 'category-start'; category: DriftCategory }
184
- | { type: 'category-end'; category: DriftCategory; verdict: 'clean' | 'drift' | 'blocked' }
188
+ | {
189
+ type: 'category-end';
190
+ category: DriftCategory;
191
+ verdict: 'clean' | 'unmeasured' | 'drift' | 'blocked';
192
+ }
185
193
  | { type: 'reset-category-progress' };
186
194
 
187
195
  const PANE_ORDER: PaneId[] = ['summary', 'categories', 'findings', 'detail', 'log'];
@@ -129,11 +129,17 @@ export function AuditTui({ source, theme: themeName }: Props) {
129
129
  return;
130
130
  }
131
131
  const findings = event.findings ?? [];
132
- const verdict: 'clean' | 'drift' | 'blocked' = findings.some((f) => f.severity === 'blocked')
132
+ // Same ranking as `computeVerdict`: an unmeasured category is not clean
133
+ // and is not the same statement as a measured difference (D7).
134
+ const verdict: 'clean' | 'unmeasured' | 'drift' | 'blocked' = findings.some(
135
+ (f) => f.severity === 'blocked',
136
+ )
133
137
  ? 'blocked'
134
- : findings.length > 0
135
- ? 'drift'
136
- : 'clean';
138
+ : findings.some((f) => f.severity === 'unmeasured')
139
+ ? 'unmeasured'
140
+ : findings.length > 0
141
+ ? 'drift'
142
+ : 'clean';
137
143
  dispatch({ type: 'category-end', category: event.category, verdict });
138
144
  };
139
145
 
@@ -8,12 +8,16 @@ export interface SeverityVisual {
8
8
 
9
9
  export const SEVERITY_VISUALS: Record<DriftSeverity, SeverityVisual> = {
10
10
  blocked: { icon: '×', color: 'red', label: 'BLOCKED' },
11
+ // Deliberately NOT green and NOT gray. An unmeasured check is not a pass and
12
+ // is not a reminder — it is a hole in what celilo knows.
13
+ unmeasured: { icon: '?', color: 'yellow', label: 'UNMEASURED' },
11
14
  drift: { icon: '▲', color: 'yellow', label: 'DRIFT' },
12
15
  todo: { icon: '➤', color: 'gray', label: 'TODO' },
13
16
  };
14
17
 
15
18
  export const VERDICT_VISUALS: Record<AuditVerdict, SeverityVisual> = {
16
19
  BLOCKED: { icon: '×', color: 'red', label: 'BLOCKED' },
20
+ UNKNOWN: { icon: '?', color: 'yellow', label: 'UNKNOWN' },
17
21
  DRIFT: { icon: '▲', color: 'yellow', label: 'DRIFT' },
18
22
  READY: { icon: '✓', color: 'green', label: 'READY' },
19
23
  };
@@ -23,6 +27,9 @@ export const VERDICT_VISUALS: Record<AuditVerdict, SeverityVisual> = {
23
27
  // what actually needs attention.
24
28
  export function severityRank(s: DriftSeverity): number {
25
29
  if (s === 'blocked') return 0;
26
- if (s === 'drift') return 1;
27
- return 2; // todo
30
+ // Above drift, for the same reason UNKNOWN outranks DRIFT: you cannot act on
31
+ // a diff you are not sure you have.
32
+ if (s === 'unmeasured') return 1;
33
+ if (s === 'drift') return 2;
34
+ return 3; // todo
28
35
  }
@@ -36,6 +36,9 @@ function StatusRow({ category, status }: { category: string; status: CategorySta
36
36
  if (status.verdict === 'clean') {
37
37
  return <Text color="green">✓ {padded}clean</Text>;
38
38
  }
39
+ if (status.verdict === 'unmeasured') {
40
+ return <Text color="yellow">? {padded}unmeasured</Text>;
41
+ }
39
42
  if (status.verdict === 'drift') {
40
43
  return <Text color="yellow">▲ {padded}drift</Text>;
41
44
  }
package/src/db/schema.ts CHANGED
@@ -241,6 +241,11 @@ export const moduleIntegrity = sqliteTable('module_integrity', {
241
241
  .unique()
242
242
  .references(() => modules.id, { onDelete: 'cascade' }),
243
243
  checksums: text('checksums', { mode: 'json' }).$type<Record<string, string>>().notNull(), // { "path/to/file": "xxhash", ... }
244
+ // Which version these checksums describe. NULL means the row was written
245
+ // before celilo stamped versions — a fact `module verify` reports rather
246
+ // than papers over, because a baseline whose version is unknown cannot be
247
+ // compared to the module's and is not evidence of anything.
248
+ version: text('version'),
244
249
  signature: text('signature'), // Nullable - null for directory imports, populated for .netapp packages
245
250
  importedAt: integer('imported_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
246
251
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
@@ -3,10 +3,18 @@
3
3
  * under both the Zod schema (ModuleManifestSchema) and the exported JSON
4
4
  * Schema at <repo>/schemas/module-manifest.schema.json.
5
5
  *
6
- * This guards two things:
7
- * 1. The exported JSON Schema is in sync with the Zod source (the same as
8
- * 'bun run check:schema'). If someone forgets to regenerate after editing
9
- * schema.ts, this test fails.
6
+ * This is the INDIRECT drift detector, not the primary guard. `bun run
7
+ * check:schema` (a step in the CI validate job) regenerates the schema and
8
+ * compares it byte-for-byte, so it fails the moment schema.ts changes without a
9
+ * regenerate. This test only fails once some real manifest in the repo happens
10
+ * to declare something a stale schema rejects, which can be days later and in
11
+ * an unrelated PR. Do not mistake one for the other: if you add a field to
12
+ * schema.ts, `check:schema` is what tells you to run `bun run export:schema`.
13
+ *
14
+ * What this test is actually for:
15
+ * 1. Every manifest the repo ships really does validate under both
16
+ * validators, so a stale schema cannot sit unnoticed once it starts
17
+ * rejecting live manifests.
10
18
  * 2. zod-to-json-schema's translation hasn't lost fidelity in a way that
11
19
  * would let bad manifests pass the editor (red underlines) while still
12
20
  * failing at module import time. If a real manifest in the repo is
@@ -677,6 +677,29 @@ export const ModuleManifestSchema = z
677
677
 
678
678
  hooks: z.object(HOOK_SCHEMAS).strict().optional(),
679
679
 
680
+ /**
681
+ * How much of `celilo module verify` this module can honestly answer.
682
+ *
683
+ * `deep: false` opts the module out of the host plane — the `--deep` pass
684
+ * that evaluates its generated playbook in check mode. The escape hatch
685
+ * exists because Ansible's check mode SKIPS a task it cannot evaluate, so a
686
+ * role driven by `command:` / `shell:` can report `changed=0` having never
687
+ * been applied. For most roles that lands as `unmeasured`, which is honest.
688
+ * A role where it would land as a persistent false `drift` should say so
689
+ * here instead of training operators to ignore the output.
690
+ *
691
+ * `reason` is REQUIRED. An opt-out with no stated reason is a check that
692
+ * disappeared, and celilo prints the reason wherever the module is verified
693
+ * (openspec/changes/module-integrity-rigor, D4 / task 6.3).
694
+ */
695
+ verify: z
696
+ .object({
697
+ deep: z.literal(false),
698
+ reason: z.string().min(1),
699
+ })
700
+ .strict()
701
+ .optional(),
702
+
680
703
  build: z
681
704
  .object({
682
705
  /** Inline shell command to build the module (run via bash -c). Mutually exclusive with script. */
@@ -1,7 +1,7 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { existsSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { copyFile, mkdir, readFile, readdir } from 'node:fs/promises';
4
- import { join } from 'node:path';
4
+ import { join, relative } from 'node:path';
5
5
  import { eq } from 'drizzle-orm';
6
6
  import { z } from 'zod';
7
7
  import { getWellKnownCapability, isWellKnown } from '../capabilities/well-known';
@@ -23,6 +23,7 @@ import {
23
23
  } from '../manifest/validate';
24
24
  import { parseJsonWithValidation } from '../validation/schemas';
25
25
  import { cleanupTempDir, extractPackage, verifyPackageIntegrity } from './packaging/extract';
26
+ import { classifyModulePath } from './packaging/package-rules';
26
27
 
27
28
  /**
28
29
  * Phase-timing helper for `celilo module import`. Set CELILO_IMPORT_DEBUG=1
@@ -232,29 +233,14 @@ export async function copyModuleFiles(sourcePath: string, targetPath: string): P
232
233
  for (const entry of entries) {
233
234
  const srcPath = join(src, entry.name);
234
235
  const destPath = join(dest, entry.name);
235
-
236
- // Skip metadata files
237
- if (entry.name === 'checksums.json' || entry.name === 'signature.sig') {
238
- continue;
239
- }
240
-
241
- // Skip directories that shouldn't be copied into celilo's data store.
242
- // node_modules inside scripts/ ARE kept — they contain the module's
243
- // runtime deps (@celilo/capabilities etc.). Top-level node_modules
244
- // (monorepo workspace deps, build tools) are skipped.
245
- if (
246
- entry.isDirectory() &&
247
- (entry.name === '.git' || entry.name === '.next' || entry.name === '.cache')
248
- ) {
249
- continue;
250
- }
251
-
252
- if (
253
- entry.isDirectory() &&
254
- entry.name === 'node_modules' &&
255
- !src.endsWith('/scripts') &&
256
- !src.endsWith('\\scripts')
257
- ) {
236
+ const relPath = relative(sourcePath, srcPath);
237
+
238
+ // `unknown` is everything that belongs to the module's SOURCE tree and
239
+ // not to its install: `.git/`, `e2e/`, tests, `tsconfig.json`, the
240
+ // node_modules the canonical rule drops. Skipping it here is what keeps
241
+ // the `unknown` class empty on a healthy install, so any `unknown` that
242
+ // audit later reports is real. `package` and `derived` both land.
243
+ if (classifyModulePath(relPath) === 'unknown') {
258
244
  continue;
259
245
  }
260
246
 
@@ -699,17 +685,32 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
699
685
  // Execution: Store integrity data from the package's checksums + signature.
700
686
  // Directory imports go through the packager too (see top of importModule),
701
687
  // so by the time we reach this point we always have these.
702
- try {
703
- const integrityData: NewModuleIntegrity = {
704
- moduleId: manifest.id,
705
- checksums: checksums ?? {},
706
- signature: signature?.trim() ?? null,
707
- };
708
- db.insert(moduleIntegrity).values(integrityData).run();
709
- } catch (error) {
710
- // Non-fatal - module is already imported
711
- console.warn('Warning: Failed to store module integrity data', error);
712
- }
688
+ //
689
+ // UPSERT, not INSERT. `moduleId` is UNIQUE, so a re-import of an already
690
+ // imported module used to collide, get caught by a warn-and-continue, and
691
+ // leave the FIRST import's checksums in place forever. Every file that
692
+ // legitimately changed since then read as [MODIFIED] and the baseline
693
+ // described a version nobody could name. Failing to record the baseline is
694
+ // not "non-fatal": it is the state that made `module verify` useless, so
695
+ // this no longer swallows its own errors.
696
+ const integrityData: NewModuleIntegrity = {
697
+ moduleId: manifest.id,
698
+ checksums: checksums ?? {},
699
+ version: manifest.version,
700
+ signature: signature?.trim() ?? null,
701
+ };
702
+ db.insert(moduleIntegrity)
703
+ .values(integrityData)
704
+ .onConflictDoUpdate({
705
+ target: moduleIntegrity.moduleId,
706
+ set: {
707
+ checksums: integrityData.checksums,
708
+ version: integrityData.version,
709
+ signature: integrityData.signature,
710
+ updatedAt: new Date(),
711
+ },
712
+ })
713
+ .run();
713
714
 
714
715
  // Record a successful build entry so deploy-time validation sees the
715
716
  // artifacts in place and skips rebuild. The packager runs the manifest