@treeseed/core 0.8.19 → 0.9.4

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.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { spawn, spawnSync } from "node:child_process";
3
3
  import { createRequire } from "node:module";
4
4
  import { dirname, isAbsolute, resolve, sep } from "node:path";
@@ -32,7 +32,8 @@ const TREESEED_DEFAULT_LOCAL_SMTP_HOST = "127.0.0.1";
32
32
  const TREESEED_DEFAULT_LOCAL_SMTP_PORT = 1025;
33
33
  const TREESEED_DEFAULT_MAILPIT_UI_PORT = 8025;
34
34
  const DEV_RELOAD_FILE = "public/__treeseed/dev-reload.json";
35
- const DEV_RUNTIME_FILE = ".treeseed/generated/dev/runtime.json";
35
+ const DEV_RUNTIME_DIR = ".treeseed/generated/dev";
36
+ const DEV_RUNTIME_LEGACY_FILE = ".treeseed/generated/dev/runtime.json";
36
37
  const DEFAULT_READINESS_TIMEOUT_MS = 9e4;
37
38
  const DEFAULT_SETUP_STEP_TIMEOUT_MS = 3e5;
38
39
  const DEFAULT_PROCESS_READY_GRACE_MS = 1200;
@@ -132,10 +133,10 @@ function fallbackWebProviderFromDeployConfig(deployConfig) {
132
133
  const record = deployConfig && typeof deployConfig === "object" ? deployConfig : {};
133
134
  return normalizeProvider(record.providers?.deploy, "local");
134
135
  }
