@stll/oxlint-config 1.0.2 → 1.2.0

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.
Files changed (49) hide show
  1. package/README.md +97 -4
  2. package/dist/cloud-setup-cli.cjs +62 -21
  3. package/dist/cloud-setup-cli.cjs.map +1 -1
  4. package/dist/cloud-setup-cli.mjs +63 -22
  5. package/dist/cloud-setup-cli.mjs.map +1 -1
  6. package/dist/consumer-compat-cli.cjs +7 -6
  7. package/dist/consumer-compat-cli.cjs.map +1 -1
  8. package/dist/consumer-compat-cli.mjs +7 -6
  9. package/dist/consumer-compat-cli.mjs.map +1 -1
  10. package/dist/git-tracked-output.cjs.map +1 -1
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/publish-build-target-vite-dts-helper.mjs +1 -2
  13. package/dist/publish-build-target-vite-dts-helper2.cjs +5 -5
  14. package/dist/publish-build-target-vite-dts-helper2.cjs.map +1 -1
  15. package/dist/publish-contract-cli.cjs +17 -16
  16. package/dist/publish-contract-cli.cjs.map +1 -1
  17. package/dist/publish-contract-cli.mjs +6 -5
  18. package/dist/publish-contract-cli.mjs.map +1 -1
  19. package/dist/publish-contract.cjs +1 -1
  20. package/dist/publish-contract.cjs.map +1 -1
  21. package/dist/publish-contract.mjs +1 -1
  22. package/dist/rolldown-runtime.cjs +1 -1
  23. package/dist/toolchain-changed.cjs +25 -15
  24. package/dist/toolchain-changed.cjs.map +1 -1
  25. package/dist/toolchain-changed.mjs +27 -17
  26. package/dist/toolchain-changed.mjs.map +1 -1
  27. package/dist/toolchain-check-cli.cjs.map +1 -1
  28. package/dist/toolchain-check-cli.mjs.map +1 -1
  29. package/dist/toolchain-guard.cjs +72 -48
  30. package/dist/toolchain-guard.cjs.map +1 -1
  31. package/dist/toolchain-guard.mjs +79 -55
  32. package/dist/toolchain-guard.mjs.map +1 -1
  33. package/dist/toolchain-inputs.cjs +803 -40
  34. package/dist/toolchain-inputs.cjs.map +1 -1
  35. package/dist/toolchain-inputs.mjs +768 -41
  36. package/dist/toolchain-inputs.mjs.map +1 -1
  37. package/dist/toolchain-schema.cjs +1 -1
  38. package/dist/toolchain-schema.cjs.map +1 -1
  39. package/dist/toolchain-schema.mjs +2 -2
  40. package/dist/typecheck-parity-cli.cjs.map +1 -1
  41. package/dist/typecheck-parity-cli.mjs.map +1 -1
  42. package/dist/typecheck-parity.cjs +8 -7
  43. package/dist/typecheck-parity.cjs.map +1 -1
  44. package/dist/typecheck-parity.mjs +6 -5
  45. package/dist/typecheck-parity.mjs.map +1 -1
  46. package/dist/typecheck-probe-cli.cjs +1 -1
  47. package/dist/typecheck-probe-cli.cjs.map +1 -1
  48. package/dist/typecheck-probe-cli.mjs +1 -1
  49. package/package.json +6 -6
@@ -1,5 +1,9 @@
1
1
  import path from "node:path";
2
+ import { createHash } from "node:crypto";
2
3
  import { subset, valid, validRange } from "semver";
4
+ import { parse as parse$1 } from "smol-toml";
5
+ import { isDeepStrictEqual } from "node:util";
6
+ import { parseDocument } from "yaml";
3
7
  import picomatch from "picomatch";
4
8
  //#region src/cloud-setup-install.ts
5
9
  const packageInstall = String.raw`
@@ -35,7 +39,7 @@ apt_install() (
35
39
  policy_state=installed
36
40
  printf '%s\n' '#!/bin/sh' 'exit 101' > /usr/sbin/policy-rc.d
37
41
  chmod 755 /usr/sbin/policy-rc.d
38
- DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$@"
42
+ DEBIAN_FRONTEND=noninteractive apt_network install -y --no-install-recommends "$@"
39
43
  )
40
44
 
41
45
  `;
@@ -159,7 +163,7 @@ const postgresInstall = String.raw`
159
163
  if [[ "$PGDG_SOURCE_STATE" == missing ]]; then
160
164
  local key key_fingerprint
161
165
  key="$(mktemp)"
162
- curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --tlsv1.2 https://www.postgresql.org/media/keys/ACCC4CF8.asc -o "$key"
166
+ download_file https://www.postgresql.org/media/keys/ACCC4CF8.asc "$key" 45
163
167
  key_fingerprint="$(pgdg_key_fingerprint "$key")"
164
168
  [[ "$key_fingerprint" == @PGDG_FINGERPRINT@ ]] || fail 'PostgreSQL signing key mismatch'
165
169
  install -d -m 755 /usr/share/keyrings
@@ -167,7 +171,7 @@ const postgresInstall = String.raw`
167
171
  rm -- "$key"
168
172
  printf '%s\n' '@PGDG_SOURCE@' > /etc/apt/sources.list.d/stll-pgdg.list
169
173
  fi
170
- apt-get update
174
+ apt_refresh_for postgresql-@POSTGRES@ postgresql-client-@POSTGRES@
171
175
  apt_install postgresql-@POSTGRES@ postgresql-client-@POSTGRES@
172
176
  fi
173
177
  postgres_ready || fail 'PostgreSQL toolchain is incomplete or differs from the policy major'
