/** * The `astrale-domain` CLI — dev | build | deploy | publish | lint. * * Thin by design: it reads `astrale.config.ts`, resolves `envs[] → params` * via the adapter, loads the env's secrets file, then drives `adapter.watch` * (dev) or `adapter.deploy` (build+ship). It prints the resulting URL and the * exact `astrale domain install --direct` line. It boots no kernel and knows * nothing provider-specific — that all lives in the adapter. * * The diagnostic spec (`.astrale/spec.json`) is written AFTER watch/deploy * returns, at the live URL: the install graph (and therefore `schemaHash`) * embeds `binding.remoteUrl`s derived from the serving URL, so a spec built at * a guessed URL would carry a hash that never matches the worker's `/meta`. * Only `astrale-domain build` (no URL exists yet) uses an explicit placeholder * and says so. * * astrale-domain dev # = deploy dev --watch * astrale-domain prod # = deploy prod * astrale-domain deploy # any env key * astrale-domain build # rebuild spec only (placeholder URL) * astrale-domain lint # generic + Astrale static diagnostics * astrale-domain publish [env] # RELEASE: publish ./schema to npm (if any) + register the deployed URL * * A release has two halves that must stay in sync: the CONTRACT (`./schema` on * npm) and the DEPLOYMENT (worker URL in the catalog). `publish` does both, * npm-FIRST — `npm publish` is irreversible, so the catalog is registered only on * schema-publish success, never pointing at an unpublished contract. The schema * publish is idempotent (skips an unbumped version). Scope it with `--schema` * (contract only — config-free) or `--skip-schema` (catalog only). All publish * logic lives in `./publish.ts`; this file only parses args and dispatches. * * `publish` registers a domain's ALREADY-deployed URL in the admin catalog by * SHELLING OUT to the operator CLI (`astrale domain publish --origin --name * --public-url`); it does NOT deploy — run `prod` / `deploy ` first, or use * `deploy --publish` to deploy AND register in one step. Standalone publish * defaults the registered address to `https://` (what every fleet domain * serves at); pass `--public-url` for workers.dev / split-host deploys. Auth * lives entirely in the operator CLI — this build tool never touches * credentials. Requires `astrale` on PATH (the same CLI the deploy footer * already points you at for `domain install`). */ import { existsSync, watch as watchFs } from 'node:fs' import { mkdir, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { basename, dirname, isAbsolute, join } from 'node:path' import { pathToFileURL } from 'node:url' import type { DeployResult, DomainAdapter, DomainInfo, WatchHandle } from '../config/adapter.js' import type { DeployConfig } from '../config/deploy.js' import { LinterToolError } from '../linter/diagnostic.js' import { lintDomain } from '../linter/lint.js' import { formatLintResult, type LintReportFormat } from '../linter/report.js' import { loadDeclaredSecrets } from './dotenv.js' import { BOLD, DIM, error, GREEN, info, RESET, warn } from './log.js' import { publishSchema, runRelease, runReleaseAfterDeploy } from './publish.js' import { buildProjectSpec } from './spec.js' const CONFIG_NAMES = ['astrale.config.ts', 'astrale.config.js', 'astrale.config.mjs'] type ParsedArgs = { command: 'dev' | 'build' | 'deploy' | 'publish' | 'lint' env: string watch: boolean /** `--fix` (lint) — apply safe Oxlint fixes before reporting remaining diagnostics. */ fix?: boolean /** `--format ` (lint) — unified human or machine output. */ format?: LintReportFormat /** `--dry-run` (publish) — pack + `npm publish --dry-run` and skip the catalog register. */ dryRun?: boolean /** `--schema` (publish) — publish ONLY the `./schema` package; no catalog, no config. */ schemaOnly?: boolean /** `--otp ` (publish / deploy --publish) — 2FA OTP forwarded to `npm publish`. */ otp?: string /** `--skip-schema` (publish / deploy --publish) — register the catalog only; don't publish `./schema`. */ skipSchema?: boolean /** `--port ` (dev only) — overrides the env's local dev port. */ port?: number /** `--host ` (dev only) — public URL of a tunnel/proxy front: binds 0.0.0.0 + pins WORKER_URL. */ host?: string /** `--publish` tail-flag on deploy/prod: ALSO register the freshly-deployed URL (deploy + register). */ publish?: boolean /** `--name ` — registry name to publish under (default: the origin's first label, e.g. `ai-gateway`). */ name?: string /** `--install-by-default` — mark the published domain for install on every new instance. */ installByDefault?: boolean /** `--public-url ` — (publish only) the address the catalog points at (default: `https://`). */ publicUrl?: string } export async function run(argv: readonly string[]): Promise { // `--help` anywhere prints usage — previously `prod --help` IGNORED the // flag and started a real deploy (observed in an external test run). if (argv.includes('--help') || argv.includes('-h')) { printUsage() return 0 } let parsed: ParsedArgs try { parsed = parseArgs(argv) } catch (err) { printUsage((err as Error).message) return 2 } if (parsed.command === 'lint') { const controller = new AbortController() let receivedSignal: NodeJS.Signals | undefined const cancel = (signal: NodeJS.Signals) => { receivedSignal ??= signal controller.abort(new Error(`Received ${signal}.`)) } const onSigint = () => cancel('SIGINT') const onSigterm = () => cancel('SIGTERM') process.once('SIGINT', onSigint) process.once('SIGTERM', onSigterm) try { const result = await lintDomain({ root: process.cwd(), fix: parsed.fix ?? false, signal: controller.signal, }) process.stdout.write(formatLintResult(result, parsed.format)) return result.exitCode } catch (err) { if (err instanceof LinterToolError) { if (err.code === 'OXLINT_CANCELLED' && receivedSignal) { return receivedSignal === 'SIGINT' ? 130 : 143 } error(err.message) return err.exitCode } throw err } finally { process.off('SIGINT', onSigint) process.off('SIGTERM', onSigterm) } } // `publish --schema` (schema only) operates on the schema PACKAGE, not // astrale.config.ts — handle it before config discovery so it works in a folder // that is purely a schema package and needs no adapter/Bun runtime. if (parsed.command === 'publish' && parsed.schemaOnly) { return await publishSchema({ projectDir: process.cwd(), dryRun: parsed.dryRun ?? false, ...(parsed.otp !== undefined ? { otp: parsed.otp } : {}), }) } // The CLI imports the project's TypeScript modules (astrale.config.ts, the ★ // files) directly — that requires a TS-native runtime. Fail with the remedy // instead of a cryptic ERR_UNKNOWN_FILE_EXTENSION deep in `import()`. if (!process.versions.bun) { error( 'astrale-domain requires Bun (it imports your astrale.config.ts directly). ' + 'Install it from https://bun.sh, then re-run.', ) return 1 } const projectDir = process.cwd() const configPath = findConfig(projectDir) if (!configPath) { error(`No astrale.config.ts found in ${projectDir}. Run this from a domain project root.`) return 1 } let def: DeployConfig try { def = await loadConfig(configPath) } catch (err) { // Adapter-swap papercut: editing the `adapter:` call without the import // (or vice versa) surfaces as a bare ReferenceError. Name the real fix. const m = err instanceof Error ? /^(\w+) is not defined/.exec(err.message) : null if (m) { error( `astrale.config.ts references \`${m[1]}\` but never imports it.\n` + ` If you swapped adapters (cloudflare ⇄ astrale), update BOTH lines:\n` + ` import { ${m[1]} } from '@astrale-os/adapter-cloudflare'\n` + ` adapter: ${m[1]}({ ... })`, ) return 1 } throw err } const specPath = join(projectDir, '.astrale', 'spec.json') if (parsed.command === 'build') { // Spec-only: no adapter params, no secrets, no deploy → no real URL. // Build at an explicit placeholder and say so: the schemaHash of this // spec will NOT match a live worker's /meta (the install graph embeds // URL-derived bindings). const placeholderUrl = `https://${def.origin}` try { await writeSpec({ projectDir, specPath, def, url: placeholderUrl }) } catch (err) { error(`Spec build failed: ${(err as Error).message}`) return 1 } info(`Built spec → ${rel(projectDir, specPath)} (origin: ${def.origin})`) info( `note: built at placeholder ${placeholderUrl} — its schemaHash differs from a deployed worker.`, ) return 0 } if (parsed.command === 'publish') { // `--schema` (schema only) was handled before config discovery; this is the // full release (schema + catalog) or `--skip-schema` (catalog only). return await runRelease({ projectDir, origin: def.origin, skipSchema: parsed.skipSchema ?? false, dryRun: parsed.dryRun ?? false, ...(parsed.name !== undefined ? { name: parsed.name } : {}), ...(parsed.publicUrl !== undefined ? { publicUrl: parsed.publicUrl } : {}), ...(parsed.otp !== undefined ? { otp: parsed.otp } : {}), ...(parsed.installByDefault ? { installByDefault: true } : {}), }) } const adapter = def.adapter as DomainAdapter // CLI param overrides — applied over the env's params here AND re-applied by // the config hot-regen path, which re-resolves `adapter.params` from the // fresh config (without them, the first config edit would silently drop the // flags — e.g. lose the `--host`-pinned WORKER_URL and drift the iss). const paramOverrides: Record = {} // CLI port override beats the env's configured port (both adapters read // `params.port` in watch). if (parsed.command === 'dev' && parsed.port !== undefined) paramOverrides.port = parsed.port // `--host `: off-localhost dev (sandbox preview, tunnel). The // adapter binds 0.0.0.0 and pins WORKER_URL so the worker's per-request-Host // identity doesn't drift to the proxy's internal hostname. if (parsed.command === 'dev' && parsed.host !== undefined) paramOverrides.host = parsed.host const params = { ...(adapter.params(parsed.env) as Record), ...paramOverrides } const domain = toDomainInfo(def) const secrets = loadSecrets(adapter, params, projectDir) if (parsed.command === 'dev' || (parsed.command === 'deploy' && parsed.watch)) { return runWatch({ adapter, params, paramOverrides, projectDir, specPath, secrets, domain, env: parsed.env, def, configPath, }) } // deploy (no watch) const result = await adapter.deploy(params, { projectDir, specPath, secrets, domain, env: parsed.env, schemaHashForUrl: async (url) => { const { schemaHash } = await buildProjectSpec(def, url) return schemaHash }, }) await writeSpecBestEffort({ projectDir, specPath, def, url: result.url }) printDeployed(result, def) if (parsed.publish) { // `--publish` = release after deploy: ship the schema (if any) THEN register // the just-deployed URL — same npm-first ordering as `publish`. return await runReleaseAfterDeploy({ projectDir, origin: def.origin, deployedUrl: result.url, skipSchema: parsed.skipSchema ?? false, ...(parsed.name !== undefined ? { name: parsed.name } : {}), ...(parsed.otp !== undefined ? { otp: parsed.otp } : {}), ...(parsed.installByDefault ? { installByDefault: true } : {}), }) } return 0 } async function runWatch(args: { adapter: DomainAdapter params: unknown paramOverrides: Record projectDir: string specPath: string secrets: Record domain: DomainInfo env: string def: DeployConfig configPath: string }): Promise { const { adapter, params, projectDir, specPath, secrets, domain, env, def } = args let reloads = 0 const onReload = () => { reloads += 1 info(`↻ reloaded (#${reloads}) — same URL, no reinstall needed for handler edits`) } const handle: WatchHandle = await adapter.watch(params, { projectDir, specPath, secrets, domain, env, onReload, }) await writeSpecBestEffort({ projectDir, specPath, def, url: handle.url }) printDevReady(handle.url, domain) const stopConfigWatch = watchConfigForRegen({ configPath: args.configPath, initial: def, env, paramOverrides: args.paramOverrides, projectDir, specPath, url: handle.url, onReload, }) await new Promise((resolveStop) => { const stop = () => { stopConfigWatch() void handle.stop().finally(resolveStop) } process.on('SIGINT', stop) process.on('SIGTERM', stop) }) return 0 } /** * Watch `astrale.config.ts` while `dev` runs: on change, re-import the config * (cache-busted), re-resolve env params + secrets, and re-run the adapter's * codegen via `adapter.regenerate` — the running dev server (e.g. `wrangler * dev`, which watches its generated config + entry) reloads them itself, so a * config edit lands without restarting the CLI. * * Watches the config's DIRECTORY, not the file: editors save via atomic * rename, which silently kills an inode-bound watch. * * Deliberate limits, surfaced to the user instead of half-applied: * • origin / adapter changes re-key the worker's identity → restart; * • a mid-edit broken config (syntax error, bad env) warns and keeps the * previous generation running; * • modules the config IMPORTS (e.g. the schema) stay module-cached — only * the config file itself is re-evaluated, which covers everything * `defineDomain` owns (vars, requires, postInstall, wrangler overlay…). */ export function watchConfigForRegen(args: { configPath: string initial: DeployConfig env: string /** CLI flag overrides (dev --port / --host) — re-applied on every regen. */ paramOverrides: Record projectDir: string specPath: string url: string onReload: () => void }): () => void { let running = false let pending = false let stopped = false let timer: ReturnType | undefined async function regen(): Promise { if (stopped) return if (running) { pending = true return } running = true try { const def = await loadConfig(args.configPath, { fresh: true }) if (def.origin !== args.initial.origin || def.adapter.name !== args.initial.adapter.name) { warn( `astrale.config.ts: \`origin\` or adapter changed — that re-keys the worker's ` + `identity; restart \`astrale-domain dev\` to apply.`, ) return } const adapter = def.adapter as DomainAdapter if (!adapter.regenerate) { warn( `astrale.config.ts changed, but the "${adapter.name}" adapter has no \`regenerate\` — ` + `restart \`astrale-domain dev\` to apply.`, ) return } const params = { ...(adapter.params(args.env) as Record), ...args.paramOverrides, } const domain = toDomainInfo(def) await adapter.regenerate(params, { projectDir: args.projectDir, specPath: args.specPath, secrets: loadSecrets(adapter, params, args.projectDir), domain, env: args.env, onReload: args.onReload, }) await writeSpecBestEffort({ projectDir: args.projectDir, specPath: args.specPath, def, url: args.url, }) info( `↻ astrale.config.ts changed — codegen regenerated, the dev server reloads it ` + `(a port / workerName / remote change still needs a restart)`, ) } catch (err) { warn( `astrale.config.ts changed but reloading it failed (previous config stays live): ` + `${(err as Error).message}`, ) } finally { running = false if (pending && !stopped) { pending = false void regen() } } } const configFile = basename(args.configPath) const watcher = watchFs(dirname(args.configPath), (_event, file) => { if (file && file !== configFile) return clearTimeout(timer) // Debounce: editors fire several fs events per save (write + rename). timer = setTimeout(() => void regen(), 200) }) return () => { stopped = true clearTimeout(timer) watcher.close() } } // ── spec ───────────────────────────────────────────────────────────────── async function writeSpec(args: { projectDir: string specPath: string def: DeployConfig url: string }): Promise { const { wire, schemaHash } = await buildProjectSpec(args.def, args.url) await mkdir(dirname(args.specPath), { recursive: true }) await writeFile(args.specPath, JSON.stringify({ ...wire, schemaHash }, null, 2)) } /** * Diagnostic-spec write for dev/deploy: best-effort — the worker builds the * live install bundle itself, so a malformed project still runs/deploys; we * warn and continue. */ async function writeSpecBestEffort(args: { projectDir: string specPath: string def: DeployConfig url: string }): Promise { try { await writeSpec(args) } catch (err) { const msg = (err as Error).message warn(`(non-fatal) skipped the local diagnostic spec — the worker builds the real one: ${msg}`) if (msg.includes('MISSING_DEF')) { warn( ` MISSING_DEF here usually means TWO copies of @astrale-os/kernel-core are loaded ` + `(linked dev deps). Try \`pnpm dedupe\`; the deploy itself is unaffected.`, ) } } } // ── helpers ──────────────────────────────────────────────────────────────── function loadSecrets( adapter: DomainAdapter, params: unknown, projectDir: string, ): Record { const file = adapter.secretsFile?.(params) if (!file) return {} const abs = isAbsolute(file) ? file : join(projectDir, file) return loadDeclaredSecrets(abs, file) } function findConfig(dir: string): string | undefined { for (const name of CONFIG_NAMES) { const candidate = join(dir, name) if (existsSync(candidate)) return candidate } return undefined } function toDomainInfo(def: DeployConfig): DomainInfo { return { origin: def.origin, requires: def.requires, ...(def.postInstall ? { postInstall: def.postInstall } : {}), // Presence comes from the definition the author wired — never a folder probe. hasViews: def.views !== undefined, hasFunctions: def.functions !== undefined, mountedViews: collectMountedViews(def.views), hasDeps: def.deps !== undefined, } } function collectMountedViews(views: DeployConfig['views']): DomainInfo['mountedViews'] { if (!views) return [] const mounted: DomainInfo['mountedViews'] = [] for (const [slug, def] of Object.entries(views)) { const mount = (def as { mount?: unknown }).mount if (typeof mount !== 'string' || mount.length === 0) continue mounted.push({ slug, mount: normalizeMount(mount) }) } return mounted } function normalizeMount(mount: string): string { return `/${mount.replace(/^\/+/, '')}` } async function loadConfig(path: string, opts?: { fresh: boolean }): Promise { // Re-importing an EDITED file: Bun's ESM registry is keyed by path and never // re-reads the source — a `?reload=N` query re-EVALUATES but serves the // CACHED (stale) source. Bun unifies that registry with `require.cache`, so // deleting the entry forces a fresh read on the next import (the CLI is // Bun-gated, see `run()`). Modules the config itself imports stay cached — // the documented hot-regen limit. if (opts?.fresh) delete createRequire(import.meta.url).cache[path] const mod = (await import(pathToFileURL(path).href)) as { default?: unknown } const def = mod.default if (!def || typeof def !== 'object' || !('adapter' in def) || !('origin' in def)) { throw new Error( `${path} must \`export default deploy(domain, adapter)\` from '@astrale-os/sdk' ` + `(where \`domain\` is a \`defineDomain({ ... })\` from your domain.ts).`, ) } return def as DeployConfig } /** Require a plausible public URL for `--host`; default the scheme to https. */ function normalizeHost(raw: string | undefined): string { if (!raw || raw.startsWith('-')) throw new Error(`--host needs a public URL, got "${raw ?? ''}"`) const url = /^https?:\/\//.test(raw) ? raw : `https://${raw}` try { return new URL(url).origin } catch { throw new Error(`--host needs a valid URL, got "${raw}"`) } } export function parseArgs(argv: readonly string[]): ParsedArgs { const [cmd, ...rest] = argv const watch = rest.includes('--watch') const fix = rest.includes('--fix') // Tail-flag (deploy/prod/publish): register the deployed URL in the admin // catalog after a successful deploy. The `publish` command implies it. const publish = rest.includes('--publish') const installByDefault = rest.includes('--install-by-default') // `--dry-run` (publish): pack + `npm publish --dry-run` and skip the catalog // register. A boolean flag, filtered out of `positionals` below. const dryRun = rest.includes('--dry-run') // Publish modes: `--schema` = schema only · `--skip-schema` = catalog only. const schemaOnly = rest.includes('--schema') const skipSchema = rest.includes('--skip-schema') // `--port ` / `--port=`: consume the flag AND its value so neither // leaks into the positionals (a bare `8788` would be read as the env name). let port: number | undefined let host: string | undefined let name: string | undefined let publicUrl: string | undefined let otp: string | undefined let format: LintReportFormat | undefined const cleaned: string[] = [] for (let i = 0; i < rest.length; i++) { const a = rest[i]! if (a === '--port') { const v = rest[++i] port = Number(v) if (!Number.isInteger(port) || port <= 0) throw new Error(`--port needs a port number, got "${v}"`) } else if (a.startsWith('--port=')) { port = Number(a.slice('--port='.length)) if (!Number.isInteger(port) || port <= 0) throw new Error(`--port needs a port number, got "${a}"`) } else if (a === '--host') { host = normalizeHost(rest[++i]) } else if (a.startsWith('--host=')) { host = normalizeHost(a.slice('--host='.length)) } else if (a === '--name') { name = rest[++i] if (!name) throw new Error('--name needs a value (the registry slug to publish under)') } else if (a.startsWith('--name=')) { name = a.slice('--name='.length) } else if (a === '--public-url') { publicUrl = rest[++i] if (!publicUrl) throw new Error('--public-url needs a value (the address the catalog points at)') } else if (a.startsWith('--public-url=')) { publicUrl = a.slice('--public-url='.length) } else if (a === '--otp') { otp = rest[++i] if (!otp) throw new Error('--otp needs a value (your 2FA one-time password)') } else if (a.startsWith('--otp=')) { otp = a.slice('--otp='.length) } else if (a === '--format') { const value = rest[++i] if (value !== 'stylish' && value !== 'json') { throw new Error(`--format must be "stylish" or "json", got "${value ?? ''}"`) } format = value } else if (a.startsWith('--format=')) { const value = a.slice('--format='.length) if (value !== 'stylish' && value !== 'json') { throw new Error(`--format must be "stylish" or "json", got "${value}"`) } format = value } else { cleaned.push(a) } } const positionals = cleaned.filter((a) => !a.startsWith('-')) // `--dry-run` belongs ONLY to `publish` — never let it silently no-op a deploy. if (dryRun && cmd !== 'publish') throw new Error('`--dry-run` is only valid for `publish`.') // `--otp` feeds the schema publish (publish, or deploy/prod `--publish`). if (otp !== undefined && !['publish', 'deploy', 'prod'].includes(cmd ?? '')) throw new Error('`--otp` is only valid for the publish / deploy commands.') // `--schema` (schema only) / `--skip-schema` (catalog only): publish-only, opposite. if (schemaOnly && cmd !== 'publish') throw new Error('`--schema` is only valid for `publish`.') if (schemaOnly && skipSchema) throw new Error( '`--schema` (schema only) and `--skip-schema` (catalog only) are mutually exclusive.', ) if (fix && cmd !== 'lint') throw new Error('`--fix` is only valid for `lint`.') if (format !== undefined && cmd !== 'lint') { throw new Error('`--format` is only valid for `lint`.') } // Publish-related fields shared by deploy/prod/publish (publish itself is // gated per-command: opt-in via `--publish`, always-on for `publish`). `otp` / // `skipSchema` ride along because the schema-publish step runs on the // `publish` command AND the `deploy --publish` tail. const pub = { ...(name !== undefined ? { name } : {}), ...(installByDefault ? { installByDefault } : {}), ...(otp !== undefined ? { otp } : {}), ...(skipSchema ? { skipSchema } : {}), } switch (cmd) { case 'dev': return { command: 'dev', env: positionals[0] ?? 'dev', watch: true, ...(port !== undefined ? { port } : {}), ...(host !== undefined ? { host } : {}), } case 'prod': return { command: 'deploy', env: 'prod', watch, ...(publish ? { publish } : {}), ...pub } case 'deploy': { const env = positionals[0] if (!env) throw new Error('`deploy` requires an env key, e.g. `deploy prod`.') return { command: 'deploy', env, watch, ...(publish ? { publish } : {}), ...pub } } case 'publish': // Release: publish ./schema + register the deployed URL (no deploy). // `--schema` = schema only · `--skip-schema` = catalog only · `--dry-run` // packs the schema and skips the catalog register. return { command: 'publish', env: positionals[0] ?? 'prod', watch: false, ...pub, ...(publicUrl !== undefined ? { publicUrl } : {}), ...(dryRun ? { dryRun } : {}), ...(schemaOnly ? { schemaOnly } : {}), } case 'build': // Spec-only — no env resolution happens on this path. return { command: 'build', env: 'dev', watch: false } case 'lint': return { command: 'lint', env: 'dev', watch: false, ...(fix ? { fix: true } : {}), ...(format ? { format } : {}), } default: throw new Error(cmd ? `Unknown command "${cmd}".` : 'Missing command.') } } function rel(from: string, to: string): string { return to.startsWith(from) ? `.${to.slice(from.length)}` : to } // ── output ─────────────────────────────────────────────────────────────── function printDevReady(url: string, domain: DomainInfo): void { process.stdout.write( `\n${GREEN}✓${RESET} ${BOLD}${domain.origin}${RESET} running (dev, hot-reload)\n` + ` ${BOLD}URL${RESET} ${GREEN}${url}${RESET}\n` + ` ${DIM}install on an instance:${RESET}\n` + ` ${BOLD}astrale domain install ${url} --direct${RESET}\n` + ` ${DIM}edit a handler → reloads at the same URL. edit the schema → reinstall.${RESET}\n\n`, ) } function printDeployed(result: DeployResult, def: DeployConfig): void { // Adapters that already completed the install (managed deploys) supply // their own next-steps; the default hint would tell the user to redo it. const footer = result.nextSteps !== undefined ? result.nextSteps === '' ? '' : `${result.nextSteps}\n` : ` ${DIM}install on an instance:${RESET}\n` + ` ${BOLD}astrale domain install ${result.url} --direct${RESET}\n` process.stdout.write( `\n${GREEN}✓${RESET} ${BOLD}${def.origin}${RESET} deployed\n` + ` ${BOLD}URL${RESET} ${GREEN}${result.url}${RESET}\n` + footer + `\n`, ) } function printUsage(msg?: string): void { if (msg) error(msg) process.stderr.write( `\nUsage:\n` + ` astrale-domain dev # deploy dev --watch (hot-reload, prints URL)\n` + ` astrale-domain prod # deploy prod\n` + ` astrale-domain deploy # deploy any env key (--watch optional)\n` + ` astrale-domain publish [env] # RELEASE: publish ./schema to npm (if any) + register the deployed URL\n` + ` astrale-domain build # rebuild the diagnostic spec only\n` + ` astrale-domain lint # generic Oxlint + Astrale domain rules\n` + `\nFlags:\n` + ` --publish # on deploy/prod: ALSO release (publish ./schema + register the deployed URL)\n` + ` --schema # (publish) publish ONLY the ./schema package — no catalog, no config\n` + ` --skip-schema # (publish / deploy --publish) register the catalog only; don't publish ./schema\n` + ` --name # registry name to publish under (default: the origin's first label)\n` + ` --public-url # (publish) address the catalog points at (default: https://)\n` + ` --install-by-default # mark the published domain for install on every new instance\n` + ` --dry-run # (publish) pack + npm publish --dry-run; never ships, skips catalog\n` + ` --otp # (publish / deploy --publish) 2FA code for npm publish\n` + ` --fix # (lint) apply safe fixes\n` + ` --format # (lint) select human or machine output\n` + `\n catalog register shells out to \`astrale domain publish\` (needs \`astrale\` on PATH).\n` + ` schema publish uses \`pnpm pack\` then \`npm publish\` (needs \`pnpm\` + \`npm\`; npm handles 2FA).\n\n`, ) }