@databricks-solutions/lakebase-scm-utils 0.3.5 → 0.3.7

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 (49) hide show
  1. package/dist/scripts/index.cjs +55 -25
  2. package/dist/scripts/index.d.cts +1 -1
  3. package/dist/scripts/index.d.ts +1 -1
  4. package/dist/scripts/index.js +54 -25
  5. package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +2 -2
  6. package/dist/scripts/lakebase/ci-resolve-branch.cli.js +2 -2
  7. package/dist/scripts/lakebase/collapse-heads.cli.cjs +2 -2
  8. package/dist/scripts/lakebase/collapse-heads.cli.js +2 -2
  9. package/dist/scripts/lakebase/detect-language.cli.cjs +2 -2
  10. package/dist/scripts/lakebase/detect-language.cli.js +2 -2
  11. package/dist/scripts/lakebase/doctor.cli.cjs +2 -2
  12. package/dist/scripts/lakebase/doctor.cli.js +2 -2
  13. package/dist/scripts/lakebase/get-connection.cli.cjs +2 -2
  14. package/dist/scripts/lakebase/get-connection.cli.js +2 -2
  15. package/dist/scripts/lakebase/index.cjs +55 -25
  16. package/dist/scripts/lakebase/index.d.cts +13 -4
  17. package/dist/scripts/lakebase/index.d.ts +13 -4
  18. package/dist/scripts/lakebase/index.js +54 -25
  19. package/dist/scripts/lakebase/infra-runner.cli.cjs +2 -2
  20. package/dist/scripts/lakebase/infra-runner.cli.js +2 -2
  21. package/dist/scripts/lakebase/new-migration.cli.cjs +2 -2
  22. package/dist/scripts/lakebase/new-migration.cli.js +2 -2
  23. package/dist/scripts/lakebase/schema-diff.cli.cjs +2 -2
  24. package/dist/scripts/lakebase/schema-diff.cli.js +2 -2
  25. package/dist/scripts/lakebase/schema-migrate.cli.cjs +2 -2
  26. package/dist/scripts/lakebase/schema-migrate.cli.js +2 -2
  27. package/dist/scripts/lakebase/scm-abandon-feature.cli.cjs +37 -22
  28. package/dist/scripts/lakebase/scm-abandon-feature.cli.js +34 -22
  29. package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +2 -2
  30. package/dist/scripts/lakebase/scm-claim-feature.cli.js +2 -2
  31. package/dist/scripts/lakebase/scm-doctor.cli.cjs +36 -21
  32. package/dist/scripts/lakebase/scm-doctor.cli.js +36 -21
  33. package/dist/scripts/lakebase/scm-merge.cli.cjs +2 -2
  34. package/dist/scripts/lakebase/scm-merge.cli.js +2 -2
  35. package/dist/scripts/lakebase/scm-park-feature.cli.cjs +586 -0
  36. package/dist/scripts/lakebase/scm-park-feature.cli.d.cts +4 -0
  37. package/dist/scripts/lakebase/scm-park-feature.cli.d.ts +4 -0
  38. package/dist/scripts/lakebase/scm-park-feature.cli.js +546 -0
  39. package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +2 -2
  40. package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +2 -2
  41. package/dist/scripts/util/index.cjs +2 -2
  42. package/dist/scripts/util/index.js +2 -2
  43. package/package.json +2 -1
  44. package/scripts/lakebase/enable-e2e.ts +34 -4
  45. package/scripts/lakebase/scm-abandon-feature.cli.ts +3 -3
  46. package/scripts/lakebase/scm-abandon-feature.ts +19 -83
  47. package/scripts/lakebase/scm-park-feature.cli.ts +150 -0
  48. package/scripts/lakebase/scm-park-feature.ts +71 -0
  49. package/scripts/lakebase/scm-release-claim.ts +146 -0