@@ -182,15 +186,15 @@ const valkeyInstall = String.raw`
182
186
  arm64) server_url='@ARM64_SERVER_URL@'; server_sha='@ARM64_SERVER_SHA@'; tools_url='@ARM64_TOOLS_URL@'; tools_sha='@ARM64_TOOLS_SHA@' ;;
183
187
  *) fail 'Unsupported Valkey architecture' ;;
184
188
  esac
185
- curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --tlsv1.2 "$server_url" -o "$scratch/valkey-server.deb"
186
- curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --tlsv1.2 "$tools_url" -o "$scratch/valkey-tools.deb"
189
+ download_file "$server_url" "$scratch/valkey-server.deb" 120
190
+ download_file "$tools_url" "$scratch/valkey-tools.deb" 120
187
191
  (cd "$scratch"; printf '%s %s\n' "$server_sha" valkey-server.deb "$tools_sha" valkey-tools.deb | sha256sum --strict -c -)
188
192
  for package in valkey-server valkey-tools; do
189
193
  [[ "$(dpkg-deb -f "$scratch/$package.deb" Package)" == "$package" ]] || fail 'Valkey artifact package mismatch'
190
194
  [[ "$(dpkg-deb -f "$scratch/$package.deb" Version)" == '@VALKEY@-1.noble' ]] || fail 'Valkey artifact version mismatch'
191
195
  [[ "$(dpkg-deb -f "$scratch/$package.deb" Architecture)" == "$ARCH" ]] || fail 'Valkey artifact architecture mismatch'
192
196
  done
193
- apt-get update
197
+ apt_refresh_for "$scratch/valkey-server.deb" "$scratch/valkey-tools.deb"
194
198
  apt_install "$scratch/valkey-server.deb" "$scratch/valkey-tools.deb"
195
199
  rm -rf -- "$scratch"
196
200
  fi
@@ -254,18 +258,267 @@ const renderCloudServiceInstall = ({ services, postgres, valkey, valkeyArtifacts
254
258
  return `${packageInstall}\n${services.includes("postgres") ? postgresSources.replaceAll("@PGDG_FINGERPRINT@", postgresSigningFingerprint) : ""}\n${services.length === 0 ? "prepare_state() { :; }\n" : state}\ninstall_services() {\n${user}${parts.join("")}\n :\n}\n`;
255
259
  };
256
260
  //#endregion
261
+ //#region src/cloud-setup-network.ts
262
+ /** Shared deadlines cover retries and package-manager subprocesses. */
263
+ const renderCloudNetwork = () => String.raw`
264
+ network_run() (
265
+ local host="$1" seconds="$2" status=0
266
+ shift 2
267
+ set -o pipefail
268
+ network_log="$(mktemp)" || return "$?"
269
+ trap 'rm -f -- "$network_log"' EXIT
270
+ trap 'exit 129' HUP
271
+ trap 'exit 130' INT
272
+ trap 'exit 143' TERM
273
+ if timeout --signal=TERM --kill-after=5s "$seconds"s "$@" 2>&1 | tee "$network_log"; then
274
+ return 0
275
+ else
276
+ # Scalar Bash array expansion reads PIPESTATUS[0] before assignment resets it.
277
+ status="$PIPESTATUS"
278
+ fi
279
+ [[ "$status" != 0 ]] || fail 'Cannot capture network command output'
280
+ if [[ "$status" == 124 || "$status" == 137 || "$status" == 28 ]] || grep -Eiq 'timed[[:space:]-]+out|time[[:space:]-]+out|timeout[[:space:]]+(was[[:space:]]+)?(reached|expired)|(connection|operation|socket)[[:space:]-]+timeout' "$network_log"; then
281
+ fail "download timed out: $host"
282
+ fi
283
+ return "$status"
284
+ )
285
+ download_file() {
286
+ local url="$1" destination="$2" seconds="$3" host
287
+ host="$(printf '%s\n' "$url" | cut -d/ -f3 | sed 's/.*@//')"
288
+ [[ "$url" == https://* && -n "$host" ]] || fail 'Downloads require an HTTPS host'
289
+ network_run "$host" "$seconds" curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --tlsv1.2 --connect-timeout 20 --max-time "$seconds" --retry 3 --retry-all-errors --retry-max-time "$seconds" "$url" -o "$destination"
290
+ }
291
+ apt_network() {
292
+ local hosts
293
+ # --print-uris reads configured indexes without contacting their servers.
294
+ hosts="$(set -o pipefail; apt-get --print-uris update | awk -F/ '/^\047https?:\/\// { host=$3; sub(/.*@/, "", host); if (!seen[host]++) { printf "%s%s", separator, host; separator="," } }')" || return "$?"
295
+ [[ -n "$hosts" ]] || hosts='configured APT hosts'
296
+ network_run "$hosts" 180 apt-get -o Acquire::http::Timeout=20 -o Acquire::https::Timeout=20 -o Acquire::Retries=3 -o APT::Update::Error-Mode=any "$@"
297
+ }
298
+ apt_refresh_for() {
299
+ # Skip the refresh only when the install downloads nothing: then no package
300
+ # source is contacted and stale cached indexes cannot point at missing files.
301
+ local uris
302
+ if uris="$(DEBIAN_FRONTEND=noninteractive apt-get --print-uris -qq -y --no-install-recommends install "$@" 2>/dev/null)" && [[ -z "$uris" ]]; then
303
+ return 0
304
+ fi
305
+ apt_network update
306
+ }
307
+ `;
308
+ //#endregion
309
+ //#region src/cloud-setup-artifacts.ts
310
+ const wasmBindgen0126 = {
311
+ version: "0.2.126",
312
+ amd64: {
313
+ url: "https://github.com/wasm-bindgen/wasm-bindgen/releases/download/0.2.126/wasm-bindgen-0.2.126-x86_64-unknown-linux-musl.tar.gz",
314
+ sha256: "064948d58e2d6c0a745216477a639ba696216d6309aaa902939d1b865b1d869d",
315
+ archive: "tar.gz",
316
+ binaries: {
317
+ "wasm-bindgen-test-runner": "wasm-bindgen-0.2.126-x86_64-unknown-linux-musl/wasm-bindgen-test-runner",
318
+ wasm2es6js: "wasm-bindgen-0.2.126-x86_64-unknown-linux-musl/wasm2es6js",
319
+ "wasm-bindgen": "wasm-bindgen-0.2.126-x86_64-unknown-linux-musl/wasm-bindgen"
320
+ }
321
+ },
322
+ arm64: {
323
+ url: "https://github.com/wasm-bindgen/wasm-bindgen/releases/download/0.2.126/wasm-bindgen-0.2.126-aarch64-unknown-linux-musl.tar.gz",
324
+ sha256: "2245120254a9f6c9a9adf3601f3d52bb31309219e9ceab7696e74e24885c440a",
325
+ archive: "tar.gz",
326
+ binaries: {
327
+ "wasm-bindgen-test-runner": "wasm-bindgen-0.2.126-aarch64-unknown-linux-musl/wasm-bindgen-test-runner",
328
+ wasm2es6js: "wasm-bindgen-0.2.126-aarch64-unknown-linux-musl/wasm2es6js",
329
+ "wasm-bindgen": "wasm-bindgen-0.2.126-aarch64-unknown-linux-musl/wasm-bindgen"
330
+ }
331
+ }
332
+ };
333
+ /** Pinned upstream Linux artifacts, verified against their published SHA-256 sums. */
334
+ const cloudSetupArtifacts = {
335
+ "rustup-init": {
336
+ version: "1.29.1",
337
+ amd64: {
338
+ url: "https://static.rust-lang.org/rustup/archive/1.29.1/x86_64-unknown-linux-gnu/rustup-init",
339
+ sha256: "dda7234360b7f578ca8b0ddcb80145646fa61a67c1720a5abc7051b35c9fcb71",
340
+ archive: "binary",
341
+ binaries: { "rustup-init": "rustup-init" }
342
+ },
343
+ arm64: {
344
+ url: "https://static.rust-lang.org/rustup/archive/1.29.1/aarch64-unknown-linux-gnu/rustup-init",
345
+ sha256: "15f6e4ce9f583b929c996c91562bad6d4454f3281de858b02cdfdef615fac433",
346
+ archive: "binary",
347
+ binaries: { "rustup-init": "rustup-init" }
348
+ }
349
+ },
350
+ "cargo-nextest": {
351
+ version: "0.9.148",
352
+ amd64: {
353
+ url: "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9.148/cargo-nextest-0.9.148-x86_64-unknown-linux-musl.tar.gz",
354
+ sha256: "bf92cb81450872d960ffa13857a83084d557ec0cdc141663d263bb23ef756f97",
355
+ archive: "tar.gz",
356
+ binaries: { "cargo-nextest": "cargo-nextest" }
357
+ },
358
+ arm64: {
359
+ url: "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9.148/cargo-nextest-0.9.148-aarch64-unknown-linux-musl.tar.gz",
360
+ sha256: "94676506ceaed01746d78cfe9ed124ee841b2932fd18d9d10f438a347bd9ba5e",
361
+ archive: "tar.gz",
362
+ binaries: { "cargo-nextest": "cargo-nextest" }
363
+ }
364
+ },
365
+ "wasm-bindgen-cli": {
366
+ version: "0.2.129",
367
+ amd64: {
368
+ url: "https://github.com/wasm-bindgen/wasm-bindgen/releases/download/0.2.129/wasm-bindgen-0.2.129-x86_64-unknown-linux-musl.tar.gz",
369
+ sha256: "82d12bb940e2d4e72e0d5605387fc1b8ca179044e012b620f0ce4e7440e8320e",
370
+ archive: "tar.gz",
371
+ binaries: {
372
+ "wasm-bindgen-test-runner": "wasm-bindgen-0.2.129-x86_64-unknown-linux-musl/wasm-bindgen-test-runner",
373
+ wasm2es6js: "wasm-bindgen-0.2.129-x86_64-unknown-linux-musl/wasm2es6js",
374
+ "wasm-bindgen": "wasm-bindgen-0.2.129-x86_64-unknown-linux-musl/wasm-bindgen"
375
+ }
376
+ },
377
+ arm64: {
378
+ url: "https://github.com/wasm-bindgen/wasm-bindgen/releases/download/0.2.129/wasm-bindgen-0.2.129-aarch64-unknown-linux-musl.tar.gz",
379
+ sha256: "2ed4351c35dd9440308bbb02767d47ea278efe851a52465300f3c94f5b6c2a87",
380
+ archive: "tar.gz",
381
+ binaries: {
382
+ "wasm-bindgen-test-runner": "wasm-bindgen-0.2.129-aarch64-unknown-linux-musl/wasm-bindgen-test-runner",
383
+ wasm2es6js: "wasm-bindgen-0.2.129-aarch64-unknown-linux-musl/wasm2es6js",
384
+ "wasm-bindgen": "wasm-bindgen-0.2.129-aarch64-unknown-linux-musl/wasm-bindgen"
385
+ }
386
+ }
387
+ },
388
+ uv: {
389
+ version: "0.13.1",
390
+ amd64: {
391
+ url: "https://github.com/astral-sh/uv/releases/download/0.13.1/uv-x86_64-unknown-linux-musl.tar.gz",
392
+ sha256: "6c86040a8f045ab9adc9e3ec4cdafa03fd00e9a988ed2bf28971c7fc2d0debd0",
393
+ archive: "tar.gz",
394
+ binaries: {
395
+ uv: "uv-x86_64-unknown-linux-musl/uv",
396
+ uvx: "uv-x86_64-unknown-linux-musl/uvx"
397
+ }
398
+ },
399
+ arm64: {
400
+ url: "https://github.com/astral-sh/uv/releases/download/0.13.1/uv-aarch64-unknown-linux-musl.tar.gz",
401
+ sha256: "345a8308e4d1835cdfaadf692836fd3137a6d45864773f05ad7bf59442b1ba16",
402
+ archive: "tar.gz",
403
+ binaries: {
404
+ uv: "uv-aarch64-unknown-linux-musl/uv",
405
+ uvx: "uv-aarch64-unknown-linux-musl/uvx"
406
+ }
407
+ }
408
+ }
409
+ };
410
+ /** Explicit releases whose CLI must match a project's wasm-bindgen crate exactly. */
411
+ const wasmBindgenArtifactReleases = [wasmBindgen0126, cloudSetupArtifacts["wasm-bindgen-cli"]];
412
+ /** Every supported version participates in lookup and catalog validation. */
413
+ const cloudSetupArtifactVersions = {
414
+ "rustup-init": [cloudSetupArtifacts["rustup-init"]],
415
+ "cargo-nextest": [cloudSetupArtifacts["cargo-nextest"]],
416
+ "wasm-bindgen-cli": wasmBindgenArtifactReleases,
417
+ uv: [cloudSetupArtifacts.uv]
418
+ };
419
+ /** A new version requires an explicit, verified catalog entry; never build from source. */
420
+ const getCloudSetupArtifact = (tool, version) => {
421
+ const releases = cloudSetupArtifactVersions[tool];
422
+ const artifact = releases.find((release) => release.version === version);
423
+ if (artifact === void 0) throw new Error(`Unsupported ${tool} version ${version}; supported versions: ${releases.map((release) => release.version).join(", ")}`);
424
+ return artifact;
425
+ };
426
+ /** Ubuntu 24.04 packages; apt resolves them from the image's configured sources. */
427
+ const cloudSetupPackages = { time: {
428
+ version: "1.9",
429
+ package: "time",
430
+ binary: "/usr/bin/time",
431
+ identity: "time (GNU Time) "
432
+ } };
433
+ /** Ubuntu 24.04 ships one release per package; other versions are unsupported. */
434
+ const getCloudSetupPackage = (tool, version) => {
435
+ const release = cloudSetupPackages[tool];
436
+ if (release.version !== version) throw new Error(`Unsupported ${tool} version ${version}; supported versions: ${release.version}`);
437
+ return release;
438
+ };
439
+ //#endregion
257
440
  //#region src/cloud-setup-schema.ts
258
441
  const cloudServices = ["postgres", "valkey"];
442
+ const cloudArtifactTools = Object.keys(cloudSetupArtifacts).filter((tool) => tool !== "rustup-init" && Object.hasOwn(cloudSetupArtifacts, tool));
443
+ const cloudPackageTools = Object.keys(cloudSetupPackages).filter((tool) => Object.hasOwn(cloudSetupPackages, tool));
444
+ const cloudExtraTools = [...cloudArtifactTools, ...cloudPackageTools];
445
+ const isCloudPackageTool = (tool) => cloudPackageTools.some((candidate) => candidate === tool);
446
+ const object = (input) => typeof input === "object" && input !== null && !Array.isArray(input);
447
+ const exactPin = (input) => typeof input === "string" && /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(input);
448
+ const exactToolPin = (tool, input) => isCloudPackageTool(tool) ? typeof input === "string" && /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:\.(?:0|[1-9]\d*))?$/.test(input) : exactPin(input);
449
+ const cloudRustComponentBinaries = {
450
+ clippy: ["cargo-clippy", "clippy-driver"],
451
+ rustfmt: ["rustfmt", "cargo-fmt"],
452
+ "rust-src": [],
453
+ "rust-analyzer": ["rust-analyzer"]
454
+ };
455
+ const parseCloudRustNames = (input, kind) => {
456
+ const label = `cloud Rust ${kind}`;
457
+ if (input === void 0) return [];
458
+ if (!Array.isArray(input)) throw new Error(`${label} must be an array`);
459
+ const result = input.map((value) => {
460
+ if (typeof value !== "string" || !/^[a-z][a-z0-9_-]*$/.test(value)) throw new Error(`${label} requires canonical names`);
461
+ if (kind === "components" && !Object.hasOwn(cloudRustComponentBinaries, value)) throw new Error(`${label} contains an unsupported component: ${value}`);
462
+ return value;
463
+ });
464
+ if (new Set(result).size !== result.length) throw new Error(`${label} contains duplicates`);
465
+ return result;
466
+ };
467
+ const parseRust = (input) => {
468
+ if (input === void 0) return void 0;
469
+ if (!object(input) || Object.keys(input).some((key) => ![
470
+ "toolchain",
471
+ "components",
472
+ "targets"
473
+ ].includes(key))) throw new Error("cloud.rust accepts only toolchain, components and targets");
474
+ const toolchain = input["toolchain"];
475
+ if (toolchain !== void 0 && !exactPin(toolchain)) throw new Error("cloud.rust.toolchain must be an exact stable release");
476
+ return {
477
+ ...toolchain === void 0 ? {} : { toolchain },
478
+ components: parseCloudRustNames(input["components"], "components"),
479
+ targets: parseCloudRustNames(input["targets"], "targets")
480
+ };
481
+ };
482
+ const parseTools = (input) => {
483
+ if (input === void 0) return void 0;
484
+ if (!object(input) || Object.keys(input).some((key) => !cloudExtraTools.some((tool) => tool === key))) throw new Error(`cloud.tools accepts only ${cloudExtraTools.join(", ")}`);
485
+ const result = {};
486
+ for (const tool of cloudExtraTools) {
487
+ const pin = input[tool];
488
+ if (pin === void 0) {
489
+ if (Object.hasOwn(input, tool)) throw new Error(`cloud.tools.${tool} must be an exact stable release`);
490
+ continue;
491
+ }
492
+ if (!exactToolPin(tool, pin)) throw new Error(`cloud.tools.${tool} must be an exact stable release`);
493
+ result[tool] = pin;
494
+ }
495
+ return result;
496
+ };
259
497
  const cloudInstallCommand = "bun install --frozen-lockfile";
498
+ const cloudInstallCommands = [cloudInstallCommand, `${cloudInstallCommand} --ignore-scripts`];
499
+ const cloudInstall = (value) => cloudInstallCommands.some((command) => value === command);
260
500
  const cloudService = (value) => cloudServices.some((service) => value === service);
501
+ const parseEnvFile = (input) => {
502
+ if (!("envFile" in input) || typeof input.envFile !== "string") throw new Error("cloud.envFile must be a canonical repository-relative path");
503
+ const envFile = input.envFile;
504
+ if (envFile.startsWith("/") || /^[A-Za-z]:/.test(envFile) || envFile.includes("\\") || /\p{Cc}/u.test(envFile) || envFile.split("/").some((part) => part === "" || part === "." || part === "..")) throw new Error("cloud.envFile must be a canonical repository-relative path");
505
+ return envFile;
506
+ };
261
507
  /** Parse an explicit declaration without assuming services for undeclared repositories. */
262
508
  const parseCloudSetup = (input) => {
263
509
  if (input === void 0) return void 0;
264
510
  if (typeof input !== "object" || input === null || Array.isArray(input)) throw new Error("cloud must be an object");
265
- for (const key of Object.keys(input)) if (key !== "services" && key !== "install" && key !== "envFile") throw new Error(`unsupported cloud key: ${key}`);
511
+ for (const key of Object.keys(input)) if (key !== "services" && key !== "install" && key !== "envFile" && key !== "rust" && key !== "tools") throw new Error(`unsupported cloud key: ${key}`);
512
+ const rust = parseRust("rust" in input ? input.rust : void 0);
513
+ const tools = parseTools("tools" in input ? input.tools : void 0);
514
+ const extensions = {
515
+ ...rust === void 0 ? {} : { rust },
516
+ ...tools === void 0 ? {} : { tools }
517
+ };
266
518
  const selected = "services" in input ? input.services : [];
267
519
  if (!Array.isArray(selected)) throw new Error("cloud.services must be an array when provided");
268
- if (!("install" in input) || input.install !== "bun install --frozen-lockfile") throw new Error(`cloud.install must be exactly ${cloudInstallCommand}`);
520
+ if (!("install" in input) || !cloudInstall(input.install)) throw new Error(`cloud.install must be exactly one of: ${cloudInstallCommands.join(", ")}`);
521
+ const install = input.install;
269
522
  const seen = /* @__PURE__ */ new Set();
270
523
  const services = Array.from(selected, (service) => {
271
524
  if (!cloudService(service)) throw new Error("cloud.services contains an unsupported service");
@@ -273,20 +526,12 @@ const parseCloudSetup = (input) => {
273
526
  seen.add(service);
274
527
  return service;
275
528
  });
276
- if (services.length === 0) {
277
- if ("envFile" in input) throw new Error("cloud.envFile is not allowed without services");
278
- return {
279
- services,
280
- install: cloudInstallCommand
281
- };
282
- }
283
- if (!("envFile" in input) || typeof input.envFile !== "string") throw new Error("cloud.envFile must be a canonical repository-relative path");
284
- const envFile = input.envFile;
285
- if (envFile.startsWith("/") || /^[A-Za-z]:/.test(envFile) || envFile.includes("\\") || /\p{Cc}/u.test(envFile) || envFile.split("/").some((part) => part === "" || part === "." || part === "..")) throw new Error("cloud.envFile must be a canonical repository-relative path");
529
+ if (services.length === 0 && "envFile" in input) throw new Error("cloud.envFile is not allowed without services");
286
530
  return {
287
531
  services,
288
- install: cloudInstallCommand,
289
- envFile
532
+ install,
533
+ ...extensions,
534
+ ...services.length === 0 ? {} : { envFile: parseEnvFile(input) }
290
535
  };
291
536
  };
292
537
  //#endregion
@@ -353,7 +598,7 @@ service_prune_pid() {
353
598
  }
354
599
  `;
355
600
  const postgresStart = String.raw`
356
- local pg_bin='/usr/lib/postgresql/@POSTGRES@/bin' pg_data="$STATE/postgres" pg_password pg_details pg_pid pg_version
601
+ local pg_bin='/usr/lib/postgresql/@POSTGRES@/bin' pg_data="$STATE/postgres" pg_password pg_details pg_pid pg_version pg_state='already running'
357
602
  service_password "$STATE/postgres.password"
358
603
  pg_password="$(cat "$STATE/postgres.password")"
359
604
  service_directory "$pg_data"
@@ -372,6 +617,7 @@ const postgresStart = String.raw`
372
617
  service_prune_pid "$pg_data/postmaster.pid" "$pg_bin/postgres" postgres
373
618
  if ! service_run env PGHOST=127.0.0.1 PGHOSTADDR=127.0.0.1 "$pg_bin/pg_ctl" -D "$pg_data" status >/dev/null 2>&1; then
374
619
  service_run env PGHOST=127.0.0.1 PGHOSTADDR=127.0.0.1 "$pg_bin/pg_ctl" -D "$pg_data" -l "$pg_data/cloud.log" -o "-h 127.0.0.1 -p 55432 -c unix_socket_directories=" -t 10 -w start >/dev/null || fail "PostgreSQL start failed; port 55432 must be available"
620
+ pg_state=started
375
621
  fi
376
622
  pg_details="$(env -u PGSERVICE -u PGSERVICEFILE -u PGOPTIONS PGHOSTADDR=127.0.0.1 PGPASSWORD="$pg_password" PGCONNECT_TIMEOUT=2 "$pg_bin/psql" -X -h 127.0.0.1 -p 55432 -U stll_cloud -d postgres -At -v ON_ERROR_STOP=1 -c "SELECT current_setting('data_directory'), current_setting('listen_addresses'), current_setting('port'), current_setting('server_version_num'), current_setting('unix_socket_directories')")" || fail "PostgreSQL authenticated readiness failed"
377
623
  pg_version="$(printf '%s\n' "$pg_details" | cut -d '|' -f4)"
@@ -381,9 +627,10 @@ const postgresStart = String.raw`
381
627
  service_process "$pg_pid" "$pg_bin/postgres"
382
628
  [[ "$(env -u PGSERVICE -u PGSERVICEFILE -u PGOPTIONS PGHOSTADDR=127.0.0.1 PGPASSWORD="$pg_password" PGCONNECT_TIMEOUT=2 "$pg_bin/psql" -X -h 127.0.0.1 -p 55432 -U stll_cloud -d postgres -At -v ON_ERROR_STOP=1 -c "SELECT count(*) FROM pg_database WHERE datname = 'cloud_test'")" == 1 ]] || env -u PGSERVICE -u PGSERVICEFILE -u PGOPTIONS PGHOSTADDR=127.0.0.1 PGPASSWORD="$pg_password" PGCONNECT_TIMEOUT=2 "$pg_bin/createdb" -h 127.0.0.1 -p 55432 -U stll_cloud cloud_test || fail "PostgreSQL test database creation failed"
383
629
  DATABASE_URL="postgresql://stll_cloud:$pg_password@127.0.0.1:55432/cloud_test"
630
+ printf 'postgres: %s\n' "$pg_state"
384
631
  `;
385
632
  const valkeyStart = String.raw`
386
- local vk_data="$STATE/valkey" vk_password vk_config vk_info vk_pid vk_expected ready=0
633
+ local vk_data="$STATE/valkey" vk_password vk_config vk_info vk_pid vk_expected ready=0 vk_state='already running'
387
634
  service_password "$STATE/valkey.password"
388
635
  vk_password="$(cat "$STATE/valkey.password")"
389
636
  service_directory "$vk_data"
@@ -403,7 +650,8 @@ const valkeyStart = String.raw`
403
650
  [[ "$(cat "$vk_config")" == "$vk_expected" ]] || fail "Valkey configuration differs from managed settings"
404
651
  service_prune_pid "$vk_data/valkey.pid" "$(command -v valkey-server)" valkey
405
652
  if [[ ! -e "$vk_data/valkey.pid" ]]; then
406
- service_run valkey-server "$vk_config" || fail "Valkey start failed; port 56379 must be available"
653
+ service_run valkey-server "$vk_config" >/dev/null || fail "Valkey start failed; port 56379 must be available"
654
+ vk_state=started
407
655
  fi
408
656
  for ((attempt=0; attempt<20; attempt++)); do
409
657
  if vk_info="$(VALKEYCLI_AUTH="$vk_password" timeout 0.2 valkey-cli -h 127.0.0.1 -p 56379 --raw INFO server 2>/dev/null)"; then
@@ -419,6 +667,7 @@ const valkeyStart = String.raw`
419
667
  [[ "$(VALKEYCLI_AUTH="$vk_password" timeout 2 valkey-cli -h 127.0.0.1 -p 56379 --raw CONFIG GET bind)" == $'bind\n127.0.0.1' ]] || fail "Valkey endpoint must bind only loopback"
420
668
  [[ "$(VALKEYCLI_AUTH="$vk_password" timeout 2 valkey-cli -h 127.0.0.1 -p 56379 --raw CONFIG GET dir)" == "$(printf 'dir\n%s' "$vk_data")" ]] || fail "Valkey endpoint has a different data directory"
421
669
  REDIS_URL="redis://:$vk_password@127.0.0.1:56379/0"
670
+ printf 'valkey: %s\n' "$vk_state"
422
671
  `;
423
672
  /** Render only declared services; installation and environment-file ownership stay with the caller. */
424
673
  const renderCloudServiceStart = ({ services, postgres, valkey }) => {
@@ -434,6 +683,300 @@ const renderCloudServiceStart = ({ services, postgres, valkey }) => {
434
683
  return `${common}\nstart_services() {\n local SERVICE_UID file attempt\n SERVICE_UID="$(id -u stll-cloud)"\n [[ -d "$STATE" && ! -L "$STATE" && "$(stat -c '%u' "$STATE")" == 0 ]] || fail "Invalid cloud service state directory"\n${bodies.join("")}\n}\n`;
435
684
  };
436
685
  //#endregion
686
+ //#region src/cloud-setup-shell.ts
687
+ const cloudShellProfilePath = "/etc/profile.d/stll-cloud.sh";
688
+ const cloudShellProfileSource = `. ${cloudShellProfilePath}`;
689
+ const quote$1 = (value) => `'${value.replaceAll("'", "'\\''")}'`;
690
+ const renderCloudShellProfile = ({ nodeVersion, bunVersion, rust }) => {
691
+ const node = `/opt/stll-cloud/node/${nodeVersion}/bin`;
692
+ const bun = `/opt/stll-cloud/bun/${bunVersion}`;
693
+ return `# Generated by stll-cloud-setup; managed cloud shell environment.
694
+ # Remove managed entries before prepending, including repeated shell startup.
695
+ _stll_cloud_remaining="\${PATH-}"
696
+ _stll_cloud_result=''
697
+ _stll_cloud_separator=''
698
+ while :; do
699
+ case "$_stll_cloud_remaining" in
700
+ *:*) _stll_cloud_part="\${_stll_cloud_remaining%%:*}"; _stll_cloud_remaining="\${_stll_cloud_remaining#*:}"; _stll_cloud_more=yes ;;
701
+ *) _stll_cloud_part="$_stll_cloud_remaining"; _stll_cloud_more=no ;;
702
+ esac
703
+ case "$_stll_cloud_part" in
704
+ ${quote$1(node)}|${quote$1(bun)}|/usr/local/bin) ;;
705
+ *) _stll_cloud_result="$_stll_cloud_result$_stll_cloud_separator$_stll_cloud_part"; _stll_cloud_separator=: ;;
706
+ esac
707
+ [ "$_stll_cloud_more" = yes ] || break
708
+ done
709
+ export PATH=${quote$1(`${node}:${bun}:/usr/local/bin`)}"\${_stll_cloud_separator:+:}$_stll_cloud_result"
710
+ unset _stll_cloud_remaining _stll_cloud_result _stll_cloud_separator _stll_cloud_part _stll_cloud_more
711
+ ${rust === void 0 ? "" : `export RUSTUP_HOME=/opt/stll-cloud/rustup\nexport CARGO_HOME=/opt/stll-cloud/cargo\nexport RUSTUP_TOOLCHAIN=${quote$1(rust.channel)}\nexport RUSTUP_AUTO_INSTALL=0\n`}`;
712
+ };
713
+ const userStartup = String.raw`
714
+ set -euo pipefail
715
+ for name in .bashrc .profile .bash_profile .bash_login; do
716
+ file="$HOME/$name"
717
+ if [[ ! -e "$file" && ! -L "$file" && "$name" != .bashrc && "$name" != .profile ]]; then continue; fi
718
+ [[ ! -L "$file" ]] || { echo "cloud-setup: shell startup file must not be a symlink: $name" >&2; exit 1; }
719
+ if [[ -e "$file" ]]; then
720
+ [[ -f "$file" && "$(stat -c '%h:%u' "$file")" == "1:$(id -u)" ]] || { echo "cloud-setup: shell startup file must be owned and single-link: $name" >&2; exit 1; }
721
+ fi
722
+ temporary="$(mktemp "$HOME/.stll-cloud-shell.XXXXXX")"
723
+ trap 'rm -f -- "$temporary"' EXIT
724
+ if [[ "$name" == .bashrc ]]; then
725
+ printf '%s\n' '# stll-cloud: early environment' '@SOURCE@' > "$temporary"
726
+ fi
727
+ if [[ -f "$file" ]]; then
728
+ # Remove only exact managed blocks; preserve unrelated startup lines and permissions.
729
+ awk '
730
+ $0 == "# stll-cloud: early environment" || $0 == "# stll-cloud: session environment" {
731
+ if ((getline following) <= 0 || following != "@SOURCE@") exit 1
732
+ next
733
+ }
734
+ { print }
735
+ ' "$file" >> "$temporary" || { echo "cloud-setup: managed shell startup block differs: $name" >&2; exit 1; }
736
+ fi
737
+ printf '%s\n' '# stll-cloud: session environment' '@SOURCE@' >> "$temporary"
738
+ if [[ -f "$file" ]] && cmp -s "$temporary" "$file"; then rm -- "$temporary"; continue; fi
739
+ if [[ -f "$file" ]]; then chmod --reference="$file" "$temporary"; else chmod 644 "$temporary"; fi
740
+ mv -f -- "$temporary" "$file"
741
+ done
742
+ `;
743
+ const renderCloudShellInstall = (options) => {
744
+ const profile = renderCloudShellProfile(options);
745
+ return `install_shell_environment() (
746
+ # Subshell scope retains temporary for EXIT cleanup after a failed command.
747
+ [[ ! -L ${quote$1(cloudShellProfilePath)} ]] || fail 'Managed shell profile must not be a symlink'
748
+ if [[ -e ${quote$1(cloudShellProfilePath)} ]]; then
749
+ [[ -f ${quote$1(cloudShellProfilePath)} && "$(stat -c '%h:%u' ${quote$1(cloudShellProfilePath)})" == 1:0 ]] || fail 'Managed shell profile must be a root-owned single-link file'
750
+ fi
751
+ temporary="$(mktemp)"
752
+ trap 'rm -f -- "$temporary"' EXIT
753
+ cat > "$temporary" <<'STLL_CLOUD_PROFILE'
754
+ ${profile}STLL_CLOUD_PROFILE
755
+ if ! cmp -s "$temporary" ${quote$1(cloudShellProfilePath)}; then install -m 644 "$temporary" ${quote$1(cloudShellProfilePath)}; fi
756
+ runuser -u "$OUTPUT_USER" -- env HOME="$OUTPUT_HOME" bash -c ${quote$1(userStartup.replaceAll("@SOURCE@", cloudShellProfileSource))}
757
+ )
758
+ verify_shell_environment() {
759
+ [[ -f ${quote$1(cloudShellProfilePath)} && ! -L ${quote$1(cloudShellProfilePath)} && "$(stat -c '%h:%u' ${quote$1(cloudShellProfilePath)})" == 1:0 ]] || fail 'Managed shell environment is unavailable; run install'
760
+ [[ "$(sha256sum -- ${quote$1(cloudShellProfilePath)} | cut -d ' ' -f1)" == ${quote$1(createHash("sha256").update(profile).digest("hex"))} ]] || fail 'Managed shell environment differs; run install'
761
+ }
762
+ `;
763
+ };
764
+ //#endregion
765
+ //#region src/cloud-setup-toolchains.ts
766
+ const record$2 = (input) => typeof input === "object" && input !== null && !Array.isArray(input);
767
+ /** Resolve the same tracked snapshot for generation and the mandatory drift check. */
768
+ const resolveCloudToolchains = ({ cloud, files, rust, rustExceptions }) => {
769
+ const source = files["rust-toolchain.toml"];
770
+ const selector = files["rust-toolchain"];
771
+ if (source !== void 0 && selector !== void 0) throw new Error("cloud Rust requires one root rust-toolchain.toml or rust-toolchain file");
772
+ let declared;
773
+ if (source !== void 0 || selector?.trimStart().startsWith("[")) {
774
+ const parsed = parse$1(source ?? selector ?? "");
775
+ if (!record$2(parsed["toolchain"])) throw new Error("cloud Rust requires [toolchain]");
776
+ declared = parsed["toolchain"];
777
+ if (Object.keys(declared).some((key) => ![
778
+ "channel",
779
+ "components",
780
+ "targets",
781
+ "profile"
782
+ ].includes(key))) throw new Error("cloud Rust supports only channel, components, targets and profile");
783
+ if (declared["profile"] !== void 0 && declared["profile"] !== "minimal") throw new Error("cloud Rust requires profile = minimal; declare required components explicitly");
784
+ } else if (selector !== void 0) declared = { channel: selector.trim() };
785
+ const rustEnabled = declared !== void 0 || cloud.rust !== void 0;
786
+ const channel = declared === void 0 ? cloud.rust?.toolchain ?? rust : declared["channel"];
787
+ if (rustEnabled) {
788
+ if (typeof channel !== "string" || !/^\d+\.\d+\.\d+$/.test(channel)) throw new Error("cloud Rust requires an exact stable toolchain");
789
+ if (channel !== rust && !rustExceptions.some(({ pin }) => pin === channel)) throw new Error(`cloud Rust must use shared Rust ${rust} or a recorded rustExceptions pin`);
790
+ if (cloud.rust?.toolchain !== void 0 && cloud.rust.toolchain !== channel) throw new Error("cloud.rust.toolchain conflicts with root Rust toolchain");
791
+ }
792
+ const components = [.../* @__PURE__ */ new Set([...parseCloudRustNames(declared?.["components"], "components"), ...cloud.rust?.components ?? []])].sort();
793
+ const targets = [.../* @__PURE__ */ new Set([...parseCloudRustNames(declared?.["targets"], "targets"), ...cloud.rust?.targets ?? []])].sort();
794
+ const resolveTool = (tool, version) => {
795
+ if (isCloudPackageTool(tool)) return {
796
+ type: "package",
797
+ tool,
798
+ release: getCloudSetupPackage(tool, version)
799
+ };
800
+ if (tool === "cargo-nextest" && !rustEnabled) throw new Error("cloud.tools.cargo-nextest requires a Rust toolchain");
801
+ if (tool === "wasm-bindgen-cli") {
802
+ const lock = files["Cargo.lock"];
803
+ if (lock === void 0) throw new Error("wasm-bindgen-cli requires tracked root Cargo.lock");
804
+ const packages = parse$1(lock)["package"];
805
+ if (!Array.isArray(packages)) throw new Error("Cargo.lock requires package entries for wasm-bindgen-cli");
806
+ const versions = new Set(packages.flatMap((entry) => record$2(entry) && entry["name"] === "wasm-bindgen" ? [entry["version"]] : []));
807
+ if (versions.size !== 1 || !versions.has(version)) throw new Error(`cloud.tools.wasm-bindgen-cli ${version} must equal the unique wasm-bindgen crate version in Cargo.lock (found ${[...versions].join(", ") || "none"})`);
808
+ }
809
+ return {
810
+ type: "artifact",
811
+ tool,
812
+ release: getCloudSetupArtifact(tool, version)
813
+ };
814
+ };
815
+ const tools = cloudExtraTools.flatMap((tool) => {
816
+ const version = cloud.tools?.[tool];
817
+ return version === void 0 ? [] : [resolveTool(tool, version)];
818
+ });
819
+ return {
820
+ rust: rustEnabled && typeof channel === "string" ? {
821
+ channel,
822
+ components,
823
+ targets,
824
+ installer: cloudSetupArtifacts["rustup-init"]
825
+ } : void 0,
826
+ tools
827
+ };
828
+ };
829
+ //#endregion
830
+ //#region src/cloud-setup-tools-install.ts
831
+ const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
832
+ const artifactSelection = (release) => `case "$ARCH" in
833
+ ${["amd64", "arm64"].map((arch) => ` ${arch}) artifact_url=${quote(release[arch].url)}; artifact_sha=${quote(release[arch].sha256)} ;;`).join("\n")}
834
+ esac`;
835
+ const helpers = String.raw`
836
+ fetch_verified() {
837
+ download_file "$1" "$3" 120
838
+ printf '%s %s\n' "$2" "$3" | sha256sum --strict -c - >/dev/null
839
+ }
840
+ `;
841
+ const rustInstall = (rust) => {
842
+ const args = [...rust.components.flatMap((name) => ["--component", name]), ...rust.targets.flatMap((name) => ["--target", name])].map(quote).join(" ");
843
+ return String.raw`
844
+ export RUSTUP_HOME=/opt/stll-cloud/rustup
845
+ export CARGO_HOME=/opt/stll-cloud/cargo
846
+ export RUSTUP_TOOLCHAIN='@RUST@'
847
+ export RUSTUP_AUTO_INSTALL=0
848
+ rust_run() {
849
+ local mode="$1"
850
+ shift
851
+ set -- runuser -u "$OUTPUT_USER" -- env HOME="$OUTPUT_HOME" RUSTUP_HOME="$RUSTUP_HOME" CARGO_HOME="$CARGO_HOME" RUSTUP_AUTO_INSTALL=0 RUSTUP_TOOLCHAIN=@RUST@ RUSTUP_DIST_SERVER=https://static.rust-lang.org RUSTUP_UPDATE_ROOT=https://static.rust-lang.org/rustup PATH="$PATH" "$@"
852
+ case "$mode" in
853
+ local) "$@" ;;
854
+ network) network_run static.rust-lang.org 180 "$@" ;;
855
+ *) fail 'Invalid Rust execution mode' ;;
856
+ esac
857
+ }
858
+ rust_ready() {
859
+ local directory
860
+ for directory in "$RUSTUP_HOME" "$CARGO_HOME"; do
861
+ [[ -d "$directory" && ! -L "$directory" && "$(stat -c '%u' "$directory")" == "$OUTPUT_UID" ]] || return 1
862
+ done
863
+ [[ -x "$CARGO_HOME/bin/rustup" ]] || return 1
864
+ [[ "$(rust_run local "$CARGO_HOME/bin/rustup" --version 2>/dev/null)" == 'rustup @RUSTUP@ '* ]] || return 1
865
+ [[ "$(rust_run local "$CARGO_HOME/bin/rustc" +@RUST@ --version)" == 'rustc @RUST@ '* ]] || return 1
866
+ local host
867
+ host="$(rust_run local "$CARGO_HOME/bin/rustc" +@RUST@ -vV | awk '$1 == "host:" { print $2 }')" || return 1
868
+ @VERIFY_COMPONENTS@
869
+ @VERIFY_TARGETS@
870
+ return 0
871
+ }
872
+ verify_rust() { rust_ready || fail 'Pinned Rust, components or targets are unavailable; run install'; }
873
+ install_rust() (
874
+ if ! command -v cc >/dev/null || ! command -v ld >/dev/null || [[ ! -f /usr/include/stdlib.h ]]; then
875
+ apt_refresh_for build-essential
876
+ apt_install build-essential
877
+ fi
878
+ local artifact_url artifact_sha
879
+ # Subshell scope retains scratch for EXIT cleanup after a failed command.
880
+ scratch="$(mktemp -d)"
881
+ trap 'rm -rf -- "$scratch"' EXIT
882
+ for directory in "$RUSTUP_HOME" "$CARGO_HOME"; do
883
+ [[ ! -L "$directory" ]] || fail 'Rust home must not be a symlink'
884
+ if [[ -e "$directory" ]]; then
885
+ [[ -d "$directory" && "$(stat -c '%u' "$directory")" == "$OUTPUT_UID" ]] || fail 'Rust home ownership differs from the invoking user'
886
+ else
887
+ install -d -m 755 -o "$OUTPUT_UID" -g "$OUTPUT_GID" "$directory"
888
+ fi
889
+ done
890
+ if [[ ! -x "$CARGO_HOME/bin/rustup" ]] || [[ "$(rust_run local "$CARGO_HOME/bin/rustup" --version 2>/dev/null)" != 'rustup @RUSTUP@ '* ]]; then
891
+ @RUSTUP_ARTIFACT@
892
+ fetch_verified "$artifact_url" "$artifact_sha" "$scratch/rustup-init"
893
+ chmod 755 "$scratch" "$scratch/rustup-init"
894
+ rust_run network "$scratch/rustup-init" -y --no-modify-path --default-toolchain none --profile minimal
895
+ fi
896
+ rust_run local "$CARGO_HOME/bin/rustup" set auto-self-update disable
897
+ if ! rust_ready; then
898
+ rust_run network "$CARGO_HOME/bin/rustup" toolchain install @RUST@ --profile minimal --no-self-update @RUST_ARGS@
899
+ fi
900
+ rust_run local "$CARGO_HOME/bin/rustup" default @RUST@
901
+ verify_rust
902
+ for tool in rustup cargo rustc rustdoc @RUST_PROXIES@; do
903
+ # Launchers persist the system homes in fresh shells without profile sourcing.
904
+ printf '%s\n' '#!/bin/sh' 'export RUSTUP_HOME=/opt/stll-cloud/rustup' 'export CARGO_HOME=/opt/stll-cloud/cargo' 'export RUSTUP_AUTO_INSTALL=0' 'export RUSTUP_TOOLCHAIN=@RUST@' "exec /opt/stll-cloud/cargo/bin/$tool \"\$@\"" > "$scratch/$tool"
905
+ if ! cmp -s "$scratch/$tool" "/usr/local/bin/$tool"; then
906
+ install -m 755 "$scratch/$tool" "/usr/local/bin/$tool"
907
+ fi
908
+ done
909
+ )
910
+ `.replaceAll("@RUSTUP@", rust.installer.version).replaceAll("@RUST@", rust.channel).replace("@RUSTUP_ARTIFACT@", artifactSelection(rust.installer)).replace("@RUST_ARGS@", args).replace("@RUST_PROXIES@", Object.entries(cloudRustComponentBinaries).flatMap(([component, binaries]) => rust.components.includes(component) ? Array.from(binaries) : []).join(" ")).replace("@VERIFY_COMPONENTS@", rust.components.map((name) => `rust_run local "$CARGO_HOME/bin/rustup" component list --toolchain ${quote(rust.channel)} --installed | awk -v name=${quote(name)} -v host="$host" '$1 == name || $1 == name "-" host { found=1 } END { exit !found }' || return 1`).join("\n ")).replace("@VERIFY_TARGETS@", rust.targets.map((name) => `rust_run local "$CARGO_HOME/bin/rustup" target list --toolchain ${quote(rust.channel)} --installed | awk -v name=${quote(name)} '$1 == name { found=1 } END { exit !found }' || return 1`).join("\n "));
911
+ };
912
+ const packageToolInstall = ({ tool, release }) => {
913
+ const name = tool.replaceAll("-", "_");
914
+ return {
915
+ name,
916
+ text: `verify_${name}() {
917
+ [[ -x ${quote(release.binary)} && "$(${quote(release.binary)} --version 2>/dev/null)" == ${quote(release.identity)}* ]] || return 1
918
+ [[ "$(dpkg-query -W -f='\${Version}' ${quote(release.package)} 2>/dev/null)" == ${quote(`${release.version}-`)}* ]]
919
+ }
920
+ install_${name}() {
921
+ if ! verify_${name}; then
922
+ apt_network update
923
+ apt_install ${quote(release.package)}
924
+ verify_${name} || fail ${quote(`Installed ${tool} version differs from its pin`)}
925
+ fi
926
+ }
927
+ `
928
+ };
929
+ };
930
+ const artifactToolInstall = ({ tool, release }) => {
931
+ const directory = `/opt/stll-cloud/tools/${tool}/${release.version}`;
932
+ const command = tool === "wasm-bindgen-cli" ? "wasm-bindgen" : tool;
933
+ const marker = `${tool === "cargo-nextest" ? "cargo-nextest" : command} ${release.version}`;
934
+ return {
935
+ name: tool.replaceAll("-", "_"),
936
+ text: `verify_${tool.replaceAll("-", "_")}() {
937
+ ${Object.keys(release.amd64.binaries).map((binary) => `[[ -x ${quote(`${directory}/${binary}`)} ]] || return 1`).join("\n ")}
938
+ [[ "$( ${quote(`${directory}/${command}`)} --version)" == ${quote(marker)} || "$( ${quote(`${directory}/${command}`)} --version)" == ${quote(`${marker} `)}* ]]
939
+ }
940
+ install_${tool.replaceAll("-", "_")}() (
941
+ local artifact_url artifact_sha
942
+ # Subshell scope retains scratch for EXIT cleanup after a failed command.
943
+ if ! verify_${tool.replaceAll("-", "_")}; then
944
+ scratch="$(mktemp -d)"
945
+ trap 'rm -rf -- "$scratch"' EXIT
946
+ ${artifactSelection(release)}
947
+ fetch_verified "$artifact_url" "$artifact_sha" "$scratch/archive.tar.gz"
948
+ tar -xzf "$scratch/archive.tar.gz" -C "$scratch"
949
+ install -d -m 755 ${quote(directory)}
950
+ case "$ARCH" in
951
+ ${["amd64", "arm64"].map((arch) => ` ${arch})\n${Object.entries(release[arch].binaries).map(([binary, path]) => ` install -m 755 "$scratch/${path}" ${quote(`${directory}/${binary}`)}`).join("\n")}\n ;;`).join("\n")}
952
+ esac
953
+ verify_${tool.replaceAll("-", "_")} || fail ${quote(`Installed ${tool} version differs from its pin`)}
954
+ fi
955
+ ${Object.keys(release.amd64.binaries).map((binary) => ` ln -sfnT -- ${quote(`${directory}/${binary}`)} ${quote(`/usr/local/bin/${binary}`)}`).join("\n")}
956
+ )
957
+ `
958
+ };
959
+ };
960
+ const renderCloudToolInstall = ({ rust, tools }) => {
961
+ const installs = tools.map((entry) => {
962
+ switch (entry.type) {
963
+ case "package": return packageToolInstall(entry);
964
+ case "artifact": return artifactToolInstall(entry);
965
+ default: throw new Error(`Unsupported cloud tool: ${String(entry)}`);
966
+ }
967
+ });
968
+ return `${helpers}${rust === void 0 ? "" : rustInstall(rust)}\n${installs.map(({ text }) => text).join("\n")}
969
+ install_tools() {
970
+ ${rust === void 0 ? ":" : "install_rust"}
971
+ ${installs.map(({ name }) => `install_${name}`).join("\n ")}
972
+ }
973
+ verify_tools() {
974
+ ${rust === void 0 ? ":" : "verify_rust"}
975
+ ${installs.map(({ name }) => `verify_${name} || fail 'Pinned ${name} is unavailable; run install'`).join("\n ")}
976
+ }
977
+ `;
978
+ };
979
+ //#endregion
437
980
  //#region src/toolchain-node.ts
438
981
  const numericComponent = "(?:0|[1-9]\\d*)";
439
982
  const policyPattern = new RegExp(`^${numericComponent}\\.x$`);
@@ -471,6 +1014,14 @@ usage() { echo 'Usage: cloud-setup.sh install|start' >&2; exit 2; }
471
1014
  case "$1" in install|start) MODE="$1" ;; *) usage ;; esac
