diff --git a/.github/workflows/release-binaries.yaml b/.github/workflows/release-binaries.yaml index 970bf0d..e40a0d9 100644 --- a/.github/workflows/release-binaries.yaml +++ b/.github/workflows/release-binaries.yaml @@ -2,13 +2,17 @@ name: Release Binaries # Build standalone `photon` binaries (no Bun runtime required) and # attach them to the GitHub Release that the `release.yaml` workflow -# created. Runs on `release: created` so it stays decoupled from the -# npm publish flow — npm consumers get the Bun bundle, Release-page -# downloaders get a self-contained executable. +# created, then update the Homebrew tap formula with new checksums. +# Triggers automatically via workflow_run when Release completes, or +# manually via workflow_dispatch for ad-hoc rebuilds. on: - release: - types: [created] + # Triggered automatically when the Release workflow finishes. + # (release: created does NOT fire when the release is made with + # GITHUB_TOKEN — workflow_run does.) + workflow_run: + workflows: ["Release"] + types: [completed] workflow_dispatch: inputs: tag: @@ -37,13 +41,35 @@ jobs: - os: ubuntu-latest target: bun-linux-arm64 asset: photon-linux-arm64 + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') runs-on: ${{ matrix.os }} steps: + - name: Resolve release tag + id: tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT" + else + SHA="${{ github.event.workflow_run.head_sha }}" + TAG=$(gh api "repos/${{ github.repository }}/releases" --paginate \ + --jq ".[] | select(.target_commitish == \"$SHA\") | .tag_name" | head -n1) + if [ -z "$TAG" ]; then + echo "::error::No release found for commit $SHA" + exit 1 + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + fi + - uses: actions/checkout@v5 + with: + ref: ${{ steps.tag.outputs.tag }} + - uses: oven-sh/setup-bun@v2 with: - # Pinned for reproducibility — matches the lower bound in - # package.json#engines.bun. Bump intentionally when upgrading. bun-version: "1.3.13" - name: Install @@ -68,20 +94,118 @@ jobs: sha256sum dist/${{ matrix.asset }} > dist/${{ matrix.asset }}.sha256 fi + - name: Upload to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ steps.tag.outputs.tag }}" \ + "dist/${{ matrix.asset }}" \ + "dist/${{ matrix.asset }}.sha256" \ + --clobber + + update-tap: + needs: build + runs-on: ubuntu-latest + steps: - name: Resolve release tag id: tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo "tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT" else - echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" + SHA="${{ github.event.workflow_run.head_sha }}" + TAG=$(gh api "repos/${{ github.repository }}/releases" --paginate \ + --jq ".[] | select(.target_commitish == \"$SHA\") | .tag_name" | head -n1) + if [ -z "$TAG" ]; then + echo "::error::No release found for commit $SHA" + exit 1 + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" fi - - name: Upload to release + - name: Download SHA256 files env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release upload "${{ steps.tag.outputs.tag }}" \ - "dist/${{ matrix.asset }}" \ - "dist/${{ matrix.asset }}.sha256" \ - --clobber + TAG="${{ steps.tag.outputs.tag }}" + for asset in photon-darwin-arm64 photon-darwin-x64 photon-linux-x64 photon-linux-arm64; do + gh release download "$TAG" --repo ${{ github.repository }} \ + --pattern "${asset}.sha256" --output "${asset}.sha256" + done + + - name: Extract version and checksums + id: meta + run: | + TAG="${{ steps.tag.outputs.tag }}" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + for asset in photon-darwin-arm64 photon-darwin-x64 photon-linux-x64 photon-linux-arm64; do + key=$(echo "$asset" | tr '-' '_') + sha=$(awk '{print $1}' "${asset}.sha256") + echo "${key}=${sha}" >> "$GITHUB_OUTPUT" + done + + - name: Checkout tap + uses: actions/checkout@v5 + with: + repository: photon-hq/homebrew-photon + token: ${{ secrets.TAP_GITHUB_TOKEN }} + + - name: Generate formula + run: | + cat > Formula/photon.rb << 'EOF' + class Photon < Formula + desc "Typed terminal UI for the Photon Dashboard" + homepage "https://github.com/photon-hq/cli" + version "VERSION_PLACEHOLDER" + license "MIT" + + on_macos do + on_arm do + url "https://github.com/photon-hq/cli/releases/download/v#{version}/photon-darwin-arm64" + sha256 "SHA_DARWIN_ARM64_PLACEHOLDER" + end + on_intel do + url "https://github.com/photon-hq/cli/releases/download/v#{version}/photon-darwin-x64" + sha256 "SHA_DARWIN_X64_PLACEHOLDER" + end + end + + on_linux do + on_intel do + url "https://github.com/photon-hq/cli/releases/download/v#{version}/photon-linux-x64" + sha256 "SHA_LINUX_X64_PLACEHOLDER" + end + on_arm do + url "https://github.com/photon-hq/cli/releases/download/v#{version}/photon-linux-arm64" + sha256 "SHA_LINUX_ARM64_PLACEHOLDER" + end + end + + def install + bin.install Dir.glob("photon*").first => "photon" + end + + test do + assert_match version.to_s, shell_output("#{bin}/photon --version") + end + end + EOF + # Strip leading whitespace from heredoc + sed -i 's/^ //' Formula/photon.rb + # Substitute placeholders + sed -i "s/VERSION_PLACEHOLDER/${{ steps.meta.outputs.version }}/" Formula/photon.rb + sed -i "s/SHA_DARWIN_ARM64_PLACEHOLDER/${{ steps.meta.outputs.photon_darwin_arm64 }}/" Formula/photon.rb + sed -i "s/SHA_DARWIN_X64_PLACEHOLDER/${{ steps.meta.outputs.photon_darwin_x64 }}/" Formula/photon.rb + sed -i "s/SHA_LINUX_X64_PLACEHOLDER/${{ steps.meta.outputs.photon_linux_x64 }}/" Formula/photon.rb + sed -i "s/SHA_LINUX_ARM64_PLACEHOLDER/${{ steps.meta.outputs.photon_linux_arm64 }}/" Formula/photon.rb + + - name: Commit and push + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Formula/photon.rb + git diff --cached --quiet && exit 0 + git commit -m "photon ${{ steps.meta.outputs.version }}" + git push diff --git a/README.md b/README.md index 74f68ec..9f43887 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,18 @@ bun add -g @photon-ai/cli # or install for daily use ## Install -Three options. Pick whichever fits. +Four options. Pick whichever fits. -### 1. One-off — no install (`npx` / `bunx`) +### 1. Homebrew (macOS / Linux) + +```sh +brew install photon-hq/photon/photon +photon login +``` + +Auto-updates with `brew upgrade photon`. No runtime dependencies — the formula installs a self-contained binary. + +### 2. One-off — no install (`npx` / `bunx`) ```sh npx @photon-ai/cli login @@ -26,7 +35,7 @@ Each invocation pulls the latest release on demand. Good for scripts, throwaway curl -fsSL https://bun.sh/install | bash ``` -### 2. Global install — daily use (`bun add -g`) +### 3. Global install — daily use (`bun add -g`) ```sh bun add -g @photon-ai/cli @@ -35,7 +44,7 @@ photon login After install, `photon` is on your `PATH`. The `pho` alias (see below) is created automatically the first time you run `photon`. -### 3. Standalone binary — no Bun, no Node +### 4. Standalone binary — no Bun, no Node For CI environments or systems where you don't want any runtime: diff --git a/bun.lock b/bun.lock index 338a5c3..05e42e8 100644 --- a/bun.lock +++ b/bun.lock @@ -17,10 +17,9 @@ }, "devDependencies": { "@types/bun": "latest", + "@types/node": "^25.6.2", "@types/update-notifier": "^6.0.8", "elysia": "1.4.28", - }, - "peerDependencies": { "typescript": "^5", }, }, @@ -68,7 +67,7 @@ "@types/configstore": ["@types/configstore@6.0.2", "", {}, "sha512-OS//b51j9uyR3zvwD04Kfs5kHpve2qalQ18JhY/ho3voGYUTPLEG90/ocfKPI48hyHH8T04f7KEEbK6Ue60oZQ=="], - "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], "@types/update-notifier": ["@types/update-notifier@6.0.8", "", { "dependencies": { "@types/configstore": "*", "boxen": "^7.1.1" } }, "sha512-IlDFnfSVfYQD+cKIg63DEXn3RFmd7W1iYtKQsJodcHK9R1yr8aKbKaPKfBxzPpcHCq2DU8zUq4PIPmy19Thjfg=="], @@ -254,6 +253,8 @@ "boxen/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + "bun-types/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], "dot-prop/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], diff --git a/package.json b/package.json index 56039be..008cd6e 100644 --- a/package.json +++ b/package.json @@ -32,12 +32,13 @@ "access": "public" }, "engines": { - "bun": ">=1.3.0" + "bun": ">=1.3.0", + "node": ">=18.0.0" }, "scripts": { "start": "bun run src/index.ts", "dev": "bun run --watch src/index.ts", - "build": "bun build ./src/index.ts --outfile dist/photon.js --target bun --minify && chmod +x dist/photon.js", + "build": "bun build ./src/index.ts --outfile dist/photon.js --target node --minify && chmod +x dist/photon.js", "compile": "bun build ./src/index.ts --compile --outfile dist/photon", "typecheck": "tsc --noEmit", "sync:api": "bun run scripts/sync-api-types.ts", @@ -45,6 +46,7 @@ }, "devDependencies": { "@types/bun": "latest", + "@types/node": "^25.6.2", "@types/update-notifier": "^6.0.8", "elysia": "1.4.28", "typescript": "^5" diff --git a/src/commands/spectrum/avatar.ts b/src/commands/spectrum/avatar.ts index ec6276e..0f90c0a 100644 --- a/src/commands/spectrum/avatar.ts +++ b/src/commands/spectrum/avatar.ts @@ -1,10 +1,20 @@ import type { Command } from "@commander-js/extra-typings"; +import { readFile, stat } from "node:fs/promises"; +import { extname } from "node:path"; import { getApi } from "~/lib/api.ts"; import { resolveProject } from "~/lib/api-context.ts"; import { PRODUCTION_URL } from "~/lib/env.ts"; import { SessionExpiredError } from "~/lib/errors.ts"; import { c, die, formatApiError } from "~/lib/output.ts"; +const MIME_TYPES: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", +}; + export function registerSpectrumAvatar(spectrum: Command): void { const avatar = spectrum.command("avatar").description("manage the Spectrum avatar image"); @@ -16,10 +26,13 @@ export function registerSpectrumAvatar(spectrum: Command): void { .option("--api-host ", "API host URL (defaults to PHOTON_API_HOST or built-in production)") .option("-t, --token ", "API token (overrides stored creds)") .action(async (file, opts) => { - const local = Bun.file(file); - if (!(await local.exists())) { + const stats = await stat(file).catch(() => null); + if (!stats) { die(`File not found: ${file}`); } + const body = await readFile(file); + const size = stats.size; + const mime = MIME_TYPES[extname(file).toLowerCase()] || "application/octet-stream"; const { projectId, env: resolved } = await resolveProject({ flagProjectId: opts.project, @@ -51,12 +64,12 @@ export function registerSpectrumAvatar(spectrum: Command): void { // 2) PUT the file body to the presigned URL. Spectrum returns a // simple PUT-style URL (per services/spectrum.ts), not multipart. - console.log(c.dim(`Uploading ${file} (${(local.size / 1024).toFixed(1)} KB)…`)); + console.log(c.dim(`Uploading ${file} (${(size / 1024).toFixed(1)} KB)…`)); const putResp = await fetch(result.uploadUrl, { method: "PUT", - body: local, + body, headers: { - "Content-Type": local.type || "application/octet-stream", + "Content-Type": mime, }, }); if (!putResp.ok) { diff --git a/src/index.ts b/src/index.ts index 2163a10..3e01e42 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env bun +#!/usr/bin/env node import { Command } from '@commander-js/extra-typings'; import { registerAuthCommands } from '~/commands/auth.ts'; import { registerBillingCommands } from '~/commands/billing.ts'; diff --git a/src/lib/api.ts b/src/lib/api.ts index fc1460a..d1309e3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -133,6 +133,8 @@ function buildTracedFetch(): typeof fetch { throw err; } }, - { preconnect: fetch.preconnect.bind(fetch) } + typeof fetch.preconnect === "function" + ? { preconnect: fetch.preconnect.bind(fetch) } + : {} ) as typeof fetch; } diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index 94cb607..ea69695 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, readdir, unlink } from "node:fs/promises"; +import { chmod, mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { credentialsDir, credentialsPath } from "~/lib/env.ts"; @@ -20,12 +20,9 @@ export interface Credentials { export async function loadCredentials( envName: string ): Promise { - const file = Bun.file(credentialsPath(envName)); - if (!(await file.exists())) { - return null; - } try { - return (await file.json()) as Credentials; + const raw = await readFile(credentialsPath(envName), "utf-8"); + return JSON.parse(raw) as Credentials; } catch { return null; } @@ -34,7 +31,7 @@ export async function loadCredentials( export async function saveCredentials(creds: Credentials): Promise { const path = credentialsPath(creds.envName); await mkdir(dirname(path), { recursive: true }); - await Bun.write(path, JSON.stringify(creds, null, 2) + "\n"); + await writeFile(path, JSON.stringify(creds, null, 2) + "\n", "utf-8"); // chmod 600 — only the owner can read this file. Token is sensitive. await chmod(path, 0o600); } diff --git a/tsconfig.json b/tsconfig.json index a0cdf78..60ed61e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "moduleDetection": "force", "jsx": "react-jsx", "allowJs": true, - "types": ["bun"], + "types": ["bun", "node"], // Bundler mode "moduleResolution": "bundler",