@notionhq/custom-blocks-dev-shell 0.1.0 → 0.1.2

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.
@@ -0,0 +1,72 @@
1
+ # Dev-shell data sources
2
+
3
+ Data sources come in kinds, marked by their `type`, and all live in the
4
+ worker's `src/data/*.json` — one source per file, read at spin-up. The shell
5
+ supports the following types today:
6
+
7
+ - `worker` — hand-authored JSON files. This is how you give your blocks data,
8
+ and what this document describes.
9
+ - `manual` — sources a user creates themselves in the shell.
10
+ - `built-in` — sources we provide.
11
+
12
+
13
+ ## File format
14
+
15
+ ```json
16
+ {
17
+ "type": "worker",
18
+ "key": "tasks",
19
+ "name": "Tasks",
20
+ "icon": "✅",
21
+ "schema": {
22
+ "title": { "name": "Title", "type": "title" },
23
+ "status": { "name": "Status", "type": "select" },
24
+ "dueDate": { "name": "Due date", "type": "date" },
25
+ "done": { "name": "Done", "type": "checkbox" }
26
+ },
27
+ "rows": [
28
+ {
29
+ "id": "tasks-1",
30
+ "title": "Ship the block",
31
+ "status": "In progress",
32
+ "dueDate": { "type": "date", "start_date": "2026-08-01" },
33
+ "done": false
34
+ }
35
+ ]
36
+ }
37
+ ```
38
+
39
+ - `type` — the source's kind; `"worker"`, the only kind these files can
40
+ declare today, is the default.
41
+ - `key` — what blocks bind against; make it match a `dataSources` key from
42
+ `worker.customBlock(...)` so properties auto-match. Defaults to the
43
+ filename.
44
+ - `name` — the label shown in the shell sidebar. Defaults to `key`.
45
+ - `icon` — optional emoji shown alongside the name.
46
+ - `schema` — property key → `{ name, type }`. Use the same property keys and
47
+ types the block declares. Types: `title`, `rich_text`, `number`, `select`,
48
+ `multi_select`, `status`, `date`, `checkbox`, `url`, `email`,
49
+ `phone_number`, `people`, `files`, `relation`.
50
+ - `rows` — each row needs a unique string `id`; property values live under
51
+ their schema keys.
52
+
53
+ Value shapes by property type:
54
+
55
+ | Type | Value |
56
+ | --- | --- |
57
+ | `title`, `rich_text`, `select`, `status`, `url`, `email`, `phone_number` | string |
58
+ | `multi_select` | array of strings |
59
+ | `number` | number |
60
+ | `checkbox` | boolean |
61
+ | `date` | `{ "type": "date", "start_date": "YYYY-MM-DD" }`; ranges and datetimes use `"daterange"` / `"datetime"` / `"datetimerange"` with `end_date`, `start_time`, `end_time` |
62
+ | `people` | array of `{ "id": "...", "table": "notion_user" }` pointers |
63
+ | `relation` | array of `{ "id": "...", "table": "block" }` pointers |
64
+ | `files` | string (the host serializes file properties as text today) |
65
+
66
+ ## Validation
67
+
68
+ Files are validated at spin-up, before they reach the shell UI. A malformed
69
+ file fails the run with the file and problem named, e.g.
70
+ `src/data/tasks.json: rows.0.id: Invalid key: Expected "id" but received undefined`.
71
+ Omitted `key`/`name` fall back to the filename; omitted `schema`/`rows` to
72
+ empty.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notionhq/custom-blocks-dev-shell",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Local preview shell for Notion custom block workers.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,14 +17,15 @@
17
17
  },
18
18
  "files": [
19
19
  "bin",
20
- "cli",
21
20
  "dist",
21
+ "dist-cli",
22
+ "docs",
22
23
  "README.md"
23
24
  ],
24
25
  "dependencies": {
25
26
  "react": "^19.2.5",
26
27
  "react-dom": "^19.2.5",
27
- "tsx": "^4.21.0",
28
+ "valibot": "^1.3.1",
28
29
  "@notionhq/custom-blocks": "0.1.0"
29
30
  },
30
31
  "devDependencies": {
@@ -40,7 +41,7 @@
40
41
  },
41
42
  "scripts": {
42
43
  "dev": "vite",
43
- "build": "vite build",
44
+ "build": "vite build && tsc --project tsconfig.cli.json",
44
45
  "test": "vitest run",
45
46
  "typecheck": "tsc --noEmit && tsc --noEmit --project tsconfig.cli.json"
46
47
  }
