@arcote.tech/arc-cli 0.7.19 → 0.7.21

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.19",
3
+ "version": "0.7.21",
4
4
  "description": "CLI tool for Arc framework",
5
5
  "module": "index.ts",
6
6
  "main": "dist/index.js",
@@ -12,13 +12,13 @@
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 --external '@opentelemetry/*' && chmod +x dist/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@arcote.tech/arc": "^0.7.19",
16
- "@arcote.tech/arc-ds": "^0.7.19",
17
- "@arcote.tech/arc-react": "^0.7.19",
18
- "@arcote.tech/arc-host": "^0.7.19",
19
- "@arcote.tech/arc-adapter-db-sqlite": "^0.7.19",
20
- "@arcote.tech/arc-adapter-db-postgres": "^0.7.19",
21
- "@arcote.tech/arc-otel": "^0.7.19",
15
+ "@arcote.tech/arc": "^0.7.21",
16
+ "@arcote.tech/arc-ds": "^0.7.21",
17
+ "@arcote.tech/arc-react": "^0.7.21",
18
+ "@arcote.tech/arc-host": "^0.7.21",
19
+ "@arcote.tech/arc-adapter-db-sqlite": "^0.7.21",
20
+ "@arcote.tech/arc-adapter-db-postgres": "^0.7.21",
21
+ "@arcote.tech/arc-otel": "^0.7.21",
22
22
  "@opentelemetry/api": "^1.9.0",
23
23
  "@opentelemetry/api-logs": "^0.57.0",
24
24
  "@opentelemetry/core": "^1.30.0",
@@ -31,7 +31,7 @@
31
31
  "@opentelemetry/sdk-trace-base": "^1.30.0",
32
32
  "@opentelemetry/sdk-trace-node": "^1.30.0",
33
33
  "@opentelemetry/semantic-conventions": "^1.27.0",
34
- "@arcote.tech/platform": "^0.7.19",
34
+ "@arcote.tech/platform": "^0.7.21",
35
35
  "@clack/prompts": "^0.9.0",
36
36
  "commander": "^11.1.0",
37
37
  "chokidar": "^3.5.3",
@@ -7,7 +7,6 @@ import {
7
7
  writeFileSync,
8
8
  } from "fs";
9
9
  import { join } from "path";
10
- import { isContextPackage, type WorkspacePackage } from "./module-builder";
11
10
 
12
11
  // ---------------------------------------------------------------------------
13
12
  // access-extractor — discovers per-module access rules (`protectedBy(...)`)
@@ -27,8 +26,8 @@ import { isContextPackage, type WorkspacePackage } from "./module-builder";
27
26
  // non-context (pure-browser) packages are skipped. This is sensible: access
28
27
  // checks logically belong with server state, not display components.
29
28
  //
30
- // Order requirement: buildContextPackages MUST run before extractAccessMap
31
- // server bundles at `packages/<pkg>/dist/server/main/index.js` must exist.
29
+ // Order requirement: buildServerApp MUST run before extractAccessMap — the
30
+ // combined server bundle at `<arcDir>/server/_server.js` must exist.
32
31
  // ---------------------------------------------------------------------------
33
32
 
