mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
Merge master into canonical Runner D delivery
Resolve the migration sequence against current master while preserving the audited Runner D data migration. Type the native backend factory mock so the merged TypeScript checks retain its two-argument contract. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
83a1c438e0
227 files changed
+64329
-3014
No files matched your search
@@ -37,6 +37,9 @@ test.after(() => {
|
||||
*/
|
||||
function makeBaseWorkspace({ helpExit, initExit, ensureExit = 0 }) {
|
||||
const baseCwd = makeTempDir("paperclip-provision-base-");
|
||||
fs.mkdirSync(path.join(baseCwd, ".paperclip"), { recursive: true });
|
||||
fs.writeFileSync(path.join(baseCwd, ".paperclip", "config.json"), "{}\n");
|
||||
fs.writeFileSync(path.join(baseCwd, ".paperclip", ".env"), "PAPERCLIP_INSTANCE_ID=base-source\n");
|
||||
const runnerPath = path.join(baseCwd, "cli", "node_modules", "tsx", "dist", "cli.mjs");
|
||||
const entryPath = path.join(baseCwd, "cli", "src", "index.ts");
|
||||
fs.mkdirSync(path.dirname(runnerPath), { recursive: true });
|
||||
@@ -68,7 +71,21 @@ if (cliArgs[0] === "worktree" && cliArgs[1] === "ensure-seeded") {
|
||||
process.exit(${ensureExit});
|
||||
}
|
||||
fs.rmSync(".paperclip/seed-pending", { force: true });
|
||||
fs.writeFileSync(".paperclip/seed-complete", "{}\\n");
|
||||
fs.rmSync(".paperclip/seed-complete", { force: true });
|
||||
fs.writeFileSync(".paperclip/seed-manifest.json", JSON.stringify({
|
||||
version: 2,
|
||||
source: { instanceId: "base-source", configPath: ${JSON.stringify(path.join(baseCwd, ".paperclip", "config.json"))} },
|
||||
snapshotAt: "2026-08-19T00:00:00.000Z",
|
||||
seedMode: "minimal",
|
||||
migrationRevision: "0142_test.sql",
|
||||
targetInstanceId: "target-test",
|
||||
phase: "complete",
|
||||
state: "verified",
|
||||
attemptId: "attempt-test",
|
||||
startedAt: "2026-08-19T00:00:00.000Z",
|
||||
finishedAt: "2026-08-19T00:01:00.000Z",
|
||||
diagnostics: [{ phase: "complete", status: "succeeded", at: "2026-08-19T00:01:00.000Z" }],
|
||||
}) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(0);
|
||||
@@ -91,6 +108,8 @@ function runProvision(baseCwd, { pathPrefix } = {}) {
|
||||
PAPERCLIP_WORKSPACE_BRANCH: "feature/provision-test",
|
||||
PAPERCLIP_WORKTREES_DIR: worktreesHome,
|
||||
PAPERCLIP_HOME: path.join(worktreesHome, "no-such-instance-home"),
|
||||
PAPERCLIP_PROJECT_WORKSPACE_ID: "project-workspace-1",
|
||||
PAPERCLIP_SEED_EXPECTED_COMPANY_ID: "company-1",
|
||||
},
|
||||
});
|
||||
return { result, worktreeCwd, worktreesHome };
|
||||
@@ -109,6 +128,8 @@ function runRuntimeProvision(baseCwd, worktreeCwd) {
|
||||
PAPERCLIP_WORKSPACE_BRANCH: "feature/provision-runtime-test",
|
||||
PAPERCLIP_WORKTREES_DIR: worktreesHome,
|
||||
PAPERCLIP_HOME: path.join(worktreesHome, "no-such-instance-home"),
|
||||
PAPERCLIP_PROJECT_WORKSPACE_ID: "project-workspace-1",
|
||||
PAPERCLIP_COMPANY_ID: "company-1",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -137,7 +158,10 @@ test("uses the base CLI when its import graph boots", () => {
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const config = readWorktreeConfig(worktreeCwd);
|
||||
assert.equal(config.$meta.source, "fake-cli");
|
||||
assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending")));
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state,
|
||||
"pending",
|
||||
);
|
||||
const initInvocation = readCliInvocations(baseCwd).find(
|
||||
(args) => args[0] === "worktree" && args[1] === "init",
|
||||
);
|
||||
@@ -166,7 +190,49 @@ test("falls back to an isolated config when the base CLI cannot boot", () => {
|
||||
);
|
||||
const env = fs.readFileSync(path.join(worktreeCwd, ".paperclip", ".env"), "utf8");
|
||||
assert.match(env, /PAPERCLIP_IN_WORKTREE=true/);
|
||||
assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending")));
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state,
|
||||
"pending",
|
||||
);
|
||||
});
|
||||
|
||||
test("reconciles deployment mode from the registered source when reusing a guest config", () => {
|
||||
const baseCwd = makeBaseWorkspace({ helpExit: 1, initExit: 0 });
|
||||
const { result: first, worktreeCwd, worktreesHome } = runProvision(baseCwd);
|
||||
assert.equal(first.status, 0, first.stderr);
|
||||
assert.equal(readWorktreeConfig(worktreeCwd).server.deploymentMode, "local_trusted");
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(baseCwd, ".paperclip", "config.json"),
|
||||
`${JSON.stringify({
|
||||
server: {
|
||||
deploymentMode: "authenticated",
|
||||
exposure: "private",
|
||||
},
|
||||
}, null, 2)}\n`,
|
||||
);
|
||||
|
||||
const second = spawnSync("bash", [script], {
|
||||
cwd: worktreeCwd,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
PATH: testPath,
|
||||
HOME: os.homedir(),
|
||||
PAPERCLIP_WORKSPACE_BASE_CWD: baseCwd,
|
||||
PAPERCLIP_WORKSPACE_CWD: worktreeCwd,
|
||||
PAPERCLIP_WORKSPACE_BRANCH: "feature/provision-test",
|
||||
PAPERCLIP_WORKTREES_DIR: worktreesHome,
|
||||
PAPERCLIP_HOME: path.join(worktreesHome, "no-such-instance-home"),
|
||||
PAPERCLIP_PROJECT_WORKSPACE_ID: "project-workspace-1",
|
||||
PAPERCLIP_SEED_EXPECTED_COMPANY_ID: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(second.status, 0, second.stderr);
|
||||
assert.match(second.stderr, /Reusing existing isolated Paperclip worktree config/);
|
||||
assert.match(second.stderr, /Reconciled isolated Paperclip worktree deployment mode/);
|
||||
assert.equal(readWorktreeConfig(worktreeCwd).server.deploymentMode, "authenticated");
|
||||
assert.equal(readWorktreeConfig(worktreeCwd).server.exposure, "private");
|
||||
});
|
||||
|
||||
test("repairs an unhealthy base install under the lock and then uses the CLI", (t) => {
|
||||
@@ -181,6 +247,9 @@ test("repairs an unhealthy base install under the lock and then uses the CLI", (
|
||||
// The CLI's health is controlled by a flag file, and a fake `pnpm install`
|
||||
// creates that flag — modeling a forced reinstall that relinks the store.
|
||||
const baseCwd = makeTempDir("paperclip-provision-repair-base-");
|
||||
fs.mkdirSync(path.join(baseCwd, ".paperclip"), { recursive: true });
|
||||
fs.writeFileSync(path.join(baseCwd, ".paperclip", "config.json"), "{}\n");
|
||||
fs.writeFileSync(path.join(baseCwd, ".paperclip", ".env"), "PAPERCLIP_INSTANCE_ID=base-source\n");
|
||||
const healthFlag = path.join(baseCwd, "cli-healthy.flag");
|
||||
const runnerPath = path.join(baseCwd, "cli", "node_modules", "tsx", "dist", "cli.mjs");
|
||||
const entryPath = path.join(baseCwd, "cli", "src", "index.ts");
|
||||
@@ -260,23 +329,72 @@ test("runtime provisioning invokes ensure-seeded once and fast-exits after succe
|
||||
|
||||
const first = runRuntimeProvision(baseCwd, worktreeCwd);
|
||||
assert.equal(first.status, 0, first.stderr);
|
||||
assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-complete")));
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state,
|
||||
"verified",
|
||||
);
|
||||
assert.ok(!fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending")));
|
||||
|
||||
const ensureCallsAfterFirst = readCliInvocations(baseCwd)
|
||||
.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded");
|
||||
assert.equal(ensureCallsAfterFirst.length, 1);
|
||||
assert.ok(ensureCallsAfterFirst[0].includes("--config"));
|
||||
assert.ok(ensureCallsAfterFirst[0].includes("--from-config"));
|
||||
assert.ok(!ensureCallsAfterFirst[0].includes("--from-config"));
|
||||
|
||||
const second = runRuntimeProvision(baseCwd, worktreeCwd);
|
||||
assert.equal(second.status, 0, second.stderr);
|
||||
assert.match(second.stderr, /already seeded; skipping/);
|
||||
assert.match(second.stderr, /verified seed manifest.*skipping/);
|
||||
const ensureCallsAfterSecond = readCliInvocations(baseCwd)
|
||||
.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded");
|
||||
assert.equal(ensureCallsAfterSecond.length, 1);
|
||||
});
|
||||
|
||||
test("runtime provisioning seeds a worktree config that has no seed markers", () => {
|
||||
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
|
||||
const worktreeCwd = makeTempDir("paperclip-provision-runtime-unmarked-config-");
|
||||
fs.mkdirSync(path.join(worktreeCwd, ".paperclip"), { recursive: true });
|
||||
fs.writeFileSync(path.join(worktreeCwd, ".paperclip", "config.json"), "{}\n");
|
||||
|
||||
const result = runRuntimeProvision(baseCwd, worktreeCwd);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(
|
||||
readCliInvocations(baseCwd)
|
||||
.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded").length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state,
|
||||
"verified",
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime provisioning bootstraps and seeds an empty .paperclip directory", () => {
|
||||
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
|
||||
fs.mkdirSync(path.join(baseCwd, "scripts"), { recursive: true });
|
||||
fs.copyFileSync(script, path.join(baseCwd, "scripts", "provision-worktree.sh"));
|
||||
const worktreeCwd = makeTempDir("paperclip-provision-runtime-empty-state-");
|
||||
fs.mkdirSync(path.join(worktreeCwd, ".paperclip"), { recursive: true });
|
||||
|
||||
const result = runRuntimeProvision(baseCwd, worktreeCwd);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stderr, /config is missing; running the built-in worktree provisioner/);
|
||||
const invocations = readCliInvocations(baseCwd);
|
||||
assert.equal(
|
||||
invocations.filter((args) => args[0] === "worktree" && args[1] === "init").length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
invocations.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded").length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state,
|
||||
"verified",
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime provisioning leaves seed-pending in place when ensure-seeded fails", () => {
|
||||
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0, ensureExit: 4 });
|
||||
const worktreeCwd = makeTempDir("paperclip-provision-runtime-failure-");
|
||||
@@ -290,3 +408,22 @@ test("runtime provisioning leaves seed-pending in place when ensure-seeded fails
|
||||
assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending")));
|
||||
assert.ok(!fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-complete")));
|
||||
});
|
||||
|
||||
test("runtime provisioning does not trust a truncated verified manifest", () => {
|
||||
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0, ensureExit: 4 });
|
||||
const worktreeCwd = makeTempDir("paperclip-provision-runtime-truncated-");
|
||||
fs.mkdirSync(path.join(worktreeCwd, ".paperclip"), { recursive: true });
|
||||
fs.writeFileSync(path.join(worktreeCwd, ".paperclip", "config.json"), "{}\n");
|
||||
fs.writeFileSync(
|
||||
path.join(worktreeCwd, ".paperclip", "seed-manifest.json"),
|
||||
JSON.stringify({ version: 2, state: "verified" }),
|
||||
);
|
||||
|
||||
const result = runRuntimeProvision(baseCwd, worktreeCwd);
|
||||
assert.equal(result.status, 4, result.stderr);
|
||||
assert.equal(
|
||||
readCliInvocations(baseCwd)
|
||||
.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded").length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
@@ -70,28 +70,54 @@ if ! git -C "$repo_dir" rev-parse --verify "refs/tags/${beta_tag}" >/dev/null 2>
|
||||
release_fail "beta tag ${beta_tag} does not exist. Draft notes are generated from a published beta."
|
||||
fi
|
||||
source_sha="$(git -C "$repo_dir" rev-parse "${beta_tag}^{commit}")"
|
||||
source_short="$(git -C "$repo_dir" rev-parse --short "$source_sha")"
|
||||
|
||||
if [ -z "$out_file" ]; then
|
||||
out_file="${repo_dir}/releases/beta/v${beta_version}.md"
|
||||
fi
|
||||
|
||||
# Range start: the newest stable tag reachable from the source commit —
|
||||
# not the newest by version, which can sit on a divergent lineage (a
|
||||
# stable cut from a candidate branch, or a source that predates it) and
|
||||
# would produce an empty or wrong range. Before the first reachable
|
||||
# stable, fall back to the nearest beta tag strictly before the source;
|
||||
# with no marker at all, cover the source commit's full history.
|
||||
range_start="$(git -C "$repo_dir" describe --tags --match 'v[0-9]*' --abbrev=0 "$source_sha" 2>/dev/null || true)"
|
||||
range_label="$range_start"
|
||||
# Range start: walk stable tags newest-first and take the first whose
|
||||
# merge-base with the source is a proper ancestor of the source. A stable
|
||||
# cut from a candidate branch is not itself an ancestor of master, but
|
||||
# its merge-base with the source is the promoted source commit — exactly
|
||||
# the point the shipped stable's content diverges from. A stable that
|
||||
# already contains the source (promoting an older commit) is skipped so
|
||||
# the range never collapses to empty. Before the first stable, fall back
|
||||
# to the nearest beta tag strictly before the source; with no marker at
|
||||
# all, cover the source commit's full history.
|
||||
range_start=""
|
||||
range_label=""
|
||||
range_cmd_start=""
|
||||
while IFS= read -r stable_tag; do
|
||||
[ -n "$stable_tag" ] || continue
|
||||
mb="$(git -C "$repo_dir" merge-base "$stable_tag" "$source_sha" 2>/dev/null || true)"
|
||||
[ -n "$mb" ] || continue
|
||||
if [ "$mb" != "$source_sha" ]; then
|
||||
range_start="$mb"
|
||||
if [ "$mb" = "$(git -C "$repo_dir" rev-parse "${stable_tag}^{commit}")" ]; then
|
||||
range_label="$stable_tag"
|
||||
range_cmd_start="$stable_tag"
|
||||
else
|
||||
# rev-parse --short picks an abbreviation that is unambiguous in
|
||||
# this repository, unlike a fixed nine-character truncation.
|
||||
mb_short="$(git -C "$repo_dir" rev-parse --short "$mb")"
|
||||
range_label="${stable_tag} (merge-base ${mb_short})"
|
||||
range_cmd_start="$mb_short"
|
||||
fi
|
||||
break
|
||||
fi
|
||||
done < <(git -C "$repo_dir" tag -l 'v[0-9]*' --sort=-v:refname)
|
||||
if [ -z "$range_start" ]; then
|
||||
range_start="$(git -C "$repo_dir" describe --tags --match 'beta/v*' --abbrev=0 "${source_sha}^" 2>/dev/null || true)"
|
||||
range_label="$range_start"
|
||||
range_cmd_start="$range_start"
|
||||
fi
|
||||
if [ -n "$range_start" ]; then
|
||||
range="${range_start}..${source_sha}"
|
||||
else
|
||||
range="$source_sha"
|
||||
range_label="the beginning of history"
|
||||
range_cmd_start="the beginning of history"
|
||||
release_info "No stable or prior beta tag found; drafting from full history."
|
||||
fi
|
||||
|
||||
@@ -114,7 +140,7 @@ conventional='^(feat|fix)(\([^)]*\))?!?: '
|
||||
mkdir -p "$(dirname "$out_file")"
|
||||
{
|
||||
printf '# Paperclip stable draft — from beta %s\n\n' "$beta_version"
|
||||
printf '> Auto-generated at beta publish from `git log %s..%s`.\n' "${range_label}" "${source_sha:0:9}"
|
||||
printf '> Auto-generated at beta publish from `git log %s..%s` (baseline: %s).\n' "${range_cmd_start}" "${source_short}" "${range_label}"
|
||||
printf '> Edit freely during the soak: rewrite for release-notes voice,\n'
|
||||
printf '> fold noise, and call out anything a self-hoster must act on.\n'
|
||||
printf '> The stable promotion reads this file from master and publishes\n'
|
||||
|
||||
@@ -79,6 +79,26 @@ test("uses the nearest ancestor stable tag, not the newest by version", () => {
|
||||
assert.doesNotMatch(body, /new work/);
|
||||
});
|
||||
|
||||
test("starts from the merge-base when the last stable was cut from a candidate branch", () => {
|
||||
const dir = makeFixtureRepo();
|
||||
commit(dir, "feat: shipped in the stable (#1)");
|
||||
git(dir, "checkout", "-q", "-b", "candidate");
|
||||
commit(dir, "docs: release notes only (#2)");
|
||||
git(dir, "tag", "v2026.200.0");
|
||||
git(dir, "checkout", "-q", "master");
|
||||
commit(dir, "feat: next-release work (#3)");
|
||||
git(dir, "tag", "beta/v2026.201.0-beta.0");
|
||||
|
||||
// The stable tag sits on the unmerged candidate branch, so it is not an
|
||||
// ancestor of the beta source. The range must start at its merge-base
|
||||
// with the source (the promoted commit), not fall back past it.
|
||||
const { body } = runDraft(dir, "2026.201.0-beta.0");
|
||||
|
||||
assert.match(body, /- feat: next-release work \(#3\)/);
|
||||
assert.doesNotMatch(body, /shipped in the stable/);
|
||||
assert.doesNotMatch(body, /release notes only/);
|
||||
});
|
||||
|
||||
test("falls back to the previous beta tag when no stable tag exists", () => {
|
||||
const dir = makeFixtureRepo();
|
||||
commit(dir, "feat: first-train work (#1)");
|
||||
|
||||
@@ -3,12 +3,9 @@ set -euo pipefail
|
||||
|
||||
base_cwd="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is required}"
|
||||
worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}"
|
||||
paperclip_home="${PAPERCLIP_HOME:-$HOME/.paperclip}"
|
||||
paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}"
|
||||
paperclip_dir="$worktree_cwd/.paperclip"
|
||||
worktree_config_path="$paperclip_dir/config.json"
|
||||
seed_pending_marker_path="$paperclip_dir/seed-pending"
|
||||
seed_complete_marker_path="$paperclip_dir/seed-complete"
|
||||
seed_manifest_path="$paperclip_dir/seed-manifest.json"
|
||||
|
||||
if [[ ! -d "$base_cwd" ]]; then
|
||||
echo "Base workspace does not exist: $base_cwd" >&2
|
||||
@@ -20,30 +17,59 @@ if [[ ! -d "$worktree_cwd" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -e "$seed_complete_marker_path" || ! -e "$seed_pending_marker_path" ]]; then
|
||||
echo "Worktree database is already seeded; skipping runtime provisioning." >&2
|
||||
exit 0
|
||||
if [[ -e "$seed_manifest_path" ]]; then
|
||||
seed_manifest_state="$(SEED_MANIFEST_PATH="$seed_manifest_path" node <<'EOF'
|
||||
const fs = require("node:fs");
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(process.env.SEED_MANIFEST_PATH, "utf8"));
|
||||
const complete = value?.version === 2
|
||||
&& value?.state === "verified"
|
||||
&& value?.phase === "complete"
|
||||
&& typeof value?.source?.instanceId === "string" && value.source.instanceId.length > 0
|
||||
&& typeof value?.source?.configPath === "string" && value.source.configPath.length > 0
|
||||
&& (value?.seedMode === "minimal" || value?.seedMode === "full")
|
||||
&& typeof value?.snapshotAt === "string" && value.snapshotAt.length > 0
|
||||
&& typeof value?.migrationRevision === "string" && value.migrationRevision.length > 0
|
||||
&& typeof value?.targetInstanceId === "string" && value.targetInstanceId.length > 0
|
||||
&& typeof value?.attemptId === "string" && value.attemptId.length > 0
|
||||
&& typeof value?.startedAt === "string"
|
||||
&& typeof value?.finishedAt === "string"
|
||||
&& Array.isArray(value?.diagnostics)
|
||||
&& value.diagnostics.some((entry) => entry?.phase === "complete" && entry?.status === "succeeded" && typeof entry?.at === "string");
|
||||
process.stdout.write(complete ? "verified" : "incomplete");
|
||||
} catch {
|
||||
process.stdout.write("invalid");
|
||||
}
|
||||
EOF
|
||||
)"
|
||||
if [[ "$seed_manifest_state" == "verified" ]]; then
|
||||
echo "Worktree database has a verified seed manifest; skipping runtime provisioning." >&2
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -f "$worktree_config_path" ]]; then
|
||||
echo "Worktree config does not exist: $worktree_config_path" >&2
|
||||
initial_provision_script="$base_cwd/scripts/provision-worktree.sh"
|
||||
if [[ ! -f "$initial_provision_script" ]]; then
|
||||
echo "Worktree config does not exist and the built-in provision script is unavailable: $worktree_config_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Worktree config is missing; running the built-in worktree provisioner before database seeding." >&2
|
||||
(
|
||||
cd "$worktree_cwd" &&
|
||||
bash "$initial_provision_script"
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ ! -f "$worktree_config_path" ]]; then
|
||||
echo "Worktree config still does not exist after built-in provisioning: $worktree_config_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source_config_path="${PAPERCLIP_CONFIG:-}"
|
||||
if [[ -z "$source_config_path" && ( -e "$base_cwd/.paperclip/config.json" || -L "$base_cwd/.paperclip/config.json" ) ]]; then
|
||||
source_config_path="$base_cwd/.paperclip/config.json"
|
||||
fi
|
||||
if [[ -z "$source_config_path" ]]; then
|
||||
source_config_path="$paperclip_home/instances/$paperclip_instance_id/config.json"
|
||||
fi
|
||||
source_config_args=(--from-config "$source_config_path")
|
||||
if [[ "$source_config_path" == "$worktree_config_path" ]]; then
|
||||
# A human may invoke this after sourcing `worktree env`, which points
|
||||
# PAPERCLIP_CONFIG at the target. In that case the CLI reads the original
|
||||
# source config from the seed-pending marker instead.
|
||||
source_config_args=()
|
||||
fi
|
||||
# The CLI derives the source from PAPERCLIP_WORKSPACE_BASE_CWD, which the
|
||||
# control plane injects from the registered project-workspace row. The seed
|
||||
# manifest is diagnostic evidence only and must never choose the clone source.
|
||||
source_config_args=()
|
||||
|
||||
base_cli_runner_path="$base_cwd/cli/node_modules/tsx/dist/cli.mjs"
|
||||
base_cli_entry_path="$base_cwd/cli/src/index.ts"
|
||||
|
||||
@@ -8,6 +8,7 @@ paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}"
|
||||
paperclip_dir="$worktree_cwd/.paperclip"
|
||||
worktree_config_path="$paperclip_dir/config.json"
|
||||
worktree_env_path="$paperclip_dir/.env"
|
||||
seed_manifest_path="$paperclip_dir/seed-manifest.json"
|
||||
seed_pending_marker_path="$paperclip_dir/seed-pending"
|
||||
seed_complete_marker_path="$paperclip_dir/seed-complete"
|
||||
worktree_name="${PAPERCLIP_WORKSPACE_BRANCH:-$(basename "$worktree_cwd")}"
|
||||
@@ -39,12 +40,16 @@ if [[ ! -d "$worktree_cwd" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source_config_path="${PAPERCLIP_CONFIG:-}"
|
||||
if [[ -z "$source_config_path" && ( -e "$base_cwd/.paperclip/config.json" || -L "$base_cwd/.paperclip/config.json" ) ]]; then
|
||||
source_config_path="$base_cwd/.paperclip/config.json"
|
||||
canonical_base_cwd="$(cd "$base_cwd" && pwd -P)"
|
||||
source_config_path="$canonical_base_cwd/.paperclip/config.json"
|
||||
if [[ ! -f "$source_config_path" || -L "$source_config_path" ]]; then
|
||||
echo "Registered base project workspace has no canonical Paperclip config: $source_config_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$source_config_path" ]]; then
|
||||
source_config_path="$paperclip_home/instances/$paperclip_instance_id/config.json"
|
||||
canonical_source_dir="$(cd "$(dirname "$source_config_path")" && pwd -P)"
|
||||
if [[ "$canonical_source_dir/config.json" != "$source_config_path" ]]; then
|
||||
echo "Registered base project workspace Paperclip config uses a symlink alias: $source_config_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
source_env_path="$(dirname "$source_config_path")/.env"
|
||||
|
||||
@@ -237,25 +242,93 @@ for (const rawValue of runtimePaths) {
|
||||
EOF
|
||||
}
|
||||
|
||||
write_seed_pending_marker() {
|
||||
SEED_PENDING_MARKER_PATH="$seed_pending_marker_path" \
|
||||
SEED_COMPLETE_MARKER_PATH="$seed_complete_marker_path" \
|
||||
reconcile_worktree_deployment_mode() {
|
||||
SOURCE_CONFIG_PATH="$source_config_path" \
|
||||
WORKTREE_CONFIG_PATH="$worktree_config_path" \
|
||||
node <<'EOF'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const sourceConfigPath = path.resolve(process.env.SOURCE_CONFIG_PATH);
|
||||
const worktreeConfigPath = path.resolve(process.env.WORKTREE_CONFIG_PATH);
|
||||
const sourceConfig = JSON.parse(fs.readFileSync(sourceConfigPath, "utf8"));
|
||||
const worktreeConfig = JSON.parse(fs.readFileSync(worktreeConfigPath, "utf8"));
|
||||
const deploymentMode = sourceConfig?.server?.deploymentMode ?? "local_trusted";
|
||||
if (deploymentMode !== "local_trusted" && deploymentMode !== "authenticated") {
|
||||
throw new Error(`Registered source has unsupported server.deploymentMode: ${deploymentMode}`);
|
||||
}
|
||||
const exposure = deploymentMode === "local_trusted"
|
||||
? "private"
|
||||
: (sourceConfig?.server?.exposure ?? "private");
|
||||
const currentServer = worktreeConfig?.server && typeof worktreeConfig.server === "object"
|
||||
? worktreeConfig.server
|
||||
: {};
|
||||
if (currentServer.deploymentMode === deploymentMode && currentServer.exposure === exposure) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
worktreeConfig.server = {
|
||||
...currentServer,
|
||||
deploymentMode,
|
||||
exposure,
|
||||
};
|
||||
if (worktreeConfig.$meta && typeof worktreeConfig.$meta === "object") {
|
||||
worktreeConfig.$meta.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
const temporaryPath = `${worktreeConfigPath}.deployment-mode-${process.pid}`;
|
||||
try {
|
||||
fs.writeFileSync(temporaryPath, `${JSON.stringify(worktreeConfig, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.renameSync(temporaryPath, worktreeConfigPath);
|
||||
} finally {
|
||||
fs.rmSync(temporaryPath, { force: true });
|
||||
}
|
||||
console.error(`Reconciled isolated Paperclip worktree deployment mode from ${sourceConfigPath}: ${deploymentMode}/${exposure}`);
|
||||
EOF
|
||||
}
|
||||
|
||||
write_seed_pending_manifest() {
|
||||
SEED_MANIFEST_PATH="$seed_manifest_path" \
|
||||
SEED_PENDING_MARKER_PATH="$seed_pending_marker_path" \
|
||||
SEED_COMPLETE_MARKER_PATH="$seed_complete_marker_path" \
|
||||
SOURCE_CONFIG_PATH="$source_config_path" \
|
||||
TARGET_INSTANCE_ID="$worktree_instance_id" \
|
||||
node <<'EOF'
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const manifestPath = process.env.SEED_MANIFEST_PATH;
|
||||
const pendingPath = process.env.SEED_PENDING_MARKER_PATH;
|
||||
const completePath = process.env.SEED_COMPLETE_MARKER_PATH;
|
||||
const sourceConfigPath = path.resolve(process.env.SOURCE_CONFIG_PATH);
|
||||
const sourceEnvPath = path.join(path.dirname(sourceConfigPath), ".env");
|
||||
let sourceInstanceId = path.basename(path.dirname(sourceConfigPath));
|
||||
if (fs.existsSync(sourceEnvPath)) {
|
||||
const match = fs.readFileSync(sourceEnvPath, "utf8").match(/^\s*(?:export\s+)?PAPERCLIP_INSTANCE_ID\s*=\s*["']?([^\s"'#]+)["']?/m);
|
||||
if (match?.[1]) sourceInstanceId = match[1];
|
||||
}
|
||||
fs.rmSync(completePath, { force: true });
|
||||
fs.rmSync(pendingPath, { force: true });
|
||||
const at = new Date().toISOString();
|
||||
fs.writeFileSync(
|
||||
pendingPath,
|
||||
manifestPath,
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
state: "pending",
|
||||
sourceConfigPath: path.resolve(process.env.SOURCE_CONFIG_PATH),
|
||||
version: 2,
|
||||
source: {
|
||||
instanceId: sourceInstanceId,
|
||||
configPath: sourceConfigPath,
|
||||
},
|
||||
snapshotAt: null,
|
||||
seedMode: "minimal",
|
||||
createdAt: new Date().toISOString(),
|
||||
migrationRevision: null,
|
||||
targetInstanceId: process.env.TARGET_INSTANCE_ID,
|
||||
phase: "pending",
|
||||
state: "pending",
|
||||
attemptId: crypto.randomUUID(),
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
diagnostics: [{ phase: "pending", status: "succeeded", at }],
|
||||
}, null, 2)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
@@ -551,8 +624,14 @@ else
|
||||
created_worktree_config=1
|
||||
fi
|
||||
|
||||
if [[ "$created_worktree_config" -eq 1 && ! -e "$seed_pending_marker_path" && ! -e "$seed_complete_marker_path" ]]; then
|
||||
write_seed_pending_marker
|
||||
# The target config can predate a deployment-mode change on the registered
|
||||
# source, and older/fallback CLI writers may default this field independently.
|
||||
# Reconcile it after either create or reuse so the final guest config always
|
||||
# carries the source's deployment/auth contract without replacing its database.
|
||||
reconcile_worktree_deployment_mode
|
||||
|
||||
if [[ "$created_worktree_config" -eq 1 && ! -e "$seed_manifest_path" && ! -e "$seed_pending_marker_path" && ! -e "$seed_complete_marker_path" ]]; then
|
||||
write_seed_pending_manifest
|
||||
fi
|
||||
|
||||
list_base_node_modules_paths() {
|
||||
|
||||
@@ -73,6 +73,7 @@ const serializedServerVitestArgs = [
|
||||
"--no-file-parallelism",
|
||||
"--maxWorkers=1",
|
||||
];
|
||||
const sourceOnlyVitestArgs = ["--exclude", "**/dist/**"];
|
||||
|
||||
function walk(dir) {
|
||||
const entries = readdirSync(dir);
|
||||
@@ -283,7 +284,7 @@ function runVitest(args, label) {
|
||||
};
|
||||
mkdirSync(env.PAPERCLIP_HOME, { recursive: true });
|
||||
mkdirSync(env.TMPDIR, { recursive: true });
|
||||
const result = spawnSync("pnpm", ["exec", "vitest", "run", ...args], {
|
||||
const result = spawnSync("pnpm", ["exec", "vitest", "run", ...sourceOnlyVitestArgs, ...args], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
|
||||
Reference in new issue
Block a user