@devrouter/cli 0.0.29 → 0.0.31

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/devrouter.js CHANGED
@@ -235,13 +235,15 @@ Config-level \`envMap\` on dependency references aliases per-dep vars to app-exp
235
235
 
236
236
  ## Workspace isolation (parallel git worktrees / agents)
237
237
 
238
- Run several worktrees of one repo in parallel without host/route collisions. A **workspace token** is a persisted identity spanning the DevPod id, devrouter routes, \`\${WORKSPACE}\` proxy upstreams, and devcontainer aliases.
238
+ Run several worktrees of one repo in parallel without host/route collisions. A **workspace token** spans the DevPod id, devrouter routes, \`\${WORKSPACE}\` proxy upstreams, and devcontainer aliases.
239
239
 
240
- - **Identity**: each linked worktree stores one authoritative identity in Git metadata. First use reuses an exact-path DevPod or derives a sanitized branch/path slug. Later flags or \`DEVROUTER_WORKSPACE\` may repeat the identity but cannot rename it. Ambiguous identities fail closed. The primary checkout remains non-namespaced.
240
+ - **Identity**: each managed linked worktree stores a local token in Git metadata plus a durable owner record in the repository's Git common directory. The record survives linked-worktree removal and binds the exact path to its DevPod ID. First use reuses an exact-path DevPod or derives a sanitized branch/path slug. Later flags or \`DEVROUTER_WORKSPACE\` may repeat the identity but cannot rename it. Ambiguous identities fail closed. The primary checkout remains non-namespaced.
241
241
  - **When active**: hosts auto-namespace (\`web.localhost\` \u2192 \`web.<ws>.localhost\`), \`\${WORKSPACE}\` in \`upstream\` is substituted with the token, and the docker \`router\` key is suffixed per workspace. The runtime config is computed in memory only \u2014 the committed \`.devrouter.yml\` is never rewritten.
242
242
  - **TLS**: namespaced hosts (\`web.<ws>.localhost\`) are not covered by the \`*.localhost\` wildcard; devrouter auto-extends the mkcert cert SANs for active hosts when TLS is enabled.
243
243
  - **devcontainer integration**: managed scaffolds list the base compose file, then \`\${localEnv:DEVCONTAINER_COMPOSE_OVERLAY:docker-compose.default.yml}\`; custom repositories may keep another default overlay. Selecting \`.devcontainer/docker-compose.devrouter.yml\` for linked worktrees must pass \`WORKSPACE\` and \`DEVROUTER_WORKSPACE\` across the combined base/overlay config and bind-mount \`\${DEVROUTER_GIT_COMMON_DIR}\` to the same absolute app-container path. The app exposes \`\${WORKSPACE}-app\`; the proxy uses \`upstream: \${WORKSPACE}-app:<port>\`.
244
- - **Lifecycle**: \`devrouter workspace up <branch>\` creates and starts a new worktree under the repository's ignored \`trees/<workspace>\` directory; \`devrouter workspace ensure .\` is the canonical startup/reconciliation command in an existing linked worktree; \`workspace ls\` lists identity/route state; \`workspace down\` serializes teardown with ensure. Ensure proves exact DevPod ownership, overlay/Git mounts, env, aliases, health, Git, HTTP route reachability, and unique running TCP upstream ownership before success, with one bounded recreate for stale state.
244
+ - **Lifecycle**: \`workspace up\` creates and starts a worktree; \`workspace ensure .\` starts/reconciles an existing linked worktree; \`workspace ls\` reports owner/Git/DevPod/route state; \`workspace stop\` stops DevPod/routes but keeps checkout, record, and data; full \`workspace down\` deletes runtime/routes and removes only a clean, unlocked worktree, then its record; \`down --keep-worktree\` keeps checkout and record. Dirty or locked full down fails before side effects.
245
+ - **Cleanup**: owner status is \`present\`, \`missing\`, \`locked\`, or \`conflict\`. \`workspace gc\` is a dry-run report; \`--yes\` deletes only exact ledger-owned missing/prunable resources and their records. GC never removes Git worktrees, branches, or prune state. Git has no worktree-removal hook.
246
+ - **Boundary**: workspace commands require Git. Normal config, app, status, and doctor flows remain usable from a \`.devrouter.yml\` folder without \`.git\`.
245
247
 
246
248
  ## Secret manager interop (Infisical/Doppler)
247
249
 
@@ -312,8 +314,10 @@ Run several worktrees of one repo in parallel without host/route collisions. A *
312
314
  - \`devrouter app rm <name> [--keep-config]\`: remove app entry (\`--keep-config\` frees only the live route/hostname, leaves \`.devrouter.yml\` untouched)
313
315
  - \`devrouter workspace up <branch> [--path <dir>] [--no-devpod] [--open]\`: create a worktree and start/prove it unless create-only mode is requested
314
316
  - \`devrouter workspace ensure [path] [--open]\`: start/reconcile and prove an existing linked worktree environment
315
- - \`devrouter workspace ls [--json]\`: list git worktrees with workspace token + route count
316
- - \`devrouter workspace down <workspace|branch> [--keep-worktree] [--keep-devpod]\`: free routes + stop devpod + remove worktree
317
+ - \`devrouter workspace ls [--json]\`: list ownership, Git, DevPod, route, path, and branch evidence
318
+ - \`devrouter workspace stop <workspace|branch>\`: stop DevPod and routes; preserve checkout, owner record, and data
319
+ - \`devrouter workspace down <workspace|branch> [--keep-worktree]\`: delete runtime/routes and optionally remove the clean worktree and record
320
+ - \`devrouter workspace gc [--json] [--yes]\`: report missing owners by default; apply exact eligible cleanup with \`--yes\`
317
321
 
318
322
  ## Validation workflow
319
323
 
@@ -1787,7 +1791,7 @@ function loadRepoConfig(repoPath) {
1787
1791
  const config = parseConfig(parsed ?? {}, configPath);
1788
1792
  const requiredVersion = config.devrouter?.version;
1789
1793
  if (requiredVersion && !hasWarnedVersionMismatch) {
1790
- const cliVersion = true ? "0.0.29" : "0.0.0-dev";
1794
+ const cliVersion = true ? "0.0.31" : "0.0.0-dev";
1791
1795
  if (cliVersion !== "0.0.0-dev" && compareSemver(requiredVersion, cliVersion) > 0) {
1792
1796
  hasWarnedVersionMismatch = true;
1793
1797
  process.stderr.write(
@@ -2287,11 +2291,13 @@ function buildOnboardingPrompt(options = {}) {
2287
2291
  "- `envMap` on dependency references (config-level) aliases per-dep vars after dependency env resolution. `envMap` fails fast when source var is missing.",
2288
2292
  "",
2289
2293
  "Workspace isolation (parallel git worktrees / agents):",
2290
- `- A "workspace token" lets several worktrees of one repo run in parallel without host/route collisions. Each linked worktree persists one authoritative identity spanning the DevPod id, devrouter routes, the \`${WORKSPACE_PLACEHOLDER}\` proxy upstream, and devcontainer aliases.`,
2291
- "- First use reuses an exact-path DevPod or derives a sanitized branch/path identity. Later flags or `DEVROUTER_WORKSPACE` may repeat but cannot rename the persisted identity. Ambiguous identities fail closed. The primary checkout stays non-namespaced.",
2294
+ `- A "workspace token" lets several worktrees of one repo run in parallel without host/route collisions. Each managed linked worktree has a local Git token plus a durable owner record in the repository's Git common directory spanning the DevPod id, devrouter routes, the \`${WORKSPACE_PLACEHOLDER}\` proxy upstream, and devcontainer aliases.`,
2295
+ "- The owner record survives linked-worktree removal and binds the exact path to its DevPod ID. First use reuses an exact-path DevPod or derives a sanitized branch/path identity. Later flags or `DEVROUTER_WORKSPACE` may repeat but cannot rename it. Ambiguous identities fail closed. The primary checkout stays non-namespaced.",
2292
2296
  `- When a workspace is active: hosts auto-namespace (\`web.localhost\` \u2192 \`web.<ws>.localhost\`), \`${WORKSPACE_PLACEHOLDER}\` in \`upstream\` is substituted with the token, and the docker \`router\` key is suffixed per workspace. The runtime config is computed in memory only \u2014 the committed \`.devrouter.yml\` is never rewritten.`,
2293
2297
  "- TLS: namespaced hosts (`web.<ws>.localhost`) are not covered by the `*.localhost` wildcard; devrouter auto-extends the mkcert cert SANs for active hosts when TLS is enabled.",
2294
- "- Lifecycle: `devrouter workspace up <branch>` creates and starts a new worktree; `devrouter workspace ensure .` is the canonical start/reconcile command inside an existing linked worktree; `workspace ls` reports state; `workspace down` serializes teardown with ensure.",
2298
+ "- Lifecycle: `workspace up` creates and starts a worktree; `workspace ensure .` starts/reconciles an existing linked worktree; `workspace ls` reports owner/Git/DevPod/route state; `workspace stop` preserves checkout, record, and data; full `workspace down` deletes runtime/routes and removes only a clean, unlocked worktree; `down --keep-worktree` retains checkout and record.",
2299
+ "- Owner status is `present`, `missing`, `locked`, or `conflict`. Dirty or locked full down fails before side effects. `workspace gc` is a dry run; only `--yes` deletes exact eligible missing resources and records, never Git worktrees, branches, or prune state.",
2300
+ "- Workspace commands require Git. Normal config, app, status, and doctor flows work from a `.devrouter.yml` folder without `.git`. Git has no worktree-removal hook; use `workspace ls`, doctor, or dry-run GC after out-of-band removal.",
2295
2301
  "- devcontainer integration: `devcontainer.json` lists the base compose file then `${localEnv:DEVCONTAINER_COMPOSE_OVERLAY:docker-compose.default.yml}`. The default overlay contains `services: {}`; `.devcontainer/docker-compose.devrouter.yml` passes `WORKSPACE` and `DEVROUTER_WORKSPACE` into the app and bind-mounts `${DEVROUTER_GIT_COMMON_DIR}` to the same absolute app-container path. Ensure proves exact DevPod ownership, overlay/Git mounts, env, aliases, health, Git, HTTP route reachability, and unique running TCP upstream ownership before success.",
2296
2302
  "",
2297
2303
  "Secret Manager Integration (config-based):",
@@ -2458,11 +2464,19 @@ var init_ai_prompt = __esm({
2458
2464
  },
2459
2465
  {
2460
2466
  command: "devrouter workspace ls [--json]",
2461
- purpose: "List git worktrees with their resolved workspace token and active route count."
2467
+ purpose: "List owner, Git, DevPod, route, path, and branch evidence for managed workspaces."
2462
2468
  },
2463
2469
  {
2464
- command: "devrouter workspace down <workspace|branch> [--keep-worktree] [--keep-devpod]",
2465
- purpose: "Free a workspace's routes, stop its devpod, and remove its worktree (routes are freed by state-file workspace tag, no config load)."
2470
+ command: "devrouter workspace stop <workspace|branch>",
2471
+ purpose: "Stop the exact DevPod and remove exact routes while preserving the worktree, owner record, and data."
2472
+ },
2473
+ {
2474
+ command: "devrouter workspace down <workspace|branch> [--keep-worktree]",
2475
+ purpose: "Delete exact runtime/routes and remove only a clean, unlocked worktree and its owner record; --keep-worktree preserves checkout and record."
2476
+ },
2477
+ {
2478
+ command: "devrouter workspace gc [--json] [--yes]",
2479
+ purpose: "Report missing owners without mutation by default; --yes deletes only exact eligible ledger-owned resources and records, never Git worktrees."
2466
2480
  }
2467
2481
  ];
2468
2482
  }
@@ -3396,12 +3410,6 @@ function reconcileRouteRunConflict(repoPath, app) {
3396
3410
  }
3397
3411
  }
3398
3412
  }
3399
- function isOrphanedWorkspaceProxyRoute(route) {
3400
- return route.mode === "proxy" && route.workspace !== void 0 && !import_node_fs9.default.existsSync(route.repoPath);
3401
- }
3402
- function findOrphanedWorkspaceProxyRoutes(routes = listHostRouteState()) {
3403
- return routes.filter((route) => isOrphanedWorkspaceProxyRoute(route));
3404
- }
3405
3413
  var import_node_fs9;
