@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/README.md +46 -0
- package/bin/om-start.mjs +78 -0
- package/package.json +27 -0
- package/platform/start.cmd +24 -0
- package/platform/start.ps1 +174 -0
- package/platform/start.sh +112 -0
- package/src/__tests__/starter.test.mjs +359 -0
- package/src/certs.mjs +266 -0
- package/src/cli.mjs +355 -0
- package/src/company.mjs +80 -0
- package/src/compose.mjs +144 -0
- package/src/constants.mjs +85 -0
- package/src/doctor.mjs +426 -0
- package/src/env-file.mjs +75 -0
- package/src/env-setup.mjs +119 -0
- package/src/infra.mjs +85 -0
- package/src/providers.mjs +189 -0
- package/src/secrets.mjs +11 -0
- package/src/spawn.mjs +89 -0
- package/src/steps.mjs +613 -0
- package/src/supervise.mjs +220 -0
- package/src/ui.mjs +122 -0
- package/src/waits.mjs +102 -0
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
|
+
}
|
package/src/secrets.mjs
ADDED
|
@@ -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
|
+
}
|