@treeseed/sdk 0.12.60 → 0.12.61

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 (44) hide show
  1. package/dist/guarantees/index.js +59 -56
  2. package/dist/hosting/contracts.d.ts +0 -19
  3. package/dist/hosting/graph.d.ts +1 -86
  4. package/dist/hosting/graph.js +96 -245
  5. package/dist/local-dev/managed-dev.js +30 -8
  6. package/dist/managed-dependencies.d.ts +3 -0
  7. package/dist/managed-dependencies.js +294 -20
  8. package/dist/operations/services/deploy.js +5 -5
  9. package/dist/operations/services/deployment-readiness.js +5 -4
  10. package/dist/operations/services/git-runner.d.ts +2 -0
  11. package/dist/operations/services/git-runner.js +23 -2
  12. package/dist/operations/services/hosted-service-checks.js +28 -0
  13. package/dist/operations/services/live-hosted-service-checks.js +51 -15
  14. package/dist/operations/services/local-cleanup.d.ts +1 -0
  15. package/dist/operations/services/local-cleanup.js +28 -9
  16. package/dist/operations/services/package-adapters.js +3 -3
  17. package/dist/operations/services/railway-api.d.ts +72 -28
  18. package/dist/operations/services/railway-api.js +321 -876
  19. package/dist/operations/services/railway-cli.d.ts +47 -0
  20. package/dist/operations/services/railway-cli.js +142 -0
  21. package/dist/operations/services/railway-deploy.d.ts +2 -2
  22. package/dist/operations/services/railway-deploy.js +36 -91
  23. package/dist/operations/services/railway-source-policy.d.ts +6 -0
  24. package/dist/operations/services/railway-source-policy.js +52 -9
  25. package/dist/operations/services/repository-save-orchestrator.d.ts +2 -0
  26. package/dist/operations/services/repository-save-orchestrator.js +45 -14
  27. package/dist/operations-types.d.ts +3 -1
  28. package/dist/platform/contracts.d.ts +1 -0
  29. package/dist/platform/deploy-config.js +2 -1
  30. package/dist/reconcile/builtin-adapters.js +519 -684
  31. package/dist/reconcile/desired-state.js +5 -3
  32. package/dist/reconcile/engine.js +34 -28
  33. package/dist/reconcile/live-acceptance.js +2 -11
  34. package/dist/reconcile/providers/railway-iac.d.ts +147 -0
  35. package/dist/reconcile/providers/railway-iac.js +289 -16
  36. package/dist/scenes/runner.js +11 -11
  37. package/dist/scripts/build-dist.js +22 -0
  38. package/dist/workflow/operations.d.ts +12 -0
  39. package/dist/workflow/operations.js +265 -90
  40. package/dist/workflow/runs.d.ts +4 -0
  41. package/dist/workflow/runs.js +4 -0
  42. package/dist/workflow-support.d.ts +1 -1
  43. package/dist/workflow-support.js +3 -1
  44. package/package.json +1 -2
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { createWriteStream, existsSync, mkdirSync, rmSync, renameSync, chmodSync, copyFileSync, readFileSync, readdirSync } from "node:fs";
2
+ import { createWriteStream, existsSync, mkdirSync, mkdtempSync, rmSync, renameSync, chmodSync, copyFileSync, readFileSync, readdirSync } from "node:fs";
3
3
  import { request as httpRequest } from "node:http";
4
4
  import { request as httpsRequest } from "node:https";
5
5
  import { platform as osPlatform, arch as osArch } from "node:os";
@@ -17,14 +17,40 @@ const GH_ASSETS = [
17
17
  { platform: "darwin", arch: "x64", assetName: `gh_${GH_VERSION}_macOS_amd64.zip`, archiveKind: "zip" },
18
18
  { platform: "darwin", arch: "arm64", assetName: `gh_${GH_VERSION}_macOS_arm64.zip`, archiveKind: "zip" }
19
19
  ];
20
- const NPM_TOOLS = [
21
- { name: "wrangler", packageName: "wrangler", binName: "wrangler", version: "4.86.0" },
20
+ const RAILWAY_VERSION = "5.23.2";
21
+ const RAILWAY_RELEASE_BASE_URL = `https://github.com/railwayapp/cli/releases/download/v${RAILWAY_VERSION}`;
22
+ const RAILWAY_ASSETS = [
22
23
  {
23
- name: "railway",
24
- packageName: "@railway/cli",
25
- binName: "railway",
26
- version: "5.23.2"
24
+ platform: "linux",
25
+ arch: "x64",
26
+ assetName: `railway-v${RAILWAY_VERSION}-x86_64-unknown-linux-gnu.tar.gz`,
27
+ archiveKind: "tar.gz",
28
+ sha256: "ced014a566bc273ce87463678a2d1e5d9ee02f165a832d5d2fd27c201855145b"
29
+ },
30
+ {
31
+ platform: "linux",
32
+ arch: "arm64",
33
+ assetName: `railway-v${RAILWAY_VERSION}-aarch64-unknown-linux-musl.tar.gz`,
34
+ archiveKind: "tar.gz",
35
+ sha256: "b6100a01d0bd5d349f39e796a907e77f45dadb42f99eee558a1aa7bb254b973e"
36
+ },
37
+ {
38
+ platform: "darwin",
39
+ arch: "x64",
40
+ assetName: `railway-v${RAILWAY_VERSION}-x86_64-apple-darwin.tar.gz`,
41
+ archiveKind: "tar.gz",
42
+ sha256: "403721baa47c2afd0391190310ae8aaf9d671d3ac3d7db123219686b2762bea3"
27
43
  },
44
+ {
45
+ platform: "darwin",
46
+ arch: "arm64",
47
+ assetName: `railway-v${RAILWAY_VERSION}-aarch64-apple-darwin.tar.gz`,
48
+ archiveKind: "tar.gz",
49
+ sha256: "83ddc35f9a5ec1a8adb4cf6a024f23227b109832b2d354633a9668c47acb02fa"
50
+ }
51
+ ];
52
+ const NPM_TOOLS = [
53
+ { name: "wrangler", packageName: "wrangler", binName: "wrangler", version: "4.86.0" },
28
54
  { name: "copilot", packageName: "@github/copilot", binName: "copilot", version: "1.0.39" },
29
55
  { name: "copilot-language-server", packageName: "@github/copilot-language-server", binName: "copilot-language-server", version: "1.480.0" }
30
56
  ];
@@ -76,9 +102,19 @@ function currentPlatformAsset() {
76
102
  }
77
103
  return GH_ASSETS.find((asset) => asset.platform === platform && asset.arch === arch) ?? null;
78
104
  }
