@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,1454 @@
1
+ import { createInterface } from 'node:readline/promises'
2
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs'
3
+ import { basename, resolve } from 'node:path'
4
+ import { stdin as input, stdout as output } from 'node:process'
5
+ import { satisfies } from 'semver'
6
+ import {
7
+ computeRailwayVariables,
8
+ formatVariablePlan,
9
+ generateProtectedSecrets,
10
+ parseEnvFile,
11
+ resolveEnvFile,
12
+ writeGeneratedSecrets,
13
+ } from './env'
14
+ import { createRailwayGraphqlClient, RailwayGraphqlError } from './graphql-client'
15
+ import { railwayOperations } from './operations'
16
+ import { parseRailwayDeployOptions, railwayDeployHelp } from './options'
17
+ import { collectSensitiveStructuredValues, redactText } from './redaction'
18
+ import { uploadLocalSource } from './railway-cli'
19
+ import {
20
+ assertLocalUploadSafe,
21
+ resolveRailwaySource,
22
+ } from './source'
23
+ import {
24
+ createRailwayState,
25
+ loadRailwayState,
26
+ railwayStatePath,
27
+ saveRailwayState,
28
+ } from './state'
29
+ import {
30
+ railwayTokenConfigPath,
31
+ resolveRailwayToken,
32
+ writeCachedRailwayToken,
33
+ } from './token'
34
+ import type {
35
+ RailwayDeployOptions,
36
+ RailwayDeployment,
37
+ RailwayEnvironmentState,
38
+ RailwayGraphqlClient,
39
+ RailwayService,
40
+ RailwaySource,
41
+ RailwayState,
42
+ } from './types'
43
+
44
+ type Connection<T> = {
45
+ edges?: Array<{ node?: T | null } | null>
46
+ }
47
+
48
+ type Prompt = {
49
+ ask(question: string): Promise<string>
50
+ close(): void
51
+ }
52
+
53
+ const SUCCESS_STATUSES = new Set(['SUCCESS', 'COMPLETED'])
54
+ const FAILURE_STATUSES = new Set(['FAILED', 'CRASHED', 'REMOVED'])
55
+
56
+ function isNotFoundError(error: unknown): boolean {
57
+ return error instanceof RailwayGraphqlError
58
+ && (
59
+ error.status === 404
60
+ || error.code === 'NOT_FOUND'
61
+ || error.code?.endsWith('_NOT_FOUND') === true
62
+ )
63
+ }
64
+
65
+ function isAmbiguousMutationError(error: unknown): boolean {
66
+ if (!(error instanceof RailwayGraphqlError)) return true
67
+ return (
68
+ error.status === undefined
69
+ || error.status >= 500
70
+ || error.code === 'INTERNAL_ERROR'
71
+ )
72
+ }
73
+
74
+ function createPrompt(): Prompt {
75
+ const readline = createInterface({ input, output })
76
+ return {
77
+ ask: (question) => readline.question(question),
78
+ close: () => readline.close(),
79
+ }
80
+ }
81
+
82
+ function nodes<T>(connection?: Connection<T> | null): T[] {
83
+ return (connection?.edges ?? [])
84
+ .map((edge) => edge?.node)
85
+ .filter((node): node is T => node !== null && node !== undefined)
86
+ }
87
+
88
+ function wait(milliseconds: number): Promise<void> {
89
+ return new Promise((resolveWait) => setTimeout(resolveWait, milliseconds))
90
+ }
91
+
92
+ function normalizeProjectName(value: string): string {
93
+ const normalized = value
94
+ .trim()
95
+ .toLowerCase()
96
+ .replace(/[^a-z0-9-]+/g, '-')
97
+ .replace(/^-+|-+$/g, '')
98
+ .replace(/-{2,}/g, '-')
99
+ .slice(0, 48)
100
+ .replace(/-+$/g, '')
101
+ if (!normalized) throw new Error('Unable to derive a valid Railway project name.')
102
+ return normalized
103
+ }
104
+
105
+ function readAppPackage(cwd: string): {
106
+ name: string
107
+ dependencies: Record<string, string>
108
+ nodeEngine?: string
109
+ } {
110
+ const packagePath = resolve(cwd, 'package.json')
111
+ if (!existsSync(packagePath)) throw new Error('Run this command from an Open Mercato app root.')
112
+ const parsed: unknown = JSON.parse(readFileSync(packagePath, 'utf8'))
113
+ if (!parsed || typeof parsed !== 'object') throw new Error('Invalid package.json.')
114
+ const record = parsed as Record<string, unknown>
115
+ const dependencies = {
116
+ ...(record.dependencies && typeof record.dependencies === 'object'
117
+ ? record.dependencies as Record<string, string>
118
+ : {}),
119
+ ...(record.devDependencies && typeof record.devDependencies === 'object'
120
+ ? record.devDependencies as Record<string, string>
121
+ : {}),
122
+ }
123
+ if (!Object.keys(dependencies).some((key) => key.startsWith('@open-mercato/'))) {
124
+ throw new Error('The current package does not declare Open Mercato dependencies.')
125
+ }
126
+ return {
127
+ name: typeof record.name === 'string' && record.name.trim() ? record.name : basename(cwd),
128
+ dependencies,
129
+ nodeEngine: record.engines && typeof record.engines === 'object'
130
+ && typeof (record.engines as Record<string, unknown>).node === 'string'
131
+ ? (record.engines as Record<string, string>).node
132
+ : undefined,
133
+ }
134
+ }
135
+
136
+ function assertSupportedNodeVersion(nodeEngine?: string): void {
137
+ if (!nodeEngine) return
138
+ if (!satisfies(process.version, nodeEngine)) {
139
+ throw new Error(
140
+ `Node ${process.version} does not satisfy this app's required version "${nodeEngine}".`,
141
+ )
142
+ }
143
+ }
144
+
145
+ function ensureEnvironmentState(state: RailwayState, name: string): RailwayEnvironmentState {
146
+ state.environments[name] ??= {}
147
+ return state.environments[name] as RailwayEnvironmentState
148
+ }
149
+
150
+ function persistState(path: string, state: RailwayState): void {
151
+ saveRailwayState(path, state)
152
+ }
153
+
154
+ async function resolveTokenInteractively(
155
+ options: RailwayDeployOptions,
156
+ prompt: Prompt | null,
157
+ ): Promise<{ token: string; cached: boolean }> {
158
+ const configPath = railwayTokenConfigPath()
159
+ const resolved = resolveRailwayToken({ flagToken: options.token, configPath })
160
+ if (resolved.token) return { token: resolved.token, cached: resolved.source === 'cache' }
161
+ if (options.nonInteractive || !prompt) {
162
+ const compatibilityHint = process.env.RAILWAY_TOKEN
163
+ ? ' RAILWAY_TOKEN is project-scoped; use RAILWAY_API_TOKEN for account/workspace operations.'
164
+ : ''
165
+ throw new Error(`Railway Account token is required.${compatibilityHint}`)
166
+ }
167
+ console.log('Create a token at https://railway.com/account/tokens (Account Settings -> Tokens).')
168
+ const token = (await prompt.ask('Railway Account token: ')).trim()
169
+ if (!token) throw new Error('Railway Account token is required.')
170
+ const persist = (await prompt.ask('Cache this token in ~/.config/open-mercato/railway.json? [y/N] ')).trim().toLowerCase()
171
+ if (persist === 'y' || persist === 'yes') {
172
+ writeCachedRailwayToken(configPath, token)
173
+ return { token, cached: true }
174
+ }
175
+ return { token, cached: false }
176
+ }
177
+
178
+ async function resolveWorkspace(
179
+ client: RailwayGraphqlClient,
180
+ preferredWorkspaceId?: string,
181
+ ): Promise<{ userId: string; workspaceId: string }> {
182
+ let data: {
183
+ me: { id: string; workspaces?: Array<{ id: string; name: string }> }
184
+ }
185
+ try {
186
+ data = await client.request(railwayOperations.me)
187
+ } catch (error) {
188
+ if (
189
+ error instanceof RailwayGraphqlError
190
+ && (error.status === 401 || error.status === 403 || error.code === 'UNAUTHORIZED')
191
+ ) {
192
+ throw new Error(
193
+ 'Token rejected. Generate a Railway Account token at https://railway.com/account/tokens.',
194
+ )
195
+ }
196
+ throw error
197
+ }
198
+ const workspaces = data.me.workspaces ?? []
199
+ const workspace = preferredWorkspaceId
200
+ ? workspaces.find((candidate) => candidate.id === preferredWorkspaceId)
201
+ : workspaces[0]
202
+ if (preferredWorkspaceId && !workspace) {
203
+ throw new Error('The recorded Railway workspace is no longer accessible to this Account token.')
204
+ }
205
+ if (!workspace) throw new Error('The Railway account has no accessible workspace.')
206
+ return { userId: data.me.id, workspaceId: workspace.id }
207
+ }
208
+
209
+ async function findProjectByName(
210
+ client: RailwayGraphqlClient,
211
+ workspaceId: string,
212
+ projectName: string,
213
+ ): Promise<{ id: string; name: string } | null> {
214
+ const data = await client.request<{
215
+ projects: Connection<{ id: string; name: string }>
216
+ }>(railwayOperations.projects, { workspaceId })
217
+ return nodes(data.projects).find((project) => project.name === projectName) ?? null
218
+ }
219
+
220
+ async function ensureProject(input: {
221
+ client: RailwayGraphqlClient
222
+ state: RailwayState
223
+ statePath: string
224
+ workspaceId: string
225
+ options: RailwayDeployOptions
226
+ }): Promise<string> {
227
+ if (input.state.projectId) {
228
+ try {
229
+ const data = await input.client.request<{
230
+ project: { id: string; name: string; workspaceId?: string | null }
231
+ }>(
232
+ railwayOperations.project,
233
+ { id: input.state.projectId },
234
+ )
235
+ if (data.project.workspaceId && data.project.workspaceId !== input.workspaceId) {
236
+ throw new Error('Recorded Railway project belongs to a different workspace.')
237
+ }
238
+ if (data.project.name !== input.state.projectName) {
239
+ if (!input.options.forceRename) {
240
+ throw new Error(
241
+ `Recorded Railway project is named "${data.project.name}". Use --force-rename to rename it.`,
242
+ )
243
+ }
244
+ await input.client.request(railwayOperations.projectUpdate, {
245
+ id: data.project.id,
246
+ input: { name: input.state.projectName },
247
+ })
248
+ }
249
+ return data.project.id
250
+ } catch (error) {
251
+ if (!isNotFoundError(error)) throw error
252
+ input.state.projectId = undefined
253
+ }
254
+ }
255
+
256
+ const existing = await findProjectByName(
257
+ input.client,
258
+ input.workspaceId,
259
+ input.state.projectName,
260
+ )
261
+ if (existing) {
262
+ input.state.projectId = existing.id
263
+ persistState(input.statePath, input.state)
264
+ return existing.id
265
+ }
266
+
267
+ try {
268
+ const data = await input.client.request<{
269
+ projectCreate: { id: string }
270
+ }>(railwayOperations.projectCreate, {
271
+ input: {
272
+ name: input.state.projectName,
273
+ description: 'Open Mercato deployment managed by mercato deploy railway',
274
+ workspaceId: input.workspaceId,
275
+ },
276
+ })
277
+ input.state.projectId = data.projectCreate.id
278
+ } catch (error) {
279
+ const discovered = await findProjectByName(
280
+ input.client,
281
+ input.workspaceId,
282
+ input.state.projectName,
283
+ )
284
+ if (!discovered) throw error
285
+ input.state.projectId = discovered.id
286
+ }
287
+ persistState(input.statePath, input.state)
288
+ return input.state.projectId as string
289
+ }
290
+
291
+ async function ensureEnvironment(input: {
292
+ client: RailwayGraphqlClient
293
+ projectId: string
294
+ name: string
295
+ environmentState: RailwayEnvironmentState
296
+ persist: () => void
297
+ }): Promise<string> {
298
+ if (input.environmentState.environmentId) {
299
+ try {
300
+ const data = await input.client.request<{ environment: { id: string } }>(
301
+ railwayOperations.environment,
302
+ { id: input.environmentState.environmentId, projectId: input.projectId },
303
+ )
304
+ return data.environment.id
305
+ } catch (error) {
306
+ if (!isNotFoundError(error)) throw error
307
+ input.environmentState.environmentId = undefined
308
+ }
309
+ }
310
+ const data = await input.client.request<{
311
+ environments: Connection<{ id: string; name: string }>
312
+ }>(railwayOperations.environments, { projectId: input.projectId })
313
+ const existing = nodes(data.environments).find((environment) => environment.name === input.name)
314
+ if (existing) {
315
+ input.environmentState.environmentId = existing.id
316
+ input.persist()
317
+ return existing.id
318
+ }
319
+ try {
320
+ const created = await input.client.request<{ environmentCreate: { id: string } }>(
321
+ railwayOperations.environmentCreate,
322
+ { input: { projectId: input.projectId, name: input.name } },
323
+ )
324
+ input.environmentState.environmentId = created.environmentCreate.id
325
+ } catch (error) {
326
+ const retryLookup = await input.client.request<{
327
+ environments: Connection<{ id: string; name: string }>
328
+ }>(railwayOperations.environments, { projectId: input.projectId })
329
+ const discovered = nodes(retryLookup.environments).find((environment) => environment.name === input.name)
330
+ if (!discovered) throw error
331
+ input.environmentState.environmentId = discovered.id
332
+ }
333
+ input.persist()
334
+ return input.environmentState.environmentId as string
335
+ }
336
+
337
+ async function listServices(client: RailwayGraphqlClient, projectId: string): Promise<RailwayService[]> {
338
+ const data = await client.request<{
339
+ project: { services: Connection<RailwayService> }
340
+ }>(railwayOperations.services, { projectId })
341
+ return nodes(data.project.services)
342
+ }
343
+
344
+ async function waitForService(
345
+ client: RailwayGraphqlClient,
346
+ projectId: string,
347
+ name: string,
348
+ timeoutMs = 180_000,
349
+ ): Promise<RailwayService> {
350
+ const deadline = Date.now() + timeoutMs
351
+ while (Date.now() < deadline) {
352
+ const service = (await listServices(client, projectId)).find((candidate) => candidate.name === name)
353
+ if (service) return service
354
+ await wait(5_000)
355
+ }
356
+ throw new Error(`Timed out waiting for Railway service "${name}". Re-run the command to resume.`)
357
+ }
358
+
359
+ async function ensureDatabaseReady(input: {
360
+ client: RailwayGraphqlClient
361
+ projectId: string
362
+ environmentId: string
363
+ serviceId: string
364
+ name: string
365
+ }): Promise<void> {
366
+ const previous = await latestDeployment(
367
+ input.client,
368
+ input.projectId,
369
+ input.environmentId,
370
+ input.serviceId,
371
+ )
372
+ if (previous && SUCCESS_STATUSES.has(previous.status)) return
373
+ if (previous && !FAILURE_STATUSES.has(previous.status)) {
374
+ await waitForDeployment({
375
+ client: input.client,
376
+ deploymentId: previous.id,
377
+ timeoutSeconds: 180,
378
+ prefix: input.name,
379
+ secrets: [],
380
+ })
381
+ return
382
+ }
383
+
384
+ let triggerError: unknown
385
+ try {
386
+ await input.client.request(railwayOperations.deploy, {
387
+ environmentId: input.environmentId,
388
+ serviceId: input.serviceId,
389
+ commitSha: null,
390
+ })
391
+ } catch (error) {
392
+ if (!isAmbiguousMutationError(error)) throw error
393
+ triggerError = error
394
+ }
395
+
396
+ const deadline = Date.now() + 30_000
397
+ while (Date.now() < deadline) {
398
+ const current = await latestDeployment(
399
+ input.client,
400
+ input.projectId,
401
+ input.environmentId,
402
+ input.serviceId,
403
+ )
404
+ if (current && current.id !== previous?.id) {
405
+ await waitForDeployment({
406
+ client: input.client,
407
+ deploymentId: current.id,
408
+ timeoutSeconds: 180,
409
+ prefix: input.name,
410
+ secrets: [],
411
+ })
412
+ return
413
+ }
414
+ await wait(2_000)
415
+ }
416
+ if (triggerError) throw triggerError
417
+ throw new Error(`Railway did not expose a ${input.name} deployment in this environment.`)
418
+ }
419
+
420
+ async function ensureDatabase(input: {
421
+ client: RailwayGraphqlClient
422
+ projectId: string
423
+ environmentId: string
424
+ workspaceId: string
425
+ name: 'Postgres' | 'Redis'
426
+ code: 'postgres' | 'redis'
427
+ currentId?: string
428
+ previousSource?: RailwaySource
429
+ onResolved: (id: string) => void
430
+ }): Promise<string> {
431
+ if (input.currentId) {
432
+ try {
433
+ const data = await input.client.request<{
434
+ service: { id: string; projectId: string }
435
+ }>(
436
+ railwayOperations.service,
437
+ { id: input.currentId },
438
+ )
439
+ if (data.service.projectId !== input.projectId) {
440
+ throw new Error(`Recorded ${input.name} service belongs to a different Railway project.`)
441
+ }
442
+ await ensureDatabaseReady({
443
+ client: input.client,
444
+ projectId: input.projectId,
445
+ environmentId: input.environmentId,
446
+ serviceId: data.service.id,
447
+ name: input.name,
448
+ })
449
+ return data.service.id
450
+ } catch (error) {
451
+ if (!isNotFoundError(error)) throw error
452
+ }
453
+ }
454
+ const existing = (await listServices(input.client, input.projectId))
455
+ .find((service) => service.name === input.name)
456
+ if (existing) {
457
+ await ensureDatabaseReady({
458
+ client: input.client,
459
+ projectId: input.projectId,
460
+ environmentId: input.environmentId,
461
+ serviceId: existing.id,
462
+ name: input.name,
463
+ })
464
+ input.onResolved(existing.id)
465
+ return existing.id
466
+ }
467
+ const templateData = await input.client.request<{
468
+ template: { id: string; serializedConfig?: string | null }
469
+ }>(railwayOperations.template, { code: input.code })
470
+ if (!templateData.template.serializedConfig) {
471
+ throw new Error(`Railway template "${input.code}" does not expose a deployable configuration.`)
472
+ }
473
+ try {
474
+ await input.client.request(railwayOperations.templateDeploy, {
475
+ input: {
476
+ projectId: input.projectId,
477
+ environmentId: input.environmentId,
478
+ workspaceId: input.workspaceId,
479
+ templateId: templateData.template.id,
480
+ serializedConfig: templateData.template.serializedConfig,
481
+ },
482
+ })
483
+ } catch (error) {
484
+ const discovered = (await listServices(input.client, input.projectId))
485
+ .find((service) => service.name === input.name)
486
+ if (!discovered) throw error
487
+ }
488
+ const created = await waitForService(input.client, input.projectId, input.name)
489
+ const readinessDeadline = Date.now() + 180_000
490
+ let ready = false
491
+ while (Date.now() < readinessDeadline) {
492
+ const deployment = await latestDeployment(
493
+ input.client,
494
+ input.projectId,
495
+ input.environmentId,
496
+ created.id,
497
+ )
498
+ if (deployment && SUCCESS_STATUSES.has(deployment.status)) {
499
+ ready = true
500
+ break
501
+ }
502
+ if (deployment && FAILURE_STATUSES.has(deployment.status)) {
503
+ throw new Error(`${input.name} provisioning failed with status ${deployment.status}.`)
504
+ }
505
+ await wait(5_000)
506
+ }
507
+ if (!ready) {
508
+ throw new Error(`Timed out waiting for ${input.name} to become ready. Re-run the command to resume.`)
509
+ }
510
+ input.onResolved(created.id)
511
+ return created.id
512
+ }
513
+
514
+ async function ensureService(input: {
515
+ client: RailwayGraphqlClient
516
+ projectId: string
517
+ environmentId: string
518
+ name: string
519
+ source: RailwaySource
520
+ currentId?: string
521
+ previousSource?: RailwaySource
522
+ startCommand?: string
523
+ region?: string
524
+ railwayConfigFile?: string
525
+ onResolved: (id: string) => void
526
+ }): Promise<string> {
527
+ let serviceId: string | undefined
528
+ let createdService = false
529
+ if (input.currentId) {
530
+ try {
531
+ const data = await input.client.request<{
532
+ service: { id: string; projectId: string }
533
+ }>(
534
+ railwayOperations.service,
535
+ { id: input.currentId },
536
+ )
537
+ if (data.service.projectId !== input.projectId) {
538
+ throw new Error(`Recorded ${input.name} service belongs to a different Railway project.`)
539
+ }
540
+ serviceId = data.service.id
541
+ } catch (error) {
542
+ if (!isNotFoundError(error)) throw error
543
+ }
544
+ }
545
+ if (!serviceId) {
546
+ const existing = (await listServices(input.client, input.projectId))
547
+ .find((service) => service.name === input.name)
548
+ serviceId = existing?.id
549
+ }
550
+ if (!serviceId) {
551
+ const serviceInput: Record<string, unknown> = {
552
+ projectId: input.projectId,
553
+ name: input.name,
554
+ }
555
+ if (input.source.mode === 'git') {
556
+ serviceInput.source = { repo: input.source.repo }
557
+ serviceInput.branch = input.source.branch
558
+ }
559
+ try {
560
+ const created = await input.client.request<{ serviceCreate: { id: string } }>(
561
+ railwayOperations.serviceCreate,
562
+ { input: serviceInput },
563
+ )
564
+ serviceId = created.serviceCreate.id
565
+ createdService = true
566
+ } catch (error) {
567
+ const discovered = (await listServices(input.client, input.projectId))
568
+ .find((service) => service.name === input.name)
569
+ if (!discovered) throw error
570
+ serviceId = discovered.id
571
+ }
572
+ }
573
+ if (input.source.mode === 'git' && !createdService) {
574
+ await input.client.request(railwayOperations.serviceConnect, {
575
+ id: serviceId,
576
+ input: {
577
+ repo: input.source.repo,
578
+ branch: input.source.branch,
579
+ },
580
+ })
581
+ } else if (input.source.mode === 'local' && input.previousSource?.mode === 'git') {
582
+ await input.client.request(railwayOperations.serviceDisconnect, { id: serviceId })
583
+ }
584
+ if (input.startCommand || input.region || input.railwayConfigFile) {
585
+ await input.client.request(railwayOperations.serviceInstanceUpdate, {
586
+ environmentId: input.environmentId,
587
+ serviceId,
588
+ input: {
589
+ ...(input.startCommand ? { startCommand: input.startCommand } : {}),
590
+ ...(input.region ? { region: input.region } : {}),
591
+ ...(input.railwayConfigFile ? { railwayConfigFile: input.railwayConfigFile } : {}),
592
+ },
593
+ })
594
+ }
595
+ input.onResolved(serviceId)
596
+ return serviceId
597
+ }
598
+
599
+ async function removeDisabledWorker(input: {
600
+ client: RailwayGraphqlClient
601
+ projectId: string
602
+ environmentId: string
603
+ workerServiceId?: string
604
+ }): Promise<void> {
605
+ if (!input.workerServiceId) return
606
+ try {
607
+ const data = await input.client.request<{
608
+ service: { id: string; name: string; projectId: string }
609
+ }>(railwayOperations.service, { id: input.workerServiceId })
610
+ if (data.service.projectId !== input.projectId || data.service.name !== 'mercato-worker') {
611
+ throw new Error('Recorded worker service does not match the managed Railway project.')
612
+ }
613
+ } catch (error) {
614
+ if (isNotFoundError(error)) return
615
+ throw error
616
+ }
617
+ try {
618
+ await input.client.request(railwayOperations.serviceDelete, {
619
+ id: input.workerServiceId,
620
+ environmentId: input.environmentId,
621
+ })
622
+ } catch (error) {
623
+ try {
624
+ await input.client.request(railwayOperations.service, { id: input.workerServiceId })
625
+ } catch (lookupError) {
626
+ if (isNotFoundError(lookupError)) return
627
+ throw lookupError
628
+ }
629
+ throw error
630
+ }
631
+ }
632
+
633
+ async function upsertVariables(input: {
634
+ client: RailwayGraphqlClient
635
+ projectId: string
636
+ environmentId: string
637
+ serviceId: string
638
+ variables: Record<string, string>
639
+ }): Promise<void> {
640
+ await input.client.request(railwayOperations.variableUpsert, {
641
+ input: {
642
+ projectId: input.projectId,
643
+ environmentId: input.environmentId,
644
+ serviceId: input.serviceId,
645
+ variables: input.variables,
646
+ replace: false,
647
+ skipDeploys: true,
648
+ },
649
+ })
650
+ }
651
+
652
+ async function loadVariables(input: {
653
+ client: RailwayGraphqlClient
654
+ projectId: string
655
+ environmentId: string
656
+ serviceId: string
657
+ }): Promise<Record<string, string>> {
658
+ const data = await input.client.request<{ variables: unknown }>(
659
+ railwayOperations.variables,
660
+ {
661
+ projectId: input.projectId,
662
+ environmentId: input.environmentId,
663
+ serviceId: input.serviceId,
664
+ },
665
+ )
666
+ if (!data.variables || typeof data.variables !== 'object' || Array.isArray(data.variables)) return {}
667
+ return Object.fromEntries(
668
+ Object.entries(data.variables as Record<string, unknown>)
669
+ .filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
670
+ )
671
+ }
672
+
673
+ async function latestDeployment(
674
+ client: RailwayGraphqlClient,
675
+ projectId: string,
676
+ environmentId: string,
677
+ serviceId: string,
678
+ ): Promise<RailwayDeployment | null> {
679
+ const data = await client.request<{
680
+ deployments: Connection<RailwayDeployment>
681
+ }>(railwayOperations.deployments, { projectId, environmentId, serviceId })
682
+ return nodes(data.deployments)[0] ?? null
683
+ }
684
+
685
+ async function printDeploymentLogs(
686
+ client: RailwayGraphqlClient,
687
+ deploymentId: string,
688
+ prefix: string,
689
+ secrets: string[],
690
+ ): Promise<void> {
691
+ for (const operation of [railwayOperations.buildLogs, railwayOperations.deploymentLogs]) {
692
+ try {
693
+ const data = await client.request<Record<string, Array<{ message?: string }>>>(
694
+ operation,
695
+ { deploymentId, limit: 100 },
696
+ )
697
+ const logKey = operation === railwayOperations.buildLogs ? 'buildLogs' : 'deploymentLogs'
698
+ for (const entry of data[logKey] ?? []) {
699
+ if (entry.message) console.log(`[${prefix}] ${redactText(entry.message, secrets)}`)
700
+ }
701
+ } catch {
702
+ // Log retrieval is best-effort; deployment status remains authoritative.
703
+ }
704
+ }
705
+ }
706
+
707
+ async function waitForDeployment(input: {
708
+ client: RailwayGraphqlClient
709
+ deploymentId: string
710
+ timeoutSeconds: number
711
+ prefix: string
712
+ secrets: string[]
713
+ }): Promise<RailwayDeployment> {
714
+ const deadline = Date.now() + input.timeoutSeconds * 1_000
715
+ let lastStatus = 'UNKNOWN'
716
+ while (Date.now() < deadline) {
717
+ const data = await input.client.request<{ deployment: RailwayDeployment }>(
718
+ railwayOperations.deployment,
719
+ { id: input.deploymentId },
720
+ )
721
+ lastStatus = data.deployment.status
722
+ if (SUCCESS_STATUSES.has(lastStatus)) return data.deployment
723
+ if (FAILURE_STATUSES.has(lastStatus)) {
724
+ await printDeploymentLogs(
725
+ input.client,
726
+ input.deploymentId,
727
+ input.prefix,
728
+ input.secrets,
729
+ )
730
+ throw new Error(`${input.prefix} deployment failed with status ${lastStatus}.`)
731
+ }
732
+ await wait(5_000)
733
+ }
734
+ throw new Error(
735
+ `Timed out waiting for ${input.prefix} deployment. Last status: ${lastStatus}. Re-run the command to resume.`,
736
+ )
737
+ }
738
+
739
+ async function deployService(input: {
740
+ client: RailwayGraphqlClient
741
+ cwd: string
742
+ source: RailwaySource
743
+ projectId: string
744
+ environmentId: string
745
+ serviceId: string
746
+ serviceName: string
747
+ timeoutSeconds: number
748
+ token: string
749
+ secrets: string[]
750
+ }): Promise<RailwayDeployment> {
751
+ const previous = await latestDeployment(
752
+ input.client,
753
+ input.projectId,
754
+ input.environmentId,
755
+ input.serviceId,
756
+ )
757
+ let deploymentId: string | null = null
758
+ let triggerError: unknown
759
+ try {
760
+ if (input.source.mode === 'git') {
761
+ await input.client.request(railwayOperations.deploy, {
762
+ environmentId: input.environmentId,
763
+ serviceId: input.serviceId,
764
+ commitSha: input.source.commitSha,
765
+ })
766
+ } else {
767
+ deploymentId = uploadLocalSource({
768
+ cwd: input.cwd,
769
+ projectId: input.projectId,
770
+ environmentId: input.environmentId,
771
+ serviceId: input.serviceId,
772
+ serviceName: input.serviceName,
773
+ token: input.token,
774
+ })
775
+ }
776
+ } catch (error) {
777
+ if (!isAmbiguousMutationError(error)) throw error
778
+ triggerError = error
779
+ }
780
+ if (!deploymentId) {
781
+ const deadline = Date.now() + 30_000
782
+ while (Date.now() < deadline) {
783
+ const current = await latestDeployment(
784
+ input.client,
785
+ input.projectId,
786
+ input.environmentId,
787
+ input.serviceId,
788
+ )
789
+ if (current && current.id !== previous?.id) {
790
+ deploymentId = current.id
791
+ break
792
+ }
793
+ await wait(2_000)
794
+ }
795
+ }
796
+ if (!deploymentId) {
797
+ if (triggerError) throw triggerError
798
+ throw new Error(`Railway did not expose a new deployment for ${input.serviceName}.`)
799
+ }
800
+ return waitForDeployment({
801
+ client: input.client,
802
+ deploymentId,
803
+ timeoutSeconds: input.timeoutSeconds,
804
+ prefix: input.serviceName,
805
+ secrets: [input.token, ...input.secrets],
806
+ })
807
+ }
808
+
809
+ function collectSensitiveValues(variables: Record<string, string>): string[] {
810
+ return collectSensitiveStructuredValues(variables)
811
+ }
812
+
813
+ async function ensureDomain(input: {
814
+ client: RailwayGraphqlClient
815
+ projectId: string
816
+ environmentId: string
817
+ serviceId: string
818
+ customDomain?: string
819
+ waitForVerification: boolean
820
+ currentDomainId?: string
821
+ }): Promise<{ id: string; url: string }> {
822
+ type DnsRecord = {
823
+ hostlabel?: string
824
+ requiredValue?: string
825
+ recordType?: string
826
+ }
827
+ type CustomDomain = {
828
+ id: string
829
+ domain: string
830
+ status?: {
831
+ verified?: boolean
832
+ dnsRecords?: DnsRecord[]
833
+ }
834
+ }
835
+ type ServiceDomain = {
836
+ id: string
837
+ domain: string
838
+ }
839
+
840
+ const existingData = await input.client.request<{
841
+ domains: {
842
+ customDomains?: CustomDomain[]
843
+ serviceDomains?: ServiceDomain[]
844
+ }
845
+ }>(railwayOperations.domains, {
846
+ projectId: input.projectId,
847
+ environmentId: input.environmentId,
848
+ serviceId: input.serviceId,
849
+ })
850
+
851
+ if (!input.customDomain && input.currentDomainId) {
852
+ const existingCustomDomain = existingData.domains.customDomains?.find(
853
+ (candidate) => candidate.id === input.currentDomainId,
854
+ )
855
+ if (existingCustomDomain) {
856
+ return {
857
+ id: existingCustomDomain.id,
858
+ url: `https://${existingCustomDomain.domain}`,
859
+ }
860
+ }
861
+ const existingRecordedServiceDomain = existingData.domains.serviceDomains?.find(
862
+ (candidate) => candidate.id === input.currentDomainId,
863
+ )
864
+ if (existingRecordedServiceDomain) {
865
+ return {
866
+ id: existingRecordedServiceDomain.id,
867
+ url: `https://${existingRecordedServiceDomain.domain}`,
868
+ }
869
+ }
870
+ }
871
+
872
+ if (input.customDomain) {
873
+ const existingDomain = existingData.domains.customDomains?.find(
874
+ (candidate) => candidate.domain === input.customDomain,
875
+ )
876
+ let domain: CustomDomain
877
+ if (existingDomain) {
878
+ domain = existingDomain
879
+ } else {
880
+ try {
881
+ const data = await input.client.request<{ customDomainCreate: CustomDomain }>(
882
+ railwayOperations.customDomainCreate,
883
+ {
884
+ input: {
885
+ projectId: input.projectId,
886
+ environmentId: input.environmentId,
887
+ serviceId: input.serviceId,
888
+ domain: input.customDomain,
889
+ targetPort: 3000,
890
+ },
891
+ },
892
+ )
893
+ domain = data.customDomainCreate
894
+ } catch (error) {
895
+ const discovered = await input.client.request<{
896
+ domains: { customDomains?: CustomDomain[] }
897
+ }>(railwayOperations.domains, {
898
+ projectId: input.projectId,
899
+ environmentId: input.environmentId,
900
+ serviceId: input.serviceId,
901
+ })
902
+ const created = discovered.domains.customDomains?.find(
903
+ (candidate) => candidate.domain === input.customDomain,
904
+ )
905
+ if (!created) throw error
906
+ domain = created
907
+ }
908
+ }
909
+ const records = domain.status?.dnsRecords ?? []
910
+ if (records.length > 0) {
911
+ console.log('Configure these DNS records:')
912
+ for (const record of records) {
913
+ console.log(` ${record.recordType ?? 'DNS'} ${record.hostlabel ?? '@'} -> ${record.requiredValue ?? ''}`)
914
+ }
915
+ }
916
+ if (input.waitForVerification && !domain.status?.verified) {
917
+ const deadline = Date.now() + 300_000
918
+ while (Date.now() < deadline) {
919
+ await wait(10_000)
920
+ const refreshed: { customDomain: CustomDomain } = await input.client.request(
921
+ railwayOperations.customDomain,
922
+ { id: domain.id, projectId: input.projectId },
923
+ )
924
+ domain = refreshed.customDomain
925
+ if (domain.status?.verified) break
926
+ }
927
+ if (!domain.status?.verified) {
928
+ console.warn('DNS not yet propagated. The deployment succeeded; re-run later to verify the domain.')
929
+ }
930
+ }
931
+ return {
932
+ id: domain.id,
933
+ url: `https://${domain.domain}`,
934
+ }
935
+ }
936
+ const existingServiceDomain = existingData.domains.serviceDomains?.[0]
937
+ if (existingServiceDomain) {
938
+ return {
939
+ id: existingServiceDomain.id,
940
+ url: `https://${existingServiceDomain.domain}`,
941
+ }
942
+ }
943
+ let createdDomain: ServiceDomain
944
+ try {
945
+ const data = await input.client.request<{
946
+ serviceDomainCreate: ServiceDomain
947
+ }>(railwayOperations.serviceDomainCreate, {
948
+ input: {
949
+ environmentId: input.environmentId,
950
+ serviceId: input.serviceId,
951
+ targetPort: 3000,
952
+ },
953
+ })
954
+ createdDomain = data.serviceDomainCreate
955
+ } catch (error) {
956
+ const discovered = await input.client.request<{
957
+ domains: { serviceDomains?: ServiceDomain[] }
958
+ }>(railwayOperations.domains, {
959
+ projectId: input.projectId,
960
+ environmentId: input.environmentId,
961
+ serviceId: input.serviceId,
962
+ })
963
+ const created = discovered.domains.serviceDomains?.[0]
964
+ if (!created) throw error
965
+ createdDomain = created
966
+ }
967
+ return {
968
+ id: createdDomain.id,
969
+ url: `https://${createdDomain.domain}`,
970
+ }
971
+ }
972
+
973
+ async function cleanupProject(input: {
974
+ client: RailwayGraphqlClient
975
+ state: RailwayState
976
+ statePath: string
977
+ options: RailwayDeployOptions
978
+ prompt: Prompt | null
979
+ workspaceId: string
980
+ }): Promise<void> {
981
+ if (!input.state.projectId) throw new Error('The Railway state file does not contain a project ID.')
982
+ if (input.state.workspaceId && input.state.workspaceId !== input.workspaceId) {
983
+ throw new Error('Recorded Railway project belongs to a different workspace.')
984
+ }
985
+ const projectData = await input.client.request<{
986
+ project: { id: string; name: string; workspaceId?: string | null }
987
+ }>(railwayOperations.project, { id: input.state.projectId })
988
+ if (
989
+ projectData.project.name !== input.state.projectName
990
+ || (projectData.project.workspaceId && projectData.project.workspaceId !== input.workspaceId)
991
+ ) {
992
+ throw new Error('Recorded Railway project identity does not match the cleanup target.')
993
+ }
994
+ if (!input.options.yes) {
995
+ if (input.options.nonInteractive || !input.prompt) {
996
+ throw new Error('--cleanup requires --yes in non-interactive mode.')
997
+ }
998
+ console.log(`Project: ${input.state.projectName} (${input.state.projectId})`)
999
+ for (const [name, environment] of Object.entries(input.state.environments)) {
1000
+ console.log(` ${name}: ${environment.appUrl ?? 'no recorded URL'}`)
1001
+ }
1002
+ const confirmation = await input.prompt.ask(`Type "${input.state.projectName}" to delete this project: `)
1003
+ if (confirmation.trim() !== input.state.projectName) throw new Error('Cleanup cancelled.')
1004
+ }
1005
+ await input.client.request(railwayOperations.projectDelete, { id: input.state.projectId })
1006
+ if (existsSync(input.statePath)) unlinkSync(input.statePath)
1007
+ console.log(`Deleted Railway project ${input.state.projectName}.`)
1008
+ }
1009
+
1010
+ function printDryRun(input: {
1011
+ state: RailwayState
1012
+ options: RailwayDeployOptions
1013
+ source: RailwaySource
1014
+ envFile: string
1015
+ appVariables: Record<string, string>
1016
+ workerVariables?: Record<string, string>
1017
+ }): void {
1018
+ console.log('Railway deployment plan (dry run)')
1019
+ console.log(` Project: ${input.state.projectName}`)
1020
+ console.log(` Environment: ${input.options.environment}`)
1021
+ console.log(` Source: ${input.source.mode} (${input.source.reason})`)
1022
+ console.log(` Env file: ${input.envFile}`)
1023
+ console.log(' Operations:')
1024
+ console.log(' - validate Railway account and workspace')
1025
+ console.log(' - create or reuse project and environment')
1026
+ console.log(' - provision Postgres and Redis from Railway templates')
1027
+ console.log(` - create or reuse ${input.options.service}`)
1028
+ if (input.options.worker) console.log(' - create or reuse mercato-worker')
1029
+ if (
1030
+ !input.options.worker
1031
+ && input.state.environments[input.options.environment]?.workerServiceId
1032
+ ) {
1033
+ console.log(' - delete the recorded managed mercato-worker service')
1034
+ }
1035
+ console.log(' - upsert service variables')
1036
+ console.log(` - deploy via ${input.source.mode === 'git' ? 'Railway Git source' : 'railway up'}`)
1037
+ console.log(' - create a Railway or custom domain and redeploy the app')
1038
+ if (input.options.volume) console.log(` - create volume mounted at ${input.options.volume}`)
1039
+ console.log(' App variables:')
1040
+ for (const line of formatVariablePlan(input.appVariables, input.options.token)) console.log(` ${line}`)
1041
+ if (input.workerVariables) {
1042
+ console.log(' Worker variables:')
1043
+ for (const line of formatVariablePlan(input.workerVariables, input.options.token)) console.log(` ${line}`)
1044
+ }
1045
+ }
1046
+
1047
+ export async function runRailwayDeploy(
1048
+ args: string[],
1049
+ dependencies: {
1050
+ cwd?: string
1051
+ prompt?: Prompt | null
1052
+ resolveSource?: typeof resolveRailwaySource
1053
+ } = {},
1054
+ ): Promise<void> {
1055
+ const options = parseRailwayDeployOptions(args)
1056
+ if (options.help) {
1057
+ console.log(railwayDeployHelp())
1058
+ return
1059
+ }
1060
+ const cwd = dependencies.cwd ?? process.cwd()
1061
+ const appPackage = readAppPackage(cwd)
1062
+ assertSupportedNodeVersion(appPackage.nodeEngine)
1063
+ const statePath = railwayStatePath(cwd, options.track)
1064
+ const loadedState = loadRailwayState(statePath)
1065
+ const requestedName = normalizeProjectName(options.project || appPackage.name)
1066
+ const state = loadedState ?? createRailwayState(requestedName, process.env.npm_package_version || 'unknown')
1067
+ if (options.cleanup && !state.projectId) {
1068
+ throw new Error('The Railway state file does not contain a project ID.')
1069
+ }
1070
+ if (loadedState && options.project && requestedName !== loadedState.projectName) {
1071
+ if (!options.forceRename) {
1072
+ throw new Error(
1073
+ `State records project "${loadedState.projectName}". Pass --force-rename to use "${requestedName}".`,
1074
+ )
1075
+ }
1076
+ state.projectName = requestedName
1077
+ }
1078
+ const environmentState = ensureEnvironmentState(state, options.environment)
1079
+ const source = options.cleanup
1080
+ ? { mode: 'local' as const, reason: 'cleanup does not deploy source' }
1081
+ : (dependencies.resolveSource ?? resolveRailwaySource)(options.source, cwd)
1082
+ if (options.cleanup && options.dryRun) {
1083
+ console.log('Railway cleanup plan (dry run)')
1084
+ console.log(` Project: ${state.projectName} (${state.projectId})`)
1085
+ console.log(` State: ${statePath}`)
1086
+ console.log(' Operation: projectDelete')
1087
+ return
1088
+ }
1089
+ if (!options.cleanup && !options.dryRun) {
1090
+ console.log(`Source: ${source.mode} (${source.reason})`)
1091
+ }
1092
+ if (!options.cleanup && source.mode === 'local') assertLocalUploadSafe(cwd)
1093
+ const envFile = options.cleanup ? '' : resolveEnvFile(cwd, options.envFile)
1094
+ const localEnv = options.cleanup ? {} : parseEnvFile(readFileSync(envFile, 'utf8'))
1095
+ let protectedSecrets = options.dryRun
1096
+ ? {
1097
+ AUTH_SECRET: 'generated-at-deploy',
1098
+ JWT_SECRET: 'generated-at-deploy',
1099
+ TENANT_DATA_ENCRYPTION_FALLBACK_KEY: 'generated-at-deploy',
1100
+ }
1101
+ : generateProtectedSecrets(localEnv)
1102
+ let appVariables = computeRailwayVariables({
1103
+ env: localEnv,
1104
+ role: 'app',
1105
+ workerEnabled: options.worker,
1106
+ protectedSecrets,
1107
+ railwayToken: options.token || process.env.RAILWAY_API_TOKEN,
1108
+ allowedSecretKeys: options.allowedSecretKeys,
1109
+ })
1110
+ let workerVariables = options.worker
1111
+ ? computeRailwayVariables({
1112
+ env: localEnv,
1113
+ role: 'worker',
1114
+ workerEnabled: true,
1115
+ protectedSecrets,
1116
+ railwayToken: options.token || process.env.RAILWAY_API_TOKEN,
1117
+ allowedSecretKeys: options.allowedSecretKeys,
1118
+ })
1119
+ : undefined
1120
+
1121
+ if (options.dryRun) {
1122
+ printDryRun({ state, options, source, envFile, appVariables, workerVariables })
1123
+ return
1124
+ }
1125
+
1126
+ const ownsPrompt = dependencies.prompt === undefined
1127
+ const prompt = dependencies.prompt === undefined
1128
+ ? (!options.nonInteractive && process.stdin.isTTY ? createPrompt() : null)
1129
+ : dependencies.prompt
1130
+ try {
1131
+ const tokenResult = await resolveTokenInteractively(options, prompt)
1132
+ const client = createRailwayGraphqlClient({
1133
+ token: tokenResult.token,
1134
+ verbose: options.verbose,
1135
+ })
1136
+ console.log('Step 1: Authenticate with Railway')
1137
+ const { workspaceId } = await resolveWorkspace(client, state.workspaceId)
1138
+
1139
+ if (options.cleanup) {
1140
+ await cleanupProject({ client, state, statePath, options, prompt, workspaceId })
1141
+ return
1142
+ }
1143
+ state.workspaceId = workspaceId
1144
+ persistState(statePath, state)
1145
+
1146
+ console.log('Step 2: Resolve workspace')
1147
+ console.log(`Step 3: Create or look up project "${state.projectName}"`)
1148
+ const projectId = await ensureProject({ client, state, statePath, workspaceId, options })
1149
+ console.log(`Step 4: Create or look up environment "${options.environment}"`)
1150
+ const environmentId = await ensureEnvironment({
1151
+ client,
1152
+ projectId,
1153
+ name: options.environment,
1154
+ environmentState,
1155
+ persist: () => persistState(statePath, state),
1156
+ })
1157
+
1158
+ if (options.region) {
1159
+ const regionData = await client.request<{
1160
+ regions: Array<{ id?: string | null; name: string; region?: string | null }>
1161
+ }>(
1162
+ railwayOperations.regions,
1163
+ { projectId },
1164
+ )
1165
+ if (!regionData.regions.some(
1166
+ (region) => region.id === options.region || region.region === options.region,
1167
+ )) {
1168
+ throw new Error(`Unknown Railway region "${options.region}".`)
1169
+ }
1170
+ }
1171
+
1172
+ console.log('Step 5: Provision managed Postgres and Redis')
1173
+ await ensureDatabase({
1174
+ client,
1175
+ projectId,
1176
+ environmentId,
1177
+ workspaceId,
1178
+ name: 'Postgres',
1179
+ code: 'postgres',
1180
+ currentId: environmentState.postgresServiceId,
1181
+ onResolved: (id) => {
1182
+ environmentState.postgresServiceId = id
1183
+ persistState(statePath, state)
1184
+ },
1185
+ })
1186
+ await ensureDatabase({
1187
+ client,
1188
+ projectId,
1189
+ environmentId,
1190
+ workspaceId,
1191
+ name: 'Redis',
1192
+ code: 'redis',
1193
+ currentId: environmentState.redisServiceId,
1194
+ onResolved: (id) => {
1195
+ environmentState.redisServiceId = id
1196
+ persistState(statePath, state)
1197
+ },
1198
+ })
1199
+
1200
+ console.log('Step 6: Create app and worker services')
1201
+ const previousSource = environmentState.source
1202
+ const appServiceId = await ensureService({
1203
+ client,
1204
+ projectId,
1205
+ environmentId,
1206
+ name: options.service,
1207
+ source,
1208
+ currentId: environmentState.appServiceId,
1209
+ previousSource,
1210
+ startCommand: 'sh ./scripts/railway-start.sh',
1211
+ region: options.region,
1212
+ railwayConfigFile: 'railway.toml',
1213
+ onResolved: (id) => {
1214
+ environmentState.appServiceId = id
1215
+ environmentState.source = source
1216
+ persistState(statePath, state)
1217
+ },
1218
+ })
1219
+ const workerServiceId = options.worker
1220
+ ? await ensureService({
1221
+ client,
1222
+ projectId,
1223
+ environmentId,
1224
+ name: 'mercato-worker',
1225
+ source,
1226
+ currentId: environmentState.workerServiceId,
1227
+ previousSource,
1228
+ startCommand: 'sh ./scripts/railway-worker.sh',
1229
+ region: options.region,
1230
+ railwayConfigFile: 'railway.worker.toml',
1231
+ onResolved: (id) => {
1232
+ environmentState.workerServiceId = id
1233
+ persistState(statePath, state)
1234
+ },
1235
+ })
1236
+ : undefined
1237
+ if (!options.worker && environmentState.workerServiceId) {
1238
+ await removeDisabledWorker({
1239
+ client,
1240
+ projectId,
1241
+ environmentId,
1242
+ workerServiceId: environmentState.workerServiceId,
1243
+ })
1244
+ environmentState.workerServiceId = undefined
1245
+ if (environmentState.lastDeployIds) {
1246
+ delete environmentState.lastDeployIds.worker
1247
+ }
1248
+ persistState(statePath, state)
1249
+ }
1250
+
1251
+ if (options.volume) {
1252
+ if (environmentState.volumeId) {
1253
+ const volumeData = await client.request<{
1254
+ project: {
1255
+ volumes: Connection<{
1256
+ id: string
1257
+ volumeInstances: Connection<{
1258
+ environmentId: string
1259
+ serviceId?: string | null
1260
+ mountPath: string
1261
+ }>
1262
+ }>
1263
+ }
1264
+ }>(railwayOperations.volumes, { projectId })
1265
+ const recordedVolume = nodes(volumeData.project.volumes).find(
1266
+ (volume) => volume.id === environmentState.volumeId
1267
+ && nodes(volume.volumeInstances).some((instance) =>
1268
+ instance.environmentId === environmentId
1269
+ && instance.serviceId === appServiceId
1270
+ && instance.mountPath === options.volume,
1271
+ ),
1272
+ )
1273
+ if (!recordedVolume) environmentState.volumeId = undefined
1274
+ }
1275
+ }
1276
+ if (options.volume && !environmentState.volumeId) {
1277
+ try {
1278
+ const volumeData = await client.request<{ volumeCreate: { id: string } }>(
1279
+ railwayOperations.volumeCreate,
1280
+ {
1281
+ input: {
1282
+ projectId,
1283
+ environmentId,
1284
+ serviceId: appServiceId,
1285
+ mountPath: options.volume,
1286
+ },
1287
+ },
1288
+ )
1289
+ environmentState.volumeId = volumeData.volumeCreate.id
1290
+ } catch (error) {
1291
+ const volumeData = await client.request<{
1292
+ project: {
1293
+ volumes: Connection<{
1294
+ id: string
1295
+ volumeInstances: Connection<{
1296
+ environmentId: string
1297
+ serviceId?: string | null
1298
+ mountPath: string
1299
+ }>
1300
+ }>
1301
+ }
1302
+ }>(railwayOperations.volumes, { projectId })
1303
+ const discovered = nodes(volumeData.project.volumes).find((volume) =>
1304
+ nodes(volume.volumeInstances).some((instance) =>
1305
+ instance.environmentId === environmentId
1306
+ && instance.serviceId === appServiceId
1307
+ && instance.mountPath === options.volume,
1308
+ ),
1309
+ )
1310
+ if (!discovered) throw error
1311
+ environmentState.volumeId = discovered.id
1312
+ }
1313
+ persistState(statePath, state)
1314
+ }
1315
+ if (!options.volume) {
1316
+ console.warn(
1317
+ 'Attachments uploaded to this deployment will be lost on redeploy. Re-run with --volume /app/storage to enable persistent storage.',
1318
+ )
1319
+ }
1320
+
1321
+ console.log('Step 7: Compute and upload environment variables')
1322
+ const existingVariables = await loadVariables({
1323
+ client,
1324
+ projectId,
1325
+ environmentId,
1326
+ serviceId: appServiceId,
1327
+ })
1328
+ protectedSecrets = generateProtectedSecrets({ ...localEnv, ...existingVariables })
1329
+ appVariables = computeRailwayVariables({
1330
+ env: localEnv,
1331
+ role: 'app',
1332
+ workerEnabled: options.worker,
1333
+ protectedSecrets,
1334
+ railwayToken: tokenResult.token,
1335
+ allowedSecretKeys: options.allowedSecretKeys,
1336
+ })
1337
+ workerVariables = options.worker
1338
+ ? computeRailwayVariables({
1339
+ env: localEnv,
1340
+ role: 'worker',
1341
+ workerEnabled: true,
1342
+ protectedSecrets,
1343
+ railwayToken: tokenResult.token,
1344
+ allowedSecretKeys: options.allowedSecretKeys,
1345
+ })
1346
+ : undefined
1347
+ await upsertVariables({ client, projectId, environmentId, serviceId: appServiceId, variables: appVariables })
1348
+ if (workerServiceId && workerVariables) {
1349
+ await upsertVariables({
1350
+ client,
1351
+ projectId,
1352
+ environmentId,
1353
+ serviceId: workerServiceId,
1354
+ variables: workerVariables,
1355
+ })
1356
+ }
1357
+ if (options.writeEnv) writeGeneratedSecrets(envFile, protectedSecrets)
1358
+
1359
+ console.log('Step 8: Trigger deployment and monitor status')
1360
+ const appDeployment = await deployService({
1361
+ client,
1362
+ cwd,
1363
+ source,
1364
+ projectId,
1365
+ environmentId,
1366
+ serviceId: appServiceId,
1367
+ serviceName: options.service,
1368
+ timeoutSeconds: options.timeoutSeconds,
1369
+ token: tokenResult.token,
1370
+ secrets: collectSensitiveValues(appVariables),
1371
+ })
1372
+ environmentState.lastDeployIds ??= {}
1373
+ environmentState.lastDeployIds.app = appDeployment.id
1374
+ persistState(statePath, state)
1375
+ if (workerServiceId) {
1376
+ const workerDeployment = await deployService({
1377
+ client,
1378
+ cwd,
1379
+ source,
1380
+ projectId,
1381
+ environmentId,
1382
+ serviceId: workerServiceId,
1383
+ serviceName: 'mercato-worker',
1384
+ timeoutSeconds: options.timeoutSeconds,
1385
+ token: tokenResult.token,
1386
+ secrets: collectSensitiveValues(workerVariables ?? {}),
1387
+ })
1388
+ environmentState.lastDeployIds.worker = workerDeployment.id
1389
+ persistState(statePath, state)
1390
+ }
1391
+
1392
+ console.log('Step 9: Provision public domain')
1393
+ const domain = await ensureDomain({
1394
+ client,
1395
+ projectId,
1396
+ environmentId,
1397
+ serviceId: appServiceId,
1398
+ customDomain: options.domain,
1399
+ waitForVerification: options.waitDomain,
1400
+ currentDomainId: environmentState.domainId,
1401
+ })
1402
+ environmentState.domainId = domain.id
1403
+ environmentState.appUrl = domain.url
1404
+ persistState(statePath, state)
1405
+ const finalAppVariables = { ...appVariables, APP_URL: domain.url, NEXT_PUBLIC_APP_URL: domain.url }
1406
+ await upsertVariables({
1407
+ client,
1408
+ projectId,
1409
+ environmentId,
1410
+ serviceId: appServiceId,
1411
+ variables: finalAppVariables,
1412
+ })
1413
+ const finalDeployment = await deployService({
1414
+ client,
1415
+ cwd,
1416
+ source,
1417
+ projectId,
1418
+ environmentId,
1419
+ serviceId: appServiceId,
1420
+ serviceName: options.service,
1421
+ timeoutSeconds: options.timeoutSeconds,
1422
+ token: tokenResult.token,
1423
+ secrets: collectSensitiveValues(finalAppVariables),
1424
+ })
1425
+ environmentState.lastDeployIds.app = finalDeployment.id
1426
+ persistState(statePath, state)
1427
+
1428
+ console.log('Step 10: Deployment complete')
1429
+ console.log('')
1430
+ console.log('Open Mercato deployed to Railway')
1431
+ console.log(` Project: https://railway.com/project/${projectId}`)
1432
+ console.log(` Environment: ${options.environment}`)
1433
+ console.log(` Source: ${source.mode}`)
1434
+ console.log(` App URL: ${domain.url}`)
1435
+ console.log(` Health: ${domain.url}/api/healthz`)
1436
+ if (workerServiceId) console.log(' Worker: mercato-worker (running)')
1437
+ console.log(` State: ${statePath}`)
1438
+ if (tokenResult.cached) console.log(` Token cache: ${railwayTokenConfigPath()}`)
1439
+ console.log('')
1440
+ console.log(`DEPLOY_URL=${domain.url}`)
1441
+ } finally {
1442
+ if (ownsPrompt) prompt?.close()
1443
+ }
1444
+ }
1445
+
1446
+ export {
1447
+ parseRailwayDeployOptions,
1448
+ railwayDeployHelp,
1449
+ computeRailwayVariables,
1450
+ formatVariablePlan,
1451
+ generateProtectedSecrets,
1452
+ parseEnvFile,
1453
+ resolveEnvFile,
1454
+ }