@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
|
@@ -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
|
+
}
|