@databricks-solutions/lakebase-scm-utils 0.3.0 → 0.3.2

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 (38) hide show
  1. package/README.md +10 -4
  2. package/dist/scripts/index.cjs +2336 -2262
  3. package/dist/scripts/index.js +395 -321
  4. package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +26 -9
  5. package/dist/scripts/lakebase/ci-resolve-branch.cli.js +27 -10
  6. package/dist/scripts/lakebase/collapse-heads.cli.cjs +26 -9
  7. package/dist/scripts/lakebase/collapse-heads.cli.js +27 -10
  8. package/dist/scripts/lakebase/detect-language.cli.cjs +26 -9
  9. package/dist/scripts/lakebase/detect-language.cli.js +27 -10
  10. package/dist/scripts/lakebase/doctor.cli.cjs +26 -9
  11. package/dist/scripts/lakebase/doctor.cli.js +27 -10
  12. package/dist/scripts/lakebase/get-connection.cli.cjs +2 -2
  13. package/dist/scripts/lakebase/get-connection.cli.js +2 -2
  14. package/dist/scripts/lakebase/index.cjs +2333 -2223
  15. package/dist/scripts/lakebase/index.js +389 -279
  16. package/dist/scripts/lakebase/infra-runner.cli.cjs +26 -9
  17. package/dist/scripts/lakebase/infra-runner.cli.js +27 -10
  18. package/dist/scripts/lakebase/new-migration.cli.cjs +26 -9
  19. package/dist/scripts/lakebase/new-migration.cli.js +27 -10
  20. package/dist/scripts/lakebase/schema-diff.cli.cjs +2 -2
  21. package/dist/scripts/lakebase/schema-diff.cli.js +2 -2
  22. package/dist/scripts/lakebase/schema-migrate.cli.cjs +26 -9
  23. package/dist/scripts/lakebase/schema-migrate.cli.js +27 -10
  24. package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +26 -9
  25. package/dist/scripts/lakebase/scm-claim-feature.cli.js +27 -10
  26. package/dist/scripts/lakebase/scm-doctor.cli.cjs +26 -9
  27. package/dist/scripts/lakebase/scm-doctor.cli.js +27 -10
  28. package/dist/scripts/lakebase/scm-merge.cli.cjs +26 -9
  29. package/dist/scripts/lakebase/scm-merge.cli.js +27 -10
  30. package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +26 -9
  31. package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +28 -11
  32. package/dist/scripts/util/index.cjs +2 -2
  33. package/dist/scripts/util/index.js +2 -2
  34. package/package.json +1 -1
  35. package/scripts/lakebase/client-lockfile.ts +102 -0
  36. package/scripts/lakebase/create-project.ts +19 -0
  37. package/scripts/lakebase/schema-migrate-runners/alembic.ts +58 -14
  38. package/templates/project/client/playwright.config.ts +35 -10
@@ -88825,8 +88825,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
88825
88825
  var cached;
