/** * Module import command * * Single entry point for both registry and local-source imports. Routing * is determined by the shape of the argument: * * celilo module import caddy → registry (kebab name) * celilo module import ./modules/caddy → local dir (relative path) * celilo module import /tmp/caddy.netapp → local file (absolute path) * celilo module import ~/dev/caddy → local dir (tilde-expanded) * celilo module import caddy.netapp → local file (.netapp extension) * * The previous CLI surface had three overlapping commands for this: * `module install` (registry-only shorthand), `module import public-registry` * (registry, verbose), and `module import file` (local, verbose). All have * been collapsed into this one command — the routing rule below is the * canonical disambiguator. */ import { existsSync } from 'node:fs'; import { rm, unlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { eq } from 'drizzle-orm'; import { getModuleStoragePath, shortenPath } from '../../config/paths'; import { getDb } from '../../db/client'; import { modules } from '../../db/schema'; import type { ModuleManifest } from '../../manifest/schema'; import { importModule } from '../../module/import'; import { RegistryClient } from '../../registry/client'; import { checkAspectApproval, computeAspectScopeHash, recordAspectApproval, } from '../../services/aspect-approvals'; import { askConfirm, withInterviewSession } from '../../services/bus-interview'; import { getArg, getFlag, hasFlag } from '../parser'; import type { CommandResult } from '../types'; import { generateTypesForImportedModule } from './module-types'; const USAGE = `Usage: celilo module import Import from celilo.computer registry celilo module import Import from a local directory or .netapp file Examples: celilo module import caddy celilo module import ./modules/caddy celilo module import /tmp/caddy.netapp`; /** * Decide whether the user's argument should route to local-filesystem * import or registry import. Path-like arguments win — see header comment * for the routing rules. Anything else is treated as a registry name * (validated against the kebab-case rule before the network call). * * Exposed for tests; not exported from the package. */ /** * Run the base-module-aspect approval flow after a module's primary * import succeeds. Per openspec/specs/base-module-aspects/spec.md D2: * * - Modules without a `base_module_aspect` block: no-op. * - Re-imports of a version that's already been approved with the * same scope: no-op (don't re-prompt). * - First-time import (or new version) with a declared aspect: * show scope, prompt unless `--accept-aspects` is passed. * - Operator declines: roll back the import (DB row + on-disk * source dir) so re-running is a clean re-do. * * Returns `{ approved: true, message?: string }` on success, or * `{ approved: false, error: string }` on decline or non-interactive * mismatch. The caller is responsible for surfacing the error. */ export async function handleAspectApprovalAfterImport(args: { moduleId: string; targetPath: string; flags: Record; db: ReturnType; }): Promise<{ approved: true; message?: string } | { approved: false; error: string }> { const { moduleId, targetPath, flags, db } = args; const moduleRow = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!moduleRow) { // Shouldn't happen — importModule just succeeded — but fail // loudly rather than silently no-op. return { approved: false, error: `Imported module '${moduleId}' missing from DB after import.`, }; } const manifest = moduleRow.manifestData as ModuleManifest; const aspect = manifest.base_module_aspect; if (!aspect) { // No aspect declared — nothing to approve. return { approved: true }; } const status = checkAspectApproval(moduleId, moduleRow.version, aspect, db); if (status === 'approved') { // Same version + same scope already approved (re-import of a // previously-accepted version). Skip the prompt silently. return { approved: true }; } const acceptViaFlag = hasFlag(flags, 'accept-aspects'); const approver = process.env.USER ?? null; if (acceptViaFlag) { recordAspectApproval({ moduleId, version: moduleRow.version, scopeHash: computeAspectScopeHash(aspect), approver, db, }); return { approved: true, message: `Base-module aspect approved via --accept-aspects (zones: ${aspect.applicable_zones.join(', ')}; triggers: ${aspect.triggers.join(', ')})`, }; } // Non-interactive (e2e / CI / automation): there is no one to answer the // approval prompt, so fail fast with guidance instead of hanging on stdin // until an outer timeout kills us (ISS-0045). Mirrors the secret-interview / // module-generate non-TTY behavior. if (!process.stdin.isTTY) { return { approved: false, error: `Module '${moduleId}' declares a base-module aspect (Ansible role '${aspect.ansible_role}', zones: ${aspect.applicable_zones.join(', ')}; triggers: ${aspect.triggers.join(', ')}). Approval can't be prompted because stdin isn't a TTY. Re-run with --accept-aspects to approve it non-interactively.`, }; } // Interactive prompt path. Display the scope clearly so the // operator's consent is informed. const scopeMsg = [ `Module '${moduleId}' declares a base-module aspect:`, ` Ansible role: ${aspect.ansible_role}`, ` Target zones: ${aspect.applicable_zones.join(', ')}`, ` Triggers: ${aspect.triggers.join(', ')}`, '', `This means: when this module's primary deploys (or one of the listed triggers fires),`, `celilo will run the '${aspect.ansible_role}' Ansible role on every non-api_only system`, 'in those zones.', ].join('\n'); // Written straight to stderr rather than through the prompt UI so the // multi-line scope block is preserved verbatim — a prompt renderer owns // its frame and reflows a multi-line message argument. process.stderr.write(`\n${scopeMsg}\n\n`); const accepted = await withInterviewSession(() => askConfirm({ scope: `module-import:${moduleId}`, key: 'approve_aspect', message: 'Approve this base-module aspect?', defaultValue: false, }), ); if (!accepted) { // Roll back the import — DB delete cascades to configs/secrets/etc. // We also rm the source dir so a re-import doesn't trip on the // existing files. db.delete(modules).where(eq(modules.id, moduleId)).run(); try { await rm(targetPath, { recursive: true, force: true }); } catch { // Best-effort cleanup; if rm fails the operator can clean up // manually. Don't mask the original "approval declined" error. } return { approved: false, error: 'Aspect approval declined; import rolled back. Re-run with --accept-aspects to skip the prompt.', }; } recordAspectApproval({ moduleId, version: moduleRow.version, scopeHash: computeAspectScopeHash(aspect), approver, db, }); return { approved: true, message: `Base-module aspect approved (zones: ${aspect.applicable_zones.join(', ')}; triggers: ${aspect.triggers.join(', ')})`, }; } export function classifyImportArg(arg: string): 'path' | 'name' { if (arg.startsWith('/') || arg.startsWith('./') || arg.startsWith('../')) return 'path'; if (arg.startsWith('~/') || arg === '~') return 'path'; if (arg.includes('/')) return 'path'; if (arg.endsWith('.netapp')) return 'path'; return 'name'; } const KEBAB_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/; export async function handleModuleImport( args: string[], flags: Record, ): Promise { const arg = getArg(args, 0); if (!arg) { return { success: false, error: `Module name or path required.\n\n${USAGE}` }; } // Catch the legacy subcommand-style invocation (`module import file `, // `module import public-registry `) before routing. Without this guard // 'file' would round-trip to the registry as a name and surface a confusing // "module not found" — the user thinks they're using a real subcommand. // Both forms have the second arg in the same slot regardless of which the // operator used, so the migration hint can offer the exact fix. if (arg === 'file' || arg === 'public-registry') { const next = getArg(args, 1); // 'file' was the local-path form. A bare name now routes to the // registry, so we have to suggest a path-shaped form (./, /, etc.) // to preserve the original intent. // 'public-registry' was the registry form. A bare name does what // the operator wanted, so the hint is straightforward. const example = arg === 'public-registry' ? ` celilo module import ${next ?? ''} # registry (bare name)` : ` celilo module import ./${next ?? ''} # local directory (leading ./) celilo module import /abs/path/to/${next ?? ''} # absolute path`; return { success: false, error: `'${arg}' is no longer a subcommand of 'module import'. The command auto-routes by argument shape now: ${example} A bare name (no leading ./, /, ~) is treated as a registry name. See 'celilo module import --help' for the full routing rules.`, }; } if (classifyImportArg(arg) === 'name') { if (!KEBAB_NAME.test(arg)) { return { success: false, error: `Not a valid module name or path: '${arg}'.\n\n${USAGE}\n\nModule names are kebab-case (lowercase letters, digits, hyphens). For local paths, use ./, /, ~/, or include / in the argument.`, }; } return handlePublicRegistryImport(arg, flags); } return handleFileImport(arg, flags); } async function handleFileImport( sourcePath: string, flags: Record, ): Promise { const resolvedSourcePath = resolve(sourcePath); const targetBasePath = getFlag(flags, 'target', getModuleStoragePath()); const resolvedTargetBasePath = resolve(targetBasePath); // Surface a more useful error than "module directory does not exist" // when the user typed a registry name with a typo (e.g. `caddy/` or // `./caddy`) but no such directory is present. Helps them realize // they probably wanted the registry form. if (!existsSync(resolvedSourcePath)) { return { success: false, error: `Path does not exist: ${resolvedSourcePath}\n\nIf you meant the registry, use a bare module name (no leading ./ or /):\n celilo module import `, }; } const db = getDb(); const result = await importModule({ sourcePath: resolvedSourcePath, targetBasePath: resolvedTargetBasePath, db, flags, }); if (!result.success) { return { success: false, error: result.error, details: result.details, }; } const aspectOutcome = await handleAspectApprovalAfterImport({ moduleId: result.moduleId, targetPath: result.targetPath, flags, db, }); if (!aspectOutcome.approved) { return { success: false, error: aspectOutcome.error }; } const typesPath = await generateTypesForImportedModule(result.targetPath); const typesMessage = typesPath ? `\nGenerated types: ${shortenPath(typesPath)}` : ''; const aspectMessage = aspectOutcome.message ? `\n${aspectOutcome.message}` : ''; return { success: true, message: `Successfully imported module: ${result.moduleId}\nFiles copied to: ${shortenPath(result.targetPath)}${typesMessage}${aspectMessage}`, data: { moduleId: result.moduleId, targetPath: result.targetPath, typesPath: typesPath ?? undefined, }, }; } async function handlePublicRegistryImport( name: string, flags: Record, ): Promise { const registryUrl = getFlag(flags, 'registry', ''); const client = new RegistryClient(registryUrl || undefined); // Look up module in the sparse index let entries: Awaited>; try { entries = await client.getIndex(name); } catch (err) { return { success: false, error: `Failed to reach registry: ${err instanceof Error ? err.message : String(err)}`, }; } if (entries.length === 0) { return { success: false, error: `Module '${name}' not found in registry` }; } const latest = client.latestVersion(entries); if (!latest) { return { success: false, error: `All versions of '${name}' are yanked` }; } // Download the .netapp const tmpPath = join(tmpdir(), `${name}-${latest.vers}-${Date.now()}.netapp`); try { const pkgData = await client.download(name, latest.vers, latest.cksum); await Bun.write(tmpPath, pkgData); } catch (err) { return { success: false, error: `Download failed: ${err instanceof Error ? err.message : String(err)}`, }; } try { const targetBasePath = getFlag(flags, 'target', getModuleStoragePath()); const resolvedTargetBasePath = resolve(targetBasePath); const importFlags = { ...flags, 'skip-verify': true }; const db = getDb(); const result = await importModule({ sourcePath: tmpPath, targetBasePath: resolvedTargetBasePath, db, flags: importFlags, }); if (!result.success) { return { success: false, error: result.error, details: result.details }; } const aspectOutcome = await handleAspectApprovalAfterImport({ moduleId: result.moduleId, targetPath: result.targetPath, flags, db, }); if (!aspectOutcome.approved) { return { success: false, error: aspectOutcome.error }; } const aspectMessage = aspectOutcome.message ? `\n${aspectOutcome.message}` : ''; // Type-generation is a developer-mode aid for module AUTHORS editing // hook scripts in the source tree. Registry installs are black-box // dependencies — the operator isn't editing them — so skipping the // type-gen pass saves the work and keeps the install output clean. return { success: true, message: `Imported ${result.moduleId}@${latest.vers}\nFiles: ${shortenPath(result.targetPath)}${aspectMessage}`, data: { moduleId: result.moduleId, version: latest.vers, targetPath: result.targetPath, }, }; } finally { try { await unlink(tmpPath); } catch {} } } export { handlePublicRegistryImport };