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