@open-mercato/cli 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4

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 (109) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/agentic/guides/core.customers.md +1 -1
  3. package/dist/agentic/shared/AGENTS.md.template +56 -33
  4. package/dist/agentic/shared/ai/skills/{auto-continue-pr → om-auto-continue-pr}/SKILL.md +19 -19
  5. package/dist/agentic/shared/ai/skills/{auto-create-pr → om-auto-continue-pr}/STANDALONE.md +1 -1
  6. package/dist/agentic/shared/ai/skills/{auto-continue-pr-loop → om-auto-continue-pr-loop}/SKILL.md +32 -32
  7. package/dist/agentic/shared/ai/skills/{auto-create-pr → om-auto-create-pr}/SKILL.md +24 -24
  8. package/dist/agentic/shared/ai/skills/{auto-fix-github → om-auto-create-pr}/STANDALONE.md +1 -1
  9. package/dist/agentic/shared/ai/skills/{auto-create-pr-loop → om-auto-create-pr-loop}/SKILL.md +40 -40
  10. package/dist/agentic/shared/ai/skills/{auto-fix-github → om-auto-fix-github}/SKILL.md +3 -3
  11. package/dist/agentic/shared/ai/skills/{auto-continue-pr → om-auto-fix-github}/STANDALONE.md +1 -1
  12. package/dist/agentic/shared/ai/skills/{auto-review-pr → om-auto-review-pr}/SKILL.md +5 -5
  13. package/dist/agentic/shared/ai/skills/{auto-review-pr → om-auto-review-pr}/STANDALONE.md +1 -1
  14. package/dist/agentic/shared/ai/skills/{auto-upgrade-0.4.10-to-0.5.0 → om-auto-upgrade-0.4.10-to-0.5.0}/SKILL.md +1 -1
  15. package/dist/agentic/shared/ai/skills/{backend-ui-design → om-backend-ui-design}/SKILL.md +2 -2
  16. package/dist/agentic/shared/ai/skills/{code-review → om-code-review}/SKILL.md +1 -1
  17. package/dist/agentic/shared/ai/skills/{code-review → om-code-review}/references/review-checklist.md +3 -0
  18. package/dist/agentic/shared/ai/skills/{data-model-design → om-data-model-design}/SKILL.md +4 -2
  19. package/dist/agentic/shared/ai/skills/{eject-and-customize → om-eject-and-customize}/SKILL.md +2 -2
  20. package/dist/agentic/shared/ai/skills/{implement-spec → om-implement-spec}/SKILL.md +6 -6
  21. package/dist/agentic/shared/ai/skills/{integration-builder → om-integration-builder}/SKILL.md +1 -1
  22. package/dist/agentic/shared/ai/skills/{integration-tests → om-integration-tests}/SKILL.md +1 -1
  23. package/dist/agentic/shared/ai/skills/{module-scaffold → om-module-scaffold}/SKILL.md +5 -3
  24. package/dist/agentic/shared/ai/skills/{module-scaffold → om-module-scaffold}/references/naming-conventions.md +7 -8
  25. package/dist/agentic/shared/ai/skills/om-prepare-issue/SKILL.md +202 -0
  26. package/dist/agentic/shared/ai/skills/{spec-writing → om-spec-writing}/SKILL.md +6 -6
  27. package/dist/agentic/shared/ai/skills/{spec-writing → om-spec-writing}/references/spec-checklist.md +3 -3
  28. package/dist/agentic/shared/ai/skills/{system-extension → om-system-extension}/SKILL.md +1 -1
  29. package/dist/agentic/shared/ai/skills/{trim-unused-modules → om-trim-unused-modules}/SKILL.md +1 -1
  30. package/dist/agentic/shared/ai/skills/{troubleshooter → om-troubleshooter}/SKILL.md +1 -1
  31. package/dist/bin.js +1 -0
  32. package/dist/bin.js.map +2 -2
  33. package/dist/lib/__integration__/TC-INT-008.spec.js +1 -1
  34. package/dist/lib/__integration__/TC-INT-008.spec.js.map +2 -2
  35. package/dist/lib/agentic-setup.js +52 -51
  36. package/dist/lib/agentic-setup.js.map +2 -2
  37. package/dist/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.meta.js +7 -0
  38. package/dist/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.meta.js.map +7 -0
  39. package/dist/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.spec.js +63 -0
  40. package/dist/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.spec.js.map +7 -0
  41. package/dist/lib/deploy/railway/env.js +98 -0
  42. package/dist/lib/deploy/railway/env.js.map +7 -0
  43. package/dist/lib/deploy/railway/graphql-client.js +99 -0
  44. package/dist/lib/deploy/railway/graphql-client.js.map +7 -0
  45. package/dist/lib/deploy/railway/index.js +1115 -0
  46. package/dist/lib/deploy/railway/index.js.map +7 -0
  47. package/dist/lib/deploy/railway/operations.js +249 -0
  48. package/dist/lib/deploy/railway/operations.js.map +7 -0
  49. package/dist/lib/deploy/railway/options.js +155 -0
  50. package/dist/lib/deploy/railway/options.js.map +7 -0
  51. package/dist/lib/deploy/railway/railway-cli.js +61 -0
  52. package/dist/lib/deploy/railway/railway-cli.js.map +7 -0
  53. package/dist/lib/deploy/railway/redaction.js +88 -0
  54. package/dist/lib/deploy/railway/redaction.js.map +7 -0
  55. package/dist/lib/deploy/railway/source.js +128 -0
  56. package/dist/lib/deploy/railway/source.js.map +7 -0
  57. package/dist/lib/deploy/railway/state.js +41 -0
  58. package/dist/lib/deploy/railway/state.js.map +7 -0
  59. package/dist/lib/deploy/railway/token.js +46 -0
  60. package/dist/lib/deploy/railway/token.js.map +7 -0
  61. package/dist/lib/deploy/railway/types.js +1 -0
  62. package/dist/lib/deploy/railway/types.js.map +7 -0
  63. package/dist/lib/generators/openapi.js +4 -0
  64. package/dist/lib/generators/openapi.js.map +2 -2
  65. package/dist/lib/testing/integration.js +57 -26
  66. package/dist/lib/testing/integration.js.map +2 -2
  67. package/dist/lib/testing/runtime-utils.js +16 -8
  68. package/dist/lib/testing/runtime-utils.js.map +2 -2
  69. package/dist/mercato.js +23 -8
  70. package/dist/mercato.js.map +2 -2
  71. package/package.json +11 -12
  72. package/src/bin.ts +1 -0
  73. package/src/lib/__integration__/TC-INT-008.spec.ts +1 -1
  74. package/src/lib/agentic-setup.ts +52 -51
  75. package/src/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.meta.ts +3 -0
  76. package/src/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.spec.ts +71 -0
  77. package/src/lib/deploy/railway/__tests__/dry-run.test.ts +61 -0
  78. package/src/lib/deploy/railway/__tests__/env.test.ts +55 -0
  79. package/src/lib/deploy/railway/__tests__/graphql-client.test.ts +80 -0
  80. package/src/lib/deploy/railway/__tests__/operations.test.ts +21 -0
  81. package/src/lib/deploy/railway/__tests__/options.test.ts +78 -0
  82. package/src/lib/deploy/railway/__tests__/redaction.test.ts +62 -0
  83. package/src/lib/deploy/railway/__tests__/source.test.ts +130 -0
  84. package/src/lib/deploy/railway/__tests__/state.test.ts +24 -0
  85. package/src/lib/deploy/railway/__tests__/token.test.ts +39 -0
  86. package/src/lib/deploy/railway/env.ts +112 -0
  87. package/src/lib/deploy/railway/graphql-client.ts +128 -0
  88. package/src/lib/deploy/railway/index.ts +1454 -0
  89. package/src/lib/deploy/railway/operations.ts +248 -0
  90. package/src/lib/deploy/railway/options.ts +167 -0
  91. package/src/lib/deploy/railway/railway-cli.ts +65 -0
  92. package/src/lib/deploy/railway/redaction.ts +91 -0
  93. package/src/lib/deploy/railway/source.ts +143 -0
  94. package/src/lib/deploy/railway/state.ts +45 -0
  95. package/src/lib/deploy/railway/token.ts +49 -0
  96. package/src/lib/deploy/railway/types.ts +87 -0
  97. package/src/lib/generators/openapi.ts +3 -0
  98. package/src/lib/testing/__tests__/integration.test.ts +79 -0
  99. package/src/lib/testing/__tests__/runtime-utils.test.ts +45 -1
  100. package/src/lib/testing/integration.ts +74 -28
  101. package/src/lib/testing/runtime-utils.ts +17 -8
  102. package/src/mercato.ts +33 -8
  103. /package/dist/agentic/shared/ai/skills/{backend-ui-design → om-backend-ui-design}/references/ui-components.md +0 -0
  104. /package/dist/agentic/shared/ai/skills/{data-model-design → om-data-model-design}/references/mikro-orm-cheatsheet.md +0 -0
  105. /package/dist/agentic/shared/ai/skills/{integration-builder → om-integration-builder}/references/adapter-contracts.md +0 -0
  106. /package/dist/agentic/shared/ai/skills/{module-scaffold → om-module-scaffold}/references/navigation-patterns.md +0 -0
  107. /package/dist/agentic/shared/ai/skills/{spec-writing → om-spec-writing}/references/spec-template.md +0 -0
  108. /package/dist/agentic/shared/ai/skills/{system-extension → om-system-extension}/references/extension-contracts.md +0 -0
  109. /package/dist/agentic/shared/ai/skills/{troubleshooter → om-troubleshooter}/references/diagnostic-commands.md +0 -0
