@bridge_gpt/mcp-server 0.2.59 → 0.2.62

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.
Files changed (78) hide show
  1. package/README.md +181 -51
  2. package/build/agent-guidance.generated.js +1 -1
  3. package/build/agent-notes.js +7 -1
  4. package/build/commands.generated.js +6 -6
  5. package/build/conductor/bridge-api-client.js +406 -4
  6. package/build/conductor/doctor.js +255 -53
  7. package/build/conductor/epic-runtime.js +11 -2
  8. package/build/conductor/install-doctor.js +24 -2
  9. package/build/conductor/local-merge.js +319 -26
  10. package/build/conductor/merge-execution-contract.js +86 -0
  11. package/build/conductor/merge-execution-eligibility.js +130 -0
  12. package/build/conductor/readiness-sections.js +256 -16
  13. package/build/conductor/readiness.js +203 -29
  14. package/build/conductor/recovery-operations.js +8 -2
  15. package/build/connect-bitbucket-api.js +6 -1
  16. package/build/connect-bitbucket.js +398 -88
  17. package/build/credential-store.js +172 -0
  18. package/build/docs.generated.js +2 -2
  19. package/build/doctor.js +277 -15
  20. package/build/drive-epic.js +147 -12
  21. package/build/epic-implementer-bin.js +15 -15
  22. package/build/epic-implementer-claude-hook-bin.js +1 -1
  23. package/build/epic-implementer-env.js +43 -14
  24. package/build/epic-integration-pr.js +61 -15
  25. package/build/epic-launch-profile.js +455 -0
  26. package/build/executor/ci-rerun-job.js +269 -0
  27. package/build/executor/cli.js +40 -0
  28. package/build/executor/credentials.js +28 -1
  29. package/build/executor/env.js +60 -0
  30. package/build/executor/http-client.js +45 -1
  31. package/build/executor/index.js +21 -4
  32. package/build/executor/integration-job.js +759 -0
  33. package/build/executor/job-runner.js +218 -9
  34. package/build/executor/job-types.js +12 -2
  35. package/build/executor/merge-job.js +11 -2
  36. package/build/executor/observation.js +114 -7
  37. package/build/executor/runner.js +101 -1
  38. package/build/executor/service-lifecycle.js +136 -0
  39. package/build/executor/service-unit.js +427 -9
  40. package/build/executor/types.js +48 -0
  41. package/build/index.js +479 -240
  42. package/build/install-bridge-conductor.js +599 -14
  43. package/build/install-bridge.js +448 -46
  44. package/build/mcp-install-state.js +42 -4
  45. package/build/merge-pull-request.js +103 -10
  46. package/build/merge-workflow-codeowners.js +224 -0
  47. package/build/merge-workflow-config.js +187 -0
  48. package/build/merge-workflow-drift.js +97 -0
  49. package/build/merge-workflow-install.js +638 -0
  50. package/build/merge-workflow.js +956 -0
  51. package/build/pipelines.generated.js +3 -3
  52. package/build/plane/cli.js +24 -2
  53. package/build/plane/preflight.js +68 -0
  54. package/build/plane/supervisor.js +6 -0
  55. package/build/readme.generated.js +1 -1
  56. package/build/run-unit-tests-launcher.js +5 -0
  57. package/build/setup-epic.js +263 -38
  58. package/build/version.generated.js +3 -3
  59. package/package.json +3 -3
  60. package/build/conduct-epic/bridge-client.js +0 -773
  61. package/build/conduct-epic/checkpoint-store.js +0 -542
  62. package/build/conduct-epic/cli.js +0 -3145
  63. package/build/conduct-epic/cut-protocol.js +0 -392
  64. package/build/conduct-epic/lock.js +0 -302
  65. package/build/conduct-epic/pr-state.js +0 -286
  66. package/build/conduct-epic/spawn.js +0 -113
  67. package/build/conductor/cli.js +0 -1278
  68. package/build/conductor-bundle-cli.js +0 -256
  69. package/build/direct-ticket-tools.js +0 -70
  70. package/build/executor/reconcile-overlap-governance.js +0 -129
  71. package/build/implement-epic/bridge-client.js +0 -773
  72. package/build/implement-epic/checkpoint-store.js +0 -542
  73. package/build/implement-epic/cli.js +0 -3158
  74. package/build/implement-epic/cut-protocol.js +0 -392
  75. package/build/implement-epic/lock.js +0 -302
  76. package/build/implement-epic/pr-state.js +0 -286
  77. package/build/implement-epic/spawn.js +0 -113
  78. package/build/plan-epic-conductor-eligibility.js +0 -213
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Host-local epic-launch profiles (BAPI-1260).
3
+ *
4
+ * A profile is the approved, reusable binding guided setup produces for one
5
+ * epic launch target: WHICH Bridge API endpoint, WHICH repository, WHICH
6
+ * execution host and mode, HOW the packaged CLI is invoked, and REFERENCES to the
7
+ * run policy and the credential. It is what lets the launch command printed at
8
+ * the end of setup run unchanged in a fresh shell — no `export BAPI_BASE_URL`,
9
+ * no remembered working shell, no database repair.
10
+ *
11
+ * ## What it is not
12
+ *
13
+ * - **Not a secret store.** A profile holds references only. The credential
14
+ * reference names a `bapi:<repo>` entry in the user-scoped credential store;
15
+ * the key itself stays there. Validation rejects any field this module does
16
+ * not know, so a secret cannot be smuggled in under a new name.
17
+ * - **Not project configuration.** Profiles live beside the credential store in
18
+ * the USER's config directory (`$XDG_CONFIG_HOME/bridge/` or
19
+ * `~/.config/bridge/`), never in the repository, so committed configuration
20
+ * stays secret-free and host-neutral.
21
+ * - **Not an authority.** A profile selects a target; the server still decides
22
+ * admission. Changing a profile's target is a guided-setup act (it re-verifies
23
+ * access at the new target); a command-line override alone never rebinds one.
24
+ *
25
+ * Every exported operation returns a typed result and never throws.
26
+ */
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+ import { chmod, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
30
+ import { durablyReplaceCredentialStoreJson, getPrimaryCredentialStorePath, withCredentialStoreLock, } from "./credential-store.js";
31
+ import { validateHttpBaseUrl, describeBaseUrlRejection } from "./base-url.js";
32
+ import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
33
+ import { isTransientNpxEntryPath } from "./mcp-server-invocation.js";
34
+ // ---------------------------------------------------------------------------
35
+ // Types
36
+ // ---------------------------------------------------------------------------
37
+ /** The only profile schema this build reads or writes. */
38
+ export const EPIC_LAUNCH_PROFILE_VERSION = 1;
39
+ /** File name of the host-local profile store, beside `credentials.json`. */
40
+ export const EPIC_LAUNCH_PROFILE_STORE_FILENAME = "epic-launch-profiles.json";
41
+ /** Selector shape: short, lowercase, shell-safe without quoting. */
42
+ export const EPIC_LAUNCH_PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,62}$/;
43
+ /** Where the executor that serves this launch runs. */
44
+ export const EPIC_LAUNCH_EXECUTION_MODES = ["local", "durable"];
45
+ /** One-time migration guidance for a profile this build cannot read. */
46
+ export function epicLaunchProfileSetupGuidance(name) {
47
+ return (`Re-run guided setup to (re)write it: npx -y ${MCP_PACKAGE_NAME} install conductor ` +
48
+ `--launch-profile ${EPIC_LAUNCH_PROFILE_NAME_PATTERN.test(name) ? name : "<name>"} --base-url <url> ...`);
49
+ }
50
+ // ---------------------------------------------------------------------------
51
+ // Validation (pure)
52
+ // ---------------------------------------------------------------------------
53
+ const PROFILE_KEYS = new Set([
54
+ "version",
55
+ "name",
56
+ "baseUrl",
57
+ "repoName",
58
+ "executionMode",
59
+ "host",
60
+ "runtime",
61
+ "policyRef",
62
+ "credentialRef",
63
+ "approvedAt",
64
+ ]);
65
+ const NPM_CHANNEL_SPEC = new RegExp(`^${MCP_PACKAGE_NAME.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}@[A-Za-z0-9][A-Za-z0-9.+-]{0,63}$`);
66
+ function isPlainObject(value) {
67
+ return typeof value === "object" && value !== null && !Array.isArray(value);
68
+ }
69
+ function nonEmptyString(value, max = 512) {
70
+ return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\0\n\r]/.test(value);
71
+ }
72
+ function malformed(name, field, why) {
73
+ return {
74
+ ok: false,
75
+ kind: "profile-malformed",
76
+ message: `Epic launch profile '${name}' is unusable: ${field} ${why}. ${epicLaunchProfileSetupGuidance(name)}`,
77
+ };
78
+ }
79
+ function validateRuntime(name, raw) {
80
+ if (!isPlainObject(raw))
81
+ return malformed(name, "runtime", "is not an object");
82
+ if (raw.kind === "packaged") {
83
+ const keys = Object.keys(raw);
84
+ if (keys.some((k) => !["kind", "executable", "entryPath"].includes(k))) {
85
+ return malformed(name, "runtime", "carries an unknown field");
86
+ }
87
+ if (!nonEmptyString(raw.executable) || !path.isAbsolute(raw.executable)) {
88
+ return malformed(name, "runtime.executable", "is not an absolute path");
89
+ }
90
+ if (!nonEmptyString(raw.entryPath) || !path.isAbsolute(raw.entryPath)) {
91
+ return malformed(name, "runtime.entryPath", "is not an absolute path");
92
+ }
93
+ if (isTransientNpxEntryPath(raw.entryPath)) {
94
+ return malformed(name, "runtime.entryPath", "lives in a transient npx cache that can be pruned");
95
+ }
96
+ return { kind: "packaged", executable: raw.executable, entryPath: raw.entryPath };
97
+ }
98
+ if (raw.kind === "npm-channel") {
99
+ if (Object.keys(raw).some((k) => !["kind", "spec"].includes(k))) {
100
+ return malformed(name, "runtime", "carries an unknown field");
101
+ }
102
+ if (typeof raw.spec !== "string" || !NPM_CHANNEL_SPEC.test(raw.spec)) {
103
+ return malformed(name, "runtime.spec", `is not ${MCP_PACKAGE_NAME}@<channel-or-version>`);
104
+ }
105
+ return { kind: "npm-channel", spec: raw.spec };
106
+ }
107
+ return malformed(name, "runtime.kind", "is not 'packaged' or 'npm-channel'");
108
+ }
109
+ /**
110
+ * Validate one decoded profile. Strict: unknown fields, a GitHub or non-`bapi:`
111
+ * credential reference, a transient runtime, a relative policy path, or a
112
+ * durable-mode profile without a packaged runtime are all refused.
113
+ */
114
+ export function validateEpicLaunchProfile(raw, selector = "<unnamed>") {
115
+ if (!isPlainObject(raw))
116
+ return malformed(selector, "the entry", "is not an object");
117
+ if (raw.version !== EPIC_LAUNCH_PROFILE_VERSION) {
118
+ return {
119
+ ok: false,
120
+ kind: "profile-unsupported-version",
121
+ message: `Epic launch profile '${selector}' uses an unsupported schema version; this build reads ` +
122
+ `version ${EPIC_LAUNCH_PROFILE_VERSION} only. ${epicLaunchProfileSetupGuidance(selector)}`,
123
+ };
124
+ }
125
+ for (const key of Object.keys(raw)) {
126
+ if (!PROFILE_KEYS.has(key))
127
+ return malformed(selector, `field '${key.slice(0, 40)}'`, "is not recognized");
128
+ }
129
+ if (typeof raw.name !== "string" || !EPIC_LAUNCH_PROFILE_NAME_PATTERN.test(raw.name) || raw.name !== selector) {
130
+ return malformed(selector, "name", "does not match its selector");
131
+ }
132
+ const url = validateHttpBaseUrl(typeof raw.baseUrl === "string" ? raw.baseUrl : undefined);
133
+ if (!url.ok)
134
+ return malformed(selector, "baseUrl", `is invalid (${describeBaseUrlRejection("baseUrl", url.reason)})`);
135
+ if (url.baseUrl !== raw.baseUrl)
136
+ return malformed(selector, "baseUrl", "is not normalized");
137
+ if (!nonEmptyString(raw.repoName, 200))
138
+ return malformed(selector, "repoName", "is missing");
139
+ if (!EPIC_LAUNCH_EXECUTION_MODES.includes(raw.executionMode)) {
140
+ return malformed(selector, "executionMode", "is not 'local' or 'durable'");
141
+ }
142
+ if (!nonEmptyString(raw.host, 255))
143
+ return malformed(selector, "host", "is missing");
144
+ const runtime = validateRuntime(selector, raw.runtime);
145
+ if ("ok" in runtime)
146
+ return runtime;
147
+ if (raw.executionMode === "durable" && runtime.kind !== "packaged") {
148
+ return malformed(selector, "runtime", "must be a packaged absolute path for durable execution");
149
+ }
150
+ let policyRef = null;
151
+ if (raw.policyRef !== null && raw.policyRef !== undefined) {
152
+ const p = raw.policyRef;
153
+ if (!isPlainObject(p) || p.kind !== "file" || Object.keys(p).some((k) => k !== "kind" && k !== "path")) {
154
+ return malformed(selector, "policyRef", "is not a file reference");
155
+ }
156
+ if (!nonEmptyString(p.path) || !path.isAbsolute(p.path)) {
157
+ return malformed(selector, "policyRef.path", "is not an absolute path");
158
+ }
159
+ policyRef = { kind: "file", path: p.path };
160
+ }
161
+ const c = raw.credentialRef;
162
+ if (!isPlainObject(c) ||
163
+ c.store !== "bridge-credentials" ||
164
+ Object.keys(c).some((k) => k !== "store" && k !== "target") ||
165
+ c.target !== `bapi:${raw.repoName}`) {
166
+ // Refused rather than generalized: a launch uses exactly one Bridge
167
+ // credential, and a `github:*` (or any other) reference here would be the
168
+ // first step toward a standing code-host credential on the launch path.
169
+ return malformed(selector, "credentialRef", "must reference exactly bapi:<repoName>");
170
+ }
171
+ if (!nonEmptyString(raw.approvedAt, 64))
172
+ return malformed(selector, "approvedAt", "is missing");
173
+ return {
174
+ ok: true,
175
+ profile: {
176
+ version: EPIC_LAUNCH_PROFILE_VERSION,
177
+ name: raw.name,
178
+ baseUrl: url.baseUrl,
179
+ repoName: raw.repoName,
180
+ executionMode: raw.executionMode,
181
+ host: raw.host,
182
+ runtime,
183
+ policyRef,
184
+ credentialRef: { store: "bridge-credentials", target: `bapi:${raw.repoName}` },
185
+ approvedAt: raw.approvedAt,
186
+ },
187
+ };
188
+ }
189
+ // ---------------------------------------------------------------------------
190
+ // Store I/O
191
+ // ---------------------------------------------------------------------------
192
+ /** The profile store path, beside the primary credential store. */
193
+ export function getEpicLaunchProfileStorePath(deps) {
194
+ return path.join(path.dirname(getPrimaryCredentialStorePath(deps)), EPIC_LAUNCH_PROFILE_STORE_FILENAME);
195
+ }
196
+ async function readStoreDocument(deps) {
197
+ const storePath = getEpicLaunchProfileStorePath(deps);
198
+ let raw;
199
+ try {
200
+ raw = await deps.readFile(storePath);
201
+ }
202
+ catch (err) {
203
+ const code = err && typeof err === "object" ? err.code : undefined;
204
+ if (code === "ENOENT") {
205
+ return { ok: true, present: false, document: { version: EPIC_LAUNCH_PROFILE_VERSION, profiles: {} } };
206
+ }
207
+ return { ok: false, kind: "store-unreadable", message: `Unable to read ${storePath}.` };
208
+ }
209
+ let parsed;
210
+ try {
211
+ parsed = JSON.parse(raw);
212
+ }
213
+ catch {
214
+ return { ok: false, kind: "store-unreadable", message: `${storePath} is not valid JSON.` };
215
+ }
216
+ if (!isPlainObject(parsed) || !isPlainObject(parsed.profiles)) {
217
+ return { ok: false, kind: "store-unreadable", message: `${storePath} has no profiles object.` };
218
+ }
219
+ if (parsed.version !== EPIC_LAUNCH_PROFILE_VERSION) {
220
+ return {
221
+ ok: false,
222
+ kind: "profile-unsupported-version",
223
+ message: `${storePath} uses an unsupported schema version; this build reads version ` +
224
+ `${EPIC_LAUNCH_PROFILE_VERSION} only. ${epicLaunchProfileSetupGuidance("<name>")}`,
225
+ };
226
+ }
227
+ return { ok: true, present: true, document: { version: EPIC_LAUNCH_PROFILE_VERSION, profiles: parsed.profiles } };
228
+ }
229
+ /**
230
+ * Read and validate one profile by selector. Read-only: a missing, malformed, or
231
+ * unsupported profile is reported with guidance and never repaired here.
232
+ *
233
+ * `host`, when supplied, must match the profile's approved host — a profile
234
+ * copied to another machine names a runtime and a credential store that do not
235
+ * exist there.
236
+ */
237
+ export async function readEpicLaunchProfile(name, deps) {
238
+ if (!EPIC_LAUNCH_PROFILE_NAME_PATTERN.test(name)) {
239
+ return {
240
+ ok: false,
241
+ kind: "invalid-name",
242
+ message: "An epic launch profile name must match [a-z0-9][a-z0-9._-]{0,62}.",
243
+ };
244
+ }
245
+ const store = await readStoreDocument(deps);
246
+ if (!store.ok)
247
+ return store;
248
+ if (!Object.prototype.hasOwnProperty.call(store.document.profiles, name)) {
249
+ return {
250
+ ok: false,
251
+ kind: "profile-missing",
252
+ message: `No epic launch profile named '${name}' exists on this host. ${epicLaunchProfileSetupGuidance(name)}`,
253
+ };
254
+ }
255
+ const validated = validateEpicLaunchProfile(store.document.profiles[name], name);
256
+ if (!validated.ok)
257
+ return validated;
258
+ if (deps.host !== undefined && validated.profile.host !== deps.host) {
259
+ return {
260
+ ok: false,
261
+ kind: "profile-host-mismatch",
262
+ message: `Epic launch profile '${name}' was approved on a different host. Profiles are host-local; ` +
263
+ epicLaunchProfileSetupGuidance(name),
264
+ };
265
+ }
266
+ return validated;
267
+ }
268
+ export async function listEpicLaunchProfiles(deps) {
269
+ const store = await readStoreDocument(deps);
270
+ if (!store.ok)
271
+ return store;
272
+ if (!store.present)
273
+ return { ok: true, present: false };
274
+ const entries = Object.keys(store.document.profiles)
275
+ .sort()
276
+ .map((name) => ({ name, validation: validateEpicLaunchProfile(store.document.profiles[name], name) }));
277
+ return { ok: true, present: true, entries };
278
+ }
279
+ /**
280
+ * Persist one validated profile: locked (the shared credential-store lock) and
281
+ * durable (temp → fsync → rename). Every other profile in the store is kept,
282
+ * including ones this build cannot validate — a newer build's profile is never
283
+ * deleted by an older one.
284
+ */
285
+ export async function writeEpicLaunchProfile(profile, deps) {
286
+ const validated = validateEpicLaunchProfile(profile, profile.name);
287
+ if (!validated.ok)
288
+ return validated;
289
+ const storePath = getEpicLaunchProfileStorePath(deps);
290
+ return withCredentialStoreLock(deps, async () => {
291
+ const store = await readStoreDocument(deps);
292
+ if (!store.ok)
293
+ return store;
294
+ const existing = store.document.profiles[profile.name];
295
+ const serialized = JSON.parse(JSON.stringify(validated.profile));
296
+ if (existing !== undefined && JSON.stringify(existing) === JSON.stringify(serialized)) {
297
+ return { ok: true, path: storePath, action: "unchanged" };
298
+ }
299
+ const next = {
300
+ version: EPIC_LAUNCH_PROFILE_VERSION,
301
+ profiles: { ...store.document.profiles, [profile.name]: serialized },
302
+ };
303
+ // The durable writer is typed for the credential store, but it serializes
304
+ // any JSON value the same way (2-space indent, 0600, fsync, rename).
305
+ const written = await durablyReplaceCredentialStoreJson(storePath, next, deps);
306
+ if (!written.ok) {
307
+ return { ok: false, kind: "store-write-failed", message: `Failed to durably write ${storePath}.` };
308
+ }
309
+ return { ok: true, path: storePath, action: existing === undefined ? "created" : "updated" };
310
+ }, (error) => ({ ok: false, kind: "store-write-failed", message: error }));
311
+ }
312
+ /**
313
+ * Normalize every applicable target source and decide ONE target, before any
314
+ * credential is resolved and before any request is made.
315
+ *
316
+ * - With a selected profile, the profile's target wins, and a `--base-url` or
317
+ * `BAPI_BASE_URL` that names a DIFFERENT endpoint is a conflict — an override
318
+ * alone never rebinds a profile; guided setup does that.
319
+ * - Without one, `--base-url` and `BAPI_BASE_URL` must agree when both are set.
320
+ * - Nothing set is `target-missing`. There is no production default here:
321
+ * production is supported, but only as a deliberate selection.
322
+ *
323
+ * Messages name the source and the rule only — never a URL, which can carry
324
+ * userinfo.
325
+ */
326
+ export function resolveEpicLaunchTarget(inputs) {
327
+ const normalize = (raw, label) => {
328
+ if (raw === undefined || raw.trim() === "")
329
+ return { ok: true, value: null };
330
+ const v = validateHttpBaseUrl(raw);
331
+ if (!v.ok)
332
+ return { ok: false, message: describeBaseUrlRejection(label, v.reason) };
333
+ return { ok: true, value: v.baseUrl };
334
+ };
335
+ const flag = normalize(inputs.flagBaseUrl, "--base-url");
336
+ if (!flag.ok)
337
+ return { ok: false, kind: "target-invalid", message: flag.message };
338
+ const env = normalize(inputs.env.BAPI_BASE_URL, "BAPI_BASE_URL");
339
+ if (!env.ok)
340
+ return { ok: false, kind: "target-invalid", message: env.message };
341
+ const profile = inputs.profile;
342
+ if (profile !== null) {
343
+ if (flag.value !== null && flag.value !== profile.baseUrl) {
344
+ return {
345
+ ok: false,
346
+ kind: "target-conflict",
347
+ message: `--base-url names a different endpoint than launch profile '${profile.name}'. An override ` +
348
+ "does not rebind a profile; re-run guided setup to change its target.",
349
+ };
350
+ }
351
+ if (env.value !== null && env.value !== profile.baseUrl) {
352
+ return {
353
+ ok: false,
354
+ kind: "target-conflict",
355
+ message: `BAPI_BASE_URL names a different endpoint than launch profile '${profile.name}'. Unset it, ` +
356
+ "or re-run guided setup to change the profile's target.",
357
+ };
358
+ }
359
+ const flagRepo = inputs.flagRepo?.trim();
360
+ if (flagRepo && flagRepo !== profile.repoName) {
361
+ return {
362
+ ok: false,
363
+ kind: "repository-conflict",
364
+ message: `--repo names a different repository than launch profile '${profile.name}'.`,
365
+ };
366
+ }
367
+ return { ok: true, baseUrl: profile.baseUrl, source: "profile", repoName: profile.repoName };
368
+ }
369
+ if (flag.value !== null && env.value !== null && flag.value !== env.value) {
370
+ return {
371
+ ok: false,
372
+ kind: "target-conflict",
373
+ message: "--base-url and BAPI_BASE_URL name different endpoints. Keep one, or make them agree.",
374
+ };
375
+ }
376
+ if (flag.value !== null)
377
+ return { ok: true, baseUrl: flag.value, source: "flag" };
378
+ if (env.value !== null)
379
+ return { ok: true, baseUrl: env.value, source: "env" };
380
+ return {
381
+ ok: false,
382
+ kind: "target-missing",
383
+ message: "No Bridge API target is selected for this epic launch. Pass --launch-profile <name> (created by " +
384
+ `\`npx -y ${MCP_PACKAGE_NAME} install conductor --launch-profile <name> --base-url <url>\`), or ` +
385
+ "--base-url <url>. Nothing defaults to production.",
386
+ };
387
+ }
388
+ // ---------------------------------------------------------------------------
389
+ // Command rendering (pure)
390
+ // ---------------------------------------------------------------------------
391
+ /** POSIX single-quote a word unless it is already shell-safe. */
392
+ export function shellQuote(word) {
393
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(word))
394
+ return word;
395
+ return `'${word.replace(/'/g, `'\\''`)}'`;
396
+ }
397
+ /** The argv prefix that invokes the packaged CLI for a runtime. */
398
+ export function epicLaunchRuntimeArgv(runtime) {
399
+ return runtime.kind === "packaged"
400
+ ? [runtime.executable, runtime.entryPath]
401
+ : ["npx", "-y", runtime.spec];
402
+ }
403
+ /**
404
+ * Render the one reusable `drive-epic` command for a profile: shell-safe,
405
+ * secret-free (a profile holds no secret and nothing here reads the
406
+ * environment), and complete — the profile selector, the plan, and the policy —
407
+ * so it runs unchanged in a fresh shell.
408
+ */
409
+ export function renderEpicLaunchCommand(profile, inputs) {
410
+ const policy = inputs.policyFile ?? profile.policyRef?.path;
411
+ const argv = [
412
+ ...epicLaunchRuntimeArgv(profile.runtime),
413
+ "drive-epic",
414
+ inputs.epicKey,
415
+ "--launch-profile",
416
+ profile.name,
417
+ "--plan-file",
418
+ inputs.planFile,
419
+ ...(policy ? ["--policy-file", policy] : []),
420
+ ...(inputs.featureBranch ? ["--feature-branch", inputs.featureBranch] : []),
421
+ ...(inputs.intoBase ? ["--into-base"] : []),
422
+ ...(inputs.attended ? ["--attended"] : []),
423
+ ];
424
+ return argv.map(shellQuote).join(" ");
425
+ }
426
+ // ---------------------------------------------------------------------------
427
+ // Production I/O
428
+ // ---------------------------------------------------------------------------
429
+ /**
430
+ * Real-filesystem write deps for the profile store and the credential binding:
431
+ * `fs/promises` plus the `open` primitive that gives the shared lock and the
432
+ * fsync'd replacement. Built here rather than imported from `index.ts`, whose
433
+ * import runs the CLI dispatcher.
434
+ */
435
+ export function createNodeEpicLaunchStoreDeps(env = process.env) {
436
+ return {
437
+ env,
438
+ homedir: os.homedir,
439
+ platform: process.platform,
440
+ readFile: (p) => readFile(p, "utf-8"),
441
+ mkdir: (p, options) => mkdir(p, options),
442
+ writeFile: (p, data, options) => writeFile(p, data, options),
443
+ rename: (oldPath, newPath) => rename(oldPath, newPath),
444
+ chmod: (p, mode) => chmod(p, mode),
445
+ unlink: (p) => unlink(p),
446
+ open: async (p, flags, mode) => {
447
+ const handle = await open(p, flags, mode);
448
+ return {
449
+ writeFile: (data) => handle.writeFile(data, { encoding: "utf-8" }),
450
+ sync: () => handle.sync(),
451
+ close: () => handle.close(),
452
+ };
453
+ },
454
+ };
455
+ }