From 379230892be2a08d826df1b78826d1f46c9141bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Tue, 5 May 2026 00:43:34 +0100 Subject: [PATCH] feat: brew install + npm sibling channels via OIDC trusted publishing (#806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: ignore local secret file * feat(cli): replace self-update with package-manager-owned runtime resolution The CLI no longer downloads runtime bundles from GitHub on first run. Instead: - A new resolveRuntime() in apps/cli/src/runtime.ts resolves the bundle in three steps: KANDEV_BUNDLE_DIR env var (Homebrew wrapper, tests) → installed @kdlbs/runtime-{platform} npm package → cached download (only when --runtime-version is given explicitly). - apps/cli/src/run.ts is split: launch logic separated from download logic. The download path stays for the explicit --runtime-version flag only. - The --version flag now prints CLI semver (was: runtime tag selector). The old behavior moves to --runtime-version, documented as advanced/debug. - The npm self-update prompt (apps/cli/src/update.ts) is removed entirely. - apps/cli/package.json declares optionalDependencies for five @kdlbs/runtime-{platform} packages and engines.npm: ">=7" (required for optional-dep resolution during npx). - Adds esbuild + a "bundle" script that produces dist/cli.bundle.js — a single-file CJS bundle with all Node deps inlined. The Homebrew formula uses this; the npm package keeps the unbundled dist/cli.js. Tests: 130/130 passing. Bundle smoke-tested in a directory with no node_modules to confirm tree-kill is correctly inlined. * feat(release): unified vX.Y.Z release flow with brew + npm sibling channels GitHub release artifacts become the canonical build output. npm and Homebrew are independent sibling channels — both consume the same GitHub release tarballs; neither depends on the other. Workflow changes (.github/workflows/release.yml): - Trigger is now workflow_dispatch only (maintainer-only, gated by repo write access). Inputs: bump dropdown (patch/minor/major) + dry_run checkbox. - Single job graph orchestrates everything in one run: prepare (bump, PR, merge, tag) → build-web/build-cli → build-bundles (5 platforms) → docker → publish-release → publish-npm + update-homebrew-tap. - The prepare job inlines version bump, optionalDependencies sync, CHANGELOG generation, PR create + squash-merge, and tag push. Replaces what release-pr.sh used to do locally. - Tag-push trigger removed; everything runs from the dispatch flow. New scripts (called by the workflow): - package-cli.sh: builds + esbuild-bundles the CLI for inclusion in release bundles (cli/bin/cli.js + cli/dist/cli.bundle.js + package.json). - package-npm-runtime.sh: generates per-platform @kdlbs/runtime-* npm packages from release bundles. Sets npm os/cpu fields so npm 7+ skips non-matching platforms during npx kandev install. - publish-npm.sh: verifies GitHub assets exist, downloads them, repackages, publishes all 5 @kdlbs/runtime-* packages + main kandev@X.Y.Z. - update-homebrew-tap.sh: clones kdlbs/homebrew-kandev via SSH using the HOMEBREW_TAP_DEPLOY_KEY repo secret, generates Formula/kandev.rb pointing at the GitHub release assets, pushes directly to main. Removed scripts: - release.sh and release-pr.sh — both are redundant with the new workflow. Required CI secrets: - NPM_TOKEN: granular access token with publish access to kandev + @kdlbs/* - HOMEBREW_TAP_DEPLOY_KEY: SSH private key for kdlbs/homebrew-kandev * docs: brew install, runtime resolution, and GHA-driven release flow - README.md: add Homebrew quick start (brew install kdlbs/kandev/kandev), update NPX docs to require npm 7+, document the three update paths. - docs/cli.md: rewrite architecture diagram to show three-step runtime resolution. Replace --version docs with --version (CLI semver) + --runtime-version (advanced/debug). Remove KANDEV_NO_UPDATE_PROMPT. - apps/cli/README.md: add Homebrew install path; document package-manager-owned updates. - apps/cli/README_internal.md: rewrite to describe the npm package vs GitHub bundle artifact-shape distinction and the three-step resolver. Document the GHA-driven release UI (Run workflow + bump dropdown). - AGENTS.md: add Release & Versioning Convention section describing the unified single-SemVer flow, the sibling channel model, and the GHA flow. * fix(release): release-bundle bin/cli.js must require the esbuild bundle The previous package-cli.sh copied apps/cli/bin/cli.js verbatim into the release bundle. That file requires `../dist/cli.js` (unbundled), which only works when node_modules is present alongside it. In the GitHub release bundle (used by Homebrew and manual installs), there is no node_modules — `require('tree-kill')` and `require('tar')` inside dist/cli.js would crash at startup. Now package-cli.sh writes a release-specific bin/cli.js that requires `../dist/cli.bundle.js` instead. The bundled file has all Node deps inlined via esbuild, so it runs standalone in the Cellar. The npm package keeps its own bin/cli.js (in apps/cli/bin/) requiring the unbundled dist/cli.js, since npm installs tree-kill etc. normally. Caught while smoke-testing the release bundle locally with KANDEV_BUNDLE_DIR — backend + web boot succeeded after this fix. * feat(release): switch npm publishing to Trusted Publishers (OIDC) The previous flow required an NPM_TOKEN secret. Local testing with that token failed with EOTP because the granular access token still required a 2FA OTP on publish despite the account-level setting change. npm now recommends Trusted Publishers (OIDC) over long-lived tokens. Workflow change (release.yml): - publish-npm job declares `permissions: id-token: write` so GitHub Actions can mint OIDC tokens. - Removes NODE_AUTH_TOKEN env var. The npm CLI auto-detects the OIDC environment and exchanges the GitHub OIDC token for a short-lived npmjs.com publish credential. - Comment block updated to reflect the new auth model. NPM_TOKEN is no longer listed in required secrets (already deleted from repo secrets). Script change (publish-npm.sh): - Updates `npm publish` calls to include `--provenance` so each published package ships with a signed attestation linking it back to this commit and workflow run. - Header comment explains the OIDC auth model. Setup required on npmjs.com (per package, before first OIDC publish): For each of the 6 packages — kandev plus @kdlbs/runtime-{linux-x64, linux-arm64,darwin-x64,darwin-arm64,win32-x64} — go to the package's "Settings → Publishing access" and add a Trusted Publisher pointing at this repo (kdlbs/kandev), workflow filename release.yml, and (optionally) an environment. * fix(release): pin optionalDependencies to bootstrap version in committed source CI was failing because apps/cli/package.json declared optionalDependencies at 0.16.0 but only 0.0.0-bootstrap exists on npm for those packages. The lockfile couldn't resolve, and `pnpm install --frozen-lockfile` rejected the working tree. Pin optionalDependencies to 0.0.0-bootstrap in the committed source so the lockfile resolves cleanly. The release workflow now bumps them to the real version inside publish-npm.sh, just before publishing main kandev — at which point the matching @kdlbs/runtime-*@VERSION tarballs have already been published earlier in the same step. This avoids the chicken-and-egg where the prepare job tried to bump optional deps to a version that didn't yet exist on npm. Workflow changes: - prepare job: only bumps the main package version, not optional deps. - publish-npm.sh: rewrites optionalDependencies to $VERSION right before the main `npm publish`, after the 5 runtime packages have been published. The published kandev@X.Y.Z still has its optionalDependencies pinned to matching @kdlbs/runtime-*@X.Y.Z. Only the local source uses the bootstrap pin for development convenience. * fix: address PR review comments on release flow + runtime resolution PR reviewers (Greptile, Claude, CodeRabbit) flagged six concrete issues. Each is addressed below. Critical: - publish-npm.sh: previously exited 0 even when runtime packages failed to publish, which would leave kandev@VERSION on the registry pointing at @kdlbs/runtime-*@VERSION that never got published. Now: capture full npm output on failure, distinguish "already published" (idempotent skip) from real failures, abort before publishing main kandev if any runtime fails. - release.yml: build-bundles referenced needs.prepare.outputs.tag without declaring prepare in its needs array. Static analysis flagged this. Added prepare to the needs list. Major: - update-homebrew-tap.sh: the formula's write_env_script only injected KANDEV_BUNDLE_DIR. run.ts:174 reads KANDEV_VERSION to display the running version in startup logs; without it Homebrew users would see "release: (env)" instead of "release: 0.17.0". Pass KANDEV_VERSION: version.to_s through the wrapper. - runtime.ts: the catch {} block silently swallowed validateBundle errors, hiding "package installed but corrupt" cases behind the generic "no runtime found" message. Split the lookup so MODULE_NOT_FOUND falls through, but validation errors propagate. - run.ts: the --runtime-version code path skipped validateBundle, missing the file-existence checks that the npm/env paths get. Added it. Minor: - release.yml: workflow-level permissions defaulted to write for everyone. Tightened to read-only at the workflow level; jobs that need writes (prepare, docker, publish-release) declare them explicitly. - args.test.ts: added missing tests for --runtime-version with no value and --runtime-version followed by another flag, parallel to the existing --port tests. Tests: 132 passing (was 130; +2 new). * fix: address second-round PR review (cleanup traps, host-key pinning, supply chain) Latest review (Claude + Greptile) flagged three security/cleanup items on top of the previous fix commit. All three addressed below. publish-npm.sh: - Add `trap 'rm -rf "$WORK_DIR"' EXIT` immediately after `mktemp -d` so the tmpdir is cleaned up even when `pnpm build` or `npm publish` fails partway through (set -e would otherwise abort before the explicit rm at the end). - Remove redundant `pnpm build` before `npm publish` — the `prepublishOnly` script in package.json already runs the build. update-homebrew-tap.sh: - Combine `cleanup_ssh` + WORK_DIR cleanup into one EXIT trap so `git clone` / `git push` failures don't leak the tmpdir. - Pin GitHub's published SSH host keys (ed25519, ECDSA, RSA) into a temp `known_hosts` file instead of using `accept-new` + `/dev/null`. This closes the "trust any host key on first connect" gap. The pinned keys come from https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints. release.yml: - Replace the curl|tar git-cliff install with `orhun/git-cliff-action@v4` (already used in publish-release). The action pins to a digest, so we no longer need to verify a tarball checksum manually. Removes a supply-chain attack surface where a tampered git-cliff binary could run during `prepare` (which has contents:write + pull-requests:write). * revert: keep curl install for git-cliff, add sha512 verification instead The previous commit (18b2a5c) replaced our `curl | tar` install with orhun/git-cliff-action@v4 under the banner "supply chain". On reflection that swap doesn't actually improve anything: - @v4 is a moving tag, not a digest pin — same trust as a tagged curl URL. - Both depend on trusting the same maintainer (orhun). - The action adds a third-party step we don't audit. The legitimate concern from the review was "no integrity verification on the downloaded tarball." Address that directly: pin the asset version, embed its published sha512, and abort if the download doesn't match. The hash for v2.6.1's x86_64-linux-gnu tarball came from https://github.com/orhun/git-cliff/releases/download/v2.6.1/.sha512. This restores the original setup with a real integrity check and one fewer external dependency. * fix: address remaining PR nits (env restore, sha256 download dir) runtime.test.ts: drop the redundant `delete process.env.KANDEV_BUNDLE_DIR` in afterEach — `process.env = { ...origEnv }` already restores the original environment. The redundant delete would silently un-set the var if a developer had it in their shell, leaking test side effects across suites. update-homebrew-tap.sh: fetch_sha256 now downloads into $WORK_DIR instead of /tmp/. The EXIT trap covers WORK_DIR, so the .sha256 files are cleaned up automatically. Avoids leaking 5 tmpfiles per release run on long-lived runners. * fix: address third-round PR comments - update-homebrew-tap.sh: Homebrew formula was calling `bin.write_env_script` on the bin directory with target `cli/bin/cli.js`, which would create the wrapper at `$HOMEBREW_PREFIX/bin/cli.js` (named after the target's basename) instead of `$HOMEBREW_PREFIX/bin/kandev`. Result: `brew install kandev` would have produced `command not found`. Use `(bin/"kandev").write_env_script(...)` to name the wrapper explicitly. Caught by Claude as a P0 blocker. - publish-npm.sh: main `kandev` publish lacked the EPUBLISHCONFLICT idempotency handling that the runtime-package loop has. If publish-npm was re-run after a partial failure (e.g. the runtime packages succeeded but the main package failed and was then manually published), the re-run would fail on the main package even though the version was already on the registry. Now: capture output, treat "already published" as success, fail loudly with full output on real errors. Caught by Greptile as P1. * refactor(release): direct push to main instead of PR-and-merge dance The prepare job was opening a release branch, creating a PR, and immediately squash-merging it via gh CLI. That pattern was carried over from the old release-pr.sh, but in this workflow it earns nothing: - main has no branch protection, so direct push is allowed. - The PR was force-merged in the same step (no `--auto`, no waiting for required checks), so it provided no pre-merge validation. - Discoverability is fine without a PR — git tag, GitHub release, workflow run, and main commit are all visible. The PR-and-merge sequence had ~5 GitHub API roundtrips, each a potential failure point, plus added 30+ lines of YAML and required pull-requests: write on the job. Replaced with a straight-line commit + tag + push: git fetch origin main && git checkout main && git reset --hard origin/main ... bump pkg.json, regen CHANGELOG, npm install ... git add ... && git commit && git tag -a && git push origin main "$TAG" Job permissions tightened to contents: write only. Race condition note: if main advances between fetch and push, the push fails non-fast-forward — same outcome as the old PR pattern, where the auto-merge would have failed. For workflow_dispatch releases with the maintainer actively driving, the race is essentially never going to happen, and the recovery (re-run the workflow) is the same. The dry-run path still bumps + regenerates CHANGELOG inside the runner so the maintainer can verify the computed values look right, just without committing or pushing anything. * chore(release): drop verbose comment about direct-push rationale * docs: drop 'no first-run download' note from user-facing docs The phrasing only makes sense to readers who knew the previous model required a first-run download. New users have no baseline to compare against, so it adds noise. Removed from docs/cli.md and apps/cli/README.md (which renders on npmjs.com). Kept in apps/cli/README_internal.md as a design note for contributors. * Revert "chore: ignore local secret file" The /secret entry was added defensively after the bootstrap test, but it clutters .gitignore with a project-specific path that nobody else needs. Drop it; secrets stay out of the repo by other means (don't commit them). * fix: address third-pass review (stale workflow comments + missing test) release.yml: - The header comment block and dry_run input description still referenced the PR-and-merge step that was removed in 53c2e45. Updated both to describe the direct-commit flow. runtime.test.ts: - The runtime resolver's npm-package branch (step 2 — the primary path after npx kandev@latest or npm install -g) had no positive coverage. In the test env, require.resolve for @kdlbs/runtime-* throws MODULE_NOT_FOUND, so the existing tests only exercised step 1 (env var) and step 3 (cache via --runtime-version), plus the no-runtime-found fallthrough. Added two tests using vi.spyOn on Module._resolveFilename to point at a fake bundle inside tmpDir: - happy path: installed npm package resolves to source=npm with the right bundleDir - corrupt-bundle path: validateBundle errors propagate (rather than being swallowed and falling through to "no runtime found") These cover the real-world scenarios users will hit on either install channel. Tests: 132 → 134. * fix(release): push commit + tag atomically Without --atomic, git push can succeed for the tag ref and fail for the main ref on a non-fast-forward, leaving an orphaned tag at the pre-bump commit. The orphan then trips the 'Tag already exists on origin' guard on the next re-run, requiring manual tag deletion to unblock. --atomic makes the multi-ref push succeed-or-fail as a unit. Caught by Greptile as P1. * refactor(cli): drop runtime.ts step 3 — cache fallback was unreachable resolveRuntime's runtimeVersion parameter and cache-fallback step were dead code from production's perspective. The --runtime-version path is handled entirely in run.ts (findCachedRelease + downloadRuntimeVersion + findBundleRoot + validateBundle), and run.ts only ever called resolveRuntime() with no argument. The parameter and step 3 only existed because tests exercised them. Two parallel "compute cache dir" implementations (runtime.ts:65 and run.ts:157) were a drift hazard if the cache layout ever changes. Changes: - Drop the runtimeVersion parameter from resolveRuntime. - Drop the cache step from runtime.ts entirely; remove the unused CACHE_DIR + findBundleRoot imports. - Narrow RuntimeSource from "env" | "npm" | "cache" to "env" | "npm", and drop the now-unused tag field from ResolvedRuntime. - Remove the three "cache fallback" tests from runtime.test.ts. The --runtime-version cache lookup is still covered by run.test.ts via findCachedRelease tests. Tests: 134 → 131 (three deleted tests are obsolete, the production behavior they were verifying is now covered by run.test.ts). * chore(test): drop dead mock setup left over from runtime.ts step 3 removal After feb7c5a removed the cache fallback from runtime.ts, the test file had two dangling artifacts: - The vi.mock("./constants", ...) block + the mockCacheDir holder + the mockCacheDir.value = tmpDir assignment in beforeEach. runtime.ts no longer imports anything from ./constants, so the mock was a no-op that would mislead readers tracing why constants are mocked here. - The PLATFORM = "macos-arm64" constant was unused — platform determinism is already enforced by the vi.mock("./platform", ...) block one section above. Caught by Claude and the github-code-quality bot. --- .github/workflows/release.yml | 329 +++++++++- AGENTS.md | 30 + README.md | 28 +- apps/cli/README.md | 30 +- apps/cli/README_internal.md | 236 +++---- apps/cli/package.json | 14 +- apps/cli/src/args.test.ts | 25 +- apps/cli/src/args.ts | 19 +- apps/cli/src/cli.ts | 33 +- apps/cli/src/run.ts | 147 ++--- apps/cli/src/runtime.test.ts | 152 +++++ apps/cli/src/runtime.ts | 83 +++ apps/cli/src/update.ts | 86 --- apps/pnpm-lock.yaml | 353 ++++++++++- docs/cli.md | 171 +++--- scripts/release/package-cli.sh | 49 ++ scripts/release/package-npm-runtime.sh | 110 ++++ scripts/release/publish-npm.sh | 181 ++++++ scripts/release/release-pr.sh | 812 ------------------------- scripts/release/release.sh | 648 -------------------- scripts/release/update-homebrew-tap.sh | 203 +++++++ 21 files changed, 1864 insertions(+), 1875 deletions(-) create mode 100644 apps/cli/src/runtime.test.ts create mode 100644 apps/cli/src/runtime.ts delete mode 100644 apps/cli/src/update.ts create mode 100755 scripts/release/package-cli.sh create mode 100755 scripts/release/package-npm-runtime.sh create mode 100755 scripts/release/publish-npm.sh delete mode 100755 scripts/release/release-pr.sh delete mode 100755 scripts/release/release.sh create mode 100755 scripts/release/update-homebrew-tap.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a1ab444a0..855e754bdc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,20 +1,213 @@ name: release -on: - push: - tags: - - "v*" - workflow_dispatch: +# Maintainer-only release workflow. +# Trigger: GitHub Actions UI → "Run workflow" (workflow_dispatch). +# +# Flow (one click → everything below): +# 1. prepare — bump version, regen CHANGELOG, commit to main, push tag vX.Y.Z +# 2. build-web — build Next.js standalone bundle +# 3. build-cli — build + esbuild-bundle CLI +# 4. build-bundles — assemble bin/ + web/ + cli/ tarballs for 5 platforms +# 5. docker — build & push multi-arch container image to GHCR +# 6. publish-release — create GH release with .tar.gz + .sha256 assets + auto-generated notes +# 7. publish-npm — publish kandev@X.Y.Z + @kdlbs/runtime-*@X.Y.Z to npmjs +# 8. update-homebrew-tap — push updated Formula/kandev.rb to kdlbs/homebrew-kandev +# +# npm publishing uses Trusted Publishers (OIDC) — no NPM_TOKEN required. +# Each package (kandev + 5 @kdlbs/runtime-* packages) must have this workflow +# configured as its trusted publisher on npmjs.com (Settings → Publishing access). +# +# Required secrets: +# HOMEBREW_TAP_DEPLOY_KEY — SSH private key for kdlbs/homebrew-kandev deploy key +on: + workflow_dispatch: + inputs: + bump: + description: "Version bump" + required: true + type: choice + default: minor + options: + - patch + - minor + - major + dry_run: + description: "Dry run (no commit, tag, npm publish, or tap update)" + type: boolean + default: false + +# Default to read-only at the workflow level. Each job opts into the writes +# it actually needs. permissions: - contents: write - packages: write + contents: read jobs: + prepare: + name: Prepare release (bump version, commit, tag) + runs-on: ubuntu-latest + permissions: + contents: write # commit + push to main, push tag + outputs: + version: ${{ steps.compute.outputs.version }} + tag: ${{ steps.compute.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: pnpm/action-setup@v4 + with: + version: "9" + + - uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Install git-cliff + env: + # Pinned to v2.6.1. To bump, also update the sha512 below — fetch the + # new one from: + # https://github.com/orhun/git-cliff/releases/download//.sha512 + CLIFF_VERSION: "2.6.1" + CLIFF_ASSET: "git-cliff-2.6.1-x86_64-unknown-linux-gnu.tar.gz" + CLIFF_SHA512: "b1e7a6b70fc794accdccd9717eff5a8bf8b507f0308da44d837d935be31b18b19db3758d1a9bb4975fbca7d01ddaae251229a753ae2cd2ecc24845cfc50d067f" + run: | + set -euo pipefail + curl -sSL "https://github.com/orhun/git-cliff/releases/download/v${CLIFF_VERSION}/${CLIFF_ASSET}" -o /tmp/git-cliff.tar.gz + echo "${CLIFF_SHA512} /tmp/git-cliff.tar.gz" | sha512sum -c - + tar -xz -C /tmp -f /tmp/git-cliff.tar.gz + sudo mv "/tmp/git-cliff-${CLIFF_VERSION}/git-cliff" /usr/local/bin/ + rm -rf /tmp/git-cliff.tar.gz "/tmp/git-cliff-${CLIFF_VERSION}" + git-cliff --version + + - name: Configure git identity + run: | + git config user.email "release-bot@kandev" + git config user.name "kandev release bot" + + - name: Compute next version + id: compute + env: + BUMP: ${{ inputs.bump }} + run: | + set -euo pipefail + PKG_VERSION="$(node -p "require('./apps/cli/package.json').version")" + + # Find latest git tag, normalizing legacy two-part tags (vM.m → M.m.0) + LATEST_TAG="$(git tag --list 'v*' | python3 -c " + import sys, re + best = (0, 0, 0) + for t in (l.strip() for l in sys.stdin if l.strip()): + s = t.lstrip('v') + m3 = re.match(r'^(\d+)\.(\d+)\.(\d+)$', s) + m2 = re.match(r'^(\d+)\.(\d+)$', s) + if m3: + v = tuple(int(x) for x in m3.groups()) + elif m2: + v = (int(m2.group(1)), int(m2.group(2)), 0) + else: + continue + if v > best: + best = v + print(f'{best[0]}.{best[1]}.{best[2]}') + ")" + + # Use the higher of package.json version and the latest tag as baseline + BASELINE="$(python3 -c " + a = '$PKG_VERSION'.split('.') + b = '$LATEST_TAG'.split('.') + ai = tuple(int(x) for x in a) + bi = tuple(int(x) for x in b) + print('$PKG_VERSION' if ai >= bi else '$LATEST_TAG') + ")" + + IFS=. read -r MAJ MIN PAT <<<"$BASELINE" + case "$BUMP" in + patch) NEXT="${MAJ}.${MIN}.$((PAT + 1))" ;; + minor) NEXT="${MAJ}.$((MIN + 1)).0" ;; + major) NEXT="$((MAJ + 1)).0.0" ;; + *) echo "Invalid bump '$BUMP'" >&2; exit 1 ;; + esac + + TAG="v${NEXT}" + echo "Current package.json version: $PKG_VERSION" + echo "Latest git tag (normalized): $LATEST_TAG" + echo "Baseline: $BASELINE" + echo "Bump: $BUMP" + echo "Next version: $NEXT" + echo "Next tag: $TAG" + echo "version=$NEXT" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + # Sanity: tag must not already exist + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists locally" >&2 + exit 1 + fi + if git ls-remote --tags origin "refs/tags/$TAG" | grep -q .; then + echo "Tag $TAG already exists on origin" >&2 + exit 1 + fi + + - name: Bump version, generate CHANGELOG, commit, tag, push + if: ${{ !inputs.dry_run }} + env: + NEXT: ${{ steps.compute.outputs.version }} + TAG: ${{ steps.compute.outputs.tag }} + run: | + set -euo pipefail + git fetch origin main + git checkout main + git reset --hard origin/main + + # Bump only the main package version. optionalDependencies stay + # pinned to 0.0.0-bootstrap in committed source — publish-npm.sh + # rewrites them to the real version in-flight before publishing, + # once the matching @kdlbs/runtime-* tarballs exist on npm. + (cd apps/cli && npm version --no-git-tag-version "$NEXT") + pnpm -C apps install --no-frozen-lockfile + + git-cliff --tag "$TAG" -o CHANGELOG.md + + git add apps/cli/package.json CHANGELOG.md apps/pnpm-lock.yaml + git commit -m "release: $NEXT" + git tag -a "$TAG" -m "release: $NEXT" + + # Push commit and tag atomically — without --atomic, Git could + # accept the tag push but reject the main update on a non-fast-forward, + # leaving an orphaned tag at the pre-bump commit (and tripping the + # "tag already exists" guard on the next re-run). --atomic makes the + # whole multi-ref push succeed or fail as a unit. + git push --atomic origin main "$TAG" + + - name: Dry-run summary + if: ${{ inputs.dry_run }} + env: + NEXT: ${{ steps.compute.outputs.version }} + TAG: ${{ steps.compute.outputs.tag }} + run: | + # In dry-run we still bump + run git-cliff locally so the maintainer + # can verify the computed version + changelog look right, but nothing + # is committed or pushed. + (cd apps/cli && npm version --no-git-tag-version "$NEXT") + pnpm -C apps install --no-frozen-lockfile + git-cliff --tag "$TAG" -o CHANGELOG.md + echo + echo "DRY RUN: Would have committed + tagged $TAG and pushed to origin/main." + echo "DRY RUN: apps/cli/package.json bumped to $NEXT (in-runner only)." + echo "DRY RUN: CHANGELOG.md regenerated (in-runner only)." + build-web: + name: Build web standalone + needs: prepare + if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} - uses: pnpm/action-setup@v4 with: version: "9" @@ -25,7 +218,7 @@ jobs: run: pnpm -C apps install --frozen-lockfile - name: Build web (standalone) env: - KANDEV_VERSION: ${{ github.ref_name }} + KANDEV_VERSION: ${{ needs.prepare.outputs.tag }} run: pnpm -C apps --filter @kandev/web build - name: Package web bundle run: bash scripts/release/package-web.sh @@ -35,8 +228,34 @@ jobs: path: dist/web include-hidden-files: true + build-cli: + name: Build & bundle CLI + needs: prepare + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + - uses: pnpm/action-setup@v4 + with: + version: "9" + - uses: actions/setup-node@v6 + with: + node-version: "24" + - name: Install dependencies + run: pnpm -C apps install --frozen-lockfile + - name: Build and bundle CLI + run: bash scripts/release/package-cli.sh + - uses: actions/upload-artifact@v4 + with: + name: cli-bundle + path: dist/kandev/cli + build-bundles: - needs: build-web + name: Build platform bundle (${{ matrix.platform }}) + needs: [prepare, build-web, build-cli] + if: ${{ !inputs.dry_run }} strategy: matrix: include: @@ -69,6 +288,8 @@ jobs: GOARCH: ${{ matrix.goarch }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} - uses: actions/setup-go@v6 with: go-version-file: apps/backend/go.mod @@ -77,6 +298,10 @@ jobs: with: name: web-bundle path: dist/web + - uses: actions/download-artifact@v4 + with: + name: cli-bundle + path: dist/kandev/cli - name: Install cross-compilation tools if: matrix.cc @@ -96,19 +321,19 @@ jobs: - name: Package bundle run: | - rm -rf dist/kandev + rm -rf dist/kandev/bin dist/kandev/web mkdir -p dist/kandev/bin EXT=${{ matrix.goos == 'windows' && '.exe' || '' }} cp apps/backend/bin/kandev${EXT} dist/kandev/bin/ cp apps/backend/bin/agentctl${EXT} dist/kandev/bin/ mkdir -p dist/kandev/web cp -R dist/web/. dist/kandev/web/ + # cli/ was already placed by the build-cli job (downloaded above) cd dist tar -czf "kandev-${{ matrix.platform }}.tar.gz" kandev shasum -a 256 "kandev-${{ matrix.platform }}.tar.gz" > "kandev-${{ matrix.platform }}.tar.gz.sha256" - - name: Upload bundle artifacts - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v4 with: name: bundle-${{ matrix.platform }} path: | @@ -116,9 +341,17 @@ jobs: dist/kandev-${{ matrix.platform }}.tar.gz.sha256 docker: + name: Build & push container image + needs: prepare + if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest + permissions: + contents: read + packages: write # push to GHCR steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} - name: Docker meta id: meta @@ -126,10 +359,10 @@ jobs: with: images: ghcr.io/kdlbs/kandev tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} + type=raw,value=${{ needs.prepare.outputs.version }} + type=raw,value=${{ needs.prepare.outputs.tag }} type=sha - type=raw,value=latest,enable=${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }} + type=raw,value=latest - uses: docker/setup-buildx-action@v3 @@ -152,11 +385,16 @@ jobs: cache-to: type=gha,mode=max publish-release: - needs: build-bundles + name: Publish GitHub release + needs: [prepare, build-bundles] + if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest + permissions: + contents: write # softprops/action-gh-release creates the release steps: - uses: actions/checkout@v4 with: + ref: ${{ needs.prepare.outputs.tag }} fetch-depth: 0 - name: Generate release notes @@ -167,8 +405,7 @@ jobs: env: OUTPUT: RELEASE_NOTES.md - - name: Download bundle artifacts - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v4 with: pattern: bundle-* path: dist/release-assets @@ -192,10 +429,64 @@ jobs: done ls -lh dist/release-assets - - name: Publish release assets + - name: Publish release uses: softprops/action-gh-release@v2 with: + tag_name: ${{ needs.prepare.outputs.tag }} + name: ${{ needs.prepare.outputs.tag }} body_path: RELEASE_NOTES.md files: | dist/release-assets/kandev-*.tar.gz dist/release-assets/kandev-*.tar.gz.sha256 + + publish-npm: + name: Publish npm packages + needs: [prepare, publish-release] + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + permissions: + id-token: write # Required for OIDC trusted publishing + contents: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - uses: pnpm/action-setup@v4 + with: + version: "9" + + - uses: actions/setup-node@v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + - name: Install dependencies + run: pnpm -C apps install --frozen-lockfile + + - name: Publish npm packages + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + bash scripts/release/publish-npm.sh \ + "${{ needs.prepare.outputs.version }}" \ + "${{ needs.prepare.outputs.tag }}" + + update-homebrew-tap: + name: Update Homebrew tap + needs: [prepare, publish-release] + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - name: Update Homebrew tap formula + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} + run: | + bash scripts/release/update-homebrew-tap.sh \ + "${{ needs.prepare.outputs.version }}" \ + "${{ needs.prepare.outputs.tag }}" diff --git a/AGENTS.md b/AGENTS.md index bfe39f030c..42dcd27818 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -274,6 +274,36 @@ lib/api/domains/ # API clients Commits to `main` **must** follow [Conventional Commits](https://www.conventionalcommits.org/) (`type: description`). PRs are squash-merged — the PR title becomes the commit, validated by CI. Changelog is auto-generated from these via git-cliff (`cliff.toml`). See `.agents/skills/commit/SKILL.md` for allowed types and examples. +### Release & Versioning Convention + +Kandev uses a **single SemVer** `X.Y.Z` shared across all distribution channels: + +- `apps/cli/package.json` version → `X.Y.Z` +- npm main package: `kandev@X.Y.Z` +- npm runtime packages: `@kdlbs/runtime-{platform}@X.Y.Z` (5 platforms; declared as `optionalDependencies` in main package) +- Git tag: `vX.Y.Z` (three-part; legacy `vM.m` tags normalize to `M.m.0`) +- Homebrew formula: `kdlbs/homebrew-kandev` `Formula/kandev.rb` `version "X.Y.Z"` +- GitHub release: `vX.Y.Z` with platform tarballs `kandev-{platform}.tar.gz` + `.sha256` + +**npm and Homebrew are sibling channels**, not chained. Both consume the same GitHub release artifacts; neither depends on the other. + +**Release flow** (entirely in CI via `.github/workflows/release.yml`, triggered by maintainer from GitHub Actions UI): + +1. Maintainer clicks "Run workflow" → picks `bump` (patch/minor/major) → optional `dry_run`. +2. `prepare` job bumps version + regenerates CHANGELOG, opens release PR, squash-merges, tags `vX.Y.Z`. +3. `build-web` + `build-cli` + `build-bundles` (5 platforms) build the release artifacts. +4. `publish-release` creates the GitHub release with platform tarballs + sha256 + auto-generated notes. +5. `publish-npm` publishes 5 `@kdlbs/runtime-*` packages + main `kandev` package to npmjs. +6. `update-homebrew-tap` pushes updated `Formula/kandev.rb` to `kdlbs/homebrew-kandev` via SSH deploy key. + +There is no local release script — the entire flow runs in GHA. + +**Runtime resolution** (in `apps/cli/src/runtime.ts`): + +1. `KANDEV_BUNDLE_DIR` env var (set by Homebrew wrapper, used by tests). +2. Installed `@kdlbs/runtime-{platform}` npm package via `require.resolve()`. +3. `--runtime-version ` cache fallback (debug only — downloads from GitHub). + ### Code Quality (enforced by linters) Static analysis runs in CI and pre-commit. New code **must** stay within these limits: diff --git a/README.md b/README.md index 9ee11f277a..6d0af035c6 100644 --- a/README.md +++ b/README.md @@ -92,13 +92,35 @@ Each executor uses git worktrees for workspace isolation, preventing concurrent ## Quick Start -### NPX (recommended) +### Homebrew (macOS, Linux) ```bash -npx kandev +brew install kdlbs/kandev/kandev +kandev ``` -This downloads pre-built backend + frontend bundles and starts them locally. The worktrees and sqlite db will be created in `~/.kandev` by default. Should work on macOS, Linux, and Windows (WSL or native). +### NPX + +```bash +npx kandev@latest +``` + +Requires npm 7+. Installs the CLI plus a platform-matched runtime bundle in one step. + +### NPM (global) + +```bash +npm install -g kandev@latest +kandev +``` + +### Updates + +- `brew upgrade kandev` +- `npx kandev@latest` (always uses the latest published version) +- `npm install -g kandev@latest` + +The worktrees and sqlite db live in `~/.kandev` by default. Works on macOS, Linux, and Windows (WSL or native). ### From Source diff --git a/apps/cli/README.md b/apps/cli/README.md index e65a916fcd..b086054566 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -4,18 +4,36 @@ Manage tasks. Orchestrate agents. Review changes. Ship value. ## Quick Start +### Homebrew + ```bash -npx kandev +brew install kdlbs/kandev/kandev +kandev ``` -Downloads the latest release, starts the backend + web app, and opens your browser. Data (worktrees, SQLite DB) is stored in `~/.kandev` by default. +### NPX (requires npm 7+) + +```bash +npx kandev@latest +``` + +Either install path resolves a platform-matched runtime (Go backend, agentctl, Next.js standalone web), launches the backend + web, and opens your browser. Data (worktrees, SQLite DB) is stored in `~/.kandev` by default. ## Version and Updates -- `npx kandev` (run mode) uses the latest GitHub release bundle by default. -- At startup, the launcher prints the resolved release tag (for example: `[kandev] release: v0.2.3 (github latest)`). -- Use `--version ` to pin a specific runtime bundle release. -- CLI package updates are checked from npm and shown as a prompt. They are not forced. +The package manager owns the runtime version. `kandev@X.Y.Z` ships with the matching runtime. + +- **Update via Homebrew**: `brew upgrade kandev` +- **Update via npm/npx**: `npx kandev@latest` or `npm install -g kandev@latest` +- **Print CLI version**: `kandev --version` + +### Advanced: pin a specific runtime tag + +`--runtime-version ` downloads a specific GitHub release runtime instead of using the installed one. For debugging compatibility issues only: + +```bash +kandev --runtime-version v0.16.0 +``` ## What You Get diff --git a/apps/cli/README_internal.md b/apps/cli/README_internal.md index 3b33a0b7f7..7779383417 100644 --- a/apps/cli/README_internal.md +++ b/apps/cli/README_internal.md @@ -1,158 +1,170 @@ -# Kandev Launcher (npx) +# Kandev CLI (internal docs) ## Architecture ```mermaid flowchart TD - A["npx kandev"] --> B["npm downloads kandev package"] - B --> C["CLI parses arguments"] - C --> D{"Command?"} + A["kandev"] --> B["CLI parses arguments"] + B --> C{"Command?"} - D -->|run| E["Fetch latest GitHub release"] - D -->|dev| F["Use local repo"] - D -->|start| G["Use local build"] + C -->|run / default| D["resolveRuntime()"] + C -->|dev| F["Use local repo"] + C -->|start| G["Use local build"] - E --> E1["Download platform zip"] - E1 --> E2["Extract to ~/.kandev/bin/"] - E2 --> E3["Start backend + Next.js standalone"] + D --> D1{"KANDEV_BUNDLE_DIR set?"} + D1 -->|yes| D2["Use env bundle (Homebrew)"] + D1 -->|no| D3["require.resolve('@kdlbs/runtime-{platform}')"] + D3 -->|hit| D4["Use installed npm bundle"] + D3 -->|miss| D5{"--runtime-version given?"} + D5 -->|yes| D6["Download from GitHub Releases (cache)"] + D5 -->|no| D7["Throw: 'No runtime found'"] + + D2 --> E["Start backend + Next.js standalone"] + D4 --> E + D6 --> E F --> F1["make dev + next dev"] - G --> G1["binary + next start"] ``` ## Overview -This package powers `npx kandev` by downloading prebuilt release bundles from GitHub Releases and running them locally. It: +This package provides the `kandev` CLI launcher. The runtime bundle (Go backend, agentctl, Next.js standalone web) is **installed by the package manager** — there is no first-run download. -- Detects OS/arch and fetches the matching bundle ZIP. -- Verifies the SHA256 checksum when available. -- Extracts the bundle into `~/.kandev/bin///`. -- Starts the backend binary and waits for the `/health` endpoint. -- Starts the Next.js standalone server with runtime `KANDEV_API_BASE_URL`. -- Uses the latest GitHub Release by default, so runtime bundles update automatically. +Two install channels share the same release artifacts: -It also supports local dev and production runs from a repo checkout. +- **npm/npx**: `kandev@X.Y.Z` declares `optionalDependencies` for `@kdlbs/runtime-{platform}@X.Y.Z`. npm 7+ filters by `os`/`cpu` and installs only the matching one. +- **Homebrew**: `kdlbs/homebrew-kandev` formula downloads the GitHub release tarball into the Cellar and sets `KANDEV_BUNDLE_DIR`. + +Both channels resolve to the same runtime bundle layout (`bin/kandev`, `bin/agentctl`, `web/server.js`). The CLI launcher (`runtime.ts`) abstracts over them. + +## Artifact shapes + +The GitHub release bundle and the npm runtime package are **different shapes** because they serve different consumers: + +``` +# GitHub release bundle (used by Homebrew + manual installs) +kandev/ +├── bin/{kandev,agentctl} +├── web/server.js +└── cli/ + ├── bin/cli.js # #!/usr/bin/env node + chmod +x + └── dist/cli.bundle.js # esbuild single-file bundle (deps inlined) + +# npm runtime package (@kdlbs/runtime-{platform}) +@kdlbs/runtime-{platform}/ +├── bin/{kandev,agentctl} +└── web/server.js +``` + +The `cli/` directory is only in the GitHub bundle (Homebrew needs the launcher). For npm installs, the main `kandev` package already provides the CLI; the runtime package only ships the binaries + web bundle. ## Commands | Command | Description | | ------------------------ | -------------------------------------------------------- | -| `kandev` or `kandev run` | Download and run release bundles (default) | +| `kandev` or `kandev run` | Run from installed runtime bundle (default) | | `kandev dev` | Run local repo with hot-reload (requires repo checkout) | | `kandev start` | Run local production build (requires `make build` first) | -## Updates - -On `run`, the launcher checks npm for the latest `kandev` CLI version and prompts: - -``` -Update available: -> . Update now? [y/N] -``` - -If you accept, it re-runs `npx kandev@latest` with the same arguments. -This check is skipped in `dev` and `start` modes. - -Note: the runtime bundles are pulled from the latest GitHub Release by default, even if the CLI version is unchanged. So: - -- **New runtime release without CLI publish**: users get new runtime automatically, but no update prompt. -- **New CLI publish**: users get an update prompt and then re-run with the new CLI. -- On startup, `run` also prints the resolved runtime release tag. -- Runtime assets are published atomically in CI (single final release upload job), so `/releases/latest` - does not surface partially uploaded platform bundles. - -You can disable the prompt with: - -```bash -KANDEV_NO_UPDATE_PROMPT=1 npx kandev -``` - -## Usage - -```bash -# Run the latest release bundle -npx kandev - -# Run a specific release tag -npx kandev run --version v0.1.0 - -# Override ports -npx kandev --backend-port 18080 --web-port 13000 - -# Local dev with hot-reload (from repo) -npx kandev dev - -# Local production build (from repo, after `make build`) -npx kandev start - -# Local test the built CLI (from repo root) -pnpm -C apps/cli build -pnpm -C apps/cli start -``` - ## CLI Options -| Option | Description | -| ----------------------- | ---------------------------------------- | -| `run` | Use release bundles (default) | -| `dev` | Use local repo with hot-reload | -| `start` | Use local production build | -| `--version ` | Release tag to install (default: latest) | -| `--backend-port ` | Override backend port | -| `--web-port ` | Override web port | -| `--help`, `-h` | Show help | +| Option | Description | +| -------------------------- | ---------------------------------------------------------- | +| `--version`, `-V` | Print CLI version and exit | +| `--port`, `--backend-port` | Backend port | +| `--web-internal-port` | Override internal Next.js port | +| `--verbose`, `-v` | Show info logs | +| `--debug` | Show debug logs + agent message dumps | +| `--runtime-version ` | **Advanced/debug**: download a specific GitHub runtime tag | +| `--help`, `-h` | Show help | + +## Updates (package-manager owned) + +The CLI no longer self-updates. Updates flow through the install channel: + +```bash +brew upgrade kandev # Homebrew +npm install -g kandev@latest # npm global +npx kandev@latest # always pulls latest +``` + +There is no `KANDEV_NO_UPDATE_PROMPT` or `KANDEV_SKIP_UPDATE` env var anymore — they're gone with the self-updater. + +## Bundling for Homebrew + +The Homebrew bundle uses `dist/cli.bundle.js`, an esbuild single-file output that inlines `tree-kill`, `tar`, and other Node deps. Required because the Cellar has no `node_modules`. The npm package uses the unbundled `dist/cli.js` and resolves deps via npm. + +```bash +pnpm -C apps/cli build # produces dist/cli.js +pnpm -C apps/cli bundle # produces dist/cli.bundle.js (~212KB) +``` + +Smoke test the bundle in a clean directory: + +```bash +mkdir /tmp/smoke && cd /tmp/smoke +node /path/to/apps/cli/dist/cli.bundle.js --help +``` + +If `Cannot find module 'tree-kill'` appears, the bundle is broken. ## Local Development ```bash -# Run CLI in dev mode +# Run CLI in dev mode (uses tsx) pnpm -C apps/cli dev # Run with arguments pnpm -C apps/cli dev -- start -pnpm -C apps/cli dev -- --backend-port 9000 +pnpm -C apps/cli dev -- --port 9000 ``` -## Build / Publish - -```bash -pnpm -C apps/cli build -npm publish --access public -``` - -The published package name is `kandev`, with a bin entry `kandev`. - ## Release -```bash -scripts/release/release.sh -``` +Releases run entirely in GitHub Actions. From the GHA UI: -The unified release script supports `cli`, `app`, or `both` in one flow. -Versioning strategy: +1. Open the **Release** workflow. +2. Click **Run workflow**. +3. Choose `bump` (patch / minor / major) from the dropdown. +4. Optionally tick `dry_run` to validate without publishing. +5. Click **Run workflow**. -- App release tags: `vM.m` -- CLI npm version: `M.m.0` -- Default bump mode: `minor` +The workflow does everything: version bump, CHANGELOG, PR, merge, tag, build, npm publish, Homebrew tap update. See [/.github/workflows/release.yml](../../.github/workflows/release.yml). + +Single SemVer flow: `apps/cli/package.json` version, git tag, npm packages, and Homebrew formula are all bumped to the same `X.Y.Z`. + +Versioning: + +- One SemVer `X.Y.Z` for everything (npm, GitHub tag, Homebrew formula) +- Git tag format: `vX.Y.Z` +- Legacy `vM.m` tags normalized to `M.m.0` during migration; new releases always use `vX.Y.Z` ## Environment Overrides -| Variable | Description | -| --------------------------- | ---------------------------------------------- | -| `KANDEV_GITHUB_OWNER` | Override GitHub repo owner (default: `kandev`) | -| `KANDEV_GITHUB_REPO` | Override GitHub repo name (default: `kandev`) | -| `KANDEV_GITHUB_TOKEN` | Optional token for GitHub API rate limits | -| `KANDEV_NO_UPDATE_PROMPT=1` | Disable the CLI update prompt | -| `KANDEV_SKIP_UPDATE=1` | Internal guard to avoid update loops | -| `KANDEV_HEALTH_TIMEOUT_MS` | Override health check timeout in milliseconds | +| Variable | Description | +| ------------------------------------- | --------------------------------------------------------------------- | +| `KANDEV_BUNDLE_DIR` | Force runtime bundle location (set by Homebrew wrapper, tests) | +| `KANDEV_PORT` / `KANDEV_BACKEND_PORT` | Backend port | +| `KANDEV_WEB_PORT` | Internal Next.js port | +| `KANDEV_HEALTH_TIMEOUT_MS` | Override health check timeout (ms) | +| `KANDEV_GITHUB_OWNER` | Override GitHub repo owner (default: `kdlbs`) for `--runtime-version` | +| `KANDEV_GITHUB_REPO` | Override GitHub repo name (default: `kandev`) for `--runtime-version` | +| `KANDEV_GITHUB_TOKEN` | Optional GitHub token for rate limits | ## Supported Platforms -| Platform | Asset Name | -| --------------------- | ------------------------------------------- | -| macOS (Apple Silicon) | `kandev-macos-arm64.zip` | -| macOS (Intel) | `kandev-macos-x64.zip` | -| Linux (x64) | `kandev-linux-x64.zip` | -| Linux (ARM64) | `kandev-linux-arm64.zip` | -| Windows (x64) | `kandev-windows-x64.zip` | -| Windows (ARM64) | Falls back to `windows-x64` (x64 emulation) | +| Platform | npm runtime package | GitHub asset name | +| --------------------- | ------------------------------------------- | --------------------------- | +| macOS (Apple Silicon) | `@kdlbs/runtime-darwin-arm64` | `kandev-macos-arm64.tar.gz` | +| macOS (Intel) | `@kdlbs/runtime-darwin-x64` | `kandev-macos-x64.tar.gz` | +| Linux (x64) | `@kdlbs/runtime-linux-x64` | `kandev-linux-x64.tar.gz` | +| Linux (ARM64) | `@kdlbs/runtime-linux-arm64` | `kandev-linux-arm64.tar.gz` | +| Windows (x64) | `@kdlbs/runtime-win32-x64` | `kandev-windows-x64.tar.gz` | +| Windows (ARM64) | Falls back to `windows-x64` (x64 emulation) | | + +Note: `platform.ts` uses internal naming (`macos`, `windows`); npm `os` field uses npm conventions (`darwin`, `win32`). `runtime.ts` maps between the two. + +## npm requirements + +The optional-dependency runtime resolution requires **npm 7 or newer**. npm 6 silently skips optional deps during `npx`, which would leave users with no runtime. `package.json` declares `engines.npm: ">=7"` to surface this at install time as a clear error. diff --git a/apps/cli/package.json b/apps/cli/package.json index e78ec42e37..a545526b7f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -2,7 +2,7 @@ "name": "kandev", "version": "0.17.0", "private": false, - "description": "NPX launcher for Kandev", + "description": "Launcher for Kandev — manage tasks, orchestrate agents, review changes, and ship value", "license": "AGPL-3.0-only", "type": "commonjs", "bin": { @@ -13,12 +13,23 @@ "bin", "dist" ], + "engines": { + "npm": ">=7" + }, + "optionalDependencies": { + "@kdlbs/runtime-linux-x64": "0.0.0-bootstrap", + "@kdlbs/runtime-linux-arm64": "0.0.0-bootstrap", + "@kdlbs/runtime-darwin-x64": "0.0.0-bootstrap", + "@kdlbs/runtime-darwin-arm64": "0.0.0-bootstrap", + "@kdlbs/runtime-win32-x64": "0.0.0-bootstrap" + }, "dependencies": { "tar": "^7.5.11", "tree-kill": "^1.2.2" }, "devDependencies": { "@types/node": "^20", + "esbuild": "^0.24.0", "tsx": "^4.15.7", "typescript": "^5", "vitest": "^1.6.0" @@ -26,6 +37,7 @@ "scripts": { "dev": "unset npm_config_prefix && tsx src/cli.ts", "build": "tsc -p tsconfig.json", + "bundle": "esbuild dist/cli.js --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.js", "start": "node dist/cli.js", "test": "vitest run", "prepublishOnly": "pnpm build" diff --git a/apps/cli/src/args.test.ts b/apps/cli/src/args.test.ts index 1e83ab7ede..c7bbaddac6 100644 --- a/apps/cli/src/args.test.ts +++ b/apps/cli/src/args.test.ts @@ -66,8 +66,29 @@ describe("parseArgs", () => { expect(() => parseArgs(["--port="])).toThrow(/--port value must be an integer/); }); - it("throws ParseError on empty --version=", () => { - expect(() => parseArgs(["--version="])).toThrow(/--version requires a value/); + it("sets showVersion for --version and -V flags", () => { + expect(parseArgs(["--version"]).options.showVersion).toBe(true); + expect(parseArgs(["-V"]).options.showVersion).toBe(true); + }); + + it("parses --runtime-version as runtimeVersion", () => { + expect(parseArgs(["--runtime-version", "v0.16.0"]).options.runtimeVersion).toBe("v0.16.0"); + expect(parseArgs(["--runtime-version=v0.16.0"]).options.runtimeVersion).toBe("v0.16.0"); + }); + + it("throws ParseError on empty --runtime-version=", () => { + expect(() => parseArgs(["--runtime-version="])).toThrow(/--runtime-version requires a value/); + }); + + it("throws ParseError when --runtime-version has no value", () => { + expect(() => parseArgs(["--runtime-version"])).toThrow(ParseError); + expect(() => parseArgs(["--runtime-version"])).toThrow(/--runtime-version requires a value/); + }); + + it("throws ParseError when the next token after --runtime-version is another flag", () => { + expect(() => parseArgs(["--runtime-version", "--debug"])).toThrow( + /--runtime-version requires a value/, + ); }); }); diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 147a0cca45..254f5bc577 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -2,11 +2,12 @@ export type Command = "run" | "dev" | "start"; export type CliOptions = { command: Command; - version?: string; + runtimeVersion?: string; backendPort?: number; webPort?: number; verbose?: boolean; debug?: boolean; + showVersion?: boolean; }; export type ParseResult = { @@ -31,19 +32,23 @@ export function parseArgs(argv: string[]): ParseResult { showHelp = true; continue; } + if (arg === "--version" || arg === "-V") { + opts.showVersion = true; + continue; + } if (arg === "dev" || arg === "run" || arg === "start") { opts.command = arg; continue; } - if (arg === "--version") { - opts.version = takeValue(argv, i, "--version"); + if (arg === "--runtime-version") { + opts.runtimeVersion = takeValue(argv, i, "--runtime-version"); i += 1; continue; } - if (arg.startsWith("--version=")) { - const value = arg.slice("--version=".length); - if (value.length === 0) throw new ParseError("--version requires a value"); - opts.version = value; + if (arg.startsWith("--runtime-version=")) { + const value = arg.slice("--runtime-version=".length); + if (value.length === 0) throw new ParseError("--runtime-version requires a value"); + opts.runtimeVersion = value; continue; } if (arg === "--dev") { diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index b1d02c7dc5..2d3baa25e1 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -7,16 +7,15 @@ import { runDev } from "./dev"; import { runRelease } from "./run"; import { runStart } from "./start"; import { ensureValidPort } from "./ports"; -import { maybePromptForUpdate } from "./update"; function printHelp() { console.log(`kandev launcher Usage: - kandev run [--version ] [--port ] [--verbose] [--debug] + kandev run [--port ] [--verbose] [--debug] kandev dev [--port ] kandev start [--port ] [--verbose] [--debug] - kandev [--version ] [--port ] [--verbose] [--debug] + kandev [--port ] [--verbose] [--debug] kandev --dev [--port ] Examples: @@ -25,16 +24,16 @@ Examples: kandev --dev kandev dev kandev start - kandev --version v0.1.0 + kandev --version kandev --port 3000 kandev --debug Options: dev Use local repo for dev (make dev + next dev) if available. start Use local production build (make build + next start). - run Use release bundles (default). + run Use installed runtime bundle (default). --dev Alias for "dev". - --version Release tag to install (default: latest). + --version, -V Print CLI version and exit. --port Port for the Go backend (the URL kandev opens on in start/run). Alias for --backend-port. Also reads KANDEV_PORT or KANDEV_BACKEND_PORT. @@ -43,11 +42,14 @@ Options: --help, -h Show help. Advanced: - --backend-port Same as --port. - --web-internal-port Override the internal Next.js port. The Go backend - reverse-proxies to it; users hit the backend port. - Also reads KANDEV_WEB_PORT. - --web-port Deprecated alias for --web-internal-port. + --backend-port Same as --port. + --web-internal-port Override the internal Next.js port. The Go backend + reverse-proxies to it; users hit the backend port. + Also reads KANDEV_WEB_PORT. + --web-port Deprecated alias for --web-internal-port. + --runtime-version Download and use a specific release tag instead of + the installed runtime. For debugging only. + Example: kandev --runtime-version v0.16.0 `); } @@ -76,6 +78,12 @@ function findRepoRoot(startDir: string): string | null { async function main(): Promise { const { options, showHelp, deprecatedFlags } = parseArgs(process.argv.slice(2)); + + if (options.showVersion) { + console.log(pkg.version); + return; + } + if (showHelp) { printHelp(); return; @@ -113,9 +121,8 @@ async function main(): Promise { return; } - await maybePromptForUpdate(pkg.version, process.argv.slice(2)); await runRelease({ - version: options.version, + runtimeVersion: options.runtimeVersion, backendPort, webPort, verbose: options.verbose, diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index 82693ab875..a64db0919f 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -17,11 +17,12 @@ import { getBinaryName, getPlatformDir } from "./platform"; import { sortVersionsDesc } from "./version"; import { pickAvailablePort } from "./ports"; import { createProcessSupervisor } from "./process"; +import { resolveRuntime, validateBundle } from "./runtime"; import { attachBackendExitHandler, logStartupInfo } from "./shared"; import { launchWebApp, openBrowser } from "./web"; export type RunOptions = { - version?: string; + runtimeVersion?: string; backendPort?: number; webPort?: number; /** Show info logs from backend + web */ @@ -30,14 +31,13 @@ export type RunOptions = { debug?: boolean; }; -type PreparedRelease = { +type PreparedBundle = { bundleDir: string; backendBin: string; backendUrl: string; backendEnv: NodeJS.ProcessEnv; webEnv: NodeJS.ProcessEnv; releaseTag: string; - requestedVersion?: string; webPort: number; agentctlPort: number; dbPath: string; @@ -109,60 +109,71 @@ export function cleanOldReleases(currentTag: string) { } } -async function prepareReleaseBundle({ - version, +/** + * Download a specific release version into the local cache. + * Only used when --runtime-version is given explicitly. + */ +async function downloadRuntimeVersion(runtimeVersion: string): Promise { + const platformDir = getPlatformDir(); + const release = await getRelease(runtimeVersion); + const tag = release.tag_name; + const assetName = `kandev-${platformDir}.tar.gz`; + const cacheDir = path.join(CACHE_DIR, tag, platformDir); + + const archivePath = await ensureAsset(tag, assetName, cacheDir, (downloaded, total) => { + const percent = total ? Math.round((downloaded / total) * 100) : 0; + const mb = (downloaded / (1024 * 1024)).toFixed(1); + const totalMb = total ? (total / (1024 * 1024)).toFixed(1) : "?"; + process.stderr.write(`\r Downloading: ${mb}MB / ${totalMb}MB (${percent}%)`); + }); + process.stderr.write("\n"); + + ensureExtracted(archivePath, cacheDir); + cleanOldReleases(tag); + return tag; +} + +async function prepareBundleForLaunch({ + runtimeVersion, backendPort, webPort, verbose = false, debug = false, -}: RunOptions): Promise { - const platformDir = getPlatformDir(); - let tag: string; - let cacheDir: string; +}: RunOptions): Promise { + let bundleDir: string; + let releaseTag: string; - try { - const release = await getRelease(version); - tag = release.tag_name; - const assetName = `kandev-${platformDir}.tar.gz`; - cacheDir = path.join(CACHE_DIR, tag, platformDir); - - const archivePath = await ensureAsset(tag, assetName, cacheDir, (downloaded, total) => { - const percent = total ? Math.round((downloaded / total) * 100) : 0; - const mb = (downloaded / (1024 * 1024)).toFixed(1); - const totalMb = total ? (total / (1024 * 1024)).toFixed(1) : "?"; - process.stderr.write(`\r Downloading: ${mb}MB / ${totalMb}MB (${percent}%)`); - }); - process.stderr.write("\n"); - - ensureExtracted(archivePath, cacheDir); - cleanOldReleases(tag); - } catch (err) { - // GitHub unreachable — try to launch from cache. - const cached = findCachedRelease(platformDir, version); - if (!cached) { - const target = version ? `version ${version}` : "latest version"; - const reason = err instanceof Error ? err.message : String(err); - throw new Error( - `Failed to fetch ${target} and no cached release found.\n` + - ` Reason: ${reason}\n` + - ` Run kandev once while online to cache a release for offline use.`, - ); + if (runtimeVersion) { + // Explicit version: ensure it is in the cache (downloading if needed), then resolve. + const platformDir = getPlatformDir(); + const cached = findCachedRelease(platformDir, runtimeVersion); + let tag: string; + if (cached) { + tag = cached.tag; + bundleDir = findBundleRoot(cached.cacheDir); + } else { + try { + tag = await downloadRuntimeVersion(runtimeVersion); + const cacheDir = path.join(CACHE_DIR, tag, platformDir); + bundleDir = findBundleRoot(cacheDir); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to fetch runtime version ${runtimeVersion}.\n` + + ` Reason: ${reason}\n` + + ` Run kandev once while online to cache a release for offline use.`, + ); + } } - tag = cached.tag; - cacheDir = cached.cacheDir; - process.stderr.write(`[kandev] GitHub unreachable — using cached release ${tag}\n`); - } - - const bundleDir = findBundleRoot(cacheDir); - - const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); - if (!fs.existsSync(backendBin)) { - throw new Error(`Backend binary not found at ${backendBin}`); - } - - const agentctlBin = path.join(bundleDir, "bin", getBinaryName("agentctl")); - if (!fs.existsSync(agentctlBin)) { - throw new Error(`agentctl binary not found at ${agentctlBin}`); + // Validate the resolved bundle has all required binaries before launching. + validateBundle(bundleDir); + releaseTag = tag; + } else { + // Default path: resolve from KANDEV_BUNDLE_DIR or installed npm runtime package. + const resolved = resolveRuntime(); + bundleDir = resolved.bundleDir; + // Use KANDEV_VERSION if set (e.g. by Homebrew wrapper), otherwise show source. + releaseTag = process.env.KANDEV_VERSION ?? `(${resolved.source})`; } const actualBackendPort = backendPort ?? (await pickAvailablePort(DEFAULT_BACKEND_PORT)); @@ -176,10 +187,8 @@ async function prepareReleaseBundle({ fs.mkdirSync(DATA_DIR, { recursive: true }); const dbPath = path.join(DATA_DIR, "kandev.db"); - // Note: Release mode doesn't configure MCP server ports as it uses - // the bundled configuration. Only backend and agentctl ports are set. - // Log level defaults to warn for clean output and can be overridden - // via KANDEV_LOG_LEVEL or --verbose/--debug flags. + const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); + const backendEnv: NodeJS.ProcessEnv = { ...process.env, KANDEV_SERVER_PORT: String(actualBackendPort), @@ -194,8 +203,6 @@ async function prepareReleaseBundle({ ...process.env, KANDEV_API_BASE_URL: backendUrl, PORT: String(actualWebPort), - // Ensure Next.js standalone server binds to 127.0.0.1 so localhost health checks work. - // Without this, HOSTNAME from the host environment can cause binding issues. HOSTNAME: "127.0.0.1", }; (webEnv as Record).NODE_ENV = "production"; @@ -206,8 +213,7 @@ async function prepareReleaseBundle({ backendUrl, backendEnv, webEnv, - releaseTag: tag, - requestedVersion: version, + releaseTag, webPort: actualWebPort, agentctlPort, dbPath, @@ -235,17 +241,14 @@ export function attachRingBuffer( return () => buf; } -function launchReleaseApps(prepared: PreparedRelease): { +function launchBundle(prepared: PreparedBundle): { supervisor: ReturnType; backendProc: ReturnType; webServerPath: string; dumpBackendLogs: () => void; } { - const releaseSource = prepared.requestedVersion - ? `(requested: ${prepared.requestedVersion})` - : "(github latest)"; logStartupInfo({ - header: `release: ${prepared.releaseTag} ${releaseSource}`, + header: `release: ${prepared.releaseTag}`, ports: { backendPort: Number(prepared.backendEnv.KANDEV_SERVER_PORT), webPort: prepared.webPort, @@ -259,10 +262,6 @@ function launchReleaseApps(prepared: PreparedRelease): { const supervisor = createProcessSupervisor(); supervisor.attachSignalHandlers(); - // Start backend: ignore stdin, always show stderr immediately. - // In verbose/debug mode stream stdout live; otherwise capture it into a ring - // buffer so we can dump the last few KB if the healthcheck fails (users were - // previously seeing opaque "timed out" errors with no backend context). const backendProc = spawn(prepared.backendBin, [], { cwd: path.dirname(prepared.backendBin), env: prepared.backendEnv, @@ -293,14 +292,20 @@ function launchReleaseApps(prepared: PreparedRelease): { } export async function runRelease({ - version, + runtimeVersion, backendPort, webPort, verbose = false, debug = false, }: RunOptions): Promise { - const prepared = await prepareReleaseBundle({ version, backendPort, webPort, verbose, debug }); - const { supervisor, backendProc, webServerPath, dumpBackendLogs } = launchReleaseApps(prepared); + const prepared = await prepareBundleForLaunch({ + runtimeVersion, + backendPort, + webPort, + verbose, + debug, + }); + const { supervisor, backendProc, webServerPath, dumpBackendLogs } = launchBundle(prepared); const healthTimeoutMs = resolveHealthTimeoutMs(HEALTH_TIMEOUT_MS_RELEASE); console.log("[kandev] starting backend..."); await waitForHealth(prepared.backendUrl, backendProc, healthTimeoutMs, dumpBackendLogs); diff --git a/apps/cli/src/runtime.test.ts b/apps/cli/src/runtime.test.ts new file mode 100644 index 0000000000..c0212277f8 --- /dev/null +++ b/apps/cli/src/runtime.test.ts @@ -0,0 +1,152 @@ +import fs from "node:fs"; +import Module from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { resolveRuntime, validateBundle } from "./runtime"; + +// Keep getPlatformDir deterministic across test environments. +vi.mock("./platform", () => ({ + getPlatformDir: () => "macos-arm64" as const, + getBinaryName: (base: string) => base, +})); + +function createFakeBundle(dir: string) { + fs.mkdirSync(path.join(dir, "bin"), { recursive: true }); + fs.writeFileSync(path.join(dir, "bin", "kandev"), "fake"); + fs.writeFileSync(path.join(dir, "bin", "agentctl"), "fake"); + fs.mkdirSync(path.join(dir, "web"), { recursive: true }); + fs.writeFileSync(path.join(dir, "web", "server.js"), "fake"); +} + +describe("validateBundle", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-validate-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("passes for a complete bundle", () => { + createFakeBundle(tmpDir); + expect(() => validateBundle(tmpDir)).not.toThrow(); + }); + + it("throws when backend binary is missing", () => { + fs.mkdirSync(path.join(tmpDir, "bin"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "bin", "agentctl"), "fake"); + fs.mkdirSync(path.join(tmpDir, "web"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "web", "server.js"), "fake"); + expect(() => validateBundle(tmpDir)).toThrow(/Backend binary not found/); + }); + + it("throws when agentctl is missing", () => { + fs.mkdirSync(path.join(tmpDir, "bin"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "bin", "kandev"), "fake"); + fs.mkdirSync(path.join(tmpDir, "web"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "web", "server.js"), "fake"); + expect(() => validateBundle(tmpDir)).toThrow(/agentctl binary not found/); + }); + + it("throws when web server.js is missing", () => { + fs.mkdirSync(path.join(tmpDir, "bin"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "bin", "kandev"), "fake"); + fs.writeFileSync(path.join(tmpDir, "bin", "agentctl"), "fake"); + expect(() => validateBundle(tmpDir)).toThrow(/Web server.*not found/); + }); +}); + +describe("resolveRuntime", () => { + let tmpDir: string; + const origEnv = { ...process.env }; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-runtime-")); + // Clear relevant env vars before each test + delete process.env.KANDEV_BUNDLE_DIR; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + // Restore the original env exactly. Don't `delete process.env.KANDEV_BUNDLE_DIR` + // afterwards — if a developer had that env var set when the suite started, + // it's already in origEnv and should remain. + process.env = { ...origEnv }; + }); + + describe("KANDEV_BUNDLE_DIR (step 1)", () => { + it("resolves from KANDEV_BUNDLE_DIR when set and valid", () => { + const bundleDir = path.join(tmpDir, "bundle"); + createFakeBundle(bundleDir); + process.env.KANDEV_BUNDLE_DIR = bundleDir; + + const result = resolveRuntime(); + expect(result.source).toBe("env"); + expect(result.bundleDir).toBe(bundleDir); + }); + + it("throws when KANDEV_BUNDLE_DIR points to an invalid bundle", () => { + const bundleDir = path.join(tmpDir, "empty-bundle"); + fs.mkdirSync(bundleDir); + process.env.KANDEV_BUNDLE_DIR = bundleDir; + + expect(() => resolveRuntime()).toThrow(/Backend binary not found/); + }); + }); + + describe("installed npm runtime package (step 2)", () => { + // Step 2 normally relies on require.resolve('@kdlbs/runtime-darwin-arm64') + // succeeding because the npm package is installed alongside the kandev + // package. In this test environment that package isn't present, so we + // intercept Node's module resolver to point at a fake bundle inside tmpDir. + function mockResolve(target: string): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ModuleAny = Module as unknown as { _resolveFilename: any }; + vi.spyOn(ModuleAny, "_resolveFilename").mockImplementationOnce(() => target); + } + + it("resolves from the installed npm runtime package", () => { + const pkgRoot = path.join(tmpDir, "npm-runtime"); + createFakeBundle(pkgRoot); + // require.resolve('/package.json') normally returns the path to the + // package.json inside node_modules. Point it at our fake bundle instead. + const pkgJsonPath = path.join(pkgRoot, "package.json"); + fs.writeFileSync(pkgJsonPath, "{}"); + mockResolve(pkgJsonPath); + + const result = resolveRuntime(); + expect(result.source).toBe("npm"); + expect(result.bundleDir).toBe(pkgRoot); + }); + + it("surfaces validateBundle errors when the npm package is installed but corrupt", () => { + const pkgRoot = path.join(tmpDir, "broken-npm-runtime"); + fs.mkdirSync(pkgRoot); + // package.json present (so require.resolve succeeds), but no bin/ or web/ + const pkgJsonPath = path.join(pkgRoot, "package.json"); + fs.writeFileSync(pkgJsonPath, "{}"); + mockResolve(pkgJsonPath); + + expect(() => resolveRuntime()).toThrow(/Backend binary not found/); + }); + }); + + describe("no runtime found", () => { + it("throws an actionable error message mentioning install paths", () => { + let error: Error | null = null; + try { + resolveRuntime(); + } catch (e) { + error = e as Error; + } + expect(error).not.toBeNull(); + expect(error!.message).toMatch(/npx kandev@latest/); + expect(error!.message).toMatch(/brew install/); + expect(error!.message).toMatch(/--runtime-version/); + }); + }); +}); diff --git a/apps/cli/src/runtime.ts b/apps/cli/src/runtime.ts new file mode 100644 index 0000000000..11fbaf6e2a --- /dev/null +++ b/apps/cli/src/runtime.ts @@ -0,0 +1,83 @@ +import path from "node:path"; +import fs from "node:fs"; + +import { resolveWebServerPath } from "./bundle"; +import { getBinaryName, getPlatformDir, type PlatformDir } from "./platform"; + +const PLATFORM_TO_NPM_PACKAGE: Record = { + "linux-x64": "@kdlbs/runtime-linux-x64", + "linux-arm64": "@kdlbs/runtime-linux-arm64", + "macos-x64": "@kdlbs/runtime-darwin-x64", + "macos-arm64": "@kdlbs/runtime-darwin-arm64", + "windows-x64": "@kdlbs/runtime-win32-x64", +}; + +export type RuntimeSource = "env" | "npm"; + +export type ResolvedRuntime = { + bundleDir: string; + source: RuntimeSource; +}; + +/** + * Resolve the runtime bundle directory using a two-step priority chain: + * + * 1. KANDEV_BUNDLE_DIR env var — set by the Homebrew formula wrapper and + * useful for local testing. Skips all other resolution. + * 2. Installed npm runtime package — looks for @kdlbs/runtime-{platform} + * in node_modules via Node module resolution. Works after + * `npx kandev@latest` or `npm install -g kandev` (requires npm 7+). + * + * The explicit `--runtime-version ` download path is handled directly + * in run.ts (which manages the GitHub download + cache itself); it does + * not flow through this function. + * + * Throws with an actionable error message if no runtime is found. + */ +export function resolveRuntime(): ResolvedRuntime { + const envBundleDir = process.env.KANDEV_BUNDLE_DIR; + if (envBundleDir) { + validateBundle(envBundleDir); + return { bundleDir: envBundleDir, source: "env" }; + } + + const platformDir = getPlatformDir(); + const packageName = PLATFORM_TO_NPM_PACKAGE[platformDir]; + let pkgJsonPath: string | null = null; + try { + pkgJsonPath = require.resolve(`${packageName}/package.json`); + } catch { + // MODULE_NOT_FOUND — npm runtime package is not installed. Fall through + // to the actionable error below. + } + if (pkgJsonPath) { + // The package IS installed. If validateBundle throws here, the bundle is + // present but corrupt — surface the error rather than the generic + // "no runtime found" message below. + const packageRoot = path.dirname(pkgJsonPath); + validateBundle(packageRoot); + return { bundleDir: packageRoot, source: "npm" }; + } + + throw new Error( + `No Kandev runtime found for ${platformDir}.\n` + + ` Install via npm (requires npm 7+): npx kandev@latest\n` + + ` Install via Homebrew: brew install kdlbs/kandev/kandev\n` + + ` Download a specific version (debug): kandev --runtime-version `, + ); +} + +export function validateBundle(bundleDir: string): void { + const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); + if (!fs.existsSync(backendBin)) { + throw new Error(`Backend binary not found in bundle at ${bundleDir}`); + } + const agentctlBin = path.join(bundleDir, "bin", getBinaryName("agentctl")); + if (!fs.existsSync(agentctlBin)) { + throw new Error(`agentctl binary not found in bundle at ${bundleDir}`); + } + const webServerPath = resolveWebServerPath(bundleDir); + if (!webServerPath) { + throw new Error(`Web server (server.js) not found in bundle at ${bundleDir}`); + } +} diff --git a/apps/cli/src/update.ts b/apps/cli/src/update.ts deleted file mode 100644 index 749d7a999f..0000000000 --- a/apps/cli/src/update.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { spawn } from "node:child_process"; -import https from "node:https"; -import readline from "node:readline"; - -import { compareVersions } from "./version"; - -function requestJson(url: string): Promise { - return new Promise((resolve, reject) => { - const req = https.get(url, { headers: { "User-Agent": "kandev-npx" } }, (res) => { - if (res.statusCode !== 200) { - return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`)); - } - let body = ""; - res.on("data", (chunk) => (body += chunk)); - res.on("end", () => { - try { - resolve(JSON.parse(body) as T); - } catch { - reject(new Error(`Failed to parse JSON from ${url}`)); - } - }); - }); - req.setTimeout(5000, () => { - req.destroy(new Error(`Request timed out fetching ${url}`)); - }); - req.on("error", reject); - }); -} - -async function getLatestNpmVersion(): Promise { - const data = await requestJson<{ "dist-tags"?: { latest?: string } }>( - "https://registry.npmjs.org/kandev", - ); - return data?.["dist-tags"]?.latest; -} - -function promptYesNo(question: string, defaultYes = false): Promise { - return new Promise((resolve) => { - if (!process.stdin.isTTY) { - resolve(false); - return; - } - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - const suffix = defaultYes ? "[Y/n]" : "[y/N]"; - rl.question(`${question} ${suffix} `, (answer: string) => { - rl.close(); - const normalized = String(answer || "") - .trim() - .toLowerCase(); - if (!normalized) { - resolve(Boolean(defaultYes)); - return; - } - resolve(normalized === "y" || normalized === "yes"); - }); - }); -} - -export async function maybePromptForUpdate(currentVersion: string, args: string[]) { - // Allow disabling update checks for CI or automation. - if (process.env.KANDEV_SKIP_UPDATE === "1" || process.env.KANDEV_NO_UPDATE_PROMPT === "1") { - return; - } - try { - const latest = await getLatestNpmVersion(); - if (!latest) return; - if (compareVersions(latest, currentVersion) <= 0) return; - const wantsUpdate = await promptYesNo( - `Update available: ${currentVersion} -> ${latest}. Update now?`, - false, - ); - if (!wantsUpdate) return; - const env = { ...process.env, KANDEV_SKIP_UPDATE: "1" }; - const child = spawn("npx", ["kandev@latest", ...args], { - stdio: "inherit", - env, - }); - child.on("exit", (code) => process.exit(code || 0)); - await new Promise(() => {}); - } catch { - // ignore update errors - } -} diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 7e200e42a7..fd80492793 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -32,7 +32,7 @@ importers: dependencies: tar: specifier: ^7.5.11 - version: 7.5.11 + version: 7.5.13 tree-kill: specifier: ^1.2.2 version: 1.2.2 @@ -40,6 +40,9 @@ importers: '@types/node': specifier: ^20 version: 20.19.28 + esbuild: + specifier: ^0.24.0 + version: 0.24.2 tsx: specifier: ^4.15.7 version: 4.21.0 @@ -49,6 +52,22 @@ importers: vitest: specifier: ^1.6.0 version: 1.6.1(@types/node@20.19.28)(happy-dom@20.8.9)(lightningcss@1.30.2) + optionalDependencies: + '@kdlbs/runtime-darwin-arm64': + specifier: 0.0.0-bootstrap + version: 0.0.0-bootstrap + '@kdlbs/runtime-darwin-x64': + specifier: 0.0.0-bootstrap + version: 0.0.0-bootstrap + '@kdlbs/runtime-linux-arm64': + specifier: 0.0.0-bootstrap + version: 0.0.0-bootstrap + '@kdlbs/runtime-linux-x64': + specifier: 0.0.0-bootstrap + version: 0.0.0-bootstrap + '@kdlbs/runtime-win32-x64': + specifier: 0.0.0-bootstrap + version: 0.0.0-bootstrap packages/theme: dependencies: @@ -400,7 +419,7 @@ importers: version: 0.55.1 next: specifier: 16.1.7 - version: 16.1.7(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.7(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -454,7 +473,7 @@ importers: version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) styled-jsx: specifier: 5.1.6 - version: 5.1.6(@babel/core@7.28.6)(react@19.2.3) + version: 5.1.6(react@19.2.3) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -890,6 +909,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.24.2': + resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -902,6 +927,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.24.2': + resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.2': resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} engines: {node: '>=18'} @@ -914,6 +945,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.24.2': + resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.2': resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} engines: {node: '>=18'} @@ -926,6 +963,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.24.2': + resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.2': resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} engines: {node: '>=18'} @@ -938,6 +981,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.24.2': + resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.2': resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} engines: {node: '>=18'} @@ -950,6 +999,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.24.2': + resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.2': resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} engines: {node: '>=18'} @@ -962,6 +1017,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.24.2': + resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.2': resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} engines: {node: '>=18'} @@ -974,6 +1035,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.24.2': + resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.2': resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} engines: {node: '>=18'} @@ -986,6 +1053,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.24.2': + resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.2': resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} engines: {node: '>=18'} @@ -998,6 +1071,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.24.2': + resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.2': resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} engines: {node: '>=18'} @@ -1010,6 +1089,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.24.2': + resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.2': resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} engines: {node: '>=18'} @@ -1022,6 +1107,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.24.2': + resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.2': resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} engines: {node: '>=18'} @@ -1034,6 +1125,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.24.2': + resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.2': resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} engines: {node: '>=18'} @@ -1046,6 +1143,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.24.2': + resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.2': resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} engines: {node: '>=18'} @@ -1058,6 +1161,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.24.2': + resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.2': resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} engines: {node: '>=18'} @@ -1070,6 +1179,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.24.2': + resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.2': resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} engines: {node: '>=18'} @@ -1082,12 +1197,24 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.24.2': + resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.2': resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.24.2': + resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.2': resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} engines: {node: '>=18'} @@ -1100,12 +1227,24 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.24.2': + resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.2': resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.24.2': + resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.2': resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} engines: {node: '>=18'} @@ -1118,6 +1257,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.24.2': + resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.2': resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} engines: {node: '>=18'} @@ -1136,6 +1281,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.24.2': + resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.2': resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} engines: {node: '>=18'} @@ -1148,6 +1299,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.24.2': + resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.2': resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} engines: {node: '>=18'} @@ -1160,6 +1317,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.24.2': + resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.2': resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} engines: {node: '>=18'} @@ -1172,6 +1335,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.24.2': + resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.2': resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} engines: {node: '>=18'} @@ -1483,6 +1652,31 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@kdlbs/runtime-darwin-arm64@0.0.0-bootstrap': + resolution: {integrity: sha512-i3H7KOcw5m1KEhXhTSa9+jNBlAh0Vc8NVkiGL9BpKB5n/COrhBol7YcN1WNkxs/6WdpD9LFo2My+51wODrC9tQ==} + cpu: [arm64] + os: [darwin] + + '@kdlbs/runtime-darwin-x64@0.0.0-bootstrap': + resolution: {integrity: sha512-ia8qU58B1pWr2AM9/qsPP514L6SftSYYmbFG49kcWNZMpRhosdbKNm1GfDce4yE+MKeOpmLmjPu/pR7ZnyMJpg==} + cpu: [x64] + os: [darwin] + + '@kdlbs/runtime-linux-arm64@0.0.0-bootstrap': + resolution: {integrity: sha512-m3UUB0GYOKNVwgW8wwdsjV6dFFmTYxI9vmLMtvgWz01GW1y+Ms5YSRhb0RT5NRMvYpn7xDntlJCjtPATE+RZ7A==} + cpu: [arm64] + os: [linux] + + '@kdlbs/runtime-linux-x64@0.0.0-bootstrap': + resolution: {integrity: sha512-XpcVCGngqq9wsZR2OASQwCiylTFUVD7iDoqeeWUuzZZ1wRxdGqYNV31xrEciq+96O/pI+dn5zWgbMYQEXc309w==} + cpu: [x64] + os: [linux] + + '@kdlbs/runtime-win32-x64@0.0.0-bootstrap': + resolution: {integrity: sha512-x/KRLCugV2eOLCJQWTUmBGU08rz7ep83GdTNZLeyQ3UmOKErVARk5eC9lWNO8LnlQr8LbahK9KdRW6Jhieee2A==} + cpu: [x64] + os: [win32] + '@lezer/common@1.5.0': resolution: {integrity: sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==} @@ -4219,6 +4413,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.24.2: + resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -6411,8 +6610,8 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} - tar@7.5.11: - resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==} + tar@7.5.13: + resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} engines: {node: '>=18'} tiny-invariant@1.3.3: @@ -7474,120 +7673,183 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.24.2': + optional: true + '@esbuild/aix-ppc64@0.27.2': optional: true '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.24.2': + optional: true + '@esbuild/android-arm64@0.27.2': optional: true '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.24.2': + optional: true + '@esbuild/android-arm@0.27.2': optional: true '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.24.2': + optional: true + '@esbuild/android-x64@0.27.2': optional: true '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.24.2': + optional: true + '@esbuild/darwin-arm64@0.27.2': optional: true '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.24.2': + optional: true + '@esbuild/darwin-x64@0.27.2': optional: true '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.24.2': + optional: true + '@esbuild/freebsd-arm64@0.27.2': optional: true '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.24.2': + optional: true + '@esbuild/freebsd-x64@0.27.2': optional: true '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.24.2': + optional: true + '@esbuild/linux-arm64@0.27.2': optional: true '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.24.2': + optional: true + '@esbuild/linux-arm@0.27.2': optional: true '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.24.2': + optional: true + '@esbuild/linux-ia32@0.27.2': optional: true '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.24.2': + optional: true + '@esbuild/linux-loong64@0.27.2': optional: true '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.24.2': + optional: true + '@esbuild/linux-mips64el@0.27.2': optional: true '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.24.2': + optional: true + '@esbuild/linux-ppc64@0.27.2': optional: true '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.24.2': + optional: true + '@esbuild/linux-riscv64@0.27.2': optional: true '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.24.2': + optional: true + '@esbuild/linux-s390x@0.27.2': optional: true '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.24.2': + optional: true + '@esbuild/linux-x64@0.27.2': optional: true + '@esbuild/netbsd-arm64@0.24.2': + optional: true + '@esbuild/netbsd-arm64@0.27.2': optional: true '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.24.2': + optional: true + '@esbuild/netbsd-x64@0.27.2': optional: true + '@esbuild/openbsd-arm64@0.24.2': + optional: true + '@esbuild/openbsd-arm64@0.27.2': optional: true '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.24.2': + optional: true + '@esbuild/openbsd-x64@0.27.2': optional: true @@ -7597,24 +7859,36 @@ snapshots: '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.24.2': + optional: true + '@esbuild/sunos-x64@0.27.2': optional: true '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.24.2': + optional: true + '@esbuild/win32-arm64@0.27.2': optional: true '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.24.2': + optional: true + '@esbuild/win32-ia32@0.27.2': optional: true '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.24.2': + optional: true + '@esbuild/win32-x64@0.27.2': optional: true @@ -7866,6 +8140,21 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kdlbs/runtime-darwin-arm64@0.0.0-bootstrap': + optional: true + + '@kdlbs/runtime-darwin-x64@0.0.0-bootstrap': + optional: true + + '@kdlbs/runtime-linux-arm64@0.0.0-bootstrap': + optional: true + + '@kdlbs/runtime-linux-x64@0.0.0-bootstrap': + optional: true + + '@kdlbs/runtime-win32-x64@0.0.0-bootstrap': + optional: true + '@lezer/common@1.5.0': {} '@lezer/common@1.5.1': {} @@ -10724,6 +11013,34 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.24.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.24.2 + '@esbuild/android-arm': 0.24.2 + '@esbuild/android-arm64': 0.24.2 + '@esbuild/android-x64': 0.24.2 + '@esbuild/darwin-arm64': 0.24.2 + '@esbuild/darwin-x64': 0.24.2 + '@esbuild/freebsd-arm64': 0.24.2 + '@esbuild/freebsd-x64': 0.24.2 + '@esbuild/linux-arm': 0.24.2 + '@esbuild/linux-arm64': 0.24.2 + '@esbuild/linux-ia32': 0.24.2 + '@esbuild/linux-loong64': 0.24.2 + '@esbuild/linux-mips64el': 0.24.2 + '@esbuild/linux-ppc64': 0.24.2 + '@esbuild/linux-riscv64': 0.24.2 + '@esbuild/linux-s390x': 0.24.2 + '@esbuild/linux-x64': 0.24.2 + '@esbuild/netbsd-arm64': 0.24.2 + '@esbuild/netbsd-x64': 0.24.2 + '@esbuild/openbsd-arm64': 0.24.2 + '@esbuild/openbsd-x64': 0.24.2 + '@esbuild/sunos-x64': 0.24.2 + '@esbuild/win32-arm64': 0.24.2 + '@esbuild/win32-ia32': 0.24.2 + '@esbuild/win32-x64': 0.24.2 + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -10766,8 +11083,8 @@ snapshots: '@next/eslint-plugin-next': 16.1.1 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -10789,7 +11106,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -10800,22 +11117,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10826,7 +11143,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -12382,7 +12699,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - next@16.1.7(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.1.7(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.7 '@swc/helpers': 0.5.15 @@ -12391,7 +12708,7 @@ snapshots: postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.3) + styled-jsx: 5.1.6(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.1.7 '@next/swc-darwin-x64': 16.1.7 @@ -13534,12 +13851,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.3): + styled-jsx@5.1.6(react@19.2.3): dependencies: client-only: 0.0.1 react: 19.2.3 - optionalDependencies: - '@babel/core': 7.28.6 stylis@4.3.6: {} @@ -13561,7 +13876,7 @@ snapshots: tapable@2.3.0: {} - tar@7.5.11: + tar@7.5.13: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 diff --git a/docs/cli.md b/docs/cli.md index 8ec15e2757..eca8d01230 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -4,17 +4,23 @@ ```mermaid flowchart TB - subgraph CLI["npx kandev"] + subgraph CLI["kandev"] run["run"] dev["dev"] start["start"] end - run --> gh["GitHub Release"] + run --> resolve["Resolve installed runtime"] dev --> makedev["make dev"] - start --> binary["Binary + next start"] + start --> binary["Local binary + next start"] - gh --> supervisor + resolve --> envvar["KANDEV_BUNDLE_DIR
(Homebrew, tests)"] + resolve --> npmpkg["@kdlbs/runtime-{platform}
(npm/npx)"] + resolve --> cache["~/.kandev/bin cache
(--runtime-version only)"] + + envvar --> supervisor + npmpkg --> supervisor + cache --> supervisor makedev --> supervisor binary --> supervisor @@ -28,51 +34,66 @@ flowchart TB ## Overview -The Kandev CLI (`kandev`) is the primary way to run the Kandev application. It handles downloading release bundles, managing processes, and providing a unified interface for both end users and developers. +The Kandev CLI (`kandev`) is the primary way to run the Kandev application. It launches the backend and web processes from an installed runtime bundle and provides a unified interface for end users and developers. ## Installation -No installation required. Use npx: +### Homebrew (macOS, Linux) ```bash -npx kandev +brew install kdlbs/kandev/kandev ``` -Or install globally: +### NPX (requires npm 7+) ```bash -npm install -g kandev +npx kandev@latest +``` + +`npx` installs the `kandev` CLI plus the matching `@kdlbs/runtime-{platform}` package via npm optional dependencies. Older npm versions silently skip optional deps and won't work — npm 7 is the floor. + +### NPM (global) + +```bash +npm install -g kandev@latest ``` ## Quick Start ```bash -# Run the latest release (recommended for users) -npx kandev +# Run the installed runtime +kandev -# Opens the app at http://localhost:37429 (or next available port) +# Opens the app at http://localhost:38429 (or next available port) +``` + +## Updates + +The package manager controls the runtime version: + +```bash +brew upgrade kandev # Homebrew users +npm install -g kandev@latest # global npm users +npx kandev@latest # npx users (always latest) ``` ## Commands ### `kandev` / `kandev run` -Downloads and runs the latest release bundle from GitHub. This is the default command and recommended for end users. +Runs the installed runtime bundle. This is the default command. ```bash -npx kandev -npx kandev run -npx kandev run --version v0.1.0 +kandev +kandev run ``` **What happens:** -1. Checks for CLI updates (prompts if newer version available) -2. Downloads the platform-specific release bundle from GitHub -3. Extracts to `~/.kandev/bin///` -4. Starts the backend server -5. Waits for backend health check -6. Starts the web app -7. Prints URLs when ready +1. Resolves the runtime bundle (KANDEV_BUNDLE_DIR → npm package → cache). +2. Starts the backend server. +3. Waits for backend health check. +4. Starts the web app. +5. Opens browser when ready. ### `kandev dev` @@ -80,53 +101,49 @@ Runs the application in development mode with hot-reloading. Requires a local re ```bash # From the repo root or any subdirectory -npx kandev dev +kandev dev ``` **What happens:** -1. Locates the repo root (looks for `apps/backend` and `apps/web`) -2. Runs `make dev` for the backend (Go with hot-reload) -3. Runs `pnpm dev` for the web app (Next.js dev server) -4. Both processes run with hot-reloading enabled +1. Locates the repo root (looks for `apps/backend` and `apps/web`). +2. Runs `make dev` for the backend (Go with hot-reload). +3. Runs `pnpm dev` for the web app (Next.js dev server). ### `kandev start` Runs the application using local production builds. Requires running `make build` first. ```bash -# Build first make build - -# Then start -npx kandev start +kandev start ``` -**What happens:** -1. Locates the repo root -2. Runs the compiled backend binary (`apps/backend/bin/kandev`) -3. Runs `pnpm start` for the web app (Next.js production server) -4. Both run in production mode without hot-reloading - ## Options | Option | Description | Example | |--------|-------------|---------| -| `--version ` | Use a specific release version | `--version v0.1.0` | -| `--backend-port ` | Override backend port | `--backend-port 18080` | -| `--web-port ` | Override web port | `--web-port 13000` | +| `--version`, `-V` | Print CLI version and exit | `kandev --version` | +| `--port ` | Backend port (alias: `--backend-port`) | `--port 3000` | +| `--web-internal-port ` | Override internal Next.js port | `--web-internal-port 13000` | +| `--verbose`, `-v` | Show info logs from backend + web | `--verbose` | +| `--debug` | Show debug logs + agent message dumps | `--debug` | | `--help`, `-h` | Show help | `--help` | +| `--runtime-version ` | **Advanced/debug only**: download a specific runtime tag from GitHub releases instead of using the installed runtime | `--runtime-version v0.16.0` | ### Examples ```bash -# Use specific release -npx kandev --version v0.1.0 +# Print CLI version +kandev --version # Custom ports -npx kandev --backend-port 18080 --web-port 13000 +kandev --port 18080 --web-internal-port 13000 -# Dev mode with custom ports -npx kandev dev --backend-port 18080 --web-port 13000 +# Dev mode +kandev dev --port 18080 + +# Force a specific runtime version (debug) +kandev --runtime-version v0.16.0 ``` ## Port Selection @@ -135,10 +152,9 @@ By default, the CLI automatically finds available ports: | Service | Default Port | Fallback | |---------|--------------|----------| -| Backend | 38429 | Auto-selects from 10000-60000 | -| Web | 37429 | Auto-selects from 10000-60000 | -| AgentCtl | 39429 | Auto-selects from 10000-60000 | -| MCP Server | 40429 | Auto-selects from 10000-60000 | +| Backend | 38429 | Auto-selects from 10000–60000 | +| Web | 37429 | Auto-selects from 10000–60000 | +| AgentCtl | 39429 | Auto-selects from 10000–60000 | If the default port is in use, the CLI finds the next available port automatically. @@ -146,25 +162,21 @@ If the default port is in use, the CLI finds the next available port automatical | Variable | Description | |----------|-------------| -| `KANDEV_GITHUB_OWNER` | Override GitHub repo owner for releases | -| `KANDEV_GITHUB_REPO` | Override GitHub repo name for releases | -| `KANDEV_GITHUB_TOKEN` | GitHub token for API rate limits | -| `KANDEV_NO_UPDATE_PROMPT=1` | Disable CLI update prompts | +| `KANDEV_BUNDLE_DIR` | Force the runtime bundle location (set by Homebrew wrapper) | +| `KANDEV_PORT` / `KANDEV_BACKEND_PORT` | Backend port (CLI flag wins) | +| `KANDEV_WEB_PORT` | Internal Next.js port | | `KANDEV_HEALTH_TIMEOUT_MS` | Override health check timeout (ms) | +| `KANDEV_GITHUB_OWNER` / `KANDEV_GITHUB_REPO` | Override GitHub repo for `--runtime-version` downloads | +| `KANDEV_GITHUB_TOKEN` | GitHub token for `--runtime-version` API access | ## Makefile Integration The repo includes a Makefile that wraps the CLI for common operations: ```bash -# Install deps, build, and start in production mode -make start - -# Run in development mode -make dev - -# Build everything -make build +make start # Production mode (after make build) +make dev # Development mode +make build # Build everything ``` See `make help` for all available commands. @@ -173,7 +185,7 @@ See `make help` for all available commands. | Feature | `run` | `dev` | `start` | |---------|-------|-------|---------| -| Source | GitHub releases | Local repo | Local build | +| Source | Installed runtime | Local repo | Local build | | Hot-reload | No | Yes | No | | Requires repo | No | Yes | Yes | | Requires build | No | No | Yes | @@ -181,12 +193,25 @@ See `make help` for all available commands. ## Troubleshooting +### "No Kandev runtime found for {platform}" + +The CLI couldn't find an installed runtime. Install one: + +```bash +# via npm (requires npm 7+ for optional dep resolution) +npx kandev@latest +# via Homebrew +brew install kdlbs/kandev/kandev +``` + +If you're on npm 6 or older, optional dependencies aren't installed by `npx`. Upgrade npm: `npm install -g npm@latest`. + ### Port Already in Use The CLI automatically finds available ports. If you need a specific port: ```bash -npx kandev --backend-port 18080 --web-port 13000 +kandev --port 18080 ``` ### Backend Takes Too Long to Start @@ -194,15 +219,7 @@ npx kandev --backend-port 18080 --web-port 13000 Increase the health check timeout: ```bash -KANDEV_HEALTH_TIMEOUT_MS=60000 npx kandev -``` - -### GitHub Rate Limits - -If you hit GitHub API rate limits, provide a token: - -```bash -KANDEV_GITHUB_TOKEN=ghp_xxx npx kandev +KANDEV_HEALTH_TIMEOUT_MS=60000 kandev ``` ### Dev Mode: "Unable to locate repo root" @@ -211,7 +228,7 @@ Run from within the kandev repository: ```bash cd /path/to/kandev -npx kandev dev +kandev dev ``` ### Start Mode: "Backend binary not found" @@ -220,12 +237,14 @@ Build the project first: ```bash make build -npx kandev start +kandev start ``` ## Data Storage | Path | Contents | |------|----------| -| `~/.kandev/bin/` | Downloaded release bundles | +| `~/.kandev/bin/` | Cached downloads from `--runtime-version` (debug only) | | `~/.kandev/data/` | SQLite database and app data | +| Homebrew Cellar | Installed runtime (when installed via brew) | +| `/node_modules/@kdlbs/runtime-{platform}/` | Installed runtime (when installed via npm/npx) | diff --git a/scripts/release/package-cli.sh b/scripts/release/package-cli.sh new file mode 100755 index 0000000000..573f08e6aa --- /dev/null +++ b/scripts/release/package-cli.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Build the CLI and copy artifacts into dist/kandev/cli/ for inclusion in release bundles. +# +# The CLI bundle (dist/cli.bundle.js) is a single-file esbuild output with all Node +# dependencies inlined. This is required for Homebrew installs where there is no +# node_modules directory in the Cellar. +# +# Output layout inside dist/kandev/cli/: +# bin/cli.js - Node entrypoint (requires #!/usr/bin/env node shebang + chmod +x) +# dist/cli.bundle.js - Self-contained bundle with inlined deps (used by Homebrew) +# package.json - Package metadata (used to read version at runtime) +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CLI_DIR="$ROOT_DIR/apps/cli" +OUT_DIR="$ROOT_DIR/dist/kandev/cli" + +echo "Building CLI for release bundle..." + +# Build TypeScript +pnpm -C "$ROOT_DIR/apps" --filter kandev build +echo " TypeScript build complete" + +# Bundle with esbuild (inlines tree-kill and all other deps) +pnpm -C "$ROOT_DIR/apps/cli" bundle +echo " esbuild bundle complete" + +# Copy artifacts into dist/kandev/cli/ +rm -rf "$OUT_DIR" +mkdir -p "$OUT_DIR/bin" "$OUT_DIR/dist" + +# Write a bin/cli.js that points at the bundled dist (for Homebrew/manual installs +# where there is no node_modules to resolve the unbundled dist/cli.js's deps). +# The npm package's bin/cli.js is different — it requires the unbundled dist/cli.js +# because tree-kill etc. are installed normally via npm. +cat > "$OUT_DIR/bin/cli.js" <<'EOF' +#!/usr/bin/env node + +require("../dist/cli.bundle.js"); +EOF +chmod +x "$OUT_DIR/bin/cli.js" + +cp "$CLI_DIR/dist/cli.bundle.js" "$OUT_DIR/dist/cli.bundle.js" +cp "$CLI_DIR/package.json" "$OUT_DIR/package.json" + +echo "CLI artifacts packaged at $OUT_DIR" +echo " bin/cli.js (entrypoint, requires dist/cli.bundle.js, chmod +x)" +echo " dist/cli.bundle.js (self-contained bundle, deps inlined)" +echo " package.json" diff --git a/scripts/release/package-npm-runtime.sh b/scripts/release/package-npm-runtime.sh new file mode 100755 index 0000000000..2bf4004eb0 --- /dev/null +++ b/scripts/release/package-npm-runtime.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Generate per-platform npm runtime packages from built dist/release-assets. +# +# Each platform bundle (kandev-{platform}.tar.gz) is repackaged as an npm package +# containing only bin/ and web/ (no cli/). These are the @kdlbs/runtime-* packages +# that the main kandev npm package declares as optionalDependencies. +# +# Usage: +# package-npm-runtime.sh +# +# Arguments: +# version SemVer string (e.g. 0.17.0) +# release-assets-dir Directory containing kandev-*.tar.gz files +# output-dir Directory where per-platform npm packages are written +# +# Output (one directory per platform ready for npm publish): +# /@kdlbs/runtime-linux-x64/ +# /@kdlbs/runtime-linux-arm64/ +# /@kdlbs/runtime-darwin-x64/ +# /@kdlbs/runtime-darwin-arm64/ +# /@kdlbs/runtime-win32-x64/ +set -euo pipefail + +VERSION="${1:?Usage: $0 }" +ASSETS_DIR="${2:?Usage: $0 }" +OUT_DIR="${3:?Usage: $0 }" + +# Maps platform dir name → npm package name + npm os/cpu fields +declare -A PLATFORM_TO_PACKAGE=( + ["linux-x64"]="@kdlbs/runtime-linux-x64" + ["linux-arm64"]="@kdlbs/runtime-linux-arm64" + ["macos-x64"]="@kdlbs/runtime-darwin-x64" + ["macos-arm64"]="@kdlbs/runtime-darwin-arm64" + ["windows-x64"]="@kdlbs/runtime-win32-x64" +) + +declare -A PLATFORM_TO_OS=( + ["linux-x64"]='["linux"]' + ["linux-arm64"]='["linux"]' + ["macos-x64"]='["darwin"]' + ["macos-arm64"]='["darwin"]' + ["windows-x64"]='["win32"]' +) + +declare -A PLATFORM_TO_CPU=( + ["linux-x64"]='["x64"]' + ["linux-arm64"]='["arm64"]' + ["macos-x64"]='["x64"]' + ["macos-arm64"]='["arm64"]' + ["windows-x64"]='["x64"]' +) + +echo "Packaging npm runtime packages for version $VERSION..." +echo " assets dir: $ASSETS_DIR" +echo " output dir: $OUT_DIR" + +for platform in linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64; do + archive="$ASSETS_DIR/kandev-${platform}.tar.gz" + if [[ ! -f "$archive" ]]; then + echo "Error: missing archive $archive" >&2 + exit 1 + fi + + package_name="${PLATFORM_TO_PACKAGE[$platform]}" + # @kdlbs/runtime-linux-x64 → scope=@kdlbs, name=runtime-linux-x64 + scope_dir="${package_name%%/*}" # @kdlbs + pkg_dir="${package_name##*/}" # runtime-linux-x64 + pkg_out="$OUT_DIR/${scope_dir}/${pkg_dir}" + + rm -rf "$pkg_out" + mkdir -p "$pkg_out" + + # Extract only bin/ and web/ from the archive (skip cli/) + local_tmp="$pkg_out/.extract_tmp" + mkdir -p "$local_tmp" + tar -xzf "$archive" -C "$local_tmp" + + # Find the extracted bundle root (named "kandev" inside the archive) + bundle_root="$local_tmp/kandev" + if [[ ! -d "$bundle_root" ]]; then + echo "Error: expected kandev/ directory in $archive" >&2 + exit 1 + fi + + cp -R "$bundle_root/bin" "$pkg_out/bin" + cp -R "$bundle_root/web" "$pkg_out/web" + rm -rf "$local_tmp" + + os_field="${PLATFORM_TO_OS[$platform]}" + cpu_field="${PLATFORM_TO_CPU[$platform]}" + + cat > "$pkg_out/package.json" < must already exist (verified before publishing). +# - Running inside GitHub Actions with `id-token: write` permission set on +# the publish-npm job. (npm publish from a local shell will fall back to +# classic auth — but tokens are not the recommended path going forward.) +# +# Usage: +# publish-npm.sh +# +# Arguments: +# version SemVer string (e.g. 0.17.0) +# tag Git tag (e.g. v0.17.0) — used to verify GitHub release assets exist +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +VERSION="${1:?Usage: $0 }" +TAG="${2:?Usage: $0 }" + +bold() { printf '\033[1m%s\033[0m' "$*"; } +green() { printf '\033[32m%s\033[0m' "$*"; } +red() { printf '\033[31m%s\033[0m' "$*"; } +yellow(){ printf '\033[33m%s\033[0m' "$*"; } + +log() { echo " >> $*"; } +log_ok() { echo " $(green "ok") $*"; } + +die() { + echo "$(red "Error:") $*" >&2 + exit 1 +} + +# -- Verify GitHub release assets exist before publishing --------------------- + +REQUIRED_PLATFORMS=(linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64) + +log "Verifying GitHub release assets exist for $TAG..." +for platform in "${REQUIRED_PLATFORMS[@]}"; do + asset="kandev-${platform}.tar.gz" + if ! gh release view "$TAG" --json assets --jq ".assets[].name" 2>/dev/null | grep -q "^${asset}$"; then + die "GitHub release asset missing: $asset in release $TAG. Run release workflow first." + fi +done +log_ok "All 5 platform assets present in GitHub release $TAG" + +# -- Download release assets for packaging ------------------------------------ + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT +ASSETS_DIR="$WORK_DIR/assets" +mkdir -p "$ASSETS_DIR" + +log "Downloading release assets for $TAG..." +for platform in "${REQUIRED_PLATFORMS[@]}"; do + asset="kandev-${platform}.tar.gz" + log " downloading $asset..." + gh release download "$TAG" --pattern "$asset" --dir "$ASSETS_DIR" +done +log_ok "Assets downloaded to $ASSETS_DIR" + +# -- Generate npm runtime packages -------------------------------------------- + +NPM_PKG_DIR="$WORK_DIR/npm-packages" +bash "$ROOT_DIR/scripts/release/package-npm-runtime.sh" "$VERSION" "$ASSETS_DIR" "$NPM_PKG_DIR" + +# -- Publish @kdlbs/runtime-* packages first --------------------------------- + +RUNTIME_PACKAGES=( + "@kdlbs/runtime-linux-x64" + "@kdlbs/runtime-linux-arm64" + "@kdlbs/runtime-darwin-x64" + "@kdlbs/runtime-darwin-arm64" + "@kdlbs/runtime-win32-x64" +) + +echo +echo "$(bold "Publishing @kdlbs/runtime-* packages...")" +FAILED_PACKAGES=() +ALREADY_PUBLISHED=() + +for pkg in "${RUNTIME_PACKAGES[@]}"; do + scope="${pkg%%/*}" # @kdlbs + name="${pkg##*/}" # runtime-linux-x64 + pkg_dir="$NPM_PKG_DIR/${scope}/${name}" + + if [[ ! -d "$pkg_dir" ]]; then + echo " $(red "missing") $pkg_dir (package directory was not generated)" >&2 + FAILED_PACKAGES+=("$pkg") + continue + fi + + log "Publishing $pkg@$VERSION..." + # Capture full npm output so we can show the real error on failure rather + # than just a generic warning. Distinguish "already published" (idempotent + # case — fine) from real failures (must abort). + if output="$(cd "$pkg_dir" && npm publish --access public --provenance 2>&1)"; then + log_ok "$pkg@$VERSION published" + elif echo "$output" | grep -qE "EPUBLISHCONFLICT|cannot publish over the previously published versions|You cannot publish over"; then + echo " $(yellow "skip") $pkg@$VERSION already published (treated as idempotent success)" >&2 + ALREADY_PUBLISHED+=("$pkg") + else + echo " $(red "FAIL") Failed to publish $pkg@$VERSION:" >&2 + echo "$output" | sed 's/^/ /' >&2 + FAILED_PACKAGES+=("$pkg") + fi +done + +# Abort before publishing main kandev if any runtime publish failed. +# Otherwise users on those platforms would get "No Kandev runtime found" +# after install, with kandev pointing at @kdlbs/runtime-*@VERSION that +# doesn't exist on npm. +if [[ "${#FAILED_PACKAGES[@]}" -gt 0 ]]; then + echo + echo "$(red "Error:") The following runtime packages failed to publish:" >&2 + for pkg in "${FAILED_PACKAGES[@]}"; do + echo " - $pkg" >&2 + done + echo >&2 + echo "Refusing to publish main kandev@$VERSION. Fix the runtime publish failures" >&2 + echo "and re-run this script (already-published runtime packages will be skipped)." >&2 + exit 1 +fi + +# -- Pin optionalDependencies before publishing main kandev ------------------ +# +# In committed source, optionalDependencies reference 0.0.0-bootstrap so the +# lockfile resolves during normal development. For the published kandev@VERSION +# package, we want optionalDependencies to point at @kdlbs/runtime-*@VERSION +# so users get matching runtime bundles. The runtime packages were just +# published above, so this version exists on npm now. +log "Pinning optionalDependencies to $VERSION before publishing main package..." +node -e " + const fs = require('fs'); + const path = '$ROOT_DIR/apps/cli/package.json'; + const pkg = JSON.parse(fs.readFileSync(path, 'utf8')); + if (pkg.optionalDependencies) { + for (const k of Object.keys(pkg.optionalDependencies)) { + pkg.optionalDependencies[k] = '$VERSION'; + } + } + fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n'); +" +log_ok "optionalDependencies pinned to $VERSION" + +# -- Publish main kandev package ---------------------------------------------- + +echo +echo "$(bold "Publishing kandev@$VERSION...")" +# Same idempotency handling as the runtime packages: capture output, treat +# "already published" as success so partial-failure re-runs converge. +# `prepublishOnly` (in package.json) runs `pnpm build` automatically. +if main_output="$(cd "$ROOT_DIR/apps/cli" && npm publish --access public --provenance 2>&1)"; then + log_ok "kandev@$VERSION published" +elif echo "$main_output" | grep -qE "EPUBLISHCONFLICT|cannot publish over the previously published versions|You cannot publish over"; then + echo " $(yellow "skip") kandev@$VERSION already published (treated as idempotent success)" >&2 + ALREADY_PUBLISHED+=("kandev") +else + echo " $(red "FAIL") Failed to publish kandev@$VERSION:" >&2 + echo "$main_output" | sed 's/^/ /' >&2 + exit 1 +fi + +# -- Report ------------------------------------------------------------------- +# (WORK_DIR cleanup happens via the EXIT trap above.) + +echo +echo "$(green "$(bold "All npm packages published successfully!")")" +if [[ "${#ALREADY_PUBLISHED[@]}" -gt 0 ]]; then + echo " $(yellow "note") The following runtime packages were already published at $VERSION:" + for pkg in "${ALREADY_PUBLISHED[@]}"; do + echo " - $pkg" + done +fi diff --git a/scripts/release/release-pr.sh b/scripts/release/release-pr.sh deleted file mode 100755 index cd3d1d7e32..0000000000 --- a/scripts/release/release-pr.sh +++ /dev/null @@ -1,812 +0,0 @@ -#!/usr/bin/env bash -# ----------------------------------------------------------------------------- -# release-pr.sh -# -# Unified release script for: -# - CLI npm release (apps/cli) -# - App runtime release (git tag that triggers GitHub Actions release workflow) -# - Both in one run -# -# Versioning model: -# - App tag format: vM.m -# - CLI npm version format: M.m.0 -# - Default bump mode: minor (major bump is explicit) -# -# High-level flow: -# 1) Validate preconditions (tools, clean tree, main branch, fetch tags) -# 2) Select release target (cli | app | both) -# 3) Auto-detect current versions/tags and compute next versions -# 4) Show a release plan and require confirmation -# 5) Execute release actions: -# - CLI: bump package version, install deps, build -# - App: generate CHANGELOG.md -# - Create release branch, commit, open PR, squash-merge -# - App: create/push release tag (after PR merge) -# - CLI: npm publish (after PR merge) -# -# Safety: -# - Use --dry-run to preview every action without mutating repo or publishing. -# - Use --yes for non-interactive confirmation. -# ----------------------------------------------------------------------------- -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -CLI_PACKAGE_JSON="$ROOT_DIR/apps/cli/package.json" - -TARGET="" -DEFAULT_BUMP="minor" -AUTO_YES=0 -DRY_RUN=0 - -CLI_SELECTED=0 -APP_SELECTED=0 - -CURRENT_BRANCH="" -CURRENT_CLI_VERSION="" -CURRENT_CLI_MAJOR=0 -CURRENT_CLI_MINOR=0 -CURRENT_CLI_PATCH=0 -CURRENT_APP_TAG="" -CURRENT_APP_TAG_FOUND=0 -CURRENT_APP_MAJOR=0 -CURRENT_APP_MINOR=0 - -CLI_BUMP="" -APP_BUMP="" -NEXT_CLI_VERSION="" -NEXT_APP_TAG="" -RELEASE_BRANCH="" - -CURRENT_STEP=0 -TOTAL_STEPS=0 - -# -- Formatting helpers ------------------------------------------------------- - -bold() { printf '\033[1m%s\033[0m' "$*"; } -dim() { printf '\033[2m%s\033[0m' "$*"; } -green() { printf '\033[32m%s\033[0m' "$*"; } -yellow() { printf '\033[33m%s\033[0m' "$*"; } -red() { printf '\033[31m%s\033[0m' "$*"; } -cyan() { printf '\033[36m%s\033[0m' "$*"; } - -log() { echo " $(dim ">>") $*"; } -phase() { echo; echo "$(bold "$*")"; } -log_ok() { echo " $(green "ok") $*"; } -log_skip() { echo " $(dim "skip") $*"; } - -step() { - ((CURRENT_STEP++)) - echo - echo "$(bold "[$CURRENT_STEP/$TOTAL_STEPS]") $1" -} - -# -- Core helpers -------------------------------------------------------------- - -usage() { - cat <<'EOF' -Usage: scripts/release/release-pr.sh [options] - -Options: - --target Release target. If omitted, prompt interactively. - --bump Bump mode. Default: minor. - --yes Skip confirmation prompts. - --dry-run Print actions without making changes/publishes. - --help, -h Show help. - -Versioning: - - App tags: vM.m - - CLI package version: M.m.0 - -Prerequisites: - - Run from a clean working tree on branch main. - - Required tools: git, node, npm, pnpm, git-cliff, gh. - -Examples: - # Fully interactive release wizard - scripts/release/release-pr.sh - - # Non-interactive CLI minor release - scripts/release/release-pr.sh --target cli --bump minor --yes - - # Preview both releases with major bump - scripts/release/release-pr.sh --target both --bump major --dry-run -EOF -} - -die() { - echo "$(red "Error:") $*" >&2 - exit 1 -} - -command_exists() { - command -v "$1" >/dev/null 2>&1 -} - -run_cmd() { - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") $*" - return 0 - fi - "$@" -} - -confirm() { - local prompt="$1" - if [[ "$AUTO_YES" -eq 1 ]]; then - return 0 - fi - echo - local answer - read -r -p "$(bold "$prompt") [y/N]: " answer - answer="$(echo "${answer:-}" | tr '[:upper:]' '[:lower:]')" - [[ "$answer" == "y" || "$answer" == "yes" ]] -} - -validate_bump() { - case "$1" in - minor|major) ;; - *) die "Invalid bump '$1'. Use 'minor' or 'major'." ;; - esac -} - -compute_total_steps() { - TOTAL_STEPS=1 # create release PR (always) - [[ "$CLI_SELECTED" -eq 1 ]] && ((TOTAL_STEPS++)) # build CLI - [[ "$APP_SELECTED" -eq 1 ]] && ((TOTAL_STEPS++)) # create app tag - [[ "$CLI_SELECTED" -eq 1 ]] && ((TOTAL_STEPS++)) # publish CLI -} - -release_branch_name() { - if [[ "$CLI_SELECTED" -eq 1 && "$APP_SELECTED" -eq 1 ]]; then - echo "release/app-${NEXT_APP_TAG}-cli-${NEXT_CLI_VERSION}" - elif [[ "$CLI_SELECTED" -eq 1 ]]; then - echo "release/cli-${NEXT_CLI_VERSION}" - else - echo "release/app-${NEXT_APP_TAG}" - fi -} - -# -- Target selection ---------------------------------------------------------- - -parse_target() { - case "$1" in - cli) - CLI_SELECTED=1 - APP_SELECTED=0 - ;; - app) - CLI_SELECTED=0 - APP_SELECTED=1 - ;; - both) - CLI_SELECTED=1 - APP_SELECTED=1 - ;; - *) - die "Invalid target '$1'. Use cli, app, or both." - ;; - esac -} - -select_target_interactive() { - echo - echo "$(bold "What do you want to release?")" - echo " $(cyan "1)") cli $(dim "- npm publish apps/cli")" - echo " $(cyan "2)") app $(dim "- git tag for GitHub Actions release")" - echo " $(cyan "3)") both $(dim "- cli + app together")" - echo - local choice - read -r -p "$(bold "Choice") [1=cli / 2=app / 3=both]: " choice - case "$choice" in - 1|cli) parse_target "cli" ;; - 2|app) parse_target "app" ;; - 3|both) parse_target "both" ;; - *) die "Invalid choice '$choice'. Enter 1, 2, 3, cli, app, or both." ;; - esac - - local selected="" - [[ "$CLI_SELECTED" -eq 1 ]] && selected+="cli " - [[ "$APP_SELECTED" -eq 1 ]] && selected+="app " - log "Selected target: $(bold "$selected")" -} - -# -- Version parsing ----------------------------------------------------------- - -parse_cli_version() { - local version="$1" - if [[ ! "$version" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+))?$ ]]; then - die "CLI version '$version' is not valid. Expected M.m.0 format." - fi - CURRENT_CLI_MAJOR="${BASH_REMATCH[1]}" - CURRENT_CLI_MINOR="${BASH_REMATCH[2]}" - CURRENT_CLI_PATCH="${BASH_REMATCH[4]:-0}" -} - -detect_current_cli_version() { - [[ -f "$CLI_PACKAGE_JSON" ]] || die "Missing $CLI_PACKAGE_JSON" - CURRENT_CLI_VERSION="$(node -p "require('$CLI_PACKAGE_JSON').version")" - parse_cli_version "$CURRENT_CLI_VERSION" - log "Current CLI version: $(bold "$CURRENT_CLI_VERSION") $(dim "(from apps/cli/package.json)")" -} - -detect_current_app_version() { - local tags - tags="$(git -C "$ROOT_DIR" tag --list 'v*')" - - CURRENT_APP_TAG_FOUND=0 - local found=0 - local best_major=0 - local best_minor=0 - local tag - - while IFS= read -r tag; do - [[ -z "$tag" ]] && continue - if [[ "$tag" =~ ^v([0-9]+)\.([0-9]+)$ ]]; then - local major="${BASH_REMATCH[1]}" - local minor="${BASH_REMATCH[2]}" - if [[ "$found" -eq 0 ]] || (( 10#$major > 10#$best_major )) || \ - (( 10#$major == 10#$best_major && 10#$minor > 10#$best_minor )); then - found=1 - best_major="$major" - best_minor="$minor" - fi - fi - done <<<"$tags" - - CURRENT_APP_MAJOR="$best_major" - CURRENT_APP_MINOR="$best_minor" - CURRENT_APP_TAG="v${CURRENT_APP_MAJOR}.${CURRENT_APP_MINOR}" - CURRENT_APP_TAG_FOUND="$found" - - if [[ "$found" -eq 1 ]]; then - log "Current app tag: $(bold "$CURRENT_APP_TAG") $(dim "(latest git tag matching vM.m)")" - else - log "Current app tag: $(dim "none found") $(dim "(will start at v0.1)")" - fi -} - -# -- Bump selection ------------------------------------------------------------ - -next_minor_pair() { - local major="$1" - local minor="$2" - echo "${major}.$((10#$minor + 1))" -} - -next_major_pair() { - local major="$1" - echo "$((10#$major + 1)).0" -} - -choose_bump() { - local subject="$1" - local current="$2" - local next_minor="$3" - local next_major="$4" - - if [[ -n "$DEFAULT_BUMP" ]]; then - validate_bump "$DEFAULT_BUMP" - fi - - if [[ "$AUTO_YES" -eq 1 && -n "$DEFAULT_BUMP" ]]; then - echo "$DEFAULT_BUMP" - return - fi - - if [[ -n "$DEFAULT_BUMP" && "$TARGET" != "" ]]; then - echo "$DEFAULT_BUMP" - return - fi - - echo >&2 - echo "$(bold "$subject version bump") $(dim "(current: $current)")" >&2 - echo " $(cyan "1)") minor -> $(bold "$next_minor") $(dim "(default)")" >&2 - echo " $(cyan "2)") major -> $(bold "$next_major")" >&2 - local choice - read -r -p "$(bold "Bump") [1=minor / 2=major]: " choice - case "${choice:-1}" in - 1|minor) echo "minor" ;; - 2|major) echo "major" ;; - *) die "Invalid bump choice '$choice'. Enter 1, 2, minor, or major." ;; - esac -} - -# -- Version computation ------------------------------------------------------- - -compute_next_versions() { - if [[ "$CLI_SELECTED" -eq 1 ]]; then - local cli_minor_pair cli_major_pair - cli_minor_pair="$(next_minor_pair "$CURRENT_CLI_MAJOR" "$CURRENT_CLI_MINOR")" - cli_major_pair="$(next_major_pair "$CURRENT_CLI_MAJOR")" - - CLI_BUMP="$(choose_bump "CLI" "${CURRENT_CLI_MAJOR}.${CURRENT_CLI_MINOR}.0" \ - "${cli_minor_pair}.0" "${cli_major_pair}.0")" - validate_bump "$CLI_BUMP" - - if [[ "$CLI_BUMP" == "minor" ]]; then - NEXT_CLI_VERSION="${cli_minor_pair}.0" - else - NEXT_CLI_VERSION="${cli_major_pair}.0" - fi - log "CLI bump: $CLI_BUMP -> $(bold "$NEXT_CLI_VERSION")" - fi - - if [[ "$APP_SELECTED" -eq 1 ]]; then - local app_minor_pair app_major_pair - app_minor_pair="$(next_minor_pair "$CURRENT_APP_MAJOR" "$CURRENT_APP_MINOR")" - app_major_pair="$(next_major_pair "$CURRENT_APP_MAJOR")" - - APP_BUMP="$(choose_bump "App" "$CURRENT_APP_TAG" "v${app_minor_pair}" "v${app_major_pair}")" - validate_bump "$APP_BUMP" - - if [[ "$APP_BUMP" == "minor" ]]; then - NEXT_APP_TAG="v${app_minor_pair}" - else - NEXT_APP_TAG="v${app_major_pair}" - fi - log "App bump: $APP_BUMP -> $(bold "$NEXT_APP_TAG")" - fi -} - -# -- Preconditions ------------------------------------------------------------- - -ensure_prerequisites() { - phase "Checking prerequisites" - - for tool in git node npm pnpm git-cliff gh; do - if command_exists "$tool"; then - log_ok "$tool" - else - die "$tool is required but not found in PATH." - fi - done - - CURRENT_BRANCH="$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD)" - if [[ "$CURRENT_BRANCH" == "main" ]]; then - log_ok "On branch $(bold "main")" - else - die "Release must run from main branch (current: $CURRENT_BRANCH)." - fi - - if [[ -n "$(git -C "$ROOT_DIR" status --porcelain)" ]]; then - die "Working tree is not clean. Commit or stash changes before releasing." - fi - log_ok "Working tree is clean" - - log "Fetching tags from origin..." - run_cmd git -C "$ROOT_DIR" fetch --tags origin - log_ok "Tags fetched" -} - -ensure_npm_auth() { - [[ "$CLI_SELECTED" -eq 1 ]] || return 0 - [[ "$DRY_RUN" -eq 1 ]] && return 0 - - log "Checking npm authentication..." - local npm_user - if npm_user="$(npm whoami 2>/dev/null)"; then - log_ok "Logged in to npm as $(bold "$npm_user")" - else - echo - echo " $(yellow "npm auth required") — you are not logged in to npm." - echo " $(dim "This is needed to publish the CLI package.")" - echo - if [[ "$AUTO_YES" -eq 1 ]]; then - die "npm login required but --yes was specified. Run 'npm login' first." - fi - local answer - read -r -p "$(bold "Run npm login now?") [Y/n]: " answer - answer="$(echo "${answer:-y}" | tr '[:upper:]' '[:lower:]')" - if [[ "$answer" == "y" || "$answer" == "yes" || -z "$answer" ]]; then - npm login - # Verify login succeeded - if npm_user="$(npm whoami 2>/dev/null)"; then - log_ok "Logged in to npm as $(bold "$npm_user")" - else - die "npm login failed. Cannot publish CLI." - fi - else - die "npm login required to publish CLI. Run 'npm login' and try again." - fi - fi -} - -ensure_app_tag_available() { - [[ "$APP_SELECTED" -eq 1 ]] || return 0 - - log "Checking tag $(bold "$NEXT_APP_TAG") is available..." - if git -C "$ROOT_DIR" rev-parse "$NEXT_APP_TAG" >/dev/null 2>&1; then - die "Tag $NEXT_APP_TAG already exists locally." - fi - - if git -C "$ROOT_DIR" ls-remote --tags origin "refs/tags/$NEXT_APP_TAG" | grep -q .; then - die "Tag $NEXT_APP_TAG already exists on origin." - fi - log_ok "Tag $(bold "$NEXT_APP_TAG") is available" -} - -latest_origin_app_tag() { - local refs - if ! refs="$(git -C "$ROOT_DIR" ls-remote --tags origin 'refs/tags/v*')"; then - return 2 - fi - - local found=0 - local best_major=0 - local best_minor=0 - local ref tag - - while read -r _ ref; do - [[ -z "${ref:-}" ]] && continue - tag="${ref#refs/tags/}" - tag="${tag%\^\{\}}" - if [[ "$tag" =~ ^v([0-9]+)\.([0-9]+)$ ]]; then - local major="${BASH_REMATCH[1]}" - local minor="${BASH_REMATCH[2]}" - if [[ "$found" -eq 0 ]] || (( 10#$major > 10#$best_major )) || \ - (( 10#$major == 10#$best_major && 10#$minor > 10#$best_minor )); then - found=1 - best_major="$major" - best_minor="$minor" - fi - fi - done <<<"$refs" - - [[ "$found" -eq 1 ]] || return 1 - echo "v${best_major}.${best_minor}" -} - -ensure_latest_app_tag_present() { - [[ "$APP_SELECTED" -eq 1 ]] || return 0 - [[ "$DRY_RUN" -eq 1 ]] && return 0 - - log "Verifying latest app tag $(bold "$CURRENT_APP_TAG") is present locally..." - if [[ "$CURRENT_APP_TAG_FOUND" -eq 0 ]]; then - local origin_status - if latest_origin_app_tag >/dev/null; then - origin_status=0 - else - origin_status="$?" - fi - if [[ "$origin_status" -eq 0 ]]; then - die "No local app tag found, but origin has app tags. Fetch origin tags and retry." - fi - if [[ "$origin_status" -ne 1 ]]; then - die "Could not reach origin to verify app tags. Check your connection and retry." - fi - log_ok "No prior app tags on origin or locally" - return 0 - fi - - local origin_app_tag - if ! origin_app_tag="$(latest_origin_app_tag)"; then - die "Could not determine latest app tag from origin. Refusing to generate CHANGELOG.md." - fi - - if [[ "$CURRENT_APP_TAG" != "$origin_app_tag" ]]; then - die "Latest local app tag is $CURRENT_APP_TAG, but origin latest is $origin_app_tag. Refusing to generate CHANGELOG.md." - fi - - if ! git -C "$ROOT_DIR" rev-parse --verify --quiet "refs/tags/$CURRENT_APP_TAG" >/dev/null; then - die "Latest app tag $CURRENT_APP_TAG is missing locally after fetching origin tags. Refusing to generate CHANGELOG.md." - fi - - log_ok "Latest app tag $(bold "$CURRENT_APP_TAG") is present" -} - -# -- Release plan -------------------------------------------------------------- - -print_plan() { - echo - echo "$(bold "=== Release Plan ===")" - echo " branch: $(bold "$CURRENT_BRANCH")" - if [[ "$CLI_SELECTED" -eq 1 ]]; then - echo " cli: $(bold "$CURRENT_CLI_VERSION") -> $(green "$NEXT_CLI_VERSION") ($CLI_BUMP bump)" - fi - if [[ "$APP_SELECTED" -eq 1 ]]; then - echo " app tag: $(bold "$CURRENT_APP_TAG") -> $(green "$NEXT_APP_TAG") ($APP_BUMP bump)" - fi - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " mode: $(yellow "DRY RUN") $(dim "(no changes will be made)")" - fi - echo - - local n=0 - if [[ "$CLI_SELECTED" -eq 1 ]]; then - echo " $(dim "Steps for CLI:")" - echo " $(dim "1.") Bump version in apps/cli/package.json" - echo " $(dim "2.") Install dependencies (pnpm install)" - echo " $(dim "3.") Build CLI (pnpm build)" - fi - if [[ "$APP_SELECTED" -eq 1 ]]; then - echo " $(dim "Steps for App:")" - echo " $(dim "1.") Generate CHANGELOG.md (git-cliff)" - fi - echo " $(dim "Steps for Release:")" - ((n++)); echo " $(dim "$n.") Create release branch and open PR" - ((n++)); echo " $(dim "$n.") Squash-merge PR into main" - if [[ "$APP_SELECTED" -eq 1 ]]; then - ((n++)); echo " $(dim "$n.") Create and push annotated git tag $NEXT_APP_TAG" - fi - if [[ "$CLI_SELECTED" -eq 1 ]]; then - ((n++)); echo " $(dim "$n.") Publish CLI to npm" - fi - echo -} - -# -- Release actions ----------------------------------------------------------- - -commit_message() { - if [[ "$CLI_SELECTED" -eq 1 && "$APP_SELECTED" -eq 1 ]]; then - echo "release: app $NEXT_APP_TAG, cli $NEXT_CLI_VERSION" - elif [[ "$CLI_SELECTED" -eq 1 ]]; then - echo "release: cli $NEXT_CLI_VERSION" - elif [[ "$APP_SELECTED" -eq 1 ]]; then - echo "release: app $NEXT_APP_TAG" - else - echo "" - fi -} - -apply_cli_release() { - [[ "$CLI_SELECTED" -eq 1 ]] || return 0 - - step "Building CLI release" - - log "Bumping CLI version to $(bold "$NEXT_CLI_VERSION")..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") npm version --no-git-tag-version $NEXT_CLI_VERSION" - else - (cd "$ROOT_DIR/apps/cli" && npm version --no-git-tag-version "$NEXT_CLI_VERSION") - fi - log_ok "Version bumped in package.json" - - log "Installing dependencies..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") pnpm install --frozen-lockfile" - else - pnpm -C "$ROOT_DIR/apps" install --frozen-lockfile - fi - log_ok "Dependencies installed" - - log "Building CLI..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") pnpm -C apps/cli build" - else - pnpm -C "$ROOT_DIR/apps/cli" build - fi - log_ok "CLI built" -} - -generate_changelog() { - [[ "$APP_SELECTED" -eq 1 ]] || return 0 - - log "Refreshing origin tags before changelog generation..." - run_cmd git -C "$ROOT_DIR" fetch --tags origin - ensure_latest_app_tag_present - - log "Generating changelog for $(bold "$NEXT_APP_TAG")..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") git-cliff --tag $NEXT_APP_TAG -o CHANGELOG.md" - return 0 - fi - git-cliff --tag "$NEXT_APP_TAG" -o "$ROOT_DIR/CHANGELOG.md" - log_ok "CHANGELOG.md updated" -} - -create_release_pr() { - local msg - msg="$(commit_message)" - [[ -n "$msg" ]] || return 0 - - step "Creating release PR" - - RELEASE_BRANCH="$(release_branch_name)" - - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") git checkout -b $RELEASE_BRANCH" - [[ "$CLI_SELECTED" -eq 1 ]] && echo " $(yellow "[dry-run]") git add apps/cli/package.json" - [[ "$APP_SELECTED" -eq 1 ]] && echo " $(yellow "[dry-run]") git add CHANGELOG.md" - echo " $(yellow "[dry-run]") git commit -m \"$msg\"" - echo " $(yellow "[dry-run]") git push -u origin $RELEASE_BRANCH" - echo " $(yellow "[dry-run]") gh pr create --title \"$msg\"" - echo " $(yellow "[dry-run]") gh pr merge --squash --delete-branch" - echo " $(yellow "[dry-run]") git checkout main && git pull origin main" - return 0 - fi - - log "Creating release branch $(bold "$RELEASE_BRANCH")..." - git -C "$ROOT_DIR" checkout -b "$RELEASE_BRANCH" - log_ok "Branch created" - - log "Staging release files..." - [[ "$CLI_SELECTED" -eq 1 ]] && git -C "$ROOT_DIR" add apps/cli/package.json - [[ "$APP_SELECTED" -eq 1 && -f "$ROOT_DIR/CHANGELOG.md" ]] && git -C "$ROOT_DIR" add CHANGELOG.md - - if git -C "$ROOT_DIR" diff --cached --quiet; then - log_skip "No staged changes to commit" - git -C "$ROOT_DIR" checkout main - git -C "$ROOT_DIR" branch -d "$RELEASE_BRANCH" - return 0 - fi - - log "Committing: $(dim "$msg")" - git -C "$ROOT_DIR" commit -m "$msg" - log_ok "Committed" - - log "Pushing release branch..." - git -C "$ROOT_DIR" push -u origin "$RELEASE_BRANCH" - log_ok "Branch pushed" - - local pr_body - pr_body="Automated release PR."$'\n\n'"**Changes:**" - [[ "$CLI_SELECTED" -eq 1 ]] && pr_body+=$'\n'"- CLI: \`$CURRENT_CLI_VERSION\` → \`$NEXT_CLI_VERSION\`" - [[ "$APP_SELECTED" -eq 1 ]] && pr_body+=$'\n'"- App: \`$CURRENT_APP_TAG\` → \`$NEXT_APP_TAG\`" - - log "Creating pull request..." - local pr_url - pr_url="$(gh pr create \ - --title "$msg" \ - --body "$pr_body" \ - --head "$RELEASE_BRANCH" \ - --base main)" - log_ok "PR created: $(bold "$pr_url")" - - log "Merging PR (squash)..." - if gh pr merge "$RELEASE_BRANCH" --squash --delete-branch --subject "$msg" --body ""; then - log_ok "PR merged" - else - echo - echo " $(yellow "Auto-merge failed.") Merge the PR manually:" - echo " $(cyan "$pr_url")" - echo - if [[ "$AUTO_YES" -eq 1 ]]; then - die "Cannot proceed without PR merge in --yes mode. Merge the PR manually and re-run." - fi - read -r -p "$(bold "Press Enter after merging the PR...")" - # Verify PR was merged - local pr_state - pr_state="$(gh pr view "$RELEASE_BRANCH" --json state --jq '.state')" - if [[ "$pr_state" != "MERGED" ]]; then - die "PR is not merged (state: $pr_state). Cannot continue." - fi - log_ok "PR merged" - fi - - log "Switching to main and pulling..." - git -C "$ROOT_DIR" checkout main - git -C "$ROOT_DIR" pull origin main - # Clean up local release branch if it still exists - git -C "$ROOT_DIR" branch -d "$RELEASE_BRANCH" 2>/dev/null || true - log_ok "main is up to date" -} - -create_and_push_app_tag() { - [[ "$APP_SELECTED" -eq 1 ]] || return 0 - - step "Creating app tag" - - log "Creating annotated tag $(bold "$NEXT_APP_TAG")..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") git tag -a $NEXT_APP_TAG -m \"release: $NEXT_APP_TAG\"" - echo " $(yellow "[dry-run]") git push origin $NEXT_APP_TAG" - return 0 - fi - - git -C "$ROOT_DIR" tag -a "$NEXT_APP_TAG" -m "release: $NEXT_APP_TAG" - log_ok "Tag created" - - log "Pushing tag to origin..." - git -C "$ROOT_DIR" push origin "$NEXT_APP_TAG" - log_ok "Tag pushed (GitHub Actions release workflow will trigger)" -} - -publish_cli() { - [[ "$CLI_SELECTED" -eq 1 ]] || return 0 - - step "Publishing CLI to npm" - - log "Running npm publish --access public..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") npm publish --access public" - return 0 - fi - (cd "$ROOT_DIR/apps/cli" && npm publish --access public) - log_ok "Published to npm" -} - -# -- Argument parsing ---------------------------------------------------------- - -parse_args() { - while [[ $# -gt 0 ]]; do - case "$1" in - --target) - [[ $# -ge 2 ]] || die "--target requires a value." - TARGET="$2" - shift 2 - ;; - --bump) - [[ $# -ge 2 ]] || die "--bump requires a value." - DEFAULT_BUMP="$2" - shift 2 - ;; - --yes) - AUTO_YES=1 - shift - ;; - --dry-run) - DRY_RUN=1 - shift - ;; - --help|-h) - usage - exit 0 - ;; - *) - die "Unknown argument '$1'." - ;; - esac - done -} - -# -- Main ---------------------------------------------------------------------- - -main() { - parse_args "$@" - validate_bump "$DEFAULT_BUMP" - - echo - echo "$(bold "Kandev Release") $(dim "(PR mode)")" - if [[ "$DRY_RUN" -eq 1 ]]; then - echo "$(yellow " DRY RUN MODE - no changes will be made")" - fi - - ensure_prerequisites - - phase "Selecting target and version" - - if [[ -n "$TARGET" ]]; then - parse_target "$TARGET" - local selected="" - [[ "$CLI_SELECTED" -eq 1 ]] && selected+="cli " - [[ "$APP_SELECTED" -eq 1 ]] && selected+="app " - log "Target: $(bold "$selected") $(dim "(from --target flag)")" - else - # Interactive mode should prompt for target and bump choices. - DEFAULT_BUMP="" - select_target_interactive - fi - - ensure_npm_auth - detect_current_cli_version - detect_current_app_version - compute_next_versions - ensure_latest_app_tag_present - ensure_app_tag_available - compute_total_steps - print_plan - - if ! confirm "Proceed with release?"; then - echo - echo "Release cancelled." - exit 0 - fi - - apply_cli_release - generate_changelog - create_release_pr - create_and_push_app_tag - publish_cli - - echo - echo "$(green "$(bold "Release complete!")")" - if [[ "$CLI_SELECTED" -eq 1 ]]; then - echo " $(green "cli:") published $NEXT_CLI_VERSION to npm" - fi - if [[ "$APP_SELECTED" -eq 1 ]]; then - echo " $(green "app:") pushed tag $NEXT_APP_TAG to origin" - fi - echo -} - -main "$@" diff --git a/scripts/release/release.sh b/scripts/release/release.sh deleted file mode 100755 index ce0e75d013..0000000000 --- a/scripts/release/release.sh +++ /dev/null @@ -1,648 +0,0 @@ -#!/usr/bin/env bash -# ----------------------------------------------------------------------------- -# release.sh -# -# Unified release script for: -# - CLI npm release (apps/cli) -# - App runtime release (git tag that triggers GitHub Actions release workflow) -# - Both in one run -# -# Versioning model: -# - App tag format: vM.m -# - CLI npm version format: M.m.0 -# - Default bump mode: minor (major bump is explicit) -# -# High-level flow: -# 1) Validate preconditions (tools, clean tree, main branch, fetch tags) -# 2) Select release target (cli | app | both) -# 3) Auto-detect current versions/tags and compute next versions -# 4) Show a release plan and require confirmation -# 5) Execute release actions: -# - CLI: bump package version, install deps, build, commit/push, npm publish -# - App: create/push release tag -# -# Safety: -# - Use --dry-run to preview every action without mutating repo or publishing. -# - Use --yes for non-interactive confirmation. -# ----------------------------------------------------------------------------- -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -CLI_PACKAGE_JSON="$ROOT_DIR/apps/cli/package.json" - -TARGET="" -DEFAULT_BUMP="minor" -AUTO_YES=0 -DRY_RUN=0 - -CLI_SELECTED=0 -APP_SELECTED=0 - -CURRENT_BRANCH="" -CURRENT_CLI_VERSION="" -CURRENT_CLI_MAJOR=0 -CURRENT_CLI_MINOR=0 -CURRENT_CLI_PATCH=0 -CURRENT_APP_TAG="" -CURRENT_APP_MAJOR=0 -CURRENT_APP_MINOR=0 - -CLI_BUMP="" -APP_BUMP="" -NEXT_CLI_VERSION="" -NEXT_APP_TAG="" - -# -- Formatting helpers ------------------------------------------------------- - -bold() { printf '\033[1m%s\033[0m' "$*"; } -dim() { printf '\033[2m%s\033[0m' "$*"; } -green() { printf '\033[32m%s\033[0m' "$*"; } -yellow() { printf '\033[33m%s\033[0m' "$*"; } -red() { printf '\033[31m%s\033[0m' "$*"; } -cyan() { printf '\033[36m%s\033[0m' "$*"; } - -log() { echo " $(dim ">>") $*"; } -log_step() { echo; echo "$(bold "[$1]") $2"; } -log_ok() { echo " $(green "ok") $*"; } -log_skip() { echo " $(dim "skip") $*"; } - -# -- Core helpers -------------------------------------------------------------- - -usage() { - cat <<'EOF' -Usage: scripts/release/release.sh [options] - -Options: - --target Release target. If omitted, prompt interactively. - --bump Bump mode. Default: minor. - --yes Skip confirmation prompts. - --dry-run Print actions without making changes/publishes. - --help, -h Show help. - -Versioning: - - App tags: vM.m - - CLI package version: M.m.0 - -Prerequisites: - - Run from a clean working tree on branch main. - - Required tools: git, node, npm, pnpm, git-cliff. - -Examples: - # Fully interactive release wizard - scripts/release/release.sh - - # Non-interactive CLI minor release - scripts/release/release.sh --target cli --bump minor --yes - - # Preview both releases with major bump - scripts/release/release.sh --target both --bump major --dry-run -EOF -} - -die() { - echo "$(red "Error:") $*" >&2 - exit 1 -} - -command_exists() { - command -v "$1" >/dev/null 2>&1 -} - -run_cmd() { - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") $*" - return 0 - fi - "$@" -} - -confirm() { - local prompt="$1" - if [[ "$AUTO_YES" -eq 1 ]]; then - return 0 - fi - echo - local answer - read -r -p "$(bold "$prompt") [y/N]: " answer - answer="$(echo "${answer:-}" | tr '[:upper:]' '[:lower:]')" - [[ "$answer" == "y" || "$answer" == "yes" ]] -} - -validate_bump() { - case "$1" in - minor|major) ;; - *) die "Invalid bump '$1'. Use 'minor' or 'major'." ;; - esac -} - -# -- Target selection ---------------------------------------------------------- - -parse_target() { - case "$1" in - cli) - CLI_SELECTED=1 - APP_SELECTED=0 - ;; - app) - CLI_SELECTED=0 - APP_SELECTED=1 - ;; - both) - CLI_SELECTED=1 - APP_SELECTED=1 - ;; - *) - die "Invalid target '$1'. Use cli, app, or both." - ;; - esac -} - -select_target_interactive() { - echo - echo "$(bold "What do you want to release?")" - echo " $(cyan "1)") cli $(dim "- npm publish apps/cli")" - echo " $(cyan "2)") app $(dim "- git tag for GitHub Actions release")" - echo " $(cyan "3)") both $(dim "- cli + app together")" - echo - local choice - read -r -p "$(bold "Choice") [1=cli / 2=app / 3=both]: " choice - case "$choice" in - 1|cli) parse_target "cli" ;; - 2|app) parse_target "app" ;; - 3|both) parse_target "both" ;; - *) die "Invalid choice '$choice'. Enter 1, 2, 3, cli, app, or both." ;; - esac - - local selected="" - [[ "$CLI_SELECTED" -eq 1 ]] && selected+="cli " - [[ "$APP_SELECTED" -eq 1 ]] && selected+="app " - log "Selected target: $(bold "$selected")" -} - -# -- Version parsing ----------------------------------------------------------- - -parse_cli_version() { - local version="$1" - if [[ ! "$version" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+))?$ ]]; then - die "CLI version '$version' is not valid. Expected M.m.0 format." - fi - CURRENT_CLI_MAJOR="${BASH_REMATCH[1]}" - CURRENT_CLI_MINOR="${BASH_REMATCH[2]}" - CURRENT_CLI_PATCH="${BASH_REMATCH[4]:-0}" -} - -detect_current_cli_version() { - [[ -f "$CLI_PACKAGE_JSON" ]] || die "Missing $CLI_PACKAGE_JSON" - CURRENT_CLI_VERSION="$(node -p "require('$CLI_PACKAGE_JSON').version")" - parse_cli_version "$CURRENT_CLI_VERSION" - log "Current CLI version: $(bold "$CURRENT_CLI_VERSION") $(dim "(from apps/cli/package.json)")" -} - -detect_current_app_version() { - local tags - tags="$(git -C "$ROOT_DIR" tag --list 'v*')" - - local found=0 - local best_major=0 - local best_minor=0 - local tag - - while IFS= read -r tag; do - [[ -z "$tag" ]] && continue - if [[ "$tag" =~ ^v([0-9]+)\.([0-9]+)(\.[0-9]+)?$ ]]; then - local major="${BASH_REMATCH[1]}" - local minor="${BASH_REMATCH[2]}" - if [[ "$found" -eq 0 ]] || (( 10#$major > 10#$best_major )) || \ - (( 10#$major == 10#$best_major && 10#$minor > 10#$best_minor )); then - found=1 - best_major="$major" - best_minor="$minor" - fi - fi - done <<<"$tags" - - CURRENT_APP_MAJOR="$best_major" - CURRENT_APP_MINOR="$best_minor" - CURRENT_APP_TAG="v${CURRENT_APP_MAJOR}.${CURRENT_APP_MINOR}" - - if [[ "$found" -eq 1 ]]; then - log "Current app tag: $(bold "$CURRENT_APP_TAG") $(dim "(latest git tag matching vM.m)")" - else - log "Current app tag: $(dim "none found") $(dim "(will start at v0.1)")" - fi -} - -# -- Bump selection ------------------------------------------------------------ - -next_minor_pair() { - local major="$1" - local minor="$2" - echo "${major}.$((10#$minor + 1))" -} - -next_major_pair() { - local major="$1" - echo "$((10#$major + 1)).0" -} - -choose_bump() { - local subject="$1" - local current="$2" - local next_minor="$3" - local next_major="$4" - - if [[ -n "$DEFAULT_BUMP" ]]; then - validate_bump "$DEFAULT_BUMP" - fi - - if [[ "$AUTO_YES" -eq 1 && -n "$DEFAULT_BUMP" ]]; then - echo "$DEFAULT_BUMP" - return - fi - - if [[ -n "$DEFAULT_BUMP" && "$TARGET" != "" ]]; then - echo "$DEFAULT_BUMP" - return - fi - - echo >&2 - echo "$(bold "$subject version bump") $(dim "(current: $current)")" >&2 - echo " $(cyan "1)") minor -> $(bold "$next_minor") $(dim "(default)")" >&2 - echo " $(cyan "2)") major -> $(bold "$next_major")" >&2 - local choice - read -r -p "$(bold "Bump") [1=minor / 2=major]: " choice - case "${choice:-1}" in - 1|minor) echo "minor" ;; - 2|major) echo "major" ;; - *) die "Invalid bump choice '$choice'. Enter 1, 2, minor, or major." ;; - esac -} - -# -- Version computation ------------------------------------------------------- - -compute_next_versions() { - if [[ "$CLI_SELECTED" -eq 1 ]]; then - local cli_minor_pair cli_major_pair - cli_minor_pair="$(next_minor_pair "$CURRENT_CLI_MAJOR" "$CURRENT_CLI_MINOR")" - cli_major_pair="$(next_major_pair "$CURRENT_CLI_MAJOR")" - - CLI_BUMP="$(choose_bump "CLI" "${CURRENT_CLI_MAJOR}.${CURRENT_CLI_MINOR}.0" \ - "${cli_minor_pair}.0" "${cli_major_pair}.0")" - validate_bump "$CLI_BUMP" - - if [[ "$CLI_BUMP" == "minor" ]]; then - NEXT_CLI_VERSION="${cli_minor_pair}.0" - else - NEXT_CLI_VERSION="${cli_major_pair}.0" - fi - log "CLI bump: $CLI_BUMP -> $(bold "$NEXT_CLI_VERSION")" - fi - - if [[ "$APP_SELECTED" -eq 1 ]]; then - local app_minor_pair app_major_pair - app_minor_pair="$(next_minor_pair "$CURRENT_APP_MAJOR" "$CURRENT_APP_MINOR")" - app_major_pair="$(next_major_pair "$CURRENT_APP_MAJOR")" - - APP_BUMP="$(choose_bump "App" "$CURRENT_APP_TAG" "v${app_minor_pair}" "v${app_major_pair}")" - validate_bump "$APP_BUMP" - - if [[ "$APP_BUMP" == "minor" ]]; then - NEXT_APP_TAG="v${app_minor_pair}" - else - NEXT_APP_TAG="v${app_major_pair}" - fi - log "App bump: $APP_BUMP -> $(bold "$NEXT_APP_TAG")" - fi -} - -# -- Preconditions ------------------------------------------------------------- - -ensure_prerequisites() { - log_step "1/5" "Checking prerequisites" - - for tool in git node npm pnpm git-cliff; do - if command_exists "$tool"; then - log_ok "$tool" - else - die "$tool is required but not found in PATH." - fi - done - - CURRENT_BRANCH="$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD)" - if [[ "$CURRENT_BRANCH" == "main" ]]; then - log_ok "On branch $(bold "main")" - else - die "Release must run from main branch (current: $CURRENT_BRANCH)." - fi - - if [[ -n "$(git -C "$ROOT_DIR" status --porcelain)" ]]; then - die "Working tree is not clean. Commit or stash changes before releasing." - fi - log_ok "Working tree is clean" - - log "Fetching tags from origin..." - run_cmd git -C "$ROOT_DIR" fetch --tags origin - log_ok "Tags fetched" -} - -ensure_npm_auth() { - [[ "$CLI_SELECTED" -eq 1 ]] || return 0 - [[ "$DRY_RUN" -eq 1 ]] && return 0 - - log "Checking npm authentication..." - local npm_user - if npm_user="$(npm whoami 2>/dev/null)"; then - log_ok "Logged in to npm as $(bold "$npm_user")" - else - echo - echo " $(yellow "npm auth required") — you are not logged in to npm." - echo " $(dim "This is needed to publish the CLI package.")" - echo - if [[ "$AUTO_YES" -eq 1 ]]; then - die "npm login required but --yes was specified. Run 'npm login' first." - fi - local answer - read -r -p "$(bold "Run npm login now?") [Y/n]: " answer - answer="$(echo "${answer:-y}" | tr '[:upper:]' '[:lower:]')" - if [[ "$answer" == "y" || "$answer" == "yes" || -z "$answer" ]]; then - npm login - # Verify login succeeded - if npm_user="$(npm whoami 2>/dev/null)"; then - log_ok "Logged in to npm as $(bold "$npm_user")" - else - die "npm login failed. Cannot publish CLI." - fi - else - die "npm login required to publish CLI. Run 'npm login' and try again." - fi - fi -} - -ensure_app_tag_available() { - [[ "$APP_SELECTED" -eq 1 ]] || return 0 - - log "Checking tag $(bold "$NEXT_APP_TAG") is available..." - if git -C "$ROOT_DIR" rev-parse "$NEXT_APP_TAG" >/dev/null 2>&1; then - die "Tag $NEXT_APP_TAG already exists locally." - fi - - if git -C "$ROOT_DIR" ls-remote --tags origin "refs/tags/$NEXT_APP_TAG" | grep -q .; then - die "Tag $NEXT_APP_TAG already exists on origin." - fi - log_ok "Tag $(bold "$NEXT_APP_TAG") is available" -} - -# -- Release plan -------------------------------------------------------------- - -print_plan() { - echo - echo "$(bold "=== Release Plan ===")" - echo " branch: $(bold "$CURRENT_BRANCH")" - if [[ "$CLI_SELECTED" -eq 1 ]]; then - echo " cli: $(bold "$CURRENT_CLI_VERSION") -> $(green "$NEXT_CLI_VERSION") ($CLI_BUMP bump)" - fi - if [[ "$APP_SELECTED" -eq 1 ]]; then - echo " app tag: $(bold "$CURRENT_APP_TAG") -> $(green "$NEXT_APP_TAG") ($APP_BUMP bump)" - fi - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " mode: $(yellow "DRY RUN") $(dim "(no changes will be made)")" - fi - echo - - if [[ "$CLI_SELECTED" -eq 1 ]]; then - echo " $(dim "Steps for CLI:")" - echo " $(dim "1.") Bump version in apps/cli/package.json" - echo " $(dim "2.") Install dependencies (pnpm install)" - echo " $(dim "3.") Build CLI (pnpm build)" - echo " $(dim "4.") Commit and push to main" - echo " $(dim "5.") Publish to npm" - fi - if [[ "$APP_SELECTED" -eq 1 ]]; then - echo " $(dim "Steps for App:")" - echo " $(dim "1.") Generate CHANGELOG.md (git-cliff)" - echo " $(dim "2.") Commit and push changelog" - echo " $(dim "3.") Create annotated git tag $NEXT_APP_TAG" - echo " $(dim "4.") Push tag to origin (triggers GitHub Actions)" - fi - echo -} - -# -- Release actions ----------------------------------------------------------- - -commit_message() { - if [[ "$CLI_SELECTED" -eq 1 && "$APP_SELECTED" -eq 1 ]]; then - echo "release: app $NEXT_APP_TAG, cli $NEXT_CLI_VERSION" - elif [[ "$CLI_SELECTED" -eq 1 ]]; then - echo "release: cli $NEXT_CLI_VERSION" - elif [[ "$APP_SELECTED" -eq 1 ]]; then - echo "release: app $NEXT_APP_TAG" - else - echo "" - fi -} - -apply_cli_release() { - [[ "$CLI_SELECTED" -eq 1 ]] || return 0 - - log_step "3/5" "Building CLI release" - - log "Bumping CLI version to $(bold "$NEXT_CLI_VERSION")..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") npm version --no-git-tag-version $NEXT_CLI_VERSION" - else - (cd "$ROOT_DIR/apps/cli" && npm version --no-git-tag-version "$NEXT_CLI_VERSION") - fi - log_ok "Version bumped in package.json" - - log "Installing dependencies..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") pnpm install --frozen-lockfile" - else - pnpm -C "$ROOT_DIR/apps" install --frozen-lockfile - fi - log_ok "Dependencies installed" - - log "Building CLI..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") pnpm -C apps/cli build" - else - pnpm -C "$ROOT_DIR/apps/cli" build - fi - log_ok "CLI built" -} - -generate_changelog() { - [[ "$APP_SELECTED" -eq 1 ]] || return 0 - - log "Generating changelog for $(bold "$NEXT_APP_TAG")..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") git-cliff --tag $NEXT_APP_TAG -o CHANGELOG.md" - return 0 - fi - git-cliff --tag "$NEXT_APP_TAG" -o "$ROOT_DIR/CHANGELOG.md" - log_ok "CHANGELOG.md updated" -} - -commit_and_push_if_needed() { - local msg - msg="$(commit_message)" - [[ -n "$msg" ]] || return 0 - - log_step "4/5" "Committing and pushing" - - if [[ "$DRY_RUN" -eq 1 ]]; then - [[ "$CLI_SELECTED" -eq 1 ]] && echo " $(yellow "[dry-run]") git add apps/cli/package.json" - [[ "$APP_SELECTED" -eq 1 ]] && echo " $(yellow "[dry-run]") git add CHANGELOG.md" - echo " $(yellow "[dry-run]") git commit -m \"$msg\"" - echo " $(yellow "[dry-run]") git push origin $CURRENT_BRANCH" - return 0 - fi - - log "Staging release files..." - [[ "$CLI_SELECTED" -eq 1 ]] && git -C "$ROOT_DIR" add apps/cli/package.json - [[ "$APP_SELECTED" -eq 1 && -f "$ROOT_DIR/CHANGELOG.md" ]] && git -C "$ROOT_DIR" add CHANGELOG.md - if git -C "$ROOT_DIR" diff --cached --quiet; then - log_skip "No staged changes to commit" - return 0 - fi - - log "Committing: $(dim "$msg")" - git -C "$ROOT_DIR" commit -m "$msg" - log_ok "Committed" - - log "Pushing to origin/$CURRENT_BRANCH..." - git -C "$ROOT_DIR" push origin "$CURRENT_BRANCH" - log_ok "Pushed" -} - -create_and_push_app_tag() { - [[ "$APP_SELECTED" -eq 1 ]] || return 0 - - if [[ "$CLI_SELECTED" -eq 0 ]]; then - log_step "3/5" "Creating app tag" - else - log_step "4/5" "Creating app tag" - fi - - log "Creating annotated tag $(bold "$NEXT_APP_TAG")..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") git tag -a $NEXT_APP_TAG -m \"release: $NEXT_APP_TAG\"" - echo " $(yellow "[dry-run]") git push origin $NEXT_APP_TAG" - return 0 - fi - - git -C "$ROOT_DIR" tag -a "$NEXT_APP_TAG" -m "release: $NEXT_APP_TAG" - log_ok "Tag created" - - log "Pushing tag to origin..." - git -C "$ROOT_DIR" push origin "$NEXT_APP_TAG" - log_ok "Tag pushed (GitHub Actions release workflow will trigger)" -} - -publish_cli() { - [[ "$CLI_SELECTED" -eq 1 ]] || return 0 - - log_step "5/5" "Publishing CLI to npm" - - log "Running npm publish --access public..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") npm publish --access public" - return 0 - fi - (cd "$ROOT_DIR/apps/cli" && npm publish --access public) - log_ok "Published to npm" -} - -# -- Argument parsing ---------------------------------------------------------- - -parse_args() { - while [[ $# -gt 0 ]]; do - case "$1" in - --target) - [[ $# -ge 2 ]] || die "--target requires a value." - TARGET="$2" - shift 2 - ;; - --bump) - [[ $# -ge 2 ]] || die "--bump requires a value." - DEFAULT_BUMP="$2" - shift 2 - ;; - --yes) - AUTO_YES=1 - shift - ;; - --dry-run) - DRY_RUN=1 - shift - ;; - --help|-h) - usage - exit 0 - ;; - *) - die "Unknown argument '$1'." - ;; - esac - done -} - -# -- Main ---------------------------------------------------------------------- - -main() { - parse_args "$@" - validate_bump "$DEFAULT_BUMP" - - echo - echo "$(bold "Kandev Release")" - if [[ "$DRY_RUN" -eq 1 ]]; then - echo "$(yellow " DRY RUN MODE - no changes will be made")" - fi - - ensure_prerequisites - - log_step "2/5" "Selecting target and version" - - if [[ -n "$TARGET" ]]; then - parse_target "$TARGET" - local selected="" - [[ "$CLI_SELECTED" -eq 1 ]] && selected+="cli " - [[ "$APP_SELECTED" -eq 1 ]] && selected+="app " - log "Target: $(bold "$selected") $(dim "(from --target flag)")" - else - # Interactive mode should prompt for target and bump choices. - DEFAULT_BUMP="" - select_target_interactive - fi - - ensure_npm_auth - detect_current_cli_version - detect_current_app_version - compute_next_versions - ensure_app_tag_available - print_plan - - if ! confirm "Proceed with release?"; then - echo - echo "Release cancelled." - exit 0 - fi - - apply_cli_release - generate_changelog - commit_and_push_if_needed - create_and_push_app_tag - publish_cli - - echo - echo "$(green "$(bold "Release complete!")")" - if [[ "$CLI_SELECTED" -eq 1 ]]; then - echo " $(green "cli:") published $NEXT_CLI_VERSION to npm" - fi - if [[ "$APP_SELECTED" -eq 1 ]]; then - echo " $(green "app:") pushed tag $NEXT_APP_TAG to origin" - fi - echo -} - -main "$@" diff --git a/scripts/release/update-homebrew-tap.sh b/scripts/release/update-homebrew-tap.sh new file mode 100755 index 0000000000..06a4e653b6 --- /dev/null +++ b/scripts/release/update-homebrew-tap.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# Update the kdlbs/homebrew-kandev tap with a new formula for the given release. +# +# Downloads .sha256 files from the GitHub release to populate the formula, +# generates Formula/kandev.rb, and pushes (or opens a PR). +# +# Usage: +# update-homebrew-tap.sh +# +# Arguments: +# version SemVer string (e.g. 0.17.0) +# tag Git tag (e.g. v0.17.0) +# +# Environment: +# HOMEBREW_TAP_REPO Override tap repo (default: kdlbs/homebrew-kandev). +# HOMEBREW_TAP_DEPLOY_KEY Private SSH key (PEM) for the tap repo. When set, +# clone via SSH and push directly to main. This is +# the default CI path. Deploy keys cannot open PRs. +# HOMEBREW_TAP_PUSH When no deploy key is present, set to "1" to push +# directly via `gh`/HTTPS instead of opening a PR. +set -euo pipefail + +VERSION="${1:?Usage: $0 }" +TAG="${2:?Usage: $0 }" +TAP_REPO="${HOMEBREW_TAP_REPO:-kdlbs/homebrew-kandev}" +PUSH_DIRECT="${HOMEBREW_TAP_PUSH:-0}" + +bold() { printf '\033[1m%s\033[0m' "$*"; } +green() { printf '\033[32m%s\033[0m' "$*"; } +red() { printf '\033[31m%s\033[0m' "$*"; } + +log() { echo " >> $*"; } +log_ok() { echo " $(green "ok") $*"; } + +die() { + echo "$(red "Error:") $*" >&2 + exit 1 +} + +# -- Determine clone strategy: deploy key (SSH) or gh (HTTPS) ----------------- + +CLONE_METHOD="gh" +WORK_DIR="$(mktemp -d)" +TAP_DIR="$WORK_DIR/tap" +SSH_KEY_FILE="" +KNOWN_HOSTS_FILE="" + +cleanup() { + [[ -n "${SSH_KEY_FILE:-}" && -f "$SSH_KEY_FILE" ]] && rm -f "$SSH_KEY_FILE" + [[ -n "${KNOWN_HOSTS_FILE:-}" && -f "$KNOWN_HOSTS_FILE" ]] && rm -f "$KNOWN_HOSTS_FILE" + [[ -n "${WORK_DIR:-}" && -d "$WORK_DIR" ]] && rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +if [[ -n "${HOMEBREW_TAP_DEPLOY_KEY:-}" ]]; then + CLONE_METHOD="ssh" + # Materialize the deploy key + a custom GIT_SSH_COMMAND so it doesn't pollute ~/.ssh + SSH_KEY_FILE="$(mktemp)" + chmod 600 "$SSH_KEY_FILE" + printf '%s\n' "$HOMEBREW_TAP_DEPLOY_KEY" > "$SSH_KEY_FILE" + # Ensure trailing newline (some secret stores strip it; openssh requires it) + if [[ "$(tail -c1 "$SSH_KEY_FILE")" != "" ]]; then + echo "" >> "$SSH_KEY_FILE" + fi + + # Pin GitHub's published SSH host keys so we don't trust whatever key the + # remote presents. Source: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints + KNOWN_HOSTS_FILE="$(mktemp)" + chmod 600 "$KNOWN_HOSTS_FILE" + cat > "$KNOWN_HOSTS_FILE" <<'EOF' +github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl +github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg= +github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk= +EOF + export GIT_SSH_COMMAND="ssh -i $SSH_KEY_FILE -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$KNOWN_HOSTS_FILE -o LogLevel=ERROR" + log "Using SSH deploy key for tap auth (pinned github.com host keys)" +fi + +# -- Fetch sha256 values from GitHub release ---------------------------------- + +fetch_sha256() { + local platform="$1" + local sha_file="kandev-${platform}.tar.gz.sha256" + local content + # Download into WORK_DIR so the EXIT trap cleans it up automatically. + content="$(gh release download "$TAG" --pattern "$sha_file" --dir "$WORK_DIR" 2>/dev/null && cat "$WORK_DIR/$sha_file")" || { + die "Failed to download $sha_file from release $TAG." + } + # sha256 files contain: — extract just the hash + echo "$content" | awk '{print $1}' +} + +log "Fetching sha256 values from GitHub release $TAG..." +SHA_MACOS_ARM64="$(fetch_sha256 macos-arm64)" +SHA_MACOS_X64="$(fetch_sha256 macos-x64)" +SHA_LINUX_ARM64="$(fetch_sha256 linux-arm64)" +SHA_LINUX_X64="$(fetch_sha256 linux-x64)" +log_ok "sha256 values retrieved" + +# -- Clone tap repo ----------------------------------------------------------- +# (WORK_DIR and TAP_DIR were created near the top so the EXIT trap covers them.) + +log "Cloning $TAP_REPO..." +if [[ "$CLONE_METHOD" == "ssh" ]]; then + git clone "git@github.com:${TAP_REPO}.git" "$TAP_DIR" +else + gh repo clone "$TAP_REPO" "$TAP_DIR" +fi +log_ok "Tap cloned to $TAP_DIR" + +# -- Generate formula --------------------------------------------------------- + +FORMULA_PATH="$TAP_DIR/Formula/kandev.rb" +mkdir -p "$(dirname "$FORMULA_PATH")" + +GITHUB_BASE="https://github.com/kdlbs/kandev/releases/download/${TAG}" + +cat > "$FORMULA_PATH" <