mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
chore: use worktreeinclude for local state setup (#2709)
This commit is contained in:
@@ -33,6 +33,29 @@ Simple fallback:
|
|||||||
|
|
||||||
Prefer `npx convex ai-files install` over copying rules by hand when possible.
|
Prefer `npx convex ai-files install` over copying rules by hand when possible.
|
||||||
|
|
||||||
|
## Command Preflight
|
||||||
|
|
||||||
|
Before running any `bunx convex ...` command in ClawHub, explicitly identify:
|
||||||
|
|
||||||
|
- target runtime: `local`, `dev`, or `prod`
|
||||||
|
- deployment: exact name or URL when known, such as `wry-manatee-359` for prod
|
||||||
|
- code state: whether the function/schema changes have already been pushed with
|
||||||
|
`bunx convex dev --once`, `bunx convex deploy`, or the production deploy
|
||||||
|
workflow
|
||||||
|
|
||||||
|
Use the current Convex CLI flag shape:
|
||||||
|
|
||||||
|
- read data: `bunx convex data --deployment <deployment> <table>`
|
||||||
|
- run a function: `bunx convex run --deployment <deployment> <function> '<json>'`
|
||||||
|
- readonly inline query:
|
||||||
|
`bunx convex run --deployment <deployment> --inline-query '<query>'`
|
||||||
|
- single-table import:
|
||||||
|
`bunx convex import --deployment <deployment> --table <table> --replace -y <file>`
|
||||||
|
|
||||||
|
If `--env-file .env.local` produces `401 MissingAccessToken`, omit the env file
|
||||||
|
and target the deployment directly with `--deployment <deployment>` or `--prod`.
|
||||||
|
Do not use stale `--deployment-name` guidance.
|
||||||
|
|
||||||
## Route to the Right Skill
|
## Route to the Right Skill
|
||||||
|
|
||||||
After that, use the most specific Convex skill for the task:
|
After that, use the most specific Convex skill for the task:
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ temporary migration code.
|
|||||||
|
|
||||||
- Never run a destructive production apply step until after presenting dry-run
|
- Never run a destructive production apply step until after presenting dry-run
|
||||||
results and receiving explicit user confirmation in the current thread.
|
results and receiving explicit user confirmation in the current thread.
|
||||||
|
- Before implementing anything, classify the requested "migration" as one of:
|
||||||
|
code deploy, existing Convex function run, operator import/export command,
|
||||||
|
schema narrowing, data cleanup, or cleanup-code removal. Do not invent a new
|
||||||
|
Convex migration function when the issue or PR specifies an operator command
|
||||||
|
such as `convex import --replace`.
|
||||||
- Before any production migration apply, force the operator to visit
|
- Before any production migration apply, force the operator to visit
|
||||||
`https://dashboard.convex.dev/`, manually click **Backup Now** on the target
|
`https://dashboard.convex.dev/`, manually click **Backup Now** on the target
|
||||||
deployment, wait for completion, and explicitly confirm in the thread. Do not
|
deployment, wait for completion, and explicitly confirm in the thread. Do not
|
||||||
|
|||||||
+2
-2
@@ -2,10 +2,10 @@
|
|||||||
url = "http://127.0.0.1:{{ (repo ~ '-' ~ branch) | hash_port }}"
|
url = "http://127.0.0.1:{{ (repo ~ '-' ~ branch) | hash_port }}"
|
||||||
|
|
||||||
[[pre-start]]
|
[[pre-start]]
|
||||||
env = "bun run setup:worktree -- --quiet"
|
env = "wt step copy-ignored || true; bun run setup:worktree -- --quiet --force --prefer-fallback"
|
||||||
|
|
||||||
[[pre-start]]
|
[[pre-start]]
|
||||||
deps = "wt step copy-ignored || true; test -x node_modules/.bin/vite || bun install"
|
deps = "test -x node_modules/.bin/vite || bun install"
|
||||||
|
|
||||||
[post-start]
|
[post-start]
|
||||||
dev = "bun scripts/dev-worktree.ts --detach --seed --port {{ (repo ~ '-' ~ branch) | hash_port }}"
|
dev = "bun scripts/dev-worktree.ts --detach --seed --port {{ (repo ~ '-' ~ branch) | hash_port }}"
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
|
.env.local
|
||||||
|
.convex/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ Keep this section as the command map agents normally need, not a full `package.j
|
|||||||
- `bun run dev` — foreground local app server at `http://localhost:3000`.
|
- `bun run dev` — foreground local app server at `http://localhost:3000`.
|
||||||
- `bunx convex dev --typecheck=disable` — local Convex backend/function watcher for manual setup.
|
- `bunx convex dev --typecheck=disable` — local Convex backend/function watcher for manual setup.
|
||||||
- `bunx convex codegen` — regenerate `convex/_generated` after Convex API/schema changes.
|
- `bunx convex codegen` — regenerate `convex/_generated` after Convex API/schema changes.
|
||||||
- `bun run setup:worktree` — link `.env.local` and `.convex` from a usable source worktree into the current worktree. Use `-- --from <path>` or `CLAWHUB_WORKTREE_SOURCE=<path>` when auto-discovery picks the wrong source.
|
- `.worktreeinclude` — Codex-managed worktrees copy ignored local state (`.env.local`, `.convex/`, and `node_modules/`) from the local checkout at creation time.
|
||||||
|
- `bun run setup:worktree` — validate copied `.env.local` / `.convex` state, or link missing fallback state from a usable source worktree. Use `-- --from <path>` or `CLAWHUB_WORKTREE_SOURCE=<path>` when auto-discovery picks the wrong source.
|
||||||
- `bun run dev:worktree` — Worktrunk-managed detached worktree server that also seeds local fixtures plus the public corpus once before starting the app when `VITE_CONVEX_URL` and `CONVEX_DEPLOYMENT` are local. Requires `wt` on `PATH`; from that worktree use `wt --yes url` to print the branch URL and `wt --yes stop` to stop it.
|
- `bun run dev:worktree` — Worktrunk-managed detached worktree server that also seeds local fixtures plus the public corpus once before starting the app when `VITE_CONVEX_URL` and `CONVEX_DEPLOYMENT` are local. Requires `wt` on `PATH`; from that worktree use `wt --yes url` to print the branch URL and `wt --yes stop` to stop it.
|
||||||
- `bun run seed:dev` — manual reseed path; runs worktree setup, waits for local Convex, seeds local fixtures plus the public corpus, and refreshes stats.
|
- `bun run seed:dev` — manual reseed path; runs worktree setup, waits for local Convex, seeds local fixtures plus the public corpus, and refreshes stats.
|
||||||
- `bun run build` — production build (Vite + Nitro).
|
- `bun run build` — production build (Vite + Nitro).
|
||||||
@@ -99,9 +100,10 @@ Specialized corpus, scanner, security-worker, UI proof, proof publishing, Crabbo
|
|||||||
|
|
||||||
## Convex Ops (Gotchas)
|
## Convex Ops (Gotchas)
|
||||||
|
|
||||||
|
- Before any `bunx convex ...` command, name the target runtime (`local`, `dev`, or `prod`), the exact deployment when known, and whether the current function/schema code has already been pushed or deployed.
|
||||||
- New Convex functions must be pushed before `convex run`: use `bunx convex dev --once` (dev) or `bunx convex deploy` (prod).
|
- New Convex functions must be pushed before `convex run`: use `bunx convex dev --once` (dev) or `bunx convex deploy` (prod).
|
||||||
- For non-interactive prod deploys, use `bunx convex deploy -y` to skip confirmation.
|
- For non-interactive prod deploys, use `bunx convex deploy -y` to skip confirmation.
|
||||||
- If `bunx convex run --env-file .env.local ...` returns `401 MissingAccessToken` despite `bunx convex login`, workaround: omit `--env-file` and use `--deployment-name <name>` / `--prod`.
|
- If `bunx convex run --env-file .env.local ...` returns `401 MissingAccessToken` despite `bunx convex login`, workaround: omit `--env-file` and use `--deployment <name>` / `--prod`.
|
||||||
|
|
||||||
## Convex Migrations & Backfills
|
## Convex Migrations & Backfills
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
import { spawnSync } from "node:child_process";
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdtempSync,
|
||||||
|
mkdirSync,
|
||||||
|
realpathSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { findSource } from "./setup-worktree";
|
import { describeSetupResult, findSource, setupWorktree } from "./setup-worktree";
|
||||||
|
|
||||||
function writeWorktree(
|
function writeWorktree(
|
||||||
path: string,
|
path: string,
|
||||||
deploymentName: string,
|
deploymentName: string,
|
||||||
options?: {
|
options?: {
|
||||||
env?: Record<string, string | null>;
|
env?: Record<string, string | null>;
|
||||||
|
adminKey?: string;
|
||||||
ports?: { cloud: number; site?: number };
|
ports?: { cloud: number; site?: number };
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -38,7 +48,11 @@ function writeWorktree(
|
|||||||
);
|
);
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(path, ".convex/local/default/config.json"),
|
join(path, ".convex/local/default/config.json"),
|
||||||
JSON.stringify({ deploymentName, ports: options?.ports ?? { cloud: 3210, site: 3211 } }),
|
JSON.stringify({
|
||||||
|
...(options?.adminKey ? { adminKey: options.adminKey } : {}),
|
||||||
|
deploymentName,
|
||||||
|
ports: options?.ports ?? { cloud: 3210, site: 3211 },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +69,256 @@ function withSourceAndCurrent(run: (source: string, current: string) => void) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runGit(cwd: string, args: string[]) {
|
||||||
|
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`git ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("setup-worktree", () => {
|
describe("setup-worktree", () => {
|
||||||
|
it("describes copied local files and fallback links clearly", () => {
|
||||||
|
expect(
|
||||||
|
describeSetupResult({
|
||||||
|
convexLinked: false,
|
||||||
|
envLinked: false,
|
||||||
|
mode: "local",
|
||||||
|
sourcePath: "/repo",
|
||||||
|
}),
|
||||||
|
).toBe("validated copied local .env.local and .convex");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
describeSetupResult({
|
||||||
|
convexLinked: true,
|
||||||
|
envLinked: false,
|
||||||
|
mode: "fallback",
|
||||||
|
sourcePath: "/repo",
|
||||||
|
}),
|
||||||
|
).toBe("fallback source /repo (env: existing, convex: linked)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses copied local worktree files without replacing them with symlinks", () => {
|
||||||
|
withSourceAndCurrent((_source, current) => {
|
||||||
|
writeWorktree(current, "local-clawhub");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
setupWorktree({
|
||||||
|
cwd: current,
|
||||||
|
options: {
|
||||||
|
force: false,
|
||||||
|
from: null,
|
||||||
|
quiet: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
convexLinked: false,
|
||||||
|
envLinked: false,
|
||||||
|
mode: "local",
|
||||||
|
sourcePath: current,
|
||||||
|
});
|
||||||
|
expect(lstatSync(join(current, ".env.local")).isSymbolicLink()).toBe(false);
|
||||||
|
expect(lstatSync(join(current, ".convex")).isSymbolicLink()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a copied env file and links only missing Convex state from a fallback source", () => {
|
||||||
|
withSourceAndCurrent((source, current) => {
|
||||||
|
writeWorktree(source, "local-clawhub");
|
||||||
|
mkdirSync(current, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(current, ".env.local"),
|
||||||
|
"CONVEX_DEPLOYMENT=local:local-clawhub\nVITE_CONVEX_URL=http://127.0.0.1:3210\nVITE_CONVEX_SITE_URL=http://127.0.0.1:3211\nCONVEX_SITE_URL=http://127.0.0.1:3211\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
setupWorktree({
|
||||||
|
cwd: current,
|
||||||
|
options: {
|
||||||
|
force: false,
|
||||||
|
from: source,
|
||||||
|
quiet: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
convexLinked: true,
|
||||||
|
envLinked: false,
|
||||||
|
mode: "fallback",
|
||||||
|
sourcePath: source,
|
||||||
|
});
|
||||||
|
expect(lstatSync(join(current, ".env.local")).isSymbolicLink()).toBe(false);
|
||||||
|
expect(lstatSync(join(current, ".convex")).isSymbolicLink()).toBe(true);
|
||||||
|
expect(existsSync(join(current, ".convex/local/default/config.json"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps copied Convex state and links only a missing env file from a fallback source", () => {
|
||||||
|
withSourceAndCurrent((source, current) => {
|
||||||
|
writeWorktree(source, "local-clawhub");
|
||||||
|
mkdirSync(join(current, ".convex/local/default"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(current, ".convex/local/default/config.json"),
|
||||||
|
JSON.stringify({ deploymentName: "local-clawhub", ports: { cloud: 3210, site: 3211 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
setupWorktree({
|
||||||
|
cwd: current,
|
||||||
|
options: {
|
||||||
|
force: false,
|
||||||
|
from: source,
|
||||||
|
quiet: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
convexLinked: false,
|
||||||
|
envLinked: true,
|
||||||
|
mode: "fallback",
|
||||||
|
sourcePath: source,
|
||||||
|
});
|
||||||
|
expect(lstatSync(join(current, ".env.local")).isSymbolicLink()).toBe(true);
|
||||||
|
expect(lstatSync(join(current, ".convex")).isSymbolicLink()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces stale copied state when forced by automated worktree setup", () => {
|
||||||
|
withSourceAndCurrent((source, current) => {
|
||||||
|
writeWorktree(source, "local-clawhub");
|
||||||
|
writeWorktree(current, "stale-clawhub", { ports: { cloud: 4321, site: 4322 } });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
setupWorktree({
|
||||||
|
cwd: current,
|
||||||
|
options: {
|
||||||
|
force: true,
|
||||||
|
from: source,
|
||||||
|
quiet: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
convexLinked: true,
|
||||||
|
envLinked: true,
|
||||||
|
mode: "fallback",
|
||||||
|
sourcePath: source,
|
||||||
|
});
|
||||||
|
expect(lstatSync(join(current, ".env.local")).isSymbolicLink()).toBe(true);
|
||||||
|
expect(lstatSync(join(current, ".convex")).isSymbolicLink()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers a fallback source over valid current copied state in automated worktree setup", () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "clawhub-worktree-git-"));
|
||||||
|
try {
|
||||||
|
const source = join(root, "source");
|
||||||
|
const current = join(root, "current");
|
||||||
|
mkdirSync(source);
|
||||||
|
runGit(source, ["init"]);
|
||||||
|
runGit(source, ["checkout", "-b", "main"]);
|
||||||
|
writeFileSync(join(source, "README.md"), "# test\n");
|
||||||
|
runGit(source, ["add", "README.md"]);
|
||||||
|
runGit(source, [
|
||||||
|
"-c",
|
||||||
|
"user.email=test@example.com",
|
||||||
|
"-c",
|
||||||
|
"user.name=Test User",
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
"init",
|
||||||
|
]);
|
||||||
|
runGit(source, ["worktree", "add", "-b", "feature", current]);
|
||||||
|
writeWorktree(source, "local-clawhub");
|
||||||
|
writeWorktree(current, "stale-clawhub", { ports: { cloud: 4321, site: 4322 } });
|
||||||
|
|
||||||
|
const result = setupWorktree({
|
||||||
|
cwd: current,
|
||||||
|
options: {
|
||||||
|
force: true,
|
||||||
|
preferFallback: true,
|
||||||
|
from: null,
|
||||||
|
quiet: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
convexLinked: true,
|
||||||
|
envLinked: true,
|
||||||
|
mode: "fallback",
|
||||||
|
sourcePath: realpathSync(source),
|
||||||
|
});
|
||||||
|
expect(lstatSync(join(current, ".env.local")).isSymbolicLink()).toBe(true);
|
||||||
|
expect(lstatSync(join(current, ".convex")).isSymbolicLink()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid copied env file instead of silently keeping it", () => {
|
||||||
|
withSourceAndCurrent((source, current) => {
|
||||||
|
writeWorktree(source, "local-clawhub");
|
||||||
|
writeFileSync(
|
||||||
|
join(current, ".env.local"),
|
||||||
|
"CONVEX_DEPLOYMENT=local:local-clawhub\nVITE_CONVEX_URL=http://127.0.0.1:3210\nVITE_CONVEX_SITE_URL=http://127.0.0.1:3210\nCONVEX_SITE_URL=http://127.0.0.1:3211\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
setupWorktree({
|
||||||
|
cwd: current,
|
||||||
|
options: {
|
||||||
|
force: false,
|
||||||
|
from: source,
|
||||||
|
quiet: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toThrow(".env.local already exists as a regular local path");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a copied env file that does not match fallback runtime URLs", () => {
|
||||||
|
withSourceAndCurrent((source, current) => {
|
||||||
|
writeWorktree(source, "local-clawhub");
|
||||||
|
writeFileSync(
|
||||||
|
join(current, ".env.local"),
|
||||||
|
"CONVEX_DEPLOYMENT=local:local-clawhub\nVITE_CONVEX_URL=http://localhost:3210\nVITE_CONVEX_SITE_URL=http://127.0.0.1:3211\nCONVEX_SITE_URL=http://127.0.0.1:3211\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
setupWorktree({
|
||||||
|
cwd: current,
|
||||||
|
options: {
|
||||||
|
force: false,
|
||||||
|
from: source,
|
||||||
|
quiet: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toThrow(".env.local already exists as a regular local path");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a copied Convex config that does not match the fallback admin key", () => {
|
||||||
|
withSourceAndCurrent((source, current) => {
|
||||||
|
writeWorktree(source, "local-clawhub", { adminKey: "source-admin-key" });
|
||||||
|
mkdirSync(join(current, ".convex/local/default"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(current, ".convex/local/default/config.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
adminKey: "stale-admin-key",
|
||||||
|
deploymentName: "local-clawhub",
|
||||||
|
ports: { cloud: 3210, site: 3211 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
setupWorktree({
|
||||||
|
cwd: current,
|
||||||
|
options: {
|
||||||
|
force: false,
|
||||||
|
from: source,
|
||||||
|
quiet: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toThrow(".convex already exists as a regular local path");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("honors an explicit source even when the current worktree is already configured", () => {
|
it("honors an explicit source even when the current worktree is already configured", () => {
|
||||||
const root = mkdtempSync(join(tmpdir(), "clawhub-worktree-"));
|
const root = mkdtempSync(join(tmpdir(), "clawhub-worktree-"));
|
||||||
try {
|
try {
|
||||||
@@ -121,6 +384,23 @@ describe("setup-worktree", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects local sources without the Convex function URL used by dev startup", () => {
|
||||||
|
withSourceAndCurrent((source, current) => {
|
||||||
|
writeWorktree(source, "local-clawhub", { env: { VITE_CONVEX_URL: null } });
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
findSource(
|
||||||
|
{
|
||||||
|
force: false,
|
||||||
|
from: source,
|
||||||
|
quiet: true,
|
||||||
|
},
|
||||||
|
current,
|
||||||
|
),
|
||||||
|
).toThrow("VITE_CONVEX_URL is required for local Convex");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects local site URLs without an explicit site proxy port", () => {
|
it("rejects local site URLs without an explicit site proxy port", () => {
|
||||||
withSourceAndCurrent((source, current) => {
|
withSourceAndCurrent((source, current) => {
|
||||||
writeWorktree(source, "local-clawhub", {
|
writeWorktree(source, "local-clawhub", {
|
||||||
|
|||||||
+117
-16
@@ -6,16 +6,34 @@ import { basename, resolve } from "node:path";
|
|||||||
type Options = {
|
type Options = {
|
||||||
from: string | null;
|
from: string | null;
|
||||||
force: boolean;
|
force: boolean;
|
||||||
|
preferFallback?: boolean;
|
||||||
quiet: boolean;
|
quiet: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Source = {
|
type Source = {
|
||||||
path: string;
|
path: string;
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
convexConfig: { deploymentName?: string; ports?: { cloud?: number; site?: number } } | null;
|
convexConfig: {
|
||||||
|
adminKey?: string;
|
||||||
|
deploymentName?: string;
|
||||||
|
ports?: { cloud?: number; site?: number };
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SetupResult = {
|
||||||
|
convexLinked: boolean;
|
||||||
|
envLinked: boolean;
|
||||||
|
mode: "local" | "fallback";
|
||||||
|
sourcePath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOCAL_CONVEX_CONFIG = ".convex/local/default/config.json";
|
const LOCAL_CONVEX_CONFIG = ".convex/local/default/config.json";
|
||||||
|
const REQUIRED_ENV_MATCH_KEYS = [
|
||||||
|
"CONVEX_DEPLOYMENT",
|
||||||
|
"VITE_CONVEX_URL",
|
||||||
|
"VITE_CONVEX_SITE_URL",
|
||||||
|
"CONVEX_SITE_URL",
|
||||||
|
] as const;
|
||||||
|
|
||||||
function parseArgs(argv: string[]): Options {
|
function parseArgs(argv: string[]): Options {
|
||||||
const options: Options = {
|
const options: Options = {
|
||||||
@@ -33,6 +51,8 @@ function parseArgs(argv: string[]): Options {
|
|||||||
options.from = arg.slice("--from=".length);
|
options.from = arg.slice("--from=".length);
|
||||||
} else if (arg === "--force") {
|
} else if (arg === "--force") {
|
||||||
options.force = true;
|
options.force = true;
|
||||||
|
} else if (arg === "--prefer-fallback") {
|
||||||
|
options.preferFallback = true;
|
||||||
} else if (arg === "--quiet") {
|
} else if (arg === "--quiet") {
|
||||||
options.quiet = true;
|
options.quiet = true;
|
||||||
}
|
}
|
||||||
@@ -60,9 +80,9 @@ function parseEnv(text: string) {
|
|||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
function listGitWorktrees() {
|
function listGitWorktrees(cwd: string) {
|
||||||
const result = spawnSync("git", ["worktree", "list", "--porcelain"], {
|
const result = spawnSync("git", ["worktree", "list", "--porcelain"], {
|
||||||
cwd: process.cwd(),
|
cwd,
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
});
|
});
|
||||||
if (result.status !== 0 || typeof result.stdout !== "string") return [];
|
if (result.status !== 0 || typeof result.stdout !== "string") return [];
|
||||||
@@ -77,16 +97,18 @@ function readSource(path: string): Source | null {
|
|||||||
const envPath = resolve(path, ".env.local");
|
const envPath = resolve(path, ".env.local");
|
||||||
if (!existsSync(envPath)) return null;
|
if (!existsSync(envPath)) return null;
|
||||||
|
|
||||||
const configPath = resolve(path, LOCAL_CONVEX_CONFIG);
|
|
||||||
const convexConfig = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path,
|
path,
|
||||||
env: parseEnv(readFileSync(envPath, "utf8")),
|
env: parseEnv(readFileSync(envPath, "utf8")),
|
||||||
convexConfig,
|
convexConfig: readConvexConfig(resolve(path, ".convex")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readConvexConfig(convexPath: string) {
|
||||||
|
const configPath = resolve(convexPath, "local/default/config.json");
|
||||||
|
return existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : null;
|
||||||
|
}
|
||||||
|
|
||||||
function isLocalHost(hostname: string) {
|
function isLocalHost(hostname: string) {
|
||||||
return (
|
return (
|
||||||
hostname === "localhost" ||
|
hostname === "localhost" ||
|
||||||
@@ -131,6 +153,7 @@ function validateSource(source: Source) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const convexUrl = source.env.VITE_CONVEX_URL;
|
const convexUrl = source.env.VITE_CONVEX_URL;
|
||||||
|
if (!convexUrl) return "VITE_CONVEX_URL is required for local Convex";
|
||||||
const configPort = source.convexConfig.ports?.cloud;
|
const configPort = source.convexConfig.ports?.cloud;
|
||||||
if (convexUrl && configPort) {
|
if (convexUrl && configPort) {
|
||||||
try {
|
try {
|
||||||
@@ -166,14 +189,14 @@ function validateSource(source: Source) {
|
|||||||
|
|
||||||
export function findSource(options: Options, cwd = process.cwd()) {
|
export function findSource(options: Options, cwd = process.cwd()) {
|
||||||
const currentPath = resolve(cwd);
|
const currentPath = resolve(cwd);
|
||||||
if (!options.from) {
|
if (!options.from && !options.preferFallback) {
|
||||||
const current = readSource(currentPath);
|
const current = readSource(currentPath);
|
||||||
if (current && !validateSource(current)) return current;
|
if (current && !validateSource(current)) return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = options.from
|
const candidates = options.from
|
||||||
? [resolve(options.from)]
|
? [resolve(options.from)]
|
||||||
: listGitWorktrees().filter((worktree) => worktree !== currentPath);
|
: listGitWorktrees(cwd).filter((worktree) => worktree !== currentPath);
|
||||||
|
|
||||||
const rejected: string[] = [];
|
const rejected: string[] = [];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
@@ -184,6 +207,11 @@ export function findSource(options: Options, cwd = process.cwd()) {
|
|||||||
rejected.push(`${candidate}: ${invalid}`);
|
rejected.push(`${candidate}: ${invalid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!options.from && options.preferFallback) {
|
||||||
|
const current = readSource(currentPath);
|
||||||
|
if (current && !validateSource(current)) return current;
|
||||||
|
}
|
||||||
|
|
||||||
const suffix = rejected.length ? `\nRejected sources:\n- ${rejected.join("\n- ")}` : "";
|
const suffix = rejected.length ? `\nRejected sources:\n- ${rejected.join("\n- ")}` : "";
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Could not find a usable worktree source with .env.local and matching Convex local config.${suffix}`,
|
`Could not find a usable worktree source with .env.local and matching Convex local config.${suffix}`,
|
||||||
@@ -195,10 +223,48 @@ function replaceableLocal(path: string) {
|
|||||||
return lstatSync(path).isSymbolicLink();
|
return lstatSync(path).isSymbolicLink();
|
||||||
}
|
}
|
||||||
|
|
||||||
function linkFromSource(name: string, sourcePath: string, force: boolean) {
|
function existingEnvMatchesSource(target: string, source: Source) {
|
||||||
const target = resolve(process.cwd(), name);
|
if (!existsSync(target)) return false;
|
||||||
|
const targetEnv = parseEnv(readFileSync(target, "utf8"));
|
||||||
|
for (const key of REQUIRED_ENV_MATCH_KEYS) {
|
||||||
|
if (targetEnv[key] !== source.env[key]) return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
validateSource({
|
||||||
|
path: resolve(target, ".."),
|
||||||
|
env: targetEnv,
|
||||||
|
convexConfig: source.convexConfig,
|
||||||
|
}) === null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function existingConvexMatchesSource(target: string, source: Source) {
|
||||||
|
if (!existsSync(target)) return false;
|
||||||
|
const targetConfig = readConvexConfig(target);
|
||||||
|
if (!source.convexConfig) return targetConfig === null;
|
||||||
|
if (!targetConfig) return false;
|
||||||
|
return (
|
||||||
|
targetConfig.deploymentName === source.convexConfig.deploymentName &&
|
||||||
|
targetConfig.adminKey === source.convexConfig.adminKey &&
|
||||||
|
targetConfig.ports?.cloud === source.convexConfig.ports?.cloud &&
|
||||||
|
targetConfig.ports?.site === source.convexConfig.ports?.site
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkFromSource(
|
||||||
|
name: string,
|
||||||
|
source: Source,
|
||||||
|
sourcePath: string,
|
||||||
|
force: boolean,
|
||||||
|
cwd: string,
|
||||||
|
) {
|
||||||
|
const target = resolve(cwd, name);
|
||||||
if (resolve(sourcePath) === target) return false;
|
if (resolve(sourcePath) === target) return false;
|
||||||
if (existsSync(target)) {
|
if (existsSync(target)) {
|
||||||
|
if (!force && !lstatSync(target).isSymbolicLink()) {
|
||||||
|
if (name === ".env.local" && existingEnvMatchesSource(target, source)) return false;
|
||||||
|
if (name === ".convex" && existingConvexMatchesSource(target, source)) return false;
|
||||||
|
}
|
||||||
if (!force && !replaceableLocal(target)) {
|
if (!force && !replaceableLocal(target)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${name} already exists as a regular local path. Move it aside or rerun setup with --force.`,
|
`${name} already exists as a regular local path. Move it aside or rerun setup with --force.`,
|
||||||
@@ -210,15 +276,50 @@ function linkFromSource(name: string, sourcePath: string, force: boolean) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setupWorktree({ cwd, options }: { cwd: string; options: Options }): SetupResult {
|
||||||
|
const current = readSource(cwd);
|
||||||
|
const source = findSource(options, cwd);
|
||||||
|
const mode = current && source.path === current.path ? "local" : "fallback";
|
||||||
|
|
||||||
|
const envLinked = linkFromSource(
|
||||||
|
".env.local",
|
||||||
|
source,
|
||||||
|
resolve(source.path, ".env.local"),
|
||||||
|
options.force,
|
||||||
|
cwd,
|
||||||
|
);
|
||||||
|
const convexLinked = linkFromSource(
|
||||||
|
".convex",
|
||||||
|
source,
|
||||||
|
resolve(source.path, ".convex"),
|
||||||
|
options.force,
|
||||||
|
cwd,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
convexLinked,
|
||||||
|
envLinked,
|
||||||
|
mode,
|
||||||
|
sourcePath: source.path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeSetupResult(result: SetupResult) {
|
||||||
|
if (result.mode === "local") {
|
||||||
|
return "validated copied local .env.local and .convex";
|
||||||
|
}
|
||||||
|
|
||||||
|
const envState = result.envLinked ? "linked" : "existing";
|
||||||
|
const convexState = result.convexLinked ? "linked" : "existing";
|
||||||
|
return `fallback source ${result.sourcePath} (env: ${envState}, convex: ${convexState})`;
|
||||||
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const options = parseArgs(process.argv.slice(2));
|
const options = parseArgs(process.argv.slice(2));
|
||||||
const source = findSource(options);
|
const result = setupWorktree({ cwd: process.cwd(), options });
|
||||||
|
|
||||||
linkFromSource(".env.local", resolve(source.path, ".env.local"), options.force);
|
|
||||||
linkFromSource(".convex", resolve(source.path, ".convex"), options.force);
|
|
||||||
|
|
||||||
if (!options.quiet) {
|
if (!options.quiet) {
|
||||||
console.log(`Worktree env setup complete using ${source.path}`);
|
console.log(`Worktree env setup complete: ${describeSetupResult(result)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-9
@@ -19,15 +19,15 @@ The source of truth for the worktree lifecycle is:
|
|||||||
|
|
||||||
- `package.json` scripts for public entrypoints.
|
- `package.json` scripts for public entrypoints.
|
||||||
- `.config/wt.toml` for Worktrunk hooks, branch-hashed URLs, detached startup, and stop cleanup.
|
- `.config/wt.toml` for Worktrunk hooks, branch-hashed URLs, detached startup, and stop cleanup.
|
||||||
- `.worktreeinclude` for ignored assets Worktrunk should copy when possible.
|
- `.worktreeinclude` for ignored local assets Codex-managed worktrees should copy at creation time. Worktrunk also uses it through `wt step copy-ignored` when possible.
|
||||||
- `scripts/setup-worktree.ts` for ignored local state discovery and symlinking.
|
- `scripts/setup-worktree.ts` for copied local state validation and fallback symlinking.
|
||||||
- `scripts/dev-worktree.ts` for detached app startup, local Convex reachability, and seeding.
|
- `scripts/dev-worktree.ts` for detached app startup, local Convex reachability, and seeding.
|
||||||
|
|
||||||
`.codex/environments/environment.toml` is Codex app configuration. It can expose convenient actions, but it is not the source of truth for the developer workflow. Update it only when the corresponding package script or worktree contract changes.
|
`.codex/environments/environment.toml` is Codex app configuration. It can expose convenient actions, but it is not the source of truth for the developer workflow. Update it only when the corresponding package script or worktree contract changes.
|
||||||
|
|
||||||
## Environment Contract
|
## Environment Contract
|
||||||
|
|
||||||
`setup:worktree` must link `.env.local` and `.convex` from a coherent source worktree into the current worktree. A source is coherent when it has `.env.local` and, for `local:` Convex deployments, a matching `.convex/local/default/config.json`.
|
Fresh Codex-managed worktrees should receive `.env.local`, `.convex/`, and `node_modules/` through `.worktreeinclude`. `setup:worktree` must then validate the copied `.env.local` and `.convex` state. If copied state is missing or incomplete, it may link missing state from a coherent source worktree as a fallback. A source is coherent when it has `.env.local` and, for `local:` Convex deployments, a matching `.convex/local/default/config.json`.
|
||||||
|
|
||||||
When auto-discovery picks the wrong source, contributors should pass an explicit source:
|
When auto-discovery picks the wrong source, contributors should pass an explicit source:
|
||||||
|
|
||||||
@@ -36,25 +36,27 @@ bun run setup:worktree -- --from /path/to/source/worktree
|
|||||||
CLAWHUB_WORKTREE_SOURCE=/path/to/source/worktree bun run setup:worktree
|
CLAWHUB_WORKTREE_SOURCE=/path/to/source/worktree bun run setup:worktree
|
||||||
```
|
```
|
||||||
|
|
||||||
The setup helper validates common local Convex mistakes before linking:
|
Manual setup refuses to overwrite regular local `.env.local` or `.convex/` paths unless they already match the chosen source. Use `--force` only for automated repair paths or when intentionally replacing copied stale state with links to the selected source. Use `--prefer-fallback` only for automated setup after an ignored-file copy, where a copied but stale current worktree should not win over a coherent fallback source.
|
||||||
|
|
||||||
|
The setup helper validates common local Convex mistakes before accepting copied state or linking fallback state:
|
||||||
|
|
||||||
- missing `CONVEX_DEPLOYMENT`
|
- missing `CONVEX_DEPLOYMENT`
|
||||||
- local deployment name mismatch
|
- local deployment name mismatch
|
||||||
- `VITE_CONVEX_URL` port mismatch
|
- missing `VITE_CONVEX_URL` or local function port mismatch
|
||||||
- `VITE_CONVEX_SITE_URL` or `CONVEX_SITE_URL` missing or pointing at the wrong local site port
|
- `VITE_CONVEX_SITE_URL` or `CONVEX_SITE_URL` missing or pointing at the wrong local site port
|
||||||
|
|
||||||
## Worktrunk Contract
|
## Worktrunk Contract
|
||||||
|
|
||||||
`bun run dev:worktree` requires the `wt` executable on `PATH`. The current repo contract treats Worktrunk as mandatory for the detached worktree path and keeps the non-Worktrunk fallback as the manual path.
|
`bun run dev:worktree` requires the `wt` executable on `PATH`. The current repo contract treats Worktrunk as mandatory for the detached worktree path and keeps the non-Worktrunk fallback as the manual path.
|
||||||
|
|
||||||
Worktrunk runs the configured pre-start hooks before starting the detached server:
|
Worktrunk runs the configured pre-start hooks before starting the detached server. It should copy ignored files before setup validation:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
bun run setup:worktree -- --quiet
|
wt step copy-ignored || true; bun run setup:worktree -- --quiet --force --prefer-fallback
|
||||||
wt step copy-ignored || true; test -x node_modules/.bin/vite || bun install
|
test -x node_modules/.bin/vite || bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
The copy step is best effort. If `.convex` is already a symlink to the source worktree, Worktrunk may report that it refused to copy `.convex` outside the destination worktree. That is acceptable as long as `setup:worktree` linked `.convex` and `.env.local`, and dependencies are present.
|
The copy step is best effort. Codex-managed worktrees copy ignored files when they are created, and Worktrunk-created or older worktrees may still need the setup fallback. The follow-up setup step uses `--force --prefer-fallback` because a Worktrunk copy can materialize stale regular `.env.local` / `.convex/` paths before setup selects a coherent fallback source. If `.convex` is already a symlink to the source worktree, Worktrunk may report that it refused to copy `.convex` outside the destination worktree. That is acceptable as long as `setup:worktree` validates or links `.convex` and `.env.local`, and dependencies are present.
|
||||||
|
|
||||||
## Runtime Contract
|
## Runtime Contract
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user