/** * All `publish` logic — the schema-publish step, the admin-catalog register, and * the release orchestration tying them together. `run.ts` only parses args and * dispatches here. * * A release has two halves that must stay in sync: the CONTRACT (the `./schema` * package on npm, so other domains can `imports`/`implements`/`extends` it) and * the DEPLOYMENT (the worker URL in the admin catalog). They run npm-FIRST — npm * publish is irreversible, so the catalog is registered only on schema-publish * success, never pointing at an unpublished contract. * * Why `pnpm pack` then `npm publish ` (not a plain `npm publish`): a * schema package ships only `dist` and uses `publishConfig` to repoint * main/exports from source `.ts` to `dist` — a field override only PNPM applies, * so npm would otherwise publish an entry pointing at unshipped source. */ import { spawn } from 'node:child_process' import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { BOLD, DIM, error, GREEN, info, RESET, warn } from './log.js' type SchemaPkg = { name?: string version?: string private?: boolean files?: readonly string[] peerDependencies?: Record dependencies?: Record optionalDependencies?: Record publishConfig?: { access?: string } } // ── release orchestration (schema THEN catalog) ────────────────────────────── /** `publish` (no deploy): publish the schema, then register the deployed URL. */ export async function runRelease(opts: { projectDir: string origin: string name?: string publicUrl?: string skipSchema: boolean dryRun: boolean otp?: string installByDefault?: boolean }): Promise { const schemaCode = await publishSchemaBeforeCatalog(opts.projectDir, opts) if (schemaCode !== 0) return schemaCode const url = opts.publicUrl ?? `https://${opts.origin}` if (opts.dryRun) { info(`dry-run: would register ${opts.origin} → ${url} in the admin catalog (not registered).`) return 0 } info(`registering ${opts.origin} → ${url} in the admin catalog (no deploy)`) return await registerCatalog(opts.projectDir, opts, url) } /** The `deploy --publish` tail: publish the schema, then register the just-deployed URL. */ export async function runReleaseAfterDeploy(opts: { projectDir: string origin: string deployedUrl: string name?: string skipSchema: boolean otp?: string installByDefault?: boolean }): Promise { const schemaCode = await publishSchemaBeforeCatalog(opts.projectDir, { ...opts, dryRun: false }) if (schemaCode !== 0) return schemaCode return await registerCatalog(opts.projectDir, opts, opts.deployedUrl) } /** * The schema half of a release. npm publish is irreversible, so it runs FIRST: * returns 0 to proceed (published | skipped | no `./schema` package), non-zero to * ABORT before the catalog touch — so the catalog never points at an unpublished * contract. */ async function publishSchemaBeforeCatalog( projectDir: string, opts: { skipSchema: boolean; dryRun: boolean; otp?: string }, ): Promise { if (opts.skipSchema) return 0 if (!existsSync(join(projectDir, 'schema', 'package.json'))) { info('no ./schema package — registering the catalog only.') return 0 } const code = await publishSchema({ projectDir, schemaDir: 'schema', dryRun: opts.dryRun, ...(opts.otp !== undefined ? { otp: opts.otp } : {}), }) if (code !== 0) error( 'schema publish failed — NOT registering the catalog (a catalog entry must never point ' + 'at an unpublished contract). Fix the schema package and re-run, or pass --skip-schema.', ) return code } /** * Register a domain's public URL in the admin catalog by shelling out to the * operator CLI (`astrale domain publish`). Deliberately a subprocess: all * credential/admin-target resolution lives in `@astrale-os/cli`, kept out of * this build tool. A missing `astrale` is reported with the exact line to run by * hand. The catalog `name` is a short slug (the origin's first label by default), * distinct from the FQDN origin and from any `@scope/…` npm package name. */ async function registerCatalog( projectDir: string, opts: { origin: string; name?: string; installByDefault?: boolean }, url: string, ): Promise { const { description } = readPackageMeta(projectDir) const name = opts.name ?? opts.origin.split('.')[0] ?? opts.origin // `--public-url`, not `--url`: the operator CLI reserves `--url` for kernel targeting. const cliArgs = [ 'domain', 'publish', '--origin', opts.origin, '--name', name, '--public-url', url, ] if (description) cliArgs.push('--description', description) if (opts.installByDefault) cliArgs.push('--install-by-default') info(`publishing to the admin catalog → astrale ${cliArgs.join(' ')}`) return await new Promise((resolve) => { const child = spawn('astrale', cliArgs, { stdio: 'inherit' }) child.on('error', (err) => { if ((err as NodeJS.ErrnoException).code === 'ENOENT') error( `\`astrale\` CLI not found on PATH. Register it by hand:\n astrale ${cliArgs.join(' ')}`, ) else error(`failed to run \`astrale domain publish\`: ${err.message}`) resolve(1) }) child.on('exit', (code) => resolve(code ?? 1)) }) } function readPackageMeta(projectDir: string): { name?: string; description?: string } { try { return JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')) as { name?: string description?: string } } catch { return {} } } // ── schema publish ─────────────────────────────────────────────────────────── /** * Build a domain's thin schema package and publish it to npm. Operates on the * schema PACKAGE (its own package.json), not astrale.config.ts, so it needs no * adapter — `publish --schema` runs it config-free. */ export async function publishSchema(args: { projectDir: string /** Schema-package dir. Default: `/schema`, else ``. */ schemaDir?: string dryRun: boolean /** 2FA one-time password forwarded to `npm publish`. */ otp?: string }): Promise { const dir = resolveSchemaDir(args.projectDir, args.schemaDir) if (!dir) { error( `No schema package found (no package.json at ./schema or the current dir). A schema\n` + ` package declares peerDependencies (@astrale-os/kernel-core + kernel-dsl + zod) and a\n` + ` publishConfig that repoints main/exports to ./dist — see domains/shell/schema.`, ) return 1 } const pkgPath = join(dir, 'package.json') let pkg: SchemaPkg try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as SchemaPkg } catch (err) { error(`Could not read ${pkgPath}: ${(err as Error).message}`) return 1 } const problems = preflight(pkg) if (problems.length > 0) { error(`Schema package is not ready to publish (${pkgPath}):`) for (const p of problems) process.stderr.write(` • ${p}\n`) return 1 } const name = pkg.name as string const version = pkg.version as string // Idempotent in BOTH modes: a dry-run must skip too, since `npm publish // --dry-run` still validates against the registry and errors on an existing version. if (await isOnRegistry(name, version)) { info( `${BOLD}${name}@${version}${RESET} is already published — ` + (args.dryRun ? `a real run would skip it.` : `bump the version to ship again.`), ) return 0 } const dest = mkdtempSync(join(tmpdir(), 'astrale-schema-pack-')) try { info(`packing ${BOLD}${name}@${version}${RESET} from ${rel(args.projectDir, dir)}`) if ((await runInherit('pnpm', ['pack', '--pack-destination', dest], dir)) !== 0) { error(`pnpm pack failed — see the build output above.`) return 1 } const tarball = readdirSync(dest) .filter((f) => f.endsWith('.tgz')) .map((f) => join(dest, f))[0] if (!tarball) { error(`pnpm pack produced no .tgz in ${dest}.`) return 1 } // `--access` mirrors the manifest's baked publishConfig.access; npm's 2FA // prompt passes through `stdio: 'inherit'` (or `--otp` for non-interactive). const access = pkg.publishConfig?.access ?? 'public' const publishArgs = [ 'publish', tarball, '--access', access, ...(args.dryRun ? ['--dry-run'] : []), ...(args.otp ? ['--otp', args.otp] : []), ] info( args.dryRun ? `dry-run: ${DIM}npm ${publishArgs.join(' ')}${RESET}` : `publishing ${DIM}(a browser / OTP prompt may appear for 2FA)${RESET}`, ) const code = await runInherit('npm', publishArgs) if (code !== 0) { error( `npm publish exited ${code}. An auth error (E401/E404) usually means the session ` + `expired — \`npm login\` and retry.`, ) return code } if (args.dryRun) info(`dry-run complete — nothing was published.`) else printPublished(name, version) return 0 } finally { try { rmSync(dest, { recursive: true, force: true }) } catch { warn(`(non-fatal) could not clean the temp pack dir ${dest}`) } } } /** Locate the schema package: an explicit path, else `/schema`, else ``. */ function resolveSchemaDir(projectDir: string, explicit?: string): string | undefined { const candidates = explicit ? [join(projectDir, explicit)] : [join(projectDir, 'schema'), projectDir] return candidates.find((c) => existsSync(join(c, 'package.json'))) } /** Static guards against the classic schema-publish footguns, before any network call. */ function preflight(pkg: SchemaPkg): string[] { const problems: string[] = [] if (!pkg.name) problems.push('package.json has no "name".') if (!pkg.version) problems.push('package.json has no "version".') else if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(pkg.version)) problems.push(`"version" ("${pkg.version}") must be an exact semver, not a range.`) if (pkg.private) problems.push('"private": true — remove it to publish.') // Peers, not deps: bundling kernel-core/dsl/zod gives the consumer a 2nd copy // and breaks the def `__brand` identity at compile time. if (!pkg.peerDependencies || Object.keys(pkg.peerDependencies).length === 0) problems.push( 'no "peerDependencies" — peer-depend on @astrale-os/kernel-core (+ kernel-dsl, zod).', ) if (!pkg.files?.includes('dist')) problems.push('"files" must include "dist" (ship only the built output).') if (!pkg.publishConfig) problems.push('no "publishConfig" — main/types/exports won\'t repoint to ./dist at pack time.') for (const field of ['dependencies', 'peerDependencies', 'optionalDependencies'] as const) { for (const [dep, spec] of Object.entries(pkg[field] ?? {})) { if (/^(link:|file:|workspace:)/.test(spec)) problems.push( `${field}["${dep}"] is "${spec}" — npm can't publish that; use a semver range.`, ) } } return problems } /** True if `@` already exists on the registry. */ async function isOnRegistry(name: string, version: string): Promise { return await new Promise((resolve) => { let out = '' const child = spawn('npm', ['view', `${name}@${version}`, 'version'], { stdio: ['ignore', 'pipe', 'ignore'], }) child.stdout?.on('data', (d) => (out += String(d))) child.on('error', () => resolve(false)) // npm missing / not-found / offline → let publish surface it child.on('exit', () => resolve(out.trim().length > 0)) }) } async function runInherit(cmd: string, cmdArgs: readonly string[], cwd?: string): Promise { return await new Promise((resolve) => { const child = spawn(cmd, cmdArgs as string[], { stdio: 'inherit', ...(cwd ? { cwd } : {}) }) child.on('error', (err) => { const c = (err as NodeJS.ErrnoException).code error( c === 'ENOENT' ? `\`${cmd}\` not found on PATH.` : `failed to run \`${cmd}\`: ${err.message}`, ) resolve(1) }) child.on('exit', (code) => resolve(code ?? 1)) }) } function printPublished(name: string, version: string): void { process.stdout.write( `\n${GREEN}✓${RESET} published ${BOLD}${name}@${version}${RESET}\n` + ` ${DIM}consume it:${RESET} ${BOLD}import { schema as S } from '${name}'${RESET}` + `${DIM} → imports: [S], implements: [S.interfaces.X]${RESET}\n` + ` ${DIM}and add ${BOLD}requires: ['']${RESET}${DIM} on the consumer ` + `(producer installs first).${RESET}\n\n`, ) } function rel(from: string, to: string): string { return to.startsWith(from) ? `.${to.slice(from.length) || '/'}` : to }