@databricks-solutions/lakebase-scm-utils 0.3.10 → 0.3.12

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/{pr-e-E4aP4j.d.cts → pr-D-eayWyJ.d.cts} +59 -1
  2. package/dist/{pr-e-E4aP4j.d.ts → pr-D-eayWyJ.d.ts} +59 -1
  3. package/dist/scripts/git/index.cjs +30 -14
  4. package/dist/scripts/git/index.d.cts +32 -1
  5. package/dist/scripts/git/index.d.ts +32 -1
  6. package/dist/scripts/git/index.js +28 -14
  7. package/dist/scripts/github/index.d.cts +2 -60
  8. package/dist/scripts/github/index.d.ts +2 -60
  9. package/dist/scripts/index.cjs +268 -76
  10. package/dist/scripts/index.d.cts +4 -4
  11. package/dist/scripts/index.d.ts +4 -4
  12. package/dist/scripts/index.js +261 -76
  13. package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +2 -2
  14. package/dist/scripts/lakebase/ci-resolve-branch.cli.js +2 -2
  15. package/dist/scripts/lakebase/collapse-heads.cli.cjs +2 -2
  16. package/dist/scripts/lakebase/collapse-heads.cli.js +2 -2
  17. package/dist/scripts/lakebase/detect-language.cli.cjs +2 -2
  18. package/dist/scripts/lakebase/detect-language.cli.js +2 -2
  19. package/dist/scripts/lakebase/doctor.cli.cjs +2 -2
  20. package/dist/scripts/lakebase/doctor.cli.js +2 -2
  21. package/dist/scripts/lakebase/get-connection.cli.cjs +2 -2
  22. package/dist/scripts/lakebase/get-connection.cli.js +2 -2
  23. package/dist/scripts/lakebase/index.cjs +280 -77
  24. package/dist/scripts/lakebase/index.d.cts +97 -2
  25. package/dist/scripts/lakebase/index.d.ts +97 -2
  26. package/dist/scripts/lakebase/index.js +275 -77
  27. package/dist/scripts/lakebase/infra-runner.cli.cjs +2 -2
  28. package/dist/scripts/lakebase/infra-runner.cli.js +2 -2
  29. package/dist/scripts/lakebase/new-migration.cli.cjs +2 -2
  30. package/dist/scripts/lakebase/new-migration.cli.js +2 -2
  31. package/dist/scripts/lakebase/schema-diff.cli.cjs +2 -2
  32. package/dist/scripts/lakebase/schema-diff.cli.js +2 -2
  33. package/dist/scripts/lakebase/schema-migrate.cli.cjs +2 -2
  34. package/dist/scripts/lakebase/schema-migrate.cli.js +2 -2
  35. package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +2 -2
  36. package/dist/scripts/lakebase/scm-claim-feature.cli.js +2 -2
  37. package/dist/scripts/lakebase/scm-doctor.cli.cjs +2 -2
  38. package/dist/scripts/lakebase/scm-doctor.cli.js +2 -2
  39. package/dist/scripts/lakebase/scm-merge.cli.cjs +2 -2
  40. package/dist/scripts/lakebase/scm-merge.cli.js +2 -2
  41. package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +2 -2
  42. package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +2 -2
  43. package/dist/scripts/util/index.cjs +2 -2
  44. package/dist/scripts/util/index.js +2 -2
  45. package/package.json +1 -1
  46. package/scripts/git/commit-push.ts +60 -14
  47. package/scripts/lakebase/create-preflight.ts +213 -1
  48. package/scripts/lakebase/create-project.ts +98 -30
  49. package/scripts/lakebase/scaffold.ts +50 -0
