@open-press/core 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine/cli.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as deployCmd from "./commands/deploy.mjs";
4
4
  import * as devCmd from "./commands/dev.mjs";
5
+ import * as doctorCmd from "./commands/doctor.mjs";
5
6
  import * as exportCmd from "./commands/export.mjs";
6
7
  import * as initCmd from "./commands/init.mjs";
7
8
  import * as inspectCmd from "./commands/inspect.mjs";
@@ -12,6 +13,7 @@ import * as replaceCmd from "./commands/replace.mjs";
12
13
  import * as renderCmd from "./commands/render.mjs";
13
14
  import * as searchCmd from "./commands/search.mjs";
14
15
  import * as typecheckCmd from "./commands/typecheck.mjs";
16
+ import * as upgradeCmd from "./commands/upgrade.mjs";
15
17
  import * as validateCmd from "./commands/validate.mjs";
16
18
  import { parseOptions } from "./commands/_shared.mjs";
17
19
  import { loadConfig } from "./config.mjs";
@@ -32,6 +34,8 @@ const COMMANDS = {
32
34
  typecheck: typecheckCmd,
33
35
  pdf: pdfCmd,
34
36
  deploy: deployCmd,
37
+ doctor: doctorCmd,
38
+ upgrade: upgradeCmd,
35
39
  };
36
40
 
37
41
  const args = process.argv.slice(2);
@@ -87,6 +91,8 @@ Commands:
87
91
  typecheck
88
92
  pdf [--output <outputDir>/<pdf.filename>] [--no-build] [--dry-run]
89
93
  deploy --confirm [--dry-run]
94
+ doctor [--json] [--no-cache] # version + skill staleness check
95
+ upgrade [--dry-run] [--no-deps] [--no-skills] [--json] # apply updates; agent-driven
90
96
 
91
97
  Style packs available for \`init --skill\`: ${skillList}
92
98
  `);
@@ -7,8 +7,9 @@ import { loadConfig, publicPdfHref } from "../config.mjs";
7
7
  import { exportDocument } from "../document-export.mjs";
8
8
  import { optimizePdfMediaForStaticRoot } from "../pdf-media.mjs";
9
9
 
10
- const ENGINE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
- const STATIC_SERVER = path.join(ENGINE_DIR, "static-server.mjs");
10
+ export const ENGINE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
+ export const CLI_ENTRY = path.join(ENGINE_DIR, "cli.mjs");
12
+ export const STATIC_SERVER = path.join(ENGINE_DIR, "static-server.mjs");
12
13
 