34
33
  export interface SerializedAccessRule {
@@ -44,19 +43,16 @@ export type SerializedAccessMap = Record<string, SerializedModuleAccess>;
44
43
 
45
44
  export async function extractAccessMap(
46
45
  rootDir: string,
47
- packages: readonly WorkspacePackage[],
46
+ serverBundlePath: string,
48
47
  ): Promise<SerializedAccessMap> {
49
- // Each entry: a context-package server bundle path that the worker will
50
- // dynamically import. Bun resolves the bundle's internal external imports
51
- // (`@arcote.tech/platform` etc.) via node_modules walking from the bundle
52
- // location workspace symlinks point back to source.
53
- const serverBundles = packages
54
- .filter((p) => isContextPackage(p.packageJson))
55
- .map((p) => ({
56
- name: p.name,
57
- path: join(p.path, "dist", "server", "main", "index.js"),
58
- }))
59
- .filter((b) => existsSync(b.path));
48
+ // The combined server bundle (`<arcDir>/server/_server.js`) side-effect-
49
+ // registers every module via the platform singleton when imported. The
50
+ // worker imports just this entry; Bun resolves its `chunk-<hash>.js` siblings
51
+ // and external peers (`@arcote.tech/platform` etc.) by walking node_modules
52
+ // from the bundle's directory.
53
+ const serverBundles = existsSync(serverBundlePath)
54
+ ? [{ name: "server", path: serverBundlePath }]
55
+ : [];
60
56
 
61
57
  // Worker must live INSIDE the workspace tree so Bun's module resolver can
62
58
  // walk up to <rootDir>/node_modules and find @arcote.tech/platform via the
@@ -18,7 +18,7 @@ import {
18
18
  type BuildCache,
19
19
  } from "./build-cache";
20
20
  import type { ChunkPlan } from "./chunk-planner";
21
- import { SHELL_EXTERNALS } from "./framework-peers";
21
+ import { SHELL_EXTERNALS, FRAMEWORK_PEERS } from "./framework-peers";
22
22
  import {
23
23
  readInstalledVersion,
24
24
  sha256Hex,
@@ -125,6 +125,43 @@ function serverExternalsPlugin(): import("bun").BunPlugin {
125
125
  };
126
126
  }
127
127
 
128
+ /**
129
+ * SERVER bundles inline their workspace (`@ndt/*`) deps so each flattened
130
+ * `.arc/platform/server/<pkg>.js` is self-contained (the deploy image has no
131
+ * `node_modules/@ndt/*` tree). Bun would resolve a bare `@ndt/x` import through
132
+ * package.json `exports` to that dep's PRE-BUILT `dist/server/main/index.js` —
133
+ * an already-fully-inlined bundle. Nesting one fully-inlined bundle inside
134
+ * every consumer is catastrophic:
135
+ * - diamonds duplicate the shared dep per graph edge (2^depth copies),
136
+ * - dist/ is never wiped, so stale bombs from a previous dep graph keep
137
+ * getting re-inlined across rebuilds.
138
+ * Result: bundles ballooned into the hundreds-of-MB / GB range (content =
139
+ * 1.1 GB, sizes lining up as powers of two by graph depth).
140
+ *
141
+ * This plugin pins every workspace package name to its SOURCE entrypoint
142
+ * (`src/index.ts`). The single Bun.build pass then includes each transitive
143
+ * module exactly once (Bun dedups by resolved path), so a server bundle is the
144
+ * size of its UNIQUE source closure — no nesting, no stale re-inlining.
145
+ *
146
+ * `sideEffects: true` keeps the dep's top-level `module(...).build()`
147
+ * registration from being tree-shaken when it's imported only for a named
148
+ * symbol (workspace packages ship `"sideEffects": false`).
149
+ */
150
+ function workspaceSourcePlugin(srcByName: Map<string, string>): import("bun").BunPlugin {
151
+ return {
152
+ name: "workspace-source",
153
+ setup(build) {
154
+ // Bare specifiers only (skip relative `./` and absolute `/`). Non-
155
+ // workspace bare imports (react, @arcote.tech/*, npm deps) miss the map
156
+ // and fall through to the `external` list unchanged.
157
+ build.onResolve({ filter: /^[^./]/ }, (args) => {
158
+ const src = srcByName.get(args.path);
159
+ return src ? { path: src, sideEffects: true } : null;
160
+ });
161
+ },
162
+ };
163
+ }
164
+
128
165
  function jsxDevShimPlugin(): import("bun").BunPlugin {
129
166
  return {
130
167
  name: "jsx-dev-runtime-shim",
@@ -145,12 +182,25 @@ export { Fragment };
145
182
  };
146
183
  }
147
184
 
148
- /** Clients that a context package is built for. */
185
+ // Per-package context build is BROWSER-only. The server side is produced by a
186
+ // single combined `buildServerApp` pass (see below) — bundling each package's
187
+ // server context separately and inlining its deps' pre-built dist caused
188
+ // catastrophic nested duplication (content.js hit 1.1 GB) and turned the
189
+ // consumer's undeclared cyclic `@ndt/*` imports into broken init order. One
190
+ // shared pass dedups every module once and is cycle-safe (same as the browser
191
+ // app build).
149
192
  const CONTEXT_CLIENTS = [
150
- { name: "server", target: "bun" as const, defines: { ONLY_SERVER: "true", ONLY_BROWSER: "false", ONLY_CLIENT: "false" } },
151
193
  { name: "browser", target: "browser" as const, defines: { ONLY_SERVER: "false", ONLY_BROWSER: "true", ONLY_CLIENT: "true" } },
152
194
  ];
153
195
 
196
+ /** Server-side build defines — applied by the combined `buildServerApp` pass. */
197
+ const SERVER_DEFINES = { ONLY_SERVER: "true", ONLY_BROWSER: "false", ONLY_CLIENT: "false" } as const;
198
+
199
+ /** Filename of the combined server bundle entry under `<arcDir>/server/`.
200
+ * loadServerContext + the access extractor import exactly this file; Bun's
201
+ * auto-emitted `chunk-<hash>.js` siblings are pulled in transitively. */
202
+ export const SERVER_ENTRY_FILE = "_server.js";
203
+
154
204
  export interface WorkspacePackage {
155
205
  name: string;
156
206
  path: string;
@@ -305,32 +355,16 @@ async function buildContextClient(
305
355
 
306
356
  console.log(` building: ${pkg.name} (${client.name})`);
307
357
 
308
- // Externals depend on the target client:
309
- //
310
- // - BROWSER client: workspace deps MUST be external. Inlining them per
311
- // package would make every context package's dist ship its own copy
312
- // of every workspace dep the top-level browser Bun.build would see
313
- // N pre-inlined copies of context singletons (`WorkspaceContext =
314
- // createContext`) and could not dedupe them, breaking provider lookup.
315
- //
316
- // - SERVER client: workspace deps MUST be bundled inline. The deploy
317
- // image flattens each package's server bundle to
318
- // `.arc/platform/server/<pkg>.js` and runs them via a single
319
- // `loadServerContext()` import loop. There is no `node_modules/@ndt/*`
320
- // tree inside the image, so bare `@ndt/workspace` specifiers would
321
- // fail to resolve at startup. Inline duplication is harmless on the
322
- // server: it's a single Node/Bun process and Arc modules register via
323
- // a shared platform registry singleton (registry.ts), so two physical
324
- // copies of the workspace module still merge into one context.
358
+ // BROWSER client: every dep (workspace + npm + framework peers) is external.
359
+ // Inlining workspace deps per package would make each context package's dist
360
+ // ship its own copy of every workspace dep the top-level browser Bun.build
361
+ // (`buildBrowserApp`) would then see N pre-inlined copies of context
362
+ // singletons (`WorkspaceContext = createContext`) and could not dedupe them,
363
+ // breaking provider lookup. The server side is handled separately by the
364
+ // combined `buildServerApp` pass.
325
365
  const peerDeps = Object.keys(pkg.packageJson.peerDependencies ?? {});
326
366
  const allDeps = (pkg.packageJson.dependencies ?? {}) as Record<string, string>;
327
- const isBrowser = client.name === "browser";
328
- const workspaceDeps = isBrowser
329
- ? Object.keys(allDeps)
330
- : Object.entries(allDeps)
331
- .filter(([, spec]) => !spec.startsWith("workspace:"))
332
- .map(([name]) => name);
333
- const externals = [...peerDeps, ...workspaceDeps];
367
+ const externals = [...peerDeps, ...Object.keys(allDeps)];
334
368
 
335
369
  const result = await Bun.build({
336
370
  entrypoints: [pkg.entrypoint],
@@ -339,9 +373,7 @@ async function buildContextClient(
339
373
  format: "esm",
340
374
  naming: "index.[ext]",
341
375
  external: externals,
342
- plugins: isBrowser
343
- ? [jsxDevShimPlugin()]
344
- : [jsxDevShimPlugin(), serverExternalsPlugin()],
376
+ plugins: [jsxDevShimPlugin()],
345
377
  define: client.defines,
346
378
  });
347
379
 
@@ -385,11 +417,13 @@ export async function buildContextPackages(
385
417
  const contexts = packages.filter((p) => isContextPackage(p.packageJson));
386
418
  if (contexts.length === 0) return { declarationErrors: [] };
387
419
 
388
- // Topological order each package's server bundle inlines its workspace
389
- // deps, so those deps must have their `dist/` ready before Bun.build tries
390
- // to resolve them. Without this, a fresh checkout (no dist/ yet) fails the
391
- // first build because content's resolve of @ndt/strategy hits a missing
392
- // file. Inside a topological level, packages are built in parallel.
420
+ // Topological order by declared `workspace:` deps. The browser BUNDLE
421
+ // externalizes every dep (order-independent), but the type-declaration pass
422
+ // (tsc) resolves `@ndt/x` types through that package's emitted
423
+ // `dist/browser/index.d.ts` so a dep's declarations must exist before a
424
+ // dependent's decl build runs, else tsc reports "Cannot find module @ndt/x".
425
+ // The declared dep graph is acyclic; undeclared cyclic imports live in source
426
+ // only and don't affect this ordering. Inside a layer, packages build in parallel.
393
427
  const byName = new Map(contexts.map((p) => [p.name, p]));
394
428
  const remaining = new Set(contexts.map((p) => p.name));
395
429
  const done = new Set<string>();
@@ -440,6 +474,147 @@ export async function buildContextPackages(
440
474
  return { declarationErrors };
441
475
  }
442
476
 
477
+ // ---------------------------------------------------------------------------
478
+ // Combined server bundle — ONE Bun.build for ALL context packages' server side.
479
+ //
480
+ // Replaces N per-package server builds that each inlined their deps' PRE-BUILT
481
+ // dist (`@ndt/x` → `x/dist/server/main/index.js`). That nested an already-
482
+ // fully-inlined bundle inside every consumer: shared deps duplicated per graph
483
+ // edge (content's server bundle reached 1.1 GB) and the consumer's undeclared
484
+ // cyclic `@ndt/*` imports turned into broken module-init order.
485
+ //
486
+ // One shared pass with `splitting: true` — the same recipe as buildBrowserApp:
487
+ // - every module is bundled from SOURCE exactly once (Bun dedups by path),
488
+ // - `@ndt/*` workspace deps resolve to their `src/` entry (workspaceSourcePlugin)
489
+ // so nothing pulls a pre-built dist,
490
+ // - npm deps + framework peers stay external — the deploy image installs them
491
+ // into /app/node_modules via `.arc/platform/package.json` (collectFrameworkDeps),
492
+ // - splitting makes Bun emit eager top-level init, which is cycle-safe; the
493
+ // old non-split per-package build emitted lazy `__esm` wrappers that broke
494
+ // under the consumer's import cycles (`workspace.ids` was undefined).
495
+ //
496
+ // Output under `<arcDir>/server/`:
497
+ // _server.js ← entry: side-effect-imports every context module
498
+ // chunk-<hash>.js × N ← auto-shared module chunks
499
+ // loadServerContext + the access extractor import `_server.js`; chunks ride along.
500
+ // ---------------------------------------------------------------------------
501
+
502
+ export interface ServerAppResult {
503
+ readonly entryFile: string;
504
+ readonly cached: boolean;
505
+ }
506
+
507
+ export async function buildServerApp(
508
+ rootDir: string,
509
+ serverDir: string,
510
+ packages: WorkspacePackage[],
511
+ cache: BuildCache,
512
+ noCache: boolean,
513
+ ): Promise<ServerAppResult> {
514
+ void rootDir; // resolution is source-based; rootDir kept for signature parity
515
+ const contexts = packages.filter((p) => isContextPackage(p.packageJson));
516
+ mkdirSync(serverDir, { recursive: true });
517
+
518
+ // Every workspace package name → its SOURCE entry. Spans ALL packages, not
519
+ // just context ones — a context package imports non-context workspace libs
520
+ // (e.g. content-core) that must inline from source too.
521
+ const srcByName = new Map(packages.map((p) => [p.name, p.entrypoint]));
522
+
523
+ // External = framework peers + every non-workspace npm dep any package
524
+ // declares. NOT bundled; resolved at runtime from /app/node_modules (the
525
+ // deploy image installs exactly this set — collectFrameworkDeps). Only the
526
+ // `@ndt/*` workspace source is inlined.
527
+ const externalSet = new Set<string>(FRAMEWORK_PEERS);
528
+ for (const p of packages) {
529
+ for (const name of Object.keys(p.packageJson.peerDependencies ?? {})) {
530
+ externalSet.add(name);
531
+ }
532
+ for (const [name, spec] of Object.entries(
533
+ (p.packageJson.dependencies ?? {}) as Record<string, string>,
534
+ )) {
535
+ if (!spec.startsWith("workspace:")) externalSet.add(name);
536
+ }
537
+ }
538
+ const external = [...externalSet];
539
+
540
+ const unitId = "server-app";
541
+ const inputHash = sha256OfJson({
542
+ members: packages
543
+ .map((p) => ({ name: p.name, src: pkgSourceHash(p) }))
544
+ .sort((a, b) => a.name.localeCompare(b.name)),
545
+ contexts: contexts.map((p) => p.name).sort(),
546
+ external: [...external].sort(),
547
+ defines: SERVER_DEFINES,
548
+ });
549
+
550
+ const entryFileAbs = join(serverDir, SERVER_ENTRY_FILE);
551
+ if (!noCache && isCacheHit(cache, unitId, inputHash, [entryFileAbs])) {
552
+ console.log(` ✓ cached: ${unitId}`);
553
+ return { entryFile: SERVER_ENTRY_FILE, cached: true };
554
+ }
555
+
556
+ console.log(` building: ${unitId} (${contexts.length} server modules)`);
557
+
558
+ // Wipe stale .js — old per-package flattened bundles AND previous chunks —
559
+ // so a smaller rebuild never leaves orphaned content-addressed chunks behind.
560
+ for (const f of readdirSync(serverDir)) {
561
+ if (f.endsWith(".js")) rmSync(join(serverDir, f), { force: true });
562
+ }
563
+
564
+ // Entry side-effect-imports every context module so each registers via the
565
+ // platform registry singleton. Written to a tmp subdir so it isn't shipped.
566
+ const tmpDir = join(serverDir, "_entries");
567
+ mkdirSync(tmpDir, { recursive: true });
568
+ const entrySrc = join(tmpDir, SERVER_ENTRY_FILE.replace(/\.js$/, ".ts"));
569
+ writeFileSync(
570
+ entrySrc,
571
+ contexts.map((p) => `import "${p.name}";`).join("\n") + "\n",
572
+ );
573
+
574
+ let result;
575
+ try {
576
+ result = await Bun.build({
577
+ entrypoints: [entrySrc],
578
+ outdir: serverDir,
579
+ target: "bun",
580
+ format: "esm",
581
+ // splitting:true is the whole point — it makes Bun emit eager top-level
582
+ // init (cycle-safe) instead of lazy `__esm` wrappers. Shared modules land
583
+ // in chunk-<hash>.js referenced by the entry.
584
+ splitting: true,
585
+ naming: "[name].[ext]",
586
+ external,
587
+ plugins: [
588
+ jsxDevShimPlugin(),
589
+ serverExternalsPlugin(),
590
+ workspaceSourcePlugin(srcByName),
591
+ ],
592
+ define: SERVER_DEFINES,
593
+ });
594
+ } finally {
595
+ rmSync(tmpDir, { recursive: true, force: true });
596
+ }
597
+
598
+ if (!result.success) {
599
+ for (const log of result.logs) console.error(log);
600
+ throw new Error("Server app build failed");
601
+ }
602
+
603
+ const entryOut = result.outputs.find((o) => o.kind === "entry-point");
604
+ if (!entryOut) {
605
+ throw new Error("Server app build: entry not found in outputs");
606
+ }
607
+ if (basename(entryOut.path) !== SERVER_ENTRY_FILE) {
608
+ throw new Error(
609
+ `Server app build: unexpected entry name ${basename(entryOut.path)} (wanted ${SERVER_ENTRY_FILE})`,
610
+ );
611
+ }
612
+
613
+ const outputHash = sha256OfDir(serverDir);
614
+ updateCache(cache, unitId, inputHash, { outputHash });
615
+ return { entryFile: SERVER_ENTRY_FILE, cached: false };
616
+ }
617
+
443
618
 
444
619
  // ---------------------------------------------------------------------------
445
620
  // Browser app build — one Bun.build with multiple entrypoints.
@@ -133,6 +133,9 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
133
133
  // - stack isn't fully ready, OR
134
134
  // - marker is missing (legacy v0.5 deploy with no .arc-state.json), OR
135
135
  // - configHash differs from last bootstrap (deploy.arc.json changed), OR
136
+ // - the CLI version changed (generators evolve — compose/Caddyfile/
137
+ // observability configs must be re-rendered + re-uploaded even when
138
+ // deploy.arc.json itself is unchanged), OR
136
139
  // - registry container isn't running (e.g. legacy stack predates v0.7)
137
140
  // Without this, an old v0.5 stack (no registry container) is classified as
138
141
  // "ready" and bootstrap is skipped — then `docker login` on the next step
@@ -141,6 +144,7 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
141
144
  state.kind !== "ready" ||
142
145
  state.marker === null ||
143
146
  state.marker.configHash !== inputs.configHash ||
147
+ state.marker.cliVersion !== inputs.cliVersion ||
144
148
  !(await isRegistryRunning(cfg));
145
149
 
146
150
  if (needUpStack) {
@@ -156,7 +160,7 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
156
160
  // without forcing a full bootstrap.
157
161
  if (cfg.observability?.enabled) {
158
162
  log("Ensuring observability sidecars are running...");
159
- const obsServices = ["otel-collector", "tempo", "loki", "prometheus", "grafana"];
163
+ const obsServices = ["otel-collector", "tempo", "loki", "prometheus", "alloy", "grafana"];
160
164
  await assertExec(
161
165
  cfg.target,
162
166
  `cd ${cfg.target.remoteDir} && docker compose pull --ignore-pull-failures ${obsServices.join(" ")} && docker compose up -d ${obsServices.join(" ")}`,
@@ -307,10 +311,11 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
307
311
  // `observability/` directory referenced by compose bind-mounts.
308
312
  if (observabilityFiles && observabilityHtpasswd) {
309
313
  // Make sure both the top-level `observability/` dir and the nested
310
- // `grafana-dashboards/` exist before scp tries to land files there.
314
+ // `grafana-dashboards/` + `grafana-alerting/` exist before scp tries
315
+ // to land files there.
311
316
  await assertExec(
312
317
  cfg.target,
313
- `mkdir -p ${cfg.target.remoteDir}/observability/grafana-dashboards`,
318
+ `mkdir -p ${cfg.target.remoteDir}/observability/grafana-dashboards ${cfg.target.remoteDir}/observability/grafana-alerting`,
314
319
  );
315
320
  // Make sure local nested dir exists too — `generateObservabilityConfigs`
316
321
  // returns relative paths like `observability/grafana-dashboards/x.json`
@@ -20,23 +20,48 @@ export function generateCaddyfile(cfg: DeployConfig): string {
20
20
  cfg.caddy.email === "internal" ? "" : `\n email ${cfg.caddy.email}`;
21
21
  const tlsDirective =
22
22
  cfg.caddy.email === "internal" ? "\n tls internal" : "";
23
+ const observability = cfg.observability?.enabled === true;
24
+
25
+ // Access logs land on stdout as JSON → Alloy tails the container → Loki
26
+ // (`{compose_service="caddy"}`). Off by default in Caddy, hence per-vhost.
27
+ const logDirective = observability
28
+ ? [" log {", " output stdout", " format json", " }"]
29
+ : [];
23
30
 
24
31
  const lines: string[] = [];
25
32
  lines.push("# Generated by `arc platform deploy` — do not edit by hand.");
26
33
  lines.push("");
27
34
  lines.push("{");
28
35
  lines.push(" admin off");
36
+ if (observability) {
37
+ // Per-request HTTP metrics (caddy_http_*). Top-level global option —
38
+ // requires Caddy >= 2.9 (the `servers > metrics` form is deprecated).
39
+ lines.push(" metrics {");
40
+ lines.push(" per_host");
41
+ lines.push(" }");
42
+ }
29
43
  if (email) lines.push(` ${email.trim()}`);
30
44
  lines.push("}");
31
45
  lines.push("");
32
46
 
47
+ // Exposition endpoint for Prometheus (scrapes caddy:2020 on arc-net).
48
+ // Plain HTTP, never exposed publicly; works with `admin off` (only the
49
+ // admin-API /metrics endpoint dies with the admin interface).
50
+ if (observability) {
51
+ lines.push(":2020 {");
52
+ lines.push(" metrics");
53
+ lines.push("}");
54
+ lines.push("");
55
+ }
56
+
33
57
  // Public blocks — one per env. When observability is on, add a `/otel/*`
34
58
  // path that forwards browser-side OTLP/HTTP to the collector. Strips the
35
59
  // /otel prefix so the collector sees the same /v1/{traces,logs,metrics}
36
60
  // paths it would receive from same-network senders.
37
61
  for (const [name, env] of Object.entries(cfg.envs)) {
38
62
  lines.push(`${env.domain} {${tlsDirective}`);
39
- if (cfg.observability?.enabled) {
63
+ lines.push(...logDirective);
64
+ if (observability) {
40
65
  lines.push(" handle_path /otel/* {");
41
66
  lines.push(" reverse_proxy otel-collector:4318");
42
67
  lines.push(" }");
@@ -54,13 +79,11 @@ export function generateCaddyfile(cfg: DeployConfig): string {
54
79
  // the apex of the first env's domain (e.g. observability.app.example.com
55
80
  // when the primary env is app.example.com). Caddy issues a separate
56
81
  // 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}`);
82
+ if (observability) {
83
+ const domain = observabilityDomain(cfg);
84
+ if (domain) {
85
+ lines.push(`${domain} {${tlsDirective}`);
86
+ lines.push(...logDirective);
64
87
  // Basic-auth credentials live in the same htpasswd file used for the
65
88
  // registry — bootstrap appends an "admin" line with bcrypted password.
66
89
  lines.push(" basic_auth {");
@@ -77,6 +100,7 @@ export function generateCaddyfile(cfg: DeployConfig): string {
77
100
  // 5 GiB request body cap fits real app images comfortably (default 100MB
78
101
  // triggers 413 on the first layer push).
79
102
  lines.push(`${cfg.registry.domain} {${tlsDirective}`);
103
+ lines.push(...logDirective);
80
104
  lines.push(" reverse_proxy registry:5000 {");
81
105
  lines.push(" header_up Host {host}");
82
106
  lines.push(" }");
@@ -88,6 +112,17 @@ export function generateCaddyfile(cfg: DeployConfig): string {
88
112
  return lines.join("\n") + "\n";
89
113
  }
90
114
 
115
+ /** Public hostname of the Grafana UI (`<subdomain>.<apex-of-first-env>`), or
116
+ * null when observability is off / no envs exist. Shared by the Caddyfile
117
+ * vhost and Grafana's GF_SERVER_ROOT_URL (compose.ts). */
118
+ export function observabilityDomain(cfg: DeployConfig): string | null {
119
+ if (!cfg.observability?.enabled) return null;
120
+ const firstEnv = Object.values(cfg.envs)[0];
121
+ if (!firstEnv) return null;
122
+ const subdomain = cfg.observability.subdomain ?? "observability";
123
+ return `${subdomain}.${apexOf(firstEnv.domain)}`;
124
+ }
125
+
91
126
  /** Apex of a (possibly-subdomain) host. `app.example.com` → `example.com`,
92
127
  * `example.com` → `example.com`, `example.co.uk` → `co.uk` (approximate —
93
128
  * good enough for the observability subdomain). */