package/cli/main.ts DELETED
@@ -1,293 +0,0 @@
1
- /**
2
- * Entry point for the published dev-shell CLI (`npx`-run from a worker
3
- * project). Mirrors the worker mode of the repo's `scripts/dev.ts`: build the
4
- * worker, extract its manifest, serve one Vite dev server per custom block
5
- * (using the worker's own Vite install), and serve the prebuilt dev-shell-2 UI
6
- * with the block registry injected at runtime.
7
- */
8
-
9
- import { spawn } from "node:child_process"
10
- import { existsSync, readFileSync } from "node:fs"
11
- import { createRequire } from "node:module"
12
- import { basename, dirname, join, resolve } from "node:path"
13
- import { fileURLToPath } from "node:url"
14
- import {
15
- BLOCK_BASE_PORT,
16
- buildBlockRegistry,
17
- SHELL_2_PORT,
18
- writeBlockViteConfig,
19
- } from "./block-server"
20
- import { serveUi } from "./serve-ui"
21
- import {
22
- blockCapabilities,
23
- findWorkerDir,
24
- generateWorkerManifest,
25
- } from "./worker-manifest"
26
-
27
- const __dirname = dirname(fileURLToPath(import.meta.url))
28
-
29
- const dim = "\x1b[2m"
30
- const bold = "\x1b[1m"
31
- const cyan = "\x1b[36m"
32
- const reset = "\x1b[0m"
33
- const label = (name: string) => `${cyan}[${name}]${reset}`
34
-
35
- type CliArgs = {
36
- worker: string | undefined
37
- shellPort: number
38
- blockBasePort: number
39
- }
40
-
41
- function parseCliArgs(argv: readonly string[]): CliArgs {
42
- const args: CliArgs = {
43
- worker: undefined,
44
- shellPort: SHELL_2_PORT,
45
- blockBasePort: BLOCK_BASE_PORT,
46
- }
47
- const takeValue = (name: string, index: number): string => {
48
- const value = argv[index]
49
- if (value === undefined || value.startsWith("--")) {
50
- throw new Error(`${name} requires a value.`)
51
- }
52
- return value
53
- }
54
- const takePort = (name: string, raw: string): number => {
55
- const port = Number(raw)
56
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
57
- throw new Error(`${name} requires a port number, got "${raw}".`)
58
- }
59
- return port
60
- }
61
- for (let index = 0; index < argv.length; index++) {
62
- const arg = argv[index]
63
- if (arg === "--worker") {
64
- args.worker = takeValue("--worker", ++index)
65
- } else if (arg.startsWith("--worker=")) {
66
- args.worker = arg.slice("--worker=".length)
67
- if (args.worker.length === 0) {
68
- throw new Error("--worker requires a path to a worker directory.")
69
- }
70
- } else if (arg === "--port") {
71
- args.shellPort = takePort("--port", takeValue("--port", ++index))
72
- } else if (arg.startsWith("--port=")) {
73
- args.shellPort = takePort("--port", arg.slice("--port=".length))
74
- } else if (arg === "--block-base-port") {
75
- args.blockBasePort = takePort(
76
- "--block-base-port",
77
- takeValue("--block-base-port", ++index),
78
- )
79
- } else if (arg.startsWith("--block-base-port=")) {
80
- args.blockBasePort = takePort(
81
- "--block-base-port",
82
- arg.slice("--block-base-port=".length),
83
- )
84
- }
85
- }
86
- return args
87
- }
88
-
89
- function resolveWorkerDir(workerArg: string | undefined): string {
90
- if (workerArg !== undefined) {
91
- return resolve(process.cwd(), workerArg)
92
- }
93
- const detected = findWorkerDir(process.cwd())
94
- if (detected !== undefined) {
95
- console.log(`Detected a worker at ${detected}.`)
96
- return detected
97
- }
98
- throw new Error(
99
- "No worker found: run from inside a worker directory, or pass --worker <dir>.",
100
- )
101
- }
102
-
103
- /**
104
- * The worker's own Vite binary. Blocks are served with the worker's Vite (and
105
- * plugins) rather than anything bundled here, matching how the block builds in
106
- * production.
107
- */
108
- function resolveViteBin(workerDir: string): string {
109
- let vitePkgPath: string
110
- try {
111
- const workerRequire = createRequire(join(workerDir, "package.json"))
112
- vitePkgPath = workerRequire.resolve("vite/package.json")
113
- } catch {
114
- throw new Error(
115
- `Could not resolve "vite" from ${workerDir}. Add vite to the worker's ` +
116
- `devDependencies and reinstall.`,
117
- )
118
- }
119
- const vitePkg = JSON.parse(readFileSync(vitePkgPath, "utf-8")) as {
120
- bin?: string | Record<string, string>
121
- }
122
- const bin = typeof vitePkg.bin === "string" ? vitePkg.bin : vitePkg.bin?.vite
123
- if (bin === undefined) {
124
- throw new Error(`The vite package at ${vitePkgPath} exposes no bin.`)
125
- }
126
- return resolve(dirname(vitePkgPath), bin)
127
- }
128
-
129
- const procs: ReturnType<typeof spawn>[] = []
130
-
131
- async function main() {
132
- const cliArgs = parseCliArgs(process.argv.slice(2))
133
- const workerDir = resolveWorkerDir(cliArgs.worker)
134
- if (!existsSync(resolve(workerDir, "node_modules"))) {
135
- throw new Error(
136
- `No node_modules in ${workerDir}. Install the worker's dependencies first ` +
137
- `(e.g. \`npm install\`), then rerun.`,
138
- )
139
- }
140
-
141
- console.log(`${label(basename(workerDir))} Extracting worker manifest...`)
142
- const { manifest, manifestPath } = await generateWorkerManifest(workerDir)
143
- console.log(`${label(basename(workerDir))} Wrote ${manifestPath}`)
144
- const blocks = blockCapabilities(manifest)
145
- if (blocks.length === 0) {
146
- // Not an error — start the shell anyway; it shows "None" under Blocks.
147
- console.log(
148
- `${label(basename(workerDir))} Worker declares no custom blocks.`,
149
- )
150
- }
151
-
152
- if (
153
- blocks.length > 0 &&
154
- cliArgs.shellPort >= cliArgs.blockBasePort &&
155
- cliArgs.shellPort < cliArgs.blockBasePort + blocks.length
156
- ) {
157
- throw new Error(
158
- `--port ${cliArgs.shellPort} collides with the block server ports ` +
159
- `(${cliArgs.blockBasePort}–${cliArgs.blockBasePort + blocks.length - 1}); ` +
160
- `pick a port outside that range or move --block-base-port.`,
161
- )
162
- }
163
-
164
- const viteBin = blocks.length > 0 ? resolveViteBin(workerDir) : undefined
165
- const registry = buildBlockRegistry(blocks, cliArgs.blockBasePort)
166
-
167
- for (const [index, capability] of blocks.entries()) {
168
- const blockDir = resolve(workerDir, capability.config.source.path)
169
- const configFile = writeBlockViteConfig(workerDir, blockDir, capability)
170
- const port = cliArgs.blockBasePort + index
171
- const proc = spawn(
172
- process.execPath,
173
- [
174
- viteBin as string,
175
- "--config",
176
- configFile,
177
- "--port",
178
- String(port),
179
- "--strictPort",
180
- ],
181
- {
182
- cwd: workerDir,
183
- stdio: ["ignore", "ignore", "inherit"],
184
- // Process groups (and negative-PID kills) are POSIX-only; on
185
- // Windows children are killed individually in shutdown().
186
- detached: process.platform !== "win32",
187
- },
188
- )
189
- proc.on("exit", code => {
190
- if (shuttingDown || code === 0 || code === null) {
191
- return
192
- }
193
- console.error(
194
- `${label(capability.key)} dev server exited with code ${code}`,
195
- )
196
- process.exitCode = code
197
- shutdown("SIGTERM")
198
- })
199
- procs.push(proc)
200
- }
201
-
202
- // The published layout is dist/ next to cli/; index.html must be prebuilt.
203
- const distDir = resolve(__dirname, "..", "dist")
204
- if (!existsSync(join(distDir, "index.html"))) {
205
- throw new Error(
206
- `No prebuilt UI found at ${distDir}. This package was not assembled ` +
207
- `correctly; reinstall it.`,
208
- )
209
- }
210
- try {
211
- await serveUi(distDir, cliArgs.shellPort, {
212
- mode: "worker",
213
- blocks: registry,
214
- })
215
- } catch (error) {
216
- const code = (error as NodeJS.ErrnoException).code
217
- if (code === "EADDRINUSE") {
218
- throw new Error(
219
- `Port ${cliArgs.shellPort} is already in use. Stop whatever holds it ` +
220
- `or rerun with --port <port> (and --block-base-port <port> for the ` +
221
- `block servers).`,
222
- )
223
- }
224
- throw error
225
- }
226
-
227
- console.log("")
228
- console.log(`${bold}Dev shell${reset}`)
229
- console.log(
230
- ` ${label("dev-shell")} ${dim}http://localhost:${cliArgs.shellPort}${reset}`,
231
- )
232
- if (blocks.length > 0) {
233
- console.log("")
234
- console.log(`${bold}Blocks${reset}`)
235
- for (const [index, entry] of registry.entries()) {
236
- console.log(
237
- ` ${label(entry.key)} ${dim}http://localhost:${cliArgs.blockBasePort + index}${reset}`,
238
- )
239
- }
240
- }
241
- console.log("")
242
- }
243
-
244
- function killProc(p: ReturnType<typeof spawn>, signal: NodeJS.Signals) {
245
- if (p.pid === undefined || p.killed) {
246
- return
247
- }
248
- try {
249
- if (process.platform === "win32") {
250
- p.kill(signal)
251
- } else {
252
- process.kill(-p.pid, signal)
253
- }
254
- } catch {}
255
- }
256
-
257
- let shuttingDown = false
258
- function shutdown(signal: NodeJS.Signals | "exit") {
259
- if (shuttingDown) {
260
- return
261
- }
262
- shuttingDown = true
263
- if (signal !== "exit") {
264
- console.log(`\n${dim}Shutting down...${reset}`)
265
- }
266
- for (const p of procs) {
267
- killProc(p, "SIGTERM")
268
- }
269
- setTimeout(() => {
270
- for (const p of procs) {
271
- killProc(p, "SIGKILL")
272
- }
273
- // Preserve a failure exit code set before shutdown (startup errors,
274
- // crashed block servers); plain signal shutdowns still exit 0.
275
- process.exit(process.exitCode ?? 0)
276
- }, 1500).unref()
277
- }
278
-
279
- for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
280
- process.on(signal, () => shutdown(signal))
281
- }
282
- process.on("exit", () => shutdown("exit"))
283
- process.on("uncaughtException", err => {
284
- console.error(err)
285
- process.exitCode = 1
286
- shutdown("SIGTERM")
287
- })
288
-
289
- main().catch(err => {
290
- console.error(err instanceof Error ? err.message : err)
291
- process.exitCode = 1
292
- shutdown("SIGTERM")
293
- })
package/cli/serve-ui.ts DELETED
@@ -1,100 +0,0 @@
1
- /**
2
- * Dependency-free static server for the prebuilt dev-shell-2 UI (`dist/`).
3
- *
4
- * The published UI is a plain Vite build, so the block registry can't ride in
5
- * through build-time `define` globals the way it does under `scripts/dev.ts`.
6
- * Instead, this server injects a `window.__DEV_SHELL_2_CONFIG__` script into
7
- * `index.html` as it is served; `src/helpers/templates.ts` prefers that global
8
- * over the baked-in defines.
9
- */
10
-
11
- import { readFileSync } from "node:fs"
12
- import { createServer, type Server } from "node:http"
13
- import { extname, join, normalize, resolve, sep } from "node:path"
14
- import type { BlockRegistryEntry } from "./block-server"
15
-
16
- export type DevShellRuntimeConfig = {
17
- mode: "worker"
18
- blocks: BlockRegistryEntry[]
19
- }
20
-
21
- const CONTENT_TYPES: Record<string, string> = {
22
- ".html": "text/html; charset=utf-8",
23
- ".js": "text/javascript; charset=utf-8",
24
- ".mjs": "text/javascript; charset=utf-8",
25
- ".css": "text/css; charset=utf-8",
26
- ".json": "application/json; charset=utf-8",
27
- ".svg": "image/svg+xml",
28
- ".png": "image/png",
29
- ".ico": "image/x-icon",
30
- ".woff": "font/woff",
31
- ".woff2": "font/woff2",
32
- ".map": "application/json; charset=utf-8",
33
- }
34
-
35
- /** `index.html` with the runtime config injected ahead of the bundle script. */
36
- function injectConfig(html: string, config: DevShellRuntimeConfig): string {
37
- const tag = `<script>window.__DEV_SHELL_2_CONFIG__ = ${JSON.stringify(config)}</script>`
38
- if (html.includes("<head>")) {
39
- return html.replace("<head>", `<head>\n\t\t${tag}`)
40
- }
41
- return `${tag}\n${html}`
42
- }
43
-
44
- /**
45
- * Serve `distDir` on `port`. Unknown extensionless paths fall back to
46
- * `index.html`. Rejects on listen errors (e.g. the port is taken).
47
- */
48
- export function serveUi(
49
- distDir: string,
50
- port: number,
51
- config: DevShellRuntimeConfig,
52
- ): Promise<Server> {
53
- const dist = resolve(distDir)
54
- const indexHtml = injectConfig(
55
- readFileSync(join(dist, "index.html"), "utf-8"),
56
- config,
57
- )
58
-
59
- const server = createServer((req, res) => {
60
- let path: string
61
- try {
62
- path = normalize(decodeURIComponent(req.url?.split("?", 1)[0] ?? "/"))
63
- } catch {
64
- res.writeHead(400)
65
- res.end()
66
- return
67
- }
68
- const filePath = join(dist, path)
69
- if (!filePath.startsWith(dist + sep) && filePath !== dist) {
70
- res.writeHead(403)
71
- res.end()
72
- return
73
- }
74
-
75
- const ext = extname(filePath)
76
- if (path === "/" || path === `${sep}index.html` || ext === "") {
77
- res.writeHead(200, { "Content-Type": CONTENT_TYPES[".html"] })
78
- res.end(indexHtml)
79
- return
80
- }
81
-
82
- try {
83
- const body = readFileSync(filePath)
84
- res.writeHead(200, {
85
- "Content-Type": CONTENT_TYPES[ext] ?? "application/octet-stream",
86
- })
87
- res.end(body)
88
- } catch {
89
- res.writeHead(404)
90
- res.end()
91
- }
92
- })
93
-
94
- return new Promise((resolvePromise, reject) => {
95
- server.once("error", reject)
96
- // Loopback only: every response embeds the worker's block registry, and
97
- // the per-block Vite servers are localhost-only too.
98
- server.listen(port, "127.0.0.1", () => resolvePromise(server))
99
- })
100
- }
@@ -1,196 +0,0 @@
1
- /**
2
- * Extract a worker's manifest without deploying it, following the localhost
3
- * verification strategy: build the worker, then read the manifest off the
4
- * built module's default export. This is the one prerequisite the dev shell
5
- * needs before it can list a worker's blocks and data sources.
6
- *
7
- * The worker manifest is otherwise in-memory only (tied to `worker.ts` + cloud
8
- * build), so we materialize it under the worker's git-ignored `.dev-shell/` dir
9
- * (clearly a dev-shell artifact, not something the author maintains).
10
- * Regenerated on every spin-up.
11
- */
12
-
13
- import { execSync } from "node:child_process"
14
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
15
- import { dirname, resolve } from "node:path"
16
- import { pathToFileURL } from "node:url"
17
-
18
- /** One custom-block capability in the worker manifest. */
19
- export type WorkerBlockCapability = {
20
- _tag: "custom_block"
21
- key: string
22
- config: {
23
- source: { type: string; path: string; command?: string; output?: string }
24
- manifest: {
25
- version: number
26
- dataSources: Record<
27
- string,
28
- {
29
- name: string
30
- description?: string
31
- icon?: unknown
32
- properties?: Record<string, { name: string; type: string }>
33
- }
34
- >
35
- }
36
- }
37
- }
38
-
39
- /** One worker-defined database in the manifest. */
40
- export type WorkerDatabaseEntry = {
41
- key: string
42
- config: {
43
- type: string
44
- initialTitle?: string
45
- primaryKeyProperty?: string
46
- schema?: {
47
- properties?: Record<string, { type: string; [k: string]: unknown }>
48
- }
49
- }
50
- }
51
-
52
- export type WorkerManifest = {
53
- sdkVersion?: string
54
- databases: WorkerDatabaseEntry[]
55
- pacers: { key: string; config: unknown }[]
56
- capabilities: { _tag: string; key: string; config: unknown }[]
57
- }
58
-
59
- export const WORKER_MANIFEST_FILENAME = "worker_manifest.json"
60
-
61
- /**
62
- * Heuristic for the no-flag fallback: does `dir` look like a worker project?
63
- * True if it carries a worker deploy binding (`worker.json`/`workers.json`) or
64
- * depends on `@notionhq/workers`.
65
- */
66
- export function looksLikeWorkerDir(dir: string): boolean {
67
- if (
68
- existsSync(resolve(dir, "worker.json")) ||
69
- existsSync(resolve(dir, "workers.json"))
70
- ) {
71
- return true
72
- }
73
- const pkgPath = resolve(dir, "package.json")
74
- if (!existsSync(pkgPath)) {
75
- return false
76
- }
77
- try {
78
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
79
- dependencies?: Record<string, string>
80
- devDependencies?: Record<string, string>
81
- }
82
- return Boolean(
83
- pkg.dependencies?.["@notionhq/workers"] ??
84
- pkg.devDependencies?.["@notionhq/workers"],
85
- )
86
- } catch {
87
- return false
88
- }
89
- }
90
-
91
- /**
92
- * Walk up from `startDir` looking for a worker directory (see
93
- * `looksLikeWorkerDir`), so the shell can be launched from anywhere inside a
94
- * worker, not only its root. Stops once it reaches a git-repo boundary (a
95
- * `.git` at the current level, checked after the worker test) or the filesystem
96
- * root — it should never escape the project the user is in.
97
- */
98
- export function findWorkerDir(startDir: string): string | undefined {
99
- let current = resolve(startDir)
100
- while (true) {
101
- if (looksLikeWorkerDir(current)) {
102
- return current
103
- }
104
- if (existsSync(resolve(current, ".git"))) {
105
- return undefined
106
- }
107
- const parent = dirname(current)
108
- if (parent === current) {
109
- return undefined
110
- }
111
- current = parent
112
- }
113
- }
114
-
115
- /** The custom-block capabilities in a manifest, narrowed by `_tag`. */
116
- export function blockCapabilities(
117
- manifest: WorkerManifest,
118
- ): WorkerBlockCapability[] {
119
- return manifest.capabilities.filter(
120
- (capability): capability is WorkerBlockCapability =>
121
- capability._tag === "custom_block",
122
- )
123
- }
124
-
125
- function isManifestShape(value: unknown): value is WorkerManifest {
126
- return (
127
- typeof value === "object" &&
128
- value !== null &&
129
- Array.isArray((value as WorkerManifest).capabilities)
130
- )
131
- }
132
-
133
- /**
134
- * Build the worker in `workerDir` and read its manifest off the built default
135
- * export. Mirrors the deploy path (build → read the built module) rather than
136
- * importing TypeScript source, so what the dev shell sees matches what a deploy
137
- * would produce.
138
- */
139
- export async function extractWorkerManifest(
140
- workerDir: string,
141
- options: { build?: boolean } = {},
142
- ): Promise<WorkerManifest> {
143
- const root = resolve(workerDir)
144
- if (!existsSync(resolve(root, "package.json"))) {
145
- throw new Error(`No package.json found in worker directory: ${root}`)
146
- }
147
-
148
- if (options.build !== false) {
149
- execSync("npm run build", { cwd: root, stdio: "inherit" })
150
- }
151
-
152
- const entry = resolve(root, "dist/index.js")
153
- if (!existsSync(entry)) {
154
- throw new Error(
155
- `Built worker entry not found at ${entry}. The worker's build must emit dist/index.js.`,
156
- )
157
- }
158
-
159
- const mod = (await import(pathToFileURL(entry).href)) as {
160
- default?: { manifest?: unknown; capabilities?: unknown }
161
- }
162
- const worker = mod.default
163
- if (worker === undefined) {
164
- throw new Error(`Built worker at ${entry} has no default export.`)
165
- }
166
-
167
- // Read the built worker's manifest, falling back to a bare capabilities array
168
- // (per the localhost verification doc's `w.manifest || w.capabilities`).
169
- const raw = worker.manifest ?? worker.capabilities
170
- if (isManifestShape(raw)) {
171
- return raw
172
- }
173
- if (Array.isArray(raw)) {
174
- return { databases: [], pacers: [], capabilities: raw }
175
- }
176
- throw new Error(
177
- `Built worker at ${entry} exposed no usable manifest (expected .manifest or .capabilities).`,
178
- )
179
- }
180
-
181
- /**
182
- * Extract the worker manifest and write it to `.dev-shell/worker_manifest.json`
183
- * inside the worker (a git-ignored dev-shell artifact dir). Returns the parsed
184
- * manifest and the path written.
185
- */
186
- export async function generateWorkerManifest(
187
- workerDir: string,
188
- options: { build?: boolean } = {},
189
- ): Promise<{ manifest: WorkerManifest; manifestPath: string }> {
190
- const manifest = await extractWorkerManifest(workerDir, options)
191
- const dir = resolve(workerDir, ".dev-shell")
192
- mkdirSync(dir, { recursive: true })
193
- const manifestPath = resolve(dir, WORKER_MANIFEST_FILENAME)
194
- writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
195
- return { manifest, manifestPath }
196
- }