@celilo/cli 0.9.1 → 0.11.0-alpha.0

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.
Files changed (34) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +16 -0
  3. package/drizzle/0014_api_principals.sql +10 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +14 -6
  6. package/src/api/protocol.test.ts +76 -0
  7. package/src/api/remote-client.test.ts +91 -0
  8. package/src/api/serve.ts +159 -0
  9. package/src/cli/command-tree-parser.ts +3 -1
  10. package/src/cli/commands/api.ts +194 -0
  11. package/src/cli/commands/apt-upgrade.test.ts +33 -0
  12. package/src/cli/commands/apt-upgrade.ts +63 -0
  13. package/src/cli/commands/commands-json.ts +29 -0
  14. package/src/cli/commands/completion.ts +1 -1
  15. package/src/cli/commands/module-list.ts +16 -2
  16. package/src/cli/commands/publish/helpers.ts +18 -0
  17. package/src/cli/commands/publish/types.ts +6 -8
  18. package/src/cli/commands/publish/workspace.test.ts +44 -7
  19. package/src/cli/commands/publish/workspace.ts +40 -164
  20. package/src/cli/commands/service-list.ts +15 -2
  21. package/src/cli/completion.ts +24 -0
  22. package/src/cli/generate-zsh-completion.test.ts +22 -4
  23. package/src/cli/generate-zsh-completion.ts +7 -3
  24. package/src/cli/index.ts +109 -2
  25. package/src/cli/parser.test.ts +13 -0
  26. package/src/cli/parser.ts +12 -3
  27. package/src/db/schema.ts +30 -0
  28. package/src/hooks/capability-loader.test.ts +77 -0
  29. package/src/hooks/capability-loader.ts +56 -0
  30. package/src/services/api-access.test.ts +138 -0
  31. package/src/services/api-access.ts +154 -0
  32. package/src/services/remote-responder.test.ts +78 -0
  33. package/src/services/remote-responder.ts +89 -0
  34. package/src/cli/command-registry.ts +0 -1443
