diff --git a/justfile b/justfile index 0ba88a6e7..9dd690ee7 100644 --- a/justfile +++ b/justfile @@ -64,8 +64,12 @@ sync-schema: # Install dependencies and build workspace packages. [unix] _setup-dev-deps: - pnpm install - cd sdk && pnpm build + ./scripts/ensure-dev-deps.sh --force + +# Repair only stale development dependencies and workspace package outputs. +[unix] +_ensure-dev-deps: + ./scripts/ensure-dev-deps.sh [unix] _install-lefthook: @@ -469,10 +473,11 @@ dev: #!/usr/bin/env bash set -euo pipefail - if [[ -n "${GOOSE_BIN:-}" ]]; then - just _setup-no-goose - else - GOOSE_BUILD_PROFILE=debug just setup + just _ensure-dev-deps + + if [[ -z "${GOOSE_BIN:-}" ]]; then + LOCAL_GOOSE_BIN="$(GOOSE_DEV_MODE=required GOOSE_BUILD_PROFILE=debug ./scripts/ensure-local-goose.sh --print-bin)" + export GOOSE_BIN="$LOCAL_GOOSE_BIN" fi VITE_PORT="$(python3 -c "import hashlib,os; h=int(hashlib.sha256(os.getcwd().encode()).hexdigest(),16); print(10000 + h % 55000)")" @@ -507,20 +512,7 @@ dev: ./scripts/prepare-bb-cli-resource.sh fi - if [[ -n "${GOOSE_BIN:-}" ]]; then - echo "Using explicitly set GOOSE_BIN: ${GOOSE_BIN}" - else - LOCAL_GOOSE_BIN="$(GOOSE_BUILD_PROFILE=debug ./scripts/ensure-local-goose.sh --check-bin)" || { - rc=$? - if [[ $rc -eq 2 ]]; then - echo "❌ Local goose binary is not ready. Run 'just setup' first." >&2 - exit 1 - fi - exit $rc - } - export GOOSE_BIN="$LOCAL_GOOSE_BIN" - echo "Using local goose binary: ${GOOSE_BIN}" - fi + echo "Using Goose binary: ${GOOSE_BIN}" DISTRO_DIR="$(pwd)/distro" if [[ -z "${GOOSE_DISTRO_DIR:-}" && -d "$DISTRO_DIR" ]]; then diff --git a/scripts/ensure-dev-deps.sh b/scripts/ensure-dev-deps.sh new file mode 100755 index 000000000..ee9364cdb --- /dev/null +++ b/scripts/ensure-dev-deps.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +force=0 + +if [[ "${1:-}" == "--force" ]]; then + force=1 + shift +fi + +if [[ $# -ne 0 ]]; then + echo "Usage: scripts/ensure-dev-deps.sh [--force]" >&2 + exit 2 +fi + +pnpm_bin="${PNPM_BIN:-pnpm}" +dependency_stamp="$repo_root/node_modules/.berd-dev-dependency-inputs.sha256" +installed_lock="$repo_root/node_modules/.pnpm/lock.yaml" +sdk_stamp="$repo_root/sdk/dist/.berd-dev-build-inputs.sha256" + +hash_files() { + local path + for path in "$@"; do + if [[ -d "$path" ]]; then + find "$path" -type f -print + elif [[ -f "$path" ]]; then + printf '%s\n' "$path" + fi + done \ + | LC_ALL=C sort -u \ + | while IFS= read -r path; do + shasum -a 256 "$path" + done \ + | shasum -a 256 \ + | awk '{print $1}' +} + +stamp_matches() { + local stamp="$1" + local expected="$2" + [[ -f "$stamp" ]] && [[ "$(<"$stamp")" == "$expected" ]] +} + +write_stamp() { + local stamp="$1" + local value="$2" + local temp_stamp + mkdir -p "$(dirname "$stamp")" + temp_stamp="$(mktemp "${stamp}.XXXXXX")" + printf '%s\n' "$value" >"$temp_stamp" + mv "$temp_stamp" "$stamp" +} + +dependency_inputs_hash() { + hash_files \ + "$repo_root/package.json" \ + "$repo_root/pnpm-lock.yaml" \ + "$repo_root/pnpm-workspace.yaml" \ + "$repo_root/sdk/package.json" +} + +sdk_inputs_hash() { + hash_files \ + "$repo_root/pnpm-lock.yaml" \ + "$repo_root/pnpm-workspace.yaml" \ + "$repo_root/sdk/package.json" \ + "$repo_root/sdk/tsconfig.json" \ + "$repo_root/sdk/generate-schema.ts" \ + "$repo_root/sdk/schema" \ + "$repo_root/sdk/src" +} + +dependency_inputs="$(dependency_inputs_hash)" + +if [[ "$force" == "1" ]] \ + || ! stamp_matches "$dependency_stamp" "$dependency_inputs" \ + || [[ ! -f "$installed_lock" ]] \ + || ! cmp -s "$repo_root/pnpm-lock.yaml" "$installed_lock"; then + echo "Preparing pnpm dependencies." + (cd "$repo_root" && "$pnpm_bin" install) + dependency_inputs="$(dependency_inputs_hash)" + write_stamp "$dependency_stamp" "$dependency_inputs" +else + echo "pnpm dependencies are current; skipping install." +fi + +sdk_inputs="$(sdk_inputs_hash)" + +if [[ "$force" == "1" ]] \ + || ! stamp_matches "$sdk_stamp" "$sdk_inputs" \ + || [[ ! -f "$repo_root/sdk/dist/index.js" ]] \ + || [[ ! -f "$repo_root/sdk/dist/index.d.ts" ]]; then + echo "Building @aaif/goose-sdk." + (cd "$repo_root/sdk" && "$pnpm_bin" build) + # Schema generation is part of the SDK build and may update derived source + # files, so record the inputs after the successful build. + sdk_inputs="$(sdk_inputs_hash)" + write_stamp "$sdk_stamp" "$sdk_inputs" +else + echo "@aaif/goose-sdk is current; skipping build." +fi diff --git a/scripts/release/tests/release-scripts.test.mjs b/scripts/release/tests/release-scripts.test.mjs index 2f1253c76..ca9eb225d 100644 --- a/scripts/release/tests/release-scripts.test.mjs +++ b/scripts/release/tests/release-scripts.test.mjs @@ -118,7 +118,7 @@ describe("managed Goose build profile", () => { /_bundle-debug-unix:[\s\S]*if \[\[ -z "\$\{GOOSE_BIN:-\}" \]\]; then[\s\S]*GOOSE_BUILD_PROFILE=debug \.\/scripts\/ensure-local-goose\.sh/, ); expect(justfile).toMatch( - /dev:[\s\S]*GOOSE_BUILD_PROFILE=debug just setup[\s\S]*GOOSE_BUILD_PROFILE=debug \.\/scripts\/ensure-local-goose\.sh --check-bin/, + /dev:[\s\S]*just _ensure-dev-deps[\s\S]*GOOSE_DEV_MODE=required GOOSE_BUILD_PROFILE=debug \.\/scripts\/ensure-local-goose\.sh --print-bin/, ); expect(devE2e).toContain("GOOSE_BUILD_PROFILE=debug just setup"); expect(schema).toContain("GOOSE_BUILD_PROFILE=debug"); diff --git a/scripts/release/tests/setup-tooling.test.mjs b/scripts/release/tests/setup-tooling.test.mjs index a1292fc92..de6ab3d12 100644 --- a/scripts/release/tests/setup-tooling.test.mjs +++ b/scripts/release/tests/setup-tooling.test.mjs @@ -76,6 +76,65 @@ async function callsFor(path) { return readFile(path, "utf8").catch(() => ""); } +async function devDepsFixture() { + const root = await mkdtemp(join(tmpdir(), "berd-dev-deps-")); + tempDirs.push(root); + const scriptDir = join(root, "scripts"); + const sdkDir = join(root, "sdk"); + const fakePnpm = join(root, "fake-pnpm"); + const calls = join(root, "pnpm-calls"); + + await Promise.all([ + mkdir(scriptDir), + mkdir(join(sdkDir, "schema"), { recursive: true }), + mkdir(join(sdkDir, "src"), { recursive: true }), + ]); + await Promise.all([ + copyFile( + join(repo, "scripts/ensure-dev-deps.sh"), + join(scriptDir, "ensure-dev-deps.sh"), + ), + writeFile(join(root, "package.json"), '{"name":"fixture"}\n'), + writeFile(join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"), + writeFile(join(root, "pnpm-workspace.yaml"), "packages: ['.', 'sdk']\n"), + writeFile(join(sdkDir, "package.json"), '{"name":"sdk"}\n'), + writeFile(join(sdkDir, "tsconfig.json"), "{}\n"), + writeFile(join(sdkDir, "generate-schema.ts"), "export {};\n"), + writeFile(join(sdkDir, "schema/schema.json"), "{}\n"), + writeFile(join(sdkDir, "src/index.ts"), "export {};\n"), + writeFile( + fakePnpm, + `#!/bin/bash +set -euo pipefail +printf '%s:%s\\n' "$PWD" "$*" >> "${calls}" +if [[ "\${1:-}" == "install" ]]; then + if [[ "\${PNPM_REWRITE_LOCK:-0}" == "1" ]] && ! grep -q autoInstallPeers pnpm-lock.yaml; then + printf 'settings:\n autoInstallPeers: true\n' >> pnpm-lock.yaml + fi + mkdir -p node_modules/.pnpm + cp pnpm-lock.yaml node_modules/.pnpm/lock.yaml +elif [[ "\${1:-}" == "build" ]]; then + mkdir -p dist + touch dist/index.js dist/index.d.ts +fi +`, + ), + ]); + await Promise.all([ + chmod(join(scriptDir, "ensure-dev-deps.sh"), 0o755), + chmod(fakePnpm, 0o755), + ]); + + const run = (args = [], env = {}) => + spawnSync(join(scriptDir, "ensure-dev-deps.sh"), args, { + cwd: root, + encoding: "utf8", + env: { ...process.env, PNPM_BIN: fakePnpm, ...env }, + }); + + return { root, calls, run }; +} + afterEach(async () => { await Promise.all( tempDirs @@ -85,21 +144,92 @@ afterEach(async () => { }); describe("setup tooling regressions", () => { - it("uses the SDK's direct build commands through the pnpm setup recipe", async () => { - const [sdkPackage, justfile] = await Promise.all([ + it("routes full setup and incremental dev preparation through the dependency guard", async () => { + const [sdkPackage, justfile, ensureDevDeps] = await Promise.all([ readFile(join(repo, "sdk/package.json"), "utf8"), readFile(justfilePath, "utf8"), + readFile(join(repo, "scripts/ensure-dev-deps.sh"), "utf8"), ]); expect(JSON.parse(sdkPackage).scripts.build).toBe( "tsx generate-schema.ts && tsc", ); expect(justfile).toMatch( - /_setup-dev-deps:\n {4}pnpm install\n {4}cd sdk && pnpm build\n/, + /_setup-dev-deps:\n {4}\.\/scripts\/ensure-dev-deps\.sh --force\n/, + ); + expect(justfile).toMatch( + /_ensure-dev-deps:\n {4}\.\/scripts\/ensure-dev-deps\.sh\n/, ); expect(justfile).toMatch( /_install-lefthook:\n {4}\.\/scripts\/install-lefthook\.sh\n/, ); + expect(ensureDevDeps).toContain('"$pnpm_bin" install'); + expect(ensureDevDeps).toContain('"$pnpm_bin" build'); + }); + + it("records dependency inputs after pnpm updates the lockfile", async () => { + const fixture = await devDepsFixture(); + + const initial = fixture.run([], { PNPM_REWRITE_LOCK: "1" }); + expect(initial.status, `${initial.stdout}\n${initial.stderr}`).toBe(0); + + await writeFile(fixture.calls, ""); + const warm = fixture.run(); + expect(warm.status, `${warm.stdout}\n${warm.stderr}`).toBe(0); + expect(warm.stdout).toContain("skipping install"); + expect(warm.stdout).toContain("skipping build"); + expect(await callsFor(fixture.calls)).toBe(""); + }); + + it("skips current dependencies and rebuilds only stale SDK inputs", async () => { + const fixture = await devDepsFixture(); + + const first = fixture.run(); + expect(first.status, `${first.stdout}\n${first.stderr}`).toBe(0); + expect(await callsFor(fixture.calls)).toBe( + `${fixture.root}:install\n${join(fixture.root, "sdk")}:build\n`, + ); + + await writeFile(fixture.calls, ""); + const warm = fixture.run(); + expect(warm.status, `${warm.stdout}\n${warm.stderr}`).toBe(0); + expect(warm.stdout).toContain("skipping install"); + expect(warm.stdout).toContain("skipping build"); + expect(await callsFor(fixture.calls)).toBe(""); + + await writeFile(fixture.calls, ""); + await writeFile( + join(fixture.root, "pnpm-lock.yaml"), + "lockfileVersion: '9.0'\nsettings:\n autoInstallPeers: true\n", + ); + const dependencyChange = fixture.run(); + expect( + dependencyChange.status, + `${dependencyChange.stdout}\n${dependencyChange.stderr}`, + ).toBe(0); + expect(await callsFor(fixture.calls)).toBe( + `${fixture.root}:install\n${join(fixture.root, "sdk")}:build\n`, + ); + + await writeFile(fixture.calls, ""); + await writeFile( + join(fixture.root, "sdk/src/index.ts"), + "export const changed = true;\n", + ); + const sdkChange = fixture.run(); + expect(sdkChange.status, `${sdkChange.stdout}\n${sdkChange.stderr}`).toBe( + 0, + ); + expect(await callsFor(fixture.calls)).toBe( + `${join(fixture.root, "sdk")}:build\n`, + ); + + await writeFile(fixture.calls, ""); + const forced = fixture.run(["--force"]); + expect(forced.status, `${forced.stdout}\n${forced.stderr}`).toBe(0); + expect(await callsFor(fixture.calls)).toBe( + `${fixture.root}:install\n${join(fixture.root, "sdk")}:build\n`, + ); }); it("prefers the repository-local lefthook shim over PATH", async () => {