@@ -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.10".length > 0) {
393
- cached = "0.3.10";
392
+ if ("0.3.12".length > 0) {
393
+ cached = "0.3.12";
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.10".length > 0) {
1671
- cached = "0.3.10";
1670
+ if ("0.3.12".length > 0) {
1671
+ cached = "0.3.12";
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.10".length > 0) {
1634
- cached = "0.3.10";
1633
+ if ("0.3.12".length > 0) {
1634
+ cached = "0.3.12";
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.10",
3
+ "version": "0.3.12",
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,
@@ -45,24 +45,70 @@ export class WorkflowScopeError extends Error {
45
45
  * For non-initial-commit flows, prefer the primitives in ./commits.ts.
46
46
  */
47
47
  export async function commitAndPush(args: CommitAndPushArgs): Promise<void> {
48
- await exec("git add -A", { cwd: args.projectDir });
49
- await exec(`git commit -m ${JSON.stringify(args.message)}`, {
50
- cwd: args.projectDir,
48
+ await commitInitial(args.projectDir, args.message);
49
+ if (args.push === false) return;
50
+ await pushInitial({ projectDir: args.projectDir, remote: args.remote, branch: args.branch });
51
+ }
52
+
53
+ /** Stage everything and make the initial commit (no push). */
54
+ export async function commitInitial(projectDir: string, message: string): Promise<void> {
55
+ await exec("git add -A", { cwd: projectDir });
56
+ await exec(`git commit -m ${JSON.stringify(message)}`, {
57
+ cwd: projectDir,
51
58
  timeout: 30_000,
52
59
  });
53
- if (args.push === false) return;
60
+ }
61
+
62
+ export interface PushInitialArgs {
63
+ projectDir: string;
64
+ /** Remote name. Default: "origin". */
65
+ remote?: string;
66
+ /** Branch name. Default: "main". */
67
+ branch?: string;
68
+ /**
69
+ * Total push attempts (>= 1). Default 1 (preserves commitAndPush's behavior).
70
+ * The create flow passes 2: the FIRST push fires the project's pre-push hook,
71
+ * which triggers the one-time cold download of the Consort toolkit — that race
72
+ * can fail the push, but the toolkit is CACHED afterward, so a single retry then
73
+ * sails through (the cold-download incident). A `workflow`-scope rejection is a
74
+ * real auth gap, never retried.
75
+ */
76
+ attempts?: number;
77
+ /** Delay between attempts (ms). Default 1500. Injected in tests. */
78
+ delayMs?: number;
79
+ /** Injectable sleep (tests). */
80
+ sleep?: (ms: number) => Promise<void>;
81
+ /** Injectable runner (tests); defaults to the real `git push`. */
82
+ run?: (cmd: string, cwd: string) => Promise<void>;
83
+ }
84
+
85
+ /**
86
+ * Push the initial branch with `-u`, retrying a transient failure up to
87
+ * `attempts` times. Maps the GitHub `workflow`-scope rejection to
88
+ * {@link WorkflowScopeError} (thrown immediately, never retried). Throws the last
89
+ * error after the final attempt.
90
+ */
91
+ export async function pushInitial(args: PushInitialArgs): Promise<void> {
54
92
  const remote = args.remote ?? "origin";
55
93
  const branch = args.branch ?? "main";
56
- try {
57
- await exec(`git push -u ${remote} ${branch}`, {
58
- cwd: args.projectDir,
59
- timeout: 30_000,
60
- });
61
- } catch (err) {
62
- const msg = err instanceof Error ? err.message : String(err);
63
- if (/without `?workflow`? scope|workflow scope/i.test(msg)) {
64
- throw new WorkflowScopeError(args.projectDir);
94
+ const attempts = Math.max(1, args.attempts ?? 1);
95
+ const sleep = args.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
96
+ const run =
97
+ args.run ??
98
+ ((cmd: string, cwd: string) => exec(cmd, { cwd, timeout: 30_000 }).then(() => undefined));
99
+ let lastErr: unknown;
100
+ for (let i = 1; i <= attempts; i++) {
101
+ try {
102
+ await run(`git push -u ${remote} ${branch}`, args.projectDir);
103
+ return;
104
+ } catch (err) {
105
+ const msg = err instanceof Error ? err.message : String(err);
106
+ if (/without `?workflow`? scope|workflow scope/i.test(msg)) {
107
+ throw new WorkflowScopeError(args.projectDir);
108
+ }
109
+ lastErr = err;
110
+ if (i < attempts) await sleep(args.delayMs ?? 1500);
65
111
  }
66
- throw err;
67
112
  }
113
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
68
114
  }
@@ -19,7 +19,7 @@ import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
20
  import { deleteLakebaseProject } from "./lakebase-project.js";
21
21
  import { runDatabricks } from "./databricks-cli.js";
22
- import { checkGitHubScopes } from "../github/repo.js";
22
+ import { checkGitHubScopes, getCurrentUser, repoExists, type GitHubScopeStatus } from "../github/repo.js";
23
23
 
24
24
  export interface PreflightResult {
25
25
  ok: boolean;
@@ -145,6 +145,134 @@ export async function checkGitHubWorkflowScope(
145
145
  }
146
146
  }
147
147
 
148
+ export interface GitHubPreflightInput {
149
+ /** Target repo owner (user or org). */
150
+ owner: string;
151
+ /** Bare repo name (the project name). */
152
+ repoName: string;
153
+ /** Scopes whose absence BLOCKS the happy path (create + push). Default ["repo","workflow"]. */
154
+ hardScopes?: readonly string[];
155
+ /** Scopes whose absence only WARNS (e.g. delete_repo, needed for rollback repo-delete). Default ["delete_repo"]. */
156
+ advisoryScopes?: readonly string[];
157
+ /** Injected probes (default to the real GitHub reads). */
158
+ currentUser?: () => Promise<string>;
159
+ scopes?: (required: readonly string[]) => Promise<GitHubScopeStatus>;
160
+ repoExists?: (fullName: string) => Promise<boolean>;
161
+ }
162
+
163
+ export interface GitHubPreflightResult extends PreflightResult {
164
+ /** Non-blocking notes (org/owner mismatch, missing advisory scope, invisible scopes). */
165
+ warnings: string[];
166
+ /** The resolved authenticated login, when B1 succeeded. */
167
+ activeUser?: string;
168
+ }
169
+
170
+ /**
171
+ * One up-front GitHub preflight run BEFORE any repo / Lakebase project is created,
172
+ * so a missing requisite fails in seconds with a fix instead of mid-create (where
173
+ * it orphans partially-created artifacts). Supersedes the workflow-only check:
174
+ *
175
+ * B1 active account getCurrentUser() must resolve (a valid token) — else a
176
+ * wrong/absent token otherwise 403s LATE at the push.
177
+ * B2 scope set checkGitHubScopes([...hard, ...advisory]): a missing HARD
178
+ * scope (repo/workflow) fails; a missing ADVISORY scope
179
+ * (delete_repo) warns (rollback repo-delete unavailable);
180
+ * granted null (fine-grained PAT / App) can't be asserted.
181
+ * B3 name availability repoExists(owner/name) → fail before anything exists.
182
+ * B4 owner note login != owner ⇒ creating under an org/other account —
183
+ * surfaced (createRepo enforces the real org-create right).
184
+ *
185
+ * CONSERVATIVE: hard-fails ONLY on the clear blockers (invalid token, missing
186
+ * happy-path scope, name taken); every other uncertainty is a warning, never a
187
+ * false block (same ethos as checkGitHubWorkflowScope). All probes injected, so
188
+ * this is unit-testable with no network.
189
+ */
190
+ export async function checkGitHubPreflight(input: GitHubPreflightInput): Promise<GitHubPreflightResult> {
191
+ const warnings: string[] = [];
192
+ const hard = input.hardScopes ?? ["repo", "workflow"];
193
+ const advisory = input.advisoryScopes ?? ["delete_repo"];
194
+ const currentUserProbe = input.currentUser ?? getCurrentUser;
195
+ const scopesProbe = input.scopes ?? ((req: readonly string[]) => checkGitHubScopes(req));
196
+ const repoExistsProbe = input.repoExists ?? repoExists;
197
+
198
+ // B1: active account — a valid token. Hard blocker; nothing downstream works without it.
199
+ let activeUser: string;
200
+ try {
201
+ activeUser = await currentUserProbe();
202
+ } catch (err) {
203
+ return {
204
+ ok: false,
205
+ warnings,
206
+ reason:
207
+ `GitHub authentication failed: ${err instanceof Error ? err.message : String(err)}. ` +
208
+ "Sign in with `gh auth login`, or set GITHUB_TOKEN to a valid token, then re-run.",
209
+ };
210
+ }
211
+
212
+ // B4 (note): a login different from the owner means an org (or another account).
213
+ // Not a hard block — createRepo surfaces the real org-create permission — but
214
+ // surface it so a wrong active account is visible BEFORE the push's late 403.
215
+ if (activeUser.toLowerCase() !== input.owner.toLowerCase()) {
216
+ warnings.push(
217
+ `Signed in to GitHub as "${activeUser}" but creating under "${input.owner}" (an org or another account); ` +
218
+ `ensure "${activeUser}" can create repositories there.`,
219
+ );
220
+ }
221
+
222
+ // B2: the full required scope set — not just `workflow`.
223
+ try {
224
+ const { granted, missing } = await scopesProbe([...hard, ...advisory]);
225
+ if (granted !== null) {
226
+ const missingHard = missing.filter((s) => hard.includes(s));
227
+ const missingAdvisory = missing.filter((s) => advisory.includes(s));
228
+ if (missingHard.length > 0) {
229
+ return {
230
+ ok: false,
231
+ warnings,
232
+ activeUser,
233
+ reason:
234
+ `GitHub token missing required scope(s): ${missingHard.join(", ")} ` +
235
+ `(grants [${granted.join(", ") || "none"}]). Fix: gh auth refresh -h github.com -s ${[...hard].join(",")}`,
236
+ };
237
+ }
238
+ if (missingAdvisory.length > 0) {
239
+ warnings.push(
240
+ `GitHub token missing the ${missingAdvisory.join(", ")} scope — automatic rollback of the repo on a failed ` +
241
+ "create is unavailable (you would delete it by hand). Grant it with: gh auth refresh -h github.com -s delete_repo",
242
+ );
243
+ }
244
+ } else {
245
+ warnings.push(
246
+ "GitHub token scopes are not visible (fine-grained PAT or App token); cannot pre-verify repo/workflow access , " +
247
+ "the create will surface any gap when it pushes.",
248
+ );
249
+ }
250
+ } catch {
251
+ warnings.push("Could not read GitHub token scopes (probe error); skipping the scope pre-check.");
252
+ }
253
+
254
+ // B3: name availability — fail before anything is created.
255
+ try {
256
+ if (await repoExistsProbe(`${input.owner}/${input.repoName}`)) {
257
+ return {
258
+ ok: false,
259
+ warnings,
260
+ activeUser,
261
+ reason:
262
+ `GitHub repo "${input.owner}/${input.repoName}" already exists. ` +
263
+ "Pick a different project name, or delete the existing repo first.",
264
+ };
265
+ }
266
+ } catch {
267
+ warnings.push(
268
+ `Could not check whether "${input.owner}/${input.repoName}" already exists (probe error); ` +
269
+ "the create will fail clearly if it does.",
270
+ );
271
+ }
272
+
273
+ return { ok: true, warnings, activeUser };
274
+ }
275
+
148
276
  /** The actionable prereq message for a GitHub token missing the `workflow` scope. */
149
277
  export function githubWorkflowScopePrereqMessage(reason?: string): string {
150
278
  return (
@@ -258,3 +386,87 @@ export async function withLakebaseRollback<T>(opts: RollbackOptions, fn: () => P
258
386
  throw wrapped;
259
387
  }
260
388
  }
389
+
390
+ export interface RollbackStep {
391
+ label: string;
392
+ undo: () => Promise<void> | void;
393
+ }
394
+
395
+ export interface RollbackLedger {
396
+ /** Record a teardown for a resource the moment it is created. */
397
+ register(label: string, undo: () => Promise<void> | void): void;
398
+ /** The steps, in registration order (teardown runs them REVERSED). */
399
+ steps(): readonly RollbackStep[];
400
+ }
401
+
402
+ /** A created-resources ledger for the full create flow (A3). */
403
+ export function createRollbackLedger(): RollbackLedger {
404
+ const steps: RollbackStep[] = [];
405
+ return {
406
+ register: (label, undo) => {
407
+ steps.push({ label, undo });
408
+ },
409
+ steps: () => steps,
410
+ };
411
+ }
412
+
413
+ export interface CreateRollbackOptions {
414
+ report?: (step: string, detail?: string) => void;
415
+ }
416
+
417
+ /**
418
+ * A3: run `fn`; on a throw, tear down EVERY resource the ledger recorded, in
419
+ * REVERSE order (last-created first), then rethrow with a summary of what was
420
+ * and wasn't cleaned up.
421
+ *
422
+ * Unlike {@link withLakebaseRollback} (Lakebase project only), this undoes the
423
+ * GitHub repo, the self-hosted runner, the local dir, AND the Lakebase project
424
+ * together — so a genuine provisioning fault leaves a clean "name is free" state
425
+ * instead of orphaning three of the four artifacts (the exact 4-artifact manual
426
+ * cleanup the cold-download incident caused). A step's own failure is captured +
427
+ * reported, never thrown, so one stuck teardown can't block the others; a
428
+ * "not found" teardown counts as already-gone. The caller registers a step the
429
+ * instant each resource is created, so a failure anywhere inside `fn` rolls back
430
+ * exactly what existed at that point.
431
+ */
432
+ export async function withCreateRollback<T>(
433
+ ledger: RollbackLedger,
434
+ opts: CreateRollbackOptions,
435
+ fn: () => Promise<T>,
436
+ ): Promise<T> {
437
+ try {
438
+ return await fn();
439
+ } catch (err) {
440
+ const report = opts.report ?? (() => {});
441
+ const steps = [...ledger.steps()].reverse();
442
+ const rolledBack: string[] = [];
443
+ const failed: string[] = [];
444
+ if (steps.length > 0) report(`Create failed; rolling back ${steps.length} created resource(s)...`);
445
+ for (const s of steps) {
446
+ try {
447
+ report(` rolling back: ${s.label}`);
448
+ await s.undo();
449
+ rolledBack.push(s.label);
450
+ } catch (e) {
451
+ const m = e instanceof Error ? e.message : String(e);
452
+ if (/not.?found/i.test(m)) {
453
+ rolledBack.push(s.label);
454
+ continue;
455
+ }
456
+ failed.push(`${s.label} (${m})`);
457
+ }
458
+ }
459
+ const base = err instanceof Error ? err.message : String(err);
460
+ const parts: string[] = [];
461
+ if (rolledBack.length) parts.push(`rolled back: ${rolledBack.join(", ")}`);
462
+ if (failed.length) {
463
+ parts.push(
464
+ `COULD NOT roll back: ${failed.join("; ")} , clean these up by hand before retrying the same name`,
465
+ );
466
+ }
467
+ const suffix = parts.length ? ` (${parts.join("; ")})` : "";
468
+ const wrapped = err instanceof Error ? err : new Error(base);
469
+ wrapped.message = `${base}${suffix}`;
470
+ throw wrapped;
471
+ }
472
+ }
@@ -9,20 +9,23 @@ import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
10
  import { writeEnvFile } from "./env-file.js";
11
11
  import { verifyProject, verifyHooks, verifyWorkflows } from "./project-verify.js";
12
- import { createRepo, getRepoFullName, getCurrentUser } from "../github/repo.js";
12
+ import { createRepo, getRepoFullName, getCurrentUser, deleteRepo } from "../github/repo.js";
13
13
  import { cloneRepo } from "../git/clone.js";
14
14
  import { gitInit } from "../git/init.js";
15
- import { commitAndPush } from "../git/commit-push.js";
15
+ import { commitInitial, pushInitial, WorkflowScopeError } from "../git/commit-push.js";
16
16
  import {
17
17
  createLakebaseProject,
18
18
  getDefaultBranchId,
19
+ deleteLakebaseProject,
19
20
  } from "./lakebase-project.js";
20
21
  import {
21
22
  checkDatabricksAuth,
22
23
  databricksAuthPrereqMessage,
23
- checkGitHubWorkflowScope,
24
- githubWorkflowScopePrereqMessage,
25
- withLakebaseRollback,
24
+ checkGitHubPreflight,
25
+ warmAndVerifyKit,
26
+ kitWarmWarning,
27
+ createRollbackLedger,
28
+ withCreateRollback,
26
29
  validateCreateInputs,
27
30
  dirIsEmpty,
28
31
  } from "./create-preflight.js";
@@ -32,7 +35,7 @@ import { createLongRunningBranch } from "./long-running-branch.js";
32
35
  import { enableE2eForProject } from "./enable-e2e.js";
33
36
  import { ensureClientLockfile } from "./client-lockfile.js";
34
37
  import { enableInfraForProject } from "./enable-infra.js";
35
- import { setupRunner } from "./runner-setup.js";
38
+ import { setupRunner, removeRunner } from "./runner-setup.js";
36
39
  import { syncCiSecrets, missingCiSecrets } from "../util/ci-secrets.js";
37
40
  import { delay } from "../util/delay.js";
38
41
  import {
@@ -291,19 +294,33 @@ export async function createProject(
291
294
  throw new Error(databricksAuthPrereqMessage(host, auth.reason));
292
295
  }
293
296
 
294
- // ── GitHub token `workflow` scope precondition ────────────────
295
- // The scaffold's final push commits .github/workflows/*, which GitHub rejects
296
- // unless the token carries the `workflow` OAuth scope. Check it HERE, before any
297
- // GitHub repo or Lakebase project exists, so a missing scope fails in seconds with
298
- // a fix instead of at the final push after all provisioning (which then rolls back).
297
+ // ── GitHub account + requisites precondition (B) ──────────────
298
+ // ONE up-front GitHub preflight, BEFORE any repo / Lakebase project exists, so a
299
+ // missing requisite fails in seconds with a fix instead of mid-create (where it
300
+ // orphans partial artifacts): the active account resolves (else a wrong/absent
301
+ // token 403s LATE at the push), the full required scope set is present (repo +
302
+ // workflow — not just workflow), and the repo name is free. Conservative: notes
303
+ // (owner/org mismatch, invisible/advisory scopes) are surfaced but never block.
299
304
  if (useGithub) {
300
- report("Checking GitHub token scopes...");
301
- const scope = await checkGitHubWorkflowScope();
302
- if (!scope.ok) {
303
- throw new Error(githubWorkflowScopePrereqMessage(scope.reason));
305
+ report("Checking GitHub account + token requisites...");
306
+ const gh = await checkGitHubPreflight({ owner: input.githubOwner!, repoName: input.projectName });
307
+ for (const w of gh.warnings) {
308
+ warnings.push(w);
309
+ report(`Note: ${w}`);
304
310
  }
311
+ if (!gh.ok) throw new Error(gh.reason);
305
312
  }
306
313
 
314
+ // The created-resources ledger (A3): each resource registers its teardown the
315
+ // moment it is created, and a genuine provisioning throw inside the wrapper below
316
+ // rolls back ALL of them in reverse — the GitHub repo, the runner, the local dir,
317
+ // AND the Lakebase project together — leaving a clean "name is free" state (not
318
+ // the old 4-artifact orphan where only the Lakebase project was undone). A
319
+ // push-only failure is caught locally below and is NOT a provisioning throw, so
320
+ // it never triggers a rollback.
321
+ const rollback = createRollbackLedger();
322
+ return await withCreateRollback(rollback, { report }, async (): Promise<CreateProjectResult> => {
323
+
307
324
  // ── Step 1+2: GitHub repo + clone, OR local-only setup ────────
308
325
  if (useGithub) {
309
326
  report("Creating GitHub repository...", fullRepoName);
@@ -311,6 +328,7 @@ export async function createProject(
311
328
  private: input.privateRepo !== false,
312
329
  description: `Lakebase project: ${input.projectName}`,
313
330
  });
331
+ rollback.register(`GitHub repo ${fullRepoName}`, () => deleteRepo(fullRepoName));
314
332
 
315
333
  report("Waiting for GitHub repo to be visible...", fullRepoName);
316
334
  const probeDelays = [1000, 2000, 3000, 5000, 8000];
@@ -349,25 +367,23 @@ export async function createProject(
349
367
  repoUrl: `https://github.com/${fullRepoName}.git`,
350
368
  parentDir: input.parentDir,
351
369
  });
370
+ rollback.register(`local project dir ${projectDir}`, () => fs.rmSync(projectDir, { recursive: true, force: true }));
352
371
  } else {
353
372
  report("Creating local project directory...", projectDir);
354
373
  // A pre-existing EMPTY dir is accepted (validated up front); mkdir with
355
374
  // recursive is a no-op on it. A non-empty dir was already rejected.
356
375
  fs.mkdirSync(projectDir, { recursive: true });
357
376
  await gitInit(projectDir);
377
+ rollback.register(`local project dir ${projectDir}`, () => fs.rmSync(projectDir, { recursive: true, force: true }));
358
378
  }
359
379
 
360
380
  // ── Step 3: Lakebase project ──────────────────────────────────
361
381
  report("Creating Lakebase database (provisioning Postgres)...", lakebaseProjectId);
362
382
  await createLakebaseProject({ projectId: lakebaseProjectId, host });
363
-
364
- // From here on the Lakebase project EXISTS. If any later step throws, roll it
365
- // back (W9) so the slug isn't orphaned , a retry with the same name would
366
- // otherwise collide with the reserved/soft-deleted slug. The wrapper rethrows
367
- // with rollback context.
368
- return await withLakebaseRollback(
369
- { projectId: lakebaseProjectId, host, report },
370
- async (): Promise<CreateProjectResult> => {
383
+ // From here on the Lakebase project EXISTS; register its teardown so a later
384
+ // provisioning throw rolls it back (W9) alongside the repo + dir above — a retry
385
+ // with the same name would otherwise collide with the reserved/soft-deleted slug.
386
+ rollback.register(`Lakebase project ${lakebaseProjectId}`, () => deleteLakebaseProject({ projectId: lakebaseProjectId, host }));
371
387
 
372
388
  // ── Step 4: Default branch lookup (non-fatal if not ready yet) ─
373
389
  report("Resolving database endpoint...");
@@ -481,6 +497,11 @@ export async function createProject(
481
497
  projectName: input.projectName,
482
498
  report: (m) => report(m),
483
499
  });
500
+ // Registered only after a SUCCESSFUL setup, so a provisioning throw later
501
+ // tears the runner down (stop + deregister + rm dir) alongside the rest.
502
+ rollback.register(`self-hosted runner ${input.projectName}`, () =>
503
+ removeRunner({ fullRepoName, projectName: input.projectName }),
504
+ );
484
505
  } catch (err) {
485
506
  const msg = err instanceof Error ? err.message : String(err);
486
507
  warnings.push(`Runner setup failed: ${msg}`);
@@ -594,6 +615,25 @@ export async function createProject(
594
615
  );
595
616
  }
596
617
 
618
+ // ── Step 7g: pre-warm the toolkit so the first push doesn't cold-download mid-hook (A1) ──
619
+ // The scaffolded project's pre-push hook runs `./scripts/lk`, which triggers the
620
+ // one-time cold download of the Consort toolkit (hundreds of npm fetches). If that
621
+ // very first `lk` is the PUSH's hook, the heavy download races the push and the push
622
+ // fails (the cold-download incident). Warming HERE — after the scaffold laid down
623
+ // scripts/lk, before the push — makes the hook find a populated cache. Best-effort
624
+ // with ONE retry: if warming still fails we do NOT block create (the push then
625
+ // cold-downloads itself, exactly as before, and the retriable push below absorbs it).
626
+ if (enableSftdd && useGithub) {
627
+ report("Warming the Consort toolkit (so the first push doesn't cold-download mid-hook)...");
628
+ let warm = warmAndVerifyKit(projectDir);
629
+ if (!warm.ok) warm = warmAndVerifyKit(projectDir);
630
+ if (!warm.ok) {
631
+ const m = kitWarmWarning(projectDir, warm.reason);
632
+ warnings.push(m);
633
+ report(`Note: ${m}`);
634
+ }
635
+ }
636
+
597
637
  // ── Step 8: Initial commit (+ push when GitHub configured) ────
598
638
  const langLabels: Record<string, string> = {
599
639
  java: "Java/Spring Boot",
@@ -622,11 +662,29 @@ export async function createProject(
622
662
  }
623
663
 
624
664
  report("Creating initial commit...");
625
- await commitAndPush({
626
- projectDir,
627
- message: `Initial project scaffold (${langLabel} + Lakebase)`,
628
- push: useGithub,
629
- });
665
+ await commitInitial(projectDir, `Initial project scaffold (${langLabel} + Lakebase)`);
666
+ // The commit is now LOCAL on disk. A push failure after this point is RETRIABLE,
667
+ // NOT a provisioning fault (A2): repo + Lakebase + runner + scaffold all succeeded,
668
+ // so tearing them down for a transient first-push cold download is wrong. Push with
669
+ // ONE retry (the toolkit is cached after the warm above / the first hook run); if it
670
+ // STILL fails, leave every resource intact and print the one-line resume — do NOT
671
+ // throw (which would fire the rollback ledger). A real `workflow`-scope rejection is
672
+ // not transient and IS surfaced as a hard error.
673
+ let pushLanded = !useGithub; // local-only: nothing to push → treat as landed
674
+ if (useGithub) {
675
+ try {
676
+ await pushInitial({ projectDir, attempts: 2 });
677
+ pushLanded = true;
678
+ } catch (err) {
679
+ if (err instanceof WorkflowScopeError) throw err;
680
+ const msg = err instanceof Error ? err.message : String(err);
681
+ const m =
682
+ `Provisioning is complete, but the initial push did not land after a retry: ${msg}. ` +
683
+ `The project on disk is fine , finish it with: cd ${projectDir} && git push -u origin main`;
684
+ warnings.push(m);
685
+ report(`WARNING: ${m}`);
686
+ }
687
+ }
630
688
 
631
689
  // ── Step 8b: Long-running tier setup (architectural choice) ───
632
690
  // Tier semantics (features are NOT tiers, they are short-lived branches):
@@ -644,7 +702,17 @@ export async function createProject(
644
702
  // origin to already have the parent ref (e.g. main, staging).
645
703
  // tiers 2/3 with --no-github is rejected up front by validateCreateInputs, so
646
704
  // by here useGithub is guaranteed true for any tiers>1 request.
647
- if (tiers === 2 || tiers === 3) {
705
+ // Skip cleanly when the initial push did NOT land (A2): createLongRunningBranch
706
+ // forks off origin/main, which does not exist yet — cutting a tier now would fail.
707
+ // Surface the deferral so the user cuts the tiers after finishing the push.
708
+ if ((tiers === 2 || tiers === 3) && !pushLanded) {
709
+ const m =
710
+ `INCOMPLETE TIERS: you requested ${tiers} tiers, but the initial push hasn't landed, so the ` +
711
+ `tier branches can't fork off origin yet. After you finish the push (above), cut them with ` +
712
+ `\`./scripts/lk lakebase-cut-tier --name staging --fork-from main\`${tiers === 3 ? " and \`--name dev --fork-from staging\`" : ""}.`;
713
+ warnings.push(m);
714
+ report(`WARNING: ${m}`);
715
+ } else if (tiers === 2 || tiers === 3) {
648
716
  report(`Cutting staging tier (tiers=${tiers}) via createLongRunningBranch...`);
649
717
  try {
650
718
  await createLongRunningBranch({
@@ -712,7 +780,7 @@ export async function createProject(
712
780
  warnings,
713
781
  };
714
782
 
715
- } // end withLakebaseRollback closure
783
+ } // end withCreateRollback closure
716
784
  );
717
785
  }
718
786
 
@@ -517,6 +517,39 @@ export async function deployVscodeSettings(targetDir: string, opts?: ScaffoldOpt
517
517
  fs.copyFileSync(src, path.join(destDir, "settings.json"));
518
518
  }
519
519
 
520
+ /**
521
+ * The per-test capture modules that live at the PROJECT ROOT. They are a pair:
522
+ * `consort-test-reporter.ts` imports `./consort-capture`, and the client's test
523
+ * surface reaches up to them from `client/` — `client/vite.config.ts` wires
524
+ * `setupFiles: "./tests/setup.ts"` + `reporters: […, "./consort-vitest-reporter.ts"]`,
525
+ * both of which `import … from "../../consort-capture"` / `"../consort-capture"`,
526
+ * and `client/playwright.config.ts` (and the Node-backend root `playwright.config.ts`)
527
+ * name `../consort-test-reporter.ts` / `./consort-test-reporter.ts` as a reporter.
528
+ * Without these two files at the root, `vitest run` can't start and Playwright
529
+ * can't load. They ship in `templates/project/common/` but are NOT part of the
530
+ * file-by-file static copy above, so they must be deployed explicitly.
531
+ */
532
+ export const TEST_CAPTURE_ROOT_FILES = ["consort-capture.ts", "consort-test-reporter.ts"] as const;
533
+
534
+ /**
535
+ * Deploy the root test-capture modules (TEST_CAPTURE_ROOT_FILES) from
536
+ * `common/` to the project root. Throws if a source template is missing (a
537
+ * kit packaging bug, not a user error). Returns the relative paths written.
538
+ */
539
+ export function deployTestCaptureRoot(targetDir: string, opts?: ScaffoldOptions): string[] {
540
+ const src = commonDir(opts);
541
+ const written: string[] = [];
542
+ for (const rel of TEST_CAPTURE_ROOT_FILES) {
543
+ const from = path.join(src, rel);
544
+ if (!fs.existsSync(from)) {
545
+ throw new Error(`Kit template missing: ${from}`);
546
+ }
547
+ fs.copyFileSync(from, path.join(targetDir, rel));
548
+ written.push(rel);
549
+ }
550
+ return written;
551
+ }
552
+
520
553
  /** Deploy .gitignore: common/.gitignore.base + <language>/.gitignore.extra. */
521
554
  export async function deployGitignore(
522
555
  targetDir: string,
@@ -675,6 +708,10 @@ export interface ScaffoldStaticAllResult {
675
708
  /** `client/*` files written when the React SPA client was scaffolded
676
709
  * (clientFramework="react"); empty/omitted otherwise. */
677
710
  client?: string[];
711
+ /** Root per-test capture modules (consort-capture.ts, consort-test-reporter.ts)
712
+ * written for a Node-flavored test surface (React client or Node backend);
713
+ * empty/omitted for Python/Java backends with no React client. */
714
+ testCapture?: string[];
678
715
  }
679
716
 
680
717
  /**
@@ -795,5 +832,18 @@ export async function scaffoldAll(args: ScaffoldAllArgs): Promise<ScaffoldStatic
795
832
  if (client.length > 0) staticResult.client = client;
796
833
  }
797
834
 
835
+ // Root per-test capture modules (consort-capture.ts + consort-test-reporter.ts).
836
+ // A Node-flavored test surface reaches up to these at the project root: the
837
+ // React client's vitest reporter + setup + playwright config, and the Node
838
+ // backend's root playwright config. Without them `vitest run` / Playwright
839
+ // fail to load (the scaffold-defect halt). Python/Java backends with no React
840
+ // client use conftest.py and never import the .ts pair, so skip them.
841
+ if (args.clientFramework === "react" || language === "nodejs") {
842
+ report("Deploying root test-capture modules");
843
+ staticResult.testCapture = deployTestCaptureRoot(args.targetDir, {
844
+ templatesDir: args.templatesDir,
845
+ });
846
+ }
847
+
798
848
  return staticResult;
799
849
  }