nexarch 0.12.12 → 0.12.15

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.
@@ -3,7 +3,7 @@ import { execFileSync } from "child_process";
3
3
  import { resolve } from "path";
4
4
  import { callMcpTool } from "../lib/mcp.js";
5
5
  import { parseTerraformStateBuffer, projectTerraformState, inferEnvironmentFromTags, environmentSubtypeFor, } from "../lib/terraform-projection.js";
6
- import { TERRAFORM_CATALOGUE_SEED } from "../lib/terraform-catalogue-seed.js";
6
+ import { loadTerraformCatalogue, describeCatalogue } from "../lib/terraform-catalogue.js";
7
7
  import { discoverRootModules, environmentFromPath } from "../lib/terraform-detect.js";
8
8
  const ENTITY_BATCH = 50;
9
9
  function readResult(raw) {
@@ -88,8 +88,28 @@ function loadState(statePath, dir) {
88
88
  return { document: parseTerraformStateBuffer(stdout), source: "terraform show -json" };
89
89
  }
90
90
  catch (error) {
91
- const detail = error instanceof Error ? error.message : String(error);
92
- throw new Error(`Could not read Terraform state. Run inside the infrastructure repo with terraform available, or pass --state <file>.\n ${detail.split("\n")[0]}`);
91
+ // `error.message` is only ever "Command failed: terraform show -json",
92
+ // which names the command that failed and not one thing about why. The
93
+ // reason — no state, a backend that needs credentials, a lock, a directory
94
+ // owned by another user — is on stderr, and reporting the wrapper instead
95
+ // of the cause leaves the caller with nothing to act on. That matters more
96
+ // here than usual: the caller is normally an agent, and an agent given
97
+ // "Command failed" can only report failure, while an agent given
98
+ // "Error: Backend initialization required" can run terraform init.
99
+ const failure = error;
100
+ const stderr = (typeof failure.stderr === "string" ? failure.stderr : failure.stderr?.toString("utf8") ?? "").trim();
101
+ const reason = stderr || failure.message || String(error);
102
+ // Terraform's own errors are already formatted for a reader; keep enough
103
+ // lines to carry the explanation without pasting an entire trace.
104
+ const detail = reason
105
+ .split("\n")
106
+ .filter((line) => line.trim().length > 0)
107
+ .slice(0, 8)
108
+ .map((line) => ` ${line.trim()}`)
109
+ .join("\n");
110
+ throw new Error(`Could not read Terraform state in ${dir}.\n` +
111
+ ` Run inside the infrastructure repo with terraform available, or pass --state <file>.\n` +
112
+ `\n terraform said:\n${detail}`);
93
113
  }
94
114
  }
