From e9cfb75ae3639f1c12221d85124cf28f81359621 Mon Sep 17 00:00:00 2001 From: citron <45784494+lcandy2@users.noreply.github.com> Date: Thu, 30 Apr 2026 03:21:57 +0800 Subject: [PATCH 1/2] fix(bin): single bin + lazy pho symlink for unpinned npx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce package.json#bin from { photon, pho } to a single string "./dist/photon.js" so npm 11's `npx @photon-ai/photon` (no version pin) auto-resolves the binary. With multiple bin entries, npx skips auto-resolve even when one matches the post-scope name, leaving users with `sh: photon: command not found` unless they pin a version. Verified against control: `@vercel/ncc` (single bin) works unpinned; multi-bin scoped packages don't. Restore the `pho` shortcut at runtime instead of declaring it in `bin`. On first invocation, `ensurePhoAlias()` walks the standard bin-dir layouts (local node_modules/.bin, bun-global, npm-global) adjacent to the running script, finds the existing `photon` bin, and creates `pho` as a relative symlink. Postinstall would have been the obvious choice but Bun blocks postinstall by default, which would silently strip `pho` from `bun add -g` users — our primary install path. Runtime creation costs two existsSync calls per launch and self-heals if `pho` is removed. Verified locally: npm install + bun add from a packed tarball both behave identically — `photon` available immediately, `pho` created on first `photon` run. Co-authored-by: Orca --- README.md | 3 ++- package.json | 5 +---- src/index.ts | 2 ++ src/lib/pho-alias.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 src/lib/pho-alias.ts diff --git a/README.md b/README.md index 699a72d..35d8ac8 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ photon spectrum users ls photon billing show ``` -`pho` is an alias for `photon` for high-frequency commands: +`pho` is an alias for `photon` for high-frequency commands. It's created +automatically the first time you run `photon` after installing: ```sh pho ls # photon projects ls diff --git a/package.json b/package.json index e2999ea..f7e2174 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,7 @@ "url": "https://github.com/photon-hq/cli/issues" }, "type": "module", - "bin": { - "photon": "./dist/photon.js", - "pho": "./dist/photon.js" - }, + "bin": "./dist/photon.js", "files": [ "dist", "README.md" diff --git a/src/index.ts b/src/index.ts index 756b7db..6a2e37b 100755 --- a/src/index.ts +++ b/src/index.ts @@ -21,9 +21,11 @@ import { UnknownEnvError, } from '~/lib/errors.ts'; import { die } from '~/lib/output.ts'; +import { ensurePhoAlias } from '~/lib/pho-alias.ts'; import { startUpdateNotifier } from '~/lib/update-check.ts'; import pkg from '../package.json' with { type: 'json' }; +ensurePhoAlias(); startUpdateNotifier(); const program = new Command() diff --git a/src/lib/pho-alias.ts b/src/lib/pho-alias.ts new file mode 100644 index 0000000..dd77309 --- /dev/null +++ b/src/lib/pho-alias.ts @@ -0,0 +1,48 @@ +import { existsSync, symlinkSync } from "node:fs"; +import { join, resolve } from "node:path"; + +/** + * Lazily install `pho` as a sibling of `photon` in whichever bin directory + * we were launched from. + * + * Why not declare both bins in package.json? npm 11's `npx ` + * (no version) skips bin auto-resolve when `bin` has multiple keys. Keeping + * `bin` as a single string preserves clean `npx @photon-ai/photon` usage. + * + * Why not a `postinstall` script? Bun blocks postinstall by default — that + * would silently strip `pho` from `bun add -g` (our primary install path). + * + * Approach: walk up from the running script's path to the package root, then + * try the standard bin-dir layouts adjacent to it (local node_modules/.bin, + * bun-global `/bin`, npm-global `/bin`). First match wins. + * + * Cost is two `existsSync` calls per launch after `pho` exists. Errors are + * swallowed: this is convenience, never load-bearing. + */ +export function ensurePhoAlias(): void { + try { + const me = process.argv[1]; + if (!me) return; + + // process.argv[1] resolves symlinks → me is `/dist/photon.js`. + // pkgRoot is two levels up. + const pkgRoot = resolve(me, "..", ".."); + + const candidates = [ + resolve(pkgRoot, "..", "..", ".bin"), // local: node_modules/.bin + resolve(pkgRoot, "..", "..", "..", "bin"), // bun global: /bin + resolve(pkgRoot, "..", "..", "..", "..", "bin"), // npm global: /bin + ]; + + for (const dir of candidates) { + const photon = join(dir, "photon"); + const pho = join(dir, "pho"); + if (!existsSync(photon)) continue; + if (existsSync(pho)) return; + symlinkSync("./photon", pho); + return; + } + } catch { + // best-effort + } +} From 5c8ffd9a6d9c6210b8f73cb66f778cb693c964f5 Mon Sep 17 00:00:00 2001 From: citron <45784494+lcandy2@users.noreply.github.com> Date: Thu, 30 Apr 2026 03:29:44 +0800 Subject: [PATCH 2/2] fix(pho-alias): correct bun-global path + handle broken symlinks + guard source mode Three review findings on PR #12: - bun add -g actually puts the bin at ~/.bun/bin/photon (5 levels up from pkgRoot), not ~/.bun/install/global/bin. Verified by running the install end-to-end: before this fix, no candidate matched and pho was silently dropped for the very install path the PR claims to fix. Updated candidates: local node_modules/.bin (2 up), npm global /bin (4 up), bun global ~/.bun/bin (5 up). - existsSync follows symlinks, so a broken pho link wasn't detected and the subsequent symlinkSync would throw EEXIST. Switched to lstatSync, which sees the link entry itself. If anything is at the pho path (broken or not), leave it alone. - Source-mode runs (bun run src/index.ts) were probing candidate bin dirs uselessly. Added a regex guard that early-exits unless process.argv[1] matches the installed-package shape .../node_modules/.../dist/photon.js. Co-authored-by: Orca --- src/lib/pho-alias.ts | 43 +++++++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/src/lib/pho-alias.ts b/src/lib/pho-alias.ts index dd77309..0b287e3 100644 --- a/src/lib/pho-alias.ts +++ b/src/lib/pho-alias.ts @@ -1,5 +1,5 @@ -import { existsSync, symlinkSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { existsSync, lstatSync, symlinkSync } from "node:fs"; +import { join, resolve, sep } from "node:path"; /** * Lazily install `pho` as a sibling of `photon` in whichever bin directory @@ -13,32 +13,51 @@ import { join, resolve } from "node:path"; * would silently strip `pho` from `bun add -g` (our primary install path). * * Approach: walk up from the running script's path to the package root, then - * try the standard bin-dir layouts adjacent to it (local node_modules/.bin, - * bun-global `/bin`, npm-global `/bin`). First match wins. + * try the standard bin-dir layouts adjacent to it. First match wins. * - * Cost is two `existsSync` calls per launch after `pho` exists. Errors are - * swallowed: this is convenience, never load-bearing. + * /node_modules/.bin (local install) + * /bin with /lib/node_modules/... (npm/yarn/pnpm -g) + * ~/.bun/bin with ~/.bun/install/global/node_modules/... (bun -g) + * + * Cost is two stat calls per launch after `pho` exists. Errors are swallowed + * — `pho` is convenience, never load-bearing. */ export function ensurePhoAlias(): void { try { const me = process.argv[1]; if (!me) return; + // Guard: only run when launched from an installed package layout. + // Source-mode runs (`bun run src/index.ts`) skip this entirely. + const installedShape = new RegExp( + `${escapeForRegex(sep)}node_modules${escapeForRegex(sep)}.+${escapeForRegex(sep)}dist${escapeForRegex(sep)}photon\\.js$`, + ); + if (!installedShape.test(me)) return; + // process.argv[1] resolves symlinks → me is `/dist/photon.js`. // pkgRoot is two levels up. const pkgRoot = resolve(me, "..", ".."); const candidates = [ - resolve(pkgRoot, "..", "..", ".bin"), // local: node_modules/.bin - resolve(pkgRoot, "..", "..", "..", "bin"), // bun global: /bin - resolve(pkgRoot, "..", "..", "..", "..", "bin"), // npm global: /bin + resolve(pkgRoot, "..", "..", ".bin"), // local: node_modules/.bin + resolve(pkgRoot, "..", "..", "..", "..", "bin"), // npm/yarn/pnpm global: /bin + resolve(pkgRoot, "..", "..", "..", "..", "..", "bin"), // bun global: ~/.bun/bin ]; for (const dir of candidates) { const photon = join(dir, "photon"); const pho = join(dir, "pho"); if (!existsSync(photon)) continue; - if (existsSync(pho)) return; + + // lstatSync detects ANY entry at `pho` — including broken symlinks + // that existsSync would miss (existsSync follows the link target). + try { + lstatSync(pho); + return; // something already lives here; leave it alone + } catch { + // pho doesn't exist; safe to create + } + symlinkSync("./photon", pho); return; } @@ -46,3 +65,7 @@ export function ensurePhoAlias(): void { // best-effort } } + +function escapeForRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +}