@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,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
|
+
}
|