|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 4 | +import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | +import { Data, Effect, FileSystem, Logger, Path } from "effect"; |
| 6 | +import { Command, Flag } from "effect/unstable/cli"; |
| 7 | +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 8 | + |
| 9 | +import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; |
| 10 | +import rootPackageJson from "../../../package.json" with { type: "json" }; |
| 11 | +import serverPackageJson from "../package.json" with { type: "json" }; |
| 12 | + |
| 13 | +class CliError extends Data.TaggedError("CliError")<{ |
| 14 | + readonly message: string; |
| 15 | + readonly cause?: unknown; |
| 16 | +}> {} |
| 17 | + |
| 18 | +const RepoRoot = Effect.service(Path.Path).pipe( |
| 19 | + Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))), |
| 20 | +); |
| 21 | + |
| 22 | +const runCommand = Effect.fn(function* (command: ChildProcess.Command) { |
| 23 | + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 24 | + const child = yield* spawner.spawn(command); |
| 25 | + const exitCode = yield* child.exitCode; |
| 26 | + |
| 27 | + if (exitCode !== 0) { |
| 28 | + return yield* new CliError({ |
| 29 | + message: `Command exited with non-zero exit code (${exitCode})`, |
| 30 | + }); |
| 31 | + } |
| 32 | +}); |
| 33 | + |
| 34 | +// --------------------------------------------------------------------------- |
| 35 | +// build subcommand |
| 36 | +// --------------------------------------------------------------------------- |
| 37 | + |
| 38 | +const buildCmd = Command.make( |
| 39 | + "build", |
| 40 | + { |
| 41 | + verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)), |
| 42 | + }, |
| 43 | + (config) => |
| 44 | + Effect.gen(function* () { |
| 45 | + const path = yield* Path.Path; |
| 46 | + const fs = yield* FileSystem.FileSystem; |
| 47 | + const repoRoot = yield* RepoRoot; |
| 48 | + const serverDir = path.join(repoRoot, "apps/server"); |
| 49 | + |
| 50 | + yield* Effect.log("[cli] Running tsdown..."); |
| 51 | + yield* runCommand( |
| 52 | + ChildProcess.make({ |
| 53 | + cwd: serverDir, |
| 54 | + stdout: config.verbose ? "inherit" : "ignore", |
| 55 | + stderr: "inherit", |
| 56 | + })`tsdown`, |
| 57 | + ); |
| 58 | + |
| 59 | + const webDist = path.join(repoRoot, "apps/web/dist"); |
| 60 | + const clientTarget = path.join(serverDir, "dist/client"); |
| 61 | + |
| 62 | + if (yield* fs.exists(webDist)) { |
| 63 | + yield* fs.copy(webDist, clientTarget); |
| 64 | + yield* Effect.log("[cli] Bundled web app into dist/client"); |
| 65 | + } else { |
| 66 | + yield* Effect.log("[cli] Web dist not found — skipping client bundle."); |
| 67 | + } |
| 68 | + }), |
| 69 | +).pipe(Command.withDescription("Build the server package (tsdown + bundle web client).")); |
| 70 | + |
| 71 | +// --------------------------------------------------------------------------- |
| 72 | +// publish subcommand |
| 73 | +// --------------------------------------------------------------------------- |
| 74 | + |
| 75 | +const publishCmd = Command.make( |
| 76 | + "publish", |
| 77 | + { |
| 78 | + tag: Flag.string("tag").pipe(Flag.withDefault("latest")), |
| 79 | + access: Flag.string("access").pipe(Flag.withDefault("public")), |
| 80 | + provenance: Flag.boolean("provenance").pipe(Flag.withDefault(false)), |
| 81 | + dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)), |
| 82 | + verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)), |
| 83 | + }, |
| 84 | + (config) => |
| 85 | + Effect.gen(function* () { |
| 86 | + const path = yield* Path.Path; |
| 87 | + const fs = yield* FileSystem.FileSystem; |
| 88 | + const repoRoot = yield* RepoRoot; |
| 89 | + const serverDir = path.join(repoRoot, "apps/server"); |
| 90 | + const packageJsonPath = path.join(serverDir, "package.json"); |
| 91 | + const backupPath = `${packageJsonPath}.bak`; |
| 92 | + |
| 93 | + // Assert build assets exist |
| 94 | + for (const relPath of ["dist/index.mjs", "dist/client/index.html"]) { |
| 95 | + const abs = path.join(serverDir, relPath); |
| 96 | + if (!(yield* fs.exists(abs))) { |
| 97 | + return yield* new CliError({ |
| 98 | + message: `Missing build asset: ${abs}. Run the build subcommand first.`, |
| 99 | + }); |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + yield* Effect.acquireUseRelease( |
| 104 | + // Acquire: backup package.json, resolve catalog: deps, strip devDependencies/scripts |
| 105 | + Effect.gen(function* () { |
| 106 | + const original = yield* fs.readFileString(packageJsonPath); |
| 107 | + yield* fs.writeFileString(backupPath, original); |
| 108 | + |
| 109 | + // Build package.json for publish |
| 110 | + const pkg = { |
| 111 | + name: serverPackageJson.name, |
| 112 | + type: serverPackageJson.type, |
| 113 | + version: serverPackageJson.version, |
| 114 | + engines: serverPackageJson.engines, |
| 115 | + files: serverPackageJson.files, |
| 116 | + dependencies: serverPackageJson.dependencies as Record<string, unknown>, |
| 117 | + }; |
| 118 | + |
| 119 | + // Resolve catalog: entries in production dependencies |
| 120 | + pkg.dependencies = resolveCatalogDependencies( |
| 121 | + pkg.dependencies, |
| 122 | + rootPackageJson.workspaces.catalog, |
| 123 | + "apps/server dependencies", |
| 124 | + ); |
| 125 | + |
| 126 | + yield* fs.writeFileString(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`); |
| 127 | + yield* Effect.log("[cli] Resolved package.json for publish"); |
| 128 | + }), |
| 129 | + // Use: npm publish |
| 130 | + () => |
| 131 | + Effect.gen(function* () { |
| 132 | + const args = ["publish", "--access", config.access, "--tag", config.tag]; |
| 133 | + if (config.provenance) args.push("--provenance"); |
| 134 | + if (config.dryRun) args.push("--dry-run"); |
| 135 | + |
| 136 | + yield* Effect.log(`[cli] Running: npm ${args.join(" ")}`); |
| 137 | + yield* runCommand( |
| 138 | + ChildProcess.make("npm", args, { |
| 139 | + cwd: serverDir, |
| 140 | + stdout: config.verbose ? "inherit" : "ignore", |
| 141 | + stderr: "inherit", |
| 142 | + }), |
| 143 | + ); |
| 144 | + }), |
| 145 | + // Release: restore |
| 146 | + () => |
| 147 | + Effect.gen(function* () { |
| 148 | + yield* fs.rename(backupPath, packageJsonPath); |
| 149 | + if (config.verbose) yield* Effect.log("[cli] Restored original package.json"); |
| 150 | + }), |
| 151 | + ); |
| 152 | + }), |
| 153 | +).pipe(Command.withDescription("Publish the server package to npm.")); |
| 154 | + |
| 155 | +// --------------------------------------------------------------------------- |
| 156 | +// root command |
| 157 | +// --------------------------------------------------------------------------- |
| 158 | + |
| 159 | +const cli = Command.make("cli").pipe( |
| 160 | + Command.withDescription("T3 server build & publish CLI."), |
| 161 | + Command.withSubcommands([buildCmd, publishCmd]), |
| 162 | +); |
| 163 | + |
| 164 | +Command.run(cli, { version: "0.0.0" }).pipe( |
| 165 | + Effect.scoped, |
| 166 | + Effect.provide([Logger.layer([Logger.consolePretty()]), NodeServices.layer]), |
| 167 | + NodeRuntime.runMain, |
| 168 | +); |
0 commit comments