| author | |
| committer | |
| log | 11606fd9c6b21abedb49fb1ca2bc94913c4dd702 |
| tree | d4338a1710e123fc8cb9aa678aace382d0d5929a |
| parent | a935db07ef2a2697720ba0a8903a1731ddb87cc0 |
10 files changed, 225 insertions(+), 53 deletions(-)
.github/workflows/release.yml+2-4| ... | ... | @@ -273,13 +273,11 @@ jobs: |
| 273 | 273 | run: bun run build --filter=@t3tools/web --filter=t3 |
| 274 | 274 | |
| 275 | 275 | - name: Publish CLI package |
| 276 | run: bun publish --access public --tag alpha --ignore-scripts | |
| 277 | working-directory: apps/server | |
| 276 | run: node apps/server/scripts/cli.ts publish --tag alpha --provenance | |
| 278 | 277 | |
| 279 | 278 | release: |
| 280 | 279 | name: Publish GitHub Release |
| 281 | needs: [preflight, build] | |
| 282 | # needs: [preflight, build, publish_cli] | |
| 280 | needs: [preflight, build, publish_cli] | |
| 283 | 281 | runs-on: ubuntu-24.04 |
| 284 | 282 | steps: |
| 285 | 283 | - name: Download all desktop artifacts |
apps/server/package.json+4-2| ... | ... | @@ -8,10 +8,9 @@ |
| 8 | 8 | "dist" |
| 9 | 9 | ], |
| 10 | 10 | "type": "module", |
| 11 | "main": "./dist/index.mjs", | |
| 12 | 11 | "scripts": { |
| 13 | 12 | "dev": "VITE_DEV_SERVER_URL=${VITE_DEV_SERVER_URL:-http://localhost:5733} bun run src/index.ts", |
| 14 | "build": "tsdown && node scripts/bundle-client.ts", | |
| 13 | "build": "node scripts/cli.ts build", | |
| 15 | 14 | "start": "node dist/index.mjs", |
| 16 | 15 | "prepare": "effect-language-service patch", |
| 17 | 16 | "typecheck": "tsc --noEmit", |
| ... | ... | @@ -38,5 +37,8 @@ |
| 38 | 37 | "tsdown": "^0.20.3", |
| 39 | 38 | "typescript": "^5.7.3", |
| 40 | 39 | "vitest": "^4.0.0" |
| 40 | }, | |
| 41 | "engines": { | |
| 42 | "node": "^22.13 || ^23.4 || >=24.10" | |
| 41 | 43 | } |
| 42 | 44 | } |
apps/server/scripts/bundle-client.ts deleted-19| ... | ... | @@ -1,19 +0,0 @@ |
| 1 | /** | |
| 2 | * Copies the built web app into dist/client/ so the published npm package | |
| 3 | * includes the web UI. This runs as a post-build step. | |
| 4 | */ | |
| 5 | import * as fs from "node:fs"; | |
| 6 | import * as path from "node:path"; | |
| 7 | ||
| 8 | const webDist = path.resolve(import.meta.dirname, "../../web/dist"); | |
| 9 | const target = path.resolve(import.meta.dirname, "../dist/client"); | |
| 10 | ||
| 11 | if (!fs.existsSync(webDist)) { | |
| 12 | console.log( | |
| 13 | "⚠ Web dist not found — skipping client bundle. Run `bun run --cwd apps/web build` first.", | |
| 14 | ); | |
| 15 | process.exit(0); | |
| 16 | } | |
| 17 | ||
| 18 | fs.cpSync(webDist, target, { recursive: true }); | |
| 19 | console.log("✓ Bundled web app into dist/client"); |
apps/server/scripts/cli.ts created+168| ... | ... | @@ -0,0 +1,168 @@ |
| 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 | ); |
apps/server/tsconfig.json+1-1| ... | ... | @@ -21,5 +21,5 @@ |
| 21 | 21 | } |
| 22 | 22 | ] |
| 23 | 23 | }, |
| 24 | "include": ["src", "tsdown.config.ts", "scripts", "integration"] | |
| 24 | "include": ["src", "tsdown.config.ts", "scripts", "integration", "../../scripts/lib"] | |
| 25 | 25 | } |
apps/web/package.json+1| ... | ... | @@ -6,6 +6,7 @@ |
| 6 | 6 | "scripts": { |
| 7 | 7 | "dev": "vite", |
| 8 | 8 | "build": "vite build", |
| 9 | "prepare": "effect-language-service patch", | |
| 9 | 10 | "preview": "vite preview", |
| 10 | 11 | "typecheck": "tsc --noEmit", |
| 11 | 12 | "test": "vitest run --passWithNoTests" |
bun.lock+6| ... | ... | @@ -115,8 +115,14 @@ |
| 115 | 115 | "packages/shared": { |
| 116 | 116 | "name": "@t3tools/shared", |
| 117 | 117 | "version": "0.0.0-alpha.1", |
| 118 | "dependencies": { | |
| 119 | "effect": "catalog:", | |
| 120 | }, | |
| 118 | 121 | "devDependencies": { |
| 122 | "@effect/language-service": "0.75.1", | |
| 123 | "@effect/vitest": "catalog:", | |
| 119 | 124 | "typescript": "^5.7.3", |
| 125 | "vitest": "^4.0.0", | |
| 120 | 126 | }, |
| 121 | 127 | }, |
| 122 | 128 | }, |
packages/shared/package.json+10-2| ... | ... | @@ -10,9 +10,17 @@ |
| 10 | 10 | } |
| 11 | 11 | }, |
| 12 | 12 | "scripts": { |
| 13 | "typecheck": "tsc --noEmit" | |
| 13 | "prepare": "effect-language-service patch", | |
| 14 | "typecheck": "tsc --noEmit", | |
| 15 | "test": "vitest run --passWithNoTests" | |
| 16 | }, | |
| 17 | "dependencies": { | |
| 18 | "effect": "catalog:" | |
| 14 | 19 | }, |
| 15 | 20 | "devDependencies": { |
| 16 | "typescript": "^5.7.3" | |
| 21 | "@effect/language-service": "0.75.1", | |
| 22 | "@effect/vitest": "catalog:", | |
| 23 | "typescript": "^5.7.3", | |
| 24 | "vitest": "^4.0.0" | |
| 17 | 25 | } |
| 18 | 26 | } |
scripts/build-desktop-artifact.ts+2-25| ... | ... | @@ -6,6 +6,8 @@ import rootPackageJson from "../package.json" with { type: "json" }; |
| 6 | 6 | import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; |
| 7 | 7 | import serverPackageJson from "../apps/server/package.json" with { type: "json" }; |
| 8 | 8 | |
| 9 | import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; | |
| 10 | ||
| 9 | 11 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 10 | 12 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 11 | 13 | import { Config, Data, Effect, FileSystem, Logger, Option, Path, Schema } from "effect"; |
| ... | ... | @@ -319,31 +321,6 @@ function validateBundledClientAssets(clientDir: string) { |
| 319 | 321 | }); |
| 320 | 322 | } |
| 321 | 323 | |
| 322 | function resolveCatalogDependencies( | |
| 323 | dependencies: Record<string, unknown>, | |
| 324 | catalog: Record<string, unknown>, | |
| 325 | dependencySourceLabel: string, | |
| 326 | ): Record<string, unknown> { | |
| 327 | return Object.fromEntries( | |
| 328 | Object.entries(dependencies).map(([dependencyName, spec]) => { | |
| 329 | if (typeof spec !== "string" || !spec.startsWith("catalog:")) { | |
| 330 | return [dependencyName, spec]; | |
| 331 | } | |
| 332 | ||
| 333 | const catalogKey = spec.slice("catalog:".length).trim(); | |
| 334 | const lookupKey = catalogKey.length > 0 ? catalogKey : dependencyName; | |
| 335 | const resolvedSpec = catalog[lookupKey]; | |
| 336 | if (typeof resolvedSpec !== "string" || resolvedSpec.length === 0) { | |
| 337 | throw new BuildScriptError({ | |
| 338 | message: `Unable to resolve '${spec}' for ${dependencySourceLabel} dependency '${dependencyName}'. Expected key '${lookupKey}' in root workspace catalog.`, | |
| 339 | }); | |
| 340 | } | |
| 341 | ||
| 342 | return [dependencyName, resolvedSpec]; | |
| 343 | }), | |
| 344 | ); | |
| 345 | } | |
| 346 | ||
| 347 | 324 | const createBuildConfig = Effect.fn(function* ( |
| 348 | 325 | platform: typeof BuildPlatform.Type, |
| 349 | 326 | target: string, |
scripts/lib/resolve-catalog.ts created+31| ... | ... | @@ -0,0 +1,31 @@ |
| 1 | /** | |
| 2 | * Resolve `catalog:` dependency specs using the workspace catalog. | |
| 3 | * | |
| 4 | * Pure function: returns a new record with every `catalog:…` value replaced by | |
| 5 | * the concrete version string found in `catalog`. Throws on missing entries. | |
| 6 | */ | |
| 7 | export function resolveCatalogDependencies( | |
| 8 | dependencies: Record<string, unknown>, | |
| 9 | catalog: Record<string, unknown>, | |
| 10 | label: string, | |
| 11 | ): Record<string, unknown> { | |
| 12 | return Object.fromEntries( | |
| 13 | Object.entries(dependencies).map(([name, spec]) => { | |
| 14 | if (typeof spec !== "string" || !spec.startsWith("catalog:")) { | |
| 15 | return [name, spec]; | |
| 16 | } | |
| 17 | ||
| 18 | const catalogKey = spec.slice("catalog:".length).trim(); | |
| 19 | const lookupKey = catalogKey.length > 0 ? catalogKey : name; | |
| 20 | const resolved = catalog[lookupKey]; | |
| 21 | ||
| 22 | if (typeof resolved !== "string" || resolved.length === 0) { | |
| 23 | throw new Error( | |
| 24 | `Unable to resolve '${spec}' for ${label} dependency '${name}'. Expected key '${lookupKey}' in root workspace catalog.`, | |
| 25 | ); | |
| 26 | } | |
| 27 | ||
| 28 | return [name, resolved]; | |
| 29 | }), | |
| 30 | ); | |
| 31 | } |