3406
3414
  var init_route_state = __esm({
3407
3415
  "src/core/route-state.ts"() {
@@ -4064,6 +4072,634 @@ var init_tool_diagnostics = __esm({
4064
4072
  }
4065
4073
  });
4066
4074
 
4075
+ // src/core/devpod-workspaces.ts
4076
+ function listDevpodWorkspaces() {
4077
+ const result = (0, import_node_child_process6.spawnSync)("devpod", ["list", "--output", "json"], { encoding: "utf-8" });
4078
+ if (result.status !== 0) {
4079
+ const details = [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
4080
+ throw new Error(`devpod list failed: ${details || "devpod is not installed or unavailable"}`);
4081
+ }
4082
+ let parsed;
4083
+ try {
4084
+ parsed = JSON.parse(result.stdout);
4085
+ } catch {
4086
+ throw new Error("devpod list returned invalid JSON.");
4087
+ }
4088
+ if (!Array.isArray(parsed)) {
4089
+ throw new Error("devpod list returned an unexpected response.");
4090
+ }
4091
+ return parsed.map((entry) => {
4092
+ const candidate = entry;
4093
+ if (typeof candidate.id !== "string" || !candidate.source || typeof candidate.source.localFolder !== "string") {
4094
+ throw new Error("devpod list returned a workspace without id/source.localFolder.");
4095
+ }
4096
+ return candidate;
4097
+ });
4098
+ }
4099
+ function inspectDevpodWorkspaceOwnership(workspaces, devpodId, worktreePath) {
4100
+ const idOwners = workspaces.filter((workspace) => workspace.id === devpodId);
4101
+ const pathOwners = workspaces.filter(
4102
+ (workspace) => sameWorkspacePath(workspace.source.localFolder, worktreePath)
4103
+ );
4104
+ const exact = idOwners.filter(
4105
+ (workspace) => sameWorkspacePath(workspace.source.localFolder, worktreePath)
4106
+ );
4107
+ if (idOwners.length > 1 || pathOwners.length > 1 || idOwners[0] && exact.length === 0 || pathOwners[0] && pathOwners[0].id !== devpodId) {
4108
+ return {
4109
+ status: "conflict",
4110
+ reason: `DevPod '${devpodId}' and worktree '${worktreePath}' do not have one exact owner.`
4111
+ };
4112
+ }
4113
+ return exact[0] ? { status: "owned", workspace: exact[0] } : { status: "absent" };
4114
+ }
4115
+ function selectDevpodWorkspace(workspaces, repoPath) {
4116
+ const matches = workspaces.filter(
4117
+ (workspace) => sameWorkspacePath(workspace.source.localFolder, repoPath)
4118
+ );
4119
+ if (matches.length > 1) {
4120
+ throw new Error(
4121
+ `Multiple DevPod workspaces reference '${repoPath}': ${matches.map((match) => match.id).join(", ")}`
4122
+ );
4123
+ }
4124
+ return matches[0];
4125
+ }
4126
+ function runDevpodWorkspaceAction(action2, devpodId) {
4127
+ const args = action2 === "delete" ? [action2, devpodId, "--ignore-not-found"] : [action2, devpodId];
4128
+ const result = (0, import_node_child_process6.spawnSync)("devpod", args, { encoding: "utf-8" });
4129
+ if (result.status !== 0) {
4130
+ const detail = [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
4131
+ throw new Error(`devpod ${action2} failed for '${devpodId}': ${detail || "unknown error"}`);
4132
+ }
4133
+ }
4134
+ var import_node_child_process6;
4135
+ var init_devpod_workspaces = __esm({
4136
+ "src/core/devpod-workspaces.ts"() {
4137
+ "use strict";
4138
+ import_node_child_process6 = require("child_process");
4139
+ init_workspace();
4140
+ }
4141
+ });
4142
+
4143
+ // src/core/workspace-ownership.ts
4144
+ function commandError(command, repoPath, stderr) {
4145
+ return new Error(
4146
+ `${command} failed for '${repoPath}': ${stderr?.trim() || "not a Git repository"}`
4147
+ );
4148
+ }
4149
+ function resolveGitCommonDir(repoPath) {
4150
+ const result = (0, import_node_child_process7.spawnSync)("git", ["-C", repoPath, "rev-parse", "--git-common-dir"], {
4151
+ encoding: "utf-8"
4152
+ });
4153
+ const output2 = result.stdout.trim();
4154
+ if (result.status !== 0 || !output2) {
4155
+ throw commandError("Could not resolve the Git common directory", repoPath, result.stderr);
4156
+ }
4157
+ return comparableWorkspacePath(import_node_path10.default.isAbsolute(output2) ? output2 : import_node_path10.default.resolve(repoPath, output2));
4158
+ }
4159
+ function listGitWorktrees(repoPath) {
4160
+ const result = (0, import_node_child_process7.spawnSync)("git", ["-C", repoPath, "worktree", "list", "--porcelain"], {
4161
+ encoding: "utf-8"
4162
+ });
4163
+ if (result.status !== 0) {
4164
+ throw commandError("git worktree list", repoPath, result.stderr);
4165
+ }
4166
+ const worktrees = [];
4167
+ let current = {};
4168
+ const finish = () => {
4169
+ if (!current.path) return;
4170
+ worktrees.push({
4171
+ path: comparableWorkspacePath(current.path),
4172
+ branch: current.branch,
4173
+ locked: current.locked ?? false,
4174
+ prunable: current.prunable ?? false
4175
+ });
4176
+ current = {};
4177
+ };
4178
+ for (const line of `${result.stdout}
4179
+ `.split("\n")) {
4180
+ if (line.startsWith("worktree ")) {
4181
+ finish();
4182
+ current.path = line.slice("worktree ".length).trim();
4183
+ } else if (line.startsWith("branch ")) {
4184
+ current.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, "");
4185
+ } else if (line === "locked" || line.startsWith("locked ")) {
4186
+ current.locked = true;
4187
+ } else if (line === "prunable" || line.startsWith("prunable ")) {
4188
+ current.prunable = true;
4189
+ } else if (line === "") {
4190
+ finish();
4191
+ }
4192
+ }
4193
+ return worktrees;
4194
+ }
4195
+ function ownershipDirectory(repoPath) {
4196
+ return import_node_path10.default.join(resolveGitCommonDir(repoPath), OWNERSHIP_DIR);
4197
+ }
4198
+ function validateWorkspace(value, label) {
4199
+ if (typeof value !== "string" || wsFromBranch(value) !== value) {
4200
+ throw new Error(`invalid workspace ownership ${label}`);
4201
+ }
4202
+ return value;
4203
+ }
4204
+ function validateTimestamp(value, label) {
4205
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) {
4206
+ throw new Error(`invalid workspace ownership ${label}`);
4207
+ }
4208
+ return value;
4209
+ }
4210
+ function validateRecord(value, expectedWorkspace) {
4211
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
4212
+ throw new Error("invalid workspace ownership record");
4213
+ }
4214
+ const candidate = value;
4215
+ if (candidate.version !== OWNERSHIP_VERSION) {
4216
+ throw new Error(`unsupported workspace ownership version '${String(candidate.version)}'`);
4217
+ }
4218
+ const workspace = validateWorkspace(candidate.workspace, "workspace");
4219
+ if (expectedWorkspace && workspace !== expectedWorkspace) {
4220
+ throw new Error(
4221
+ `workspace ownership file '${expectedWorkspace}' contains identity '${workspace}'`
4222
+ );
4223
+ }
4224
+ if (typeof candidate.worktreePath !== "string" || !import_node_path10.default.isAbsolute(candidate.worktreePath)) {
4225
+ throw new Error("invalid workspace ownership worktreePath");
4226
+ }
4227
+ if (candidate.branch !== null && typeof candidate.branch !== "string") {
4228
+ throw new Error("invalid workspace ownership branch");
4229
+ }
4230
+ const devpodId = validateWorkspace(candidate.devpodId, "devpodId");
4231
+ return {
4232
+ version: OWNERSHIP_VERSION,
4233
+ workspace,
4234
+ worktreePath: comparableWorkspacePath(candidate.worktreePath),
4235
+ branch: candidate.branch,
4236
+ devpodId,
4237
+ createdAt: validateTimestamp(candidate.createdAt, "createdAt"),
4238
+ updatedAt: validateTimestamp(candidate.updatedAt, "updatedAt")
4239
+ };
4240
+ }
4241
+ function readRecordFile(filePath, expectedWorkspace) {
4242
+ let parsed;
4243
+ try {
4244
+ parsed = JSON.parse(import_node_fs13.default.readFileSync(filePath, "utf-8"));
4245
+ } catch (error) {
4246
+ if (error instanceof SyntaxError) {
4247
+ throw new Error(`invalid workspace ownership JSON at '${filePath}'`);
4248
+ }
4249
+ throw error;
4250
+ }
4251
+ return validateRecord(parsed, expectedWorkspace);
4252
+ }
4253
+ function listWorkspaceOwnership(repoPath) {
4254
+ const directory = ownershipDirectory(repoPath);
4255
+ return listWorkspaceOwnershipInDirectory(directory);
4256
+ }
4257
+ function listWorkspaceOwnershipInDirectory(directory) {
4258
+ let entries;
4259
+ try {
4260
+ entries = import_node_fs13.default.readdirSync(directory, { withFileTypes: true });
4261
+ } catch (error) {
4262
+ if (error.code === "ENOENT") return [];
4263
+ throw error;
4264
+ }
4265
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
4266
+ const workspace = entry.name.slice(0, -".json".length);
4267
+ validateWorkspace(workspace, "filename");
4268
+ return readRecordFile(import_node_path10.default.join(directory, entry.name), workspace);
4269
+ });
4270
+ }
4271
+ function writeWorkspaceOwnershipInDirectory(directory, input2) {
4272
+ const workspace = validateWorkspace(input2.workspace, "workspace");
4273
+ const devpodId = validateWorkspace(input2.devpodId, "devpodId");
4274
+ const worktreePath = comparableWorkspacePath(input2.worktreePath);
4275
+ const filePath = import_node_path10.default.join(directory, `${workspace}.json`);
4276
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4277
+ const records = listWorkspaceOwnershipInDirectory(directory);
4278
+ const existing = records.find((record2) => record2.workspace === workspace);
4279
+ if (existing && !sameWorkspacePath(existing.worktreePath, worktreePath)) {
4280
+ throw new Error(
4281
+ `Workspace '${workspace}' already belongs to '${existing.worktreePath}', refusing '${worktreePath}'.`
4282
+ );
4283
+ }
4284
+ const pathOwner = records.find(
4285
+ (record2) => record2.workspace !== workspace && sameWorkspacePath(record2.worktreePath, worktreePath)
4286
+ );
4287
+ if (pathOwner) {
4288
+ throw new Error(
4289
+ `Worktree '${worktreePath}' is already owned by workspace '${pathOwner.workspace}'.`
4290
+ );
4291
+ }
4292
+ if (existing && existing.devpodId !== devpodId) {
4293
+ throw new Error(
4294
+ `Workspace '${workspace}' already owns DevPod '${existing.devpodId}', refusing '${devpodId}'.`
4295
+ );
4296
+ }
4297
+ const record = {
4298
+ version: OWNERSHIP_VERSION,
4299
+ workspace,
4300
+ worktreePath,
4301
+ branch: input2.branch ?? null,
4302
+ devpodId,
4303
+ createdAt: existing?.createdAt ?? validateTimestamp(now, "createdAt"),
4304
+ updatedAt: validateTimestamp(now, "updatedAt")
4305
+ };
4306
+ const tempPath = `${filePath}.${process.pid}.${(0, import_node_crypto3.randomUUID)()}.tmp`;
4307
+ try {
4308
+ import_node_fs13.default.writeFileSync(tempPath, `${JSON.stringify(record, null, 2)}
4309
+ `, {
4310
+ encoding: "utf-8",
4311
+ flag: "wx"
4312
+ });
4313
+ import_node_fs13.default.renameSync(tempPath, filePath);
4314
+ } finally {
4315
+ import_node_fs13.default.rmSync(tempPath, { force: true });
4316
+ }
4317
+ return record;
4318
+ }
4319
+ function removeWorkspaceOwnershipInDirectory(directory, workspace) {
4320
+ const filePath = import_node_path10.default.join(directory, `${validateWorkspace(workspace, "workspace")}.json`);
4321
+ try {
4322
+ import_node_fs13.default.rmSync(filePath);
4323
+ return true;
4324
+ } catch (error) {
4325
+ if (error.code === "ENOENT") return false;
4326
+ throw error;
4327
+ }
4328
+ }
4329
+ function sameOwnershipRecord(left, right) {
4330
+ return left.version === right.version && left.workspace === right.workspace && sameWorkspacePath(left.worktreePath, right.worktreePath) && left.branch === right.branch && left.devpodId === right.devpodId && left.createdAt === right.createdAt && left.updatedAt === right.updatedAt;
4331
+ }
4332
+ function removeWorkspaceOwnershipIfMatchesInDirectory(directory, expected) {
4333
+ const filePath = import_node_path10.default.join(
4334
+ directory,
4335
+ `${validateWorkspace(expected.workspace, "workspace")}.json`
4336
+ );
4337
+ let current;
4338
+ try {
4339
+ current = readRecordFile(filePath, expected.workspace);
4340
+ } catch (error) {
4341
+ if (error.code === "ENOENT") return "absent";
4342
+ throw error;
4343
+ }
4344
+ if (!sameOwnershipRecord(current, expected)) return "changed";
4345
+ import_node_fs13.default.rmSync(filePath);
4346
+ return "removed";
4347
+ }
4348
+ function withWorkspaceOwnershipTransaction(repoPath, operation) {
4349
+ const directory = ownershipDirectory(repoPath);
4350
+ import_node_fs13.default.mkdirSync(directory, { recursive: true });
4351
+ return withFileLockSync(
4352
+ import_node_path10.default.join(directory, ".lock"),
4353
+ { activity: "workspace ownership transaction", target: `'${repoPath}'`, waitMs: 5e3 },
4354
+ () => operation({
4355
+ list: () => listWorkspaceOwnershipInDirectory(directory),
4356
+ write: (input2) => writeWorkspaceOwnershipInDirectory(directory, input2),
4357
+ remove: (workspace) => removeWorkspaceOwnershipInDirectory(directory, workspace),
4358
+ removeIfMatches: (expected) => removeWorkspaceOwnershipIfMatchesInDirectory(directory, expected)
4359
+ })
4360
+ );
4361
+ }
4362
+ function writeWorkspaceOwnership(repoPath, input2) {
4363
+ return withWorkspaceOwnershipTransaction(repoPath, (transaction) => transaction.write(input2));
4364
+ }
4365
+ function removeWorkspaceOwnership(repoPath, workspace) {
4366
+ return withWorkspaceOwnershipTransaction(
4367
+ repoPath,
4368
+ (transaction) => transaction.remove(workspace)
4369
+ );
4370
+ }
4371
+ function inspectWorkspaceOwnership(record, worktrees, devpods) {
4372
+ const worktree = worktrees.find(
4373
+ (candidate) => sameWorkspacePath(candidate.path, record.worktreePath)
4374
+ );
4375
+ const devpodOwnership = devpods ? inspectDevpodWorkspaceOwnership(devpods, record.devpodId, record.worktreePath) : void 0;
4376
+ const devpodStatus = devpodOwnership?.status ?? "unknown";
4377
+ if (devpodStatus === "conflict") {
4378
+ return { ownerStatus: "conflict", devpodStatus, worktree };
4379
+ }
4380
+ if (worktree?.locked) {
4381
+ return { ownerStatus: "locked", devpodStatus, worktree };
4382
+ }
4383
+ if ((!worktree || worktree.prunable) && import_node_fs13.default.existsSync(record.worktreePath)) {
4384
+ return { ownerStatus: "conflict", devpodStatus, worktree };
4385
+ }
4386
+ if (!worktree || worktree.prunable) {
4387
+ return { ownerStatus: "missing", devpodStatus, worktree };
4388
+ }
4389
+ let persisted;
4390
+ try {
4391
+ persisted = readPersistedWorkspace(worktree.path);
4392
+ } catch {
4393
+ return { ownerStatus: "conflict", devpodStatus, worktree };
4394
+ }
4395
+ return {
4396
+ ownerStatus: persisted === record.workspace ? "present" : "conflict",
4397
+ devpodStatus,
4398
+ worktree
4399
+ };
4400
+ }
4401
+ function listMissingWorkspaceOwnership(repoPath) {
4402
+ const worktrees = listGitWorktrees(repoPath);
4403
+ return listWorkspaceOwnership(repoPath).filter(
4404
+ (record) => inspectWorkspaceOwnership(record, worktrees, void 0).ownerStatus === "missing"
4405
+ );
4406
+ }
4407
+ var import_node_child_process7, import_node_crypto3, import_node_fs13, import_node_path10, OWNERSHIP_VERSION, OWNERSHIP_DIR;
4408
+ var init_workspace_ownership = __esm({
4409
+ "src/core/workspace-ownership.ts"() {
4410
+ "use strict";
4411
+ import_node_child_process7 = require("child_process");
4412
+ import_node_crypto3 = require("crypto");
4413
+ import_node_fs13 = __toESM(require("fs"));
4414
+ import_node_path10 = __toESM(require("path"));
4415
+ init_devpod_workspaces();
4416
+ init_file_lock();
4417
+ init_workspace();
4418
+ OWNERSHIP_VERSION = 1;
4419
+ OWNERSHIP_DIR = import_node_path10.default.join("devrouter", "workspaces");
4420
+ }
4421
+ });
4422
+
4423
+ // src/core/workspace-gc.ts
4424
+ function routesForOwner(routes, workspace, worktreePath) {
4425
+ return routes.filter(
4426
+ (route) => route.workspace === workspace && sameWorkspacePath(route.repoPath, worktreePath)
4427
+ );
4428
+ }
4429
+ function inRepositoryWorkspaceScope(repoPath, worktreePath, livePaths) {
4430
+ if (livePaths.some((candidate) => sameWorkspacePath(candidate, worktreePath))) return true;
4431
+ const comparableRepo = comparableWorkspacePath(repoPath);
4432
+ const comparableWorktree = comparableWorkspacePath(worktreePath);
4433
+ const localRoot = import_node_path11.default.join(comparableRepo, "trees") + import_node_path11.default.sep;
4434
+ if (comparableWorktree.startsWith(localRoot)) return true;
4435
+ const legacyPrefix = `${import_node_path11.default.basename(comparableRepo)}-`;
4436
+ return import_node_path11.default.dirname(comparableWorktree) === import_node_path11.default.dirname(comparableRepo) && import_node_path11.default.basename(comparableWorktree).startsWith(legacyPrefix);
4437
+ }
4438
+ function previewActions(devpodStatus, routeCount, includeRecord) {
4439
+ const actions = [
4440
+ {
4441
+ resource: "devpod",
4442
+ status: devpodStatus === "owned" ? "would-delete" : "already-absent"
4443
+ },
4444
+ {
4445
+ resource: "routes",
4446
+ status: routeCount > 0 ? "would-delete" : "already-absent",
4447
+ count: routeCount
4448
+ }
4449
+ ];
4450
+ if (includeRecord) actions.push({ resource: "record", status: "would-delete" });
4451
+ return actions;
4452
+ }
4453
+ function ownedReason(ownerStatus) {
4454
+ switch (ownerStatus) {
4455
+ case "missing":
4456
+ return "Ownership record is missing from live Git registration or marked prunable.";
4457
+ case "present":
4458
+ return "Registered worktree is present.";
4459
+ case "locked":
4460
+ return "Git worktree is locked and protected from garbage collection.";
4461
+ case "conflict":
4462
+ return "Ownership conflicts with live Git, persisted token, or DevPod source evidence.";
4463
+ }
4464
+ }
4465
+ function ownedCandidate(record, worktrees, devpods, routes) {
4466
+ const status = inspectWorkspaceOwnership(record, worktrees, devpods);
4467
+ const routeCount = routesForOwner(routes, record.workspace, record.worktreePath).length;
4468
+ const eligible = status.ownerStatus === "missing" && status.devpodStatus !== "conflict";
4469
+ return {
4470
+ kind: "owned",
4471
+ workspace: record.workspace,
4472
+ devpodId: record.devpodId,
4473
+ worktreePath: record.worktreePath,
4474
+ ownerStatus: status.ownerStatus,
4475
+ devpodStatus: status.devpodStatus,
4476
+ routeCount,
4477
+ eligible,
4478
+ reason: ownedReason(status.ownerStatus),
4479
+ actions: eligible ? previewActions(status.devpodStatus, routeCount, true) : [
4480
+ { resource: "devpod", status: "skipped" },
4481
+ { resource: "routes", status: "skipped", count: routeCount },
4482
+ { resource: "record", status: "skipped" }
4483
+ ]
4484
+ };
4485
+ }
4486
+ function legacyCandidates(repoPath, records, worktrees, devpods, routes) {
4487
+ const livePaths = worktrees.map((worktree) => worktree.path);
4488
+ const evidence = /* @__PURE__ */ new Map();
4489
+ const isRecordedRoute = (workspace, worktreePath) => records.some(
4490
+ (record) => record.workspace === workspace && sameWorkspacePath(record.worktreePath, worktreePath)
4491
+ );
4492
+ const isRecordedDevpod = (devpodId, worktreePath) => records.some(
4493
+ (record) => record.devpodId === devpodId && sameWorkspacePath(record.worktreePath, worktreePath)
4494
+ );
4495
+ const key = (workspace, worktreePath) => `${workspace}\0${comparableWorkspacePath(worktreePath)}`;
4496
+ for (const devpod of devpods) {
4497
+ if (isRecordedDevpod(devpod.id, devpod.source.localFolder) || !inRepositoryWorkspaceScope(repoPath, devpod.source.localFolder, livePaths)) {
4498
+ continue;
4499
+ }
4500
+ evidence.set(key(devpod.id, devpod.source.localFolder), {
4501
+ workspace: devpod.id,
4502
+ worktreePath: comparableWorkspacePath(devpod.source.localFolder),
4503
+ devpodStatus: "owned",
4504
+ routeCount: 0
4505
+ });
4506
+ }
4507
+ for (const route of routes) {
4508
+ if (!route.workspace || isRecordedRoute(route.workspace, route.repoPath) || !inRepositoryWorkspaceScope(repoPath, route.repoPath, livePaths)) {
4509
+ continue;
4510
+ }
4511
+ const evidenceKey = key(route.workspace, route.repoPath);
4512
+ const existing = evidence.get(evidenceKey);
4513
+ evidence.set(evidenceKey, {
4514
+ workspace: route.workspace,
4515
+ worktreePath: comparableWorkspacePath(route.repoPath),
4516
+ devpodStatus: existing?.devpodStatus ?? "absent",
4517
+ routeCount: (existing?.routeCount ?? 0) + 1
4518
+ });
4519
+ }
4520
+ return Array.from(evidence.values()).map((entry) => ({
4521
+ kind: "legacy",
4522
+ ...entry,
4523
+ eligible: false,
4524
+ reason: "Legacy workspace has no ownership record; use explicit workspace down or manual cleanup.",
4525
+ actions: [
4526
+ { resource: "devpod", status: "skipped" },
4527
+ { resource: "routes", status: "skipped", count: entry.routeCount }
4528
+ ]
4529
+ }));
4530
+ }
4531
+ function revalidateCandidate(candidate, repoPath, transaction) {
4532
+ const record = transaction.list().find((entry) => entry.workspace === candidate.workspace);
4533
+ if (!record || !sameWorkspacePath(record.worktreePath, candidate.worktreePath) || record.devpodId !== candidate.devpodId) {
4534
+ return {
4535
+ status: "blocked",
4536
+ candidate: {
4537
+ ...candidate,
4538
+ eligible: false,
4539
+ reason: "Ownership record disappeared or changed before cleanup.",
4540
+ actions: [
4541
+ { resource: "devpod", status: "skipped" },
4542
+ { resource: "routes", status: "skipped", count: candidate.routeCount },
4543
+ { resource: "record", status: "skipped" }
4544
+ ]
4545
+ }
4546
+ };
4547
+ }
4548
+ const fresh = ownedCandidate(
4549
+ record,
4550
+ listGitWorktrees(repoPath),
4551
+ listDevpodWorkspaces(),
4552
+ listHostRouteState()
4553
+ );
4554
+ if (fresh.eligible) return { status: "ready", candidate: fresh, record };
4555
+ return {
4556
+ status: "blocked",
4557
+ candidate: {
4558
+ ...fresh,
4559
+ reason: `Ownership changed before cleanup: ${fresh.reason}`
4560
+ }
4561
+ };
4562
+ }
4563
+ function recheckDevpodOwnership(record) {
4564
+ const ownership = inspectDevpodWorkspaceOwnership(
4565
+ listDevpodWorkspaces(),
4566
+ record.devpodId,
4567
+ record.worktreePath
4568
+ );
4569
+ if (ownership.status === "conflict") {
4570
+ throw new Error(ownership.reason);
4571
+ }
4572
+ return ownership.status;
4573
+ }
4574
+ function failedAction(resource, error) {
4575
+ return {
4576
+ resource,
4577
+ status: "failed",
4578
+ error: error instanceof Error ? error.message : String(error)
4579
+ };
4580
+ }
4581
+ function applyCandidate(candidate, repoPath) {
4582
+ try {
4583
+ return withWorkspaceOwnershipTransaction(repoPath, (transaction) => {
4584
+ let revalidated;
4585
+ try {
4586
+ revalidated = revalidateCandidate(candidate, repoPath, transaction);
4587
+ } catch (error) {
4588
+ return {
4589
+ ...candidate,
4590
+ actions: [
4591
+ failedAction(
4592
+ "record",
4593
+ `Ownership revalidation failed: ${error instanceof Error ? error.message : String(error)}`
4594
+ )
4595
+ ]
4596
+ };
4597
+ }
4598
+ if (revalidated.status === "blocked") return revalidated.candidate;
4599
+ const { candidate: fresh, record } = revalidated;
4600
+ const actions = [];
4601
+ let devpodStatus;
4602
+ try {
4603
+ devpodStatus = recheckDevpodOwnership(record);
4604
+ if (devpodStatus === "owned") runDevpodWorkspaceAction("delete", record.devpodId);
4605
+ } catch (error) {
4606
+ return { ...fresh, actions: [failedAction("devpod", error)] };
4607
+ }
4608
+ actions.push({
4609
+ resource: "devpod",
4610
+ status: devpodStatus === "owned" ? "deleted" : "already-absent"
4611
+ });
4612
+ try {
4613
+ const routes = removeWorkspaceRoutesForWorktree(fresh.workspace, fresh.worktreePath);
4614
+ actions.push({
4615
+ resource: "routes",
4616
+ status: routes.length > 0 ? "deleted" : "already-absent",
4617
+ count: routes.length
4618
+ });
4619
+ } catch (error) {
4620
+ return { ...fresh, actions: [...actions, failedAction("routes", error)] };
4621
+ }
4622
+ try {
4623
+ const removal = transaction.removeIfMatches(record);
4624
+ if (removal === "changed") {
4625
+ throw new Error("Ownership record changed during cleanup; record was retained.");
4626
+ }
4627
+ actions.push({
4628
+ resource: "record",
4629
+ status: removal === "removed" ? "deleted" : "already-absent"
4630
+ });
4631
+ } catch (error) {
4632
+ return { ...fresh, actions: [...actions, failedAction("record", error)] };
4633
+ }
4634
+ return { ...fresh, actions };
4635
+ });
4636
+ } catch (error) {
4637
+ const detail = error instanceof Error ? error.message : String(error);
4638
+ return {
4639
+ ...candidate,
4640
+ actions: [failedAction("record", `Ownership transaction failed: ${detail}`)]
4641
+ };
4642
+ }
4643
+ }
4644
+ function summarizeGc(mode, candidates) {
4645
+ return {
4646
+ total: candidates.length,
4647
+ eligible: candidates.filter((candidate) => candidate.eligible).length,
4648
+ cleaned: mode === "apply" ? candidates.filter(
4649
+ (candidate) => candidate.actions.some(
4650
+ (action2) => action2.resource === "record" && (action2.status === "deleted" || action2.status === "already-absent")
4651
+ )
4652
+ ).length : 0,
4653
+ blocked: candidates.filter((candidate) => !candidate.eligible).length,
4654
+ errors: candidates.filter(
4655
+ (candidate) => candidate.actions.some((action2) => action2.status === "failed")
4656
+ ).length
4657
+ };
4658
+ }
4659
+ function inspectWorkspaceGc(repo) {
4660
+ const repoPath = comparableWorkspacePath(resolveRepoPath(repo));
4661
+ const worktrees = listGitWorktrees(repoPath);
4662
+ const records = listWorkspaceOwnership(repoPath);
4663
+ const devpods = listDevpodWorkspaces();
4664
+ const routes = listHostRouteState();
4665
+ const candidates = [
4666
+ ...records.map((record) => ownedCandidate(record, worktrees, devpods, routes)),
4667
+ ...legacyCandidates(repoPath, records, worktrees, devpods, routes)
4668
+ ];
4669
+ return {
4670
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4671
+ repoPath,
4672
+ mode: "dry-run",
4673
+ summary: summarizeGc("dry-run", candidates),
4674
+ candidates
4675
+ };
4676
+ }
4677
+ function applyWorkspaceGc(plan) {
4678
+ const candidates = plan.candidates.map(
4679
+ (candidate) => candidate.eligible ? applyCandidate(candidate, plan.repoPath) : candidate
4680
+ );
4681
+ return {
4682
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4683
+ repoPath: plan.repoPath,
4684
+ mode: "apply",
4685
+ summary: summarizeGc("apply", candidates),
4686
+ candidates
4687
+ };
4688
+ }
4689
+ var import_node_path11;
4690
+ var init_workspace_gc = __esm({
4691
+ "src/core/workspace-gc.ts"() {
4692
+ "use strict";
4693
+ import_node_path11 = __toESM(require("path"));
4694
+ init_devpod_workspaces();
4695
+ init_host_routes();
4696
+ init_repo_config();
4697
+ init_route_state();
4698
+ init_workspace();
4699
+ init_workspace_ownership();
4700
+ }
4701
+ });
4702
+
4067
4703
  // src/core/doctor.ts
