@baize-ai/core 0.3.14 → 0.3.15
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 +12 -0
- package/cli/commands/add.js +3 -0
- package/cli/commands/component.js +289 -40
- package/cli/commands/init.js +43 -0
- package/cli/lib/upgrade.js +55 -15
- package/docker/entrypoint.sh +0 -20
- package/docker-compose.yml +20 -2
- package/package.json +3 -3
- package/skills/web-console/public/app.js +24 -7
- package/skills/web-console/public/index.html +1 -1
- package/skills/web-console/scripts/a2a-admin.js +225 -26
- package/skills/web-console/scripts/server.js +16 -8
- package/templates/pm2/ecosystem.config.cjs +6 -0
- package/test/channel-admin.test.js +7 -5
- package/test/helpers/run-upgrade-file-driver.mjs +15 -0
- package/test/upgrade-file.test.js +229 -0
- package/test/upgrade-local-version.test.js +70 -0
- package/test/upgrade-restart-hook.test.js +129 -0
- package/test/web-console-routes.test.js +83 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ 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.15] - 2026-08-31
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- 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)删除
|
|
12
|
+
- core pm2 ecosystem 不再纳管 baize-a2a(D49):其生命周期唯一归属 daemon-ctl——消除三条启动路径并存导致的 8443 双实例竞态
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- `baize upgrade <name> --file <tgz>`(D49):商业组件离线升级——manifest/版本闸(新 > 旧)、备份回滚、既有 9 步管线;step8 重启钩子对 baize-a2a 优先走其 `cli.js restart`(覆盖 nohup daemon,pm2-only 路径的盲区修复)
|
|
16
|
+
- getLocalVersion 优先读组件 package.json(SKILL.md frontmatter 曾恒 0.1.0 → 升级判定失真)
|
|
17
|
+
- Web 控制台 A2A 上传:已安装 = 升级(版本闸 409 version_not_higher + 备份回滚 + 自动重启 daemon),安装/升级双语义
|
|
18
|
+
- docker-compose healthcheck:A2A enabled 时追加 TCP 8443 就绪探测(pm2 list 无法发现静默空转的 daemon)
|
|
19
|
+
|
|
8
20
|
## [0.3.14] - 2026-08-30
|
|
9
21
|
|
|
10
22
|
### Fixed
|
package/cli/commands/add.js
CHANGED
|
@@ -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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
*/
|
package/cli/commands/init.js
CHANGED
|
@@ -1145,6 +1145,47 @@ function startCoreServices(webPassword = null) {
|
|
|
1145
1145
|
}
|
|
1146
1146
|
}
|
|
1147
1147
|
|
|
1148
|
+
// ── A2A daemon bootstrap (D49) ───────────────────────────────────
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Fire-and-forget A2A daemon bootstrap. When the commercial baize-a2a
|
|
1152
|
+
* component is installed AND enabled, bring its daemon up through the
|
|
1153
|
+
* component's own CLI (`start --background`), which:
|
|
1154
|
+
* - prefers pm2 (autorestart + `pm2 save`/resurrect survive reboots),
|
|
1155
|
+
* - waits for TCP + card-identity readiness (30s),
|
|
1156
|
+
* - falls back to a nohup daemon when pm2 is unavailable,
|
|
1157
|
+
* - records the outcome in components/a2a/bootstrap-state.json.
|
|
1158
|
+
*
|
|
1159
|
+
* CRITICAL: this must NEVER block init. The child runs detached; init does
|
|
1160
|
+
* not wait and cannot fail because of it. Docker and bare metal take the
|
|
1161
|
+
* exact same path — the Docker-only entrypoint self-heal (D48 Step 3b) was
|
|
1162
|
+
* removed in favor of this unified bootstrap (user decision, 2026-08-31).
|
|
1163
|
+
*/
|
|
1164
|
+
function bootstrapA2aDaemon({ quiet = false } = {}) {
|
|
1165
|
+
try {
|
|
1166
|
+
const cli = path.join(SKILLS_DIR, 'a2a', 'scripts', 'cli.js');
|
|
1167
|
+
const a2aConfigPath = path.join(COMPONENTS_DIR, 'a2a', 'config.json');
|
|
1168
|
+
if (!fs.existsSync(cli) || !fs.existsSync(a2aConfigPath)) return;
|
|
1169
|
+
let enabled = false;
|
|
1170
|
+
try {
|
|
1171
|
+
enabled = JSON.parse(fs.readFileSync(a2aConfigPath, 'utf8')).enabled === true;
|
|
1172
|
+
} catch {
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
if (!enabled) return;
|
|
1176
|
+
fs.mkdirSync(path.join(COMPONENTS_DIR, 'a2a', 'logs'), { recursive: true });
|
|
1177
|
+
const child = spawn(process.execPath, [cli, 'start', '--background'], {
|
|
1178
|
+
detached: true,
|
|
1179
|
+
stdio: 'ignore',
|
|
1180
|
+
env: process.env,
|
|
1181
|
+
});
|
|
1182
|
+
child.unref();
|
|
1183
|
+
if (!quiet) console.log(` ${dim('A2A daemon bootstrap launched (background) — see components/a2a/bootstrap-state.json.')}`);
|
|
1184
|
+
} catch (err) {
|
|
1185
|
+
if (!quiet) console.log(` ${warn(`A2A bootstrap failed to launch: ${err.message}`)}`);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1148
1189
|
// ── PM2 boot auto-start ──────────────────────────────────────────
|
|
1149
1190
|
|
|
1150
1191
|
/**
|
|
@@ -2394,6 +2435,7 @@ export async function initCommand(args) {
|
|
|
2394
2435
|
}
|
|
2395
2436
|
if (!quiet) console.log(heading('Starting services...'));
|
|
2396
2437
|
const servicesStarted = startCoreServices(opts.webPassword);
|
|
2438
|
+
bootstrapA2aDaemon({ quiet });
|
|
2397
2439
|
if (servicesStarted > 0) {
|
|
2398
2440
|
setupPm2Startup();
|
|
2399
2441
|
if (!quiet) console.log(`\n${green(`${servicesStarted} service(s) started.`)} ${dim('Run "baize status" to check.')}`);
|
|
@@ -2516,6 +2558,7 @@ export async function initCommand(args) {
|
|
|
2516
2558
|
// Step 11: Start services
|
|
2517
2559
|
if (!quiet) console.log(`\n${heading('Starting services...')}`);
|
|
2518
2560
|
const servicesStarted = startCoreServices(opts.webPassword);
|
|
2561
|
+
bootstrapA2aDaemon({ quiet });
|
|
2519
2562
|
|
|
2520
2563
|
if (servicesStarted > 0) {
|
|
2521
2564
|
setupPm2Startup();
|
package/cli/lib/upgrade.js
CHANGED
|
@@ -21,31 +21,35 @@ import { copyTree, syncTree } from './fs-utils.js';
|
|
|
21
21
|
import { applyCaddyRoutes } from './caddy.js';
|
|
22
22
|
import { smartSync, formatMergeResult } from './smart-merge.js';
|
|
23
23
|
import { restartFromEcosystem, restartManagedProcess } from './pm2.js';
|
|
24
|
+
import { a2aCliPath } from './a2a.js';
|
|
24
25
|
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
26
27
|
// Version helpers
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
28
29
|
|
|
30
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
31
|
+
|
|
29
32
|
/**
|
|
30
|
-
* Read the local version
|
|
33
|
+
* Read the local version of an installed component (K6).
|
|
34
|
+
* Primary: skillDir/package.json `version` (valid semver only — SKILL.md
|
|
35
|
+
* frontmatter historically stayed at 0.1.0 while package.json advanced, which
|
|
36
|
+
* broke upgrade version gating). Fallback: SKILL.md frontmatter.
|
|
31
37
|
*/
|
|
32
|
-
function getLocalVersion(skillDir) {
|
|
33
|
-
// Primary: SKILL.md frontmatter
|
|
34
|
-
const parsed = parseSkillMd(skillDir);
|
|
35
|
-
if (parsed?.frontmatter?.version) {
|
|
36
|
-
return { success: true, version: String(parsed.frontmatter.version) };
|
|
37
|
-
}
|
|
38
|
-
// Fallback: package.json
|
|
38
|
+
export function getLocalVersion(skillDir) {
|
|
39
39
|
const pkgPath = path.join(skillDir, 'package.json');
|
|
40
40
|
try {
|
|
41
41
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
42
|
-
if (pkg.version) {
|
|
43
|
-
return { success: true, version:
|
|
42
|
+
if (typeof pkg.version === 'string' && SEMVER_RE.test(pkg.version.trim())) {
|
|
43
|
+
return { success: true, version: pkg.version.trim() };
|
|
44
44
|
}
|
|
45
45
|
} catch {
|
|
46
|
-
// package.json
|
|
46
|
+
// package.json missing or invalid — fall through to SKILL.md
|
|
47
|
+
}
|
|
48
|
+
const parsed = parseSkillMd(skillDir);
|
|
49
|
+
if (parsed?.frontmatter?.version) {
|
|
50
|
+
return { success: true, version: String(parsed.frontmatter.version) };
|
|
47
51
|
}
|
|
48
|
-
return { success: false, error: 'Version not found in
|
|
52
|
+
return { success: false, error: 'Version not found in package.json or SKILL.md' };
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
/**
|
|
@@ -645,8 +649,27 @@ function truncateHookOutput(value, maxLength = 1000) {
|
|
|
645
649
|
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
|
|
646
650
|
}
|
|
647
651
|
|
|
652
|
+
const A2A_CLI_RESTART_TIMEOUT_MS = 120000;
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Resolve the baize-a2a scripts/cli.js for the K5 restart hook. The component's
|
|
656
|
+
* own skill dir wins (that is where `baize add` / web install put it); the
|
|
657
|
+
* fallback reuses cli/lib/a2a.js resolution (env override included).
|
|
658
|
+
*/
|
|
659
|
+
function defaultResolveA2aCli(skillDir) {
|
|
660
|
+
const local = path.join(skillDir, 'scripts', 'cli.js');
|
|
661
|
+
if (fs.existsSync(local)) return local;
|
|
662
|
+
return a2aCliPath();
|
|
663
|
+
}
|
|
664
|
+
|
|
648
665
|
/**
|
|
649
|
-
* Step 8: restart
|
|
666
|
+
* Step 8: restart the component service (if it was running before upgrade).
|
|
667
|
+
*
|
|
668
|
+
* K5 restart hook: a nohup-started a2a daemon (Docker entrypoint) is invisible
|
|
669
|
+
* to pm2, so the pm2-only restart below never refreshed its code (C6). For
|
|
670
|
+
* service name 'baize-a2a' the a2a CLI `restart` (daemon-ctl: nohup pid +
|
|
671
|
+
* pm2 + waitReady) is preferred — it covers both lifecycles — falling back to
|
|
672
|
+
* the existing pm2 path when the CLI is unavailable or fails.
|
|
650
673
|
*/
|
|
651
674
|
export function step8_startService(ctx, deps = {}) {
|
|
652
675
|
const startTime = Date.now();
|
|
@@ -654,13 +677,30 @@ export function step8_startService(ctx, deps = {}) {
|
|
|
654
677
|
const exists = deps.existsSync ?? fs.existsSync;
|
|
655
678
|
const restartManaged = deps.restartManagedProcess ?? restartManagedProcess;
|
|
656
679
|
const restartViaEcosystem = deps.restartFromEcosystem ?? restartFromEcosystem;
|
|
680
|
+
const spawnSyncFn = deps.spawnSyncFn ?? spawnSync;
|
|
681
|
+
const resolveA2aCli = deps.resolveA2aCli ?? defaultResolveA2aCli;
|
|
682
|
+
|
|
683
|
+
const parsed = parseSkillMd(ctx.skillDir);
|
|
684
|
+
const serviceName = parsed?.frontmatter?.lifecycle?.service?.name || `baize-${ctx.component}`;
|
|
685
|
+
|
|
686
|
+
if (serviceName === 'baize-a2a') {
|
|
687
|
+
const cli = resolveA2aCli(ctx.skillDir);
|
|
688
|
+
if (cli) {
|
|
689
|
+
const res = spawnSyncFn(process.execPath, [cli, 'restart', '--json'], {
|
|
690
|
+
input: '',
|
|
691
|
+
encoding: 'utf8',
|
|
692
|
+
timeout: A2A_CLI_RESTART_TIMEOUT_MS,
|
|
693
|
+
});
|
|
694
|
+
if (!res.error && res.status === 0) {
|
|
695
|
+
return { step: 8, name: 'start_service', status: 'done', message: `${serviceName} (a2a cli restart)`, duration: Date.now() - startTime };
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
657
699
|
|
|
658
700
|
if (!ctx.serviceWasRunning) {
|
|
659
701
|
return { step: 8, name: 'start_service', status: 'skipped', message: 'was not running', duration: Date.now() - startTime };
|
|
660
702
|
}
|
|
661
703
|
|
|
662
|
-
const parsed = parseSkillMd(ctx.skillDir);
|
|
663
|
-
const serviceName = parsed?.frontmatter?.lifecycle?.service?.name || `baize-${ctx.component}`;
|
|
664
704
|
const ecosystemPath = path.join(ctx.skillDir, 'ecosystem.config.cjs');
|
|
665
705
|
|
|
666
706
|
try {
|
package/docker/entrypoint.sh
CHANGED
|
@@ -97,7 +97,6 @@ mkdir -p "${BAIZE_DIR}/.baize"
|
|
|
97
97
|
printf '{"status":"ok","at":"%s"}\n' "$(date -Iseconds)" > "${BAIZE_DIR}/.baize/init-state.json"
|
|
98
98
|
|
|
99
99
|
|
|
100
|
-
ok "Workspace ready"
|
|
101
100
|
|
|
102
101
|
# ── Pass through channel env vars to .env ─────────────────────────────────────
|
|
103
102
|
# baize init doesn't write channel tokens — those come from component installs.
|
|
@@ -153,25 +152,6 @@ PM2_PID=$!
|
|
|
153
152
|
sleep 3
|
|
154
153
|
ok "Services started"
|
|
155
154
|
|
|
156
|
-
# ── Step 3b: A2A self-heal (D48) ─────────────────────────────────────────────
|
|
157
|
-
# After a container restart, a previously-enabled A2A component must come back
|
|
158
|
-
# up by itself (zero manual ops). If the A2A config says enabled, start the
|
|
159
|
-
# baize-a2a daemon — it registers/renews its certificate with the admin and
|
|
160
|
-
# serves the advertise port. Idempotent: pm2 start restarts an existing entry.
|
|
161
|
-
if [ -f "${BAIZE_DIR}/components/a2a/config.json" ]; then
|
|
162
|
-
A2A_ENABLED=$(node -e "
|
|
163
|
-
try {
|
|
164
|
-
const c = JSON.parse(require('fs').readFileSync('${BAIZE_DIR}/components/a2a/config.json','utf8'));
|
|
165
|
-
process.stdout.write(c.enabled ? '1' : '0');
|
|
166
|
-
} catch { process.stdout.write('0'); }
|
|
167
|
-
" 2>/dev/null || echo "0")
|
|
168
|
-
if [ "$A2A_ENABLED" = "1" ]; then
|
|
169
|
-
ok "A2A enabled — starting baize-a2a daemon"
|
|
170
|
-
( cd "${BAIZE_DIR}/.claude/skills/a2a" && pm2 start ecosystem.config.cjs >/dev/null 2>&1 ) || \
|
|
171
|
-
warn "A2A daemon start failed — check 'docker exec <c> pm2 logs baize-a2a'"
|
|
172
|
-
fi
|
|
173
|
-
fi
|
|
174
|
-
|
|
175
155
|
# ── Step 4: Start the configured agent runtime in tmux ───────────────────────
|
|
176
156
|
|
|
177
157
|
# ── Step 4: Start the configured agent runtime in tmux ───────────────────────
|
package/docker-compose.yml
CHANGED
|
@@ -57,9 +57,27 @@ services:
|
|
|
57
57
|
|
|
58
58
|
# ── Health ────────────────────────────────────────────────────────────────
|
|
59
59
|
healthcheck:
|
|
60
|
-
test:
|
|
60
|
+
test:
|
|
61
|
+
- CMD
|
|
62
|
+
- node
|
|
63
|
+
- -e
|
|
64
|
+
- |
|
|
65
|
+
const fs = require('fs'), net = require('net');
|
|
66
|
+
// Core health = pm2 reports online. A2A (commercial component, D49):
|
|
67
|
+
// when installed and enabled, the container is only healthy if its
|
|
68
|
+
// daemon actually LISTENS (pm2 status alone masked the KI-015
|
|
69
|
+
// silent-idle shape); otherwise the check is skipped.
|
|
70
|
+
try { require('child_process').execSync('pm2 list', { stdio: 'ignore' }); } catch { process.exit(1); }
|
|
71
|
+
try {
|
|
72
|
+
const cfg = JSON.parse(fs.readFileSync('/home/baize/baize/components/a2a/config.json', 'utf8'));
|
|
73
|
+
if (cfg.enabled !== true) process.exit(0);
|
|
74
|
+
const s = net.connect({ host: '127.0.0.1', port: cfg.listenPort || 8443, timeout: 3000 });
|
|
75
|
+
s.on('connect', () => { s.destroy(); process.exit(0); });
|
|
76
|
+
s.on('error', () => process.exit(1));
|
|
77
|
+
s.on('timeout', () => { s.destroy(); process.exit(1); });
|
|
78
|
+
} catch { process.exit(0); }
|
|
61
79
|
interval: 30s
|
|
62
|
-
timeout:
|
|
80
|
+
timeout: 15s
|
|
63
81
|
retries: 3
|
|
64
82
|
start_period: 600s
|
|
65
83
|
|