@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,143 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import type { RailwaySource, RailwaySourceMode } from './types'
5
+
6
+ type CommandRunner = (command: string, args: string[], cwd: string) => string
7
+
8
+ const defaultRunner: CommandRunner = (command, args, cwd) =>
9
+ execFileSync(command, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
10
+
11
+ function tryRun(runner: CommandRunner, command: string, args: string[], cwd: string): string | null {
12
+ try {
13
+ return runner(command, args, cwd)
14
+ } catch {
15
+ return null
16
+ }
17
+ }
18
+
19
+ export function normalizeGitRepository(remoteUrl: string): string | null {
20
+ const trimmed = remoteUrl.trim().replace(/\.git$/, '')
21
+ const sshMatch = trimmed.match(/^git@github\.com:([^/]+\/[^/]+)$/)
22
+ if (sshMatch) return sshMatch[1] ?? null
23
+ try {
24
+ const parsed = new URL(trimmed)
25
+ if (parsed.hostname !== 'github.com') return null
26
+ return parsed.pathname.replace(/^\/+/, '') || null
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ export function inspectGitSource(cwd: string, runner: CommandRunner = defaultRunner): RailwaySource | null {
33
+ const branch = tryRun(runner, 'git', ['branch', '--show-current'], cwd)
34
+ const remoteUrl = tryRun(runner, 'git', ['remote', 'get-url', 'origin'], cwd)
35
+ if (!branch || !remoteUrl) return null
36
+ const repo = normalizeGitRepository(remoteUrl)
37
+ if (!repo) return null
38
+ const commitSha = tryRun(runner, 'git', ['rev-parse', 'HEAD'], cwd) ?? undefined
39
+ return {
40
+ mode: 'git',
41
+ reason: `GitHub remote origin and branch ${branch} detected`,
42
+ repo,
43
+ branch,
44
+ commitSha,
45
+ }
46
+ }
47
+
48
+ export function assertGitDeployReady(cwd: string, runner: CommandRunner = defaultRunner): void {
49
+ const status = tryRun(runner, 'git', ['status', '--porcelain'], cwd)
50
+ if (status === null) throw new Error('Git is required for --source git.')
51
+ if (status.length > 0) throw new Error('Git-backed deploy requires a clean working tree.')
52
+ const upstream = tryRun(runner, 'git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], cwd)
53
+ if (!upstream) throw new Error('The current branch has no upstream. Push it before a Git-backed deploy.')
54
+ const ahead = tryRun(runner, 'git', ['rev-list', '--count', '@{u}..HEAD'], cwd)
55
+ if (ahead === null || Number.parseInt(ahead, 10) > 0) {
56
+ throw new Error('The current branch has local commits that are not pushed to its upstream.')
57
+ }
58
+ const behind = tryRun(runner, 'git', ['rev-list', '--count', 'HEAD..@{u}'], cwd)
59
+ if (behind === null || Number.parseInt(behind, 10) > 0) {
60
+ throw new Error('The current branch is behind its upstream. Pull the latest commits before a Git-backed deploy.')
61
+ }
62
+ }
63
+
64
+ export function isRailwayCliAvailable(cwd: string, runner: CommandRunner = defaultRunner): boolean {
65
+ return tryRun(runner, 'railway', ['--version'], cwd) !== null
66
+ }
67
+
68
+ export function resolveRailwaySource(
69
+ mode: RailwaySourceMode,
70
+ cwd: string,
71
+ runner: CommandRunner = defaultRunner,
72
+ ): RailwaySource {
73
+ const gitSource = inspectGitSource(cwd, runner)
74
+ if (mode === 'git') {
75
+ if (!gitSource) {
76
+ throw new Error('--source git requires a GitHub origin remote and a current branch.')
77
+ }
78
+ assertGitDeployReady(cwd, runner)
79
+ return gitSource
80
+ }
81
+ if (mode === 'local') {
82
+ if (!isRailwayCliAvailable(cwd, runner)) {
83
+ throw new Error('--source local requires the Railway CLI. Install it from https://docs.railway.com/reference/cli-api.')
84
+ }
85
+ return { mode: 'local', reason: 'Local upload explicitly requested' }
86
+ }
87
+ if (gitSource) {
88
+ try {
89
+ assertGitDeployReady(cwd, runner)
90
+ return gitSource
91
+ } catch (error) {
92
+ if (!isRailwayCliAvailable(cwd, runner)) throw error
93
+ const reason = error instanceof Error ? error.message : 'Git source is not deployable'
94
+ return {
95
+ mode: 'local',
96
+ reason: `${reason} Falling back to railway up.`,
97
+ }
98
+ }
99
+ }
100
+ if (!isRailwayCliAvailable(cwd, runner)) {
101
+ throw new Error('No usable GitHub remote found and the Railway CLI is unavailable for local upload.')
102
+ }
103
+ return { mode: 'local', reason: 'No usable GitHub remote found; falling back to railway up' }
104
+ }
105
+
106
+ const REQUIRED_IGNORE_ENTRIES = [
107
+ { label: '.env', matches: (line: string) => line === '.env' || line === '.env*' },
108
+ { label: '.env.*', matches: (line: string) => line === '.env.*' || line === '.env*' },
109
+ { label: '*.pem', matches: (line: string) => line === '*.pem' },
110
+ { label: '*.key', matches: (line: string) => line === '*.key' },
111
+ { label: 'id_rsa', matches: (line: string) => line === 'id_*' || line === 'id_rsa' },
112
+ { label: 'id_ed25519', matches: (line: string) => line === 'id_*' || line === 'id_ed25519' },
113
+ { label: '.git', matches: (line: string) => line === '.git' },
114
+ { label: '.railway', matches: (line: string) => line === '.railway' },
115
+ { label: 'node_modules', matches: (line: string) => line === 'node_modules' },
116
+ { label: '.yarn/cache', matches: (line: string) => line === '.yarn/cache' },
117
+ { label: '.next', matches: (line: string) => line === '.next' },
118
+ { label: '.turbo', matches: (line: string) => line === '.turbo' },
119
+ { label: '*.db', matches: (line: string) => line === '*.db' },
120
+ { label: '*.sqlite', matches: (line: string) => line === '*.sqlite' || line === '*.sqlite*' },
121
+ { label: '*.sqlite3', matches: (line: string) => line === '*.sqlite3' || line === '*.sqlite*' },
122
+ { label: '.mercato/railway.json', matches: (line: string) =>
123
+ line === '.mercato/railway.json' || line === '.mercato/railway.json*' },
124
+ { label: '.mercato/railway.json.local', matches: (line: string) =>
125
+ line === '.mercato/railway.json.local' || line === '.mercato/railway.json*' },
126
+ ]
127
+
128
+ export function assertLocalUploadSafe(cwd: string): void {
129
+ const ignorePath = resolve(cwd, '.railwayignore')
130
+ if (!existsSync(ignorePath)) {
131
+ throw new Error('Local Railway upload requires a .railwayignore file.')
132
+ }
133
+ const entries = readFileSync(ignorePath, 'utf8')
134
+ .split(/\r?\n/)
135
+ .map((line) => line.trim().replace(/^\/+|\/+$/g, ''))
136
+ .filter((line) => line.length > 0 && !line.startsWith('#') && !line.startsWith('!'))
137
+ const missing = REQUIRED_IGNORE_ENTRIES
138
+ .filter((requirement) => !entries.some(requirement.matches))
139
+ .map((requirement) => requirement.label)
140
+ if (missing.length > 0) {
141
+ throw new Error(`.railwayignore is missing required safety entries: ${missing.join(', ')}`)
142
+ }
143
+ }
@@ -0,0 +1,45 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import type { RailwayState } from './types'
4
+
5
+ export function railwayStatePath(cwd: string, track: boolean): string {
6
+ return resolve(cwd, '.mercato', track ? 'railway.json' : 'railway.json.local')
7
+ }
8
+
9
+ function isRailwayState(value: unknown): value is RailwayState {
10
+ if (!value || typeof value !== 'object') return false
11
+ const record = value as Record<string, unknown>
12
+ return (
13
+ record.schemaVersion === 1 &&
14
+ record.provider === 'railway' &&
15
+ typeof record.projectName === 'string' &&
16
+ typeof record.environments === 'object' &&
17
+ record.environments !== null
18
+ )
19
+ }
20
+
21
+ export function loadRailwayState(path: string): RailwayState | null {
22
+ if (!existsSync(path)) return null
23
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
24
+ if (!isRailwayState(parsed)) {
25
+ throw new Error(`Unsupported or invalid Railway state file: ${path}`)
26
+ }
27
+ return parsed
28
+ }
29
+
30
+ export function createRailwayState(projectName: string, cliVersion: string): RailwayState {
31
+ return {
32
+ schemaVersion: 1,
33
+ provider: 'railway',
34
+ projectName,
35
+ environments: {},
36
+ writtenBy: { cliVersion },
37
+ }
38
+ }
39
+
40
+ export function saveRailwayState(path: string, state: RailwayState): void {
41
+ mkdirSync(dirname(path), { recursive: true })
42
+ const temporaryPath = `${path}.tmp`
43
+ writeFileSync(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
44
+ renameSync(temporaryPath, path)
45
+ }
@@ -0,0 +1,49 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ export function railwayTokenConfigPath(
6
+ environment: NodeJS.ProcessEnv = process.env,
7
+ platform: NodeJS.Platform = process.platform,
8
+ ): string {
9
+ if (platform === 'win32') {
10
+ return join(environment.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'open-mercato', 'railway.json')
11
+ }
12
+ return join(environment.XDG_CONFIG_HOME || join(homedir(), '.config'), 'open-mercato', 'railway.json')
13
+ }
14
+
15
+ export function readCachedRailwayToken(path: string, platform: NodeJS.Platform = process.platform): string | null {
16
+ if (!existsSync(path)) return null
17
+ if (platform !== 'win32') {
18
+ const mode = statSync(path).mode & 0o777
19
+ if ((mode & 0o077) !== 0) {
20
+ throw new Error(`Refusing to read ${path}: permissions must be 0600.`)
21
+ }
22
+ }
23
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
24
+ if (!parsed || typeof parsed !== 'object') return null
25
+ const token = (parsed as Record<string, unknown>).token
26
+ return typeof token === 'string' && token.trim() ? token.trim() : null
27
+ }
28
+
29
+ export function writeCachedRailwayToken(path: string, token: string): void {
30
+ mkdirSync(dirname(path), { recursive: true })
31
+ writeFileSync(path, `${JSON.stringify({ token }, null, 2)}\n`, { mode: 0o600 })
32
+ if (process.platform !== 'win32') chmodSync(path, 0o600)
33
+ }
34
+
35
+ export function resolveRailwayToken(input: {
36
+ flagToken?: string
37
+ environment?: NodeJS.ProcessEnv
38
+ configPath?: string
39
+ }): { token: string | null; source: 'flag' | 'environment' | 'cache' | null } {
40
+ const environment = input.environment ?? process.env
41
+ if (input.flagToken?.trim()) return { token: input.flagToken.trim(), source: 'flag' }
42
+ if (environment.RAILWAY_API_TOKEN?.trim()) {
43
+ return { token: environment.RAILWAY_API_TOKEN.trim(), source: 'environment' }
44
+ }
45
+ const configPath = input.configPath ?? railwayTokenConfigPath(environment)
46
+ const cached = readCachedRailwayToken(configPath)
47
+ if (cached) return { token: cached, source: 'cache' }
48
+ return { token: null, source: null }
49
+ }
@@ -0,0 +1,87 @@
1
+ export type RailwaySourceMode = 'auto' | 'git' | 'local'
2
+ export type ResolvedRailwaySourceMode = Exclude<RailwaySourceMode, 'auto'>
3
+
4
+ export type RailwayDeployOptions = {
5
+ project?: string
6
+ environment: string
7
+ service: string
8
+ worker: boolean
9
+ source: RailwaySourceMode
10
+ region?: string
11
+ envFile?: string
12
+ domain?: string
13
+ waitDomain: boolean
14
+ volume?: string
15
+ token?: string
16
+ nonInteractive: boolean
17
+ dryRun: boolean
18
+ cleanup: boolean
19
+ yes: boolean
20
+ writeEnv: boolean
21
+ track: boolean
22
+ forceRename: boolean
23
+ timeoutSeconds: number
24
+ allowedSecretKeys: string[]
25
+ verbose: boolean
26
+ help: boolean
27
+ }
28
+
29
+ export type RailwaySource = {
30
+ mode: ResolvedRailwaySourceMode
31
+ reason: string
32
+ repo?: string
33
+ branch?: string
34
+ commitSha?: string
35
+ }
36
+
37
+ export type RailwayEnvironmentState = {
38
+ environmentId?: string
39
+ appServiceId?: string
40
+ workerServiceId?: string
41
+ postgresServiceId?: string
42
+ redisServiceId?: string
43
+ domainId?: string
44
+ volumeId?: string
45
+ appUrl?: string
46
+ source?: RailwaySource
47
+ lastDeployIds?: {
48
+ app?: string
49
+ worker?: string
50
+ }
51
+ }
52
+
53
+ export type RailwayState = {
54
+ schemaVersion: 1
55
+ provider: 'railway'
56
+ projectId?: string
57
+ workspaceId?: string
58
+ projectName: string
59
+ environments: Record<string, RailwayEnvironmentState>
60
+ writtenBy: {
61
+ cliVersion: string
62
+ }
63
+ }
64
+
65
+ export type RailwayGraphqlOperation = {
66
+ name: string
67
+ query: string
68
+ mutation?: boolean
69
+ }
70
+
71
+ export type RailwayGraphqlClient = {
72
+ request<T>(operation: RailwayGraphqlOperation, variables?: Record<string, unknown>): Promise<T>
73
+ }
74
+
75
+ export type RailwayService = {
76
+ id: string
77
+ name: string
78
+ }
79
+
80
+ export type RailwayDeployment = {
81
+ id: string
82
+ status: string
83
+ staticUrl?: string | null
84
+ url?: string | null
85
+ serviceId?: string | null
86
+ environmentId?: string | null
87
+ }
package/src/mercato.ts CHANGED
@@ -83,7 +83,7 @@ const TURBOPACK_CORRUPTION_PATTERNS = [
83
83
  'TurbopackInternalError',
84
84
  ]
85
85
 
86
- const BUILTIN_CLI_MODULE_IDS = new Set(['queue', 'generate', 'db', 'server', 'test'])
86
+ const BUILTIN_CLI_MODULE_IDS = new Set(['queue', 'generate', 'deploy', 'db', 'server', 'test'])
87
87
 
88
88
  function collectNestedErrors(error: unknown, seen = new Set<unknown>()): ErrorWithCause[] {
89
89
  if (!error || seen.has(error)) {
@@ -254,10 +254,13 @@ function removeTurbopackDevCache(appDir: string): void {
254
254
  fs.rmSync(path.join(appDir, '.mercato', 'next', 'dev'), { recursive: true, force: true })
255
255
  }
256
256
 
257
- async function ensureEnvLoaded() {
257
+ async function ensureEnvLoaded(options: { createIfMissing?: boolean; quiet?: boolean } = {}) {
258
258
  if (envLoaded) return
259
259
  envLoaded = true
260
- const quietDotenv = process.env.DOTENV_CONFIG_QUIET === '1' || process.env.DOTENV_CONFIG_QUIET === 'true'
260
+ const quietDotenv =
261
+ options.quiet === true ||
262
+ process.env.DOTENV_CONFIG_QUIET === '1' ||
263
+ process.env.DOTENV_CONFIG_QUIET === 'true'
261
264
 
262
265
  // Try to find and load .env from the app directory
263
266
  // First, try to find the app directory via resolver
@@ -268,7 +271,11 @@ async function ensureEnvLoaded() {
268
271
 
269
272
  // Load .env from app directory if it exists
270
273
  const envPath = path.join(appDir, '.env')
271
- if (!fs.existsSync(envPath) && process.env.NODE_ENV !== 'production') {
274
+ if (
275
+ options.createIfMissing !== false &&
276
+ !fs.existsSync(envPath) &&
277
+ process.env.NODE_ENV !== 'production'
278
+ ) {
272
279
  const examplePath = path.join(appDir, '.env.example')
273
280
  if (fs.existsSync(examplePath)) {
274
281
  fs.copyFileSync(examplePath, envPath)
@@ -751,9 +758,9 @@ async function buildAllModules(): Promise<Module[]> {
751
758
  }
752
759
 
753
760
  export async function run(argv = process.argv) {
754
- await ensureEnvLoaded()
755
761
  const [, , ...parts] = argv
756
762
  const [first, second, ...remaining] = parts
763
+ await ensureEnvLoaded({ createIfMissing: first !== 'deploy', quiet: first === 'deploy' })
757
764
 
758
765
  // Handle init command directly
759
766
  if (first === 'init') {
@@ -1461,6 +1468,19 @@ export async function run(argv = process.argv) {
1461
1468
  } catch { /* @/cli may not exist in standalone apps — safe to ignore */ }
1462
1469
  }
1463
1470
  const all = modules.slice()
1471
+
1472
+ all.push({
1473
+ id: 'deploy',
1474
+ cli: [
1475
+ {
1476
+ command: 'railway',
1477
+ run: async (args: string[]) => {
1478
+ const { runRailwayDeploy } = await import('./lib/deploy/railway/index')
1479
+ await runRailwayDeploy(args)
1480
+ },
1481
+ },
1482
+ ],
1483
+ } as Module)
1464
1484
 
1465
1485
  // Built-in CLI module: queue
1466
1486
  all.push({
@@ -2365,11 +2385,16 @@ export async function run(argv = process.argv) {
2365
2385
 
2366
2386
  console.log('')
2367
2387
  const started = Date.now()
2368
- console.log(`🚀 Running ${modName}:${cmdName} ${rest.join(' ')}`)
2388
+ const loggedArgs = modName === 'deploy' && cmdName === 'railway'
2389
+ ? (await import('./lib/deploy/railway/options')).redactRailwayCliArgs(rest)
2390
+ : rest
2391
+ console.log(`🚀 Running ${modName}:${cmdName} ${loggedArgs.join(' ')}`)
2369
2392
  try {
2370
2393
  await cmd.run(rest)
2371
- const ms = Date.now() - started
2372
- console.log(`⏱️ Done in ${ms}ms`)
2394
+ if (modName !== 'deploy' || cmdName !== 'railway') {
2395
+ const ms = Date.now() - started
2396
+ console.log(`⏱️ Done in ${ms}ms`)
2397
+ }
2373
2398
  return 0
2374
2399
  } catch (e: any) {
2375
2400
  console.error(`💥 Failed: ${formatCliFailureMessage(modName, cmdName, e)}`)