4068
4704
  function asRecord2(value) {
4069
4705
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -4123,11 +4759,11 @@ function inspectPostgresCredentials(repoPath, config) {
4123
4759
  parseErrors.push(`${app.name}: ${composeFile} (${message})`);
4124
4760
  continue;
4125
4761
  }
4126
- if (!import_node_fs13.default.existsSync(absolutePath)) {
4762
+ if (!import_node_fs14.default.existsSync(absolutePath)) {
4127
4763
  continue;
4128
4764
  }
4129
4765
  try {
4130
- const raw = import_node_fs13.default.readFileSync(absolutePath, "utf-8");
4766
+ const raw = import_node_fs14.default.readFileSync(absolutePath, "utf-8");
4131
4767
  const parsed = import_yaml4.default.parse(raw);
4132
4768
  const root = asRecord2(parsed);
4133
4769
  const services = asRecord2(root?.services);
@@ -4390,7 +5026,7 @@ async function buildDoctorReport(options = {}) {
4390
5026
  const config = runtimeConfig.config;
4391
5027
  loadedConfig = config;
4392
5028
  loadedWorkspace = runtimeConfig.workspace;
4393
- const cliVersion = true ? "0.0.29" : "0.0.0-dev";
5029
+ const cliVersion = true ? "0.0.31" : "0.0.0-dev";
4394
5030
  const configVersion = config.devrouter?.version;
4395
5031
  if (configVersion && cliVersion !== "0.0.0-dev" && compareSemver(configVersion, cliVersion) > 0) {
4396
5032
  addCheck(checks, {
@@ -4420,9 +5056,9 @@ async function buildDoctorReport(options = {}) {
4420
5056
  (app) => app.docker.composeFiles.map((filePath) => ({
4421
5057
  app: app.name,
4422
5058
  filePath,
4423
- absolutePath: import_node_path10.default.resolve(repo.path, filePath)
5059
+ absolutePath: import_node_path12.default.resolve(repo.path, filePath)
4424
5060
  }))
4425
- ).filter((entry) => !import_node_fs13.default.existsSync(entry.absolutePath));
5061
+ ).filter((entry) => !import_node_fs14.default.existsSync(entry.absolutePath));
4426
5062
  addCheck(checks, {
4427
5063
  id: "repo.compose-files",
4428
5064
  level: missingComposeFiles.length === 0 ? "ok" : "error",
@@ -4465,8 +5101,8 @@ async function buildDoctorReport(options = {}) {
4465
5101
  const missingHostCwds = config.apps.filter((app) => app.runtime === "host").map((app) => ({
4466
5102
  app: app.name,
4467
5103
  cwd: app.hostRun.cwd,
4468
- absolutePath: import_node_path10.default.resolve(repo.path, app.hostRun.cwd)
4469
- })).filter((entry) => !import_node_fs13.default.existsSync(entry.absolutePath));
5104
+ absolutePath: import_node_path12.default.resolve(repo.path, app.hostRun.cwd)
5105
+ })).filter((entry) => !import_node_fs14.default.existsSync(entry.absolutePath));
4470
5106
  addCheck(checks, {
4471
5107
  id: "repo.host-cwd",
4472
5108
  level: missingHostCwds.length === 0 ? "ok" : "error",
@@ -4569,13 +5205,37 @@ async function buildDoctorReport(options = {}) {
4569
5205
  summary: staleCount === 0 ? "No stale host route entries detected." : `${staleCount} stale host route entr${staleCount === 1 ? "y" : "ies"} detected (dead PID).`,
4570
5206
  suggestion: staleCount === 0 ? void 0 : "Re-run the affected host app(s) or remove routes with: dev app rm <name> --repo <path> --keep-config"
4571
5207
  });
4572
- const orphanedWorkspaceRoutes = findOrphanedWorkspaceProxyRoutes();
4573
- addCheck(checks, {
4574
- id: "routes.orphaned-workspace-routes",
4575
- level: orphanedWorkspaceRoutes.length === 0 ? "ok" : "warn",
4576
- summary: orphanedWorkspaceRoutes.length === 0 ? "No orphaned workspace proxy routes detected." : `${orphanedWorkspaceRoutes.length} orphaned workspace proxy route entr${orphanedWorkspaceRoutes.length === 1 ? "y" : "ies"} detected (worktree removed without 'dev workspace down').`,
4577
- suggestion: orphanedWorkspaceRoutes.length === 0 ? void 0 : "Run: dev workspace down <workspace>"
4578
- });
5208
+ let gitWorkspaceRepo = false;
5209
+ try {
5210
+ resolveGitCommonDir(resolvedRepoPath);
5211
+ gitWorkspaceRepo = true;
5212
+ } catch {
5213
+ }
5214
+ if (gitWorkspaceRepo) {
5215
+ try {
5216
+ const gc = inspectWorkspaceGc(resolvedRepoPath);
5217
+ const needsAttention = gc.candidates.filter(
5218
+ (candidate) => candidate.eligible || candidate.kind === "legacy" || candidate.ownerStatus === "conflict"
5219
+ );
5220
+ addCheck(checks, {
5221
+ id: "workspace.ownership-cleanup",
5222
+ level: needsAttention.length === 0 ? "ok" : "warn",
5223
+ summary: needsAttention.length === 0 ? "No missing or legacy workspace ownership detected." : `${needsAttention.length} workspace ownership entr${needsAttention.length === 1 ? "y needs" : "ies need"} explicit cleanup review.`,
5224
+ details: needsAttention.length === 0 ? void 0 : needsAttention.map(
5225
+ (candidate) => `${candidate.workspace}: ${candidate.worktreePath} (${candidate.reason})`
5226
+ ).join("\n"),
5227
+ suggestion: needsAttention.length === 0 ? void 0 : `Run: dev workspace gc --repo ${resolvedRepoPath}`
5228
+ });
5229
+ } catch (error) {
5230
+ addCheck(checks, {
5231
+ id: "workspace.ownership-cleanup",
5232
+ level: "warn",
5233
+ summary: "Could not inspect workspace ownership cleanup state.",
5234
+ details: error instanceof Error ? error.message : String(error),
5235
+ suggestion: `Run: dev workspace gc --repo ${resolvedRepoPath}`
5236
+ });
5237
+ }
5238
+ }
4579
5239
  const summary = collectSummary(checks);
4580
5240
  const nextSteps = collectNextSteps(checks, statusNextSteps);
4581
5241
  return {
@@ -4586,12 +5246,12 @@ async function buildDoctorReport(options = {}) {
4586
5246
  nextSteps
4587
5247
  };
4588
5248
  }
4589
- var import_node_fs13, import_node_path10, import_yaml4, POSTGRES_DEFAULTS;
5249
+ var import_node_fs14, import_node_path12, import_yaml4, POSTGRES_DEFAULTS;
4590
5250
  var init_doctor = __esm({
4591
5251
  "src/core/doctor.ts"() {
4592
5252
  "use strict";
4593
- import_node_fs13 = __toESM(require("fs"));
4594
- import_node_path10 = __toESM(require("path"));
5253
+ import_node_fs14 = __toESM(require("fs"));
5254
+ import_node_path12 = __toESM(require("path"));
4595
5255
  import_yaml4 = __toESM(require("yaml"));
4596
5256
  init_docker();
4597
5257
  init_host_routes();
@@ -4604,6 +5264,8 @@ var init_doctor = __esm({
4604
5264
  init_status();
4605
5265
  init_tls();
4606
5266
  init_tool_diagnostics();
5267
+ init_workspace_gc();
5268
+ init_workspace_ownership();
4607
5269
  POSTGRES_DEFAULTS = {
4608
5270
  POSTGRES_USER: "prisma",
4609
5271
  POSTGRES_PASSWORD: "prisma",
@@ -4840,12 +5502,12 @@ function parseSsPortListeners(stdout, port) {
4840
5502
  return listeners;
4841
5503
  }
4842
5504
  function findPortListeners(port) {
4843
- const result = (0, import_node_child_process6.spawnSync)("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], {
5505
+ const result = (0, import_node_child_process8.spawnSync)("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], {
4844
5506
  encoding: "utf-8"
4845
5507
  });
4846
5508
  if (result.error && result.error.code === "ENOENT") {
4847
5509
  if (process.platform === "linux") {
4848
- const ssResult = (0, import_node_child_process6.spawnSync)("ss", ["-H", "-lntp", "-p"], { encoding: "utf-8" });
5510
+ const ssResult = (0, import_node_child_process8.spawnSync)("ss", ["-H", "-lntp", "-p"], { encoding: "utf-8" });
4849
5511
  if (ssResult.status === 0 && ssResult.stdout) {
4850
5512
  return parseSsPortListeners(ssResult.stdout, port);
4851
5513
  }
@@ -4874,11 +5536,11 @@ function findPortListeners(port) {
4874
5536
  };
4875
5537
  });
4876
5538
  }
4877
- var import_node_child_process6;
5539
+ var import_node_child_process8;
4878
5540
  var init_ports = __esm({
4879
5541
  "src/util/ports.ts"() {
4880
5542
  "use strict";
4881
- import_node_child_process6 = require("child_process");
5543
+ import_node_child_process8 = require("child_process");
4882
5544
  }
4883
5545
  });
4884
5546
 
@@ -5074,7 +5736,7 @@ async function runOpenCommand(name) {
5074
5736
  );
5075
5737
  return;
5076
5738
  }
5077
- const result = (0, import_node_child_process7.spawnSync)("open", [url], { encoding: "utf-8" });
5739
+ const result = (0, import_node_child_process9.spawnSync)("open", [url], { encoding: "utf-8" });
5078
5740
  if (result.status !== 0) {
5079
5741
  const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
5080
5742
  throw new Error(`Unable to open '${url}': ${details || "unknown error"}`);
@@ -5082,11 +5744,11 @@ async function runOpenCommand(name) {
5082
5744
  process.stdout.write(`Opened ${url}
5083
5745
  `);
5084
5746
  }
5085
- var import_node_child_process7;
5747
+ var import_node_child_process9;
5086
5748
  var init_open = __esm({
5087
5749
  "src/commands/open.ts"() {
5088
5750
  "use strict";
5089
- import_node_child_process7 = require("child_process");
5751
+ import_node_child_process9 = require("child_process");
5090
5752
  init_docker();
5091
5753
  init_host_routes();
5092
5754
  init_repo_config();
@@ -5109,7 +5771,7 @@ async function runLogsCommand(options) {
5109
5771
  const args = ["logs", "--tail", tail, ROUTER_CONTAINER_NAME];
5110
5772
  if (options.follow) {
5111
5773
  args.splice(1, 0, "-f");
5112
- const child = (0, import_node_child_process8.spawn)("docker", args, { stdio: "inherit" });
5774
+ const child = (0, import_node_child_process10.spawn)("docker", args, { stdio: "inherit" });
5113
5775
  const onSignal = () => {
5114
5776
  child.kill("SIGTERM");
5115
5777
  };
@@ -5123,17 +5785,17 @@ async function runLogsCommand(options) {
5123
5785
  });
5124
5786
  });
5125
5787
  } else {
5126
- const result = (0, import_node_child_process8.spawnSync)("docker", args, { stdio: "inherit" });
5788
+ const result = (0, import_node_child_process10.spawnSync)("docker", args, { stdio: "inherit" });
5127
5789
  if (result.status !== 0) {
5128
5790
  throw new Error("Failed to retrieve router logs.");
5129
5791
  }
5130
5792
  }
5131
5793
  }
5132
- var import_node_child_process8;
5794
+ var import_node_child_process10;
5133
5795
  var init_logs = __esm({
5134
5796
  "src/commands/logs.ts"() {
5135
5797
  "use strict";
5136
- import_node_child_process8 = require("child_process");
5798
+ import_node_child_process10 = require("child_process");
5137
5799
  init_docker();
5138
5800
  init_router();
5139
5801
  }
@@ -5173,17 +5835,17 @@ function asRecord3(value) {
5173
5835
  return value;
5174
5836
  }
5175
5837
  function readJson(filePath) {
5176
- if (!import_node_fs14.default.existsSync(filePath)) {
5838
+ if (!import_node_fs15.default.existsSync(filePath)) {
5177
5839
  return void 0;
5178
5840
  }
5179
5841
  try {
5180
- return JSON.parse(import_node_fs14.default.readFileSync(filePath, "utf-8"));
5842
+ return JSON.parse(import_node_fs15.default.readFileSync(filePath, "utf-8"));
5181
5843
  } catch {
5182
5844
  return void 0;
5183
5845
  }
5184
5846
  }
5185
5847
  function relative(repoPath, filePath) {
5186
- return import_node_path11.default.relative(repoPath, filePath) || ".";
5848
+ return import_node_path13.default.relative(repoPath, filePath) || ".";
5187
5849
  }
5188
5850
  function redactEnvAssignments(value) {
5189
5851
  return value.replace(
@@ -5224,7 +5886,7 @@ function inspectPackageManager(repoPath, pkg) {
5224
5886
  ["bun.lock", "bun"]
5225
5887
  ];
5226
5888
  for (const [fileName, name] of lockfiles) {
5227
- if (import_node_fs14.default.existsSync(import_node_path11.default.join(repoPath, fileName))) {
5889
+ if (import_node_fs15.default.existsSync(import_node_path13.default.join(repoPath, fileName))) {
5228
5890
  return { name, source: fileName };
5229
5891
  }
5230
5892
  }
@@ -5239,9 +5901,9 @@ function inspectNode(repoPath, pkg) {
5239
5901
  if (typeof engines?.node === "string") {
5240
5902
  return { version: engines.node, source: "package.json:engines.node" };
5241
5903
  }
5242
- const nvmrc = import_node_path11.default.join(repoPath, ".nvmrc");
5243
- if (import_node_fs14.default.existsSync(nvmrc)) {
5244
- const version = import_node_fs14.default.readFileSync(nvmrc, "utf-8").trim();
5904
+ const nvmrc = import_node_path13.default.join(repoPath, ".nvmrc");
5905
+ if (import_node_fs15.default.existsSync(nvmrc)) {
5906
+ const version = import_node_fs15.default.readFileSync(nvmrc, "utf-8").trim();
5245
5907
  return { version, source: ".nvmrc" };
5246
5908
  }
5247
5909
  return void 0;
@@ -5302,7 +5964,7 @@ function configuredComposeFiles(repoPath) {
5302
5964
  const files = config.apps.filter(
5303
5965
  (app) => app.runtime === "docker"
5304
5966
  ).flatMap((app) => app.docker.composeFiles).filter(
5305
- (fileName) => !import_node_path11.default.isAbsolute(fileName) && !import_node_path11.default.normalize(fileName).startsWith("..")
5967
+ (fileName) => !import_node_path13.default.isAbsolute(fileName) && !import_node_path13.default.normalize(fileName).startsWith("..")
5306
5968
  );
5307
5969
  return Array.from(new Set(files));
5308
5970
  } catch {
@@ -5320,7 +5982,7 @@ function composeFiles(repoPath) {
5320
5982
  ...configuredComposeFiles(repoPath)
5321
5983
  ];
5322
5984
  return Array.from(new Set(candidates)).filter(
5323
- (fileName) => import_node_fs14.default.existsSync(import_node_path11.default.join(repoPath, fileName))
5985
+ (fileName) => import_node_fs15.default.existsSync(import_node_path13.default.join(repoPath, fileName))
5324
5986
  );
5325
5987
  }
5326
5988
  function stringArray(value) {
@@ -5358,7 +6020,7 @@ function inspectServices(repoPath) {
5358
6020
  const services = [];
5359
6021
  for (const fileName of composeFiles(repoPath)) {
5360
6022
  try {
5361
- const parsed = import_yaml5.default.parse(import_node_fs14.default.readFileSync(import_node_path11.default.join(repoPath, fileName), "utf-8"));
6023
+ const parsed = import_yaml5.default.parse(import_node_fs15.default.readFileSync(import_node_path13.default.join(repoPath, fileName), "utf-8"));
5362
6024
  const serviceMap = asRecord3(asRecord3(parsed)?.services);
5363
6025
  for (const [name, value] of Object.entries(serviceMap ?? {})) {
5364
6026
  const service = asRecord3(value);
@@ -5393,8 +6055,8 @@ function inspectServices(repoPath) {
5393
6055
  return services;
5394
6056
  }
5395
6057
  function inspectEnvFiles(repoPath) {
5396
- const files = import_node_fs14.default.readdirSync(repoPath).filter((fileName) => /^\.env(\.|$)/.test(fileName)).sort().map((fileName) => {
5397
- const content = import_node_fs14.default.readFileSync(import_node_path11.default.join(repoPath, fileName), "utf-8");
6058
+ const files = import_node_fs15.default.readdirSync(repoPath).filter((fileName) => /^\.env(\.|$)/.test(fileName)).sort().map((fileName) => {
6059
+ const content = import_node_fs15.default.readFileSync(import_node_path13.default.join(repoPath, fileName), "utf-8");
5398
6060
  const names = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#") && line.includes("=")).map((line) => line.split("=")[0]?.trim()).filter((name) => Boolean(name)).sort();
5399
6061
  return { path: fileName, names };
5400
6062
  });
@@ -5408,18 +6070,18 @@ function inspectEnvFiles(repoPath) {
5408
6070
  };
5409
6071
  }
5410
6072
  function inspectDevcontainer(repoPath) {
5411
- const dir = import_node_path11.default.join(repoPath, ".devcontainer");
5412
- if (!import_node_fs14.default.existsSync(dir)) {
6073
+ const dir = import_node_path13.default.join(repoPath, ".devcontainer");
6074
+ if (!import_node_fs15.default.existsSync(dir)) {
5413
6075
  return { exists: false, files: [] };
5414
6076
  }
5415
6077
  return {
5416
6078
  exists: true,
5417
- files: import_node_fs14.default.readdirSync(dir).filter((fileName) => import_node_fs14.default.statSync(import_node_path11.default.join(dir, fileName)).isFile()).sort().map((fileName) => `.devcontainer/${fileName}`)
6079
+ files: import_node_fs15.default.readdirSync(dir).filter((fileName) => import_node_fs15.default.statSync(import_node_path13.default.join(dir, fileName)).isFile()).sort().map((fileName) => `.devcontainer/${fileName}`)
5418
6080
  };
5419
6081
  }
5420
6082
  function inspectDevrouter(repoPath) {
5421
6083
  const configPath = getRepoConfigPath(repoPath);
5422
- if (!import_node_fs14.default.existsSync(configPath)) {
6084
+ if (!import_node_fs15.default.existsSync(configPath)) {
5423
6085
  return {
5424
6086
  exists: false,
5425
6087
  configPath,
@@ -5463,15 +6125,15 @@ function inspectAgentGuidance(repoPath) {
5463
6125
  ["AGENTS.md", "agents"],
5464
6126
  ["CLAUDE.md", "claude"]
5465
6127
  ]) {
5466
- if (import_node_fs14.default.existsSync(import_node_path11.default.join(repoPath, fileName))) {
6128
+ if (import_node_fs15.default.existsSync(import_node_path13.default.join(repoPath, fileName))) {
5467
6129
  results.push({ path: fileName, kind });
5468
6130
  }
5469
6131
  }
5470
- const skillsDir = import_node_path11.default.join(repoPath, ".agents", "skills");
5471
- if (import_node_fs14.default.existsSync(skillsDir)) {
5472
- for (const name of import_node_fs14.default.readdirSync(skillsDir).sort()) {
5473
- const skillPath = import_node_path11.default.join(skillsDir, name, "SKILL.md");
5474
- if (import_node_fs14.default.existsSync(skillPath)) {
6132
+ const skillsDir = import_node_path13.default.join(repoPath, ".agents", "skills");
6133
+ if (import_node_fs15.default.existsSync(skillsDir)) {
6134
+ for (const name of import_node_fs15.default.readdirSync(skillsDir).sort()) {
6135
+ const skillPath = import_node_path13.default.join(skillsDir, name, "SKILL.md");
6136
+ if (import_node_fs15.default.existsSync(skillPath)) {
5475
6137
  results.push({ path: relative(repoPath, skillPath), kind: "skill" });
5476
6138
  }
5477
6139
  }
@@ -5514,7 +6176,7 @@ function buildIssues(report) {
5514
6176
  }
5515
6177
  function inspectRepo(options = {}) {
5516
6178
  const repoPath = resolveRepoPath(options.repo);
5517
- const pkg = readJson(import_node_path11.default.join(repoPath, "package.json"));
6179
+ const pkg = readJson(import_node_path13.default.join(repoPath, "package.json"));
5518
6180
  const scripts = inspectScripts(pkg);
5519
6181
  const reportWithoutIssues = {
5520
6182
  repoPath,
@@ -5533,12 +6195,12 @@ function inspectRepo(options = {}) {
5533
6195
  issues: buildIssues(reportWithoutIssues)
5534
6196
  };
5535
6197
  }
5536
- var import_node_fs14, import_node_path11, import_yaml5;
6198
+ var import_node_fs15, import_node_path13, import_yaml5;
5537
6199
  var init_repo_inspect = __esm({
5538
6200
  "src/core/repo-inspect.ts"() {
5539
6201
  "use strict";
5540
- import_node_fs14 = __toESM(require("fs"));
5541
- import_node_path11 = __toESM(require("path"));
6202
+ import_node_fs15 = __toESM(require("fs"));
6203
+ import_node_path13 = __toESM(require("path"));
5542
6204
  import_yaml5 = __toESM(require("yaml"));
5543
6205
  init_repo_config();
5544
6206
  }
@@ -5716,7 +6378,7 @@ function requiredFileChecks(repoPath) {
5716
6378
  ".devcontainer/docker-compose.yml",
5717
6379
  ".devrouter.yml"
5718
6380
  ];
5719
- const missing = required.filter((fileName) => !import_node_fs15.default.existsSync(import_node_path12.default.join(repoPath, fileName)));
6381
+ const missing = required.filter((fileName) => !import_node_fs16.default.existsSync(import_node_path14.default.join(repoPath, fileName)));
5720
6382
  return {
5721
6383
  id: "repo.devcontainer.verify-files",
5722
6384
  level: missing.length === 0 ? "ok" : "error",
@@ -5781,7 +6443,7 @@ function routeUrl2(host) {
5781
6443
  return `${isTLSEnabled() ? "https" : "http"}://${host}`;
5782
6444
  }
5783
6445
  function curlRoute(host) {
5784
- const result = (0, import_node_child_process9.spawnSync)("curl", ["-k", "-fsS", "--max-time", "5", routeUrl2(host)], {
6446
+ const result = (0, import_node_child_process11.spawnSync)("curl", ["-k", "-fsS", "--max-time", "5", routeUrl2(host)], {
5785
6447
  encoding: "utf-8"
5786
6448
  });
5787
6449
  if (result.status === 0) {
@@ -5955,13 +6617,13 @@ async function verifyDevcontainer(options = {}) {
5955
6617
  nextSteps: collectNextSteps3(checks)
5956
6618
  };
5957
6619
  }
5958
- var import_node_child_process9, import_node_fs15, import_node_path12;
6620
+ var import_node_child_process11, import_node_fs16, import_node_path14;
5959
6621
  var init_devcontainer_verify = __esm({
5960
6622
  "src/core/devcontainer-verify.ts"() {
5961
6623
  "use strict";
5962
- import_node_child_process9 = require("child_process");
5963
- import_node_fs15 = __toESM(require("fs"));
5964
- import_node_path12 = __toESM(require("path"));
6624
+ import_node_child_process11 = require("child_process");
6625
+ import_node_fs16 = __toESM(require("fs"));
6626
+ import_node_path14 = __toESM(require("path"));
5965
6627
  init_capabilities();
5966
6628
  init_concurrency();
5967
6629
  init_doctor();
@@ -5984,27 +6646,39 @@ function majorVersion(value, fallback) {
5984
6646
  function devrouterVersion(value) {
5985
6647
  return value && /^\d+\.\d+\.\d+$/.test(value) ? value : DEFAULT_DEVROUTER_VERSION;
5986
6648
  }
5987
- function inferDevScript(repo) {
6649
+ function escapeExtendedRegex(value) {
6650
+ return value.replace(/[\\.^$|?*+()[\]{}]/g, "\\$&");
6651
+ }
6652
+ function inferDevProcess(repo) {
5988
6653
  const script = repo.scripts.find((entry) => entry.name === "dev") ?? repo.scripts.find((entry) => entry.name.endsWith(":dev"));
5989
- if (!script) {
5990
- return "pnpm dev";
5991
- }
5992
- return script.name === "dev" ? "pnpm dev" : `pnpm run -- ${shellSingleQuote(script.name)}`;
6654
+ const scriptName = script?.name ?? "dev";
6655
+ return {
6656
+ command: scriptName === "dev" ? "pnpm dev" : `pnpm run -- ${shellSingleQuote(scriptName)}`,
6657
+ match: `pnpm(\\.cjs)? .*${escapeExtendedRegex(scriptName)}`
6658
+ };
5993
6659
  }
5994
6660
  function inferPort2(repo) {
5995
6661
  return repo.apps.find((app) => app.port)?.port ?? 3e3;
5996
6662
  }
5997
- function renderDockerfile(nodeMajor, pnpmVersion) {
6663
+ function renderDockerfile(nodeMajor, pnpmVersion, version) {
5998
6664
  const pnpmPackageSpec = `pnpm@${pnpmVersion}`;
6665
+ const devrouterPackageSpec = `@devrouter/cli@${version}`;
6666
+ const devrouterTarball = `devrouter-cli-${version}.tgz`;
5999
6667
  return `# ${MANAGED_MARKER}
6000
6668
  FROM node:${nodeMajor}-bookworm-slim
6001
6669
 
6002
6670
  RUN apt-get update \\
6003
- && apt-get install -y --no-install-recommends git ca-certificates curl procps openssl \\
6671
+ && apt-get install -y --no-install-recommends git ca-certificates curl procps openssl tar util-linux \\
6004
6672
  && rm -rf /var/lib/apt/lists/*
6005
6673
 
6006
6674
  RUN npm install -g ${shellSingleQuote(pnpmPackageSpec)}
6007
6675
 
6676
+ RUN npm pack --silent ${shellSingleQuote(devrouterPackageSpec)} \\
6677
+ && tar -xzf ${shellSingleQuote(devrouterTarball)} --strip-components=2 \\
6678
+ -C /usr/local/bin package/bin/devrouter-process \\
6679
+ && chmod +x /usr/local/bin/devrouter-process \\
6680
+ && rm ${shellSingleQuote(devrouterTarball)}
6681
+
6008
6682
  WORKDIR /workspaces/app
6009
6683
  `;
6010
6684
  }
@@ -6140,7 +6814,7 @@ fi
6140
6814
  function shellSingleQuote(value) {
6141
6815
  return `'${value.replace(/'/g, `'"'"'`)}'`;
6142
6816
  }
6143
- function renderPostStart(devCommand) {
6817
+ function renderPostStart(devProcess) {
6144
6818
  return `#!/usr/bin/env bash
6145
6819
  # ${MANAGED_MARKER}
6146
6820
  set -euo pipefail
@@ -6151,11 +6825,11 @@ set -a
6151
6825
  . .devcontainer/devcontainer.env
6152
6826
  set +a
6153
6827
 
6154
- if pgrep -f ${shellSingleQuote(devCommand)} >/dev/null 2>&1; then
6155
- exit 0
6156
- fi
6157
-
6158
- setsid bash -lc ${shellSingleQuote(devCommand)} >/tmp/devrouter-app.log 2>&1 </dev/null &
6828
+ devrouter-process ensure \\
6829
+ --name app \\
6830
+ --match ${shellSingleQuote(devProcess.match)} \\
6831
+ --log /tmp/devrouter-app.log \\
6832
+ -- bash -lc ${shellSingleQuote(devProcess.command)}
6159
6833
  `;
6160
6834
  }
6161
6835
  function renderDevrouter(projectName, port, version) {
@@ -6268,11 +6942,11 @@ function packageManagerIssues(repo) {
6268
6942
  }
6269
6943
  function plannedFiles(repoPath, version) {
6270
6944
  const repo = inspectRepo({ repo: repoPath });
6271
- const projectName = sanitizeProjectName(import_node_path13.default.basename(repo.repoPath));
6945
+ const projectName = sanitizeProjectName(import_node_path15.default.basename(repo.repoPath));
6272
6946
  const nodeMajor = majorVersion(repo.node?.version, "24");
6273
6947
  const pnpmVersion = repo.packageManager?.name === "pnpm" && repo.packageManager.version ? repo.packageManager.version : DEFAULT_PNPM_VERSION;
6274
6948
  const port = inferPort2(repo);
6275
- const devCommand = inferDevScript(repo);
6949
+ const devProcess = inferDevProcess(repo);
6276
6950
  const issues = packageManagerIssues(repo);
6277
6951
  return {
6278
6952
  projectName,
@@ -6280,7 +6954,7 @@ function plannedFiles(repoPath, version) {
6280
6954
  files: [
6281
6955
  {
6282
6956
  relativePath: ".devcontainer/Dockerfile",
6283
- content: renderDockerfile(nodeMajor, pnpmVersion)
6957
+ content: renderDockerfile(nodeMajor, pnpmVersion, version)
6284
6958
  },
6285
6959
  { relativePath: ".devcontainer/docker-compose.yml", content: renderCompose(projectName) },
6286
6960
  {
@@ -6304,7 +6978,7 @@ function plannedFiles(repoPath, version) {
6304
6978
  },
6305
6979
  {
6306
6980
  relativePath: ".devcontainer/post-start.sh",
6307
- content: renderPostStart(devCommand),
6981
+ content: renderPostStart(devProcess),
6308
6982
  executable: true
6309
6983
  },
6310
6984
  { relativePath: ".devcontainer/README.md", content: renderReadme(projectName) },
@@ -6313,8 +6987,8 @@ function plannedFiles(repoPath, version) {
6313
6987
  };
6314
6988
  }
6315
6989
  function classifyFile(repoPath, file) {
6316
- const absolutePath = import_node_path13.default.join(repoPath, file.relativePath);
6317
- if (!import_node_fs16.default.existsSync(absolutePath)) {
6990
+ const absolutePath = import_node_path15.default.join(repoPath, file.relativePath);
6991
+ if (!import_node_fs17.default.existsSync(absolutePath)) {
6318
6992
  return {
6319
6993
  path: file.relativePath,
6320
6994
  action: "create",
@@ -6322,7 +6996,7 @@ function classifyFile(repoPath, file) {
6322
6996
  bytes: Buffer.byteLength(file.content)
6323
6997
  };
6324
6998
  }
6325
- const current = import_node_fs16.default.readFileSync(absolutePath, "utf-8");
6999
+ const current = import_node_fs17.default.readFileSync(absolutePath, "utf-8");
6326
7000
  if (!current.includes(MANAGED_MARKER)) {
6327
7001
  return {
6328
7002
  path: file.relativePath,
@@ -6379,11 +7053,11 @@ function buildPlan(repoPath, dryRun, version) {
6379
7053
  };
6380
7054
  }
6381
7055
  function writeFile(repoPath, file) {
6382
- const absolutePath = import_node_path13.default.join(repoPath, file.relativePath);
6383
- import_node_fs16.default.mkdirSync(import_node_path13.default.dirname(absolutePath), { recursive: true });
6384
- import_node_fs16.default.writeFileSync(absolutePath, file.content, "utf-8");
7056
+ const absolutePath = import_node_path15.default.join(repoPath, file.relativePath);
7057
+ import_node_fs17.default.mkdirSync(import_node_path15.default.dirname(absolutePath), { recursive: true });
7058
+ import_node_fs17.default.writeFileSync(absolutePath, file.content, "utf-8");
6385
7059
  if (file.executable) {
6386
- import_node_fs16.default.chmodSync(absolutePath, 493);
7060
+ import_node_fs17.default.chmodSync(absolutePath, 493);
6387
7061
  }
6388
7062
  }
6389
7063
  function writeDevcontainer(options = {}) {
@@ -6421,12 +7095,12 @@ function writeDevcontainer(options = {}) {
6421
7095
  nextSteps: postWriteNextSteps(repoPath)
6422
7096
  };
6423
7097
  }
6424
- var import_node_fs16, import_node_path13, MANAGED_MARKER, DEFAULT_DEVROUTER_VERSION, DEFAULT_PNPM_VERSION, VALID_PACKAGE_VERSION_RE;
7098
+ var import_node_fs17, import_node_path15, MANAGED_MARKER, DEFAULT_DEVROUTER_VERSION, DEFAULT_PNPM_VERSION, VALID_PACKAGE_VERSION_RE;
6425
7099
  var init_devcontainer_write = __esm({
6426
7100
  "src/core/devcontainer-write.ts"() {
6427
7101
  "use strict";
6428
- import_node_fs16 = __toESM(require("fs"));
6429
- import_node_path13 = __toESM(require("path"));
7102
+ import_node_fs17 = __toESM(require("fs"));
7103
+ import_node_path15 = __toESM(require("path"));
6430
7104
  init_repo_config();
6431
7105
  init_repo_inspect();
6432
7106
  init_workspace();
@@ -6753,7 +7427,7 @@ function sanitizeRouterId(value) {
6753
7427
  return value.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
6754
7428
  }
6755
7429
  function repoHash(repoPath) {
6756
- return (0, import_node_crypto3.createHash)("sha1").update(import_node_path14.default.resolve(repoPath)).digest("hex").slice(0, 12);
7430
+ return (0, import_node_crypto4.createHash)("sha1").update(import_node_path16.default.resolve(repoPath)).digest("hex").slice(0, 12);
6757
7431
  }
6758
7432
  function asDockerApp(app) {
6759
7433
  return app.runtime === "docker";
@@ -6832,11 +7506,11 @@ function prepareDockerOverlay(repoPath, appName, apps, publishTcpPorts = false)
6832
7506
  if (dockerApps.length === 0) {
6833
7507
  throw new Error("No docker apps selected to prepare compose overlay.");
6834
7508
  }
6835
- const cachePath = import_node_path14.default.join(CACHE_DIR, repoHash(repoPath), sanitizeRouterId(appName));
6836
- import_node_fs17.default.mkdirSync(cachePath, { recursive: true });
6837
- const overlayPath = import_node_path14.default.join(cachePath, "compose.devrouter.yml");
7509
+ const cachePath = import_node_path16.default.join(CACHE_DIR, repoHash(repoPath), sanitizeRouterId(appName));
7510
+ import_node_fs18.default.mkdirSync(cachePath, { recursive: true });
7511
+ const overlayPath = import_node_path16.default.join(cachePath, "compose.devrouter.yml");
6838
7512
  const overlayDocument = buildOverlayDocument(dockerApps, publishTcpPorts);
6839
- import_node_fs17.default.writeFileSync(overlayPath, import_yaml6.default.stringify(overlayDocument, { lineWidth: 0 }), "utf-8");
7513
+ import_node_fs18.default.writeFileSync(overlayPath, import_yaml6.default.stringify(overlayDocument, { lineWidth: 0 }), "utf-8");
6840
7514
  return {
6841
7515
  overlayPath,
6842
7516
  composeFiles: ensureComposeFiles(dockerApps),
@@ -6850,7 +7524,7 @@ function runDockerComposeUp(repoPath, composeFiles2, overlayPath, services) {
6850
7524
  fileArgs.push("-f", resolved);
6851
7525
  }
6852
7526
  const args = ["compose", ...fileArgs, "-f", overlayPath, "up", "-d", "--wait", ...services];
6853
- const result = (0, import_node_child_process10.spawnSync)("docker", args, {
7527
+ const result = (0, import_node_child_process12.spawnSync)("docker", args, {
6854
7528
  encoding: "utf-8",
6855
7529
  cwd: repoPath
6856
7530
  });
@@ -6878,7 +7552,7 @@ function queryRunningComposeServices(repoPath, composeFiles2, overlayPath, servi
6878
7552
  "--services",
6879
7553
  ...services
6880
7554
  ];
6881
- const result = (0, import_node_child_process10.spawnSync)("docker", args, {
7555
+ const result = (0, import_node_child_process12.spawnSync)("docker", args, {
6882
7556
  encoding: "utf-8",
6883
7557
  cwd: repoPath
6884
7558
  });
@@ -6904,7 +7578,7 @@ function runDockerComposeStop(repoPath, composeFiles2, overlayPath, services) {
6904
7578
  fileArgs.push("-f", resolved);
6905
7579
  }
6906
7580
  const args = ["compose", ...fileArgs, "-f", overlayPath, "stop", ...services];
6907
- const result = (0, import_node_child_process10.spawnSync)("docker", args, {
7581
+ const result = (0, import_node_child_process12.spawnSync)("docker", args, {
6908
7582
  encoding: "utf-8",
6909
7583
  cwd: repoPath
6910
7584
  });
@@ -6930,7 +7604,7 @@ function runDockerComposeLogs(repoPath, composeFiles2, overlayPath, services, ta
6930
7604
  String(tail),
6931
7605
  ...services
6932
7606
  ];
6933
- (0, import_node_child_process10.spawnSync)("docker", args, {
7607
+ (0, import_node_child_process12.spawnSync)("docker", args, {
6934
7608
  stdio: "inherit",
6935
7609
  cwd: repoPath
6936
7610
  });
@@ -6942,7 +7616,7 @@ function queryMappedPort(repoPath, composeFiles2, overlayPath, service, internal
6942
7616
  fileArgs.push("-f", resolved);
6943
7617
  }
6944
7618
  const args = ["compose", ...fileArgs, "-f", overlayPath, "port", service, String(internalPort)];
6945
- const result = (0, import_node_child_process10.spawnSync)("docker", args, {
7619
+ const result = (0, import_node_child_process12.spawnSync)("docker", args, {
6946
7620
  encoding: "utf-8",
6947
7621
  cwd: repoPath
6948
7622
  });
@@ -6956,14 +7630,14 @@ function queryMappedPort(repoPath, composeFiles2, overlayPath, service, internal
6956
7630
  const port = Number(match[1]);
6957
7631
  return Number.isInteger(port) && port > 0 ? port : void 0;
6958
7632
  }
6959
- var import_node_child_process10, import_node_crypto3, import_node_fs17, import_node_path14, import_yaml6;
7633
+ var import_node_child_process12, import_node_crypto4, import_node_fs18, import_node_path16, import_yaml6;
6960
7634
  var init_docker_run = __esm({
6961
7635
  "src/core/docker-run.ts"() {
6962
7636
  "use strict";
6963
- import_node_child_process10 = require("child_process");
6964
- import_node_crypto3 = require("crypto");
6965
- import_node_fs17 = __toESM(require("fs"));
6966
- import_node_path14 = __toESM(require("path"));
7637
+ import_node_child_process12 = require("child_process");
7638
+ import_node_crypto4 = require("crypto");
7639
+ import_node_fs18 = __toESM(require("fs"));
7640
+ import_node_path16 = __toESM(require("path"));
6967
7641
  import_yaml6 = __toESM(require("yaml"));
6968
7642
  init_docker_error_guidance();
6969
7643
  init_paths();
@@ -7053,7 +7727,7 @@ function isProcessRunning(pid) {
7053
7727
  }
7054
7728
  }
7055
7729
  function readProcessTree(rootPid) {
7056
- const result = (0, import_node_child_process11.spawnSync)("ps", ["-ax", "-o", "pid=,ppid="], { encoding: "utf-8" });
7730
+ const result = (0, import_node_child_process13.spawnSync)("ps", ["-ax", "-o", "pid=,ppid="], { encoding: "utf-8" });
7057
7731
  if (result.status !== 0) {
7058
7732
  return [rootPid];
7059
7733
  }
@@ -7164,12 +7838,12 @@ function detectListeningPorts(pids) {
7164
7838
  if (pids.length === 0) {
7165
7839
  return [];
7166
7840
  }
7167
- const result = (0, import_node_child_process11.spawnSync)("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", pids.join(",")], {
7841
+ const result = (0, import_node_child_process13.spawnSync)("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", pids.join(",")], {
7168
7842
  encoding: "utf-8"
7169
7843
  });
7170
7844
  if (result.error && result.error.code === "ENOENT") {
7171
7845
  if (process.platform === "linux") {
7172
- const ssResult = (0, import_node_child_process11.spawnSync)("ss", ["-H", "-lntp", "-p"], { encoding: "utf-8" });
7846
+ const ssResult = (0, import_node_child_process13.spawnSync)("ss", ["-H", "-lntp", "-p"], { encoding: "utf-8" });
7173
7847
  if (ssResult.status === 0 && ssResult.stdout) {
7174
7848
  return parseSsListeningPorts(ssResult.stdout, new Set(pids));
7175
7849
  }
@@ -7228,7 +7902,7 @@ async function runHostApp(repoPath, app, extraEnv = {}, secretManager, env, work
7228
7902
  app.hostRun.command,
7229
7903
  true
7230
7904
  ) : app.hostRun.command;
7231
- const child = (0, import_node_child_process11.spawn)(spawnCommand, {
7905
+ const child = (0, import_node_child_process13.spawn)(spawnCommand, {
7232
7906
  cwd: commandCwd,
7233
7907
  stdio: "inherit",
7234
7908
  shell: true,
@@ -7611,7 +8285,7 @@ async function execWithAppEnv(options) {
7611
8285
  options.command[0],
7612
8286
  true
7613
8287
  );
7614
- child = (0, import_node_child_process11.spawn)(wrapped, {
8288
+ child = (0, import_node_child_process13.spawn)(wrapped, {
7615
8289
  cwd: deps.repoPath,
7616
8290
  stdio: "inherit",
7617
8291
  shell: true,
@@ -7625,7 +8299,7 @@ async function execWithAppEnv(options) {
7625
8299
  false
7626
8300
  );
7627
8301
  const [cmd, ...wrappedArgs] = wrapped;
7628
- child = (0, import_node_child_process11.spawn)(cmd, wrappedArgs, {
8302
+ child = (0, import_node_child_process13.spawn)(cmd, wrappedArgs, {
7629
8303
  cwd: deps.repoPath,
7630
8304
  stdio: "inherit",
7631
8305
  shell: false,
@@ -7633,7 +8307,7 @@ async function execWithAppEnv(options) {
7633
8307
  });
7634
8308
  }
7635
8309
  } else if (options.shell) {
7636
- child = (0, import_node_child_process11.spawn)(options.command[0], {
8310
+ child = (0, import_node_child_process13.spawn)(options.command[0], {
7637
8311
  cwd: deps.repoPath,
7638
8312
  stdio: "inherit",
7639
8313
  shell: true,
@@ -7641,7 +8315,7 @@ async function execWithAppEnv(options) {
7641
8315
  });
7642
8316
  } else {
7643
8317
  const [command, ...args] = options.command;
7644
- child = (0, import_node_child_process11.spawn)(command, args, {
8318
+ child = (0, import_node_child_process13.spawn)(command, args, {
7645
8319
  cwd: deps.repoPath,
7646
8320
  stdio: "inherit",
7647
8321
  shell: false,
@@ -7662,11 +8336,11 @@ async function execWithAppEnv(options) {
7662
8336
  deps.stopDeps();
7663
8337
  }
7664
8338
  }
7665
- var import_node_child_process11, import_node_net, import_node_process, import_promises, POLL_INTERVAL_MS, DEFAULT_PORT_TIMEOUT_MS, PROCESS_TERMINATION_GRACE_MS;
8339
+ var import_node_child_process13, import_node_net, import_node_process, import_promises, POLL_INTERVAL_MS, DEFAULT_PORT_TIMEOUT_MS, PROCESS_TERMINATION_GRACE_MS;
7666
8340
  var init_app_run = __esm({
7667
8341
  "src/core/app-run.ts"() {
7668
8342
  "use strict";
7669
- import_node_child_process11 = require("child_process");
8343
+ import_node_child_process13 = require("child_process");
7670
8344
  import_node_net = __toESM(require("net"));
7671
8345
  import_node_process = require("process");
7672
8346
  import_promises = require("readline/promises");
@@ -7796,47 +8470,13 @@ var init_tls2 = __esm({
7796
8470
  });
7797
8471
 
7798
8472
  // src/core/workspace-ensure.ts
7799
- function selectDevpodWorkspace(workspaces, repoPath) {
7800
- const matches = workspaces.filter(
7801
- (workspace) => sameWorkspacePath(workspace.source.localFolder, repoPath)
7802
- );
7803
- if (matches.length > 1) {
7804
- throw new Error(
7805
- `Multiple DevPod workspaces reference '${repoPath}': ${matches.map((match) => match.id).join(", ")}`
7806
- );
7807
- }
7808
- return matches[0];
7809
- }
7810
- function listDevpodWorkspaces() {
7811
- const result = (0, import_node_child_process12.spawnSync)("devpod", ["list", "--output", "json"], { encoding: "utf-8" });
7812
- if (result.status !== 0) {
7813
- const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
7814
- throw new Error(`devpod list failed: ${details || "devpod is not installed or unavailable"}`);
7815
- }
7816
- let parsed;
7817
- try {
7818
- parsed = JSON.parse(result.stdout);
7819
- } catch {
7820
- throw new Error("devpod list returned invalid JSON.");
7821
- }
7822
- if (!Array.isArray(parsed)) {
7823
- throw new Error("devpod list returned an unexpected response.");
7824
- }
7825
- return parsed.map((entry) => {
7826
- const candidate = entry;
7827
- if (typeof candidate.id !== "string" || !candidate.source || typeof candidate.source.localFolder !== "string") {
7828
- throw new Error("devpod list returned a workspace without id/source.localFolder.");
7829
- }
7830
- return candidate;
7831
- });
7832
- }
7833
8473
  function assertOverlay(container, repoPath) {
7834
8474
  const workingDir = container.labels["com.docker.compose.project.working_dir"];
7835
- if (!workingDir || !sameWorkspacePath(workingDir, import_node_path15.default.join(repoPath, ".devcontainer"))) {
8475
+ if (!workingDir || !sameWorkspacePath(workingDir, import_node_path17.default.join(repoPath, ".devcontainer"))) {
7836
8476
  throw new Error(`Container '${container.id}' does not belong to the exact worktree.`);
7837
8477
  }
7838
8478
  const configFiles = (container.labels["com.docker.compose.project.config_files"] ?? "").split(",").filter(Boolean);
7839
- const expectedOverlay = import_node_path15.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
8479
+ const expectedOverlay = import_node_path17.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
7840
8480
  if (!configFiles.some((configFile) => sameWorkspacePath(configFile, expectedOverlay))) {
7841
8481
  throw new Error(`Container '${container.id}' was not started with ${DEVCONTAINER_OVERLAY}.`);
7842
8482
  }
@@ -7854,7 +8494,7 @@ function validateWorkspaceContainers(containers, options) {
7854
8494
  const owned = containers.filter((container) => {
7855
8495
  const workingDir = container.labels["com.docker.compose.project.working_dir"];
7856
8496
  return Boolean(
7857
- workingDir && sameWorkspacePath(workingDir, import_node_path15.default.join(options.repoPath, ".devcontainer"))
8497
+ workingDir && sameWorkspacePath(workingDir, import_node_path17.default.join(options.repoPath, ".devcontainer"))
7858
8498
  );
7859
8499
  });
7860
8500
  const appContainers = owned.filter(
@@ -7902,7 +8542,7 @@ function validateWorkspaceContainers(containers, options) {
7902
8542
  return { id: appContainer.id, workspacePath: repoMount.Destination };
7903
8543
  }
7904
8544
  function inspectWorkspaceContainers() {
7905
- const listed = (0, import_node_child_process12.spawnSync)("docker", ["ps", "-a", "--format", "{{.ID}}"], {
8545
+ const listed = (0, import_node_child_process14.spawnSync)("docker", ["ps", "-a", "--format", "{{.ID}}"], {
7906
8546
  encoding: "utf-8"
7907
8547
  });
7908
8548
  if (listed.status !== 0) {
@@ -7914,7 +8554,7 @@ function inspectWorkspaceContainers() {
7914
8554
  if (ids.length === 0) {
7915
8555
  return [];
7916
8556
  }
7917
- const inspected = (0, import_node_child_process12.spawnSync)("docker", ["inspect", "--format", SAFE_INSPECT_TEMPLATE, ...ids], {
8557
+ const inspected = (0, import_node_child_process14.spawnSync)("docker", ["inspect", "--format", SAFE_INSPECT_TEMPLATE, ...ids], {
7918
8558
  encoding: "utf-8"
7919
8559
  });
7920
8560
  if (inspected.status !== 0) {
@@ -7927,18 +8567,18 @@ function inspectWorkspaceContainers() {
7927
8567
  function sleep2(ms) {
7928
8568
  return new Promise((resolve) => setTimeout(resolve, ms));
7929
8569
  }
7930
- async function waitForContainerPreflight(repoPath, gitCommonDir2, workspace, upstreamHosts, timeoutMs) {
8570
+ async function waitForContainerPreflight(repoPath, gitCommonDir, workspace, upstreamHosts, timeoutMs) {
7931
8571
  const deadline = Date.now() + timeoutMs;
7932
8572
  let lastError;
7933
8573
  do {
7934
8574
  try {
7935
8575
  const appContainer = validateWorkspaceContainers(inspectWorkspaceContainers(), {
7936
8576
  repoPath,
7937
- gitCommonDir: gitCommonDir2,
8577
+ gitCommonDir,
7938
8578
  workspace,
7939
8579
  upstreamHosts
7940
8580
  });
7941
- const workspaceEnv = (0, import_node_child_process12.spawnSync)("docker", ["exec", appContainer.id, "printenv", "WORKSPACE"], {
8581
+ const workspaceEnv = (0, import_node_child_process14.spawnSync)("docker", ["exec", appContainer.id, "printenv", "WORKSPACE"], {
7942
8582
  encoding: "utf-8"
7943
8583
  });
7944
8584
  if (workspaceEnv.status !== 0 || workspaceEnv.stdout.trim() !== workspace) {
@@ -7946,7 +8586,7 @@ async function waitForContainerPreflight(repoPath, gitCommonDir2, workspace, ups
7946
8586
  `Workspace app container must expose WORKSPACE='${workspace}' (got '${workspaceEnv.stdout.trim() || "(empty)"}').`
7947
8587
  );
7948
8588
  }
7949
- const devrouterWorkspaceEnv = (0, import_node_child_process12.spawnSync)(
8589
+ const devrouterWorkspaceEnv = (0, import_node_child_process14.spawnSync)(
7950
8590
  "docker",
7951
8591
  ["exec", appContainer.id, "printenv", "DEVROUTER_WORKSPACE"],
7952
8592
  { encoding: "utf-8" }
@@ -7954,7 +8594,7 @@ async function waitForContainerPreflight(repoPath, gitCommonDir2, workspace, ups
7954
8594
  if (devrouterWorkspaceEnv.status !== 0 || devrouterWorkspaceEnv.stdout.trim() !== workspace) {
7955
8595
  throw new Error(`Workspace app container must expose DEVROUTER_WORKSPACE='${workspace}'.`);
7956
8596
  }
7957
- const gitCheck = (0, import_node_child_process12.spawnSync)(
8597
+ const gitCheck = (0, import_node_child_process14.spawnSync)(
7958
8598
  "docker",
7959
8599
  [
7960
8600
  "exec",
@@ -8020,7 +8660,7 @@ async function waitForHttpRoutes(apps, timeoutMs) {
8020
8660
  const deadline = Date.now() + timeoutMs;
8021
8661
  do {
8022
8662
  for (const [name, app] of pending) {
8023
- const result = (0, import_node_child_process12.spawnSync)(
8663
+ const result = (0, import_node_child_process14.spawnSync)(
8024
8664
  "curl",
8025
8665
  [
8026
8666
  "-k",
@@ -8079,17 +8719,6 @@ function resolveIdentity(repoPath) {
8079
8719
  hadExactDevpod: Boolean(existingDevpod)
8080
8720
  };
8081
8721
  }
8082
- function gitCommonDir(repoPath) {
8083
- const result = (0, import_node_child_process12.spawnSync)(
8084
- "git",
8085
- ["-C", repoPath, "rev-parse", "--path-format=absolute", "--git-common-dir"],
8086
- { encoding: "utf-8" }
8087
- );
8088
- if (result.status !== 0 || !result.stdout.trim()) {
8089
- throw new Error(`Could not resolve the Git common directory for '${repoPath}'.`);
8090
- }
8091
- return comparableWorkspacePath(result.stdout.trim());
8092
- }
8093
8722
  function startDevpod(repoPath, workspace, commonDir, recreate = false) {
8094
8723
  const args = [
8095
8724
  "up",
@@ -8105,7 +8734,7 @@ function startDevpod(repoPath, workspace, commonDir, recreate = false) {
8105
8734
  if (recreate) {
8106
8735
  args.push("--recreate");
8107
8736
  }
8108
- const result = (0, import_node_child_process12.spawnSync)("devpod", args, {
8737
+ const result = (0, import_node_child_process14.spawnSync)("devpod", args, {
8109
8738
  stdio: "inherit",
8110
8739
  env: {
8111
8740
  ...process.env,
@@ -8127,7 +8756,7 @@ function assertDevpodAttachment(repoPath, workspace) {
8127
8756
  }
8128
8757
  function openUrls(urls) {
8129
8758
  for (const url of urls) {
8130
- const opened = (0, import_node_child_process12.spawnSync)("open", [url], { encoding: "utf-8" });
8759
+ const opened = (0, import_node_child_process14.spawnSync)("open", [url], { encoding: "utf-8" });
8131
8760
  if (opened.status !== 0) {
8132
8761
  process.stderr.write(`Warning: could not open '${url}'.
8133
8762
  `);
@@ -8139,12 +8768,19 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
8139
8768
  if (!isLinkedWorktree(repoPath)) {
8140
8769
  throw new Error(`workspace ensure requires a linked Git worktree (got '${repoPath}').`);
8141
8770
  }
8771
+ const missingOwners = listMissingWorkspaceOwnership(repoPath);
8772
+ if (missingOwners.length > 0) {
8773
+ process.stderr.write(
8774
+ `Warning: ${missingOwners.length} managed workspace owner${missingOwners.length === 1 ? " is" : "s are"} missing. Review: dev workspace gc --repo ${repoPath}
8775
+ `
8776
+ );
8777
+ }
8142
8778
  return withWorkspaceLifecycleLock(repoPath, async () => {
8143
8779
  let environmentStarted = false;
8144
8780
  try {
8145
8781
  const { workspace, hadExactDevpod } = resolveIdentity(repoPath);
8146
- const overlayPath = import_node_path15.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
8147
- if (!import_node_fs18.default.existsSync(overlayPath)) {
8782
+ const overlayPath = import_node_path17.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
8783
+ if (!import_node_fs19.default.existsSync(overlayPath)) {
8148
8784
  throw new Error(`Missing required DevPod compose overlay: ${overlayPath}`);
8149
8785
  }
8150
8786
  const runtime = loadRuntimeConfig(repoPath, workspace);
@@ -8157,12 +8793,20 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
8157
8793
  );
8158
8794
  }
8159
8795
  }
8160
- const commonDir = gitCommonDir(repoPath);
8796
+ const commonDir = resolveGitCommonDir(repoPath);
8161
8797
  const upstreamHosts = parsedUpstreams.map((upstream) => upstream.host);
8798
+ const ownership = {
8799
+ workspace,
8800
+ worktreePath: repoPath,
8801
+ branch: currentBranch(repoPath),
8802
+ devpodId: workspace
8803
+ };
8804
+ writeWorkspaceOwnership(repoPath, ownership);
8162
8805
  const startAndProveAttachment = (recreate = false) => {
8163
8806
  startDevpod(repoPath, workspace, commonDir, recreate);
8164
8807
  environmentStarted = true;
8165
8808
  assertDevpodAttachment(repoPath, workspace);
8809
+ writeWorkspaceOwnership(repoPath, ownership);
8166
8810
  };
8167
8811
  const recreateAndWait = async () => {
8168
8812
  startAndProveAttachment(true);
@@ -8240,19 +8884,21 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
8240
8884
  }
8241
8885
  });
8242
8886
  }
8243
- var import_node_child_process12, import_node_fs18, import_node_path15, DEVCONTAINER_OVERLAY, DEFAULT_READINESS_TIMEOUT_MS, POLL_INTERVAL_MS2, SAFE_INSPECT_TEMPLATE;
8887
+ var import_node_child_process14, import_node_fs19, import_node_path17, DEVCONTAINER_OVERLAY, DEFAULT_READINESS_TIMEOUT_MS, POLL_INTERVAL_MS2, SAFE_INSPECT_TEMPLATE;
8244
8888
  var init_workspace_ensure = __esm({
8245
8889
  "src/core/workspace-ensure.ts"() {
8246
8890
  "use strict";
8247
- import_node_child_process12 = require("child_process");
8248
- import_node_fs18 = __toESM(require("fs"));
8249
- import_node_path15 = __toESM(require("path"));
8891
+ import_node_child_process14 = require("child_process");
8892
+ import_node_fs19 = __toESM(require("fs"));
8893
+ import_node_path17 = __toESM(require("path"));
8894
+ init_devpod_workspaces();
8250
8895
  init_docker();
8251
8896
  init_host_routes();
8252
8897
  init_repo_config();
8253
8898
  init_router();
8254
8899
  init_tls();
8255
8900
  init_workspace();
8901
+ init_workspace_ownership();
8256
8902
  DEVCONTAINER_OVERLAY = "docker-compose.devrouter.yml";
8257
8903
  DEFAULT_READINESS_TIMEOUT_MS = 12e4;
8258
8904
  POLL_INTERVAL_MS2 = 1e3;
@@ -8261,49 +8907,29 @@ var init_workspace_ensure = __esm({
8261
8907
  });
8262
8908
 
8263
8909
  // src/core/workspace-lifecycle.ts
8264
- function hasDevpod() {
8265
- const result = (0, import_node_child_process13.spawnSync)("devpod", ["version"], { encoding: "utf-8" });
8266
- return result.status === 0;
8267
- }
8268
- function listGitWorktrees(repoPath) {
8269
- const result = (0, import_node_child_process13.spawnSync)("git", ["-C", repoPath, "worktree", "list", "--porcelain"], {
8270
- encoding: "utf-8"
8271
- });
8272
- if (result.status !== 0) {
8273
- return [];
8274
- }
8275
- const worktrees = [];
8276
- let current = {};
8277
- for (const line of result.stdout.split("\n")) {
8278
- if (line.startsWith("worktree ")) {
8279
- current = { path: line.slice("worktree ".length).trim() };
8280
- } else if (line.startsWith("branch ")) {
8281
- current.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, "");
8282
- } else if (line.trim() === "" && current.path) {
8283
- worktrees.push({ path: current.path, branch: current.branch });
8284
- current = {};
8285
- }
8286
- }
8287
- if (current.path) {
8288
- worktrees.push({ path: current.path, branch: current.branch });
8289
- }
8290
- return worktrees;
8910
+ function warnMissingWorkspaceOwnership(repoPath) {
8911
+ const missing = listMissingWorkspaceOwnership(repoPath);
8912
+ if (missing.length === 0) return;
8913
+ process.stderr.write(
8914
+ `Warning: ${missing.length} managed workspace owner${missing.length === 1 ? " is" : "s are"} missing. Review: dev workspace gc --repo ${repoPath}
8915
+ `
8916
+ );
8291
8917
  }
8292
8918
  function defaultWorktreePath(mainRepo, ws) {
8293
- return import_node_path16.default.join(mainRepo, "trees", ws);
8919
+ return import_node_path18.default.join(mainRepo, "trees", ws);
8294
8920
  }
8295
8921
  function assertDefaultWorktreeRootIgnored(mainRepo) {
8296
- const ignored = (0, import_node_child_process13.spawnSync)("git", ["-C", mainRepo, "check-ignore", "-q", "--no-index", "trees/"], {
8922
+ const ignored = (0, import_node_child_process15.spawnSync)("git", ["-C", mainRepo, "check-ignore", "-q", "--no-index", "trees/"], {
8297
8923
  encoding: "utf-8"
8298
8924
  });
8299
8925
  if (ignored.status !== 0) {
8300
8926
  throw new Error(
8301
- `Default worktree root '${import_node_path16.default.join(mainRepo, "trees")}' is not ignored. Add 'trees/' to '${import_node_path16.default.join(mainRepo, ".gitignore")}' or use --path.`
8927
+ `Default worktree root '${import_node_path18.default.join(mainRepo, "trees")}' is not ignored. Add 'trees/' to '${import_node_path18.default.join(mainRepo, ".gitignore")}' or use --path.`
8302
8928
  );
8303
8929
  }
8304
8930
  }
8305
8931
  function legacyDefaultWorktreePath(mainRepo, ws) {
8306
- return import_node_path16.default.join(import_node_path16.default.dirname(mainRepo), `${import_node_path16.default.basename(mainRepo)}-${ws}`);
8932
+ return import_node_path18.default.join(import_node_path18.default.dirname(mainRepo), `${import_node_path18.default.basename(mainRepo)}-${ws}`);
8307
8933
  }
8308
8934
  function teardownFallbackPath(mainRepo, workspace) {
8309
8935
  const candidates = [
@@ -8332,14 +8958,119 @@ function oneWorkspaceMatch(target, matches) {
8332
8958
  }
8333
8959
  return matches[0];
8334
8960
  }
8961
+ function oneRecordMatch(target, matches) {
8962
+ if (matches.length > 1) {
8963
+ throw new Error(
8964
+ `Workspace target '${target}' is ambiguous: ${matches.map((match) => match.worktreePath).join(", ")}`
8965
+ );
8966
+ }
8967
+ return matches[0];
8968
+ }
8969
+ function resolveWorkspaceTarget(mainRepo, target, worktrees, records) {
8970
+ const requestedWorkspace = wsFromBranch(target);
8971
+ if (!requestedWorkspace) {
8972
+ throw new Error(`'${target}' does not yield a valid workspace token.`);
8973
+ }
8974
+ const liveBranchWorktree = oneWorkspaceMatch(
8975
+ target,
8976
+ worktrees.filter((worktree2) => worktree2.workspace !== void 0 && worktree2.branch === target)
8977
+ );
8978
+ if (liveBranchWorktree) {
8979
+ const liveWorkspace = liveBranchWorktree.workspace;
8980
+ if (!liveWorkspace) {
8981
+ throw new Error(`Live branch '${target}' is not an isolated workspace.`);
8982
+ }
8983
+ const liveRecord = oneRecordMatch(
8984
+ target,
8985
+ records.filter((record2) => sameWorkspacePath(record2.worktreePath, liveBranchWorktree.path))
8986
+ );
8987
+ return {
8988
+ workspace: liveRecord?.workspace ?? liveWorkspace,
8989
+ worktreePath: liveBranchWorktree.path,
8990
+ worktree: liveBranchWorktree,
8991
+ record: liveRecord
8992
+ };
8993
+ }
8994
+ const record = records.find((candidate) => candidate.workspace === requestedWorkspace);
8995
+ if (record) {
8996
+ return {
8997
+ workspace: record.workspace,
8998
+ worktreePath: record.worktreePath,
8999
+ worktree: worktreeForRecord(worktrees, record),
9000
+ record
9001
+ };
9002
+ }
9003
+ const linked = worktrees.filter((worktree2) => worktree2.workspace !== void 0);
9004
+ const worktree = oneWorkspaceMatch(
9005
+ target,
9006
+ linked.filter((candidate) => candidate.workspace === requestedWorkspace)
9007
+ ) ?? oneWorkspaceMatch(
9008
+ target,
9009
+ linked.filter(
9010
+ (candidate) => candidate.branch !== void 0 && wsFromBranch(candidate.branch) === requestedWorkspace
9011
+ )
9012
+ );
9013
+ const workspace = worktree?.workspace ?? requestedWorkspace;
9014
+ return {
9015
+ workspace,
9016
+ worktreePath: worktree?.path ?? teardownFallbackPath(mainRepo, workspace),
9017
+ worktree,
9018
+ record: void 0
9019
+ };
9020
+ }
9021
+ function worktreeForRecord(worktrees, record) {
9022
+ return worktrees.find((candidate) => sameWorkspacePath(candidate.path, record.worktreePath));
9023
+ }
9024
+ function devpodForTarget(target, worktrees, devpods) {
9025
+ if (target.record) {
9026
+ const status = inspectWorkspaceOwnership(target.record, worktrees, devpods);
9027
+ if (status.ownerStatus === "conflict") {
9028
+ throw new Error(
9029
+ `Workspace '${target.workspace}' ownership conflicts with live Git or DevPod evidence; no resources were changed.`
9030
+ );
9031
+ }
9032
+ return status.devpodStatus === "owned" ? devpods.find((devpod) => devpod.id === target.record?.devpodId) : void 0;
9033
+ }
9034
+ const devpodId = target.workspace;
9035
+ const ownership = inspectDevpodWorkspaceOwnership(devpods, devpodId, target.worktreePath);
9036
+ if (ownership.status === "conflict") {
9037
+ throw new Error(ownership.reason);
9038
+ }
9039
+ return ownership.status === "owned" ? ownership.workspace : void 0;
9040
+ }
9041
+ function assertFullDownPreflight(mainRepo, target) {
9042
+ if (sameWorkspacePath(target.worktreePath, mainRepo)) {
9043
+ throw new Error("Refusing to remove the primary Git checkout.");
9044
+ }
9045
+ if (!target.worktree || target.worktree.prunable || !import_node_fs20.default.existsSync(target.worktreePath)) return;
9046
+ if (target.worktree.locked) {
9047
+ throw new Error(
9048
+ `Worktree '${target.worktreePath}' is locked; unlock it before workspace down.`
9049
+ );
9050
+ }
9051
+ const status = (0, import_node_child_process15.spawnSync)(
9052
+ "git",
9053
+ ["-C", target.worktreePath, "status", "--porcelain", "--untracked-files=normal"],
9054
+ { encoding: "utf-8" }
9055
+ );
9056
+ if (status.status !== 0) {
9057
+ const detail = [status.error?.message, status.stderr].filter(Boolean).join("\n").trim();
9058
+ throw new Error(`git status failed for '${target.worktreePath}': ${detail || "unknown error"}`);
9059
+ }
9060
+ if (status.stdout.trim()) {
9061
+ throw new Error(
9062
+ `Worktree '${target.worktreePath}' has uncommitted changes; use --keep-worktree or clean it before workspace down.`
9063
+ );
9064
+ }
9065
+ }
8335
9066
  async function workspaceUp(branch, opts = {}) {
8336
9067
  const mainRepo = resolveRepoPath(opts.repoPath);
8337
9068
  const ws = wsFromBranch(branch);
8338
9069
  if (!ws) {
8339
9070
  throw new Error(`Branch '${branch}' does not yield a valid workspace token.`);
8340
9071
  }
8341
- const worktreePath = opts.path ? import_node_path16.default.resolve(opts.path) : defaultWorktreePath(mainRepo, ws);
8342
- if (import_node_fs19.default.existsSync(worktreePath)) {
9072
+ const worktreePath = opts.path ? import_node_path18.default.resolve(opts.path) : defaultWorktreePath(mainRepo, ws);
9073
+ if (import_node_fs20.default.existsSync(worktreePath)) {
8343
9074
  const registered = listGitWorktrees(mainRepo).find(
8344
9075
  (worktree) => sameWorkspacePath(worktree.path, worktreePath)
8345
9076
  );
@@ -8357,11 +9088,11 @@ async function workspaceUp(branch, opts = {}) {
8357
9088
  if (!opts.path) {
8358
9089
  assertDefaultWorktreeRootIgnored(mainRepo);
8359
9090
  }
8360
- const add = (0, import_node_child_process13.spawnSync)("git", ["-C", mainRepo, "worktree", "add", worktreePath, branch], {
9091
+ const add = (0, import_node_child_process15.spawnSync)("git", ["-C", mainRepo, "worktree", "add", worktreePath, branch], {
8361
9092
  encoding: "utf-8"
8362
9093
  });
8363
9094
  if (add.status !== 0) {
8364
- const addNew = (0, import_node_child_process13.spawnSync)(
9095
+ const addNew = (0, import_node_child_process15.spawnSync)(
8365
9096
  "git",
8366
9097
  ["-C", mainRepo, "worktree", "add", "-b", branch, worktreePath],
8367
9098
  {
@@ -8377,6 +9108,7 @@ async function workspaceUp(branch, opts = {}) {
8377
9108
  `);
8378
9109
  }
8379
9110
  if (opts.noDevpod) {
9111
+ warnMissingWorkspaceOwnership(mainRepo);
8380
9112
  process.stdout.write("Skipped environment startup; no routes were changed.\n");
8381
9113
  return;
8382
9114
  }
@@ -8393,79 +9125,104 @@ ${ensured.urls.map((url) => ` ${url}`).join("\n")}
8393
9125
  function workspaceLs(repoPath) {
8394
9126
  const mainRepo = resolveRepoPath(repoPath);
8395
9127
  const worktrees = identifyGitWorktrees(mainRepo);
8396
- const routesByWorktreePath = listRoutesForWorktreePaths(
8397
- worktrees.map((worktree) => worktree.path)
9128
+ const records = listWorkspaceOwnership(mainRepo);
9129
+ let devpods;
9130
+ try {
9131
+ devpods = listDevpodWorkspaces();
9132
+ } catch {
9133
+ devpods = void 0;
9134
+ }
9135
+ const paths = Array.from(
9136
+ /* @__PURE__ */ new Set([
9137
+ ...worktrees.map((worktree) => worktree.path),
9138
+ ...records.map((record) => record.worktreePath)
9139
+ ])
8398
9140
  );
8399
- return worktrees.map((wt) => {
8400
- const wsRoutes = routesByWorktreePath.get(wt.path) ?? [];
9141
+ const routesByWorktreePath = listRoutesForWorktreePaths(paths);
9142
+ const worktreesByPath = new Map(worktrees.map((worktree) => [worktree.path, worktree]));
9143
+ const recordsByPath = new Map(records.map((record) => [record.worktreePath, record]));
9144
+ return paths.map((worktreePath) => {
9145
+ const worktree = worktreesByPath.get(worktreePath);
9146
+ const record = recordsByPath.get(worktreePath);
9147
+ const status = record ? inspectWorkspaceOwnership(record, worktrees, devpods) : void 0;
9148
+ const matchingDevpods = devpods?.filter(
9149
+ (devpod) => sameWorkspacePath(devpod.source.localFolder, worktreePath)
9150
+ );
9151
+ const wsRoutes = routesByWorktreePath.get(worktreePath) ?? [];
8401
9152
  return {
8402
- workspace: wt.workspace,
8403
- branch: wt.branch,
8404
- worktreePath: wt.path,
9153
+ workspace: record?.workspace ?? worktree?.workspace,
9154
+ branch: worktree?.branch ?? record?.branch ?? void 0,
9155
+ worktreePath,
9156
+ legacy: !record && worktree?.workspace !== void 0,
9157
+ ownerStatus: status?.ownerStatus,
9158
+ devpodStatus: status?.devpodStatus ?? (devpods === void 0 ? "unknown" : matchingDevpods?.length === 0 ? "absent" : matchingDevpods?.length === 1 ? "owned" : "conflict"),
8405
9159
  routeCount: wsRoutes.length,
8406
9160
  hosts: wsRoutes.map((route) => route.host)
8407
9161
  };
8408
9162
  });
8409
9163
  }
8410
- async function workspaceDown(target, opts = {}) {
8411
- const requestedWorkspace = wsFromBranch(target);
8412
- if (!requestedWorkspace) {
8413
- throw new Error(`'${target}' does not yield a valid workspace token.`);
8414
- }
9164
+ async function runWorkspaceLifecycle(action2, target, opts = {}) {
8415
9165
  const mainRepo = resolveRepoPath(opts.repoPath);
8416
- const worktrees = identifyGitWorktrees(mainRepo).filter(
8417
- (worktree) => worktree.workspace !== void 0
8418
- );
8419
- const exactBranchMatch = worktrees.find((worktree) => worktree.branch === target);
8420
- const match = exactBranchMatch ?? oneWorkspaceMatch(
8421
- target,
8422
- worktrees.filter((worktree) => worktree.workspace === requestedWorkspace)
8423
- ) ?? oneWorkspaceMatch(
8424
- target,
8425
- worktrees.filter(
8426
- (worktree) => worktree.branch !== void 0 && wsFromBranch(worktree.branch) === requestedWorkspace
8427
- )
8428
- );
8429
- const workspace = match?.workspace ?? requestedWorkspace;
8430
- const worktreePath = match?.path ?? teardownFallbackPath(mainRepo, workspace);
8431
- const teardown = async () => {
8432
- const routes = removeWorkspaceRoutesForWorktree(workspace, worktreePath);
8433
- process.stdout.write(`Freed ${routes.length} route(s) for workspace '${workspace}'.
8434
- `);
8435
- if (!opts.keepDevpod && hasDevpod()) {
8436
- (0, import_node_child_process13.spawnSync)("devpod", ["stop", workspace], { stdio: "inherit" });
8437
- }
8438
- if (!opts.keepWorktree) {
8439
- if (import_node_fs19.default.existsSync(worktreePath) && worktreePath !== mainRepo) {
8440
- const rm = (0, import_node_child_process13.spawnSync)("git", ["-C", mainRepo, "worktree", "remove", worktreePath], {
9166
+ const worktrees = identifyGitWorktrees(mainRepo);
9167
+ const records = listWorkspaceOwnership(mainRepo);
9168
+ const resolved = resolveWorkspaceTarget(mainRepo, target, worktrees, records);
9169
+ const operation = async () => {
9170
+ const removeWorktree = action2 === "down" && !opts.keepWorktree;
9171
+ const devpodAction = action2 === "stop" ? "stop" : "delete";
9172
+ if (removeWorktree) {
9173
+ assertFullDownPreflight(mainRepo, resolved);
9174
+ }
9175
+ const devpods = listDevpodWorkspaces();
9176
+ const devpod = devpodForTarget(resolved, worktrees, devpods);
9177
+ if (devpod) {
9178
+ runDevpodWorkspaceAction(devpodAction, devpod.id);
9179
+ }
9180
+ const routes = removeWorkspaceRoutesForWorktree(resolved.workspace, resolved.worktreePath);
9181
+ process.stdout.write(
9182
+ `Freed ${routes.length} route(s) for workspace '${resolved.workspace}'.
9183
+ `
9184
+ );
9185
+ if (removeWorktree) {
9186
+ if (resolved.worktree && !resolved.worktree.prunable && import_node_fs20.default.existsSync(resolved.worktreePath)) {
9187
+ const rm = (0, import_node_child_process15.spawnSync)("git", ["-C", mainRepo, "worktree", "remove", resolved.worktreePath], {
8441
9188
  encoding: "utf-8"
8442
9189
  });
8443
- if (rm.status === 0) {
8444
- process.stdout.write(`Removed worktree ${worktreePath}.
8445
- `);
8446
- } else {
8447
- process.stderr.write(
8448
- `Warning: could not remove worktree ${worktreePath}: ${rm.stderr || "unknown error"}
8449
- `
9190
+ if (rm.status !== 0) {
9191
+ const detail = [rm.error?.message, rm.stderr].filter(Boolean).join("\n").trim();
9192
+ throw new Error(
9193
+ `git worktree remove failed for '${resolved.worktreePath}': ${detail || "unknown error"}`
8450
9194
  );
8451
9195
  }
9196
+ process.stdout.write(`Removed worktree ${resolved.worktreePath}.
9197
+ `);
9198
+ }
9199
+ if (resolved.record) {
9200
+ removeWorkspaceOwnership(mainRepo, resolved.workspace);
8452
9201
  }
8453
9202
  }
8454
- return { freedRoutes: routes.length, workspace };
9203
+ return { freedRoutes: routes.length, workspace: resolved.workspace };
8455
9204
  };
8456
- return match && import_node_fs19.default.existsSync(worktreePath) ? withWorkspaceLifecycleLock(worktreePath, teardown) : teardown();
9205
+ return resolved.worktree && !resolved.worktree.prunable && import_node_fs20.default.existsSync(resolved.worktreePath) ? withWorkspaceLifecycleLock(resolved.worktreePath, operation) : operation();
9206
+ }
9207
+ async function workspaceStop(target, opts = {}) {
9208
+ return runWorkspaceLifecycle("stop", target, opts);
9209
+ }
9210
+ async function workspaceDown(target, opts = {}) {
9211
+ return runWorkspaceLifecycle("down", target, opts);
8457
9212
  }
8458
- var import_node_child_process13, import_node_fs19, import_node_path16;
9213
+ var import_node_child_process15, import_node_fs20, import_node_path18;
8459
9214
  var init_workspace_lifecycle = __esm({
8460
9215
  "src/core/workspace-lifecycle.ts"() {
8461
9216
  "use strict";
8462
- import_node_child_process13 = require("child_process");
8463
- import_node_fs19 = __toESM(require("fs"));
8464
- import_node_path16 = __toESM(require("path"));
9217
+ import_node_child_process15 = require("child_process");
9218
+ import_node_fs20 = __toESM(require("fs"));
9219
+ import_node_path18 = __toESM(require("path"));
9220
+ init_devpod_workspaces();
8465
9221
  init_repo_config();
8466
9222
  init_route_state();
8467
9223
  init_workspace();
8468
9224
  init_workspace_ensure();
9225
+ init_workspace_ownership();
8469
9226
  }
8470
9227
  });
8471
9228
 
@@ -8474,19 +9231,34 @@ var workspace_exports = {};
8474
9231
  __export(workspace_exports, {
8475
9232
  runWorkspaceDownCommand: () => runWorkspaceDownCommand,
8476
9233
  runWorkspaceEnsureCommand: () => runWorkspaceEnsureCommand,
9234
+ runWorkspaceGcCommand: () => runWorkspaceGcCommand,
8477
9235
  runWorkspaceLsCommand: () => runWorkspaceLsCommand,
9236
+ runWorkspaceStopCommand: () => runWorkspaceStopCommand,
8478
9237
  runWorkspaceUpCommand: () => runWorkspaceUpCommand
8479
9238
  });
9239
+ function resolveGitWorkspaceRepo(repoPath) {
9240
+ const resolved = resolveRepoPath(repoPath);
9241
+ try {
9242
+ resolveGitCommonDir(resolved);
9243
+ } catch (error) {
9244
+ throw new Error(`Workspace commands require a Git repository: '${resolved}'.`, {
9245
+ cause: error
9246
+ });
9247
+ }
9248
+ return resolved;
9249
+ }
8480
9250
  async function runWorkspaceUpCommand(branch, options) {
9251
+ const repoPath = resolveGitWorkspaceRepo(options.repo);
8481
9252
  await workspaceUp(branch, {
8482
9253
  path: options.path,
8483
9254
  noDevpod: options.noDevpod,
8484
9255
  open: options.open,
8485
- repoPath: options.repo
9256
+ repoPath
8486
9257
  });
8487
9258
  }
8488
9259
  async function runWorkspaceEnsureCommand(options) {
8489
- const result = await workspaceEnsure(options.path, { open: options.open });
9260
+ const repoPath = resolveGitWorkspaceRepo(options.path);
9261
+ const result = await workspaceEnsure(repoPath, { open: options.open });
8490
9262
  process.stdout.write(
8491
9263
  `Workspace '${result.workspace}' is ready (${result.devpodId}).
8492
9264
  ${result.urls.map((url) => ` ${url}`).join("\n")}
@@ -8494,7 +9266,8 @@ ${result.urls.map((url) => ` ${url}`).join("\n")}
8494
9266
  );
8495
9267
  }
8496
9268
  function runWorkspaceLsCommand(options) {
8497
- const rows = workspaceLs(options.repo);
9269
+ const repoPath = resolveGitWorkspaceRepo(options.repo);
9270
+ const rows = workspaceLs(repoPath);
8498
9271
  if (options.json) {
8499
9272
  process.stdout.write(`${JSON.stringify(rows, null, 2)}
8500
9273
  `);
@@ -8506,24 +9279,56 @@ function runWorkspaceLsCommand(options) {
8506
9279
  }
8507
9280
  for (const row of rows) {
8508
9281
  const label = row.workspace ?? "(primary)";
9282
+ const ownership = row.ownerStatus ?? (row.legacy ? "legacy" : "unmanaged");
8509
9283
  process.stdout.write(
8510
- `${label} ${row.branch ?? "-"} ${row.routeCount} route(s) ${row.worktreePath}
9284
+ `${label} ${row.branch ?? "-"} ${ownership} devpod:${row.devpodStatus} ${row.routeCount} route(s) ${row.worktreePath}
8511
9285
  `
8512
9286
  );
8513
9287
  }
8514
9288
  }
8515
9289
  async function runWorkspaceDownCommand(target, options) {
9290
+ const repoPath = resolveGitWorkspaceRepo(options.repo);
8516
9291
  await workspaceDown(target, {
8517
9292
  keepWorktree: options.keepWorktree,
8518
- keepDevpod: options.keepDevpod,
8519
- repoPath: options.repo
9293
+ repoPath
8520
9294
  });
8521
9295
  }
9296
+ async function runWorkspaceStopCommand(target, options) {
9297
+ const repoPath = resolveGitWorkspaceRepo(options.repo);
9298
+ await workspaceStop(target, { repoPath });
9299
+ }
9300
+ function runWorkspaceGcCommand(options) {
9301
+ const repoPath = resolveGitWorkspaceRepo(options.repo);
9302
+ const plan = inspectWorkspaceGc(repoPath);
9303
+ const report = options.yes ? applyWorkspaceGc(plan) : plan;
9304
+ if (options.json) {
9305
+ process.stdout.write(`${JSON.stringify(report, null, 2)}
9306
+ `);
9307
+ } else {
9308
+ process.stdout.write(
9309
+ `Workspace GC ${report.mode}: ${report.summary.eligible} eligible, ${report.summary.blocked} blocked, ${report.summary.cleaned} cleaned, ${report.summary.errors} error(s).
9310
+ `
9311
+ );
9312
+ for (const candidate of report.candidates) {
9313
+ process.stdout.write(
9314
+ `${candidate.workspace} ${candidate.kind} ${candidate.ownerStatus ?? "legacy"} ${candidate.eligible ? "eligible" : "blocked"} ${candidate.worktreePath}
9315
+ `
9316
+ );
9317
+ }
9318
+ if (!options.yes && report.summary.eligible > 0) {
9319
+ process.stdout.write("Dry run only. Re-run with --yes to apply eligible cleanup.\n");
9320
+ }
9321
+ }
9322
+ if (report.summary.errors > 0) process.exitCode = 1;
9323
+ }
8522
9324
  var init_workspace2 = __esm({
8523
9325
  "src/commands/workspace.ts"() {
8524
9326
  "use strict";
9327
+ init_repo_config();
8525
9328
  init_workspace_ensure();
9329
+ init_workspace_gc();
8526
9330
  init_workspace_lifecycle();
9331
+ init_workspace_ownership();
8527
9332
  }
8528
9333
  });
8529
9334
 
@@ -8565,7 +9370,7 @@ var init_version = __esm({
8565
9370
 
8566
9371
  // src/cli.ts
8567
9372
  var import_commander = require("commander");
8568
- var CLI_VERSION = true ? "0.0.29" : "0.0.0-dev";
9373
+ var CLI_VERSION = true ? "0.0.31" : "0.0.0-dev";
8569
9374
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
8570
9375
  function withErrorHandling(action2) {
8571
9376
  return async (...args) => {
@@ -8805,7 +9610,21 @@ workspaceCommand.command("ls").description("List git worktrees with their worksp
8805
9610
  runWorkspaceLsCommand2(options);
8806
9611
  })
8807
9612
  );
8808
- workspaceCommand.command("down").description("Free a workspace's routes, stop its devpod, and remove its worktree").argument("<workspace>", "Workspace token or branch name").option("--keep-worktree", "Leave the git worktree in place").option("--keep-devpod", "Leave the devpod workspace running").option("--repo <path>", "Main repository path (defaults to current directory)").action(
9613
+ workspaceCommand.command("gc").description("Report or clean missing ledger-owned workspace resources").option("--repo <path>", "Main repository path (defaults to current directory)").option("--json", "Output JSON").option("--yes", "Delete eligible DevPod, route, and ownership resources").action(
9614
+ withErrorHandling(async (_options, command) => {
9615
+ const options = command.opts();
9616
+ const { runWorkspaceGcCommand: runWorkspaceGcCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));
9617
+ runWorkspaceGcCommand2(options);
9618
+ })
9619
+ );
9620
+ workspaceCommand.command("stop").description("Stop a workspace's DevPod and free routes while preserving its worktree and data").argument("<workspace>", "Workspace token or live branch name").option("--repo <path>", "Main repository path (defaults to current directory)").action(
9621
+ withErrorHandling(async (target, _options, command) => {
9622
+ const options = command.opts();
9623
+ const { runWorkspaceStopCommand: runWorkspaceStopCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));
9624
+ await runWorkspaceStopCommand2(target, options);
9625
+ })
9626
+ );
9627
+ workspaceCommand.command("down").description("Delete a workspace's DevPod and routes, then remove its clean Git worktree").argument("<workspace>", "Workspace token or live branch name").option("--keep-worktree", "Delete runtime resources but preserve the Git worktree and record").option("--repo <path>", "Main repository path (defaults to current directory)").action(
8809
9628
  withErrorHandling(async (target, _options, command) => {
8810
9629
  const options = command.opts();
8811
9630
  const { runWorkspaceDownCommand: runWorkspaceDownCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));