@arcote.tech/arc-cli 0.7.5 → 0.7.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-cli",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "CLI tool for Arc framework",
5
5
  "module": "index.ts",
6
6
  "main": "dist/index.js",
@@ -12,13 +12,14 @@
12
12
  "build": "bun build --target=bun ./src/index.ts --outdir=dist --external @arcote.tech/arc --external @arcote.tech/arc-ds --external @arcote.tech/arc-react --external @arcote.tech/platform && chmod +x dist/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@arcote.tech/arc": "^0.7.5",
16
- "@arcote.tech/arc-ds": "^0.7.5",
17
- "@arcote.tech/arc-react": "^0.7.5",
18
- "@arcote.tech/arc-host": "^0.7.5",
19
- "@arcote.tech/arc-adapter-db-sqlite": "^0.7.5",
20
- "@arcote.tech/arc-adapter-db-postgres": "^0.7.5",
21
- "@arcote.tech/platform": "^0.7.5",
15
+ "@arcote.tech/arc": "^0.7.6",
16
+ "@arcote.tech/arc-ds": "^0.7.6",
17
+ "@arcote.tech/arc-react": "^0.7.6",
18
+ "@arcote.tech/arc-host": "^0.7.6",
19
+ "@arcote.tech/arc-adapter-db-sqlite": "^0.7.6",
20
+ "@arcote.tech/arc-adapter-db-postgres": "^0.7.6",
21
+ "@arcote.tech/arc-otel": "^0.7.6",
22
+ "@arcote.tech/platform": "^0.7.6",
22
23
  "@clack/prompts": "^0.9.0",
23
24
  "commander": "^11.1.0",
24
25
  "chokidar": "^3.5.3",
@@ -87,6 +87,16 @@ export async function platformDeploy(
87
87
  );
88
88
  }
89
89
 
90
+ // Observability stack — one Grafana password per deployment (stack-wide,
91
+ // not per-env). Persisted in `deploy.arc.env` (globals) so subsequent
92
+ // deploys reuse the same login.
93
+ if (cfg.observability?.enabled) {
94
+ const key = cfg.observability.adminPasswordEnv ?? "ARC_OBSERVABILITY_PASSWORD";
95
+ ensurePersistedSecret(ws.rootDir, "globals", key, () =>
96
+ crypto.randomUUID().replace(/-/g, ""),
97
+ );
98
+ }
99
+
90
100
  // Filter envs if an arg was provided
91
101
  const targetEnvs = envArg
92
102
  ? envArg in cfg.envs
@@ -7,6 +7,7 @@ import { generateCaddyfile } from "./caddyfile";
7
7
  import { generateCompose } from "./compose";
8
8
  import type { DeployConfig } from "./config";
9
9
  import { generateHtpasswd } from "./htpasswd";
10
+ import { generateObservabilityConfigs } from "./observability-configs";
10
11
  import { runTerraform } from "./terraform";
11
12
  import { saveDeployConfig } from "./config";
12
13
  import { ok, log, err } from "../platform/shared";
@@ -225,6 +226,30 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
225
226
  writeFileSync(join(workDir, "Caddyfile"), generateCaddyfile(cfg));
226
227
  writeFileSync(join(workDir, "docker-compose.yml"), generateCompose({ cfg }));
227
228
 
229
+ // Observability config files + Caddy basic-auth htpasswd for Grafana.
230
+ // Bundled together so a deploy without `observability.enabled` skips it
231
+ // all (no orphan files left on the remote).
232
+ let observabilityFiles: Record<string, string> | null = null;
233
+ let observabilityHtpasswd: string | null = null;
234
+ if (cfg.observability?.enabled) {
235
+ observabilityFiles = generateObservabilityConfigs(cfg);
236
+ const adminPasswordEnv =
237
+ cfg.observability.adminPasswordEnv ?? "ARC_OBSERVABILITY_PASSWORD";
238
+ const adminPassword = process.env[adminPasswordEnv];
239
+ if (!adminPassword) {
240
+ throw new Error(
241
+ `bootstrap: ${adminPasswordEnv} not set — observability needs a Grafana admin password.`,
242
+ );
243
+ }
244
+ // Caddy basic_auth import file uses `username bcrypt_hash` syntax.
245
+ observabilityHtpasswd = await generateHtpasswd("admin", adminPassword);
246
+ mkdirSync(join(workDir, "observability"), { recursive: true });
247
+ for (const [relPath, contents] of Object.entries(observabilityFiles)) {
248
+ writeFileSync(join(workDir, relPath), contents);
249
+ }
250
+ writeFileSync(join(workDir, "observability-htpasswd"), observabilityHtpasswd);
251
+ }
252
+
228
253
  // Ensure remoteDir exists
