create-mercato-app 0.6.1-develop.3164.d94717d609 → 0.6.1-develop.3167.1.7aedd7b2ba

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/README.md CHANGED
@@ -126,6 +126,19 @@ The standalone dev splash also exposes a GitHub publishing panel after `yarn dev
126
126
  yarn setup:reinstall
127
127
  ```
128
128
 
129
+ To run several persistent local apps against the same PostgreSQL server, pass an optional database-name override. The flag is purely additive — omitting it preserves existing behavior.
130
+
131
+ ```bash
132
+ # explicit name; .env is updated by default after a confirmation prompt
133
+ yarn setup --database-name=client_a
134
+
135
+ # bare flag derives the database name from the current directory name
136
+ yarn setup --database-name
137
+
138
+ # one-off run that only injects DATABASE_URL into the current child env
139
+ yarn dev --database-name=review_1720 --no-update-env
140
+ ```
141
+
129
142
  3. Manual alternative if you want to edit the environment first:
130
143
  ```bash
131
144
  cp .env.example .env
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-mercato-app",
3
- "version": "0.6.1-develop.3164.d94717d609",
3
+ "version": "0.6.1-develop.3167.1.7aedd7b2ba",
4
4
  "type": "module",
5
5
  "description": "Create a new Open Mercato application",
6
6
  "main": "./dist/index.js",
@@ -105,6 +105,20 @@ yarn reinstall
105
105
  | `OM_DEV_SPLASH_CLAUDE_CODE_PATH` | auto-detect | Optional path override for the Claude Code CLI. |
106
106
  | `OM_DEV_SPLASH_CODEX_PATH` | auto-detect | Optional path override for the Codex CLI. |
107
107
  | `OM_DEV_AUTO_MIGRATE` | `1` | When set to `1` (default), `yarn dev` runs `yarn db:migrate` once at startup before Next.js boots. Set to `0` to disable. See "Single-shot Database Migrations" below. |
108
+ | `OM_DEV_DATABASE_NAME` | unset | Same as passing `--database-name=<value>` to `yarn dev` / `yarn setup`. CLI flag wins. |
109
+ | `OM_DEV_DATABASE_UPDATE_ENV` | unset | Non-interactive answer for the `.env` update prompt (`true`/`false`). Equivalent to `--update-env` / `--no-update-env`. |
110
+
111
+ ### Persistent Parallel Local Databases
112
+
113
+ Add `--database-name[=<name>]` to `yarn dev`, `yarn dev:greenfield`, or `yarn setup` to point this app at an isolated PostgreSQL database without manually editing `.env` first. Behavior:
114
+
115
+ - `yarn dev` (no flag) is unchanged — no prompt, no `.env` mutation.
116
+ - `yarn setup --database-name=client_a` rewrites the `DATABASE_URL` database segment in `./.env` after a confirmation prompt (default yes).
117
+ - `yarn dev --database-name` (bare flag) derives the database name from the current directory.
118
+ - `yarn dev --database-name=review_1720 --no-update-env` injects the rewritten URL into the current child process only and leaves `.env` untouched.
119
+ - Non-interactive runs (`CI=true` or piped stdin) default to updating `.env`; pass `--no-update-env` to opt out.
120
+
121
+ The override only changes the database segment of `DATABASE_URL`. Credentials, host, port, query strings (`?schema=…`, `?sslmode=…`), and other env variables are preserved verbatim.
108
122
 
109
123
  ## Infrastructure
110
124
 
@@ -0,0 +1,343 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import readline from 'node:readline'
4
+
5
+ const DATABASE_URL_KEY = 'DATABASE_URL'
6
+ const DATABASE_NAME_FLAG = '--database-name'
7
+ const NO_UPDATE_ENV_FLAG = '--no-update-env'
8
+ const UPDATE_ENV_FLAG = '--update-env'
9
+ const DATABASE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/
10
+ const MAX_DATABASE_NAME_LENGTH = 63
11
+
12
+ export function collectForwardedSetupFlags(argv) {
13
+ const out = []
14
+ for (let index = 0; index < argv.length; index += 1) {
15
+ const arg = argv[index]
16
+ if (arg === DATABASE_NAME_FLAG) {
17
+ out.push(arg)
18
+ const next = argv[index + 1]
19
+ if (typeof next === 'string' && !next.startsWith('-')) {
20
+ out.push(next)
21
+ index += 1
22
+ }
23
+ continue
24
+ }
25
+ if (typeof arg === 'string' && arg.startsWith(`${DATABASE_NAME_FLAG}=`)) {
26
+ out.push(arg)
27
+ continue
28
+ }
29
+ if (arg === NO_UPDATE_ENV_FLAG || arg === UPDATE_ENV_FLAG) {
30
+ out.push(arg)
31
+ }
32
+ }
33
+ return out
34
+ }
35
+
36
+ export function parseDatabaseNameArgs(argv) {
37
+ const remaining = []
38
+ let provided = false
39
+ let rawValue = null
40
+ let updateEnv = null
41
+
42
+ for (let index = 0; index < argv.length; index += 1) {
43
+ const arg = argv[index]
44
+ if (arg === DATABASE_NAME_FLAG) {
45
+ provided = true
46
+ const next = argv[index + 1]
47
+ if (typeof next === 'string' && !next.startsWith('-')) {
48
+ rawValue = next
49
+ index += 1
50
+ } else {
51
+ rawValue = null
52
+ }
53
+ continue
54
+ }
55
+ if (typeof arg === 'string' && arg.startsWith(`${DATABASE_NAME_FLAG}=`)) {
56
+ provided = true
57
+ rawValue = arg.slice(DATABASE_NAME_FLAG.length + 1)
58
+ continue
59
+ }
60
+ if (arg === NO_UPDATE_ENV_FLAG) {
61
+ updateEnv = false
62
+ continue
63
+ }
64
+ if (arg === UPDATE_ENV_FLAG) {
65
+ updateEnv = true
66
+ continue
67
+ }
68
+ remaining.push(arg)
69
+ }
70
+
71
+ return {
72
+ provided,
73
+ rawValue,
74
+ updateEnv,
75
+ remainingArgv: remaining,
76
+ }
77
+ }
78
+
79
+ export function deriveDatabaseNameFromCwd(cwd) {
80
+ const basename = path.basename(String(cwd ?? ''))
81
+ const lower = basename.toLowerCase()
82
+ const withUnderscores = lower.replace(/[^a-z0-9]+/g, '_')
83
+ const trimmed = withUnderscores.replace(/^_+|_+$/g, '')
84
+ if (!trimmed) return 'open_mercato_dev'
85
+ if (/^[0-9]/.test(trimmed)) return `om_${trimmed}`
86
+ return trimmed
87
+ }
88
+
89
+ export function validateDatabaseName(name) {
90
+ if (typeof name !== 'string') {
91
+ return { ok: false, reason: 'Database name must be a string.' }
92
+ }
93
+ if (name.length === 0) {
94
+ return { ok: false, reason: 'Database name must not be empty.' }
95
+ }
96
+ if (name.length > MAX_DATABASE_NAME_LENGTH) {
97
+ return {
98
+ ok: false,
99
+ reason: `Database name must be ${MAX_DATABASE_NAME_LENGTH} characters or fewer.`,
100
+ }
101
+ }
102
+ if (!DATABASE_NAME_PATTERN.test(name)) {
103
+ return {
104
+ ok: false,
105
+ reason: 'Database name must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.',
106
+ }
107
+ }
108
+ return { ok: true }
109
+ }
110
+
111
+ export function resolveDatabaseName({ rawValue, cwd }) {
112
+ const trimmed = typeof rawValue === 'string' ? rawValue.trim() : ''
113
+ if (!trimmed) {
114
+ const derived = deriveDatabaseNameFromCwd(cwd)
115
+ return { name: derived, source: 'cwd' }
116
+ }
117
+ return { name: trimmed, source: 'explicit' }
118
+ }
119
+
120
+ export function rewriteDatabaseUrl(url, databaseName) {
121
+ if (typeof url !== 'string' || url.length === 0) {
122
+ throw new Error('DATABASE_URL is empty.')
123
+ }
124
+ const parsed = new URL(url)
125
+ parsed.pathname = `/${encodeURIComponent(databaseName)}`
126
+ return parsed.toString()
127
+ }
128
+
129
+ export function updateDatabaseUrlInEnvText(source, databaseName) {
130
+ const lines = source.split(/\r?\n/)
131
+ let replaced = false
132
+ let changed = false
133
+ let previousValue = null
134
+ let nextValue = null
135
+
136
+ for (let index = 0; index < lines.length; index += 1) {
137
+ const line = lines[index]
138
+ const match = line.match(/^(\s*(?:export\s+)?DATABASE_URL\s*=\s*)(.*)$/)
139
+ if (!match) continue
140
+ const prefix = match[1]
141
+ const rawCurrent = match[2]
142
+ const { value: currentValue, quote } = stripEnvValueQuotes(rawCurrent)
143
+ if (replaced) {
144
+ continue
145
+ }
146
+ replaced = true
147
+ previousValue = currentValue
148
+ try {
149
+ nextValue = rewriteDatabaseUrl(currentValue, databaseName)
150
+ } catch (error) {
151
+ throw new Error(`Failed to rewrite ${DATABASE_URL_KEY}: ${error instanceof Error ? error.message : String(error)}`)
152
+ }
153
+ if (nextValue === currentValue) {
154
+ continue
155
+ }
156
+ lines[index] = `${prefix}${quote}${nextValue}${quote}`
157
+ changed = true
158
+ }
159
+
160
+ if (!replaced) {
161
+ throw new Error(`No ${DATABASE_URL_KEY} entry found in env file.`)
162
+ }
163
+
164
+ return {
165
+ text: lines.join('\n'),
166
+ changed,
167
+ previousValue,
168
+ nextValue,
169
+ }
170
+ }
171
+
172
+ function stripEnvValueQuotes(rawValue) {
173
+ if (typeof rawValue !== 'string') return { value: '', quote: '' }
174
+ const trimmed = rawValue.replace(/\s+#.*$/, '').trim()
175
+ if (
176
+ trimmed.length >= 2
177
+ && (
178
+ (trimmed.startsWith('"') && trimmed.endsWith('"'))
179
+ || (trimmed.startsWith("'") && trimmed.endsWith("'"))
180
+ )
181
+ ) {
182
+ return { value: trimmed.slice(1, -1), quote: trimmed[0] }
183
+ }
184
+ return { value: trimmed, quote: '' }
185
+ }
186
+
187
+ export function readEnvDatabaseUrl(source) {
188
+ for (const line of source.split(/\r?\n/)) {
189
+ const match = line.match(/^\s*(?:export\s+)?DATABASE_URL\s*=\s*(.*)$/)
190
+ if (!match) continue
191
+ return stripEnvValueQuotes(match[1]).value
192
+ }
193
+ return null
194
+ }
195
+
196
+ export function isNonInteractiveEnvironment({ env, stdinIsTTY }) {
197
+ if (env && typeof env === 'object') {
198
+ const ci = String(env.CI ?? '').trim().toLowerCase()
199
+ if (['1', 'true', 'yes', 'on'].includes(ci)) return true
200
+ }
201
+ if (stdinIsTTY === false) return true
202
+ return false
203
+ }
204
+
205
+ export function parseUpdateEnvAnswer(answer) {
206
+ if (typeof answer !== 'string') return null
207
+ const normalized = answer.trim().toLowerCase()
208
+ if (normalized === '') return true
209
+ if (['y', 'yes', '1', 'true'].includes(normalized)) return true
210
+ if (['n', 'no', '0', 'false'].includes(normalized)) return false
211
+ return null
212
+ }
213
+
214
+ async function promptUpdateEnv({ databaseName, input, output }) {
215
+ if (!input || !output) return true
216
+ const rl = readline.createInterface({ input, output })
217
+ try {
218
+ return await new Promise((resolve) => {
219
+ rl.question(`[dev] Update .env to use database "${databaseName}"? [Y/n] `, (answer) => {
220
+ const parsed = parseUpdateEnvAnswer(answer)
221
+ resolve(parsed === null ? true : parsed)
222
+ })
223
+ })
224
+ } finally {
225
+ rl.close()
226
+ }
227
+ }
228
+
229
+ export function resolveUpdateEnvDecisionFromEnv(env) {
230
+ if (!env || typeof env !== 'object') return null
231
+ const raw = env.OM_DEV_DATABASE_UPDATE_ENV
232
+ if (typeof raw !== 'string') return null
233
+ const normalized = raw.trim().toLowerCase()
234
+ if (['', '1', 'true', 'yes', 'on', 'y'].includes(normalized)) return true
235
+ if (['0', 'false', 'no', 'off', 'n'].includes(normalized)) return false
236
+ return null
237
+ }
238
+
239
+ export function readDatabaseNameEnvOverride(env) {
240
+ if (!env || typeof env !== 'object') return null
241
+ const raw = env.OM_DEV_DATABASE_NAME
242
+ if (typeof raw !== 'string') return null
243
+ const trimmed = raw.trim()
244
+ if (trimmed.length === 0) return null
245
+ return trimmed
246
+ }
247
+
248
+ export async function resolveDatabaseNameOverride(options) {
249
+ const {
250
+ argv = [],
251
+ env = {},
252
+ cwd = process.cwd(),
253
+ envFilePath,
254
+ stdin = null,
255
+ stdout = null,
256
+ logger = noopLogger(),
257
+ fsImpl = fs,
258
+ } = options
259
+
260
+ const parsed = parseDatabaseNameArgs(argv)
261
+ const envOverrideName = readDatabaseNameEnvOverride(env)
262
+
263
+ const flagPresent = parsed.provided || envOverrideName !== null
264
+ if (!flagPresent) {
265
+ return { applied: false, remainingArgv: parsed.remainingArgv }
266
+ }
267
+
268
+ const rawValue = parsed.provided ? parsed.rawValue : envOverrideName
269
+ const resolved = resolveDatabaseName({ rawValue, cwd })
270
+
271
+ const validation = validateDatabaseName(resolved.name)
272
+ if (!validation.ok) {
273
+ throw new Error(`Invalid database name "${resolved.name}": ${validation.reason}`)
274
+ }
275
+
276
+ if (!envFilePath) {
277
+ throw new Error('Cannot resolve env file path for database-name override.')
278
+ }
279
+
280
+ if (!fsImpl.existsSync(envFilePath)) {
281
+ throw new Error(`Env file not found at ${envFilePath}. Cannot apply --database-name.`)
282
+ }
283
+
284
+ const envSource = fsImpl.readFileSync(envFilePath, 'utf8')
285
+ const previousUrl = readEnvDatabaseUrl(envSource)
286
+ if (!previousUrl) {
287
+ throw new Error(`Env file ${envFilePath} does not declare ${DATABASE_URL_KEY}.`)
288
+ }
289
+
290
+ const rewritten = updateDatabaseUrlInEnvText(envSource, resolved.name)
291
+
292
+ logger.info?.(`[dev] Using database "${resolved.name}" from ${parsed.provided ? '--database-name' : 'OM_DEV_DATABASE_NAME'}.`)
293
+
294
+ let updateEnvDecision
295
+ if (parsed.updateEnv === false) {
296
+ updateEnvDecision = false
297
+ } else if (parsed.updateEnv === true) {
298
+ updateEnvDecision = true
299
+ } else {
300
+ const envDecision = resolveUpdateEnvDecisionFromEnv(env)
301
+ if (envDecision !== null) {
302
+ updateEnvDecision = envDecision
303
+ } else if (isNonInteractiveEnvironment({ env, stdinIsTTY: stdin?.isTTY })) {
304
+ updateEnvDecision = true
305
+ } else {
306
+ updateEnvDecision = await promptUpdateEnv({
307
+ databaseName: resolved.name,
308
+ input: stdin,
309
+ output: stdout,
310
+ })
311
+ }
312
+ }
313
+
314
+ if (updateEnvDecision) {
315
+ if (rewritten.changed) {
316
+ fsImpl.writeFileSync(envFilePath, rewritten.text)
317
+ logger.info?.(`[dev] Updated ${path.basename(envFilePath)} ${DATABASE_URL_KEY}.`)
318
+ } else {
319
+ logger.info?.(`[dev] ${path.basename(envFilePath)} already targets database "${resolved.name}".`)
320
+ }
321
+ } else {
322
+ logger.info?.(`[dev] Leaving ${path.basename(envFilePath)} unchanged; child commands will use database "${resolved.name}" for this run.`)
323
+ }
324
+
325
+ return {
326
+ applied: true,
327
+ databaseName: resolved.name,
328
+ source: resolved.source,
329
+ envFilePath,
330
+ previousDatabaseUrl: previousUrl,
331
+ nextDatabaseUrl: rewritten.nextValue,
332
+ envFileUpdated: updateEnvDecision && rewritten.changed,
333
+ envFileWriteSkipped: !updateEnvDecision,
334
+ childEnv: { [DATABASE_URL_KEY]: rewritten.nextValue },
335
+ remainingArgv: parsed.remainingArgv,
336
+ }
337
+ }
338
+
339
+ function noopLogger() {
340
+ return { info: () => {}, warn: () => {}, error: () => {} }
341
+ }
342
+
343
+ export const __DATABASE_URL_KEY__ = DATABASE_URL_KEY
@@ -30,6 +30,7 @@ import {
30
30
  resolveDevBaseUrl,
31
31
  resolveSplashUrl as resolveSplashAccessUrl,
32
32
  } from './dev-splash-url.mjs'
33
+ import { resolveDatabaseNameOverride } from './dev-database-url.mjs'
33
34
 
34
35
  function detectDevRuntimeMode() {
35
36
  const cwd = process.cwd()
@@ -573,6 +574,39 @@ function ensureStandaloneEnvFile() {
573
574
  }
574
575
  }
575
576
 
577
+ function resolveDatabaseEnvFilePath() {
578
+ return isMonorepo
579
+ ? path.join(process.cwd(), 'apps', 'mercato', '.env')
580
+ : path.join(process.cwd(), '.env')
581
+ }
582
+
583
+ async function applyDatabaseNameOverrideIfRequested() {
584
+ let result
585
+ try {
586
+ result = await resolveDatabaseNameOverride({
587
+ argv: args,
588
+ env: process.env,
589
+ cwd: process.cwd(),
590
+ envFilePath: resolveDatabaseEnvFilePath(),
591
+ stdin: process.stdin,
592
+ stdout: process.stdout,
593
+ logger: { info: (msg) => console.log(msg) },
594
+ })
595
+ } catch (error) {
596
+ console.error(`❌ ${error instanceof Error ? error.message : String(error)}`)
597
+ shutdown(1)
598
+ return null
599
+ }
600
+
601
+ if (result?.applied) {
602
+ process.env.DATABASE_URL = result.childEnv.DATABASE_URL
603
+ updateSplashState({
604
+ activity: `Using database "${result.databaseName}" for this run`,
605
+ })
606
+ }
607
+ return result
608
+ }
609
+
576
610
  function normalizeLocaleToken(value) {
577
611
  return String(value ?? '').trim().toLowerCase().replace(/_/g, '-')
578
612
  }
@@ -1613,6 +1647,7 @@ async function runClassicGreenfieldDev() {
1613
1647
 
1614
1648
  async function runStandaloneSetup() {
1615
1649
  ensureStandaloneEnvFile()
1650
+ await applyDatabaseNameOverrideIfRequested()
1616
1651
  if (standaloneLocalRegistryRefresh) {
1617
1652
  await runStage('🧼 Clearing local Open Mercato cache', ['cache', 'clean', '--all'], {
1618
1653
  stageCurrent: 0,
@@ -1639,6 +1674,7 @@ async function runStandaloneSetup() {
1639
1674
 
1640
1675
  async function runClassicStandaloneSetup() {
1641
1676
  ensureStandaloneEnvFile()
1677
+ await applyDatabaseNameOverrideIfRequested()
1642
1678
  if (standaloneLocalRegistryRefresh) {
1643
1679
  await runRawYarnCommand(['cache', 'clean', '--all'])
1644
1680
  }
@@ -1675,6 +1711,7 @@ async function main() {
1675
1711
  await runStandaloneSetup()
1676
1712
  return
1677
1713
  }
1714
+ await applyDatabaseNameOverrideIfRequested()
1678
1715
  if (classic) {
1679
1716
  await runClassicStandaloneDev()
1680
1717
  return
@@ -1699,6 +1736,8 @@ async function main() {
1699
1736
  return
1700
1737
  }
1701
1738
 
1739
+ await applyDatabaseNameOverrideIfRequested()
1740
+
1702
1741
  if (appOnly) {
1703
1742
  launchMonorepoAppDev()
1704
1743
  return
@@ -1,8 +1,11 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
  import { existsSync } from 'node:fs'
3
+ import { collectForwardedSetupFlags } from './dev-database-url.mjs'
3
4
 
4
- const reinstall = process.argv.includes('--reinstall')
5
- const classic = process.argv.includes('--classic')
5
+ const argv = process.argv.slice(2)
6
+ const reinstall = argv.includes('--reinstall')
7
+ const classic = argv.includes('--classic')
8
+ const forwardedDatabaseFlags = collectForwardedSetupFlags(argv)
6
9
 
7
10
  if (!existsSync('node_modules/cross-spawn')) {
8
11
  const bootstrap = spawnSync('yarn', ['install'], {
@@ -14,7 +17,13 @@ if (!existsSync('node_modules/cross-spawn')) {
14
17
 
15
18
  const result = spawnSync(
16
19
  process.execPath,
17
- ['./scripts/dev.mjs', '--setup', ...(reinstall ? ['--reinstall'] : []), ...(classic ? ['--classic'] : [])],
20
+ [
21
+ './scripts/dev.mjs',
22
+ '--setup',
23
+ ...(reinstall ? ['--reinstall'] : []),
24
+ ...(classic ? ['--classic'] : []),
25
+ ...forwardedDatabaseFlags,
26
+ ],
18
27
  {
19
28
  stdio: 'inherit',
20
29
  shell: process.platform === 'win32',