Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 137 additions & 13 deletions .github/workflows/release-binaries.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Comment on lines +56 to +63
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
fi

- uses: actions/checkout@v5
with:
ref: ${{ steps.tag.outputs.tag }}
Comment thread
lcandy2 marked this conversation as resolved.

- 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
Expand All @@ -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
Comment on lines 117 to +124
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
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:

Expand Down
7 changes: 4 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,21 @@
"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",
"prepublishOnly": "bun run typecheck && bun run build"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^25.6.2",
"@types/update-notifier": "^6.0.8",
"elysia": "1.4.28",
"typescript": "^5"
Expand Down
23 changes: 18 additions & 5 deletions src/commands/spectrum/avatar.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
".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");

Expand All @@ -16,10 +26,13 @@ export function registerSpectrumAvatar(spectrum: Command): void {
.option("--api-host <url>", "API host URL (defaults to PHOTON_API_HOST or built-in production)")
.option("-t, --token <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}`);
Comment on lines +29 to 31
}
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,
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
4 changes: 3 additions & 1 deletion src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
11 changes: 4 additions & 7 deletions src/lib/credentials.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -20,12 +20,9 @@ export interface Credentials {
export async function loadCredentials(
envName: string
): Promise<Credentials | null> {
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;
}
Expand All @@ -34,7 +31,7 @@ export async function loadCredentials(
export async function saveCredentials(creds: Credentials): Promise<void> {
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);
}
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"types": ["bun"],
"types": ["bun", "node"],

// Bundler mode
"moduleResolution": "bundler",
Expand Down