229
254
  await assertExec(
230
255
  cfg.target,
@@ -257,6 +282,30 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
257
282
  `${cfg.target.remoteDir}/registry-auth/htpasswd`,
258
283
  );
259
284
 
285
+ // Upload observability assets when enabled. Caddyfile imports
286
+ // `observability-htpasswd` from /etc/caddy (volume-mounted), so it has
287
+ // to land in the same dir as Caddyfile on the host. The collector /
288
+ // tempo / loki / prometheus / grafana configs go into a sibling
289
+ // `observability/` directory referenced by compose bind-mounts.
290
+ if (observabilityFiles && observabilityHtpasswd) {
291
+ await assertExec(
292
+ cfg.target,
293
+ `mkdir -p ${cfg.target.remoteDir}/observability`,
294
+ );
295
+ for (const relPath of Object.keys(observabilityFiles)) {
296
+ await scpUpload(
297
+ cfg.target,
298
+ join(workDir, relPath),
299
+ `${cfg.target.remoteDir}/${relPath}`,
300
+ );
301
+ }
302
+ await scpUpload(
303
+ cfg.target,
304
+ join(workDir, "observability-htpasswd"),
305
+ `${cfg.target.remoteDir}/observability-htpasswd`,
306
+ );
307
+ }
308
+
260
309
  // Ensure /opt/arc/.env exists so docker compose doesn't error on var
261
310
  // substitution. Per-env ARC_IMAGE_<ENV> lines are written by deploy.
262
311
  await assertExec(
@@ -264,6 +313,21 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
264
313
  `touch ${cfg.target.remoteDir}/.env`,
265
314
  );
266
315
 
316
+ // Propagate the Grafana admin password to /opt/arc/.env when observability
317
+ // is enabled. Compose interpolates `${ARC_OBSERVABILITY_PASSWORD:?}` into
318
+ // the grafana service's GF_SECURITY_ADMIN_PASSWORD env.
319
+ if (cfg.observability?.enabled) {
320
+ const key =
321
+ cfg.observability.adminPasswordEnv ?? "ARC_OBSERVABILITY_PASSWORD";
322
+ const value = process.env[key];
323
+ if (!value) {
324
+ throw new Error(
325
+ `bootstrap: ${key} not set — observability needs a Grafana admin password.`,
326
+ );
327
+ }
328
+ await writeEnvLine(cfg.target, `${cfg.target.remoteDir}/.env`, key, value);
329
+ }
330
+
267
331
  // Propagate postgres sidecar passwords to /opt/arc/.env so compose can
268
332
  // interpolate `${ARC_PG_PASSWORD_<UPPER>}` for both the pg container and
269
333
  // arc-<env>'s DATABASE_URL. Local source is `deploy.arc.<env>.env` /
