@celilo/cli 0.1.5 → 0.1.7

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 (145) hide show
  1. package/drizzle/0004_caddy_hostname_list.sql +25 -0
  2. package/drizzle/meta/_journal.json +14 -0
  3. package/package.json +9 -2
  4. package/src/ansible/inventory.test.ts +3 -2
  5. package/src/ansible/inventory.ts +5 -1
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +34 -1
  8. package/src/cli/cli.test.ts +2 -2
  9. package/src/cli/command-registry.ts +146 -3
  10. package/src/cli/command-tree-parser.test.ts +1 -1
  11. package/src/cli/command-tree-parser.ts +9 -8
  12. package/src/cli/commands/hook-run.ts +15 -66
  13. package/src/cli/commands/module-audit.ts +14 -44
  14. package/src/cli/commands/module-deploy.ts +4 -1
  15. package/src/cli/commands/module-import-registry.test.ts +115 -0
  16. package/src/cli/commands/module-import.ts +106 -22
  17. package/src/cli/commands/module-publish.test.ts +235 -0
  18. package/src/cli/commands/module-publish.ts +234 -0
  19. package/src/cli/commands/module-remove.ts +82 -2
  20. package/src/cli/commands/module-search.ts +57 -0
  21. package/src/cli/commands/module-secret-get.ts +59 -0
  22. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  23. package/src/cli/commands/module-verify.test.ts +59 -0
  24. package/src/cli/commands/module-verify.ts +53 -0
  25. package/src/cli/commands/status.ts +30 -20
  26. package/src/cli/commands/system-audit.test.ts +138 -0
  27. package/src/cli/commands/system-audit.ts +571 -0
  28. package/src/cli/commands/system-update.ts +391 -0
  29. package/src/cli/completion.ts +15 -1
  30. package/src/cli/fuel-gauge.ts +68 -3
  31. package/src/cli/generate-zsh-completion.ts +13 -3
  32. package/src/cli/index.ts +112 -5
  33. package/src/cli/parser.ts +11 -0
  34. package/src/cli/prompts.ts +36 -5
  35. package/src/cli/tui/audit-state.test.ts +246 -0
  36. package/src/cli/tui/audit-state.ts +525 -0
  37. package/src/cli/tui/audit-tui.test.tsx +135 -0
  38. package/src/cli/tui/audit-tui.tsx +624 -0
  39. package/src/cli/tui/celebration.tsx +29 -0
  40. package/src/cli/tui/clipboard.test.ts +94 -0
  41. package/src/cli/tui/clipboard.ts +101 -0
  42. package/src/cli/tui/icons.ts +22 -0
  43. package/src/cli/tui/keybar.tsx +65 -0
  44. package/src/cli/tui/keymap.test.ts +105 -0
  45. package/src/cli/tui/keymap.ts +70 -0
  46. package/src/cli/tui/modals/analyzing.tsx +75 -0
  47. package/src/cli/tui/modals/celebration.tsx +44 -0
  48. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  49. package/src/cli/tui/modals/remediate.tsx +44 -0
  50. package/src/cli/tui/modals.test.ts +137 -0
  51. package/src/cli/tui/mouse.test.ts +78 -0
  52. package/src/cli/tui/mouse.ts +114 -0
  53. package/src/cli/tui/panes/categories.tsx +62 -0
  54. package/src/cli/tui/panes/command-log.tsx +87 -0
  55. package/src/cli/tui/panes/detail.tsx +175 -0
  56. package/src/cli/tui/panes/findings.tsx +97 -0
  57. package/src/cli/tui/panes/summary.tsx +64 -0
  58. package/src/cli/tui/spawn.ts +130 -0
  59. package/src/cli/tui/theme.ts +42 -0
  60. package/src/cli/tui/wrap.test.ts +43 -0
  61. package/src/cli/tui/wrap.ts +45 -0
  62. package/src/cli/types.ts +5 -0
  63. package/src/db/client.ts +55 -2
  64. package/src/db/schema.ts +26 -17
  65. package/src/hooks/capability-loader.ts +133 -73
  66. package/src/hooks/define-hook.test.ts +9 -1
  67. package/src/hooks/executor.ts +22 -1
  68. package/src/hooks/load-hook-config.test.ts +165 -0
  69. package/src/hooks/load-hook-config.ts +60 -0
  70. package/src/hooks/logger.ts +42 -12
  71. package/src/hooks/run-named-hook.ts +128 -0
  72. package/src/hooks/types.ts +19 -0
  73. package/src/manifest/ensure-schema.test.ts +115 -0
  74. package/src/manifest/schema.ts +76 -0
  75. package/src/module/import.ts +20 -12
  76. package/src/module/packaging/build.ts +85 -16
  77. package/src/module/packaging/release-metadata.test.ts +103 -0
  78. package/src/module/packaging/release-metadata.ts +145 -0
  79. package/src/registry/client.test.ts +228 -0
  80. package/src/registry/client.ts +157 -0
  81. package/src/services/audit/backups.test.ts +233 -0
  82. package/src/services/audit/backups.ts +128 -0
  83. package/src/services/audit/capability-abi.test.ts +153 -0
  84. package/src/services/audit/capability-abi.ts +204 -0
  85. package/src/services/audit/cli-version.test.ts +60 -0
  86. package/src/services/audit/cli-version.ts +87 -0
  87. package/src/services/audit/health.test.ts +84 -0
  88. package/src/services/audit/health.ts +43 -0
  89. package/src/services/audit/index.test.ts +99 -0
  90. package/src/services/audit/index.ts +118 -0
  91. package/src/services/audit/machines-reachable.test.ts +87 -0
  92. package/src/services/audit/machines-reachable.ts +87 -0
  93. package/src/services/audit/module-configs.test.ts +131 -0
  94. package/src/services/audit/module-configs.ts +80 -0
  95. package/src/services/audit/module-versions.test.ts +99 -0
  96. package/src/services/audit/module-versions.ts +154 -0
  97. package/src/services/audit/schema.test.ts +68 -0
  98. package/src/services/audit/schema.ts +115 -0
  99. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  100. package/src/services/audit/secrets-decryptable.ts +97 -0
  101. package/src/services/audit/services-credentials.test.ts +54 -0
  102. package/src/services/audit/services-credentials.ts +64 -0
  103. package/src/services/audit/services-reachable.test.ts +60 -0
  104. package/src/services/audit/services-reachable.ts +64 -0
  105. package/src/services/audit/terraform-plan.test.ts +127 -0
  106. package/src/services/audit/terraform-plan.ts +153 -0
  107. package/src/services/audit/types.test.ts +36 -0
  108. package/src/services/audit/types.ts +90 -0
  109. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  110. package/src/services/audit/unconfigured-modules.ts +71 -0
  111. package/src/services/audit/undeployed-modules.test.ts +66 -0
  112. package/src/services/audit/undeployed-modules.ts +72 -0
  113. package/src/services/build-stream.ts +122 -122
  114. package/src/services/config-interview.ts +407 -2
  115. package/src/services/deploy-ansible.ts +73 -7
  116. package/src/services/deploy-preflight.ts +45 -4
  117. package/src/services/deploy-terraform.ts +31 -24
  118. package/src/services/deploy-validation.ts +167 -23
  119. package/src/services/ensure-interview.test.ts +245 -0
  120. package/src/services/health-runner.ts +110 -38
  121. package/src/services/module-build.ts +11 -13
  122. package/src/services/module-deploy.ts +370 -59
  123. package/src/services/ssh-key-manager.test.ts +1 -1
  124. package/src/services/ssh-key-manager.ts +3 -2
  125. package/src/services/terraform-env.ts +62 -0
  126. package/src/services/update/dep-graph.test.ts +214 -0
  127. package/src/services/update/dep-graph.ts +215 -0
  128. package/src/services/update/orchestrator.test.ts +463 -0
  129. package/src/services/update/orchestrator.ts +359 -0
  130. package/src/services/update/progress.ts +49 -0
  131. package/src/services/update/self-update.test.ts +68 -0
  132. package/src/services/update/self-update.ts +57 -0
  133. package/src/services/update/types.ts +94 -0
  134. package/src/templates/generator.test.ts +1 -1
  135. package/src/templates/generator.ts +42 -1
  136. package/src/test-utils/completion-harness.test.ts +1 -1
  137. package/src/test-utils/completion-harness.ts +4 -4
  138. package/src/variables/capability-self-ref.test.ts +203 -0
  139. package/src/variables/context.ts +49 -1
  140. package/src/variables/declarative-derivation.test.ts +306 -0
  141. package/src/variables/declarative-derivation.ts +4 -2
  142. package/src/variables/parser.test.ts +56 -1
  143. package/src/variables/parser.ts +47 -6
  144. package/src/variables/resolver.ts +27 -9
  145. package/tsconfig.json +1 -0