472
1015
  fail() { echo "cloud-setup: $*" >&2; exit 1; }
473
1016
  trap 'echo "cloud-setup: command failed at line $LINENO" >&2' ERR
1017
+ # Each phase logs its start and duration so a stalled phase is visible in the task log.
1018
+ step() {
1019
+ local name="$1" started="$SECONDS"
1020
+ shift
1021
+ echo "cloud-setup: $name started" >&2
1022
+ "$@"
1023
+ echo "cloud-setup: $name done in $((SECONDS - started))s" >&2
1024
+ }
474
1025
  [[ -f /etc/os-release ]] || fail 'Ubuntu 24.04 is required'
475
1026
  . /etc/os-release
476
1027
  [[ "$ID" == ubuntu && "$VERSION_ID" == 24.04 && "$VERSION_CODENAME" == noble ]] || fail 'Only Ubuntu 24.04 (noble) is supported'
@@ -506,22 +1057,24 @@ case "$(uname -m)" in
506
1057
  *) fail 'Only amd64 and arm64 are supported' ;;
507
1058
  esac
508
1059
 
1060
+ @NETWORK@
1061
+
509
1062
  runtime_ready() {
510
1063
  [[ -x "$NODE_DIR/bin/node" && "$($NODE_DIR/bin/node --version)" == "v$NODE_VERSION" ]] || fail 'Pinned Node is unavailable; run install'
511
1064
  [[ -x "$BUN_DIR/bun" && "$($BUN_DIR/bun --version)" == "$BUN_VERSION" ]] || fail 'Pinned Bun is unavailable; run install'
512
1065
  }