@@ -30,14 +30,48 @@ export function generateCaddyfile(cfg: DeployConfig): string {
30
30
  lines.push("}");
31
31
  lines.push("");
32
32
 
33
- // Public blocks — one per env
33
+ // Public blocks — one per env. When observability is on, add a `/otel/*`
34
+ // path that forwards browser-side OTLP/HTTP to the collector. Strips the
35
+ // /otel prefix so the collector sees the same /v1/{traces,logs,metrics}
36
+ // paths it would receive from same-network senders.
34
37
  for (const [name, env] of Object.entries(cfg.envs)) {
35
38
  lines.push(`${env.domain} {${tlsDirective}`);
36
- lines.push(` reverse_proxy arc-${name}:5005`);
39
+ if (cfg.observability?.enabled) {
40
+ lines.push(" handle_path /otel/* {");
41
+ lines.push(" reverse_proxy otel-collector:4318");
42
+ lines.push(" }");
43
+ lines.push(" handle {");
44
+ lines.push(` reverse_proxy arc-${name}:5005`);
45
+ lines.push(" }");
46
+ } else {
47
+ lines.push(` reverse_proxy arc-${name}:5005`);
48
+ }
37
49
  lines.push("}");
38
50
  lines.push("");
39
51
  }
40
52
 
53
+ // Observability subdomain — Grafana UI behind Caddy basic-auth. Reuses
54
+ // the apex of the first env's domain (e.g. observability.app.example.com
55
+ // when the primary env is app.example.com). Caddy issues a separate
56
+ // ACME certificate for this hostname.
57
+ if (cfg.observability?.enabled) {
58
+ const firstEnv = Object.values(cfg.envs)[0];
59
+ if (firstEnv) {
60
+ const subdomain = cfg.observability.subdomain ?? "observability";
61
+ const apex = apexOf(firstEnv.domain);
62
+ const observabilityDomain = `${subdomain}.${apex}`;
63
+ lines.push(`${observabilityDomain} {${tlsDirective}`);
64
+ // Basic-auth credentials live in the same htpasswd file used for the
65
+ // registry — bootstrap appends an "admin" line with bcrypted password.
66
+ lines.push(" basic_auth {");
67
+ lines.push(" import /etc/caddy/observability-htpasswd");
68
+ lines.push(" }");
69
+ lines.push(" reverse_proxy grafana:3000");
70
+ lines.push("}");
71
+ lines.push("");
72
+ }
73
+ }
74
+
41
75
  // Private Docker Registry — Caddy proxies HTTPS termination + Let's Encrypt;
42
76
  // the registry:2 container handles its own htpasswd basic auth upstream.
43
77
  // 5 GiB request body cap fits real app images comfortably (default 100MB
@@ -53,3 +87,12 @@ export function generateCaddyfile(cfg: DeployConfig): string {
53
87
 
54
88
  return lines.join("\n") + "\n";
55
89
  }
90
+
91
+ /** Apex of a (possibly-subdomain) host. `app.example.com` → `example.com`,
92
+ * `example.com` → `example.com`, `example.co.uk` → `co.uk` (approximate —
93
+ * good enough for the observability subdomain). */
94
+ function apexOf(host: string): string {
95
+ const parts = host.split(".");
96
+ if (parts.length <= 2) return host;
97
+ return parts.slice(-2).join(".");
98
+ }
@@ -34,6 +34,14 @@ export function generateCompose({ cfg }: ComposeOptions): string {
34
34
  lines.push(' - "443:443"');
35
35
  lines.push(" volumes:");
36
36
  lines.push(" - ./Caddyfile:/etc/caddy/Caddyfile:ro");
37
+ if (cfg.observability?.enabled) {
38
+ // Bind-mounted in even when the file is absent (Caddy reads it lazily
39
+ // on observability vhost requests). Always-defined avoids compose
40
+ // bouncing the container when observability is later disabled.
41
+ lines.push(
42
+ " - ./observability-htpasswd:/etc/caddy/observability-htpasswd:ro",
43
+ );
44
+ }
37
45
  lines.push(" - caddy_data:/data");
38
46
  lines.push(" - caddy_config:/config");
39
47
  lines.push(" networks:");
@@ -99,8 +107,26 @@ export function generateCompose({ cfg }: ComposeOptions): string {
99
107
  ` DATABASE_URL: "postgresql://arc:\${ARC_PG_PASSWORD_${upperName}:?missing ARC_PG_PASSWORD_${upperName}}@arc-db-${name}:5432/arc"`,
100
108
  );
101
109
  }
102
- // PORT + DATABASE_URL are reserved — user envVars can't override them.
103
- const reserved = new Set(["PORT", "DATABASE_URL"]);
110
+ if (cfg.observability?.enabled) {
111
+ // OTel SDK reads OTEL_EXPORTER_OTLP_ENDPOINT and stripts /v1/<signal>
112
+ // onto it. service.name + deployment.environment land as resource
113
+ // attributes, which Grafana uses for service-level filtering.
114
+ lines.push(" ARC_OTEL_ENABLED: \"true\"");
115
+ lines.push(" OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318");
116
+ lines.push(` OTEL_SERVICE_NAME: arc-${name}`);
117
+ lines.push(
118
+ ` OTEL_RESOURCE_ATTRIBUTES: "service.namespace=arc,deployment.environment=${name}"`,
119
+ );
120
+ }
121
+ // PORT + DATABASE_URL + OTEL_* are reserved — user envVars can't override them.
122
+ const reserved = new Set([
123
+ "PORT",
124
+ "DATABASE_URL",
125
+ "ARC_OTEL_ENABLED",
126
+ "OTEL_EXPORTER_OTLP_ENDPOINT",
127
+ "OTEL_SERVICE_NAME",
128
+ "OTEL_RESOURCE_ATTRIBUTES",
129
+ ]);
104
130
  for (const [k, v] of Object.entries(userEnv)) {
105
131
  if (reserved.has(k)) continue;
106
132
  lines.push(` ${k}: ${JSON.stringify(v)}`);
@@ -143,6 +169,107 @@ export function generateCompose({ cfg }: ComposeOptions): string {
143
169
  lines.push("");
144
170
  }
145
171
 
172
+ // Observability sidecars — only when `observability.enabled`. Five
173
+ // services come up together: otel-collector receives OTLP from app
174
+ // containers (and from the browser via the public /otel route), fans out
175
+ // to Tempo/Loki/Prometheus; Grafana ties them into a single UI fronted
176
+ // by Caddy basic-auth on a separate subdomain.
177
+ if (cfg.observability?.enabled) {
178
+ lines.push(" otel-collector:");
179
+ lines.push(" image: otel/opentelemetry-collector-contrib:0.114.0");
180
+ lines.push(" container_name: arc-otel-collector");
181
+ lines.push(" restart: unless-stopped");
182
+ lines.push(" command: [\"--config=/etc/otelcol-contrib/config.yaml\"]");
183
+ lines.push(" volumes:");
184
+ lines.push(
185
+ " - ./observability/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro",
186
+ );
187
+ lines.push(" networks: [arc-net]");
188
+ lines.push(" expose:");
189
+ lines.push(" - \"4317\" # OTLP gRPC");
190
+ lines.push(" - \"4318\" # OTLP HTTP");
191
+ lines.push(" - \"8888\" # collector self-metrics (Prom scrape)");
192
+ lines.push(" depends_on:");
193
+ lines.push(" - tempo");
194
+ lines.push(" - loki");
195
+ lines.push(" - prometheus");
196
+ lines.push("");
197
+
198
+ lines.push(" tempo:");
199
+ lines.push(" image: grafana/tempo:2.6.1");
200
+ lines.push(" container_name: arc-tempo");
201
+ lines.push(" restart: unless-stopped");
202
+ lines.push(" command: [\"-config.file=/etc/tempo.yaml\"]");
203
+ lines.push(" user: \"0\" # tempo writes to /var/tempo, owned by root in the image");
204
+ lines.push(" volumes:");
205
+ lines.push(" - ./observability/tempo.yaml:/etc/tempo.yaml:ro");
206
+ lines.push(" - tempo_data:/var/tempo");
207
+ lines.push(" networks: [arc-net]");
208
+ lines.push(" expose:");
209
+ lines.push(" - \"3200\" # HTTP API for Grafana");
210
+ lines.push(" - \"4317\" # OTLP from collector");
211
+ lines.push("");
212
+
213
+ lines.push(" loki:");
214
+ lines.push(" image: grafana/loki:3.3.2");
215
+ lines.push(" container_name: arc-loki");
216
+ lines.push(" restart: unless-stopped");
217
+ lines.push(" command: [\"-config.file=/etc/loki/local-config.yaml\"]");
218
+ lines.push(" user: \"0\"");
219
+ lines.push(" volumes:");
220
+ lines.push(" - ./observability/loki-config.yaml:/etc/loki/local-config.yaml:ro");
221
+ lines.push(" - loki_data:/loki");
222
+ lines.push(" networks: [arc-net]");
223
+ lines.push(" expose:");
224
+ lines.push(" - \"3100\" # HTTP API");
225
+ lines.push("");
226
+
227
+ lines.push(" prometheus:");
228
+ lines.push(" image: prom/prometheus:v2.55.1");
229
+ lines.push(" container_name: arc-prometheus");
230
+ lines.push(" restart: unless-stopped");
231
+ lines.push(" command:");
232
+ lines.push(" - \"--config.file=/etc/prometheus/prometheus.yml\"");
233
+ lines.push(" - \"--storage.tsdb.path=/prometheus\"");
234
+ lines.push(" - \"--web.enable-remote-write-receiver\"");
235
+ lines.push(" - \"--enable-feature=exemplar-storage\"");
236
+ lines.push(" volumes:");
237
+ lines.push(" - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro");
238
+ lines.push(" - prometheus_data:/prometheus");
239
+ lines.push(" networks: [arc-net]");
240
+ lines.push(" expose:");
241
+ lines.push(" - \"9090\" # HTTP API + remote_write receiver");
242
+ lines.push("");
243
+
244
+ const adminPasswordEnv = cfg.observability.adminPasswordEnv ?? "ARC_OBSERVABILITY_PASSWORD";
245
+ lines.push(" grafana:");
246
+ lines.push(" image: grafana/grafana:11.4.0");
247
+ lines.push(" container_name: arc-grafana");
248
+ lines.push(" restart: unless-stopped");
249
+ lines.push(" environment:");
250
+ lines.push(" GF_SECURITY_ADMIN_USER: admin");
251
+ lines.push(
252
+ ` GF_SECURITY_ADMIN_PASSWORD: \${${adminPasswordEnv}:?missing ${adminPasswordEnv}}`,
253
+ );
254
+ lines.push(" GF_USERS_ALLOW_SIGN_UP: \"false\"");
255
+ lines.push(" GF_AUTH_ANONYMOUS_ENABLED: \"false\"");
256
+ // Subpath-less — the upstream URL is served from root via Caddy reverse
257
+ // proxy on a dedicated subdomain.
258
+ lines.push(" volumes:");
259
+ lines.push(
260
+ " - ./observability/grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml:ro",
261
+ );
262
+ lines.push(" - grafana_data:/var/lib/grafana");
263
+ lines.push(" networks: [arc-net]");
264
+ lines.push(" expose:");
265
+ lines.push(" - \"3000\" # Grafana UI (behind Caddy basic-auth)");
266
+ lines.push(" depends_on:");
267
+ lines.push(" - tempo");
268
+ lines.push(" - loki");
269
+ lines.push(" - prometheus");
270
+ lines.push("");
271
+ }
272
+
146
273
  lines.push("networks:");
147
274
  lines.push(" arc-net:");
148
275
  lines.push("");
@@ -157,6 +284,12 @@ export function generateCompose({ cfg }: ComposeOptions): string {
157
284
  lines.push(` arc-data-${name}:`);
158
285
  }
159
286
  }
287
+ if (cfg.observability?.enabled) {
288
+ lines.push(" tempo_data:");
289
+ lines.push(" loki_data:");
290
+ lines.push(" prometheus_data:");
291
+ lines.push(" grafana_data:");
292
+ }
160
293
 
161
294
  return lines.join("\n") + "\n";
162
295
  }
