codebyplan 1.13.16 → 1.13.17

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.
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ var VERSION, PACKAGE_NAME;
14
14
  var init_version = __esm({
15
15
  "src/lib/version.ts"() {
16
16
  "use strict";
17
- VERSION = "1.13.16";
17
+ VERSION = "1.13.17";
18
18
  PACKAGE_NAME = "codebyplan";
19
19
  }
20
20
  });
@@ -663,10 +663,8 @@ function noAuthHint() {
663
663
  Or, for the legacy 30-day shim:
664
664
  export CODEBYPLAN_API_KEY=<key> # from https://codebyplan.com/settings/api-keys/`;
665
665
  }
666
- function validateApiKey() {
667
- if (!legacyApiKey()) {
668
- throw new Error(noAuthHint());
669
- }
666
+ async function validateAuth() {
667
+ await getAuthHeaders();
670
668
  }
671
669
  async function getAuthHeaders() {
672
670
  try {
@@ -3797,7 +3795,7 @@ async function eslintInit(repoId, projectPath) {
3797
3795
  async function runEslint() {
3798
3796
  const subcommand = process.argv[3];
3799
3797
  const flags = parseFlags(4);
3800
- validateApiKey();
3798
+ await validateAuth();
3801
3799
  const config = await resolveConfig(flags);
3802
3800
  const { repoId, projectPath } = config;
3803
3801
  switch (subcommand) {
@@ -7073,6 +7071,94 @@ var init_cmux_serve = __esm({
7073
7071
  }
7074
7072
  });
7075
7073
 
7074
+ // src/lib/worktree-port-resolver.ts
7075
+ async function resolveWorktreePortAllocations(repoId, projectPath) {
7076
+ let resolvedWorktreeId;
7077
+ try {
7078
+ const deviceId = await getOrCreateDeviceId(projectPath);
7079
+ let branch = "main";
7080
+ try {
7081
+ const { execSync: execSync11 } = await import("node:child_process");
7082
+ branch = execSync11("git symbolic-ref --short HEAD", {
7083
+ cwd: projectPath,
7084
+ encoding: "utf-8"
7085
+ }).trim();
7086
+ } catch {
7087
+ }
7088
+ const tupleId = await resolveWorktreeId({
7089
+ repoId,
7090
+ repoPath: projectPath,
7091
+ branch,
7092
+ deviceId
7093
+ });
7094
+ if (tupleId) {
7095
+ resolvedWorktreeId = tupleId;
7096
+ } else {
7097
+ resolvedWorktreeId = await resolveAndCacheWorktreeId(repoId, projectPath) ?? void 0;
7098
+ }
7099
+ } catch (err) {
7100
+ const msg = err instanceof Error ? err.message : String(err);
7101
+ console.warn(
7102
+ ` Warning: failed to cache worktree_id (self-heal skipped): ${msg}`
7103
+ );
7104
+ }
7105
+ let portAllocations = [];
7106
+ try {
7107
+ const portsRes = await apiGet(
7108
+ `/port-allocations`,
7109
+ resolvedWorktreeId ? {
7110
+ repo_id: repoId,
7111
+ worktree_id: resolvedWorktreeId,
7112
+ limit: PORT_ALLOCATIONS_UNFILTERED_LIMIT
7113
+ } : {
7114
+ repo_id: repoId,
7115
+ worktree_id: "null",
7116
+ limit: PORT_ALLOCATIONS_UNFILTERED_LIMIT
7117
+ }
7118
+ );
7119
+ const allAllocations = portsRes.data ?? [];
7120
+ const filtered = resolvedWorktreeId ? allAllocations.filter((a) => a.worktree_id === resolvedWorktreeId) : allAllocations.filter((a) => !a.worktree_id);
7121
+ portAllocations = filtered.map((a) => {
7122
+ const clean = {};
7123
+ for (const key of ALLOWED_FIELDS) {
7124
+ if (key in a) clean[key] = a[key];
7125
+ }
7126
+ return clean;
7127
+ });
7128
+ } catch (err) {
7129
+ console.warn(
7130
+ ` Warning: failed to fetch port allocations: ${err instanceof Error ? err.message : String(err)}`
7131
+ );
7132
+ }
7133
+ const matchingAlloc = portAllocations[0];
7134
+ return { resolvedWorktreeId, portAllocations, matchingAlloc };
7135
+ }
7136
+ var PORT_ALLOCATIONS_UNFILTERED_LIMIT, ALLOWED_FIELDS;
7137
+ var init_worktree_port_resolver = __esm({
7138
+ "src/lib/worktree-port-resolver.ts"() {
7139
+ "use strict";
7140
+ init_api();
7141
+ init_resolve_worktree();
7142
+ init_local_config();
7143
+ PORT_ALLOCATIONS_UNFILTERED_LIMIT = "500";
7144
+ ALLOWED_FIELDS = [
7145
+ "id",
7146
+ "repo_id",
7147
+ "port",
7148
+ "label",
7149
+ "server_type",
7150
+ "auto_start",
7151
+ "command",
7152
+ "working_dir",
7153
+ "env_vars",
7154
+ "external_refs",
7155
+ "worktree_id",
7156
+ "created_at",
7157
+ "updated_at"
7158
+ ];
7159
+ }
7160
+ });
7161
+
7076
7162
  // src/lib/migrate-local-config.ts
7077
7163
  import { mkdir as mkdir6, readFile as readFile16, unlink as unlink2, writeFile as writeFile12 } from "node:fs/promises";
7078
7164
  import { join as join24 } from "node:path";
@@ -7307,6 +7393,7 @@ __export(config_exports, {
7307
7393
  readGitConfig: () => readGitConfig,
7308
7394
  readRepoConfig: () => readRepoConfig,
7309
7395
  readServerConfig: () => readServerConfig,
7396
+ readServerLocalConfig: () => readServerLocalConfig,
7310
7397
  readShipmentConfig: () => readShipmentConfig,
7311
7398
  readVendorConfig: () => readVendorConfig,
7312
7399
  runConfig: () => runConfig
@@ -7316,7 +7403,7 @@ import { join as join25 } from "node:path";
7316
7403
  async function runConfig() {
7317
7404
  const flags = parseFlags(3);
7318
7405
  const dryRun = hasFlag("dry-run", 3);
7319
- validateApiKey();
7406
+ await validateAuth();
7320
7407
  const config = await resolveConfig(flags);
7321
7408
  const { repoId, projectPath } = config;
7322
7409
  console.log(`
@@ -7346,35 +7433,12 @@ async function runConfig() {
7346
7433
  }
7347
7434
  async function syncConfigToFile(repoId, projectPath, dryRun) {
7348
7435
  const codebyplanDir = join25(projectPath, ".codebyplan");
7349
- let resolvedWorktreeId;
7350
- try {
7351
- const deviceId = await getOrCreateDeviceId(projectPath);
7352
- let branch = "main";
7353
- try {
7354
- const { execSync: execSync11 } = await import("node:child_process");
7355
- branch = execSync11("git symbolic-ref --short HEAD", {
7356
- cwd: projectPath,
7357
- encoding: "utf-8"
7358
- }).trim();
7359
- } catch {
7360
- }
7361
- const tupleId = await resolveWorktreeId({
7362
- repoId,
7363
- repoPath: projectPath,
7364
- branch,
7365
- deviceId
7366
- });
7367
- if (tupleId) {
7368
- resolvedWorktreeId = tupleId;
7369
- } else {
7370
- resolvedWorktreeId = await resolveAndCacheWorktreeId(repoId, projectPath) ?? void 0;
7371
- }
7372
- } catch (err) {
7373
- const msg = err instanceof Error ? err.message : String(err);
7374
- console.warn(
7375
- ` Warning: failed to cache worktree_id (self-heal skipped): ${msg}`
7376
- );
7377
- }
7436
+ const {
7437
+ resolvedWorktreeId,
7438
+ portAllocations,
7439
+ matchingAlloc: matchingAllocRaw
7440
+ } = await resolveWorktreePortAllocations(repoId, projectPath);
7441
+ const matchingAlloc = matchingAllocRaw;
7378
7442
  let repoRes;
7379
7443
  try {
7380
7444
  repoRes = await apiGet(`/repos/${repoId}`);
@@ -7397,42 +7461,6 @@ async function syncConfigToFile(repoId, projectPath, dryRun) {
7397
7461
  process.exit(1);
7398
7462
  }
7399
7463
  const repo = repoRes.data;
7400
- let portAllocations = [];
7401
- try {
7402
- const portsRes = await apiGet(
7403
- `/port-allocations`,
7404
- { repo_id: repoId }
7405
- );
7406
- const allAllocations = portsRes.data ?? [];
7407
- const filtered = resolvedWorktreeId ? allAllocations.filter((a) => a.worktree_id === resolvedWorktreeId) : allAllocations.filter((a) => !a.worktree_id);
7408
- const ALLOWED_FIELDS = [
7409
- "id",
7410
- "repo_id",
7411
- "port",
7412
- "label",
7413
- "server_type",
7414
- "auto_start",
7415
- "command",
7416
- "working_dir",
7417
- "env_vars",
7418
- "external_refs",
7419
- "worktree_id",
7420
- "created_at",
7421
- "updated_at"
7422
- ];
7423
- portAllocations = filtered.map((a) => {
7424
- const clean = {};
7425
- for (const key of ALLOWED_FIELDS) {
7426
- if (key in a) clean[key] = a[key];
7427
- }
7428
- return clean;
7429
- });
7430
- } catch (err) {
7431
- console.warn(
7432
- ` Warning: failed to fetch port allocations: ${err instanceof Error ? err.message : String(err)}`
7433
- );
7434
- }
7435
- const matchingAlloc = portAllocations[0];
7436
7464
  const defaultBranchConfig = {
7437
7465
  protected: ["main"],
7438
7466
  integration: null,
@@ -7573,14 +7601,24 @@ async function readE2eConfig2(projectPath) {
7573
7601
  return null;
7574
7602
  }
7575
7603
  }
7604
+ async function readServerLocalConfig(projectPath) {
7605
+ try {
7606
+ const raw = await readFile17(
7607
+ join25(projectPath, ".codebyplan", "server.local.json"),
7608
+ "utf-8"
7609
+ );
7610
+ return JSON.parse(raw);
7611
+ } catch {
7612
+ return null;
7613
+ }
7614
+ }
7576
7615
  var legacyBranchConfigWarned;
7577
7616
  var init_config = __esm({
7578
7617
  "src/cli/config.ts"() {
7579
7618
  "use strict";
7580
7619
  init_flags();
7581
7620
  init_api();
7582
- init_resolve_worktree();
7583
- init_local_config();
7621
+ init_worktree_port_resolver();
7584
7622
  init_migrate_local_config();
7585
7623
  legacyBranchConfigWarned = false;
7586
7624
  }
@@ -7738,22 +7776,48 @@ var init_port_verify = __esm({
7738
7776
  // src/cli/ports.ts
7739
7777
  var ports_exports = {};
7740
7778
  __export(ports_exports, {
7779
+ parseEnvFile: () => parseEnvFile,
7741
7780
  runPorts: () => runPorts
7742
7781
  });
7782
+ import { mkdir as mkdir8, readFile as readFile19, writeFile as writeFile14 } from "node:fs/promises";
7783
+ import { join as join26 } from "node:path";
7743
7784
  async function runPorts() {
7744
7785
  const flags = parseFlags(3);
7745
7786
  const dryRun = hasFlag("dry-run", 3);
7746
7787
  const fix = hasFlag("fix", 3);
7747
- validateApiKey();
7748
- const config = await resolveConfig(flags);
7749
- const { repoId, projectPath } = config;
7788
+ const writeLocal = hasFlag("write-local", 3);
7789
+ const provisionE2e = hasFlag("provision-e2e", 3);
7790
+ if (flags["path"]?.startsWith("--")) {
7791
+ console.warn(
7792
+ ` Warning: --path value "${flags["path"]}" looks like a flag. Pass --path <dir> BEFORE boolean flags (e.g. --path . --provision-e2e).`
7793
+ );
7794
+ }
7795
+ const provisionE2eOnly = provisionE2e && !writeLocal;
7796
+ let repoId = "";
7797
+ let projectPath;
7798
+ if (provisionE2eOnly) {
7799
+ projectPath = flags["path"] ?? process.cwd();
7800
+ } else {
7801
+ await validateAuth();
7802
+ const config = await resolveConfig(flags);
7803
+ repoId = config.repoId;
7804
+ projectPath = config.projectPath;
7805
+ }
7750
7806
  console.log(`
7751
7807
  CodeByPlan Ports`);
7752
- console.log(` Repo: ${repoId}`);
7808
+ if (repoId) console.log(` Repo: ${repoId}`);
7753
7809
  console.log(` Path: ${projectPath}`);
7754
7810
  if (dryRun) console.log(` Mode: dry-run`);
7755
7811
  if (fix) console.log(` Mode: fix`);
7812
+ if (writeLocal) console.log(` Mode: write-local`);
7813
+ if (provisionE2e) console.log(` Mode: provision-e2e`);
7756
7814
  console.log();
7815
+ if (writeLocal || provisionE2e) {
7816
+ if (writeLocal) await writeServerLocalConfig(repoId, projectPath, dryRun);
7817
+ if (provisionE2e) await provisionE2eEnv(projectPath, dryRun);
7818
+ console.log("\n Ports complete.\n");
7819
+ return;
7820
+ }
7757
7821
  try {
7758
7822
  const portsRes = await apiGet(
7759
7823
  `/port-allocations`,
@@ -7822,12 +7886,135 @@ async function runPorts() {
7822
7886
  }
7823
7887
  console.log("\n Ports complete.\n");
7824
7888
  }
7889
+ async function writeServerLocalConfig(repoId, projectPath, dryRun) {
7890
+ const { resolvedWorktreeId, portAllocations, matchingAlloc } = await resolveWorktreePortAllocations(repoId, projectPath);
7891
+ if (portAllocations.length === 0) {
7892
+ console.warn(
7893
+ " Skipped .codebyplan/server.local.json \u2014 no worktree port allocations resolved (API failure or none assigned)."
7894
+ );
7895
+ return;
7896
+ }
7897
+ const payload = {
7898
+ server_port: matchingAlloc?.port ?? null,
7899
+ server_type: matchingAlloc?.server_type ?? null,
7900
+ // No cast needed: resolveWorktreePortAllocations returns Partial<PortAllocation>[]
7901
+ // and ServerLocalConfig.port_allocations is typed the same — honest end-to-end.
7902
+ port_allocations: portAllocations
7903
+ };
7904
+ const codebyplanDir = join26(projectPath, ".codebyplan");
7905
+ const filePath = join26(codebyplanDir, "server.local.json");
7906
+ const newJson = JSON.stringify(payload, null, 2) + "\n";
7907
+ let currentJson = "";
7908
+ try {
7909
+ currentJson = await readFile19(filePath, "utf-8");
7910
+ } catch {
7911
+ }
7912
+ if (currentJson === newJson) {
7913
+ console.log(" server.local.json up to date.");
7914
+ return;
7915
+ }
7916
+ if (dryRun) {
7917
+ console.log(" Would update .codebyplan/server.local.json (dry-run).");
7918
+ return;
7919
+ }
7920
+ await mkdir8(codebyplanDir, { recursive: true });
7921
+ await writeFile14(filePath, newJson, "utf-8");
7922
+ console.log(
7923
+ ` Updated .codebyplan/server.local.json (worktree ${resolvedWorktreeId ?? "\u2014"}, ${portAllocations.length} allocation${portAllocations.length === 1 ? "" : "s"}).`
7924
+ );
7925
+ }
7926
+ async function provisionE2eEnv(projectPath, dryRun) {
7927
+ const relSource = join26("apps", "web", ".env.local");
7928
+ const sourcePath = join26(projectPath, relSource);
7929
+ let sourceRaw;
7930
+ try {
7931
+ sourceRaw = await readFile19(sourcePath, "utf-8");
7932
+ } catch {
7933
+ console.warn(
7934
+ ` Skipped .codebyplan/e2e.env \u2014 source ${relSource} not found.`
7935
+ );
7936
+ return;
7937
+ }
7938
+ const sourceVars = parseEnvFile(sourceRaw);
7939
+ const lines = [];
7940
+ const missing = [];
7941
+ for (const key of E2E_ENV_VARS) {
7942
+ const val = sourceVars[key];
7943
+ if (val === void 0) {
7944
+ missing.push(key);
7945
+ continue;
7946
+ }
7947
+ lines.push(`${key}=${val}`);
7948
+ }
7949
+ if (missing.length > 0) {
7950
+ console.warn(
7951
+ ` Warning: ${missing.length} E2E var(s) missing from ${relSource}: ${missing.join(", ")}`
7952
+ );
7953
+ }
7954
+ if (lines.length === 0) {
7955
+ console.warn(
7956
+ " Skipped .codebyplan/e2e.env \u2014 none of the expected E2E vars were found."
7957
+ );
7958
+ return;
7959
+ }
7960
+ const codebyplanDir = join26(projectPath, ".codebyplan");
7961
+ const filePath = join26(codebyplanDir, "e2e.env");
7962
+ const newContent = lines.join("\n") + "\n";
7963
+ let currentContent = "";
7964
+ try {
7965
+ currentContent = await readFile19(filePath, "utf-8");
7966
+ } catch {
7967
+ }
7968
+ if (currentContent === newContent) {
7969
+ console.log(" e2e.env up to date.");
7970
+ return;
7971
+ }
7972
+ if (dryRun) {
7973
+ console.log(" Would provision .codebyplan/e2e.env (dry-run).");
7974
+ return;
7975
+ }
7976
+ await mkdir8(codebyplanDir, { recursive: true });
7977
+ await writeFile14(filePath, newContent, "utf-8");
7978
+ console.log(
7979
+ ` Provisioned .codebyplan/e2e.env (${lines.length} var${lines.length === 1 ? "" : "s"}).`
7980
+ );
7981
+ }
7982
+ function parseEnvFile(raw) {
7983
+ const out = {};
7984
+ for (const line of raw.split("\n")) {
7985
+ const trimmed = line.trim();
7986
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
7987
+ const eq = trimmed.indexOf("=");
7988
+ if (eq === -1) continue;
7989
+ const key = trimmed.slice(0, eq).trim();
7990
+ if (key === "") continue;
7991
+ let value = trimmed.slice(eq + 1).trim();
7992
+ const quoted = value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"));
7993
+ if (quoted) {
7994
+ value = value.slice(1, -1);
7995
+ } else {
7996
+ const commentIdx = value.indexOf(" #");
7997
+ if (commentIdx !== -1) value = value.slice(0, commentIdx).trimEnd();
7998
+ }
7999
+ out[key] = value;
8000
+ }
8001
+ return out;
8002
+ }
8003
+ var E2E_ENV_VARS;
7825
8004
  var init_ports = __esm({
7826
8005
  "src/cli/ports.ts"() {
7827
8006
  "use strict";
7828
8007
  init_flags();
7829
8008
  init_api();
7830
8009
  init_port_verify();
8010
+ init_worktree_port_resolver();
8011
+ E2E_ENV_VARS = [
8012
+ "E2E_USER_EMAIL",
8013
+ "E2E_USER_PASSWORD",
8014
+ "NEXT_PUBLIC_SUPABASE_URL",
8015
+ "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY",
8016
+ "SUPABASE_SECRET_KEY"
8017
+ ];
7831
8018
  }
7832
8019
  });
7833
8020
 
@@ -7845,7 +8032,7 @@ async function runTechStack() {
7845
8032
  await runFullTechStack(dryRun);
7846
8033
  return;
7847
8034
  }
7848
- validateApiKey();
8035
+ await validateAuth();
7849
8036
  const config = await resolveConfig(flags);
7850
8037
  const { repoId, projectPath } = config;
7851
8038
  console.log(`
@@ -7980,7 +8167,7 @@ async function syncTechStackForPath(repoId, projectPath, dryRun) {
7980
8167
  }
7981
8168
  }
7982
8169
  async function runFullTechStack(dryRun) {
7983
- validateApiKey();
8170
+ await validateAuth();
7984
8171
  const localConfig = await readLocalConfig(process.cwd());
7985
8172
  if (!localConfig?.device_id) {
7986
8173
  console.error(
@@ -8981,6 +9168,11 @@ void (async () => {
8981
9168
  --repo-id <uuid> Repository ID (or set via .codebyplan/repo.json)
8982
9169
  --dry-run Preview changes without writing
8983
9170
  --fix Auto-create missing port allocations
9171
+ --write-local Write this worktree's port allocations to the
9172
+ gitignored .codebyplan/server.local.json overlay
9173
+ (never the committed server.json)
9174
+ --provision-e2e Copy E2E credentials from apps/web/.env.local into
9175
+ the gitignored .codebyplan/e2e.env (local, no API)
8984
9176
 
8985
9177
  Tech stack options:
8986
9178
  --path <dir> Project root directory (default: cwd)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebyplan",
3
- "version": "1.13.16",
3
+ "version": "1.13.17",
4
4
  "description": "CLI for CodeByPlan — AI-powered development planning and tracking",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,6 +61,6 @@
61
61
  "prettier": "^3.8.1",
62
62
  "typescript": "^5",
63
63
  "typescript-eslint": "^8.20.0",
64
- "vitest": "^4.1.2"
64
+ "vitest": "^4.1.8"
65
65
  }
66
66
  }
@@ -26,136 +26,237 @@ pnpm exec playwright install --with-deps chromium
26
26
 
27
27
  ## playwright.config.ts
28
28
 
29
- Derive `baseURL` from `.codebyplan/server.json` at config-read time. Match by label
30
- (`"Web Dev"`) rather than array position a monorepo can have several nextjs allocations.
29
+ Resolve the apps/web dev-server port at config-read time via the shared resolver
30
+ `apps/web/e2e/resolve-web-dev-port.ts` — imported by BOTH `playwright.config.ts` and
31
+ `e2e/auth.setup.ts` (single source of truth). It reads the per-worktree
32
+ `.codebyplan/server.local.json` overlay first, then the committed `.codebyplan/server.json`.
33
+ Match by label rather than array position — a monorepo can have several Next.js allocations
34
+ with similar label prefixes.
35
+
36
+ **Label-matching rules** (`findWebDevPort`):
37
+
38
+ - `server.local.json` overlay: each label has the worktree name appended as the last
39
+ parenthetical group (e.g. `"Web Dev (codebyplan-mcp-1)"`). Strip exactly ONE trailing
40
+ `" (…)"` group, then require the result `=== "Web Dev"`.
41
+ - `"Web Dev (codebyplan-mcp-1)"` → strip → `"Web Dev"` ✓
42
+ - `"Web Dev (codebyplan-desktop) (codebyplan-mcp-1)"` → strip → `"Web Dev (codebyplan-desktop)"` ✗
43
+ - `server.json` committed base: require `label === "Web Dev"` exactly (do NOT strip —
44
+ `"Web Dev (codebyplan-desktop)"` must not match).
45
+
46
+ **Resolution order** (first hit wins):
47
+
48
+ 0. `PLAYWRIGHT_BASE_URL` — explicit CI / local override (`parsePortFromUrl` extracts the port)
49
+ 1. `.codebyplan/server.local.json` — `findWebDevPort(…, {stripWorktreeSuffix: true})`
50
+ 2. `.codebyplan/server.json` — `findWebDevPort(…, {stripWorktreeSuffix: false})`
51
+ 3. `E2E_BASE_URL` — `parsePortFromUrl` (kept BELOW the overlay: a stale `E2E_BASE_URL` in a
52
+ gitignored `.env.local` must never shadow the worktree's own port — set `PLAYWRIGHT_BASE_URL`
53
+ to override in CI)
54
+ 4. `3010` — last resort
55
+
56
+ The resolver uses `readFileSync` + `JSON.parse` with paths relative to `apps/web/e2e/`
57
+ (`resolve(__dirname, "../../../.codebyplan/…")`). Each read is wrapped in `try/catch` — the
58
+ overlay is gitignored and absent in CI. Do NOT import from the `codebyplan` CLI package
59
+ (async, cross-package coupling). `findWebDevPort` + `parsePortFromUrl` are pure and unit-tested
60
+ in `e2e/__tests__/resolve-web-dev-port.test.ts`.
31
61
 
32
62
  ```ts
63
+ import { readFileSync } from "node:fs";
64
+ import { resolve } from "node:path";
33
65
  import { defineConfig, devices } from "@playwright/test";
34
- import { execSync } from "child_process";
35
66
 
36
- function getBaseUrl(): string {
67
+ import { resolveWebDevPort } from "./e2e/resolve-web-dev-port";
68
+
69
+ // Load apps/web/.env.local into process.env (process.env wins on conflict)
70
+ (function loadDotEnvLocal() {
71
+ try {
72
+ const text = readFileSync(resolve(__dirname, ".env.local"), "utf-8");
73
+ for (const line of text.split("\n")) {
74
+ const t = line.trim();
75
+ if (!t || t.startsWith("#")) continue;
76
+ const eq = t.indexOf("=");
77
+ if (eq === -1) continue;
78
+ const k = t.slice(0, eq).trim();
79
+ let v = t.slice(eq + 1).trim();
80
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))
81
+ v = v.slice(1, -1);
82
+ if (!(k in process.env)) process.env[k] = v;
83
+ }
84
+ } catch { /* absent in CI */ }
85
+ })();
86
+
87
+ // Load .codebyplan/e2e.env — Supabase + auth credentials (process.env wins)
88
+ (function loadE2eEnv() {
37
89
  try {
38
- const raw = execSync(
39
- "jq -r '.port_allocations[] | select(.label==\"Web Dev\") | .port' .codebyplan/server.json 2>/dev/null | head -1",
40
- { encoding: "utf-8" }
41
- ).trim();
42
- const port = parseInt(raw, 10);
43
- return `http://localhost:${port}`;
44
- } catch {
45
- return "http://localhost:3010";
46
- }
47
- }
90
+ const text = readFileSync(resolve(__dirname, "../../.codebyplan/e2e.env"), "utf-8");
91
+ for (const line of text.split("\n")) {
92
+ const t = line.trim();
93
+ if (!t || t.startsWith("#")) continue;
94
+ const eq = t.indexOf("=");
95
+ if (eq === -1) continue;
96
+ const k = t.slice(0, eq).trim();
97
+ let v = t.slice(eq + 1).trim();
98
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))
99
+ v = v.slice(1, -1);
100
+ if (!(k in process.env)) process.env[k] = v;
101
+ }
102
+ } catch { /* absent in CI — shell / CI secrets used instead */ }
103
+ })();
104
+
105
+ // findWebDevPort, parsePortFromUrl, and resolveWebDevPort live in the shared
106
+ // module ./e2e/resolve-web-dev-port.ts (imported above) — single source of
107
+ // truth, also consumed by e2e/auth.setup.ts. Resolution order:
108
+ // 0. PLAYWRIGHT_BASE_URL → 1. server.local.json → 2. server.json
109
+ // → 3. E2E_BASE_URL → 4. 3010.
110
+ const port = resolveWebDevPort();
48
111
 
49
112
  export default defineConfig({
50
- testDir: "apps/web/e2e",
51
- fullyParallel: false,
113
+ testDir: "./e2e",
114
+ testMatch: "*.spec.ts",
115
+ globalSetup: require.resolve("./e2e/global-setup"),
116
+ fullyParallel: true,
52
117
  forbidOnly: !!process.env.CI,
53
- retries: process.env.CI ? 2 : 0,
118
+ retries: process.env.CI ? 1 : 0,
54
119
  workers: 1, // serialize against shared remote Supabase — see e2e.md § Supabase Parallelism
55
- reporter: process.env.CI ? "github" : "html",
56
- globalSetup: "./apps/web/e2e/global-setup",
120
+ reporter: "list",
121
+ timeout: 30_000,
122
+ expect: {
123
+ toHaveScreenshot: { stylePath: "./e2e/screenshot.css" },
124
+ },
57
125
  use: {
58
- baseURL: getBaseUrl(),
126
+ baseURL: `http://localhost:${port}`,
127
+ storageState: "e2e/.auth/refreshed-state.json",
128
+ actionTimeout: 15_000,
59
129
  trace: "on-first-retry",
60
130
  screenshot: "only-on-failure",
61
131
  },
62
- projects: [
63
- { name: "setup", testMatch: /global\.setup\.ts/ },
64
- {
65
- name: "web",
66
- use: { ...devices["Desktop Chrome"], storageState: "apps/web/e2e/.auth/user.json" },
67
- dependencies: ["setup"],
68
- },
69
- ],
70
132
  webServer: {
71
- command: "pnpm --filter @codebyplan/web dev",
72
- url: getBaseUrl(),
133
+ command: `pnpm --filter @codebyplan/web dev --port ${port}`,
134
+ url: `http://localhost:${port}`,
73
135
  reuseExistingServer: !process.env.CI,
74
136
  timeout: 120_000,
137
+ env: {
138
+ // Forward Supabase + auth vars into the spawned dev server. Only forward
139
+ // vars that are present in process.env (undefined would stringify to "undefined").
140
+ ...(process.env.NEXT_PUBLIC_SUPABASE_URL && {
141
+ NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
142
+ }),
143
+ ...(process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY && {
144
+ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
145
+ }),
146
+ ...(process.env.SUPABASE_SECRET_KEY && {
147
+ SUPABASE_SECRET_KEY: process.env.SUPABASE_SECRET_KEY,
148
+ }),
149
+ },
75
150
  },
151
+ projects: [
152
+ {
153
+ name: "chromium",
154
+ use: { ...devices["Desktop Chrome"] },
155
+ },
156
+ ],
76
157
  });
77
158
  ```
78
159
 
79
160
  ## Auth — Global Setup + Storage State
80
161
 
81
- `apps/web/e2e/global-setup.ts`:
162
+ `apps/web/e2e/global-setup.ts` performs two phases at startup:
82
163
 
83
- ```ts
84
- import { chromium, FullConfig } from "@playwright/test";
85
- import path from "path";
86
-
87
- const AUTH_FILE = path.join(__dirname, ".auth/user.json");
88
-
89
- export default async function globalSetup(config: FullConfig) {
90
- const email = process.env.E2E_TEST_EMAIL;
91
- const password = process.env.E2E_TEST_PASSWORD;
92
-
93
- if (!email || !password) {
94
- throw new Error(
95
- "E2E_TEST_EMAIL and E2E_TEST_PASSWORD must be set.\n" +
96
- "Copy .env.local.example to .env.local, then run: pnpm e2e:provision"
97
- );
98
- }
99
-
100
- const { baseURL } = config.projects[0].use;
101
- const browser = await chromium.launch();
102
- const page = await browser.newPage();
103
-
104
- await page.goto(`${baseURL}/login`);
105
- await page.getByLabel(/email/i).fill(email);
106
- await page.getByLabel(/password/i).fill(password);
107
- await page.getByRole("button", { name: /sign in|log in/i }).click();
108
- await page.waitForURL(/\/(dashboard|home|app)/, { timeout: 15_000 });
109
-
110
- await page.goto(baseURL!); // cold-start warmup
111
- await page.context().storageState({ path: AUTH_FILE });
112
- await browser.close();
113
- }
114
- ```
164
+ **Phase 1 — Auth refresh**: reads `e2e/.auth/state.json`, finds the Supabase auth cookie
165
+ (`sb-<projectref>-auth-token`), decodes its base64-JSON payload (`decodeAuthCookie` from the
166
+ shared `e2e/auth-cookie.ts` module), calls `supabase.auth.refreshSession({refresh_token})` for
167
+ fresh tokens, re-encodes via `encodeAuthCookie`, and writes the result to
168
+ `e2e/.auth/refreshed-state.json`. No browser required — pure HTTP against Supabase auth.
115
169
 
116
- Gitignore storage state before first use:
170
+ **Phase 2 Maintainer seeding**: uses the service-role client to ensure the test user
171
+ holds a maintainer-or-above role on at least one organization (`e2e-test-fixture` slug).
172
+ Idempotent — if a qualifying membership already exists, phase 2 is a no-op.
117
173
 
118
- ```bash
119
- mkdir -p apps/web/e2e/.auth
120
- echo "apps/web/e2e/.auth/" >> .gitignore
121
- ```
174
+ **Required env vars** (read via `readEnv(name, fallbacks)` which checks `process.env` first,
175
+ then falls back to the parsed `.env.local` file):
176
+
177
+ - `NEXT_PUBLIC_SUPABASE_URL` — both phases
178
+ - `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` — Phase 1 refresh client
179
+ - `SUPABASE_SECRET_KEY` — Phase 2 admin operations
180
+ - `E2E_USER_EMAIL` — Phase 2 user lookup + seeding
181
+
182
+ Because `readEnv` checks `process.env` before `.env.local`, loading these vars into
183
+ `process.env` via `loadE2eEnv()` in `playwright.config.ts` is sufficient — global-setup
184
+ will pick them up.
185
+
186
+ ### Seeding `state.json` — `e2e/auth.setup.ts` (`pnpm e2e:auth-setup`)
187
+
188
+ global-setup Phase 1 only *refreshes* an existing `state.json`; the initial seed is written by
189
+ `e2e/auth.setup.ts`. It is a pure-HTTP API seed — **no browser, no dev server, no hydration
190
+ timing**: it loads creds from `.env.local` + `.codebyplan/e2e.env`, calls
191
+ `supabase.auth.signInWithPassword({email, password})` with the publishable-key client, derives
192
+ the project ref from `NEXT_PUBLIC_SUPABASE_URL`, and writes a `sb-<projectref>-auth-token`
193
+ cookie (domain `localhost`) into `state.json` using the same `encodeAuthCookie` from
194
+ `e2e/auth-cookie.ts` that global-setup consumes. This makes seeding deterministic in any
195
+ worktree — run `pnpm e2e:auth-setup` (optionally `--port N`) when `state.json` is missing or
196
+ its refresh token has expired. Do NOT reintroduce a browser-login flow (the `(auth)/login`
197
+ page is a client component whose `onSubmit` only attaches after hydration — clicking submit
198
+ pre-hydration falls through to a native GET and never authenticates).
122
199
 
123
200
  ## Auth Probe
124
201
 
125
- `apps/web/e2e/_probe/auth.spec.ts` — validates the login path directly (outside storage-
126
- state flow) so credential failures are diagnosed cleanly:
202
+ `apps/web/e2e/_probe/auth.spec.ts` — verifies that the stored auth state
203
+ (`refreshed-state.json`) grants access to the authenticated dashboard without
204
+ redirecting to the login page. It is intentionally minimal (one test) and runs
205
+ before the full suite to confirm the auth preflight:
127
206
 
128
207
  ```ts
129
208
  import { test, expect } from "@playwright/test";
130
209
 
131
- test("auth probe: can log in with E2E_TEST_EMAIL/E2E_TEST_PASSWORD", async ({ page }) => {
132
- const email = process.env.E2E_TEST_EMAIL;
133
- const password = process.env.E2E_TEST_PASSWORD;
134
- expect(email, "E2E_TEST_EMAIL env var is required").toBeTruthy();
135
- expect(password, "E2E_TEST_PASSWORD env var is required").toBeTruthy();
136
-
137
- await page.goto("/login");
138
- await page.getByLabel(/email/i).fill(email!);
139
- await page.getByLabel(/password/i).fill(password!);
140
- await page.getByRole("button", { name: /sign in|log in/i }).click();
210
+ test("auth probe: authenticated user reaches /dashboard without login redirect", async ({
211
+ page,
212
+ }) => {
213
+ const response = await page.goto("/dashboard", {
214
+ waitUntil: "domcontentloaded",
215
+ timeout: 20_000,
216
+ });
141
217
 
142
- await expect(page).toHaveURL(/\/(dashboard|home|app)/, { timeout: 15_000 });
218
+ // Must NOT be redirected to /login or /auth/login.
219
+ const finalUrl = page.url();
220
+ expect(
221
+ finalUrl,
222
+ `Auth probe failed: landed on ${finalUrl} instead of /dashboard. Check state.json / refreshed-state.json.`
223
+ ).not.toMatch(/\/(auth\/)?login/);
224
+
225
+ // HTTP status must be < 400.
226
+ const status = response?.status() ?? 0;
227
+ expect(
228
+ status,
229
+ `Auth probe failed: /dashboard returned HTTP ${status}.`
230
+ ).toBeLessThan(400);
231
+
232
+ // Dashboard heading must be present.
233
+ await expect(
234
+ page.getByRole("heading", { level: 1, name: /welcome to codebyplan|dashboard/i })
235
+ ).toBeVisible({ timeout: 15_000 });
143
236
  });
144
237
  ```
145
238
 
146
- Run probe: `pnpm exec playwright test --project=web _probe/auth`
239
+ Run probe: `pnpm exec playwright test --project=chromium _probe/auth`
147
240
 
148
241
  ## Pre-flight Probes (Step 6.5.2)
149
242
 
150
243
  **Dev server**: `curl -s -o /dev/null -w "%{http_code}" http://localhost:{port}/` — expect
151
244
  200/3xx. On failure:
152
245
 
153
- > "Dev server is not responding on port `{port}`. Please run `cd apps/{app} && pnpm dev`
246
+ > "Dev server is not responding on port `{port}`. Please run `cd apps/web && pnpm dev --port {port}`
154
247
  > in a separate terminal, then reply 'ready' when the page loads in your browser."
155
248
 
156
- **Port alignment**: parse `playwright.config.ts` `baseURL` port; compare to
157
- `.codebyplan/server.json` `port_allocations[]`. On mismatch ask which is correct, then
158
- propose an Edit to align them.
249
+ Note: Playwright's `webServer` block behaviour differs by environment. In local worktree
250
+ runs (`reuseExistingServer: true`), Playwright reuses an already-running server and only
251
+ auto-starts one when nothing is listening on the port — this probe is mainly a safety net.
252
+ In CI (`reuseExistingServer: false`), Playwright always spawns a fresh server regardless of
253
+ any already-running process, so the dev-server readiness probe is the active guard for that
254
+ path.
255
+
256
+ **Port alignment**: parse `playwright.config.ts` `baseURL` port; compare to the resolved
257
+ port from `.codebyplan/server.local.json` (worktree overlay, checked first) then
258
+ `.codebyplan/server.json` (committed base). On mismatch ask which is correct, then propose
259
+ an Edit to align them.
159
260
 
160
261
  ## Spec-Writing Patterns
161
262
 
@@ -227,7 +328,7 @@ Include this in the specialist output alongside `screenshots[]`.
227
328
  ## Run Command
228
329
 
229
330
  ```bash
230
- pnpm exec playwright test {spec} --project=web --reporter=list
331
+ pnpm exec playwright test {spec} --project=chromium --reporter=list
231
332
  ```
232
333
 
233
334
  ## Selector Conventions
@@ -238,13 +339,13 @@ the new page state rather than holding stale `Locator` handles.
238
339
 
239
340
  ## CI Secrets
240
341
 
241
- `E2E_TEST_EMAIL`, `E2E_TEST_PASSWORD`, `NEXT_PUBLIC_SUPABASE_URL`,
242
- `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` (or legacy `_ANON_KEY`).
342
+ `E2E_USER_EMAIL`, `E2E_USER_PASSWORD`, `NEXT_PUBLIC_SUPABASE_URL`,
343
+ `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_SECRET_KEY`.
243
344
 
244
345
  ## Pitfalls
245
346
 
246
347
  **Cold-start timeouts** — warmup in `globalSetup` (after `page.goto(baseURL!)`) primes
247
- Turbopack compilation. **Port mismatch** — compare `baseURL` port to `server.json` before
248
- running. **Supabase parallelism** — remote Supabase requires `workers: 1` to prevent
249
- auth/RLS races. **SCSS Module selectors** — use `[class*='componentName'].first()` or
250
- role-based selectors.
348
+ Turbopack compilation. **Port mismatch** — compare `baseURL` port to resolved port from
349
+ `server.local.json` / `server.json` before running. **Supabase parallelism** — remote
350
+ Supabase requires `workers: 1` to prevent auth/RLS races. **SCSS Module selectors** — use
351
+ `[class*='componentName'].first()` or role-based selectors.
@@ -118,11 +118,12 @@ with the blocking preflight field populated.
118
118
 
119
119
  ### 6.5.1 Environment Variables
120
120
 
121
- Check `apps/{app}/.env.local` and process env. Framework-specific required var names come
122
- from the `credential_vars` input field (the dispatching skill reads them from
123
- `.codebyplan/e2e.json`). Naming conventions:
121
+ Check `apps/{app}/.env.local`, `.codebyplan/e2e.env`, and process env. Framework-specific
122
+ required var names come from the `credential_vars` input field (the dispatching skill reads
123
+ them from `.codebyplan/e2e.json`). Naming conventions:
124
124
 
125
- - Playwright uses `E2E_TEST_*` (avoids collision with non-E2E `TEST_*` vars).
125
+ - Playwright uses `E2E_USER_EMAIL` / `E2E_USER_PASSWORD` (matches `.codebyplan/e2e.json`
126
+ `credentials.frameworks.playwright` and the `e2e.env` file loaded by `playwright.config.ts`).
126
127
  - Maestro/XCUITest stay on `TEST_*` per `rules/maestro-auth-state-reset.md`.
127
128
 
128
129
  For any missing var:
@@ -148,8 +149,9 @@ On any failure, `AskUserQuestion` with remediation steps; re-probe after "ready"
148
149
  silently skip a required runtime prerequisite.
149
150
 
150
151
  **Port alignment (Playwright only)**: parse `playwright.config.ts` `baseURL` and compare
151
- to `.codebyplan/server.json` `port_allocations[]` for the app. On mismatch ask which is
152
- correct before running.
152
+ to the resolved port from `.codebyplan/server.local.json` (worktree overlay, checked first)
153
+ then `.codebyplan/server.json` (committed base) `port_allocations[]` for the app. On
154
+ mismatch ask which is correct before running.
153
155
 
154
156
  ### 6.5.3 Auth Probe (only when `has_auth`)
155
157
 
@@ -303,9 +305,12 @@ Every repo with Playwright auth ships:
303
305
 
304
306
  - `scripts/provision-e2e-user.ts` — idempotent script creating the canonical E2E user
305
307
  and (for multi-tenant repos) a `test` subdomain. Wired to `pnpm e2e:provision`.
306
- - `.env.local.example` — lists every env var `globalSetup` requires.
307
- - CI secrets: `E2E_TEST_EMAIL`, `E2E_TEST_PASSWORD`, `NEXT_PUBLIC_SUPABASE_URL`,
308
- `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` (or legacy `_ANON_KEY`).
308
+ - `.codebyplan/e2e.env` — gitignored per-worktree file listing every env var `globalSetup`
309
+ requires. Written by `codebyplan e2e:provision` or manually. Loaded by `playwright.config.ts`
310
+ into `process.env` so global-setup picks them up via its `readEnv` helper.
311
+ - `.env.local.example` — lists every env var `globalSetup` requires for reference.
312
+ - CI secrets: `E2E_USER_EMAIL`, `E2E_USER_PASSWORD`, `NEXT_PUBLIC_SUPABASE_URL`,
313
+ `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_SECRET_KEY`.
309
314
 
310
315
  Per-repo specifics (email, vault name, remaining-spec migration list) live in the repo's
311
316
  own `docs/e2e-setup.md`, not in this shared file.
@@ -142,6 +142,17 @@ cp "$MAIN_REPO/.env.local" "$WORKTREE_PATH/.env.local"
142
142
 
143
143
  Verify `.env.local` is already in `.gitignore` (it should be via `.env.local` pattern). If not, add it.
144
144
 
145
+ Also copy the gitignored E2E credentials source (`.codebyplan/e2e.env`, referenced by `.codebyplan/e2e.json`) so the new worktree can run Playwright auth flows immediately:
146
+
147
+ ```bash
148
+ mkdir -p "$WORKTREE_PATH/.codebyplan"
149
+ if [ -f "$MAIN_REPO/.codebyplan/e2e.env" ]; then
150
+ cp "$MAIN_REPO/.codebyplan/e2e.env" "$WORKTREE_PATH/.codebyplan/e2e.env"
151
+ fi
152
+ ```
153
+
154
+ If the main repo has no `.codebyplan/e2e.env` yet, provision it after setup by running `codebyplan ports --path "$WORKTREE_PATH" --provision-e2e` (copies the canonical E2E vars from `apps/web/.env.local`). Pass `--path` BEFORE the boolean flag. `.codebyplan/e2e.env` is gitignored — never commit it.
155
+
145
156
  ### Step 8: Push Branch
146
157
 
147
158
  ```bash