@arcote.tech/arc-cli 0.7.20 → 0.7.22

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.
@@ -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.
@@ -13,6 +13,7 @@ import { ensurePersistedSecret } from "../deploy/env-file";
13
13
  import { buildImage, sanitizeImageName } from "../deploy/image";
14
14
  import { detectRemoteState } from "../deploy/remote-state";
15
15
  import { dockerLogin, dockerPush } from "../deploy/registry";
16
+ import { closeSshMaster } from "../deploy/ssh";
16
17
  import { runSurvey } from "../deploy/survey";
17
18
  import {
18
19
  buildAll,
@@ -155,45 +156,51 @@ export async function platformDeploy(
155
156
  // before dockerLogin/dockerPush — without registry container + Caddy vhost
156
157
  // for it, dockerLogin would TLS-fail.
157
158
  log("Inspecting remote server...");
158
- const state = await detectRemoteState(cfg);
159
- log(`Remote state: ${state.kind}`);
160
-
161
- const cliVersion = readCliVersion();
162
- const configHash = await hashDeployConfig(ws.rootDir);
163
- await bootstrap({
164
- cfg,
165
- rootDir: ws.rootDir,
166
- state,
167
- cliVersion,
168
- configHash,
169
- forceAnsible: options.forceBootstrap,
170
- });
171
-
172
- // 5. Push the image to the now-running registry.
173
- if (!options.imageTag) {
174
- log(`Logging in to ${cfg.registry.domain}...`);
175
- await dockerLogin(cfg.registry);
176
- log(`Pushing ${fullRef}...`);
177
- await dockerPush(fullRef);
178
- ok("Image pushed");
179
- }
159
+ try {
160
+ const state = await detectRemoteState(cfg);
161
+ log(`Remote state: ${state.kind}`);
180
162
 
181
- // 6. Update each env — atomic /opt/arc/.env line + pull + up + health
182
- for (const env of targetEnvs) {
183
- log(`Updating env "${env}"...`);
184
- const outcome = await updateEnvDeployment({
185
- target: cfg.target,
163
+ const cliVersion = readCliVersion();
164
+ const configHash = await hashDeployConfig(ws.rootDir);
165
+ await bootstrap({
186
166
  cfg,
187
- env,
188
- fullRef,
167
+ rootDir: ws.rootDir,
168
+ state,
169
+ cliVersion,
170
+ configHash,
171
+ forceAnsible: options.forceBootstrap,
189
172
  });
190
- if (outcome.redeployed) {
191
- ok(`${env}: live at ${fullRef}`);
192
- } else {
193
- err(
194
- `${env}: deployed but health check did not pass within retries — check \`docker logs arc-${env}\``,
195
- );
173
+
174
+ // 5. Push the image to the now-running registry.
175
+ if (!options.imageTag) {
176
+ log(`Logging in to ${cfg.registry.domain}...`);
177
+ await dockerLogin(cfg.registry);
178
+ log(`Pushing ${fullRef}...`);
179
+ await dockerPush(fullRef);
180
+ ok("Image pushed");
181
+ }
182
+
183
+ // 6. Update each env — atomic /opt/arc/.env line + pull + up + health
184
+ for (const env of targetEnvs) {
185
+ log(`Updating env "${env}"...`);
186
+ const outcome = await updateEnvDeployment({
187
+ target: cfg.target,
188
+ cfg,
189
+ env,
190
+ fullRef,
191
+ });
192
+ if (outcome.redeployed) {
193
+ ok(`${env}: live at ${fullRef}`);
194
+ } else {
195
+ err(
196
+ `${env}: deployed but health check did not pass within retries — check \`docker logs arc-${env}\``,
197
+ );
198
+ }
196
199
  }
200
+ } finally {
201
+ // Tear down the multiplexed SSH master so the control socket doesn't
202
+ // linger for ControlPersist seconds after the process exits.
203
+ await closeSshMaster(cfg.target);
197
204
  }
198
205
  }
199
206
 
@@ -48,58 +48,35 @@ export async function updateEnvDeployment(
48
48
  const { target, cfg, env, fullRef } = opts;
49
49
  const upperEnv = env.toUpperCase().replace(/-/g, "_");
50
50
  const envVarName = `ARC_IMAGE_${upperEnv}`;
51
-
52
- // Step 1 — atomic .env line update. Use awk to either replace the existing
53
- // line or append a new one, write to .env.tmp, then mv. mv on the same fs
54
- // is atomic, so concurrent reads see either the old or new file, never a
55
- // partial write.
56
51
  const envPath = `${cfg.target.remoteDir}/.env`;
57
52
  const escapedRef = fullRef.replace(/"/g, '\\"');
58
- const updateScript = [
59
- `touch ${envPath} && `,
60
- `awk -v line="${envVarName}=${escapedRef}" -v key="${envVarName}=" '`,
61
- ` BEGIN { replaced=0 } `,
62
- ` $0 ~ "^"key { print line; replaced=1; next } `,
63
- ` { print } `,
64
- ` END { if (!replaced) print line } `,
65
- `' ${envPath} > ${envPath}.tmp && mv ${envPath}.tmp ${envPath}`,
66
- ].join("");
67
- await assertExec(target, updateScript);
68
-
69
- // Step 2 — pull. May be a no-op if `fullRef` was already pulled previously.
70
- await assertExec(
71
- target,
72
- `cd ${cfg.target.remoteDir} && docker compose pull arc-${env}`,
73
- );
74
-
75
- // Step 3 — recreate. `up -d` is a no-op if the container is already running
76
- // with the requested image; otherwise it recreates. Either way the container
77
- // ends in "running" state with the desired image.
78
- await assertExec(
79
- target,
80
- `cd ${cfg.target.remoteDir} && docker compose up -d arc-${env}`,
81
- );
82
-
83
- // Step 4 — retention. Find image tags for this workspace, sort by created
84
- // time (newest first), drop the top N, delete the rest. `:latest` is moved
85
- // by docker push and stays — we never explicitly delete it.
86
53
  const retain = opts.retainImages ?? 3;
87
54
  const imageBaseName = imageBaseFromRef(fullRef);
88
- if (imageBaseName) {
89
- const pruneScript = [
90
- `docker images "${imageBaseName}" --format "{{.Repository}}:{{.Tag}} {{.CreatedAt}}" `,
91
- `| grep -v ":latest " `,
92
- `| sort -k2,3 -r `,
93
- `| tail -n +${retain + 1} `,
94
- `| awk '{print $1}' `,
95
- `| xargs -r docker rmi 2>/dev/null || true`,
96
- ].join("");
97
- await sshExec(target, pruneScript, { quiet: true });
98
- }
55
+
56
+ // Steps 1-4 batched into ONE SSH round-trip (was four, each a fresh ssh
57
+ // handshake). `set -e` aborts on a real failure (e.g. pull) before recreate.
58
+ // 1. atomic .env line update — awk rewrites to .tmp then `mv` (same-fs mv is
59
+ // atomic, so concurrent reads never see a partial file).
60
+ // 2. pull new layers, 3. recreate (`up -d` no-ops if image unchanged).
61
+ // 4. retention prune best-effort (`|| true`), never fails the deploy.
62
+ const pruneCmd = imageBaseName
63
+ ? `docker images "${imageBaseName}" --format "{{.Repository}}:{{.Tag}} {{.CreatedAt}}" | grep -v ":latest " | sort -k2,3 -r | tail -n +${retain + 1} | awk '{print $1}' | xargs -r docker rmi 2>/dev/null || true`
64
+ : `true`;
65
+ const script = [
66
+ `set -e`,
67
+ `cd ${cfg.target.remoteDir}`,
68
+ `touch ${envPath}`,
69
+ `awk -v line="${envVarName}=${escapedRef}" -v key="${envVarName}=" 'BEGIN{replaced=0} $0 ~ "^"key {print line; replaced=1; next} {print} END{if(!replaced) print line}' ${envPath} > ${envPath}.tmp && mv ${envPath}.tmp ${envPath}`,
70
+ `docker compose pull arc-${env}`,
71
+ `docker compose up -d arc-${env}`,
72
+ pruneCmd,
73
+ ].join("\n");
74
+ await assertExec(target, script);
99
75
 
100
76
  // Step 5 — health check. arc-<env> exposes 5005 inside the docker network;
101
- // from the host we can reach it via `docker exec caddy curl -fsS ...`
102
- // (no port mapped to host). Cheap, requires no public DNS.
77
+ // we reach it via `docker exec arc-<env> ...`. Polls separately (must retry
78
+ // until the container is up); on a no-op the already-running container passes
79
+ // on the first probe, so this stays a single round-trip.
103
80
  const ok = await healthCheck(target, env);
104
81
 
105
82
  return { env, fullRef, redeployed: ok };
@@ -87,8 +87,13 @@ ${envNames.map((name) => ` - "https://${cfg.envs[name]!.domain}"`).jo
87
87
 
88
88
  # Per-container CPU / memory / network / block-IO + restarts straight from
89
89
  # the Docker daemon (socket bind-mounted read-only, see compose).
90
+ # api_version pinned: the receiver defaults to Docker API 1.25, which modern
91
+ # daemons (Engine 25+ require >= 1.40) reject — without this the receiver
92
+ # fails to start and takes the whole collector down. Quoted so YAML doesn't
93
+ # parse 1.40 → 1.4. Must be <= the daemon's max; 1.40 is the safe floor.
90
94
  docker_stats:
91
95
  endpoint: unix:///var/run/docker.sock
96
+ api_version: "1.40"
92
97
  collection_interval: 30s
93
98
  metrics:
94
99
  container.restarts:
@@ -372,6 +377,15 @@ export function generateAlloyConfig(): string {
372
377
  discovery.docker "containers" {
373
378
  host = "unix:///var/run/docker.sock"
374
379
  refresh_interval = "15s"
380
+
381
+ // Only containers managed by a compose project (our stack). Ad-hoc / rogue
382
+ // containers (manual debug runs, other stacks) are excluded — one bad
383
+ // stream (e.g. log entries older than Loki's reject window) otherwise 400s
384
+ // the whole loki.write batch and drops good app logs with it.
385
+ filter {
386
+ name = "label"
387
+ values = ["com.docker.compose.project"]
388
+ }
375
389
  }
376
390
 
377
391
  discovery.relabel "containers" {
@@ -36,8 +36,22 @@ export async function detectRemoteState(
36
36
  return { kind: "unreachable", reason: "target.host not yet set" };
37
37
  }
38
38
 
39
- if (!(await canSsh(cfg.target))) {
40
- // On a freshly provisioned VM, only root exists ansible creates `deploy`
39
+ const composeDir = cfg.target.remoteDir;
40
+ // ONE round-trip instead of four (canSsh + docker check + compose ps + cat
41
+ // marker). The three checks run in a single delimited script; each swallows
42
+ // its own errors (`|| true`), so the script always exits 0 when it RUNS —
43
+ // a non-zero SSH exit therefore means the CONNECTION failed, not the probe.
44
+ const probe = [
45
+ `command -v docker >/dev/null 2>&1 && echo DOCKER=1 || echo DOCKER=0`,
46
+ `echo '---PS---'`,
47
+ `[ -f ${composeDir}/docker-compose.yml ] && (cd ${composeDir} && docker compose ps --format '{{.Service}}' 2>/dev/null) || true`,
48
+ `echo '---MARKER---'`,
49
+ `cat ${STATE_MARKER_PATH} 2>/dev/null || true`,
50
+ ].join("\n");
51
+
52
+ const res = await sshExec(cfg.target, probe, { quiet: true });
53
+ if (res.exitCode !== 0) {
54
+ // On a freshly provisioned VM only root exists — ansible creates `deploy`
41
55
  // later. If root SSH works but the configured user doesn't, treat as
42
56
  // no-docker so bootstrap re-runs ansible (idempotent) instead of spinning
43
57
  // up a duplicate server via terraform.
@@ -47,36 +61,26 @@ export async function detectRemoteState(
47
61
  return { kind: "unreachable", reason: "ssh connection failed" };
48
62
  }
49
63
 
50
- const dockerCheck = await sshExec(cfg.target, "command -v docker", {
51
- quiet: true,
52
- });
53
- if (dockerCheck.exitCode !== 0) {
64
+ const out = res.stdout;
65
+ if (!out.includes("DOCKER=1")) {
54
66
  return { kind: "no-docker" };
55
67
  }
56
68
 
57
- const composeDir = `${cfg.target.remoteDir}`;
58
- const psCheck = await sshExec(
59
- cfg.target,
60
- `test -f ${composeDir}/docker-compose.yml && cd ${composeDir} && docker compose ps --format '{{.Service}}' || true`,
61
- { quiet: true },
62
- );
63
- if (psCheck.exitCode !== 0 || psCheck.stdout.trim() === "") {
69
+ const psSection = sectionBetween(out, "---PS---", "---MARKER---").trim();
70
+ if (psSection === "") {
64
71
  return { kind: "no-stack" };
65
72
  }
66
-
67
- const running = psCheck.stdout
73
+ const running = psSection
68
74
  .split("\n")
69
75
  .map((l) => l.trim())
70
76
  .filter((l) => l.startsWith("arc-"))
71
77
  .map((l) => l.replace(/^arc-/, ""));
72
78
 
73
- const markerRaw = await sshExec(cfg.target, `cat ${STATE_MARKER_PATH}`, {
74
- quiet: true,
75
- });
79
+ const markerSection = afterMarker(out, "---MARKER---").trim();
76
80
  let marker: RemoteStateMarker | null = null;
77
- if (markerRaw.exitCode === 0) {
81
+ if (markerSection) {
78
82
  try {
79
- marker = JSON.parse(markerRaw.stdout) as RemoteStateMarker;
83
+ marker = JSON.parse(markerSection) as RemoteStateMarker;
80
84
  } catch {
81
85
  marker = null;
82
86
  }
@@ -85,6 +89,21 @@ export async function detectRemoteState(
85
89
  return { kind: "ready", runningEnvs: running, marker };
86
90
  }
87
91
 
92
+ /** Substring strictly between two delimiters (empty if either is absent). */
93
+ function sectionBetween(s: string, start: string, end: string): string {
94
+ const i = s.indexOf(start);
95
+ if (i < 0) return "";
96
+ const from = i + start.length;
97
+ const j = s.indexOf(end, from);
98
+ return s.slice(from, j < 0 ? undefined : j);
99
+ }
100
+
101
+ /** Everything after the last delimiter (empty if absent). */
102
+ function afterMarker(s: string, marker: string): string {
103
+ const i = s.indexOf(marker);
104
+ return i < 0 ? "" : s.slice(i + marker.length);
105
+ }
106
+
88
107
  /** Write the state marker file on the remote host. */
89
108
  export async function writeStateMarker(
90
109
  target: DeployTarget,