feat: brew install + npm sibling channels via OIDC trusted publishing (#806)

* 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 <tag> docs with --version (CLI semver) +
  --runtime-version <tag> (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/<asset>.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.
This commit is contained in:
José Almeida
2026-05-05 00:43:34 +01:00
committed by GitHub
parent b6a274183b
commit 379230892b
21 changed files with 1864 additions and 1875 deletions
+310 -19
View File
@@ -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/<tag>/<asset>.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 }}"
+30
View File
@@ -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 <tag>` 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:
+25 -3
View File
@@ -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
+24 -6
View File
@@ -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 <tag>` 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 <tag>` 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
+124 -112
View File
@@ -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/<version>/<platform>/`.
- 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: <current> -> <latest>. 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 <tag>` | Release tag to install (default: latest) |
| `--backend-port <port>` | Override backend port |
| `--web-port <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 <tag>` | **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.
+13 -1
View File
@@ -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"
+23 -2
View File
@@ -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/,
);
});
});
+12 -7
View File
@@ -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") {
+20 -13
View File
@@ -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 <tag>] [--port <port>] [--verbose] [--debug]
kandev run [--port <port>] [--verbose] [--debug]
kandev dev [--port <port>]
kandev start [--port <port>] [--verbose] [--debug]
kandev [--version <tag>] [--port <port>] [--verbose] [--debug]
kandev [--port <port>] [--verbose] [--debug]
kandev --dev [--port <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 <tag> 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<void> {
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<void> {
return;
}
await maybePromptForUpdate(pkg.version, process.argv.slice(2));
await runRelease({
version: options.version,
runtimeVersion: options.runtimeVersion,
backendPort,
webPort,
verbose: options.verbose,
+76 -71
View File
@@ -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<string> {
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<PreparedRelease> {
const platformDir = getPlatformDir();
let tag: string;
let cacheDir: string;
}: RunOptions): Promise<PreparedBundle> {
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<string, string>).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<typeof createProcessSupervisor>;
backendProc: ReturnType<typeof spawn>;
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<void> {
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);
+152
View File
@@ -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('<pkg>/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/);
});
});
});
+83
View File
@@ -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<PlatformDir, string> = {
"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 <tag>` 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 <tag>`,
);
}
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}`);
}
}
-86
View File
@@ -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<T>(url: string): Promise<T> {
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<string | undefined> {
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<boolean> {
return new Promise<boolean>((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
}
}
+334 -19
View File
@@ -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
+95 -76
View File
@@ -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<br/>(Homebrew, tests)"]
resolve --> npmpkg["@kdlbs/runtime-{platform}<br/>(npm/npx)"]
resolve --> cache["~/.kandev/bin cache<br/>(--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/<version>/<platform>/`
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 <tag>` | Use a specific release version | `--version v0.1.0` |
| `--backend-port <port>` | Override backend port | `--backend-port 18080` |
| `--web-port <port>` | Override web port | `--web-port 13000` |
| `--version`, `-V` | Print CLI version and exit | `kandev --version` |
| `--port <port>` | Backend port (alias: `--backend-port`) | `--port 3000` |
| `--web-internal-port <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 <tag>` | **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) |
| `<npm cache>/node_modules/@kdlbs/runtime-{platform}/` | Installed runtime (when installed via npm/npx) |
+49
View File
@@ -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"
+110
View File
@@ -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 <version> <release-assets-dir> <output-dir>
#
# 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):
# <output-dir>/@kdlbs/runtime-linux-x64/
# <output-dir>/@kdlbs/runtime-linux-arm64/
# <output-dir>/@kdlbs/runtime-darwin-x64/
# <output-dir>/@kdlbs/runtime-darwin-arm64/
# <output-dir>/@kdlbs/runtime-win32-x64/
set -euo pipefail
VERSION="${1:?Usage: $0 <version> <release-assets-dir> <output-dir>}"
ASSETS_DIR="${2:?Usage: $0 <version> <release-assets-dir> <output-dir>}"
OUT_DIR="${3:?Usage: $0 <version> <release-assets-dir> <output-dir>}"
# 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" <<EOF
{
"name": "$package_name",
"version": "$VERSION",
"description": "Kandev runtime bundle for $platform",
"license": "AGPL-3.0-only",
"os": $os_field,
"cpu": $cpu_field,
"files": [
"bin",
"web"
]
}
EOF
echo " packaged $package_name@$VERSION"
done
echo "Done. Runtime packages written to $OUT_DIR"
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
# Publish the main kandev npm package + all @kdlbs/runtime-* optional packages.
#
# Authentication: Trusted Publishers (OIDC). Each of the 6 packages must have
# this workflow configured as its trusted publisher on npmjs.com. The npm CLI
# auto-detects OIDC credentials from GitHub Actions and exchanges them for a
# short-lived publish token. No NPM_TOKEN secret is needed.
#
# Prerequisites:
# - GitHub release assets for <tag> 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 <version> <tag>
#
# 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 <version> <tag>}"
TAG="${2:?Usage: $0 <version> <tag>}"
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
-812
View File
@@ -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 <cli|app|both> Release target. If omitted, prompt interactively.
--bump <minor|major> 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 "$@"
-648
View File
@@ -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 <cli|app|both> Release target. If omitted, prompt interactively.
--bump <minor|major> 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 "$@"
+203
View File
@@ -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 <version> <tag>
#
# 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 <version> <tag>}"
TAG="${2:?Usage: $0 <version> <tag>}"
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: <hash> <filename> — 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" <<EOF
class Kandev < Formula
desc "Manage tasks, orchestrate agents, review changes, and ship value"
homepage "https://github.com/kdlbs/kandev"
license "AGPL-3.0-only"
version "$VERSION"
# Node is required: the CLI launcher and Next.js standalone server both need it.
depends_on "node"
on_macos do
if Hardware::CPU.arm?
url "${GITHUB_BASE}/kandev-macos-arm64.tar.gz"
sha256 "$SHA_MACOS_ARM64"
else
url "${GITHUB_BASE}/kandev-macos-x64.tar.gz"
sha256 "$SHA_MACOS_X64"
end
end
on_linux do
if Hardware::CPU.arm?
url "${GITHUB_BASE}/kandev-linux-arm64.tar.gz"
sha256 "$SHA_LINUX_ARM64"
else
url "${GITHUB_BASE}/kandev-linux-x64.tar.gz"
sha256 "$SHA_LINUX_X64"
end
end
def install
libexec.install Dir["*"]
# cli/bin/cli.js has #!/usr/bin/env node shebang. (bin/"kandev").write_env_script
# creates a wrapper at \$HOMEBREW_PREFIX/bin/kandev that sets KANDEV_BUNDLE_DIR
# (so the CLI launcher finds bin/ and web/ in the Cellar) and KANDEV_VERSION
# (read by run.ts at startup so the launcher logs "release: X.Y.Z" instead of
# "release: (env)"). Calling write_env_script on the bin directory itself would
# name the wrapper after the target's basename (cli.js), giving the wrong name.
(bin/"kandev").write_env_script libexec/"cli/bin/cli.js",
KANDEV_BUNDLE_DIR: libexec.to_s,
KANDEV_VERSION: version.to_s
end
test do
assert_match "kandev launcher", shell_output("#{bin}/kandev --help")
end
end
EOF
log_ok "Formula written to Formula/kandev.rb"
# -- Commit and push (or open PR) ---------------------------------------------
cd "$TAP_DIR"
git config user.email "release-bot@kandev"
git config user.name "kandev release bot"
git add Formula/kandev.rb
if git diff --cached --quiet; then
log "Formula unchanged — nothing to commit"
exit 0
fi
git commit -m "kandev $VERSION"
# Deploy keys can push but cannot open PRs (PR creation requires user/app auth).
# So when using SSH (deploy key path) or HOMEBREW_TAP_PUSH=1, push directly.
if [[ "$CLONE_METHOD" == "ssh" || "$PUSH_DIRECT" == "1" ]]; then
git push origin HEAD:main
log_ok "Pushed directly to main in $TAP_REPO"
else
BRANCH="update-kandev-$VERSION"
git branch -m "$BRANCH"
git push -u origin "$BRANCH"
gh pr create \
--repo "$TAP_REPO" \
--title "kandev $VERSION" \
--body "Updates formula to [kandev $VERSION](https://github.com/kdlbs/kandev/releases/tag/$TAG)." \
--head "$BRANCH" \
--base main
log_ok "PR opened in $TAP_REPO"
fi
# WORK_DIR cleanup happens via the EXIT trap.
echo "$(green "$(bold "Homebrew tap updated to $VERSION!")")"