@databricks-solutions/lakebase-scm-utils 0.3.4 → 0.3.6

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 (47) hide show
  1. package/dist/scripts/github/index.cjs +14 -0
  2. package/dist/scripts/github/index.d.cts +19 -1
  3. package/dist/scripts/github/index.d.ts +19 -1
  4. package/dist/scripts/github/index.js +13 -0
  5. package/dist/scripts/index.cjs +94 -6
  6. package/dist/scripts/index.d.cts +2 -2
  7. package/dist/scripts/index.d.ts +2 -2
  8. package/dist/scripts/index.js +91 -6
  9. package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +2 -2
  10. package/dist/scripts/lakebase/ci-resolve-branch.cli.js +2 -2
  11. package/dist/scripts/lakebase/collapse-heads.cli.cjs +2 -2
  12. package/dist/scripts/lakebase/collapse-heads.cli.js +2 -2
  13. package/dist/scripts/lakebase/detect-language.cli.cjs +2 -2
  14. package/dist/scripts/lakebase/detect-language.cli.js +2 -2
  15. package/dist/scripts/lakebase/doctor.cli.cjs +15664 -7598
  16. package/dist/scripts/lakebase/doctor.cli.js +8243 -106
  17. package/dist/scripts/lakebase/get-connection.cli.cjs +2 -2
  18. package/dist/scripts/lakebase/get-connection.cli.js +2 -2
  19. package/dist/scripts/lakebase/index.cjs +187 -99
  20. package/dist/scripts/lakebase/index.d.cts +22 -1
  21. package/dist/scripts/lakebase/index.d.ts +22 -1
  22. package/dist/scripts/lakebase/index.js +185 -99
  23. package/dist/scripts/lakebase/infra-runner.cli.cjs +2 -2
  24. package/dist/scripts/lakebase/infra-runner.cli.js +2 -2
  25. package/dist/scripts/lakebase/new-migration.cli.cjs +2 -2
  26. package/dist/scripts/lakebase/new-migration.cli.js +2 -2
  27. package/dist/scripts/lakebase/schema-diff.cli.cjs +2 -2
  28. package/dist/scripts/lakebase/schema-diff.cli.js +2 -2
  29. package/dist/scripts/lakebase/schema-migrate.cli.cjs +2 -2
  30. package/dist/scripts/lakebase/schema-migrate.cli.js +2 -2
  31. package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +2 -2
  32. package/dist/scripts/lakebase/scm-claim-feature.cli.js +2 -2
  33. package/dist/scripts/lakebase/scm-doctor.cli.cjs +2 -2
  34. package/dist/scripts/lakebase/scm-doctor.cli.js +2 -2
  35. package/dist/scripts/lakebase/scm-merge.cli.cjs +2 -2
  36. package/dist/scripts/lakebase/scm-merge.cli.js +2 -2
  37. package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +2 -2
  38. package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +2 -2
  39. package/dist/scripts/util/index.cjs +2 -2
  40. package/dist/scripts/util/index.js +2 -2
  41. package/package.json +1 -1
  42. package/scripts/github/repo.ts +37 -1
  43. package/scripts/lakebase/create-preflight.ts +45 -0
  44. package/scripts/lakebase/create-project.ts +15 -0
  45. package/scripts/lakebase/doctor.ts +50 -0
  46. package/scripts/lakebase/enable-e2e.ts +34 -4
  47. package/templates/project/common/.github/workflows/pr.yml +14 -5
@@ -19,6 +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
23
 
23
24
  export interface PreflightResult {
24
25
  ok: boolean;
@@ -113,6 +114,50 @@ export async function checkDatabricksAuth(
113
114
  }
114
115
  }
115
116
 
