@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.
@@ -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 url', async () => {
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/toml, stashes official key, restarts', async () => {
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.toml
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(/^model = "deepseek-chat"$/m);
123
- expect(toml).toMatch(/^openai_base_url = "https:\/\/api\.deepseek\.com"$/m);
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 clears codex base_url and resets model', async () => {
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('replaces existing top-level keys without duplicating', () => {
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(path.join(homeDir, '.codex', 'config.toml'), 'model = "gpt-5.5"\n');
200
- mp.editCodexConfigToml({ model: 'deepseek-chat', openaiBaseUrl: 'https://api.deepseek.com' });
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.match(/^model = "deepseek-chat"$/gm)).toHaveLength(1);
203
- expect(toml.match(/^openai_base_url = "https:\/\/api\.deepseek\.com"$/gm)).toHaveLength(1);
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('removes openai_base_url on official restore, keeping sections', () => {
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
- fs.writeFileSync(path.join(homeDir, '.codex', 'config.toml'), 'openai_base_url = "https://api.deepseek.com"\n\n[model_providers.my]\nmodel = "deepseek-chat"\n');
209
- mp.editCodexConfigToml({ model: 'gpt-5.5', openaiBaseUrl: null });
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(/^openai_base_url/m);
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
+ });