@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/doctor.mjs
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import dns from 'node:dns/promises'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import net from 'node:net'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
|
|
8
|
+
import { detectDocker, runCaptureSync } from './compose.mjs'
|
|
9
|
+
import { probeTlsInterception, summarizeProbeResults } from './certs.mjs'
|
|
10
|
+
import { CAPTURED_CA_BUNDLE, LOOPBACK_HOST, resolveStackPorts } from './constants.mjs'
|
|
11
|
+
import { color, icons, printBanner } from './ui.mjs'
|
|
12
|
+
|
|
13
|
+
// Read-only environment audit. Every check returns
|
|
14
|
+
// { id, title, level: 'pass' | 'warn' | 'fail', detail, guide?: string[], it?: string[] }
|
|
15
|
+
// `guide` is what the USER can do; `it` is the line item for the aggregated
|
|
16
|
+
// "hand this to IT" sheet (admin/policy actions on managed devices).
|
|
17
|
+
|
|
18
|
+
const GIB = 1024 ** 3
|
|
19
|
+
|
|
20
|
+
function result(id, title, level, detail, { guide = [], it = [] } = {}) {
|
|
21
|
+
return { id, title, level, detail, guide, it }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function commandOutput(command, args) {
|
|
25
|
+
// runCaptureSync routes .cmd shims (yarn/npm/corepack) through cmd.exe —
|
|
26
|
+
// Node >= 18.20 refuses to spawn those directly on Windows (EINVAL), which
|
|
27
|
+
// used to make every yarn probe report "not found" on Windows machines.
|
|
28
|
+
const run = runCaptureSync(command, args)
|
|
29
|
+
if (run.error || run.status !== 0) return null
|
|
30
|
+
return `${run.stdout ?? ''}${run.stderr ?? ''}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function checkNodeVersion() {
|
|
34
|
+
const major = Number.parseInt(process.versions.node.split('.')[0], 10)
|
|
35
|
+
if (major === 24) return result('node', 'Node.js 24', 'pass', `v${process.versions.node}`)
|
|
36
|
+
return result('node', 'Node.js 24', major > 24 ? 'warn' : 'fail', `found v${process.versions.node}, this repo pins Node 24.x`, {
|
|
37
|
+
guide: ['Re-run the platform bootstrap (packages/starter/platform/start.sh, start.cmd on Windows) — it installs a private Node 24 without admin rights.'],
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function checkYarn(repoRoot) {
|
|
42
|
+
const output = commandOutput('yarn', ['--version'])
|
|
43
|
+
let pinned = ''
|
|
44
|
+
try {
|
|
45
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'))
|
|
46
|
+
pinned = String(pkg.packageManager ?? '').split('+')[0].replace(/^yarn@/, '')
|
|
47
|
+
} catch {
|
|
48
|
+
pinned = ''
|
|
49
|
+
}
|
|
50
|
+
if (!output) {
|
|
51
|
+
return result('yarn', 'Yarn (corepack)', 'fail', 'yarn not found on PATH', {
|
|
52
|
+
guide: ['Run: corepack enable && corepack prepare $(node -p "require(\'./package.json\').packageManager.split(\'+\')[0]") --activate'],
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
const version = output.trim()
|
|
56
|
+
if (pinned && version !== pinned) {
|
|
57
|
+
return result('yarn', 'Yarn (corepack)', 'warn', `yarn ${version} on PATH, repo pins ${pinned} (corepack resolves the pin inside the repo)`)
|
|
58
|
+
}
|
|
59
|
+
return result('yarn', 'Yarn (corepack)', 'pass', `yarn ${version}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function checkGit() {
|
|
63
|
+
const output = commandOutput('git', ['--version'])
|
|
64
|
+
if (output) return result('git', 'Git', 'pass', output.trim())
|
|
65
|
+
return result('git', 'Git', 'fail', 'git not found on PATH', {
|
|
66
|
+
guide: process.platform === 'win32'
|
|
67
|
+
? ['Install Git for Windows from https://git-scm.com/download/win (user-scope install works without admin).']
|
|
68
|
+
: ['Install git via your package manager (macOS: xcode-select --install).'],
|
|
69
|
+
it: ['Approve/install Git for Windows (https://git-scm.com/download/win)'],
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function checkContainerRuntime() {
|
|
74
|
+
const docker = detectDocker()
|
|
75
|
+
if (docker.ok) {
|
|
76
|
+
const info = commandOutput('docker', ['info', '--format', '{{.OperatingSystem}} / {{.ServerVersion}}'])
|
|
77
|
+
return result('runtime', 'Container runtime', 'pass', (info ?? 'engine running').trim())
|
|
78
|
+
}
|
|
79
|
+
const isWindows = process.platform === 'win32'
|
|
80
|
+
const isMac = process.platform === 'darwin'
|
|
81
|
+
const guide = isWindows
|
|
82
|
+
? [
|
|
83
|
+
'Install ONE of (both use the WSL2 backend):',
|
|
84
|
+
' • Rancher Desktop (no admin needed, per-user install): https://rancherdesktop.io — pick the dockerd (moby) engine, Kubernetes off.',
|
|
85
|
+
' • Docker Desktop (needs admin + license for large companies): https://docs.docker.com/desktop/setup/install/windows-install/',
|
|
86
|
+
'Then start it and re-run this command.',
|
|
87
|
+
]
|
|
88
|
+
: isMac
|
|
89
|
+
? ['Install Docker Desktop (https://docs.docker.com/desktop/setup/install/mac-install/) or `brew install colima docker docker-compose && colima start`.']
|
|
90
|
+
: ['Install Docker Engine + compose plugin (https://docs.docker.com/engine/install/) and add your user to the docker group.']
|
|
91
|
+
return result('runtime', 'Container runtime', 'fail', `not usable (${docker.reason})`, {
|
|
92
|
+
guide,
|
|
93
|
+
it: isWindows
|
|
94
|
+
? ['Install a container runtime: Rancher Desktop (per-user, free) or Docker Desktop (license required above 250 employees).']
|
|
95
|
+
: ['Install Docker Engine / Docker Desktop for this user.'],
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Docker Desktop < 4.42.0 rejects certificates with negative serial numbers —
|
|
100
|
+
// which Zscaler interception CAs use — so pulls fail with cert errors even
|
|
101
|
+
// though the CA is trusted. https://github.com/docker/for-win/issues/14803
|
|
102
|
+
export function checkDockerDesktopVersion() {
|
|
103
|
+
const platformName = commandOutput('docker', ['version', '--format', '{{.Server.Platform.Name}}'])
|
|
104
|
+
const match = /Docker Desktop\s+(\d+)\.(\d+)\.(\d+)/.exec(platformName ?? '')
|
|
105
|
+
if (!match) return null
|
|
106
|
+
const [major, minor] = [Number(match[1]), Number(match[2])]
|
|
107
|
+
const version = `${match[1]}.${match[2]}.${match[3]}`
|
|
108
|
+
if (major > 4 || (major === 4 && minor >= 42)) {
|
|
109
|
+
return result('docker-desktop', 'Docker Desktop version', 'pass', version)
|
|
110
|
+
}
|
|
111
|
+
return result('docker-desktop', 'Docker Desktop version', 'warn', `${version} — versions below 4.42.0 reject Zscaler's negative-serial certificates (pull failures behind Zscaler)`, {
|
|
112
|
+
guide: ['Update Docker Desktop to 4.42.0 or newer.'],
|
|
113
|
+
it: ['Approve a Docker Desktop update to >= 4.42.0 (Zscaler TLS-interception compatibility fix).'],
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function checkWsl2() {
|
|
118
|
+
if (process.platform !== 'win32') return null
|
|
119
|
+
// `wsl --status` succeeds only when the WSL platform actually works; the
|
|
120
|
+
// wsl.exe stub exists in System32 even on machines with the feature off, so
|
|
121
|
+
// presence alone proves nothing.
|
|
122
|
+
const status = commandOutput('wsl.exe', ['--status'])
|
|
123
|
+
if (status !== null) {
|
|
124
|
+
return result('wsl2', 'WSL2', 'pass', 'wsl --status responds')
|
|
125
|
+
}
|
|
126
|
+
return result('wsl2', 'WSL2', 'fail', 'WSL2 is not functional (required by Docker Desktop and Rancher Desktop)', {
|
|
127
|
+
guide: [
|
|
128
|
+
'Ask IT to enable WSL2, or run in an elevated PowerShell if you are allowed to:',
|
|
129
|
+
' wsl --install --no-distribution',
|
|
130
|
+
'A reboot is required afterwards; then re-run this command.',
|
|
131
|
+
],
|
|
132
|
+
it: [
|
|
133
|
+
'Enable Windows features "Virtual Machine Platform" + "Windows Subsystem for Linux" and CPU virtualization (BIOS/firmware) on this device, then reboot:',
|
|
134
|
+
' wsl --install --no-distribution',
|
|
135
|
+
],
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Hybrid mode compiles native Node addons (better-sqlite3, isolated-vm,
|
|
140
|
+
// cpu-features, …) from C++ during `yarn install`. On Windows that needs the
|
|
141
|
+
// Visual C++ toolchain, which the starter never installs (propose-only) — so a
|
|
142
|
+
// clean machine otherwise fails the install with a cryptic node-gyp error.
|
|
143
|
+
// macOS/Linux ship clang/gcc widely enough that we don't gate on them here.
|
|
144
|
+
export function checkBuildToolchain() {
|
|
145
|
+
if (process.platform !== 'win32') return null
|
|
146
|
+
const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'
|
|
147
|
+
const vswhere = path.join(programFilesX86, 'Microsoft Visual Studio', 'Installer', 'vswhere.exe')
|
|
148
|
+
let installPath = ''
|
|
149
|
+
if (fs.existsSync(vswhere)) {
|
|
150
|
+
const out = commandOutput(vswhere, [
|
|
151
|
+
'-latest', '-products', '*',
|
|
152
|
+
'-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64',
|
|
153
|
+
'-property', 'installationPath',
|
|
154
|
+
])
|
|
155
|
+
installPath = (out ?? '').trim()
|
|
156
|
+
}
|
|
157
|
+
if (installPath) {
|
|
158
|
+
return result('build-tools', 'C++ build toolchain (native Node modules)', 'pass', 'Visual Studio Build Tools with the VC++ workload')
|
|
159
|
+
}
|
|
160
|
+
return result('build-tools', 'C++ build toolchain (native Node modules)', 'fail', 'not found — native addons (better-sqlite3, isolated-vm, …) cannot compile on the host', {
|
|
161
|
+
guide: [
|
|
162
|
+
'Hybrid mode builds native Node modules on the host, which needs the Visual C++ toolchain.',
|
|
163
|
+
'Install it (several GB, admin required), then open a NEW terminal and re-run:',
|
|
164
|
+
' winget install Microsoft.VisualStudio.2022.BuildTools --override "--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"',
|
|
165
|
+
'No admin, or cannot install it? Build everything in containers instead: yarn om up --mode docker',
|
|
166
|
+
],
|
|
167
|
+
it: ['Install "Visual Studio 2022 Build Tools" with the "Desktop development with C++" (VCTools) workload — required to compile native Node modules for the native/hybrid dev workflow.'],
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function checkResources(repoRoot) {
|
|
172
|
+
const totalGb = os.totalmem() / GIB
|
|
173
|
+
let freeDiskGb = null
|
|
174
|
+
try {
|
|
175
|
+
const stat = fs.statfsSync(repoRoot)
|
|
176
|
+
freeDiskGb = (stat.bavail * stat.bsize) / GIB
|
|
177
|
+
} catch {
|
|
178
|
+
freeDiskGb = null
|
|
179
|
+
}
|
|
180
|
+
const diskDetail = freeDiskGb === null ? 'free disk unknown' : `${freeDiskGb.toFixed(0)} GB free disk`
|
|
181
|
+
const detail = `${totalGb.toFixed(0)} GB RAM, ${diskDetail}`
|
|
182
|
+
if (totalGb < 12) {
|
|
183
|
+
return result('resources', 'Hardware (16 GB RAM recommended, 12 GB minimum)', 'fail', detail, {
|
|
184
|
+
it: ['This device is below the 12 GB RAM floor for the Open Mercato dev stack — a hardware upgrade or a remote dev environment is needed.'],
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
if (totalGb < 16 || (freeDiskGb !== null && freeDiskGb < 20)) {
|
|
188
|
+
return result('resources', 'Hardware (16 GB RAM recommended, 12 GB minimum)', 'warn', `${detail} — expect slow builds; ~20 GB free disk needed for the first run`)
|
|
189
|
+
}
|
|
190
|
+
return result('resources', 'Hardware', 'pass', detail)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function checkLocalhostResolution() {
|
|
194
|
+
try {
|
|
195
|
+
const records = await dns.lookup('localhost', { all: true, verbatim: true })
|
|
196
|
+
const hasV4 = records.some((entry) => entry.family === 4)
|
|
197
|
+
const v6First = records.length > 0 && records[0].family === 6
|
|
198
|
+
if (!hasV4) {
|
|
199
|
+
return result('localhost', 'localhost resolution', 'fail', '`localhost` does not resolve to 127.0.0.1 on this machine', {
|
|
200
|
+
guide: ['The starter always probes 127.0.0.1 directly, but other tools may break. Check the hosts file for a missing `127.0.0.1 localhost` line.'],
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
if (v6First) {
|
|
204
|
+
return result('localhost', 'localhost resolution', 'warn', '`localhost` prefers ::1 (IPv6) — the starter uses 127.0.0.1 explicitly, but hand-typed localhost URLs can behave differently')
|
|
205
|
+
}
|
|
206
|
+
return result('localhost', 'localhost resolution', 'pass', records.map((entry) => entry.address).join(', '))
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return result('localhost', 'localhost resolution', 'fail', `lookup failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function probePort(port) {
|
|
213
|
+
return new Promise((resolve) => {
|
|
214
|
+
const socket = net.connect({ host: LOOPBACK_HOST, port, timeout: 750 })
|
|
215
|
+
socket.once('connect', () => {
|
|
216
|
+
socket.destroy()
|
|
217
|
+
resolve(true)
|
|
218
|
+
})
|
|
219
|
+
const closed = () => {
|
|
220
|
+
socket.destroy()
|
|
221
|
+
resolve(false)
|
|
222
|
+
}
|
|
223
|
+
socket.once('timeout', closed)
|
|
224
|
+
socket.once('error', closed)
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Ports our own stack owns are only a conflict while the stack is down; the
|
|
229
|
+
// caller passes `stackRunning` accordingly.
|
|
230
|
+
export async function checkPorts(repoRoot, { stackRunning = false } = {}) {
|
|
231
|
+
if (stackRunning) return result('ports', 'Port availability', 'pass', 'stack is running — listeners are expected')
|
|
232
|
+
const ports = resolveStackPorts(repoRoot)
|
|
233
|
+
const inUse = []
|
|
234
|
+
for (const [service, port] of Object.entries(ports)) {
|
|
235
|
+
if (await probePort(port)) inUse.push(`${port} (${service})`)
|
|
236
|
+
}
|
|
237
|
+
if (inUse.length === 0) return result('ports', 'Port availability', 'pass', 'all stack ports are free')
|
|
238
|
+
return result('ports', 'Port availability', 'warn', `already listening: ${inUse.join(', ')}`, {
|
|
239
|
+
guide: [
|
|
240
|
+
'If these are leftovers of a previous run, stop them: yarn om stop (or: npx @open-mercato/starter stop).',
|
|
241
|
+
'If another app owns the port (local Postgres on 5432 is common), override the port in .env (e.g. POSTGRES_PORT=5433) and re-run.',
|
|
242
|
+
],
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function checkTlsInterception(repoRoot) {
|
|
247
|
+
const results = await probeTlsInterception()
|
|
248
|
+
const { intercepted, unreachable, clean } = summarizeProbeResults(results)
|
|
249
|
+
const bundleExists = fs.existsSync(path.join(repoRoot, CAPTURED_CA_BUNDLE))
|
|
250
|
+
if (intercepted.length > 0) {
|
|
251
|
+
return result(
|
|
252
|
+
'tls',
|
|
253
|
+
'Corporate TLS interception',
|
|
254
|
+
bundleExists ? 'warn' : 'fail',
|
|
255
|
+
`intercepted: ${intercepted.map((entry) => entry.host).join(', ')}${bundleExists ? ' (CA bundle already captured)' : ''}`,
|
|
256
|
+
{
|
|
257
|
+
guide: bundleExists
|
|
258
|
+
? ['The starter already provisions the captured CA into host tooling and image builds. If pulls still fail, your container engine needs the CA too — see the IT sheet.']
|
|
259
|
+
: ['Run the starter (yarn om / npx @open-mercato/starter) — it captures the interception CA automatically and provisions it into yarn, node, and image builds.'],
|
|
260
|
+
it: ['Provide the corporate root CA bundle (PEM) for developer tooling, or approve automatic capture. Docker Desktop reads the Windows certificate store; Rancher Desktop needs the CA provisioned into its WSL distro.'],
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
if (unreachable.length > 0 && clean.length === 0) {
|
|
265
|
+
return result('tls', 'Network egress', 'fail', `unreachable: ${unreachable.map((entry) => `${entry.host} (${entry.reason})`).join(', ')}`, {
|
|
266
|
+
guide: ['Check proxy settings: set HTTPS_PROXY/HTTP_PROXY if your network requires a proxy, then re-run.'],
|
|
267
|
+
it: ['Allow developer egress to registry.yarnpkg.com, github.com, registry-1.docker.io (or provide internal mirrors — see starters/company/).'],
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
if (unreachable.length > 0) {
|
|
271
|
+
return result('tls', 'Network egress', 'warn', `partially blocked: ${unreachable.map((entry) => entry.host).join(', ')}`)
|
|
272
|
+
}
|
|
273
|
+
return result('tls', 'Network egress + TLS', 'pass', 'all probe hosts reachable with a trusted chain')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function checkProxyConsistency() {
|
|
277
|
+
const envProxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || ''
|
|
278
|
+
if (process.platform !== 'win32') {
|
|
279
|
+
return envProxy
|
|
280
|
+
? result('proxy', 'Proxy configuration', 'pass', `using ${envProxy}`)
|
|
281
|
+
: null
|
|
282
|
+
}
|
|
283
|
+
const reg = commandOutput('reg', ['query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings', '/v', 'ProxyEnable'])
|
|
284
|
+
const systemProxyOn = /ProxyEnable\s+REG_DWORD\s+0x1/i.test(reg ?? '')
|
|
285
|
+
if (systemProxyOn && !envProxy) {
|
|
286
|
+
return result('proxy', 'Proxy configuration', 'warn', 'Windows uses a system proxy but HTTPS_PROXY is not set for command-line tools', {
|
|
287
|
+
guide: ['Set the proxy for CLI tools too, e.g. in PowerShell: $env:HTTPS_PROXY = "http://proxy.example.com:8080" (ask IT for the proxy address; add it to your user environment variables to persist).'],
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
if (envProxy) return result('proxy', 'Proxy configuration', 'pass', `using ${envProxy}`)
|
|
291
|
+
return null
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function checkClonePath(repoRoot) {
|
|
295
|
+
if (process.platform !== 'win32') return null
|
|
296
|
+
const findings = []
|
|
297
|
+
const oneDrive = process.env.OneDrive
|
|
298
|
+
if (oneDrive && repoRoot.toLowerCase().startsWith(oneDrive.toLowerCase())) {
|
|
299
|
+
findings.push('repo lives inside OneDrive — file watching and node_modules sync fight the sync client')
|
|
300
|
+
}
|
|
301
|
+
if (repoRoot.startsWith('\\\\')) {
|
|
302
|
+
findings.push('repo lives on a UNC/network path')
|
|
303
|
+
}
|
|
304
|
+
const longPaths = commandOutput('reg', ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem', '/v', 'LongPathsEnabled'])
|
|
305
|
+
const longPathsOn = /LongPathsEnabled\s+REG_DWORD\s+0x1/i.test(longPaths ?? '')
|
|
306
|
+
if (!longPathsOn) {
|
|
307
|
+
findings.push('NTFS long paths are disabled (MAX_PATH=260 can break deep node_modules)')
|
|
308
|
+
}
|
|
309
|
+
if (findings.length === 0) return result('clone-path', 'Clone location', 'pass', repoRoot)
|
|
310
|
+
return result('clone-path', 'Clone location', 'warn', findings.join('; '), {
|
|
311
|
+
guide: ['Prefer a short local path like C:\\dev\\open-mercato.', 'Enable long paths (admin): git config --system core.longpaths true — plus the registry switch below.'],
|
|
312
|
+
it: ['Enable NTFS long paths: reg add "HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem" /v LongPathsEnabled /t REG_DWORD /d 1'],
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Verifies a container can reach a host port through host.docker.internal —
|
|
317
|
+
// the linchpin of the hybrid topology (OpenCode container -> host MCP). Two
|
|
318
|
+
// probes: (1) with the same extra_hosts pin compose.infra.yml uses (unless
|
|
319
|
+
// OM_HOST_GATEWAY overrides it), (2) with the engine's NATIVE resolution.
|
|
320
|
+
// On Rancher Desktop/WSL2 the pin can point at the WSL distro instead of the
|
|
321
|
+
// Windows host while native resolution is correct — the fix is OM_HOST_GATEWAY.
|
|
322
|
+
function probeContainerToHost(mcpPort, addHost) {
|
|
323
|
+
const args = ['run', '--rm']
|
|
324
|
+
if (addHost) args.push('--add-host', addHost)
|
|
325
|
+
args.push('busybox:1.37', 'sh', '-c', `wget -q -O- -T 5 http://host.docker.internal:${mcpPort}/health 2>/dev/null || nslookup host.docker.internal 2>&1`)
|
|
326
|
+
const run = spawnSync('docker', args, { encoding: 'utf8', timeout: 60000, windowsHide: true })
|
|
327
|
+
const output = `${run.stdout ?? ''}${run.stderr ?? ''}`.trim()
|
|
328
|
+
return {
|
|
329
|
+
ran: !run.error,
|
|
330
|
+
mcpAnswered: run.status === 0 && output.includes('"status"'),
|
|
331
|
+
resolvedIp: output.match(/Address:\s+(\d+\.\d+\.\d+\.\d+)/)?.[1] ?? null,
|
|
332
|
+
output,
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function checkContainerToHost(repoRoot, { mcpPort }) {
|
|
337
|
+
const docker = detectDocker()
|
|
338
|
+
if (!docker.ok) return null
|
|
339
|
+
const pin = `host.docker.internal:${process.env.OM_HOST_GATEWAY?.trim() || 'host-gateway'}`
|
|
340
|
+
const pinned = probeContainerToHost(mcpPort, pin)
|
|
341
|
+
if (!pinned.ran) return result('container-host', 'Container → host connectivity', 'warn', 'probe container could not run')
|
|
342
|
+
if (pinned.mcpAnswered) {
|
|
343
|
+
return result('container-host', 'Container → host connectivity', 'pass', `host.docker.internal:${mcpPort}/health responded (mapping: ${pin})`)
|
|
344
|
+
}
|
|
345
|
+
const native = probeContainerToHost(mcpPort, null)
|
|
346
|
+
if (native.mcpAnswered) {
|
|
347
|
+
return result('container-host', 'Container → host connectivity', 'fail', `the compose extra_hosts pin (${pin}) does NOT reach the host, but the engine's native host.docker.internal DOES${native.resolvedIp ? ` (${native.resolvedIp})` : ''} — common on Rancher Desktop/WSL2`, {
|
|
348
|
+
guide: [`Set OM_HOST_GATEWAY=${native.resolvedIp ?? '<the engine-native IP>'} in the root .env, then restart the opencode container.`],
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
if (pinned.resolvedIp || native.resolvedIp) {
|
|
352
|
+
const isWindows = process.platform === 'win32'
|
|
353
|
+
const firewallRule = `New-NetFirewallRule -DisplayName "Open Mercato MCP dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${mcpPort} -Profile Any`
|
|
354
|
+
return result('container-host', 'Container → host connectivity', 'warn', `host.docker.internal resolves${pinned.resolvedIp ? ` (${pinned.resolvedIp})` : ''} but the MCP port did not answer — MCP is not running yet, or a host firewall blocks the container→host hop`, {
|
|
355
|
+
guide: [
|
|
356
|
+
'If the stack is not running yet, start it (yarn om) and re-run the doctor.',
|
|
357
|
+
isWindows
|
|
358
|
+
? `Windows Firewall drops the container→host hop over the WSL/vEthernet adapter. Allow it (elevated PowerShell): ${firewallRule}`
|
|
359
|
+
: 'Windows: allow node.exe inbound on the MCP port for Private networks (the first-run firewall prompt).',
|
|
360
|
+
`Linux (ufw): sudo ufw allow from 172.16.0.0/12 to any port ${mcpPort} proto tcp`,
|
|
361
|
+
],
|
|
362
|
+
it: isWindows
|
|
363
|
+
? [`Approve an inbound firewall rule for the local dev MCP server: ${firewallRule}`]
|
|
364
|
+
: [],
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
return result('container-host', 'Container → host connectivity', 'fail', 'host.docker.internal does not resolve inside containers', {
|
|
368
|
+
guide: ['Set OPENCODE_MCP_URL in .env to a host IP the containers can reach, e.g. http://<your-lan-ip>:' + mcpPort + '/mcp.'],
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function runDoctor(repoRoot, { company = null, stackRunning = false, includeContainerProbe = false, mode = 'hybrid' } = {}) {
|
|
373
|
+
const ports = resolveStackPorts(repoRoot)
|
|
374
|
+
const checks = [
|
|
375
|
+
checkNodeVersion(),
|
|
376
|
+
checkYarn(repoRoot),
|
|
377
|
+
checkGit(),
|
|
378
|
+
checkWsl2(),
|
|
379
|
+
checkContainerRuntime(),
|
|
380
|
+
checkDockerDesktopVersion(),
|
|
381
|
+
// The host C++ toolchain only matters when native modules build on the
|
|
382
|
+
// host — i.e. hybrid mode. In --mode docker they compile in the container.
|
|
383
|
+
mode === 'docker' ? null : checkBuildToolchain(),
|
|
384
|
+
checkResources(repoRoot),
|
|
385
|
+
await checkLocalhostResolution(),
|
|
386
|
+
checkProxyConsistency(),
|
|
387
|
+
await checkTlsInterception(repoRoot),
|
|
388
|
+
await checkPorts(repoRoot, { stackRunning }),
|
|
389
|
+
checkClonePath(repoRoot),
|
|
390
|
+
]
|
|
391
|
+
if (includeContainerProbe) checks.push(checkContainerToHost(repoRoot, { mcpPort: ports.mcp }))
|
|
392
|
+
for (const custom of company?.checks ?? []) {
|
|
393
|
+
try {
|
|
394
|
+
const res = await custom.run({ repoRoot, ports })
|
|
395
|
+
checks.push(result(custom.id, custom.title, res.level ?? 'warn', res.detail ?? '', { guide: res.guide ?? [], it: res.it ?? [] }))
|
|
396
|
+
} catch (error) {
|
|
397
|
+
checks.push(result(custom.id, custom.title, 'warn', `company check crashed: ${error instanceof Error ? error.message : String(error)}`))
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return checks.filter(Boolean)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const LEVEL_ICONS = { pass: icons.ok, warn: icons.warn, fail: icons.fail }
|
|
404
|
+
|
|
405
|
+
export function printDoctorReport(checks, { company = null, log = console.log } = {}) {
|
|
406
|
+
printBanner('environment doctor — read-only audit, nothing is changed', { log })
|
|
407
|
+
for (const check of checks) {
|
|
408
|
+
log(`${LEVEL_ICONS[check.level] ?? icons.info} ${color.bold(check.title)} ${color.dim('—')} ${check.detail}`)
|
|
409
|
+
for (const line of check.guide) log(color.dim(` ${line}`))
|
|
410
|
+
}
|
|
411
|
+
const itLines = checks.flatMap((check) => check.it)
|
|
412
|
+
if (itLines.length > 0) {
|
|
413
|
+
log('')
|
|
414
|
+
log(color.bold(color.yellow('── Hand this to IT ' + '─'.repeat(40))))
|
|
415
|
+
log('The items below need admin rights or policy changes on this managed device:')
|
|
416
|
+
for (const line of itLines) log(` * ${line}`)
|
|
417
|
+
if (company?.itContact) log(` Contact: ${company.itContact}`)
|
|
418
|
+
}
|
|
419
|
+
const fails = checks.filter((check) => check.level === 'fail').length
|
|
420
|
+
const warns = checks.filter((check) => check.level === 'warn').length
|
|
421
|
+
log('')
|
|
422
|
+
if (fails > 0) log(color.bold(color.red(`Verdict: ${fails} blocking issue(s), ${warns} warning(s). Fix the blocking items, then re-run.`)))
|
|
423
|
+
else if (warns > 0) log(color.bold(color.yellow(`Verdict: ready, with ${warns} warning(s).`)))
|
|
424
|
+
else log(color.bold(color.green('Verdict: ready.')))
|
|
425
|
+
return fails === 0
|
|
426
|
+
}
|
package/src/env-file.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
|
|
3
|
+
// Dotenv helpers shared by the starter scripts. Files are always written as
|
|
4
|
+
// UTF-8 without BOM with LF endings — some compose dotenv parsers choke on a
|
|
5
|
+
// BOM, and CRLF would leak into container env values.
|
|
6
|
+
|
|
7
|
+
export function readEnvValue(filePath, key) {
|
|
8
|
+
if (!fs.existsSync(filePath)) return null
|
|
9
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
10
|
+
const pattern = new RegExp(`^${escapeRegExp(key)}=(.*)$`, 'm')
|
|
11
|
+
const match = content.match(pattern)
|
|
12
|
+
if (!match) return null
|
|
13
|
+
return match[1].replace(/\r$/, '')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Fill-missing-only by default: an existing non-empty value is never
|
|
17
|
+
// overwritten, so re-running an installer never rotates secrets. With
|
|
18
|
+
// `replaceEmpty`, a bare `KEY=` placeholder line is filled in place.
|
|
19
|
+
export function addEnvValue(filePath, key, value, { replaceEmpty = false } = {}) {
|
|
20
|
+
const existing = readEnvValue(filePath, key)
|
|
21
|
+
if (existing !== null) {
|
|
22
|
+
if (!(replaceEmpty && existing.trim() === '')) return false
|
|
23
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
24
|
+
const pattern = new RegExp(`^${escapeRegExp(key)}=[ \\t]*\r?$`, 'm')
|
|
25
|
+
writeEnvFileText(filePath, content.replace(pattern, `${key}=${value}`))
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
28
|
+
let prefix = ''
|
|
29
|
+
if (fs.existsSync(filePath)) {
|
|
30
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
31
|
+
if (raw.length > 0 && !raw.endsWith('\n')) prefix = '\n'
|
|
32
|
+
}
|
|
33
|
+
fs.appendFileSync(filePath, `${prefix}${key}=${value}\n`)
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Overwrite-or-append, for keys where a canonical source of truth exists
|
|
38
|
+
// (e.g. the starter mirroring AI provider config into the app env). Use
|
|
39
|
+
// addEnvValue for secrets that must never rotate on re-runs.
|
|
40
|
+
export function setEnvValue(filePath, key, value) {
|
|
41
|
+
if (!fs.existsSync(filePath)) {
|
|
42
|
+
writeEnvFileText(filePath, `${key}=${value}\n`)
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
46
|
+
const pattern = new RegExp(`^${escapeRegExp(key)}=.*$`, 'm')
|
|
47
|
+
if (pattern.test(content)) {
|
|
48
|
+
const next = content.replace(pattern, `${key}=${value}`)
|
|
49
|
+
if (next !== content) writeEnvFileText(filePath, next)
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
const prefix = content.length > 0 && !content.endsWith('\n') ? '\n' : ''
|
|
53
|
+
fs.appendFileSync(filePath, `${prefix}${key}=${value}\n`)
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function replaceEnvLine(content, key, value) {
|
|
58
|
+
const pattern = new RegExp(`^${escapeRegExp(key)}=.*$`, 'm')
|
|
59
|
+
if (!pattern.test(content)) return content
|
|
60
|
+
return content.replace(pattern, `${key}=${value}`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function uncommentEnvLine(content, key, value) {
|
|
64
|
+
const pattern = new RegExp(`^#[ \\t]*${escapeRegExp(key)}=.*$`, 'm')
|
|
65
|
+
if (!pattern.test(content)) return content
|
|
66
|
+
return content.replace(pattern, `${key}=${value}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function writeEnvFileText(filePath, content) {
|
|
70
|
+
fs.writeFileSync(filePath, content.replace(/\r\n/g, '\n'))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function escapeRegExp(value) {
|
|
74
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
75
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
|
|
5
|
+
import { addEnvValue, readEnvValue, replaceEnvLine, uncommentEnvLine, writeEnvFileText } from './env-file.mjs'
|
|
6
|
+
import { secretFingerprint, secretHex } from './secrets.mjs'
|
|
7
|
+
|
|
8
|
+
// Node port of the Windows launcher's Initialize-EnvFiles: fill-missing-only,
|
|
9
|
+
// secrets are generated once and never overwritten on re-runs, and postgres
|
|
10
|
+
// credentials are only generated while no initialized data volume exists yet
|
|
11
|
+
// (changing them afterwards would break auth against the data inside it).
|
|
12
|
+
|
|
13
|
+
function postgresVolumeExists() {
|
|
14
|
+
const result = spawnSync('docker', ['volume', 'ls', '--format', '{{.Name}}'], { encoding: 'utf8' })
|
|
15
|
+
if (result.error || result.status !== 0) return false
|
|
16
|
+
return String(result.stdout ?? '')
|
|
17
|
+
.split('\n')
|
|
18
|
+
.map((line) => line.trim())
|
|
19
|
+
.includes('mercato-postgres-data')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ensureEnvFiles(repoRoot, { log = console.log, warn = console.warn, extraDefaults = {} } = {}) {
|
|
23
|
+
const rootEnv = path.join(repoRoot, '.env')
|
|
24
|
+
const appEnvExample = path.join(repoRoot, 'apps', 'mercato', '.env.example')
|
|
25
|
+
const appEnv = path.join(repoRoot, 'apps', 'mercato', '.env')
|
|
26
|
+
|
|
27
|
+
const volumeExists = postgresVolumeExists()
|
|
28
|
+
|
|
29
|
+
if (!fs.existsSync(rootEnv)) {
|
|
30
|
+
writeEnvFileText(rootEnv, '# Open Mercato compose environment (generated by the hybrid starter)\n')
|
|
31
|
+
log(`Created ${rootEnv}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let jwtSecret = readEnvValue(rootEnv, 'JWT_SECRET')
|
|
35
|
+
if (!jwtSecret || !jwtSecret.trim()) jwtSecret = secretHex(32)
|
|
36
|
+
|
|
37
|
+
const setSecret = (key, value) => {
|
|
38
|
+
if (addEnvValue(rootEnv, key, value)) log(` set ${key} (${secretFingerprint(value)})`)
|
|
39
|
+
}
|
|
40
|
+
const setValue = (key, value) => {
|
|
41
|
+
if (addEnvValue(rootEnv, key, value)) log(` set ${key}=${value}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setSecret('JWT_SECRET', jwtSecret)
|
|
45
|
+
setSecret('TENANT_DATA_ENCRYPTION_FALLBACK_KEY', secretHex(16))
|
|
46
|
+
const meiliKey = readEnvValue(rootEnv, 'MEILISEARCH_MASTER_KEY')?.trim() || secretHex(16)
|
|
47
|
+
setSecret('MEILISEARCH_MASTER_KEY', meiliKey)
|
|
48
|
+
setValue('APP_URL', `http://localhost:${readEnvValue(rootEnv, 'APP_PORT')?.trim() || '3000'}`)
|
|
49
|
+
setValue('OM_INIT_SUPERADMIN_EMAIL', 'superadmin@acme.com')
|
|
50
|
+
for (const [key, value] of Object.entries(extraDefaults)) {
|
|
51
|
+
setValue(key, value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let postgresPassword = readEnvValue(rootEnv, 'POSTGRES_PASSWORD')?.trim() || ''
|
|
55
|
+
if (volumeExists) {
|
|
56
|
+
if (readEnvValue(rootEnv, 'OM_INIT_SUPERADMIN_PASSWORD') === null) {
|
|
57
|
+
warn('Existing postgres volume detected — keeping compose default credentials. Remove the mercato-postgres-data volume for a clean slate.')
|
|
58
|
+
}
|
|
59
|
+
if (!postgresPassword) {
|
|
60
|
+
warn('Existing postgres volume but no POSTGRES_PASSWORD in .env — the app will try the compose default (postgres), which only works if the volume was created with it.')
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
setSecret('OM_INIT_SUPERADMIN_PASSWORD', secretHex(8))
|
|
64
|
+
if (!postgresPassword) {
|
|
65
|
+
postgresPassword = secretHex(16)
|
|
66
|
+
setSecret('POSTGRES_PASSWORD', postgresPassword)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// NOTE: MCP_SERVER_API_KEY is intentionally NOT generated here. `yarn dev`
|
|
70
|
+
// provisions a real DB-backed key into .mercato/mcp-shared; a random env
|
|
71
|
+
// value would shadow it and break OpenCode -> MCP authentication.
|
|
72
|
+
|
|
73
|
+
if (!fs.existsSync(appEnv)) {
|
|
74
|
+
if (fs.existsSync(appEnvExample)) {
|
|
75
|
+
let content = fs.readFileSync(appEnvExample, 'utf8')
|
|
76
|
+
content = replaceEnvLine(content, 'JWT_SECRET', jwtSecret)
|
|
77
|
+
content = replaceEnvLine(content, 'AUTH_SECRET', secretHex(32))
|
|
78
|
+
content = replaceEnvLine(content, 'LOOKUP_HASH_PEPPER', secretHex(16))
|
|
79
|
+
if (postgresPassword) {
|
|
80
|
+
// 127.0.0.1, never `localhost`: on modern Windows localhost resolves
|
|
81
|
+
// to ::1 first while the published container ports listen on IPv4.
|
|
82
|
+
content = replaceEnvLine(
|
|
83
|
+
content,
|
|
84
|
+
'DATABASE_URL',
|
|
85
|
+
`postgres://postgres:${postgresPassword}@127.0.0.1:5432/open-mercato`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
// The hybrid starter runs a meilisearch container, so point the app at it
|
|
89
|
+
// (the example ships these commented out).
|
|
90
|
+
content = uncommentEnvLine(content, 'MEILISEARCH_HOST', 'http://127.0.0.1:7700')
|
|
91
|
+
content = uncommentEnvLine(content, 'MEILISEARCH_API_KEY', meiliKey)
|
|
92
|
+
writeEnvFileText(appEnv, content)
|
|
93
|
+
log(`Created ${appEnv} from .env.example (placeholder secrets replaced)`)
|
|
94
|
+
} else {
|
|
95
|
+
warn('apps/mercato/.env.example not found; skipping app .env creation')
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
log('apps/mercato/.env already exists (left untouched)')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The host-run `mercato` CLI (yarn initialize, yarn ... mcp:ensure-api-key)
|
|
102
|
+
// loads apps/mercato/.env, NOT the root compose .env. The superadmin
|
|
103
|
+
// identity must live there too: otherwise `initialize` seeds one admin while
|
|
104
|
+
// MCP key provisioning resolves a different (default) owner, throws "owner
|
|
105
|
+
// not found", and OpenCode never gets a key. Propagate the same values
|
|
106
|
+
// (fill-missing-only, so a user's own app .env is never clobbered).
|
|
107
|
+
if (fs.existsSync(appEnv)) {
|
|
108
|
+
const superEmail = readEnvValue(rootEnv, 'OM_INIT_SUPERADMIN_EMAIL')?.trim()
|
|
109
|
+
const superPassword = readEnvValue(rootEnv, 'OM_INIT_SUPERADMIN_PASSWORD')?.trim()
|
|
110
|
+
if (superEmail && addEnvValue(appEnv, 'OM_INIT_SUPERADMIN_EMAIL', superEmail)) {
|
|
111
|
+
log(' set OM_INIT_SUPERADMIN_EMAIL in apps/mercato/.env')
|
|
112
|
+
}
|
|
113
|
+
if (superPassword && addEnvValue(appEnv, 'OM_INIT_SUPERADMIN_PASSWORD', superPassword)) {
|
|
114
|
+
log(' set OM_INIT_SUPERADMIN_PASSWORD (sha256 …) in apps/mercato/.env')
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { rootEnv, appEnv, jwtSecret }
|
|
119
|
+
}
|