@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.
@@ -0,0 +1,359 @@
1
+ import assert from 'node:assert/strict'
2
+ import crypto from 'node:crypto'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { test } from 'node:test'
7
+
8
+ import { parseComposePsOutput, resolveRepoRoot } from '../compose.mjs'
9
+ import { hostTrustEnv, summarizeProbeResults, writeCaBundle } from '../certs.mjs'
10
+ import { DEFAULT_OPENCODE_BASE_IMAGE, DEFAULT_PORTS, resolveStackPorts } from '../constants.mjs'
11
+ import { addEnvValue, readEnvValue, setEnvValue } from '../env-file.mjs'
12
+ import { ensureLlmProvider, syncProviderConfigToAppEnv } from '../providers.mjs'
13
+ import { ensureWindowsUtf8Console, resolveSpawnCommand } from '../spawn.mjs'
14
+ import { StepBlocked, buildToolchainStep, clearConvergenceState, listMigrationModules, migrationsFingerprint, readAppliedMigrationModules, resolveOpencodeBaseImage, runSteps } from '../steps.mjs'
15
+ import { checkBuildToolchain } from '../doctor.mjs'
16
+
17
+ function makeTempDir(prefix) {
18
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix))
19
+ }
20
+
21
+ function makeFakeRepo() {
22
+ const dir = makeTempDir('om-starter-repo-')
23
+ fs.mkdirSync(path.join(dir, 'starters', 'docker'), { recursive: true })
24
+ fs.writeFileSync(path.join(dir, 'starters', 'docker', 'compose.infra.yml'), 'services: {}\n')
25
+ return dir
26
+ }
27
+
28
+ test('ensureWindowsUtf8Console switches the console code page only on win32 and never throws', () => {
29
+ const calls = []
30
+ const fakeSpawnSync = (command, args) => {
31
+ calls.push({ command, args })
32
+ return { status: 0 }
33
+ }
34
+
35
+ assert.equal(ensureWindowsUtf8Console({ platform: 'linux', spawnSyncImpl: fakeSpawnSync }), false)
36
+ assert.equal(calls.length, 0)
37
+
38
+ assert.equal(ensureWindowsUtf8Console({ platform: 'win32', spawnSyncImpl: fakeSpawnSync, systemRoot: 'C:\\Windows' }), true)
39
+ assert.equal(calls.length, 1)
40
+ assert.deepEqual(calls[0].args, ['65001'])
41
+ assert.ok(calls[0].command.endsWith(path.join('System32', 'chcp.com')))
42
+
43
+ const throwingSpawnSync = () => {
44
+ throw new Error('no console attached')
45
+ }
46
+ assert.equal(ensureWindowsUtf8Console({ platform: 'win32', spawnSyncImpl: throwingSpawnSync }), false)
47
+ })
48
+
49
+ test('resolveRepoRoot walks up to the compose marker and returns null outside a repo', () => {
50
+ const repo = makeFakeRepo()
51
+ const nested = path.join(repo, 'packages', 'starter', 'src')
52
+ fs.mkdirSync(nested, { recursive: true })
53
+ assert.equal(resolveRepoRoot(nested), repo)
54
+ const outside = makeTempDir('om-starter-outside-')
55
+ assert.equal(resolveRepoRoot(outside), null)
56
+ })
57
+
58
+ test('parseComposePsOutput accepts both NDJSON and array formats', () => {
59
+ const ndjson = '{"Service":"postgres","State":"running"}\n{"Service":"redis","State":"exited"}'
60
+ assert.equal(parseComposePsOutput(ndjson).length, 2)
61
+ const array = '[{"Service":"postgres","State":"running"}]'
62
+ assert.equal(parseComposePsOutput(array)[0].Service, 'postgres')
63
+ assert.deepEqual(parseComposePsOutput(''), [])
64
+ })
65
+
66
+ test('addEnvValue is fill-missing-only and replaceEmpty fills bare placeholders', () => {
67
+ const dir = makeTempDir('om-starter-env-')
68
+ const envPath = path.join(dir, '.env')
69
+ fs.writeFileSync(envPath, 'EXISTING=keep\nEMPTY=\n')
70
+ assert.equal(addEnvValue(envPath, 'EXISTING', 'overwrite'), false)
71
+ assert.equal(readEnvValue(envPath, 'EXISTING'), 'keep')
72
+ assert.equal(addEnvValue(envPath, 'EMPTY', 'filled', { replaceEmpty: true }), true)
73
+ assert.equal(readEnvValue(envPath, 'EMPTY'), 'filled')
74
+ assert.equal(addEnvValue(envPath, 'NEW', 'value'), true)
75
+ assert.equal(readEnvValue(envPath, 'NEW'), 'value')
76
+ })
77
+
78
+ test('setEnvValue overwrites existing lines and appends missing ones', () => {
79
+ const dir = makeTempDir('om-starter-setenv-')
80
+ const envPath = path.join(dir, '.env')
81
+ fs.writeFileSync(envPath, 'OM_AI_PROVIDER=openai\nOTHER=keep\n')
82
+ assert.equal(setEnvValue(envPath, 'OM_AI_PROVIDER', 'azure'), true)
83
+ assert.equal(readEnvValue(envPath, 'OM_AI_PROVIDER'), 'azure')
84
+ assert.equal(readEnvValue(envPath, 'OTHER'), 'keep')
85
+ assert.equal(setEnvValue(envPath, 'OM_AI_MODEL', 'my-deployment'), true)
86
+ assert.equal(readEnvValue(envPath, 'OM_AI_MODEL'), 'my-deployment')
87
+ })
88
+
89
+ test('syncProviderConfigToAppEnv mirrors root AI config into the app env without rotating keys', () => {
90
+ const dir = makeTempDir('om-starter-llm-sync-')
91
+ const rootEnv = path.join(dir, '.env')
92
+ const appEnv = path.join(dir, 'app.env')
93
+ fs.writeFileSync(rootEnv, 'OM_AI_PROVIDER=azure\nOM_AI_MODEL=my-deployment\nAZURE_OPENAI_API_KEY=root-secret\nAZURE_OPENAI_BASE_URL=https://example.azure.com\n')
94
+ fs.writeFileSync(appEnv, 'OM_AI_PROVIDER=openai\nOM_AI_MODEL=gpt-5-mini\nAZURE_OPENAI_API_KEY=\nAZURE_OPENAI_BASE_URL=\n')
95
+
96
+ syncProviderConfigToAppEnv({ rootEnv, appEnv }, () => {})
97
+
98
+ assert.equal(readEnvValue(appEnv, 'OM_AI_PROVIDER'), 'azure')
99
+ assert.equal(readEnvValue(appEnv, 'OM_AI_MODEL'), 'my-deployment')
100
+ assert.equal(readEnvValue(appEnv, 'AZURE_OPENAI_API_KEY'), 'root-secret')
101
+ assert.equal(readEnvValue(appEnv, 'AZURE_OPENAI_BASE_URL'), 'https://example.azure.com')
102
+
103
+ fs.writeFileSync(appEnv, 'OM_AI_PROVIDER=azure\nAZURE_OPENAI_API_KEY=manually-rotated\n')
104
+ syncProviderConfigToAppEnv({ rootEnv, appEnv }, () => {})
105
+ assert.equal(readEnvValue(appEnv, 'AZURE_OPENAI_API_KEY'), 'manually-rotated')
106
+
107
+ syncProviderConfigToAppEnv({ rootEnv, appEnv: path.join(dir, 'missing.env') }, () => {})
108
+ syncProviderConfigToAppEnv({ rootEnv, appEnv: null }, () => {})
109
+ })
110
+
111
+ test('ensureLlmProvider syncs the app env when the root env is already configured', async () => {
112
+ const dir = makeTempDir('om-starter-llm-configured-')
113
+ const rootEnv = path.join(dir, '.env')
114
+ const appEnv = path.join(dir, 'app.env')
115
+ fs.writeFileSync(rootEnv, 'OM_AI_PROVIDER=azure\nOM_AI_MODEL=my-deployment\nAZURE_OPENAI_API_KEY=root-secret\n')
116
+ fs.writeFileSync(appEnv, 'OM_AI_PROVIDER=openai\nAZURE_OPENAI_API_KEY=\n')
117
+
118
+ const outcome = await ensureLlmProvider({ rootEnv, appEnv }, { log: () => {}, warn: () => {} })
119
+
120
+ assert.equal(outcome, 'configured')
121
+ assert.equal(readEnvValue(appEnv, 'OM_AI_PROVIDER'), 'azure')
122
+ assert.equal(readEnvValue(appEnv, 'OM_AI_MODEL'), 'my-deployment')
123
+ assert.equal(readEnvValue(appEnv, 'AZURE_OPENAI_API_KEY'), 'root-secret')
124
+ })
125
+
126
+ test('hostTrustEnv wires CA bundle, system CA flag, and corepack proxy fix', () => {
127
+ const env = hostTrustEnv('/tmp/bundle.pem', { NODE_OPTIONS: '--max-old-space-size=2048', HTTPS_PROXY: 'http://proxy:8080' })
128
+ assert.equal(env.NODE_EXTRA_CA_CERTS, '/tmp/bundle.pem')
129
+ assert.ok(env.NODE_OPTIONS.includes('--use-system-ca'))
130
+ assert.ok(env.NODE_OPTIONS.includes('--max-old-space-size=2048'))
131
+ assert.equal(env.NODE_USE_ENV_PROXY, '1')
132
+ const noProxy = hostTrustEnv(null, {})
133
+ assert.equal(noProxy.NODE_EXTRA_CA_CERTS, undefined)
134
+ assert.equal(noProxy.NODE_USE_ENV_PROXY, undefined)
135
+ assert.ok(noProxy.NODE_OPTIONS.includes('--use-system-ca'))
136
+ })
137
+
138
+ test('writeCaBundle merges company bundles and captured certs, returns null when empty', () => {
139
+ const repo = makeFakeRepo()
140
+ const companyBundle = path.join(repo, 'company-ca.pem')
141
+ fs.writeFileSync(companyBundle, '-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n')
142
+ const bundlePath = writeCaBundle(repo, {
143
+ companyBundles: [companyBundle],
144
+ capturedPems: [{ fingerprint: 'FP', subject: 'Test Interception CA', pem: '-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n' }],
145
+ })
146
+ const content = fs.readFileSync(bundlePath, 'utf8')
147
+ assert.ok(content.includes('AAA'))
148
+ assert.ok(content.includes('BBB'))
149
+ assert.ok(content.includes('Test Interception CA'))
150
+ assert.equal(writeCaBundle(repo, { companyBundles: [], capturedPems: [] }), null)
151
+ })
152
+
153
+ test('summarizeProbeResults buckets probe outcomes', () => {
154
+ const summary = summarizeProbeResults([
155
+ { host: 'a', status: 'ok' },
156
+ { host: 'b', status: 'intercepted' },
157
+ { host: 'c', status: 'unreachable' },
158
+ ])
159
+ assert.equal(summary.clean.length, 1)
160
+ assert.equal(summary.intercepted[0].host, 'b')
161
+ assert.equal(summary.unreachable[0].host, 'c')
162
+ })
163
+
164
+ test('resolveStackPorts honors env overrides and falls back to defaults', () => {
165
+ const repo = makeFakeRepo()
166
+ const ports = resolveStackPorts(repo, { POSTGRES_PORT: '5433', APP_PORT: 'not-a-number' })
167
+ assert.equal(ports.postgres, 5433)
168
+ assert.equal(ports.app, DEFAULT_PORTS.app)
169
+ assert.equal(ports.mcp, DEFAULT_PORTS.mcp)
170
+ })
171
+
172
+ test('resolveSpawnCommand leaves real executables and unix platforms shell-free', () => {
173
+ assert.deepEqual(resolveSpawnCommand('docker', ['compose', 'version'], { platform: 'win32' }), {
174
+ command: 'docker',
175
+ args: ['compose', 'version'],
176
+ spawnOptions: {},
177
+ })
178
+ assert.deepEqual(resolveSpawnCommand('yarn', ['install'], { platform: 'linux' }), {
179
+ command: 'yarn',
180
+ args: ['install'],
181
+ spawnOptions: {},
182
+ })
183
+ })
184
+
185
+ test('resolveSpawnCommand hands cmd.exe one pre-quoted command line for Windows shims', () => {
186
+ const plain = resolveSpawnCommand('yarn', ['--version'], { platform: 'win32' })
187
+ assert.equal(plain.command, 'yarn --version')
188
+ assert.deepEqual(plain.args, [])
189
+ assert.equal(plain.spawnOptions.shell, true)
190
+
191
+ const quoted = resolveSpawnCommand('corepack', ['prepare', 'yarn@4.12.0', '--activate'], { platform: 'win32' })
192
+ assert.equal(quoted.command, 'corepack prepare yarn@4.12.0 --activate')
193
+
194
+ const spaced = resolveSpawnCommand('yarn.cmd', ['run', 'a b'], { platform: 'win32' })
195
+ assert.equal(spaced.command, 'yarn.cmd run "a b"')
196
+ })
197
+
198
+ test('resolveSpawnCommand rejects cmd metacharacters instead of degrading into injection', () => {
199
+ assert.throws(() => resolveSpawnCommand('yarn', ['%TEMP%'], { platform: 'win32' }))
200
+ assert.throws(() => resolveSpawnCommand('yarn', ['a&whoami'], { platform: 'win32' }))
201
+ assert.throws(() => resolveSpawnCommand('npm', ['pkg|rm'], { platform: 'win32' }))
202
+ })
203
+
204
+ function quietCtx(overrides = {}) {
205
+ return { log: () => {}, flags: {}, company: { steps: { disable: [], extra: [] } }, ...overrides }
206
+ }
207
+
208
+ test('runSteps skips satisfied steps, applies unsatisfied ones, and stops on StepBlocked with guidance', async () => {
209
+ const order = []
210
+ const steps = [
211
+ { id: 'done', title: 'Done', async check() { order.push('check:done'); return { ok: true, detail: 'ok' } }, async apply() { order.push('apply:done') } },
212
+ { id: 'todo', title: 'Todo', async check() { return { ok: false, detail: '' } }, async apply() { order.push('apply:todo') } },
213
+ { id: 'skipped', title: 'Skipped', appliesTo: () => false, async check() { order.push('check:skipped'); return { ok: false } } },
214
+ ]
215
+ const outcome = await runSteps(steps, quietCtx())
216
+ assert.equal(outcome.ok, true)
217
+ assert.deepEqual(order, ['check:done', 'apply:todo'])
218
+
219
+ const blocked = await runSteps([
220
+ { id: 'gate', title: 'Gate', async check() { throw new StepBlocked('gate', ['install the thing']) } },
221
+ { id: 'never', title: 'Never', async check() { order.push('check:never'); return { ok: true } } },
222
+ ], quietCtx())
223
+ assert.equal(blocked.ok, false)
224
+ assert.equal(blocked.blockedStep, 'gate')
225
+ assert.ok(!order.includes('check:never'))
226
+ })
227
+
228
+ test('runSteps reports step failures without throwing', async () => {
229
+ const outcome = await runSteps([
230
+ { id: 'boom', title: 'Boom', async check() { return { ok: false } }, async apply() { throw new Error('exploded') } },
231
+ ], quietCtx())
232
+ assert.equal(outcome.ok, false)
233
+ assert.equal(outcome.failedStep, 'boom')
234
+ })
235
+
236
+ test('migrationsFingerprint tracks Migration*.ts files and ignores snapshot dot-files', () => {
237
+ const repo = makeFakeRepo()
238
+ const migrationsDir = path.join(repo, 'packages', 'core', 'src', 'modules', 'demo', 'migrations')
239
+ fs.mkdirSync(migrationsDir, { recursive: true })
240
+ fs.writeFileSync(path.join(migrationsDir, 'Migration20260101000000.ts'), 'export class M {}\n')
241
+ const initial = migrationsFingerprint(repo)
242
+
243
+ fs.writeFileSync(path.join(migrationsDir, '.snapshot-open-mercato.json'), '{"changed":true}\n')
244
+ assert.equal(migrationsFingerprint(repo), initial)
245
+
246
+ fs.writeFileSync(path.join(migrationsDir, 'Migration20260102000000.ts'), 'export class M2 {}\n')
247
+ assert.notEqual(migrationsFingerprint(repo), initial)
248
+
249
+ const appMigrations = path.join(repo, 'apps', 'mercato', 'src', 'modules', 'local', 'migrations')
250
+ fs.mkdirSync(appMigrations, { recursive: true })
251
+ const withApp = migrationsFingerprint(repo)
252
+ fs.writeFileSync(path.join(appMigrations, 'Migration20260103000000.ts'), 'export class M3 {}\n')
253
+ assert.notEqual(migrationsFingerprint(repo), withApp)
254
+ })
255
+
256
+ test('clearConvergenceState removes markers but keeps mode and db-initialized state', () => {
257
+ const repo = makeFakeRepo()
258
+ const stateDir = path.join(repo, '.mercato', 'starter')
259
+ fs.mkdirSync(stateDir, { recursive: true })
260
+ fs.writeFileSync(path.join(stateDir, 'mode'), 'hybrid\n')
261
+ fs.writeFileSync(path.join(stateDir, 'install.hash'), 'abc\n')
262
+ fs.writeFileSync(path.join(stateDir, 'db-initialized-cafe01'), 'when\n')
263
+ fs.writeFileSync(path.join(stateDir, 'db-migrations-cafe01'), 'hash\n')
264
+
265
+ const cleared = clearConvergenceState(repo)
266
+ assert.deepEqual(cleared.sort(), ['db-migrations-cafe01', 'install.hash'])
267
+ assert.deepEqual(fs.readdirSync(stateDir).sort(), ['db-initialized-cafe01', 'mode'])
268
+ assert.deepEqual(clearConvergenceState(path.join(repo, 'nope')), [])
269
+ })
270
+
271
+ test('listMigrationModules names every module that ships Migration files', () => {
272
+ const repo = makeFakeRepo()
273
+ const orchestratorDir = path.join(repo, 'packages', 'enterprise', 'src', 'modules', 'agent_orchestrator', 'migrations')
274
+ const customersDir = path.join(repo, 'packages', 'core', 'src', 'modules', 'customers', 'migrations')
275
+ const emptyDir = path.join(repo, 'packages', 'core', 'src', 'modules', 'no_migrations', 'migrations')
276
+ fs.mkdirSync(orchestratorDir, { recursive: true })
277
+ fs.mkdirSync(customersDir, { recursive: true })
278
+ fs.mkdirSync(emptyDir, { recursive: true })
279
+ fs.writeFileSync(path.join(orchestratorDir, 'Migration20260101000000_agent_orchestrator.ts'), 'x')
280
+ fs.writeFileSync(path.join(customersDir, 'Migration20260101000000_customers.ts'), 'x')
281
+ fs.writeFileSync(path.join(emptyDir, '.snapshot-open-mercato.json'), '{}')
282
+
283
+ const modules = listMigrationModules(repo)
284
+ assert.deepEqual([...modules].sort(), ['agent_orchestrator', 'customers'])
285
+ })
286
+
287
+ test('readAppliedMigrationModules parses psql bookkeeping tables and returns null when the probe fails', () => {
288
+ const repo = makeFakeRepo()
289
+ fs.mkdirSync(path.join(repo, 'apps', 'mercato'), { recursive: true })
290
+ fs.writeFileSync(path.join(repo, 'apps', 'mercato', '.env'), 'DATABASE_URL=postgres://postgres:pw@127.0.0.1:5432/open-mercato\n')
291
+ const ctx = { repoRoot: repo }
292
+
293
+ let seenArgs = null
294
+ const okCompose = (repoRoot, args) => {
295
+ seenArgs = args
296
+ return { status: 0, stdout: 'mikro_orm_migrations_customers\nmikro_orm_migrations_agent_orchestrator\n\n' }
297
+ }
298
+ const applied = readAppliedMigrationModules(ctx, { runComposeImpl: okCompose })
299
+ assert.deepEqual([...applied].sort(), ['agent_orchestrator', 'customers'])
300
+ assert.ok(seenArgs.includes('open-mercato'))
301
+
302
+ assert.equal(readAppliedMigrationModules(ctx, { runComposeImpl: () => ({ status: 1, stdout: '' }) }), null)
303
+ assert.equal(readAppliedMigrationModules(ctx, { runComposeImpl: () => { throw new Error('compose missing') } }), null)
304
+ })
305
+
306
+ test('resolveOpencodeBaseImage: env wins, then .env, then the pinned default', () => {
307
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'om-starter-opencode-'))
308
+ try {
309
+ assert.equal(
310
+ resolveOpencodeBaseImage(root, {}),
311
+ DEFAULT_OPENCODE_BASE_IMAGE,
312
+ )
313
+ fs.writeFileSync(path.join(root, '.env'), 'OPENCODE_BASE_IMAGE=registry.corp/opencode-base:1.18.3\n')
314
+ assert.equal(
315
+ resolveOpencodeBaseImage(root, {}),
316
+ 'registry.corp/opencode-base:1.18.3',
317
+ )
318
+ assert.equal(
319
+ resolveOpencodeBaseImage(root, { OPENCODE_BASE_IMAGE: 'override/base:2' }),
320
+ 'override/base:2',
321
+ )
322
+ } finally {
323
+ fs.rmSync(root, { recursive: true, force: true })
324
+ }
325
+ })
326
+
327
+ test('checkBuildToolchain is a no-op off Windows and the gate only applies to hybrid+win32', () => {
328
+ if (process.platform !== 'win32') {
329
+ assert.equal(checkBuildToolchain(), null)
330
+ }
331
+ // The converge gate never runs off Windows, and never in docker mode.
332
+ assert.equal(buildToolchainStep.appliesTo({ mode: 'hybrid' }), process.platform === 'win32')
333
+ assert.equal(buildToolchainStep.appliesTo({ mode: 'docker' }), false)
334
+ })
335
+
336
+ test('buildToolchainStep warns instead of blocking when the workspace install already converged', async () => {
337
+ const repo = makeFakeRepo()
338
+ const missingToolchain = () => ({ level: 'fail', guide: ['install VC++'] })
339
+ const ctx = { repoRoot: repo, log: () => {}, checkBuildToolchainImpl: missingToolchain }
340
+
341
+ // No node_modules, no install marker: an install is pending — hard block.
342
+ await assert.rejects(() => buildToolchainStep.check(ctx), (error) => error instanceof StepBlocked)
343
+
344
+ // Converged workspace (node_modules + matching install.hash): warn, no block.
345
+ fs.mkdirSync(path.join(repo, 'node_modules'), { recursive: true })
346
+ fs.mkdirSync(path.join(repo, '.mercato', 'starter'), { recursive: true })
347
+ const currentHash = crypto.createHash('sha256')
348
+ .update('yarn.lock').update(Buffer.alloc(0))
349
+ .update('package.json').update(Buffer.alloc(0))
350
+ .digest('hex')
351
+ fs.writeFileSync(path.join(repo, '.mercato', 'starter', 'install.hash'), `${currentHash}\n`)
352
+ const outcome = await buildToolchainStep.check(ctx)
353
+ assert.equal(outcome.ok, true)
354
+ assert.match(outcome.detail, /already converged/)
355
+
356
+ // A pending lockfile change re-arms the hard block.
357
+ fs.writeFileSync(path.join(repo, 'yarn.lock'), 'changed\n')
358
+ await assert.rejects(() => buildToolchainStep.check(ctx), (error) => error instanceof StepBlocked)
359
+ })
package/src/certs.mjs ADDED
@@ -0,0 +1,266 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import tls from 'node:tls'
5
+
6
+ import { CAPTURED_CA_BUNDLE, DOCKER_CERTS_DIR, OPENCODE_CERTS_DIR, TLS_PROBE_HOSTS } from './constants.mjs'
7
+
8
+ // Corporate TLS interception handling. Managed devices (Zscaler, Netskope,
9
+ // Palo Alto, ...) resign every TLS connection with a company root CA that the
10
+ // OS trusts but Node/yarn/docker builds do not. The strategy:
11
+ // detect — probe well-known hosts with Node's default trust store
12
+ // capture — export the interception root(s) from the presented chain
13
+ // provision — drop the bundle everywhere the stack needs it:
14
+ // * NODE_EXTRA_CA_CERTS for every host-side child process (yarn, corepack,
15
+ // node scripts)
16
+ // * docker/certs/ + docker/opencode/certs/ (baked into image builds and
17
+ // runtime container trust by the existing Dockerfiles)
18
+ // guide — engine-level trust (Docker Desktop reads the Windows cert store;
19
+ // Rancher Desktop needs the CA inside its WSL distro) is reported by the
20
+ // doctor with exact commands, since it touches machine state we do not own.
21
+
22
+ const PROBE_TIMEOUT_MS = 5000
23
+
24
+ const INTERCEPTION_ERROR_CODES = new Set([
25
+ 'UNABLE_TO_GET_ISSUER_CERT',
26
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
27
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
28
+ 'SELF_SIGNED_CERT_IN_CHAIN',
29
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
30
+ 'CERT_UNTRUSTED',
31
+ ])
32
+
33
+ function connectOnce(host, { rejectUnauthorized }) {
34
+ return new Promise((resolve) => {
35
+ const socket = tls.connect({
36
+ host,
37
+ port: 443,
38
+ servername: host,
39
+ rejectUnauthorized,
40
+ // Only the platform trust matters for classification — an inherited
41
+ // NODE_EXTRA_CA_CERTS would mask the interception we are looking for,
42
+ // but Node bakes it in at process start, so run detection before we
43
+ // inject the bundle into our own environment (see provisionHostTrust).
44
+ timeout: PROBE_TIMEOUT_MS,
45
+ })
46
+ const finish = (result) => {
47
+ socket.destroy()
48
+ resolve(result)
49
+ }
50
+ socket.once('secureConnect', () => {
51
+ finish({ host, status: 'ok', authorized: socket.authorized, peer: socket.getPeerCertificate(true) })
52
+ })
53
+ socket.once('timeout', () => finish({ host, status: 'unreachable', reason: 'timeout' }))
54
+ socket.once('error', (error) => {
55
+ const code = error?.code ?? ''
56
+ if (INTERCEPTION_ERROR_CODES.has(code)) {
57
+ finish({ host, status: 'intercepted', reason: code })
58
+ } else {
59
+ finish({ host, status: 'unreachable', reason: code || String(error?.message ?? 'unknown error') })
60
+ }
61
+ })
62
+ })
63
+ }
64
+
65
+ // Classify egress per host: 'ok' | 'intercepted' | 'unreachable'.
66
+ export async function probeTlsInterception(hosts = TLS_PROBE_HOSTS) {
67
+ const results = []
68
+ for (const host of hosts) {
69
+ const strict = await connectOnce(host, { rejectUnauthorized: true })
70
+ if (strict.status !== 'intercepted') {
71
+ results.push({ host, status: strict.status, reason: strict.reason ?? '' })
72
+ continue
73
+ }
74
+ results.push({ host, status: 'intercepted', reason: strict.reason ?? '' })
75
+ }
76
+ return results
77
+ }
78
+
79
+ function derToPem(raw) {
80
+ const base64 = Buffer.from(raw).toString('base64')
81
+ const lines = base64.match(/.{1,64}/g) ?? []
82
+ return `-----BEGIN CERTIFICATE-----\n${lines.join('\n')}\n-----END CERTIFICATE-----\n`
83
+ }
84
+
85
+ function walkToRoots(peer) {
86
+ // The presented chain ends at the interception root (self-signed: the cert
87
+ // whose issuerCertificate points at itself). Collect every CA cert above the
88
+ // leaf — proxies often present an intermediate + root and both are needed.
89
+ const collected = []
90
+ const seen = new Set()
91
+ let current = peer
92
+ let hops = 0
93
+ while (current && typeof current === 'object' && hops < 10) {
94
+ hops += 1
95
+ const isSelfIssued = current.issuerCertificate === current
96
+ const fingerprint = current.fingerprint256 ?? current.fingerprint ?? ''
97
+ const isCa = current.ca === true || isSelfIssued
98
+ if (isCa && fingerprint && !seen.has(fingerprint)) {
99
+ seen.add(fingerprint)
100
+ collected.push({ fingerprint, subject: current.subject?.CN ?? current.subject?.O ?? 'unknown', raw: current.raw })
101
+ }
102
+ if (isSelfIssued) break
103
+ current = current.issuerCertificate
104
+ }
105
+ return collected
106
+ }
107
+
108
+ // Capture the interception CA chain from every intercepted host, deduped by
109
+ // fingerprint. Returns [{ fingerprint, subject, pem }].
110
+ export async function captureInterceptionCas(hosts) {
111
+ const captured = new Map()
112
+ for (const host of hosts) {
113
+ const relaxed = await connectOnce(host, { rejectUnauthorized: false })
114
+ if (relaxed.status !== 'ok' || !relaxed.peer) continue
115
+ for (const cert of walkToRoots(relaxed.peer)) {
116
+ if (!cert.raw || captured.has(cert.fingerprint)) continue
117
+ captured.set(cert.fingerprint, { fingerprint: cert.fingerprint, subject: cert.subject, pem: derToPem(cert.raw) })
118
+ }
119
+ }
120
+ return [...captured.values()]
121
+ }
122
+
123
+ function readBundleText(filePath) {
124
+ try {
125
+ return fs.readFileSync(filePath, 'utf8').trim()
126
+ } catch {
127
+ return ''
128
+ }
129
+ }
130
+
131
+ // Netskope agents ship a ready-made combined PEM on disk — the cheapest and
132
+ // most reliable source when present.
133
+ // https://community.netskope.com/next-gen-swg-2/configuring-cli-based-tools-and-development-frameworks-to-work-with-netskope-ssl-interception-7015
134
+ export function findVendorCaBundles() {
135
+ if (process.platform !== 'win32') return []
136
+ const programData = process.env.ProgramData ?? 'C:\\ProgramData'
137
+ return [
138
+ path.join(programData, 'Netskope', 'STAgent', 'data', 'nscacert_combined.pem'),
139
+ path.join(programData, 'Netskope', 'STAgent', 'download', 'nscacert_combined.pem'),
140
+ ].filter((candidate) => fs.existsSync(candidate))
141
+ }
142
+
143
+ const WINDOWS_INTERCEPTION_SUBJECT_PATTERN = 'Zscaler|Netskope|Palo Alto|Blue Coat|Forcepoint|Fortinet|McAfee Web|SSL Inspection|TLS Inspection|Decryption'
144
+
145
+ // Harvest GPO-deployed interception roots from the Windows certificate store
146
+ // (readable without admin; CurrentUser\Root inherits the machine store). The
147
+ // interception root often is NOT sent on the wire — the store is the only
148
+ // place to get it. Cmdlet-only PowerShell, safe under Constrained Language.
149
+ export function harvestWindowsStoreCas() {
150
+ if (process.platform !== 'win32') return []
151
+ const script = [
152
+ `$pattern = '${WINDOWS_INTERCEPTION_SUBJECT_PATTERN}'`,
153
+ "Get-ChildItem Cert:\\CurrentUser\\Root | Where-Object { $_.Subject -match $pattern } | ForEach-Object {",
154
+ " 'FINGERPRINT:' + $_.Thumbprint",
155
+ " 'SUBJECT:' + $_.Subject",
156
+ " '-----BEGIN CERTIFICATE-----'",
157
+ " [Convert]::ToBase64String($_.RawData, [Base64FormattingOptions]::InsertLineBreaks)",
158
+ " '-----END CERTIFICATE-----'",
159
+ '}',
160
+ ].join('; ')
161
+ const run = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', timeout: 30000, windowsHide: true })
162
+ if (run.error || run.status !== 0) return []
163
+ const certs = []
164
+ const blocks = String(run.stdout ?? '').split('FINGERPRINT:').slice(1)
165
+ for (const block of blocks) {
166
+ const [fingerprint] = block.split(/\r?\n/, 1)
167
+ const subject = block.match(/SUBJECT:(.*)/)?.[1]?.trim() ?? 'unknown'
168
+ const pemMatch = block.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/)
169
+ if (!pemMatch) continue
170
+ certs.push({ fingerprint: fingerprint.trim(), subject, pem: `${pemMatch[0].replace(/\r\n/g, '\n')}\n` })
171
+ }
172
+ return certs
173
+ }
174
+
175
+ // Build the effective CA bundle from company-provided files plus captured CAs
176
+ // and write it to .mercato/certs/corporate-ca.pem. Returns the bundle path or
177
+ // null when there is nothing to trust.
178
+ export function writeCaBundle(repoRoot, { companyBundles = [], capturedPems = [] } = {}) {
179
+ const sections = []
180
+ for (const bundlePath of companyBundles) {
181
+ const text = readBundleText(bundlePath)
182
+ if (text) sections.push(`# source: ${bundlePath}\n${text}`)
183
+ }
184
+ for (const cert of capturedPems) {
185
+ sections.push(`# captured interception CA: ${cert.subject} (${cert.fingerprint})\n${cert.pem.trim()}`)
186
+ }
187
+ if (sections.length === 0) return null
188
+ const bundlePath = path.join(repoRoot, CAPTURED_CA_BUNDLE)
189
+ fs.mkdirSync(path.dirname(bundlePath), { recursive: true })
190
+ fs.writeFileSync(bundlePath, `${sections.join('\n\n')}\n`)
191
+ return bundlePath
192
+ }
193
+
194
+ // Copy the bundle into the gitignored image-build drop zones. The root
195
+ // Dockerfile and docker/opencode/Dockerfile append everything in these dirs to
196
+ // the container trust store (build-time AND runtime egress).
197
+ export function provisionDockerCerts(repoRoot, bundlePath) {
198
+ const targets = [
199
+ path.join(repoRoot, DOCKER_CERTS_DIR, 'corporate-root-ca.crt'),
200
+ path.join(repoRoot, OPENCODE_CERTS_DIR, 'corporate-root-ca.crt'),
201
+ ]
202
+ const written = []
203
+ for (const target of targets) {
204
+ fs.mkdirSync(path.dirname(target), { recursive: true })
205
+ const next = fs.readFileSync(bundlePath, 'utf8')
206
+ const current = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null
207
+ if (current !== next) fs.writeFileSync(target, next)
208
+ written.push(target)
209
+ }
210
+ return written
211
+ }
212
+
213
+ // Environment for host-side child processes so node/yarn/corepack trust the
214
+ // corporate CA and traverse the corporate proxy:
215
+ // * NODE_EXTRA_CA_CERTS — additive to Node's bundled roots (safe everywhere)
216
+ // * --use-system-ca (Node >= 24) — additively trusts the OS store, which on
217
+ // managed Windows contains the GPO-deployed interception root; this makes
218
+ // host tooling work even when nothing could be captured
219
+ // * NODE_USE_ENV_PROXY=1 — corepack 0.35+ (bundled since Node 24.16) dropped
220
+ // its own proxy handling; without this flag HTTP(S)_PROXY is ignored
221
+ export function hostTrustEnv(bundlePath, baseEnv = process.env) {
222
+ const env = { ...baseEnv }
223
+ if (bundlePath) env.NODE_EXTRA_CA_CERTS = bundlePath
224
+ const nodeOptions = String(env.NODE_OPTIONS ?? '')
225
+ if (!nodeOptions.includes('--use-system-ca')) {
226
+ env.NODE_OPTIONS = `${nodeOptions} --use-system-ca`.trim()
227
+ }
228
+ const hasProxy = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'].some((key) => env[key])
229
+ if (hasProxy && !env.NODE_USE_ENV_PROXY) env.NODE_USE_ENV_PROXY = '1'
230
+ return env
231
+ }
232
+
233
+ // Rancher Desktop imports Windows-store CAs into its WSL distro on start, but
234
+ // the mechanism is undocumented and has regressed more than once — the
235
+ // documented, deterministic channel is a provisioning script that runs as root
236
+ // inside the distro BEFORE dockerd starts. It must use LF line endings.
237
+ // https://docs.rancherdesktop.io/how-to-guides/provisioning-scripts
238
+ export function provisionRancherDesktopCa(repoRoot, bundlePath) {
239
+ if (process.platform !== 'win32') return null
240
+ const localAppData = process.env.LOCALAPPDATA
241
+ if (!localAppData || !fs.existsSync(path.join(localAppData, 'rancher-desktop'))) return null
242
+ const provisioningDir = path.join(localAppData, 'rancher-desktop', 'provisioning')
243
+ const scriptPath = path.join(provisioningDir, 'open-mercato-corp-ca.start')
244
+ // /mnt/c/... path of the bundle as seen from inside the WSL distro.
245
+ const driveMatch = /^([A-Za-z]):[\\/](.*)$/.exec(bundlePath)
246
+ if (!driveMatch) return null
247
+ const wslBundlePath = `/mnt/${driveMatch[1].toLowerCase()}/${driveMatch[2].replace(/\\/g, '/')}`
248
+ const script = [
249
+ '#!/bin/sh',
250
+ '# Written by the Open Mercato starter: trust the corporate CA before dockerd starts.',
251
+ `cp "${wslBundlePath}" /usr/local/share/ca-certificates/open-mercato-corp-ca.crt && update-ca-certificates`,
252
+ '',
253
+ ].join('\n')
254
+ fs.mkdirSync(provisioningDir, { recursive: true })
255
+ const current = fs.existsSync(scriptPath) ? fs.readFileSync(scriptPath, 'utf8') : null
256
+ if (current !== script) fs.writeFileSync(scriptPath, script)
257
+ return scriptPath
258
+ }
259
+
260
+ export function summarizeProbeResults(results) {
261
+ return {
262
+ intercepted: results.filter((entry) => entry.status === 'intercepted'),
263
+ unreachable: results.filter((entry) => entry.status === 'unreachable'),
264
+ clean: results.filter((entry) => entry.status === 'ok'),
265
+ }
266
+ }