@devrouter/cli 0.0.44 → 0.0.45
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 +290 -81
- package/package.json +1 -1
- package/upgrade-prompts/0.0.45.md +33 -0
package/dist/devrouter.js
CHANGED
|
@@ -316,9 +316,9 @@ Config-level \`envMap\` on dependency references aliases per-dep vars to app-exp
|
|
|
316
316
|
|
|
317
317
|
## Workspace isolation (parallel git worktrees / agents)
|
|
318
318
|
|
|
319
|
-
Run several worktrees of one repo in parallel without host/route collisions. A **workspace token** spans the
|
|
319
|
+
Run several worktrees of one repo in parallel without host/route collisions. A **workspace token** spans the workspace-runtime id, devrouter routes, \`\${WORKSPACE}\` proxy upstreams, and devcontainer aliases.
|
|
320
320
|
|
|
321
|
-
- **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
|
|
321
|
+
- **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 workspace-runtime ID. First use reconciles persisted metadata, the exact-path owner record, and both DevPod and Devsy registries. It reuses an established agreement, keeps the readable sanitized branch/path slug when free, or claims a deterministic hash-suffixed fallback on collision before provider or route mutation. Later flags or \`DEVROUTER_WORKSPACE\` may repeat the identity but cannot rename it. Unreadable or conflicting evidence fails closed. The primary checkout remains non-namespaced.
|
|
322
322
|
- **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. Managed \`ensure\` rejects every HTTP/TCP proxy upstream outside that exact alias namespace before it mutates DevPod or routes. The runtime config is computed in memory only \u2014 the committed \`.devrouter.yml\` is never rewritten.
|
|
323
323
|
- **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.
|
|
324
324
|
- **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>\`.
|
|
@@ -1057,6 +1057,21 @@ function wsFromBranch(branch) {
|
|
|
1057
1057
|
const slug = branch.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+/, "").slice(0, MAX_WORKSPACE_LENGTH).replace(/-+$/, "");
|
|
1058
1058
|
return slug.length > 0 ? slug : void 0;
|
|
1059
1059
|
}
|
|
1060
|
+
function workspaceIdentityFallback(source, attempt) {
|
|
1061
|
+
const readablePrefix = (wsFromBranch(source) ?? "workspace").slice(0, WORKSPACE_IDENTITY_PREFIX_LENGTH).replace(/-+$/, "");
|
|
1062
|
+
const prefix = readablePrefix || "workspace";
|
|
1063
|
+
const hash = (0, import_node_crypto3.createHash)("sha256").update(WORKSPACE_IDENTITY_HASH_DOMAIN, "utf-8").update("\0", "utf-8").update(source, "utf-8").update("\0", "utf-8").update(String(attempt), "utf-8").digest("hex").slice(0, WORKSPACE_IDENTITY_HASH_LENGTH);
|
|
1064
|
+
return `${prefix}-${hash}`;
|
|
1065
|
+
}
|
|
1066
|
+
function workspaceIdentityCandidates(source) {
|
|
1067
|
+
const candidates = [];
|
|
1068
|
+
const legacy = wsFromBranch(source);
|
|
1069
|
+
if (legacy) candidates.push(legacy);
|
|
1070
|
+
for (let attempt = 0; candidates.length < WORKSPACE_IDENTITY_CANDIDATE_LIMIT; attempt += 1) {
|
|
1071
|
+
candidates.push(workspaceIdentityFallback(source, attempt));
|
|
1072
|
+
}
|
|
1073
|
+
return candidates;
|
|
1074
|
+
}
|
|
1060
1075
|
function isLinkedWorktree(repoPath) {
|
|
1061
1076
|
const gitPath = import_node_path4.default.join(repoPath, ".git");
|
|
1062
1077
|
let stat;
|
|
@@ -1138,8 +1153,8 @@ function persistWorkspace(repoPath, value) {
|
|
|
1138
1153
|
if (!gitDir) {
|
|
1139
1154
|
throw new Error(`cannot persist workspace identity: '${repoPath}' is not a Git checkout`);
|
|
1140
1155
|
}
|
|
1141
|
-
|
|
1142
|
-
|
|
1156
|
+
writeFileAtomically(import_node_path4.default.join(gitDir, WORKSPACE_METADATA_FILE), `${workspace}
|
|
1157
|
+
`);
|
|
1143
1158
|
return workspace;
|
|
1144
1159
|
}
|
|
1145
1160
|
async function withWorkspaceLifecycleLock(repoPath, operation) {
|
|
@@ -1198,15 +1213,21 @@ function resolveWorkspace(repoPath, override) {
|
|
|
1198
1213
|
}
|
|
1199
1214
|
return deriveLinkedWorktreeWorkspace(repoPath);
|
|
1200
1215
|
}
|
|
1201
|
-
var import_node_child_process3, import_node_fs5, import_node_path4, MAX_WORKSPACE_LENGTH, WORKSPACE_METADATA_FILE, WORKSPACE_LOCK_FILE;
|
|
1216
|
+
var import_node_child_process3, import_node_crypto3, import_node_fs5, import_node_path4, MAX_WORKSPACE_LENGTH, WORKSPACE_IDENTITY_CANDIDATE_LIMIT, WORKSPACE_IDENTITY_HASH_LENGTH, WORKSPACE_IDENTITY_PREFIX_LENGTH, WORKSPACE_IDENTITY_HASH_DOMAIN, WORKSPACE_METADATA_FILE, WORKSPACE_LOCK_FILE;
|
|
1202
1217
|
var init_workspace = __esm({
|
|
1203
1218
|
"src/core/workspace.ts"() {
|
|
1204
1219
|
"use strict";
|
|
1205
1220
|
import_node_child_process3 = require("child_process");
|
|
1221
|
+
import_node_crypto3 = require("crypto");
|
|
1206
1222
|
import_node_fs5 = __toESM(require("fs"));
|
|
1207
1223
|
import_node_path4 = __toESM(require("path"));
|
|
1224
|
+
init_atomic_file();
|
|
1208
1225
|
init_file_lock();
|
|
1209
1226
|
MAX_WORKSPACE_LENGTH = 32;
|
|
1227
|
+
WORKSPACE_IDENTITY_CANDIDATE_LIMIT = 16;
|
|
1228
|
+
WORKSPACE_IDENTITY_HASH_LENGTH = 8;
|
|
1229
|
+
WORKSPACE_IDENTITY_PREFIX_LENGTH = MAX_WORKSPACE_LENGTH - WORKSPACE_IDENTITY_HASH_LENGTH - 1;
|
|
1230
|
+
WORKSPACE_IDENTITY_HASH_DOMAIN = "devrouter-workspace-identity-v1";
|
|
1210
1231
|
WORKSPACE_METADATA_FILE = "devrouter-workspace";
|
|
1211
1232
|
WORKSPACE_LOCK_FILE = "devrouter-workspace.lock";
|
|
1212
1233
|
}
|
|
@@ -2509,7 +2530,7 @@ function loadRepoConfig(repoPath) {
|
|
|
2509
2530
|
const config = parseConfig(parsed ?? {}, configPath);
|
|
2510
2531
|
const requiredVersion = config.devrouter?.version;
|
|
2511
2532
|
if (requiredVersion && !hasWarnedVersionMismatch) {
|
|
2512
|
-
const cliVersion = true ? "0.0.
|
|
2533
|
+
const cliVersion = true ? "0.0.45" : "0.0.0-dev";
|
|
2513
2534
|
if (cliVersion !== "0.0.0-dev" && compareSemver(requiredVersion, cliVersion) > 0) {
|
|
2514
2535
|
hasWarnedVersionMismatch = true;
|
|
2515
2536
|
process.stderr.write(
|
|
@@ -3035,8 +3056,8 @@ function buildOnboardingPrompt(options = {}) {
|
|
|
3035
3056
|
"- Use `devrouter status --repo <REPO_PATH> --json` and `devrouter doctor --repo <REPO_PATH> --json` to inspect managed desired/active resources, fingerprints, transition state, and values-free drift.",
|
|
3036
3057
|
"",
|
|
3037
3058
|
"Workspace isolation (parallel git worktrees / agents):",
|
|
3038
|
-
`- 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
|
|
3039
|
-
"- The owner record survives linked-worktree removal and binds the exact path to its
|
|
3059
|
+
`- 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 workspace-runtime id, devrouter routes, the \`${WORKSPACE_PLACEHOLDER}\` proxy upstream, and devcontainer aliases.`,
|
|
3060
|
+
"- The owner record survives linked-worktree removal and binds the exact path to its workspace-runtime ID. First use reconciles persisted metadata, the exact-path owner record, and both DevPod and Devsy registries. It preserves an established agreement, uses the readable sanitized identity when free, or claims a deterministic hash-suffixed fallback on collision before provider or route mutation. Later flags or `DEVROUTER_WORKSPACE` may repeat but cannot rename it. Unreadable or conflicting evidence fails closed. The primary checkout stays non-namespaced.",
|
|
3040
3061
|
`- 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. Managed ensure rejects every HTTP/TCP upstream outside that exact alias namespace before mutation. The runtime config is computed in memory only \u2014 the committed \`.devrouter.yml\` is never rewritten.`,
|
|
3041
3062
|
"- 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.",
|
|
3042
3063
|
"- Lifecycle: after one-time setup, use `devrouter ensure .` for both primary and linked checkouts; never branch on checkout kind or use live verify as startup. Managed consumer images contain no devrouter package/helper: ensure delivers its matching helper at runtime and invokes an exact captured snapshot of the repository-owned post-start adapter. Keep `.devrouter.yml` as the only consumer-side version pin. Use `devrouter stop .` for a non-destructive pause, `devrouter stop . --delete` only for explicit exact-owner cleanup without removing the checkout, and `devrouter exec . -- <command...>` for container commands. Never substitute raw DevPod/Devsy mutations; they bypass the machine-global ownership lock. `workspace up` creates linked worktrees; destructive worktree removal and GC remain ledger-scoped.",
|
|
@@ -4110,7 +4131,7 @@ function readRegularFile(filePath) {
|
|
|
4110
4131
|
return readRegularFileBytes(filePath)?.toString("utf-8");
|
|
4111
4132
|
}
|
|
4112
4133
|
function adapterFingerprint(adapter) {
|
|
4113
|
-
return (0,
|
|
4134
|
+
return (0, import_node_crypto4.createHash)("sha256").update(adapter).digest("hex");
|
|
4114
4135
|
}
|
|
4115
4136
|
function resolveProcessHelperPath() {
|
|
4116
4137
|
const candidates = [
|
|
@@ -4257,12 +4278,12 @@ function runManagedProcessAction(options) {
|
|
|
4257
4278
|
}
|
|
4258
4279
|
return "drifted";
|
|
4259
4280
|
}
|
|
4260
|
-
var import_node_child_process5,
|
|
4281
|
+
var import_node_child_process5, import_node_crypto4, import_node_fs10, import_node_path9, MANAGED_MARKER, MANAGED_ADAPTER_PATH, RUNTIME_HELPER_PATH, ADAPTER_WRAPPER;
|
|
4261
4282
|
var init_managed_post_start = __esm({
|
|
4262
4283
|
"src/core/managed-post-start.ts"() {
|
|
4263
4284
|
"use strict";
|
|
4264
4285
|
import_node_child_process5 = require("child_process");
|
|
4265
|
-
|
|
4286
|
+
import_node_crypto4 = require("crypto");
|
|
4266
4287
|
import_node_fs10 = __toESM(require("fs"));
|
|
4267
4288
|
import_node_path9 = __toESM(require("path"));
|
|
4268
4289
|
init_devcontainer_config();
|
|
@@ -4764,7 +4785,7 @@ var init_routes = __esm({
|
|
|
4764
4785
|
|
|
4765
4786
|
// src/core/managed-runtime-state.ts
|
|
4766
4787
|
function stateKey(repoPath, workspace) {
|
|
4767
|
-
return (0,
|
|
4788
|
+
return (0, import_node_crypto5.createHash)("sha256").update(`${repoPath}\0${workspace ?? ""}`, "utf-8").digest("hex");
|
|
4768
4789
|
}
|
|
4769
4790
|
function managedRuntimeStatePath(repoPath, workspace) {
|
|
4770
4791
|
return import_node_path12.default.join(DEVROUTER_HOME, "managed-runtime", `${stateKey(repoPath, workspace)}.json`);
|
|
@@ -4855,11 +4876,11 @@ function markManagedRuntimeDegraded(state, transitionPhase) {
|
|
|
4855
4876
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4856
4877
|
});
|
|
4857
4878
|
}
|
|
4858
|
-
var
|
|
4879
|
+
var import_node_crypto5, import_node_fs13, import_node_path12;
|
|
4859
4880
|
var init_managed_runtime_state = __esm({
|
|
4860
4881
|
"src/core/managed-runtime-state.ts"() {
|
|
4861
4882
|
"use strict";
|
|
4862
|
-
|
|
4883
|
+
import_node_crypto5 = require("crypto");
|
|
4863
4884
|
import_node_fs13 = __toESM(require("fs"));
|
|
4864
4885
|
import_node_path12 = __toESM(require("path"));
|
|
4865
4886
|
init_atomic_file();
|
|
@@ -4992,7 +5013,7 @@ function isWildcard(values) {
|
|
|
4992
5013
|
return values?.length === 1 && values[0] === "*";
|
|
4993
5014
|
}
|
|
4994
5015
|
function sha256(contents) {
|
|
4995
|
-
return (0,
|
|
5016
|
+
return (0, import_node_crypto6.createHash)("sha256").update(contents, "utf-8").digest("hex");
|
|
4996
5017
|
}
|
|
4997
5018
|
function safeComposeProject(value) {
|
|
4998
5019
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) {
|
|
@@ -5177,12 +5198,12 @@ function stopExactManagedService(containerId, service) {
|
|
|
5177
5198
|
throw new Error(`Could not stop exact managed service '${service}' (${containerId}).`);
|
|
5178
5199
|
}
|
|
5179
5200
|
}
|
|
5180
|
-
var import_node_child_process6,
|
|
5201
|
+
var import_node_child_process6, import_node_crypto6, import_node_fs14, import_node_path13, import_yaml4, MANAGED_DEVCONTAINER_PATH, MANAGED_DEVCONTAINER_MARKER;
|
|
5181
5202
|
var init_devcontainer_profile = __esm({
|
|
5182
5203
|
"src/core/devcontainer-profile.ts"() {
|
|
5183
5204
|
"use strict";
|
|
5184
5205
|
import_node_child_process6 = require("child_process");
|
|
5185
|
-
|
|
5206
|
+
import_node_crypto6 = require("crypto");
|
|
5186
5207
|
import_node_fs14 = __toESM(require("fs"));
|
|
5187
5208
|
import_node_path13 = __toESM(require("path"));
|
|
5188
5209
|
import_yaml4 = __toESM(require("yaml"));
|
|
@@ -5954,7 +5975,7 @@ function parseDnsHostsFromSubjectAltName(subjectAltName) {
|
|
|
5954
5975
|
return normalizeUniqueHosts(names);
|
|
5955
5976
|
}
|
|
5956
5977
|
function parseCertificateDnsHosts(pem) {
|
|
5957
|
-
const certificate = new
|
|
5978
|
+
const certificate = new import_node_crypto7.X509Certificate(pem);
|
|
5958
5979
|
const subjectAltName = certificate.subjectAltName ?? "";
|
|
5959
5980
|
if (subjectAltName.length === 0) {
|
|
5960
5981
|
return [];
|
|
@@ -6154,12 +6175,12 @@ Run: ${tlsSetupCommand(options.repoPath)}`
|
|
|
6154
6175
|
);
|
|
6155
6176
|
}
|
|
6156
6177
|
}
|
|
6157
|
-
var import_node_child_process8,
|
|
6178
|
+
var import_node_child_process8, import_node_crypto7, import_node_fs16, import_node_path15, DEFAULT_TLS_CERT_HOSTS, TLS_CERTIFICATE_LOCK_FILE, TLS_CERTIFICATE_LOCK_WAIT_MS, TLS_CERTIFICATE_WRITE_ATTEMPTS;
|
|
6158
6179
|
var init_tls = __esm({
|
|
6159
6180
|
"src/core/tls.ts"() {
|
|
6160
6181
|
"use strict";
|
|
6161
6182
|
import_node_child_process8 = require("child_process");
|
|
6162
|
-
|
|
6183
|
+
import_node_crypto7 = require("crypto");
|
|
6163
6184
|
import_node_fs16 = __toESM(require("fs"));
|
|
6164
6185
|
import_node_path15 = __toESM(require("path"));
|
|
6165
6186
|
init_docker();
|
|
@@ -7455,12 +7476,16 @@ function removeWorkspaceOwnershipIfMatchesInDirectory(directory, expected) {
|
|
|
7455
7476
|
import_node_fs21.default.rmSync(filePath);
|
|
7456
7477
|
return "removed";
|
|
7457
7478
|
}
|
|
7458
|
-
function withWorkspaceOwnershipTransaction(repoPath, operation) {
|
|
7479
|
+
function withWorkspaceOwnershipTransaction(repoPath, operation, options = {}) {
|
|
7459
7480
|
const directory = ownershipDirectory(repoPath);
|
|
7460
7481
|
import_node_fs21.default.mkdirSync(directory, { recursive: true });
|
|
7461
7482
|
return withFileLockSync(
|
|
7462
7483
|
import_node_path20.default.join(directory, ".lock"),
|
|
7463
|
-
{
|
|
7484
|
+
{
|
|
7485
|
+
activity: "workspace ownership transaction",
|
|
7486
|
+
target: `'${repoPath}'`,
|
|
7487
|
+
waitMs: options.waitMs ?? 5e3
|
|
7488
|
+
},
|
|
7464
7489
|
() => operation({
|
|
7465
7490
|
list: () => listWorkspaceOwnershipInDirectory(directory),
|
|
7466
7491
|
write: (input2) => writeWorkspaceOwnershipInDirectory(directory, input2),
|
|
@@ -7469,8 +7494,169 @@ function withWorkspaceOwnershipTransaction(repoPath, operation) {
|
|
|
7469
7494
|
})
|
|
7470
7495
|
);
|
|
7471
7496
|
}
|
|
7472
|
-
function
|
|
7473
|
-
|
|
7497
|
+
function providerPathOwner(providerWorkspaces, worktreePath) {
|
|
7498
|
+
const owners = providerWorkspaces.filter(
|
|
7499
|
+
(workspace) => sameWorkspacePath(workspace.source.localFolder, worktreePath)
|
|
7500
|
+
);
|
|
7501
|
+
if (owners.length > 1) {
|
|
7502
|
+
throw new Error(
|
|
7503
|
+
`Worktree '${worktreePath}' is registered by multiple workspace runtimes; no identity was claimed.`
|
|
7504
|
+
);
|
|
7505
|
+
}
|
|
7506
|
+
return owners[0];
|
|
7507
|
+
}
|
|
7508
|
+
function persistedWorkspaceOwners(repoPath, worktreePath) {
|
|
7509
|
+
const owners = /* @__PURE__ */ new Map();
|
|
7510
|
+
for (const worktree of listGitWorktrees(repoPath)) {
|
|
7511
|
+
if (sameWorkspacePath(worktree.path, worktreePath)) continue;
|
|
7512
|
+
let workspace;
|
|
7513
|
+
try {
|
|
7514
|
+
workspace = readPersistedWorkspace(worktree.path);
|
|
7515
|
+
} catch (error) {
|
|
7516
|
+
if (!import_node_fs21.default.existsSync(worktree.path)) continue;
|
|
7517
|
+
throw error;
|
|
7518
|
+
}
|
|
7519
|
+
if (!workspace) continue;
|
|
7520
|
+
const existing = owners.get(workspace);
|
|
7521
|
+
if (existing && !sameWorkspacePath(existing, worktree.path)) {
|
|
7522
|
+
throw new Error(`Persisted workspace identity '${workspace}' belongs to multiple worktrees.`);
|
|
7523
|
+
}
|
|
7524
|
+
owners.set(workspace, worktree.path);
|
|
7525
|
+
}
|
|
7526
|
+
return owners;
|
|
7527
|
+
}
|
|
7528
|
+
function claimConflict(workspace, devpodId, worktreePath, records, providerWorkspaces, persistedOwners, exactRecord) {
|
|
7529
|
+
const recordOwner = records.find(
|
|
7530
|
+
(record) => record !== exactRecord && (record.workspace === workspace || record.devpodId === devpodId)
|
|
7531
|
+
);
|
|
7532
|
+
if (recordOwner) {
|
|
7533
|
+
return `workspace owner record '${recordOwner.workspace}' for '${recordOwner.worktreePath}'`;
|
|
7534
|
+
}
|
|
7535
|
+
const providerOwner = providerWorkspaces.find(
|
|
7536
|
+
(providerWorkspace) => providerWorkspace.id === devpodId && !sameWorkspacePath(providerWorkspace.source.localFolder, worktreePath)
|
|
7537
|
+
);
|
|
7538
|
+
if (providerOwner) {
|
|
7539
|
+
return `workspace runtime identity '${providerOwner.id}' already belongs to '${providerOwner.source.localFolder}'`;
|
|
7540
|
+
}
|
|
7541
|
+
const persistedOwner = persistedOwners.get(workspace);
|
|
7542
|
+
if (persistedOwner) {
|
|
7543
|
+
return `persisted checkout metadata for '${persistedOwner}'`;
|
|
7544
|
+
}
|
|
7545
|
+
return void 0;
|
|
7546
|
+
}
|
|
7547
|
+
function claimWorkspaceIdentity(repoPath, input2) {
|
|
7548
|
+
const worktreePath = comparableWorkspacePath(repoPath);
|
|
7549
|
+
const exactProvider = providerPathOwner(input2.providerWorkspaces, worktreePath);
|
|
7550
|
+
return withWorkspaceOwnershipTransaction(repoPath, (transaction) => {
|
|
7551
|
+
const records = transaction.list();
|
|
7552
|
+
const exactRecords = records.filter(
|
|
7553
|
+
(record) => sameWorkspacePath(record.worktreePath, worktreePath)
|
|
7554
|
+
);
|
|
7555
|
+
if (exactRecords.length > 1) {
|
|
7556
|
+
throw new Error(
|
|
7557
|
+
`Worktree '${worktreePath}' has multiple workspace owner records; no identity was claimed.`
|
|
7558
|
+
);
|
|
7559
|
+
}
|
|
7560
|
+
const exactRecord = exactRecords[0];
|
|
7561
|
+
const persisted = readPersistedWorkspace(worktreePath);
|
|
7562
|
+
const persistedOwners = persistedWorkspaceOwners(repoPath, worktreePath);
|
|
7563
|
+
if (exactRecord) {
|
|
7564
|
+
if (persisted && persisted !== exactRecord.workspace) {
|
|
7565
|
+
throw new Error(
|
|
7566
|
+
`Persisted workspace identity '${persisted}' disagrees with owner record '${exactRecord.workspace}'.`
|
|
7567
|
+
);
|
|
7568
|
+
}
|
|
7569
|
+
if (exactProvider && exactProvider.id !== exactRecord.devpodId) {
|
|
7570
|
+
throw new Error(
|
|
7571
|
+
`Workspace runtime '${exactProvider.id}' disagrees with owner record '${exactRecord.devpodId}'.`
|
|
7572
|
+
);
|
|
7573
|
+
}
|
|
7574
|
+
const conflict2 = claimConflict(
|
|
7575
|
+
exactRecord.workspace,
|
|
7576
|
+
exactRecord.devpodId,
|
|
7577
|
+
worktreePath,
|
|
7578
|
+
records,
|
|
7579
|
+
input2.providerWorkspaces,
|
|
7580
|
+
persistedOwners,
|
|
7581
|
+
exactRecord
|
|
7582
|
+
);
|
|
7583
|
+
if (conflict2) {
|
|
7584
|
+
throw new Error(
|
|
7585
|
+
`Workspace '${exactRecord.workspace}' conflicts with ${conflict2}; no identity was claimed.`
|
|
7586
|
+
);
|
|
7587
|
+
}
|
|
7588
|
+
if (!persisted) persistWorkspace(worktreePath, exactRecord.workspace);
|
|
7589
|
+
return transaction.write({
|
|
7590
|
+
workspace: exactRecord.workspace,
|
|
7591
|
+
worktreePath,
|
|
7592
|
+
branch: input2.branch ?? null,
|
|
7593
|
+
devpodId: exactRecord.devpodId
|
|
7594
|
+
});
|
|
7595
|
+
}
|
|
7596
|
+
if (persisted && exactProvider && persisted !== exactProvider.id) {
|
|
7597
|
+
throw new Error(
|
|
7598
|
+
`Persisted workspace identity '${persisted}' disagrees with workspace runtime '${exactProvider.id}'.`
|
|
7599
|
+
);
|
|
7600
|
+
}
|
|
7601
|
+
let workspace = persisted ?? exactProvider?.id;
|
|
7602
|
+
let devpodId = exactProvider?.id ?? persisted;
|
|
7603
|
+
if (!workspace || !devpodId) {
|
|
7604
|
+
if (input2.unavailableRuntimes.length > 0) {
|
|
7605
|
+
throw new Error(
|
|
7606
|
+
`Cannot claim a new workspace identity because these runtime registries are unavailable: ${input2.unavailableRuntimes.join(", ")}.`
|
|
7607
|
+
);
|
|
7608
|
+
}
|
|
7609
|
+
const candidate = workspaceIdentityCandidates(input2.source).find(
|
|
7610
|
+
(next) => !claimConflict(
|
|
7611
|
+
next,
|
|
7612
|
+
next,
|
|
7613
|
+
worktreePath,
|
|
7614
|
+
records,
|
|
7615
|
+
input2.providerWorkspaces,
|
|
7616
|
+
persistedOwners
|
|
7617
|
+
)
|
|
7618
|
+
);
|
|
7619
|
+
if (!candidate) {
|
|
7620
|
+
throw new Error(
|
|
7621
|
+
`Could not allocate a collision-safe workspace identity for '${worktreePath}'.`
|
|
7622
|
+
);
|
|
7623
|
+
}
|
|
7624
|
+
workspace = candidate;
|
|
7625
|
+
devpodId = candidate;
|
|
7626
|
+
}
|
|
7627
|
+
const conflict = claimConflict(
|
|
7628
|
+
workspace,
|
|
7629
|
+
devpodId,
|
|
7630
|
+
worktreePath,
|
|
7631
|
+
records,
|
|
7632
|
+
input2.providerWorkspaces,
|
|
7633
|
+
persistedOwners
|
|
7634
|
+
);
|
|
7635
|
+
if (conflict) {
|
|
7636
|
+
throw new Error(
|
|
7637
|
+
`Workspace '${workspace}' conflicts with ${conflict}; no identity was claimed.`
|
|
7638
|
+
);
|
|
7639
|
+
}
|
|
7640
|
+
const written = transaction.write({
|
|
7641
|
+
workspace,
|
|
7642
|
+
worktreePath,
|
|
7643
|
+
branch: input2.branch ?? null,
|
|
7644
|
+
devpodId
|
|
7645
|
+
});
|
|
7646
|
+
try {
|
|
7647
|
+
persistWorkspace(worktreePath, workspace);
|
|
7648
|
+
} catch (error) {
|
|
7649
|
+
const cleanup = transaction.removeIfMatches(written);
|
|
7650
|
+
if (cleanup !== "removed") {
|
|
7651
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
7652
|
+
throw new Error(
|
|
7653
|
+
`Could not persist workspace identity and owner-record rollback was '${cleanup}': ${detail}`
|
|
7654
|
+
);
|
|
7655
|
+
}
|
|
7656
|
+
throw error;
|
|
7657
|
+
}
|
|
7658
|
+
return written;
|
|
7659
|
+
});
|
|
7474
7660
|
}
|
|
7475
7661
|
function removeWorkspaceOwnership(repoPath, workspace) {
|
|
7476
7662
|
return withWorkspaceOwnershipTransaction(
|
|
@@ -8153,7 +8339,7 @@ async function buildDoctorReport(options = {}) {
|
|
|
8153
8339
|
const config = runtimeConfig.config;
|
|
8154
8340
|
loadedConfig = config;
|
|
8155
8341
|
loadedWorkspace = runtimeConfig.workspace;
|
|
8156
|
-
const cliVersion = true ? "0.0.
|
|
8342
|
+
const cliVersion = true ? "0.0.45" : "0.0.0-dev";
|
|
8157
8343
|
const configVersion = config.devrouter?.version;
|
|
8158
8344
|
if (configVersion && cliVersion !== "0.0.0-dev" && compareSemver(configVersion, cliVersion) > 0) {
|
|
8159
8345
|
addCheck(checks, {
|
|
@@ -9229,25 +9415,30 @@ async function waitForHttpRoutes(repoPath, apps, timeoutMs) {
|
|
|
9229
9415
|
);
|
|
9230
9416
|
}
|
|
9231
9417
|
function resolveLinkedTarget(repoPath) {
|
|
9232
|
-
const
|
|
9233
|
-
const
|
|
9234
|
-
|
|
9235
|
-
|
|
9236
|
-
|
|
9237
|
-
|
|
9238
|
-
|
|
9239
|
-
|
|
9240
|
-
|
|
9418
|
+
const snapshots = getWorkspaceRegistrySnapshots();
|
|
9419
|
+
const providerWorkspaces = [
|
|
9420
|
+
...snapshots.devpod ?? [],
|
|
9421
|
+
...snapshots.devsy?.map((workspace) => ({
|
|
9422
|
+
id: workspace.id,
|
|
9423
|
+
source: workspace.source,
|
|
9424
|
+
...workspace.lastUsed ? { lastUsed: workspace.lastUsed } : {},
|
|
9425
|
+
...workspace.lastUsedMalformed ? { lastUsedMalformed: true } : {}
|
|
9426
|
+
})) ?? []
|
|
9427
|
+
];
|
|
9428
|
+
const branch = currentBranch(repoPath);
|
|
9429
|
+
const claim = claimWorkspaceIdentity(repoPath, {
|
|
9430
|
+
source: branch ?? repoPath,
|
|
9431
|
+
branch: branch ?? null,
|
|
9432
|
+
providerWorkspaces,
|
|
9433
|
+
unavailableRuntimes: snapshots.unavailable
|
|
9434
|
+
});
|
|
9435
|
+
const existingDevpod = providerWorkspaces.find(
|
|
9436
|
+
(workspace) => workspace.id === claim.devpodId && sameWorkspacePath(workspace.source.localFolder, repoPath)
|
|
9241
9437
|
);
|
|
9242
|
-
if (otherOwner) {
|
|
9243
|
-
throw new Error(
|
|
9244
|
-
`DevPod identity '${candidate}' already belongs to '${otherOwner.source.localFolder}'.`
|
|
9245
|
-
);
|
|
9246
|
-
}
|
|
9247
9438
|
return {
|
|
9248
9439
|
kind: "linked",
|
|
9249
|
-
workspace:
|
|
9250
|
-
devpodId:
|
|
9440
|
+
workspace: claim.workspace,
|
|
9441
|
+
devpodId: claim.devpodId,
|
|
9251
9442
|
hadExactDevpod: Boolean(existingDevpod),
|
|
9252
9443
|
gitCommonDir: resolveGitCommonDir(repoPath)
|
|
9253
9444
|
};
|
|
@@ -9394,15 +9585,6 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
|
|
|
9394
9585
|
managedConfigWritten = true;
|
|
9395
9586
|
}
|
|
9396
9587
|
const upstreamHosts = parsedUpstreams.map((upstream) => upstream.host);
|
|
9397
|
-
const ownership = target.kind === "linked" ? {
|
|
9398
|
-
workspace: target.workspace,
|
|
9399
|
-
worktreePath: repoPath,
|
|
9400
|
-
branch: currentBranch(repoPath),
|
|
9401
|
-
devpodId: target.devpodId
|
|
9402
|
-
} : void 0;
|
|
9403
|
-
if (ownership) {
|
|
9404
|
-
writeWorkspaceOwnership(repoPath, ownership);
|
|
9405
|
-
}
|
|
9406
9588
|
const currentTarget = () => target.kind === "linked" ? target : { ...target, devpodId };
|
|
9407
9589
|
const startAndProveAttachment = (recreate = false) => {
|
|
9408
9590
|
const requestedTarget = currentTarget();
|
|
@@ -9425,9 +9607,6 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
|
|
|
9425
9607
|
if (error instanceof DevpodStartPostconditionError) environmentStarted = true;
|
|
9426
9608
|
throw error;
|
|
9427
9609
|
}
|
|
9428
|
-
if (ownership) {
|
|
9429
|
-
writeWorkspaceOwnership(repoPath, ownership);
|
|
9430
|
-
}
|
|
9431
9610
|
};
|
|
9432
9611
|
const preflight = (timeoutMs) => waitForContainerPreflight(repoPath, currentTarget(), upstreamHosts, timeoutMs);
|
|
9433
9612
|
const recreateAndPreflight = async () => {
|
|
@@ -9845,6 +10024,7 @@ var init_workspace_ensure = __esm({
|
|
|
9845
10024
|
init_router();
|
|
9846
10025
|
init_workspace();
|
|
9847
10026
|
init_workspace_ownership();
|
|
10027
|
+
init_workspace_runtime();
|
|
9848
10028
|
DEVCONTAINER_OVERLAY = "docker-compose.devrouter.yml";
|
|
9849
10029
|
DEFAULT_READINESS_TIMEOUT_MS = 12e4;
|
|
9850
10030
|
POLL_INTERVAL_MS = 1e3;
|
|
@@ -9901,6 +10081,11 @@ var init_ensure = __esm({
|
|
|
9901
10081
|
});
|
|
9902
10082
|
|
|
9903
10083
|
// src/core/workspace-lifecycle.ts
|
|
10084
|
+
function withWorkspaceAllocationTransaction(repoPath, operation) {
|
|
10085
|
+
return withWorkspaceOwnershipTransaction(repoPath, operation, {
|
|
10086
|
+
waitMs: WORKSPACE_ALLOCATION_LOCK_WAIT_MS
|
|
10087
|
+
});
|
|
10088
|
+
}
|
|
9904
10089
|
function warnMissingWorkspaceOwnership(repoPath) {
|
|
9905
10090
|
const missing = listMissingWorkspaceOwnership(repoPath);
|
|
9906
10091
|
if (missing.length === 0) return;
|
|
@@ -10062,44 +10247,67 @@ async function workspaceUp(branch, opts = {}) {
|
|
|
10062
10247
|
if (!ws) {
|
|
10063
10248
|
throw new Error(`Branch '${branch}' does not yield a valid workspace token.`);
|
|
10064
10249
|
}
|
|
10065
|
-
const worktreePath =
|
|
10066
|
-
|
|
10067
|
-
const
|
|
10068
|
-
|
|
10250
|
+
const worktreePath = withWorkspaceAllocationTransaction(mainRepo, () => {
|
|
10251
|
+
const worktrees = listGitWorktrees(mainRepo);
|
|
10252
|
+
const branchWorktrees = worktrees.filter((worktree) => worktree.branch === branch);
|
|
10253
|
+
if (branchWorktrees.length > 1) {
|
|
10254
|
+
throw new Error(
|
|
10255
|
+
`Branch '${branch}' is checked out in multiple worktrees: ${branchWorktrees.map((worktree) => worktree.path).join(", ")}`
|
|
10256
|
+
);
|
|
10257
|
+
}
|
|
10258
|
+
const existingBranch = branchWorktrees[0];
|
|
10259
|
+
const candidatePaths = workspaceIdentityCandidates(branch).map(
|
|
10260
|
+
(candidate) => defaultWorktreePath(mainRepo, candidate)
|
|
10261
|
+
);
|
|
10262
|
+
const generatedPath = candidatePaths.find(
|
|
10263
|
+
(candidate) => !import_node_fs24.default.existsSync(candidate) && !worktrees.some((worktree) => sameWorkspacePath(worktree.path, candidate))
|
|
10069
10264
|
);
|
|
10070
|
-
|
|
10071
|
-
|
|
10265
|
+
const selectedPath = opts.path ? import_node_path24.default.resolve(opts.path) : existingBranch?.path ?? generatedPath;
|
|
10266
|
+
if (!selectedPath) {
|
|
10267
|
+
throw new Error(`Could not allocate a collision-safe worktree path for branch '${branch}'.`);
|
|
10072
10268
|
}
|
|
10073
|
-
if (
|
|
10269
|
+
if (existingBranch && !sameWorkspacePath(existingBranch.path, selectedPath)) {
|
|
10074
10270
|
throw new Error(
|
|
10075
|
-
`
|
|
10271
|
+
`Branch '${branch}' already uses worktree '${existingBranch.path}', not '${selectedPath}'.`
|
|
10076
10272
|
);
|
|
10077
10273
|
}
|
|
10078
|
-
|
|
10274
|
+
if (import_node_fs24.default.existsSync(selectedPath)) {
|
|
10275
|
+
const registered = worktrees.find(
|
|
10276
|
+
(worktree) => sameWorkspacePath(worktree.path, selectedPath)
|
|
10277
|
+
);
|
|
10278
|
+
if (!registered || sameWorkspacePath(registered.path, mainRepo)) {
|
|
10279
|
+
throw new Error(
|
|
10280
|
+
`Existing path '${selectedPath}' is not a linked worktree of '${mainRepo}'.`
|
|
10281
|
+
);
|
|
10282
|
+
}
|
|
10283
|
+
if (registered.branch !== branch) {
|
|
10284
|
+
throw new Error(
|
|
10285
|
+
`Existing worktree '${selectedPath}' uses branch '${registered.branch ?? "detached"}', not '${branch}'.`
|
|
10286
|
+
);
|
|
10287
|
+
}
|
|
10288
|
+
process.stdout.write(`Worktree already exists: ${selectedPath}
|
|
10079
10289
|
`);
|
|
10080
|
-
|
|
10081
|
-
if (!opts.path) {
|
|
10082
|
-
assertDefaultWorktreeRootIgnored(mainRepo);
|
|
10290
|
+
return selectedPath;
|
|
10083
10291
|
}
|
|
10084
|
-
|
|
10292
|
+
if (!opts.path) assertDefaultWorktreeRootIgnored(mainRepo);
|
|
10293
|
+
const add = (0, import_node_child_process20.spawnSync)("git", ["-C", mainRepo, "worktree", "add", selectedPath, branch], {
|
|
10085
10294
|
encoding: "utf-8"
|
|
10086
10295
|
});
|
|
10087
10296
|
if (add.status !== 0) {
|
|
10088
10297
|
const addNew = (0, import_node_child_process20.spawnSync)(
|
|
10089
10298
|
"git",
|
|
10090
|
-
["-C", mainRepo, "worktree", "add", "-b", branch,
|
|
10091
|
-
{
|
|
10092
|
-
encoding: "utf-8"
|
|
10093
|
-
}
|
|
10299
|
+
["-C", mainRepo, "worktree", "add", "-b", branch, selectedPath],
|
|
10300
|
+
{ encoding: "utf-8" }
|
|
10094
10301
|
);
|
|
10095
10302
|
if (addNew.status !== 0) {
|
|
10096
|
-
const detail = [add.stderr, addNew.stderr].map((
|
|
10303
|
+
const detail = [add.stderr, addNew.stderr].map((stderr) => stderr?.trim()).filter(Boolean).join("; ");
|
|
10097
10304
|
throw new Error(`git worktree add failed: ${detail || "unknown error"}`);
|
|
10098
10305
|
}
|
|
10099
10306
|
}
|
|
10100
|
-
process.stdout.write(`Created worktree ${
|
|
10307
|
+
process.stdout.write(`Created worktree ${selectedPath}
|
|
10101
10308
|
`);
|
|
10102
|
-
|
|
10309
|
+
return selectedPath;
|
|
10310
|
+
});
|
|
10103
10311
|
if (opts.noDevpod) {
|
|
10104
10312
|
warnMissingWorkspaceOwnership(mainRepo);
|
|
10105
10313
|
process.stdout.write("Skipped environment startup; no routes were changed.\n");
|
|
@@ -10242,7 +10450,7 @@ async function workspaceStop(target, opts = {}) {
|
|
|
10242
10450
|
async function workspaceDown(target, opts = {}) {
|
|
10243
10451
|
return runWorkspaceLifecycle("down", target, opts);
|
|
10244
10452
|
}
|
|
10245
|
-
var import_node_child_process20, import_node_fs24, import_node_path24;
|
|
10453
|
+
var import_node_child_process20, import_node_fs24, import_node_path24, WORKSPACE_ALLOCATION_LOCK_WAIT_MS;
|
|
10246
10454
|
var init_workspace_lifecycle = __esm({
|
|
10247
10455
|
"src/core/workspace-lifecycle.ts"() {
|
|
10248
10456
|
"use strict";
|
|
@@ -10256,6 +10464,7 @@ var init_workspace_lifecycle = __esm({
|
|
|
10256
10464
|
init_workspace();
|
|
10257
10465
|
init_workspace_ensure();
|
|
10258
10466
|
init_workspace_ownership();
|
|
10467
|
+
WORKSPACE_ALLOCATION_LOCK_WAIT_MS = 6e4;
|
|
10259
10468
|
}
|
|
10260
10469
|
});
|
|
10261
10470
|
|
|
@@ -10449,7 +10658,7 @@ async function devpodExec(repoPath, command) {
|
|
|
10449
10658
|
}
|
|
10450
10659
|
assertRunningDevpod(devpod.id, repoPath);
|
|
10451
10660
|
const workspaceDirectory = resolveWorkspaceDirectory(repoPath);
|
|
10452
|
-
const statusMarker = `__DEVROUTER_EXIT_${(0,
|
|
10661
|
+
const statusMarker = `__DEVROUTER_EXIT_${(0, import_node_crypto8.randomUUID)()}__:`;
|
|
10453
10662
|
const statusMarkerBytes = Buffer.from(statusMarker, "ascii");
|
|
10454
10663
|
const literalCommand = command.map(quotePosixArg).join(" ");
|
|
10455
10664
|
const wrappedCommand = `${literalCommand}; __devrouter_status=$?; printf '${statusMarker}%s\\n' "$__devrouter_status" >&2; exit 0`;
|
|
@@ -10526,12 +10735,12 @@ async function devpodExec(repoPath, command) {
|
|
|
10526
10735
|
});
|
|
10527
10736
|
});
|
|
10528
10737
|
}
|
|
10529
|
-
var import_node_child_process22,
|
|
10738
|
+
var import_node_child_process22, import_node_crypto8, DEVPOD_MISSING_EXIT_STATUS_DIAGNOSTIC;
|
|
10530
10739
|
var init_devpod_exec = __esm({
|
|
10531
10740
|
"src/core/devpod-exec.ts"() {
|
|
10532
10741
|
"use strict";
|
|
10533
10742
|
import_node_child_process22 = require("child_process");
|
|
10534
|
-
|
|
10743
|
+
import_node_crypto8 = require("crypto");
|
|
10535
10744
|
init_devpod_environment();
|
|
10536
10745
|
init_devpod_workspaces();
|
|
10537
10746
|
init_devsy_exec();
|
|
@@ -12356,7 +12565,7 @@ function sanitizeRouterId(value) {
|
|
|
12356
12565
|
return value.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
12357
12566
|
}
|
|
12358
12567
|
function repoHash(repoPath) {
|
|
12359
|
-
return (0,
|
|
12568
|
+
return (0, import_node_crypto9.createHash)("sha1").update(import_node_path28.default.resolve(repoPath)).digest("hex").slice(0, 12);
|
|
12360
12569
|
}
|
|
12361
12570
|
function asDockerApp(app) {
|
|
12362
12571
|
return app.runtime === "docker";
|
|
@@ -12559,12 +12768,12 @@ function queryMappedPort(repoPath, composeFiles2, overlayPath, service, internal
|
|
|
12559
12768
|
const port = Number(match[1]);
|
|
12560
12769
|
return Number.isInteger(port) && port > 0 ? port : void 0;
|
|
12561
12770
|
}
|
|
12562
|
-
var import_node_child_process25,
|
|
12771
|
+
var import_node_child_process25, import_node_crypto9, import_node_fs28, import_node_path28, import_yaml7;
|
|
12563
12772
|
var init_docker_run = __esm({
|
|
12564
12773
|
"src/core/docker-run.ts"() {
|
|
12565
12774
|
"use strict";
|
|
12566
12775
|
import_node_child_process25 = require("child_process");
|
|
12567
|
-
|
|
12776
|
+
import_node_crypto9 = require("crypto");
|
|
12568
12777
|
import_node_fs28 = __toESM(require("fs"));
|
|
12569
12778
|
import_node_path28 = __toESM(require("path"));
|
|
12570
12779
|
import_yaml7 = __toESM(require("yaml"));
|
|
@@ -14359,7 +14568,7 @@ var init_version = __esm({
|
|
|
14359
14568
|
|
|
14360
14569
|
// src/cli.ts
|
|
14361
14570
|
var import_commander = require("commander");
|
|
14362
|
-
var CLI_VERSION = true ? "0.0.
|
|
14571
|
+
var CLI_VERSION = true ? "0.0.45" : "0.0.0-dev";
|
|
14363
14572
|
var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
|
|
14364
14573
|
function withErrorHandling(action2) {
|
|
14365
14574
|
return async (...args) => {
|
package/package.json
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Upgrade to devrouter 0.0.45
|
|
2
|
+
|
|
3
|
+
Parallel linked worktrees now claim collision-safe workspace identities across
|
|
4
|
+
mixed DevPod and Devsy fleets.
|
|
5
|
+
|
|
6
|
+
1. Install `@devrouter/cli@0.0.45` on the host and bump `.devrouter.yml` to
|
|
7
|
+
`devrouter.version: 0.0.45`.
|
|
8
|
+
2. Keep using `devrouter ensure <checkout>` or `devrouter workspace up
|
|
9
|
+
<branch>`. Do not derive workspace ids or worktree paths in wrapper scripts.
|
|
10
|
+
3. Existing persisted workspace identities stay unchanged. A genuinely new
|
|
11
|
+
checkout keeps its readable branch/path identity when free and receives a
|
|
12
|
+
deterministic hash-suffixed fallback only when another checkout or provider
|
|
13
|
+
already occupies that identity.
|
|
14
|
+
4. Treat an unreadable DevPod or Devsy registry as a startup blocker for a new
|
|
15
|
+
claim. Devrouter fails before provider, process, service, or route mutation.
|
|
16
|
+
|
|
17
|
+
Verification:
|
|
18
|
+
|
|
19
|
+
- Run `devrouter -V --repo <checkout>` and confirm the installed CLI and local
|
|
20
|
+
repository report `0.0.45`.
|
|
21
|
+
- Start two new linked checkouts whose branch names share the first 32
|
|
22
|
+
sanitized characters, one with DevPod and one with Devsy.
|
|
23
|
+
- Require distinct persisted workspace ids, exact provider registrations, and
|
|
24
|
+
namespaced routes. Stop each checkout with `devrouter stop <checkout>` and
|
|
25
|
+
require both route sets to disappear.
|
|
26
|
+
|
|
27
|
+
Report template:
|
|
28
|
+
|
|
29
|
+
- CLI/config version: `0.0.45`
|
|
30
|
+
- Colliding-prefix identities: `<passed or details>`
|
|
31
|
+
- DevPod/Devsy ownership: `<passed or details>`
|
|
32
|
+
- Exact stop and route release: `<passed or details>`
|
|
33
|
+
- Remaining blockers: `<none or details>`
|