@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/agentic/guides/core.customers.md +1 -1
- package/dist/agentic/shared/AGENTS.md.template +56 -33
- package/dist/agentic/shared/ai/skills/{auto-continue-pr → om-auto-continue-pr}/SKILL.md +19 -19
- package/dist/agentic/shared/ai/skills/{auto-create-pr → om-auto-continue-pr}/STANDALONE.md +1 -1
- package/dist/agentic/shared/ai/skills/{auto-continue-pr-loop → om-auto-continue-pr-loop}/SKILL.md +32 -32
- package/dist/agentic/shared/ai/skills/{auto-create-pr → om-auto-create-pr}/SKILL.md +24 -24
- package/dist/agentic/shared/ai/skills/{auto-fix-github → om-auto-create-pr}/STANDALONE.md +1 -1
- package/dist/agentic/shared/ai/skills/{auto-create-pr-loop → om-auto-create-pr-loop}/SKILL.md +40 -40
- package/dist/agentic/shared/ai/skills/{auto-fix-github → om-auto-fix-github}/SKILL.md +3 -3
- package/dist/agentic/shared/ai/skills/{auto-continue-pr → om-auto-fix-github}/STANDALONE.md +1 -1
- package/dist/agentic/shared/ai/skills/{auto-review-pr → om-auto-review-pr}/SKILL.md +5 -5
- package/dist/agentic/shared/ai/skills/{auto-review-pr → om-auto-review-pr}/STANDALONE.md +1 -1
- 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
- package/dist/agentic/shared/ai/skills/{backend-ui-design → om-backend-ui-design}/SKILL.md +2 -2
- package/dist/agentic/shared/ai/skills/{code-review → om-code-review}/SKILL.md +1 -1
- package/dist/agentic/shared/ai/skills/{code-review → om-code-review}/references/review-checklist.md +3 -0
- package/dist/agentic/shared/ai/skills/{data-model-design → om-data-model-design}/SKILL.md +4 -2
- package/dist/agentic/shared/ai/skills/{eject-and-customize → om-eject-and-customize}/SKILL.md +2 -2
- package/dist/agentic/shared/ai/skills/{implement-spec → om-implement-spec}/SKILL.md +6 -6
- package/dist/agentic/shared/ai/skills/{integration-builder → om-integration-builder}/SKILL.md +1 -1
- package/dist/agentic/shared/ai/skills/{integration-tests → om-integration-tests}/SKILL.md +1 -1
- package/dist/agentic/shared/ai/skills/{module-scaffold → om-module-scaffold}/SKILL.md +5 -3
- package/dist/agentic/shared/ai/skills/{module-scaffold → om-module-scaffold}/references/naming-conventions.md +7 -8
- package/dist/agentic/shared/ai/skills/om-prepare-issue/SKILL.md +202 -0
- package/dist/agentic/shared/ai/skills/{spec-writing → om-spec-writing}/SKILL.md +6 -6
- package/dist/agentic/shared/ai/skills/{spec-writing → om-spec-writing}/references/spec-checklist.md +3 -3
- package/dist/agentic/shared/ai/skills/{system-extension → om-system-extension}/SKILL.md +1 -1
- package/dist/agentic/shared/ai/skills/{trim-unused-modules → om-trim-unused-modules}/SKILL.md +1 -1
- package/dist/agentic/shared/ai/skills/{troubleshooter → om-troubleshooter}/SKILL.md +1 -1
- package/dist/bin.js +1 -0
- package/dist/bin.js.map +2 -2
- package/dist/lib/__integration__/TC-INT-008.spec.js +1 -1
- package/dist/lib/__integration__/TC-INT-008.spec.js.map +2 -2
- package/dist/lib/agentic-setup.js +52 -51
- package/dist/lib/agentic-setup.js.map +2 -2
- package/dist/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.meta.js +7 -0
- package/dist/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.meta.js.map +7 -0
- package/dist/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.spec.js +63 -0
- package/dist/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.spec.js.map +7 -0
- package/dist/lib/deploy/railway/env.js +98 -0
- package/dist/lib/deploy/railway/env.js.map +7 -0
- package/dist/lib/deploy/railway/graphql-client.js +99 -0
- package/dist/lib/deploy/railway/graphql-client.js.map +7 -0
- package/dist/lib/deploy/railway/index.js +1115 -0
- package/dist/lib/deploy/railway/index.js.map +7 -0
- package/dist/lib/deploy/railway/operations.js +249 -0
- package/dist/lib/deploy/railway/operations.js.map +7 -0
- package/dist/lib/deploy/railway/options.js +155 -0
- package/dist/lib/deploy/railway/options.js.map +7 -0
- package/dist/lib/deploy/railway/railway-cli.js +61 -0
- package/dist/lib/deploy/railway/railway-cli.js.map +7 -0
- package/dist/lib/deploy/railway/redaction.js +88 -0
- package/dist/lib/deploy/railway/redaction.js.map +7 -0
- package/dist/lib/deploy/railway/source.js +128 -0
- package/dist/lib/deploy/railway/source.js.map +7 -0
- package/dist/lib/deploy/railway/state.js +41 -0
- package/dist/lib/deploy/railway/state.js.map +7 -0
- package/dist/lib/deploy/railway/token.js +46 -0
- package/dist/lib/deploy/railway/token.js.map +7 -0
- package/dist/lib/deploy/railway/types.js +1 -0
- package/dist/lib/deploy/railway/types.js.map +7 -0
- package/dist/lib/generators/openapi.js +4 -0
- package/dist/lib/generators/openapi.js.map +2 -2
- package/dist/lib/testing/integration.js +57 -26
- package/dist/lib/testing/integration.js.map +2 -2
- package/dist/lib/testing/runtime-utils.js +16 -8
- package/dist/lib/testing/runtime-utils.js.map +2 -2
- package/dist/mercato.js +23 -8
- package/dist/mercato.js.map +2 -2
- package/package.json +11 -12
- package/src/bin.ts +1 -0
- package/src/lib/__integration__/TC-INT-008.spec.ts +1 -1
- package/src/lib/agentic-setup.ts +52 -51
- package/src/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.meta.ts +3 -0
- package/src/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.spec.ts +71 -0
- package/src/lib/deploy/railway/__tests__/dry-run.test.ts +61 -0
- package/src/lib/deploy/railway/__tests__/env.test.ts +55 -0
- package/src/lib/deploy/railway/__tests__/graphql-client.test.ts +80 -0
- package/src/lib/deploy/railway/__tests__/operations.test.ts +21 -0
- package/src/lib/deploy/railway/__tests__/options.test.ts +78 -0
- package/src/lib/deploy/railway/__tests__/redaction.test.ts +62 -0
- package/src/lib/deploy/railway/__tests__/source.test.ts +130 -0
- package/src/lib/deploy/railway/__tests__/state.test.ts +24 -0
- package/src/lib/deploy/railway/__tests__/token.test.ts +39 -0
- package/src/lib/deploy/railway/env.ts +112 -0
- package/src/lib/deploy/railway/graphql-client.ts +128 -0
- package/src/lib/deploy/railway/index.ts +1454 -0
- package/src/lib/deploy/railway/operations.ts +248 -0
- package/src/lib/deploy/railway/options.ts +167 -0
- package/src/lib/deploy/railway/railway-cli.ts +65 -0
- package/src/lib/deploy/railway/redaction.ts +91 -0
- package/src/lib/deploy/railway/source.ts +143 -0
- package/src/lib/deploy/railway/state.ts +45 -0
- package/src/lib/deploy/railway/token.ts +49 -0
- package/src/lib/deploy/railway/types.ts +87 -0
- package/src/lib/generators/openapi.ts +3 -0
- package/src/lib/testing/__tests__/integration.test.ts +79 -0
- package/src/lib/testing/__tests__/runtime-utils.test.ts +45 -1
- package/src/lib/testing/integration.ts +74 -28
- package/src/lib/testing/runtime-utils.ts +17 -8
- package/src/mercato.ts +33 -8
- /package/dist/agentic/shared/ai/skills/{backend-ui-design → om-backend-ui-design}/references/ui-components.md +0 -0
- /package/dist/agentic/shared/ai/skills/{data-model-design → om-data-model-design}/references/mikro-orm-cheatsheet.md +0 -0
- /package/dist/agentic/shared/ai/skills/{integration-builder → om-integration-builder}/references/adapter-contracts.md +0 -0
- /package/dist/agentic/shared/ai/skills/{module-scaffold → om-module-scaffold}/references/navigation-patterns.md +0 -0
- /package/dist/agentic/shared/ai/skills/{spec-writing → om-spec-writing}/references/spec-template.md +0 -0
- /package/dist/agentic/shared/ai/skills/{system-extension → om-system-extension}/references/extension-contracts.md +0 -0
- /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 {
|
|
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
|
-
|
|
1117
|
+
const tryListen = (host: string): Promise<number | null> => new Promise((resolve, reject) => {
|
|
1109
1118
|
const server = createServer()
|
|
1110
|
-
server.on('error',
|
|
1111
|
-
|
|
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
|
|
1149
|
-
if (
|
|
1166
|
+
const wildcardIpv6Availability = await canBind('::')
|
|
1167
|
+
if (wildcardIpv6Availability === false) {
|
|
1150
1168
|
return false
|
|
1151
1169
|
}
|
|
1152
1170
|
|
|
1153
|
-
const
|
|
1154
|
-
if (
|
|
1171
|
+
const ipv4Availability = await canBind('127.0.0.1')
|
|
1172
|
+
if (ipv4Availability === false) {
|
|
1155
1173
|
return false
|
|
1156
1174
|
}
|
|
1157
1175
|
|
|
1158
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
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 (
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1767
|
-
|
|
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
|
|