codebyplan 1.13.16 → 1.13.19

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.19";
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 {
@@ -2375,6 +2373,22 @@ var whoami_exports = {};
2375
2373
  __export(whoami_exports, {
2376
2374
  runWhoami: () => runWhoami
2377
2375
  });
2376
+ async function fetchMe(accessToken) {
2377
+ try {
2378
+ const res = await fetch(meEndpoint(), {
2379
+ headers: { Authorization: `Bearer ${accessToken}` },
2380
+ signal: AbortSignal.timeout(1e4)
2381
+ });
2382
+ if (!res.ok) return null;
2383
+ const body = await res.json();
2384
+ return {
2385
+ email: body.email ?? null,
2386
+ username: body.username ?? null
2387
+ };
2388
+ } catch {
2389
+ return null;
2390
+ }
2391
+ }
2378
2392
  async function runWhoami(opts = {}) {
2379
2393
  const tokens = await loadTokens();
2380
2394
  if (opts.json) {
@@ -2383,8 +2397,13 @@ async function runWhoami(opts = {}) {
2383
2397
  process.exitCode = 1;
2384
2398
  return;
2385
2399
  }
2400
+ const me2 = await fetchMe(tokens.access_token);
2386
2401
  console.log(
2387
- JSON.stringify({ user_id: tokens.user_id, email: tokens.email })
2402
+ JSON.stringify({
2403
+ user_id: tokens.user_id,
2404
+ email: me2?.email ?? tokens.email,
2405
+ username: me2?.username ?? null
2406
+ })
2388
2407
  );
2389
2408
  return;
2390
2409
  }
@@ -2393,15 +2412,26 @@ async function runWhoami(opts = {}) {
2393
2412
  process.exitCode = 1;
2394
2413
  return;
2395
2414
  }
2415
+ const me = await fetchMe(tokens.access_token);
2416
+ const degraded = me === null;
2417
+ const email = me?.email ?? tokens.email;
2418
+ const username = me?.username ?? null;
2396
2419
  console.log(`
2397
- user_id: ${tokens.user_id}`);
2398
- console.log(` email: ${tokens.email ?? "(unknown)"}
2420
+ user_id: ${tokens.user_id}`);
2421
+ console.log(` email: ${email ?? "(unknown)"}`);
2422
+ console.log(` username: ${username ?? "(not set)"}`);
2423
+ if (degraded) {
2424
+ console.log(` (profile fetch failed \u2014 showing stored credentials)
2399
2425
  `);
2426
+ } else {
2427
+ console.log();
2428
+ }
2400
2429
  }
2401
2430
  var init_whoami = __esm({
2402
2431
  "src/cli/whoami.ts"() {
2403
2432
  "use strict";
2404
2433
  init_keychain();
2434
+ init_urls();
2405
2435
  }
2406
2436
  });
2407
2437
 
@@ -3797,7 +3827,7 @@ async function eslintInit(repoId, projectPath) {
3797
3827
  async function runEslint() {
3798
3828
  const subcommand = process.argv[3];
3799
3829
  const flags = parseFlags(4);
3800
- validateApiKey();
3830
+ await validateAuth();
3801
3831
  const config = await resolveConfig(flags);
3802
3832
  const { repoId, projectPath } = config;
3803
3833
  switch (subcommand) {
@@ -7073,6 +7103,94 @@ var init_cmux_serve = __esm({
7073
7103
  }
7074
7104
  });
7075
7105
 
7106
+ // src/lib/worktree-port-resolver.ts
7107
+ async function resolveWorktreePortAllocations(repoId, projectPath) {
7108
+ let resolvedWorktreeId;
7109
+ try {
7110
+ const deviceId = await getOrCreateDeviceId(projectPath);
7111
+ let branch = "main";
7112
+ try {
7113
+ const { execSync: execSync11 } = await import("node:child_process");
7114
+ branch = execSync11("git symbolic-ref --short HEAD", {
7115
+ cwd: projectPath,
7116
+ encoding: "utf-8"
7117
+ }).trim();
7118
+ } catch {
7119
+ }
7120
+ const tupleId = await resolveWorktreeId({
7121
+ repoId,
7122
+ repoPath: projectPath,
7123
+ branch,
7124
+ deviceId
7125
+ });
7126
+ if (tupleId) {
7127
+ resolvedWorktreeId = tupleId;
7128
+ } else {
7129
+ resolvedWorktreeId = await resolveAndCacheWorktreeId(repoId, projectPath) ?? void 0;
7130
+ }
7131
+ } catch (err) {
7132
+ const msg = err instanceof Error ? err.message : String(err);
7133
+ console.warn(
7134
+ ` Warning: failed to cache worktree_id (self-heal skipped): ${msg}`
7135
+ );
7136
+ }
7137
+ let portAllocations = [];
7138
+ try {
7139
+ const portsRes = await apiGet(
7140
+ `/port-allocations`,
7141
+ resolvedWorktreeId ? {
7142
+ repo_id: repoId,
7143
+ worktree_id: resolvedWorktreeId,
7144
+ limit: PORT_ALLOCATIONS_UNFILTERED_LIMIT
7145
+ } : {
7146
+ repo_id: repoId,
7147
+ worktree_id: "null",
7148
+ limit: PORT_ALLOCATIONS_UNFILTERED_LIMIT
7149
+ }
7150
+ );
7151
+ const allAllocations = portsRes.data ?? [];
7152
+ const filtered = resolvedWorktreeId ? allAllocations.filter((a) => a.worktree_id === resolvedWorktreeId) : allAllocations.filter((a) => !a.worktree_id);
7153
+ portAllocations = filtered.map((a) => {
7154
+ const clean = {};
7155
+ for (const key of ALLOWED_FIELDS) {
7156
+ if (key in a) clean[key] = a[key];
7157
+ }
7158
+ return clean;
7159
+ });
7160
+ } catch (err) {
7161
+ console.warn(
7162
+ ` Warning: failed to fetch port allocations: ${err instanceof Error ? err.message : String(err)}`
7163
+ );
7164
+ }
7165
+ const matchingAlloc = portAllocations[0];
7166
+ return { resolvedWorktreeId, portAllocations, matchingAlloc };
7167
+ }
7168
+ var PORT_ALLOCATIONS_UNFILTERED_LIMIT, ALLOWED_FIELDS;
7169
+ var init_worktree_port_resolver = __esm({
7170
+ "src/lib/worktree-port-resolver.ts"() {
7171
+ "use strict";
7172
+ init_api();
7173
+ init_resolve_worktree();
7174
+ init_local_config();
7175
+ PORT_ALLOCATIONS_UNFILTERED_LIMIT = "500";
7176
+ ALLOWED_FIELDS = [
7177
+ "id",
7178
+ "repo_id",
7179
+ "port",
7180
+ "label",
7181
+ "server_type",
7182
+ "auto_start",
7183
+ "command",
7184
+ "working_dir",
7185
+ "env_vars",
7186
+ "external_refs",
7187
+ "worktree_id",
7188
+ "created_at",
7189
+ "updated_at"
7190
+ ];
7191
+ }
7192
+ });
7193
+
7076
7194
  // src/lib/migrate-local-config.ts
7077
7195
  import { mkdir as mkdir6, readFile as readFile16, unlink as unlink2, writeFile as writeFile12 } from "node:fs/promises";
7078
7196
  import { join as join24 } from "node:path";
@@ -7307,6 +7425,7 @@ __export(config_exports, {
7307
7425
  readGitConfig: () => readGitConfig,
7308
7426
  readRepoConfig: () => readRepoConfig,
7309
7427
  readServerConfig: () => readServerConfig,
7428
+ readServerLocalConfig: () => readServerLocalConfig,
7310
7429
  readShipmentConfig: () => readShipmentConfig,
7311
7430
  readVendorConfig: () => readVendorConfig,
7312
7431
  runConfig: () => runConfig
@@ -7316,7 +7435,7 @@ import { join as join25 } from "node:path";
7316
7435
  async function runConfig() {
7317
7436
  const flags = parseFlags(3);
7318
7437
  const dryRun = hasFlag("dry-run", 3);
7319
- validateApiKey();
7438
+ await validateAuth();
7320
7439
  const config = await resolveConfig(flags);
7321
7440
  const { repoId, projectPath } = config;
7322
7441
  console.log(`
@@ -7346,35 +7465,12 @@ async function runConfig() {
7346
7465
  }
7347
7466
  async function syncConfigToFile(repoId, projectPath, dryRun) {
7348
7467
  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
- }
7468
+ const {
7469
+ resolvedWorktreeId,
7470
+ portAllocations,
7471
+ matchingAlloc: matchingAllocRaw
7472
+ } = await resolveWorktreePortAllocations(repoId, projectPath);
7473
+ const matchingAlloc = matchingAllocRaw;
7378
7474
  let repoRes;
7379
7475
  try {
7380
7476
  repoRes = await apiGet(`/repos/${repoId}`);
@@ -7397,42 +7493,6 @@ async function syncConfigToFile(repoId, projectPath, dryRun) {
7397
7493
  process.exit(1);
7398
7494
  }
7399
7495
  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
7496
  const defaultBranchConfig = {
7437
7497
  protected: ["main"],
7438
7498
  integration: null,
@@ -7573,14 +7633,24 @@ async function readE2eConfig2(projectPath) {
7573
7633
  return null;
7574
7634
  }
7575
7635
  }
7636
+ async function readServerLocalConfig(projectPath) {
7637
+ try {
7638
+ const raw = await readFile17(
7639
+ join25(projectPath, ".codebyplan", "server.local.json"),
7640
+ "utf-8"
7641
+ );
7642
+ return JSON.parse(raw);
7643
+ } catch {
7644
+ return null;
7645
+ }
7646
+ }
7576
7647
  var legacyBranchConfigWarned;
7577
7648
  var init_config = __esm({
7578
7649
  "src/cli/config.ts"() {
7579
7650
  "use strict";
7580
7651
  init_flags();
7581
7652
  init_api();
7582
- init_resolve_worktree();
7583
- init_local_config();
7653
+ init_worktree_port_resolver();
7584
7654
  init_migrate_local_config();
7585
7655
  legacyBranchConfigWarned = false;
7586
7656
  }
@@ -7738,22 +7808,48 @@ var init_port_verify = __esm({
7738
7808
  // src/cli/ports.ts
7739
7809
  var ports_exports = {};
7740
7810
  __export(ports_exports, {
7811
+ parseEnvFile: () => parseEnvFile,
7741
7812
  runPorts: () => runPorts
7742
7813
  });
7814
+ import { mkdir as mkdir8, readFile as readFile19, writeFile as writeFile14 } from "node:fs/promises";
7815
+ import { join as join26 } from "node:path";
7743
7816
  async function runPorts() {
7744
7817
  const flags = parseFlags(3);
7745
7818
  const dryRun = hasFlag("dry-run", 3);
7746
7819
  const fix = hasFlag("fix", 3);
7747
- validateApiKey();
7748
- const config = await resolveConfig(flags);
7749
- const { repoId, projectPath } = config;
7820
+ const writeLocal = hasFlag("write-local", 3);
7821
+ const provisionE2e = hasFlag("provision-e2e", 3);
7822
+ if (flags["path"]?.startsWith("--")) {
7823
+ console.warn(
7824
+ ` Warning: --path value "${flags["path"]}" looks like a flag. Pass --path <dir> BEFORE boolean flags (e.g. --path . --provision-e2e).`
7825
+ );
7826
+ }
7827
+ const provisionE2eOnly = provisionE2e && !writeLocal;
7828
+ let repoId = "";
7829
+ let projectPath;
7830
+ if (provisionE2eOnly) {
7831
+ projectPath = flags["path"] ?? process.cwd();
7832
+ } else {
7833
+ await validateAuth();
7834
+ const config = await resolveConfig(flags);
7835
+ repoId = config.repoId;
7836
+ projectPath = config.projectPath;
7837
+ }
7750
7838
  console.log(`
7751
7839
  CodeByPlan Ports`);
7752
- console.log(` Repo: ${repoId}`);
7840
+ if (repoId) console.log(` Repo: ${repoId}`);
7753
7841
  console.log(` Path: ${projectPath}`);
7754
7842
  if (dryRun) console.log(` Mode: dry-run`);
7755
7843
  if (fix) console.log(` Mode: fix`);
7844
+ if (writeLocal) console.log(` Mode: write-local`);
7845
+ if (provisionE2e) console.log(` Mode: provision-e2e`);
7756
7846
  console.log();
7847
+ if (writeLocal || provisionE2e) {
7848
+ if (writeLocal) await writeServerLocalConfig(repoId, projectPath, dryRun);
7849
+ if (provisionE2e) await provisionE2eEnv(projectPath, dryRun);
7850
+ console.log("\n Ports complete.\n");
7851
+ return;
7852
+ }
7757
7853
  try {
7758
7854
  const portsRes = await apiGet(
7759
7855
  `/port-allocations`,
@@ -7822,12 +7918,135 @@ async function runPorts() {
7822
7918
  }
7823
7919
  console.log("\n Ports complete.\n");
7824
7920
  }
7921
+ async function writeServerLocalConfig(repoId, projectPath, dryRun) {
7922
+ const { resolvedWorktreeId, portAllocations, matchingAlloc } = await resolveWorktreePortAllocations(repoId, projectPath);
7923
+ if (portAllocations.length === 0) {
7924
+ console.warn(
7925
+ " Skipped .codebyplan/server.local.json \u2014 no worktree port allocations resolved (API failure or none assigned)."
7926
+ );
7927
+ return;
7928
+ }
7929
+ const payload = {
7930
+ server_port: matchingAlloc?.port ?? null,
7931
+ server_type: matchingAlloc?.server_type ?? null,
7932
+ // No cast needed: resolveWorktreePortAllocations returns Partial<PortAllocation>[]
7933
+ // and ServerLocalConfig.port_allocations is typed the same — honest end-to-end.
7934
+ port_allocations: portAllocations
7935
+ };
7936
+ const codebyplanDir = join26(projectPath, ".codebyplan");
7937
+ const filePath = join26(codebyplanDir, "server.local.json");
7938
+ const newJson = JSON.stringify(payload, null, 2) + "\n";
7939
+ let currentJson = "";
7940
+ try {
7941
+ currentJson = await readFile19(filePath, "utf-8");
7942
+ } catch {
7943
+ }
7944
+ if (currentJson === newJson) {
7945
+ console.log(" server.local.json up to date.");
7946
+ return;
7947
+ }
7948
+ if (dryRun) {
7949
+ console.log(" Would update .codebyplan/server.local.json (dry-run).");
7950
+ return;
7951
+ }
7952
+ await mkdir8(codebyplanDir, { recursive: true });
7953
+ await writeFile14(filePath, newJson, "utf-8");
7954
+ console.log(
7955
+ ` Updated .codebyplan/server.local.json (worktree ${resolvedWorktreeId ?? "\u2014"}, ${portAllocations.length} allocation${portAllocations.length === 1 ? "" : "s"}).`
7956
+ );
7957
+ }
7958
+ async function provisionE2eEnv(projectPath, dryRun) {
7959
+ const relSource = join26("apps", "web", ".env.local");
7960
+ const sourcePath = join26(projectPath, relSource);
7961
+ let sourceRaw;
7962
+ try {
7963
+ sourceRaw = await readFile19(sourcePath, "utf-8");
7964
+ } catch {
7965
+ console.warn(
7966
+ ` Skipped .codebyplan/e2e.env \u2014 source ${relSource} not found.`
7967
+ );
7968
+ return;
7969
+ }
7970
+ const sourceVars = parseEnvFile(sourceRaw);
7971
+ const lines = [];
7972
+ const missing = [];
7973
+ for (const key of E2E_ENV_VARS) {
7974
+ const val = sourceVars[key];
7975
+ if (val === void 0) {
7976
+ missing.push(key);
7977
+ continue;
7978
+ }
7979
+ lines.push(`${key}=${val}`);
7980
+ }
7981
+ if (missing.length > 0) {
7982
+ console.warn(
7983
+ ` Warning: ${missing.length} E2E var(s) missing from ${relSource}: ${missing.join(", ")}`
7984
+ );
7985
+ }
7986
+ if (lines.length === 0) {
7987
+ console.warn(
7988
+ " Skipped .codebyplan/e2e.env \u2014 none of the expected E2E vars were found."
7989
+ );
7990
+ return;
7991
+ }
7992
+ const codebyplanDir = join26(projectPath, ".codebyplan");
7993
+ const filePath = join26(codebyplanDir, "e2e.env");
7994
+ const newContent = lines.join("\n") + "\n";
7995
+ let currentContent = "";
7996
+ try {
7997
+ currentContent = await readFile19(filePath, "utf-8");
7998
+ } catch {
7999
+ }
8000
+ if (currentContent === newContent) {
8001
+ console.log(" e2e.env up to date.");
8002
+ return;
8003
+ }
8004
+ if (dryRun) {
8005
+ console.log(" Would provision .codebyplan/e2e.env (dry-run).");
8006
+ return;
8007
+ }
8008
+ await mkdir8(codebyplanDir, { recursive: true });
8009
+ await writeFile14(filePath, newContent, "utf-8");
8010
+ console.log(
8011
+ ` Provisioned .codebyplan/e2e.env (${lines.length} var${lines.length === 1 ? "" : "s"}).`
8012
+ );
8013
+ }
8014
+ function parseEnvFile(raw) {
8015
+ const out = {};
8016
+ for (const line of raw.split("\n")) {
8017
+ const trimmed = line.trim();
8018
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
8019
+ const eq = trimmed.indexOf("=");
8020
+ if (eq === -1) continue;
8021
+ const key = trimmed.slice(0, eq).trim();
8022
+ if (key === "") continue;
8023
+ let value = trimmed.slice(eq + 1).trim();
8024
+ const quoted = value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"));
8025
+ if (quoted) {
8026
+ value = value.slice(1, -1);
8027
+ } else {
8028
+ const commentIdx = value.indexOf(" #");
8029
+ if (commentIdx !== -1) value = value.slice(0, commentIdx).trimEnd();
8030
+ }
8031
+ out[key] = value;
8032
+ }
8033
+ return out;
8034
+ }
8035
+ var E2E_ENV_VARS;
7825
8036
  var init_ports = __esm({
7826
8037
  "src/cli/ports.ts"() {
7827
8038
  "use strict";
7828
8039
  init_flags();
7829
8040
  init_api();
7830
8041
  init_port_verify();
8042
+ init_worktree_port_resolver();
8043
+ E2E_ENV_VARS = [
8044
+ "E2E_USER_EMAIL",
8045
+ "E2E_USER_PASSWORD",
8046
+ "NEXT_PUBLIC_SUPABASE_URL",
8047
+ "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY",
8048
+ "SUPABASE_SECRET_KEY"
8049
+ ];
7831
8050
  }
7832
8051
  });
7833
8052
 
@@ -7845,7 +8064,7 @@ async function runTechStack() {
7845
8064
  await runFullTechStack(dryRun);
7846
8065
  return;
7847
8066
  }
7848
- validateApiKey();
8067
+ await validateAuth();
7849
8068
  const config = await resolveConfig(flags);
7850
8069
  const { repoId, projectPath } = config;
7851
8070
  console.log(`
@@ -7980,7 +8199,7 @@ async function syncTechStackForPath(repoId, projectPath, dryRun) {
7980
8199
  }
7981
8200
  }
7982
8201
  async function runFullTechStack(dryRun) {
7983
- validateApiKey();
8202
+ await validateAuth();
7984
8203
  const localConfig = await readLocalConfig(process.cwd());
7985
8204
  if (!localConfig?.device_id) {
7986
8205
  console.error(
@@ -8981,6 +9200,11 @@ void (async () => {
8981
9200
  --repo-id <uuid> Repository ID (or set via .codebyplan/repo.json)
8982
9201
  --dry-run Preview changes without writing
8983
9202
  --fix Auto-create missing port allocations
9203
+ --write-local Write this worktree's port allocations to the
9204
+ gitignored .codebyplan/server.local.json overlay
9205
+ (never the committed server.json)
9206
+ --provision-e2e Copy E2E credentials from apps/web/.env.local into
9207
+ the gitignored .codebyplan/e2e.env (local, no API)
8984
9208
 
8985
9209
  Tech stack options:
8986
9210
  --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.19",
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.
@@ -64,10 +64,10 @@ while IFS= read -r FILE; do
64
64
  continue
65
65
  fi
66
66
 
67
- # Skip files under a __tests__/ directory — fixtures, helpers, setup, and
68
- # other test infrastructure are imported by the test files that exercise
69
- # them; requiring a dedicated .test.ts for a fixture is nonsensical.
70
- if echo "$FILE" | grep -qE '/__tests__/'; then
67
+ # Skip files under a __tests__/ or __mocks__/ directory — fixtures, helpers,
68
+ # setup, manual mocks, and other test infrastructure are imported by the test
69
+ # files that exercise them; requiring a dedicated .test.ts is nonsensical.
70
+ if echo "$FILE" | grep -qE '/__tests__/|/__mocks__/'; then
71
71
  SKIPPED=$((SKIPPED + 1))
72
72
  continue
73
73
  fi
@@ -53,6 +53,16 @@
53
53
  "Skill(cbp-checkpoint-end)",
54
54
  "Skill(cbp-ship)",
55
55
  "Skill(cbp-ship-main)",
56
+ "Skill(cbp-checkpoint-start)",
57
+ "Skill(cbp-checkpoint-create)",
58
+ "Skill(cbp-checkpoint-check)",
59
+ "Skill(cbp-checkpoint-complete)",
60
+ "Skill(cbp-round-update)",
61
+ "Skill(cbp-session-end)",
62
+ "Skill(cbp-task-complete)",
63
+ "Skill(cbp-standalone-task-create)",
64
+ "Skill(cbp-standalone-task-start)",
65
+ "Skill(cbp-standalone-task-complete)",
56
66
  "mcp__codebyplan__accept_invite",
57
67
  "mcp__codebyplan__change_role",
58
68
  "mcp__codebyplan__create_launch",
@@ -100,11 +110,7 @@
100
110
  "Skill(cbp-build-cc-rule)",
101
111
  "Skill(cbp-build-cc-settings)",
102
112
  "Skill(cbp-build-cc-skill)",
103
- "Skill(cbp-checkpoint-check)",
104
- "Skill(cbp-checkpoint-complete)",
105
- "Skill(cbp-checkpoint-create)",
106
113
  "Skill(cbp-checkpoint-plan)",
107
- "Skill(cbp-checkpoint-start)",
108
114
  "Skill(cbp-checkpoint-update)",
109
115
  "Skill(cbp-frontend-a11y)",
110
116
  "Skill(cbp-frontend-design)",
@@ -121,28 +127,17 @@
121
127
  "Skill(cbp-round-execute)",
122
128
  "Skill(cbp-round-input)",
123
129
  "Skill(cbp-round-start)",
124
- "Skill(cbp-round-update)",
125
- "Skill(cbp-session-end)",
126
130
  "Skill(cbp-session-start)",
127
131
  "Skill(cbp-setup-cmux)",
128
132
  "Skill(cbp-setup-e2e)",
129
133
  "Skill(cbp-setup-eslint)",
130
134
  "Skill(cbp-ship-configure)",
131
135
  "Skill(cbp-standalone-task-check)",
132
- "Skill(cbp-standalone-task-complete)",
133
- "Skill(cbp-standalone-task-create)",
134
- "Skill(cbp-standalone-task-start)",
135
136
  "Skill(cbp-standalone-task-testing)",
136
137
  "Skill(cbp-supabase-branch-check)",
137
138
  "Skill(cbp-supabase-migrate)",
138
139
  "Skill(cbp-supabase-setup)",
139
- "Skill(cbp-standalone-task-check)",
140
- "Skill(cbp-standalone-task-complete)",
141
- "Skill(cbp-standalone-task-create)",
142
- "Skill(cbp-standalone-task-start)",
143
- "Skill(cbp-standalone-task-testing)",
144
140
  "Skill(cbp-task-check)",
145
- "Skill(cbp-task-complete)",
146
141
  "Skill(cbp-task-create)",
147
142
  "Skill(cbp-task-start)",
148
143
  "Skill(cbp-task-testing)",
@@ -22,7 +22,7 @@ Precedence is `deny > ask > allow`; arrays union across scopes (managed/user/pro
22
22
 
23
23
  ### `allow` — the autonomous workflow surface
24
24
 
25
- - **All `/cbp-*` skills** except the production-shipment trio below. Invoking a skill is the intended mode of operation; the gated side effects happen inside via the Bash/MCP tools the skill calls, which carry their own tiering.
25
+ - **Non-lifecycle, non-shipment `/cbp-*` skills** authoring (`cbp-build-cc-*`), frontend (`cbp-frontend-*`), git (`cbp-git-*`, `cbp-merge-main`, `cbp-refresh-infra`), round work (`cbp-round-check`/`-end`/`-execute`/`-input`/`-start`), setup/configure (`cbp-setup-*`, `cbp-ship-configure`, `cbp-supabase-*`), task prep (`cbp-task-check`/`-create`/`-start`/`-testing`, `cbp-standalone-task-check`/`-testing`), planning (`cbp-checkpoint-plan`/`-update`), plus `cbp-session-start` and `cbp-todo`. Invoking a skill is the intended mode of operation; the gated side effects happen inside via the Bash/MCP tools the skill calls, which carry their own tiering. The lifecycle/state-transition skills are the exception — they live in `ask` (next section).
26
26
  - **All `mcp__codebyplan__*` reads** (`get_*`, `list_*`, `search_*`, `health_check`, `lookup_symbol`, `resolve_library_id`, `get_chunk`).
27
27
  - **Routine workflow-write MCP tools** the pipeline calls many times per task: create/update/complete checkpoint, task, and round; session log + session-state writes; `create_worktree`, `add_library`, `flag_stale_chunk`, `update_server_config`, `update_eslint_repo_config`, `update_task_template`. Gating these with `ask` would make the autonomous workflow unusable.
28
28
  - **Read/safe CLI commands** (both `codebyplan X` and `npx codebyplan X`): `whoami`, `resolve-worktree`, `statusline`, `ports`, `tech-stack`, `eslint`, `round`, `help`, `--version`.
@@ -30,6 +30,7 @@ Precedence is `deny > ask > allow`; arrays union across scopes (managed/user/pro
30
30
  ### `ask` — the deliberate confirm-gate
31
31
 
32
32
  - **Production-shipment skills**: `cbp-ship`, `cbp-ship-main`, `cbp-checkpoint-end` — these promote/deploy to production, so they prompt even in an otherwise auto-allowed setup.
33
+ - **Lifecycle / state-transition skills**: `cbp-checkpoint-start`, `cbp-checkpoint-create`, `cbp-checkpoint-check`, `cbp-checkpoint-complete`, `cbp-round-update`, `cbp-session-end`, `cbp-task-complete`, `cbp-standalone-task-create`, `cbp-standalone-task-start`, `cbp-standalone-task-complete` — these open or close checkpoints, tasks, rounds, and sessions (advancing workflow state in the database), so they stop for explicit confirmation rather than running autonomously.
33
34
  - **Destructive / admin / external MCP tools**: `delete_session_log`, `delete_worktree`, `delete_launch`, `create_repo`, `create_launch`, `update_launch`, `release_assignment`, and the membership tools `invite_member`, `remove_member`, `change_role`, `accept_invite`, `revoke_invite`.
34
35
  - **Mutating / external / clobber-risk CLI commands** (both prefixes): `setup`, `login`, `logout`, `upgrade-auth`, `config` (can overwrite committed `.codebyplan/` files), `branch` (rewrites branch config), `ship`, `claude` (`install`/`update`/`uninstall` overwrite `.claude/`).
35
36
 
@@ -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