@@ -0,0 +1,143 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import type { RailwaySource, RailwaySourceMode } from './types'
5
+
6
+ type CommandRunner = (command: string, args: string[], cwd: string) => string
7
+
8
+ const defaultRunner: CommandRunner = (command, args, cwd) =>
9
+ execFileSync(command, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
10
+
11
+ function tryRun(runner: CommandRunner, command: string, args: string[], cwd: string): string | null {
12
+ try {
13
+ return runner(command, args, cwd)
14
+ } catch {
15
+ return null
16
+ }
17
+ }
18
+
19
+ export function normalizeGitRepository(remoteUrl: string): string | null {
20
+ const trimmed = remoteUrl.trim().replace(/\.git$/, '')
21
+ const sshMatch = trimmed.match(/^git@github\.com:([^/]+\/[^/]+)$/)
22
+ if (sshMatch) return sshMatch[1] ?? null
23
+ try {
24
+ const parsed = new URL(trimmed)
25
+ if (parsed.hostname !== 'github.com') return null
26
+ return parsed.pathname.replace(/^\/+/, '') || null
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ export function inspectGitSource(cwd: string, runner: CommandRunner = defaultRunner): RailwaySource | null {
33
+ const branch = tryRun(runner, 'git', ['branch', '--show-current'], cwd)
34
+ const remoteUrl = tryRun(runner, 'git', ['remote', 'get-url', 'origin'], cwd)
35
+ if (!branch || !remoteUrl) return null
36
+ const repo = normalizeGitRepository(remoteUrl)
37
+ if (!repo) return null
38
+ const commitSha = tryRun(runner, 'git', ['rev-parse', 'HEAD'], cwd) ?? undefined
39
+ return {
40
+ mode: 'git',
41
+ reason: `GitHub remote origin and branch ${branch} detected`,
42
+ repo,
43
+ branch,
44
+ commitSha,
45
+ }
46
+ }
47
+
48
+ export function assertGitDeployReady(cwd: string, runner: CommandRunner = defaultRunner): void {
49
+ const status = tryRun(runner, 'git', ['status', '--porcelain'], cwd)
50
+ if (status === null) throw new Error('Git is required for --source git.')
51
+ if (status.length > 0) throw new Error('Git-backed deploy requires a clean working tree.')
52
+ const upstream = tryRun(runner, 'git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], cwd)
53
+ if (!upstream) throw new Error('The current branch has no upstream. Push it before a Git-backed deploy.')
54
+ const ahead = tryRun(runner, 'git', ['rev-list', '--count', '@{u}..HEAD'], cwd)
55
+ if (ahead === null || Number.parseInt(ahead, 10) > 0) {
56
+ throw new Error('The current branch has local commits that are not pushed to its upstream.')
57
+ }
58
+ const behind = tryRun(runner, 'git', ['rev-list', '--count', 'HEAD..@{u}'], cwd)
59
+ if (behind === null || Number.parseInt(behind, 10) > 0) {
60
+ throw new Error('The current branch is behind its upstream. Pull the latest commits before a Git-backed deploy.')
61
+ }
62
+ }
63
+
64
+ export function isRailwayCliAvailable(cwd: string, runner: CommandRunner = defaultRunner): boolean {
65
+ return tryRun(runner, 'railway', ['--version'], cwd) !== null
66
+ }
67
+
68
+ export function resolveRailwaySource(
69
+ mode: RailwaySourceMode,
70
+ cwd: string,
71
+ runner: CommandRunner = defaultRunner,
72
+ ): RailwaySource {
73
+ const gitSource = inspectGitSource(cwd, runner)
74
+ if (mode === 'git') {
75
+ if (!gitSource) {
76
+ throw new Error('--source git requires a GitHub origin remote and a current branch.')
77
+ }
78
+ assertGitDeployReady(cwd, runner)
79
+ return gitSource
80
+ }
81
+ if (mode === 'local') {
82
+ if (!isRailwayCliAvailable(cwd, runner)) {
83
+ throw new Error('--source local requires the Railway CLI. Install it from https://docs.railway.com/reference/cli-api.')
84
+ }
85
+ return { mode: 'local', reason: 'Local upload explicitly requested' }
86
+ }
87
+ if (gitSource) {
88
+ try {
89
+ assertGitDeployReady(cwd, runner)
90
+ return gitSource
91
+ } catch (error) {
92
+ if (!isRailwayCliAvailable(cwd, runner)) throw error
93
+ const reason = error instanceof Error ? error.message : 'Git source is not deployable'
94
+ return {
95
+ mode: 'local',
96
+ reason: `${reason} Falling back to railway up.`,
97
+ }
98
+ }
99
+ }
100
+ if (!isRailwayCliAvailable(cwd, runner)) {
101
+ throw new Error('No usable GitHub remote found and the Railway CLI is unavailable for local upload.')
102
+ }
103
+ return { mode: 'local', reason: 'No usable GitHub remote found; falling back to railway up' }
104
+ }
105
+
106
+ const REQUIRED_IGNORE_ENTRIES = [
107
+ { label: '.env', matches: (line: string) => line === '.env' || line === '.env*' },
108
+ { label: '.env.*', matches: (line: string) => line === '.env.*' || line === '.env*' },
109
+ { label: '*.pem', matches: (line: string) => line === '*.pem' },
110
+ { label: '*.key', matches: (line: string) => line === '*.key' },
111
+ { label: 'id_rsa', matches: (line: string) => line === 'id_*' || line === 'id_rsa' },
112
+ { label: 'id_ed25519', matches: (line: string) => line === 'id_*' || line === 'id_ed25519' },
113
+ { label: '.git', matches: (line: string) => line === '.git' },
114
+ { label: '.railway', matches: (line: string) => line === '.railway' },
115
+ { label: 'node_modules', matches: (line: string) => line === 'node_modules' },
116
+ { label: '.yarn/cache', matches: (line: string) => line === '.yarn/cache' },
117
+ { label: '.next', matches: (line: string) => line === '.next' },
118
+ { label: '.turbo', matches: (line: string) => line === '.turbo' },
119
+ { label: '*.db', matches: (line: string) => line === '*.db' },
120
+ { label: '*.sqlite', matches: (line: string) => line === '*.sqlite' || line === '*.sqlite*' },
121
+ { label: '*.sqlite3', matches: (line: string) => line === '*.sqlite3' || line === '*.sqlite*' },
122
+ { label: '.mercato/railway.json', matches: (line: string) =>
123
+ line === '.mercato/railway.json' || line === '.mercato/railway.json*' },
124
+ { label: '.mercato/railway.json.local', matches: (line: string) =>
125
+ line === '.mercato/railway.json.local' || line === '.mercato/railway.json*' },
126
+ ]
127
+
128
+ export function assertLocalUploadSafe(cwd: string): void {
129
+ const ignorePath = resolve(cwd, '.railwayignore')
130
+ if (!existsSync(ignorePath)) {
131
+ throw new Error('Local Railway upload requires a .railwayignore file.')
132
+ }
133
+ const entries = readFileSync(ignorePath, 'utf8')
134
+ .split(/\r?\n/)
135
+ .map((line) => line.trim().replace(/^\/+|\/+$/g, ''))
136
+ .filter((line) => line.length > 0 && !line.startsWith('#') && !line.startsWith('!'))
137
+ const missing = REQUIRED_IGNORE_ENTRIES
138
+ .filter((requirement) => !entries.some(requirement.matches))
139
+ .map((requirement) => requirement.label)
140
+ if (missing.length > 0) {
141
+ throw new Error(`.railwayignore is missing required safety entries: ${missing.join(', ')}`)
142
+ }
143
+ }
@@ -0,0 +1,45 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import type { RailwayState } from './types'
4
+
5
+ export function railwayStatePath(cwd: string, track: boolean): string {
6
+ return resolve(cwd, '.mercato', track ? 'railway.json' : 'railway.json.local')
7
+ }
8
+
9
+ function isRailwayState(value: unknown): value is RailwayState {
10
+ if (!value || typeof value !== 'object') return false
11
+ const record = value as Record<string, unknown>
12
+ return (
13
+ record.schemaVersion === 1 &&
14
+ record.provider === 'railway' &&
15
+ typeof record.projectName === 'string' &&
16
+ typeof record.environments === 'object' &&
17
+ record.environments !== null
18
+ )
19
+ }
20
+
21
+ export function loadRailwayState(path: string): RailwayState | null {
22
+ if (!existsSync(path)) return null
23
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
24
+ if (!isRailwayState(parsed)) {
25
+ throw new Error(`Unsupported or invalid Railway state file: ${path}`)
26
+ }
27
+ return parsed
28
+ }
29
+
30
+ export function createRailwayState(projectName: string, cliVersion: string): RailwayState {
31
+ return {
32
+ schemaVersion: 1,
33
+ provider: 'railway',
34
+ projectName,
35
+ environments: {},
36
+ writtenBy: { cliVersion },
37
+ }
38
+ }
39
+
40
+ export function saveRailwayState(path: string, state: RailwayState): void {
41
+ mkdirSync(dirname(path), { recursive: true })
42
+ const temporaryPath = `${path}.tmp`
43
+ writeFileSync(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
44
+ renameSync(temporaryPath, path)
45
+ }
@@ -0,0 +1,49 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ export function railwayTokenConfigPath(
6
+ environment: NodeJS.ProcessEnv = process.env,
7
+ platform: NodeJS.Platform = process.platform,
8
+ ): string {
9
+ if (platform === 'win32') {
10
+ return join(environment.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'open-mercato', 'railway.json')
11
+ }
12
+ return join(environment.XDG_CONFIG_HOME || join(homedir(), '.config'), 'open-mercato', 'railway.json')
13
+ }
14
+
15
+ export function readCachedRailwayToken(path: string, platform: NodeJS.Platform = process.platform): string | null {
16
+ if (!existsSync(path)) return null
17
+ if (platform !== 'win32') {
18
+ const mode = statSync(path).mode & 0o777
19
+ if ((mode & 0o077) !== 0) {
20
+ throw new Error(`Refusing to read ${path}: permissions must be 0600.`)
21
+ }
22
+ }
23
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
24
+ if (!parsed || typeof parsed !== 'object') return null
25
+ const token = (parsed as Record<string, unknown>).token
26
+ return typeof token === 'string' && token.trim() ? token.trim() : null
27
+ }
28
+
29
+ export function writeCachedRailwayToken(path: string, token: string): void {
30
+ mkdirSync(dirname(path), { recursive: true })
31
+ writeFileSync(path, `${JSON.stringify({ token }, null, 2)}\n`, { mode: 0o600 })
32
+ if (process.platform !== 'win32') chmodSync(path, 0o600)
33
+ }
34
+
35
+ export function resolveRailwayToken(input: {
36
+ flagToken?: string
37
+ environment?: NodeJS.ProcessEnv
38
+ configPath?: string
39
+ }): { token: string | null; source: 'flag' | 'environment' | 'cache' | null } {
40
+ const environment = input.environment ?? process.env
41
+ if (input.flagToken?.trim()) return { token: input.flagToken.trim(), source: 'flag' }
42
+ if (environment.RAILWAY_API_TOKEN?.trim()) {
43
+ return { token: environment.RAILWAY_API_TOKEN.trim(), source: 'environment' }
44
+ }
45
+ const configPath = input.configPath ?? railwayTokenConfigPath(environment)
46
+ const cached = readCachedRailwayToken(configPath)
47
+ if (cached) return { token: cached, source: 'cache' }
48
+ return { token: null, source: null }
49
+ }
@@ -0,0 +1,87 @@
1
+ export type RailwaySourceMode = 'auto' | 'git' | 'local'
2
+ export type ResolvedRailwaySourceMode = Exclude<RailwaySourceMode, 'auto'>
3
+
4
+ export type RailwayDeployOptions = {
5
+ project?: string
6
+ environment: string
7
+ service: string
8
+ worker: boolean
9
+ source: RailwaySourceMode
10
+ region?: string
11
+ envFile?: string
12
+ domain?: string
13
+ waitDomain: boolean
14
+ volume?: string
15
+ token?: string
16
+ nonInteractive: boolean
17
+ dryRun: boolean
18
+ cleanup: boolean
19
+ yes: boolean
20
+ writeEnv: boolean
21
+ track: boolean
22
+ forceRename: boolean
23
+ timeoutSeconds: number
24
+ allowedSecretKeys: string[]
25
+ verbose: boolean
26
+ help: boolean
27
+ }
28
+
29
+ export type RailwaySource = {
30
+ mode: ResolvedRailwaySourceMode
31
+ reason: string
32
+ repo?: string
33
+ branch?: string
34
+ commitSha?: string
35
+ }
36
+
37
+ export type RailwayEnvironmentState = {
38
+ environmentId?: string
39
+ appServiceId?: string
40
+ workerServiceId?: string
41
+ postgresServiceId?: string
42
+ redisServiceId?: string
43
+ domainId?: string
44
+ volumeId?: string
45
+ appUrl?: string
46
+ source?: RailwaySource
47
+ lastDeployIds?: {
48
+ app?: string
49
+ worker?: string
50
+ }
51
+ }
52
+
53
+ export type RailwayState = {
54
+ schemaVersion: 1
55
+ provider: 'railway'
56
+ projectId?: string
57
+ workspaceId?: string
58
+ projectName: string
59
+ environments: Record<string, RailwayEnvironmentState>
60
+ writtenBy: {
61
+ cliVersion: string
62
+ }
63
+ }
64
+
65
+ export type RailwayGraphqlOperation = {
66
+ name: string
67
+ query: string
68
+ mutation?: boolean
69
+ }
70
+
71
+ export type RailwayGraphqlClient = {
72
+ request<T>(operation: RailwayGraphqlOperation, variables?: Record<string, unknown>): Promise<T>
73
+ }
74
+
75
+ export type RailwayService = {
76
+ id: string
77
+ name: string
78
+ }
79
+
80
+ export type RailwayDeployment = {
81
+ id: string
82
+ status: string
83
+ staticUrl?: string | null
84
+ url?: string | null
85
+ serviceId?: string | null
86
+ environmentId?: string | null
87
+ }
@@ -485,6 +485,9 @@ process.stdout.write(JSON.stringify(deepClone(doc), (_, v) =>
485
485
  }
486
486
  return null
487
487
  } finally {
488
+ // Shut down esbuild's persistent Go service so it does not deadlock at
489
+ // process exit when a plugin request is still in flight.
490
+ try { await esbuild.stop() } catch {}
488
491
  // Clean up old files from previous tsx-based approach
489
492
  for (const file of ['_openapi-register.mjs', '_openapi-loader.mjs', '_next-stub.cjs']) {
490
493
  try { fs.unlinkSync(path.join(cacheDir, file)) } catch {}
@@ -17,7 +17,10 @@ import {
17
17
  resolveAppReadyTimeoutMs,
18
18
  shouldReuseBuildArtifacts,
19
19
  acquireEphemeralRuntimeLock,
20
+ waitForApplicationReadiness,
20
21
  } from '../integration'
22
+ import { EventEmitter } from 'node:events'
23
+ import type { ChildProcess } from 'node:child_process'
21
24
 
22
25
  const CACHE_TTL_ENV_VAR = 'OM_INTEGRATION_BUILD_CACHE_TTL_SECONDS'
23
26
  const APP_READY_TIMEOUT_ENV_VAR = 'OM_INTEGRATION_APP_READY_TIMEOUT_SECONDS'
@@ -556,3 +559,79 @@ describe('integration cache and options', () => {
556
559
  }
557
560
  })
558
561
  })
562
+
563
+ describe('waitForApplicationReadiness', () => {
564
+ const makeFakeProcess = (): ChildProcess => new EventEmitter() as unknown as ChildProcess
565
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
566
+
567
+ it('serializes probe cycles so slow probes never pile up concurrent login attempts', async () => {
568
+ let inFlight = 0
569
+ let maxInFlight = 0
570
+ let loginPageCycles = 0
571
+
572
+ const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(async (input) => {
573
+ const url = typeof input === 'string' ? input : String(input)
574
+ inFlight += 1
575
+ maxInFlight = Math.max(maxInFlight, inFlight)
576
+ try {
577
+ // Each probe fetch is slower than the retry interval; the old race-against-a-tick loop
578
+ // would launch overlapping cycles here and blow past 3 concurrent in-flight requests.
579
+ await sleep(40)
580
+ const isLoginPage = url.endsWith('/login') && !url.endsWith('/api/auth/login')
581
+ if (isLoginPage) {
582
+ loginPageCycles += 1
583
+ if (loginPageCycles <= 2) {
584
+ return { status: 503, ok: false, text: async () => '' } as unknown as Response
585
+ }
586
+ return {
587
+ status: 200,
588
+ ok: true,
589
+ text: async () => '<!doctype html><script src="/_next/static/chunks/app.js"></script>',
590
+ } as unknown as Response
591
+ }
592
+ if (url.endsWith('/api/auth/login')) {
593
+ return { status: 200, ok: true, text: async () => JSON.stringify({ token: 'token' }) } as unknown as Response
594
+ }
595
+ if (url.includes('/api/customers/people')) {
596
+ return { status: 200, ok: true, text: async () => JSON.stringify({ items: [] }) } as unknown as Response
597
+ }
598
+ return { status: 200, ok: true, text: async () => '' } as unknown as Response
599
+ } finally {
600
+ inFlight -= 1
601
+ }
602
+ })
603
+
604
+ try {
605
+ await waitForApplicationReadiness('http://127.0.0.1:5001', makeFakeProcess(), {
606
+ timeoutMs: 5_000,
607
+ intervalMs: 5,
608
+ stabilizationMs: 10,
609
+ })
610
+ // One cycle issues exactly three parallel probe fetches (login page, backend login,
611
+ // authenticated login). Serialized cycles keep the peak at three; overlap would exceed it.
612
+ expect(maxInFlight).toBeLessThanOrEqual(3)
613
+ expect(loginPageCycles).toBeGreaterThanOrEqual(3)
614
+ } finally {
615
+ fetchSpy.mockRestore()
616
+ }
617
+ })
618
+
619
+ it('fails fast when the application process exits before becoming ready', async () => {
620
+ const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(async () => {
621
+ await sleep(20)
622
+ return { status: 503, ok: false, text: async () => '' } as unknown as Response
623
+ })
624
+ const fakeProcess = makeFakeProcess()
625
+
626
+ try {
627
+ const readiness = waitForApplicationReadiness('http://127.0.0.1:5001', fakeProcess, {
628
+ timeoutMs: 5_000,
629
+ intervalMs: 5,
630
+ })
631
+ setTimeout(() => fakeProcess.emit('exit', 1), 30)
632
+ await expect(readiness).rejects.toThrow(/exited before readiness check \(exit 1\)/)
633
+ } finally {
634
+ fetchSpy.mockRestore()
635
+ }
636
+ })
637
+ })
@@ -1,4 +1,48 @@
1
- import { redactPostgresUrl } from '../runtime-utils'
1
+ import { createServer } from 'node:net'
2
+ import { getFreePort, isPortAvailable, redactPostgresUrl } from '../runtime-utils'
3
+
4
+ async function listen(host: string, port: number): Promise<{ close: () => Promise<void> }> {
5
+ return new Promise((resolve, reject) => {
6
+ const server = createServer()
7
+ server.once('error', reject)
8
+ server.listen(port, host, () => {
9
+ resolve({
10
+ close: () => new Promise((closeResolve, closeReject) => {
11
+ server.close((error) => {
12
+ if (error) {
13
+ closeReject(error)
14
+ return
15
+ }
16
+ closeResolve()
17
+ })
18
+ }),
19
+ })
20
+ })
21
+ })
22
+ }
23
+
24
+ describe('integration port helpers', () => {
25
+ it('treats wildcard IPv6 listeners as occupying the app port', async () => {
26
+ const port = await getFreePort()
27
+ let listener: { close: () => Promise<void> } | null = null
28
+
29
+ try {
30
+ listener = await listen('::', port)
31
+ } catch (error) {
32
+ const errorCode = (error as NodeJS.ErrnoException).code
33
+ if (errorCode === 'EAFNOSUPPORT') {
34
+ return
35
+ }
36
+ throw error
37
+ }
38
+
39
+ try {
40
+ await expect(isPortAvailable(port)).resolves.toBe(false)
41
+ } finally {
42
+ await listener.close()
43
+ }
44
+ })
45
+ })
2
46
 
3
47
  describe('redactPostgresUrl', () => {
4
48
  it('redacts password in a postgres url', () => {
@@ -8,6 +8,7 @@ import path from 'node:path'
8
8
  import { createInterface, type Interface } from 'node:readline/promises'
9
9
  import { stdin as input, stdout as output } from 'node:process'
10
10
  import spawn from 'cross-spawn'
11
+ import { fetchWithTimeout, type FetchWithTimeoutInit } from '@open-mercato/shared/lib/http/fetchWithTimeout'
11
12
  import { resolveEnvironment } from '../resolver'
12
13
  import { resolveSpawnCommand } from '../spawn'
13
14
  import { discoverIntegrationSpecFiles as discoverIntegrationSpecFilesShared } from './integration-discovery'
@@ -774,6 +775,14 @@ function delay(milliseconds: number): Promise<void> {
774
775
  })
775
776
  }
776
777
 
778
+ // Each readiness probe fetch is bounded so a single stuck connection cannot consume the whole
779
+ // readiness budget; on timeout it surfaces as a probe failure and the next cycle retries.
780
+ const READINESS_PROBE_FETCH_TIMEOUT_MS = 15_000
781
+
782
+ function probeFetch(input: string, init: FetchWithTimeoutInit = {}): Promise<Response> {
783
+ return fetchWithTimeout(input, { timeoutMs: READINESS_PROBE_FETCH_TIMEOUT_MS, ...init })
784
+ }
785
+
777
786
  export function resolveBuildCacheTtlSeconds(logPrefix: string): number {
778
787
  const rawValue = process.env[BUILD_CACHE_TTL_ENV_VAR]
779
788
  if (!rawValue) {
@@ -1105,10 +1114,17 @@ export async function shouldRebuildBuildArtifacts(
1105
1114
  }
1106
1115
 
1107
1116
  async function getFreePort(): Promise<number> {
1108
- return new Promise((resolve, reject) => {
1117
+ const tryListen = (host: string): Promise<number | null> => new Promise((resolve, reject) => {
1109
1118
  const server = createServer()
1110
- server.on('error', reject)
1111
- server.listen(0, '127.0.0.1', () => {
1119
+ server.on('error', (error) => {
1120
+ const errorCode = (error as NodeJS.ErrnoException).code
1121
+ if (errorCode === 'EAFNOSUPPORT') {
1122
+ resolve(null)
1123
+ return
1124
+ }
1125
+ reject(error)
1126
+ })
1127
+ server.listen(0, host, () => {
1112
1128
  const address = server.address()
1113
1129
  if (!address || typeof address === 'string') {
1114
1130
  server.close()
@@ -1125,6 +1141,8 @@ async function getFreePort(): Promise<number> {
1125
1141
  })
1126
1142
  })
1127
1143
  })
1144
+
1145
+ return (await tryListen('::')) ?? await tryListen('127.0.0.1') ?? Promise.reject(new Error('Unable to allocate free port'))
1128
1146
  }
1129
1147
 
1130
1148
  async function isPortAvailable(port: number): Promise<boolean> {
@@ -1145,17 +1163,17 @@ async function isPortAvailable(port: number): Promise<boolean> {
1145
1163
  })
1146
1164
  })
1147
1165
 
1148
- const ipv4Availability = await canBind('127.0.0.1')
1149
- if (ipv4Availability === false) {
1166
+ const wildcardIpv6Availability = await canBind('::')
1167
+ if (wildcardIpv6Availability === false) {
1150
1168
  return false
1151
1169
  }
1152
1170
 
1153
- const ipv6Availability = await canBind('::1')
1154
- if (ipv6Availability === false) {
1171
+ const ipv4Availability = await canBind('127.0.0.1')
1172
+ if (ipv4Availability === false) {
1155
1173
  return false
1156
1174
  }
1157
1175
 
1158
- return ipv4Availability === true || ipv6Availability === true
1176
+ return wildcardIpv6Availability === true || ipv4Availability === true
1159
1177
  }
1160
1178
 
1161
1179
  async function getPreferredPort(preferredPort: number): Promise<number> {
@@ -1263,7 +1281,7 @@ function isSuccessfulBrowserNavigationStatus(status: number): boolean {
1263
1281
 
1264
1282
  async function probeLoginPage(baseUrl: string): Promise<LoginPageProbeResult> {
1265
1283
  try {
1266
- const response = await fetch(`${baseUrl}/login`, {
1284
+ const response = await probeFetch(`${baseUrl}/login`, {
1267
1285
  method: 'GET',
1268
1286
  redirect: 'manual',
1269
1287
  })
@@ -1309,7 +1327,7 @@ async function probeBackendLoginEndpoint(baseUrl: string): Promise<BackendLoginP
1309
1327
  const form = new URLSearchParams()
1310
1328
  form.set('email', 'integration-healthcheck@example.invalid')
1311
1329
  form.set('password', 'invalid-password')
1312
- const response = await fetch(`${baseUrl}/api/auth/login`, {
1330
+ const response = await probeFetch(`${baseUrl}/api/auth/login`, {
1313
1331
  method: 'POST',
1314
1332
  redirect: 'manual',
1315
1333
  headers: {
@@ -1341,7 +1359,7 @@ async function probeAuthenticatedApi(baseUrl: string): Promise<AuthenticatedApiP
1341
1359
  const form = new URLSearchParams()
1342
1360
  form.set('email', 'admin@acme.com')
1343
1361
  form.set('password', 'secret')
1344
- const loginResponse = await fetch(`${baseUrl}/api/auth/login`, {
1362
+ const loginResponse = await probeFetch(`${baseUrl}/api/auth/login`, {
1345
1363
  method: 'POST',
1346
1364
  redirect: 'manual',
1347
1365
  headers: {
@@ -1372,7 +1390,7 @@ async function probeAuthenticatedApi(baseUrl: string): Promise<AuthenticatedApiP
1372
1390
  }
1373
1391
  }
1374
1392
 
1375
- const apiResponse = await fetch(`${baseUrl}/api/customers/people?pageSize=1`, {
1393
+ const apiResponse = await probeFetch(`${baseUrl}/api/customers/people?pageSize=1`, {
1376
1394
  headers: {
1377
1395
  Authorization: `Bearer ${token}`,
1378
1396
  },
@@ -1732,30 +1750,51 @@ export async function tryReuseExistingEnvironment(options: EphemeralRuntimeOptio
1732
1750
  }
1733
1751
  }
1734
1752
 
1735
- async function waitForApplicationReadiness(
1753
+ export async function waitForApplicationReadiness(
1736
1754
  baseUrl: string,
1737
1755
  appProcess: ChildProcess,
1738
- options: { timeoutMs: number },
1756
+ options: { timeoutMs: number; intervalMs?: number; stabilizationMs?: number },
1739
1757
  ): Promise<void> {
1740
1758
  const startTimestamp = Date.now()
1741
- const exitPromise = getProcessExitPromise(appProcess)
1742
- const readinessStabilizationMs = 600
1759
+ const intervalMs = options.intervalMs ?? APP_READY_INTERVAL_MS
1760
+ const readinessStabilizationMs = options.stabilizationMs ?? 600
1743
1761
  let lastProbe: ApplicationReadinessProbeResult | null = null
1744
1762
 
1763
+ // Wrap process exit into a single never-rejecting promise so we can race it without piling up
1764
+ // rejection handlers or losing the exit code across loop iterations.
1765
+ let exitCode: number | null = null
1766
+ const exitSignal = getProcessExitPromise(appProcess).then(
1767
+ (code) => {
1768
+ exitCode = code ?? null
1769
+ return { exited: true as const }
1770
+ },
1771
+ () => {
1772
+ exitCode = null
1773
+ return { exited: true as const }
1774
+ },
1775
+ )
1776
+ const exitError = () =>
1777
+ new Error(`Application process exited before readiness check (exit ${exitCode ?? 'unknown'})`)
1778
+
1745
1779
  while (Date.now() - startTimestamp < options.timeoutMs) {
1746
- const result = await Promise.race([
1747
- probeApplicationReadiness(baseUrl).then((probe) => ({ kind: 'probe' as const, probe })),
1748
- exitPromise.then((code) => ({ kind: 'exit' as const, code })),
1749
- delay(APP_READY_INTERVAL_MS).then(() => ({ kind: 'timeout' as const })),
1780
+ // Run one probe cycle to completion before starting the next. Overlapping cycles (the previous
1781
+ // race-against-a-1s-tick design) abandoned slow probes without cancelling them, so every second
1782
+ // a fresh /api/auth/login attempt piled onto the most expensive endpoint — amplifying the very
1783
+ // contention that delays readiness when 15 ephemeral shards boot in parallel. Each fetch is
1784
+ // independently bounded by fetchWithTimeout, so a stuck connection cannot stall the cycle.
1785
+ const cycle = await Promise.race([
1786
+ probeApplicationReadiness(baseUrl).then((probe) => ({ probe })),
1787
+ exitSignal,
1750
1788
  ])
1751
1789
 
1752
- if (result.kind === 'probe') {
1753
- lastProbe = result.probe
1754
- if (!result.probe.ready) {
1755
- continue
1756
- }
1790
+ if ('exited' in cycle) {
1791
+ throw exitError()
1792
+ }
1793
+
1794
+ lastProbe = cycle.probe
1795
+ if (cycle.probe.ready) {
1757
1796
  const processExited = await Promise.race([
1758
- exitPromise.then(() => true),
1797
+ exitSignal.then(() => true),
1759
1798
  delay(readinessStabilizationMs).then(() => false),
1760
1799
  ])
1761
1800
  if (processExited) {
@@ -1763,8 +1802,15 @@ async function waitForApplicationReadiness(
1763
1802
  }
1764
1803
  return
1765
1804
  }
1766
- if (result.kind === 'exit') {
1767
- throw new Error(`Application process exited before readiness check (exit ${result.code ?? 'unknown'})`)
1805
+
1806
+ const remainingMs = options.timeoutMs - (Date.now() - startTimestamp)
1807
+ if (remainingMs <= 0) break
1808
+ const waited = await Promise.race([
1809
+ exitSignal,
1810
+ delay(Math.min(intervalMs, remainingMs)).then(() => null),
1811
+ ])
1812
+ if (waited && 'exited' in waited) {
1813
+ throw exitError()
1768
1814
  }
1769
1815
  }
1770
1816