@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 +26 -0
- package/Dockerfile +10 -1
- package/cli/commands/add.js +3 -0
- package/cli/commands/component.js +289 -40
- package/cli/commands/init.js +91 -5
- package/cli/commands/runtime.js +9 -1
- package/cli/lib/__tests__/init-base-url.test.js +55 -13
- package/cli/lib/__tests__/runtime-base-url.test.js +3 -1
- package/cli/lib/__tests__/runtime-setup.test.js +53 -6
- package/cli/lib/runtime/codex.js +17 -0
- package/cli/lib/runtime-setup.js +153 -12
- package/cli/lib/upgrade.js +55 -15
- package/docker/entrypoint.sh +0 -20
- package/docker-compose.yml +20 -2
- package/package.json +2 -2
- package/scripts/docker-publish.sh +0 -4
- package/scripts/pack-release.sh +34 -26
- package/skills/web-console/public/app.js +44 -82
- package/skills/web-console/public/index.html +1 -1
- package/skills/web-console/scripts/a2a-admin.js +225 -26
- package/skills/web-console/scripts/model-provider.js +97 -59
- package/skills/web-console/scripts/server.js +22 -32
- 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/model-provider.test.js +150 -40
- 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 +94 -17
|
@@ -41,6 +41,10 @@ function readToml() {
|
|
|
41
41
|
const p = path.join(homeDir, '.codex', 'config.toml');
|
|
42
42
|
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
|
|
43
43
|
}
|
|
44
|
+
function readProjectToml() {
|
|
45
|
+
const p = path.join(baizeDir, '.codex', 'config.toml');
|
|
46
|
+
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
|
|
47
|
+
}
|
|
44
48
|
function readStore() {
|
|
45
49
|
const p = path.join(baizeDir, '.baize', 'providers.json');
|
|
46
50
|
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : {};
|
|
@@ -52,10 +56,11 @@ describe('provider store', () => {
|
|
|
52
56
|
expect(mp.getProviders()).toEqual({ active: 'official', providers: [] });
|
|
53
57
|
});
|
|
54
58
|
|
|
55
|
-
test('saveProvider validates name and
|
|
59
|
+
test('saveProvider validates name, url, and kind', async () => {
|
|
56
60
|
expect((await mp.saveProvider({})).success).toBe(false);
|
|
57
61
|
expect((await mp.saveProvider({ name: 'x', cc: { baseUrl: 'ftp://bad' } })).success).toBe(false);
|
|
58
62
|
expect((await mp.saveProvider({ name: 'x', codex: { baseUrl: 'nope' } })).success).toBe(false);
|
|
63
|
+
expect((await mp.saveProvider({ name: 'x', codex: { kind: 'bogus-kind' } })).success).toBe(false);
|
|
59
64
|
});
|
|
60
65
|
|
|
61
66
|
test('saveProvider persists sanitized entry (no secrets echoed)', async () => {
|
|
@@ -70,6 +75,64 @@ describe('provider store', () => {
|
|
|
70
75
|
expect(stored.providers[0].cc.authToken).toBe('sk-ds-token'); // persisted, just not echoed
|
|
71
76
|
});
|
|
72
77
|
|
|
78
|
+
test('saveProvider assigns codex kind + providerKey (D51)', async () => {
|
|
79
|
+
const r = await mp.saveProvider(DEEPSEEK);
|
|
80
|
+
expect(r.provider.codex.kind).toBe('responses-compatible');
|
|
81
|
+
expect(r.provider.codex.providerKey).toBe('deepseek');
|
|
82
|
+
// Stored (not just sanitized) so the slug is stable across restarts.
|
|
83
|
+
const stored = readStore();
|
|
84
|
+
expect(stored.providers[0].codex.kind).toBe('responses-compatible');
|
|
85
|
+
expect(stored.providers[0].codex.providerKey).toBe('deepseek');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('provider without codex.baseUrl defaults to openai-official kind', async () => {
|
|
89
|
+
const r = await mp.saveProvider({ name: 'Claude Only', cc: { baseUrl: 'https://a', authToken: 'sk', model: 'm' } });
|
|
90
|
+
expect(r.success).toBe(true);
|
|
91
|
+
expect(r.provider.codex.kind).toBe('openai-official');
|
|
92
|
+
expect(r.provider.codex.providerKey).toBe('claude-only');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('codex kind can be set explicitly, chat-only included', async () => {
|
|
96
|
+
const r = await mp.saveProvider({ name: 'Old Gateway', codex: { baseUrl: 'https://legacy.example.com', kind: 'chat-only' } });
|
|
97
|
+
expect(r.success).toBe(true);
|
|
98
|
+
expect(r.provider.codex.kind).toBe('chat-only');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('providerKey falls back to codex-<hash8> for reserved slugs (D51)', async () => {
|
|
102
|
+
// Raw reserved key 'openai' → codex-<hash8>; the id itself is sanitized to
|
|
103
|
+
// 'openai-custom' by genId, which is a safe non-reserved slug.
|
|
104
|
+
const r = await mp.saveProvider({ name: 'OpenAI', codex: { baseUrl: 'https://api.deepseek.com', apiKey: 'sk', model: 'm' } });
|
|
105
|
+
expect(r.success).toBe(true);
|
|
106
|
+
expect(r.provider.id).toBe('openai-custom'); // genId reservation (D51-extended)
|
|
107
|
+
expect(r.provider.codex.providerKey).toBe('openai-custom');
|
|
108
|
+
expect(r.provider.codex.providerKey).not.toBe('openai');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('explicit kind survives an update; slug stays stable (D51)', async () => {
|
|
112
|
+
const created = await mp.saveProvider(DEEPSEEK);
|
|
113
|
+
const updated = await mp.saveProvider({ id: created.provider.id, name: 'DeepSeek 主', codex: { baseUrl: 'https://api.deepseek.com/v2', apiKey: 'sk2', model: 'm2' } });
|
|
114
|
+
expect(updated.success).toBe(true);
|
|
115
|
+
expect(updated.provider.codex.kind).toBe('responses-compatible'); // unchanged
|
|
116
|
+
expect(updated.provider.codex.providerKey).toBe('deepseek'); // slug never regenerated
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('lazy migration backfills kind/providerKey for legacy store entries (D51)', async () => {
|
|
120
|
+
// Simulate a pre-D51 providers.json: codex config but no kind/providerKey.
|
|
121
|
+
fs.writeFileSync(
|
|
122
|
+
path.join(baizeDir, '.baize', 'providers.json'),
|
|
123
|
+
JSON.stringify({
|
|
124
|
+
active: 'legacy',
|
|
125
|
+
providers: [{ id: 'legacy', name: 'Legacy', codex: { baseUrl: 'https://api.deepseek.com', apiKey: 'sk', model: 'm' } }],
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
const list = mp.getProviders();
|
|
129
|
+
expect(list.providers[0].codex.kind).toBe('responses-compatible');
|
|
130
|
+
expect(list.providers[0].codex.providerKey).toBe('legacy');
|
|
131
|
+
// Backfill is persisted by the lazy migration.
|
|
132
|
+
expect(readStore().providers[0].codex.kind).toBe('responses-compatible');
|
|
133
|
+
expect(readStore().providers[0].codex.providerKey).toBe('legacy');
|
|
134
|
+
});
|
|
135
|
+
|
|
73
136
|
test('deleteProvider falls back to official when active', async () => {
|
|
74
137
|
await mp.saveProvider(DEEPSEEK);
|
|
75
138
|
const store = readStore();
|
|
@@ -82,7 +145,7 @@ describe('provider store', () => {
|
|
|
82
145
|
});
|
|
83
146
|
|
|
84
147
|
describe('activateProvider — external (cc + codex)', () => {
|
|
85
|
-
test('applies env/settings/auth
|
|
148
|
+
test('applies env/settings/auth + provider block, stashes official key, restarts', async () => {
|
|
86
149
|
// Pre-configure an official API key via D5-style storage
|
|
87
150
|
fs.writeFileSync(path.join(baizeDir, '.env'), 'ANTHROPIC_API_KEY=sk-ant-official-key\n');
|
|
88
151
|
fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true });
|
|
@@ -104,7 +167,7 @@ describe('activateProvider — external (cc + codex)', () => {
|
|
|
104
167
|
expect(env).toMatch(/^ANTHROPIC_BASE_URL=https:\/\/api\.deepseek\.com\/anthropic$/m);
|
|
105
168
|
expect(env).toMatch(/^ANTHROPIC_MODEL=deepseek-chat$/m);
|
|
106
169
|
expect(env).toMatch(/^ANTHROPIC_SMALL_FAST_MODEL=deepseek-chat$/m);
|
|
107
|
-
expect(env).toMatch(/^OPENAI_BASE_URL=https:\/\/api\.deepseek\.com$/m);
|
|
170
|
+
expect(env).toMatch(/^OPENAI_BASE_URL=https:\/\/api\.deepseek\.com$/m); // legacy health-probe surface, kept
|
|
108
171
|
expect(env).not.toMatch(/ANTHROPIC_API_KEY/);
|
|
109
172
|
|
|
110
173
|
// settings.json env: same
|
|
@@ -115,12 +178,29 @@ describe('activateProvider — external (cc + codex)', () => {
|
|
|
115
178
|
// stash preserved for revert
|
|
116
179
|
expect(readStore().stash.apiKey).toBe('sk-ant-official-key');
|
|
117
180
|
|
|
118
|
-
// codex auth.json + config
|
|
181
|
+
// codex auth.json + provider block config (D51)
|
|
119
182
|
const auth = JSON.parse(fs.readFileSync(path.join(homeDir, '.codex', 'auth.json'), 'utf8'));
|
|
120
183
|
expect(auth.OPENAI_API_KEY).toBe('sk-ds-key');
|
|
121
184
|
const toml = readToml();
|
|
122
|
-
expect(toml).toMatch(/^
|
|
123
|
-
expect(toml).toMatch(
|
|
185
|
+
expect(toml).toMatch(/^model_provider = "deepseek"$/m);
|
|
186
|
+
expect(toml).toMatch(/^\[model_providers\.deepseek\]$/m);
|
|
187
|
+
expect(toml).toMatch(/^name = "deepseek"$/m);
|
|
188
|
+
expect(toml).toMatch(/^base_url = "https:\/\/api\.deepseek\.com"$/m);
|
|
189
|
+
expect(toml).toMatch(/^wire_api = "responses"$/m);
|
|
190
|
+
expect(toml).toMatch(/^experimental_bearer_token = "sk-ds-key"$/m);
|
|
191
|
+
// the legacy override must never coexist with the provider block
|
|
192
|
+
expect(toml).not.toMatch(/openai_base_url/);
|
|
193
|
+
|
|
194
|
+
// project-level: model ensured, openai_base_url cleaned, no provider block
|
|
195
|
+
const projectToml = readProjectToml();
|
|
196
|
+
expect(projectToml).toMatch(/^model = "deepseek-chat"$/m);
|
|
197
|
+
expect(projectToml).not.toMatch(/openai_base_url/);
|
|
198
|
+
expect(projectToml).not.toMatch(/model_provider/);
|
|
199
|
+
expect(projectToml).not.toMatch(/\[model_providers\./);
|
|
200
|
+
|
|
201
|
+
// token-bearing global config is user-only
|
|
202
|
+
const mode = fs.statSync(path.join(homeDir, '.codex', 'config.toml')).mode & 0o777;
|
|
203
|
+
expect(mode).toBe(0o600);
|
|
124
204
|
});
|
|
125
205
|
|
|
126
206
|
test('probe failure surfaces a warning but still applies', async () => {
|
|
@@ -139,6 +219,22 @@ describe('activateProvider — external (cc + codex)', () => {
|
|
|
139
219
|
});
|
|
140
220
|
});
|
|
141
221
|
|
|
222
|
+
describe('activateProvider — chat-only interception (D51)', () => {
|
|
223
|
+
test('activating a chat-only provider is rejected before anything is written', async () => {
|
|
224
|
+
const saved = await mp.saveProvider({
|
|
225
|
+
name: '旧网关',
|
|
226
|
+
codex: { baseUrl: 'https://legacy.example.com', apiKey: 'sk-legacy', model: 'gpt-3.5', kind: 'chat-only' },
|
|
227
|
+
});
|
|
228
|
+
const before = readToml();
|
|
229
|
+
const r = await mp.activateProvider(saved.provider.id, { probe: async () => true, restart: noopRestart });
|
|
230
|
+
expect(r.success).toBe(false);
|
|
231
|
+
expect(r.error).toMatch(/仅支持 Chat 接口/);
|
|
232
|
+
expect(r.error).toMatch(/Claude 运行时/);
|
|
233
|
+
expect(readToml()).toBe(before); // nothing written
|
|
234
|
+
expect(readEnv()).not.toMatch(/OPENAI_BASE_URL=https:\/\/legacy\.example\.com/);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
142
238
|
describe('activateProvider — official restores', () => {
|
|
143
239
|
test('official clears external vars and restores stashed key', async () => {
|
|
144
240
|
fs.writeFileSync(path.join(baizeDir, '.env'), 'ANTHROPIC_API_KEY=sk-ant-official-key\n');
|
|
@@ -160,58 +256,57 @@ describe('activateProvider — official restores', () => {
|
|
|
160
256
|
expect(readStore().stash).toBeUndefined(); // stash cleared from file
|
|
161
257
|
});
|
|
162
258
|
|
|
163
|
-
test('official-openai
|
|
259
|
+
test('official-openai drops the provider block and resets model to gpt-5.5 (D51)', async () => {
|
|
164
260
|
await mp.saveProvider(DEEPSEEK);
|
|
165
261
|
await mp.activateProvider('deepseek', { probe: async () => true, restart: noopRestart });
|
|
166
262
|
|
|
167
263
|
const r = await mp.activateProvider('official-openai', { restart: noopRestart });
|
|
168
264
|
expect(r.success).toBe(true);
|
|
265
|
+
expect(r.codexProviderKey).toBeNull();
|
|
169
266
|
const env = readEnv();
|
|
170
|
-
expect(env).not.toMatch(/OPENAI_BASE_URL/);
|
|
171
267
|
const toml = readToml();
|
|
268
|
+
expect(env).not.toMatch(/OPENAI_BASE_URL/);
|
|
172
269
|
expect(toml).toMatch(/^model = "gpt-5.5"$/m);
|
|
270
|
+
expect(toml).not.toMatch(/^model_provider/m);
|
|
271
|
+
expect(toml).not.toMatch(/\[model_providers\.deepseek\]/);
|
|
173
272
|
expect(toml).not.toMatch(/openai_base_url/);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
describe('editCodexConfigToml (TOML scope aware)', () => {
|
|
178
|
-
test('top-level keys land BEFORE the first section header; section content byte-preserved', () => {
|
|
179
|
-
fs.mkdirSync(path.join(homeDir, '.codex'), { recursive: true });
|
|
180
|
-
fs.writeFileSync(path.join(homeDir, '.codex', 'config.toml'), '# comment\n\n[model_providers.my]\nbase_url = "https://x"\nmodel = "other"\n');
|
|
181
|
-
mp.editCodexConfigToml({ model: 'deepseek-chat', openaiBaseUrl: 'https://api.deepseek.com' });
|
|
182
|
-
const toml = readToml();
|
|
183
|
-
const sectionIdx = toml.indexOf('[model_providers.my]');
|
|
184
|
-
// top-level keys must appear in the prefix BEFORE the section header
|
|
185
|
-
const topLevelIdx = toml.indexOf('model = "deepseek-chat"');
|
|
186
|
-
const baseUrlIdx = toml.indexOf('openai_base_url = "https://api.deepseek.com"');
|
|
187
|
-
expect(topLevelIdx).toBeGreaterThanOrEqual(0);
|
|
188
|
-
expect(baseUrlIdx).toBeGreaterThanOrEqual(0);
|
|
189
|
-
expect(topLevelIdx).toBeLessThan(sectionIdx);
|
|
190
|
-
expect(baseUrlIdx).toBeLessThan(sectionIdx);
|
|
191
|
-
// section content untouched
|
|
192
|
-
expect(toml).toMatch(/^\[model_providers\.my\]$/m);
|
|
193
|
-
expect(toml).toMatch(/^base_url = "https:\/\/x"$/m);
|
|
194
|
-
expect(toml).toMatch(/^model = "other"$/m);
|
|
273
|
+
const projectToml = readProjectToml();
|
|
274
|
+
expect(projectToml).not.toMatch(/openai_base_url/);
|
|
195
275
|
});
|
|
196
276
|
|
|
197
|
-
test('
|
|
277
|
+
test('official-openai removes baize-written tables but preserves hand-edited ones (D51 review P2)', async () => {
|
|
278
|
+
// Contract (review P2): a baize-written block always has name === key &&
|
|
279
|
+
// wire_api === 'responses'. Those are removed on restore. Hand-edited
|
|
280
|
+
// user providers (any other shape) survive untouched.
|
|
198
281
|
fs.mkdirSync(path.join(homeDir, '.codex'), { recursive: true });
|
|
199
|
-
fs.writeFileSync(
|
|
200
|
-
|
|
282
|
+
fs.writeFileSync(
|
|
283
|
+
path.join(homeDir, '.codex', 'config.toml'),
|
|
284
|
+
'model_provider = "deepseek"\n\n[model_providers.deepseek]\nname = "deepseek"\nbase_url = "https://api.deepseek.com"\nwire_api = "responses"\n\n[model_providers.myrelay]\nname = "My Relay"\nbase_url = "https://r.example"\n',
|
|
285
|
+
);
|
|
286
|
+
const r = await mp.activateProvider('official-openai', { restart: noopRestart });
|
|
287
|
+
expect(r.success).toBe(true);
|
|
201
288
|
const toml = readToml();
|
|
202
|
-
expect(toml.
|
|
203
|
-
expect(toml.
|
|
289
|
+
expect(toml).toMatch(/^model = "gpt-5.5"$/m);
|
|
290
|
+
expect(toml).not.toMatch(/^model_provider/m);
|
|
291
|
+
expect(toml).not.toMatch(/\[model_providers\.deepseek\]/, 'baize-written block removed');
|
|
292
|
+
expect(toml).toMatch(/\[model_providers\.myrelay\]/, 'hand-edited provider survives');
|
|
293
|
+
expect(toml).toMatch(/name = "My Relay"/);
|
|
204
294
|
});
|
|
205
295
|
|
|
206
|
-
test('
|
|
296
|
+
test('activating an openai-official-kind provider keeps the built-in OpenAI provider (D51)', async () => {
|
|
297
|
+
fs.mkdirSync(path.join(baizeDir, '.baize'), { recursive: true });
|
|
298
|
+
fs.writeFileSync(path.join(baizeDir, '.baize', 'config.json'), JSON.stringify({ runtime: 'codex' }));
|
|
207
299
|
fs.mkdirSync(path.join(homeDir, '.codex'), { recursive: true });
|
|
208
|
-
|
|
209
|
-
|
|
300
|
+
// fixture: baize-written shape (name === key && wire_api === responses) so restore removes it
|
|
301
|
+
fs.writeFileSync(path.join(homeDir, '.codex', 'config.toml'), 'model_provider = "deepseek"\n\n[model_providers.deepseek]\nname = "deepseek"\nbase_url = "https://api.deepseek.com"\nwire_api = "responses"\n');
|
|
302
|
+
await mp.saveProvider({ name: '官方 OpenAI', codex: { apiKey: 'sk-official', model: 'gpt-5.5' } }); // no baseUrl → openai-official
|
|
303
|
+
const r = await mp.activateProvider('openai-custom', { probe: async () => true, restart: noopRestart });
|
|
304
|
+
expect(r.success).toBe(true);
|
|
305
|
+
expect(r.codexProviderKey).toBeNull();
|
|
210
306
|
const toml = readToml();
|
|
211
|
-
expect(toml).not.toMatch(/^
|
|
307
|
+
expect(toml).not.toMatch(/^model_provider/m); // built-in provider back in charge
|
|
308
|
+
expect(toml).not.toMatch(/\[model_providers\.deepseek\]/);
|
|
212
309
|
expect(toml).toMatch(/^model = "gpt-5.5"$/m);
|
|
213
|
-
expect(toml).toMatch(/^\[model_providers\.my\]$/m);
|
|
214
|
-
expect(toml).toMatch(/^model = "deepseek-chat"$/m); // section model untouched
|
|
215
310
|
});
|
|
216
311
|
});
|
|
217
312
|
|
|
@@ -268,3 +363,18 @@ describe('activateProvider — side-coverage validation (D13)', () => {
|
|
|
268
363
|
expect(r.success).toBe(true);
|
|
269
364
|
});
|
|
270
365
|
});
|
|
366
|
+
|
|
367
|
+
describe('D51 migration — legacy activation cleans stale overrides', () => {
|
|
368
|
+
test('activating a provider removes a pre-existing openai_base_url override', async () => {
|
|
369
|
+
fs.mkdirSync(path.join(homeDir, '.codex'), { recursive: true });
|
|
370
|
+
fs.writeFileSync(path.join(homeDir, '.codex', 'config.toml'), 'openai_base_url = "https://old.example.com/v1"\nmodel = "old"\n');
|
|
371
|
+
fs.mkdirSync(path.join(baizeDir, '.baize'), { recursive: true });
|
|
372
|
+
fs.writeFileSync(path.join(baizeDir, '.baize', 'config.json'), JSON.stringify({ runtime: 'codex' }));
|
|
373
|
+
await mp.saveProvider({ name: 'codex-only', codex: { baseUrl: 'https://api.deepseek.com', apiKey: 'sk', model: 'deepseek-chat' } });
|
|
374
|
+
const r = await mp.activateProvider('codex-only', { probe: async () => true, restart: noopRestart });
|
|
375
|
+
expect(r.success).toBe(true);
|
|
376
|
+
const toml = readToml();
|
|
377
|
+
expect(toml).not.toMatch(/openai_base_url/);
|
|
378
|
+
expect(toml).toMatch(/^model_provider = "codex-only"$/m);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* K4 e2e: `baize upgrade <name> --file <tgz>` — offline commercial upgrade
|
|
3
|
+
* channel. The tarball IS the source (checkForUpdates/downloadToTemp skipped);
|
|
4
|
+
* version gate via K6 getLocalVersion (package.json first); the existing
|
|
5
|
+
* 9-step runUpgrade pipeline (backup → smart merge → npm install → baseline →
|
|
6
|
+
* restart) executes unchanged, with rollback on failure.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, test, expect, beforeAll, afterAll } from '@jest/globals';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import {
|
|
14
|
+
generateManifest,
|
|
15
|
+
loadManifest,
|
|
16
|
+
saveMergeBaseline,
|
|
17
|
+
} from '../cli/lib/manifest.js';
|
|
18
|
+
|
|
19
|
+
const DRIVER = path.join(import.meta.dirname, 'helpers', 'run-upgrade-file-driver.mjs');
|
|
20
|
+
|
|
21
|
+
let tmpRoot;
|
|
22
|
+
let baizeDir;
|
|
23
|
+
let skillsDir;
|
|
24
|
+
let shimDir;
|
|
25
|
+
let failFlag;
|
|
26
|
+
|
|
27
|
+
function mkTmp() {
|
|
28
|
+
return fs.mkdtempSync(path.join(tmpRoot, 'test-'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeFile(dir, relPath, content) {
|
|
32
|
+
const full = path.join(dir, relPath);
|
|
33
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
34
|
+
fs.writeFileSync(full, content);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readFile(dir, relPath) {
|
|
38
|
+
return fs.readFileSync(path.join(dir, relPath), 'utf8');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Install a v1 fixture: real skill dir + authoritative baseline manifest +
|
|
42
|
+
* components.json registration (required: upgrade refuses unregistered comps). */
|
|
43
|
+
function installV1(name) {
|
|
44
|
+
const dest = path.join(skillsDir, name);
|
|
45
|
+
const source = mkTmp();
|
|
46
|
+
writeFile(source, 'a.js', 'v1');
|
|
47
|
+
writeFile(source, 'package.json', JSON.stringify({ name, version: '1.0.0' }));
|
|
48
|
+
writeFile(dest, 'a.js', 'v1');
|
|
49
|
+
writeFile(dest, 'package.json', JSON.stringify({ name, version: '1.0.0' }));
|
|
50
|
+
saveMergeBaseline(dest, source, generateManifest(source));
|
|
51
|
+
const componentsFile = path.join(baizeDir, '.baize', 'components.json');
|
|
52
|
+
fs.mkdirSync(path.dirname(componentsFile), { recursive: true });
|
|
53
|
+
const components = JSON.parse(fs.existsSync(componentsFile) ? fs.readFileSync(componentsFile, 'utf8') : '{}');
|
|
54
|
+
components[name] = {
|
|
55
|
+
version: '1.0.0',
|
|
56
|
+
repo: `baize-ai/${name}`,
|
|
57
|
+
skillDir: dest,
|
|
58
|
+
installedAt: new Date().toISOString(),
|
|
59
|
+
};
|
|
60
|
+
fs.writeFileSync(componentsFile, JSON.stringify(components, null, 2));
|
|
61
|
+
return dest;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Pack a real .tgz (single wrapper dir, npm publish layout). */
|
|
65
|
+
function buildTarball(name, version, { pkgName = name, aContent = `v-${version}` } = {}) {
|
|
66
|
+
const dir = mkTmp();
|
|
67
|
+
writeFile(dir, path.join('package', 'a.js'), aContent);
|
|
68
|
+
writeFile(dir, path.join('package', 'package.json'), JSON.stringify({ name: pkgName, version }));
|
|
69
|
+
const out = path.join(os.tmpdir(), `upgrade-file-${name}-${version}-${Math.random().toString(36).slice(2)}.tgz`);
|
|
70
|
+
execTar(dir);
|
|
71
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
72
|
+
return out;
|
|
73
|
+
|
|
74
|
+
function execTar(from) {
|
|
75
|
+
spawnSync('tar', ['czf', out, '-C', from, 'package'], { encoding: 'utf8' });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function runFileUpgrade(name, tgzPath, { check = false } = {}) {
|
|
80
|
+
const args = [DRIVER, name, tgzPath];
|
|
81
|
+
if (check) args.push('--check');
|
|
82
|
+
const child = spawnSync(process.execPath, args, {
|
|
83
|
+
encoding: 'utf8',
|
|
84
|
+
env: {
|
|
85
|
+
...process.env,
|
|
86
|
+
BAIZE_DIR: baizeDir,
|
|
87
|
+
PATH: shimDir + path.delimiter + process.env.PATH,
|
|
88
|
+
BAIZE_TEST_BASELINE_COMMIT_FAIL: '0',
|
|
89
|
+
},
|
|
90
|
+
timeout: 60000,
|
|
91
|
+
});
|
|
92
|
+
let body = null;
|
|
93
|
+
try { body = JSON.parse(child.stdout); } catch { /* surfaced below */ }
|
|
94
|
+
return { status: child.status, stderr: child.stderr, body };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
beforeAll(() => {
|
|
98
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'baize-upgrade-file-e2e-'));
|
|
99
|
+
baizeDir = path.join(tmpRoot, 'baize-home');
|
|
100
|
+
skillsDir = path.join(baizeDir, '.claude', 'skills');
|
|
101
|
+
fs.mkdirSync(skillsDir, { recursive: true });
|
|
102
|
+
|
|
103
|
+
// npm shim: the pipeline's step4 must never hit the network; the fail flag
|
|
104
|
+
// lets a test exercise rollback-on-npm-install-failure.
|
|
105
|
+
shimDir = path.join(tmpRoot, 'shim-bin');
|
|
106
|
+
fs.mkdirSync(shimDir, { recursive: true });
|
|
107
|
+
failFlag = path.join(shimDir, 'npm-fail');
|
|
108
|
+
fs.writeFileSync(path.join(shimDir, 'npm'), [
|
|
109
|
+
'#!/bin/sh',
|
|
110
|
+
`if [ -e "${failFlag}" ]; then echo "simulated npm failure" >&2; exit 1; fi`,
|
|
111
|
+
'exit 0',
|
|
112
|
+
'',
|
|
113
|
+
].join('\n'), { mode: 0o755 });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterAll(() => {
|
|
117
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('baize upgrade <name> --file <tgz> (K4)', () => {
|
|
121
|
+
test('upgrades through the real 9-step pipeline from a local tarball', () => {
|
|
122
|
+
const name = 'file-upgrade-happy';
|
|
123
|
+
const skillDir = installV1(name);
|
|
124
|
+
const tgz = buildTarball(name, '2.0.0', { aContent: 'v2' });
|
|
125
|
+
|
|
126
|
+
const { status, body } = runFileUpgrade(name, tgz);
|
|
127
|
+
|
|
128
|
+
expect(status).toBe(0);
|
|
129
|
+
expect(body.success).toBe(true);
|
|
130
|
+
expect(body.from).toBe('1.0.0');
|
|
131
|
+
expect(body.to).toBe('2.0.0');
|
|
132
|
+
expect(body.source).toMatchObject({ type: 'local-tarball' });
|
|
133
|
+
// Pipeline actually ran (steps 1-9 present) — not a shortcut path
|
|
134
|
+
const stepNames = body.steps.map((s) => s.name);
|
|
135
|
+
expect(stepNames).toEqual(expect.arrayContaining(['stop_service', 'backup', 'smart_merge', 'npm_install', 'commit_baseline']));
|
|
136
|
+
// New files landed
|
|
137
|
+
expect(readFile(skillDir, 'a.js')).toBe('v2');
|
|
138
|
+
expect(JSON.parse(readFile(skillDir, 'package.json')).version).toBe('2.0.0');
|
|
139
|
+
// Authoritative baseline advanced to the tarball content
|
|
140
|
+
const manifest = loadManifest(skillDir);
|
|
141
|
+
expect(manifest.files['a.js']).toBeDefined();
|
|
142
|
+
// components.json bumped
|
|
143
|
+
const components = JSON.parse(readFile(path.join(baizeDir, '.baize'), 'components.json'));
|
|
144
|
+
expect(components[name].version).toBe('2.0.0');
|
|
145
|
+
expect(components[name].upgradedAt).toBeTruthy();
|
|
146
|
+
fs.rmSync(tgz, { force: true });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('rejects a tarball whose version is not higher (version_not_higher)', () => {
|
|
150
|
+
const name = 'file-upgrade-gate';
|
|
151
|
+
installV1(name);
|
|
152
|
+
const tgz = buildTarball(name, '1.0.0', { aContent: 'should-not-land' });
|
|
153
|
+
|
|
154
|
+
const { status, body } = runFileUpgrade(name, tgz);
|
|
155
|
+
|
|
156
|
+
expect(status).toBe(1);
|
|
157
|
+
expect(body.success).toBe(false);
|
|
158
|
+
expect(body.error).toBe('version_not_higher');
|
|
159
|
+
expect(body.local).toBe('1.0.0');
|
|
160
|
+
expect(body.incoming).toBe('1.0.0');
|
|
161
|
+
// Installation untouched
|
|
162
|
+
expect(readFile(path.join(skillsDir, name), 'a.js')).toBe('v1');
|
|
163
|
+
const components = JSON.parse(readFile(path.join(baizeDir, '.baize'), 'components.json'));
|
|
164
|
+
expect(components[name].version).toBe('1.0.0');
|
|
165
|
+
fs.rmSync(tgz, { force: true });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('rejects a downgrade the same way', () => {
|
|
169
|
+
const name = 'file-upgrade-downgrade';
|
|
170
|
+
installV1(name);
|
|
171
|
+
const tgz = buildTarball(name, '0.9.0', { aContent: 'old-code' });
|
|
172
|
+
|
|
173
|
+
const { status, body } = runFileUpgrade(name, tgz);
|
|
174
|
+
|
|
175
|
+
expect(status).toBe(1);
|
|
176
|
+
expect(body.error).toBe('version_not_higher');
|
|
177
|
+
expect(body.incoming).toBe('0.9.0');
|
|
178
|
+
expect(readFile(path.join(skillsDir, name), 'a.js')).toBe('v1');
|
|
179
|
+
fs.rmSync(tgz, { force: true });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('rejects a tarball whose package name mismatches the installed component', () => {
|
|
183
|
+
const name = 'file-upgrade-mismatch';
|
|
184
|
+
installV1(name);
|
|
185
|
+
const tgz = buildTarball(name, '2.0.0', { pkgName: 'some-other-package' });
|
|
186
|
+
|
|
187
|
+
const { status, body } = runFileUpgrade(name, tgz);
|
|
188
|
+
|
|
189
|
+
expect(status).toBe(1);
|
|
190
|
+
expect(body.success).toBe(false);
|
|
191
|
+
expect(body.error).toMatch(/manifest name mismatch/);
|
|
192
|
+
expect(readFile(path.join(skillsDir, name), 'a.js')).toBe('v1');
|
|
193
|
+
fs.rmSync(tgz, { force: true });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('rolls back the installation when npm install fails mid-pipeline', () => {
|
|
197
|
+
const name = 'file-upgrade-rollback';
|
|
198
|
+
const skillDir = installV1(name);
|
|
199
|
+
const tgz = buildTarball(name, '2.0.0', { aContent: 'v2' });
|
|
200
|
+
|
|
201
|
+
fs.writeFileSync(failFlag, '');
|
|
202
|
+
try {
|
|
203
|
+
const { status, body } = runFileUpgrade(name, tgz);
|
|
204
|
+
expect(status).toBe(1);
|
|
205
|
+
expect(body.success).toBe(false);
|
|
206
|
+
expect(body.failedStep).toBe(4);
|
|
207
|
+
expect(body.rollback?.performed).toBe(true);
|
|
208
|
+
// Original code restored from .backup
|
|
209
|
+
expect(readFile(skillDir, 'a.js')).toBe('v1');
|
|
210
|
+
expect(JSON.parse(readFile(skillDir, 'package.json')).version).toBe('1.0.0');
|
|
211
|
+
} finally {
|
|
212
|
+
fs.rmSync(failFlag, { force: true });
|
|
213
|
+
}
|
|
214
|
+
fs.rmSync(tgz, { force: true });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('--check compares versions without touching the installation', () => {
|
|
218
|
+
const name = 'file-upgrade-check';
|
|
219
|
+
installV1(name);
|
|
220
|
+
const tgz = buildTarball(name, '2.0.0');
|
|
221
|
+
|
|
222
|
+
const { status, body } = runFileUpgrade(name, tgz, { check: true });
|
|
223
|
+
|
|
224
|
+
expect(status).toBe(0);
|
|
225
|
+
expect(body).toMatchObject({ action: 'check', success: true, hasUpdate: true, current: '1.0.0', latest: '2.0.0' });
|
|
226
|
+
expect(readFile(path.join(skillsDir, name), 'a.js')).toBe('v1');
|
|
227
|
+
fs.rmSync(tgz, { force: true });
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* K6: getLocalVersion must read skillDir/package.json version first (SKILL.md
|
|
3
|
+
* frontmatter is stale by design — it historically stayed at 0.1.0 while
|
|
4
|
+
* package.json advanced, breaking upgrade version gating). SKILL.md remains
|
|
5
|
+
* the fallback for components that never shipped a package.json.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from '@jest/globals';
|
|
11
|
+
import { getLocalVersion } from '../cli/lib/upgrade.js';
|
|
12
|
+
|
|
13
|
+
let tmp;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'local-version-'));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function write(rel, content) {
|
|
24
|
+
const full = path.join(tmp, rel);
|
|
25
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
26
|
+
fs.writeFileSync(full, content);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('getLocalVersion reads package.json first (K6)', () => {
|
|
30
|
+
test('package.json version wins over SKILL.md frontmatter', () => {
|
|
31
|
+
write('SKILL.md', '---\nname: a2a\nversion: 0.1.0\n---\n# A2A\n');
|
|
32
|
+
write('package.json', JSON.stringify({ name: 'x', version: '0.2.0' }));
|
|
33
|
+
expect(getLocalVersion(tmp)).toEqual({ success: true, version: '0.2.0' });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('falls back to SKILL.md frontmatter when package.json is absent', () => {
|
|
37
|
+
write('SKILL.md', '---\nname: a2a\nversion: 1.2.3\n---\n# A2A\n');
|
|
38
|
+
expect(getLocalVersion(tmp)).toEqual({ success: true, version: '1.2.3' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('falls back to SKILL.md when package.json version is invalid semver', () => {
|
|
42
|
+
write('SKILL.md', '---\nname: a2a\nversion: 3.4.5\n---\n# A2A\n');
|
|
43
|
+
write('package.json', JSON.stringify({ name: 'x', version: 'not-a-version' }));
|
|
44
|
+
expect(getLocalVersion(tmp)).toEqual({ success: true, version: '3.4.5' });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('falls back to SKILL.md when package.json is unparseable', () => {
|
|
48
|
+
write('SKILL.md', '---\nname: a2a\nversion: 3.4.5\n---\n# A2A\n');
|
|
49
|
+
write('package.json', '{broken');
|
|
50
|
+
expect(getLocalVersion(tmp)).toEqual({ success: true, version: '3.4.5' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('prerelease versions in package.json are accepted', () => {
|
|
54
|
+
write('package.json', JSON.stringify({ name: 'x', version: '0.2.0-beta.1' }));
|
|
55
|
+
expect(getLocalVersion(tmp)).toEqual({ success: true, version: '0.2.0-beta.1' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('fails when neither source has a version', () => {
|
|
59
|
+
write('SKILL.md', '---\nname: a2a\n---\n# A2A\n');
|
|
60
|
+
const result = getLocalVersion(tmp);
|
|
61
|
+
expect(result.success).toBe(false);
|
|
62
|
+
expect(result.error).toMatch(/Version not found/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('fails for a missing skill dir', () => {
|
|
66
|
+
const missing = path.join(tmp, 'nope');
|
|
67
|
+
const result = getLocalVersion(missing);
|
|
68
|
+
expect(result.success).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|