@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
@@ -0,0 +1,47 @@
1
+ type RailwayCliInput = {
2
+ args: string[];
3
+ env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
4
+ cwd?: string;
5
+ stdin?: string;
6
+ timeoutMs?: number;
7
+ };
8
+ export declare function runRailwayCliJson<T = unknown>({ args, env, cwd, stdin, timeoutMs }: RailwayCliInput): Promise<T>;
9
+ export declare function deleteRailwayVolumeWithCli(input: {
10
+ projectId: string;
11
+ environmentId: string;
12
+ volumeId: string;
13
+ env?: NodeJS.ProcessEnv;
14
+ }): Promise<unknown>;
15
+ export declare function detachRailwayVolumeWithCli(input: {
16
+ projectId: string;
17
+ environmentId: string;
18
+ serviceId: string;
19
+ volumeId: string;
20
+ env?: NodeJS.ProcessEnv;
21
+ }): Promise<unknown>;
22
+ export declare function attachRailwayVolumeWithCli(input: {
23
+ projectId: string;
24
+ environmentId: string;
25
+ serviceId: string;
26
+ volumeId: string;
27
+ env?: NodeJS.ProcessEnv;
28
+ }): Promise<unknown>;
29
+ export declare function updateRailwayVolumeWithCli(input: {
30
+ projectId: string;
31
+ environmentId: string;
32
+ serviceId: string;
33
+ volumeId: string;
34
+ name: string;
35
+ mountPath: string;
36
+ env?: NodeJS.ProcessEnv;
37
+ }): Promise<unknown>;
38
+ export declare function connectRailwayServiceSourceWithCli(input: {
39
+ projectId?: string | null;
40
+ environmentId: string;
41
+ serviceId: string;
42
+ repo?: string | null;
43
+ branch?: string | null;
44
+ image?: string | null;
45
+ env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
46
+ }): Promise<unknown>;
47
+ export {};
@@ -0,0 +1,142 @@
1
+ import { spawn } from "node:child_process";
2
+ import { resolveTreeseedToolCommand } from "../../managed-dependencies.js";
3
+ import { withTreeseedServiceCredentialEnv } from "../../service-credentials.js";
4
+ async function runRailwayCliJson({ args, env = process.env, cwd = process.cwd(), stdin, timeoutMs = 6e4 }) {
5
+ const effectiveEnv = withTreeseedServiceCredentialEnv(env);
6
+ const command = resolveTreeseedToolCommand("railway", { env: effectiveEnv });
7
+ if (!command) {
8
+ throw new Error("The managed Railway CLI is unavailable. Run `npx trsd install --json` before retrying reconciliation.");
9
+ }
10
+ return await new Promise((resolve, reject) => {
11
+ const detached = process.platform !== "win32";
12
+ const child = spawn(command.command, [...command.argsPrefix, ...args], {
13
+ cwd,
14
+ env: effectiveEnv,
15
+ stdio: ["pipe", "pipe", "pipe"],
16
+ detached
17
+ });
18
+ let stdout = "";
19
+ let stderr = "";
20
+ let timedOut = false;
21
+ let settled = false;
22
+ let forceKill = null;
23
+ const killProcessTree = (signal) => {
24
+ if (child.pid && detached) {
25
+ try {
26
+ process.kill(-child.pid, signal);
27
+ return;
28
+ } catch {
29
+ }
30
+ }
31
+ child.kill(signal);
32
+ };
33
+ const timeout = setTimeout(() => {
34
+ timedOut = true;
35
+ killProcessTree("SIGTERM");
36
+ forceKill = setTimeout(() => killProcessTree("SIGKILL"), 2e3);
37
+ }, timeoutMs);
38
+ const finish = (callback) => {
39
+ if (settled) return;
40
+ settled = true;
41
+ clearTimeout(timeout);
42
+ if (forceKill) clearTimeout(forceKill);
43
+ callback();
44
+ };
45
+ child.stdout.setEncoding("utf8");
46
+ child.stderr.setEncoding("utf8");
47
+ child.stdout.on("data", (chunk) => {
48
+ stdout += chunk;
49
+ });
50
+ child.stderr.on("data", (chunk) => {
51
+ stderr += chunk;
52
+ });
53
+ child.on("error", (error) => finish(() => reject(error)));
54
+ child.on("close", (code) => {
55
+ finish(() => {
56
+ if (timedOut) {
57
+ reject(new Error(`Railway CLI timed out after ${timeoutMs}ms: railway ${args.join(" ")}`));
58
+ return;
59
+ }
60
+ if (code !== 0) {
61
+ reject(new Error(`Railway CLI failed (${code ?? "signal"}): ${stderr.trim() || stdout.trim() || args[0]}`));
62
+ return;
63
+ }
64
+ try {
65
+ resolve(stdout.trim() ? JSON.parse(stdout) : {});
66
+ } catch (error) {
67
+ reject(new Error(`Railway CLI returned invalid JSON for ${args.join(" ")}: ${error instanceof Error ? error.message : String(error)}`));
68
+ }
69
+ });
70
+ });
71
+ if (stdin !== void 0) child.stdin.end(stdin);
72
+ else child.stdin.end();
73
+ });
74
+ }
75
+ function volumeScope(projectId, environmentId, serviceId) {
76
+ return ["volume", "--project", projectId, "--environment", environmentId, ...serviceId ? ["--service", serviceId] : []];
77
+ }
78
+ async function deleteRailwayVolumeWithCli(input) {
79
+ return runRailwayCliJson({
80
+ args: [...volumeScope(input.projectId, input.environmentId), "delete", "--volume", input.volumeId, "--yes", "--json"],
81
+ env: input.env
82
+ });
83
+ }
84
+ async function detachRailwayVolumeWithCli(input) {
85
+ return runRailwayCliJson({
86
+ args: [...volumeScope(input.projectId, input.environmentId, input.serviceId), "detach", "--volume", input.volumeId, "--yes", "--json"],
87
+ env: input.env
88
+ });
89
+ }
90
+ async function attachRailwayVolumeWithCli(input) {
91
+ return runRailwayCliJson({
92
+ args: [...volumeScope(input.projectId, input.environmentId, input.serviceId), "attach", "--volume", input.volumeId, "--yes", "--json"],
93
+ env: input.env
94
+ });
95
+ }
96
+ async function updateRailwayVolumeWithCli(input) {
97
+ return runRailwayCliJson({
98
+ args: [
99
+ ...volumeScope(input.projectId, input.environmentId, input.serviceId),
100
+ "update",
101
+ "--volume",
102
+ input.volumeId,
103
+ "--name",
104
+ input.name,
105
+ "--mount-path",
106
+ input.mountPath,
107
+ "--json"
108
+ ],
109
+ env: input.env
110
+ });
111
+ }
112
+ async function connectRailwayServiceSourceWithCli(input) {
113
+ const repo = String(input.repo ?? "").trim();
114
+ const image = String(input.image ?? "").trim();
115
+ if (Boolean(repo) === Boolean(image)) {
116
+ throw new Error("Railway service source connection requires exactly one GitHub repository or image reference.");
117
+ }
118
+ return runRailwayCliJson({
119
+ args: [
120
+ "service",
121
+ "source",
122
+ "connect",
123
+ ...String(input.projectId ?? "").trim() ? ["--project", String(input.projectId).trim()] : [],
124
+ "--environment",
125
+ input.environmentId,
126
+ "--service",
127
+ input.serviceId,
128
+ ...repo ? ["--repo", repo] : ["--image", image],
129
+ ...repo && String(input.branch ?? "").trim() ? ["--branch", String(input.branch).trim()] : [],
130
+ "--json"
131
+ ],
132
+ env: input.env
133
+ });
134
+ }
135
+ export {
136
+ attachRailwayVolumeWithCli,
137
+ connectRailwayServiceSourceWithCli,
138
+ deleteRailwayVolumeWithCli,
139
+ detachRailwayVolumeWithCli,
140
+ runRailwayCliJson,
141
+ updateRailwayVolumeWithCli
142
+ };
@@ -60,8 +60,8 @@ export declare function waitForRailwayManagedDeploymentsSettled(tenantRoot: any,
60
60
  message: string;
61
61
  }>;