@@ -8,8 +8,9 @@ import * as p from '@clack/prompts';
8
8
  import { and, eq } from 'drizzle-orm';
9
9
  import { log, promptPassword, promptText } from '../cli/prompts';
10
10
  import type { DbClient } from '../db/client';
11
- import { moduleConfigs, secrets } from '../db/schema';
12
- import { encryptSecret } from '../secrets/encryption';
11
+ import { moduleConfigs, modules, secrets } from '../db/schema';
12
+ import type { Ensure } from '../manifest/schema';
13
+ import { decryptSecret, encryptSecret } from '../secrets/encryption';
13
14
  import { deriveSecret, generateSecret } from '../secrets/generators';
14
15
  import { getOrCreateMasterKey } from '../secrets/master-key';
15
16
  import type { Machine } from '../types/infrastructure';
@@ -81,6 +82,80 @@ function resolveMachineZoneIp(machine: Machine, zone: string): string | null {
81
82
  return iface?.ipAddress ?? null;
82
83
  }
83
84
 
85
+ /**
86
+ * Auto-derive $machine: variables from a machine without prompting.
87
+ * Called in both interactive and non-interactive deployments so machine-
88
+ * catalogued data (zones, zone IPs, etc.) is always applied automatically.
89
+ *
90
+ * @returns InterviewResult with the keys that were successfully derived
91
+ */
92
+ export async function autoDeriveMachineConfig(
93
+ moduleId: string,
94
+ missingVariables: MissingVariable[],
95
+ db: DbClient,
96
+ machine: Machine,
97
+ ): Promise<InterviewResult> {
98
+ const configured: string[] = [];
99
+
100
+ for (const variable of missingVariables) {
101
+ if (!variable.derive_from?.startsWith('$machine:')) continue;
102
+
103
+ const derived = resolveMachineDerivation(variable.derive_from, machine);
104
+ if (derived === null) continue;
105
+
106
+ log.info(`✓ ${variable.name} = ${derived} (auto-derived from ${machine.hostname})`);
107
+
108
+ await db
109
+ .insert(moduleConfigs)
110
+ .values({
111
+ moduleId,
112
+ key: variable.name,
113
+ value: derived,
114
+ valueJson: null,
115
+ createdAt: new Date(),
116
+ updatedAt: new Date(),
117
+ })
118
+ .onConflictDoUpdate({
119
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
120
+ set: { value: derived, updatedAt: new Date() },
121
+ });
122
+ configured.push(variable.name);
123
+
124
+ // Handle per_selection follow-ups (e.g., zone_ip_* from zone list)
125
+ if (variable.options && variable.per_selection) {
126
+ for (const selectedVal of derived.split(',')) {
127
+ const followUpKey = variable.per_selection.key_pattern.replace('{value}', selectedVal);
128
+ let followUpValue: string | null = null;
129
+
130
+ if (variable.per_selection.derive_from === '$machine:zone_ip') {
131
+ followUpValue = resolveMachineZoneIp(machine, selectedVal);
132
+ }
133
+
134
+ if (followUpValue !== null) {
135
+ log.info(`✓ ${followUpKey} = ${followUpValue} (auto-derived from ${machine.hostname})`);
136
+ await db
137
+ .insert(moduleConfigs)
138
+ .values({
139
+ moduleId,
140
+ key: followUpKey,
141
+ value: followUpValue,
142
+ valueJson: null,
143
+ createdAt: new Date(),
144
+ updatedAt: new Date(),
145
+ })
146
+ .onConflictDoUpdate({
147
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
148
+ set: { value: followUpValue, updatedAt: new Date() },
149
+ });
150
+ configured.push(followUpKey);
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ return { success: true, configured };
157
+ }
158
+
84
159
  /**
85
160
  * Interview user for missing required configuration
86
161
  *
@@ -692,3 +767,333 @@ export async function interviewForMissingSecrets(
692
767
  configured,
693
768
  };
694
769
  }
770
+
771
+ // ─────────────────────────────────────────────────────────────────────────
772
+ // Cross-module "ensure" interview
773
+ //
774
+ // When a hook's capability call detects that a value isn't covered by a
775
+ // provider module's config, the framework runs this interview against
776
+ // the provider's `ensures` block to extend its config in place. See
777
+ // `apps/celilo/designs/CROSS_MODULE_CONFIG_INTERVIEW.md`.
778
+ // ─────────────────────────────────────────────────────────────────────────
779
+
780
+ interface EnsureTargetParts {
781
+ /** Always one of "config" | "secret" — derived from `target:` prefix. */
782
+ scope: 'config' | 'secret';
783
+ /** Variable / secret name on the provider module. */
784
+ name: string;
785
+ }
786
+
787
+ function parseEnsureTarget(target: string): EnsureTargetParts {
788
+ const [scope, name] = target.split('.', 2);
789
+ if ((scope !== 'config' && scope !== 'secret') || !name) {
790
+ throw new Error(
791
+ `Invalid ensure target: "${target}" (expected "config.<name>" or "secret.<name>")`,
792
+ );
793
+ }
794
+ return { scope, name };
795
+ }
796
+
797
+ function renderEnsureTemplate(template: string, value: string): string {
798
+ return template.replace(/\{\{\s*value\s*\}\}/g, value);
799
+ }
800
+
801
+ async function readModuleConfigKey(moduleId: string, key: string, db: DbClient): Promise<unknown> {
802
+ const row = db
803
+ .select()
804
+ .from(moduleConfigs)
805
+ .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, key)))
806
+ .get();
807
+ if (!row) return undefined;
808
+ if (row.valueJson) {
809
+ try {
810
+ return JSON.parse(row.valueJson);
811
+ } catch {
812
+ return row.value;
813
+ }
814
+ }
815
+ return row.value;
816
+ }
817
+
818
+ async function writeModuleConfigKey(
819
+ moduleId: string,
820
+ key: string,
821
+ value: unknown,
822
+ db: DbClient,
823
+ ): Promise<void> {
824
+ const valueJson = JSON.stringify(value);
825
+ const stringValue = typeof value === 'string' ? value : valueJson;
826
+ await db
827
+ .insert(moduleConfigs)
828
+ .values({
829
+ moduleId,
830
+ key,
831
+ value: stringValue,
832
+ valueJson,
833
+ createdAt: new Date(),
834
+ updatedAt: new Date(),
835
+ })
836
+ .onConflictDoUpdate({
837
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
838
+ set: { value: stringValue, valueJson, updatedAt: new Date() },
839
+ })
840
+ .run();
841
+ }
842
+
843
+ async function readModuleSecretKey(
844
+ moduleId: string,
845
+ name: string,
846
+ db: DbClient,
847
+ masterKey: Buffer,
848
+ ): Promise<string | undefined> {
849
+ const row = db
850
+ .select()
851
+ .from(secrets)
852
+ .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name)))
853
+ .get();
854
+ if (!row) return undefined;
855
+ return decryptSecret(
856
+ { encryptedValue: row.encryptedValue, iv: row.iv, authTag: row.authTag },
857
+ masterKey,
858
+ );
859
+ }
860
+
861
+ async function writeModuleSecretKey(
862
+ moduleId: string,
863
+ name: string,
864
+ plaintext: string,
865
+ db: DbClient,
866
+ masterKey: Buffer,
867
+ ): Promise<void> {
868
+ const encrypted = encryptSecret(plaintext, masterKey);
869
+ // No unique index on (module_id, name) for secrets; manual upsert.
870
+ const existing = db
871
+ .select()
872
+ .from(secrets)
873
+ .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name)))
874
+ .get();
875
+ if (existing) {
876
+ await db
877
+ .update(secrets)
878
+ .set({
879
+ encryptedValue: encrypted.encryptedValue,
880
+ iv: encrypted.iv,
881
+ authTag: encrypted.authTag,
882
+ updatedAt: new Date(),
883
+ })
884
+ .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name)))
885
+ .run();
886
+ } else {
887
+ await db
888
+ .insert(secrets)
889
+ .values({
890
+ moduleId,
891
+ name,
892
+ encryptedValue: encrypted.encryptedValue,
893
+ iv: encrypted.iv,
894
+ authTag: encrypted.authTag,
895
+ })
896
+ .run();
897
+ }
898
+ }
899
+
900
+ export interface EnsureInterviewOptions {
901
+ /**
902
+ * In non-interactive mode the framework prints the recipe and aborts
903
+ * before reaching this function — but tests / scripted callers still
904
+ * use this flag to apply changes without prompting for any text input.
905
+ */
906
+ noInteractive?: boolean;
907
+ /** Override prompts for testing — return string answers in order. */
908
+ promptOverride?: (prompt: string, hint?: string) => Promise<string>;
909
+ }
910
+
911
+ export interface EnsureInterviewResult {
912
+ success: boolean;
913
+ /** True when every input was already satisfied (idempotent retry). */
914
+ alreadyApplied?: boolean;
915
+ error?: string;
916
+ /** Human-readable lines describing what changed (for the deploy log). */
917
+ applied: string[];
918
+ }
919
+
920
+ /**
921
+ * Render the CLI recipe shown in `--no-interactive` mode. Generated
922
+ * from the manifest's `ensures` block so it stays correct as modules
923
+ * evolve.
924
+ */
925
+ export function renderEnsureRecipe(
926
+ providerModuleId: string,
927
+ ensure: Ensure,
928
+ value: string,
929
+ ): string {
930
+ const lines: string[] = [
931
+ `Provider module "${providerModuleId}" doesn't yet ensure "${ensure.id}" for "${value}".`,
932
+ 'Resolve before re-running:',
933
+ '',
934
+ ];
935
+ for (const input of ensure.inputs) {
936
+ const { scope, name } = parseEnsureTarget(input.target);
937
+ if (input.kind === 'append_to_array') {
938
+ lines.push(
939
+ ` # append "${value}" to ${scope}.${name}:`,
940
+ ` celilo module config get ${providerModuleId} ${name} # read existing array`,
941
+ ` celilo module config set ${providerModuleId} ${name} '<existing + "${value}">'`,
942
+ );
943
+ } else {
944
+ const renderedKey = renderEnsureTemplate(input.key, value);
945
+ const renderedPrompt = renderEnsureTemplate(input.prompt, value);
946
+ const cmd = scope === 'secret' ? 'secret' : 'config';
947
+ lines.push(
948
+ ` # ${renderedPrompt}:`,
949
+ ` celilo module ${cmd} set ${providerModuleId} ${name} '<JSON object with "${renderedKey}":"<value>">'`,
950
+ );
951
+ }
952
+ lines.push('');
953
+ }
954
+ if (ensure.post === 'redeploy_self') {
955
+ lines.push(` celilo module deploy ${providerModuleId}`);
956
+ }
957
+ return lines.join('\n');
958
+ }
959
+
960
+ /**
961
+ * Apply one `ensures` block to the provider module's config + secrets.
962
+ *
963
+ * - `append_to_array`: idempotent — re-running with an already-present
964
+ * value is a no-op.
965
+ * - `set_in_object`: prompts only when the key isn't already set on the
966
+ * target object. Re-running with the same value is a no-op.
967
+ *
968
+ * Returns success even when every input was already satisfied — the
969
+ * caller (module-deploy) decides whether to retry the hook regardless.
970
+ */
971
+ export async function interviewForEnsureInputs(
972
+ providerModuleId: string,
973
+ ensure: Ensure,
974
+ value: string,
975
+ db: DbClient,
976
+ options: EnsureInterviewOptions = {},
977
+ ): Promise<EnsureInterviewResult> {
978
+ const applied: string[] = [];
979
+ let allNoop = true;
980
+
981
+ // No confirm prompt: running `module deploy <consumer>` is itself the
982
+ // user's consent for whatever cross-module config its hooks imply. A
983
+ // confirm here would be redundant — the user just typed the deploy
984
+ // command. Prompts that *gather information* (e.g. a DDNS password)
985
+ // still appear, because the framework genuinely doesn't know the
986
+ // value. See apps/celilo/designs/CADDY_HOSTNAME_LIST.md, Decision 6.
987
+
988
+ const masterKey = await getOrCreateMasterKey();
989
+
990
+ for (const input of ensure.inputs) {
991
+ const { scope, name } = parseEnsureTarget(input.target);
992
+
993
+ if (input.kind === 'append_to_array') {
994
+ if (scope !== 'config') {
995
+ return {
996
+ success: false,
997
+ applied,
998
+ error: `append_to_array only supports config targets (got ${input.target})`,
999
+ };
1000
+ }
1001
+ const current = await readModuleConfigKey(providerModuleId, name, db);
1002
+ const arr = Array.isArray(current) ? [...current] : [];
1003
+ if (arr.includes(value)) {
1004
+ applied.push(`${input.target} already contains "${value}" — skipped`);
1005
+ continue;
1006
+ }
1007
+ arr.push(value);
1008
+ await writeModuleConfigKey(providerModuleId, name, arr, db);
1009
+ applied.push(`${input.target} ← appended "${value}"`);
1010
+ allNoop = false;
1011
+ continue;
1012
+ }
1013
+
1014
+ // input.kind === 'set_in_object'
1015
+ const objectKey = renderEnsureTemplate(input.key, value);
1016
+ const promptMsg = renderEnsureTemplate(input.prompt, value);
1017
+
1018
+ if (scope === 'config') {
1019
+ const current = await readModuleConfigKey(providerModuleId, name, db);
1020
+ const obj =
1021
+ current && typeof current === 'object' && !Array.isArray(current)
1022
+ ? { ...(current as Record<string, unknown>) }
1023
+ : {};
1024
+ if (objectKey in obj) {
1025
+ applied.push(`${input.target}["${objectKey}"] already set — skipped`);
1026
+ continue;
1027
+ }
1028
+ const promptFn =
1029
+ options.promptOverride ??
1030
+ (async () => promptText({ message: promptMsg, placeholder: input.hint }));
1031
+ const userValue = await promptFn(promptMsg, input.hint);
1032
+ obj[objectKey] = userValue;
1033
+ await writeModuleConfigKey(providerModuleId, name, obj, db);
1034
+ applied.push(`${input.target}["${objectKey}"] set`);
1035
+ allNoop = false;
1036
+ continue;
1037
+ }
1038
+
1039
+ // Secret object: stored as a JSON-encoded string secret. Round-trip
1040
+ // through parse/serialize so other keys are preserved.
1041
+ const currentRaw = await readModuleSecretKey(providerModuleId, name, db, masterKey);
1042
+ let obj: Record<string, unknown> = {};
1043
+ if (currentRaw) {
1044
+ try {
1045
+ const parsed = JSON.parse(currentRaw);
1046
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1047
+ obj = parsed as Record<string, unknown>;
1048
+ }
1049
+ } catch {
1050
+ // Existing secret isn't a JSON object — overwrite (the schema
1051
+ // declares this secret as an object, so any other shape is stale).
1052
+ }
1053
+ }
1054
+ if (objectKey in obj) {
1055
+ applied.push(`${input.target}["${objectKey}"] already set — skipped`);
1056
+ continue;
1057
+ }
1058
+ const promptFn =
1059
+ options.promptOverride ??
1060
+ // promptPassword doesn't take a placeholder, so fold the hint
1061
+ // into the message — the user only sees the message before typing.
1062
+ (async () =>
1063
+ promptPassword({
1064
+ message: input.hint ? `${promptMsg}\n ${input.hint}` : promptMsg,
1065
+ }));
1066
+ const userValue = await promptFn(promptMsg, input.hint);
1067
+ obj[objectKey] = userValue;
1068
+ await writeModuleSecretKey(providerModuleId, name, JSON.stringify(obj), db, masterKey);
1069
+ applied.push(`${input.target}["${objectKey}"] set`);
1070
+ allNoop = false;
1071
+ }
1072
+
1073
+ return { success: true, alreadyApplied: allNoop, applied };
1074
+ }
1075
+
1076
+ /**
1077
+ * Look up an `ensures` block on a provider module's manifest by id.
1078
+ * Returns null when the module doesn't exist or doesn't declare a
1079
+ * matching ensure — callers should treat that as a hard failure since
1080
+ * the consumer's capability call asked for something the provider
1081
+ * doesn't know how to satisfy.
1082
+ */
1083
+ export function findEnsureOnProvider(
1084
+ providerModuleId: string,
1085
+ ensureId: string,
1086
+ db: DbClient,
1087
+ ): Ensure | null {
1088
+ const row = db.select().from(modules).where(eq(modules.id, providerModuleId)).get();
1089
+ if (!row?.manifestData) return null;
1090
+ const manifest = row.manifestData as {
1091
+ provides?: { capabilities?: Array<{ ensures?: Ensure[] }> };
1092
+ };
1093
+ for (const cap of manifest.provides?.capabilities ?? []) {
1094
+ for (const ensure of cap.ensures ?? []) {
1095
+ if (ensure.id === ensureId) return ensure;
1096
+ }
1097
+ }
1098
+ return null;
1099
+ }
@@ -18,6 +18,69 @@ export interface AnsibleResult {
18
18
  error?: string;
19
19
  }
