authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-03 00:30:15-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-03 00:30:15-08:00
log11606fd9c6b21abedb49fb1ca2bc94913c4dd702
treed4338a1710e123fc8cb9aa678aace382d0d5929a
parenta935db07ef2a2697720ba0a8903a1731ddb87cc0

cli for pub


10 files changed, 225 insertions(+), 53 deletions(-)

.github/workflows/release.yml+2-4
......@@ -273,13 +273,11 @@ jobs:
273273 run: bun run build --filter=@t3tools/web --filter=t3
274274
275275 - 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
278277
279278 release:
280279 name: Publish GitHub Release
281 needs: [preflight, build]
282 # needs: [preflight, build, publish_cli]
280 needs: [preflight, build, publish_cli]
283281 runs-on: ubuntu-24.04
284282 steps:
285283 - name: Download all desktop artifacts
apps/server/package.json+4-2
......@@ -8,10 +8,9 @@
88 "dist"
99 ],
1010 "type": "module",
11 "main": "./dist/index.mjs",
1211 "scripts": {
1312 "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",
1514 "start": "node dist/index.mjs",
1615 "prepare": "effect-language-service patch",
1716 "typecheck": "tsc --noEmit",
......@@ -38,5 +37,8 @@
3837 "tsdown": "^0.20.3",
3938 "typescript": "^5.7.3",
4039 "vitest": "^4.0.0"
40 },
41 "engines": {
42 "node": "^22.13 || ^23.4 || >=24.10"
4143 }
4244}
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 */
5import * as fs from "node:fs";
6import * as path from "node:path";
7
8const webDist = path.resolve(import.meta.dirname, "../../web/dist");
9const target = path.resolve(import.meta.dirname, "../dist/client");
10
11if (!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
18fs.cpSync(webDist, target, { recursive: true });
19console.log("✓ Bundled web app into dist/client");
apps/server/scripts/cli.ts created+168
......@@ -0,0 +1,168 @@
1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { Data, Effect, FileSystem, Logger, Path } from "effect";
6import { Command, Flag } from "effect/unstable/cli";
7import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
8
9import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts";
10import rootPackageJson from "../../../package.json" with { type: "json" };
11import serverPackageJson from "../package.json" with { type: "json" };
12
13class CliError extends Data.TaggedError("CliError")<{
14 readonly message: string;
15 readonly cause?: unknown;
16}> {}
17
18const RepoRoot = Effect.service(Path.Path).pipe(
19 Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))),
20);
21
22const 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
38const 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
75const 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
159const cli = Command.make("cli").pipe(
160 Command.withDescription("T3 server build & publish CLI."),
161 Command.withSubcommands([buildCmd, publishCmd]),
162);
163
164Command.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 @@
2121 }
2222 ]
2323 },
24 "include": ["src", "tsdown.config.ts", "scripts", "integration"]
24 "include": ["src", "tsdown.config.ts", "scripts", "integration", "../../scripts/lib"]
2525}
apps/web/package.json+1
......@@ -6,6 +6,7 @@
66 "scripts": {
77 "dev": "vite",
88 "build": "vite build",
9 "prepare": "effect-language-service patch",
910 "preview": "vite preview",
1011 "typecheck": "tsc --noEmit",
1112 "test": "vitest run --passWithNoTests"
bun.lock+6
......@@ -115,8 +115,14 @@
115115 "packages/shared": {
116116 "name": "@t3tools/shared",
117117 "version": "0.0.0-alpha.1",
118 "dependencies": {
119 "effect": "catalog:",
120 },
118121 "devDependencies": {
122 "@effect/language-service": "0.75.1",
123 "@effect/vitest": "catalog:",
119124 "typescript": "^5.7.3",
125 "vitest": "^4.0.0",
120126 },
121127 },
122128 },
packages/shared/package.json+10-2
......@@ -10,9 +10,17 @@
1010 }
1111 },
1212 "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:"
1419 },
1520 "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"
1725 }
1826}
scripts/build-desktop-artifact.ts+2-25
......@@ -6,6 +6,8 @@ import rootPackageJson from "../package.json" with { type: "json" };
66import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" };
77import serverPackageJson from "../apps/server/package.json" with { type: "json" };
88
9import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";
10
911import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
1012import * as NodeServices from "@effect/platform-node/NodeServices";
1113import { Config, Data, Effect, FileSystem, Logger, Option, Path, Schema } from "effect";
......@@ -319,31 +321,6 @@ function validateBundledClientAssets(clientDir: string) {
319321 });
320322}
321323
322function 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
347324const createBuildConfig = Effect.fn(function* (
348325 platform: typeof BuildPlatform.Type,
349326 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 */
7export 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}