62
62
  export declare function configuredRailwayServices(tenantRoot: any, scope: any, envOverlay?: {}, options?: {}): unknown[];
63
- export declare function legacyEnvironmentSpecificRailwayResourceNames(services: ReturnType<typeof configuredRailwayServices>): string[];
64
- export declare function railwayLegacyAliasMigrationPolicy(scope: 'staging' | 'prod', services: ReturnType<typeof configuredRailwayServices>): {
63
+ export declare function obsoleteUnqualifiedRailwayResourceNames(services: ReturnType<typeof configuredRailwayServices>): string[];
64
+ export declare function railwayObsoleteAliasCleanupPolicy(scope: 'staging' | 'prod', services: ReturnType<typeof configuredRailwayServices>, liveProjectServiceNames?: Iterable<string>, activeEnvironmentServiceNames?: Iterable<string>): {
65
65
  retainedResourceNames: string[];
66
66
  allowedResourceDeletions: string[];
67
67
  };
@@ -6,7 +6,7 @@ import { resolveTreeseedMachineEnvironmentValues } from "./config-runtime.js";
6
6
  import { createPersistentDeployTarget, resolveTreeseedResourceIdentity } from "./deploy.js";
7
7
  import { classifyTreeseedGitMode, runTreeseedGitText } from "./git-runner.js";
8
8
  import { discoverTreeseedApplications } from "../../hosting/apps.js";
9
- import { apiRailwayDefaultDockerfilePath, apiRailwayDefaultSourceRepo, assertApiRailwaySourcePolicy, isApiRailwaySourcePolicyService } from "./railway-source-policy.js";
9
+ import { apiRailwayDefaultDockerfilePath, apiRailwayDefaultSourceRepo, assertApiRailwaySourcePolicy, isApiRailwaySourcePolicyService, railwayEnvironmentQualifiedServiceName, railwayTreeDxServiceName } from "./railway-source-policy.js";
10
10
  import { runPrefixedCommand, sleep } from "./bootstrap-runner.js";
11
11
  import {
12
12
  ensureRailwayEnvironment,
@@ -384,74 +384,6 @@ function isRailwayScheduleCapabilityError(error) {
384
384
  const message = error instanceof Error ? error.message : String(error ?? "");
385
385
  return /cronTriggers|cronTriggerCreate|cronTriggerUpdate/iu.test(message);
386
386
  }
387
- function defaultRailwayScheduleQueries() {
388
- return {
389
- listQuery: envValue("TREESEED_RAILWAY_SCHEDULE_LIST_QUERY") || `
390
- query TreeseedScheduleList($serviceId: String!, $environmentId: String!, $projectId: String) {
391
- service(id: $serviceId) {
392
- id
393
- name
394
- cronTriggers {
395
- edges {
396
- node {
397
- id
398
- name
399
- schedule
400
- command
401
- enabled
402
- service { id name }
403
- environment { id name }
404
- }
405
- }
406
- }
407
- }
408
- }
409
- `.trim(),
410
- createMutation: envValue("TREESEED_RAILWAY_SCHEDULE_CREATE_MUTATION") || `
411
- mutation TreeseedScheduleCreate($serviceId: String!, $environmentId: String!, $name: String!, $schedule: String!, $command: String!, $enabled: Boolean!) {
412
- cronTriggerCreate(
413
- input: {
414
- serviceId: $serviceId
415
- environmentId: $environmentId
416
- name: $name
417
- schedule: $schedule
418
- command: $command
419
- enabled: $enabled
420
- }
421
- ) {
422
- id
423
- name
424
- schedule
425
- command
426
- enabled
427
- service { id name }
428
- environment { id name }
429
- }
430
- }
431
- `.trim(),
432
- updateMutation: envValue("TREESEED_RAILWAY_SCHEDULE_UPDATE_MUTATION") || `
433
- mutation TreeseedScheduleUpdate($id: String!, $name: String!, $schedule: String!, $command: String!, $enabled: Boolean!) {
434
- cronTriggerUpdate(
435
- id: $id
436
- input: {
437
- name: $name
438
- schedule: $schedule
439
- command: $command
440
- enabled: $enabled
441
- }
442
- ) {
443
- id
444
- name
445
- schedule
446
- command
447
- enabled
448
- service { id name }
449
- environment { id name }
450
- }
451
- }
452
- `.trim()
453
- };
454
- }
455
387
  async function waitForRailwayManagedDeploymentsSettled(tenantRoot, scope, {
456
388
  services = configuredRailwayServices(tenantRoot, scope),
457
389
  env = process.env,
@@ -686,7 +618,7 @@ function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, app
686
618
  const publicBaseUrl = service.environments?.[normalizedScope]?.baseUrl ?? service.publicBaseUrl ?? (serviceKey === "api" ? configuredApiPublicBaseUrl(deployConfig, normalizedScope) : null);
687
619
  const environmentConfig = service.environments?.[normalizedScope];
688
620
  const baseServiceName = service.railway?.serviceName ?? (serviceKey === "workerRunner" ? deriveRailwayWorkerRunnerServiceName(identity.deploymentKey) : `${identity.deploymentKey}-${railwayServiceNameSuffix(serviceKey)}`);
689
- const configuredServiceName = baseServiceName;
621
+ const configuredServiceName = typeof environmentConfig?.serviceName === "string" && environmentConfig.serviceName.trim() ? environmentConfig.serviceName.trim() : isApiRailwaySourcePolicyService({ key: serviceKey, serviceName: baseServiceName }) ? railwayEnvironmentQualifiedServiceName(baseServiceName, normalizedScope) : baseServiceName;
690
622
  const configuredRunnerPool = service.railway?.runnerPool && typeof service.railway.runnerPool === "object" ? service.railway.runnerPool : null;
691
623
  const runnerPool = serviceKey === "operationsRunner" || serviceKey === "capacityProviderRunner" ? {
692
624
  bootstrapCount: Math.max(1, Number.parseInt(String(configuredRunnerPool?.bootstrapCount ?? (serviceKey === "capacityProviderRunner" ? 1 : OPERATIONS_RUNNER_BOOTSTRAP_COUNT)), 10) || (serviceKey === "capacityProviderRunner" ? 1 : OPERATIONS_RUNNER_BOOTSTRAP_COUNT)),
@@ -797,9 +729,10 @@ function configuredPublicTreeDxRailwayServices({ tenantRoot, scope, deployConfig
797
729
  const baseImageRef = envValue("TREESEED_PUBLIC_TREEDX_IMAGE_REF", imageRefEnv) || "treeseed/treedx";
798
730
  return Array.from({ length: bootstrapCount }, (_, offset) => {
799
731
  const index = offset + 1;
800
- const serviceName = `${PUBLIC_TREEDX_NODE_SERVICE_KEY_PREFIX}${String(index).padStart(2, "0")}`;
732
+ const baseServiceName = `${PUBLIC_TREEDX_NODE_SERVICE_KEY_PREFIX}${String(index).padStart(2, "0")}`;
733
+ const serviceName = railwayTreeDxServiceName(index, scope);
801
734
  const service = {
802
- key: serviceName,
735
+ key: baseServiceName,
803
736
  serviceName,
804
737
  sourceMode,
805
738
  sourceRepo: sourceMode === "git" ? repository : null,
@@ -985,29 +918,40 @@ function configuredRailwayServices(tenantRoot, scope, envOverlay = {}, options =
985
918
  ));
986
919
  return [...direct, ...nested];
987
920
  }
988
- function legacyEnvironmentSpecificRailwayResourceNames(services) {
921
+ function obsoleteUnqualifiedRailwayResourceNames(services) {
989
922
  const aliases = /* @__PURE__ */ new Set();
990
923
  for (const service of services) {
991
- let alias = null;
992
- if (service.key === "api") {
993
- alias = `${service.serviceName}-production`;
994
- } else if (service.key === "operationsRunner") {
995
- alias = service.serviceName.replace(/-(\d+)$/u, "-production-$1");
996
- } else {
997
- const treeDxMatch = service.serviceName.match(/^public-treedx-node-(\d+)$/u);
998
- if (treeDxMatch?.[1]) alias = `public-treedx-node-production-${treeDxMatch[1]}`;
999
- }
924
+ const alias = service.serviceName.replace(/-(?:staging|production)(?=-\d+$|$)/u, "");
1000
925
  if (!alias || alias === service.serviceName) continue;
1001
926
  aliases.add(alias);
1002
927
  if (service.volumeMountPath) aliases.add(`${alias}-volume`);
928
+ const index = /-(\d+)$/u.exec(service.serviceName)?.[1] ?? "01";
929
+ const environmentSuffix = service.railwayEnvironment === "production" ? "production" : "staging";
930
+ const formerNames = service.key === "operationsRunner" ? [
931
+ `treeseed-api-operations-runner-${index}`,
932
+ `treeseed-api-operations-runner-${environmentSuffix}-${index}`
933
+ ] : service.key.startsWith("public-treedx-node-") ? [
934
+ `public-treedx-node-${index}`,
935
+ `public-treedx-node-${environmentSuffix}-${index}`
936
+ ] : [];
937
+ for (const formerName of formerNames) {
938
+ if (formerName === service.serviceName) continue;
939
+ aliases.add(formerName);
940
+ if (service.volumeMountPath) aliases.add(`${formerName}-volume`);
941
+ }
1003
942
  }
1004
943
  return [...aliases];
1005
944
  }
1006
- function railwayLegacyAliasMigrationPolicy(scope, services) {
1007
- const aliases = legacyEnvironmentSpecificRailwayResourceNames(services);
945
+ function railwayObsoleteAliasCleanupPolicy(scope, services, liveProjectServiceNames = [], activeEnvironmentServiceNames = []) {
946
+ const aliases = obsoleteUnqualifiedRailwayResourceNames(services);
947
+ const liveNames = new Set(liveProjectServiceNames);
948
+ void scope;
949
+ void activeEnvironmentServiceNames;
950
+ const qualifiedServices = services.filter((service) => service.serviceName !== service.serviceName.replace(/-(?:staging|production)(?=-\d+$|$)/u, "")).map((service) => service.serviceName);
951
+ const qualifiedResourcesExist = aliases.length > 0 && qualifiedServices.every((name) => liveNames.has(name));
1008
952
  return {
1009
- retainedResourceNames: scope === "staging" ? aliases : [],
1010
- allowedResourceDeletions: scope === "prod" ? aliases : []
953
+ retainedResourceNames: qualifiedResourcesExist ? [] : aliases,
954
+ allowedResourceDeletions: qualifiedResourcesExist ? aliases : []
1011
955
  };
1012
956
  }
1013
957
  function configuredRailwayScheduledJobs(tenantRoot, scope, { phase = "deploy" } = {}) {
@@ -1734,7 +1678,7 @@ async function deployRailwayService(tenantRoot, service, {
1734
1678
  return {
1735
1679
  service: service.key,
1736
1680
  status: "planned",
1737
- command: "railway-api serviceInstanceDeployV2",
1681
+ command: "railway-cli service redeploy",
1738
1682
  cwd: service.rootDir,
1739
1683
  publicBaseUrl: service.publicBaseUrl,
1740
1684
  timings,
@@ -1796,9 +1740,10 @@ async function deployRailwayService(tenantRoot, service, {
1796
1740
  }
1797
1741
  }
1798
1742
  if (deployTransport !== "cli-fallback") {
1799
- writePhase("deploy", `Deploying Railway service ${cliDeployService.serviceName ?? cliDeployService.serviceId ?? cliDeployService.key} through the Railway API.`);
1743
+ writePhase("deploy", `Deploying Railway service ${cliDeployService.serviceName ?? cliDeployService.serviceId ?? cliDeployService.key} through the managed Railway CLI.`);
1800
1744
  const apiDeploy = await timedRailwayPhase(timings, "railway:api-deploy", () => withRailwayPhaseTimeout(
1801
1745
  () => deployRailwayServiceInstance({
1746
+ projectId: cliDeployService.projectId,
1802
1747
  serviceId: cliDeployService.serviceId,
1803
1748
  environmentId: cliDeployService.environmentId,
1804
1749
  env: commandEnv,
@@ -1810,7 +1755,7 @@ async function deployRailwayService(tenantRoot, service, {
1810
1755
  return {
1811
1756
  service: deployService.key,
1812
1757
  status: "deployed",
1813
- command: "railway-api serviceInstanceDeployV2",
1758
+ command: "railway-cli service redeploy",
1814
1759
  cwd: deployService.rootDir,
1815
1760
  publicBaseUrl: deployService.publicBaseUrl,
1816
1761
  timings,
@@ -1848,9 +1793,9 @@ export {
1848
1793
  findStaleTreeseedOperationsRunnerResources,
1849
1794
  isTreeseedOperationsRunnerResourceName,
1850
1795
  isUsableRailwayToken,
1851
- legacyEnvironmentSpecificRailwayResourceNames,
1796
+ obsoleteUnqualifiedRailwayResourceNames,
1852
1797
  parseRailwayJsonOutput,
1853
- railwayLegacyAliasMigrationPolicy,
1798
+ railwayObsoleteAliasCleanupPolicy,
1854
1799
  railwayServiceRuntimeStartCommand,
1855
1800
  resolveRailwayAuthToken,
1856
1801
  resolveRailwayDeploymentProfile,
@@ -12,6 +12,12 @@ export type TreeseedRailwaySourcePolicyService = {
12
12
  buildCommand?: string | null;
13
13
  startCommand?: string | null;
14
14
  };
15
+ export type TreeseedRailwaySourceIdentity = TreeseedRailwaySourcePolicyService & {
16
+ environment?: string | null;
17
+ };
18
+ export declare function railwayEnvironmentQualifiedServiceName(serviceName: string, scope: TreeseedRailwaySourcePolicyScope | string): string;
19
+ export declare function railwayTreeDxServiceName(index: number, scope: string): string;
20
+ export declare function assertNoRailwaySourceIdentityCollisions(services: TreeseedRailwaySourceIdentity[]): void;
15
21
  export declare function isApiRailwaySourcePolicyService(service: TreeseedRailwaySourcePolicyService): boolean;
16
22
  export declare function isImmutableRailwayImageRef(value: unknown): boolean;
17
23
  export declare function apiRailwayDefaultSourceRepo(service: TreeseedRailwaySourcePolicyService): "treeseed-ai/treedx" | "treeseed-ai/api" | null;
@@ -1,7 +1,45 @@
1
+ const SOURCE_DIVERGENT_ENVIRONMENT_SUFFIX = /-(?:staging|production)(?=-\d+$|$)/u;
2
+ function railwayEnvironmentQualifiedServiceName(serviceName, scope) {
3
+ const normalizedName = String(serviceName ?? "").trim();
4
+ const suffix = scope === "prod" || scope === "production" ? "production" : scope === "staging" ? "staging" : null;
5
+ if (!normalizedName || !suffix) return normalizedName;
6
+ const unqualifiedName = normalizedName.replace(SOURCE_DIVERGENT_ENVIRONMENT_SUFFIX, "");
7
+ const indexedName = /^(.*?)(-\d+)$/u.exec(unqualifiedName);
8
+ return indexedName ? `${indexedName[1]}-${suffix}${indexedName[2]}` : `${unqualifiedName}-${suffix}`;
9
+ }
10
+ function railwayTreeDxServiceName(index, scope) {
11
+ const normalizedIndex = String(Math.max(1, Math.trunc(index))).padStart(2, "0");
12
+ return railwayEnvironmentQualifiedServiceName(`treeseed-treedx-${normalizedIndex}`, scope);
13
+ }
14
+ function railwaySourceSignature(service) {
15
+ return JSON.stringify({
16
+ sourceMode: service.sourceMode ?? null,
17
+ sourceRepo: service.sourceRepo ?? null,
18
+ sourceBranch: service.sourceBranch ?? null,
19
+ sourceRootDirectory: service.sourceRootDirectory ?? null,
20
+ imageRef: service.imageRef ?? null,
21
+ dockerfilePath: service.dockerfilePath ?? null,
22
+ buildCommand: service.buildCommand ?? null
23
+ });
24
+ }
25
+ function assertNoRailwaySourceIdentityCollisions(services) {
26
+ const identities = /* @__PURE__ */ new Map();
27
+ for (const service of services) {
28
+ const serviceName = String(service.serviceName ?? "").trim();
29
+ if (!serviceName) continue;
30
+ const existing = identities.get(serviceName);
31
+ if (existing && railwaySourceSignature(existing) !== railwaySourceSignature(service)) {
32
+ throw new Error(
33
+ `${serviceName}: Railway service identity is shared by ${existing.environment ?? "one environment"} and ${service.environment ?? "another environment"} with different source/build configurations. Use environment-qualified service names.`
34
+ );
35
+ }
36
+ identities.set(serviceName, service);
37
+ }
38
+ }
1
39
  function isApiRailwaySourcePolicyService(service) {
2
40
  const key = String(service.key ?? "").trim();
3
41
  const serviceName = String(service.serviceName ?? "").trim();
4
- return key.startsWith("public-treedx-node-") || /^treeseed-api(?:-production)?$/u.test(serviceName) || /^treeseed-api-operations-runner(?:-production)?-\d+$/u.test(serviceName) || /^public-treedx-node(?:-production)?-\d+$/u.test(serviceName);
42
+ return key.startsWith("public-treedx-node-") || /^treeseed-api(?:-(?:staging|production))?$/u.test(serviceName) || /^treeseed-api-operations-runner(?:-(?:staging|production))?(?:-\d+)?$/u.test(serviceName) || /^treeseed-ops(?:-(?:staging|production))?-\d+$/u.test(serviceName) || /^public-treedx-node(?:-(?:staging|production))?-\d+$/u.test(serviceName) || /^treeseed-treedx(?:-(?:staging|production))?-\d+$/u.test(serviceName);
5
43
  }
6
44
  function isImmutableRailwayImageRef(value) {
7
45
  const imageRef = typeof value === "string" ? value.trim() : "";
@@ -11,24 +49,26 @@ function isImmutableRailwayImageRef(value) {
11
49
  }
12
50
  function apiRailwayDefaultSourceRepo(service) {
13
51
  const serviceName = String(service.serviceName ?? "").trim();
14
- if (String(service.key ?? "") === "api" || String(service.key ?? "") === "operationsRunner" || /^treeseed-api(?:-production)?$/u.test(serviceName) || /^treeseed-api-operations-runner(?:-production)?-\d+$/u.test(serviceName)) return "treeseed-ai/api";
15
- if (/^public-treedx-node(?:-production)?-\d+$/u.test(serviceName) || String(service.key ?? "").startsWith("public-treedx-node-")) return "treeseed-ai/treedx";
52
+ if (String(service.key ?? "") === "api" || String(service.key ?? "") === "operationsRunner" || /^treeseed-api(?:-(?:staging|production))?$/u.test(serviceName) || /^treeseed-api-operations-runner(?:-(?:staging|production))?(?:-\d+)?$/u.test(serviceName)) return "treeseed-ai/api";
53
+ if (/^public-treedx-node(?:-(?:staging|production))?-\d+$/u.test(serviceName) || String(service.key ?? "").startsWith("public-treedx-node-")) return "treeseed-ai/treedx";
16
54
  return null;
17
55
  }
18
56
  function apiRailwayDefaultDockerfilePath(service) {
19
57
  const serviceName = String(service.serviceName ?? "").trim();
20
- if (String(service.key ?? "") === "api" || /^treeseed-api(?:-production)?$/u.test(serviceName)) return "/Dockerfile.api";
21
- if (String(service.key ?? "") === "operationsRunner" || /^treeseed-api-operations-runner(?:-production)?-\d+$/u.test(serviceName)) return "/Dockerfile.operations-runner";
22
- if (/^public-treedx-node(?:-production)?-\d+$/u.test(serviceName) || String(service.key ?? "").startsWith("public-treedx-node-")) return "/Dockerfile";
58
+ if (String(service.key ?? "") === "api" || /^treeseed-api(?:-(?:staging|production))?$/u.test(serviceName)) return "/Dockerfile.api";
59
+ if (String(service.key ?? "") === "operationsRunner" || /^treeseed-api-operations-runner(?:-(?:staging|production))?(?:-\d+)?$/u.test(serviceName)) return "/Dockerfile.operations-runner";
60
+ if (/^public-treedx-node(?:-(?:staging|production))?-\d+$/u.test(serviceName) || String(service.key ?? "").startsWith("public-treedx-node-")) return "/Dockerfile";
23
61
  return null;
24
62
  }
25
63
  function assertApiRailwaySourcePolicy(scope, service) {
26
64
  if (!isApiRailwaySourcePolicyService(service)) return;
27
65
  const normalizedScope = scope === "prod" ? "prod" : scope === "staging" ? "staging" : "local";
28
66
  const label = service.serviceName ?? service.key ?? "Railway service";
67
+ const serviceName = String(service.serviceName ?? "").trim();
68
+ const expectedServiceName = railwayEnvironmentQualifiedServiceName(serviceName, normalizedScope);
29
69
  if (normalizedScope === "staging") {
30
70
  const issues = [
31
- /-production(?:-|$)/u.test(String(service.serviceName ?? "")) ? "serviceName must use the project-wide canonical identity" : null,
71
+ serviceName === expectedServiceName && /-staging(?:-|$)/u.test(serviceName) ? null : `serviceName must be ${expectedServiceName}`,
32
72
  service.sourceMode === "git" ? null : "sourceMode must be git",
33
73
  service.imageRef ? "imageRef must be empty" : null,
34
74
  service.sourceRepo ? null : "sourceRepo must be set",
@@ -43,7 +83,7 @@ function assertApiRailwaySourcePolicy(scope, service) {
43
83
  }
44
84
  if (normalizedScope === "prod") {
45
85
  const issues = [
46
- /-production(?:-|$)/u.test(String(service.serviceName ?? "")) ? "serviceName must use the project-wide canonical identity" : null,
86
+ serviceName === expectedServiceName && /-production(?:-|$)/u.test(serviceName) ? null : `serviceName must be ${expectedServiceName}`,
47
87
  service.sourceMode === "image" ? null : "sourceMode must be image",
48
88
  isImmutableRailwayImageRef(service.imageRef) ? null : "imageRef must be an immutable released image tag",
49
89
  service.sourceRepo ? "sourceRepo must be empty" : null,
@@ -63,6 +103,9 @@ export {
63
103
  apiRailwayDefaultDockerfilePath,
64
104
  apiRailwayDefaultSourceRepo,
65
105
  assertApiRailwaySourcePolicy,
106
+ assertNoRailwaySourceIdentityCollisions,
66
107
  isApiRailwaySourcePolicyService,
67
- isImmutableRailwayImageRef
108
+ isImmutableRailwayImageRef,
109
+ railwayEnvironmentQualifiedServiceName,
110
+ railwayTreeDxServiceName
68
111
  };
@@ -174,6 +174,8 @@ export declare function discoverRepositorySaveNodes(root: string, gitRoot?: stri
174
174
  stablePackageRelease?: boolean;
175
175
  }): RepositorySaveNode[];
176
176
  export declare function repositorySaveWaves(nodes: RepositorySaveNode[]): RepositorySaveNode[][];
177
+ export declare function validateStandaloneGitDependencyLockfile(node: RepositorySaveNode, options: Pick<RepositorySaveOptions, 'onProgress'>): boolean;
178
+ export declare function applyPackageVersion(node: RepositorySaveNode, version: string): boolean;
177
179
  export declare function planRepositorySave(options: RepositorySaveOptions): RepositorySavePlan;
178
180
  export declare function refreshAndValidateRootWorkspaceLockfileForSave(options: {
179
181
  root: string;
@@ -779,6 +779,8 @@ function syncDirectGitDependencyLockfileEntries(node, options, references) {
779
779
  function validateStandaloneGitDependencyLockfile(node, options) {
780
780
  const lockfilePath = resolve(node.path, "package-lock.json");
781
781
  const lockfileExists = existsSync(lockfilePath);
782
+ const previousLockfile = lockfileExists ? readFileSync(lockfilePath, "utf8") : null;
783
+ const isolatedRoot = mkdtempSync(resolve(tmpdir(), "treeseed-lockfile-"));
782
784
  const validateArgs = [
783
785
  "ci",
784
786
  "--package-lock-only",
@@ -789,11 +791,16 @@ function validateStandaloneGitDependencyLockfile(node, options) {
789
791
  ];
790
792
  try {
791
793
  if (!lockfileExists) throw new Error("standalone lockfile missing");
792
- runCapturedCommand(node, options, "lockfile", "npm", validateArgs, { timeoutMs: 5 * 6e4 });
794
+ copyFileSync(resolve(node.path, "package.json"), resolve(isolatedRoot, "package.json"));
795
+ copyFileSync(lockfilePath, resolve(isolatedRoot, "package-lock.json"));
796
+ runCapturedCommand(node, options, "lockfile", "npm", validateArgs, {
797
+ cwd: isolatedRoot,
798
+ timeoutMs: 5 * 6e4
799
+ });
793
800
  } catch (validationError) {
794
- const previousLockfile = lockfileExists ? readFileSync(lockfilePath, "utf8") : null;
795
- const isolatedRoot = mkdtempSync(resolve(tmpdir(), "treeseed-lockfile-"));
796
801
  try {
802
+ rmSync(isolatedRoot, { recursive: true, force: true });
803
+ mkdirSync(isolatedRoot, { recursive: true });
797
804
  copyFileSync(resolve(node.path, "package.json"), resolve(isolatedRoot, "package.json"));
798
805
  runCapturedCommand(node, options, "lockfile", "npm", [
799
806
  "install",
@@ -814,9 +821,9 @@ function validateStandaloneGitDependencyLockfile(node, options) {
814
821
  } catch (regenerationError) {
815
822
  if (previousLockfile !== null) writeFileSync(lockfilePath, previousLockfile, "utf8");
816
823
  throw regenerationError instanceof Error ? regenerationError : validationError;
817
- } finally {
818
- rmSync(isolatedRoot, { recursive: true, force: true });
819
824
  }
825
+ } finally {
826
+ rmSync(isolatedRoot, { recursive: true, force: true });
820
827
  }
821
828
  emitProgress(options, node, "lockfile", "Validated the standalone lockfile against the committed package manifest.");
822
829
  return true;
@@ -828,10 +835,30 @@ function planPackageVersion(node, options) {
828
835
  }
829
836
  function applyPackageVersion(node, version) {
830
837
  if (!node.packageJson || !node.packageJsonPath) return false;
831
- if (node.packageJson.version === version) return false;
832
- node.packageJson.version = version;
833
- writeJson(node.packageJsonPath, node.packageJson);
834
- return true;
838
+ let changed = false;
839
+ if (node.packageJson.version !== version) {
840
+ node.packageJson.version = version;
841
+ writeJson(node.packageJsonPath, node.packageJson);
842
+ changed = true;
843
+ }
844
+ const lockfilePath = resolve(node.path, "package-lock.json");
845
+ if (existsSync(lockfilePath)) {
846
+ const lockfile = readJson(lockfilePath);
847
+ const rootEntry = lockfile.packages && typeof lockfile.packages === "object" && !Array.isArray(lockfile.packages) ? lockfile.packages[""] : null;
848
+ const lockfileMatches = lockfile.version === version && rootEntry?.version === version && (typeof node.packageJson.name !== "string" || rootEntry?.name === node.packageJson.name);
849
+ if (lockfileMatches) return changed;
850
+ lockfile.version = version;
851
+ const packages = lockfile.packages && typeof lockfile.packages === "object" && !Array.isArray(lockfile.packages) ? lockfile.packages : {};
852
+ packages[""] = {
853
+ ...packages[""] ?? {},
854
+ ...typeof node.packageJson.name === "string" ? { name: node.packageJson.name } : {},
855
+ version
856
+ };
857
+ lockfile.packages = packages;
858
+ writeJson(lockfilePath, lockfile);
859
+ changed = true;
860
+ }
861
+ return changed;
835
862
  }
836
863
  function shouldSkipNetworkInstall() {
837
864
  return process.env.TREESEED_SAVE_NPM_INSTALL_MODE !== "allow";
@@ -952,6 +979,10 @@ async function runNpmInstallWithRetry(node, options, gitDependencyRefreshSpecs =
952
979
  let lastError = null;
953
980
  const packageJson = node.packageJson ?? (existsSync(resolve(node.path, "package.json")) ? readJson(resolve(node.path, "package.json")) : null);
954
981
  const rootWorkspaceInstall = node.path === options.root && Array.isArray(packageJson?.workspaces);
982
+ if (!rootWorkspaceInstall && node.branchMode !== "project-save") {
983
+ validateStandaloneGitDependencyLockfile(node, options);
984
+ return { status: "completed", attempts: 1, reason: "isolated-lockfile-validation" };
985
+ }
955
986
  const installFlags = rootWorkspaceInstall ? ["--package-lock-only", "--ignore-scripts"] : node.branchMode === "project-save" ? ["--ignore-scripts"] : ["--package-lock-only", "--ignore-scripts"];
956
987
  const args = rootWorkspaceInstall ? gitDependencyRefreshSpecs.length > 0 ? ["install", ...gitDependencyRefreshSpecs, ...installFlags, "--force"] : ["install", ...installFlags] : gitDependencyRefreshSpecs.length > 0 ? ["install", ...gitDependencyRefreshSpecs, ...installFlags, "--force", "--workspaces=false"] : ["install", ...installFlags, "--workspaces=false"];
957
988
  for (let attempt = 1; attempt <= 5; attempt += 1) {
@@ -1795,13 +1826,11 @@ async function saveOneRepository(node, options, state) {
1795
1826
  if (selection.reused) {
1796
1827
  emitProgress(options, node, "version", `Reusing existing interrupted save version ${plannedVersion}.`);
1797
1828
  } else {
1798
- applyPackageVersion(node, plannedVersion);
1829
+ emitProgress(options, node, "version", `Planned ${plannedVersion}.`);
1799
1830
  }
1831
+ applyPackageVersion(node, plannedVersion);
1800
1832
  node.plannedVersion = plannedVersion;
1801
1833
  report.version = plannedVersion;
1802
- if (!selection.reused) {
1803
- emitProgress(options, node, "version", `Planned ${plannedVersion}.`);
1804
- }
1805
1834
  const reference = finalizePackageReference(node, plannedVersion, options);
1806
1835
  node.plannedTag = reference.tagName;
1807
1836
  report.tagName = reference.tagName;
@@ -2068,6 +2097,7 @@ function repositorySaveErrorDetails(error) {
2068
2097
  };
2069
2098
  }
2070
2099
  export {
2100
+ applyPackageVersion,
2071
2101
  discoverRepositorySaveNodes,
2072
2102
  nextDevVersion,
2073
2103
  planRepositorySave,
@@ -2075,5 +2105,6 @@ export {
2075
2105
  repositorySaveErrorDetails,
2076
2106
  repositorySaveWaves,
2077
2107
  runRepositorySaveOrchestrator,
2078
- runStreamingCommand
2108
+ runStreamingCommand,
2109
+ validateStandaloneGitDependencyLockfile
2079
2110
  };
@@ -36,7 +36,9 @@ export type TreeseedOperationConfirm = (message: string, expected: string) => Pr
36
36
  export type TreeseedOperationSpawn = (command: string, args: string[], options: {
37
37
  cwd: string;
38
38
  env: NodeJS.ProcessEnv;
39
- stdio?: 'inherit';
39
+ stdio?: 'inherit' | 'pipe';
40
+ timeout?: number;
41
+ killSignal?: NodeJS.Signals;
40
42
  }) => {
41
43
  status?: number | null;
42
44
  };