13
14
  export function parseOptions(argv) {
14
15
  const options = {};
@@ -62,6 +63,12 @@ export function runCommand(commandName, commandArgs, cwd) {
62
63
  return result.status ?? 1;
63
64
  }
64
65
 
66
+ export function formatNodeScriptCommand(root, scriptPath) {
67
+ const relative = path.relative(root, scriptPath).replaceAll("\\", "/");
68
+ const displayPath = relative && !relative.startsWith("../") ? relative : scriptPath;
69
+ return `node ${displayPath}`;
70
+ }
71
+
65
72
  export async function buildReactStatic({ root, noBuild = false, recurse, silent = false }) {
66
73
  if (noBuild) return 0;
67
74
  if (!silent) {
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { deploySync } from "../deploy-sync.mjs";
3
- import { buildReactPdf, runCommand, writePdfStageDeployConfig } from "./_shared.mjs";
3
+ import { CLI_ENTRY, buildReactPdf, formatNodeScriptCommand, runCommand, writePdfStageDeployConfig } from "./_shared.mjs";
4
4
 
5
5
  export async function run({ root, config, options, recurse }) {
6
6
  if (config.deploy.requiresConfirmation === true && !options.confirm) {
@@ -12,9 +12,9 @@ export async function run({ root, config, options, recurse }) {
12
12
  const commitDirty = config.deploy.commitDirty;
13
13
  if (options.dryRun) {
14
14
  console.log("OpenPress deploy dry run");
15
- console.log("Command: node engine/cli.mjs render . --renderer react");
15
+ console.log(`Command: ${formatNodeScriptCommand(root, CLI_ENTRY)} render . --renderer react`);
16
16
  console.log(`Step: deploy-sync (copy ${config.outputDir} → ${source})`);
17
- console.log(`Command: node engine/cli.mjs pdf . --output ${source}/${config.pdf.filename}`);
17
+ console.log(`Command: ${formatNodeScriptCommand(root, CLI_ENTRY)} pdf . --output ${source}/${config.pdf.filename}`);
18
18
  console.log(`Step: write ${source}/openpress/deploy.json with deployment metadata`);
19
19
  console.log(`Command: npx wrangler pages deploy ${source}${projectName ? ` --project-name=${projectName}` : ""}${commitDirty ? " --commit-dirty=true" : ""}`);
20
20
  return 0;
@@ -1,5 +1,6 @@
1
1
  import { exportDocument } from "../document-export.mjs";
2
- import { runCommand } from "./_shared.mjs";
2
+ import { diagnose } from "./doctor.mjs";
3
+ import { CLI_ENTRY, formatNodeScriptCommand, runCommand } from "./_shared.mjs";
3
4
 
4
5
  export async function run({ root, options }) {
5
6
  const renderer = options.renderer ?? "react";
@@ -13,7 +14,7 @@ export async function run({ root, options }) {
13
14
  if (options.dryRun) {
14
15
  console.log(`OpenPress dev URL: ${url}`);
15
16
  if (!options.noBuild) {
16
- console.log("Command: node engine/cli.mjs export .");
17
+ console.log(`Command: ${formatNodeScriptCommand(root, CLI_ENTRY)} export .`);
17
18
  }
18
19
  console.log(`Command: npx vite --config vite.config.ts --host ${host} --port ${port}`);
19
20
  return 0;
@@ -21,6 +22,28 @@ export async function run({ root, options }) {
21
22
  if (!options.noBuild) {
22
23
  await exportDocument(root);
23
24
  }
25
+
26
+ // One-line update notice (24h cached, network failure is silent).
27
+ await printDoctorNoticeIfStale(root);
28
+
24
29
  console.log(`OpenPress dev: ${url}`);
25
30
  return runCommand("npx", ["vite", "--config", "vite.config.ts", "--host", host, "--port", port], root);
26
31
  }
32
+
33
+ async function printDoctorNoticeIfStale(root) {
34
+ try {
35
+ const report = await diagnose(root);
36
+ if (!report.stale) return;
37
+ const parts = [];
38
+ if (report.coreUpdateAvailable) {
39
+ parts.push(`@open-press/core ${report.coreVersion} → ${report.coreLatest}`);
40
+ }
41
+ if (report.pendingMigrations.length > 0) {
42
+ parts.push(`${report.pendingMigrations.length} migration note(s)`);
43
+ }
44
+ if (parts.length === 0) return;
45
+ console.log(`○ open-press: ${parts.join(" · ")} — run \`npx open-press doctor\` for details.`);
46
+ } catch {
47
+ // Doctor is informational only; never block dev.
48
+ }
49
+ }
@@ -0,0 +1,229 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
6
+ const CORE_PACKAGE = "@open-press/core";
7
+
8
+ export async function run({ root, options }) {
9
+ const json = Boolean(options?.json);
10
+ const noCache = Boolean(options?.noCache);
11
+
12
+ const report = await diagnose(root, { noCache });
13
+
14
+ if (json) {
15
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
16
+ } else {
17
+ printHumanReport(report);
18
+ }
19
+
20
+ // Exit 0 even when stale — doctor is informational, not a gate.
21
+ // Agents / CI can check report.stale or report.coreUpdateAvailable.
22
+ return 0;
23
+ }
24
+
25
+ /**
26
+ * Diagnose workspace against latest framework state.
27
+ * Result shape:
28
+ * {
29
+ * coreVersion: "0.4.0", // installed
30
+ * coreLatest: "0.5.0" | null, // null on network failure
31
+ * coreUpdateAvailable: boolean,
32
+ * skillsInstalled: ["openpress", ...],
33
+ * skillsLockSource: "quan0715/open-press" | null,
34
+ * pendingMigrations: ["0.5.0"], // versions with docs/migrations notes
35
+ * stale: boolean, // either core or skills behind
36
+ * cachedAt: ISO timestamp
37
+ * }
38
+ */
39
+ export async function diagnose(root, { noCache = false } = {}) {
40
+ const cachePath = path.join(root, ".openpress", "cache", "doctor.json");
41
+
42
+ if (!noCache) {
43
+ const cached = await readCached(cachePath);
44
+ if (cached) return cached;
45
+ }
46
+
47
+ const coreVersion = await readCoreVersion(root);
48
+ const coreLatest = await fetchCoreLatest();
49
+ const skillsInstalled = await listInstalledSkills(root);
50
+ const skillsLockSource = await readSkillsLockSource(root);
51
+ const pendingMigrations = await listPendingMigrations(root, coreVersion, coreLatest);
52
+
53
+ const coreUpdateAvailable = Boolean(
54
+ coreVersion && coreLatest && coreVersion !== coreLatest && semverLt(coreVersion, coreLatest),
55
+ );
56
+
57
+ const report = {
58
+ coreVersion,
59
+ coreLatest,
60
+ coreUpdateAvailable,
61
+ skillsInstalled,
62
+ skillsLockSource,
63
+ pendingMigrations,
64
+ stale: coreUpdateAvailable || pendingMigrations.length > 0,
65
+ cachedAt: new Date().toISOString(),
66
+ };
67
+
68
+ await writeCached(cachePath, report).catch(() => {});
69
+ return report;
70
+ }
71
+
72
+ async function readCached(cachePath) {
73
+ try {
74
+ const stats = await stat(cachePath);
75
+ if (Date.now() - stats.mtimeMs > CACHE_TTL_MS) return null;
76
+ return JSON.parse(await readFile(cachePath, "utf8"));
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ async function writeCached(cachePath, report) {
83
+ await mkdir(path.dirname(cachePath), { recursive: true });
84
+ await writeFile(cachePath, JSON.stringify(report, null, 2) + "\n", "utf8");
85
+ }
86
+
87
+ async function readCoreVersion(root) {
88
+ // Try workspace package.json deps first; fall back to installed package.
89
+ try {
90
+ const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
91
+ const range = pkg.dependencies?.[CORE_PACKAGE] ?? pkg.devDependencies?.[CORE_PACKAGE];
92
+ if (range) {
93
+ // Try the installed version (more accurate than the range).
94
+ try {
95
+ const installed = JSON.parse(
96
+ await readFile(path.join(root, "node_modules", CORE_PACKAGE, "package.json"), "utf8"),
97
+ );
98
+ return installed.version;
99
+ } catch {
100
+ return range.replace(/^[\^~>=<\s]+/, "");
101
+ }
102
+ }
103
+ } catch {}
104
+
105
+ // Self-bundled framework (cli scaffolded workspace): pkg.version is the framework version.
106
+ try {
107
+ const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
108
+ if (pkg.name === CORE_PACKAGE) return pkg.version;
109
+ } catch {}
110
+
111
+ return null;
112
+ }
113
+
114
+ async function fetchCoreLatest() {
115
+ try {
116
+ const res = await fetch(`https://registry.npmjs.org/${CORE_PACKAGE}/latest`, {
117
+ headers: { Accept: "application/json" },
118
+ signal: AbortSignal.timeout(5000),
119
+ });
120
+ if (!res.ok) return null;
121
+ const data = await res.json();
122
+ return typeof data.version === "string" ? data.version : null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ async function listInstalledSkills(root) {
129
+ const skillsDir = path.join(root, ".agents", "skills");
130
+ try {
131
+ const { readdir } = await import("node:fs/promises");
132
+ const entries = await readdir(skillsDir, { withFileTypes: true });
133
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
134
+ } catch {
135
+ return [];
136
+ }
137
+ }
138
+
139
+ async function readSkillsLockSource(root) {
140
+ try {
141
+ const lock = JSON.parse(await readFile(path.join(root, "skills-lock.json"), "utf8"));
142
+ const sources = lock?.sources;
143
+ if (Array.isArray(sources) && sources.length > 0) return sources[0]?.source ?? null;
144
+ return null;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ async function listPendingMigrations(root, currentVersion, latestVersion) {
151
+ if (!currentVersion || !latestVersion || !semverLt(currentVersion, latestVersion)) return [];
152
+ // Look for docs/migrations/<version>.md files for versions in (current, latest].
153
+ const migrationsDir = path.join(root, "docs", "migrations");
154
+ try {
155
+ const { readdir } = await import("node:fs/promises");
156
+ const files = await readdir(migrationsDir);
157
+ return files
158
+ .filter((f) => /^\d+\.\d+\.\d+\.md$/.test(f))
159
+ .map((f) => f.replace(/\.md$/, ""))
160
+ .filter((v) => semverGt(v, currentVersion) && !semverGt(v, latestVersion))
161
+ .sort(semverCompare);
162
+ } catch {
163
+ return [];
164
+ }
165
+ }
166
+
167
+ function semverParse(v) {
168
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
169
+ if (!m) return [0, 0, 0];
170
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
171
+ }
172
+ function semverCompare(a, b) {
173
+ const A = semverParse(a);
174
+ const B = semverParse(b);
175
+ for (let i = 0; i < 3; i++) if (A[i] !== B[i]) return A[i] - B[i];
176
+ return 0;
177
+ }
178
+ function semverLt(a, b) { return semverCompare(a, b) < 0; }
179
+ function semverGt(a, b) { return semverCompare(a, b) > 0; }
180
+
181
+ function printHumanReport(report) {
182
+ const lines = [];
183
+ lines.push("○ open-press doctor");
184
+ lines.push("");
185
+ lines.push("framework");
186
+ if (report.coreVersion) {
187
+ if (report.coreLatest === null) {
188
+ lines.push(` ? @open-press/core: ${report.coreVersion} installed (couldn't check latest — offline?)`);
189
+ } else if (report.coreUpdateAvailable) {
190
+ lines.push(` ⚠ @open-press/core: ${report.coreVersion} installed → ${report.coreLatest} available`);
191
+ } else {
192
+ lines.push(` ✓ @open-press/core: ${report.coreVersion} (latest)`);
193
+ }
194
+ } else {
195
+ lines.push(" ? @open-press/core: not detected in this workspace");
196
+ }
197
+ lines.push("");
198
+ lines.push("skills");
199
+ if (report.skillsInstalled.length === 0) {
200
+ lines.push(" ? no skills installed under .agents/skills/");
201
+ lines.push(" run: npx skills add quan0715/open-press");
202
+ } else {
203
+ lines.push(` ✓ ${report.skillsInstalled.length} skills installed`);
204
+ if (report.skillsLockSource) {
205
+ lines.push(` source: ${report.skillsLockSource}`);
206
+ lines.push(" refresh: npx skills upgrade");
207
+ }
208
+ }
209
+ lines.push("");
210
+ lines.push("migrations");
211
+ if (report.pendingMigrations.length === 0) {
212
+ if (report.coreUpdateAvailable) {
213
+ lines.push(` ✓ no breaking migrations documented for the ${report.coreLatest} window`);
214
+ } else {
215
+ lines.push(" ✓ up to date");
216
+ }
217
+ } else {
218
+ lines.push(` ⚠ ${report.pendingMigrations.length} migration note(s) since your version:`);
219
+ for (const v of report.pendingMigrations) lines.push(` - docs/migrations/${v}.md`);
220
+ }
221
+ lines.push("");
222
+ if (report.stale) {
223
+ lines.push("next");
224
+ lines.push(" npx open-press upgrade # apply all updates (agent-driven)");
225
+ lines.push(" npx open-press doctor --json # machine-readable output");
226
+ lines.push("");
227
+ }
228
+ process.stdout.write(lines.join("\n"));
229
+ }
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { buildReactPdf } from "./_shared.mjs";
2
+ import { CLI_ENTRY, STATIC_SERVER, buildReactPdf, formatNodeScriptCommand } from "./_shared.mjs";
3
3
 
4
4
  export async function run({ root, config, options, recurse }) {
5
5
  const outputPath = options.output ? path.resolve(root, options.output) : undefined;
@@ -7,8 +7,8 @@ export async function run({ root, config, options, recurse }) {
7
7
  const relOutput = path.relative(root, outputPath ?? config.paths.pdf);
8
8
  const host = options.host ?? "127.0.0.1";
9
9
  const port = options.port ?? "5185";
10
- console.log("Command: node engine/cli.mjs render . --renderer react");
11
- console.log(`Command: node engine/static-server.mjs ${config.outputDir} --host ${host} --port ${port} --workspace .`);
10
+ console.log(`Command: ${formatNodeScriptCommand(root, CLI_ENTRY)} render . --renderer react`);
11
+ console.log(`Command: ${formatNodeScriptCommand(root, STATIC_SERVER)} ${config.outputDir} --host ${host} --port ${port} --workspace .`);
12
12
  console.log(`Command: Chrome --print-to-pdf=${relOutput} http://${host}:${port}/?print=1`);
13
13
  return 0;
14
14
  }
@@ -1,4 +1,4 @@
1
- import { runCommand } from "./_shared.mjs";
1
+ import { CLI_ENTRY, STATIC_SERVER, formatNodeScriptCommand, runCommand } from "./_shared.mjs";
2
2
 
3
3
  export async function run({ root, config, options, recurse }) {
4
4
  const renderer = options.renderer ?? "react";
@@ -12,9 +12,9 @@ export async function run({ root, config, options, recurse }) {
12
12
  if (options.dryRun) {
13
13
  console.log(`OpenPress preview URL: ${url}`);
14
14
  if (!options.noBuild) {
15
- console.log("Command: node engine/cli.mjs render . --renderer react");
15
+ console.log(`Command: ${formatNodeScriptCommand(root, CLI_ENTRY)} render . --renderer react`);
16
16
  }
17
- console.log(`Command: node engine/static-server.mjs ${config.outputDir} --host ${host} --port ${port} --workspace .`);
17
+ console.log(`Command: ${formatNodeScriptCommand(root, STATIC_SERVER)} ${config.outputDir} --host ${host} --port ${port} --workspace .`);
18
18
  return 0;
19
19
  }
20
20
  if (!options.noBuild) {
@@ -22,5 +22,5 @@ export async function run({ root, config, options, recurse }) {
22
22
  if (renderCode !== 0) return renderCode;
23
23
  }
24
24
  console.log(`OpenPress preview: ${url}`);
25
- return runCommand("node", ["engine/static-server.mjs", config.outputDir, "--host", host, "--port", port, "--workspace", "."], root);
25
+ return runCommand("node", [STATIC_SERVER, config.outputDir, "--host", host, "--port", port, "--workspace", "."], root);
26
26
  }
@@ -0,0 +1,117 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { diagnose } from "./doctor.mjs";
5
+ import { runCommand } from "./_shared.mjs";
6
+
7
+ export async function run({ root, options }) {
8
+ const dryRun = Boolean(options?.dryRun);
9
+ const skipSkills = Boolean(options?.noSkills);
10
+ const skipDeps = Boolean(options?.noDeps);
11
+ const json = Boolean(options?.json);
12
+
13
+ // 1. Fresh diagnose (force re-check, ignore cache).
14
+ const before = await diagnose(root, { noCache: true });
15
+
16
+ if (!before.stale) {
17
+ const message = "open-press is already up to date.";
18
+ if (json) {
19
+ process.stdout.write(JSON.stringify({ status: "noop", before }, null, 2) + "\n");
20
+ } else {
21
+ process.stdout.write(`✓ ${message}\n`);
22
+ }
23
+ return 0;
24
+ }
25
+
26
+ if (!json) {
27
+ process.stdout.write("○ open-press upgrade\n\n");
28
+ if (before.coreUpdateAvailable) {
29
+ process.stdout.write(
30
+ ` @open-press/core: ${before.coreVersion} → ${before.coreLatest}\n`,
31
+ );
32
+ }
33
+ if (before.pendingMigrations.length > 0) {
34
+ process.stdout.write(` migration notes: ${before.pendingMigrations.join(", ")}\n`);
35
+ }
36
+ process.stdout.write("\n");
37
+ }
38
+
39
+ if (dryRun) {
40
+ if (!json) {
41
+ process.stdout.write("dry run — nothing changed. The agent should:\n");
42
+ process.stdout.write(" 1. read each docs/migrations/<version>.md for document-level changes\n");
43
+ process.stdout.write(" 2. apply edits to document/ where needed\n");
44
+ process.stdout.write(" 3. re-run: npx open-press upgrade (without --dry-run)\n");
45
+ } else {
46
+ process.stdout.write(JSON.stringify({ status: "dry-run", before }, null, 2) + "\n");
47
+ }
48
+ return 0;
49
+ }
50
+
51
+ // 2. Refresh framework dep (only when workspace declares @open-press/core).
52
+ if (!skipDeps && (await hasCoreDep(root))) {
53
+ if (!json) process.stdout.write("▸ updating @open-press/core via npm…\n");
54
+ const code = runCommand("npm", ["update", "@open-press/core"], root);
55
+ if (code !== 0) {
56
+ if (!json) process.stdout.write(" ⚠ npm update returned non-zero; continuing\n");
57
+ }
58
+ }
59
+
60
+ // 3. Refresh skills (npx skills upgrade respects skills-lock.json).
61
+ if (!skipSkills) {
62
+ if (!json) process.stdout.write("▸ refreshing skills via npx skills upgrade…\n");
63
+ runCommand("npx", ["-y", "skills@latest", "upgrade"], root);
64
+ }
65
+
66
+ // 4. Surface migration notes for the agent to read.
67
+ const migrationContents = await loadMigrations(root, before.pendingMigrations);
68
+
69
+ // 5. Re-diagnose to confirm the move.
70
+ const after = await diagnose(root, { noCache: true });
71
+
72
+ if (json) {
73
+ process.stdout.write(
74
+ JSON.stringify(
75
+ { status: "applied", before, after, migrationContents: migrationContents.map((m) => m.path) },
76
+ null,
77
+ 2,
78
+ ) + "\n",
79
+ );
80
+ return 0;
81
+ }
82
+
83
+ process.stdout.write("\n✓ upgrade applied. Now read these migration notes:\n\n");
84
+ if (migrationContents.length === 0) {
85
+ process.stdout.write(" (no migration docs in this version range)\n\n");
86
+ } else {
87
+ for (const m of migrationContents) {
88
+ process.stdout.write(` ─ ${m.path}\n`);
89
+ }
90
+ process.stdout.write(
91
+ "\nAgent: open each file, identify document-level changes, grep document/ for affected patterns, propose edits before applying.\n",
92
+ );
93
+ }
94
+
95
+ process.stdout.write("\nVerify with:\n npm run openpress:validate\n npm run openpress:render\n\n");
96
+ return 0;
97
+ }
98
+
99
+ async function hasCoreDep(root) {
100
+ try {
101
+ const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
102
+ return Boolean(pkg.dependencies?.["@open-press/core"] || pkg.devDependencies?.["@open-press/core"]);
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ async function loadMigrations(root, versions) {
109
+ const results = [];
110
+ for (const v of versions) {
111
+ const p = path.join(root, "docs", "migrations", `${v}.md`);
112
+ if (existsSync(p)) {
113
+ results.push({ version: v, path: path.relative(root, p) });
114
+ }
115
+ }
116
+ return results;
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-press/core",
3
- "version": "0.3.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "open-press core — runtime primitives, CLI, and render pipeline for AI-first fixed-layout documents.",
6
6
  "license": "MIT",
@@ -48,6 +48,8 @@
48
48
  "js-yaml": "^4.1.1",
49
49
  "katex": "^0.16.47",
50
50
  "lucide-react": "^1.16.0",
51
+ "playwright": "^1.60.0",
52
+ "postcss": "^8.5.6",
51
53
  "react": "^19.2.6",
52
54
  "react-dom": "^19.2.6",
53
55
  "rehype-katex": "^7.0.1",
package/vite.config.ts CHANGED
@@ -9,13 +9,18 @@ import { loadConfig, publicPdfHref } from "./engine/config.mjs";
9
9
  import { handleCommentRequest } from "./engine/react/comment-endpoint.mjs";
10
10
  import { handleProjectAssetRequest } from "./engine/react/project-asset-endpoint.mjs";
11
11
 
12
- const sourceRoot = fileURLToPath(new URL("./src", import.meta.url));
13
- const workspaceRoot = fileURLToPath(new URL("./", import.meta.url));
14
- const openpressCoreEntry = fileURLToPath(new URL("./src/openpress/core/index.tsx", import.meta.url));
15
- const reactDocumentComponentsRoot = path.join(workspaceRoot, "document", "components");
12
+ const frameworkRoot = fileURLToPath(new URL("./", import.meta.url));
13
+ const workspaceRoot = process.env.OPENPRESS_WORKSPACE_ROOT
14
+ ? path.resolve(process.env.OPENPRESS_WORKSPACE_ROOT)
15
+ : frameworkRoot;
16
+ const sourceRoot = path.join(frameworkRoot, "src");
17
+ const openpressCliPath = path.join(frameworkRoot, "engine", "cli.mjs");
18
+ const staticServerPath = path.join(frameworkRoot, "engine", "static-server.mjs");
19
+ const openpressCoreEntry = path.join(frameworkRoot, "src", "openpress", "core", "index.tsx");
16
20
  const openpressConfig = await loadConfig(workspaceRoot);
17
21
  const outputDir = openpressConfig.paths.outputDir;
18
- const reactDocumentRoot = path.join(workspaceRoot, "document");
22
+ const reactDocumentRoot = openpressConfig.paths.documentRoot;
23
+ const reactDocumentComponentsRoot = openpressConfig.paths.componentsDir;
19
24
  const reactDocumentEntry = path.join(reactDocumentRoot, "index.tsx");
20
25
  const activeContentDir = await fileExists(reactDocumentEntry)
21
26
  ? path.join(reactDocumentRoot, "chapters")
@@ -48,6 +53,7 @@ export default defineConfig({
48
53
  plugins: [openpressLocalDeployPlugin(), react()],
49
54
  define: workspaceDefines,
50
55
  resolve: {
56
+ dedupe: ["react", "react-dom", "@mdx-js/react"],
51
57
  alias: {
52
58
  "@openpress/core": openpressCoreEntry,
53
59
  "@/components": reactDocumentComponentsRoot,
@@ -69,6 +75,9 @@ export default defineConfig({
69
75
  server: {
70
76
  host: "127.0.0.1",
71
77
  port: 5173,
78
+ fs: {
79
+ allow: Array.from(new Set([frameworkRoot, workspaceRoot])),
80
+ },
72
81
  watch: {
73
82
  ignored: ["**/.openpress/tmp/**", `**/${openpressConfig.outputDir}/**`],
74
83
  },
@@ -201,7 +210,7 @@ async function handleLocalPdfExportRequest(req: IncomingMessage, res: ServerResp
201
210
  ok: result.code === 0 && exists,
202
211
  code: result.code,
203
212
  pdf: `/__openpress/local-pdf-file?ts=${Date.now()}`,
204
- command: "node engine/cli.mjs pdf .",
213
+ command: openpressCliCommand(["pdf", "."]),
205
214
  stdout: result.stdout,
206
215
  stderr: result.stderr,
207
216
  });
@@ -266,7 +275,7 @@ async function handleLocalDeployRequest(req: IncomingMessage, res: ServerRespons
266
275
  deploy_adapter: openpressConfig.deploy.adapter,
267
276
  deploy_source: openpressConfig.deploy.source,
268
277
  deploy_project_name: openpressConfig.deploy.projectName,
269
- command: "node engine/cli.mjs deploy . --confirm",
278
+ command: openpressCliCommand(["deploy", ".", "--confirm"]),
270
279
  });
271
280
  return;
272
281
  }
@@ -285,7 +294,7 @@ async function handleLocalDeployRequest(req: IncomingMessage, res: ServerRespons
285
294
  pdf: deployedUrl ? `${deployedUrl}/${openpressConfig.pdf.filename}` : deploymentInfo.pdf,
286
295
  public_url: publicUrl,
287
296
  dirty: false,
288
- command: "node engine/cli.mjs deploy . --confirm",
297
+ command: openpressCliCommand(["deploy", ".", "--confirm"]),
289
298
  stdout: result.stdout,
290
299
  stderr: result.stderr,
291
300
  });
@@ -293,7 +302,7 @@ async function handleLocalDeployRequest(req: IncomingMessage, res: ServerRespons
293
302
 
294
303
  function runLocalPdfExport() {
295
304
  return new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
296
- const child = spawn("node", ["engine/cli.mjs", "pdf", "."], {
305
+ const child = spawn("node", [openpressCliPath, "pdf", "."], {
297
306
  cwd: workspaceRoot,
298
307
  shell: false,
299
308
  });
@@ -316,7 +325,7 @@ function runLocalPdfExport() {
316
325
 
317
326
  function runLocalDeploy() {
318
327
  return new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
319
- const child = spawn("node", ["engine/cli.mjs", "deploy", ".", "--confirm"], {
328
+ const child = spawn("node", [openpressCliPath, "deploy", ".", "--confirm"], {
320
329
  cwd: workspaceRoot,
321
330
  shell: false,
322
331
  });
@@ -405,7 +414,7 @@ function getLocalDeploymentSourcePaths() {
405
414
  openpressConfig.paths.themeDir,
406
415
  openpressConfig.paths.designDoc,
407
416
  openpressConfig.paths.componentsDir,
408
- path.join(workspaceRoot, "src"),
417
+ path.join(frameworkRoot, "src"),
409
418
  path.join(workspaceRoot, "index.html"),
410
419
  path.join(workspaceRoot, "package.json"),
411
420
  path.join(workspaceRoot, "openpress.config.mjs"),
@@ -414,6 +423,12 @@ function getLocalDeploymentSourcePaths() {
414
423
  ];
415
424
  }
416
425
 
426
+ function openpressCliCommand(args: string[]) {
427
+ const relativeCliPath = path.relative(workspaceRoot, openpressCliPath).replaceAll("\\", "/");
428
+ const displayCliPath = relativeCliPath && !relativeCliPath.startsWith("../") ? relativeCliPath : openpressCliPath;
429
+ return `node ${displayCliPath} ${args.join(" ")}`;
430
+ }
431
+
417
432
  async function findNewestLocalSourceMtime(paths: string[]) {
418
433
  const times = await Promise.all(paths.map((sourcePath) => findNewestLocalMtime(sourcePath)));
419
434
  return Math.max(0, ...times);