nexarch 0.12.5 → 0.12.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.
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ import { appliedPolicies } from "./commands/applied-policies.js";
26
26
  import { governanceSummary } from "./commands/governance-summary.js";
27
27
  import { proposalsStart } from "./commands/proposals-start.js";
28
28
  import { registerRuntime } from "./commands/register-runtime.js";
29
+ import { ingestInfra } from "./commands/ingest-infra.js";
29
30
  const [, , command, ...args] = process.argv;
30
31
  const commands = {
31
32
  login,
@@ -37,6 +38,7 @@ const commands = {
37
38
  "init-agent": initAgent,
38
39
  "agent-identify": agentIdentify,
39
40
  "init-project": initProject,
41
+ "ingest-infra": ingestInfra,
40
42
  "update-project": (args) => initProject(["--refresh", ...args]),
41
43
  "update-entity": updateEntity,
42
44
  "add-relationship": addRelationship,
@@ -102,15 +104,22 @@ Usage:
102
104
  config files against the reference library, write entities and
103
105
  relationships to the architecture graph, and log unresolved
104
106
  names as reference candidates.
105
- When entity-type=application and existing applications are present,
106
- prompts to map to an existing app or create a new one.
107
+ Monorepos register a project entity plus one proposed
108
+ application per deployable package (sourced_from the project).
109
+ Single-package repos register the repo's one application; when
110
+ similar applications exist they are listed, and the new
111
+ application is created as proposed for review at activation.
112
+ Terraform repositories take a different path: no dependency
113
+ scan and no applications. They register a project plus the
114
+ environments their root modules define, and hand over the
115
+ per-root ingest-infra commands that read the actual estate.
107
116
  Options: --dir <path> (default: cwd)
108
117
  --name <name> override project name
109
118
  --entity-type <code> (default: application)
110
119
  --application-ref <entityRef> force mapping target
111
120
  --create-application force new application entity
112
121
  --auto-map-application auto-map only when high confidence
113
- --non-interactive fail on ambiguous mapping
122
+ --non-interactive deprecated (mapping no longer prompts)
114
123
  --batch-size <n> upsert batch size (default: 10)
115
124
  --profile include timing/profile data in JSON output
116
125
  --dry-run preview without writing
@@ -4,7 +4,35 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from "fs";
4
4
  function credentialsPath() {
5
5
  return join(homedir(), ".nexarch", "credentials.json");
6
6
  }
7
+ /**
8
+ * Credentials supplied by the environment, for CI.
9
+ *
10
+ * A pipeline cannot run `nexarch login` — that flow needs a browser and a
11
+ * human — so unattended commands such as `ingest-infra` take a service
12
+ * credential from the environment instead. `NEXARCH_TOKEN` is the token issued
13
+ * by `init-agent`; the company is carried by the token itself, so
14
+ * `NEXARCH_COMPANY_ID` is only needed for legacy static tokens.
15
+ *
16
+ * No local expiry check: the gateway is the authority on whether a service
17
+ * credential is still valid, and a stale clock in a runner should not be able
18
+ * to fail a build with a misleading "session expired".
19
+ */
20
+ function credentialsFromEnvironment() {
21
+ const token = process.env.NEXARCH_TOKEN?.trim();
22
+ if (!token)
23
+ return null;
24
+ return {
25
+ token,
26
+ email: process.env.NEXARCH_ACTOR ?? "ci",
27
+ company: "",
28
+ companyId: process.env.NEXARCH_COMPANY_ID?.trim() ?? "",
29
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
30
+ };
31
+ }
7
32
  export function loadCredentials() {
33
+ const fromEnv = credentialsFromEnvironment();
34
+ if (fromEnv)
35
+ return fromEnv;
8
36
  const path = credentialsPath();
9
37
  if (!existsSync(path))
10
38
  return null;
@@ -35,8 +63,12 @@ export function removeCredentials() {
35
63
  export function requireCredentials() {
36
64
  const creds = loadCredentials();
37
65
  if (!creds) {
38
- throw new Error("Not logged in. Run `nexarch login` to authenticate.");
66
+ throw new Error("Not logged in. Run `nexarch login` to authenticate, or set NEXARCH_TOKEN for unattended use.");
39
67
  }
68
+ // Environment-supplied service credentials are validated by the gateway, not
69
+ // by this process; only the stored interactive session carries a local expiry.
70
+ if (process.env.NEXARCH_TOKEN?.trim())
71
+ return creds;
40
72
  if (new Date(creds.expiresAt) < new Date()) {
41
73
  throw new Error("Your session has expired. Run `nexarch login` to re-authenticate.");
42
74
  }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Seed catalogue for Terraform resource types.
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.
9
+ *
10
+ * Two rules when adding an entry:
11
+ *
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.
18
+ */
19
+ const COMMON = ["name", "location", "resource_group_name"];
20
+ export const TERRAFORM_CATALOGUE_SEED = [
21
+ // ---------------------------------------------------------------- compute
22
+ {
23
+ resourceType: "azurerm_container_app",
24
+ entityTypeCode: "platform_component",
25
+ entitySubtypeCode: "platform_runtime",
26
+ role: "compute_host",
27
+ bindable: true,
28
+ attributes: [...COMMON, "container_app_environment_id", "revision_mode", "workload_profile_name"],
29
+ },
30
+ {
31
+ resourceType: "azurerm_container_app_job",
32
+ entityTypeCode: "platform_component",
33
+ entitySubtypeCode: "platform_runtime",
34
+ role: "compute_host",
35
+ bindable: true,
36
+ attributes: [...COMMON, "container_app_environment_id", "replica_timeout_in_seconds", "workload_profile_name"],
37
+ },
38
+ {
39
+ resourceType: "azurerm_container_app_environment",
40
+ entityTypeCode: "platform_component",
41
+ 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"],
44
+ },
45
+ {
46
+ resourceType: "azurerm_linux_web_app",
47
+ entityTypeCode: "platform_component",
48
+ entitySubtypeCode: "platform_runtime",
49
+ role: "compute_host",
50
+ bindable: true,
51
+ attributes: [...COMMON, "service_plan_id", "https_only", "default_hostname"],
52
+ },
53
+ {
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"],
59
+ },
60
+ // ------------------------------------------------------------------- data
61
+ {
62
+ resourceType: "azurerm_postgresql_flexible_server",
63
+ entityTypeCode: "data_store",
64
+ entitySubtypeCode: "store_relational",
65
+ 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"],
67
+ },
68
+ {
69
+ resourceType: "azurerm_postgresql_flexible_server_database",
70
+ entityTypeCode: "data_store",
71
+ entitySubtypeCode: "store_relational",
72
+ role: "data_store",
73
+ attributes: ["name", "server_id", "charset", "collation"],
74
+ },
75
+ {
76
+ resourceType: "azurerm_storage_account",
77
+ entityTypeCode: "data_store",
78
+ entitySubtypeCode: "store_object",
79
+ 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"],
81
+ },
82
+ {
83
+ resourceType: "azurerm_storage_container",
84
+ entityTypeCode: "data_store",
85
+ entitySubtypeCode: "store_object",
86
+ role: "data_store",
87
+ attributes: ["name", "container_access_type", "storage_account_id", "has_immutability_policy"],
88
+ },
89
+ {
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"],
95
+ },
96
+ // ---------------------------------------------------------------- secrets
97
+ {
98
+ resourceType: "azurerm_key_vault",
99
+ entityTypeCode: "platform_component",
100
+ 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"],
103
+ },
104
+ // -------------------------------------------------------------- messaging
105
+ {
106
+ resourceType: "azurerm_servicebus_namespace",
107
+ entityTypeCode: "platform_component",
108
+ entitySubtypeCode: "platform_service",
109
+ role: "messaging",
110
+ attributes: [...COMMON, "sku", "public_network_access_enabled"],
111
+ },
112
+ {
113
+ resourceType: "azurerm_servicebus_queue",
114
+ entityTypeCode: "platform_component",
115
+ entitySubtypeCode: "platform_service",
116
+ role: "messaging",
117
+ attributes: ["name", "namespace_id", "max_delivery_count", "requires_session"],
118
+ },
119
+ // ---------------------------------------------------------------- network
120
+ {
121
+ resourceType: "azurerm_virtual_network",
122
+ entityTypeCode: "platform_component",
123
+ entitySubtypeCode: "platform_service",
124
+ role: "network_boundary",
125
+ attributes: [...COMMON, "address_space", "guid"],
126
+ },
127
+ {
128
+ resourceType: "azurerm_subnet",
129
+ entityTypeCode: "platform_component",
130
+ entitySubtypeCode: "platform_service",
131
+ role: "network_boundary",
132
+ attributes: ["name", "resource_group_name", "virtual_network_name", "address_prefixes", "default_outbound_access_enabled", "private_endpoint_network_policies"],
133
+ },
134
+ {
135
+ resourceType: "azurerm_private_endpoint",
136
+ entityTypeCode: "platform_component",
137
+ entitySubtypeCode: "platform_service",
138
+ role: "network_boundary",
139
+ attributes: [...COMMON, "subnet_id"],
140
+ },
141
+ {
142
+ resourceType: "azurerm_private_dns_zone",
143
+ entityTypeCode: "platform_component",
144
+ entitySubtypeCode: "platform_service",
145
+ role: "network_boundary",
146
+ attributes: ["name", "resource_group_name", "number_of_record_sets"],
147
+ },
148
+ // ---------------------------------------------------------------- identity
149
+ {
150
+ resourceType: "azurerm_user_assigned_identity",
151
+ 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"],
161
+ },
162
+ // ---------------------------------------------------------------- registry
163
+ {
164
+ resourceType: "azurerm_container_registry",
165
+ entityTypeCode: "platform_component",
166
+ entitySubtypeCode: "platform_service",
167
+ role: "registry",
168
+ attributes: [...COMMON, "login_server", "sku", "admin_enabled", "public_network_access_enabled"],
169
+ },
170
+ // ----------------------------------------------------------------- edge/AI
171
+ {
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",
201
+ entityTypeCode: "platform_component",
202
+ entitySubtypeCode: "platform_service",
203
+ role: "observability",
204
+ attributes: [...COMMON, "sku", "retention_in_days", "workspace_id"],
205
+ },
206
+ ];
@@ -0,0 +1,338 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
2
+ import { join, sep } from "path";
3
+ const SCAN_DEPTH = 8;
4
+ const IGNORED_DIRS = new Set(["node_modules", ".git", ".terraform", "dist", "build", ".next"]);
5
+ function collectTerraformFiles(dir, depth = 0, out = []) {
6
+ if (depth > SCAN_DEPTH)
7
+ return out;
8
+ let entries;
9
+ try {
10
+ entries = readdirSync(dir);
11
+ }
12
+ catch {
13
+ return out;
14
+ }
15
+ for (const entry of entries) {
16
+ if (IGNORED_DIRS.has(entry))
17
+ continue;
18
+ const full = join(dir, entry);
19
+ let isDir = false;
20
+ try {
21
+ isDir = statSync(full).isDirectory();
22
+ }
23
+ catch {
24
+ continue;
25
+ }
26
+ if (isDir)
27
+ collectTerraformFiles(full, depth + 1, out);
28
+ else if (entry.endsWith(".tf"))
29
+ out.push(full);
30
+ }
31
+ return out;
32
+ }
33
+ function detectCiSystem(dir) {
34
+ if (existsSync(join(dir, ".gitlab-ci.yml")))
35
+ return { ciSystem: "gitlab", ciFile: ".gitlab-ci.yml" };
36
+ if (existsSync(join(dir, ".github", "workflows")))
37
+ return { ciSystem: "github", ciFile: ".github/workflows" };
38
+ const seen = [];
39
+ const visit = (current, depth) => {
40
+ if (depth > 3 || seen.length > 0)
41
+ return;
42
+ let entries;
43
+ try {
44
+ entries = readdirSync(current);
45
+ }
46
+ catch {
47
+ return;
48
+ }
49
+ for (const entry of entries) {
50
+ if (IGNORED_DIRS.has(entry))
51
+ continue;
52
+ const full = join(current, entry);
53
+ let isDir = false;
54
+ try {
55
+ isDir = statSync(full).isDirectory();
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ if (isDir) {
61
+ visit(full, depth + 1);
62
+ continue;
63
+ }
64
+ const relative = full.slice(dir.length + 1).split(sep).join("/");
65
+ if (/azure-pipelines.*\.ya?ml$/i.test(entry)) {
66
+ seen.push({ ciSystem: "azure-pipelines", ciFile: relative });
67
+ return;
68
+ }
69
+ if (entry === ".gitlab-ci.yml") {
70
+ seen.push({ ciSystem: "gitlab", ciFile: relative });
71
+ return;
72
+ }
73
+ }
74
+ };
75
+ visit(dir, 0);
76
+ return seen[0] ?? { ciSystem: null, ciFile: null };
77
+ }
78
+ /** Collects reusable module directories wherever they live, not only at the root. */
79
+ function collectModuleDirectories(dir, depth = 0, out = []) {
80
+ if (depth > 3)
81
+ return out;
82
+ let entries;
83
+ try {
84
+ entries = readdirSync(dir);
85
+ }
86
+ catch {
87
+ return out;
88
+ }
89
+ for (const entry of entries) {
90
+ if (IGNORED_DIRS.has(entry))
91
+ continue;
92
+ const full = join(dir, entry);
93
+ try {
94
+ if (!statSync(full).isDirectory())
95
+ continue;
96
+ }
97
+ catch {
98
+ continue;
99
+ }
100
+ if (entry === "modules") {
101
+ // The children of a modules directory are the reusable building blocks:
102
+ // network, database, keyvault, and so on — the vocabulary of the estate.
103
+ try {
104
+ for (const child of readdirSync(full)) {
105
+ if (statSync(join(full, child)).isDirectory())
106
+ out.push(child);
107
+ }
108
+ }
109
+ catch {
110
+ // Unreadable modules directory is not fatal.
111
+ }
112
+ continue;
113
+ }
114
+ collectModuleDirectories(full, depth + 1, out);
115
+ }
116
+ return out;
117
+ }
118
+ const ROOT_SCAN_DEPTH = 4;
119
+ const ENVIRONMENT_DIR = /^(dev|development|test|qa|uat|sit|stag|staging|preprod|pre-prod|prod|production|live|dr)$/i;
120
+ /**
121
+ * Infers an environment from a root module's path.
122
+ *
123
+ * A directory called `environments/prod` is a stronger statement about which
124
+ * environment is being ingested than anything inside the state, and it is
125
+ * available before Terraform is run at all.
126
+ */
127
+ export function environmentFromPath(relativePath) {
128
+ const segments = relativePath.split(/[\\/]+/).filter(Boolean);
129
+ for (let i = segments.length - 1; i >= 0; i -= 1) {
130
+ if (ENVIRONMENT_DIR.test(segments[i]))
131
+ return segments[i].toLowerCase();
132
+ }
133
+ return null;
134
+ }
135
+ /**
136
+ * Finds the root modules in a repository.
137
+ *
138
+ * Real estates are not one directory with one state. A repository commonly
139
+ * carries a root module per environment plus separate stacks for shared
140
+ * services, each with its own state, so assuming the current directory is
141
+ * *the* root module fails on the first serious layout it meets.
142
+ *
143
+ * Distinguishing a root from a shared module is the crux: both contain `.tf`
144
+ * files and both may declare `required_providers`. A root is what configures a
145
+ * backend or a provider, is initialised, carries tfvars, or lives under a
146
+ * conventional stacks directory — and never lives under `modules/`.
147
+ */
148
+ export function discoverRootModules(repoDir) {
149
+ const found = [];
150
+ const visit = (dir, relative, depth) => {
151
+ if (depth > ROOT_SCAN_DEPTH)
152
+ return;
153
+ let entries;
154
+ try {
155
+ entries = readdirSync(dir);
156
+ }
157
+ catch {
158
+ return;
159
+ }
160
+ const tfFiles = entries.filter((entry) => entry.endsWith(".tf"));
161
+ const isUnderModules = /(^|[\\/])modules([\\/]|$)/i.test(relative);
162
+ if (tfFiles.length > 0 && !isUnderModules) {
163
+ const signals = [];
164
+ let hasBackend = false;
165
+ let hasLocalBackend = false;
166
+ let hasProvider = false;
167
+ for (const file of tfFiles.slice(0, 25)) {
168
+ let content = "";
169
+ try {
170
+ content = readFileSync(join(dir, file), "utf8");
171
+ }
172
+ catch {
173
+ continue;
174
+ }
175
+ const backendMatch = content.match(/\bbackend\s+"([a-z0-9_]+)"\s*\{/);
176
+ if (backendMatch) {
177
+ hasBackend = true;
178
+ if (backendMatch[1] === "local")
179
+ hasLocalBackend = true;
180
+ }
181
+ // A provider *configuration*, not a required_providers declaration —
182
+ // shared modules declare requirements but must not configure providers.
183
+ if (/^\s*provider\s+"[a-z0-9_]+"\s*\{/m.test(content))
184
+ hasProvider = true;
185
+ }
186
+ const initialised = existsSync(join(dir, ".terraform"));
187
+ const hasTfvars = entries.some((entry) => entry.endsWith(".tfvars") || entry.endsWith(".tfvars.json"));
188
+ const environmentHint = environmentFromPath(relative);
189
+ const underStacksDir = /(^|[\\/])(environments|envs|stacks|live)([\\/]|$)/i.test(relative);
190
+ if (hasBackend)
191
+ signals.push(hasLocalBackend ? "local backend" : "remote backend");
192
+ if (hasProvider)
193
+ signals.push("provider configuration");
194
+ if (initialised)
195
+ signals.push("initialised");
196
+ if (hasTfvars)
197
+ signals.push("tfvars");
198
+ if (underStacksDir)
199
+ signals.push("stacks directory");
200
+ if (hasBackend || hasProvider || initialised || (underStacksDir && hasTfvars) || (underStacksDir && environmentHint)) {
201
+ found.push({ dir, relative: relative || ".", signals, environmentHint, initialised, usesLocalBackend: hasLocalBackend });
202
+ // A root module's subdirectories are its own modules, not further roots.
203
+ return;
204
+ }
205
+ }
206
+ for (const entry of entries) {
207
+ if (IGNORED_DIRS.has(entry))
208
+ continue;
209
+ const full = join(dir, entry);
210
+ try {
211
+ if (!statSync(full).isDirectory())
212
+ continue;
213
+ }
214
+ catch {
215
+ continue;
216
+ }
217
+ visit(full, relative ? `${relative}/${entry}` : entry, depth + 1);
218
+ }
219
+ };
220
+ visit(repoDir, "", 0);
221
+ return found.sort((a, b) => a.relative.localeCompare(b.relative));
222
+ }
223
+ export function detectInfrastructureProject(dir) {
224
+ const files = collectTerraformFiles(dir);
225
+ const signals = [];
226
+ if (files.length > 0)
227
+ signals.push(`${files.length} Terraform file${files.length === 1 ? "" : "s"}`);
228
+ if (existsSync(join(dir, ".terraform.lock.hcl")))
229
+ signals.push("provider lock file");
230
+ if (existsSync(join(dir, ".terraform")))
231
+ signals.push("initialised working directory");
232
+ let hasRemoteBackend = false;
233
+ let hasProviderBlock = false;
234
+ // The cap guards against a pathological repo, not against a large one: now
235
+ // that the scan reaches the whole tree, a low cap would read only whichever
236
+ // subtree the walk happened to enter first and miss the backend entirely.
237
+ for (const file of files.slice(0, 400)) {
238
+ let content = "";
239
+ try {
240
+ content = readFileSync(file, "utf8");
241
+ }
242
+ catch {
243
+ continue;
244
+ }
245
+ // `backend "local"` marks a root module but is emphatically not remote
246
+ // state — a bootstrap stack uses it precisely because it creates the remote
247
+ // backend everything else then uses.
248
+ const backendMatch = content.match(/\bbackend\s+"([a-z0-9_]+)"\s*\{/);
249
+ if (backendMatch && backendMatch[1] !== "local")
250
+ hasRemoteBackend = true;
251
+ if (/\bprovider\s+"[a-z0-9_]+"\s*\{/.test(content))
252
+ hasProviderBlock = true;
253
+ }
254
+ if (hasRemoteBackend)
255
+ signals.push("remote state backend");
256
+ if (hasProviderBlock)
257
+ signals.push("provider configuration");
258
+ const moduleDirectories = [...new Set(collectModuleDirectories(dir))].sort();
259
+ if (moduleDirectories.length > 0)
260
+ signals.push(`${moduleDirectories.length} reusable modules`);
261
+ // One stray .tf file inside an application repo is not an infrastructure
262
+ // repo. Requiring corroboration keeps the classification from hijacking
263
+ // onboarding for a project that merely ships a snippet of Terraform.
264
+ // Deep layouts keep every .tf file below the two-level scan above, so
265
+ // discovery is consulted as well: a repository whose roots live in
266
+ // infra/environments/<env> is unmistakably an infrastructure repository even
267
+ // though its top two levels contain no Terraform at all.
268
+ const rootModules = discoverRootModules(dir);
269
+ if (rootModules.length > 0) {
270
+ signals.push(`${rootModules.length} root module${rootModules.length === 1 ? "" : "s"}`);
271
+ }
272
+ const isInfrastructure = rootModules.length > 0 || (files.length > 0 && (files.length >= 3 || hasRemoteBackend || hasProviderBlock || moduleDirectories.length > 0));
273
+ return {
274
+ isInfrastructure,
275
+ rootModules,
276
+ signals,
277
+ terraformFileCount: files.length,
278
+ hasRemoteBackend,
279
+ hasProviderBlock,
280
+ moduleDirectories,
281
+ ...detectCiSystem(dir),
282
+ };
283
+ }
284
+ /**
285
+ * The pipeline step to add, for whichever CI system the repo already uses.
286
+ * Onboarding hands this over rather than pointing at documentation: an
287
+ * ingestion command nobody wires up is a graph that lies within a week.
288
+ */
289
+ export function ciSnippetFor(ciSystem, ciFile = null) {
290
+ if (ciSystem === "github") {
291
+ return {
292
+ file: ".github/workflows/terraform.yml (in the apply job)",
293
+ snippet: [
294
+ " - name: Sync architecture graph",
295
+ " run: |",
296
+ " terraform show -json > tfstate.json",
297
+ " npx nexarch@latest ingest-infra --state tfstate.json",
298
+ " env:",
299
+ " NEXARCH_TOKEN: ${{ secrets.NEXARCH_TOKEN }}",
300
+ ].join("\n"),
301
+ };
302
+ }
303
+ if (ciSystem === "azure-pipelines") {
304
+ return {
305
+ file: `${ciFile ?? "azure-pipelines.yml"} (after the apply step)`,
306
+ snippet: [
307
+ " - script: |",
308
+ " terraform show -json > tfstate.json",
309
+ " npx nexarch@latest ingest-infra --state tfstate.json",
310
+ " displayName: Sync architecture graph",
311
+ " env:",
312
+ " NEXARCH_TOKEN: $(NEXARCH_TOKEN)",
313
+ ].join("\n"),
314
+ };
315
+ }
316
+ if (ciSystem === "gitlab") {
317
+ return {
318
+ file: ciFile ?? ".gitlab-ci.yml",
319
+ snippet: [
320
+ "nexarch-ingest:",
321
+ " stage: .post",
322
+ " script:",
323
+ " - terraform show -json > tfstate.json",
324
+ " - npx nexarch@latest ingest-infra --state tfstate.json",
325
+ " variables:",
326
+ " NEXARCH_TOKEN: $NEXARCH_TOKEN",
327
+ ].join("\n"),
328
+ };
329
+ }
330
+ // Nothing was detected. Handing over a GitLab job here — the previous
331
+ // behaviour — states as fact something never observed, and a team on another
332
+ // CI system has to work out why the advice does not fit. The two commands are
333
+ // the part that is actually known; the wrapper is not.
334
+ return {
335
+ file: "your Terraform pipeline, after the apply step",
336
+ snippet: ["terraform show -json > tfstate.json", "npx nexarch@latest ingest-infra --state tfstate.json"].join("\n"),
337
+ };
338
+ }