unbound-cli 1.20.2 → 1.20.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.20.2",
3
+ "version": "1.20.4",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -124,7 +124,7 @@ const ACTION_LABEL = {
124
124
  conflict: 'CONFLICT',
125
125
  };
126
126
 
127
- function printPlan({ file, pack, ops, live }) {
127
+ function printPlan({ file, pack, ops, live, versionState }) {
128
128
  console.log('');
129
129
  output.info(`Pack ${pack.name} v${pack.version} (${file})`);
130
130
  output.table(ops, [
@@ -143,6 +143,9 @@ function printPlan({ file, pack, ops, live }) {
143
143
  output.warn(`${off.length} polic${many ? 'ies are' : 'y is'} off on the server but on in the pack (switched off in the console, or just enabled in the file). Apply turns ${many ? 'them' : 'it'} on; to keep ${many ? 'one' : 'it'} off, set enabled: false in the pack file.`);
144
144
  }
145
145
  const s = summarize(ops);
146
+ if (versionState.supported && versionState.version !== pack.version) {
147
+ output.info(`Stored version: ${versionState.version || 'none'} -> ${pack.version}`);
148
+ }
146
149
  const parts = [];
147
150
  if (s.create) parts.push(`${s.create} to create`);
148
151
  if (s.update) parts.push(`${s.update} to update`);
@@ -154,12 +157,14 @@ function printPlan({ file, pack, ops, live }) {
154
157
  console.log(` ${parts.join(', ')}`);
155
158
  }
156
159
 
157
- function planJson({ file, pack, ops }) {
160
+ function planJson({ file, pack, ops, versionState }) {
161
+ const summary = summarize(ops);
162
+ const versionOnly = versionState.supported && versionState.version !== pack.version && !summary.drift;
158
163
  return {
159
164
  pack: pack.name,
160
165
  version: pack.version,
161
166
  file,
162
- summary: summarize(ops),
167
+ summary: { ...summary, drift: summary.drift + (versionOnly ? 1 : 0) },
163
168
  ops: ops.map((op) => ({
164
169
  op: op.op,
165
170
  kind: op.kind,
@@ -179,10 +184,21 @@ async function planPack(pack, { prune, names }) {
179
184
  throw new PackError(`could not load the MCP server catalogue (${ref.mcpServersError}); this pack uses mcp.actionType, which needs it. Retry, or list mcp.tools explicitly`);
180
185
  }
181
186
  const desired = toDesired(pack, ref);
187
+ const versionState = await remote.fetchVersion(pack.name);
182
188
  const live = await remote.fetchManaged(pack.name);
183
189
  live.names = names || await remote.fetchAllNames();
184
190
  const ops = buildPlan(desired, live, { prune });
185
- return { ops, live, ref };
191
+ return { ops, live, ref, versionState };
192
+ }
193
+
194
+ async function fetchStablePack(name) {
195
+ const before = await remote.fetchVersion(name);
196
+ const live = await remote.fetchManaged(name);
197
+ const after = await remote.fetchVersion(name);
198
+ if (before.supported !== after.supported || before.version !== after.version) {
199
+ throw new PackError(`pack "${name}" changed during export; retry`);
200
+ }
201
+ return { live, version: after.version };
186
202
  }
187
203
 
188
204
  /**
@@ -207,13 +223,14 @@ async function runApply(target, { dryRun = false, prune = false, yes = false, js
207
223
  const names = await remote.fetchAllNames();
208
224
  const planned = [];
209
225
  for (const { file, pack } of packs) {
210
- const { ops, live } = await planPack(pack, { prune, names });
211
- planned.push({ file, pack, ops, live });
226
+ const { ops, live, versionState } = await planPack(pack, { prune, names });
227
+ planned.push({ file, pack, ops, live, versionState });
212
228
  }
213
229
 
214
230
  const totals = planned.reduce((acc, p) => {
215
231
  const s = summarize(p.ops);
216
232
  for (const k of Object.keys(s)) acc[k] = (acc[k] || 0) + s[k];
233
+ if (p.versionState.supported && p.versionState.version !== p.pack.version && !s.drift) acc.drift += 1;
217
234
  return acc;
218
235
  }, {});
219
236
 
@@ -231,12 +248,6 @@ async function runApply(target, { dryRun = false, prune = false, yes = false, js
231
248
  return { drift: totals.drift, failed: 0 };
232
249
  }
233
250
 
234
- if (!totals.drift) {
235
- if (json) output.json({ mode: label, dryRun: false, packs: planned.map(planJson), results: [] });
236
- else output.success('Already in sync.');
237
- return { drift: 0, failed: 0 };
238
- }
239
-
240
251
  const destructive = totals.delete + totals.replace;
241
252
  if (destructive && !yes) {
242
253
  const ok = await confirm(`This will delete ${destructive} polic${destructive > 1 ? 'ies' : 'y'} (${totals.delete} delete, ${totals.replace} replace). Continue?`);
@@ -249,6 +260,22 @@ async function runApply(target, { dryRun = false, prune = false, yes = false, js
249
260
  const results = [];
250
261
  for (const p of planned) {
251
262
  const todo = p.ops.filter((op) => ['create', 'update', 'replace', 'delete'].includes(op.op));
263
+ if (!todo.length && (!p.versionState.supported || p.versionState.version === p.pack.version)) continue;
264
+ if (p.versionState.supported) {
265
+ try {
266
+ await remote.applyPlan(p.pack.name, p.pack.version, todo, {
267
+ expectedVersion: p.versionState.version,
268
+ });
269
+ results.push(...(todo.length ? todo.map((op) => ({ pack: p.pack.name, policy: op.serverName, op: op.op, status: 'ok', error: null }))
270
+ : [{ pack: p.pack.name, policy: null, op: 'version', status: 'ok', error: null }]));
271
+ } catch (err) {
272
+ const message = err && err.message ? err.message : String(err);
273
+ results.push({ pack: p.pack.name, policy: null, op: 'apply', status: 'failed', error: message });
274
+ if (!json) output.error(`Pack ${p.pack.name}: ${message}`);
275
+ }
276
+ continue;
277
+ }
278
+ output.warn('Older backend: applying policies one at a time; this run is not atomic.');
252
279
  const packResults = await remote.writeOps(todo, {
253
280
  onResult: (r) => {
254
281
  if (json) return;
@@ -264,7 +291,8 @@ async function runApply(target, { dryRun = false, prune = false, yes = false, js
264
291
  output.json({ mode: label, dryRun: false, packs: planned.map(planJson), results });
265
292
  } else {
266
293
  console.log('');
267
- if (failed) output.warn(`${results.length - failed} succeeded, ${failed} failed. Re-run to retry; every operation is idempotent by name.`);
294
+ if (failed) output.warn(`${results.length - failed} change(s) succeeded, ${failed} pack or policy write(s) failed. Re-run to retry.`);
295
+ else if (!results.length) output.success('Already in sync.');
268
296
  else output.success(`${results.length} change${results.length > 1 ? 's' : ''} applied.`);
269
297
  }
270
298
  return { drift: totals.drift, failed };
@@ -384,9 +412,8 @@ Examples:
384
412
  $ unbound pack sync policies/ --dry-run
385
413
  $ unbound pack sync policies/ --yes
386
414
 
387
- A pack whose file is removed from the folder keeps its policies: the server
388
- has no record of which packs were applied before. Use \`unbound pack delete <pack>\`
389
- to remove a whole pack.
415
+ A pack whose file is removed from the folder keeps its policies. Use
416
+ \`unbound pack delete <pack> --pack-version <new-version>\` to remove a whole pack.
390
417
  `)
391
418
  .action(async (target, opts) => {
392
419
  try {
@@ -402,7 +429,7 @@ to remove a whole pack.
402
429
  .description('Write the live policies of a pack as a pack file.')
403
430
  .option('-o, --out <path>', 'File to write (default: stdout). With --all: a folder, one file per pack')
404
431
  .option('--all', 'Export every pack found in the org')
405
- .option('--pack-version <version>', 'metadata.version to write (default 1.0.0). Not --version, which prints the CLI version', '1.0.0')
432
+ .option('--pack-version <version>', 'Override the stored metadata.version')
406
433
  .option('--json', 'Write JSON instead of YAML')
407
434
  .addHelpText('after', `
408
435
  Examples:
@@ -426,13 +453,14 @@ Examples:
426
453
  output.info('No pack-managed policies found in this org.');
427
454
  }
428
455
  fs.mkdirSync(opts.out, { recursive: true });
429
- for (const [name, live] of [...packs.entries()].sort(([a], [b]) => a.localeCompare(b))) {
456
+ for (const name of [...packs.keys()].sort((a, b) => a.localeCompare(b))) {
457
+ const { live, version } = await fetchStablePack(name);
430
458
  for (const { row } of live.unsupported) output.warn(`${row.name}: unsupported policy type, not exported.`);
431
459
  if (!live.tool.length && !live.generic.length) {
432
460
  output.warn(`pack "${name}": only unsupported policies, no file written.`);
433
461
  continue;
434
462
  }
435
- const doc = liveToPack(name, live, ref, { version: opts.packVersion });
463
+ const doc = liveToPack(name, live, ref, { version: opts.packVersion || version || '1.0.0' });
436
464
  writeOrPrint(serialize(doc), path.join(opts.out, `${name}${ext}`));
437
465
  }
438
466
  if (unmanaged.length) {
@@ -441,14 +469,14 @@ Examples:
441
469
  return;
442
470
  }
443
471
 
444
- const live = await remote.fetchManaged(packName);
472
+ const { live, version } = await fetchStablePack(packName);
445
473
  for (const { row } of live.unsupported) output.warn(`${row.name}: unsupported policy type, not exported.`);
446
474
  if (!live.tool.length && !live.generic.length) {
447
475
  throw new PackError(live.unsupported.length
448
476
  ? `pack "${packName}" only has policies of types this CLI version does not manage; nothing to export`
449
477
  : `pack "${packName}" manages no policies in this org`);
450
478
  }
451
- const doc = liveToPack(packName, live, ref, { version: opts.packVersion });
479
+ const doc = liveToPack(packName, live, ref, { version: opts.packVersion || version || '1.0.0' });
452
480
  writeOrPrint(serialize(doc), opts.out);
453
481
  } catch (err) {
454
482
  fail(err);
@@ -462,7 +490,7 @@ Examples:
462
490
  .option('--id <slug>', 'Id to use (only with a single --from). Default: a slug of the policy name')
463
491
  .option('--yes', 'Skip the confirmation prompt')
464
492
  .option('-o, --out <path>', 'Write the resulting pack file here (default: stdout; progress goes to stderr)')
465
- .option('--pack-version <version>', 'metadata.version for the printed pack file (default 1.0.0)', '1.0.0')
493
+ .option('--pack-version <version>', 'New version for the pack and printed file (required when the pack already has a stored version)')
466
494
  .addHelpText('after', `
467
495
  Examples:
468
496
  $ unbound pack adopt k8s-baseline --from "Block kubectl secrets"
@@ -499,6 +527,11 @@ manage the pack with apply.
499
527
  const dupes = adoptions.map((r) => r.id).filter((id, i, all) => all.indexOf(id) !== i);
500
528
  if (dupes.length) throw new PackError(`two policies would get the same id: ${[...new Set(dupes)].join(', ')}`);
501
529
 
530
+ const versionState = await remote.fetchVersion(packName);
531
+ if (versionState.supported && versionState.version && !opts.packVersion) {
532
+ throw new PackError(`pack "${packName}" is at v${versionState.version}; pass --pack-version with a greater version for adoption`);
533
+ }
534
+ const packVersion = opts.packVersion || versionState.version || '1.0.0';
502
535
  const existing = await remote.fetchManaged(packName);
503
536
  for (const r of adoptions) {
504
537
  const taken = [...existing.tool, ...existing.generic, ...existing.unsupported.map((u) => u.row)].find((row) => row.code_slug === r.id);
@@ -510,14 +543,21 @@ manage the pack with apply.
510
543
  const ok = await confirm(`Mark ${adoptions.length} polic${adoptions.length > 1 ? 'ies' : 'y'} as managed by pack "${packName}"?`);
511
544
  if (!ok) { output.warn('Aborted.'); return; }
512
545
  }
513
- for (const r of adoptions) {
514
- await remote.updateRow(r.table, r.row.id, { code_pack: packName, code_slug: r.id });
515
- // stderr on purpose: stdout carries only the pack file so it can be redirected.
516
- output.info(`Adopted "${r.row.name}" as ${packName}/${r.id}`);
546
+ if (versionState.supported) {
547
+ await remote.applyPlan(packName, packVersion, adoptions.map((r) => ({
548
+ op: 'adopt', table: r.table, row: r.row,
549
+ body: { origin: 'CODE', code_pack: packName, code_slug: r.id },
550
+ })), { expectedVersion: versionState.version });
551
+ } else {
552
+ output.warn('Older backend: adopting policies one at a time; this run is not atomic.');
553
+ for (const r of adoptions) await remote.updateRow(r.table, r.row.id, { code_pack: packName, code_slug: r.id });
517
554
  }
555
+ for (const r of adoptions) output.info(`Adopted "${r.row.name}" as ${packName}/${r.id}`);
518
556
  const ref = await remote.loadReference();
519
- const live = await remote.fetchManaged(packName);
520
- writeOrPrint(stringifyPack(liveToPack(packName, live, ref, { version: opts.packVersion })), opts.out);
557
+ const { live, version } = versionState.supported
558
+ ? await fetchStablePack(packName)
559
+ : { live: await remote.fetchManaged(packName), version: packVersion };
560
+ writeOrPrint(stringifyPack(liveToPack(packName, live, ref, { version: version || packVersion })), opts.out);
521
561
  } catch (err) {
522
562
  fail(err);
523
563
  }
@@ -527,40 +567,52 @@ manage the pack with apply.
527
567
  .command('delete <pack>')
528
568
  .description('Delete every policy a pack manages.')
529
569
  .option('--yes', 'Skip the confirmation prompt')
570
+ .option('--pack-version <version>', 'New version to store for the deletion (required when the pack has a stored version)')
530
571
  .action(async (packName, opts) => {
531
572
  try {
532
573
  requireLogin();
533
574
  if (!SLUG_RE.test(packName)) throw new PackError('pack name must match [a-z0-9][a-z0-9-]{0,63}');
575
+ const versionState = await remote.fetchVersion(packName);
534
576
  const live = await remote.fetchManaged(packName);
535
577
  const rows = [
536
578
  ...live.tool.map((row) => ({ table: 'tool', row })),
537
579
  ...live.generic.map((row) => ({ table: 'generic', row })),
538
580
  ];
581
+ if (versionState.supported && live.unsupported.length) {
582
+ throw new PackError(`pack "${packName}" has ${live.unsupported.length} policy type(s) this CLI cannot manage; update the CLI before deleting the pack`);
583
+ }
539
584
  for (const { row } of live.unsupported) output.warn(`${row.name}: unsupported policy type, left untouched; delete it in the console.`);
540
- if (!rows.length) {
585
+ if (!rows.length && (!versionState.supported || !versionState.version)) {
541
586
  output.info(`Pack "${packName}" manages no policies of a managed type in this org.`);
542
587
  return;
543
588
  }
589
+ if (versionState.supported && versionState.version && !opts.packVersion) {
590
+ throw new PackError(`pack "${packName}" is at v${versionState.version}; pass --pack-version with a greater version for deletion`);
591
+ }
544
592
  for (const { row } of rows) console.log(` ${row.name}`);
545
- if (!opts.yes) {
593
+ if (!opts.yes && rows.length) {
546
594
  const ok = await confirm(`Delete these ${rows.length} polic${rows.length > 1 ? 'ies' : 'y'}?`);
547
595
  if (!ok) { output.warn('Aborted.'); return; }
548
596
  }
549
- let failed = 0;
550
- for (const { table, row } of rows) {
551
- try {
552
- await remote.deleteRow(table, row.id);
553
- output.success(`Deleted ${row.name}`);
554
- } catch (err) {
555
- failed += 1;
556
- output.error(`${row.name}: ${err.message}`);
597
+ if (versionState.supported) {
598
+ await remote.applyPlan(packName, opts.packVersion || versionState.version || '1.0.0',
599
+ rows.map(({ table, row }) => ({ op: 'delete', table, row })),
600
+ { deletePack: true, expectedVersion: versionState.version });
601
+ for (const { row } of rows) output.success(`Deleted ${row.name}`);
602
+ if (!rows.length) output.success(`Pack "${packName}" has no policies; stored version advanced.`);
603
+ } else {
604
+ output.warn('Older backend: deleting policies one at a time; this run is not atomic.');
605
+ const results = await remote.writeOps(rows.map(({ table, row }) => ({ op: 'delete', table, row })));
606
+ for (const result of results) {
607
+ if (result.status === 'ok') output.success(`Deleted ${result.op.row.name}`);
608
+ else output.error(`${result.op.row.name}: ${result.error}`);
557
609
  }
610
+ if (results.some((result) => result.status === 'failed')) process.exitCode = 1;
558
611
  }
559
- if (failed) process.exitCode = 1;
560
612
  } catch (err) {
561
613
  fail(err);
562
614
  }
563
615
  });
564
616
  }
565
617
 
566
- module.exports = { register, _internals: { loadPackFiles, changeText, runApply, confirm } };
618
+ module.exports = { register, _internals: { loadPackFiles, changeText, runApply, fetchStablePack, confirm } };
@@ -8,6 +8,7 @@ const { PackError, TOOL_TYPES, GENERIC_KINDS } = require('./pack-schema');
8
8
  const TOOL_PATH = '/api/v1/command-policies/';
9
9
  const GENERIC_PATH = '/api/v1/policies/';
10
10
  const PAGE_SIZE = 200;
11
+ const PACK_PATH = (name) => `/api/v1/packs/${encodeURIComponent(name)}/`;
11
12
 
12
13
  // Every write declares itself as the code path. The server marks the row
13
14
  // `origin: CODE` (so the console only lets people switch it on or off) and lets
@@ -193,6 +194,35 @@ async function deleteRow(table, id) {
193
194
  return api.del(pathFor(table, id), { query: { origin: CODE_ORIGIN } });
194
195
  }
195
196
 
197
+ async function fetchVersion(name) {
198
+ try {
199
+ const result = await api.get(PACK_PATH(name));
200
+ return { supported: true, version: result.version || null };
201
+ } catch (err) {
202
+ if (err && err.statusCode === 404) return { supported: false, version: null };
203
+ throw err;
204
+ }
205
+ }
206
+
207
+ async function applyPlan(name, version, ops, { deletePack = false, expectedVersion = null } = {}) {
208
+ const operations = ops.map((op) => {
209
+ const operation = { op: op.op, table: op.table };
210
+ if (op.row) operation.id = op.row.id;
211
+ if (op.body) {
212
+ operation.body = {
213
+ ...op.body,
214
+ origin: CODE_ORIGIN,
215
+ code_pack: name,
216
+ code_slug: op.desired ? op.desired.id : op.body.code_slug,
217
+ };
218
+ }
219
+ return operation;
220
+ });
221
+ return api.post(`${PACK_PATH(name)}apply/`, {
222
+ body: { version, expected_version: expectedVersion, operations, delete_pack: deletePack },
223
+ });
224
+ }
225
+
196
226
  /**
197
227
  * Id of a freshly created row. The generic endpoint wraps in {policy}, the
198
228
  * command-policies endpoint returns the row unwrapped today; both envelopes
@@ -305,4 +335,6 @@ module.exports = {
305
335
  tempSlug,
306
336
  createdId,
307
337
  writeOps,
338
+ fetchVersion,
339
+ applyPlan,
308
340
  };
package/src/toolHealth.js CHANGED
@@ -206,12 +206,27 @@ function envKeyCheck(label, name, kind = 'aux') {
206
206
  };
207
207
  }
208
208
 
209
- // Setup writes UNBOUND_DEVICE_TOKEN; installs from before it carry only the per-tool name.
209
+ function configDeviceToken() {
210
+ try {
211
+ const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.unbound', 'config.json'), 'utf8'));
212
+ return cfg.api_key || cfg.device_token || null;
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ // Env wins when present so a stale exported key is still validated; setup keeps the token in ~/.unbound/config.json and removes the env var.
210
219
  function deviceTokenCheck(legacyName) {
211
220
  const check = envKeyCheck('API key env', 'UNBOUND_DEVICE_TOKEN');
212
221
  if (check.ok) return check;
213
222
  const legacy = envKeyCheck('API key env', legacyName);
214
- return legacy.ok ? legacy : check;
223
+ if (legacy.ok) return legacy;
224
+ const value = configDeviceToken();
225
+ if (!value) return check;
226
+ return {
227
+ name: 'API key env', ok: true, kind: 'aux', detail: 'device token set (config.json)', summary: 'API key set',
228
+ keyCheck: true, envName: 'device token', value, candidates: [value],
229
+ };
215
230
  }
216
231
 
217
232
  function envCheck(label, name, expected, kind = 'aux') {
@@ -0,0 +1,312 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const http = require('node:http');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { spawn } = require('node:child_process');
8
+
9
+ function runCli(file, port, home, args = ['apply', '-f', file, '--yes']) {
10
+ return new Promise((resolve, reject) => {
11
+ const child = spawn(process.execPath, [path.resolve(__dirname, '../src/index.js'),
12
+ 'pack', ...args], {
13
+ env: {
14
+ ...process.env, HOME: home, UNBOUND_TOKEN: 'test-token',
15
+ UNBOUND_API_URL: `http://127.0.0.1:${port}`,
16
+ UNBOUND_NO_UPDATE_CHECK: '1', UNBOUND_TELEMETRY: '0',
17
+ },
18
+ });
19
+ let stdout = '';
20
+ let stderr = '';
21
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
22
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
23
+ child.on('error', reject);
24
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
25
+ });
26
+ }
27
+
28
+ test('two CLI applies produce one success and one clear lock conflict', async () => {
29
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-cli-'));
30
+ const file = path.join(home, 'pack.yaml');
31
+ fs.writeFileSync(file, [
32
+ 'apiVersion: unbound.security/v1',
33
+ 'kind: PolicyPack',
34
+ 'metadata:',
35
+ ' name: sample',
36
+ ' version: 1.0.0',
37
+ 'policies:',
38
+ ' - id: budget',
39
+ ' kind: cost',
40
+ ' config:',
41
+ ' monthly_budget: 500',
42
+ '',
43
+ ].join('\n'));
44
+
45
+ let versionReads = [];
46
+ let applying = false;
47
+ let liveRows = 0;
48
+ const server = http.createServer((req, res) => {
49
+ const send = (status, body) => {
50
+ res.writeHead(status, { 'Content-Type': 'application/json' });
51
+ res.end(JSON.stringify(body));
52
+ };
53
+ if (req.method === 'GET' && req.url.startsWith('/api/v1/policies/form_data/')) {
54
+ return send(200, { user_groups: [], guardrails: [], ai_models: [], tool_types: [] });
55
+ }
56
+ if (req.method === 'GET' && req.url.startsWith('/api/v1/labels/')) return send(200, { labels: [] });
57
+ if (req.method === 'GET' && req.url.startsWith('/api/v1/command-policies/')) return send(200, { policies: [], total_pages: 1 });
58
+ if (req.method === 'GET' && req.url.startsWith('/api/v1/policies/')) return send(200, { policies: [] });
59
+ if (req.method === 'GET' && req.url === '/api/v1/packs/sample/') {
60
+ versionReads.push(res);
61
+ if (versionReads.length === 2) {
62
+ for (const pending of versionReads) {
63
+ pending.writeHead(200, { 'Content-Type': 'application/json' });
64
+ pending.end(JSON.stringify({ name: 'sample', version: null }));
65
+ }
66
+ versionReads = [];
67
+ }
68
+ return;
69
+ }
70
+ if (req.method === 'POST' && req.url === '/api/v1/packs/sample/apply/') {
71
+ if (applying) return send(409, { error: 'another apply is running, retry' });
72
+ applying = true;
73
+ return setTimeout(() => {
74
+ liveRows = 1;
75
+ applying = false;
76
+ send(200, { name: 'sample', version: '1.0.0', operations_applied: 1 });
77
+ }, 250);
78
+ }
79
+ return send(404, { error: 'not found' });
80
+ });
81
+ try {
82
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
83
+ const port = server.address().port;
84
+ const results = await Promise.all([runCli(file, port, home), runCli(file, port, home)]);
85
+ assert.deepEqual(results.map((result) => result.code).sort(), [0, 1]);
86
+ assert.equal(liveRows, 1);
87
+ assert.match(results.find((result) => result.code === 1).stderr, /another apply is running, retry/i);
88
+ } finally {
89
+ server.close();
90
+ fs.rmSync(home, { recursive: true, force: true });
91
+ }
92
+ });
93
+
94
+ test('folder apply continues after one pack fails atomically', async () => {
95
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-folder-'));
96
+ for (const name of ['alpha', 'beta']) {
97
+ fs.writeFileSync(path.join(home, `${name}.yaml`), [
98
+ 'apiVersion: unbound.security/v1', 'kind: PolicyPack', 'metadata:',
99
+ ` name: ${name}`, ' version: 1.0.0', 'policies:',
100
+ ` - id: ${name}-budget`, ' kind: cost', ' config:', ' monthly_budget: 500', '',
101
+ ].join('\n'));
102
+ }
103
+ const posts = [];
104
+ const server = http.createServer((req, res) => {
105
+ const send = (status, body) => {
106
+ res.writeHead(status, { 'Content-Type': 'application/json' });
107
+ res.end(JSON.stringify(body));
108
+ };
109
+ if (req.url.startsWith('/api/v1/policies/form_data/')) {
110
+ return send(200, { user_groups: [], guardrails: [], ai_models: [], tool_types: [] });
111
+ }
112
+ if (req.url.startsWith('/api/v1/labels/')) return send(200, { labels: [] });
113
+ if (req.url.startsWith('/api/v1/command-policies/')) return send(200, { policies: [], total_pages: 1 });
114
+ if (req.url.startsWith('/api/v1/policies/')) return send(200, { policies: [] });
115
+ const match = req.url.match(/^\/api\/v1\/packs\/(alpha|beta)\/(apply\/)?$/);
116
+ if (match && req.method === 'GET') return send(200, { name: match[1], version: null });
117
+ if (match && req.method === 'POST') {
118
+ posts.push(match[1]);
119
+ return match[1] === 'alpha'
120
+ ? send(409, { error: 'another apply is running, retry' })
121
+ : send(200, { name: 'beta', version: '1.0.0', operations_applied: 1 });
122
+ }
123
+ return send(404, { error: 'not found' });
124
+ });
125
+ try {
126
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
127
+ const result = await runCli(home, server.address().port, home);
128
+ assert.equal(result.code, 1);
129
+ assert.deepEqual(posts, ['alpha', 'beta']);
130
+ assert.match(result.stderr, /another apply is running, retry/i);
131
+ } finally {
132
+ server.close();
133
+ fs.rmSync(home, { recursive: true, force: true });
134
+ }
135
+ });
136
+
137
+ test('in-sync apply avoids an empty write', async () => {
138
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-noop-'));
139
+ const file = path.join(home, 'sample.yaml');
140
+ fs.writeFileSync(file, [
141
+ 'apiVersion: unbound.security/v1', 'kind: PolicyPack', 'metadata:',
142
+ ' name: sample', ' version: 1.0.0', 'policies:',
143
+ ' - id: budget', ' kind: cost', ' config:', ' monthly_budget: 500', '',
144
+ ].join('\n'));
145
+ let posts = 0;
146
+ const row = {
147
+ id: 5, name: 'budget', type: 'COST', origin: 'CODE', code_pack: 'sample',
148
+ code_slug: 'budget', enabled: true, status: 'ACTIVE', config: { monthly_budget: 500 },
149
+ scope_user_groups: [], scope_tool_types: [], labels: [],
150
+ };
151
+ const server = http.createServer((req, res) => {
152
+ const send = (status, body) => {
153
+ res.writeHead(status, { 'Content-Type': 'application/json' });
154
+ res.end(JSON.stringify(body));
155
+ };
156
+ if (req.url.startsWith('/api/v1/policies/form_data/')) {
157
+ return send(200, { user_groups: [], guardrails: [], ai_models: [], tool_types: [] });
158
+ }
159
+ if (req.url.startsWith('/api/v1/labels/')) return send(200, { labels: [] });
160
+ if (req.url.startsWith('/api/v1/command-policies/')) return send(200, { policies: [], total_pages: 1 });
161
+ if (req.url.startsWith('/api/v1/policies/')) return send(200, { policies: [row] });
162
+ if (req.url === '/api/v1/packs/sample/') return send(200, { name: 'sample', version: '1.0.0' });
163
+ if (req.method === 'POST') posts += 1;
164
+ return send(404, { error: 'not found' });
165
+ });
166
+ try {
167
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
168
+ const result = await runCli(file, server.address().port, home);
169
+ assert.equal(result.code, 0, result.stderr);
170
+ assert.equal(posts, 0);
171
+ } finally {
172
+ server.close();
173
+ fs.rmSync(home, { recursive: true, force: true });
174
+ }
175
+ });
176
+
177
+ test('delete requires a new version before prompting', async () => {
178
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-delete-version-'));
179
+ const row = {
180
+ id: 5, name: 'budget', type: 'COST', origin: 'CODE', code_pack: 'sample',
181
+ code_slug: 'budget', enabled: true, status: 'ACTIVE', config: { monthly_budget: 500 },
182
+ };
183
+ const server = http.createServer((req, res) => {
184
+ const send = (status, body) => {
185
+ res.writeHead(status, { 'Content-Type': 'application/json' });
186
+ res.end(JSON.stringify(body));
187
+ };
188
+ if (req.url.startsWith('/api/v1/command-policies/')) return send(200, { policies: [], total_pages: 1 });
189
+ if (req.url.startsWith('/api/v1/policies/')) return send(200, { policies: [row] });
190
+ if (req.url === '/api/v1/packs/sample/') return send(200, { name: 'sample', version: '1.0.0' });
191
+ return send(404, { error: 'not found' });
192
+ });
193
+ try {
194
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
195
+ const result = await runCli(null, server.address().port, home, ['delete', 'sample']);
196
+ assert.equal(result.code, 2);
197
+ assert.match(result.stderr, /pass --pack-version/);
198
+ assert.doesNotMatch(result.stdout, /Delete these/);
199
+ } finally {
200
+ server.close();
201
+ fs.rmSync(home, { recursive: true, force: true });
202
+ }
203
+ });
204
+
205
+ test('version-only apply stores the new version with an empty plan', async () => {
206
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-version-only-'));
207
+ const file = path.join(home, 'sample.yaml');
208
+ fs.writeFileSync(file, [
209
+ 'apiVersion: unbound.security/v1', 'kind: PolicyPack', 'metadata:',
210
+ ' name: sample', ' version: 2.0.0', 'policies:',
211
+ ' - id: budget', ' kind: cost', ' config:', ' monthly_budget: 500', '',
212
+ ].join('\n'));
213
+ const row = {
214
+ id: 5, name: 'budget', type: 'COST', origin: 'CODE', code_pack: 'sample',
215
+ code_slug: 'budget', enabled: true, status: 'ACTIVE', config: { monthly_budget: 500 },
216
+ scope_user_groups: [], scope_tool_types: [], labels: [],
217
+ };
218
+ let version = '1.0.0';
219
+ const writes = [];
220
+ const server = http.createServer((req, res) => {
221
+ const send = (status, body) => {
222
+ res.writeHead(status, { 'Content-Type': 'application/json' });
223
+ res.end(JSON.stringify(body));
224
+ };
225
+ if (req.url.startsWith('/api/v1/policies/form_data/')) return send(200, { user_groups: [], guardrails: [], ai_models: [], tool_types: [] });
226
+ if (req.url.startsWith('/api/v1/labels/')) return send(200, { labels: [] });
227
+ if (req.url.startsWith('/api/v1/command-policies/')) return send(200, { policies: [], total_pages: 1 });
228
+ if (req.url.startsWith('/api/v1/policies/')) return send(200, { policies: [row] });
229
+ if (req.url === '/api/v1/packs/sample/' && req.method === 'GET') return send(200, { name: 'sample', version });
230
+ if (req.url === '/api/v1/packs/sample/apply/' && req.method === 'POST') {
231
+ req.on('data', (chunk) => writes.push(chunk));
232
+ req.on('end', () => { version = '2.0.0'; send(200, { name: 'sample', version, operations_applied: 0 }); });
233
+ return;
234
+ }
235
+ return send(404, { error: 'not found' });
236
+ });
237
+ try {
238
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
239
+ const diff = await runCli(file, server.address().port, home, ['diff', '-f', file, '--json']);
240
+ assert.equal(diff.code, 1, diff.stderr);
241
+ assert.equal(JSON.parse(diff.stdout).packs[0].summary.drift, 1);
242
+ const result = await runCli(file, server.address().port, home);
243
+ assert.equal(result.code, 0, result.stderr);
244
+ assert.equal(version, '2.0.0');
245
+ assert.deepEqual(JSON.parse(Buffer.concat(writes).toString()).operations, []);
246
+ } finally {
247
+ server.close();
248
+ fs.rmSync(home, { recursive: true, force: true });
249
+ }
250
+ });
251
+
252
+ test('empty pack delete sends a versioned delete plan', async () => {
253
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-empty-delete-'));
254
+ let body;
255
+ const server = http.createServer((req, res) => {
256
+ const send = (status, data) => {
257
+ res.writeHead(status, { 'Content-Type': 'application/json' });
258
+ res.end(JSON.stringify(data));
259
+ };
260
+ if (req.url.startsWith('/api/v1/command-policies/')) return send(200, { policies: [], total_pages: 1 });
261
+ if (req.url.startsWith('/api/v1/policies/')) return send(200, { policies: [] });
262
+ if (req.url === '/api/v1/packs/sample/' && req.method === 'GET') return send(200, { name: 'sample', version: '1.0.0' });
263
+ if (req.url === '/api/v1/packs/sample/apply/' && req.method === 'POST') {
264
+ const chunks = [];
265
+ req.on('data', (chunk) => chunks.push(chunk));
266
+ req.on('end', () => { body = JSON.parse(Buffer.concat(chunks).toString()); send(200, { name: 'sample', version: '2.0.0', operations_applied: 0 }); });
267
+ return;
268
+ }
269
+ return send(404, { error: 'not found' });
270
+ });
271
+ try {
272
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
273
+ const result = await runCli(null, server.address().port, home, ['delete', 'sample', '--pack-version', '2.0.0']);
274
+ assert.equal(result.code, 0, result.stderr);
275
+ assert.deepEqual(body, { version: '2.0.0', expected_version: '1.0.0', operations: [], delete_pack: true });
276
+ } finally {
277
+ server.close();
278
+ fs.rmSync(home, { recursive: true, force: true });
279
+ }
280
+ });
281
+
282
+ test('adopt refuses to print a pack changed during the final read', async () => {
283
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-adopt-race-'));
284
+ let versionReads = 0;
285
+ const row = { id: 5, name: 'Budget', type: 'COST', origin: 'MANUAL', enabled: true, status: 'ACTIVE', config: { monthly_budget: 500 } };
286
+ const server = http.createServer((req, res) => {
287
+ const send = (status, data) => {
288
+ res.writeHead(status, { 'Content-Type': 'application/json' });
289
+ res.end(JSON.stringify(data));
290
+ };
291
+ if (req.url.startsWith('/api/v1/policies/form_data/')) return send(200, { user_groups: [], guardrails: [], ai_models: [], tool_types: [] });
292
+ if (req.url.startsWith('/api/v1/labels/')) return send(200, { labels: [] });
293
+ if (req.url.startsWith('/api/v1/command-policies/')) return send(200, { policies: [], total_pages: 1 });
294
+ if (req.url.startsWith('/api/v1/policies/')) return send(200, { policies: [row] });
295
+ if (req.url === '/api/v1/packs/sample/' && req.method === 'GET') {
296
+ versionReads += 1;
297
+ return send(200, { name: 'sample', version: versionReads === 1 ? '1.0.0' : versionReads === 2 ? '2.0.0' : '3.0.0' });
298
+ }
299
+ if (req.url === '/api/v1/packs/sample/apply/' && req.method === 'POST') return send(200, { name: 'sample', version: '2.0.0', operations_applied: 1 });
300
+ return send(404, { error: 'not found' });
301
+ });
302
+ try {
303
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
304
+ const result = await runCli(null, server.address().port, home, ['adopt', 'sample', '--from', 'Budget', '--pack-version', '2.0.0', '--yes']);
305
+ assert.equal(result.code, 2);
306
+ assert.match(result.stderr, /changed during export; retry/);
307
+ assert.equal(result.stdout, '');
308
+ } finally {
309
+ server.close();
310
+ fs.rmSync(home, { recursive: true, force: true });
311
+ }
312
+ });
@@ -2,7 +2,8 @@ const test = require('node:test');
2
2
  const assert = require('node:assert/strict');
3
3
  const { PassThrough } = require('node:stream');
4
4
 
5
- const { confirm } = require('../src/commands/pack')._internals;
5
+ const { confirm, fetchStablePack } = require('../src/commands/pack')._internals;
6
+ const remote = require('../src/lib/pack-remote');
6
7
 
7
8
  test('confirm fails closed when stdin is not a TTY', () => {
8
9
  const stdin = new PassThrough();
@@ -25,3 +26,31 @@ test('confirm resolves true on y', async () => {
25
26
  stdin.write('y\n');
26
27
  assert.equal(await p, true);
27
28
  });
29
+
30
+ test('export refuses rows read across a stored version change', async () => {
31
+ const fetchVersion = remote.fetchVersion;
32
+ const fetchManaged = remote.fetchManaged;
33
+ let reads = 0;
34
+ remote.fetchVersion = async () => ({ supported: true, version: ++reads === 1 ? '1.0.0' : '2.0.0' });
35
+ remote.fetchManaged = async () => ({ generic: [{ id: 1 }], tool: [], unsupported: [] });
36
+ try {
37
+ await assert.rejects(() => fetchStablePack('sample'), /changed during export; retry/);
38
+ } finally {
39
+ remote.fetchVersion = fetchVersion;
40
+ remote.fetchManaged = fetchManaged;
41
+ }
42
+ });
43
+
44
+ test('export accepts rows bracketed by the same stored version', async () => {
45
+ const fetchVersion = remote.fetchVersion;
46
+ const fetchManaged = remote.fetchManaged;
47
+ const live = { generic: [{ id: 1 }], tool: [], unsupported: [] };
48
+ remote.fetchVersion = async () => ({ supported: true, version: '2.0.0' });
49
+ remote.fetchManaged = async () => live;
50
+ try {
51
+ assert.deepEqual(await fetchStablePack('sample'), { live, version: '2.0.0' });
52
+ } finally {
53
+ remote.fetchVersion = fetchVersion;
54
+ remote.fetchManaged = fetchManaged;
55
+ }
56
+ });
@@ -25,6 +25,48 @@ function reset() {
25
25
  stub.onDel = async () => ({});
26
26
  }
27
27
 
28
+ test('fetchVersion preserves a stored version and only treats 404 as an old backend', async () => {
29
+ reset();
30
+ stub.onGet = async () => ({ name: 'base', version: '2.0.0' });
31
+ assert.deepEqual(await remote.fetchVersion('base'), { supported: true, version: '2.0.0' });
32
+ stub.onGet = async () => { throw Object.assign(new Error('missing'), { statusCode: 404 }); };
33
+ assert.deepEqual(await remote.fetchVersion('base'), { supported: false, version: null });
34
+ stub.onGet = async () => { throw Object.assign(new Error('unavailable'), { statusCode: 503 }); };
35
+ await assert.rejects(() => remote.fetchVersion('base'), /unavailable/);
36
+ });
37
+
38
+ test('applyPlan submits all operations in one request with a stored-version check', async () => {
39
+ reset();
40
+ await remote.applyPlan('base', '3.0.0', [
41
+ { op: 'update', table: 'tool', row: { id: 7, code_slug: 'tool' },
42
+ desired: { id: 'tool' }, body: { action: 'AUDIT' } },
43
+ { op: 'delete', table: 'generic', row: { id: 9 } },
44
+ ], { expectedVersion: '2.0.0' });
45
+ assert.equal(calls.length, 1);
46
+ assert.equal(calls[0][1], '/api/v1/packs/base/apply/');
47
+ assert.deepEqual(calls[0][2].body, {
48
+ version: '3.0.0', expected_version: '2.0.0', delete_pack: false,
49
+ operations: [
50
+ { op: 'update', table: 'tool', id: 7, body: {
51
+ action: 'AUDIT', origin: 'CODE', code_pack: 'base', code_slug: 'tool',
52
+ } },
53
+ { op: 'delete', table: 'generic', id: 9 },
54
+ ],
55
+ });
56
+ });
57
+
58
+ test('applyPlan retains the chosen slug when adopting a console policy', async () => {
59
+ reset();
60
+ await remote.applyPlan('base', '1.0.0', [{
61
+ op: 'adopt', table: 'generic', row: { id: 7 },
62
+ body: { origin: 'CODE', code_pack: 'base', code_slug: 'chosen-id' },
63
+ }]);
64
+ assert.deepEqual(calls[0][2].body.operations[0], {
65
+ op: 'adopt', table: 'generic', id: 7,
66
+ body: { origin: 'CODE', code_pack: 'base', code_slug: 'chosen-id' },
67
+ });
68
+ });
69
+
28
70
  test('replaceRow creates under a temporary name before deleting the old row', async () => {
29
71
  reset();
30
72
  await remote.replaceRow('tool', { id: '11', name: 'Block x' }, { name: 'Block x', policy_type: 'MCP_TOOL', code_pack: 'base', code_slug: 'x' });
@@ -1111,6 +1111,31 @@ test('claude-code: only the per-tool UNBOUND_CLAUDE_API_KEY → healthy', () =>
1111
1111
  assert.equal(claudeWithEnv({ UNBOUND_CLAUDE_API_KEY: 'k' }).status, 'healthy');
1112
1112
  });
1113
1113
 
1114
+ test('claude-code: device token in config.json and no env → healthy', () => {
1115
+ const t = withHome((tmp, th) => {
1116
+ writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(tmp, '.claude', 'hooks', 'unbound.py') }] } }));
1117
+ writeFile(path.join(tmp, '.claude', 'hooks', 'unbound.py'), '#');
1118
+ writeFile(path.join(tmp, '.unbound', 'config.json'), JSON.stringify({ device_token: 'k' }));
1119
+ return th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'claude-code');
1120
+ });
1121
+ assert.equal(t.status, 'healthy');
1122
+ });
1123
+
1124
+ test('claude-code: an exported key is the one validated even when config.json holds another', () => {
1125
+ process.env.UNBOUND_DEVICE_TOKEN = 'from-env';
1126
+ try {
1127
+ const t = withHome((tmp, th) => {
1128
+ writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(tmp, '.claude', 'hooks', 'unbound.py') }] } }));
1129
+ writeFile(path.join(tmp, '.claude', 'hooks', 'unbound.py'), '#');
1130
+ writeFile(path.join(tmp, '.unbound', 'config.json'), JSON.stringify({ device_token: 'from-config' }));
1131
+ return th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'claude-code');
1132
+ });
1133
+ assert.equal(t.checks.find((c) => c.keyCheck).value, 'from-env');
1134
+ } finally {
1135
+ delete process.env.UNBOUND_DEVICE_TOKEN;
1136
+ }
1137
+ });
1138
+
1114
1139
  test('claude-code: neither env set → tampered, naming UNBOUND_DEVICE_TOKEN', () => {
1115
1140
  const t = claudeWithEnv({});
1116
1141
  assert.equal(t.status, 'tampered');