513
1066
  install_runtimes() {
514
- if ! command -v curl >/dev/null || ! command -v unzip >/dev/null || ! command -v xz >/dev/null || ! command -v openssl >/dev/null || ! command -v gpg >/dev/null; then
515
- apt-get update
516
- DEBIAN_FRONTEND=noninteractive apt_install ca-certificates curl unzip xz-utils openssl gnupg util-linux
1067
+ if ! command -v curl >/dev/null || ! command -v unzip >/dev/null || ! command -v xz >/dev/null || ! command -v openssl >/dev/null || ! command -v gpg >/dev/null || ! ldconfig -p | awk '$1 == "libatomic.so.1" { found=1 } END { exit !found }'; then
1068
+ apt_refresh_for ca-certificates curl unzip xz-utils openssl gnupg util-linux libatomic1
1069
+ DEBIAN_FRONTEND=noninteractive apt_install ca-certificates curl unzip xz-utils openssl gnupg util-linux libatomic1
517
1070
  fi
518
1071
  local scratch node_archive bun_archive bun_folder
519
1072
  scratch="$(mktemp -d)"
520
1073
  trap 'rm -rf -- "$scratch"' RETURN
521
1074
  if [[ ! -x "$NODE_DIR/bin/node" ]] || [[ "$($NODE_DIR/bin/node --version)" != "v$NODE_VERSION" ]]; then
522
1075
  node_archive="node-v$NODE_VERSION-linux-$NODE_ARCH.tar.xz"
523
- curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --tlsv1.2 "https://nodejs.org/dist/v$NODE_VERSION/$node_archive" -o "$scratch/$node_archive"
524
- curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --tlsv1.2 "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt" -o "$scratch/node-sums"
1076
+ download_file "https://nodejs.org/dist/v$NODE_VERSION/$node_archive" "$scratch/$node_archive" 120
1077
+ download_file "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt" "$scratch/node-sums" 45
525
1078
  (cd "$scratch"; awk -v name="$node_archive" '$2 == name { print; count++ } END { if (count != 1) exit 1 }' node-sums | sha256sum --strict -c -)
526
1079
  install -d -m 755 "$NODE_DIR"
527
1080
  tar -xJf "$scratch/$node_archive" --strip-components=1 -C "$NODE_DIR"
@@ -529,8 +1082,8 @@ install_runtimes() {
529
1082
  if [[ ! -x "$BUN_DIR/bun" ]] || [[ "$($BUN_DIR/bun --version)" != "$BUN_VERSION" ]]; then
530
1083
  bun_folder="bun-linux-$BUN_ARCH"
531
1084
  bun_archive="$bun_folder.zip"
532
- curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --tlsv1.2 "https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION/$bun_archive" -o "$scratch/$bun_archive"
533
- curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --tlsv1.2 "https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION/SHASUMS256.txt" -o "$scratch/bun-sums"
1085
+ download_file "https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION/$bun_archive" "$scratch/$bun_archive" 120
1086
+ download_file "https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION/SHASUMS256.txt" "$scratch/bun-sums" 45
534
1087
  (cd "$scratch"; awk -v name="$bun_archive" '$2 == name || $2 == "*" name { print $1 " " name; count++ } END { if (count != 1) exit 1 }' bun-sums | sha256sum --strict -c -)
535
1088
  unzip -q "$scratch/$bun_archive" -d "$scratch/bun"
536
1089
  install -d -m 755 "$BUN_DIR"
@@ -546,7 +1099,7 @@ install_runtimes() {
546
1099
  }
547
1100
 
548
1101
  dependency_install() {
549
- local directory
1102
+ local directory download_hosts
550
1103
  for directory in /var/cache/stll-cloud /var/cache/stll-cloud/bun; do
551
1104
  [[ ! -L "$directory" ]] || fail 'Dependency cache parent must not be a symlink'
552
1105
  if [[ -e "$directory" ]]; then
@@ -559,24 +1112,52 @@ dependency_install() {
559
1112
  [[ -d "$BUN_CACHE" && "$(stat -c '%u' "$BUN_CACHE")" == "$OUTPUT_UID" ]] || fail 'Dependency cache ownership differs from the invoking user'
560
1113
  fi
561
1114
  install -d -m 700 -o "$OUTPUT_UID" -g "$OUTPUT_GID" "$BUN_CACHE"
562
- runuser -u "$OUTPUT_USER" -- env HOME="$OUTPUT_HOME" BUN_INSTALL_CACHE_DIR="$BUN_CACHE" PATH="$PATH" "$BUN_DIR/bun" install --frozen-lockfile
1115
+ # Report hostnames only: lockfile/config URLs may contain credentials.
1116
+ # These are known install hosts; lifecycle scripts can contact other hosts.
1117
+ download_hosts="$(HOME="$OUTPUT_HOME" "$NODE_DIR/bin/node" -e '
1118
+ const fs = require("node:fs");
1119
+ const path = require("node:path");
1120
+ const hosts = new Set(["registry.npmjs.org"]);
1121
+ const inputs = [process.env.BUN_CONFIG_REGISTRY ?? "", process.env.NPM_CONFIG_REGISTRY ?? "", process.env.npm_config_registry ?? ""];
1122
+ for (const file of ["bun.lock", ".npmrc", "bunfig.toml", path.join(process.env.HOME, ".npmrc"), path.join(process.env.HOME, ".bunfig.toml"), path.join(process.env.XDG_CONFIG_HOME ?? process.env.HOME, ".bunfig.toml")]) {
1123
+ if (fs.existsSync(file)) inputs.push(fs.readFileSync(file, "utf8"));
1124
+ }
1125
+ for (const input of inputs) {
1126
+ for (const match of input.matchAll(/(?:git\+)?https?:\/\/[^\s"\x27<>\\]+/g)) {
1127
+ try { hosts.add(new URL(match[0].replace(/^git\+/, "")).hostname); } catch {}
1128
+ }
1129
+ if (/github:/.test(input)) { hosts.add("github.com"); hosts.add("codeload.github.com"); }
1130
+ }
1131
+ process.stdout.write([...hosts].sort().join(","));
1132
+ ')"
1133
+ # Bun 1.4.3 socket inactivity timeout, in seconds; the outer bound includes scripts.
1134
+ network_run "$download_hosts" 240 runuser -u "$OUTPUT_USER" -- env HOME="$OUTPUT_HOME" BUN_INSTALL_CACHE_DIR="$BUN_CACHE" PATH="$PATH" BUN_CONFIG_HTTP_IDLE_TIMEOUT=20 "$BUN_DIR/bun" @INSTALL_ARGS@
563
1135
  }
564
1136
 
565
1137
  @SERVICE_INSTALL@
566
1138
  @SERVICE_START@
567
1139
 
1140
+ @TOOL_INSTALL@
1141
+
1142
+ @INPUT_VALIDATION@
1143
+
568
1144
  @ENV_FUNCTIONS@
569
1145
 
570
1146
  if [[ "$MODE" == install ]]; then
571
- install_runtimes
572
- install_services
573
- dependency_install
1147
+ step runtimes install_runtimes
1148
+ step services install_services
1149
+ step tools install_tools
1150
+ step shell install_shell_environment
1151
+ step dependencies dependency_install
574
1152
  exit 0
575
1153
  fi
576
- runtime_ready
1154
+ step runtime-check runtime_ready
1155
+ step tool-check verify_tools
1156
+ step shell install_shell_environment
1157
+ step shell-check verify_shell_environment
577
1158
  @ENV_VALIDATE@
578
- prepare_state
579
- start_services
1159
+ step state prepare_state
1160
+ step service-start start_services
580
1161
  @ENV_WRITE@
581
1162
  `;
582
1163
  const environmentFunctions = String.raw`
@@ -608,13 +1189,36 @@ write_environment() {
608
1189
  }
609
1190
  `;
610
1191
  const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
611
- const generateCloudSetup = ({ policy, cloud, nodeVersion }) => {
1192
+ const generateCloudSetup = ({ policy, cloud, nodeVersion, files = {}, rustExceptions = [] }) => {
612
1193
  const selected = parseCloudSetup(cloud);
613
1194
  if (selected === void 0) throw new Error("cloud declaration is required");
614
1195
  const exactNode = nodeVersion.trim();
615
1196
  if (valid(exactNode) !== exactNode || !nodeSelectorMatches(exactNode, policy.node)) throw new Error("cloud requires a tracked exact .node-version within the shared Node series");
1197
+ const toolchains = resolveCloudToolchains({
1198
+ cloud: selected,
1199
+ files,
1200
+ rust: policy.rust,
1201
+ rustExceptions
1202
+ });
616
1203
  const services = cloudServices.filter((service) => selected.services.includes(service));
617
1204
  const replacements = /* @__PURE__ */ new Map([
1205
+ ["@INSTALL_ARGS@", selected.install.replace(/^bun /, "")],
1206
+ ["@NETWORK@", renderCloudNetwork()],
1207
+ ["@TOOL_INSTALL@", renderCloudToolInstall(toolchains) + renderCloudShellInstall({
1208
+ nodeVersion: exactNode,
1209
+ bunVersion: policy.bun,
1210
+ rust: toolchains.rust
1211
+ })],
1212
+ ["@INPUT_VALIDATION@", [
1213
+ "rust-toolchain.toml",
1214
+ "rust-toolchain",
1215
+ ...selected.tools?.["wasm-bindgen-cli"] === void 0 ? [] : ["Cargo.lock"]
1216
+ ].flatMap((file) => {
1217
+ const content = files[file];
1218
+ if (content === void 0) return [`[[ ! -e ${shellQuote(file)} && ! -L ${shellQuote(file)} ]] || fail ${shellQuote(`${file} appeared; regenerate cloud-setup.sh`)}`];
1219
+ const hash = createHash("sha256").update(content).digest("hex");
1220
+ return [`[[ -f ${shellQuote(file)} && ! -L ${shellQuote(file)} ]] && [[ "$(sha256sum -- ${shellQuote(file)} | cut -d ' ' -f1)" == ${shellQuote(hash)} ]] || fail ${shellQuote(`${file} changed; regenerate cloud-setup.sh`)}`];
1221
+ }).join("\n")],
618
1222
  ["@BUN@", policy.bun],
619
1223
  ["@NODE@", exactNode],
620
1224
  ["@ENV_ASSIGNMENTS@", selected.envFile === void 0 ? "" : `ENV_FILE=${shellQuote(selected.envFile)}\nENV_MARKER=${shellQuote(cloudEnvironmentMarker)}`],
@@ -640,6 +1244,129 @@ const generateCloudSetup = ({ policy, cloud, nodeVersion }) => {
640
1244
  });
641
1245
  };
642
1246
  //#endregion
1247
+ //#region src/cloud-setup-claude.ts
1248
+ const cloudClaudeSettingsPath = ".claude/settings.json";
1249
+ const sessionStartHook = (script) => ({
1250
+ matcher: "",
1251
+ hooks: [{
1252
+ type: "command",
1253
+ command: `if [ "\${CLAUDE_CODE_REMOTE:-}" = "true" ]; then
1254
+ ${script} start || exit "$?"
1255
+ if [ -n "\${CLAUDE_ENV_FILE:-}" ]; then
1256
+ if [ ! -f ${cloudShellProfilePath} ]; then
1257
+ printf '%s\\n' 'cloud-setup: cloud shell profile is unavailable; run install' >&2
1258
+ exit 1
1259
+ fi
1260
+ printf '\\n' >> "$CLAUDE_ENV_FILE" || exit "$?"
1261
+ cat ${cloudShellProfilePath} >> "$CLAUDE_ENV_FILE"
1262
+ fi
1263
+ fi`
1264
+ }]
1265
+ });
1266
+ const cloudSessionStartHook = sessionStartHook(`bash "$CLAUDE_PROJECT_DIR/${cloudSetupPath}"`);
1267
+ const previousCloudSessionStartHooks = [sessionStartHook(`bash ${cloudSetupPath}`)];
1268
+ const cloudAttribution = {
1269
+ commit: "",
1270
+ pr: ""
1271
+ };
1272
+ const record$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1273
+ const array = (value) => Array.isArray(value);
1274
+ const parseSettings = (text) => {
1275
+ try {
1276
+ return JSON.parse(text, (_key, value) => {
1277
+ if (typeof value === "number" && (!Number.isFinite(value) || Number.isInteger(value) && !Number.isSafeInteger(value))) throw new Error("settings contains a number that cannot be safely preserved");
1278
+ return value;
1279
+ });
1280
+ } catch (error) {
1281
+ if (error instanceof SyntaxError) throw new Error("settings must contain valid JSON");
1282
+ throw error;
1283
+ }
1284
+ };
1285
+ const readSettings = (text) => {
1286
+ const settings = text === void 0 ? {} : parseSettings(text);
1287
+ if (text !== void 0 && parseDocument(text, { uniqueKeys: true }).errors.length > 0) throw new Error("settings contains duplicate or ambiguous JSON keys");
1288
+ if (!record$1(settings)) throw new Error("settings must be a JSON object");
1289
+ if (settings["disableAllHooks"] === true) throw new Error("disableAllHooks prevents SessionStart from running");
1290
+ const hooks = settings["hooks"] === void 0 ? {} : settings["hooks"];
1291
+ if (!record$1(hooks)) throw new Error("hooks must be a JSON object");
1292
+ const sessionStart = hooks["SessionStart"] === void 0 ? [] : hooks["SessionStart"];
1293
+ if (!array(sessionStart) || sessionStart.some((entry) => !record$1(entry) || !array(entry["hooks"]))) throw new Error("hooks.SessionStart must be an array of hook groups");
1294
+ return {
1295
+ settings,
1296
+ hooks,
1297
+ sessionStart
1298
+ };
1299
+ };
1300
+ const cloudHookGroups = (sessionStart) => sessionStart.filter((entry) => record$1(entry) && array(entry["hooks"]) && entry["hooks"].some((hook) => record$1(hook) && typeof hook["command"] === "string" && hook["command"].includes(".agents/cloud-setup.sh")));
1301
+ const cloudSessionStartFix = `preserve unrelated settings in ${cloudClaudeSettingsPath}, repair its JSON/hooks structure and set disableAllHooks to false if needed, and ensure hooks.SessionStart contains exactly this cloud setup entry (replace conflicting cloud setup entries):\n` + JSON.stringify(cloudSessionStartHook, null, 2);
1302
+ const cloudAttributionFix = `preserve unrelated settings in ${cloudClaudeSettingsPath} and set its top-level attribution to exactly:\n` + JSON.stringify({ attribution: cloudAttribution }, null, 2);
1303
+ const sessionStartState = (sessionStart) => {
1304
+ const groups = cloudHookGroups(sessionStart);
1305
+ const group = groups.at(0);
1306
+ if (group === void 0) return { type: "missing" };
1307
+ if (groups.length === 1 && isDeepStrictEqual(group, cloudSessionStartHook)) return { type: "current" };
1308
+ if (groups.length === 1 && previousCloudSessionStartHooks.some((hook) => isDeepStrictEqual(group, hook))) return {
1309
+ type: "previous",
1310
+ group
1311
+ };
1312
+ return { type: "conflict" };
1313
+ };
1314
+ const attributionState = (settings) => {
1315
+ const attribution = settings["attribution"];
1316
+ if (attribution === void 0) return "missing";
1317
+ return isDeepStrictEqual(attribution, cloudAttribution) ? "current" : "conflict";
1318
+ };
1319
+ const mergeError = (reason, fix) => /* @__PURE__ */ new Error(`cannot safely merge ${cloudClaudeSettingsPath}: ${reason}; ${fix}`);
1320
+ const readMergeableSettings = (text) => {
1321
+ try {
1322
+ return readSettings(text);
1323
+ } catch (error) {
1324
+ throw mergeError(error instanceof Error ? error.message : String(error), cloudSessionStartFix);
1325
+ }
1326
+ };
1327
+ /** Add the cloud hook and attribution; never overwrite conflicting values. */
1328
+ const mergeCloudClaudeSettings = (text) => {
1329
+ const { settings, hooks, sessionStart } = readMergeableSettings(text);
1330
+ const hook = sessionStartState(sessionStart);
1331
+ if (hook.type === "conflict") throw mergeError("existing cloud setup SessionStart hook differs", cloudSessionStartFix);
1332
+ const attribution = attributionState(settings);
1333
+ if (attribution === "conflict") throw mergeError("existing attribution differs", cloudAttributionFix);
1334
+ if (hook.type === "current" && attribution === "current" && text !== void 0) return text;
1335
+ const mergedSessionStart = (() => {
1336
+ switch (hook.type) {
1337
+ case "current": return sessionStart;
1338
+ case "missing": return [...sessionStart, cloudSessionStartHook];
1339
+ case "previous": return sessionStart.map((entry) => entry === hook.group ? cloudSessionStartHook : entry);
1340
+ default: throw new Error(`Unsupported hook state: ${String(hook)}`);
1341
+ }
1342
+ })();
1343
+ return `${JSON.stringify({
1344
+ ...settings,
1345
+ attribution: cloudAttribution,
1346
+ hooks: {
1347
+ ...hooks,
1348
+ SessionStart: mergedSessionStart
1349
+ }
1350
+ }, null, 2)}\n`;
1351
+ };
1352
+ /** Settings formatting and unrelated hooks do not affect the generated contract. */
1353
+ const hasCloudSessionStartHook = (text) => {
1354
+ if (text === void 0) return false;
1355
+ try {
1356
+ return sessionStartState(readSettings(text).sessionStart).type === "current";
1357
+ } catch {
1358
+ return false;
1359
+ }
1360
+ };
1361
+ const hasCloudAttribution = (text) => {
1362
+ if (text === void 0) return false;
1363
+ try {
1364
+ return attributionState(readSettings(text).settings) === "current";
1365
+ } catch {
1366
+ return false;
1367
+ }
1368
+ };
1369
+ //#endregion
643
1370
  //#region src/toolchain-workspaces.ts
644
1371
  /** Match manifest directories with the same positive and negative workspace patterns. */
645
1372
  const workspaceContains = ({ directory, file, patterns, rootMembership }) => {
@@ -806,13 +1533,13 @@ const isDockerDefinitionPath = (file) => {
806
1533
  const isShellScript = (file, text = "") => !excludedInputPath(file) && (/\.(?:sh|bash|zsh)$/.test(file) || /^#![ \t]*(?:\/(?:[^ \t\r\n]+\/)*(?:bash|dash|ksh|zsh|sh)|\/usr\/bin\/env[ \t]+(?:-S[ \t]+)?(?:bash|dash|ksh|zsh|sh))(?=[ \t\r\n]|$)/.test(text));
807
1534
  /** Lockfiles establish ecosystem presence without reading their dependency graphs. */
808
1535
  const toolchainInputKind = (file, text = "") => {
809
- if (file === ".agents/cloud-setup.sh") return "config";
1536
+ if (file === ".agents/cloud-setup.sh" || file === ".claude/settings.json" || file === "Cargo.lock") return "config";
810
1537
  if (githubAutomationFileKind(file) !== void 0) return "config";
811
1538
  if (excludedInputPath(file)) return void 0;
812
1539
  if (pythonDependencyManifestKind(file) === "pipfile-lock" || javascriptDependencyLockfiles.some((name) => file.split("/").at(-1) === name) || /(?:^|\/)uv\.lock$/.test(file)) return "presence";
813
1540
  if (githubAutomationFileKind(file) !== void 0 || isShellScript(file, text) || /(?:^|\/)ci-service-images\.json$/.test(file) || isMiseConfigPath(file) || isPythonDependencyManifest(file) || isDockerDefinitionPath(file) || isComposeDefinitionPath(file) || isKubernetesDefinitionPath(file) || /(?:^|\/)(?:package\.json|consumer-compat\.json|pnpm-workspace\.yaml|stll-toolchain\.json|\.bun-version|\.node-version|\.nvmrc|\.python-version|rust-toolchain(?:\.toml)?|\.tool-versions|\.?mise\.toml|uv\.toml|\.uv\.toml|\.github\/dependabot\.ya?ml|Cargo\.toml)$/.test(file)) return "config";
814
1541
  };
815
1542
  //#endregion
816
- export { nodeSelectorMatches as _, isPythonDependencyManifest as a, pythonDependencyManifestKind as c, isComposeDefinitionPath as d, isKubernetesDefinitionPath as f, nodePolicyValid as g, generateCloudSetup as h, isMiseConfigPath as i, toolchainInputKind as l, cloudSetupPath as m, isDependabotGithubActionsPath as n, isShellScript as o, workspaceContains as p, isDockerDefinitionPath as r, javascriptDependencyLockfiles as s, githubAutomationFileKind as t, containerDocumentImages as u, nodeSupportRangeMatches as v, parseCloudSetup as y };
1543
+ export { nodeSelectorMatches as C, nodePolicyValid as S, parseCloudSetup as T, hasCloudAttribution as _, isPythonDependencyManifest as a, cloudSetupPath as b, pythonDependencyManifestKind as c, isComposeDefinitionPath as d, isKubernetesDefinitionPath as f, cloudSessionStartFix as g, cloudClaudeSettingsPath as h, isMiseConfigPath as i, toolchainInputKind as l, cloudAttributionFix as m, isDependabotGithubActionsPath as n, isShellScript as o, workspaceContains as p, isDockerDefinitionPath as r, javascriptDependencyLockfiles as s, githubAutomationFileKind as t, containerDocumentImages as u, hasCloudSessionStartHook as v, nodeSupportRangeMatches as w, generateCloudSetup as x, mergeCloudClaudeSettings as y };
817
1544
 
818
1545
  //# sourceMappingURL=toolchain-inputs.mjs.map