117
+ /** Injectable scope probe for checkGitHubWorkflowScope (defaults to the real GitHub API read). */
118
+ type ScopeProbe = () => Promise<{ granted: string[] | null; missing: string[] }>;
119
+
120
+ /**
121
+ * W-workflow-scope: preflight the GitHub token's `workflow` OAuth scope BEFORE
122
+ * any repo / Lakebase project is created. The scaffold's FINAL push commits
123
+ * `.github/workflows/*`, which GitHub REJECTS unless the token carries
124
+ * `workflow` — without this, that rejection only surfaces at the very end,
125
+ * after all the provisioning, forcing a full rollback. Gating the ONE scope the
126
+ * push needs here turns that into an immediate, actionable failure.
127
+ *
128
+ * Non-blocking in two cases (ok:true): a token that exposes NO OAuth scopes
129
+ * (fine-grained PAT / GitHub App token — `granted` null; its workflow
130
+ * permission isn't visible this way, so we can't assert), and any probe error
131
+ * (a genuinely bad/absent token surfaces clearly at repo creation, not here).
132
+ */
133
+ export async function checkGitHubWorkflowScope(
134
+ probe: ScopeProbe = () => checkGitHubScopes(["workflow"]),
135
+ ): Promise<PreflightResult> {
136
+ try {
137
+ const { granted, missing } = await probe();
138
+ if (granted === null) return { ok: true }; // scopes not visible → can't assert, don't false-fail
139
+ if (missing.includes("workflow")) {
140
+ return { ok: false, reason: `token grants [${granted.join(", ") || "none"}] but not \`workflow\`` };
141
+ }
142
+ return { ok: true };
143
+ } catch {
144
+ return { ok: true }; // best-effort: a probe failure must not block scaffold
145
+ }
146
+ }
147
+
148
+ /** The actionable prereq message for a GitHub token missing the `workflow` scope. */
149
+ export function githubWorkflowScopePrereqMessage(reason?: string): string {
150
+ return (
151
+ "The GitHub token lacks the `workflow` OAuth scope, required to push the project's " +
152
+ "`.github/workflows/*` CI files. (Without this check, scaffolding runs all the way to the " +
153
+ "final push, is rejected, and rolls back.) Grant the scope, then re-run:\n" +
154
+ " - gh CLI: gh auth refresh -h github.com -s workflow\n" +
155
+ " - VS Code: re-sign in to GitHub and approve the workflow scope\n" +
156
+ " - token: set GITHUB_TOKEN to a PAT that includes the `workflow` scope" +
157
+ (reason ? `\n(${reason})` : "")
158
+ );
159
+ }
160
+
116
161
  /** The actionable prereq message for a failed auth probe (W5). */
