Преглед изворни кода

fix(cli): ship studio runtime with published package

Ma пре 7 месеци
родитељ
комит
128cd79d10

+ 1 - 1
package.json

@@ -26,7 +26,7 @@
     "test": "pnpm -r test",
     "test": "pnpm -r test",
     "lint": "pnpm -r lint",
     "lint": "pnpm -r lint",
     "typecheck": "pnpm -r typecheck",
     "typecheck": "pnpm -r typecheck",
-    "verify:publish-manifests": "node scripts/verify-no-workspace-protocol.mjs packages/core packages/cli",
+    "verify:publish-manifests": "node scripts/verify-no-workspace-protocol.mjs packages/core packages/cli packages/studio",
     "release": "pnpm build && pnpm test"
     "release": "pnpm build && pnpm test"
   },
   },
   "engines": {
   "engines": {

+ 1 - 0
packages/cli/package.json

@@ -44,6 +44,7 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@actalk/inkos-core": "workspace:*",
     "@actalk/inkos-core": "workspace:*",
+    "@actalk/inkos-studio": "workspace:*",
     "commander": "^13.0.0",
     "commander": "^13.0.0",
     "dotenv": "^16.4.0",
     "dotenv": "^16.4.0",
     "epub-gen-memory": "^1.0.10",
     "epub-gen-memory": "^1.0.10",

+ 34 - 4
packages/cli/src/__tests__/publish-package.test.ts

@@ -8,13 +8,17 @@ import { describe, expect, it } from "vitest";
 const testDir = dirname(fileURLToPath(import.meta.url));
 const testDir = dirname(fileURLToPath(import.meta.url));
 const cliDir = resolve(testDir, "..", "..");
 const cliDir = resolve(testDir, "..", "..");
 const workspaceRoot = resolve(cliDir, "..", "..");
 const workspaceRoot = resolve(cliDir, "..", "..");
+const studioDir = resolve(workspaceRoot, "packages", "studio");
 const sourceCliPackageJsonPromise = readFile(resolve(cliDir, "package.json"), "utf-8").then((raw) =>
 const sourceCliPackageJsonPromise = readFile(resolve(cliDir, "package.json"), "utf-8").then((raw) =>
   JSON.parse(raw),
   JSON.parse(raw),
 );
 );
+const sourceStudioPackageJsonPromise = readFile(resolve(studioDir, "package.json"), "utf-8").then((raw) =>
+  JSON.parse(raw),
+);
 
 
-async function extractPackedPackageJson(packDir: string) {
+async function packPackage(packageDir: string, packDir: string) {
   execFileSync("npm", ["pack", "--pack-destination", packDir], {
   execFileSync("npm", ["pack", "--pack-destination", packDir], {
-    cwd: cliDir,
+    cwd: packageDir,
     env: process.env,
     env: process.env,
     encoding: "utf-8",
     encoding: "utf-8",
   });
   });
@@ -24,8 +28,13 @@ async function extractPackedPackageJson(packDir: string) {
     throw new Error(`Expected exactly one tarball in ${packDir}, found ${tgzFiles.length}`);
     throw new Error(`Expected exactly one tarball in ${packDir}, found ${tgzFiles.length}`);
   }
   }
 
 
+  return join(packDir, tgzFiles[0]);
+}
+
+async function extractPackedPackageJson(packageDir: string, packDir: string) {
+  const tarballPath = await packPackage(packageDir, packDir);
   const tarArgs = process.platform === "win32" ? ["--force-local", "-xOf"] : ["-xOf"];
   const tarArgs = process.platform === "win32" ? ["--force-local", "-xOf"] : ["-xOf"];
-  return execFileSync("tar", [...tarArgs, join(packDir, tgzFiles[0]), "package/package.json"], {
+  return execFileSync("tar", [...tarArgs, tarballPath, "package/package.json"], {
     cwd: workspaceRoot,
     cwd: workspaceRoot,
     encoding: "utf-8",
     encoding: "utf-8",
   });
   });
@@ -93,6 +102,7 @@ describe.sequential("publish packaging", () => {
     const cliPackageJson = await sourceCliPackageJsonPromise;
     const cliPackageJson = await sourceCliPackageJsonPromise;
 
 
     expect(cliPackageJson.dependencies["@actalk/inkos-core"]).toBe("workspace:*");
     expect(cliPackageJson.dependencies["@actalk/inkos-core"]).toBe("workspace:*");
+    expect(cliPackageJson.dependencies["@actalk/inkos-studio"]).toBe("workspace:*");
   });
   });
 
 
   it("verifies publishable manifests before npm publish runs", async () => {
   it("verifies publishable manifests before npm publish runs", async () => {
@@ -212,12 +222,32 @@ describe.sequential("publish packaging", () => {
     const packDir = await mkdtemp(join(tmpdir(), "inkos-cli-pack-"));
     const packDir = await mkdtemp(join(tmpdir(), "inkos-cli-pack-"));
 
 
     try {
     try {
-      const packedPackageJson = JSON.parse(await extractPackedPackageJson(packDir));
+      const packedPackageJson = JSON.parse(await extractPackedPackageJson(cliDir, packDir));
       const corePackageJson = JSON.parse(
       const corePackageJson = JSON.parse(
         await readFile(resolve(workspaceRoot, "packages/core/package.json"), "utf-8"),
         await readFile(resolve(workspaceRoot, "packages/core/package.json"), "utf-8"),
       );
       );
+      const studioPackageJson = await sourceStudioPackageJsonPromise;
 
 
       expect(packedPackageJson.dependencies["@actalk/inkos-core"]).toBe(corePackageJson.version);
       expect(packedPackageJson.dependencies["@actalk/inkos-core"]).toBe(corePackageJson.version);
+      expect(packedPackageJson.dependencies["@actalk/inkos-studio"]).toBe(studioPackageJson.version);
+    } finally {
+      await rm(packDir, { recursive: true, force: true });
+    }
+  });
+
+  it("packs the studio runtime entry alongside the built frontend", { timeout: 30_000 }, async () => {
+    const packDir = await mkdtemp(join(tmpdir(), "inkos-studio-pack-"));
+
+    try {
+      const tarballPath = await packPackage(studioDir, packDir);
+      const tarArgs = process.platform === "win32" ? ["--force-local", "-tf"] : ["-tf"];
+      const archiveListing = execFileSync("tar", [...tarArgs, tarballPath], {
+        cwd: workspaceRoot,
+        encoding: "utf-8",
+      });
+
+      expect(archiveListing).toContain("package/dist/index.html");
+      expect(archiveListing).toContain("package/dist/api/index.js");
     } finally {
     } finally {
       await rm(packDir, { recursive: true, force: true });
       await rm(packDir, { recursive: true, force: true });
     }
     }

+ 22 - 0
packages/cli/src/__tests__/studio-runtime.test.ts

@@ -1,6 +1,10 @@
 import { beforeEach, describe, expect, it, vi } from "vitest";
 import { beforeEach, describe, expect, it, vi } from "vitest";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
 
 
 const accessMock = vi.fn();
 const accessMock = vi.fn();
+const testDir = dirname(fileURLToPath(import.meta.url));
+const cliPackageRoot = resolve(testDir, "..", "..");
 
 
 vi.mock("node:fs/promises", () => ({
 vi.mock("node:fs/promises", () => ({
   access: accessMock,
   access: accessMock,
@@ -73,4 +77,22 @@ describe("studio runtime resolution", () => {
       args: ["/repo/test-project/node_modules/@actalk/inkos-studio/dist/api/index.js", "/repo/test-project"],
       args: ["/repo/test-project/node_modules/@actalk/inkos-studio/dist/api/index.js", "/repo/test-project"],
     });
     });
   });
   });
+
+  it("falls back to the CLI installation's bundled studio runtime", async () => {
+    accessMock.mockImplementation(async (path: string) => {
+      if (path === `${cliPackageRoot}/node_modules/@actalk/inkos-studio/dist/api/index.js`) {
+        return;
+      }
+      throw new Error(`missing: ${path}`);
+    });
+
+    const { resolveStudioLaunch } = await import("../commands/studio.js");
+    const launch = await resolveStudioLaunch("/repo/test-project");
+
+    expect(launch).toEqual({
+      studioEntry: `${cliPackageRoot}/node_modules/@actalk/inkos-studio/dist/api/index.js`,
+      command: "node",
+      args: [`${cliPackageRoot}/node_modules/@actalk/inkos-studio/dist/api/index.js`, "/repo/test-project"],
+    });
+  });
 });
 });

+ 7 - 0
packages/cli/src/commands/studio.ts

@@ -3,6 +3,7 @@ import { findProjectRoot, log, logError } from "../utils.js";
 import { spawn } from "node:child_process";
 import { spawn } from "node:child_process";
 import { dirname, join } from "node:path";
 import { dirname, join } from "node:path";
 import { access } from "node:fs/promises";
 import { access } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
 
 
 export interface StudioLaunchSpec {
 export interface StudioLaunchSpec {
   readonly studioEntry: string;
   readonly studioEntry: string;
@@ -22,6 +23,8 @@ async function firstAccessiblePath(paths: readonly string[]): Promise<string | u
   return undefined;
   return undefined;
 }
 }
 
 
+const cliPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
+
 export async function resolveStudioLaunch(root: string): Promise<StudioLaunchSpec | null> {
 export async function resolveStudioLaunch(root: string): Promise<StudioLaunchSpec | null> {
   const sourceEntry = await firstAccessiblePath([
   const sourceEntry = await firstAccessiblePath([
     join(root, "packages", "studio", "src", "api", "index.ts"),
     join(root, "packages", "studio", "src", "api", "index.ts"),
@@ -61,6 +64,10 @@ export async function resolveStudioLaunch(root: string): Promise<StudioLaunchSpe
   const builtEntry = await firstAccessiblePath([
   const builtEntry = await firstAccessiblePath([
     join(root, "node_modules", "@actalk", "inkos-studio", "dist", "api", "index.js"),
     join(root, "node_modules", "@actalk", "inkos-studio", "dist", "api", "index.js"),
     join(root, "node_modules", "@actalk", "inkos-studio", "server.cjs"),
     join(root, "node_modules", "@actalk", "inkos-studio", "server.cjs"),
+    join(cliPackageRoot, "node_modules", "@actalk", "inkos-studio", "dist", "api", "index.js"),
+    join(cliPackageRoot, "node_modules", "@actalk", "inkos-studio", "server.cjs"),
+    join(cliPackageRoot, "..", "inkos-studio", "dist", "api", "index.js"),
+    join(cliPackageRoot, "..", "inkos-studio", "server.cjs"),
   ]);
   ]);
   if (builtEntry) {
   if (builtEntry) {
     return {
     return {

+ 19 - 3
packages/studio/package.json

@@ -1,15 +1,31 @@
 {
 {
   "name": "@actalk/inkos-studio",
   "name": "@actalk/inkos-studio",
   "version": "1.0.0",
   "version": "1.0.0",
-  "private": true,
   "description": "InkOS Studio — Web workbench for novel writing",
   "description": "InkOS Studio — Web workbench for novel writing",
   "type": "module",
   "type": "module",
+  "main": "dist/api/index.js",
+  "types": "dist/api/index.d.ts",
+  "files": [
+    "dist",
+    "!dist/**/*.test.*"
+  ],
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/Narcooo/inkos.git",
+    "directory": "packages/studio"
+  },
   "scripts": {
   "scripts": {
+    "prepack": "npm run build && node ../../scripts/prepare-package-for-publish.mjs",
+    "postpack": "node ../../scripts/restore-package-json.mjs",
+    "prepublishOnly": "node ../../scripts/verify-no-workspace-protocol.mjs .",
     "dev": "vite --host --port 4567",
     "dev": "vite --host --port 4567",
-    "build": "vite build",
+    "build": "npm run build:client && npm run build:server",
+    "build:client": "vite build",
+    "build:server": "tsc -p tsconfig.server.json",
     "preview": "vite preview",
     "preview": "vite preview",
     "test": "vitest run",
     "test": "vitest run",
-    "typecheck": "tsc --noEmit"
+    "typecheck": "tsc --noEmit && tsc -p tsconfig.server.json --noEmit"
   },
   },
   "dependencies": {
   "dependencies": {
     "@actalk/inkos-core": "workspace:*",
     "@actalk/inkos-core": "workspace:*",

+ 11 - 0
packages/studio/tsconfig.server.json

@@ -0,0 +1,11 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src",
+    "lib": ["ES2022"],
+    "types": ["node"]
+  },
+  "include": ["src/api"],
+  "exclude": ["src/api/**/*.test.ts"]
+}

+ 3 - 0
pnpm-lock.yaml

@@ -13,6 +13,9 @@ importers:
       '@actalk/inkos-core':
       '@actalk/inkos-core':
         specifier: workspace:*
         specifier: workspace:*
         version: link:../core
         version: link:../core
+      '@actalk/inkos-studio':
+        specifier: workspace:*
+        version: link:../studio
       commander:
       commander:
         specifier: ^13.0.0
         specifier: ^13.0.0
         version: 13.1.0
         version: 13.1.0