@@ -0,0 +1,63 @@
1
+ /**
2
+ * `celilo apt-upgrade` — upgrade the deb-installed celilo packages and apply
3
+ * pending DB migrations. The management server (celilo-bootstrap) installs
4
+ * celilo via apt, so keeping it current means the apt chain, not `bun update -g`
5
+ * (that path is `system update`'s self-update, for npm-global installs).
6
+ *
7
+ * Steps (ISS-0100 — the postinst does NOT auto-apply migrations):
8
+ * 1. apt-get update
9
+ * 2. apt-get -y --only-upgrade install celilo celilo-bootstrap
10
+ * 3. a FRESH `celilo system migrate` — spawned as the just-installed binary so
11
+ * the new version's migrations run, not the ones loaded in this process.
12
+ *
13
+ * This is the RW target behind the MCP's `celilo_apt_upgrade` tool. It runs as
14
+ * the celilo user (via api-serve); the apt steps sudo to root, gated by the
15
+ * scoped /etc/sudoers.d/celilo-apt-upgrade grant that celilo-bootstrap ships.
16
+ */
17
+
18
+ import { spawnSync } from 'node:child_process';
19
+ import type { CommandResult } from '../types';
20
+
21
+ /** Wrapper the deb installs; the fresh migrate step runs the upgraded binary. */
22
+ const CELILO_BIN = '/usr/local/bin/celilo';
23
+
24
+ /** One command to run in the chain — argv plus a human label for output. */
25
+ interface Step {
26
+ label: string;
27
+ argv: string[];
28
+ }
29
+
30
+ const STEPS: Step[] = [
31
+ { label: 'apt-get update', argv: ['sudo', 'apt-get', 'update'] },
32
+ {
33
+ label: 'apt-get upgrade celilo, celilo-bootstrap',
34
+ argv: ['sudo', 'apt-get', '-y', '--only-upgrade', 'install', 'celilo', 'celilo-bootstrap'],
35
+ },
36
+ { label: 'apply DB migrations', argv: [CELILO_BIN, 'system', 'migrate'] },
37
+ ];
38
+
39
+ /** Run one argv, inheriting stdio so its output streams through api-serve. */
40
+ export type StepRunner = (argv: string[]) => { status: number | null };
41
+
42
+ const defaultRunner: StepRunner = (argv) => spawnSync(argv[0], argv.slice(1), { stdio: 'inherit' });
43
+
44
+ export async function handleAptUpgrade(
45
+ _args: string[],
46
+ _flags: Record<string, string | boolean>,
47
+ runStep: StepRunner = defaultRunner,
48
+ ): Promise<CommandResult> {
49
+ for (const step of STEPS) {
50
+ process.stdout.write(`\n▸ ${step.label}\n`);
51
+ const { status } = runStep(step.argv);
52
+ if (status !== 0) {
53
+ return {
54
+ success: false,
55
+ error: `apt-upgrade failed at "${step.label}" (exit ${status ?? 'signal'}). Nothing further was run.`,
56
+ };
57
+ }
58
+ }
59
+ return {
60
+ success: true,
61
+ message: 'celilo apt packages upgraded and migrations applied.',
62
+ };
63
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Commands Command
3
+ *
4
+ * Serializes the CLI command registry (`COMMANDS`) as JSON so an external
5
+ * consumer — `@celilo/mcp` above all — can fetch the *live* surface of whatever
6
+ * celilo version this server runs and generate its tool set from it, rather than
7
+ * compiling a copy that drifts (design D3). The registry is already structured
8
+ * data; this just prints it.
9
+ */
10
+
11
+ import { COMMANDS } from '@celilo/core';
12
+ import type { CommandResult } from '../types';
13
+
14
+ /**
15
+ * Handle `celilo commands [--json]`.
16
+ *
17
+ * The only useful output is the JSON tree, so we emit it whether or not `--json`
18
+ * is passed; the flag exists to make the intent explicit and completable.
19
+ */
20
+ export async function handleCommands(
21
+ _args: string[],
22
+ _flags: Record<string, boolean | string> = {},
23
+ ): Promise<CommandResult> {
24
+ return {
25
+ success: true,
26
+ message: JSON.stringify(COMMANDS),
27
+ rawOutput: true,
28
+ };
29
+ }
@@ -3,7 +3,7 @@
3
3
  * Generate shell completion scripts for bash/zsh
4
4
  */
5
5
 
6
- import { COMMANDS } from '../command-registry';
6
+ import { COMMANDS } from '@celilo/core';
7
7
  import { generateBashCompletion, generateFishCompletion } from '../completion';
8
8
  import { generateRichZshCompletion } from '../generate-zsh-completion';
9
9
  import { celiloIntro } from '../prompts';
@@ -1,20 +1,34 @@
1
1
  import { getDb } from '../../db/client';
2
2
  import { modules } from '../../db/schema';
3
+ import { hasFlag } from '../parser';
3
4
  import type { CommandResult } from '../types';
4
5
 
5
6
  /**
6
7
  * Handle module list command
7
8
  *
8
- * Usage: celilo module list
9
+ * Usage: celilo module list [--json]
9
10
  *
10
11
  * @returns Command result
11
12
  */
12
- export async function handleModuleList(): Promise<CommandResult> {
13
+ export async function handleModuleList(
14
+ flags: Record<string, string | boolean> = {},
15
+ ): Promise<CommandResult> {
13
16
  const db = getDb();
14
17
 
15
18
  // Query all modules
16
19
  const moduleRows = db.select().from(modules).all();
17
20
 
21
+ // Stable machine-readable roster — the backbone the MCP composite
22
+ // troubleshooting tools correlate audit findings against (ce-77i.5).
23
+ if (hasFlag(flags, 'json')) {
24
+ return {
25
+ success: true,
26
+ message: JSON.stringify({ modules: moduleRows }, null, 2),
27
+ rawOutput: true,
28
+ data: moduleRows,
29
+ };
30
+ }
31
+
18
32
  if (moduleRows.length === 0) {
19
33
  return {
20
34
  success: true,
@@ -125,6 +125,24 @@ export function readExternalProjectPaths(): string[] {
125
125
  return [];
126
126
  }
127
127
 
128
+ /**
129
+ * Read `NPM_PUBLISH_TARGET` from .env — the registry URL `celilo publish`
130
+ * points `bun publish --registry` at for @celilo/* packages (a deployed
131
+ * npm-cache-node). Unset → publish to npmjs (current behavior). See
132
+ * v2/NPM_CACHE_NODE.md Phase 3.1 / v2/PUBLILO_CLI.md decision 10.
133
+ */
134
+ export function readNpmPublishTarget(): string | null {
135
+ if (!existsSync(ENV_FILE)) return null;
136
+ const content = readFileSync(ENV_FILE, 'utf-8');
137
+ for (const line of content.split('\n')) {
138
+ const m = line.match(/^\s*NPM_PUBLISH_TARGET\s*=\s*(.+?)\s*$/);
139
+ if (!m) continue;
140
+ const raw = m[1].replace(/^["']|["']$/g, '').trim();
141
+ return raw || null;
142
+ }
143
+ return null;
144
+ }
145
+
128
146
  /**
129
147
  * Recursively find every package.json under a root, skipping
130
148
  * node_modules and common build-output dirs (so we don't try to rewrite
@@ -139,15 +139,13 @@ export interface RewriteOptions {
139
139
  }
140
140
 
141
141
  /**
142
- * Per-package pre-publish work. Currently only `@celilo/e2e` triggers
143
- * any of these (it ships netapps + registry server + caches inside its
144
- * tarball), but the plan still enumerates them so dry-run can show
145
- * what extra work the publish entails.
142
+ * Per-package pre-publish work. Currently only `@celilo/e2e` triggers this
143
+ * (it bundles the npm-compat registry server source inside its tarball).
144
+ * Sim-content caches (website/npm) and standard-module netapps are NOT
145
+ * bundled at publish — they're fetched from the public celilo sources at
146
+ * `cele2e build-infra` time instead (ce-qwz Decisions 2B + 3, ce-i2i).
146
147
  */
147
- export type PrePublishHookKind =
148
- | 'registryServerBundle'
149
- | 'rebuildE2eNetapps'
150
- | 'stageE2ePublishCaches';
148
+ export type PrePublishHookKind = 'registryServerBundle';
151
149
 
152
150
  /**
153
151
  * Single workspace package planned to publish (or explicitly skip) in
@@ -57,6 +57,7 @@ mock.module('./helpers', () => ({
57
57
  },
58
58
  listModuleDirs: () => [],
59
59
  readExternalProjectPaths: () => [],
60
+ readNpmPublishTarget: () => null,
60
61
  findPackageJsons: () => [],
61
62
  bareVersion: (s: string) => s.replace(/^[\s^~=><]+/, '').trim(),
62
63
  withOperator: (oldSpec: string, newVersion: string) => {
@@ -74,6 +75,10 @@ mock.module('./alpha', () => ({
74
75
  return { name: spec.slice(0, i), version: spec.slice(i + 1) };
75
76
  },
76
77
  stripAlphaSuffix: (v: string) => v.replace(/-alpha\.\d+$/, ''),
78
+ prereleaseDistTag: (v: string) => {
79
+ const dash = v.indexOf('-');
80
+ return dash === -1 ? undefined : v.slice(dash + 1).split('.')[0] || undefined;
81
+ },
77
82
  isAlphaVersion: (v: string) => /-alpha\.\d+$/.test(v),
78
83
  nextAlphaNumber: (name: string, semverCore: string) =>
79
84
  nextAlphaResponses[`${name}@${semverCore}`] ?? 0,
@@ -84,7 +89,7 @@ mock.module('./alpha', () => ({
84
89
  decideAlphaSkip: () => ({ skip: false }),
85
90
  }));
86
91
 
87
- const { planWorkspace } = await import('./workspace');
92
+ const { planWorkspace, buildPublishArgs } = await import('./workspace');
88
93
 
89
94
  function buildBaseMap(): Map<string, string> {
90
95
  const m = new Map<string, string>();
@@ -131,7 +136,7 @@ describe('planWorkspace', () => {
131
136
  expect(cli?.skipReason).toBeUndefined();
132
137
  });
133
138
 
134
- test('e2e gets the registry-bundle + netapps + cache hooks', () => {
139
+ test('e2e gets only the registry-bundle hook (caches/netapps fetched at build-infra, not bundled)', () => {
135
140
  publishedSet = new Set();
136
141
  const result = planWorkspace({
137
142
  mode: { kind: 'normal' },
@@ -141,11 +146,7 @@ describe('planWorkspace', () => {
141
146
  });
142
147
 
143
148
  const e2e = result.items.find((i) => i.pkg === 'packages/e2e');
144
- expect(e2e?.hooks).toEqual([
145
- 'registryServerBundle',
146
- 'rebuildE2eNetapps',
147
- 'stageE2ePublishCaches',
148
- ]);
149
+ expect(e2e?.hooks).toEqual(['registryServerBundle']);
149
150
  const cli = result.items.find((i) => i.pkg === 'apps/celilo');
150
151
  expect(cli?.hooks).toEqual([]);
151
152
  });
@@ -302,6 +303,42 @@ describe('planWorkspace', () => {
302
303
  });
303
304
  });
304
305
 
306
+ describe('buildPublishArgs registry target', () => {
307
+ test('no target → default registry (unchanged behavior)', () => {
308
+ expect(buildPublishArgs({ name: '@celilo/cli', tag: undefined }, null)).toEqual([
309
+ 'publish',
310
+ '--access',
311
+ 'public',
312
+ ]);
313
+ });
314
+
315
+ test('target set → --registry appended for @celilo/* package', () => {
316
+ expect(
317
+ buildPublishArgs({ name: '@celilo/cli', tag: undefined }, 'https://npm.example.test/'),
318
+ ).toEqual(['publish', '--access', 'public', '--registry', 'https://npm.example.test/']);
319
+ });
320
+
321
+ test('tag and registry both present', () => {
322
+ expect(
323
+ buildPublishArgs({ name: '@celilo/cli', tag: 'alpha' }, 'https://npm.example.test/'),
324
+ ).toEqual([
325
+ 'publish',
326
+ '--access',
327
+ 'public',
328
+ '--tag',
329
+ 'alpha',
330
+ '--registry',
331
+ 'https://npm.example.test/',
332
+ ]);
333
+ });
334
+
335
+ test('non-@celilo package never gets the private registry', () => {
336
+ expect(
337
+ buildPublishArgs({ name: 'some-other-pkg', tag: undefined }, 'https://npm.example.test/'),
338
+ ).toEqual(['publish', '--access', 'public']);
339
+ });
340
+ });
341
+
305
342
  describe('dependency order', () => {
306
343
  test('preserves PACKAGES order in planned items', () => {
307
344
  publishedSet = new Set();
@@ -17,18 +17,8 @@
17
17
  */
18
18
 
19
19
  import { spawnSync } from 'node:child_process';
20
- import {
21
- cpSync,
22
- existsSync,
23
- mkdirSync,
24
- readFileSync,
25
- readdirSync,
26
- rmSync,
27
- statSync,
28
- unlinkSync,
29
- writeFileSync,
30
- } from 'node:fs';
31
- import { join, relative } from 'node:path';
20
+ import { readFileSync, writeFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
32
22
  import {
33
23
  ALPHA_TAG,
34
24
  alphaSkipDecision,
@@ -36,7 +26,7 @@ import {
36
26
  prereleaseDistTag,
37
27
  stripAlphaSuffix,
38
28
  } from './alpha';
39
- import { REPO_ROOT, isPublished, readPkg } from './helpers';
29
+ import { REPO_ROOT, isPublished, readNpmPublishTarget, readPkg } from './helpers';
40
30
  import type {
41
31
  PackageJson,
42
32
  PublishMode,
@@ -74,13 +64,15 @@ export interface PlanWorkspaceOutput {
74
64
 
75
65
  /**
76
66
  * Pre-publish hooks that fire for @celilo/e2e (the only package that
77
- * needs them today). Listed in the WorkspaceItem so dry-run can show
78
- * "@celilo/e2e (will rebuild netapps, stage caches)" without executing
79
- * anything.
67
+ * needs one today): refresh the bundled npm-compat registry-server source.
68
+ * Sim-content caches and standard-module netapps are no longer staged into
69
+ * the tarball at publish — a monorepo-free consumer fetches them from the
70
+ * public celilo sources at `cele2e build-infra` time (ce-qwz Decisions
71
+ * 2B + 3, ce-i2i).
80
72
  */
81
73
  function workspaceHooksFor(pkg: string): WorkspaceItem['hooks'] {
82
74
  if (pkg !== 'packages/e2e') return [];
83
- return ['registryServerBundle', 'rebuildE2eNetapps', 'stageE2ePublishCaches'];
75
+ return ['registryServerBundle'];
84
76
  }
85
77
 
86
78
  /**
@@ -328,147 +320,23 @@ export async function verifyPublishedDeps(
328
320
  }
329
321
  }
330
322
 
331
- // ─── Per-package pre-publish hooks ─────────────────────────────────
332
-
333
- /**
334
- * Modules excluded from the @celilo/e2e netapps shipment:
335
- * - celilo-registry: bundles the bun-compiled registry server
336
- * binaries (~76 MB packed). Not used by typical consumer e2e
337
- * tests; including it would bloat the npm tarball past the SSL
338
- * transport's reliable window.
339
- * - archive: not a real module dir.
340
- */
341
- const E2E_NETAPP_EXCLUDES = new Set(['archive', 'celilo-registry']);
342
-
343
- /**
344
- * Pre-publish step for @celilo/e2e: package each (non-excluded)
345
- * module under `<root>/modules/` into `packages/e2e/netapps/`. Replaces
346
- * whatever was last left there by a local `cele2e build-infra` so the
347
- * shipped tarball is always built from the current branch, not the
348
- * publisher's last local development build.
349
- */
350
- export function rebuildE2eNetapps(repoRoot: string): void {
351
- const modulesDir = join(repoRoot, 'modules');
352
- const netappsDir = join(repoRoot, 'packages/e2e/netapps');
353
- const celiloWrapper = join(repoRoot, 'celilo');
354
-
355
- if (!existsSync(modulesDir)) {
356
- console.warn(
357
- `⚠ ${modulesDir} not found — skipping netapp rebuild. The published tarball will reuse whatever's currently in packages/e2e/netapps/.`,
358
- );
359
- return;
360
- }
361
- if (!existsSync(celiloWrapper)) {
362
- console.warn(
363
- `⚠ ${celiloWrapper} not found — skipping netapp rebuild. Same staleness risk as above.`,
364
- );
365
- return;
366
- }
367
-
368
- console.log('Rebuilding packages/e2e/netapps/ from infra/modules/...');
369
-
370
- for (const existing of readdirSync(netappsDir).filter((f) => f.endsWith('.netapp'))) {
371
- try {
372
- unlinkSync(join(netappsDir, existing));
373
- } catch {
374
- // Best effort
375
- }
376
- }
377
-
378
- const moduleDirs = readdirSync(modulesDir).filter((name) => {
379
- if (E2E_NETAPP_EXCLUDES.has(name)) return false;
380
- const dir = join(modulesDir, name);
381
- try {
382
- return statSync(dir).isDirectory() && existsSync(join(dir, 'manifest.yml'));
383
- } catch {
384
- return false;
385
- }
386
- });
387
-
388
- let okCount = 0;
389
- const failures: Array<{ name: string; stderr: string }> = [];
390
- for (const name of moduleDirs) {
391
- const moduleDir = join(modulesDir, name);
392
- const out = join(netappsDir, `${name}.netapp`);
393
- const r = spawnSync(celiloWrapper, ['package', moduleDir, '--output', out], {
394
- cwd: repoRoot,
395
- stdio: ['ignore', 'pipe', 'pipe'],
396
- encoding: 'utf-8',
397
- });
398
- if (r.status === 0) {
399
- okCount++;
400
- } else {
401
- failures.push({ name, stderr: (r.stderr ?? '').trim() || `exit ${r.status}` });
402
- }
403
- }
404
-
405
- if (failures.length > 0) {
406
- console.error(`✗ Failed to package ${failures.length} module(s):`);
407
- for (const f of failures) {
408
- console.error(` ${f.name}: ${f.stderr}`);
409
- }
410
- console.error(
411
- 'Aborting publish — shipping with missing netapps would silently break consumer e2e tests.',
412
- );
413
- process.exit(1);
414
- }
415
-
416
- console.log(
417
- `Refreshed ${okCount} netapp(s) in packages/e2e/netapps/ (excluded: ${[...E2E_NETAPP_EXCLUDES].join(', ')}).\n`,
418
- );
419
- }
420
-
421
323
  /**
422
- * Pre-publish step for @celilo/e2e: stage `.celilo-website-cache/` and
423
- * `.npm-registry-cache/` inside packages/e2e/ so the tarball ships with
424
- * everything Dockerfile.celilo-website-sim and Dockerfile.npm-registry-sim
425
- * need to COPY at docker-build time.
324
+ * Build the `bun publish` argv for a workspace item. When a private
325
+ * registry target is configured (a deployed npm-cache-node) AND the
326
+ * package is @celilo/*-scoped, point bun at it via --registry; otherwise
327
+ * publish to the default registry (npmjs — current behavior).
328
+ * v2/NPM_CACHE_NODE.md Phase 3.1 / v2/PUBLILO_CLI.md decision 10.
426
329
  */
427
- export function stageE2ePublishCaches(repoRoot: string): void {
428
- const websiteSrc = join(repoRoot, 'modules', 'celilo-website', 'site');
429
- const e2eDir = join(repoRoot, 'packages', 'e2e');
430
- const websiteCache = join(e2eDir, '.celilo-website-cache');
431
- const npmCache = join(e2eDir, '.npm-registry-cache');
432
- const packScript = join(e2eDir, 'scripts', 'pack-celilo-packages.ts');
433
-
434
- if (!existsSync(websiteSrc)) {
435
- console.error(`✗ ${websiteSrc} not found — cannot stage .celilo-website-cache.`);
436
- process.exit(1);
330
+ export function buildPublishArgs(
331
+ item: Pick<WorkspaceItem, 'name' | 'tag'>,
332
+ registryTarget: string | null,
333
+ ): string[] {
334
+ const args = ['publish', '--access', 'public'];
335
+ if (item.tag) args.push('--tag', item.tag);
336
+ if (registryTarget && item.name.startsWith('@celilo/')) {
337
+ args.push('--registry', registryTarget);
437
338
  }
438
- if (!existsSync(packScript)) {
439
- console.error(`✗ ${packScript} not found — cannot stage .npm-registry-cache.`);
440
- process.exit(1);
441
- }
442
-
443
- console.log('Staging .celilo-website-cache/ from modules/celilo-website/site/...');
444
- const installResult = spawnSync('bun', ['install'], { cwd: websiteSrc, stdio: 'pipe' });
445
- if (installResult.status !== 0) {
446
- console.error('✗ bun install for celilo-website failed:');
447
- console.error(installResult.stderr?.toString());
448
- process.exit(1);
449
- }
450
- const buildResult = spawnSync('bun', ['run', 'build'], { cwd: websiteSrc, stdio: 'pipe' });
451
- if (buildResult.status !== 0) {
452
- console.error('✗ bun run build for celilo-website failed:');
453
- console.error(buildResult.stderr?.toString());
454
- process.exit(1);
455
- }
456
- rmSync(websiteCache, { recursive: true, force: true });
457
- mkdirSync(websiteCache, { recursive: true });
458
- cpSync(join(websiteSrc, 'dist'), websiteCache, { recursive: true });
459
- console.log(`✓ Staged .celilo-website-cache/ (from ${relative(repoRoot, websiteSrc)}/dist/)\n`);
460
-
461
- console.log('Staging .npm-registry-cache/ from @celilo/* workspace tarballs...');
462
- const packResult = spawnSync('bun', ['run', packScript], { cwd: repoRoot, stdio: 'pipe' });
463
- if (packResult.status !== 0) {
464
- console.error('✗ pack-celilo-packages.ts failed:');
465
- console.error(packResult.stderr?.toString());
466
- process.exit(1);
467
- }
468
- const packed = existsSync(npmCache)
469
- ? readdirSync(npmCache).filter((f) => f.endsWith('.tgz'))
470
- : [];
471
- console.log(`✓ Staged .npm-registry-cache/ with ${packed.length} workspace tarball(s)\n`);
339
+ return args;
472
340
  }
473
341
 
474
342
  // ─── Executor ──────────────────────────────────────────────────────
@@ -486,6 +354,13 @@ export async function executeWorkspace(input: ExecuteWorkspaceInput): Promise<Pu
486
354
  const published: PublishResult['published'] = [];
487
355
  const skipped: string[] = [];
488
356
 
357
+ // When an npm-cache-node is configured, @celilo/* tarballs go there
358
+ // (the cache forwards upstream under policy); unset → npmjs.
359
+ const registryTarget = readNpmPublishTarget();
360
+ if (registryTarget) {
361
+ console.log(`\nPublishing @celilo/* to configured registry: ${registryTarget}`);
362
+ }
363
+
489
364
  for (const item of items) {
490
365
  const { pkg, name, baseVersion, versionToPublish } = item;
491
366
 
@@ -511,12 +386,6 @@ export async function executeWorkspace(input: ExecuteWorkspaceInput): Promise<Pu
511
386
  'Refreshed packages/e2e/registry-server/ bundle from packages/registry-server.\n',
512
387
  );
513
388
  }
514
- if (item.hooks.includes('rebuildE2eNetapps')) {
515
- rebuildE2eNetapps(REPO_ROOT);
516
- }
517
- if (item.hooks.includes('stageE2ePublishCaches')) {
518
- stageE2ePublishCaches(REPO_ROOT);
519
- }
520
389
 
521
390
  const { original: pkgJsonOriginal, rewrites: workspaceRewrites } = rewriteWorkspaceDeps(
522
391
  pkg,
@@ -548,8 +417,7 @@ export async function executeWorkspace(input: ExecuteWorkspaceInput): Promise<Pu
548
417
  process.exit(1);
549
418
  }
550
419
 
551
- const publishArgs = ['publish', '--access', 'public'];
552
- if (item.tag) publishArgs.push('--tag', item.tag);
420
+ const publishArgs = buildPublishArgs(item, registryTarget);
553
421
 
554
422
  let publishStatus: number | null = null;
555
423
  let publishError: unknown = null;
@@ -591,7 +459,15 @@ export async function executeWorkspace(input: ExecuteWorkspaceInput): Promise<Pu
591
459
  }
592
460
 
593
461
  if (workspaceRewrites.length > 0) {
594
- await verifyPublishedDeps(name, versionToPublish, workspaceRewrites);
462
+ // Verify against the same registry we published to — a cache node
463
+ // forwards upstream under policy, so the version may not be on
464
+ // npmjs yet.
465
+ await verifyPublishedDeps(
466
+ name,
467
+ versionToPublish,
468
+ workspaceRewrites,
469
+ registryTarget ?? undefined,
470
+ );
595
471
  }
596
472
 
597
473
  // Silence the unused-var warning on baseVersion — we keep the value
@@ -22,8 +22,6 @@ export async function handleServiceList(
22
22
  flags: Record<string, boolean | string> = {},
23
23
  ): Promise<CommandResult> {
24
24
  try {
25
- celiloIntro('Container Services');
26
-
27
25
  // Apply zone filter if provided
28
26
  const filters: ContainerServiceFilters = {};
29
27
  if (flags.zone && typeof flags.zone === 'string') {
@@ -32,6 +30,21 @@ export async function handleServiceList(
32
30
 
33
31
  const services = await listContainerServices(filters);
34
32
 
33
+ // Machine-readable output (consumed by @celilo/mcp auto-detect). Emit the
34
+ // fields detection needs — provider drives which tool groups surface.
35
+ if (flags.json) {
36
+ const payload = services.map((s) => ({
37
+ serviceId: s.serviceId,
38
+ name: s.name,
39
+ provider: s.providerName,
40
+ zones: s.zones,
41
+ verified: s.verified,
42
+ }));
43
+ return { success: true, message: JSON.stringify(payload), rawOutput: true };
44
+ }
45
+
46
+ celiloIntro('Container Services');
47
+
35
48
  if (services.length === 0) {
36
49
  console.log('No container services configured.\n');
37
50
  console.log('Add a service:');
@@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm';
7
7
  import { getDb } from '../db/client';
8
8
  import { capabilities, modules } from '../db/schema';
9
9
  import type { ModuleManifest } from '../manifest/schema';
10
+ import { listPrincipals } from '../services/api-access';
10
11
  import { listBackups } from '../services/backup-metadata';
11
12
  import { listBackupStorages } from '../services/backup-storage';
12
13
  import { listContainerServices } from '../services/container-service';
@@ -28,9 +29,12 @@ export async function getCompletions(words: string[], current: number): Promise<
28
29
  // currentIndex === 0 means we're completing the first word (the command)
29
30
  if (currentIndex === 0) {
30
31
  const commands = [
32
+ 'api',
33
+ 'apt-upgrade',
31
34
  'audit',
32
35
  'backup',
33
36
  'capability',
37
+ 'commands',
34
38
  'dns',
35
39
  'completion',
36
40
  'events',
@@ -332,6 +336,26 @@ export async function getCompletions(words: string[], current: number): Promise<
332
336
  return filterSuggestions(configKeys, args[4] || '');
333
337
  }
334
338
 
339
+ // API subcommands
340
+ if (command === 'api' && currentIndex === 1) {
341
+ const subcommands = ['grant', 'list', 'revoke', 'authorized-keys', 'key'];
342
+ return filterSuggestions(subcommands, args[1] || '');
343
+ }
344
+
345
+ // API revoke - complete with principal names
346
+ if (command === 'api' && args[1] === 'revoke' && currentIndex === 2) {
347
+ const principals = await listPrincipals();
348
+ return filterSuggestions(
349
+ principals.map((p) => p.name),
350
+ args[2] || '',
351
+ );
352
+ }
353
+
354
+ // API key subcommands
355
+ if (command === 'api' && args[1] === 'key' && currentIndex === 2) {
356
+ return filterSuggestions(['new'], args[2] || '');
357
+ }
358
+
335
359
  // Machine subcommands
336
360
  if (command === 'machine' && currentIndex === 1) {
337
361
  const subcommands = ['add', 'list', 'status', 'remove', 'earmark', 'detect'];
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { COMMANDS } from './command-registry';
3
- import type { CommandDef } from './command-registry';
2
+ import { COMMANDS } from '@celilo/core';
3
+ import type { CommandDef } from '@celilo/core';
4
4
  import { generateRichZshCompletion } from './generate-zsh-completion';
5
5
 
6
6
  describe('Zsh Completion Generator', () => {
@@ -79,12 +79,15 @@ describe('Zsh Completion Generator', () => {
79
79
  });
80
80
 
81
81
  test('all subcommand descriptions are present', () => {
82
+ function escapeDesc(text: string): string {
83
+ return text.replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "'\\''");
84
+ }
82
85
  function checkDescriptions(commands: CommandDef[]): void {
83
86
  for (const cmd of commands) {
84
87
  if (cmd.subcommands) {
85
88
  for (const sub of cmd.subcommands) {
86
- // Description should appear in the _commands function
87
- expect(output).toContain(sub.description.replace(/:/g, '\\:'));
89
+ // Description should appear (escaped) in the _commands function
90
+ expect(output).toContain(escapeDesc(sub.description));
88
91
  }
89
92
  checkDescriptions(cmd.subcommands);
90
93
  }
@@ -92,4 +95,19 @@ describe('Zsh Completion Generator', () => {
92
95
  }
93
96
  checkDescriptions(COMMANDS);
94
97
  });
98
+
99
+ test('generated completion is valid zsh syntax', () => {
100
+ // Recurrence gate: any unescaped quote/redirect in a description breaks the
101
+ // whole file (an apostrophe in "account's" once did). zsh -n parses without
102
+ // executing. Skip cleanly where zsh is unavailable (minimal CI images).
103
+ let zsh: ReturnType<typeof Bun.spawnSync>;
104
+ try {
105
+ zsh = Bun.spawnSync(['zsh', '-nc', output]);
106
+ } catch {
107
+ return; // ponytail: no zsh here — nothing to check
108
+ }
109
+ if (zsh.exitCode === null) return;
110
+ expect(zsh.stderr?.toString() ?? '').toBe('');
111
+ expect(zsh.exitCode).toBe(0);
112
+ });
95
113
  });