@baize-ai/core 0.3.14 → 0.3.16

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/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ All notable changes to baize-core will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.16] - 2026-09-13
9
+
10
+ ### Added
11
+ - Codex 供应商配置改造(D51):独立 provider 块写入器 + kind/slug store + init/runtime 遗留路径转换 + web-console 只读激活卡;`/models` 探测读取 provider 块 base_url
12
+ - Dockerfile 本地源码构建支持(D51):`BAZE_CORE_TARBALL=/core.tgz` 安装工作区包而非 registry 版本——验证未发布修复(如 D51)进镜像
13
+
14
+ ### Fixed
15
+ - D51 部署缺陷(init):web-console `import ../../../cli/lib/runtime-setup.js` 运行时 `~/.claude/cli/lib/` 不存在 → ERR_MODULE_NOT_FOUND 崩溃——`syncCoreSkills` 补拷 `package/cli` 至 `SKILLS_DIR 上级/cli`,并从包根复制零依赖 `node_modules/smol-toml`
16
+ - D51 配置污染事故根因(4a37368):`globalCodexConfigPath` 优先读 `HOME` env 而非 `os.homedir()`——jest ESM sandbox 曾写穿真实 `~/.codex/config.toml`
17
+ - D51 review 修复:restore 只删 baize-written 块(`name=key && wire_api=responses`);写盘直设 `0600` + 去重用例;激活卡展示 kind
18
+
19
+ ### Changed
20
+ - A2A 授权治理收敛(D52 2b11171):agent 本地 authz 仅保留 mode 切换(open/allowlist)——allow/block 名单由 admin 控制台统一管控(PUT 忽略名单强制清空 + UI 隐藏名单编辑并提示管控来源;移除死代码 `validateAuthzList`/peerIds 请求)
21
+
22
+ ## [0.3.15] - 2026-08-31
23
+
24
+ ### Fixed
25
+ - A2A daemon 监控自愈统一收编 `baize init`(D49):init 幂等 bootstrap(enabled → detached `cli.js start --background` → daemon-ctl pm2+waitReady → pm2 save),结果落 `components/a2a/bootstrap-state.json` 并在 web 状态卡展示;Docker 与裸机同一路径,entrypoint 的 Docker 专属 Step 3b(D48)删除
26
+ - core pm2 ecosystem 不再纳管 baize-a2a(D49):其生命周期唯一归属 daemon-ctl——消除三条启动路径并存导致的 8443 双实例竞态
27
+
28
+ ### Added
29
+ - `baize upgrade <name> --file <tgz>`(D49):商业组件离线升级——manifest/版本闸(新 > 旧)、备份回滚、既有 9 步管线;step8 重启钩子对 baize-a2a 优先走其 `cli.js restart`(覆盖 nohup daemon,pm2-only 路径的盲区修复)
30
+ - getLocalVersion 优先读组件 package.json(SKILL.md frontmatter 曾恒 0.1.0 → 升级判定失真)
31
+ - Web 控制台 A2A 上传:已安装 = 升级(版本闸 409 version_not_higher + 备份回滚 + 自动重启 daemon),安装/升级双语义
32
+ - docker-compose healthcheck:A2A enabled 时追加 TCP 8443 就绪探测(pm2 list 无法发现静默空转的 daemon)
33
+
8
34
  ## [0.3.14] - 2026-08-30
9
35
 
10
36
  ### Fixed
package/Dockerfile CHANGED
@@ -82,8 +82,12 @@ ENV WEB_CONSOLE_BIND=0.0.0.0
82
82
  # build context) — source stays private, the image matches the npm version
83
83
  # exactly. Build with: docker build --build-arg BAZE_CORE_VERSION=0.2.0 .
84
84
  ARG BAZE_CORE_VERSION=latest
85
+ # Local-source build: pass BAZE_CORE_TARBALL=/core.tgz (COPY'd into the
86
+ # build context) to install the workspace package instead of the registry
87
+ # version — lets us verify un-released fixes (e.g. D51) in the image.
88
+ ARG BAZE_CORE_TARBALL=
85
89
  WORKDIR /home/baize
86
- RUN npm install -g @baize-ai/core@${BAZE_CORE_VERSION} \
90
+ RUN if [ -n "${BAZE_CORE_TARBALL}" ]; then npm install -g /tmp/${BAZE_CORE_TARBALL}; else npm install -g @baize-ai/core@${BAZE_CORE_VERSION}; fi \
87
91
  && baize --version \
