@treeseed/core 0.6.14 → 0.6.16

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/dist/dev.d.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  import type { ChildProcess, SpawnOptions } from 'node:child_process';
2
+ import { spawnSync } from 'node:child_process';
2
3
  export declare const TREESEED_DEFAULT_WEB_HOST = "127.0.0.1";
3
4
  export declare const TREESEED_DEFAULT_WEB_PORT = 4321;
4
5
  export declare const TREESEED_DEFAULT_API_HOST = "127.0.0.1";
5
6
  export declare const TREESEED_DEFAULT_API_PORT = 3000;
6
7
  export declare const TREESEED_DEFAULT_MANAGER_PORT = 3100;
7
8
  export type TreeseedIntegratedDevSurface = 'integrated' | 'services' | 'web' | 'api' | 'manager' | 'worker';
9
+ export type TreeseedIntegratedDevSetupMode = 'auto' | 'check' | 'off';
10
+ export type TreeseedIntegratedDevFeedbackMode = 'live' | 'restart' | 'off';
11
+ export type TreeseedIntegratedDevOpenMode = 'auto' | 'on' | 'off';
8
12
  export type TreeseedIntegratedDevOptions = {
9
13
  surface?: TreeseedIntegratedDevSurface;
10
14
  watch?: boolean;
@@ -16,9 +20,16 @@ export type TreeseedIntegratedDevOptions = {
16
20
  apiHost?: string;
17
21
  apiPort?: number;
18
22
  managerPort?: number;
23
+ setupMode?: TreeseedIntegratedDevSetupMode;
24
+ feedbackMode?: TreeseedIntegratedDevFeedbackMode;
25
+ openMode?: TreeseedIntegratedDevOpenMode;
26
+ plan?: boolean;
27
+ json?: boolean;
19
28
  includeServices?: boolean;
20
29
  projectId?: string;
21
30
  teamId?: string;
31
+ readinessTimeoutMs?: number;
32
+ processReadyGraceMs?: number;
22
33
  };
23
34
  export type TreeseedIntegratedDevCommand = {
24
35
  id: 'web' | 'api' | 'manager' | 'worker';
@@ -28,18 +39,63 @@ export type TreeseedIntegratedDevCommand = {
28
39
  cwd: string;
29
40
  env: NodeJS.ProcessEnv;
30
41
  };
42
+ export type TreeseedIntegratedDevWatchEntry = {
43
+ kind: 'tenant' | 'package' | 'sdk';
44
+ root: string;
45
+ };
46
+ export type TreeseedIntegratedDevSetupStep = {
47
+ id: string;
48
+ label: string;
49
+ required: boolean;
50
+ command?: string;
51
+ args?: string[];
52
+ status: 'planned' | 'completed' | 'skipped' | 'degraded' | 'failed';
53
+ detail?: string;
54
+ };
55
+ export type TreeseedIntegratedDevReadinessCheck = {
56
+ id: TreeseedIntegratedDevCommand['id'];
57
+ label: string;
58
+ required: boolean;
59
+ strategy: 'http' | 'process';
60
+ url?: string;
61
+ };
31
62
  export type TreeseedIntegratedDevPlan = {
32
63
  surface: TreeseedIntegratedDevSurface;
64
+ setupMode: TreeseedIntegratedDevSetupMode;
65
+ feedbackMode: TreeseedIntegratedDevFeedbackMode;
66
+ openMode: TreeseedIntegratedDevOpenMode;
67
+ watch: boolean;
33
68
  tenantRoot: string;
34
69
  apiBaseUrl: string;
70
+ webUrl: string | null;
71
+ setupSteps: TreeseedIntegratedDevSetupStep[];
72
+ readyChecks: TreeseedIntegratedDevReadinessCheck[];
73
+ watchEntries: TreeseedIntegratedDevWatchEntry[];
35
74
  commands: TreeseedIntegratedDevCommand[];
36
75
  };
37
76
  type SpawnLike = (command: string, args: string[], options: SpawnOptions) => ChildProcess;
77
+ type SpawnSyncLike = typeof spawnSync;
38
78
  type SignalRegistrar = (signal: NodeJS.Signals, handler: () => void) => () => void;
79
+ type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
80
+ type WatchChange = {
81
+ changedPaths: string[];
82
+ tenantChanged: boolean;
83
+ packageChanged: boolean;
84
+ sdkChanged: boolean;
85
+ };
86
+ type WatchStarter = (input: {
87
+ watchEntries: TreeseedIntegratedDevWatchEntry[];
88
+ onChange: (change: WatchChange) => void | Promise<void>;
89
+ }) => () => void;
39
90
  type TreeseedIntegratedDevDependencies = {
40
91
  spawn: SpawnLike;
92
+ spawnSync: SpawnSyncLike;
41
93
  onSignal: SignalRegistrar;
42
94
  prepareEnvironment: (tenantRoot: string) => void;
95
+ fetch: FetchLike;
96
+ write: (line: string, stream: 'stdout' | 'stderr') => void;
97
+ openBrowser: (url: string) => void | Promise<void>;
98
+ startWatch: WatchStarter;
43
99
  };
44
100
  export declare function createTreeseedIntegratedDevPlan(options?: TreeseedIntegratedDevOptions): TreeseedIntegratedDevPlan;
45
101
  export declare function runTreeseedIntegratedDev(options?: TreeseedIntegratedDevOptions, deps?: Partial<TreeseedIntegratedDevDependencies>): Promise<number>;
package/dist/dev.js CHANGED
@@ -1,9 +1,16 @@
1
- import { existsSync } from "node:fs";
2
- import { spawn } from "node:child_process";
1
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { spawn, spawnSync } from "node:child_process";
3
3
  import { createRequire } from "node:module";
4
- import { dirname, resolve } from "node:path";
4
+ import { dirname, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { applyTreeseedEnvironmentToProcess, assertTreeseedCommandEnvironment } from "@treeseed/sdk/workflow-support";
6
+ import { setTimeout as delay } from "node:timers/promises";
7
+ import {
8
+ applyTreeseedEnvironmentToProcess,
9
+ assertTreeseedCommandEnvironment,
10
+ ensureLocalWorkspaceLinks,
11
+ findNearestTreeseedWorkspaceRoot,
12
+ resolveTreeseedToolBinary
13
+ } from "@treeseed/sdk/workflow-support";
7
14
  const require2 = createRequire(import.meta.url);
8
15
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
9
16
  const TREESEED_DEFAULT_WEB_HOST = "127.0.0.1";
@@ -11,6 +18,11 @@ const TREESEED_DEFAULT_WEB_PORT = 4321;
11
18
  const TREESEED_DEFAULT_API_HOST = "127.0.0.1";
12
19
  const TREESEED_DEFAULT_API_PORT = 3e3;
13
20
  const TREESEED_DEFAULT_MANAGER_PORT = 3100;
21
+ const DEV_RELOAD_FILE = "public/__treeseed/dev-reload.json";
22
+ const WATCH_INTERVAL_MS = 900;
23
+ const WATCH_DEBOUNCE_MS = 350;
24
+ const DEFAULT_READINESS_TIMEOUT_MS = 9e4;
25
+ const DEFAULT_PROCESS_READY_GRACE_MS = 1200;
14
26
  function resolvePackageRoot(packageName, tenantRoot) {
15
27
  const resolvedPath = require2.resolve(packageName, {
16
28
  paths: [tenantRoot, packageRoot, process.cwd()]
@@ -39,6 +51,24 @@ function resolveNodeEntrypoint(packageDir, sourceRelativePath, distRelativePath)
39
51
  args: [resolve(packageDir, distRelativePath)]
40
52
  };
41
53
  }
54
+ function resolveOptionalScriptEntrypoint(packageDir, sourceRelativePath, distRelativePath) {
55
+ const sourcePath = resolve(packageDir, sourceRelativePath);
56
+ const runTsPath = resolve(packageDir, "scripts", "run-ts.mjs");
57
+ if (existsSync(sourcePath) && existsSync(runTsPath)) {
58
+ return {
59
+ command: process.execPath,
60
+ args: [runTsPath, sourcePath]
61
+ };
62
+ }
63
+ const distPath = resolve(packageDir, distRelativePath);
64
+ if (existsSync(distPath)) {
65
+ return {
66
+ command: process.execPath,
67
+ args: [distPath]
68
+ };
69
+ }
70
+ return null;
71
+ }
42
72
  function resolveTenantApiEntrypoint(tenantRoot, runTsPath) {
43
73
  const javascriptCandidates = [
44
74
  resolve(tenantRoot, "src", "api", "server.js"),
@@ -67,10 +97,122 @@ function withWatchArgs(args, watchPaths) {
67
97
  function normalizePort(value, fallback) {
68
98
  return Number.isInteger(value) && Number(value) > 0 ? Number(value) : fallback;
69
99
  }
100
+ function normalizeSetupMode(value) {
101
+ return value ?? "auto";
102
+ }
103
+ function normalizeFeedbackMode(value) {
104
+ return value ?? "live";
105
+ }
106
+ function normalizeOpenMode(value) {
107
+ return value ?? "auto";
108
+ }
109
+ function browserHost(host) {
110
+ return host === "0.0.0.0" || host === "::" || host === "[::]" ? "127.0.0.1" : host;
111
+ }
112
+ function webUrlFor(host, port) {
113
+ return `http://${browserHost(host)}:${port}`;
114
+ }
115
+ function createWatchEntries(tenantRoot, sdkPackageRoot) {
116
+ const entries = [
117
+ { kind: "tenant", root: resolve(tenantRoot, "src") },
118
+ { kind: "tenant", root: resolve(tenantRoot, "public") },
119
+ { kind: "tenant", root: resolve(tenantRoot, "astro.config.ts") },
120
+ { kind: "tenant", root: resolve(tenantRoot, "treeseed.site.yaml") }
121
+ ];
122
+ if (!packageRoot.split(sep).includes("node_modules")) {
123
+ entries.push(
124
+ { kind: "package", root: resolve(packageRoot, "src") },
125
+ { kind: "package", root: resolve(packageRoot, "scripts") },
126
+ { kind: "package", root: resolve(packageRoot, "package.json") }
127
+ );
128
+ }
129
+ if (!sdkPackageRoot.split(sep).includes("node_modules")) {
130
+ entries.push(
131
+ { kind: "sdk", root: resolve(sdkPackageRoot, "src") },
132
+ { kind: "sdk", root: resolve(sdkPackageRoot, "scripts") },
133
+ { kind: "sdk", root: resolve(sdkPackageRoot, "package.json") }
134
+ );
135
+ }
136
+ return entries;
137
+ }
138
+ function isSurfaceIncluded(plan, id) {
139
+ return plan.commands.some((command) => command.id === id);
140
+ }
141
+ function createSetupSteps(tenantRoot, setupMode, sdkPackageRoot, planLike, env) {
142
+ if (setupMode === "off") {
143
+ return [
144
+ {
145
+ id: "setup-disabled",
146
+ label: "Local setup disabled",
147
+ required: false,
148
+ status: "skipped",
149
+ detail: "Run without --setup off to prepare workspace links, local D1 state, and generated dev artifacts."
150
+ }
151
+ ];
152
+ }
153
+ const coreScripts = [
154
+ ["starlight-patch", "Patch Starlight content path", "scripts/patch-starlight-content-path.ts", "dist/scripts/patch-starlight-content-path.js"],
155
+ ["books", "Generate book/public artifacts", "scripts/aggregate-book.ts", "dist/scripts/aggregate-book.js"],
156
+ ["worker-bundle", "Generate local worker bundle", "scripts/build-tenant-worker.ts", "dist/scripts/build-tenant-worker.js"]
157
+ ];
158
+ const steps = [
159
+ {
160
+ id: "workspace-links",
161
+ label: "Ensure local workspace links",
162
+ required: setupMode === "auto",
163
+ status: "planned"
164
+ },
165
+ {
166
+ id: "wrangler",
167
+ label: "Verify Wrangler executable",
168
+ required: isSurfaceIncluded(planLike, "api"),
169
+ status: "planned",
170
+ detail: resolveTreeseedToolBinary("wrangler", { env }) ?? void 0
171
+ },
172
+ ...coreScripts.map(([id, label, source, dist]) => {
173
+ const script = resolveOptionalScriptEntrypoint(packageRoot, source, dist);
174
+ return {
175
+ id,
176
+ label,
177
+ required: true,
178
+ command: script?.command,
179
+ args: script?.args,
180
+ status: script ? "planned" : "skipped",
181
+ detail: script ? void 0 : `Script not found at ${source}.`
182
+ };
183
+ }),
184
+ {
185
+ id: "mailpit",
186
+ label: "Check optional Mailpit email runtime",
187
+ required: false,
188
+ status: "planned"
189
+ }
190
+ ];
191
+ if (isSurfaceIncluded(planLike, "api") && existsSync(resolve(tenantRoot, "migrations"))) {
192
+ const migrate = resolveOptionalScriptEntrypoint(
193
+ sdkPackageRoot,
194
+ "scripts/tenant-d1-migrate-local.ts",
195
+ "dist/scripts/tenant-d1-migrate-local.js"
196
+ );
197
+ steps.push({
198
+ id: "d1-migrations",
199
+ label: "Run local D1 migrations",
200
+ required: true,
201
+ command: migrate?.command,
202
+ args: migrate?.args,
203
+ status: migrate ? "planned" : "failed",
204
+ detail: migrate ? void 0 : "Unable to resolve the local D1 migration script."
205
+ });
206
+ }
207
+ return steps;
208
+ }
70
209
  function createTreeseedIntegratedDevPlan(options = {}) {
71
210
  const tenantRoot = resolve(options.cwd ?? process.cwd());
72
211
  const surface = options.surface ?? "integrated";
73
- const watch = options.watch === true;
212
+ const setupMode = normalizeSetupMode(options.setupMode);
213
+ const feedbackMode = normalizeFeedbackMode(options.feedbackMode);
214
+ const openMode = normalizeOpenMode(options.openMode);
215
+ const watch = feedbackMode !== "off" || options.watch === true;
74
216
  const webHost = options.webHost ?? TREESEED_DEFAULT_WEB_HOST;
75
217
  const webPort = normalizePort(options.webPort, TREESEED_DEFAULT_WEB_PORT);
76
218
  const apiHost = options.apiHost ?? TREESEED_DEFAULT_API_HOST;
@@ -80,7 +222,8 @@ function createTreeseedIntegratedDevPlan(options = {}) {
80
222
  const projectId = options.projectId ?? process.env.TREESEED_PROJECT_ID;
81
223
  const teamId = options.teamId ?? process.env.TREESEED_HOSTING_TEAM_ID;
82
224
  const mergedEnv = { ...process.env, ...options.env ?? {} };
83
- const apiBaseUrl = mergedEnv.TREESEED_API_BASE_URL?.trim() || `http://${apiHost}:${apiPort}`;
225
+ const apiBaseUrl = options.apiHost != null || options.apiPort != null ? `http://${apiHost}:${apiPort}` : mergedEnv.TREESEED_API_BASE_URL?.trim() || `http://${apiHost}:${apiPort}`;
226
+ const webUrl = surface === "integrated" || surface === "web" ? webUrlFor(webHost, webPort) : null;
84
227
  const sdkPackageRoot = resolvePackageRoot("@treeseed/sdk", tenantRoot);
85
228
  const coreRunTsPath = resolve(packageRoot, "scripts", "run-ts.mjs");
86
229
  const webEntrypoint = resolveNodeEntrypoint(
@@ -103,9 +246,11 @@ function createTreeseedIntegratedDevPlan(options = {}) {
103
246
  "src/services/worker.ts",
104
247
  "dist/services/worker.js"
105
248
  );
249
+ const watchEntries = watch ? createWatchEntries(tenantRoot, sdkPackageRoot) : [];
106
250
  const watchPaths = [
107
251
  resolve(packageRoot, existsSync(resolve(packageRoot, "src")) ? "src" : "dist"),
108
252
  resolve(tenantRoot, "src"),
253
+ resolve(tenantRoot, "public"),
109
254
  resolve(tenantRoot, "treeseed.site.yaml"),
110
255
  resolve(tenantRoot, "astro.config.ts")
111
256
  ];
@@ -115,9 +260,12 @@ function createTreeseedIntegratedDevPlan(options = {}) {
115
260
  TREESEED_API_BASE_URL: apiBaseUrl,
116
261
  TREESEED_MARKET_API_BASE_URL: mergedEnv.TREESEED_MARKET_API_BASE_URL ?? apiBaseUrl,
117
262
  TREESEED_PROJECT_ID: projectId ?? mergedEnv.TREESEED_PROJECT_ID,
118
- TREESEED_HOSTING_TEAM_ID: teamId ?? mergedEnv.TREESEED_HOSTING_TEAM_ID
263
+ TREESEED_HOSTING_TEAM_ID: teamId ?? mergedEnv.TREESEED_HOSTING_TEAM_ID,
264
+ TREESEED_API_D1_DATABASE_NAME: mergedEnv.TREESEED_API_D1_DATABASE_NAME ?? "SITE_DATA_DB",
265
+ SITE_DATA_DB: mergedEnv.SITE_DATA_DB ?? "SITE_DATA_DB",
266
+ TREESEED_API_D1_LOCAL_PERSIST_TO: mergedEnv.TREESEED_API_D1_LOCAL_PERSIST_TO ?? resolve(tenantRoot, ".wrangler", "state", "v3", "d1")
119
267
  };
120
- if (watch) {
268
+ if (watch && feedbackMode === "live") {
121
269
  sharedEnv.TREESEED_PUBLIC_DEV_WATCH_RELOAD = sharedEnv.TREESEED_PUBLIC_DEV_WATCH_RELOAD || "true";
122
270
  }
123
271
  const commands = [];
@@ -140,7 +288,7 @@ function createTreeseedIntegratedDevPlan(options = {}) {
140
288
  cwd: tenantRoot,
141
289
  env: {
142
290
  ...sharedEnv,
143
- PORT: sharedEnv.PORT ?? String(apiPort)
291
+ PORT: options.apiPort != null ? String(apiPort) : sharedEnv.PORT ?? String(apiPort)
144
292
  }
145
293
  });
146
294
  }
@@ -153,8 +301,8 @@ function createTreeseedIntegratedDevPlan(options = {}) {
153
301
  cwd: tenantRoot,
154
302
  env: {
155
303
  ...sharedEnv,
156
- PORT: sharedEnv.PORT ?? String(managerPort),
157
- TREESEED_MANAGER_BASE_URL: sharedEnv.TREESEED_MANAGER_BASE_URL ?? `http://${apiHost}:${managerPort}`
304
+ PORT: options.managerPort != null ? String(managerPort) : sharedEnv.PORT ?? String(managerPort),
305
+ TREESEED_MANAGER_BASE_URL: options.managerPort != null ? `http://${apiHost}:${managerPort}` : sharedEnv.TREESEED_MANAGER_BASE_URL ?? `http://${apiHost}:${managerPort}`
158
306
  }
159
307
  });
160
308
  }
@@ -168,10 +316,44 @@ function createTreeseedIntegratedDevPlan(options = {}) {
168
316
  env: sharedEnv
169
317
  });
170
318
  }
319
+ const readyChecks = commands.map((command) => {
320
+ if (command.id === "web") {
321
+ return {
322
+ id: command.id,
323
+ label: command.label,
324
+ required: true,
325
+ strategy: "http",
326
+ url: webUrl ?? void 0
327
+ };
328
+ }
329
+ if (command.id === "api") {
330
+ return {
331
+ id: command.id,
332
+ label: command.label,
333
+ required: true,
334
+ strategy: "http",
335
+ url: `${apiBaseUrl.replace(/\/$/u, "")}/readyz`
336
+ };
337
+ }
338
+ return {
339
+ id: command.id,
340
+ label: command.label,
341
+ required: false,
342
+ strategy: "process"
343
+ };
344
+ });
171
345
  return {
172
346
  surface,
347
+ setupMode,
348
+ feedbackMode,
349
+ openMode,
350
+ watch,
173
351
  tenantRoot,
174
352
  apiBaseUrl,
353
+ webUrl,
354
+ setupSteps: createSetupSteps(tenantRoot, setupMode, sdkPackageRoot, { commands }, sharedEnv),
355
+ readyChecks,
356
+ watchEntries,
175
357
  commands
176
358
  };
177
359
  }
@@ -194,8 +376,370 @@ function stopChildProcess(child, signal = "SIGTERM") {
194
376
  } catch {
195
377
  }
196
378
  }
379
+ function writeDevReloadStamp(projectRoot) {
380
+ const outputPath = resolve(projectRoot, DEV_RELOAD_FILE);
381
+ mkdirSync(dirname(outputPath), { recursive: true });
382
+ writeFileSync(
383
+ outputPath,
384
+ `${JSON.stringify(
385
+ {
386
+ buildId: `${Date.now()}`,
387
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
388
+ },
389
+ null,
390
+ 2
391
+ )}
392
+ `,
393
+ "utf8"
394
+ );
395
+ }
396
+ function shouldIgnoreWatchPath(filePath, rootPath) {
397
+ const rel = relative(rootPath, filePath);
398
+ if (!rel || rel.startsWith(`..${sep}`) || rel === "..") {
399
+ return false;
400
+ }
401
+ const normalized = rel.split(sep).join("/");
402
+ return normalized === ".git" || normalized.startsWith(".git/") || normalized === "node_modules" || normalized.startsWith("node_modules/") || normalized === ".astro" || normalized.startsWith(".astro/") || normalized === ".wrangler" || normalized.startsWith(".wrangler/") || normalized === ".local" || normalized.startsWith(".local/") || normalized === ".treeseed" || normalized.startsWith(".treeseed/") || normalized === "dist" || normalized.startsWith("dist/") || normalized === "coverage" || normalized.startsWith("coverage/") || normalized.startsWith("public/books/") || normalized.startsWith("public/__treeseed/");
403
+ }
404
+ function collectRootSnapshot(rootPath, snapshot) {
405
+ if (!existsSync(rootPath)) {
406
+ return;
407
+ }
408
+ const stats = statSync(rootPath);
409
+ if (stats.isFile()) {
410
+ snapshot.set(rootPath, `${stats.mtimeMs}:${stats.size}`);
411
+ return;
412
+ }
413
+ for (const entry of readdirSync(rootPath, { withFileTypes: true })) {
414
+ const fullPath = resolve(rootPath, entry.name);
415
+ if (shouldIgnoreWatchPath(fullPath, rootPath)) {
416
+ continue;
417
+ }
418
+ if (entry.isDirectory()) {
419
+ collectDirectorySnapshot(fullPath, rootPath, snapshot);
420
+ continue;
421
+ }
422
+ const entryStats = statSync(fullPath);
423
+ snapshot.set(fullPath, `${entryStats.mtimeMs}:${entryStats.size}`);
424
+ }
425
+ }
426
+ function collectDirectorySnapshot(directoryPath, rootPath, snapshot) {
427
+ if (shouldIgnoreWatchPath(directoryPath, rootPath)) {
428
+ return;
429
+ }
430
+ for (const entry of readdirSync(directoryPath, { withFileTypes: true })) {
431
+ const fullPath = resolve(directoryPath, entry.name);
432
+ if (shouldIgnoreWatchPath(fullPath, rootPath)) {
433
+ continue;
434
+ }
435
+ if (entry.isDirectory()) {
436
+ collectDirectorySnapshot(fullPath, rootPath, snapshot);
437
+ continue;
438
+ }
439
+ const stats = statSync(fullPath);
440
+ snapshot.set(fullPath, `${stats.mtimeMs}:${stats.size}`);
441
+ }
442
+ }
443
+ function collectSnapshot(entries) {
444
+ const snapshot = /* @__PURE__ */ new Map();
445
+ for (const entry of entries) {
446
+ collectRootSnapshot(entry.root, snapshot);
447
+ }
448
+ return snapshot;
449
+ }
450
+ function diffSnapshots(previousSnapshot, nextSnapshot) {
451
+ const changed = /* @__PURE__ */ new Set();
452
+ for (const [filePath, signature] of nextSnapshot.entries()) {
453
+ if (previousSnapshot.get(filePath) !== signature) {
454
+ changed.add(filePath);
455
+ }
456
+ }
457
+ for (const filePath of previousSnapshot.keys()) {
458
+ if (!nextSnapshot.has(filePath)) {
459
+ changed.add(filePath);
460
+ }
461
+ }
462
+ return [...changed];
463
+ }
464
+ function classifyChanges(changedPaths, watchEntries) {
465
+ function matchesEntry(filePath, entry) {
466
+ return filePath === entry.root || filePath.startsWith(`${entry.root}${sep}`);
467
+ }
468
+ return {
469
+ changedPaths,
470
+ sdkChanged: changedPaths.some(
471
+ (filePath) => watchEntries.some((entry) => entry.kind === "sdk" && matchesEntry(filePath, entry))
472
+ ),
473
+ packageChanged: changedPaths.some(
474
+ (filePath) => watchEntries.some((entry) => entry.kind === "package" && matchesEntry(filePath, entry))
475
+ ),
476
+ tenantChanged: changedPaths.some(
477
+ (filePath) => watchEntries.some((entry) => entry.kind === "tenant" && matchesEntry(filePath, entry))
478
+ )
479
+ };
480
+ }
481
+ function startPollingWatch({ watchEntries, onChange }) {
482
+ let previousSnapshot = collectSnapshot(watchEntries);
483
+ let queuedPaths = [];
484
+ let debounceTimer = null;
485
+ let running = false;
486
+ const intervalId = setInterval(() => {
487
+ const nextSnapshot = collectSnapshot(watchEntries);
488
+ const changedPaths = diffSnapshots(previousSnapshot, nextSnapshot);
489
+ previousSnapshot = nextSnapshot;
490
+ if (changedPaths.length === 0) {
491
+ return;
492
+ }
493
+ queuedPaths.push(...changedPaths);
494
+ if (debounceTimer) {
495
+ clearTimeout(debounceTimer);
496
+ }
497
+ debounceTimer = setTimeout(() => {
498
+ void flush();
499
+ }, WATCH_DEBOUNCE_MS);
500
+ }, WATCH_INTERVAL_MS);
501
+ async function flush() {
502
+ if (running || queuedPaths.length === 0) {
503
+ return;
504
+ }
505
+ const changedPaths = [...new Set(queuedPaths)];
506
+ queuedPaths = [];
507
+ running = true;
508
+ try {
509
+ await onChange(classifyChanges(changedPaths, watchEntries));
510
+ } finally {
511
+ running = false;
512
+ }
513
+ }
514
+ return () => {
515
+ if (debounceTimer) {
516
+ clearTimeout(debounceTimer);
517
+ }
518
+ clearInterval(intervalId);
519
+ };
520
+ }
521
+ function defaultWrite(line, stream) {
522
+ const target = stream === "stderr" ? process.stderr : process.stdout;
523
+ target.write(line);
524
+ }
525
+ function emitEvent(options, write, event, stream = event.type === "error" ? "stderr" : "stdout") {
526
+ if (options.json) {
527
+ write(`${JSON.stringify({ schemaVersion: 1, kind: "treeseed.dev.event", ...event })}
528
+ `, stream);
529
+ return;
530
+ }
531
+ const surface = event.surface ? `[${event.surface}]` : event.type === "setup" ? "[setup]" : "[dev]";
532
+ const message = event.message ?? event.detail ?? event.status ?? "";
533
+ write(`${surface} ${String(message)}
534
+ `, stream);
535
+ }
536
+ function writePlan(plan, options, write) {
537
+ if (options.json) {
538
+ write(`${JSON.stringify({ schemaVersion: 1, kind: "treeseed.dev.plan", ok: true, payload: plan }, null, 2)}
539
+ `, "stdout");
540
+ return;
541
+ }
542
+ write(`Treeseed dev plan
543
+ `, "stdout");
544
+ write(`surface: ${plan.surface}
545
+ `, "stdout");
546
+ write(`setup: ${plan.setupMode}
547
+ `, "stdout");
548
+ write(`feedback: ${plan.feedbackMode}
549
+ `, "stdout");
550
+ if (plan.webUrl) {
551
+ write(`web: ${plan.webUrl}
552
+ `, "stdout");
553
+ }
554
+ write(`api: ${plan.apiBaseUrl}
555
+ `, "stdout");
556
+ for (const command of plan.commands) {
557
+ write(`- ${command.id}: ${command.command} ${command.args.join(" ")}
558
+ `, "stdout");
559
+ }
560
+ }
561
+ function attachPrefixedLogReader(child, surface, options, write) {
562
+ function attach(stream, name) {
563
+ if (!stream || typeof stream.on !== "function") {
564
+ return;
565
+ }
566
+ let buffer = "";
567
+ stream.on("data", (chunk) => {
568
+ buffer += chunk.toString();
569
+ for (; ; ) {
570
+ const newlineIndex = buffer.indexOf("\n");
571
+ if (newlineIndex < 0) {
572
+ break;
573
+ }
574
+ const line = buffer.slice(0, newlineIndex);
575
+ buffer = buffer.slice(newlineIndex + 1);
576
+ if (options.json) {
577
+ emitEvent(options, write, { type: "log", surface, message: line, detail: { stream: name } }, name);
578
+ } else {
579
+ write(`[${surface}] ${line}
580
+ `, name);
581
+ }
582
+ }
583
+ });
584
+ stream.on("end", () => {
585
+ if (buffer.length > 0) {
586
+ if (options.json) {
587
+ emitEvent(options, write, { type: "log", surface, message: buffer, detail: { stream: name } }, name);
588
+ } else {
589
+ write(`[${surface}] ${buffer}
590
+ `, name);
591
+ }
592
+ buffer = "";
593
+ }
594
+ });
595
+ }
596
+ attach(child.stdout ?? null, "stdout");
597
+ attach(child.stderr ?? null, "stderr");
598
+ }
599
+ function runSetupStep(step, plan, deps) {
600
+ if (!step.command || !step.args) {
601
+ return {
602
+ ...step,
603
+ status: step.status === "failed" ? "failed" : "skipped"
604
+ };
605
+ }
606
+ const result = deps.spawnSync(step.command, step.args, {
607
+ cwd: plan.tenantRoot,
608
+ env: {
609
+ ...process.env,
610
+ ...plan.commands[0]?.env,
611
+ TREESEED_LOCAL_DEV_MODE: "cloudflare",
612
+ TREESEED_PUBLIC_DEV_WATCH_RELOAD: plan.feedbackMode === "live" ? "true" : process.env.TREESEED_PUBLIC_DEV_WATCH_RELOAD
613
+ },
614
+ encoding: "utf8"
615
+ });
616
+ if ((result.status ?? 1) === 0) {
617
+ return {
618
+ ...step,
619
+ status: "completed",
620
+ detail: [result.stdout, result.stderr].filter(Boolean).join("\n").trim() || step.detail
621
+ };
622
+ }
623
+ return {
624
+ ...step,
625
+ status: step.required ? "failed" : "degraded",
626
+ detail: [result.stdout, result.stderr].filter(Boolean).join("\n").trim() || `Exited with ${result.status ?? 1}.`
627
+ };
628
+ }
629
+ function runLocalSetup(plan, options, deps) {
630
+ const results = [];
631
+ if (plan.setupMode === "off") {
632
+ for (const step of plan.setupSteps) {
633
+ results.push(step);
634
+ emitEvent(options, deps.write, { type: "setup", status: step.status, message: `${step.label}: ${step.status}`, detail: step.detail });
635
+ }
636
+ return results;
637
+ }
638
+ for (const step of plan.setupSteps) {
639
+ let result = step;
640
+ if (step.id === "workspace-links") {
641
+ if (plan.setupMode === "check") {
642
+ result = { ...step, status: "skipped", detail: "Workspace links were checked in non-mutating mode." };
643
+ } else {
644
+ const workspaceRoot = findNearestTreeseedWorkspaceRoot(plan.tenantRoot);
645
+ if (workspaceRoot) {
646
+ const links = ensureLocalWorkspaceLinks(workspaceRoot, {
647
+ mode: "auto",
648
+ env: { ...process.env, ...plan.commands[0]?.env }
649
+ });
650
+ result = {
651
+ ...step,
652
+ status: links.issues.length > 0 ? "failed" : "completed",
653
+ detail: links.issues.length > 0 ? links.issues.join("; ") : `Verified ${links.links.length} workspace link${links.links.length === 1 ? "" : "s"}.`
654
+ };
655
+ } else {
656
+ result = { ...step, status: "skipped", detail: "No Treeseed workspace root found." };
657
+ }
658
+ }
659
+ } else if (step.id === "wrangler") {
660
+ const wrangler = resolveTreeseedToolBinary("wrangler", { env: { ...process.env, ...plan.commands[0]?.env } });
661
+ result = wrangler ? { ...step, status: "completed", detail: wrangler } : {
662
+ ...step,
663
+ status: step.required ? "failed" : "degraded",
664
+ detail: "Wrangler was not found. Run `npx trsd install --json` and retry `npx trsd dev`."
665
+ };
666
+ } else if (step.id === "mailpit") {
667
+ const docker = resolveTreeseedToolBinary("docker", { env: { ...process.env, ...plan.commands[0]?.env } });
668
+ result = docker ? { ...step, status: "completed", detail: `Docker detected at ${docker}; Mailpit remains optional for local dev.` } : { ...step, status: "degraded", detail: "Docker is unavailable, so Mailpit email previews are disabled." };
669
+ } else if (plan.setupMode === "check") {
670
+ result = { ...step, status: step.status === "failed" ? "failed" : "skipped", detail: step.detail ?? "Skipped in setup check mode." };
671
+ } else {
672
+ result = runSetupStep(step, plan, deps);
673
+ }
674
+ results.push(result);
675
+ emitEvent(options, deps.write, {
676
+ type: "setup",
677
+ status: result.status,
678
+ message: `${result.label}: ${result.status}`,
679
+ detail: result.detail
680
+ }, result.status === "failed" ? "stderr" : "stdout");
681
+ }
682
+ const failedRequired = results.some((step) => step.required && step.status === "failed");
683
+ if (plan.feedbackMode === "live" && plan.setupMode === "auto" && !failedRequired) {
684
+ writeDevReloadStamp(plan.tenantRoot);
685
+ emitEvent(options, deps.write, { type: "reload", message: "Wrote initial browser reload stamp." });
686
+ }
687
+ return results;
688
+ }
689
+ async function fetchOk(fetchFn, url, timeoutMs) {
690
+ const controller = new AbortController();
691
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
692
+ try {
693
+ const response = await fetchFn(url, { signal: controller.signal });
694
+ return response.ok;
695
+ } catch {
696
+ return false;
697
+ } finally {
698
+ clearTimeout(timeout);
699
+ }
700
+ }
701
+ async function waitForHttpReady(fetchFn, url, timeoutMs) {
702
+ const startedAt = Date.now();
703
+ while (Date.now() - startedAt < timeoutMs) {
704
+ if (await fetchOk(fetchFn, url, 2e3)) {
705
+ return true;
706
+ }
707
+ await delay(500);
708
+ }
709
+ return false;
710
+ }
711
+ async function defaultOpenBrowser(url) {
712
+ const platform = process.platform;
713
+ const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
714
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
715
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
716
+ child.unref();
717
+ }
718
+ function shouldOpenBrowser(plan) {
719
+ if (!plan.webUrl || plan.openMode === "off") {
720
+ return false;
721
+ }
722
+ if (plan.openMode === "on") {
723
+ return true;
724
+ }
725
+ return process.stdout.isTTY === true && process.env.CI !== "true";
726
+ }
727
+ function failedSetupMessage(failed) {
728
+ return [
729
+ `${failed.label} failed.`,
730
+ failed.detail ? String(failed.detail) : null,
731
+ "Run `npx trsd install --json` if a managed executable is missing, then retry `npx trsd dev --setup auto`."
732
+ ].filter(Boolean).join(" ");
733
+ }
197
734
  async function runTreeseedIntegratedDev(options = {}, deps = {}) {
198
735
  const tenantRoot = resolve(options.cwd ?? process.cwd());
736
+ const write = deps.write ?? defaultWrite;
737
+ const spawnProcess = deps.spawn ?? spawn;
738
+ const spawnSyncProcess = deps.spawnSync ?? spawnSync;
739
+ const onSignal = deps.onSignal ?? defaultSignalRegistrar;
740
+ const fetchFn = deps.fetch ?? globalThis.fetch.bind(globalThis);
741
+ const openBrowser = deps.openBrowser ?? defaultOpenBrowser;
742
+ const startWatch = deps.startWatch ?? startPollingWatch;
199
743
  const prepareEnvironment = deps.prepareEnvironment ?? defaultPrepareEnvironment;
200
744
  prepareEnvironment(tenantRoot);
201
745
  const plan = createTreeseedIntegratedDevPlan({
@@ -206,10 +750,22 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
206
750
  ...options.env ?? {}
207
751
  }
208
752
  });
209
- const spawnProcess = deps.spawn ?? spawn;
210
- const onSignal = deps.onSignal ?? defaultSignalRegistrar;
753
+ if (options.plan) {
754
+ writePlan(plan, options, write);
755
+ return 0;
756
+ }
757
+ const setupResults = runLocalSetup(plan, options, { spawnSync: spawnSyncProcess, write });
758
+ const failedSetup = setupResults.find((step) => step.status === "failed" && step.required);
759
+ if (failedSetup) {
760
+ emitEvent(options, write, { type: "error", message: failedSetupMessage(failedSetup), detail: failedSetup });
761
+ return 1;
762
+ }
211
763
  const children = /* @__PURE__ */ new Map();
764
+ const commandsById = new Map(plan.commands.map((command) => [command.id, command]));
765
+ const exited = /* @__PURE__ */ new Map();
766
+ let stopWatching = null;
212
767
  let settled = false;
768
+ let restarting = false;
213
769
  return await new Promise((resolveExitCode) => {
214
770
  const disposers = [
215
771
  onSignal("SIGINT", () => finalize(130)),
@@ -220,6 +776,10 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
220
776
  return;
221
777
  }
222
778
  settled = true;
779
+ if (stopWatching) {
780
+ stopWatching();
781
+ stopWatching = null;
782
+ }
223
783
  for (const [childId, child] of children.entries()) {
224
784
  if (childId !== originId) {
225
785
  stopChildProcess(child);
@@ -228,20 +788,149 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
228
788
  for (const dispose of disposers) {
229
789
  dispose();
230
790
  }
791
+ emitEvent(options, write, { type: "shutdown", exitCode, message: `Dev runtime stopped with exit code ${exitCode}.` }, exitCode === 0 ? "stdout" : "stderr");
231
792
  resolveExitCode(exitCode);
232
793
  }
233
- for (const command of plan.commands) {
794
+ function spawnCommand(command) {
795
+ emitEvent(options, write, {
796
+ type: "spawn",
797
+ surface: command.id,
798
+ command: command.command,
799
+ args: command.args,
800
+ message: `Starting ${command.label}.`
801
+ });
234
802
  const child = spawnProcess(command.command, command.args, {
235
803
  cwd: command.cwd,
236
804
  env: command.env,
237
- stdio: options.stdio ?? "inherit"
805
+ stdio: options.stdio ?? ["inherit", "pipe", "pipe"],
806
+ detached: false
238
807
  });
239
808
  children.set(command.id, child);
809
+ attachPrefixedLogReader(child, command.id, options, write);
240
810
  child.on("exit", (code, signal) => {
811
+ children.delete(command.id);
812
+ exited.set(command.id, { code, signal });
813
+ if (restarting || settled) {
814
+ return;
815
+ }
241
816
  const exitCode = signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : code ?? 0;
817
+ emitEvent(options, write, {
818
+ type: exitCode === 0 ? "shutdown" : "error",
819
+ surface: command.id,
820
+ exitCode,
821
+ signal,
822
+ message: `${command.label} exited with ${signal ?? exitCode}.`
823
+ }, exitCode === 0 ? "stdout" : "stderr");
242
824
  finalize(exitCode, command.id);
243
825
  });
826
+ return child;
827
+ }
828
+ async function restartCommand(id) {
829
+ const command = commandsById.get(id);
830
+ if (!command || settled) {
831
+ return;
832
+ }
833
+ const current = children.get(id);
834
+ restarting = true;
835
+ if (current) {
836
+ stopChildProcess(current);
837
+ await delay(350);
838
+ }
839
+ children.delete(id);
840
+ exited.delete(id);
841
+ restarting = false;
842
+ spawnCommand(command);
843
+ emitEvent(options, write, { type: "restart", surface: id, message: `Restarted ${command.label}.` });
844
+ }
845
+ async function waitForReadiness() {
846
+ const readinessTimeoutMs = options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS;
847
+ const processReadyGraceMs = options.processReadyGraceMs ?? DEFAULT_PROCESS_READY_GRACE_MS;
848
+ for (const check of plan.readyChecks) {
849
+ if (settled) {
850
+ return;
851
+ }
852
+ let ready = false;
853
+ if (check.strategy === "http" && check.url) {
854
+ ready = await waitForHttpReady(fetchFn, check.url, readinessTimeoutMs);
855
+ } else {
856
+ await delay(processReadyGraceMs);
857
+ ready = !exited.has(check.id);
858
+ }
859
+ if (settled) {
860
+ return;
861
+ }
862
+ if (!ready && check.required) {
863
+ emitEvent(options, write, {
864
+ type: "error",
865
+ surface: check.id,
866
+ url: check.url,
867
+ message: `${check.label} did not become ready${check.url ? ` at ${check.url}` : ""}.`
868
+ });
869
+ finalize(1, check.id);
870
+ return;
871
+ }
872
+ emitEvent(options, write, {
873
+ type: "ready",
874
+ surface: check.id,
875
+ status: ready ? "ready" : "degraded",
876
+ url: check.url,
877
+ message: `${check.label} is ${ready ? "ready" : "degraded"}.`
878
+ });
879
+ }
880
+ if (plan.webUrl) {
881
+ emitEvent(options, write, { type: "ready", url: plan.webUrl, message: `Treeseed dev ready at ${plan.webUrl}.` });
882
+ }
883
+ if (shouldOpenBrowser(plan)) {
884
+ try {
885
+ await openBrowser(plan.webUrl);
886
+ emitEvent(options, write, { type: "open", url: plan.webUrl, message: `Opened ${plan.webUrl}.` });
887
+ } catch (error) {
888
+ emitEvent(options, write, {
889
+ type: "open",
890
+ status: "degraded",
891
+ url: plan.webUrl,
892
+ message: `Could not open ${plan.webUrl}.`,
893
+ detail: error instanceof Error ? error.message : String(error)
894
+ });
895
+ }
896
+ }
897
+ }
898
+ for (const command of plan.commands) {
899
+ spawnCommand(command);
900
+ }
901
+ if (plan.watchEntries.length > 0 && plan.feedbackMode !== "off") {
902
+ stopWatching = startWatch({
903
+ watchEntries: plan.watchEntries,
904
+ onChange: async (change) => {
905
+ if (settled) {
906
+ return;
907
+ }
908
+ emitEvent(options, write, {
909
+ type: "restart",
910
+ message: `Detected ${change.changedPaths.length} change${change.changedPaths.length === 1 ? "" : "s"}.`,
911
+ detail: {
912
+ tenantChanged: change.tenantChanged,
913
+ packageChanged: change.packageChanged,
914
+ sdkChanged: change.sdkChanged
915
+ }
916
+ });
917
+ if (change.packageChanged || change.sdkChanged) {
918
+ await Promise.all([
919
+ restartCommand("api"),
920
+ restartCommand("manager"),
921
+ restartCommand("worker")
922
+ ]);
923
+ } else if (change.tenantChanged) {
924
+ await restartCommand("api");
925
+ }
926
+ if (plan.feedbackMode === "live") {
927
+ writeDevReloadStamp(plan.tenantRoot);
928
+ emitEvent(options, write, { type: "reload", message: "Wrote browser reload stamp." });
929
+ }
930
+ }
931
+ });
244
932
  }
933
+ void waitForReadiness();
245
934
  });
246
935
  }
247
936
  export {
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { runTreeseedIntegratedDev } from '../dev.js';
2
+ import { runTreeseedIntegratedDev, } from '../dev.js';
3
3
  const args = process.argv.slice(2);
4
4
  function readFlag(name) {
5
5
  return args.includes(name);
@@ -11,6 +11,14 @@ function readOption(name) {
11
11
  }
12
12
  return args[index + 1];
13
13
  }
14
+ function readNumberOption(name) {
15
+ const value = readOption(name);
16
+ if (!value) {
17
+ return undefined;
18
+ }
19
+ const parsed = Number(value);
20
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
21
+ }
14
22
  function parseSurface(value) {
15
23
  if (value === 'web'
16
24
  || value === 'api'
@@ -22,9 +30,37 @@ function parseSurface(value) {
22
30
  }
23
31
  return 'integrated';
24
32
  }
33
+ function parseSetupMode(value) {
34
+ if (value === 'auto' || value === 'check' || value === 'off') {
35
+ return value;
36
+ }
37
+ return undefined;
38
+ }
39
+ function parseFeedbackMode(value) {
40
+ if (value === 'live' || value === 'restart' || value === 'off') {
41
+ return value;
42
+ }
43
+ return undefined;
44
+ }
45
+ function parseOpenMode(value) {
46
+ if (value === 'auto' || value === 'on' || value === 'off') {
47
+ return value;
48
+ }
49
+ return undefined;
50
+ }
25
51
  const exitCode = await runTreeseedIntegratedDev({
26
52
  surface: parseSurface(readOption('--surface')),
27
53
  watch: readFlag('--watch'),
54
+ webHost: readOption('--host'),
55
+ webPort: readNumberOption('--port'),
56
+ apiHost: readOption('--api-host'),
57
+ apiPort: readNumberOption('--api-port'),
58
+ managerPort: readNumberOption('--manager-port'),
59
+ setupMode: parseSetupMode(readOption('--setup')),
60
+ feedbackMode: parseFeedbackMode(readOption('--feedback')),
61
+ openMode: parseOpenMode(readOption('--open')),
62
+ plan: readFlag('--plan'),
63
+ json: readFlag('--json'),
28
64
  projectId: readOption('--project-id'),
29
65
  teamId: readOption('--team-id'),
30
66
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treeseed/core",
3
- "version": "0.6.14",
3
+ "version": "0.6.16",
4
4
  "description": "Treeseed integrated platform starter for Astro/Starlight web runtimes and Hono API runtimes.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -76,7 +76,7 @@
76
76
  "@astrojs/sitemap": "3.7.0",
77
77
  "@astrojs/starlight": "0.37.6",
78
78
  "@tailwindcss/vite": "^4.1.4",
79
- "@treeseed/sdk": "0.6.12",
79
+ "@treeseed/sdk": "0.6.14",
80
80
  "astro": "^5.6.1",
81
81
  "esbuild": "^0.28.0",
82
82
  "hono": "^4.8.2",