@open-mercato/starter 0.6.5

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/src/infra.mjs ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // Hybrid infra lifecycle: OpenCode + postgres/redis/meilisearch containers.
3
+ // Preferred entry: `yarn om infra up|down` (or npx @open-mercato/starter).
4
+ // Direct usage: node packages/starter/src/infra.mjs up [--profile <name>]
5
+ // node packages/starter/src/infra.mjs down [--volumes --yes]
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ import { detectDocker, resolveRepoRoot, runCompose } from './compose.mjs'
11
+
12
+ export function ensureMcpSharedDir(repoRoot) {
13
+ // Pre-create the bind-mount source so dockerd never creates it root-owned
14
+ // on native Linux (which would break host-side key provisioning).
15
+ fs.mkdirSync(path.join(repoRoot, '.mercato', 'mcp-shared'), { recursive: true })
16
+ }
17
+
18
+ export function infraUp(repoRooot, { profiles = [] } = {}) {
19
+ const repoRoot = repoRooot ?? resolveRepoRoot()
20
+ ensureMcpSharedDir(repoRoot)
21
+ const upArgs = []
22
+ for (const profile of profiles) {
23
+ upArgs.push('--profile', profile)
24
+ }
25
+ // --wait blocks on the postgres/redis/meilisearch healthchecks; opencode has
26
+ // no healthcheck here and counts as started immediately (its entrypoint
27
+ // waits for the host MCP server on its own). The opencode image is obtained
28
+ // beforehand (base pull + thin local build, see steps.mjs
29
+ // ensureOpencodeImage) — this file never builds.
30
+ upArgs.push('up', '-d', '--wait')
31
+ return runCompose(repoRoot, upArgs).status ?? 1
32
+ }
33
+
34
+ export function infraDown(repoRoot, { volumes = false } = {}) {
35
+ return runCompose(repoRoot ?? resolveRepoRoot(), ['down', ...(volumes ? ['--volumes'] : [])]).status ?? 1
36
+ }
37
+
38
+ function main() {
39
+ const args = process.argv.slice(2)
40
+ const action = args[0]
41
+ const repoRoot = resolveRepoRoot()
42
+ if (!repoRoot) {
43
+ console.error('❌ No Open Mercato checkout found above the current directory.')
44
+ process.exit(2)
45
+ }
46
+
47
+ const docker = detectDocker()
48
+ if (!docker.ok) {
49
+ console.error(`❌ Docker is not available (${docker.reason}).`)
50
+ console.error(' Install/start Docker Desktop, Rancher Desktop, or a native engine with the compose v2 plugin, then re-run.')
51
+ process.exit(2)
52
+ }
53
+
54
+ if (action === 'up') {
55
+ const profiles = []
56
+ for (let index = 1; index < args.length; index++) {
57
+ if (args[index] === '--profile' && args[index + 1]) {
58
+ profiles.push(args[index + 1])
59
+ index++
60
+ }
61
+ }
62
+ const status = infraUp(repoRoot, { build: !args.includes('--no-build'), profiles })
63
+ if (status === 0) {
64
+ console.log('')
65
+ console.log('✅ Infra containers are up (opencode :4096, postgres :5432, redis :6379, meilisearch :7700).')
66
+ console.log(' Next: yarn dev (starts the app and the MCP server on this machine)')
67
+ }
68
+ process.exit(status)
69
+ }
70
+
71
+ if (action === 'down') {
72
+ const volumes = args.includes('--volumes')
73
+ if (volumes && !args.includes('--yes')) {
74
+ console.error('❌ --volumes DELETES all database/search data. Re-run with --yes to confirm.')
75
+ process.exit(1)
76
+ }
77
+ process.exit(infraDown(repoRoot, { volumes }))
78
+ }
79
+
80
+ console.error('Usage: yarn om infra <up|down> [--profile <name>] [--volumes --yes]')
81
+ process.exit(1)
82
+ }
83
+
84
+ const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
85
+ if (isDirectRun) main()
@@ -0,0 +1,189 @@
1
+ import fs from 'node:fs'
2
+ import readline from 'node:readline'
3
+
4
+ import { addEnvValue, readEnvValue, setEnvValue } from './env-file.mjs'
5
+ import { secretFingerprint } from './secrets.mjs'
6
+
7
+ // Every OM-supported chat provider (from apps/mercato/.env.example). This is
8
+ // the SINGLE copy — the hand-synced PowerShell duplicate died with the old
9
+ // Windows launcher. Embedding-only providers (Mistral, Cohere, AWS Bedrock)
10
+ // are excluded — the assistant needs a chat model.
11
+ export const LLM_PROVIDERS = [
12
+ { provider: 'openai', label: 'OpenAI', keyEnv: 'OPENAI_API_KEY', baseUrlEnv: 'OPENAI_BASE_URL', keyRequired: true, baseUrlRequired: false, baseUrl: '', modelRequired: false, modelHint: 'e.g. gpt-5-mini' },
13
+ { provider: 'anthropic', label: 'Anthropic (Claude)', keyEnv: 'ANTHROPIC_API_KEY', baseUrlEnv: null, keyRequired: true, baseUrlRequired: false, baseUrl: '', modelRequired: false, modelHint: 'e.g. claude-haiku-4-5-20251001' },
14
+ { provider: 'google', label: 'Google Gemini', keyEnv: 'GOOGLE_GENERATIVE_AI_API_KEY', baseUrlEnv: null, keyRequired: true, baseUrlRequired: false, baseUrl: '', modelRequired: false, modelHint: 'e.g. gemini-3-flash' },
15
+ { provider: 'azure', label: 'Azure OpenAI / AI Foundry', keyEnv: 'AZURE_OPENAI_API_KEY', baseUrlEnv: 'AZURE_OPENAI_BASE_URL', keyRequired: true, baseUrlRequired: true, baseUrl: '', modelRequired: true, modelHint: 'your Azure deployment name' },
16
+ { provider: 'openrouter', label: 'OpenRouter (gateway)', keyEnv: 'OPENROUTER_API_KEY', baseUrlEnv: 'OPENROUTER_BASE_URL', keyRequired: true, baseUrlRequired: false, baseUrl: '', modelRequired: false, modelHint: 'e.g. meta-llama/llama-3.3-70b-instruct' },
17
+ { provider: 'deepinfra', label: 'DeepInfra', keyEnv: 'DEEPINFRA_API_KEY', baseUrlEnv: 'DEEPINFRA_BASE_URL', keyRequired: true, baseUrlRequired: false, baseUrl: '', modelRequired: false, modelHint: 'e.g. zai-org/GLM-5.1' },
18
+ { provider: 'groq', label: 'Groq', keyEnv: 'GROQ_API_KEY', baseUrlEnv: 'GROQ_BASE_URL', keyRequired: true, baseUrlRequired: false, baseUrl: '', modelRequired: false, modelHint: 'e.g. llama-3.3-70b-versatile' },
19
+ { provider: 'together', label: 'Together AI', keyEnv: 'TOGETHER_API_KEY', baseUrlEnv: 'TOGETHER_BASE_URL', keyRequired: true, baseUrlRequired: false, baseUrl: '', modelRequired: false, modelHint: 'e.g. meta-llama/Llama-3.3-70B-Instruct-Turbo' },
20
+ { provider: 'fireworks', label: 'Fireworks AI', keyEnv: 'FIREWORKS_API_KEY', baseUrlEnv: 'FIREWORKS_BASE_URL', keyRequired: true, baseUrlRequired: false, baseUrl: '', modelRequired: false, modelHint: 'e.g. accounts/fireworks/models/llama-v3p3-70b-instruct' },
21
+ { provider: 'litellm', label: 'LiteLLM (self-hosted proxy)', keyEnv: 'LITELLM_API_KEY', baseUrlEnv: 'LITELLM_BASE_URL', keyRequired: true, baseUrlRequired: true, baseUrl: '', modelRequired: true, modelHint: 'model name as configured in your proxy' },
22
+ { provider: 'ollama', label: 'Ollama (local)', keyEnv: 'OLLAMA_API_KEY', baseUrlEnv: 'OLLAMA_BASE_URL', keyRequired: false, baseUrlRequired: true, baseUrl: 'http://host.docker.internal:11434/v1', modelRequired: true, modelHint: 'e.g. llama3.3' },
23
+ { provider: 'lm-studio', label: 'LM Studio (local)', keyEnv: 'LM_STUDIO_API_KEY', baseUrlEnv: 'LM_STUDIO_BASE_URL', keyRequired: false, baseUrlRequired: true, baseUrl: 'http://host.docker.internal:1234/v1', modelRequired: true, modelHint: 'the loaded model id' },
24
+ ]
25
+
26
+ function applyProviderConfig(rootEnv, entry, { key = '', baseUrl = '', model = '' }, log) {
27
+ if (key.trim()) {
28
+ if (addEnvValue(rootEnv, entry.keyEnv, key.trim(), { replaceEmpty: true })) {
29
+ log(` set ${entry.keyEnv} (${secretFingerprint(key.trim())})`)
30
+ }
31
+ }
32
+ if (entry.baseUrlEnv && baseUrl.trim()) {
33
+ if (addEnvValue(rootEnv, entry.baseUrlEnv, baseUrl.trim(), { replaceEmpty: true })) {
34
+ log(` set ${entry.baseUrlEnv}=${baseUrl.trim()}`)
35
+ }
36
+ }
37
+ if (addEnvValue(rootEnv, 'OM_AI_PROVIDER', entry.provider, { replaceEmpty: true })) {
38
+ log(` set OM_AI_PROVIDER=${entry.provider}`)
39
+ }
40
+ if (model.trim()) {
41
+ if (addEnvValue(rootEnv, 'OM_AI_MODEL', model.trim(), { replaceEmpty: true })) {
42
+ log(` set OM_AI_MODEL=${model.trim()}`)
43
+ }
44
+ }
45
+ }
46
+
47
+ // The root .env is the starter's source of truth for AI provider config: it
48
+ // feeds the compose interpolation for the opencode container. The host app
49
+ // only loads apps/mercato/.env, so the chosen provider must be mirrored there
50
+ // too or the app keeps the .env.example defaults (provider 'openai' with an
51
+ // empty key) and the selection silently "does not persist". Provider/model are
52
+ // overwritten to converge on the root value; API keys and base URLs only fill
53
+ // empty placeholders so a manually rotated app key is never clobbered.
54
+ export function syncProviderConfigToAppEnv(files, log = console.log) {
55
+ if (!files.appEnv || !fs.existsSync(files.appEnv)) return
56
+ const provider = readEnvValue(files.rootEnv, 'OM_AI_PROVIDER')?.trim()
57
+ if (!provider) return
58
+ const entry = LLM_PROVIDERS.find((candidate) => candidate.provider === provider)
59
+ const overwriteKeys = ['OM_AI_PROVIDER', 'OM_AI_MODEL']
60
+ const fillKeys = entry ? [entry.keyEnv, ...(entry.baseUrlEnv ? [entry.baseUrlEnv] : [])] : []
61
+
62
+ for (const key of overwriteKeys) {
63
+ const value = readEnvValue(files.rootEnv, key)?.trim()
64
+ if (!value) continue
65
+ if (readEnvValue(files.appEnv, key)?.trim() === value) continue
66
+ setEnvValue(files.appEnv, key, value)
67
+ log(` synced ${key} into apps/mercato/.env`)
68
+ }
69
+ for (const key of fillKeys) {
70
+ const value = readEnvValue(files.rootEnv, key)?.trim()
71
+ if (!value) continue
72
+ if (addEnvValue(files.appEnv, key, value, { replaceEmpty: true })) {
73
+ log(` synced ${key} into apps/mercato/.env`)
74
+ }
75
+ }
76
+ }
77
+
78
+ function normalizeEnvTargets(rootEnvOrFiles) {
79
+ if (typeof rootEnvOrFiles === 'string') return { rootEnv: rootEnvOrFiles, appEnv: null }
80
+ return { rootEnv: rootEnvOrFiles.rootEnv, appEnv: rootEnvOrFiles.appEnv ?? null }
81
+ }
82
+
83
+ // Returns 'configured' | 'skipped' | 'failed'. Mirrors the semantics of the
84
+ // Windows launcher's Invoke-LlmProviderPrompt: OM_AI_PROVIDER or an existing
85
+ // key in .env means "already configured"; a key in the ambient environment is
86
+ // persisted without prompting; otherwise prompt interactively unless
87
+ // skip/non-interactive flags are set. Accepts the root .env path, or
88
+ // { rootEnv, appEnv } to also keep the app env in sync (see
89
+ // syncProviderConfigToAppEnv).
90
+ export async function ensureLlmProvider(rootEnvOrFiles, { skipPrompt = false, nonInteractive = false, log = console.log, warn = console.warn } = {}) {
91
+ const files = normalizeEnvTargets(rootEnvOrFiles)
92
+ const rootEnv = files.rootEnv
93
+ const configuredProvider = readEnvValue(rootEnv, 'OM_AI_PROVIDER')
94
+ if (configuredProvider && configuredProvider.trim()) {
95
+ log(`AI already configured: provider '${configuredProvider.trim()}' (from .env)`)
96
+ syncProviderConfigToAppEnv(files, log)
97
+ return 'configured'
98
+ }
99
+
100
+ for (const entry of LLM_PROVIDERS) {
101
+ const fromFile = readEnvValue(rootEnv, entry.keyEnv)
102
+ if (fromFile && fromFile.trim()) {
103
+ log(`AI already configured: ${entry.label}`)
104
+ applyProviderConfig(rootEnv, entry, { key: '' }, log)
105
+ syncProviderConfigToAppEnv(files, log)
106
+ return 'configured'
107
+ }
108
+ const fromEnv = process.env[entry.keyEnv]
109
+ if (fromEnv && fromEnv.trim()) {
110
+ const baseFromEnv = entry.baseUrlEnv ? process.env[entry.baseUrlEnv] ?? '' : ''
111
+ applyProviderConfig(rootEnv, entry, { key: fromEnv, baseUrl: baseFromEnv }, log)
112
+ log(`AI configured from environment: ${entry.label}`)
113
+ syncProviderConfigToAppEnv(files, log)
114
+ return 'configured'
115
+ }
116
+ }
117
+
118
+ if (skipPrompt) {
119
+ warn('LLM prompt skipped with no provider key — AI chat will not work until you set a provider (e.g. OPENAI_API_KEY) in .env and restart the opencode container.')
120
+ return 'skipped'
121
+ }
122
+ if (nonInteractive || !process.stdin.isTTY) {
123
+ warn('No LLM provider API key found. Set a provider key (e.g. OPENAI_API_KEY) in the environment or .env, or pass --skip-llm-prompt to proceed without AI.')
124
+ return 'failed'
125
+ }
126
+
127
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
128
+ const ask = (question) => new Promise((resolve) => rl.question(question, resolve))
129
+
130
+ try {
131
+ console.log('')
132
+ console.log('The AI assistant (OpenCode + MCP) needs one LLM provider to power the Cmd+K agent.')
133
+ console.log('Setup requires one to continue. (Ctrl+C aborts; re-run later with --skip-llm-prompt to configure it in .env yourself.)')
134
+
135
+ while (true) {
136
+ console.log('')
137
+ LLM_PROVIDERS.forEach((entry, index) => {
138
+ console.log(` [${String(index + 1).padStart(2)}] ${entry.label}`)
139
+ })
140
+ const choice = await ask(`Choose a provider [1-${LLM_PROVIDERS.length}]: `)
141
+ const index = Number.parseInt(choice, 10)
142
+ if (!Number.isInteger(index) || index < 1 || index > LLM_PROVIDERS.length) {
143
+ console.log(`Please enter a number between 1 and ${LLM_PROVIDERS.length}.`)
144
+ continue
145
+ }
146
+ const selected = LLM_PROVIDERS[index - 1]
147
+
148
+ let key = ''
149
+ if (selected.keyRequired) {
150
+ key = (await ask(`Paste your ${selected.label} API key: `)).trim()
151
+ if (!key) {
152
+ console.log('A key is required for this provider. Try again.')
153
+ continue
154
+ }
155
+ } else {
156
+ key = (await ask(`${selected.label} API key (optional for local servers — press Enter to skip): `)).trim()
157
+ }
158
+
159
+ let baseUrl = ''
160
+ if (selected.baseUrlEnv) {
161
+ const promptText = selected.baseUrlRequired
162
+ ? (selected.baseUrl ? `${selected.label} base URL [${selected.baseUrl}]: ` : `${selected.label} base URL (required): `)
163
+ : `${selected.label} base URL (optional — press Enter for the provider default): `
164
+ const entered = (await ask(promptText)).trim()
165
+ baseUrl = entered || selected.baseUrl
166
+ if (selected.baseUrlRequired && !baseUrl) {
167
+ console.log('This provider requires a base URL. Try again.')
168
+ continue
169
+ }
170
+ }
171
+
172
+ const modelPrompt = selected.modelRequired
173
+ ? `${selected.label} model (${selected.modelHint}) (required): `
174
+ : `${selected.label} model (${selected.modelHint}) (optional — press Enter for the default): `
175
+ const model = (await ask(modelPrompt)).trim()
176
+ if (selected.modelRequired && !model) {
177
+ console.log('This provider needs a model/deployment id. Try again.')
178
+ continue
179
+ }
180
+
181
+ applyProviderConfig(rootEnv, selected, { key, baseUrl, model }, log)
182
+ log(`AI provider configured: ${selected.label}`)
183
+ syncProviderConfigToAppEnv(files, log)
184
+ return 'configured'
185
+ }
186
+ } finally {
187
+ rl.close()
188
+ }
189
+ }
@@ -0,0 +1,11 @@
1
+ import crypto from 'node:crypto'
2
+
3
+ export function secretHex(bytes = 32) {
4
+ return crypto.randomBytes(bytes).toString('hex')
5
+ }
6
+
7
+ export function secretFingerprint(value) {
8
+ if (!value) return 'empty'
9
+ const digest = crypto.createHash('sha256').update(value).digest('hex')
10
+ return `sha256:${digest.slice(0, 12)}`
11
+ }
package/src/spawn.mjs ADDED
@@ -0,0 +1,89 @@
1
+ // Windows-aware spawn resolution, mirroring scripts/dev-spawn-utils.mjs (the
2
+ // repo-wide hardening against cmd.exe argument injection) without its
3
+ // cross-spawn dependency - this package must stay stdlib-only so it runs from
4
+ // a fresh clone and via npx.
5
+ //
6
+ // yarn/npm/corepack are .cmd shims on Windows and Node >= 18.20 refuses to
7
+ // spawn those without a shell. Going through cmd.exe means every byte of the
8
+ // command line is metacharacter territory, so:
9
+ // * commands and args are validated (control chars always; %, !, and cmd
10
+ // metacharacters when a shell is involved)
11
+ // * args with whitespace are double-quoted
12
+ // Anything that fails validation aborts the spawn instead of degrading into
13
+ // an injectable command line.
14
+
15
+ import { spawnSync } from 'node:child_process'
16
+ import path from 'node:path'
17
+
18
+ const PROCESS_VALUE_UNSAFE_CHAR_PATTERN = /[\u0000-\u001f\u007f]/
19
+ const WINDOWS_CMD_UNSAFE_CHAR_PATTERN = /[\u0000-\u001f\u007f%!^&|<>"]/
20
+
21
+ const WINDOWS_SHELL_COMMANDS = new Set(['yarn', 'npm', 'corepack', 'npx'])
22
+
23
+ function isWindowsShellCommand(command) {
24
+ return WINDOWS_SHELL_COMMANDS.has(command) || /\.(cmd|bat)$/i.test(command)
25
+ }
26
+
27
+ function assertProcessSafeValue(value, label) {
28
+ const stringValue = String(value)
29
+ if (PROCESS_VALUE_UNSAFE_CHAR_PATTERN.test(stringValue)) {
30
+ throw new Error(label + ' contains unsupported control characters.')
31
+ }
32
+ return stringValue
33
+ }
34
+
35
+ function assertWindowsCmdSafeValue(value, label) {
36
+ const stringValue = assertProcessSafeValue(value, label)
37
+ if (WINDOWS_CMD_UNSAFE_CHAR_PATTERN.test(stringValue)) {
38
+ throw new Error(label + ' contains unsupported characters for Windows command execution.')
39
+ }
40
+ return stringValue
41
+ }
42
+
43
+ function quoteForCmd(value) {
44
+ return /\s/.test(value) ? '"' + value + '"' : value
45
+ }
46
+
47
+ // cmd.exe decodes batch files with the code page the console had when the
48
+ // cmd process started — never as UTF-8. Yarn Berry writes its temp PATH
49
+ // wrappers and install-script shims (%TEMP%\xfs-*\*.cmd) as UTF-8 with
50
+ // absolute paths, so under an OEM code page (e.g. 852 on Polish systems) a
51
+ // checkout beneath a non-ASCII profile path turns into mojibake and every
52
+ // wrapped spawn — including native-module builds during `yarn install` —
53
+ // dies with MODULE_NOT_FOUND. Switching the console to UTF-8 before the
54
+ // first child spawn makes every descendant cmd.exe decode those wrappers
55
+ // correctly. Best-effort by design: no console (detached/CI) or a missing
56
+ // chcp.com must never block the starter.
57
+ export function ensureWindowsUtf8Console(options = {}) {
58
+ const platform = options.platform ?? process.platform
59
+ if (platform !== 'win32') return false
60
+ const spawnSyncImpl = options.spawnSyncImpl ?? spawnSync
61
+ const chcpPath = path.join(options.systemRoot ?? process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'chcp.com')
62
+ try {
63
+ // No windowsHide here: CREATE_NO_WINDOW would give chcp.com a throwaway
64
+ // console instead of the one every later child inherits.
65
+ const result = spawnSyncImpl(chcpPath, ['65001'], { stdio: 'ignore' })
66
+ return result?.status === 0
67
+ } catch {
68
+ return false
69
+ }
70
+ }
71
+
72
+ export function resolveSpawnCommand(command, commandArgs = [], options = {}) {
73
+ const platform = options.platform ?? process.platform
74
+ const safeCommand = assertProcessSafeValue(command, 'Process command')
75
+ const safeArgs = commandArgs.map((arg, index) => assertProcessSafeValue(arg, 'Process argument #' + (index + 1)))
76
+
77
+ const needsShell = platform === 'win32' && isWindowsShellCommand(safeCommand)
78
+ if (!needsShell) {
79
+ return { command: safeCommand, args: safeArgs, spawnOptions: {} }
80
+ }
81
+
82
+ // Everything is validated against cmd metacharacters and pre-quoted here, so
83
+ // hand cmd.exe ONE finished command line. Passing an args array alongside
84
+ // shell:true would make Node concatenate it unescaped (deprecated as DEP0190
85
+ // since Node 24 — the warning lands on stderr, which PowerShell paints red).
86
+ const cmdSafeCommand = quoteForCmd(assertWindowsCmdSafeValue(safeCommand, 'Windows command'))
87
+ const cmdSafeArgs = safeArgs.map((arg, index) => quoteForCmd(assertWindowsCmdSafeValue(arg, 'Windows command argument #' + (index + 1))))
88
+ return { command: [cmdSafeCommand, ...cmdSafeArgs].join(' '), args: [], spawnOptions: { shell: true } }
89
+ }