@open-mercato/cli 0.6.5-develop.5116.1.f0af9e5080 → 0.6.5-develop.5155.1.148d10a46d
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/bin.js +1 -0
- package/dist/bin.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/mercato.js +23 -8
- package/dist/mercato.js.map +2 -2
- package/package.json +10 -10
- package/src/bin.ts +1 -0
- 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/mercato.ts +33 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/cli",
|
|
3
|
-
"version": "0.6.5-develop.
|
|
3
|
+
"version": "0.6.5-develop.5155.1.148d10a46d",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -55,25 +55,25 @@
|
|
|
55
55
|
"node": "24.x"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@mikro-orm/core": "^7.1.
|
|
59
|
-
"@mikro-orm/decorators": "^7.1.
|
|
60
|
-
"@mikro-orm/migrations": "^7.1.
|
|
61
|
-
"@mikro-orm/postgresql": "^7.1.
|
|
62
|
-
"@open-mercato/queue": "0.6.5-develop.
|
|
63
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
58
|
+
"@mikro-orm/core": "^7.1.4",
|
|
59
|
+
"@mikro-orm/decorators": "^7.1.4",
|
|
60
|
+
"@mikro-orm/migrations": "^7.1.4",
|
|
61
|
+
"@mikro-orm/postgresql": "^7.1.4",
|
|
62
|
+
"@open-mercato/queue": "0.6.5-develop.5155.1.148d10a46d",
|
|
63
|
+
"@open-mercato/shared": "0.6.5-develop.5155.1.148d10a46d",
|
|
64
64
|
"cross-spawn": "^7.0.6",
|
|
65
65
|
"pg": "8.21.0",
|
|
66
|
-
"semver": "^7.8.
|
|
66
|
+
"semver": "^7.8.2",
|
|
67
67
|
"testcontainers": "^12.0.1",
|
|
68
68
|
"ts-morph": "^28.0.0",
|
|
69
69
|
"tsx": "^4.22.4",
|
|
70
70
|
"typescript": "^6.0.3"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
73
|
+
"@open-mercato/shared": "0.6.5-develop.5155.1.148d10a46d"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
76
|
+
"@open-mercato/shared": "0.6.5-develop.5155.1.148d10a46d",
|
|
77
77
|
"@types/jest": "^30.0.0",
|
|
78
78
|
"jest": "^30.4.2",
|
|
79
79
|
"ts-jest": "^29.4.11"
|
package/src/bin.ts
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { expect, test } from '@playwright/test'
|
|
5
|
+
|
|
6
|
+
const token = process.env.RAILWAY_INTEGRATION_TOKEN
|
|
7
|
+
const appRoot = process.env.RAILWAY_INTEGRATION_APP_ROOT
|
|
8
|
+
const cliBin = resolve(process.cwd(), 'packages/cli/dist/bin.js')
|
|
9
|
+
|
|
10
|
+
function runCli(args: string[]): string {
|
|
11
|
+
if (!appRoot || !token) throw new Error('Railway integration environment is incomplete.')
|
|
12
|
+
return execFileSync(process.execPath, [cliBin, ...args], {
|
|
13
|
+
cwd: appRoot,
|
|
14
|
+
encoding: 'utf8',
|
|
15
|
+
env: {
|
|
16
|
+
...process.env,
|
|
17
|
+
RAILWAY_API_TOKEN: token,
|
|
18
|
+
CI: 'true',
|
|
19
|
+
},
|
|
20
|
+
timeout: 15 * 60 * 1000,
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test.describe('mercato deploy railway live integration', () => {
|
|
25
|
+
test.skip(!token || !appRoot, 'RAILWAY_INTEGRATION_TOKEN and RAILWAY_INTEGRATION_APP_ROOT are required')
|
|
26
|
+
|
|
27
|
+
test('deploys, verifies health, resumes, and cleans up', async ({ request }) => {
|
|
28
|
+
expect(existsSync(cliBin)).toBe(true)
|
|
29
|
+
const projectName = `mercato-integration-${Date.now()}`
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const output = runCli([
|
|
33
|
+
'deploy',
|
|
34
|
+
'railway',
|
|
35
|
+
'--project',
|
|
36
|
+
projectName,
|
|
37
|
+
'--source',
|
|
38
|
+
'local',
|
|
39
|
+
'--no-worker',
|
|
40
|
+
'--non-interactive',
|
|
41
|
+
])
|
|
42
|
+
const outputLines = output.trim().split(/\r?\n/)
|
|
43
|
+
const deployUrl = outputLines
|
|
44
|
+
.find((line) => line.startsWith('DEPLOY_URL='))
|
|
45
|
+
?.slice('DEPLOY_URL='.length)
|
|
46
|
+
expect(deployUrl).toBeTruthy()
|
|
47
|
+
expect(outputLines.at(-1)).toBe(`DEPLOY_URL=${deployUrl}`)
|
|
48
|
+
|
|
49
|
+
const health = await request.get(`${deployUrl}/api/healthz`)
|
|
50
|
+
expect(health.status()).toBe(200)
|
|
51
|
+
expect(await health.json()).toEqual({
|
|
52
|
+
status: 'ok',
|
|
53
|
+
ts: expect.any(String),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const resumeOutput = runCli([
|
|
57
|
+
'deploy',
|
|
58
|
+
'railway',
|
|
59
|
+
'--source',
|
|
60
|
+
'local',
|
|
61
|
+
'--no-worker',
|
|
62
|
+
'--non-interactive',
|
|
63
|
+
])
|
|
64
|
+
expect(resumeOutput).toContain(`Project: https://railway.com/project/`)
|
|
65
|
+
} finally {
|
|
66
|
+
if (appRoot && existsSync(resolve(appRoot, '.mercato', 'railway.json'))) {
|
|
67
|
+
runCli(['deploy', 'railway', '--cleanup', '--non-interactive', '--yes'])
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { runRailwayDeploy } from '../index'
|
|
5
|
+
|
|
6
|
+
describe('mercato deploy railway --dry-run', () => {
|
|
7
|
+
it('prints a deterministic local-source plan without a token or state mutation', async () => {
|
|
8
|
+
const cwd = mkdtempSync(join(tmpdir(), 'mercato-railway-dry-run-'))
|
|
9
|
+
mkdirSync(join(cwd, 'node_modules'), { recursive: true })
|
|
10
|
+
writeFileSync(join(cwd, 'package.json'), JSON.stringify({
|
|
11
|
+
name: 'My Shop',
|
|
12
|
+
dependencies: { '@open-mercato/core': '0.6.4' },
|
|
13
|
+
}))
|
|
14
|
+
writeFileSync(join(cwd, '.env'), 'CUSTOM_SETTING=enabled\n')
|
|
15
|
+
writeFileSync(
|
|
16
|
+
join(cwd, '.railwayignore'),
|
|
17
|
+
'.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',
|
|
18
|
+
)
|
|
19
|
+
const log = jest.spyOn(console, 'log').mockImplementation(() => undefined)
|
|
20
|
+
|
|
21
|
+
await runRailwayDeploy(['--dry-run', '--source', 'local'], {
|
|
22
|
+
cwd,
|
|
23
|
+
resolveSource: () => ({ mode: 'local', reason: 'test local source' }),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const output = log.mock.calls.map((call) => call.join(' ')).join('\n')
|
|
27
|
+
expect(output).toContain('Railway deployment plan (dry run)')
|
|
28
|
+
expect(output).toContain('Project: my-shop')
|
|
29
|
+
expect(output).toContain('Source: local')
|
|
30
|
+
expect(output).toContain('AUTH_SECRET=<redacted>')
|
|
31
|
+
expect(output).not.toContain('generated-at-deploy')
|
|
32
|
+
log.mockRestore()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('prints a cleanup plan without deleting tracked state', async () => {
|
|
36
|
+
const cwd = mkdtempSync(join(tmpdir(), 'mercato-railway-cleanup-dry-run-'))
|
|
37
|
+
mkdirSync(join(cwd, '.mercato'), { recursive: true })
|
|
38
|
+
writeFileSync(join(cwd, 'package.json'), JSON.stringify({
|
|
39
|
+
name: 'My Shop',
|
|
40
|
+
dependencies: { '@open-mercato/core': '0.6.4' },
|
|
41
|
+
}))
|
|
42
|
+
const statePath = join(cwd, '.mercato', 'railway.json')
|
|
43
|
+
writeFileSync(statePath, JSON.stringify({
|
|
44
|
+
schemaVersion: 1,
|
|
45
|
+
provider: 'railway',
|
|
46
|
+
projectId: 'project-1',
|
|
47
|
+
projectName: 'my-shop',
|
|
48
|
+
environments: {},
|
|
49
|
+
writtenBy: { cliVersion: 'test' },
|
|
50
|
+
}))
|
|
51
|
+
const log = jest.spyOn(console, 'log').mockImplementation(() => undefined)
|
|
52
|
+
|
|
53
|
+
await runRailwayDeploy(['--cleanup', '--dry-run'], { cwd })
|
|
54
|
+
|
|
55
|
+
const output = log.mock.calls.map((call) => call.join(' ')).join('\n')
|
|
56
|
+
expect(output).toContain('Railway cleanup plan (dry run)')
|
|
57
|
+
expect(output).toContain('projectDelete')
|
|
58
|
+
expect(existsSync(statePath)).toBe(true)
|
|
59
|
+
log.mockRestore()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computeRailwayVariables,
|
|
3
|
+
generateProtectedSecrets,
|
|
4
|
+
parseEnvFile,
|
|
5
|
+
} from '../env'
|
|
6
|
+
|
|
7
|
+
describe('Railway environment variables', () => {
|
|
8
|
+
it('parses common dotenv syntax', () => {
|
|
9
|
+
expect(parseEnvFile(`
|
|
10
|
+
# comment
|
|
11
|
+
PLAIN=value
|
|
12
|
+
export QUOTED="hello world"
|
|
13
|
+
SINGLE='x'
|
|
14
|
+
`)).toEqual({
|
|
15
|
+
PLAIN: 'value',
|
|
16
|
+
QUOTED: 'hello world',
|
|
17
|
+
SINGLE: 'x',
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('generates missing protected secrets and preserves existing ones', () => {
|
|
22
|
+
const secrets = generateProtectedSecrets({ AUTH_SECRET: 'existing' })
|
|
23
|
+
expect(secrets.AUTH_SECRET).toBe('existing')
|
|
24
|
+
expect(secrets.JWT_SECRET).toHaveLength(128)
|
|
25
|
+
expect(secrets.TENANT_DATA_ENCRYPTION_FALLBACK_KEY.length).toBeGreaterThan(30)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('computes app and worker settings without leaking Railway tokens', () => {
|
|
29
|
+
const variables = computeRailwayVariables({
|
|
30
|
+
env: {
|
|
31
|
+
RAILWAY_API_TOKEN: 'never-upload',
|
|
32
|
+
CUSTOM_SETTING: 'enabled',
|
|
33
|
+
},
|
|
34
|
+
role: 'app',
|
|
35
|
+
workerEnabled: true,
|
|
36
|
+
appUrl: 'https://example.up.railway.app',
|
|
37
|
+
protectedSecrets: {
|
|
38
|
+
AUTH_SECRET: 'auth',
|
|
39
|
+
JWT_SECRET: 'jwt',
|
|
40
|
+
TENANT_DATA_ENCRYPTION_FALLBACK_KEY: 'encryption',
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
expect(variables).toMatchObject({
|
|
44
|
+
DATABASE_URL: '${{Postgres.DATABASE_URL}}',
|
|
45
|
+
REDIS_URL: '${{Redis.REDIS_URL}}',
|
|
46
|
+
CACHE_STRATEGY: 'redis',
|
|
47
|
+
QUEUE_STRATEGY: 'async',
|
|
48
|
+
AUTO_SPAWN_WORKERS: 'false',
|
|
49
|
+
PORT: '3000',
|
|
50
|
+
APP_URL: 'https://example.up.railway.app',
|
|
51
|
+
CUSTOM_SETTING: 'enabled',
|
|
52
|
+
})
|
|
53
|
+
expect(variables.RAILWAY_API_TOKEN).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRailwayGraphqlClient,
|
|
3
|
+
RailwayGraphqlError,
|
|
4
|
+
} from '../graphql-client'
|
|
5
|
+
|
|
6
|
+
const query = { name: 'TestQuery', query: 'query TestQuery { me { id } }' }
|
|
7
|
+
const mutation = { ...query, name: 'TestMutation', mutation: true }
|
|
8
|
+
|
|
9
|
+
function response(body: unknown, status = 200): Response {
|
|
10
|
+
return new Response(JSON.stringify(body), { status })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('Railway GraphQL client', () => {
|
|
14
|
+
it('sends operationName, query, variables, and bearer auth', async () => {
|
|
15
|
+
const fetchImpl = jest.fn(async () => response({ data: { me: { id: 'user' } } }))
|
|
16
|
+
const client = createRailwayGraphqlClient({
|
|
17
|
+
token: 'account-token',
|
|
18
|
+
fetchImpl: fetchImpl as typeof fetch,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
await expect(client.request(query, { input: 'value' })).resolves.toEqual({ me: { id: 'user' } })
|
|
22
|
+
expect(fetchImpl).toHaveBeenCalledWith(
|
|
23
|
+
'https://backboard.railway.com/graphql/v2',
|
|
24
|
+
expect.objectContaining({
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: expect.objectContaining({ Authorization: 'Bearer account-token' }),
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
operationName: 'TestQuery',
|
|
29
|
+
query: query.query,
|
|
30
|
+
variables: { input: 'value' },
|
|
31
|
+
}),
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('retries idempotent reads once on a server error', async () => {
|
|
37
|
+
const fetchImpl = jest.fn()
|
|
38
|
+
.mockResolvedValueOnce(response({ errors: [{ message: 'temporary' }] }, 500))
|
|
39
|
+
.mockResolvedValueOnce(response({ data: { ok: true } }))
|
|
40
|
+
const client = createRailwayGraphqlClient({
|
|
41
|
+
token: 'token',
|
|
42
|
+
fetchImpl: fetchImpl as typeof fetch,
|
|
43
|
+
})
|
|
44
|
+
await expect(client.request(query)).resolves.toEqual({ ok: true })
|
|
45
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('never blindly retries mutations', async () => {
|
|
49
|
+
const fetchImpl = jest.fn(async () => response({ errors: [{ message: 'ambiguous' }] }, 500))
|
|
50
|
+
const client = createRailwayGraphqlClient({
|
|
51
|
+
token: 'token',
|
|
52
|
+
fetchImpl: fetchImpl as typeof fetch,
|
|
53
|
+
})
|
|
54
|
+
await expect(client.request(mutation)).rejects.toBeInstanceOf(RailwayGraphqlError)
|
|
55
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('redacts sensitive request and response fields in verbose logs', async () => {
|
|
59
|
+
const logger = { log: jest.fn() }
|
|
60
|
+
const fetchImpl = jest.fn(async () => response({
|
|
61
|
+
data: { variables: { AUTH_SECRET: 'response-secret', SAFE_VALUE: 'visible' } },
|
|
62
|
+
}))
|
|
63
|
+
const client = createRailwayGraphqlClient({
|
|
64
|
+
token: 'account-token',
|
|
65
|
+
verbose: true,
|
|
66
|
+
fetchImpl: fetchImpl as typeof fetch,
|
|
67
|
+
logger,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
await client.request(query, {
|
|
71
|
+
input: { JWT_SECRET: 'request-secret', SAFE_VALUE: 'visible' },
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const output = logger.log.mock.calls.flat().join('\n')
|
|
75
|
+
expect(output).not.toContain('request-secret')
|
|
76
|
+
expect(output).not.toContain('response-secret')
|
|
77
|
+
expect(output).toContain('<redacted>')
|
|
78
|
+
expect(output).toContain('visible')
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
railwayOperations,
|
|
4
|
+
railwaySchemaFingerprintSource,
|
|
5
|
+
} from '../operations'
|
|
6
|
+
|
|
7
|
+
describe('Railway GraphQL operation contract', () => {
|
|
8
|
+
it('matches the verified operation fingerprint', () => {
|
|
9
|
+
const fingerprint = createHash('sha256')
|
|
10
|
+
.update(railwaySchemaFingerprintSource())
|
|
11
|
+
.digest('hex')
|
|
12
|
+
expect(fingerprint).toBe('01e3c5feaa163ebe626a1cb9ae5f22b5e7ea316162fed9353375f34c25ebc522')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('uses template-backed databases and singular service variable upserts', () => {
|
|
16
|
+
const source = railwaySchemaFingerprintSource()
|
|
17
|
+
expect(source).toContain('templateDeployV2')
|
|
18
|
+
expect(source).not.toContain('pluginCreate')
|
|
19
|
+
expect(railwayOperations.variableUpsert.query).toContain('VariableCollectionUpsertInput')
|
|
20
|
+
})
|
|
21
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { parseRailwayDeployOptions, redactRailwayCliArgs } from '../options'
|
|
2
|
+
|
|
3
|
+
describe('parseRailwayDeployOptions', () => {
|
|
4
|
+
it('parses the complete command surface', () => {
|
|
5
|
+
expect(parseRailwayDeployOptions([
|
|
6
|
+
'--project=my-shop',
|
|
7
|
+
'--env', 'staging',
|
|
8
|
+
'--service', 'web',
|
|
9
|
+
'--no-worker',
|
|
10
|
+
'--source', 'local',
|
|
11
|
+
'--region', 'europe-west4',
|
|
12
|
+
'--env-file', '.env.railway',
|
|
13
|
+
'--domain', 'shop.example.com',
|
|
14
|
+
'--no-wait-domain',
|
|
15
|
+
'--volume', '/app/storage',
|
|
16
|
+
'--token', 'token',
|
|
17
|
+
'--non-interactive',
|
|
18
|
+
'--dry-run',
|
|
19
|
+
'--yes',
|
|
20
|
+
'--write-env',
|
|
21
|
+
'--no-track',
|
|
22
|
+
'--force-rename',
|
|
23
|
+
'--timeout', '120',
|
|
24
|
+
'--allow-secret-passthrough', 'OPENAI_API_KEY',
|
|
25
|
+
'--allow-secret-passthrough=RESEND_API_KEY',
|
|
26
|
+
'--verbose',
|
|
27
|
+
], {})).toMatchObject({
|
|
28
|
+
project: 'my-shop',
|
|
29
|
+
environment: 'staging',
|
|
30
|
+
service: 'web',
|
|
31
|
+
worker: false,
|
|
32
|
+
source: 'local',
|
|
33
|
+
region: 'europe-west4',
|
|
34
|
+
envFile: '.env.railway',
|
|
35
|
+
domain: 'shop.example.com',
|
|
36
|
+
waitDomain: false,
|
|
37
|
+
volume: '/app/storage',
|
|
38
|
+
token: 'token',
|
|
39
|
+
nonInteractive: true,
|
|
40
|
+
dryRun: true,
|
|
41
|
+
yes: true,
|
|
42
|
+
writeEnv: true,
|
|
43
|
+
track: false,
|
|
44
|
+
forceRename: true,
|
|
45
|
+
timeoutSeconds: 120,
|
|
46
|
+
allowedSecretKeys: ['OPENAI_API_KEY', 'RESEND_API_KEY'],
|
|
47
|
+
verbose: true,
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('enables non-interactive mode in CI', () => {
|
|
52
|
+
expect(parseRailwayDeployOptions([], { CI: 'true' }).nonInteractive).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('allows cleanup dry-runs in non-interactive environments without --yes', () => {
|
|
56
|
+
expect(parseRailwayDeployOptions(['--cleanup', '--dry-run'], { CI: 'true' }))
|
|
57
|
+
.toMatchObject({
|
|
58
|
+
cleanup: true,
|
|
59
|
+
dryRun: true,
|
|
60
|
+
nonInteractive: true,
|
|
61
|
+
yes: false,
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it.each([
|
|
66
|
+
[['--source', 'invalid'], 'Invalid --source'],
|
|
67
|
+
[['--timeout', '0'], '--timeout must be'],
|
|
68
|
+
[['--unknown'], 'Unknown Railway deploy option'],
|
|
69
|
+
[['--cleanup', '--non-interactive'], 'requires --yes'],
|
|
70
|
+
])('rejects invalid arguments', (args, expectedMessage) => {
|
|
71
|
+
expect(() => parseRailwayDeployOptions(args, {})).toThrow(expectedMessage)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('redacts both token flag syntaxes in dispatcher output', () => {
|
|
75
|
+
expect(redactRailwayCliArgs(['--token', 'secret', '--token=other', '--dry-run']))
|
|
76
|
+
.toEqual(['--token', '****', '--token=****', '--dry-run'])
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -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
|
+
})
|