135
- function selectWebLocalRuntime(surfaceConfig, providerFallback = "local") {
136
+ function selectWebLocalRuntime(surfaceConfig, providerFallback = "local", overrideRuntime) {
136
137
  const record = surfaceConfig && typeof surfaceConfig === "object" ? surfaceConfig : {};
137
138
  const provider = normalizeProvider(record.provider, providerFallback);
138
- const requested = normalizeLocalRuntimeMode(record.local?.runtime);
139
+ const requested = overrideRuntime ?? normalizeLocalRuntimeMode(record.local?.runtime);
139
140
  if (provider === "cloudflare" && requested !== "local") {
140
141
  return {
141
142
  requested,
@@ -151,7 +152,7 @@ function selectWebLocalRuntime(surfaceConfig, providerFallback = "local") {
151
152
  requested,
152
153
  provider,
153
154
  selected: "astro-local",
154
- reason: requested === "local" ? "Configured to use the full local Astro runtime." : `Provider "${provider}" has no provider-local web runtime; using Astro local.`
155
+ reason: overrideRuntime === "local" ? "CLI override selected the full local Astro runtime for faster UI development." : requested === "local" ? "Configured to use the full local Astro runtime." : `Provider "${provider}" has no provider-local web runtime; using Astro local.`
155
156
  };
156
157
  }
157
158
  function loadDevDeployConfig(tenantRoot) {
@@ -271,6 +272,30 @@ function resolveSeededLocalProjectId(persistTo, projectSlug = "market") {
271
272
  db?.close();
272
273
  }
273
274
  }
275
+ function resolveSeededLocalTeamId(persistTo, projectId, teamSlug = "treeseed") {
276
+ const sqlitePath = resolveLocalD1SqlitePath(persistTo);
277
+ if (!sqlitePath) return null;
278
+ let db = null;
279
+ try {
280
+ db = new DatabaseSync(sqlitePath, { readOnly: true });
281
+ if (projectId) {
282
+ const projectRow = db.prepare(
283
+ `SELECT team_id FROM projects WHERE id = ? LIMIT 1`
284
+ ).get(projectId);
285
+ if (typeof projectRow?.team_id === "string" && projectRow.team_id.trim()) {
286
+ return projectRow.team_id.trim();
287
+ }
288
+ }
289
+ const teamRow = db.prepare(
290
+ `SELECT id FROM teams WHERE LOWER(slug) = LOWER(?) ORDER BY created_at ASC LIMIT 1`
291
+ ).get(teamSlug);
292
+ return typeof teamRow?.id === "string" && teamRow.id.trim() ? teamRow.id.trim() : null;
293
+ } catch {
294
+ return null;
295
+ } finally {
296
+ db?.close();
297
+ }
298
+ }
274
299
  function createTreeseedIntegratedDevResetPlan(options) {
275
300
  if (!options.enabled) {
276
301
  return null;
@@ -529,10 +554,13 @@ function createTreeseedIntegratedDevPlan(options = {}) {
529
554
  const agentPackageRoot = resolvePackageRootEnvOverride(mergedEnv, "TREESEED_AGENT_PACKAGE_ROOT", tenantRoot) ?? resolveOptionalPackageRoot("@treeseed/agent", tenantRoot);
530
555
  const cliPackageRoot = resolveOptionalPackageRoot("@treeseed/cli", tenantRoot);
531
556
  const deployConfig = loadDevDeployConfig(tenantRoot);
532
- const webLocalRuntime = selectWebLocalRuntime(deployConfig?.surfaces?.web, fallbackWebProviderFromDeployConfig(deployConfig));
557
+ const webLocalRuntime = selectWebLocalRuntime(deployConfig?.surfaces?.web, fallbackWebProviderFromDeployConfig(deployConfig), options.webRuntime);
533
558
  const usesCloudflareWebRuntime = webLocalRuntime.selected === "cloudflare-wrangler-local";
534
- const localD1PersistTo = mergedEnv.TREESEED_API_D1_LOCAL_PERSIST_TO ?? (usesCloudflareWebRuntime ? resolve(tenantRoot, ".treeseed", "generated", "environments", "local", ".wrangler", "state", "v3", "d1") : resolve(tenantRoot, ".wrangler", "state", "v3", "d1"));
559
+ const usesGeneratedLocalD1State = usesCloudflareWebRuntime || webLocalRuntime.provider === "cloudflare" || selectedCommandIds.some((id) => id !== "web");
560
+ const localD1PersistTo = mergedEnv.TREESEED_API_D1_LOCAL_PERSIST_TO ?? (usesGeneratedLocalD1State ? resolve(tenantRoot, ".treeseed", "generated", "environments", "local", ".wrangler", "state", "v3", "d1") : resolve(tenantRoot, ".wrangler", "state", "v3", "d1"));
535
561
  const projectId = options.projectId ?? mergedEnv.TREESEED_PROJECT_ID ?? resolveSeededLocalProjectId(localD1PersistTo);
562
+ const resolvedHostingTeamId = teamId ?? mergedEnv.TREESEED_HOSTING_TEAM_ID;
563
+ const resolvedTeamId = mergedEnv.TREESEED_TEAM_ID ?? resolvedHostingTeamId ?? resolveSeededLocalTeamId(localD1PersistTo, projectId ?? null);
536
564
  const webEntrypoint = resolveNodeEntrypoint(
537
565
  sdkPackageRoot,
538
566
  "scripts/tenant-astro-command.ts",
@@ -557,13 +585,14 @@ function createTreeseedIntegratedDevPlan(options = {}) {
557
585
  const resetRequested = options.reset === true;
558
586
  const sharedEnv = {
559
587
  ...mergedEnv,
560
- TREESEED_LOCAL_DEV_MODE: mergedEnv.TREESEED_LOCAL_DEV_MODE ?? "cloudflare",
588
+ TREESEED_LOCAL_DEV_MODE: usesCloudflareWebRuntime ? mergedEnv.TREESEED_LOCAL_DEV_MODE ?? "cloudflare" : void 0,
561
589
  TREESEED_SITE_URL: mergedEnv.TREESEED_SITE_URL ?? webUrl,
562
590
  BETTER_AUTH_URL: mergedEnv.BETTER_AUTH_URL ?? webUrl,
563
591
  TREESEED_API_BASE_URL: apiBaseUrl,
564
592
  TREESEED_MARKET_API_BASE_URL: mergedEnv.TREESEED_MARKET_API_BASE_URL ?? apiBaseUrl,
565
593
  TREESEED_PROJECT_ID: projectId ?? mergedEnv.TREESEED_PROJECT_ID,
566
- TREESEED_HOSTING_TEAM_ID: teamId ?? mergedEnv.TREESEED_HOSTING_TEAM_ID,
594
+ TREESEED_TEAM_ID: resolvedTeamId ?? mergedEnv.TREESEED_TEAM_ID,
595
+ TREESEED_HOSTING_TEAM_ID: resolvedHostingTeamId ?? mergedEnv.TREESEED_HOSTING_TEAM_ID,
567
596
  TREESEED_API_D1_DATABASE_NAME: mergedEnv.TREESEED_API_D1_DATABASE_NAME ?? "SITE_DATA_DB",
568
597
  SITE_DATA_DB: mergedEnv.SITE_DATA_DB ?? "SITE_DATA_DB",
569
598
  TREESEED_API_D1_LOCAL_PERSIST_TO: localD1PersistTo,
@@ -645,6 +674,7 @@ function createTreeseedIntegratedDevPlan(options = {}) {
645
674
  readyChecks,
646
675
  watchEntries,
647
676
  commands,
677
+ logPath: resolve(tenantRoot, ".treeseed", "logs", `dev-${runtimeScopeKey(commands.map((command) => command.id))}.jsonl`),
648
678
  localRuntimes: {
649
679
  ...commands.some((command) => command.id === "web") ? { web: webLocalRuntime } : {},
650
680
  ...commands.some((command) => command.id === "api") ? { api: nodeLocalRuntime("Treeseed API") } : {},
@@ -686,6 +716,29 @@ function defaultProcessIsAlive(pid) {
686
716
  return false;
687
717
  }
688
718
  }
719
+ function defaultInspectPortOwners(ports) {
720
+ const uniquePorts = [...new Set(ports.filter((port) => Number.isInteger(port) && port > 0))];
721
+ if (uniquePorts.length === 0) return [];
722
+ const result = spawnSync("ss", ["-ltnp"], { encoding: "utf8" });
723
+ if ((result.status ?? 1) !== 0) return [];
724
+ const lines = String(result.stdout ?? "").split(/\r?\n/u);
725
+ const owners = [];
726
+ for (const port of uniquePorts) {
727
+ const portPattern = new RegExp(`:${port}\\b`, "u");
728
+ for (const line of lines) {
729
+ if (!portPattern.test(line)) continue;
730
+ const pidMatch = line.match(/pid=(\d+)/u);
731
+ const nameMatch = line.match(/users:\(\("([^"]+)"/u);
732
+ owners.push({
733
+ port,
734
+ pid: pidMatch ? Number(pidMatch[1]) : null,
735
+ processName: nameMatch?.[1],
736
+ detail: line.trim()
737
+ });
738
+ }
739
+ }
740
+ return owners;
741
+ }
689
742
  function defaultRemovePath(path) {
690
743
  rmSync(path, { recursive: true, force: true });
691
744
  }
@@ -782,44 +835,131 @@ function resolveLocalMachineEnv(tenantRoot) {
782
835
  return {};
783
836
  }
784
837
  }
785
- function devRuntimeStatePath(tenantRoot) {
786
- return resolve(tenantRoot, DEV_RUNTIME_FILE);
838
+ function devRuntimeStateDir(tenantRoot) {
839
+ return resolve(tenantRoot, DEV_RUNTIME_DIR);
840
+ }
841
+ function devRuntimeStatePath(tenantRoot, key) {
842
+ return resolve(devRuntimeStateDir(tenantRoot), `runtime-${key}.json`);
843
+ }
844
+ function legacyDevRuntimeStatePath(tenantRoot) {
845
+ return resolve(tenantRoot, DEV_RUNTIME_LEGACY_FILE);
787
846
  }
788
- function readDevRuntimeState(tenantRoot) {
847
+ function runtimeScopeKey(commandIds) {
848
+ const selected = CANONICAL_COMMAND_IDS.filter((id) => commandIds.includes(id));
849
+ return selected.length > 0 ? selected.join("-") : "integrated";
850
+ }
851
+ function readDevRuntimeStateFile(path) {
789
852
  try {
790
- const parsed = JSON.parse(readFileSync(devRuntimeStatePath(tenantRoot), "utf8"));
853
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
791
854
  if (!Number.isInteger(parsed.pid) || typeof parsed.tenantRoot !== "string" || typeof parsed.startedAt !== "string") {
792
855
  return null;
793
856
  }
857
+ const commandIds = Array.isArray(parsed.commandIds) ? parsed.commandIds.filter((id) => CANONICAL_COMMAND_IDS.includes(id)) : void 0;
794
858
  return {
795
859
  pid: parsed.pid,
796
860
  tenantRoot: parsed.tenantRoot,
797
- startedAt: parsed.startedAt
861
+ startedAt: parsed.startedAt,
862
+ ...commandIds ? { commandIds } : {},
863
+ statePath: path
798
864
  };
799
865
  } catch {
800
866
  return null;
801
867
  }
802
868
  }
803
- function writeCurrentDevRuntimeState(tenantRoot) {
804
- const outputPath = devRuntimeStatePath(tenantRoot);
869
+ function listDevRuntimeStates(tenantRoot) {
870
+ const states = [];
871
+ const legacy = readDevRuntimeStateFile(legacyDevRuntimeStatePath(tenantRoot));
872
+ if (legacy) {
873
+ states.push(legacy);
874
+ }
875
+ try {
876
+ for (const entry of readdirSync(devRuntimeStateDir(tenantRoot))) {
877
+ if (!entry.startsWith("runtime-") || !entry.endsWith(".json")) {
878
+ continue;
879
+ }
880
+ const state = readDevRuntimeStateFile(resolve(devRuntimeStateDir(tenantRoot), entry));
881
+ if (state) {
882
+ states.push(state);
883
+ }
884
+ }
885
+ } catch {
886
+ }
887
+ return states;
888
+ }
889
+ function runtimeStateOverlaps(state, commandIds) {
890
+ if (!state.commandIds || state.commandIds.length === 0) {
891
+ return true;
892
+ }
893
+ return state.commandIds.some((id) => commandIds.includes(id));
894
+ }
895
+ function listLiveOverlappingDevRuntimeStates(tenantRoot, commandIds, processIsAlive) {
896
+ const live = [];
897
+ for (const state of listDevRuntimeStates(tenantRoot)) {
898
+ const statePath = state.statePath;
899
+ if (!statePath || !runtimeStateOverlaps(state, commandIds)) {
900
+ continue;
901
+ }
902
+ if (state.pid === process.pid) {
903
+ continue;
904
+ }
905
+ if (!processIsAlive(state.pid)) {
906
+ rmSync(statePath, { force: true });
907
+ continue;
908
+ }
909
+ live.push(state);
910
+ }
911
+ return live;
912
+ }
913
+ function parsePortFromUrl(value) {
914
+ if (!value) return null;
915
+ try {
916
+ const url = new URL(value);
917
+ const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
918
+ return Number.isInteger(port) && port > 0 ? port : null;
919
+ } catch {
920
+ return null;
921
+ }
922
+ }
923
+ function requiredDevPorts(plan) {
924
+ const ports = [];
925
+ for (const command of plan.commands) {
926
+ if (command.id === "web") {
927
+ const port = parsePortFromUrl(plan.webUrl ?? void 0);
928
+ if (port) ports.push(port);
929
+ }
930
+ if (command.id === "api") {
931
+ const port = parsePortFromUrl(plan.apiBaseUrl);
932
+ if (port) ports.push(port);
933
+ }
934
+ }
935
+ return [...new Set(ports)];
936
+ }
937
+ function formatPortOwner(owner) {
938
+ return `port ${owner.port}${owner.pid ? ` pid ${owner.pid}` : ""}${owner.processName ? ` (${owner.processName})` : ""}`;
939
+ }
940
+ function writeCurrentDevRuntimeState(tenantRoot, commandIds) {
941
+ const outputPath = devRuntimeStatePath(tenantRoot, runtimeScopeKey(commandIds));
805
942
  mkdirSync(dirname(outputPath), { recursive: true });
806
943
  writeFileSync(
807
944
  outputPath,
808
945
  `${JSON.stringify({
809
946
  pid: process.pid,
810
947
  tenantRoot,
948
+ commandIds,
811
949
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
812
950
  }, null, 2)}
813
951
  `,
814
952
  "utf8"
815
953
  );
954
+ return outputPath;
816
955
  }
817
- function removeCurrentDevRuntimeState(tenantRoot) {
818
- const state = readDevRuntimeState(tenantRoot);
956
+ function removeCurrentDevRuntimeState(tenantRoot, commandIds) {
957
+ const statePath = devRuntimeStatePath(tenantRoot, runtimeScopeKey(commandIds));
958
+ const state = readDevRuntimeStateFile(statePath);
819
959
  if (!state || state.pid !== process.pid) {
820
960
  return;
821
961
  }
822
- rmSync(devRuntimeStatePath(tenantRoot), { force: true });
962
+ rmSync(statePath, { force: true });
823
963
  }
824
964
  async function waitForProcessExit(pid, processIsAlive, timeoutMs) {
825
965
  const startedAt = Date.now();
@@ -831,38 +971,106 @@ async function waitForProcessExit(pid, processIsAlive, timeoutMs) {
831
971
  }
832
972
  return !processIsAlive(pid);
833
973
  }
834
- async function stopPreviousDevRuntime(tenantRoot, options, deps) {
835
- const state = readDevRuntimeState(tenantRoot);
836
- if (!state) {
837
- return;
838
- }
839
- const statePath = devRuntimeStatePath(tenantRoot);
840
- if (state.pid === process.pid) {
841
- return;
842
- }
843
- if (!deps.processIsAlive(state.pid)) {
974
+ async function stopPreviousDevRuntimes(tenantRoot, commandIds, options, deps) {
975
+ for (const state of listLiveOverlappingDevRuntimeStates(tenantRoot, commandIds, deps.processIsAlive)) {
976
+ const statePath = state.statePath;
977
+ if (!statePath) continue;
978
+ emitEvent(options, deps.write, {
979
+ type: "replace",
980
+ message: `Stopping previous Treeseed dev runtime (${state.pid}) before starting overlapping surfaces.`,
981
+ detail: { pid: state.pid, startedAt: state.startedAt, commandIds: state.commandIds ?? null }
982
+ });
983
+ try {
984
+ deps.killProcess(state.pid, "SIGTERM");
985
+ } catch {
986
+ }
987
+ if (await waitForProcessExit(state.pid, deps.processIsAlive, options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS)) {
988
+ rmSync(statePath, { force: true });
989
+ continue;
990
+ }
991
+ try {
992
+ deps.killProcess(state.pid, "SIGKILL");
993
+ } catch {
994
+ }
995
+ await waitForProcessExit(state.pid, deps.processIsAlive, DEFAULT_KILL_GRACE_MS);
844
996
  rmSync(statePath, { force: true });
845
- return;
846
997
  }
847
- emitEvent(options, deps.write, {
848
- type: "replace",
849
- message: `Stopping previous Treeseed dev runtime (${state.pid}) before starting a new one.`,
850
- detail: { pid: state.pid, startedAt: state.startedAt }
851
- });
852
- try {
853
- deps.killProcess(state.pid, "SIGTERM");
854
- } catch {
998
+ }
999
+ async function stopPortOwners(owners, options, deps) {
1000
+ const pids = [...new Set(owners.map((owner) => owner.pid).filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid))];
1001
+ for (const pid of pids) {
1002
+ emitEvent(options, deps.write, {
1003
+ type: "replace",
1004
+ message: `Stopping service on required dev port (pid ${pid}).`,
1005
+ detail: owners.filter((owner) => owner.pid === pid)
1006
+ });
1007
+ try {
1008
+ deps.killProcess(pid, "SIGTERM");
1009
+ } catch {
1010
+ }
1011
+ if (await waitForProcessExit(pid, deps.processIsAlive, options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS)) {
1012
+ continue;
1013
+ }
1014
+ try {
1015
+ deps.killProcess(pid, "SIGKILL");
1016
+ } catch {
1017
+ }
1018
+ await waitForProcessExit(pid, deps.processIsAlive, DEFAULT_KILL_GRACE_MS);
855
1019
  }
856
- if (await waitForProcessExit(state.pid, deps.processIsAlive, options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS)) {
857
- rmSync(statePath, { force: true });
858
- return;
1020
+ }
1021
+ async function prepareDevRuntimeSlots(plan, options, deps) {
1022
+ const commandIds = plan.commands.map((command) => command.id);
1023
+ const liveRuntimeStates = listLiveOverlappingDevRuntimeStates(plan.tenantRoot, commandIds, deps.processIsAlive);
1024
+ const ports = requiredDevPorts(plan);
1025
+ const portOwners = deps.inspectPortOwners(ports).filter((owner) => owner.pid !== process.pid);
1026
+ if (options.force !== true) {
1027
+ if (liveRuntimeStates.length > 0 || portOwners.length > 0) {
1028
+ emitEvent(options, deps.write, {
1029
+ type: "error",
1030
+ status: "existing-service",
1031
+ message: [
1032
+ "Treeseed dev found an existing runtime or service on a required port.",
1033
+ "Stop it first, or rerun with --force to terminate overlapping Treeseed dev services and port owners."
1034
+ ].join(" "),
1035
+ detail: {
1036
+ runtimes: liveRuntimeStates.map((state) => ({
1037
+ pid: state.pid,
1038
+ startedAt: state.startedAt,
1039
+ commandIds: state.commandIds ?? null,
1040
+ statePath: state.statePath ?? null
1041
+ })),
1042
+ ports: portOwners.map((owner) => ({ ...owner, label: formatPortOwner(owner) }))
1043
+ }
1044
+ });
1045
+ return false;
1046
+ }
1047
+ return true;
859
1048
  }
860
- try {
861
- deps.killProcess(state.pid, "SIGKILL");
862
- } catch {
1049
+ await stopPreviousDevRuntimes(plan.tenantRoot, commandIds, options, deps);
1050
+ if (portOwners.length > 0) {
1051
+ const ownersWithoutPid = portOwners.filter((owner) => owner.pid == null);
1052
+ if (ownersWithoutPid.length > 0) {
1053
+ emitEvent(options, deps.write, {
1054
+ type: "error",
1055
+ status: "existing-service",
1056
+ message: `Cannot force-stop required dev ports because some listeners did not expose process ids: ${ownersWithoutPid.map(formatPortOwner).join(", ")}.`,
1057
+ detail: ownersWithoutPid
1058
+ });
1059
+ return false;
1060
+ }
1061
+ await stopPortOwners(portOwners, options, deps);
863
1062
  }
864
- await waitForProcessExit(state.pid, deps.processIsAlive, DEFAULT_KILL_GRACE_MS);
865
- rmSync(statePath, { force: true });
1063
+ const remainingPortOwners = deps.inspectPortOwners(ports).filter((owner) => owner.pid !== process.pid);
1064
+ if (remainingPortOwners.length > 0) {
1065
+ emitEvent(options, deps.write, {
1066
+ type: "error",
1067
+ status: "existing-service",
1068
+ message: `Required dev ports are still occupied after --force: ${remainingPortOwners.map(formatPortOwner).join(", ")}.`,
1069
+ detail: remainingPortOwners
1070
+ });
1071
+ return false;
1072
+ }
1073
+ return true;
866
1074
  }
867
1075
  function emitEvent(options, write, event, stream = event.type === "error" ? "stderr" : "stdout") {
868
1076
  if (options.json) {
@@ -875,6 +1083,20 @@ function emitEvent(options, write, event, stream = event.type === "error" ? "std
875
1083
  write(`${surface} ${String(message)}
876
1084
  `, stream);
877
1085
  }
1086
+ function createDevLogWrite(baseWrite, logPath) {
1087
+ mkdirSync(dirname(logPath), { recursive: true });
1088
+ appendFileSync(logPath, `${JSON.stringify({
1089
+ schemaVersion: 1,
1090
+ kind: "treeseed.dev.log",
1091
+ type: "start",
1092
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
1093
+ })}
1094
+ `, "utf8");
1095
+ return (line, stream) => {
1096
+ baseWrite(line, stream);
1097
+ appendFileSync(logPath, line, "utf8");
1098
+ };
1099
+ }
878
1100
  function runTreeseedIntegratedDevReset(reset, options, deps) {
879
1101
  if (!reset?.enabled) {
880
1102
  return null;
@@ -979,6 +1201,8 @@ function writePlan(plan, options, write) {
979
1201
  `, "stdout");
980
1202
  }
981
1203
  write(`api: ${plan.apiBaseUrl}
1204
+ `, "stdout");
1205
+ write(`log: ${plan.logPath}
982
1206
  `, "stdout");
983
1207
  for (const [name, runtime] of Object.entries(plan.localRuntimes)) {
984
1208
  write(`runtime ${name}: ${runtime.selected} (${runtime.provider}, requested ${runtime.requested})${runtime.reason ? ` - ${runtime.reason}` : ""}
@@ -1216,7 +1440,7 @@ function failedSetupMessage(failed) {
1216
1440
  }
1217
1441
  async function runTreeseedIntegratedDev(options = {}, deps = {}) {
1218
1442
  const tenantRoot = resolve(options.cwd ?? process.cwd());
1219
- const write = deps.write ?? defaultWrite;
1443
+ let write = deps.write ?? defaultWrite;
1220
1444
  const spawnProcess = deps.spawn ?? spawn;
1221
1445
  const spawnSyncProcess = deps.spawnSync ?? spawnSync;
1222
1446
  const onSignal = deps.onSignal ?? defaultSignalRegistrar;
@@ -1228,6 +1452,7 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
1228
1452
  const prepareEnvironment = deps.prepareEnvironment ?? defaultPrepareEnvironment;
1229
1453
  const removePath = deps.removePath ?? defaultRemovePath;
1230
1454
  const stopMailpit = deps.stopMailpitContainers ?? stopKnownMailpitContainers;
1455
+ const inspectPortOwners = deps.inspectPortOwners ?? defaultInspectPortOwners;
1231
1456
  prepareEnvironment(tenantRoot);
1232
1457
  const plan = createTreeseedIntegratedDevPlan({
1233
1458
  ...options,
@@ -1241,8 +1466,15 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
1241
1466
  writePlan(plan, options, write);
1242
1467
  return 0;
1243
1468
  }
1244
- if (readDevRuntimeState(tenantRoot)) {
1245
- await stopPreviousDevRuntime(tenantRoot, options, { write, killProcess, processIsAlive });
1469
+ const commandIds = plan.commands.map((command) => command.id);
1470
+ write = createDevLogWrite(write, plan.logPath);
1471
+ emitEvent(options, write, {
1472
+ type: "log",
1473
+ message: `Writing Treeseed dev logs to ${plan.logPath}.`,
1474
+ detail: { logPath: plan.logPath }
1475
+ });
1476
+ if (!await prepareDevRuntimeSlots(plan, options, { write, killProcess, processIsAlive, inspectPortOwners })) {
1477
+ return 1;
1246
1478
  }
1247
1479
  const resetResults = runTreeseedIntegratedDevReset(plan.reset, options, {
1248
1480
  write,
@@ -1258,7 +1490,7 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
1258
1490
  });
1259
1491
  return 1;
1260
1492
  }
1261
- writeCurrentDevRuntimeState(tenantRoot);
1493
+ writeCurrentDevRuntimeState(tenantRoot, commandIds);
1262
1494
  const children = /* @__PURE__ */ new Map();
1263
1495
  const commandsById = new Map(plan.commands.map((command) => [command.id, command]));
1264
1496
  const requiredSurfaceIds = new Set(plan.readyChecks.filter((check) => check.required).map((check) => check.id));
@@ -1311,7 +1543,7 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
1311
1543
  for (const dispose of disposers) {
1312
1544
  dispose();
1313
1545
  }
1314
- removeCurrentDevRuntimeState(tenantRoot);
1546
+ removeCurrentDevRuntimeState(tenantRoot, commandIds);
1315
1547
  emitEvent(
1316
1548
  options,
1317
1549
  write,