88
92
  # ── Build-time skill dependency preinstall (reproducibility) ─────────
89
93
  # `baize init` reinstalls skill deps at runtime; doing it here bakes the
@@ -134,6 +138,11 @@ RUN mkdir -p \
134
138
  # ── Copy PM2 ecosystem config ─────────────────────────────────────────────────
135
139
  COPY --chown=baize:baize templates/pm2/ecosystem.config.cjs /home/baize/baize/pm2/ecosystem.config.cjs
136
140
 
141
+ # ── Local-source build context (optional) ────────────────────────────────────
142
+ # When BAZE_CORE_TARBALL is set, the tarball must live at ./core.tgz in the
143
+ # build context; it is staged at /tmp so the install RUN above can consume it.
144
+ COPY core.tgz /tmp/core.tgz
145
+
137
146
  # ── Copy entrypoint ───────────────────────────────────────────────────────────
138
147
  COPY --chown=baize:baize docker/entrypoint.sh /entrypoint.sh
139
148
  RUN chmod +x /entrypoint.sh
@@ -714,6 +714,9 @@ async function installDeclarative(resolved, skillDir, skipConfirm, jsonOutput, b
714
714
  console.log(` ${success(`${bold(service.name || ('baize-' + resolved.name))} started`)}`);
715
715
  } else {
716
716
  console.log(` ${error(`Failed to start service: ${svcResult.error}`)}`);
717
+ if (name === 'a2a') {
718
+ console.log(` ${dim('Run `baize a2a start` (or \`baize init\`) to bring the A2A daemon up.')}`);
719
+ }
717
720
  console.log(` ${dim('You can start it manually later.')}`);
718
721
  }
719
722
  }
@@ -3,14 +3,15 @@
3
3
  */
4
4
 
5
5
  import fs from 'node:fs';
6
+ import os from 'node:os';
6
7
  import path from 'node:path';
7
8
  import { execFileSync } from 'node:child_process';
8
9
  import { BAIZE_DIR, SKILLS_DIR, COMPONENTS_DIR, getBaizeConfig } from '../lib/config.js';
9
10
  import { bold, dim, green, red, yellow, cyan, success, error, warn, heading } from '../lib/colors.js';
10
11
  import { loadRegistry } from '../lib/registry.js';
11
- import { searchNpmPackages, npmRegistryUrl } from '../lib/download.js';
12
+ import { searchNpmPackages, npmRegistryUrl, acquireSource } from '../lib/download.js';
12
13
  import { loadComponents, saveComponents } from '../lib/components.js';