20
20
 
21
+ /**
22
+ * Parse raw Ansible output lines into concise human-readable status.
23
+ * Returns null for lines that should be suppressed (decorative separators, etc.)
24
+ */
25
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally stripping ANSI escape codes
26
+ const ANSI_ESCAPE = /\x1b\[[0-9;]*m/g;
27
+
28
+ /**
29
+ * Emit structured progress markers to stdout for each Ansible play/task.
30
+ * Uses console.log (same channel as log.info) so the e2e runner sees them
31
+ * in real-time. The runner matches [ansible:play] / [ansible:task] patterns.
32
+ */
33
+ function emitAnsibleProgress(rawChunk: string): void {
34
+ // In non-TTY mode (e.g. inside a Docker exec) write to stdout, which is
35
+ // line-buffered by stdbuf. stderr may arrive batched or out-of-order through
36
+ // docker exec -T, so stdout gives the e2e runner reliable real-time delivery.
37
+ const out = process.stdout.isTTY ? process.stderr : process.stdout;
38
+ for (const line of rawChunk.split('\n')) {
39
+ const stripped = line.replace(ANSI_ESCAPE, '').trim();
40
+ if (/^PLAY \[/.test(stripped)) {
41
+ const name = stripped.replace(/^PLAY \[/, '').replace(/\].*$/, '');
42
+ out.write(`[ansible:play] ${name}\n`);
43
+ } else if (/^TASK \[/.test(stripped)) {
44
+ const name = stripped.replace(/^TASK \[/, '').replace(/\].*$/, '');
45
+ out.write(`[ansible:task] ${name}\n`);
46
+ } else if (/^RUNNING HANDLER \[/.test(stripped)) {
47
+ const name = stripped.replace(/^RUNNING HANDLER \[/, '').replace(/\].*$/, '');
48
+ out.write(`[ansible:handler] ${name}\n`);
49
+ } else if (/^\w[\w.-]+ *:/.test(stripped) && stripped.includes('ok=')) {
50
+ out.write(`[ansible:recap] ${stripped}\n`);
51
+ }
52
+ }
53
+ }
54
+
55
+ function parseAnsibleLine(line: string): string | null {
56
+ const stripped = line.replace(ANSI_ESCAPE, '').trim();
57
+
58
+ if (/^PLAY \[/.test(stripped)) {
59
+ const name = stripped.replace(/^PLAY \[/, '').replace(/\].*$/, '');
60
+ return `▶ ${name}`;
61
+ }
62
+ if (/^TASK \[/.test(stripped)) {
63
+ const name = stripped.replace(/^TASK \[/, '').replace(/\].*$/, '');
64
+ return ` • ${name}`;
65
+ }
66
+ if (/^RUNNING HANDLER \[/.test(stripped)) {
67
+ const name = stripped.replace(/^RUNNING HANDLER \[/, '').replace(/\].*$/, '');
68
+ return ` ↺ handler: ${name}`;
69
+ }
70
+ if (/^ok:/.test(stripped)) return ' ok';
71
+ if (/^changed:/.test(stripped)) return ' changed';
72
+ if (/^skipping:/.test(stripped)) return ' skipped';
73
+ if (/^failed:/.test(stripped)) return ' FAILED';
74
+ if (/^fatal:/.test(stripped)) return ` FATAL: ${stripped.slice(7)}`;
75
+ if (/^PLAY RECAP/.test(stripped)) return ' recap:';
76
+ if (/^\w[\w.-]+ *:/.test(stripped) && stripped.includes('ok=')) return ` ${stripped}`;
77
+
78
+ // Suppress decorative separator lines (all * or = chars)
79
+ if (/^[*=\s]+$/.test(stripped)) return null;
80
+
81
+ return null;
82
+ }
83
+
21
84
  /**
22
85
  * Execute Ansible playbook with streaming progress
23
86
  * Execution function - performs software deployment
@@ -33,12 +96,8 @@ export async function executeAnsible(
33
96
  const inventoryPath = join(ansibleDir, 'inventory', 'hosts.ini');
34
97
  const playbookPath = join(ansibleDir, 'playbook.yml');
35
98
 
36
- // Get Ansible Vault password
37
99
  const vaultPassword = await getVaultPassword();
38
100
 
39
- // Write vault password to temp file (same approach as ansible/secrets.ts)
40
- // Using /dev/stdin doesn't work reliably — Ansible tries to execute it as a
41
- // script and hits permission errors on some platforms.
42
101
  const tempDir = await mkdtemp(join(tmpdir(), 'celilo-vault-'));
43
102
  const passwordPath = join(tempDir, 'vault-pass');
44
103
  await writeFile(passwordPath, vaultPassword, { mode: 0o600 });
@@ -47,8 +106,6 @@ export async function executeAnsible(
47
106
  log.info('Deploying software...');
48
107
 
49
108
  try {
50
- // Execute ansible-playbook with streaming progress
51
- // Note: Paths escaped for shell execution (macOS paths contain spaces)
52
109
  const result = await executeBuildWithProgress({
53
110
  command: 'ansible-playbook',
54
111
  args: [
@@ -59,11 +116,20 @@ export async function executeAnsible(
59
116
  shellEscape(playbookPath),
60
117
  ],
61
118
  cwd: ansibleDir,
119
+ // PYTHONUNBUFFERED=1: forces Python (Ansible) to flush stdout immediately
120
+ // instead of buffering when writing to a pipe — makes progress stream in
121
+ // real-time rather than appearing in one burst at the end.
122
+ // ANSIBLE_SSH_PIPELINING: enables SSH pipelining for performance without ControlMaster
123
+ env: {
124
+ PYTHONUNBUFFERED: '1',
125
+ ANSIBLE_SSH_PIPELINING: 'True',
126
+ },
62
127
  title: 'Deploying software',
63
128
  noInteractive: options?.noInteractive,
129
+ filterOutput: parseAnsibleLine,
130
+ onOutput: emitAnsibleProgress,
64
131
  });
65
132
 
66
- // Persist full output to log file for debugging
67
133
  const timestamp = new Date().toISOString();
68
134
  const logHeader = `\n--- Ansible deploy ${timestamp} ---\n`;
69
135
  await appendFile(logPath, logHeader + result.output, 'utf-8');
@@ -21,6 +21,7 @@
21
21
  * - Modify any state
22
22
  */
23
23
 
24
+ import { compareConsumerToProvider } from '@celilo/capabilities';
24
25
  import { eq } from 'drizzle-orm';
25
26
  import type { DbClient } from '../db/client';
26
27
  import { getDb } from '../db/client';
@@ -39,6 +40,7 @@ export interface PreflightError {
39
40
  category:
40
41
  | 'missing-config'
41
42
  | 'missing-capability'
43
+ | 'capability-version-mismatch'
42
44
  | 'unresolved-template'
43
45
  | 'no-infrastructure'
44
46
  | 'missing-secret';
@@ -84,21 +86,58 @@ export async function runPreflight(
84
86
 
85
87
  const manifest = module.manifestData as ModuleManifest;
86
88
 
87
- // 2. Required capabilities have providers?
89
+ // 2. Required capabilities have providers, and at least one
90
+ // provider's version is compatible. Multi-provider scenarios
91
+ // (e.g., zone-scoped dns_registrar with separate internal +
92
+ // external providers) pass when ANY installed provider matches
93
+ // the consumer's required major — that's what the runtime
94
+ // zone-aware lookup actually picks. The audit catches the same
95
+ // drift after deploy; this surface refuses up front so the
96
+ // operator gets an actionable error instead of a silent
97
+ // template-generation against an ABI the provider can't fulfil.
88
98
  if (manifest.requires?.capabilities) {
89
99
  for (const cap of manifest.requires.capabilities) {
90
- const provider = db
100
+ const providers = db
91
101
  .select()
92
102
  .from(capabilities)
93
103
  .where(eq(capabilities.capabilityName, cap.name))
94
- .get();
95
- if (!provider) {
104
+ .all();
105
+ if (providers.length === 0) {
96
106
  errors.push({
97
107
  category: 'missing-capability',
98
108
  message: `Required capability '${cap.name}' has no provider installed`,
99
109
  suggestion: `Deploy a module that provides '${cap.name}' first`,
100
110
  });
111
+ continue;
101
112
  }
113
+ let exampleMismatch: {
114
+ provider: (typeof providers)[number];
115
+ reason: string;
116
+ } | null = null;
117
+ let anyCompatible = false;
118
+ for (const p of providers) {
119
+ const result = compareConsumerToProvider(cap.version, p.version);
120
+ if (result.compatible) {
121
+ anyCompatible = true;
122
+ break;
123
+ }
124
+ if (!exampleMismatch) {
125
+ exampleMismatch = { provider: p, reason: result.reason };
126
+ }
127
+ }
128
+ if (anyCompatible || !exampleMismatch) continue;
129
+
130
+ const { provider: example, reason } = exampleMismatch;
131
+ errors.push({
132
+ category: 'capability-version-mismatch',
133
+ message:
134
+ `Module '${moduleId}' requires ${cap.name}@${cap.version} but ` +
135
+ `provider '${example.moduleId}' offers ${cap.name}@${example.version}`,
136
+ suggestion:
137
+ reason === 'caller_minor_too_old'
138
+ ? `Upgrade provider '${example.moduleId}' to a version that provides ${cap.name}@${cap.version} or newer`
139
+ : `The interface major version differs. Update '${moduleId}' to require ${cap.name}@${example.version} (matching the provider's major), or rebuild '${example.moduleId}' against ${cap.name}@${cap.version}.`,
140
+ });
102
141
  }
103
142
  }
104
143
 
@@ -264,6 +303,8 @@ function errorIcon(category: PreflightError['category']): string {
264
303
  return '✗';
265
304
  case 'missing-capability':
266
305
  return '◯';
306
+ case 'capability-version-mismatch':
307
+ return '⚠';
267
308
  case 'unresolved-template':
268
309
  return '⟲';
269
310
  case 'no-infrastructure':