105
+ function currentRailwayPlatformAsset() {
106
+ const platform = osPlatform();
107
+ const arch = osArch();
108
+ if (platform !== "linux" && platform !== "darwin") return null;
109
+ if (arch !== "x64" && arch !== "arm64") return null;
110
+ return RAILWAY_ASSETS.find((asset) => asset.platform === platform && asset.arch === arch) ?? null;
111
+ }
79
112
  function managedGhBin(env = process.env) {
80
113
  return resolve(resolveToolsHome(env), "gh", GH_VERSION, platformKey(), "bin", "gh");
81
114
  }
115
+ function managedRailwayBin(env = process.env) {
116
+ return resolve(resolveToolsHome(env), "railway", RAILWAY_VERSION, platformKey(), "bin", "railway");
117
+ }
82
118
  function tokenEnv(env = process.env) {
83
119
  const translated = withTreeseedServiceCredentialEnv(env);
84
120
  const ghToken = translated.GH_TOKEN?.trim() || translated.GITHUB_TOKEN?.trim() || "";
@@ -247,28 +283,126 @@ function npmBackedDependenciesAvailable() {
247
283
  }
248
284
  }
249
285
  function resolveNpmInstallCommand(env = process.env) {
286
+ const installArgs = ["install", "--ignore-scripts", "--prefer-offline", "--workspaces=false", "--no-audit", "--no-fund"];
250
287
  const npmCommandOverride = env.TREESEED_NPM_INSTALL_COMMAND;
251
288
  if (npmCommandOverride?.trim()) {
252
289
  return {
253
290
  command: npmCommandOverride,
254
- args: ["install", "--no-audit", "--no-fund"],
255
- display: [npmCommandOverride, "install", "--no-audit", "--no-fund"]
291
+ args: installArgs,
292
+ display: [npmCommandOverride, ...installArgs]
256
293
  };
257
294
  }
258
295
  const npmExecPath = env.npm_execpath || env.NPM_EXEC_PATH;
259
296
  if (npmExecPath?.trim()) {
260
297
  return {
261
298
  command: process.execPath,
262
- args: [npmExecPath, "install", "--no-audit", "--no-fund"],
263
- display: [process.execPath, npmExecPath, "install", "--no-audit", "--no-fund"]
299
+ args: [npmExecPath, ...installArgs],
300
+ display: [process.execPath, npmExecPath, ...installArgs]
264
301
  };
265
302
  }
266
303
  return {
267
304
  command: "npm",
268
- args: ["install", "--no-audit", "--no-fund"],
269
- display: ["npm", "install", "--no-audit", "--no-fund"]
305
+ args: installArgs,
306
+ display: ["npm", ...installArgs]
270
307
  };
271
308
  }