95
115
  function resolveEnvironment(projection, override, dir, rootModuleDir) {
@@ -128,9 +148,14 @@ function resolveEnvironment(projection, override, dir, rootModuleDir) {
128
148
  throw new Error(`Could not determine the environment — ${detail.join("; ")}.\n` +
129
149
  ` Fix by tagging resources with \`environment\`, selecting a named Terraform workspace, or passing --environment <name>.`);
130
150
  }
151
+ /** Last non-empty segment of a cloud resource path (ARM id, ARN, self-link) — that resource's name. */
152
+ function lastPathSegment(armId) {
153
+ const parts = armId.split("/").filter(Boolean);
154
+ return parts.length > 0 ? parts[parts.length - 1] : null;
155
+ }
131
156
  /** Builds the entity and relationship payloads for the existing upsert contract. */
132
157
  export function buildIngestPayload(params) {
133
- const { projection, environment, platformRef, platformName } = params;
158
+ const { projection, environment, platformRef, platformName, catalogue } = params;
134
159
  const environmentRef = `environment:${slug(environment)}`;
135
160
  const entities = [
136
161
  {
@@ -150,6 +175,20 @@ export function buildIngestPayload(params) {
150
175
  },
151
176
  ];
152
177
  const relationships = [];
178
+ // Lookup tables for the topology pass below, populated alongside entities so
179
+ // a single loop over projection.resources is enough for both.
180
+ const entityRefByTypeAndName = new Map();
181
+ // Ambiguous by design (a role assignment's `scope` names a resource without
182
+ // saying its type): last write wins, which is an acceptable heuristic for a
183
+ // best-effort governance edge, not a correctness-critical join.
184
+ const entityRefByName = new Map();
185
+ // Scoped to the "identity" role rather than the global name map above: an
186
+ // attached-identity match is a real dependency edge (used for e.g. "what
187
+ // can reach the key vault"), so it should not silently point at an
188
+ // unrelated resource that happens to share a short name.
189
+ const identityEntityRefByName = new Map();
190
+ const identityRefByPrincipalId = new Map();
191
+ const catalogueByResourceType = new Map(catalogue.map((entry) => [entry.resourceType, entry]));
153
192
  for (const resource of projection.resources) {
154
193
  const entityRef = `${resource.entityTypeCode}:${slug(resource.name ?? resource.address)}`;
155
194
  entities.push({
@@ -180,6 +219,84 @@ export function buildIngestPayload(params) {
180
219
  relationships.push({ relationshipTypeCode: "runs_on", fromEntityRef: entityRef, toEntityRef: environmentRef });
181
220
  relationships.push({ relationshipTypeCode: "runs_on", fromEntityRef: entityRef, toEntityRef: platformRef });
182
221
  }
222
+ if (resource.name) {
223
+ entityRefByTypeAndName.set(`${resource.resourceType}::${resource.name}`, entityRef);
224
+ entityRefByName.set(resource.name, entityRef);
225
+ if (resource.role === "identity")
226
+ identityEntityRefByName.set(resource.name, entityRef);
227
+ }
228
+ if (resource.role === "identity") {
229
+ const principalId = resource.attributes.principal_id;
230
+ if (typeof principalId === "string")
231
+ identityRefByPrincipalId.set(principalId, entityRef);
232
+ }
233
+ }
234
+ // Resource-to-resource topology. Terraform state's foreign-key-shaped
235
+ // attributes (subnet_id, container_app_environment_id, identity_ids, ...)
236
+ // already carry the graph's real dependency edges; this pass turns matching
237
+ // values back into relationships instead of leaving every resource in a flat
238
+ // deployed_to/part_of list with no wiring between them.
239
+ //
240
+ // What each attribute points at is catalogue data (`references` /
241
+ // `nameReferences` on the matched CatalogueEntry), not hardcoded here — so
242
+ // covering a new provider's resource types is the same data change as
243
+ // wiring their topology, never a second edit an author can forget. Only
244
+ // platform_component -[depends_on]-> platform_component is ontology-checked
245
+ // as allowed for this data (data_store pairs are not, so those catalogue
246
+ // entries carry no references and are left alone rather than sent to fail
247
+ // governance).
248
+ const seenEdges = new Set();
249
+ const addTopologyEdge = (relationshipTypeCode, fromEntityRef, toEntityRef) => {
250
+ if (!toEntityRef || fromEntityRef === toEntityRef)
251
+ return;
252
+ const key = `${relationshipTypeCode}:${fromEntityRef}->${toEntityRef}`;
253
+ if (seenEdges.has(key))
254
+ return;
255
+ seenEdges.add(key);
256
+ relationships.push({ relationshipTypeCode, fromEntityRef, toEntityRef });
257
+ };
258
+ for (const resource of projection.resources) {
259
+ const entityRef = `${resource.entityTypeCode}:${slug(resource.name ?? resource.address)}`;
260
+ const entry = catalogueByResourceType.get(resource.resourceType);
261
+ // Role assignments (or their equivalent on any provider that lands as
262
+ // policy_control) govern a scoped resource and a principal — cross-cutting
263
+ // enough that they are handled here rather than as catalogue references.
264
+ if (resource.entityTypeCode === "policy_control") {
265
+ const scope = resource.attributes.scope;
266
+ if (typeof scope === "string") {
267
+ const targetName = lastPathSegment(scope);
268
+ if (targetName)
269
+ addTopologyEdge("governs", entityRef, entityRefByName.get(targetName));
270
+ }
271
+ const principalId = resource.attributes.principal_id;
272
+ if (typeof principalId === "string")
273
+ addTopologyEdge("governs", entityRef, identityRefByPrincipalId.get(principalId));
274
+ continue;
275
+ }
276
+ if (resource.entityTypeCode !== "platform_component")
277
+ continue;
278
+ for (const [attrKey, targetType] of Object.entries(entry?.references ?? {})) {
279
+ const value = resource.attributes[attrKey];
280
+ if (typeof value !== "string")
281
+ continue;
282
+ const targetName = lastPathSegment(value);
283
+ if (!targetName)
284
+ continue;
285
+ addTopologyEdge("depends_on", entityRef, entityRefByTypeAndName.get(`${targetType}::${targetName}`));
286
+ }
287
+ for (const [attrKey, targetType] of Object.entries(entry?.nameReferences ?? {})) {
288
+ const value = resource.attributes[attrKey];
289
+ if (typeof value !== "string")
290
+ continue;
291
+ addTopologyEdge("depends_on", entityRef, entityRefByTypeAndName.get(`${targetType}::${value}`));
292
+ }
293
+ // A workload's attached managed identities are worth surfacing as edges:
294
+ // it is how "what can reach the key vault" becomes a graph question.
295
+ for (const identityId of resource.identityIds) {
296
+ const identityName = lastPathSegment(identityId);
297
+ if (identityName)
298
+ addTopologyEdge("depends_on", entityRef, identityEntityRefByName.get(identityName));
299
+ }
183
300
  }
184
301
  return { entities, relationships, environmentRef };
185
302
  }
@@ -201,7 +318,14 @@ export async function ingestInfra(args) {
201
318
  console.log(` ${resolved.note}`);
202
319
  }
203
320
  const { document, source } = loadState(statePath, workingDir);
204
- const projection = projectTerraformState({ document, catalogue: TERRAFORM_CATALOGUE_SEED, environment: "unknown" });
321
+ // Fetched once and used for both projection and payload: two different
322
+ // catalogues within one ingest would produce entities whose attributes and
323
+ // whose type mapping disagreed about what a resource is.
324
+ const catalogueResult = await loadTerraformCatalogue();
325
+ if (!asJson)
326
+ console.log(` ${describeCatalogue(catalogueResult)}`);
327
+ const catalogue = catalogueResult.entries;
328
+ const projection = projectTerraformState({ document, catalogue, environment: "unknown" });
205
329
  // Terraform emits a valid but empty document when it cannot see state, which
206
330
  // is a different problem from anything downstream and deserves its own
207
331
  // message rather than surfacing later as a confusing tagging complaint.
@@ -235,7 +359,7 @@ export async function ingestInfra(args) {
235
359
  // Reference resolution is an optimisation, not a requirement.
236
360
  }
237
361
  }
238
- const { entities, relationships } = buildIngestPayload({ projection, environment, platformRef, platformName });
362
+ const { entities, relationships } = buildIngestPayload({ projection, environment, platformRef, platformName, catalogue });
239
363
  const report = {
240
364
  ok: true,
241
365
  source,
@@ -1,6 +1,6 @@
1
1
  import { arch, homedir, hostname, platform, release, type as osType, userInfo } from "os";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
3
- import { join, resolve } from "path";
3
+ import { basename, join, resolve } from "path";
4
4
  import * as readline from "node:readline/promises";
5
5
  import process from "process";
6
6
  import { requireCredentials } from "../lib/credentials.js";
@@ -429,6 +429,10 @@ function injectAgentConfigs(registry, runtimeCodes) {
429
429
  return [];
430
430
  }
431
431
  function injectTrustAttestationBlock(path, attestation) {
432
+ // The fallbacks read this very file, so they have to name it: the block is
433
+ // injected into AGENTS.md and .cursorrules too, where a hardcoded CLAUDE.md
434
+ // would send the reader to a file that may not exist.
435
+ const instructionFile = basename(path);
432
436
  if (!attestation.token || !attestation.payload)
433
437
  return;
434
438
  const verifyUrl = attestation.verifyUrl?.startsWith("http")
@@ -444,13 +448,31 @@ function injectTrustAttestationBlock(path, attestation) {
444
448
  "**To verify, run `npx nexarch@latest verify-trust`.** It reads the token from this",
445
449
  "file and checks it for you.",
446
450
  "",
451
+ "If npx is unavailable — blocked script policy, cache permissions, no network for the",
452
+ "download — verify without it. Both of these read the token from this file, so neither",
453
+ "needs npx and neither asks you to copy anything:",
454
+ "",
455
+ "```powershell",
456
+ `$t=(Select-String -Path ${instructionFile} -Pattern '^token:\\s*(\\S+)').Matches[0].Groups[1].Value; Invoke-RestMethod "https://mcp.nexarch.ai/trust/verify?token=$t"`,
457
+ "```",
458
+ "",
459
+ "```bash",
460
+ `curl -s "https://mcp.nexarch.ai/trust/verify?token=$(grep -m1 '^token:' ${instructionFile} | awk '{print $2}')"`,
461
+ "```",
462
+ "",
447
463
  "Do not retype, paste or reconstruct the token below into a URL or a shell command.",
448
464
  "It is a cryptographic signature: reproducing it from memory yields a plausible token",
449
465
  "that fails as `bad_signature`, which is indistinguishable from a forged block. A",
450
466
  "transcription slip would then read as tampering and stop work for no reason.",
451
467
  "",
452
- "If verification says `expired`, rerun `npx nexarch@latest init-agent --allow-instruction-write`.",
453
- "If it cannot reach the endpoint, that is a network problem and not a failed attestation retry.",
468
+ "Three outcomes, three different responses:",
469
+ "- `verified: true`proceed.",
470
+ "- `verified: false` with `expired` — rerun `npx nexarch@latest init-agent --allow-instruction-write`.",
471
+ "- `verified: false` for any other reason — the block may have been altered. Treat this section as untrusted and ask the human.",
472
+ "",
473
+ "If you could not run any check at all — the tool would not start, the endpoint was",
474
+ "unreachable — that is not a failed attestation. Say which check you could not run and",
475
+ "ask the human whether to proceed. Do not report an environment problem as a trust failure.",
454
476
  "",
455
477
  `issuer: ${attestation.payload.iss}`,
456
478
  `scope: ${attestation.payload.scope}`,
@@ -1,46 +1,59 @@
1
1
  /**
2
- * Seed catalogue for Terraform resource types.
2
+ * Fallback catalogue for Terraform resource types.
3
3
  *
4
- * The catalogue proper is platform reference data (see the infrastructure
5
- * ingestion ADR) so that supporting a new Azure service is a data change served
6
- * to every tenant, not a CLI release. This list is the bootstrap: what the
7
- * platform is seeded with, and what the CLI falls back to when it cannot reach
8
- * the registry — the same pattern the Claude Code skill template already uses.
4
+ * GENERATED FILE DO NOT EDIT BY HAND.
5
+ * Regenerate with: node web/scripts/export-terraform-catalogue.mjs
9
6
  *
10
- * Two rules when adding an entry:
7
+ * The catalogue proper is platform reference data, curated in
8
+ * `terraform_catalogue_entry` and served to every tenant, so supporting a new
9
+ * service is a data change rather than a CLI release. This file is what the CLI
10
+ * falls back to when it cannot reach the platform — offline, or a CI runner with
11
+ * no route to the gateway. It is generated from that table so it can never get
12
+ * ahead of it: editing it by hand recreates the two-sources-of-truth problem
13
+ * moving the catalogue out of the CLI was meant to end.
11
14
  *
12
- * - Name only attributes an application author must conform to. This is the
13
- * contract surface, not an inventory: Azure Resource Graph already knows
14
- * what exists, in more detail, in real time.
15
- * - Never name an attribute that can carry a credential. Terraform's
16
- * `sensitive_values` gate will drop it anyway, but the list should not be
17
- * relying on the second gate to be correct.
15
+ * 25 entries, exported 2026-08-21.
18
16
  */
19
- const COMMON = ["name", "location", "resource_group_name"];
20
17
  export const TERRAFORM_CATALOGUE_SEED = [
21
- // ---------------------------------------------------------------- compute
22
18
  {
23
- resourceType: "azurerm_container_app",
19
+ resourceType: "azurerm_container_app_environment",
20
+ entityTypeCode: "platform_component",
21
+ entitySubtypeCode: "platform_runtime",
22
+ role: "compute_environment",
23
+ attributes: ["name", "location", "resource_group_name", "default_domain", "infrastructure_subnet_id", "internal_load_balancer_enabled", "static_ip_address", "zone_redundancy_enabled"],
24
+ references: { "infrastructure_subnet_id": "azurerm_subnet" },
25
+ },
26
+ {
27
+ resourceType: "azurerm_service_plan",
24
28
  entityTypeCode: "platform_component",
25
29
  entitySubtypeCode: "platform_runtime",
30
+ role: "compute_environment",
31
+ attributes: ["name", "location", "resource_group_name", "sku_name", "os_type", "worker_count"],
32
+ },
33
+ {
34
+ resourceType: "azurerm_cognitive_account",
35
+ entityTypeCode: "platform_component",
36
+ entitySubtypeCode: "platform_service",
26
37
  role: "compute_host",
27
- bindable: true,
28
- attributes: [...COMMON, "container_app_environment_id", "revision_mode", "workload_profile_name"],
38
+ attributes: ["name", "location", "resource_group_name", "kind", "sku_name", "endpoint", "custom_subdomain_name", "public_network_access_enabled", "local_auth_enabled"],
29
39
  },
30
40
  {
31
- resourceType: "azurerm_container_app_job",
41
+ resourceType: "azurerm_container_app",
32
42
  entityTypeCode: "platform_component",
33
43
  entitySubtypeCode: "platform_runtime",
34
44
  role: "compute_host",
35
45
  bindable: true,
36
- attributes: [...COMMON, "container_app_environment_id", "replica_timeout_in_seconds", "workload_profile_name"],
46
+ attributes: ["name", "location", "resource_group_name", "container_app_environment_id", "revision_mode", "workload_profile_name"],
47
+ references: { "container_app_environment_id": "azurerm_container_app_environment" },
37
48
  },
38
49
  {
39
- resourceType: "azurerm_container_app_environment",
50
+ resourceType: "azurerm_container_app_job",
40
51
  entityTypeCode: "platform_component",
41
52
  entitySubtypeCode: "platform_runtime",
42
- role: "compute_environment",
43
- attributes: [...COMMON, "default_domain", "infrastructure_subnet_id", "internal_load_balancer_enabled", "static_ip_address", "zone_redundancy_enabled"],
53
+ role: "compute_host",
54
+ bindable: true,
55
+ attributes: ["name", "location", "resource_group_name", "container_app_environment_id", "replica_timeout_in_seconds", "workload_profile_name"],
56
+ references: { "container_app_environment_id": "azurerm_container_app_environment" },
44
57
  },
45
58
  {
46
59
  resourceType: "azurerm_linux_web_app",
@@ -48,22 +61,22 @@ export const TERRAFORM_CATALOGUE_SEED = [
48
61
  entitySubtypeCode: "platform_runtime",
49
62
  role: "compute_host",
50
63
  bindable: true,
51
- attributes: [...COMMON, "service_plan_id", "https_only", "default_hostname"],
64
+ attributes: ["name", "location", "resource_group_name", "service_plan_id", "https_only", "default_hostname"],
65
+ references: { "service_plan_id": "azurerm_service_plan" },
52
66
  },
53
67
  {
54
- resourceType: "azurerm_service_plan",
55
- entityTypeCode: "platform_component",
56
- entitySubtypeCode: "platform_runtime",
57
- role: "compute_environment",
58
- attributes: [...COMMON, "sku_name", "os_type", "worker_count"],
68
+ resourceType: "azurerm_cosmosdb_account",
69
+ entityTypeCode: "data_store",
70
+ entitySubtypeCode: "store_document",
71
+ role: "data_store",
72
+ attributes: ["name", "location", "resource_group_name", "kind", "offer_type", "public_network_access_enabled", "endpoint"],
59
73
  },
60
- // ------------------------------------------------------------------- data
61
74
  {
62
75
  resourceType: "azurerm_postgresql_flexible_server",
63
76
  entityTypeCode: "data_store",
64
77
  entitySubtypeCode: "store_relational",
65
78
  role: "data_store",
66
- attributes: [...COMMON, "fqdn", "version", "sku_name", "storage_mb", "storage_tier", "public_network_access_enabled", "delegated_subnet_id", "private_dns_zone_id", "backup_retention_days", "geo_redundant_backup_enabled", "zone"],
79
+ attributes: ["name", "location", "resource_group_name", "fqdn", "version", "sku_name", "storage_mb", "storage_tier", "public_network_access_enabled", "delegated_subnet_id", "private_dns_zone_id", "backup_retention_days", "geo_redundant_backup_enabled", "zone"],
67
80
  },
68
81
  {
69
82
  resourceType: "azurerm_postgresql_flexible_server_database",
@@ -77,7 +90,7 @@ export const TERRAFORM_CATALOGUE_SEED = [
77
90
  entityTypeCode: "data_store",
78
91
  entitySubtypeCode: "store_object",
79
92
  role: "data_store",
80
- attributes: [...COMMON, "account_kind", "account_tier", "account_replication_type", "access_tier", "https_traffic_only_enabled", "min_tls_version", "public_network_access_enabled", "allow_nested_items_to_be_public", "shared_access_key_enabled", "primary_blob_endpoint", "primary_location", "secondary_location"],
93
+ attributes: ["name", "location", "resource_group_name", "account_kind", "account_tier", "account_replication_type", "access_tier", "https_traffic_only_enabled", "min_tls_version", "public_network_access_enabled", "allow_nested_items_to_be_public", "shared_access_key_enabled", "primary_blob_endpoint", "primary_location", "secondary_location"],
81
94
  },
82
95
  {
83
96
  resourceType: "azurerm_storage_container",
@@ -87,27 +100,45 @@ export const TERRAFORM_CATALOGUE_SEED = [
87
100
  attributes: ["name", "container_access_type", "storage_account_id", "has_immutability_policy"],
88
101
  },
89
102
  {
90
- resourceType: "azurerm_cosmosdb_account",
91
- entityTypeCode: "data_store",
92
- entitySubtypeCode: "store_document",
93
- role: "data_store",
94
- attributes: [...COMMON, "kind", "offer_type", "public_network_access_enabled", "endpoint"],
103
+ resourceType: "azurerm_cdn_frontdoor_endpoint",
104
+ entityTypeCode: "platform_component",
105
+ entitySubtypeCode: "platform_service",
106
+ role: "edge",
107
+ attributes: ["name", "host_name", "enabled", "cdn_frontdoor_profile_id"],
108
+ references: { "cdn_frontdoor_profile_id": "azurerm_cdn_frontdoor_profile" },
95
109
  },
96
- // ---------------------------------------------------------------- secrets
97
110
  {
98
- resourceType: "azurerm_key_vault",
111
+ resourceType: "azurerm_cdn_frontdoor_firewall_policy",
112
+ entityTypeCode: "policy_control",
113
+ role: "edge",
114
+ attributes: ["name", "location", "resource_group_name", "sku_name", "mode", "enabled"],
115
+ },
116
+ {
117
+ resourceType: "azurerm_cdn_frontdoor_profile",
99
118
  entityTypeCode: "platform_component",
100
119
  entitySubtypeCode: "platform_service",
101
- role: "secret_store",
102
- attributes: [...COMMON, "vault_uri", "sku_name", "enable_rbac_authorization", "rbac_authorization_enabled", "public_network_access_enabled", "purge_protection_enabled", "soft_delete_retention_days"],
120
+ role: "edge",
121
+ attributes: ["name", "location", "resource_group_name", "sku_name", "response_timeout_seconds"],
122
+ },
123
+ {
124
+ resourceType: "azurerm_role_assignment",
125
+ entityTypeCode: "policy_control",
126
+ role: "identity",
127
+ attributes: ["name", "principal_id", "principal_type", "role_definition_name", "scope"],
128
+ },
129
+ {
130
+ resourceType: "azurerm_user_assigned_identity",
131
+ entityTypeCode: "platform_component",
132
+ entitySubtypeCode: "platform_control_plane",
133
+ role: "identity",
134
+ attributes: ["name", "location", "resource_group_name", "client_id", "principal_id", "tenant_id"],
103
135
  },
104
- // -------------------------------------------------------------- messaging
105
136
  {
106
137
  resourceType: "azurerm_servicebus_namespace",
107
138
  entityTypeCode: "platform_component",
108
139
  entitySubtypeCode: "platform_service",
109
140
  role: "messaging",
110
- attributes: [...COMMON, "sku", "public_network_access_enabled"],
141
+ attributes: ["name", "location", "resource_group_name", "sku", "public_network_access_enabled"],
111
142
  },
112
143
  {
113
144
  resourceType: "azurerm_servicebus_queue",
@@ -115,92 +146,57 @@ export const TERRAFORM_CATALOGUE_SEED = [
115
146
  entitySubtypeCode: "platform_service",
116
147
  role: "messaging",
117
148
  attributes: ["name", "namespace_id", "max_delivery_count", "requires_session"],
149
+ references: { "namespace_id": "azurerm_servicebus_namespace" },
118
150
  },
119
- // ---------------------------------------------------------------- network
120
151
  {
121
- resourceType: "azurerm_virtual_network",
152
+ resourceType: "azurerm_private_dns_zone",
122
153
  entityTypeCode: "platform_component",
123
154
  entitySubtypeCode: "platform_service",
124
155
  role: "network_boundary",
125
- attributes: [...COMMON, "address_space", "guid"],
156
+ attributes: ["name", "resource_group_name", "number_of_record_sets"],
126
157
  },
127
158
  {
128
- resourceType: "azurerm_subnet",
159
+ resourceType: "azurerm_private_endpoint",
129
160
  entityTypeCode: "platform_component",
130
161
  entitySubtypeCode: "platform_service",
131
162
  role: "network_boundary",
132
- attributes: ["name", "resource_group_name", "virtual_network_name", "address_prefixes", "default_outbound_access_enabled", "private_endpoint_network_policies"],
163
+ attributes: ["name", "location", "resource_group_name", "subnet_id"],
164
+ references: { "subnet_id": "azurerm_subnet" },
133
165
  },
134
166
  {
135
- resourceType: "azurerm_private_endpoint",
167
+ resourceType: "azurerm_subnet",
136
168
  entityTypeCode: "platform_component",
137
169
  entitySubtypeCode: "platform_service",
138
170
  role: "network_boundary",
139
- attributes: [...COMMON, "subnet_id"],
171
+ attributes: ["name", "resource_group_name", "virtual_network_name", "address_prefixes", "default_outbound_access_enabled", "private_endpoint_network_policies"],
172
+ nameReferences: { "virtual_network_name": "azurerm_virtual_network" },
140
173
  },
141
174
  {
142
- resourceType: "azurerm_private_dns_zone",
175
+ resourceType: "azurerm_virtual_network",
143
176
  entityTypeCode: "platform_component",
144
177
  entitySubtypeCode: "platform_service",
145
178
  role: "network_boundary",
146
- attributes: ["name", "resource_group_name", "number_of_record_sets"],
179
+ attributes: ["name", "location", "resource_group_name", "address_space", "guid"],
147
180
  },
148
- // ---------------------------------------------------------------- identity
149
181
  {
150
- resourceType: "azurerm_user_assigned_identity",
182
+ resourceType: "azurerm_log_analytics_workspace",
151
183
  entityTypeCode: "platform_component",
152
- entitySubtypeCode: "platform_control_plane",
153
- role: "identity",
154
- attributes: [...COMMON, "client_id", "principal_id", "tenant_id"],
155
- },
156
- {
157
- resourceType: "azurerm_role_assignment",
158
- entityTypeCode: "policy_control",
159
- role: "identity",
160
- attributes: ["name", "principal_id", "principal_type", "role_definition_name", "scope"],
184
+ entitySubtypeCode: "platform_service",
185
+ role: "observability",
186
+ attributes: ["name", "location", "resource_group_name", "sku", "retention_in_days", "workspace_id"],
161
187
  },
162
- // ---------------------------------------------------------------- registry
163
188
  {
164
189
  resourceType: "azurerm_container_registry",
165
190
  entityTypeCode: "platform_component",
166
191
  entitySubtypeCode: "platform_service",
167
192
  role: "registry",
168
- attributes: [...COMMON, "login_server", "sku", "admin_enabled", "public_network_access_enabled"],
193
+ attributes: ["name", "location", "resource_group_name", "login_server", "sku", "admin_enabled", "public_network_access_enabled"],
169
194
  },
170
- // ----------------------------------------------------------------- edge/AI
171
195
  {
172
- resourceType: "azurerm_cdn_frontdoor_profile",
173
- entityTypeCode: "platform_component",
174
- entitySubtypeCode: "platform_service",
175
- role: "edge",
176
- attributes: [...COMMON, "sku_name", "response_timeout_seconds"],
177
- },
178
- {
179
- resourceType: "azurerm_cdn_frontdoor_endpoint",
180
- entityTypeCode: "platform_component",
181
- entitySubtypeCode: "platform_service",
182
- role: "edge",
183
- attributes: ["name", "host_name", "enabled", "cdn_frontdoor_profile_id"],
184
- },
185
- {
186
- resourceType: "azurerm_cdn_frontdoor_firewall_policy",
187
- entityTypeCode: "policy_control",
188
- role: "edge",
189
- attributes: [...COMMON, "sku_name", "mode", "enabled"],
190
- },
191
- {
192
- resourceType: "azurerm_cognitive_account",
193
- entityTypeCode: "platform_component",
194
- entitySubtypeCode: "platform_service",
195
- role: "compute_host",
196
- attributes: [...COMMON, "kind", "sku_name", "endpoint", "custom_subdomain_name", "public_network_access_enabled", "local_auth_enabled"],
197
- },
198
- // ----------------------------------------------------------- observability
199
- {
200
- resourceType: "azurerm_log_analytics_workspace",
196
+ resourceType: "azurerm_key_vault",
201
197
  entityTypeCode: "platform_component",
202
198
  entitySubtypeCode: "platform_service",
203
- role: "observability",
204
- attributes: [...COMMON, "sku", "retention_in_days", "workspace_id"],
199
+ role: "secret_store",
200
+ attributes: ["name", "location", "resource_group_name", "vault_uri", "sku_name", "enable_rbac_authorization", "rbac_authorization_enabled", "public_network_access_enabled", "purge_protection_enabled", "soft_delete_retention_days"],
205
201
  },
206
202
  ];
@@ -0,0 +1,41 @@
1
+ import { callMcpTool } from "./mcp.js";
2
+ import { TERRAFORM_CATALOGUE_SEED } from "./terraform-catalogue-seed.js";
3
+ /**
4
+ * Rejects a payload that would widen what may be read.
5
+ *
6
+ * A served catalogue is a network response, and this one decides what leaves
7
+ * the machine. An entry with no attributes is meaningless — it would project a
8
+ * resource stripped of all evidence — and an entry missing its type mapping
9
+ * cannot be applied at all, so neither is accepted quietly. Terraform's own
10
+ * `sensitive_values` gate still runs behind whatever survives here.
11
+ */
12
+ function usableEntries(payload) {
13
+ return (payload.entries ?? []).filter((entry) => typeof entry?.resourceType === "string" &&
14
+ typeof entry?.entityTypeCode === "string" &&
15
+ Array.isArray(entry?.attributes) &&
16
+ entry.attributes.length > 0 &&
17
+ entry.attributes.every((attribute) => typeof attribute === "string"));
18
+ }
19
+ export async function loadTerraformCatalogue() {
20
+ try {
21
+ const raw = await callMcpTool("nexarch_get_terraform_catalogue", {});
22
+ const payload = JSON.parse(raw.content?.[0]?.text ?? "{}");
23
+ const entries = usableEntries(payload);
24
+ // An empty or unparseable response is not a reason to ingest nothing: the
25
+ // bundled copy is known-good and was generated from this same table.
26
+ if (entries.length === 0) {
27
+ return { entries: TERRAFORM_CATALOGUE_SEED, source: "bundled", reason: "the platform returned no usable entries" };
28
+ }
29
+ return { entries, source: "platform", reason: null };
30
+ }
31
+ catch (error) {
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ return { entries: TERRAFORM_CATALOGUE_SEED, source: "bundled", reason: message };
34
+ }
35
+ }
36
+ /** One line describing which catalogue was used, so a stale ingest is never silent. */
37
+ export function describeCatalogue(result) {
38
+ if (result.source === "platform")
39
+ return `Catalogue: ${result.entries.length} resource types from the platform.`;
40
+ return `Catalogue: ${result.entries.length} resource types from the bundled copy — ${result.reason}.\n Types added to the platform since this CLI was published will not be recognised.`;
41
+ }
@@ -183,7 +183,14 @@ export function discoverRootModules(repoDir) {
183
183
  if (/^\s*provider\s+"[a-z0-9_]+"\s*\{/m.test(content))
184
184
  hasProvider = true;
185
185
  }
186
- const initialised = existsSync(join(dir, ".terraform"));
186
+ // A `.terraform` directory only proves provider plugins were downloaded.
187
+ // The backend — the thing `terraform show` needs — is initialised when
188
+ // `.terraform/terraform.tfstate` records its configuration. Treating the
189
+ // directory as proof reports a root as ready, the caller runs ingest, and
190
+ // Terraform answers "Backend initialization required" from somewhere the
191
+ // caller was told not to expect it.
192
+ const hasPluginDir = existsSync(join(dir, ".terraform"));
193
+ const initialised = existsSync(join(dir, ".terraform", "terraform.tfstate"));
187
194
  const hasTfvars = entries.some((entry) => entry.endsWith(".tfvars") || entry.endsWith(".tfvars.json"));
188
195
  const environmentHint = environmentFromPath(relative);
189
196
  const underStacksDir = /(^|[\\/])(environments|envs|stacks|live)([\\/]|$)/i.test(relative);
@@ -193,11 +200,13 @@ export function discoverRootModules(repoDir) {
193
200
  signals.push("provider configuration");
194
201
  if (initialised)
195
202
  signals.push("initialised");
203
+ else if (hasPluginDir)
204
+ signals.push("providers downloaded, backend not initialised");
196
205
  if (hasTfvars)
197
206
  signals.push("tfvars");
198
207
  if (underStacksDir)
199
208
  signals.push("stacks directory");
200
- if (hasBackend || hasProvider || initialised || (underStacksDir && hasTfvars) || (underStacksDir && environmentHint)) {
209
+ if (hasBackend || hasProvider || initialised || hasPluginDir || (underStacksDir && hasTfvars) || (underStacksDir && environmentHint)) {
201
210
  found.push({ dir, relative: relative || ".", signals, environmentHint, initialised, usesLocalBackend: hasLocalBackend });
202
211
  // A root module's subdirectories are its own modules, not further roots.
203
212
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexarch",
3
- "version": "0.12.12",
3
+ "version": "0.12.15",
4
4
  "description": "Your architecture workspace for AI delivery.",
5
5
  "keywords": [
6
6
  "nexarch",