diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da2d5d8c3ae3662ffbda8cad6c29558a66304cb4..89b811d02a2d2819d39907983682c4e6110c62fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -273,13 +273,11 @@ jobs: run: bun run build --filter=@t3tools/web --filter=t3 - name: Publish CLI package - run: bun publish --access public --tag alpha --ignore-scripts - working-directory: apps/server + run: node apps/server/scripts/cli.ts publish --tag alpha --provenance release: name: Publish GitHub Release - needs: [preflight, build] - # needs: [preflight, build, publish_cli] + needs: [preflight, build, publish_cli] runs-on: ubuntu-24.04 steps: - name: Download all desktop artifacts diff --git a/apps/server/package.json b/apps/server/package.json index 4990aa272cab0551cccf9501faaa9db2ea6ca6b5..e6ebb2dad3693402c5679f7a9991387fdf9fe5a0 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -8,10 +8,9 @@ "dist" ], "type": "module", - "main": "./dist/index.mjs", "scripts": { "dev": "VITE_DEV_SERVER_URL=${VITE_DEV_SERVER_URL:-http://localhost:5733} bun run src/index.ts", - "build": "tsdown && node scripts/bundle-client.ts", + "build": "node scripts/cli.ts build", "start": "node dist/index.mjs", "prepare": "effect-language-service patch", "typecheck": "tsc --noEmit", @@ -38,5 +37,8 @@ "tsdown": "^0.20.3", "typescript": "^5.7.3", "vitest": "^4.0.0" + }, + "engines": { + "node": "^22.13 || ^23.4 || >=24.10" } } diff --git a/apps/server/scripts/bundle-client.ts b/apps/server/scripts/bundle-client.ts deleted file mode 100644 index 85d5a9aebbdab16b94bb54d6664b24caa0bbd12c..0000000000000000000000000000000000000000 --- a/apps/server/scripts/bundle-client.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copies the built web app into dist/client/ so the published npm package - * includes the web UI. This runs as a post-build step. - */ -import * as fs from "node:fs"; -import * as path from "node:path"; - -const webDist = path.resolve(import.meta.dirname, "../../web/dist"); -const target = path.resolve(import.meta.dirname, "../dist/client"); - -if (!fs.existsSync(webDist)) { - console.log( - "⚠ Web dist not found — skipping client bundle. Run `bun run --cwd apps/web build` first.", - ); - process.exit(0); -} - -fs.cpSync(webDist, target, { recursive: true }); -console.log("✓ Bundled web app into dist/client"); diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts new file mode 100644 index 0000000000000000000000000000000000000000..2dee1cc4ff455cc5f0116904fe530d5cdaa81896 --- /dev/null +++ b/apps/server/scripts/cli.ts @@ -0,0 +1,168 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Data, Effect, FileSystem, Logger, Path } from "effect"; +import { Command, Flag } from "effect/unstable/cli"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; +import rootPackageJson from "../../../package.json" with { type: "json" }; +import serverPackageJson from "../package.json" with { type: "json" }; + +class CliError extends Data.TaggedError("CliError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const RepoRoot = Effect.service(Path.Path).pipe( + Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))), +); + +const runCommand = Effect.fn(function* (command: ChildProcess.Command) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(command); + const exitCode = yield* child.exitCode; + + if (exitCode !== 0) { + return yield* new CliError({ + message: `Command exited with non-zero exit code (${exitCode})`, + }); + } +}); + +// --------------------------------------------------------------------------- +// build subcommand +// --------------------------------------------------------------------------- + +const buildCmd = Command.make( + "build", + { + verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)), + }, + (config) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repoRoot = yield* RepoRoot; + const serverDir = path.join(repoRoot, "apps/server"); + + yield* Effect.log("[cli] Running tsdown..."); + yield* runCommand( + ChildProcess.make({ + cwd: serverDir, + stdout: config.verbose ? "inherit" : "ignore", + stderr: "inherit", + })`tsdown`, + ); + + const webDist = path.join(repoRoot, "apps/web/dist"); + const clientTarget = path.join(serverDir, "dist/client"); + + if (yield* fs.exists(webDist)) { + yield* fs.copy(webDist, clientTarget); + yield* Effect.log("[cli] Bundled web app into dist/client"); + } else { + yield* Effect.log("[cli] Web dist not found — skipping client bundle."); + } + }), +).pipe(Command.withDescription("Build the server package (tsdown + bundle web client).")); + +// --------------------------------------------------------------------------- +// publish subcommand +// --------------------------------------------------------------------------- + +const publishCmd = Command.make( + "publish", + { + tag: Flag.string("tag").pipe(Flag.withDefault("latest")), + access: Flag.string("access").pipe(Flag.withDefault("public")), + provenance: Flag.boolean("provenance").pipe(Flag.withDefault(false)), + dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)), + verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)), + }, + (config) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repoRoot = yield* RepoRoot; + const serverDir = path.join(repoRoot, "apps/server"); + const packageJsonPath = path.join(serverDir, "package.json"); + const backupPath = `${packageJsonPath}.bak`; + + // Assert build assets exist + for (const relPath of ["dist/index.mjs", "dist/client/index.html"]) { + const abs = path.join(serverDir, relPath); + if (!(yield* fs.exists(abs))) { + return yield* new CliError({ + message: `Missing build asset: ${abs}. Run the build subcommand first.`, + }); + } + } + + yield* Effect.acquireUseRelease( + // Acquire: backup package.json, resolve catalog: deps, strip devDependencies/scripts + Effect.gen(function* () { + const original = yield* fs.readFileString(packageJsonPath); + yield* fs.writeFileString(backupPath, original); + + // Build package.json for publish + const pkg = { + name: serverPackageJson.name, + type: serverPackageJson.type, + version: serverPackageJson.version, + engines: serverPackageJson.engines, + files: serverPackageJson.files, + dependencies: serverPackageJson.dependencies as Record, + }; + + // Resolve catalog: entries in production dependencies + pkg.dependencies = resolveCatalogDependencies( + pkg.dependencies, + rootPackageJson.workspaces.catalog, + "apps/server dependencies", + ); + + yield* fs.writeFileString(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`); + yield* Effect.log("[cli] Resolved package.json for publish"); + }), + // Use: npm publish + () => + Effect.gen(function* () { + const args = ["publish", "--access", config.access, "--tag", config.tag]; + if (config.provenance) args.push("--provenance"); + if (config.dryRun) args.push("--dry-run"); + + yield* Effect.log(`[cli] Running: npm ${args.join(" ")}`); + yield* runCommand( + ChildProcess.make("npm", args, { + cwd: serverDir, + stdout: config.verbose ? "inherit" : "ignore", + stderr: "inherit", + }), + ); + }), + // Release: restore + () => + Effect.gen(function* () { + yield* fs.rename(backupPath, packageJsonPath); + if (config.verbose) yield* Effect.log("[cli] Restored original package.json"); + }), + ); + }), +).pipe(Command.withDescription("Publish the server package to npm.")); + +// --------------------------------------------------------------------------- +// root command +// --------------------------------------------------------------------------- + +const cli = Command.make("cli").pipe( + Command.withDescription("T3 server build & publish CLI."), + Command.withSubcommands([buildCmd, publishCmd]), +); + +Command.run(cli, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide([Logger.layer([Logger.consolePretty()]), NodeServices.layer]), + NodeRuntime.runMain, +); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 6173f99e44ba9f6d8c146dfd0acf9d7106170f8c..07d52467f51c5b08cdd5395b03e97df9efeaaf66 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -21,5 +21,5 @@ } ] }, - "include": ["src", "tsdown.config.ts", "scripts", "integration"] + "include": ["src", "tsdown.config.ts", "scripts", "integration", "../../scripts/lib"] } diff --git a/apps/web/package.json b/apps/web/package.json index 078c2389258dd1830432dad23a02ef9f63042c1a..0bfe89ba6b59e15db888ae0b0af534f5b8915b26 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "vite build", + "prepare": "effect-language-service patch", "preview": "vite preview", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests" diff --git a/bun.lock b/bun.lock index 9ce15ebbb4f5b5a9447f18cbd9a57c14326ea65e..e443f3f0309b4d11d712c52c2c9a974c324ad19d 100644 --- a/bun.lock +++ b/bun.lock @@ -115,8 +115,14 @@ "packages/shared": { "name": "@t3tools/shared", "version": "0.0.0-alpha.1", + "dependencies": { + "effect": "catalog:", + }, "devDependencies": { + "@effect/language-service": "0.75.1", + "@effect/vitest": "catalog:", "typescript": "^5.7.3", + "vitest": "^4.0.0", }, }, }, diff --git a/packages/shared/package.json b/packages/shared/package.json index 222d20e9718a43bc72343fa4eeaae13abad969d1..b3c56640ff228b2f786f35ac8d06d4e3f43ae0ef 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -10,9 +10,17 @@ } }, "scripts": { - "typecheck": "tsc --noEmit" + "prepare": "effect-language-service patch", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "effect": "catalog:" }, "devDependencies": { - "typescript": "^5.7.3" + "@effect/language-service": "0.75.1", + "@effect/vitest": "catalog:", + "typescript": "^5.7.3", + "vitest": "^4.0.0" } } diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index a54bd41d08ce1f37ed7fe2c87ddec96c342a015b..2dc0f7b7228fc8082936a77808ff1ef6d1e845f9 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -6,6 +6,8 @@ import rootPackageJson from "../package.json" with { type: "json" }; import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; import serverPackageJson from "../apps/server/package.json" with { type: "json" }; +import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; + import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { Config, Data, Effect, FileSystem, Logger, Option, Path, Schema } from "effect"; @@ -319,31 +321,6 @@ function validateBundledClientAssets(clientDir: string) { }); } -function resolveCatalogDependencies( - dependencies: Record, - catalog: Record, - dependencySourceLabel: string, -): Record { - return Object.fromEntries( - Object.entries(dependencies).map(([dependencyName, spec]) => { - if (typeof spec !== "string" || !spec.startsWith("catalog:")) { - return [dependencyName, spec]; - } - - const catalogKey = spec.slice("catalog:".length).trim(); - const lookupKey = catalogKey.length > 0 ? catalogKey : dependencyName; - const resolvedSpec = catalog[lookupKey]; - if (typeof resolvedSpec !== "string" || resolvedSpec.length === 0) { - throw new BuildScriptError({ - message: `Unable to resolve '${spec}' for ${dependencySourceLabel} dependency '${dependencyName}'. Expected key '${lookupKey}' in root workspace catalog.`, - }); - } - - return [dependencyName, resolvedSpec]; - }), - ); -} - const createBuildConfig = Effect.fn(function* ( platform: typeof BuildPlatform.Type, target: string, diff --git a/scripts/lib/resolve-catalog.ts b/scripts/lib/resolve-catalog.ts new file mode 100644 index 0000000000000000000000000000000000000000..2946c4a5d9e1fbdc2674a1c5b7b2adc09806ad37 --- /dev/null +++ b/scripts/lib/resolve-catalog.ts @@ -0,0 +1,31 @@ +/** + * Resolve `catalog:` dependency specs using the workspace catalog. + * + * Pure function: returns a new record with every `catalog:…` value replaced by + * the concrete version string found in `catalog`. Throws on missing entries. + */ +export function resolveCatalogDependencies( + dependencies: Record, + catalog: Record, + label: string, +): Record { + return Object.fromEntries( + Object.entries(dependencies).map(([name, spec]) => { + if (typeof spec !== "string" || !spec.startsWith("catalog:")) { + return [name, spec]; + } + + const catalogKey = spec.slice("catalog:".length).trim(); + const lookupKey = catalogKey.length > 0 ? catalogKey : name; + const resolved = catalog[lookupKey]; + + if (typeof resolved !== "string" || resolved.length === 0) { + throw new Error( + `Unable to resolve '${spec}' for ${label} dependency '${name}'. Expected key '${lookupKey}' in root workspace catalog.`, + ); + } + + return [name, resolved]; + }), + ); +}