@@ -0,0 +1,546 @@
1
+ #!/usr/bin/env node
2
+
3
+ // scripts/lakebase/scm-park-feature.cli.ts
4
+ import * as path2 from "path";
5
+
6
+ // scripts/util/cli-entry.ts
7
+ import { realpathSync } from "fs";
8
+ import { fileURLToPath } from "url";
9
+ function isCliEntry(importMetaUrl) {
10
+ const invokedRaw = process.argv[1];
11
+ if (!invokedRaw) return false;
12
+ let invokedResolved;
13
+ let moduleResolved;
14
+ try {
15
+ invokedResolved = realpathSync(invokedRaw);
16
+ } catch {
17
+ return false;
18
+ }
19
+ try {
20
+ moduleResolved = realpathSync(fileURLToPath(importMetaUrl));
21
+ } catch {
22
+ return false;
23
+ }
24
+ return invokedResolved === moduleResolved;
25
+ }
26
+
27
+ // scripts/util/exec.ts
28
+ import * as cp from "child_process";
29
+ function shq(s) {
30
+ return `'${s.replace(/'/g, "'\\''")}'`;
31
+ }
32
+ function exec2(command, opts = {}) {
33
+ return new Promise((resolve2, reject) => {
34
+ const options = {
35
+ cwd: opts.cwd,
36
+ timeout: opts.timeout ?? 6e4
37
+ };
38
+ if (opts.env) {
39
+ options.env = { ...process.env, ...opts.env };
40
+ }
41
+ cp.exec(command, options, (err, stdout, stderr) => {
42
+ if (err) {
43
+ const msg = String(stderr || err.message);
44
+ reject(new Error(`${command}: ${msg}`));
45
+ return;
46
+ }
47
+ resolve2(String(stdout).trim());
48
+ });
49
+ });
50
+ }
51
+
52
+ // scripts/git/inspect.ts
53
+ async function getCurrentBranch(args) {
54
+ try {
55
+ const name = await exec2("git rev-parse --abbrev-ref HEAD", {
56
+ cwd: args.cwd
57
+ });
58
+ return name === "HEAD" ? "" : name;
59
+ } catch {
60
+ return "";
61
+ }
62
+ }
63
+
64
+ // scripts/git/status.ts
65
+ async function isDirty(args) {
66
+ try {
67
+ const ignore = args.ignore ?? [];
68
+ const untrackedFlag = args.untracked === false ? " --untracked-files=no" : "";
69
+ let command = `git status --porcelain${untrackedFlag}`;
70
+ if (ignore.length > 0) {
71
+ const excludes = ignore.map((p) => shq(`:(exclude)${p.replace(/\/+$/, "")}`)).join(" ");
72
+ command = `git status --porcelain${untrackedFlag} -- . ${excludes}`;
73
+ }
74
+ const out = await exec2(command, { cwd: args.cwd });
75
+ return out.trim().length > 0;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ // scripts/lakebase/scm-workflow-state.ts
82
+ import * as fs from "fs";
83
+ import * as path from "path";
84
+ import { execFileSync } from "child_process";
85
+ function isGitTracked(projectDir, rel) {
86
+ try {
87
+ execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd: projectDir, stdio: "ignore" });
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+ function ensureWorkflowStateUntracked(projectDir) {
94
+ const rel = ".lakebase/workflow-state.json";
95
+ try {
96
+ if (isGitTracked(projectDir, rel)) {
97
+ execFileSync("git", ["rm", "--cached", "--quiet", "--ignore-unmatch", "--", rel], { cwd: projectDir, stdio: "ignore" });
98
+ }
99
+ } catch {
100
+ }
101
+ try {
102
+ const gitignore = path.join(projectDir, ".gitignore");
103
+ const existing = fs.existsSync(gitignore) ? fs.readFileSync(gitignore, "utf8") : "";
104
+ if (!existing.split("\n").some((l) => l.trim() === rel)) {
105
+ const sep = existing === "" || existing.endsWith("\n") ? "" : "\n";
106
+ fs.appendFileSync(
107
+ gitignore,
108
+ `${sep}# Runtime SCM claim state (per working tree): a branch checkout must never restore
109
+ # a stale committed claim over the live one (issue #203 / Finding 28).
110
+ ${rel}
111
+ `
112
+ );
113
+ }
114
+ } catch {
115
+ }
116
+ }
117
+ var SCM_STATES = [
118
+ "scaffold-complete",
119
+ "feature-claimed",
120
+ "pr-ready",
121
+ "ci-green",
122
+ "merged"
123
+ ];
124
+ var STATE_INDEX = SCM_STATES.reduce(
125
+ (acc, s, i) => ({ ...acc, [s]: i }),
126
+ {}
127
+ );
128
+ var STATE_FILE_REL = ".lakebase/workflow-state.json";
129
+ function stateFilePath(projectDir) {
130
+ return path.join(projectDir, STATE_FILE_REL);
131
+ }
132
+ function readWorkflowState(projectDir) {
133
+ const p = stateFilePath(projectDir);
134
+ if (!fs.existsSync(p)) return null;
135
+ const raw = fs.readFileSync(p, "utf8");
136
+ let parsed;
137
+ try {
138
+ parsed = JSON.parse(raw);
139
+ } catch (e) {
140
+ throw new Error(
141
+ `Failed to parse ${STATE_FILE_REL}: ${e.message}`
142
+ );
143
+ }
144
+ const result = validateWorkflowState(parsed);
145
+ if (!result.ok) {
146
+ const summary = result.errors.map((e) => ` - ${e.path}: ${e.message}`).join("\n");
147
+ throw new Error(
148
+ `Invalid ${STATE_FILE_REL}:
149
+ ${summary}
150
+
151
+ Fix the file or delete it to re-init.`
152
+ );
153
+ }
154
+ return result.value;
155
+ }
156
+ function writeWorkflowState(projectDir, state) {
157
+ const result = validateWorkflowState(state);
158
+ if (!result.ok) {
159
+ const summary = result.errors.map((e) => ` - ${e.path}: ${e.message}`).join("\n");
160
+ throw new Error(`Refusing to write invalid SCM state:
161
+ ${summary}`);
162
+ }
163
+ ensureWorkflowStateUntracked(projectDir);
164
+ const dir = path.join(projectDir, ".lakebase");
165
+ fs.mkdirSync(dir, { recursive: true });
166
+ const target = stateFilePath(projectDir);
167
+ const tmp = `${target}.tmp`;
168
+ const ordered = orderForOutput(result.value);
169
+ fs.writeFileSync(tmp, `${JSON.stringify(ordered, null, 2)}
170
+ `, "utf8");
171
+ fs.renameSync(tmp, target);
172
+ }
173
+ function validateWorkflowState(value) {
174
+ const errors = [];
175
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
176
+ return {
177
+ ok: false,
178
+ errors: [{ path: "$", message: "must be an object" }]
179
+ };
180
+ }
181
+ const v = value;
182
+ if (v.version !== 1) {
183
+ errors.push({ path: "version", message: `must be 1, got ${String(v.version)}` });
184
+ }
185
+ if (typeof v.state !== "string" || !SCM_STATES.includes(v.state)) {
186
+ errors.push({
187
+ path: "state",
188
+ message: `must be one of ${SCM_STATES.join(" | ")}`
189
+ });
190
+ }
191
+ if (v.tier_topology !== 1 && v.tier_topology !== 2 && v.tier_topology !== 3) {
192
+ errors.push({
193
+ path: "tier_topology",
194
+ message: "must be 1, 2, or 3"
195
+ });
196
+ }
197
+ if (typeof v.project_id !== "string" || v.project_id.length === 0) {
198
+ errors.push({
199
+ path: "project_id",
200
+ message: "must be a non-empty string"
201
+ });
202
+ }
203
+ const stringFields = [
204
+ "feature_id",
205
+ "branch",
206
+ "parent_branch",
207
+ "lakebase_branch_uid",
208
+ "claimed_at",
209
+ "pr_url",
210
+ "pushed_at",
211
+ "ci_run_url",
212
+ "ci_green_at",
213
+ "merged_at",
214
+ "migrate_run_url",
215
+ "migrate_completed_at",
216
+ "$schema"
217
+ ];
218
+ for (const key of stringFields) {
219
+ if (v[key] === void 0) continue;
220
+ if (typeof v[key] !== "string" || v[key].length === 0) {
221
+ errors.push({
222
+ path: key,
223
+ message: "must be a non-empty string when present"
224
+ });
225
+ }
226
+ }
227
+ const requiredForState = {
228
+ "scaffold-complete": [],
229
+ "feature-claimed": [
230
+ "feature_id",
231
+ "branch",
232
+ "parent_branch",
233
+ "lakebase_branch_uid",
234
+ "claimed_at"
235
+ ],
236
+ "pr-ready": [
237
+ "feature_id",
238
+ "branch",
239
+ "parent_branch",
240
+ "lakebase_branch_uid",
241
+ "claimed_at",
242
+ "pr_url",
243
+ "pushed_at"
244
+ ],
245
+ "ci-green": [
246
+ "feature_id",
247
+ "branch",
248
+ "parent_branch",
249
+ "lakebase_branch_uid",
250
+ "claimed_at",
251
+ "pr_url",
252
+ "pushed_at",
253
+ "ci_run_url",
254
+ "ci_green_at"
255
+ ],
256
+ merged: [
257
+ "feature_id",
258
+ "branch",
259
+ "parent_branch",
260
+ "lakebase_branch_uid",
261
+ "claimed_at",
262
+ "pr_url",
263
+ "pushed_at",
264
+ "ci_run_url",
265
+ "ci_green_at",
266
+ "merged_at"
267
+ ]
268
+ };
269
+ if (typeof v.state === "string" && SCM_STATES.includes(v.state)) {
270
+ for (const key of requiredForState[v.state]) {
271
+ if (v[key] === void 0) {
272
+ errors.push({
273
+ path: key,
274
+ message: `required when state is "${v.state}"`
275
+ });
276
+ }
277
+ }
278
+ }
279
+ const allowedKeys = /* @__PURE__ */ new Set([
280
+ "$schema",
281
+ "version",
282
+ "state",
283
+ "tier_topology",
284
+ "project_id",
285
+ "feature_id",
286
+ "branch",
287
+ "parent_branch",
288
+ "lakebase_branch_uid",
289
+ "claimed_at",
290
+ "pr_url",
291
+ "pushed_at",
292
+ "ci_run_url",
293
+ "ci_green_at",
294
+ "merged_at",
295
+ "migrate_run_url",
296
+ "migrate_completed_at"
297
+ ]);
298
+ for (const key of Object.keys(v)) {
299
+ if (!allowedKeys.has(key)) {
300
+ errors.push({ path: key, message: "unknown property" });
301
+ }
302
+ }
303
+ if (errors.length > 0) return { ok: false, errors };
304
+ return { ok: true, value: v };
305
+ }
306
+ function orderForOutput(state) {
307
+ const keyOrder = [
308
+ "$schema",
309
+ "version",
310
+ "state",
311
+ "tier_topology",
312
+ "project_id",
313
+ "feature_id",
314
+ "branch",
315
+ "parent_branch",
316
+ "lakebase_branch_uid",
317
+ "claimed_at",
318
+ "pr_url",
319
+ "pushed_at",
320
+ "ci_run_url",
321
+ "ci_green_at",
322
+ "merged_at",
323
+ "migrate_run_url",
324
+ "migrate_completed_at"
325
+ ];
326
+ const out = {};
327
+ for (const k of keyOrder) {
328
+ if (state[k] !== void 0) {
329
+ out[k] = state[k];
330
+ }
331
+ }
332
+ return out;
333
+ }
334
+
335
+ // scripts/lakebase/scm-release-claim.ts
336
+ var ScmReleaseError = class extends Error {
337
+ constructor(message, code) {
338
+ super(message);
339
+ this.code = code;
340
+ this.name = "ScmReleaseError";
341
+ }
342
+ code;
343
+ };
344
+ async function prepareClaimRelease(args, verb) {
345
+ const current = readWorkflowState(args.projectDir);
346
+ if (!current) {
347
+ throw new ScmReleaseError(
348
+ `No SCM workflow state found at ${args.projectDir}/.lakebase/workflow-state.json.`,
349
+ "no-state-file"
350
+ );
351
+ }
352
+ if (current.state !== "feature-claimed") {
353
+ throw new ScmReleaseError(
354
+ `${verb} refuses state "${current.state}". Only feature-claimed is ${verb}able; later states must complete or be reverted via gh.`,
355
+ "bad-precondition"
356
+ );
357
+ }
358
+ if (!current.feature_id || !current.branch || !current.parent_branch || !current.lakebase_branch_uid) {
359
+ throw new ScmReleaseError(
360
+ `feature-claimed row is missing required invariants. Cannot ${verb} safely; consider re-adopting state first.`,
361
+ "missing-claim-fields"
362
+ );
363
+ }
364
+ if (!args.force) {
365
+ const dirty = await isDirty({ cwd: args.projectDir });
366
+ if (dirty) {
367
+ throw new ScmReleaseError(
368
+ `Working tree has uncommitted changes; refusing to ${verb} (switching HEAD off the feature branch would strand them). Commit / stash / discard first, or pass --force.`,
369
+ "dirty-working-tree"
370
+ );
371
+ }
372
+ }
373
+ const switchTo = args.switchTo ?? current.parent_branch;
374
+ const warnings = [];
375
+ const headBranch = await getCurrentBranch({ cwd: args.projectDir });
376
+ if (headBranch === current.branch) {
377
+ try {
378
+ await exec2(`git checkout ${JSON.stringify(switchTo)}`, {
379
+ cwd: args.projectDir,
380
+ timeout: 1e4
381
+ });
382
+ } catch (err) {
383
+ warnings.push(
384
+ `git checkout ${switchTo} failed: ${err instanceof Error ? err.message : String(err)}. Local branch state may be left on the feature branch.`
385
+ );
386
+ }
387
+ }
388
+ return { current, switchTo, warnings };
389
+ }
390
+ function resetToScaffoldComplete(projectDir, current) {
391
+ const reset = {
392
+ $schema: current.$schema,
393
+ version: 1,
394
+ state: "scaffold-complete",
395
+ tier_topology: current.tier_topology,
396
+ project_id: current.project_id
397
+ };
398
+ writeWorkflowState(projectDir, reset);
399
+ return reset;
400
+ }
401
+
402
+ // scripts/lakebase/scm-park-feature.ts
403
+ async function parkFeatureBranch(args) {
404
+ const { current, switchTo, warnings } = await prepareClaimRelease(
405
+ { projectDir: args.projectDir, force: args.force, switchTo: args.switchTo },
406
+ "park"
407
+ );
408
+ const reset = resetToScaffoldComplete(args.projectDir, current);
409
+ return {
410
+ state: reset,
411
+ keptBranch: current.branch,
412
+ parkedFeatureId: current.feature_id,
413
+ switchedTo: switchTo,
414
+ warnings
415
+ };
416
+ }
417
+
418
+ // scripts/lakebase/scm-park-feature.cli.ts
419
+ function parseArgs(argv) {
420
+ const out = {};
421
+ for (let i = 0; i < argv.length; i++) {
422
+ const a = argv[i];
423
+ switch (a) {
424
+ case "--project-dir":
425
+ case "--cwd":
426
+ out.projectDir = argv[++i];
427
+ break;
428
+ case "--switch-to":
429
+ out.switchTo = argv[++i];
430
+ break;
431
+ case "--force":
432
+ out.force = true;
433
+ break;
434
+ case "--json":
435
+ out.json = true;
436
+ break;
437
+ case "--pretty":
438
+ out.pretty = true;
439
+ break;
440
+ case "--help":
441
+ case "-h":
442
+ out.help = true;
443
+ break;
444
+ }
445
+ }
446
+ return out;
447
+ }
448
+ var HELP = `lakebase-scm-park-feature (phase B+)
449
+
450
+ Release a feature claim but KEEP its paired branch: switch HEAD to the parent
451
+ tier, reset workflow-state to scaffold-complete, leave the Lakebase + git
452
+ branch intact. Frees the single SCM claim so another feature can be claimed
453
+ and driven; resume the parked feature later with
454
+ 'lakebase-scm-claim-feature-branch <feature>' (it reuses the kept branch).
455
+
456
+ Usage:
457
+ lakebase-scm-park-feature [flags]
458
+
459
+ Flags:
460
+ --project-dir <dir> Project root (default: cwd)
461
+ --switch-to <branch> Branch to checkout after releasing (default: parent_branch)
462
+ --force Allow park even with a dirty working tree
463
+ (edits are kept but stranded on the feature branch)
464
+ --json Machine-readable JSON output
465
+ --pretty Pretty-print JSON (only with --json)
466
+ -h, --help Show this help
467
+
468
+ Exit codes:
469
+ 0 = parked (claim freed, branch kept, state reset to scaffold-complete)
470
+ 1 = no state file
471
+ 2 = precondition refused (wrong state, dirty tree without --force, missing fields)
472
+ 3 = substrate failure
473
+ `;
474
+ function renderHuman(report) {
475
+ if (!report.ok) {
476
+ return `lakebase-scm-park-feature: ${report.error?.code}
477
+
478
+ ${report.error?.message}`;
479
+ }
480
+ const r = report.result;
481
+ const lines = ["Feature parked (branch kept):"];
482
+ lines.push(` state : ${r.state.state}`);
483
+ lines.push(` parked_feature : ${r.parkedFeatureId}`);
484
+ lines.push(` kept_branch : ${r.keptBranch}`);
485
+ lines.push(` switched_to : ${r.switchedTo}`);
486
+ lines.push("");
487
+ lines.push(` resume with : lakebase-scm-claim-feature-branch ${r.parkedFeatureId}`);
488
+ if (r.warnings.length > 0) {
489
+ lines.push("");
490
+ lines.push("warnings:");
491
+ for (const w of r.warnings) lines.push(` - ${w}`);
492
+ }
493
+ return lines.join("\n");
494
+ }
495
+ function exitCodeForError(err) {
496
+ if (err instanceof ScmReleaseError) {
497
+ if (err.code === "no-state-file") return 1;
498
+ return 2;
499
+ }
500
+ return 3;
501
+ }
502
+ async function runScmParkFeatureCli(argv) {
503
+ const args = parseArgs(argv);
504
+ if (args.help) {
505
+ process.stdout.write(`${HELP}
506
+ `);
507
+ return 0;
508
+ }
509
+ const projectDir = path2.resolve(args.projectDir ?? process.cwd());
510
+ try {
511
+ const result = await parkFeatureBranch({
512
+ projectDir,
513
+ switchTo: args.switchTo,
514
+ force: args.force
515
+ });
516
+ const report = { ok: true, result };
517
+ if (args.json) {
518
+ const indent = args.pretty ? 2 : 0;
519
+ process.stdout.write(`${JSON.stringify(report, null, indent)}
520
+ `);
521
+ } else {
522
+ process.stdout.write(`${renderHuman(report)}
523
+ `);
524
+ }
525
+ return 0;
526
+ } catch (e) {
527
+ const err = e;
528
+ const code = err instanceof ScmReleaseError ? err.code : "substrate-failure";
529
+ const report = { ok: false, error: { code, message: err.message } };
530
+ if (args.json) {
531
+ const indent = args.pretty ? 2 : 0;
532
+ process.stdout.write(`${JSON.stringify(report, null, indent)}
533
+ `);
534
+ } else {
535
+ process.stderr.write(`${renderHuman(report)}
536
+ `);
537
+ }
538
+ return exitCodeForError(err);
539
+ }
540
+ }
541
+ if (isCliEntry(import.meta.url)) {
542
+ void runScmParkFeatureCli(process.argv.slice(2)).then((c) => process.exit(c));
543
+ }
544
+ export {
545
+ runScmParkFeatureCli
546
+ };
@@ -79288,8 +79288,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
79288
79288
  var cached;
79289
79289
  function substrateSelfVersion() {
79290
79290
  if (cached !== void 0) return cached;
79291
- if ("0.3.5".length > 0) {
79292
- cached = "0.3.5";
79291
+ if ("0.3.7".length > 0) {
79292
+ cached = "0.3.7";
79293
79293
  return cached;
79294
79294
  }
79295
79295
  cached = "unknown";
@@ -389,8 +389,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
389
389
  var cached;
390
390
  function substrateSelfVersion() {
391
391
  if (cached !== void 0) return cached;
392
- if ("0.3.5".length > 0) {
393
- cached = "0.3.5";
392
+ if ("0.3.7".length > 0) {
393
+ cached = "0.3.7";
394
394
  return cached;
395
395
  }
396
396
  cached = "unknown";
@@ -1667,8 +1667,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
1667
1667
  var cached;
1668
1668
  function substrateSelfVersion() {
1669
1669
  if (cached !== void 0) return cached;
1670
- if ("0.3.5".length > 0) {
1671
- cached = "0.3.5";
1670
+ if ("0.3.7".length > 0) {
1671
+ cached = "0.3.7";
1672
1672
  return cached;
1673
1673
  }
1674
1674
  cached = "unknown";
@@ -1630,8 +1630,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
1630
1630
  var cached;
1631
1631
  function substrateSelfVersion() {
1632
1632
  if (cached !== void 0) return cached;
1633
- if ("0.3.5".length > 0) {
1634
- cached = "0.3.5";
1633
+ if ("0.3.7".length > 0) {
1634
+ cached = "0.3.7";
1635
1635
  return cached;
1636
1636
  }
1637
1637
  cached = "unknown";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@databricks-solutions/lakebase-scm-utils",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Lakebase SCM + substrate engine: database branching, paired-branch SCM workflows, connection/credential minting, schema migration, project scaffold + deploy primitives, and the git/github utility layer. Extracted from consort for portable, standalone consumption by lakebase-scm-extension and the SFTDD orchestration.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -97,6 +97,7 @@
97
97
  "lakebase-schema-migrate": "./dist/scripts/lakebase/schema-migrate.cli.js",
98
98
  "lakebase-scm-abandon-feature": "./dist/scripts/lakebase/scm-abandon-feature.cli.js",
99
99
  "lakebase-scm-adopt-state": "./dist/scripts/lakebase/scm-adopt-state.cli.js",
100
+ "lakebase-scm-park-feature": "./dist/scripts/lakebase/scm-park-feature.cli.js",
100
101
  "lakebase-scm-claim-feature-branch": "./dist/scripts/lakebase/scm-claim-feature.cli.js",
101
102
  "lakebase-scm-cleanup": "./dist/scripts/lakebase/scm-cleanup.cli.js",
102
103
  "lakebase-scm-doctor": "./dist/scripts/lakebase/scm-doctor.cli.js",
@@ -202,6 +202,19 @@ const RUN_TESTS_E2E_MARKER = "# run Playwright E2E suite when configured";
202
202
  */
203
203
  const CLIENT_E2E_MARKER = "# run client Playwright E2E suite when configured";
204
204
 
205
+ /**
206
+ * The markers matched ONLY as standalone lines (the appended block's own opening
207
+ * comment lines), never as substrings. The base run-tests.sh TEMPLATE documents the
208
+ * E2E-assembly contract in a header comment that QUOTES RUN_TESTS_E2E_MARKER verbatim
209
+ * inside backticks; a substring `includes`/`indexOf` match there matched the prose
210
+ * mention and truncated the ENTIRE primary-suite body (the run-tests.sh clobber that
211
+ * silently made every honest-GREEN + deploy verify exit 0). A line-anchored (`^…$`,
212
+ * multiline) match only ever finds the real block, never the documentation comment.
213
+ */
214
+ const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
215
+ const RUN_TESTS_E2E_MARKER_LINE = new RegExp(`^${escapeRe(RUN_TESTS_E2E_MARKER)}$`, "m");
216
+ const CLIENT_E2E_MARKER_LINE = new RegExp(`^${escapeRe(CLIENT_E2E_MARKER)}$`, "m");
217
+
205
218
  /** The full E2E block appended to run-tests.sh (project-root layout + Python +
206
219
  * the client-workspace layout). Built once so the fresh-insert and the in-place
207
220
  * upgrade write byte-identical content. */
@@ -307,16 +320,33 @@ export function addE2eToRunTestsScript(
307
320
  return { patched: false, inserted: false };
308
321
  }
309
322
  const original = fs.readFileSync(scriptPath, "utf8");
310
- const hasBase = original.includes(RUN_TESTS_E2E_MARKER);
311
- const hasClient = original.includes(CLIENT_E2E_MARKER);
323
+ // Match markers as standalone LINES, not substrings (see RUN_TESTS_E2E_MARKER_LINE):
324
+ // a prose mention in the template header must never be treated as the block boundary.
325
+ const baseMarker = RUN_TESTS_E2E_MARKER_LINE.exec(original);
326
+ const hasBase = baseMarker !== null;
327
+ const hasClient = CLIENT_E2E_MARKER_LINE.test(original);
312
328
  if (hasBase && hasClient) {
313
329
  return { patched: true, inserted: false }; // already the current block
314
330
  }
315
331
  // Truncate an OLD block (base marker present, client branch missing) so we replace
316
332
  // rather than duplicate; else append to the end after the language-specific suite.
317
- const cut = hasBase ? original.slice(0, original.indexOf(RUN_TESTS_E2E_MARKER)) : original;
333
+ const cut = hasBase ? original.slice(0, baseMarker!.index) : original;
318
334
  const base = cut.replace(/\n+$/, "\n");
319
- fs.writeFileSync(scriptPath, base + runTestsE2eBlock(), "utf8");
335
+ const assembled = base + runTestsE2eBlock();
336
+ // FAIL-CLOSED: never ship a run-tests.sh that lost its primary suite. The base
337
+ // template assigns REPO_ROOT and runs the pytest/vitest body; if the ORIGINAL
338
+ // carried that body but the ASSEMBLED result would not, the cut dropped real
339
+ // content — throw rather than write a hollow, always-exit-0 harness (the clobber
340
+ // defect). A genuinely body-less original (nothing to preserve) is untouched.
341
+ const BODY_SENTINEL = 'REPO_ROOT="$(cd';
342
+ if (original.includes(BODY_SENTINEL) && !assembled.includes(BODY_SENTINEL)) {
343
+ throw new Error(
344
+ `addE2eToRunTestsScript: refusing to write ${scriptPath} — the E2E assembly would drop the ` +
345
+ `primary-suite body (REPO_ROOT/pytest/vitest). This is the marker-match clobber; the marker ` +
346
+ `must anchor to the appended block's own line, not a prose mention.`,
347
+ );
348
+ }
349
+ fs.writeFileSync(scriptPath, assembled, "utf8");
320
350
  return { patched: true, inserted: true };
321
351
  }
322
352
 
@@ -5,7 +5,7 @@
5
5
  import * as path from "node:path";
6
6
  import { isCliEntry } from "../util/cli-entry.js";
7
7
  import {
8
- ScmAbandonError,
8
+ ScmReleaseError,
9
9
  abandonFeatureBranch,
10
10
  type AbandonFeatureResult,
11
11
  } from "./scm-abandon-feature.js";
@@ -103,7 +103,7 @@ function renderHuman(report: AbandonReport): string {
103
103
  }
104
104
 
105
105
  function exitCodeForError(err: Error): number {
106
- if (err instanceof ScmAbandonError) {
106
+ if (err instanceof ScmReleaseError) {
107
107
  if (err.code === "no-state-file") return 1;
108
108
  return 2;
109
109
  }
@@ -137,7 +137,7 @@ export async function runScmAbandonFeatureCli(
137
137
  } catch (e) {
138
138
  const err = e as Error;
139
139
  const code =
140
- err instanceof ScmAbandonError ? err.code : "substrate-failure";
140
+ err instanceof ScmReleaseError ? err.code : "substrate-failure";
141
141
  const report: AbandonReport = {
142
142
  ok: false,
143
143
  error: { code, message: err.message },