UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

404 lines (403 loc) 18.3 kB
import * as fs from "fs"; import * as path from "path"; import { execFileSync } from "child_process"; import chalk from "chalk"; import { logInfo, logWarn } from "../../utils/log.js"; import { MeshCliError, emitJsonPayload } from "../../utils/errors.js"; import { probeRegistryToken } from "../../utils/auth-preflight.js"; import { RegistryNotAuthorizedError } from "../registry.js"; import { readRegistrySession } from "../../utils/registry-identity.js"; import { findMeshJson, isValidTenantName, writeMeshJson } from "../../utils/mesh-json.js"; import { discoverConfigForContext, isRemoteEnvironment, readCredentials, renderNoConfigHelp, runLoginFlow, tokenStillValid, } from "../login.js"; import { bootstrapAppsRepo, isInsidePlatformMonorepo, shouldBootstrapAppsRepo } from "../create-app.js"; import { resolveTargetRoot, syncSkills } from "../skills.js"; import { detectLocalTenant } from "../local/dev-local.js"; export const NO_SESSION_NO_TTY_MESSAGE = "No registry session and no terminal to sign in with.\n" + " On a headless box: mesh registry login --device (one-time, prints a code)\n" + " In CI with AWS creds: mesh registry login --ci"; export const TENANT_REQUIRED_MESSAGE = "--tenant is required (no TTY for interactive mode)"; export const INTERRUPTED_EXIT_CODE = 130; const STEP_LABELS = { registry: "Registry access", platform: "Platform sign-in", repo: "Repo", skills: "Agent skills", }; export function wizardExitCode(steps) { return steps.some((s) => s.status === "fail") ? 1 : 0; } export function wizardNextCommands(mode, steps = []) { const repo = steps.find((s) => s.name === STEP_LABELS.repo); const first = repo && repo.status !== "pass" && repo.fix ? repo.fix : "mesh create-app"; return mode === "local" ? [first] : [first, "mesh deploy up # from the app directory, once it exists"]; } export function stepBlocks(result) { return result.status === "fail" && result.blocking !== false; } export function describeMode(mode) { return mode === "local" ? "local only" : `deployed platform (${mode.platform})`; } export function renderWizardSummary(outcome, opts = {}) { const paint = opts.color === false ? { green: (s) => s, red: (s) => s, yellow: (s) => s, dim: (s) => s, cyan: (s) => s } : chalk; const icon = (status) => status === "pass" ? paint.green("✔") : status === "fail" ? paint.red("✘") : status === "warn" ? paint.yellow("▲") : paint.dim("–"); const lines = [`Setup summary — tenant ${outcome.tenant} (${describeMode(outcome.mode)})`]; for (const step of outcome.steps) { lines.push(` ${icon(step.status)} ${step.name.padEnd(18)} ${step.detail}`); if (step.fix && step.status !== "pass") lines.push(` ${paint.dim("→ run:")} ${paint.cyan(step.fix)}`); } lines.push("", "Next:"); for (const cmd of outcome.next) lines.push(` ${cmd}`); return lines.join("\n"); } export function resolveAnswers(opts, recorded, interactive) { const open = []; let tenant = opts.tenant; if (tenant !== undefined && !isValidTenantName(tenant)) { throw new MeshCliError(`Invalid tenant name '${tenant}' (lowercase alphanumeric + dashes, starting with a letter).`); } if (!tenant && (opts.yes || !interactive) && recorded) tenant = recorded.tenant; if (!tenant) { if (!interactive) throw new MeshCliError(TENANT_REQUIRED_MESSAGE); open.push("tenant"); } let mode; if (opts.local && opts.platform) { throw new MeshCliError("--local and --platform are mutually exclusive."); } if (opts.platform) mode = { platform: opts.platform }; else if (opts.local) mode = "local"; else if (opts.yes || !interactive) { mode = recorded && recorded.platform !== "local" ? { platform: recorded.platform } : "local"; } else { open.push("mode"); } return { tenant, mode, open }; } export const registryStep = { name: "registry", async run(ctx) { const name = STEP_LABELS.registry; const probe = await (ctx.seams?.probe ?? probeRegistryToken)(); if (probe.state === "fresh") { const session = readRegistrySession(); return { name, status: "pass", detail: `Registry access OK (${session ? `signed in as ${session.email}` : "token valid"})` }; } if (probe.state === "unreachable") { return { name, status: "warn", detail: `could not reach the registry to verify access (${probe.detail ?? "unknown error"})`, fix: "mesh registry login", }; } if (!ctx.interactive && !ctx.device && !ctx.profile) { logWarn(NO_SESSION_NO_TTY_MESSAGE); return { name, status: "fail", detail: NO_SESSION_NO_TTY_MESSAGE.split("\n")[0], fix: "mesh registry login --device", blocking: true }; } try { const login = ctx.seams?.runRegistryLogin ?? (async (opts) => { const { runRegistryLogin } = await import("../registry.js"); await runRegistryLogin(undefined, opts); }); await login({ device: ctx.device, profile: ctx.profile }); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (err instanceof RegistryNotAuthorizedError) { return { name, status: "fail", detail: `not authorized — ${message.split("\n")[0]}`, fix: "ask a Mesh platform admin for the registry:read role, then: mesh registry login", blocking: false, }; } return { name, status: "fail", detail: message.split("\n")[0], fix: ctx.device || ctx.remote ? "mesh registry login --device" : "mesh registry login", blocking: true, }; } const session = readRegistrySession(); return { name, status: "pass", detail: `Registry access OK (${session ? `signed in as ${session.email}` : "token written"})` }; }, }; const platformStep = { name: "platform", async run(ctx) { const name = STEP_LABELS.platform; if (!ctx.context) return { name, status: "skip", detail: "local only — no platform to sign in to" }; const context = ctx.context; const cached = readCredentials(context); if (cached && tokenStillValid(cached.expiresAt)) { return { name, status: "pass", detail: `signed in to ${context}${cached.email ? ` as ${cached.email}` : ""}` }; } const config = await discoverConfigForContext(context); if (!config) { logWarn(renderNoConfigHelp(context)); return { name, status: "fail", detail: `no configuration found for ${context} (see above)`, fix: `mesh login ${context}` }; } if (!ctx.interactive && !ctx.device) { return { name, status: "fail", detail: `no session for ${context} and no terminal to sign in with`, fix: `mesh login ${context} --device` }; } try { await runLoginFlow(context, config, { device: ctx.device }); } catch (err) { return { name, status: "fail", detail: `sign-in failed: ${err.message}`, fix: `mesh login ${context}${ctx.device ? " --device" : ""}` }; } const creds = readCredentials(context); return { name, status: "pass", detail: `signed in to ${context}${creds?.email ? ` as ${creds.email}` : ""} — tenant registration: mesh init app-tenant --context ${context} --hub-url <hub api url>`, }; }, }; export function classifyRepo(cwd) { if (isInsidePlatformMonorepo(cwd)) return { kind: "platform-monorepo", root: resolveTargetRoot(cwd) }; if (shouldBootstrapAppsRepo(cwd)) return { kind: "empty-apps-repo", root: cwd }; const root = resolveTargetRoot(cwd); const inRepo = fs.existsSync(path.join(root, ".git")); if (!inRepo) { const hasContent = fs.readdirSync(cwd).some((entry) => !entry.startsWith(".")); return { kind: hasContent ? "no-repo" : "empty-dir", root: cwd }; } const hasWorkspace = fs.existsSync(path.join(root, "pnpm-workspace.yaml")) || fs.existsSync(path.join(root, "pnpm-lock.yaml")); const hasApps = fs.existsSync(path.join(root, "apps")) || fs.existsSync(path.join(root, "tenants")); if (hasWorkspace || hasApps) return { kind: "apps-repo", root }; return { kind: "other-repo", root }; } function gitInit(dir) { execFileSync("git", ["init", "-q"], { cwd: dir, stdio: ["ignore", "ignore", "pipe"] }); } export const INIT_REPO_FIX = "git init && mesh init"; export const repoStep = { name: "repo", async run(ctx) { const name = STEP_LABELS.repo; if (ctx.skipRepo) return { name, status: "skip", detail: "--skip-repo" }; const { kind, root } = classifyRepo(ctx.cwd); switch (kind) { case "platform-monorepo": return { name, status: "skip", detail: "inside mesh-platform — apps here link workspace packages; nothing to set up" }; case "empty-dir": { const go = ctx.yes || !ctx.interactive ? true : await ctx.prompts.confirm({ message: `This folder is not a git repo — initialize ${root} as your ${ctx.tenant}-mesh-apps repo here?`, default: true, }); if (!go) { return { name, status: "skip", detail: "folder left as is", fix: `cd <your ${ctx.tenant}-mesh-apps clone> && mesh init` }; } try { (ctx.seams?.gitInit ?? gitInit)(root); } catch (err) { const why = err instanceof Error && "stderr" in err && err.stderr ? String(err.stderr).trim() : ""; return { name, status: "fail", detail: `git init failed — is git installed and ${root} writable?${why ? ` (${why})` : ""}`, fix: INIT_REPO_FIX, }; } const created = bootstrapAppsRepo(root, ctx.tenant); return { name, status: "pass", detail: `initialized a git repo and bootstrapped the apps repo: ${created.join(", ")}` }; } case "no-repo": return { name, status: "skip", detail: `not in a git repo, and this folder already holds files — clone (or git init) your ${ctx.tenant}-mesh-apps repo and re-run mesh init there`, fix: INIT_REPO_FIX, }; case "other-repo": return { name, status: "warn", detail: "this repo is not a Mesh apps repo (no pnpm workspace, no apps/)", fix: "mesh create-app # scaffolds an app and the workspace files around it", }; case "empty-apps-repo": { const go = ctx.yes || !ctx.interactive ? true : await ctx.prompts.confirm({ message: `Empty repo — bootstrap the ${ctx.tenant}-mesh-apps layout here?`, default: true }); if (!go) return { name, status: "skip", detail: "empty repo left as is", fix: "mesh create-app" }; const created = bootstrapAppsRepo(root, ctx.tenant); return { name, status: "pass", detail: `bootstrapped apps repo: ${created.join(", ")}` }; } case "apps-repo": { const missing = []; if (!fs.existsSync(path.join(root, "package.json"))) missing.push("package.json"); if (!fs.existsSync(path.join(root, "pnpm-workspace.yaml")) && !fs.existsSync(path.join(root, "pnpm-lock.yaml"))) { missing.push("pnpm-workspace.yaml"); } if (missing.length) { return { name, status: "warn", detail: `apps repo missing ${missing.join(", ")}`, fix: "mesh create-app # writes the workspace files" }; } return { name, status: "pass", detail: `apps repo at ${root}` }; } } }, }; const skillsStep = { name: "skills", async run(ctx) { const name = STEP_LABELS.skills; if (ctx.skipRepo) return { name, status: "skip", detail: "--skip-repo" }; const { kind, root } = classifyRepo(ctx.cwd); if (kind !== "apps-repo" && kind !== "empty-apps-repo") { return { name, status: "skip", detail: "no apps repo to install skills into" }; } try { const complete = syncSkills(root); return complete ? { name, status: "pass", detail: "base skills + Intent discovery in sync" } : { name, status: "pass", detail: "base skills synced — after pnpm install, run: mesh skills sync (platform skills)" }; } catch (err) { return { name, status: "fail", detail: `skills sync failed: ${err.message}`, fix: "mesh skills sync", blocking: false }; } }, }; export const WIZARD_STEPS = [registryStep, platformStep, repoStep, skillsStep]; export async function runWizardSteps(ctx, steps) { const results = []; for (const step of steps) { const result = await step.run(ctx); results.push(result); if (stepBlocks(result)) break; } return results; } async function inquirerPrompts() { const [{ default: input }, { default: select }, { default: confirm }] = await Promise.all([ import("@inquirer/input"), import("@inquirer/select"), import("@inquirer/confirm"), ]); return { input: (args) => input(args), select: (args) => select(args), confirm: (args) => confirm(args), }; } function isInterrupt(err) { return err instanceof Error && err.name === "ExitPromptError"; } export async function runInitWizard(opts, deps = {}) { const cwd = deps.cwd ?? process.cwd(); const interactive = deps.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY); const remote = deps.remote ?? isRemoteEnvironment(); const recorded = findMeshJson(cwd)?.data ?? null; const origLog = console.log; if (opts.json) console.log = (...a) => console.error(...a); try { const answers = resolveAnswers(opts, recorded, interactive); const prompts = deps.prompts ?? (answers.open.length ? await inquirerPrompts() : {}); let tenant = answers.tenant; if (!tenant) { const detected = recorded?.tenant ?? (() => { try { const t = detectLocalTenant(cwd); return t === "local" ? undefined : t; } catch { return undefined; } })(); tenant = await prompts.input({ message: "Tenant name", default: detected, validate: (v) => (isValidTenantName(v) ? true : "lowercase letters, digits and dashes, starting with a letter"), }); } let mode = answers.mode; if (!mode) { const choice = await prompts.select({ message: "Where does this tenant run?", choices: [ { name: "Local only (mesh start / mesh dev — no deployed platform yet)", value: "local" }, { name: `A deployed Mesh platform (I have a ${tenant}.<env> context)`, value: "deployed" }, ], default: recorded && recorded.platform !== "local" ? "deployed" : "local", }); if (choice === "local") mode = "local"; else { const env = await prompts.input({ message: "Environment", default: recorded && recorded.platform !== "local" ? recorded.platform : "dev", validate: (v) => (/^[a-z][a-z0-9-]*$/.test(v) ? true : "lowercase letters, digits and dashes"), }); mode = { platform: env }; } } const ctx = { cwd, tenant, mode, context: mode === "local" ? null : `${tenant}.${mode.platform}`, device: opts.device, profile: opts.profile, interactive, remote, yes: Boolean(opts.yes), skipRepo: Boolean(opts.skipRepo), prompts, seams: deps.seams, }; logInfo(`Setting up tenant '${tenant}' (${describeMode(mode)})…`); const steps = await runWizardSteps(ctx, deps.steps ?? WIZARD_STEPS); const { kind, root } = classifyRepo(cwd); if (!opts.skipRepo && (kind === "apps-repo" || kind === "empty-apps-repo")) { const written = writeMeshJson(root, { tenant, platform: mode === "local" ? "local" : mode.platform }); logInfo(`Recorded tenant + mode in ${written}`); } const outcome = { tenant, mode, steps, next: wizardNextCommands(mode, steps) }; const code = wizardExitCode(steps); if (opts.json) { console.log = origLog; emitJsonPayload({ ok: code === 0, ...outcome }); } else { console.log(""); console.log(renderWizardSummary(outcome)); console.log(""); } return code; } catch (err) { if (isInterrupt(err)) { console.error(""); logWarn("Interrupted — nothing was written."); return INTERRUPTED_EXIT_CODE; } throw err; } finally { console.log = origLog; } }