@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,62 @@
1
+ import {
2
+ assertSafeVariables,
3
+ collectSensitiveStructuredValues,
4
+ detectCredentialValue,
5
+ formatVariableValue,
6
+ redactStructuredValue,
7
+ redactText,
8
+ } from '../redaction'
9
+
10
+ describe('Railway secret redaction', () => {
11
+ it.each([
12
+ ['sk-testcredential0123456789'],
13
+ ['xoxb-1234567890-abcdefghij'],
14
+ ['ghp_abcdefghijklmnopqrstuvwxyz'],
15
+ ['-----BEGIN PRIVATE KEY-----\nsecret'],
16
+ ])('detects credential-like values', (value) => {
17
+ expect(detectCredentialValue(value)).toBe(true)
18
+ })
19
+
20
+ it('requires an exact per-key passthrough', () => {
21
+ expect(() => assertSafeVariables({ OPENAI_API_KEY: 'sk-testcredential0123456789' }))
22
+ .toThrow('--allow-secret-passthrough OPENAI_API_KEY')
23
+ expect(() => assertSafeVariables(
24
+ { OPENAI_API_KEY: 'sk-testcredential0123456789' },
25
+ { allowedKeys: ['OPENAI_API_KEY'] },
26
+ )).not.toThrow()
27
+ })
28
+
29
+ it('redacts authorization headers and known secrets', () => {
30
+ expect(redactText('Authorization: Bearer token-value secret-value', ['secret-value']))
31
+ .toBe('Authorization: Bearer **** ****')
32
+ })
33
+
34
+ it('prints only a fingerprint for sensitive variables', () => {
35
+ const formatted = formatVariableValue('AUTH_SECRET', 'top-secret')
36
+ expect(formatted).toMatch(/^<redacted> \(sha256:[a-f0-9]{8}\)$/)
37
+ expect(formatted).not.toContain('top-secret')
38
+ })
39
+
40
+ it('redacts structured values by sensitive field name', () => {
41
+ expect(redactStructuredValue({
42
+ variables: {
43
+ AUTH_SECRET: 'random-hex-without-a-credential-prefix',
44
+ SAFE_VALUE: 'visible',
45
+ },
46
+ })).toEqual({
47
+ variables: {
48
+ AUTH_SECRET: '<redacted>',
49
+ SAFE_VALUE: 'visible',
50
+ },
51
+ })
52
+ })
53
+
54
+ it('collects sensitive values for downstream log redaction', () => {
55
+ expect(collectSensitiveStructuredValues({
56
+ variables: {
57
+ AUTH_SECRET: 'auth-secret',
58
+ nested: { API_KEY: 'api-key', SAFE_VALUE: 'visible' },
59
+ },
60
+ })).toEqual(expect.arrayContaining(['auth-secret', 'api-key']))
61
+ })
62
+ })
@@ -0,0 +1,130 @@
1
+ import {
2
+ assertGitDeployReady,
3
+ assertLocalUploadSafe,
4
+ normalizeGitRepository,
5
+ resolveRailwaySource,
6
+ } from '../source'
7
+ import { mkdtempSync, writeFileSync } from 'node:fs'
8
+ import { tmpdir } from 'node:os'
9
+ import { join } from 'node:path'
10
+
11
+ function runner(responses: Record<string, string | Error>) {
12
+ return (command: string, args: string[]) => {
13
+ const key = `${command} ${args.join(' ')}`
14
+ const response = responses[key]
15
+ if (response instanceof Error || response === undefined) throw response ?? new Error(key)
16
+ return response
17
+ }
18
+ }
19
+
20
+ describe('Railway source strategy', () => {
21
+ it.each([
22
+ ['git@github.com:open-mercato/example.git', 'open-mercato/example'],
23
+ ['https://github.com/open-mercato/example.git', 'open-mercato/example'],
24
+ ['https://gitlab.com/open-mercato/example.git', null],
25
+ ])('normalizes supported remotes', (remote, expected) => {
26
+ expect(normalizeGitRepository(remote)).toBe(expected)
27
+ })
28
+
29
+ it('prefers a synchronized GitHub remote in auto mode', () => {
30
+ const run = runner({
31
+ 'git branch --show-current': 'main',
32
+ 'git remote get-url origin': 'git@github.com:open-mercato/example.git',
33
+ 'git rev-parse HEAD': 'abc123',
34
+ 'git status --porcelain': '',
35
+ 'git rev-parse --abbrev-ref --symbolic-full-name @{u}': 'origin/main',
36
+ 'git rev-list --count @{u}..HEAD': '0',
37
+ 'git rev-list --count HEAD..@{u}': '0',
38
+ })
39
+
40
+ expect(resolveRailwaySource('auto', '/tmp/app', run)).toMatchObject({
41
+ mode: 'git',
42
+ repo: 'open-mercato/example',
43
+ branch: 'main',
44
+ commitSha: 'abc123',
45
+ })
46
+ })
47
+
48
+ it('falls back to local upload when no supported remote exists', () => {
49
+ const run = runner({
50
+ 'railway --version': 'railway 4.66.1',
51
+ })
52
+ expect(resolveRailwaySource('auto', '/tmp/app', run)).toEqual({
53
+ mode: 'local',
54
+ reason: 'No usable GitHub remote found; falling back to railway up',
55
+ })
56
+ })
57
+
58
+ it('falls back to local upload when Git exists but is not ready for remote deploy', () => {
59
+ const run = runner({
60
+ 'git branch --show-current': 'main',
61
+ 'git remote get-url origin': 'git@github.com:open-mercato/example.git',
62
+ 'git rev-parse HEAD': 'abc123',
63
+ 'git status --porcelain': ' M package.json',
64
+ 'railway --version': 'railway 5.1.0',
65
+ })
66
+ expect(resolveRailwaySource('auto', '/tmp/app', run)).toMatchObject({
67
+ mode: 'local',
68
+ reason: expect.stringContaining('clean working tree'),
69
+ })
70
+ })
71
+
72
+ it('rejects dirty Git-backed deploys', () => {
73
+ const run = runner({
74
+ 'git status --porcelain': ' M package.json',
75
+ })
76
+ expect(() => assertGitDeployReady('/tmp/app', run)).toThrow('clean working tree')
77
+ })
78
+
79
+ it('rejects a Git branch that is behind its upstream', () => {
80
+ const run = runner({
81
+ 'git status --porcelain': '',
82
+ 'git rev-parse --abbrev-ref --symbolic-full-name @{u}': 'origin/main',
83
+ 'git rev-list --count @{u}..HEAD': '0',
84
+ 'git rev-list --count HEAD..@{u}': '2',
85
+ })
86
+ expect(() => assertGitDeployReady('/tmp/app', run)).toThrow('behind its upstream')
87
+ })
88
+
89
+ it('rejects a local upload ignore file without state protection', () => {
90
+ const cwd = mkdtempSync(join(tmpdir(), 'mercato-railway-ignore-'))
91
+ writeFileSync(join(cwd, '.railwayignore'), '.env\n*.pem\n*.key\n.git\nnode_modules\n')
92
+ expect(() => assertLocalUploadSafe(cwd)).toThrow('.mercato/railway.json')
93
+ })
94
+
95
+ it('accepts the scaffold local upload safety contract', () => {
96
+ const cwd = mkdtempSync(join(tmpdir(), 'mercato-railway-ignore-'))
97
+ writeFileSync(
98
+ join(cwd, '.railwayignore'),
99
+ '.env*\n*.pem\n*.key\nid_*\n.git\n.railway\nnode_modules\n.yarn/cache\n.next\n.turbo\n*.db\n*.sqlite\n*.sqlite3\n.mercato/railway.json*\n',
100
+ )
101
+ expect(() => assertLocalUploadSafe(cwd)).not.toThrow()
102
+ })
103
+
104
+ it('does not accept required safety entries from comments', () => {
105
+ const cwd = mkdtempSync(join(tmpdir(), 'mercato-railway-ignore-'))
106
+ writeFileSync(
107
+ join(cwd, '.railwayignore'),
108
+ '# .env*\n*.pem\n*.key\nid_*\n.git\n.railway\nnode_modules\n.yarn/cache\n.next\n.turbo\n*.db\n*.sqlite\n*.sqlite3\n.mercato/railway.json*\n',
109
+ )
110
+ expect(() => assertLocalUploadSafe(cwd)).toThrow('.env')
111
+ })
112
+
113
+ it('does not treat a single env filename as protection for every env file', () => {
114
+ const cwd = mkdtempSync(join(tmpdir(), 'mercato-railway-ignore-'))
115
+ writeFileSync(
116
+ join(cwd, '.railwayignore'),
117
+ '.env\n*.pem\n*.key\nid_*\n.git\n.railway\nnode_modules\n.yarn/cache\n.next\n.turbo\n*.db\n*.sqlite\n*.sqlite3\n.mercato/railway.json*\n',
118
+ )
119
+ expect(() => assertLocalUploadSafe(cwd)).toThrow('.env.*')
120
+ })
121
+
122
+ it('does not accept an unrelated Railway state filename prefix', () => {
123
+ const cwd = mkdtempSync(join(tmpdir(), 'mercato-railway-ignore-'))
124
+ writeFileSync(
125
+ join(cwd, '.railwayignore'),
126
+ '.env*\n*.pem\n*.key\nid_*\n.git\n.railway\nnode_modules\n.yarn/cache\n.next\n.turbo\n*.db\n*.sqlite\n*.sqlite3\n.mercato/railway.json.example\n',
127
+ )
128
+ expect(() => assertLocalUploadSafe(cwd)).toThrow('.mercato/railway.json')
129
+ })
130
+ })
@@ -0,0 +1,24 @@
1
+ import { mkdtempSync, readFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import {
5
+ createRailwayState,
6
+ loadRailwayState,
7
+ railwayStatePath,
8
+ saveRailwayState,
9
+ } from '../state'
10
+
11
+ describe('Railway deployment state', () => {
12
+ it('round-trips schema version 1 without secrets', () => {
13
+ const cwd = mkdtempSync(join(tmpdir(), 'mercato-railway-state-'))
14
+ const path = railwayStatePath(cwd, true)
15
+ const state = createRailwayState('my-shop', '0.6.4')
16
+ state.projectId = 'project-id'
17
+ state.environments.production = { environmentId: 'environment-id' }
18
+ saveRailwayState(path, state)
19
+
20
+ expect(loadRailwayState(path)).toEqual(state)
21
+ expect(readFileSync(path, 'utf8')).not.toMatch(/token|password/i)
22
+ expect(railwayStatePath(cwd, false).endsWith('.mercato/railway.json.local')).toBe(true)
23
+ })
24
+ })
@@ -0,0 +1,39 @@
1
+ import { chmodSync, mkdtempSync, readFileSync, statSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import {
5
+ readCachedRailwayToken,
6
+ resolveRailwayToken,
7
+ writeCachedRailwayToken,
8
+ } from '../token'
9
+
10
+ describe('Railway token resolution', () => {
11
+ it('uses flag, environment, then cache precedence', () => {
12
+ const cwd = mkdtempSync(join(tmpdir(), 'mercato-railway-token-'))
13
+ const configPath = join(cwd, 'railway.json')
14
+ writeCachedRailwayToken(configPath, 'cached-token')
15
+
16
+ expect(resolveRailwayToken({
17
+ flagToken: 'flag-token',
18
+ environment: { RAILWAY_API_TOKEN: 'environment-token' },
19
+ configPath,
20
+ })).toEqual({ token: 'flag-token', source: 'flag' })
21
+ expect(resolveRailwayToken({
22
+ environment: { RAILWAY_API_TOKEN: 'environment-token' },
23
+ configPath,
24
+ })).toEqual({ token: 'environment-token', source: 'environment' })
25
+ expect(resolveRailwayToken({ environment: {}, configPath }))
26
+ .toEqual({ token: 'cached-token', source: 'cache' })
27
+ })
28
+
29
+ it('writes a private cache file and rejects broad permissions', () => {
30
+ const cwd = mkdtempSync(join(tmpdir(), 'mercato-railway-token-'))
31
+ const configPath = join(cwd, 'railway.json')
32
+ writeCachedRailwayToken(configPath, 'cached-token')
33
+
34
+ expect(statSync(configPath).mode & 0o777).toBe(0o600)
35
+ expect(readFileSync(configPath, 'utf8')).toContain('cached-token')
36
+ chmodSync(configPath, 0o644)
37
+ expect(() => readCachedRailwayToken(configPath, 'linux')).toThrow('permissions must be 0600')
38
+ })
39
+ })
@@ -0,0 +1,112 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { spawnSync } from 'node:child_process'
3
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
4
+ import { dirname, resolve } from 'node:path'
5
+ import { assertSafeVariables, formatVariableValue } from './redaction'
6
+
7
+ export type RailwayServiceRole = 'app' | 'worker'
8
+
9
+ export function resolveEnvFile(cwd: string, requested?: string): string {
10
+ const candidates = requested ? [requested] : ['.env.production', '.env']
11
+ for (const candidate of candidates) {
12
+ const path = resolve(cwd, candidate)
13
+ if (existsSync(path)) return path
14
+ }
15
+ throw new Error(`Environment file not found. Checked: ${candidates.join(', ')}.`)
16
+ }
17
+
18
+ export function parseEnvFile(contents: string): Record<string, string> {
19
+ const result: Record<string, string> = {}
20
+ for (const rawLine of contents.split(/\r?\n/)) {
21
+ const line = rawLine.trim()
22
+ if (!line || line.startsWith('#')) continue
23
+ const normalized = line.startsWith('export ') ? line.slice(7).trim() : line
24
+ const separator = normalized.indexOf('=')
25
+ if (separator <= 0) continue
26
+ const key = normalized.slice(0, separator).trim()
27
+ let value = normalized.slice(separator + 1).trim()
28
+ if (
29
+ (value.startsWith('"') && value.endsWith('"')) ||
30
+ (value.startsWith("'") && value.endsWith("'"))
31
+ ) {
32
+ value = value.slice(1, -1)
33
+ }
34
+ result[key] = value.replace(/\\n/g, '\n')
35
+ }
36
+ return result
37
+ }
38
+
39
+ export function generateProtectedSecrets(existing: Record<string, string>): Record<string, string> {
40
+ return {
41
+ AUTH_SECRET: existing.AUTH_SECRET || randomBytes(64).toString('hex'),
42
+ JWT_SECRET: existing.JWT_SECRET || randomBytes(64).toString('hex'),
43
+ TENANT_DATA_ENCRYPTION_FALLBACK_KEY:
44
+ existing.TENANT_DATA_ENCRYPTION_FALLBACK_KEY || randomBytes(32).toString('base64url'),
45
+ }
46
+ }
47
+
48
+ export function computeRailwayVariables(input: {
49
+ env: Record<string, string>
50
+ role: RailwayServiceRole
51
+ workerEnabled: boolean
52
+ appUrl?: string
53
+ protectedSecrets: Record<string, string>
54
+ railwayToken?: string
55
+ allowedSecretKeys?: string[]
56
+ }): Record<string, string> {
57
+ const variables: Record<string, string> = {
58
+ ...input.env,
59
+ ...input.protectedSecrets,
60
+ DATABASE_URL: '${{Postgres.DATABASE_URL}}',
61
+ REDIS_URL: '${{Redis.REDIS_URL}}',
62
+ CACHE_STRATEGY: 'redis',
63
+ NODE_ENV: 'production',
64
+ NEXT_TELEMETRY_DISABLED: '1',
65
+ PORT: '3000',
66
+ OM_AI_PROVIDER: input.env.OM_AI_PROVIDER || 'openai',
67
+ OM_AI_MODEL: input.env.OM_AI_MODEL || 'gpt-5-mini',
68
+ QUEUE_STRATEGY: 'async',
69
+ NEXT_PUBLIC_QUEUE_STRATEGY: 'async',
70
+ AUTO_SPAWN_WORKERS: input.role === 'app' && !input.workerEnabled ? 'true' : 'false',
71
+ OM_AUTO_SPAWN_WORKERS: input.role === 'app' && !input.workerEnabled ? 'true' : 'false',
72
+ }
73
+
74
+ if (input.appUrl) {
75
+ variables.APP_URL = input.appUrl
76
+ variables.NEXT_PUBLIC_APP_URL = input.appUrl
77
+ }
78
+
79
+ delete variables.RAILWAY_API_TOKEN
80
+ delete variables.RAILWAY_TOKEN
81
+ assertSafeVariables(variables, {
82
+ railwayToken: input.railwayToken,
83
+ allowedKeys: input.allowedSecretKeys,
84
+ })
85
+ return variables
86
+ }
87
+
88
+ export function formatVariablePlan(
89
+ variables: Record<string, string>,
90
+ railwayToken?: string,
91
+ ): string[] {
92
+ return Object.keys(variables)
93
+ .sort()
94
+ .map((key) => `+ ADD ${key}=${formatVariableValue(key, variables[key] ?? '', railwayToken)}`)
95
+ }
96
+
97
+ export function writeGeneratedSecrets(envFile: string, secrets: Record<string, string>): void {
98
+ const ignoreCheck = spawnSync('git', ['check-ignore', '--quiet', envFile], {
99
+ cwd: dirname(envFile),
100
+ stdio: 'ignore',
101
+ })
102
+ if (ignoreCheck.status !== 0) {
103
+ throw new Error(`Refusing --write-env because ${envFile} is not ignored by Git.`)
104
+ }
105
+ const existing = readFileSync(envFile, 'utf8')
106
+ const missing = Object.entries(secrets).filter(([key]) => !new RegExp(`^${key}=`, 'm').test(existing))
107
+ if (missing.length === 0) return
108
+ const suffix = missing.map(([key, value]) => `${key}=${value}`).join('\n')
109
+ writeFileSync(envFile, `${existing.trimEnd()}\n\n# DO NOT COMMIT: generated for Railway deployment\n${suffix}\n`, {
110
+ mode: 0o600,
111
+ })
112
+ }
@@ -0,0 +1,128 @@
1
+ import {
2
+ collectSensitiveStructuredValues,
3
+ redactStructuredValue,
4
+ redactText,
5
+ } from './redaction'
6
+ import type { RailwayGraphqlClient, RailwayGraphqlOperation } from './types'
7
+
8
+ type FetchLike = typeof fetch
9
+
10
+ type GraphqlResponse<T> = {
11
+ data?: T
12
+ errors?: Array<{
13
+ message?: string
14
+ extensions?: { code?: string }
15
+ }>
16
+ }
17
+
18
+ export class RailwayGraphqlError extends Error {
19
+ constructor(
20
+ message: string,
21
+ public readonly status?: number,
22
+ public readonly code?: string,
23
+ ) {
24
+ super(message)
25
+ this.name = 'RailwayGraphqlError'
26
+ }
27
+ }
28
+
29
+ function shouldRetry(error: unknown): boolean {
30
+ return (
31
+ error instanceof RailwayGraphqlError &&
32
+ ((error.status !== undefined && error.status >= 500) || error.code === 'INTERNAL_ERROR')
33
+ )
34
+ }
35
+
36
+ function wait(milliseconds: number): Promise<void> {
37
+ return new Promise((resolve) => setTimeout(resolve, milliseconds))
38
+ }
39
+
40
+ export function createRailwayGraphqlClient(input: {
41
+ token: string
42
+ verbose?: boolean
43
+ endpoint?: string
44
+ fetchImpl?: FetchLike
45
+ logger?: Pick<Console, 'log'>
46
+ }): RailwayGraphqlClient {
47
+ const endpoint = input.endpoint ?? 'https://backboard.railway.com/graphql/v2'
48
+ if (!endpoint.startsWith('https://')) {
49
+ throw new Error('Railway GraphQL endpoint must use HTTPS.')
50
+ }
51
+ const fetchImpl = input.fetchImpl ?? fetch
52
+ const logger = input.logger ?? console
53
+
54
+ async function execute<T>(
55
+ operation: RailwayGraphqlOperation,
56
+ variables: Record<string, unknown>,
57
+ ): Promise<T> {
58
+ const requestSecrets = [input.token, ...collectSensitiveStructuredValues(variables)]
59
+ if (input.verbose) {
60
+ logger.log(
61
+ `[railway:gql] ${operation.name} ${JSON.stringify(redactStructuredValue(variables, requestSecrets))}`,
62
+ )
63
+ }
64
+ const response = await fetchImpl(endpoint, {
65
+ method: 'POST',
66
+ headers: {
67
+ Authorization: `Bearer ${input.token}`,
68
+ 'Content-Type': 'application/json',
69
+ },
70
+ body: JSON.stringify({
71
+ operationName: operation.name,
72
+ query: operation.query,
73
+ variables,
74
+ }),
75
+ signal: AbortSignal.timeout(operation.mutation ? 30_000 : 10_000),
76
+ })
77
+ const bodyText = await response.text()
78
+ let body: GraphqlResponse<T>
79
+ try {
80
+ body = JSON.parse(bodyText) as GraphqlResponse<T>
81
+ } catch {
82
+ throw new RailwayGraphqlError(
83
+ `Railway returned a non-JSON response (${response.status}).`,
84
+ response.status,
85
+ )
86
+ }
87
+ const firstError = body.errors?.[0]
88
+ if (!response.ok || firstError) {
89
+ throw new RailwayGraphqlError(
90
+ redactText(
91
+ firstError?.message || `Railway request failed (${response.status}).`,
92
+ requestSecrets,
93
+ ),
94
+ response.status,
95
+ firstError?.extensions?.code,
96
+ )
97
+ }
98
+ if (body.data === undefined) {
99
+ throw new RailwayGraphqlError('Railway response did not include data.', response.status)
100
+ }
101
+ if (input.verbose) {
102
+ logger.log(
103
+ `[railway:gql] ${operation.name} response ${JSON.stringify(redactStructuredValue(body.data, requestSecrets))}`,
104
+ )
105
+ }
106
+ return body.data
107
+ }
108
+
109
+ return {
110
+ async request<T>(
111
+ operation: RailwayGraphqlOperation,
112
+ variables: Record<string, unknown> = {},
113
+ ): Promise<T> {
114
+ const attempts = operation.mutation ? 1 : 2
115
+ let lastError: unknown
116
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
117
+ try {
118
+ return await execute<T>(operation, variables)
119
+ } catch (error) {
120
+ lastError = error
121
+ if (attempt + 1 >= attempts || !shouldRetry(error)) throw error
122
+ await wait(250 * (2 ** attempt))
123
+ }
124
+ }
125
+ throw lastError
126
+ },
127
+ }
128
+ }