309
+ function esbuildPlatformPackage() {
310
+ const platform = osPlatform();
311
+ const arch = osArch();
312
+ if (platform === "linux" && ["x64", "arm64", "arm", "ia32", "ppc64", "riscv64", "s390x"].includes(arch)) return `@esbuild/linux-${arch}`;
313
+ if (platform === "darwin" && ["x64", "arm64"].includes(arch)) return `@esbuild/darwin-${arch}`;
314
+ if (platform === "win32" && ["x64", "arm64", "ia32"].includes(arch)) return `@esbuild/win32-${arch}`;
315
+ return null;
316
+ }
317
+ function collectInstalledNativeDependencyIssues(tenantRoot) {
318
+ return collectNativeDependencyRepairs(tenantRoot).map((repair) => repair.issue);
319
+ }
320
+ function collectNativeDependencyRepairs(tenantRoot) {
321
+ const binaryPackage = esbuildPlatformPackage();
322
+ if (!binaryPackage) return [];
323
+ try {
324
+ const lock = JSON.parse(readFileSync(resolve(tenantRoot, "package-lock.json"), "utf8"));
325
+ const packages = lock.packages ?? {};
326
+ const repairs = [];
327
+ for (const [packagePath, entry] of Object.entries(packages)) {
328
+ if (packagePath !== "node_modules/esbuild" && !packagePath.endsWith("/node_modules/esbuild")) continue;
329
+ if (!existsSync(resolve(tenantRoot, packagePath, "package.json"))) continue;
330
+ const binaryPath = `${packagePath.slice(0, -"esbuild".length)}${binaryPackage}`;
331
+ const expectedBinary = packages[binaryPath];
332
+ if (!expectedBinary?.version) continue;
333
+ const installedBinaryPath = resolve(tenantRoot, binaryPath, "package.json");
334
+ if (!existsSync(installedBinaryPath)) {
335
+ repairs.push({
336
+ packagePath: binaryPath,
337
+ packageName: binaryPackage,
338
+ version: expectedBinary.version,
339
+ integrity: expectedBinary.integrity,
340
+ issue: `${binaryPath}: missing native binary ${expectedBinary.version} for ${packagePath}`
341
+ });
342
+ continue;
343
+ }
344
+ const installedBinary = JSON.parse(readFileSync(installedBinaryPath, "utf8"));
345
+ if (installedBinary.version !== expectedBinary.version || installedBinary.version !== entry.version) {
346
+ repairs.push({
347
+ packagePath: binaryPath,
348
+ packageName: binaryPackage,
349
+ version: expectedBinary.version,
350
+ integrity: expectedBinary.integrity,
351
+ issue: `${binaryPath}: native binary ${installedBinary.version ?? "(missing version)"} does not match host ${entry.version ?? "(missing version)"}`
352
+ });
353
+ }
354
+ }
355
+ return repairs;
356
+ } catch {
357
+ return [];
358
+ }
359
+ }
360
+ function resolveNpmPackCommand(env, spec, destination) {
361
+ const args = ["pack", spec, "--json", "--ignore-scripts", "--pack-destination", destination];
362
+ const npmExecPath = env.npm_execpath || env.NPM_EXEC_PATH;
363
+ return npmExecPath?.trim() ? { command: process.execPath, args: [npmExecPath, ...args] } : { command: "npm", args };
364
+ }
365
+ function repairInstalledNativeDependencies(tenantRoot, options) {
366
+ const repairs = collectNativeDependencyRepairs(tenantRoot);
367
+ if (repairs.length === 0) return null;
368
+ const tempParent = resolve(tenantRoot, ".treeseed", "tmp");
369
+ mkdirSync(tempParent, { recursive: true });
370
+ const tempRoot = mkdtempSync(resolve(tempParent, "native-dependency-"));
371
+ try {
372
+ for (const repair of repairs) {
373
+ options.write?.(`Hydrating ${repair.packageName}@${repair.version} for ${repair.packagePath}.`);
374
+ const pack = resolveNpmPackCommand(options.env, `${repair.packageName}@${repair.version}`, tempRoot);
375
+ const packed = options.spawn(pack.command, pack.args, {
376
+ cwd: tenantRoot,
377
+ env: { ...options.env, TREESEED_MANAGED_NPM_INSTALL: "1" },
378
+ stdio: "pipe",
379
+ encoding: "utf8"
380
+ });
381
+ if (packed.status !== 0 || packed.error) return null;
382
+ let metadata;
383
+ try {
384
+ metadata = JSON.parse(String(packed.stdout ?? ""));
385
+ } catch {
386
+ return null;
387
+ }
388
+ const artifact = metadata[0];
389
+ if (!artifact?.filename || repair.integrity && artifact.integrity !== repair.integrity) return null;
390
+ const target = resolve(tenantRoot, repair.packagePath);
391
+ rmSync(target, { recursive: true, force: true });
392
+ mkdirSync(target, { recursive: true });
393
+ const extracted = options.spawn("tar", ["-xzf", resolve(tempRoot, artifact.filename), "-C", target, "--strip-components=1"], {
394
+ cwd: tenantRoot,
395
+ env: options.env,
396
+ stdio: "pipe",
397
+ encoding: "utf8"
398
+ });
399
+ if (extracted.status !== 0 || extracted.error) return null;
400
+ }
401
+ return collectNativeDependencyRepairs(tenantRoot).length === 0 ? repairs.length : null;
402
+ } finally {
403
+ rmSync(tempRoot, { recursive: true, force: true });
404
+ }
405
+ }
272
406
  function resolveNpmRebuildCommand(env = process.env, packageNames) {
273
407
  const npmExecPath = env.npm_execpath || env.NPM_EXEC_PATH;
274
408
  if (npmExecPath?.trim()) {
@@ -284,6 +418,15 @@ function resolveNpmRebuildCommand(env = process.env, packageNames) {
284
418
  display: ["npm", "rebuild", ...packageNames]
285
419
  };
286
420
  }
421
+ function staleNpmGitClonePath(detail) {
422
+ const match = /destination path '([^']+\/_cacache\/tmp\/git-clone[^/]+\/\.git)' already exists/u.exec(detail);
423
+ if (!match?.[1]) return null;
424
+ const gitPath = resolve(match[1]);
425
+ const cloneRoot = dirname(gitPath);
426
+ if (basename(gitPath) !== ".git" || !basename(cloneRoot).startsWith("git-clone")) return null;
427
+ if (basename(dirname(cloneRoot)) !== "tmp" || basename(dirname(dirname(cloneRoot))) !== "_cacache") return null;
428
+ return cloneRoot;
429
+ }
287
430
  function runNpmBootstrap(options) {
288
431
  const tenantRoot = options.tenantRoot ? resolve(options.tenantRoot) : null;
289
432
  const npmCommand = resolveNpmInstallCommand(options.env);
@@ -308,7 +451,20 @@ function runNpmBootstrap(options) {
308
451
  const nodeModulesMissing = !existsSync(resolve(tenantRoot, "node_modules"));
309
452
  const npmDepsMissing = !npmBackedDependenciesAvailable();
310
453
  const missingRuntimeTools = npmToolsMissingRuntime();
311
- if (!options.force && !nodeModulesMissing && npmDepsMissing && missingRuntimeTools.length > 0) {
454
+ const nativeDependencyIssues = nodeModulesMissing ? [] : collectInstalledNativeDependencyIssues(tenantRoot);
455
+ if (nativeDependencyIssues.length > 0) {
456
+ const repaired = repairInstalledNativeDependencies(tenantRoot, options);
457
+ if (repaired !== null) {
458
+ return [{
459
+ root: tenantRoot,
460
+ command: ["npm", "pack", "<lockfile-pinned-native-dependencies>"],
461
+ status: "installed",
462
+ exitCode: 0,
463
+ detail: `Hydrated ${repaired} lockfile-pinned native package artifact${repaired === 1 ? "" : "s"} without resolving the workspace dependency graph.`
464
+ }];
465
+ }
466
+ }
467
+ if (!nodeModulesMissing && npmDepsMissing && missingRuntimeTools.length > 0) {
312
468
  return [{
313
469
  root: tenantRoot,
314
470
  command: npmCommand.display,
@@ -317,16 +473,16 @@ function runNpmBootstrap(options) {
317
473
  detail: `npm dependencies are installed; rebuilding missing runtime tools: ${missingRuntimeTools.map((tool) => tool.packageName).join(", ")}.`
318
474
  }];
319
475
  }
320
- if (!options.force && !nodeModulesMissing && !npmDepsMissing) {
476
+ if (!nodeModulesMissing && !npmDepsMissing && nativeDependencyIssues.length === 0) {
321
477
  return [{
322
478
  root: tenantRoot,
323
479
  command: npmCommand.display,
324
480
  status: "already-present",
325
481
  exitCode: 0,
326
- detail: "npm dependencies are already installed."
482
+ detail: options.force ? "npm dependencies are already installed; force is limited to Treeseed-managed tool repair." : "npm dependencies are already installed."
327
483
  }];
328
484
  }
329
- options.write?.(`Installing npm dependencies in ${tenantRoot}...`);
485
+ options.write?.(nativeDependencyIssues.length > 0 ? `Repairing npm dependency integrity in ${tenantRoot}: ${nativeDependencyIssues.join("; ")}` : `Installing npm dependencies in ${tenantRoot}...`);
330
486
  const testNpmInstallStatus = options.env.NODE_ENV === "test" ? options.env.TREESEED_TEST_NPM_INSTALL_STATUS : void 0;
331
487
  if (testNpmInstallStatus === "installed" || testNpmInstallStatus === "failed") {
332
488
  const ok2 = testNpmInstallStatus === "installed";
@@ -338,7 +494,7 @@ function runNpmBootstrap(options) {
338
494
  detail: ok2 ? "npm install completed successfully." : "npm install failed."
339
495
  }];
340
496
  }
341
- const result = options.spawn(npmCommand.command, npmCommand.args, {
497
+ const spawnInstall = () => options.spawn(npmCommand.command, npmCommand.args, {
342
498
  cwd: tenantRoot,
343
499
  env: {
344
500
  ...options.env,
@@ -347,8 +503,17 @@ function runNpmBootstrap(options) {
347
503
  stdio: "pipe",
348
504
  encoding: "utf8"
349
505
  });
350
- const detail = `${result.stderr ?? ""}
506
+ let result = spawnInstall();
507
+ let detail = `${result.stderr ?? ""}
351
508
  ${result.stdout ?? ""}`.trim() || result.error?.message || "";
509
+ const staleClone = result.status !== 0 ? staleNpmGitClonePath(detail) : null;
510
+ if (staleClone) {
511
+ options.write?.(`Removing interrupted npm Git clone ${staleClone} and retrying dependency repair once.`);
512
+ rmSync(staleClone, { recursive: true, force: true });
513
+ result = spawnInstall();
514
+ detail = `${result.stderr ?? ""}
515
+ ${result.stdout ?? ""}`.trim() || result.error?.message || "";
516
+ }
352
517
  const ok = result.status === 0 && !result.error;
353
518
  return [{
354
519
  root: tenantRoot,
@@ -429,6 +594,10 @@ function resolveTreeseedToolBinary(toolName, options = {}) {
429
594
  }
430
595
  return locateSystemBinary("gh", spawnSync, options.env ?? process.env);
431
596
  }
597
+ if (toolName === "railway") {
598
+ const managed = managedRailwayBin(options.env);
599
+ return existsSync(managed) ? managed : null;
600
+ }
432
601
  const npmTool = findNpmTool(toolName);
433
602
  if (npmTool) {
434
603
  return resolveNpmToolRuntimeBinary(npmTool);
@@ -664,6 +833,95 @@ async function installGh(options) {
664
833
  rmSync(stagingRoot, { recursive: true, force: true });
665
834
  }
666
835
  }
836
+ async function installRailway(options) {
837
+ const asset = currentRailwayPlatformAsset();
838
+ if (!asset) {
839
+ return report({
840
+ name: "railway",
841
+ kind: "download",
842
+ version: RAILWAY_VERSION,
843
+ source: "not-applicable",
844
+ status: "unsupported",
845
+ required: true,
846
+ detail: "Managed Railway CLI installation supports Linux and macOS on x64 or arm64."
847
+ });
848
+ }
849
+ const binaryPath = managedRailwayBin(options.env);
850
+ if (existsSync(binaryPath)) {
851
+ const check = checkCommand(binaryPath, ["--version"], {
852
+ cwd: options.tenantRoot,
853
+ env: options.env,
854
+ spawn: options.spawn
855
+ });
856
+ if (check.ok && check.stdout.includes(RAILWAY_VERSION)) {
857
+ return report({
858
+ name: "railway",
859
+ kind: "download",
860
+ version: RAILWAY_VERSION,
861
+ source: "managed-cache",
862
+ binaryPath,
863
+ status: options.force ? "repaired" : "already-present",
864
+ required: true,
865
+ detail: check.stdout.split("\n")[0] ?? `Railway CLI ${RAILWAY_VERSION} is installed.`
866
+ });
867
+ }
868
+ }
869
+ const toolsHome = resolveToolsHome(options.env);
870
+ const tmpRoot = resolve(toolsHome, ".tmp", `railway-${process.pid}-${Date.now()}`);
871
+ const archivePath = resolve(tmpRoot, asset.assetName);
872
+ const extractRoot = resolve(tmpRoot, "extract");
873
+ const installRoot = dirname(dirname(binaryPath));
874
+ const stagingRoot = `${installRoot}.staging-${process.pid}-${Date.now()}`;
875
+ try {
876
+ options.write?.(`Installing Railway CLI ${RAILWAY_VERSION}...`);
877
+ rmSync(tmpRoot, { recursive: true, force: true });
878
+ mkdirSync(extractRoot, { recursive: true });
879
+ await options.downloadFile(`${RAILWAY_RELEASE_BASE_URL}/${asset.assetName}`, archivePath);
880
+ const assetHash = sha256File(archivePath);
881
+ if (assetHash !== asset.sha256) {
882
+ throw new Error(`Railway CLI archive hash mismatch for ${asset.assetName}: expected ${asset.sha256}, got ${assetHash}.`);
883
+ }
884
+ const tar = await import("tar");
885
+ await tar.x({ file: archivePath, cwd: extractRoot });
886
+ const extractedBinary = resolve(extractRoot, "railway");
887
+ if (!existsSync(extractedBinary)) throw new Error(`Unable to find railway binary in ${asset.assetName}.`);
888
+ rmSync(stagingRoot, { recursive: true, force: true });
889
+ mkdirSync(resolve(stagingRoot, "bin"), { recursive: true });
890
+ copyFileSync(extractedBinary, resolve(stagingRoot, "bin", "railway"));
891
+ chmodSync(resolve(stagingRoot, "bin", "railway"), 493);
892
+ rmSync(installRoot, { recursive: true, force: true });
893
+ mkdirSync(dirname(installRoot), { recursive: true });
894
+ renameSync(stagingRoot, installRoot);
895
+ const check = checkCommand(binaryPath, ["--version"], { cwd: options.tenantRoot, env: options.env, spawn: options.spawn });
896
+ if (!check.ok || !check.stdout.includes(RAILWAY_VERSION)) {
897
+ throw new Error(check.detail || `Railway CLI ${RAILWAY_VERSION} failed after installation.`);
898
+ }
899
+ return report({
900
+ name: "railway",
901
+ kind: "download",
902
+ version: RAILWAY_VERSION,
903
+ source: "managed-cache",
904
+ binaryPath,
905
+ status: options.force ? "repaired" : "installed",
906
+ required: true,
907
+ detail: check.stdout.split("\n")[0] ?? `Railway CLI ${RAILWAY_VERSION} installed.`
908
+ });
909
+ } catch (error) {
910
+ return report({
911
+ name: "railway",
912
+ kind: "download",
913
+ version: RAILWAY_VERSION,
914
+ source: "managed-cache",
915
+ binaryPath,
916
+ status: "failed",
917
+ required: true,
918
+ detail: error instanceof Error ? error.message : String(error)
919
+ });
920
+ } finally {
921
+ rmSync(tmpRoot, { recursive: true, force: true });
922
+ rmSync(stagingRoot, { recursive: true, force: true });
923
+ }
924
+ }
667
925
  function statusForNpmTool(tool, options) {
668
926
  try {
669
927
  const binaryPath = resolveNpmToolRuntimeBinary(tool);
@@ -808,6 +1066,7 @@ async function installTreeseedDependencies(options = {}) {
808
1066
  const reports = [
809
1067
  systemStatus("git", true, effectiveOptions),
810
1068
  await installGh(effectiveOptions),
1069
+ await installRailway(effectiveOptions),
811
1070
  ...NPM_TOOLS.map((tool) => statusForNpmTool(tool, effectiveOptions)),
812
1071
  ...NPM_PACKAGES.map((pkg) => statusForNpmPackage(pkg)),
813
1072
  systemStatus("docker", false, effectiveOptions),
@@ -844,9 +1103,21 @@ function collectTreeseedDependencyStatus(options = {}) {
844
1103
  required: true,
845
1104
  detail: `GitHub CLI ${GH_VERSION} is not installed in the Treeseed tool cache.`
846
1105
  });
1106
+ const railwayBinary = managedRailwayBin(env);
1107
+ const railwayStatus = report({
1108
+ name: "railway",
1109
+ kind: "download",
1110
+ version: RAILWAY_VERSION,
1111
+ source: "managed-cache",
1112
+ binaryPath: railwayBinary,
1113
+ status: existsSync(railwayBinary) ? "already-present" : "missing",
1114
+ required: true,
1115
+ detail: existsSync(railwayBinary) ? `Railway CLI ${RAILWAY_VERSION} is installed in the Treeseed tool cache.` : `Railway CLI ${RAILWAY_VERSION} is not installed in the Treeseed tool cache.`
1116
+ });
847
1117
  const reports = [
848
1118
  systemStatus("git", true, options),
849
1119
  ghStatus,
1120
+ railwayStatus,
850
1121
  ...NPM_TOOLS.map((tool) => statusForNpmTool(tool, options)),
851
1122
  ...NPM_PACKAGES.map((pkg) => statusForNpmPackage(pkg)),
852
1123
  systemStatus("docker", false, options),
@@ -901,12 +1172,15 @@ function formatTreeseedDependencyReport(result) {
901
1172
  ].join("\n");
902
1173
  }
903
1174
  export {
1175
+ collectInstalledNativeDependencyIssues,
904
1176
  collectTreeseedDependencyStatus,
905
1177
  collectTreeseedToolStatus,
906
1178
  createTreeseedManagedToolEnv,
907
1179
  formatTreeseedDependencyFailureDetails,
908
1180
  formatTreeseedDependencyReport,
909
1181
  installTreeseedDependencies,
1182
+ repairInstalledNativeDependencies,
910
1183
  resolveTreeseedToolBinary,
911
- resolveTreeseedToolCommand
1184
+ resolveTreeseedToolCommand,
1185
+ staleNpmGitClonePath
912
1186
  };
@@ -616,7 +616,7 @@ function defaultStateFromConfig(deployConfig, target) {
616
616
  projectId: serviceConfig?.railway?.projectId ?? null,
617
617
  projectName: serviceConfig?.railway?.projectName ?? sharedDeploymentName(identity),
618
618
  serviceId: serviceConfig?.railway?.serviceId ?? null,
619
- serviceName: serviceConfig?.railway?.serviceName ?? sharedDeploymentName(identity, serviceKey),
619
+ serviceName: serviceConfig?.environments?.[scope]?.serviceName ?? serviceConfig?.railway?.serviceName ?? sharedDeploymentName(identity, serviceKey),
620
620
  workerName: serviceConfig?.cloudflare?.workerName ?? null,
621
621
  rootDir: serviceConfig?.railway?.rootDir ?? serviceConfig?.rootDir ?? null,
622
622
  environment: normalizeRailwayEnvironmentName(serviceConfig?.environments?.[scope]?.railwayEnvironment ?? scope),
@@ -2563,7 +2563,7 @@ function configuredRailwayDestroyTargets(tenantRoot, deployConfig, scope) {
2563
2563
  if (!service || service.enabled === false || (service.provider ?? "railway") !== "railway") {
2564
2564
  continue;
2565
2565
  }
2566
- const baseServiceName = service.railway?.serviceName ?? `${identity.deploymentKey}-${serviceKey === "operationsRunner" ? "operations-runner" : serviceKey}`;
2566
+ const baseServiceName = service.environments?.[normalizedScope]?.serviceName ?? service.railway?.serviceName ?? `${identity.deploymentKey}-${serviceKey === "operationsRunner" ? "operations-runner" : serviceKey}`;
2567
2567
  const runnerPool = serviceKey === "operationsRunner" && service.railway?.runnerPool && typeof service.railway.runnerPool === "object" ? service.railway.runnerPool : null;
2568
2568
  const count = serviceKey === "operationsRunner" ? Math.max(1, Number.parseInt(String(runnerPool?.bootstrapCount ?? 1), 10) || 1) : 1;
2569
2569
  for (let index = 1; index <= count; index += 1) {
@@ -2662,7 +2662,7 @@ async function destroyRailwayResources(tenantRoot, deployConfig, target, { planO
2662
2662
  if (planOnly) {
2663
2663
  operations.push(resourceOperation("railway", "custom-domain", domain.domain, "planned", { id: domain.id }));
2664
2664
  } else {
2665
- const result = await deleteRailwayCustomDomain({ domainId: domain.id, env });
2665
+ const result = await deleteRailwayCustomDomain({ projectId: project.id, environmentId: environment.id, serviceId: railwayService.id, domainId: domain.id, env });
2666
2666
  operations.push(resourceOperation("railway", "custom-domain", domain.domain, result.status, { id: domain.id }));
2667
2667
  }
2668
2668
  }
@@ -2678,7 +2678,7 @@ async function destroyRailwayResources(tenantRoot, deployConfig, target, { planO
2678
2678
  if (planOnly) {
2679
2679
  operations.push(resourceOperation("railway", "volume", volume.name, "planned", { id: volume.id, projectId: project.id }));
2680
2680
  } else {
2681
- const result = await deleteRailwayVolume({ volumeId: volume.id, env });
2681
+ const result = await deleteRailwayVolume({ projectId: project.id, environmentId: environment.id, volumeId: volume.id, env });
2682
2682
  operations.push(resourceOperation("railway", "volume", volume.name, result.status, { id: volume.id, projectId: project.id }));
2683
2683
  }
2684
2684
  }
@@ -2697,7 +2697,7 @@ async function destroyRailwayResources(tenantRoot, deployConfig, target, { planO
2697
2697
  if (planOnly) {
2698
2698
  operations.push(resourceOperation("railway", "environment", environment.name, "planned", { id: environment.id, projectId: project.id }));
2699
2699
  } else {
2700
- const result = await deleteRailwayEnvironment({ environmentId: environment.id, env });
2700
+ const result = await deleteRailwayEnvironment({ projectId: project.id, environmentId: environment.id, env });
2701
2701
  operations.push(resourceOperation("railway", "environment", environment.name, result.status, { id: environment.id, projectId: project.id }));
2702
2702
  }
2703
2703
  }
@@ -2,6 +2,7 @@ import { relative, resolve } from "node:path";
2
2
  import { compileTreeseedHostingGraph, serializeHostingUnit } from "../../hosting/index.js";
3
3
  import { discoverTreeseedPackageAdapters } from "./package-adapters.js";
4
4
  import { configuredRailwayServices } from "./railway-deploy.js";
5
+ import { railwayEnvironmentQualifiedServiceName } from "./railway-source-policy.js";
5
6
  function relRoot(tenantRoot, path) {
6
7
  const value = relative(tenantRoot, path).split("\\").join("/");
7
8
  return value || ".";
@@ -143,8 +144,8 @@ function collectTreeseedDeploymentReadiness(options) {
143
144
  checks.push(check("hosting:api:healthcheckPath", api.config?.healthcheckPath, "/healthz", "API healthcheck path is /healthz.", "Set services.api.railway.healthcheckPath to /healthz.", "healthcheckPath"));
144
145
  }
145
146
  if (railwayApi) {
146
- const expectedServiceName = "treeseed-api";
147
- checks.push(check("railway-config:api:serviceName", railwayApi.serviceName, expectedServiceName, "Railway API service uses the project-wide canonical name.", `Set the API service identity to ${expectedServiceName} for every environment.`, "serviceName"));
147
+ const expectedServiceName = railwayEnvironmentQualifiedServiceName("treeseed-api", environment);
148
+ checks.push(check("railway-config:api:serviceName", railwayApi.serviceName, expectedServiceName, "Railway API service uses its environment-qualified identity.", `Set services.api.environments.${environment}.serviceName to ${expectedServiceName}.`, "serviceName"));
148
149
  checks.push(check("railway-config:api:rootDirectory", relRoot(tenantRoot, railwayApi.rootDir), expectedApiRailwayRoot, "Railway API effective rootDirectory points at the API app.", "Set services.api.railway.rootDir relative to the owning API manifest.", "rootDirectory"));
149
150
  }
150
151
  if (!runner) {
@@ -177,8 +178,8 @@ function collectTreeseedDeploymentReadiness(options) {
177
178
  checks.push(check("hosting:operationsRunner:volumeMountPath", runner.config?.volumeMountPath, "/data", "Runner volume mount path is stable.", "Set services.operationsRunner.railway.volumeMountPath to /data.", "volumeMountPath"));
178
179
  }
179
180
  if (railwayRunner) {
180
- const expectedServiceName = "treeseed-api-operations-runner-01";
181
- checks.push(check("railway-config:operationsRunner:serviceName", railwayRunner.serviceName, expectedServiceName, "Railway runner uses the project-wide canonical service name.", `Set the runner service identity to ${expectedServiceName} for every environment.`, "serviceName"));
181
+ const expectedServiceName = railwayEnvironmentQualifiedServiceName("treeseed-ops-01", environment);
182
+ checks.push(check("railway-config:operationsRunner:serviceName", railwayRunner.serviceName, expectedServiceName, "Railway runner uses its environment-qualified identity.", `Set services.operationsRunner.environments.${environment}.serviceName to ${expectedServiceName}.`, "serviceName"));
182
183
  checks.push(check("railway-config:operationsRunner:rootDirectory", relRoot(tenantRoot, railwayRunner.rootDir), expectedRunnerRailwayRoot, "Railway runner effective rootDirectory points at the API app.", "Set services.operationsRunner.railway.rootDir relative to the owning API manifest.", "rootDirectory"));
183
184
  }
184
185
  if (!database) {
@@ -49,6 +49,8 @@ type RunOptions = {
49
49
  export type TreeseedGitBatchOperation = RunOptions & {
50
50
  args: string[];
51
51
  };
52
+ export declare function treeseedGitCommandUsesRemote(args: string[]): boolean;
53
+ export declare function resolveTreeseedGitCommandTimeoutMs(args: string[], timeoutMs?: number): number | undefined;
52
54
  export declare function classifyTreeseedGitMode(args: string[]): TreeseedGitRunnerMode;
53
55
  export declare function inspectTreeseedGitLocks(cwd: string): TreeseedGitLockDiagnostic;
54
56
  export declare function inspectTreeseedGitLockSet(cwd: string): TreeseedGitLockDiagnostic[];
@@ -1,6 +1,14 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { dirname, relative, resolve } from "node:path";
4
+ const DEFAULT_REMOTE_GIT_TIMEOUT_MS = 6e4;
5
+ const REMOTE_GIT_COMMANDS = /* @__PURE__ */ new Set(["clone", "fetch", "ls-remote", "pull", "push"]);
6
+ function treeseedGitCommandUsesRemote(args) {
7
+ return REMOTE_GIT_COMMANDS.has(args[0] ?? "");
8
+ }
9
+ function resolveTreeseedGitCommandTimeoutMs(args, timeoutMs) {
10
+ return timeoutMs ?? (treeseedGitCommandUsesRemote(args) ? DEFAULT_REMOTE_GIT_TIMEOUT_MS : void 0);
11
+ }
4
12
  function pidAlive(pid) {
5
13
  try {
6
14
  process.kill(pid, 0);
@@ -26,15 +34,26 @@ function gitSync(args, options) {
26
34
  }
27
35
  }
28
36
  }
37
+ if (treeseedGitCommandUsesRemote(args) && !env.GIT_SSH_COMMAND) {
38
+ env.GIT_SSH_COMMAND = "ssh -o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=2";
39
+ }
40
+ const timeoutMs = resolveTreeseedGitCommandTimeoutMs(args, options.timeoutMs);
29
41
  const spawnOptions = {
30
42
  cwd: options.cwd,
31
43
  env,
32
44
  encoding: "utf8",
33
45
  stdio: "pipe",
34
- timeout: options.timeoutMs,
46
+ timeout: timeoutMs,
35
47
  maxBuffer: options.maxBuffer ?? 1024 * 1024 * 32
36
48
  };
37
49
  const result = spawnSync("git", args, spawnOptions);
50
+ if (result.error) {
51
+ const code = result.error.code;
52
+ if (code === "ETIMEDOUT") {
53
+ throw new Error(`Git remote command timed out after ${timeoutMs}ms: git ${args.join(" ")} in ${options.cwd}. Check network and repository access, then rerun the Treeseed command.`);
54
+ }
55
+ throw new Error(`Unable to run git ${args.join(" ")} in ${options.cwd}: ${result.error.message}`);
56
+ }
38
57
  const output = {
39
58
  status: result.status,
40
59
  stdout: result.stdout ?? "",
@@ -472,8 +491,10 @@ export {
472
491
  inspectTreeseedGitLocks,
473
492
  inspectTreeseedWorkspaceGitLocks,
474
493
  recoverTreeseedGitLocks,
494
+ resolveTreeseedGitCommandTimeoutMs,
475
495
  runTreeseedGit,
476
496
  runTreeseedGitBatch,
477
497
  runTreeseedGitOk,
478
- runTreeseedGitText
498
+ runTreeseedGitText,
499
+ treeseedGitCommandUsesRemote
479
500
  };
@@ -236,6 +236,19 @@ function collectTreeseedHostedServiceChecks(options) {
236
236
  status: isImmutableRailwayImageRef(service.imageRef) ? "passed" : "failed",
237
237
  issues: isImmutableRailwayImageRef(service.imageRef) ? [] : [`Production API Railway service ${service.serviceName} is missing an immutable image ref.`]
238
238
  }));
239
+ const observedGitSource = Boolean(observed?.deploymentRepo || observed?.deploymentBranch);
240
+ checks.push(check({
241
+ id: `railway:${service.instanceKey}:production-source-isolation`,
242
+ provider: "railway",
243
+ serviceKey: service.key,
244
+ serviceType,
245
+ target,
246
+ description: `Railway ${service.serviceName} production deployment has no Git source.`,
247
+ expected: { repo: null, branch: null },
248
+ observed: observed ? { repo: observed.deploymentRepo ?? null, branch: observed.deploymentBranch ?? null } : { skipped: true },
249
+ status: observed ? observedGitSource ? "failed" : "passed" : "skipped",
250
+ issues: observed && observedGitSource ? [`Production API Railway service ${service.serviceName} still reports Git source metadata.`] : []
251
+ }));
239
252
  }
240
253
  checks.push(check({
241
254
  id: `railway:${service.instanceKey}:service`,
@@ -304,6 +317,21 @@ function collectTreeseedHostedServiceChecks(options) {
304
317
  issues: !observed || sourceUploadDeployment || actualBranch === service.sourceBranch ? [] : [`Expected deployment branch=${service.sourceBranch}, observed ${actualBranch ?? "(unset)"}.`]
305
318
  }));
306
319
  }
320
+ if (service.sourceCommit) {
321
+ const actualCommit = observed?.deploymentCommitHash ?? null;
322
+ checks.push(check({
323
+ id: `railway:${service.instanceKey}:deployment-commit`,
324
+ provider: "railway",
325
+ serviceKey: service.key,
326
+ serviceType,
327
+ target,
328
+ description: `Railway ${service.serviceName} latest deployment uses the exact promoted commit.`,
329
+ expected: { commitHash: service.sourceCommit },
330
+ observed: observed ? { commitHash: actualCommit } : { skipped: true },
331
+ status: observed ? statusForMatch(actualCommit, service.sourceCommit) : "skipped",
332
+ issues: !observed || actualCommit === service.sourceCommit ? [] : [`Expected deployment commitHash=${service.sourceCommit}, observed ${actualCommit ?? "(unset)"}.`]
333
+ }));
334
+ }
307
335
  if (expectedRootDirectory) {
308
336
  checks.push(check({
309
337
  id: `railway:${service.instanceKey}:deployment-root-directory`,