@treeseed/core 0.6.15 → 0.6.17

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