13
- import { checkForUpdates, getLocalSourceUpgradeError, getRepo, runUpgrade, downloadToTemp, readChangelog, filterChangelog, cleanupTemp } from '../lib/upgrade.js';
14
+ import { checkForUpdates, getLocalVersion, getLocalSourceUpgradeError, getRepo, runUpgrade, downloadToTemp, readChangelog, filterChangelog, cleanupTemp } from '../lib/upgrade.js';
14
15
  import {
15
16
  checkForCoreUpdates, runSelfUpgrade,
16
17
  downloadCoreToTemp, readChangelog as readCoreChangelog,
@@ -21,7 +22,7 @@ import { parseSkillMd } from '../lib/skill.js';
21
22
  import { linkBins, unlinkBins } from '../lib/bin.js';
22
23
  import { removeCaddyRoutes } from '../lib/caddy.js';
23
24
  import { acquireLock, releaseLock } from '../lib/lock.js';
24
- import { fetchRawFile } from '../lib/github.js';
25
+ import { fetchRawFile, compareSemverDesc } from '../lib/github.js';
25
26
  import { promptYesNo } from '../lib/prompts.js';
26
27
  import { evaluateUpgrade } from '../lib/claude-eval.js';
27
28
 
@@ -251,6 +252,8 @@ export async function upgradeComponent(args) {
251
252
  const skipEval = args.includes('--skip-eval');
252
253
  const beta = args.includes('--beta');
253
254
  const hasTempDirFlag = args.includes('--temp-dir');
255
+ const fileIndex = args.indexOf('--file');
256
+ const upgradeFile = fileIndex !== -1 ? args[fileIndex + 1] : null;
254
257
 
255
258
  if (hasTempDirFlag) {
256
259
  const msg = '--temp-dir is not supported.';
@@ -272,6 +275,17 @@ export async function upgradeComponent(args) {
272
275
  process.exit(1);
273
276
  }
274
277
 
278
+ if (fileIndex !== -1 && (!upgradeFile || upgradeFile.startsWith('-'))) {
279
+ console.error('Error: --file requires a path to a .tgz archive.');
280
+ process.exit(1);
281
+ }
282
+
283
+ // --file is a fully offline source: remote/branch resolution flags make no sense.
284
+ if (upgradeFile && (branch || beta)) {
285
+ console.error('Error: --file cannot be combined with --branch or --beta.');
286
+ process.exit(1);
287
+ }
288
+
275
289
  // --beta and --branch are mutually exclusive
276
290
  if (beta && branch) {
277
291
  console.error('Error: --beta and --branch are mutually exclusive.');
@@ -291,7 +305,7 @@ export async function upgradeComponent(args) {
291
305
  }
292
306
 
293
307
  // Get target component (filter out flags and flag values)
294
- const flagsWithValues = new Set(['--branch', '--mode']);
308
+ const flagsWithValues = new Set(['--branch', '--mode', '--file']);
295
309
  const target = args.find((a, i) => {
296
310
  if (a.startsWith('-')) return false;
297
311
  if (a === 'confirm') return false;
@@ -300,6 +314,12 @@ export async function upgradeComponent(args) {
300
314
  return true;
301
315
  });
302
316
 
317
+ // --file upgrades one installed component from a local tarball.
318
+ if (upgradeFile && (upgradeAll || upgradeSelf)) {
319
+ console.error('Error: --file upgrades a single installed component; it cannot be combined with --all or --self.');
320
+ process.exit(1);
321
+ }
322
+
303
323
  // Handle --self: upgrade baize-core itself
304
324
  if (upgradeSelf) {
305
325
  if (checkOnly) {
@@ -326,6 +346,7 @@ export async function upgradeComponent(args) {
326
346
  console.log(' --yes, -y Skip confirmation');
327
347
  console.log(' --skip-eval Skip upgrade analysis of local changes');
328
348
  console.log(' --beta Include prerelease (beta) versions');
349
+ console.log(' --file <tgz> Upgrade from a local .tar.gz archive (offline delivery)');
329
350
  console.log(' --branch <b> Upgrade from a specific branch (e.g. feat/xxx)');
330
351
  console.log(' --mode <m> Merge mode: "merge" (default, smart three-way) or "overwrite"');
331
352
  console.log('\nExamples:');
@@ -356,20 +377,25 @@ export async function upgradeComponent(args) {
356
377
  process.exit(1);
357
378
  }
358
379
 
359
- const localSourceError = getLocalSourceUpgradeError(target, components[target]);
360
- if (localSourceError) {
361
- const result = {
362
- action: checkOnly ? 'check' : 'upgrade',
363
- component: target,
364
- ...localSourceError,
365
- reply: localSourceError.message,
366
- };
367
- if (jsonOutput) {
368
- console.log(JSON.stringify(result, null, 2));
369
- } else {
370
- console.error(`Error: ${localSourceError.message}`);
380
+ if (upgradeFile) {
381
+ // The tarball IS the local source for this upgrade — a local-source
382
+ // registration does not block it.
383
+ } else {
384
+ const localSourceError = getLocalSourceUpgradeError(target, components[target]);
385
+ if (localSourceError) {
386
+ const result = {
387
+ action: checkOnly ? 'check' : 'upgrade',
388
+ component: target,
389
+ ...localSourceError,
390
+ reply: localSourceError.message,
391
+ };
392
+ if (jsonOutput) {
393
+ console.log(JSON.stringify(result, null, 2));
394
+ } else {
395
+ console.error(`Error: ${localSourceError.message}`);
396
+ }
397
+ process.exit(1);
371
398
  }
372
- process.exit(1);
373
399
  }
374
400
 
375
401
  if (!fs.existsSync(skillDir)) {
@@ -388,6 +414,17 @@ export async function upgradeComponent(args) {
388
414
  process.exit(1);
389
415
  }
390
416
 
417
+ // Mode 0: offline upgrade from a local tgz (K4 commercial delivery —
418
+ // baize-a2a never ships to npm; --file and web upload are the only channels).
419
+ if (upgradeFile) {
420
+ if (checkOnly) {
421
+ return handleFileCheckOnly(target, upgradeFile, { jsonOutput });
422
+ }
423
+ const ok = await handleFileUpgradeFlow(target, upgradeFile, { jsonOutput, skipConfirm: skipConfirm || explicitConfirm, mode });
424
+ if (!ok) process.exit(1);
425
+ return;
426
+ }
427
+
391
428
  // Mode 1: Check only (--check) — no lock, downloads to temp for file comparison
392
429
  if (checkOnly) {
393
430
  return handleCheckOnly(target, { jsonOutput, branch, beta });
@@ -717,29 +754,7 @@ async function handleUpgradeFlow(component, { jsonOutput, skipConfirm, skipEval,
717
754
  });
718
755
 
719
756
  if (result.success) {
720
- // Phase C: Cleanup
721
- // Update components.json
722
- const components = loadComponents();
723
- if (components[component]) {
724
- components[component].version = result.to || components[component].version;
725
- components[component].upgradedAt = new Date().toISOString();
726
-
727
- // Update bin symlinks (remove old, create new)
728
- const oldBin = components[component].bin;
729
- if (oldBin) unlinkBins(oldBin);
730
- const updatedSkill = parseSkillMd(skillDir);
731
- const newBin = linkBins(skillDir, updatedSkill?.frontmatter?.bin);
732
- if (newBin) {
733
- components[component].bin = newBin;
734
- } else {
735
- delete components[component].bin;
736
- }
737
-
738
- saveComponents(components);
739
- }
740
-
741
- // Clean old backups (keep only the latest)
742
- cleanOldBackups(skillDir);
757
+ applyUpgradeBookkeeping(component, skillDir, result);
743
758
  }
744
759
 
745
760
  // Output result
@@ -777,6 +792,240 @@ async function handleUpgradeFlow(component, { jsonOutput, skipConfirm, skipEval,
777
792
  }
778
793
  }
779
794
 
795
+ /**
796
+ * Post-success bookkeeping shared by every upgrade path: bump components.json,
797
+ * refresh bin symlinks, prune old backups.
798
+ */
799
+ function applyUpgradeBookkeeping(component, skillDir, result) {
800
+ const components = loadComponents();
801
+ if (components[component]) {
802
+ components[component].version = result.to || components[component].version;
803
+ components[component].upgradedAt = new Date().toISOString();
804
+
805
+ // Update bin symlinks (remove old, create new)
806
+ const oldBin = components[component].bin;
807
+ if (oldBin) unlinkBins(oldBin);
808
+ const updatedSkill = parseSkillMd(skillDir);
809
+ const newBin = linkBins(skillDir, updatedSkill?.frontmatter?.bin);
810
+ if (newBin) {
811
+ components[component].bin = newBin;
812
+ } else {
813
+ delete components[component].bin;
814
+ }
815
+
816
+ saveComponents(components);
817
+ }
818
+
819
+ // Clean old backups (keep only the latest)
820
+ cleanOldBackups(skillDir);
821
+ }
822
+
823
+ /**
824
+ * Resolve a --file path (~ expansion + absolutize).
825
+ */
826
+ function resolveTgzPath(filePath) {
827
+ const expanded = filePath === '~'
828
+ ? os.homedir()
829
+ : filePath.startsWith('~/')
830
+ ? path.join(os.homedir(), filePath.slice(2))
831
+ : filePath;
832
+ return path.resolve(expanded);
833
+ }
834
+
835
+ /**
836
+ * Extract a local .tgz into a fresh temp dir and read its package manifest.
837
+ * Safety vetting (path traversal / links) is delegated to the local-tarball
838
+ * source resolver in cli/lib/download.js — the same gate `baize add` uses.
839
+ *
840
+ * @returns {{ ok: true, tempDir: string, version: string, name: string|null }
841
+ * | { ok: false, error: string }}
842
+ */
843
+ function extractTarballArtifact(tgzPath) {
844
+ if (!/\.(?:tar\.gz|tgz)$/i.test(tgzPath)) {
845
+ return { ok: false, error: `--file must be a .tgz archive: ${tgzPath}` };
846
+ }
847
+ if (!fs.existsSync(tgzPath)) {
848
+ return { ok: false, error: `File not found: ${tgzPath}` };
849
+ }
850
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'baize-upgrade-file-'));
851
+ const extract = acquireSource({ type: 'local-tarball', path: tgzPath }, tempDir);
852
+ if (!extract.success) {
853
+ cleanupTemp(tempDir);
854
+ return { ok: false, error: extract.error || 'Failed to extract tarball' };
855
+ }
856
+ let version = null;
857
+ let name = null;
858
+ try {
859
+ const pkg = JSON.parse(fs.readFileSync(path.join(tempDir, 'package.json'), 'utf8'));
860
+ if (typeof pkg.version === 'string' && pkg.version.trim()) version = pkg.version.trim();
861
+ if (typeof pkg.name === 'string' && pkg.name.trim()) name = pkg.name.trim();
862
+ } catch {
863
+ // missing/invalid package.json → version stays null, rejected by caller
864
+ }
865
+ if (!version) {
866
+ cleanupTemp(tempDir);
867
+ return { ok: false, error: 'Tarball has no package.json version' };
868
+ }
869
+ return { ok: true, tempDir, version, name };
870
+ }
871
+
872
+ /**
873
+ * K4: offline upgrade from a local .tgz (commercial delivery channel —
874
+ * baize-a2a never ships to npm, so --file and web upload are the only
875
+ * upgrade paths). Skips checkForUpdates/downloadToTemp: the tarball IS the
876
+ * source. Version gate: incoming semver must be strictly higher than the
877
+ * locally installed one (getLocalVersion reads package.json first — K6).
878
+ * Everything downstream reuses the existing 9-step runUpgrade pipeline
879
+ * (backup → smart merge → npm install → baseline → restart), including
880
+ * failure rollback.
881
+ *
882
+ * Returns true on success, false on failure. No process.exit() here.
883
+ */
884
+ async function handleFileUpgradeFlow(component, filePath, { jsonOutput, skipConfirm, mode = 'merge' }) {
885
+ const skillDir = path.join(SKILLS_DIR, component);
886
+ let tempDir = null;
887
+
888
+ const failWith = (payload) => {
889
+ if (jsonOutput) {
890
+ const errOutput = { action: 'upgrade', component, success: false, ...payload };
891
+ errOutput.reply = formatC4Reply('error', { message: payload.message || payload.error });
892
+ console.log(JSON.stringify(errOutput, null, 2));
893
+ } else {
894
+ console.error(`Error: ${payload.message || payload.error}`);
895
+ }
896
+ return false;
897
+ };
898
+
899
+ const artifact = extractTarballArtifact(resolveTgzPath(filePath));
900
+ if (!artifact.ok) {
901
+ return failWith({ error: artifact.error });
902
+ }
903
+ tempDir = artifact.tempDir;
904
+
905
+ const lockResult = acquireLock(component);
906
+ if (!lockResult.success) {
907
+ return failWith({ error: lockResult.error });
908
+ }
909
+
910
+ try {
911
+ // Manifest sanity: the artifact must be the same package as installed.
912
+ let installedName = null;
913
+ try {
914
+ const localPkg = JSON.parse(fs.readFileSync(path.join(skillDir, 'package.json'), 'utf8'));
915
+ if (typeof localPkg.name === 'string') installedName = localPkg.name;
916
+ } catch {
917
+ // skill package.json is optional for the name check
918
+ }
919
+ if (installedName && artifact.name && artifact.name !== installedName) {
920
+ return failWith({ error: `manifest name mismatch: installed ${installedName}, tarball ${artifact.name}` });
921
+ }
922
+
923
+ const local = getLocalVersion(skillDir);
924
+ if (!local.success) {
925
+ return failWith({ error: `Cannot read current version: ${local.error}` });
926
+ }
927
+
928
+ if (compareSemverDesc(local.version, artifact.version) <= 0) {
929
+ return failWith({
930
+ error: 'version_not_higher',
931
+ message: `Incoming version ${artifact.version} is not higher than installed ${local.version} — nothing to upgrade.`,
932
+ local: local.version,
933
+ incoming: artifact.version,
934
+ });
935
+ }
936
+
937
+ if (!jsonOutput) {
938
+ console.log(`\n${bold(component)}: ${dim(local.version)} → ${bold(artifact.version)}`);
939
+ console.log(dim(`Source: local tarball (${filePath})`));
940
+ }
941
+
942
+ // Confirmation (no download/eval steps — the artifact is already local)
943
+ if (!skipConfirm) {
944
+ const confirmed = await promptYesNo('Proceed with upgrade? [y/N]: ');
945
+ if (!confirmed) {
946
+ console.log('Upgrade cancelled.');
947
+ return true; // Not an error — user chose to cancel
948
+ }
949
+ } else if (!jsonOutput) {
950
+ console.log(`Upgrading ${bold(component)}...`);
951
+ }
952
+
953
+ const result = runUpgrade(component, {
954
+ tempDir,
955
+ newVersion: artifact.version,
956
+ mode,
957
+ jsonOutput,
958
+ onStep: !jsonOutput ? printStep : undefined,
959
+ });
960
+
961
+ if (result.success) {
962
+ applyUpgradeBookkeeping(component, skillDir, result);
963
+ }
964
+
965
+ if (jsonOutput) {
966
+ const output = { ...result, source: { type: 'local-tarball', path: resolveTgzPath(filePath) } };
967
+ output.reply = formatC4Reply('upgrade', { component, ...result });
968
+ console.log(JSON.stringify(output, null, 2));
969
+ } else if (result.success) {
970
+ console.log(`\n${success(`${bold(component)} upgraded: ${dim(result.from)} → ${bold(result.to)}`)}`);
971
+ } else {
972
+ console.log(`\n${error(`Upgrade failed (step ${result.failedStep}): ${result.error}`)}`);
973
+ if (result.rollback?.performed) {
974
+ console.log(`\n${bold('Auto-rollback performed:')}`);
975
+ for (const r of result.rollback.steps) {
976
+ if (r.success) console.log(` ${success(r.action)}`);
977
+ else console.log(` ${error(r.action)}`);
978
+ }
979
+ }
980
+ }
981
+
982
+ return result.success;
983
+ } finally {
984
+ cleanupTemp(tempDir);
985
+ releaseLock(component);
986
+ }
987
+ }
988
+
989
+ /**
990
+ * K4 --check variant: compare a local tgz against the installed version
991
+ * without touching the installation (no lock needed).
992
+ */
993
+ function handleFileCheckOnly(component, filePath, { jsonOutput }) {
994
+ const skillDir = path.join(SKILLS_DIR, component);
995
+ const artifact = extractTarballArtifact(resolveTgzPath(filePath));
996
+ if (!artifact.ok) {
997
+ if (jsonOutput) {
998
+ console.log(JSON.stringify({ action: 'check', component, success: false, error: artifact.error }, null, 2));
999
+ } else {
1000
+ console.error(`Error: ${artifact.error}`);
1001
+ }
1002
+ process.exit(1);
1003
+ }
1004
+
1005
+ const local = getLocalVersion(skillDir);
1006
+ const hasUpdate = local.success && compareSemverDesc(local.version, artifact.version) > 0;
1007
+ const result = {
1008
+ action: 'check',
1009
+ component,
1010
+ success: local.success,
1011
+ hasUpdate,
1012
+ current: local.success ? local.version : null,
1013
+ latest: artifact.version,
1014
+ source: { type: 'local-tarball', path: resolveTgzPath(filePath) },
1015
+ ...(local.success ? {} : { error: local.error }),
1016
+ };
1017
+ if (jsonOutput) {
1018
+ console.log(JSON.stringify(result, null, 2));
1019
+ } else if (hasUpdate) {
1020
+ console.log(`${bold(component)}: ${dim(result.current)} → ${bold(result.latest)} (local tarball)`);
1021
+ } else if (local.success) {
1022
+ console.log(success(`${bold(component)} is up to date (v${result.current}); tarball v${artifact.version} is not higher.`));
1023
+ } else {
1024
+ console.error(`Error: ${local.error}`);
1025
+ process.exit(1);
1026
+ }
1027
+ }
1028
+
780
1029
  /**
781
1030
  * Clean old .backup/ directories, keeping only the latest.
782
1031
  */
@@ -41,6 +41,7 @@ import {
41
41
  saveCodexApiKeyToEnv,
42
42
  saveCodexBaseUrlToEnv,
43
43
  writeCodexConfig,
44
+ applyCodexProviderBlock,
44
45
  } from '../lib/runtime-setup.js';
45
46
 
46
47
  // Source directories (shipped with baize package)
@@ -839,6 +840,30 @@ function syncCoreSkills() {
839
840
  const installed = [];
840
841
  const updated = [];
841
842
 
843
+ // D51: web-console imports `../../../cli/lib/runtime-setup.js`, which from
844
+ // `~/.claude/skills/web-console/scripts/` resolves to `~/.claude/cli/lib/`.
845
+ // The npm package ships `package/cli`, but init never deployed it to the
846
+ // runtime tree — web-console crashed with ERR_MODULE_NOT_FOUND in the image.
847
+ // Deploy the shared CLI lib beside SKILLS_DIR so the import path mirrors the
848
+ // package layout (package/cli ↔ <SKILLS_DIR>/../cli).
849
+ const cliSrc = path.join(PACKAGE_ROOT, 'cli');
850
+ const cliDest = path.join(path.dirname(SKILLS_DIR), 'cli');
851
+ if (fs.existsSync(cliSrc)) {
852
+ try {
853
+ copyTree(cliSrc, cliDest);
854
+ // runtime-setup.js imports smol-toml (D51). The cli/ tree is deployed
855
+ // outside any skill's node_modules, so the dependency must ride along —
856
+ // mirror the package layout by copying the package's smol-toml into
857
+ // <cliDest>/node_modules/.
858
+ const tomlSrc = path.join(PACKAGE_ROOT, 'node_modules', 'smol-toml');
859
+ if (fs.existsSync(tomlSrc)) {
860
+ copyTree(tomlSrc, path.join(cliDest, 'node_modules', 'smol-toml'));
861
+ }
862
+ } catch {
863
+ console.log(` ${warn(`Failed to sync cli/ to ${cliDest}`)}`);
864
+ }
865
+ }
866
+
842
867
  const entries = fs.readdirSync(CORE_SKILLS_SRC, { withFileTypes: true });
843
868
  for (const entry of entries) {
844
869
  if (!entry.isDirectory()) continue;
@@ -1145,6 +1170,47 @@ function startCoreServices(webPassword = null) {
1145
1170
  }
1146
1171
  }
1147
1172
 
1173
+ // ── A2A daemon bootstrap (D49) ───────────────────────────────────
1174
+
1175
+ /**
1176
+ * Fire-and-forget A2A daemon bootstrap. When the commercial baize-a2a
1177
+ * component is installed AND enabled, bring its daemon up through the
1178
+ * component's own CLI (`start --background`), which:
1179
+ * - prefers pm2 (autorestart + `pm2 save`/resurrect survive reboots),
1180
+ * - waits for TCP + card-identity readiness (30s),
1181
+ * - falls back to a nohup daemon when pm2 is unavailable,
1182
+ * - records the outcome in components/a2a/bootstrap-state.json.
1183
+ *
1184
+ * CRITICAL: this must NEVER block init. The child runs detached; init does
1185
+ * not wait and cannot fail because of it. Docker and bare metal take the
1186
+ * exact same path — the Docker-only entrypoint self-heal (D48 Step 3b) was
1187
+ * removed in favor of this unified bootstrap (user decision, 2026-08-31).
1188
+ */
1189
+ function bootstrapA2aDaemon({ quiet = false } = {}) {
1190
+ try {
1191
+ const cli = path.join(SKILLS_DIR, 'a2a', 'scripts', 'cli.js');
1192
+ const a2aConfigPath = path.join(COMPONENTS_DIR, 'a2a', 'config.json');
1193
+ if (!fs.existsSync(cli) || !fs.existsSync(a2aConfigPath)) return;
1194
+ let enabled = false;
1195
+ try {
1196
+ enabled = JSON.parse(fs.readFileSync(a2aConfigPath, 'utf8')).enabled === true;
1197
+ } catch {
1198
+ return;
1199
+ }
1200
+ if (!enabled) return;
1201
+ fs.mkdirSync(path.join(COMPONENTS_DIR, 'a2a', 'logs'), { recursive: true });
1202
+ const child = spawn(process.execPath, [cli, 'start', '--background'], {
1203
+ detached: true,
1204
+ stdio: 'ignore',
1205
+ env: process.env,
1206
+ });
1207
+ child.unref();
1208
+ if (!quiet) console.log(` ${dim('A2A daemon bootstrap launched (background) — see components/a2a/bootstrap-state.json.')}`);
1209
+ } catch (err) {
1210
+ if (!quiet) console.log(` ${warn(`A2A bootstrap failed to launch: ${err.message}`)}`);
1211
+ }
1212
+ }
1213
+
1148
1214
  // ── PM2 boot auto-start ──────────────────────────────────────────
1149
1215
 
1150
1216
  /**
@@ -2141,9 +2207,17 @@ export async function initCommand(args) {
2141
2207
  }
2142
2208
  }
2143
2209
  }
2144
-
2145
- // Write ~/.codex/config.toml to suppress interactive prompts on first launch
2146
- if (writeCodexConfig(BAIZE_DIR, { openaiBaseUrl: pendingCodexBaseUrl || undefined })) {
2210
+ // Write ~/.codex/config.toml to suppress interactive prompts on first
2211
+ // launch. A custom --codex-base-url becomes a dedicated provider block
2212
+ // (D51) never the built-in-OpenAI openai_base_url override.
2213
+ if (pendingCodexBaseUrl) {
2214
+ applyCodexProviderBlock({
2215
+ providerKey: 'baize-init',
2216
+ baseUrl: pendingCodexBaseUrl,
2217
+ token: opts.codexApiKey || undefined,
2218
+ });
2219
+ if (!quiet) console.log(` ${success('Codex startup config written (provider block)')}`);
2220
+ } else if (writeCodexConfig(BAIZE_DIR)) {
2147
2221
  if (!quiet) console.log(` ${success('Codex startup config written')}`);
2148
2222
  }
2149
2223
  }
@@ -2368,7 +2442,12 @@ export async function initCommand(args) {
2368
2442
  }
2369
2443
  if (pendingCodexBaseUrl) {
2370
2444
  saveCodexBaseUrlToEnv(pendingCodexBaseUrl);
2371
- writeCodexConfig(BAIZE_DIR, { openaiBaseUrl: pendingCodexBaseUrl });
2445
+ // D51: custom endpoint → dedicated provider block, not the built-in-OpenAI override.
2446
+ applyCodexProviderBlock({
2447
+ providerKey: 'baize-init',
2448
+ baseUrl: pendingCodexBaseUrl,
2449
+ token: opts.codexApiKey || undefined,
2450
+ });
2372
2451
  }
2373
2452
 
2374
2453
  // Timezone: use resolved value or show current
@@ -2394,6 +2473,7 @@ export async function initCommand(args) {
2394
2473
  }
2395
2474
  if (!quiet) console.log(heading('Starting services...'));
2396
2475
  const servicesStarted = startCoreServices(opts.webPassword);
2476
+ bootstrapA2aDaemon({ quiet });
2397
2477
  if (servicesStarted > 0) {
2398
2478
  setupPm2Startup();
2399
2479
  if (!quiet) console.log(`\n${green(`${servicesStarted} service(s) started.`)} ${dim('Run "baize status" to check.')}`);
@@ -2476,7 +2556,12 @@ export async function initCommand(args) {
2476
2556
  }
2477
2557
  if (pendingCodexBaseUrl) {
2478
2558
  saveCodexBaseUrlToEnv(pendingCodexBaseUrl);
2479
- writeCodexConfig(BAIZE_DIR, { openaiBaseUrl: pendingCodexBaseUrl });
2559
+ // D51: custom endpoint → dedicated provider block, not the built-in-OpenAI override.
2560
+ applyCodexProviderBlock({
2561
+ providerKey: 'baize-init',
2562
+ baseUrl: pendingCodexBaseUrl,
2563
+ token: opts.codexApiKey || undefined,
2564
+ });
2480
2565
  }
2481
2566
  // Step 8: Configure timezone
2482
2567
  if (!quiet) console.log(`\n${heading('Timezone configuration...')}`);
@@ -2516,6 +2601,7 @@ export async function initCommand(args) {
2516
2601
  // Step 11: Start services
2517
2602
  if (!quiet) console.log(`\n${heading('Starting services...')}`);
2518
2603
  const servicesStarted = startCoreServices(opts.webPassword);
2604
+ bootstrapA2aDaemon({ quiet });
2519
2605
 
2520
2606
  if (servicesStarted > 0) {
2521
2607
  setupPm2Startup();
@@ -29,6 +29,7 @@ import {
29
29
  saveSetupTokenToEnv,
30
30
  saveCodexApiKey,
31
31
  saveCodexBaseUrlToEnv,
32
+ applyCodexProviderBlock,
32
33
  saveCodexApiKeyToEnv,
33
34
  writeCodexConfig,
34
35
  } from '../lib/runtime-setup.js';
@@ -123,7 +124,14 @@ export function applyBaseUrl(target, baseUrl) {
123
124
  }
124
125
  if (target === 'codex') {
125
126
  if (!saveCodexBaseUrlToEnv(baseUrl)) return false;
126
- return writeCodexConfig(BAIZE_DIR, { openaiBaseUrl: baseUrl });
127
+ // D51: external endpoints get a dedicated provider block (DeepSeek-safe,
128
+ // local compaction) — never the built-in-openai openai_base_url override.
129
+ try {
130
+ applyCodexProviderBlock({ providerKey: 'baize-runtime', baseUrl, projectDir: BAIZE_DIR });
131
+ return true;
132
+ } catch {
133
+ return false;
134
+ }
127
135
  }
128
136
  return false;
129
137
  }