@celilo/cli 0.9.1 → 0.11.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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +12 -0
- package/drizzle/0014_api_principals.sql +10 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/api/protocol.test.ts +76 -0
- package/src/api/protocol.ts +159 -0
- package/src/api/remote-client.test.ts +91 -0
- package/src/api/remote-client.ts +218 -0
- package/src/api/serve.ts +159 -0
- package/src/cli/command-registry.ts +45 -0
- package/src/cli/commands/api.ts +194 -0
- package/src/cli/commands/publish/helpers.ts +18 -0
- package/src/cli/commands/publish/types.ts +6 -8
- package/src/cli/commands/publish/workspace.test.ts +44 -7
- package/src/cli/commands/publish/workspace.ts +40 -164
- package/src/cli/completion.ts +22 -0
- package/src/cli/index.ts +95 -0
- package/src/cli/parser.test.ts +13 -0
- package/src/cli/parser.ts +11 -2
- package/src/db/schema.ts +30 -0
- package/src/hooks/capability-loader.test.ts +77 -0
- package/src/hooks/capability-loader.ts +56 -0
- package/src/services/api-access.test.ts +138 -0
- package/src/services/api-access.ts +154 -0
- package/src/services/remote-responder.test.ts +78 -0
- package/src/services/remote-responder.ts +89 -0
|
@@ -17,18 +17,8 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { spawnSync } from 'node:child_process';
|
|
20
|
-
import {
|
|
21
|
-
|
|
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
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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'
|
|
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
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
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
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
package/src/cli/completion.ts
CHANGED
|
@@ -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,6 +29,7 @@ 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',
|
|
31
33
|
'audit',
|
|
32
34
|
'backup',
|
|
33
35
|
'capability',
|
|
@@ -332,6 +334,26 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
332
334
|
return filterSuggestions(configKeys, args[4] || '');
|
|
333
335
|
}
|
|
334
336
|
|
|
337
|
+
// API subcommands
|
|
338
|
+
if (command === 'api' && currentIndex === 1) {
|
|
339
|
+
const subcommands = ['grant', 'list', 'revoke', 'authorized-keys', 'key'];
|
|
340
|
+
return filterSuggestions(subcommands, args[1] || '');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// API revoke - complete with principal names
|
|
344
|
+
if (command === 'api' && args[1] === 'revoke' && currentIndex === 2) {
|
|
345
|
+
const principals = await listPrincipals();
|
|
346
|
+
return filterSuggestions(
|
|
347
|
+
principals.map((p) => p.name),
|
|
348
|
+
args[2] || '',
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// API key subcommands
|
|
353
|
+
if (command === 'api' && args[1] === 'key' && currentIndex === 2) {
|
|
354
|
+
return filterSuggestions(['new'], args[2] || '');
|
|
355
|
+
}
|
|
356
|
+
|
|
335
357
|
// Machine subcommands
|
|
336
358
|
if (command === 'machine' && currentIndex === 1) {
|
|
337
359
|
const subcommands = ['add', 'list', 'status', 'remove', 'earmark', 'detect'];
|
package/src/cli/index.ts
CHANGED
|
@@ -5,8 +5,16 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import * as p from '@clack/prompts';
|
|
8
|
+
import { resolveRemote, runRemoteClient } from '../api/remote-client';
|
|
8
9
|
import { CLIServerRequestSchema, parseJsonWithValidation } from '../validation/schemas';
|
|
9
10
|
import { COMMANDS, type CommandDef } from './command-registry';
|
|
11
|
+
import {
|
|
12
|
+
handleApiAuthorizedKeys,
|
|
13
|
+
handleApiGrant,
|
|
14
|
+
handleApiKeyNew,
|
|
15
|
+
handleApiList,
|
|
16
|
+
handleApiRevoke,
|
|
17
|
+
} from './commands/api';
|
|
10
18
|
import { handleCapabilityInfo } from './commands/capability-info';
|
|
11
19
|
import { handleCapabilityList } from './commands/capability-list';
|
|
12
20
|
import { handleCompletion } from './commands/completion';
|
|
@@ -184,10 +192,14 @@ Commands:
|
|
|
184
192
|
proxmox Proxmox cluster introspection (proxmox node list)
|
|
185
193
|
publish Publish workspace packages to npm and modules to celilo.computer
|
|
186
194
|
subscribers Manage build-bus subscribers (cross-machine publish-event delivery)
|
|
195
|
+
api Manage remote-API access (principals, grants, authorized_keys)
|
|
187
196
|
completion Generate shell completion scripts (bash/zsh)
|
|
188
197
|
|
|
189
198
|
help, --help, -h Show this help message
|
|
190
199
|
|
|
200
|
+
Run any command on a remote celilo-mgr over SSH:
|
|
201
|
+
celilo --remote <ssh-dest> <command> (or set CELILO_REMOTE=<ssh-dest>)
|
|
202
|
+
|
|
191
203
|
For command-specific help:
|
|
192
204
|
celilo package --help
|
|
193
205
|
celilo module --help
|
|
@@ -1015,6 +1027,24 @@ Using Vault Password:
|
|
|
1015
1027
|
export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
1016
1028
|
const parsed = parseArguments(argv);
|
|
1017
1029
|
|
|
1030
|
+
// Remote API server: the sshd forced-command entry point
|
|
1031
|
+
// (`celilo api-serve --principal=<id>`). Not an operator command — kept out
|
|
1032
|
+
// of the registry/completion on purpose. Runs a persistent NDJSON protocol
|
|
1033
|
+
// loop until stdin closes, then exits the process; never returns here.
|
|
1034
|
+
if (parsed.command === 'api-serve') {
|
|
1035
|
+
const principal = typeof parsed.flags.principal === 'string' ? parsed.flags.principal : '';
|
|
1036
|
+
if (!principal) {
|
|
1037
|
+
return {
|
|
1038
|
+
success: false,
|
|
1039
|
+
error:
|
|
1040
|
+
'api-serve requires --principal <name> (normally supplied by the forced command in authorized_keys)',
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
const { apiServeMode } = await import('../api/serve');
|
|
1044
|
+
await apiServeMode(principal);
|
|
1045
|
+
return { success: true, message: '' };
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1018
1048
|
// Handle --get-completions for shell completion (must be before other processing)
|
|
1019
1049
|
if (parsed.flags['get-completions']) {
|
|
1020
1050
|
try {
|
|
@@ -1673,6 +1703,64 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1673
1703
|
return handleRestore(restoreArgs, parsed.flags);
|
|
1674
1704
|
}
|
|
1675
1705
|
|
|
1706
|
+
if (parsed.command === 'api') {
|
|
1707
|
+
if (parsed.flags.help || parsed.flags.h) {
|
|
1708
|
+
return {
|
|
1709
|
+
success: true,
|
|
1710
|
+
message: [
|
|
1711
|
+
'celilo api — manage remote-API access',
|
|
1712
|
+
'',
|
|
1713
|
+
'Usage:',
|
|
1714
|
+
' celilo api grant <principal> --key <pubkey|path> --can <grant[,grant...]>',
|
|
1715
|
+
' celilo api list',
|
|
1716
|
+
' celilo api revoke <principal>',
|
|
1717
|
+
' celilo api authorized-keys',
|
|
1718
|
+
' celilo api key new <name>',
|
|
1719
|
+
'',
|
|
1720
|
+
'Grants are command:subcommand (module:deploy), command:* (service:*), or * (all).',
|
|
1721
|
+
].join('\n'),
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
if (!parsed.subcommand) {
|
|
1726
|
+
return {
|
|
1727
|
+
success: false,
|
|
1728
|
+
error: 'API subcommand required\n\nRun "celilo api --help" for usage',
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
const apiFlagError = checkFlags('api', parsed.subcommand, parsed.flags, parsed.args);
|
|
1733
|
+
if (apiFlagError) return apiFlagError;
|
|
1734
|
+
|
|
1735
|
+
if (parsed.subcommand === 'grant') {
|
|
1736
|
+
return handleApiGrant(parsed.args, parsed.flags);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
if (parsed.subcommand === 'list') {
|
|
1740
|
+
return handleApiList();
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
if (parsed.subcommand === 'revoke') {
|
|
1744
|
+
return handleApiRevoke(parsed.args);
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
if (parsed.subcommand === 'authorized-keys') {
|
|
1748
|
+
return handleApiAuthorizedKeys();
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
if (parsed.subcommand === 'key') {
|
|
1752
|
+
if (parsed.args[0] === 'new') {
|
|
1753
|
+
return handleApiKeyNew(parsed.args.slice(1));
|
|
1754
|
+
}
|
|
1755
|
+
return { success: false, error: 'Usage: celilo api key new <name>' };
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
return {
|
|
1759
|
+
success: false,
|
|
1760
|
+
error: `Unknown api subcommand: ${parsed.subcommand}\n\nRun "celilo api --help" for usage`,
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1676
1764
|
if (parsed.command === 'machine') {
|
|
1677
1765
|
// Handle machine --help
|
|
1678
1766
|
if (parsed.flags.help || parsed.flags.h) {
|
|
@@ -2084,6 +2172,13 @@ export async function main(): Promise<void> {
|
|
|
2084
2172
|
return;
|
|
2085
2173
|
}
|
|
2086
2174
|
|
|
2175
|
+
// Remote execution: `celilo --remote <dest> <cmd>` or CELILO_REMOTE=<dest>.
|
|
2176
|
+
// SSH to the remote celilo-mgr and drive its api-serve over the wire.
|
|
2177
|
+
const remote = resolveRemote(process.argv);
|
|
2178
|
+
if (remote) {
|
|
2179
|
+
process.exit(await runRemoteClient(remote.dest, remote.commandArgv));
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2087
2182
|
// Normal single-command execution
|
|
2088
2183
|
try {
|
|
2089
2184
|
const result = await runCli(process.argv);
|
package/src/cli/parser.test.ts
CHANGED
|
@@ -107,6 +107,19 @@ describe('CLI Parser', () => {
|
|
|
107
107
|
expect(result.flags).toEqual({ json: true, verbose: true });
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
test('should parse --flag=value inline form', () => {
|
|
111
|
+
const result = parseArguments([
|
|
112
|
+
'node',
|
|
113
|
+
'celilo',
|
|
114
|
+
'api-serve',
|
|
115
|
+
'--principal=alice',
|
|
116
|
+
'--empty=',
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
expect(result.command).toBe('api-serve');
|
|
120
|
+
expect(result.flags).toEqual({ principal: 'alice', empty: '' });
|
|
121
|
+
});
|
|
122
|
+
|
|
110
123
|
test('should not consume key=value args as flag values', () => {
|
|
111
124
|
const result = parseArguments([
|
|
112
125
|
'node',
|
package/src/cli/parser.ts
CHANGED
|
@@ -82,8 +82,17 @@ export function parseArguments(argv: string[]): ParsedCommand {
|
|
|
82
82
|
if (!arg) continue;
|
|
83
83
|
|
|
84
84
|
if (arg.startsWith('--')) {
|
|
85
|
-
|
|
86
|
-
const
|
|
85
|
+
const body = arg.slice(2);
|
|
86
|
+
const eqIndex = body.indexOf('=');
|
|
87
|
+
|
|
88
|
+
// Inline-value form: --flag=value (everything after the first `=`).
|
|
89
|
+
if (eqIndex >= 0) {
|
|
90
|
+
flags[body.slice(0, eqIndex)] = body.slice(eqIndex + 1);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Space-separated form: --flag value
|
|
95
|
+
const flagName = body;
|
|
87
96
|
const nextArg = restArgs[i + 1];
|
|
88
97
|
|
|
89
98
|
// Check if next arg is a value (not another flag, not a key=value pair)
|
package/src/db/schema.ts
CHANGED
|
@@ -654,6 +654,34 @@ export const aspectApprovals = sqliteTable(
|
|
|
654
654
|
}),
|
|
655
655
|
);
|
|
656
656
|
|
|
657
|
+
/**
|
|
658
|
+
* Remote API principals (see v2/API_COMMUNICATION.md).
|
|
659
|
+
*
|
|
660
|
+
* Each row is one API identity: a name, one SSH public key, and the set of
|
|
661
|
+
* operations it may run. celilo renders these into the API account's
|
|
662
|
+
* `authorized_keys` (one forced-command line per row) and authz keys off the
|
|
663
|
+
* principal → `grants`.
|
|
664
|
+
*
|
|
665
|
+
* ponytail: one key per principal (a person wanting a second device makes a
|
|
666
|
+
* second principal, e.g. `alice-laptop`). If multiple keys per identity is ever
|
|
667
|
+
* needed, split into an `api_keys` child table — not worth it yet.
|
|
668
|
+
*/
|
|
669
|
+
export const apiPrincipals = sqliteTable('api_principals', {
|
|
670
|
+
id: text('id').primaryKey(), // UUID
|
|
671
|
+
/** Human-readable principal name, kebab-case (e.g. "alice", "ci-deployer"). */
|
|
672
|
+
name: text('name').notNull().unique(),
|
|
673
|
+
/** SSH public key line: `<type> <base64> [comment]`. */
|
|
674
|
+
publicKey: text('public_key').notNull(),
|
|
675
|
+
/**
|
|
676
|
+
* Operations this principal may run, as `command:subcommand` grants
|
|
677
|
+
* (`module:deploy`), `command:*` wildcards (`service:*`), or `*` (all).
|
|
678
|
+
* Deny-by-default: an operation not matched by any grant is refused.
|
|
679
|
+
*/
|
|
680
|
+
grants: text('grants', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
|
|
681
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
682
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
683
|
+
});
|
|
684
|
+
|
|
657
685
|
/**
|
|
658
686
|
* Type exports for use in application code
|
|
659
687
|
*/
|
|
@@ -695,3 +723,5 @@ export type ModuleOperation = typeof moduleOperations.$inferSelect;
|
|
|
695
723
|
export type NewModuleOperation = typeof moduleOperations.$inferInsert;
|
|
696
724
|
export type AspectApproval = typeof aspectApprovals.$inferSelect;
|
|
697
725
|
export type NewAspectApproval = typeof aspectApprovals.$inferInsert;
|
|
726
|
+
export type ApiPrincipal = typeof apiPrincipals.$inferSelect;
|
|
727
|
+
export type NewApiPrincipal = typeof apiPrincipals.$inferInsert;
|
|
@@ -126,4 +126,81 @@ describe('Capability Loader', () => {
|
|
|
126
126
|
const consumer = await loadCapabilityFunctions('apt-repo', db, noopLogger);
|
|
127
127
|
expect(consumer).not.toHaveProperty('web_routes');
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
// ce-iku regression: public_web's managed-domain check must reflect the
|
|
131
|
+
// registrar's DECLARED `domain_list` computed field (namecheap:
|
|
132
|
+
// keys(secret.ddns_passwords)) — the same live set DDNS validation sees —
|
|
133
|
+
// NOT a stale `config.domains` row that survived an older manifest version.
|
|
134
|
+
// Without the fix the config-shape heuristic short-circuits on the stale
|
|
135
|
+
// config.domains and a just-added domain (present only in the secret) is
|
|
136
|
+
// silently excluded, dead-ending register_route forever.
|
|
137
|
+
test('public_web managed-domains come from the registrar domain_list computed field, not a stale config.domains', async () => {
|
|
138
|
+
const { encryptSecret } = await import('../secrets/encryption');
|
|
139
|
+
const { getOrCreateMasterKey } = await import('../secrets/master-key');
|
|
140
|
+
const { isMissingProviderInputError } = await import('@celilo/capabilities');
|
|
141
|
+
const masterKey = await getOrCreateMasterKey();
|
|
142
|
+
|
|
143
|
+
// Provider: caddy (public_web). Needs ≥1 configured hostname + target_ip
|
|
144
|
+
// for createPublicWeb to build.
|
|
145
|
+
const caddyPath = join(tempDir, 'caddy');
|
|
146
|
+
mkdirSync(caddyPath, { recursive: true });
|
|
147
|
+
db.$client.run(
|
|
148
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('caddy', 'Caddy', '1.0.0', '${caddyPath}', '{}')`,
|
|
149
|
+
);
|
|
150
|
+
db.$client.run(
|
|
151
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('caddy', 'public_web', '1.0.0', '{}', unixepoch())`,
|
|
152
|
+
);
|
|
153
|
+
upsertModuleConfig(db, 'caddy', 'hostnames', ['seed.celilo.computer']);
|
|
154
|
+
upsertModuleConfig(db, 'caddy', 'target_ip', '10.0.20.10');
|
|
155
|
+
|
|
156
|
+
// Provider: namecheap (dns_registrar). Its `data` declares the canonical
|
|
157
|
+
// domain_list computed field. config.domains is STALE (missing the newly
|
|
158
|
+
// onboarded domain); the SECRET holds the real, current set.
|
|
159
|
+
const ncPath = join(tempDir, 'namecheap');
|
|
160
|
+
mkdirSync(ncPath, { recursive: true });
|
|
161
|
+
const registrarData = JSON.stringify({
|
|
162
|
+
provider: 'namecheap',
|
|
163
|
+
domain_list: { __celilo_computed__: 'keys(secret.ddns_passwords)' },
|
|
164
|
+
});
|
|
165
|
+
db.$client.run(
|
|
166
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('namecheap', 'Namecheap', '3.2.0', '${ncPath}', '{}')`,
|
|
167
|
+
);
|
|
168
|
+
db.$client.run(
|
|
169
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('namecheap', 'dns_registrar', '4.0.0', '${registrarData}', unixepoch())`,
|
|
170
|
+
);
|
|
171
|
+
// Stale config that must be IGNORED — omits buildyourowninternet.dev.
|
|
172
|
+
upsertModuleConfig(db, 'namecheap', 'domains', ['celilo.computer']);
|
|
173
|
+
// Live secret — the source of truth — includes the new domain.
|
|
174
|
+
const ddnsEnc = encryptSecret(
|
|
175
|
+
JSON.stringify({ 'celilo.computer': 'pw1', 'buildyourowninternet.dev': 'pw2' }),
|
|
176
|
+
masterKey,
|
|
177
|
+
);
|
|
178
|
+
db.$client.run(
|
|
179
|
+
`INSERT INTO secrets (module_id, name, encrypted_value, iv, auth_tag) VALUES ('namecheap', 'ddns_passwords', '${ddnsEnc.encryptedValue}', '${ddnsEnc.iv}', '${ddnsEnc.authTag}')`,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const consumer = await loadCapabilityFunctions('byoi', db, noopLogger);
|
|
183
|
+
const publicWeb = consumer.public_web as {
|
|
184
|
+
register_route: (r: { type: string; path: string; hostname: string }) => Promise<unknown>;
|
|
185
|
+
};
|
|
186
|
+
expect(publicWeb).toBeTruthy();
|
|
187
|
+
|
|
188
|
+
// Does register_route reject specifically because the hostname's apex
|
|
189
|
+
// isn't in any managed domain? (Later reconcile errors are a different
|
|
190
|
+
// failure — the domain check already passed by then.)
|
|
191
|
+
async function rejectsAsMissingProvider(hostname: string): Promise<boolean> {
|
|
192
|
+
try {
|
|
193
|
+
await publicWeb.register_route({ type: 'static', path: '/', hostname });
|
|
194
|
+
return false;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
return isMissingProviderInputError(err);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// In the secret (domain_list) but NOT in the stale config.domains — must
|
|
201
|
+
// pass the managed-domain check.
|
|
202
|
+
expect(await rejectsAsMissingProvider('www.buildyourowninternet.dev')).toBe(false);
|
|
203
|
+
// In neither the secret nor config — control: must still be rejected.
|
|
204
|
+
expect(await rejectsAsMissingProvider('app.notmanaged.example')).toBe(true);
|
|
205
|
+
});
|
|
129
206
|
});
|