88826
88826
  function substrateSelfVersion() {
88827
88827
  if (cached !== void 0) return cached;
88828
- if ("0.3.0".length > 0) {
88829
- cached = "0.3.0";
88828
+ if ("0.3.2".length > 0) {
88829
+ cached = "0.3.2";
88830
88830
  return cached;
88831
88831
  }
88832
88832
  cached = "unknown";
@@ -89010,26 +89010,43 @@ init_cjs_shims();
89010
89010
  var import_node_child_process5 = require("child_process");
89011
89011
  var fs8 = __toESM(require("fs"), 1);
89012
89012
  var path7 = __toESM(require("path"), 1);
89013
- function resolveAlembicBin(projectDir) {
89014
- const candidates = [
89013
+ var uvOnPath;
89014
+ function hasUv() {
89015
+ if (uvOnPath !== void 0) return uvOnPath;
89016
+ try {
89017
+ (0, import_node_child_process5.execFileSync)("uv", ["--version"], { stdio: "ignore" });
89018
+ uvOnPath = true;
89019
+ } catch {
89020
+ uvOnPath = false;
89021
+ }
89022
+ return uvOnPath;
89023
+ }
89024
+ function resolveAlembicInvocation(projectDir, uvAvailable = hasUv()) {
89025
+ const venvCandidates = [
89015
89026
  path7.join(projectDir, ".venv", "bin", "alembic"),
89016
89027
  path7.join(projectDir, "venv", "bin", "alembic")
89017
89028
  ];
89018
- for (const candidate of candidates) {
89029
+ for (const candidate of venvCandidates) {
89019
89030
  try {
89020
- if (fs8.existsSync(candidate)) return candidate;
89031
+ if (fs8.existsSync(candidate)) return { bin: candidate, prefixArgs: [] };
89021
89032
  } catch {
89022
89033
  }
89023
89034
  }
89024
- return "alembic";
89035
+ try {
89036
+ if (uvAvailable && fs8.existsSync(path7.join(projectDir, "pyproject.toml"))) {
89037
+ return { bin: "uv", prefixArgs: ["run", "alembic"] };
89038
+ }
89039
+ } catch {
89040
+ }
89041
+ return { bin: "alembic", prefixArgs: [] };
89025
89042
  }
89026
89043
  function spawnAlembic(projectDir, args, dsn) {
89027
89044
  return new Promise((resolve2, reject) => {
89028
- const bin = resolveAlembicBin(projectDir);
89045
+ const { bin, prefixArgs } = resolveAlembicInvocation(projectDir);
89029
89046
  const env = { ...process.env };
89030
89047
  env.PYTHONPATH = [projectDir, process.env.PYTHONPATH].filter(Boolean).join(path7.delimiter);
89031
89048
  if (dsn) env.DATABASE_URL = dsn;
89032
- const child = (0, import_node_child_process5.spawn)(bin, args, {
89049
+ const child = (0, import_node_child_process5.spawn)(bin, [...prefixArgs, ...args], {
89033
89050
  cwd: projectDir,
89034
89051
  env,
89035
89052
  stdio: ["ignore", "pipe", "pipe"]
@@ -9978,8 +9978,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
9978
9978
  var cached;
9979
9979
  function substrateSelfVersion() {
9980
9980
  if (cached !== void 0) return cached;
9981
- if ("0.3.0".length > 0) {
9982
- cached = "0.3.0";
9981
+ if ("0.3.2".length > 0) {
9982
+ cached = "0.3.2";
9983
9983
  return cached;
9984
9984
  }
9985
9985
  cached = "unknown";
@@ -10160,29 +10160,46 @@ import * as path9 from "path";
10160
10160
 
10161
10161
  // scripts/lakebase/schema-migrate-runners/alembic.ts
10162
10162
  init_esm_shims();
10163
- import { spawn } from "child_process";
10163
+ import { spawn, execFileSync as execFileSync5 } from "child_process";
10164
10164
  import * as fs7 from "fs";
10165
10165
  import * as path8 from "path";
10166
- function resolveAlembicBin(projectDir) {
10167
- const candidates = [
10166
+ var uvOnPath;
10167
+ function hasUv() {
10168
+ if (uvOnPath !== void 0) return uvOnPath;
10169
+ try {
10170
+ execFileSync5("uv", ["--version"], { stdio: "ignore" });
10171
+ uvOnPath = true;
10172
+ } catch {
10173
+ uvOnPath = false;
10174
+ }
10175
+ return uvOnPath;
10176
+ }
10177
+ function resolveAlembicInvocation(projectDir, uvAvailable = hasUv()) {
10178
+ const venvCandidates = [
10168
10179
  path8.join(projectDir, ".venv", "bin", "alembic"),
10169
10180
  path8.join(projectDir, "venv", "bin", "alembic")
10170
10181
  ];
10171
- for (const candidate of candidates) {
10182
+ for (const candidate of venvCandidates) {
10172
10183
  try {
10173
- if (fs7.existsSync(candidate)) return candidate;
10184
+ if (fs7.existsSync(candidate)) return { bin: candidate, prefixArgs: [] };
10174
10185
  } catch {
10175
10186
  }
10176
10187
  }
10177
- return "alembic";
10188
+ try {
10189
+ if (uvAvailable && fs7.existsSync(path8.join(projectDir, "pyproject.toml"))) {
10190
+ return { bin: "uv", prefixArgs: ["run", "alembic"] };
10191
+ }
10192
+ } catch {
10193
+ }
10194
+ return { bin: "alembic", prefixArgs: [] };
10178
10195
  }
10179
10196
  function spawnAlembic(projectDir, args, dsn) {
10180
10197
  return new Promise((resolve2, reject) => {
10181
- const bin = resolveAlembicBin(projectDir);
10198
+ const { bin, prefixArgs } = resolveAlembicInvocation(projectDir);
10182
10199
  const env = { ...process.env };
10183
10200
  env.PYTHONPATH = [projectDir, process.env.PYTHONPATH].filter(Boolean).join(path8.delimiter);
10184
10201
  if (dsn) env.DATABASE_URL = dsn;
10185
- const child = spawn(bin, args, {
10202
+ const child = spawn(bin, [...prefixArgs, ...args], {
10186
10203
  cwd: projectDir,
10187
10204
  env,
10188
10205
  stdio: ["ignore", "pipe", "pipe"]
@@ -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.0".length > 0) {
79292
- cached = "0.3.0";
79291
+ if ("0.3.2".length > 0) {
79292
+ cached = "0.3.2";
79293
79293
  return cached;
79294
79294
  }
79295
79295
  cached = "unknown";
@@ -79547,26 +79547,43 @@ init_cjs_shims();
79547
79547
  var import_node_child_process3 = require("child_process");
79548
79548
  var fs7 = __toESM(require("fs"), 1);
79549
79549
  var path5 = __toESM(require("path"), 1);
79550
- function resolveAlembicBin(projectDir) {
79551
- const candidates = [
79550
+ var uvOnPath;
79551
+ function hasUv() {
79552
+ if (uvOnPath !== void 0) return uvOnPath;
79553
+ try {
79554
+ (0, import_node_child_process3.execFileSync)("uv", ["--version"], { stdio: "ignore" });
79555
+ uvOnPath = true;
79556
+ } catch {
79557
+ uvOnPath = false;
79558
+ }
79559
+ return uvOnPath;
79560
+ }
79561
+ function resolveAlembicInvocation(projectDir, uvAvailable = hasUv()) {
79562
+ const venvCandidates = [
79552
79563
  path5.join(projectDir, ".venv", "bin", "alembic"),
79553
79564
  path5.join(projectDir, "venv", "bin", "alembic")
79554
79565
  ];
79555
- for (const candidate of candidates) {
79566
+ for (const candidate of venvCandidates) {
79556
79567
  try {
79557
- if (fs7.existsSync(candidate)) return candidate;
79568
+ if (fs7.existsSync(candidate)) return { bin: candidate, prefixArgs: [] };
79558
79569
  } catch {
79559
79570
  }
79560
79571
  }
79561
- return "alembic";
79572
+ try {
79573
+ if (uvAvailable && fs7.existsSync(path5.join(projectDir, "pyproject.toml"))) {
79574
+ return { bin: "uv", prefixArgs: ["run", "alembic"] };
79575
+ }
79576
+ } catch {
79577
+ }
79578
+ return { bin: "alembic", prefixArgs: [] };
79562
79579
  }
79563
79580
  function spawnAlembic(projectDir, args, dsn) {
79564
79581
  return new Promise((resolve2, reject) => {
79565
- const bin = resolveAlembicBin(projectDir);
79582
+ const { bin, prefixArgs } = resolveAlembicInvocation(projectDir);
79566
79583
  const env = { ...process.env };
79567
79584
  env.PYTHONPATH = [projectDir, process.env.PYTHONPATH].filter(Boolean).join(path5.delimiter);
79568
79585
  if (dsn) env.DATABASE_URL = dsn;
79569
- const child = (0, import_node_child_process3.spawn)(bin, args, {
79586
+ const child = (0, import_node_child_process3.spawn)(bin, [...prefixArgs, ...args], {
79570
79587
  cwd: projectDir,
79571
79588
  env,
79572
79589
  stdio: ["ignore", "pipe", "pipe"]
@@ -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.0".length > 0) {
393
- cached = "0.3.0";
392
+ if ("0.3.2".length > 0) {
393
+ cached = "0.3.2";
394
394
  return cached;
395
395
  }
396
396
  cached = "unknown";
@@ -640,29 +640,46 @@ import * as fs7 from "fs";
640
640
  import * as path6 from "path";
641
641
 
642
642
  // scripts/lakebase/schema-migrate-runners/alembic.ts
643
- import { spawn } from "child_process";
643
+ import { spawn, execFileSync as execFileSync3 } from "child_process";
644
644
  import * as fs6 from "fs";
645
645
  import * as path5 from "path";
646
- function resolveAlembicBin(projectDir) {
647
- const candidates = [
646
+ var uvOnPath;
647
+ function hasUv() {
648
+ if (uvOnPath !== void 0) return uvOnPath;
649
+ try {
650
+ execFileSync3("uv", ["--version"], { stdio: "ignore" });
651
+ uvOnPath = true;
652
+ } catch {
653
+ uvOnPath = false;
654
+ }
655
+ return uvOnPath;
656
+ }
657
+ function resolveAlembicInvocation(projectDir, uvAvailable = hasUv()) {
658
+ const venvCandidates = [
648
659
  path5.join(projectDir, ".venv", "bin", "alembic"),
649
660
  path5.join(projectDir, "venv", "bin", "alembic")
650
661
  ];
651
- for (const candidate of candidates) {
662
+ for (const candidate of venvCandidates) {
652
663
  try {
653
- if (fs6.existsSync(candidate)) return candidate;
664
+ if (fs6.existsSync(candidate)) return { bin: candidate, prefixArgs: [] };
654
665
  } catch {
655
666
  }
656
667
  }
657
- return "alembic";
668
+ try {
669
+ if (uvAvailable && fs6.existsSync(path5.join(projectDir, "pyproject.toml"))) {
670
+ return { bin: "uv", prefixArgs: ["run", "alembic"] };
671
+ }
672
+ } catch {
673
+ }
674
+ return { bin: "alembic", prefixArgs: [] };
658
675
  }
659
676
  function spawnAlembic(projectDir, args, dsn) {
660
677
  return new Promise((resolve2, reject) => {
661
- const bin = resolveAlembicBin(projectDir);
678
+ const { bin, prefixArgs } = resolveAlembicInvocation(projectDir);
662
679
  const env = { ...process.env };
663
680
  env.PYTHONPATH = [projectDir, process.env.PYTHONPATH].filter(Boolean).join(path5.delimiter);
664
681
  if (dsn) env.DATABASE_URL = dsn;
665
- const child = spawn(bin, args, {
682
+ const child = spawn(bin, [...prefixArgs, ...args], {
666
683
  cwd: projectDir,
667
684
  env,
668
685
  stdio: ["ignore", "pipe", "pipe"]
@@ -2137,7 +2154,7 @@ async function reconcileTierBranch(args, deps = {}) {
2137
2154
  // scripts/lakebase/scm-workflow-state.ts
2138
2155
  import * as fs13 from "fs";
2139
2156
  import * as path14 from "path";
2140
- import { execFileSync as execFileSync3 } from "child_process";
2157
+ import { execFileSync as execFileSync4 } from "child_process";
2141
2158
  var SCM_STATES = [
2142
2159
  "scaffold-complete",
2143
2160
  "feature-claimed",
@@ -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.0".length > 0) {
1671
- cached = "0.3.0";
1670
+ if ("0.3.2".length > 0) {
1671
+ cached = "0.3.2";
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.0".length > 0) {
1634
- cached = "0.3.0";
1633
+ if ("0.3.2".length > 0) {
1634
+ cached = "0.3.2";
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.0",
3
+ "version": "0.3.2",
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,
@@ -0,0 +1,102 @@
1
+ // Generate + normalize the client's package-lock.json at scaffold time, so a
2
+ // scaffolded UI project ships a COMMITTED lockfile that resolves from the
3
+ // public npm registry.
4
+ //
5
+ // WHY: createProject writes the client template + wires Playwright but never
6
+ // runs `npm install` in client/, so no package-lock.json exists at the initial
7
+ // commit. The user had to generate it by hand (README: `cd client && npm
8
+ // install`), and when they did it on the Databricks network the lock pinned
9
+ // npm-proxy.cloud.databricks.com in its `resolved` URLs — which hangs a cold
10
+ // install off-network (the lockfile-registry-guard hazard: the proxy host
11
+ // resolves but never accepts a TCP connection off the Databricks network).
12
+ //
13
+ // WHAT: `npm install --package-lock-only` resolves the dep tree and writes
14
+ // client/package-lock.json WITHOUT installing node_modules (fast, no tarball
15
+ // downloads), then we host-swap any Databricks-proxy `resolved` host to
16
+ // registry.npmjs.org (integrity hashes are unchanged — the proxy is a
17
+ // passthrough mirror of the same tarballs). The scaffold's `git add -A` initial
18
+ // commit then picks the normalized lock up for free.
19
+ //
20
+ // Non-fatal: a resolve failure (offline, registry down) is a warning, not a
21
+ // create abort — the user can still `cd client && npm install` as before.
22
+
23
+ import * as fs from "node:fs";
24
+ import * as path from "node:path";
25
+ import { exec } from "../util/exec.js";
26
+ import { withProxyEnv } from "../util/proxy-env.js";
27
+
28
+ /** The Databricks npm proxy host a cold off-network install hangs on. */
29
+ const PROXY_HOST = "npm-proxy.cloud.databricks.com";
30
+ /** The public registry every tracked lockfile must resolve from. */
31
+ const PUBLIC_HOST = "registry.npmjs.org";
32
+
33
+ export interface EnsureClientLockfileArgs {
34
+ /** The project root (the client dir lives at <projectDir>/<clientDir>). */
35
+ projectDir: string;
36
+ /** The client dir name under projectDir. Default "client". */
37
+ clientDir?: string;
38
+ }
39
+
40
+ export interface EnsureClientLockfileResult {
41
+ /** True when a package-lock.json exists in the client dir after this ran. */
42
+ generated: boolean;
43
+ /** Count of proxy-host `resolved` URLs rewritten to the public registry. */
44
+ swaps: number;
45
+ /** Why it did not run / generate (no client package.json, exec error). */
46
+ reason?: string;
47
+ }
48
+
49
+ /**
50
+ * Host-swap any Databricks-proxy `resolved` URL to the public registry. A pure
51
+ * string substitution — integrity hashes are left untouched because the proxy
52
+ * is a passthrough mirror of the same tarballs. Returns the rewritten text and
53
+ * the number of swaps.
54
+ */
55
+ export function normalizeLockfileRegistry(lockText: string): { text: string; swaps: number } {
56
+ let swaps = 0;
57
+ const text = lockText.replace(new RegExp(PROXY_HOST.replace(/\./g, "\\."), "g"), () => {
58
+ swaps++;
59
+ return PUBLIC_HOST;
60
+ });
61
+ return { text, swaps };
62
+ }
63
+
64
+ /**
65
+ * For a UI project, generate client/package-lock.json (lock only, no install)
66
+ * and normalize its registry host. Idempotent, non-fatal: callers push the
67
+ * reason onto their warnings and continue.
68
+ */
69
+ export async function ensureClientLockfile(
70
+ args: EnsureClientLockfileArgs,
71
+ ): Promise<EnsureClientLockfileResult> {
72
+ const clientDir = path.join(args.projectDir, args.clientDir ?? "client");
73
+ const pkg = path.join(clientDir, "package.json");
74
+ if (!fs.existsSync(pkg)) {
75
+ return { generated: false, swaps: 0, reason: "no client package.json" };
76
+ }
77
+ const lockPath = path.join(clientDir, "package-lock.json");
78
+ try {
79
+ // --package-lock-only resolves the dep tree + writes the lock, skipping
80
+ // node_modules (no tarball downloads), so the scaffold stays fast.
81
+ await exec("npm install --package-lock-only", {
82
+ cwd: clientDir,
83
+ env: withProxyEnv(),
84
+ timeout: 180_000,
85
+ });
86
+ } catch (err) {
87
+ const msg = err instanceof Error ? err.message : String(err);
88
+ return { generated: fs.existsSync(lockPath), swaps: 0, reason: `npm resolve failed: ${msg}` };
89
+ }
90
+ if (!fs.existsSync(lockPath)) {
91
+ return { generated: false, swaps: 0, reason: "npm wrote no package-lock.json" };
92
+ }
93
+ // Normalize proxy -> public registry so a cold off-network install never hangs.
94
+ try {
95
+ const { text, swaps } = normalizeLockfileRegistry(fs.readFileSync(lockPath, "utf8"));
96
+ if (swaps > 0) fs.writeFileSync(lockPath, text);
97
+ return { generated: true, swaps };
98
+ } catch (err) {
99
+ const msg = err instanceof Error ? err.message : String(err);
100
+ return { generated: true, swaps: 0, reason: `lock normalize failed: ${msg}` };
101
+ }
102
+ }
@@ -28,6 +28,7 @@ import { scaffoldAll, substrateVersion } from "./scaffold.js";
28
28
  import type { ClientFramework } from "./scaffold-language.js";
29
29
  import { createLongRunningBranch } from "./long-running-branch.js";
30
30
  import { enableE2eForProject } from "./enable-e2e.js";
31
+ import { ensureClientLockfile } from "./client-lockfile.js";
31
32
  import { enableInfraForProject } from "./enable-infra.js";
32
33
  import { setupRunner } from "./runner-setup.js";
33
34
  import { syncCiSecrets, missingCiSecrets } from "../util/ci-secrets.js";
@@ -587,6 +588,24 @@ export async function createProject(
587
588
  dotnet: `.NET/ASP.NET Core (${input.migrationTool === "flyway" ? "Flyway" : "EF Core"})`,
588
589
  };
589
590
  const langLabel = langLabels[language] ?? language;
591
+
592
+ // ── Step 7f: client lockfile (UI projects) ───────────────────
593
+ // Generate + registry-normalize client/package-lock.json BEFORE the commit so
594
+ // `git add -A` tracks it. Without this the scaffold ships no client lock, and
595
+ // the user's hand-generated one (made on the Databricks network) pins the npm
596
+ // proxy and hangs a cold off-network install. Non-fatal: warn + continue.
597
+ if (clientFramework !== "none") {
598
+ report("Generating client package-lock.json (registry-normalized)...");
599
+ const lock = await ensureClientLockfile({ projectDir });
600
+ if (lock.generated) {
601
+ report(` wrote client/package-lock.json${lock.swaps > 0 ? ` (normalized ${lock.swaps} proxy URL(s) to the public registry)` : ""}`);
602
+ } else {
603
+ const m = `Could not generate client/package-lock.json (${lock.reason ?? "unknown"}). Generate it with: (cd client && npm install) — the lock is committed on first push.`;
604
+ warnings.push(m);
605
+ report(`WARNING: ${m}`);
606
+ }
607
+ }
608
+
590
609
  report("Creating initial commit...");
591
610
  await commitAndPush({
592
611
  projectDir,
@@ -14,7 +14,7 @@
14
14
  // stdout/stderr is not load-bearing here, which keeps the runner robust
15
15
  // to logger config drift in the consumer's `alembic.ini`.
16
16
 
17
- import { spawn } from "node:child_process";
17
+ import { spawn, execFileSync } from "node:child_process";
18
18
  import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
20
  import {
@@ -32,26 +32,70 @@ interface RunnerCtx {
32
32
  }
33
33
 
34
34
  /**
35
- * Resolve the `alembic` binary path. uv-managed Python projects install
36
- * alembic into a per-project `.venv/bin/alembic`, which is NOT on the
37
- * runner's PATH. Spawning bare `alembic` fails with ENOENT in CI even
38
- * after `uv sync` succeeded. Prefer the project-local venv when it
39
- * exists; fall back to bare `alembic` for projects with a pre-activated
40
- * shell venv or a system-wide install.
35
+ * Whether `uv` is on PATH. Memoized: resolution runs per alembic subcommand and
36
+ * the answer cannot change within a process.
41
37
  */
42
- export function resolveAlembicBin(projectDir: string): string {
43
- const candidates = [
38
+ let uvOnPath: boolean | undefined;
39
+ function hasUv(): boolean {
40
+ if (uvOnPath !== undefined) return uvOnPath;
41
+ try {
42
+ execFileSync("uv", ["--version"], { stdio: "ignore" });
43
+ uvOnPath = true;
44
+ } catch {
45
+ uvOnPath = false;
46
+ }
47
+ return uvOnPath;
48
+ }
49
+
50
+ /**
51
+ * Resolve HOW to invoke alembic in a project: an executable plus the args that
52
+ * must PREFIX the alembic subcommand.
53
+ *
54
+ * uv-managed Python projects install alembic into a per-project `.venv/bin/alembic`,
55
+ * which is NOT on the runner's PATH, so that venv-local bin is preferred when it
56
+ * exists (it is also what CI has after `uv sync`). But a FRESH clone / rehydrated
57
+ * project has no `.venv` yet (nothing has run `uv sync`), and spawning bare
58
+ * `alembic` then fails ENOENT — the failure the `lk lakebase-new-migration` path
59
+ * hit on a never-synced project, before any test run created the venv. For such a
60
+ * uv project (pyproject.toml present and `uv` on PATH) invoke via `uv run alembic`,
61
+ * which auto-creates the venv and syncs the declared deps (alembic is a top-level
62
+ * dep) BEFORE running — exactly what scripts/run-tests.sh relies on. CI is
63
+ * unaffected: it hits the venv-local bin first. Last resort is bare `alembic`
64
+ * (a pre-activated shell venv or a system-wide install).
65
+ */
66
+ export function resolveAlembicInvocation(
67
+ projectDir: string,
68
+ uvAvailable: boolean = hasUv()
69
+ ): { bin: string; prefixArgs: string[] } {
70
+ const venvCandidates = [
44
71
  path.join(projectDir, ".venv", "bin", "alembic"),
45
72
  path.join(projectDir, "venv", "bin", "alembic"),
46
73
  ];
47
- for (const candidate of candidates) {
74
+ for (const candidate of venvCandidates) {
48
75
  try {
49
- if (fs.existsSync(candidate)) return candidate;
76
+ if (fs.existsSync(candidate)) return { bin: candidate, prefixArgs: [] };
50
77
  } catch {
51
78
  // best-effort; keep checking
52
79
  }
53
80
  }
54
- return "alembic";
81
+ try {
82
+ if (uvAvailable && fs.existsSync(path.join(projectDir, "pyproject.toml"))) {
83
+ return { bin: "uv", prefixArgs: ["run", "alembic"] };
84
+ }
85
+ } catch {
86
+ // best-effort; fall through to bare alembic
87
+ }
88
+ return { bin: "alembic", prefixArgs: [] };
89
+ }
90
+
91
+ /**
92
+ * Back-compat: the executable that would run alembic (the venv-local path or bare
93
+ * `alembic`). Prefer resolveAlembicInvocation, which also models the `uv run`
94
+ * prefix for a never-synced uv project.
95
+ */
96
+ export function resolveAlembicBin(projectDir: string): string {
97
+ const { bin, prefixArgs } = resolveAlembicInvocation(projectDir);
98
+ return prefixArgs.length ? `${bin} ${prefixArgs.join(" ")}` : bin;
55
99
  }
56
100
 
57
101
  /**
@@ -65,7 +109,7 @@ function spawnAlembic(
65
109
  dsn?: string
66
110
  ): Promise<{ stdout: string; stderr: string }> {
67
111
  return new Promise((resolve, reject) => {
68
- const bin = resolveAlembicBin(projectDir);
112
+ const { bin, prefixArgs } = resolveAlembicInvocation(projectDir);
69
113
  // Put the project root on PYTHONPATH so `app` is importable for EVERY alembic
70
114
  // subcommand. `upgrade` runs env.py (which prepends sys.path), but `history`
71
115
  // and other read-only commands do not, yet they still import every migration
@@ -74,7 +118,7 @@ function spawnAlembic(
74
118
  const env: NodeJS.ProcessEnv = { ...process.env };
75
119
  env.PYTHONPATH = [projectDir, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter);
76
120
  if (dsn) env.DATABASE_URL = dsn;
77
- const child = spawn(bin, args, {
121
+ const child = spawn(bin, [...prefixArgs, ...args], {
78
122
  cwd: projectDir,
79
123
  env,
80
124
  stdio: ["ignore", "pipe", "pipe"],
@@ -1,21 +1,47 @@
1
1
  import { defineConfig, devices } from "@playwright/test";
2
2
  import { existsSync } from "node:fs";
3
+ import { execFileSync } from "node:child_process";
3
4
  import { join } from "node:path";
4
5
 
6
+ // The backend lives at the project root, one level up from this client/ config
7
+ // (Playwright runs from client/, hence cwd:".." on the webServers below).
8
+ const ROOT = join(process.cwd(), "..");
9
+
5
10
  // End-to-end tests drive the rendered SPA through a real browser against the
6
11
  // real backend, backed by the paired Lakebase branch. Playwright boots BOTH
7
12
  // servers: the backend FIRST (so Vite's /api proxy has a target), then Vite.
8
13
  // Adapt the backend `command`/`url` to your stack if it is not Python/FastAPI.
9
14
  //
10
- // Resiliency: the ports are env-driven (E2E_BACKEND_PORT / E2E_CLIENT_PORT) with
11
- // the conventional defaults. In CI (reuseExistingServer:false), a stale server
12
- // left on 8000/5173 by a prior run would otherwise make webServer hard-fail with
13
- // "port already used". So CI's E2E step allocates FREE ports (scripts/port-utils.sh,
14
- // the same probe run-dev.sh uses) and hands them in here , the run moves off a
15
- // busy port instead of colliding. The Vite proxy is pointed at the resolved
16
- // backend port via VITE_PROXY_TARGET so /api + /health still reach the backend.
17
- const BACKEND_PORT = process.env.E2E_BACKEND_PORT ?? "8000";
18
- const CLIENT_PORT = process.env.E2E_CLIENT_PORT ?? "5173";
15
+ // Resiliency: the ports are env-driven (E2E_BACKEND_PORT / E2E_CLIENT_PORT).
16
+ // run-tests.sh and CI pre-allocate FREE ports (scripts/port-utils.sh) and hand them
17
+ // in via those vars, so a stale server left on 8000/5173 by a prior run never makes
18
+ // webServer hard-fail with "port already used". When NOTHING hands them in — a direct
19
+ // `npx playwright test` (e.g. the build driver running a targeted spec, not through
20
+ // run-tests.sh) — resolve a free port HERE the SAME way, by sourcing that one shared
21
+ // free_port helper, so a direct run is just as collision-proof and never defaults onto
22
+ // a busy :8000 / :5173. One free-port owner (port-utils.sh), two entry points. The Vite
23
+ // proxy is pointed at the resolved backend port via VITE_PROXY_TARGET so /api + /health
24
+ // still reach the backend wherever it landed.
25
+ function resolvePort(envVar: string, fallback: number): string {
26
+ const fromEnv = process.env[envVar];
27
+ if (fromEnv) return fromEnv;
28
+ const portUtils = join(ROOT, "scripts", "port-utils.sh");
29
+ if (existsSync(portUtils)) {
30
+ try {
31
+ const out = execFileSync(
32
+ "bash",
33
+ ["-c", `source ${JSON.stringify(portUtils)} && free_port ${fallback}`],
34
+ { encoding: "utf8" }
35
+ ).trim();
36
+ if (/^\d+$/.test(out)) return out;
37
+ } catch {
38
+ // fall through to the conventional default
39
+ }
40
+ }
41
+ return String(fallback);
42
+ }
43
+ const BACKEND_PORT = resolvePort("E2E_BACKEND_PORT", 8000);
44
+ const CLIENT_PORT = resolvePort("E2E_CLIENT_PORT", 5173);
19
45
  // Use 127.0.0.1, NOT localhost, and bind both servers to it (below). `localhost`
20
46
  // is ambiguous: on macOS it resolves to IPv6 ::1 first, but uvicorn binds IPv4
21
47
  // 127.0.0.1 only (its default) and Vite's dev server is likewise IPv4 — so a
@@ -31,7 +57,6 @@ const CLIENT_URL = `http://127.0.0.1:${CLIENT_PORT}`;
31
57
  // one level up from this client/ config (Playwright runs from client/, hence cwd:".." below). Pick
32
58
  // the migrate-before-serve command + health URL for the detected stack so E2E boots on ANY language
33
59
  // , a nodejs scaffold otherwise inherited the Python (alembic/uvicorn) command and could not start.
34
- const ROOT = join(process.cwd(), "..");
35
60
  function backendWebServer(): { command: string; url: string } {
36
61
  if (existsSync(join(ROOT, "pom.xml"))) {
37
62
  // Java/Kotlin (Spring): flyway migrate, then boot on the resolved port.