@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.
Files changed (56) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/bin.js +1 -0
  3. package/dist/bin.js.map +2 -2
  4. package/dist/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.meta.js +7 -0
  5. package/dist/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.meta.js.map +7 -0
  6. package/dist/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.spec.js +63 -0
  7. package/dist/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.spec.js.map +7 -0
  8. package/dist/lib/deploy/railway/env.js +98 -0
  9. package/dist/lib/deploy/railway/env.js.map +7 -0
  10. package/dist/lib/deploy/railway/graphql-client.js +99 -0
  11. package/dist/lib/deploy/railway/graphql-client.js.map +7 -0
  12. package/dist/lib/deploy/railway/index.js +1115 -0
  13. package/dist/lib/deploy/railway/index.js.map +7 -0
  14. package/dist/lib/deploy/railway/operations.js +249 -0
  15. package/dist/lib/deploy/railway/operations.js.map +7 -0
  16. package/dist/lib/deploy/railway/options.js +155 -0
  17. package/dist/lib/deploy/railway/options.js.map +7 -0
  18. package/dist/lib/deploy/railway/railway-cli.js +61 -0
  19. package/dist/lib/deploy/railway/railway-cli.js.map +7 -0
  20. package/dist/lib/deploy/railway/redaction.js +88 -0
  21. package/dist/lib/deploy/railway/redaction.js.map +7 -0
  22. package/dist/lib/deploy/railway/source.js +128 -0
  23. package/dist/lib/deploy/railway/source.js.map +7 -0
  24. package/dist/lib/deploy/railway/state.js +41 -0
  25. package/dist/lib/deploy/railway/state.js.map +7 -0
  26. package/dist/lib/deploy/railway/token.js +46 -0
  27. package/dist/lib/deploy/railway/token.js.map +7 -0
  28. package/dist/lib/deploy/railway/types.js +1 -0
  29. package/dist/lib/deploy/railway/types.js.map +7 -0
  30. package/dist/mercato.js +23 -8
  31. package/dist/mercato.js.map +2 -2
  32. package/package.json +10 -10
  33. package/src/bin.ts +1 -0
  34. package/src/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.meta.ts +3 -0
  35. package/src/lib/deploy/railway/__integration__/TC-CLI-RAILWAY-001.spec.ts +71 -0
  36. package/src/lib/deploy/railway/__tests__/dry-run.test.ts +61 -0
  37. package/src/lib/deploy/railway/__tests__/env.test.ts +55 -0
  38. package/src/lib/deploy/railway/__tests__/graphql-client.test.ts +80 -0
  39. package/src/lib/deploy/railway/__tests__/operations.test.ts +21 -0
  40. package/src/lib/deploy/railway/__tests__/options.test.ts +78 -0
  41. package/src/lib/deploy/railway/__tests__/redaction.test.ts +62 -0
  42. package/src/lib/deploy/railway/__tests__/source.test.ts +130 -0
  43. package/src/lib/deploy/railway/__tests__/state.test.ts +24 -0
  44. package/src/lib/deploy/railway/__tests__/token.test.ts +39 -0
  45. package/src/lib/deploy/railway/env.ts +112 -0
  46. package/src/lib/deploy/railway/graphql-client.ts +128 -0
  47. package/src/lib/deploy/railway/index.ts +1454 -0
  48. package/src/lib/deploy/railway/operations.ts +248 -0
  49. package/src/lib/deploy/railway/options.ts +167 -0
  50. package/src/lib/deploy/railway/railway-cli.ts +65 -0
  51. package/src/lib/deploy/railway/redaction.ts +91 -0
  52. package/src/lib/deploy/railway/source.ts +143 -0
  53. package/src/lib/deploy/railway/state.ts +45 -0
  54. package/src/lib/deploy/railway/token.ts +49 -0
  55. package/src/lib/deploy/railway/types.ts +87 -0
  56. 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
+ }