117
162
  export function databricksAuthPrereqMessage(host?: string, reason?: string): string {
118
163
  const hostFlag = host ? ` --host ${host.replace(/\/+$/, "")}` : "";
@@ -20,6 +20,8 @@ import {
20
20
  import {
21
21
  checkDatabricksAuth,
22
22
  databricksAuthPrereqMessage,
23
+ checkGitHubWorkflowScope,
24
+ githubWorkflowScopePrereqMessage,
23
25
  withLakebaseRollback,
24
26
  validateCreateInputs,
25
27
  dirIsEmpty,
@@ -289,6 +291,19 @@ export async function createProject(
289
291
  throw new Error(databricksAuthPrereqMessage(host, auth.reason));
290
292
  }
291
293
 
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).
299
+ if (useGithub) {
300
+ report("Checking GitHub token scopes...");
301
+ const scope = await checkGitHubWorkflowScope();
302
+ if (!scope.ok) {
303
+ throw new Error(githubWorkflowScopePrereqMessage(scope.reason));
304
+ }
305
+ }
306
+
292
307
  // ── Step 1+2: GitHub repo + clone, OR local-only setup ────────
293
308
  if (useGithub) {
294
309
  report("Creating GitHub repository...", fullRepoName);
@@ -14,6 +14,7 @@ import { listBranches } from "./branch-utils.js";
14
14
  import { verifyHooks } from "./project-verify.js";
15
15
  import { detectLanguage } from "./schema-migrate.js";
16
16
  import { detectWorkflowDrift } from "./workflow-drift.js";
17
+ import { checkGitHubScopes } from "../github/repo.js";
17
18
 
18
19
  export type CheckStatus = "ok" | "warn" | "fail" | "skip";
19
20
 
@@ -593,6 +594,53 @@ async function checkGitRemote(projectDir: string): Promise<CheckResult> {
593
594
  }
594
595
  }
595
596
 
597
+ /**
598
+ * Probe whether the GitHub token carries the `workflow` scope needed to push
599
+ * `.github/workflows/*`. A missing scope is exactly why a scaffold push (or any
600
+ * later workflow edit) gets rejected at the very end and rolls back. Best-effort:
601
+ * no token, OR a fine-grained / GitHub App token (no visible OAuth scopes), OR
602
+ * an API error → `skip`, never a false `fail`. `probe` is injectable for tests.
603
+ */
604
+ export async function checkGitHubTokenScope(
605
+ probe: () => Promise<{ granted: string[] | null; missing: string[] }> = () =>
606
+ checkGitHubScopes(["workflow"])
607
+ ): Promise<CheckResult> {
608
+ try {
609
+ const { granted, missing } = await probe();
610
+ if (granted === null) {
611
+ return {
612
+ name: "github-token-scope",
613
+ status: "skip",
614
+ message:
615
+ "GitHub token scopes not visible (fine-grained PAT / App token) - `workflow` scope unverified",
616
+ };
617
+ }
618
+ if (missing.includes("workflow")) {
619
+ return {
620
+ name: "github-token-scope",
621
+ status: "fail",
622
+ message:
623
+ "GitHub token lacks the `workflow` scope - pushes touching .github/workflows/* will be rejected",
624
+ detail: { granted },
625
+ hint: "Grant it with `gh auth refresh -h github.com -s workflow`, re-sign in to GitHub in VS Code and approve the workflow scope, or set GITHUB_TOKEN to a PAT that includes `workflow`.",
626
+ };
627
+ }
628
+ return {
629
+ name: "github-token-scope",
630
+ status: "ok",
631
+ message: "GitHub token has the `workflow` scope",
632
+ detail: { granted },
633
+ };
634
+ } catch (err) {
635
+ return {
636
+ name: "github-token-scope",
637
+ status: "skip",
638
+ message: "GitHub token scope check skipped (no token resolved, or GitHub unreachable)",
639
+ detail: { error: (err as Error).message },
640
+ };
641
+ }
642
+ }
643
+
596
644
  function checkLanguage(projectDir: string): CheckResult {
597
645
  try {
598
646
  const lang = detectLanguage(projectDir);
@@ -732,6 +780,7 @@ export async function runDoctor(args: DoctorArgs = {}): Promise<DoctorReport> {
732
780
  host
733
781
  );
734
782
  const gitRemote = await checkGitRemote(projectDir);
783
+ const githubTokenScope = await checkGitHubTokenScope();
735
784
  const language = checkLanguage(projectDir);
736
785
  const hooks = checkHooks(projectDir);
737
786
  const workflowDrift = checkWorkflowDrift(projectDir);
@@ -763,6 +812,7 @@ export async function runDoctor(args: DoctorArgs = {}): Promise<DoctorReport> {
763
812
  configProfile,
764
813
  lakebaseProject,
765
814
  gitRemote,
815
+ githubTokenScope,
766
816
  language,
767
817
  hooks,
768
818
  workflowDrift,
@@ -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
 
@@ -148,11 +148,6 @@ jobs:
148
148
  # the CI branch is provisioned and fail fast. `--if-present` no-ops a
149
149
  # client with no `test` script. The client's Playwright E2E runs later
150
150
  # (its own step, gated on client/playwright.config.ts).
151
- - name: Run client unit tests
152
- if: hashFiles('client/package.json') != ''
153
- working-directory: client
154
- run: npm run test --if-present
155
-
156
151
  # The official action installs to a user-writable path (no sudo). It's
157
152
  # a no-op if a compatible databricks CLI is already on PATH.
158
153
  - name: Install Databricks CLI
@@ -384,6 +379,20 @@ jobs:
384
379
  --pretty
385
380
  echo "Migrations completed."
386
381
 
382
+ - name: Run client unit tests
383
+ # The kit's client/vitest.globalSetup.ts boots a REAL backend over the /api
384
+ # boundary (never mock when a real counterpart exists), so the component suite
385
+ # NEEDS the CI Lakebase branch + DATABASE_URL. Run it AFTER datasource config +
386
+ # migrations, not before. No-op when there are no component tests (globalSetup
387
+ # returns early); skipped when there is no CI Lakebase branch (url empty).
388
+ if: hashFiles('client/package.json') != '' && steps.lakebase-db.outputs.url != ''
389
+ working-directory: client
390
+ env:
391
+ DATABASE_URL: ${{ env.DATABASE_URL }}
392
+ DB_USERNAME: ${{ env.DB_USERNAME }}
393
+ DB_PASSWORD: ${{ env.DB_PASSWORD }}
394
+ run: npm run test --if-present
395
+
387
396
  - name: Run tests
388
397
  env:
389
398
  DATABASE_URL: ${{ env.DATABASE_URL }}