@@ -68,6 +68,35 @@ export interface DeployProvision {
68
68
  ansible?: DeployProvisionAnsible;
69
69
  }
70
70
 
71
+ /**
72
+ * Optional observability stack (otel-collector + Tempo + Loki + Prometheus
73
+ * + Grafana). Stack-wide — applies to every env on the same VPS, accessed
74
+ * through a single Grafana UI at `<subdomain>.<apex-of-app-domain>`.
75
+ *
76
+ * When `enabled: true`, every arc-<env> container gets:
77
+ * - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
78
+ * - OTEL_SERVICE_NAME=arc-<env>
79
+ * - ARC_OTEL_ENABLED=true
80
+ *
81
+ * Sidecar containers come up on the same `arc-net`. Grafana password is
82
+ * stored in `deploy.arc.env` under `adminPasswordEnv`; first deploy auto-
83
+ * generates one if missing.
84
+ */
85
+ export interface DeployObservability {
86
+ enabled: boolean;
87
+ /** Subdomain under the apex of the first env's domain. Default "observability". */
88
+ subdomain?: string;
89
+ /** Env var name (in `deploy.arc.env`) that holds the Grafana admin password.
90
+ * Default "ARC_OBSERVABILITY_PASSWORD". CLI auto-generates one when absent. */
91
+ adminPasswordEnv?: string;
92
+ /** Optional retention overrides. Defaults: traces 7d, logs 7d, metrics 30d. */
93
+ retention?: {
94
+ traces?: string;
95
+ logs?: string;
96
+ metrics?: string;
97
+ };
98
+ }
99
+
71
100
  export interface DeployRegistry {
72
101
  /** Full domain — Caddy reverse-proxies this to the registry:5000 container. */
73
102
  domain: string;
@@ -83,6 +112,7 @@ export interface DeployConfig {
83
112
  caddy: DeployCaddy;
84
113
  registry: DeployRegistry;
85
114
  provision?: DeployProvision;
115
+ observability?: DeployObservability;
86
116
  }
87
117
 
88
118
  export const DEPLOY_CONFIG_FILE = "deploy.arc.json";
@@ -281,6 +311,31 @@ export function validateDeployConfig(input: unknown): DeployConfig {
281
311
  validated.envs[name] = { domain, envVars, db };
282
312
  }
283
313
 
314
+ const observabilityRaw = (input as Record<string, unknown>).observability;
315
+ if (observabilityRaw !== undefined) {
316
+ if (!isObject(observabilityRaw)) throw cfgErr("observability", "object");
317
+ const enabledRaw = observabilityRaw.enabled;
318
+ if (typeof enabledRaw !== "boolean") throw cfgErr("observability.enabled", "boolean");
319
+ const retentionRaw = observabilityRaw.retention;
320
+ let retention: DeployObservability["retention"];
321
+ if (retentionRaw !== undefined) {
322
+ if (!isObject(retentionRaw)) throw cfgErr("observability.retention", "object");
323
+ retention = {
324
+ traces: optionalString(retentionRaw, "observability.retention.traces"),
325
+ logs: optionalString(retentionRaw, "observability.retention.logs"),
326
+ metrics: optionalString(retentionRaw, "observability.retention.metrics"),
327
+ };
328
+ }
329
+ validated.observability = {
330
+ enabled: enabledRaw,
331
+ subdomain: optionalString(observabilityRaw, "observability.subdomain") ?? "observability",
332
+ adminPasswordEnv:
333
+ optionalString(observabilityRaw, "observability.adminPasswordEnv") ??
334
+ "ARC_OBSERVABILITY_PASSWORD",
335
+ retention,
336
+ };
337
+ }
338
+
284
339
  const provision = (input as Record<string, unknown>).provision;
285
340
  if (provision !== undefined) {
286
341
  if (!isObject(provision)) throw cfgErr("provision", "object");
@@ -58,24 +58,30 @@ export function applyDeployGlobals(globals: Record<string, string>): void {
58
58
  }
59
59
 
60
60
  /**
61
- * Ensure a value exists for `key` in `deploy.arc.<envName>.env`. If absent,
62
- * generate one via `generate()`, append it to the file (creating the file
63
- * if needed), and return it. Pre-existing values (from the file or
61
+ * Ensure a value exists for `key` in a `deploy.arc.*.env` file. If absent,
62
+ * generate one via `generate()`, append it to the file (creating it if
63
+ * needed), and return the value. Pre-existing values (from the file or
64
64
  * process.env) are returned unchanged.
65
65
  *
66
- * Used to bootstrap secrets the framework owns end-to-end (e.g. the
67
- * Postgres sidecar password) without forcing the user to hand-edit the
68
- * file before the first deploy.
66
+ * `scope`:
67
+ * - `"globals"` writes to `deploy.arc.env` (cross-env secrets like the
68
+ * observability Grafana password one stack-wide value).
69
+ * - An env name writes to `deploy.arc.<envName>.env` (per-env secrets
70
+ * like the Postgres sidecar password — one per deployment env).
71
+ *
72
+ * Used to bootstrap secrets the framework owns end-to-end without forcing
73
+ * the user to hand-edit the file before the first deploy.
69
74
  */
70
75
  export function ensurePersistedSecret(
71
76
  rootDir: string,
72
- envName: string,
77
+ scope: "globals" | string,
73
78
  key: string,
74
79
  generate: () => string,
75
80
  ): string {
76
81
  if (process.env[key]) return process.env[key]!;
77
82
 
78
- const path = join(rootDir, `deploy.arc.${envName}.env`);
83
+ const fileName = scope === "globals" ? "deploy.arc.env" : `deploy.arc.${scope}.env`;
84
+ const path = join(rootDir, fileName);
79
85
  if (existsSync(path)) {
80
86
  const existing = parseEnvFile(readFileSync(path, "utf-8"), path);
81
87
  if (existing[key]) {