@arcote.tech/arc-cli 0.7.21 → 0.7.23

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
@@ -38091,9 +38091,20 @@ async function streamToString(stream2) {
38091
38091
  return "";
38092
38092
  return new Response(stream2).text();
38093
38093
  }
38094
+ function sshMuxArgs() {
38095
+ return [
38096
+ "-o",
38097
+ "ControlMaster=auto",
38098
+ "-o",
38099
+ `ControlPath=${join17(homedir3(), ".ssh", "cm-arc-%C")}`,
38100
+ "-o",
38101
+ "ControlPersist=120"
38102
+ ];
38103
+ }
38094
38104
  function baseSshArgs(target) {
38095
38105
  const key = pickSshKey(target);
38096
38106
  const args = [
38107
+ ...sshMuxArgs(),
38097
38108
  "-o",
38098
38109
  "BatchMode=yes",
38099
38110
  "-o",
@@ -38157,6 +38168,7 @@ async function canSsh(target) {
38157
38168
  async function scpUpload(target, localPath, remotePath) {
38158
38169
  const key = pickSshKey(target);
38159
38170
  const args = [
38171
+ ...sshMuxArgs(),
38160
38172
  "-o",
38161
38173
  "BatchMode=yes",
38162
38174
  "-o",
@@ -38181,6 +38193,22 @@ async function scpUpload(target, localPath, remotePath) {
38181
38193
  throw new Error(`scp failed (${exitCode}): ${stderr}`);
38182
38194
  }
38183
38195
  }
38196
+ async function closeSshMaster(target) {
38197
+ try {
38198
+ const proc2 = spawn3({
38199
+ cmd: [
38200
+ "ssh",
38201
+ ...baseSshArgs(target),
38202
+ "-O",
38203
+ "exit",
38204
+ `${target.user}@${target.host}`
38205
+ ],
38206
+ stdout: "ignore",
38207
+ stderr: "ignore"
38208
+ });
38209
+ await proc2.exited;
38210
+ } catch {}
38211
+ }
38184
38212
 
38185
38213
  // src/deploy/remote-state.ts
38186
38214
  var STATE_MARKER_PATH = "/opt/arc/.arc-state.json";
@@ -38188,38 +38216,55 @@ async function detectRemoteState(cfg) {
38188
38216
  if (cfg.target.host === "PENDING_TERRAFORM" || !cfg.target.host) {
38189
38217
  return { kind: "unreachable", reason: "target.host not yet set" };
38190
38218
  }
38191
- if (!await canSsh(cfg.target)) {
38219
+ const composeDir = cfg.target.remoteDir;
38220
+ const probe = [
38221
+ `command -v docker >/dev/null 2>&1 && echo DOCKER=1 || echo DOCKER=0`,
38222
+ `echo '---PS---'`,
38223
+ `[ -f ${composeDir}/docker-compose.yml ] && (cd ${composeDir} && docker compose ps --format '{{.Service}}' 2>/dev/null) || true`,
38224
+ `echo '---MARKER---'`,
38225
+ `cat ${STATE_MARKER_PATH} 2>/dev/null || true`
38226
+ ].join(`
38227
+ `);
38228
+ const res = await sshExec(cfg.target, probe, { quiet: true });
38229
+ if (res.exitCode !== 0) {
38192
38230
  if (await canSsh({ ...cfg.target, user: "root" })) {
38193
38231
  return { kind: "no-docker" };
38194
38232
  }
38195
38233
  return { kind: "unreachable", reason: "ssh connection failed" };
38196
38234
  }
38197
- const dockerCheck = await sshExec(cfg.target, "command -v docker", {
38198
- quiet: true
38199
- });
38200
- if (dockerCheck.exitCode !== 0) {
38235
+ const out = res.stdout;
38236
+ if (!out.includes("DOCKER=1")) {
38201
38237
  return { kind: "no-docker" };
38202
38238
  }
38203
- const composeDir = `${cfg.target.remoteDir}`;
38204
- const psCheck = await sshExec(cfg.target, `test -f ${composeDir}/docker-compose.yml && cd ${composeDir} && docker compose ps --format '{{.Service}}' || true`, { quiet: true });
38205
- if (psCheck.exitCode !== 0 || psCheck.stdout.trim() === "") {
38239
+ const psSection = sectionBetween(out, "---PS---", "---MARKER---").trim();
38240
+ if (psSection === "") {
38206
38241
  return { kind: "no-stack" };
38207
38242
  }
38208
- const running = psCheck.stdout.split(`
38243
+ const running = psSection.split(`
38209
38244
  `).map((l) => l.trim()).filter((l) => l.startsWith("arc-")).map((l) => l.replace(/^arc-/, ""));
38210
- const markerRaw = await sshExec(cfg.target, `cat ${STATE_MARKER_PATH}`, {
38211
- quiet: true
38212
- });
38245
+ const markerSection = afterMarker(out, "---MARKER---").trim();
38213
38246
  let marker = null;
38214
- if (markerRaw.exitCode === 0) {
38247
+ if (markerSection) {
38215
38248
  try {
38216
- marker = JSON.parse(markerRaw.stdout);
38249
+ marker = JSON.parse(markerSection);
38217
38250
  } catch {
38218
38251
  marker = null;
38219
38252
  }
38220
38253
  }
38221
38254
  return { kind: "ready", runningEnvs: running, marker };
38222
38255
  }
38256
+ function sectionBetween(s, start, end) {
38257
+ const i = s.indexOf(start);
38258
+ if (i < 0)
38259
+ return "";
38260
+ const from = i + start.length;
38261
+ const j = s.indexOf(end, from);
38262
+ return s.slice(from, j < 0 ? undefined : j);
38263
+ }
38264
+ function afterMarker(s, marker) {
38265
+ const i = s.indexOf(marker);
38266
+ return i < 0 ? "" : s.slice(i + marker.length);
38267
+ }
38223
38268
  async function writeStateMarker(target, marker) {
38224
38269
  const json = JSON.stringify(marker, null, 2);
38225
38270
  await assertExec(target, `sudo tee ${STATE_MARKER_PATH} > /dev/null <<'JSON'
@@ -38476,31 +38521,20 @@ async function updateEnvDeployment(opts) {
38476
38521
  const envVarName = `ARC_IMAGE_${upperEnv}`;
38477
38522
  const envPath = `${cfg.target.remoteDir}/.env`;
38478
38523
  const escapedRef = fullRef.replace(/"/g, "\\\"");
38479
- const updateScript = [
38480
- `touch ${envPath} && `,
38481
- `awk -v line="${envVarName}=${escapedRef}" -v key="${envVarName}=" '`,
38482
- ` BEGIN { replaced=0 } `,
38483
- ` $0 ~ "^"key { print line; replaced=1; next } `,
38484
- ` { print } `,
38485
- ` END { if (!replaced) print line } `,
38486
- `' ${envPath} > ${envPath}.tmp && mv ${envPath}.tmp ${envPath}`
38487
- ].join("");
38488
- await assertExec(target, updateScript);
38489
- await assertExec(target, `cd ${cfg.target.remoteDir} && docker compose pull arc-${env2}`);
38490
- await assertExec(target, `cd ${cfg.target.remoteDir} && docker compose up -d arc-${env2}`);
38491
38524
  const retain = opts.retainImages ?? 3;
38492
38525
  const imageBaseName = imageBaseFromRef(fullRef);
38493
- if (imageBaseName) {
38494
- const pruneScript = [
38495
- `docker images "${imageBaseName}" --format "{{.Repository}}:{{.Tag}} {{.CreatedAt}}" `,
38496
- `| grep -v ":latest " `,
38497
- `| sort -k2,3 -r `,
38498
- `| tail -n +${retain + 1} `,
38499
- `| awk '{print $1}' `,
38500
- `| xargs -r docker rmi 2>/dev/null || true`
38501
- ].join("");
38502
- await sshExec(target, pruneScript, { quiet: true });
38503
- }
38526
+ const pruneCmd = imageBaseName ? `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` : `true`;
38527
+ const script = [
38528
+ `set -e`,
38529
+ `cd ${cfg.target.remoteDir}`,
38530
+ `touch ${envPath}`,
38531
+ `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}`,
38532
+ `docker compose pull arc-${env2}`,
38533
+ `docker compose up -d arc-${env2}`,
38534
+ pruneCmd
38535
+ ].join(`
38536
+ `);
38537
+ await assertExec(target, script);
38504
38538
  const ok2 = await healthCheck(target, env2);
38505
38539
  return { env: env2, fullRef, redeployed: ok2 };
38506
38540
  }
@@ -39541,38 +39575,42 @@ async function platformDeploy(envArg, options = {}) {
39541
39575
  }
39542
39576
  }
39543
39577
  log2("Inspecting remote server...");
39544
- const state = await detectRemoteState(cfg);
39545
- log2(`Remote state: ${state.kind}`);
39546
- const cliVersion = readCliVersion2();
39547
- const configHash = await hashDeployConfig(ws.rootDir);
39548
- await bootstrap({
39549
- cfg,
39550
- rootDir: ws.rootDir,
39551
- state,
39552
- cliVersion,
39553
- configHash,
39554
- forceAnsible: options.forceBootstrap
39555
- });
39556
- if (!options.imageTag) {
39557
- log2(`Logging in to ${cfg.registry.domain}...`);
39558
- await dockerLogin(cfg.registry);
39559
- log2(`Pushing ${fullRef}...`);
39560
- await dockerPush(fullRef);
39561
- ok("Image pushed");
39562
- }
39563
- for (const env2 of targetEnvs) {
39564
- log2(`Updating env "${env2}"...`);
39565
- const outcome = await updateEnvDeployment({
39566
- target: cfg.target,
39578
+ try {
39579
+ const state = await detectRemoteState(cfg);
39580
+ log2(`Remote state: ${state.kind}`);
39581
+ const cliVersion = readCliVersion2();
39582
+ const configHash = await hashDeployConfig(ws.rootDir);
39583
+ await bootstrap({
39567
39584
  cfg,
39568
- env: env2,
39569
- fullRef
39585
+ rootDir: ws.rootDir,
39586
+ state,
39587
+ cliVersion,
39588
+ configHash,
39589
+ forceAnsible: options.forceBootstrap
39570
39590
  });
39571
- if (outcome.redeployed) {
39572
- ok(`${env2}: live at ${fullRef}`);
39573
- } else {
39574
- err(`${env2}: deployed but health check did not pass within retries \u2014 check \`docker logs arc-${env2}\``);
39591
+ if (!options.imageTag) {
39592
+ log2(`Logging in to ${cfg.registry.domain}...`);
39593
+ await dockerLogin(cfg.registry);
39594
+ log2(`Pushing ${fullRef}...`);
39595
+ await dockerPush(fullRef);
39596
+ ok("Image pushed");
39597
+ }
39598
+ for (const env2 of targetEnvs) {
39599
+ log2(`Updating env "${env2}"...`);
39600
+ const outcome = await updateEnvDeployment({
39601
+ target: cfg.target,
39602
+ cfg,
39603
+ env: env2,
39604
+ fullRef
39605
+ });
39606
+ if (outcome.redeployed) {
39607
+ ok(`${env2}: live at ${fullRef}`);
39608
+ } else {
39609
+ err(`${env2}: deployed but health check did not pass within retries \u2014 check \`docker logs arc-${env2}\``);
39610
+ }
39575
39611
  }
39612
+ } finally {
39613
+ await closeSshMaster(cfg.target);
39576
39614
  }
39577
39615
  }
39578
39616
  function readCliVersion2() {
@@ -40851,6 +40889,7 @@ async function createArcServer(config) {
40851
40889
  const cronScheduler = new CronScheduler(contextHandler);
40852
40890
  cronScheduler.start();
40853
40891
  const connectionManager = new ConnectionManager;
40892
+ const coep = config.coep ?? "unsafe-none";
40854
40893
  function buildCorsHeaders(req) {
40855
40894
  const origin = req?.headers.get("Origin") || "*";
40856
40895
  return {
@@ -40859,7 +40898,7 @@ async function createArcServer(config) {
40859
40898
  "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Arc-Scope, X-Arc-Tokens",
40860
40899
  "Access-Control-Allow-Credentials": "true",
40861
40900
  "Cross-Origin-Opener-Policy": "same-origin",
40862
- "Cross-Origin-Embedder-Policy": "require-corp",
40901
+ "Cross-Origin-Embedder-Policy": coep,
40863
40902
  "Cross-Origin-Resource-Policy": "cross-origin"
40864
40903
  };
40865
40904
  }
@@ -41320,6 +41359,7 @@ function spaFallbackHandler(getShellHtml) {
41320
41359
  }
41321
41360
  async function startPlatformServer(opts) {
41322
41361
  const { ws, port, devMode, context: context2 } = opts;
41362
+ const coep = process.env.ARC_COEP ?? opts.coep ?? "unsafe-none";
41323
41363
  ensureModuleSigSecret(ws, !!devMode);
41324
41364
  let telemetry;
41325
41365
  let telemetryShutdown;
@@ -41368,7 +41408,7 @@ async function startPlatformServer(opts) {
41368
41408
  "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
41369
41409
  "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Arc-Tokens",
41370
41410
  "Cross-Origin-Opener-Policy": "same-origin",
41371
- "Cross-Origin-Embedder-Policy": "require-corp",
41411
+ "Cross-Origin-Embedder-Policy": coep ?? "unsafe-none",
41372
41412
  "Cross-Origin-Resource-Policy": "cross-origin"
41373
41413
  };
41374
41414
  const server = Bun.serve({
@@ -41420,6 +41460,7 @@ async function startPlatformServer(opts) {
41420
41460
  context: context2,
41421
41461
  dbAdapterFactory,
41422
41462
  port,
41463
+ coep,
41423
41464
  httpHandlers: [
41424
41465
  apiEndpointsHandler(ws, getManifest, null, moduleAccessMap, telemetry),
41425
41466
  devReloadHandler(sseClients),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-cli",
3
- "version": "0.7.21",
3
+ "version": "0.7.23",
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.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",
15
+ "@arcote.tech/arc": "^0.7.23",
16
+ "@arcote.tech/arc-ds": "^0.7.23",
17
+ "@arcote.tech/arc-react": "^0.7.23",
18
+ "@arcote.tech/arc-host": "^0.7.23",
19
+ "@arcote.tech/arc-adapter-db-sqlite": "^0.7.23",
20
+ "@arcote.tech/arc-adapter-db-postgres": "^0.7.23",
21
+ "@arcote.tech/arc-otel": "^0.7.23",
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.21",
34
+ "@arcote.tech/platform": "^0.7.23",
35
35
  "@clack/prompts": "^0.9.0",
36
36
  "commander": "^11.1.0",
37
37
  "chokidar": "^3.5.3",
@@ -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 };
@@ -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,
package/src/deploy/ssh.ts CHANGED
@@ -43,6 +43,27 @@ export interface SshExecResult {
43
43
  exitCode: number;
44
44
  }
45
45
 
46
+ /**
47
+ * SSH connection multiplexing. Without this every remote command spawns a
48
+ * fresh `ssh` (full TCP + auth handshake) — a single deploy fires ~15-25 of
49
+ * them, so the handshake overhead alone runs into minutes. `ControlMaster=auto`
50
+ * makes the first connection open a master socket; every later ssh/scp to the
51
+ * same host reuses it with no handshake. `ControlPersist` keeps the master
52
+ * alive for the whole (short) deploy. The socket lives in ~/.ssh (private) and
53
+ * `%C` is ssh's hash of host+port+user — short enough for the ~104-byte socket
54
+ * path limit, and distinct per target (so the root fallback gets its own).
55
+ */
56
+ function sshMuxArgs(): string[] {
57
+ return [
58
+ "-o",
59
+ "ControlMaster=auto",
60
+ "-o",
61
+ `ControlPath=${join(homedir(), ".ssh", "cm-arc-%C")}`,
62
+ "-o",
63
+ "ControlPersist=120",
64
+ ];
65
+ }
66
+
46
67
  export function baseSshArgs(target: DeployTarget): string[] {
47
68
  // Pin to a single identity to avoid "Too many authentication failures":
48
69
  // the server's MaxAuthTries=3 (set by ansible) trips when ssh-agent has
@@ -50,6 +71,7 @@ export function baseSshArgs(target: DeployTarget): string[] {
50
71
  // PreferredAuthentications=publickey skips gssapi/keyboard prompts entirely.
51
72
  const key = pickSshKey(target);
52
73
  const args = [
74
+ ...sshMuxArgs(),
53
75
  "-o",
54
76
  "BatchMode=yes",
55
77
  "-o",
@@ -159,6 +181,8 @@ export async function scpUpload(
159
181
  ): Promise<void> {
160
182
  const key = pickSshKey(target);
161
183
  const args = [
184
+ // Same ControlPath as baseSshArgs — scp reuses the ssh master socket.
185
+ ...sshMuxArgs(),
162
186
  "-o",
163
187
  "BatchMode=yes",
164
188
  "-o",
@@ -184,3 +208,28 @@ export async function scpUpload(
184
208
  throw new Error(`scp failed (${exitCode}): ${stderr}`);
185
209
  }
186
210
  }
211
+
212
+ /**
213
+ * Close the multiplexed SSH master connection (best-effort). Call once at the
214
+ * end of a deploy so the control socket doesn't linger for ControlPersist
215
+ * seconds after the process exits. Safe to call even if no master was opened —
216
+ * `ssh -O exit` against a missing socket just returns non-zero, which we ignore.
217
+ */
218
+ export async function closeSshMaster(target: DeployTarget): Promise<void> {
219
+ try {
220
+ const proc = spawn({
221
+ cmd: [
222
+ "ssh",
223
+ ...baseSshArgs(target),
224
+ "-O",
225
+ "exit",
226
+ `${target.user}@${target.host}`,
227
+ ],
228
+ stdout: "ignore",
229
+ stderr: "ignore",
230
+ });
231
+ await proc.exited;
232
+ } catch {
233
+ // best-effort cleanup — a missing/already-closed master is fine.
234
+ }
235
+ }
@@ -29,6 +29,13 @@ export interface PlatformServerOptions {
29
29
  dbPath?: string;
30
30
  /** If true, enables SSE reload stream + mutable manifest (dev mode) */
31
31
  devMode?: boolean;
32
+ /**
33
+ * Cross-origin isolation policy. Default `"unsafe-none"`. Apps z SQLite
34
+ * WASM/OPFS lub SharedArrayBuffer ustawiają `"require-corp"` w
35
+ * `deploy.arc.json` — wtedy każdy cross-origin resource (3rd-party
36
+ * widgets) musi mieć `Cross-Origin-Resource-Policy`. Patrz `ArcServerConfig.coep`.
37
+ */
38
+ coep?: "unsafe-none" | "credentialless" | "require-corp";
32
39
  }
33
40
 
34
41
  export interface PlatformServer {
@@ -507,6 +514,15 @@ export async function startPlatformServer(
507
514
  opts: PlatformServerOptions,
508
515
  ): Promise<PlatformServer> {
509
516
  const { ws, port, devMode, context } = opts;
517
+ // Default COEP: env var > opts > unsafe-none. Apps z SQLite WASM/OPFS
518
+ // muszą explicit ustawić "require-corp" (przez ARC_COEP w .env / Docker
519
+ // envVars). Default unsafe-none pozwala apps używać 3rd-party widgetów
520
+ // (PayU Secure Form, Stripe Elements, Google/Apple Pay) bez proxy.
521
+ const coep = (process.env.ARC_COEP as
522
+ | "unsafe-none"
523
+ | "credentialless"
524
+ | "require-corp"
525
+ | undefined) ?? opts.coep ?? "unsafe-none";
510
526
  ensureModuleSigSecret(ws, !!devMode);
511
527
 
512
528
  // OpenTelemetry — only when explicitly enabled (deploy injects the env
@@ -567,10 +583,10 @@ export async function startPlatformServer(
567
583
  "Access-Control-Allow-Methods":
568
584
  "GET, POST, PUT, DELETE, PATCH, OPTIONS",
569
585
  "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Arc-Tokens",
570
- // Cross-origin isolation — wymagane dla SharedArrayBuffer + OPFS
571
- // (SQLite WASM persistent storage w przeglądarce).
586
+ // Cross-origin isolation — domyślnie unsafe-none (3rd-party widgets);
587
+ // require-corp tylko dla apps używających SharedArrayBuffer/OPFS.
572
588
  "Cross-Origin-Opener-Policy": "same-origin",
573
- "Cross-Origin-Embedder-Policy": "require-corp",
589
+ "Cross-Origin-Embedder-Policy": coep ?? "unsafe-none",
574
590
  "Cross-Origin-Resource-Policy": "cross-origin",
575
591
  };
576
592
 
@@ -641,6 +657,7 @@ export async function startPlatformServer(
641
657
  context,
642
658
  dbAdapterFactory,
643
659
  port,
660
+ coep,
644
661
  httpHandlers: [
645
662
  // Platform-specific handlers (checked AFTER arc handlers)
646
663
  apiEndpointsHandler(ws, getManifest, null, moduleAccessMap, telemetry),