@devlln/helm
Advanced tools
| import test from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import { | ||
| buildBridgeUpdateCommand, | ||
| checkForBridgeUpdate, | ||
| compareSemver, | ||
| detectBridgeInstallMethod, | ||
| shouldEnableBridgeAutoUpdate, | ||
| } from "./bridgeAutoUpdater.js"; | ||
| test("compareSemver compares numeric version segments", () => { | ||
| assert.equal(compareSemver("0.1.10", "0.2.0"), -1); | ||
| assert.equal(compareSemver("0.2.0", "0.1.10"), 1); | ||
| assert.equal(compareSemver("1.0.0", "1.0.0"), 0); | ||
| assert.equal(compareSemver("1.0.0-beta.1", "1.0.0"), 0); | ||
| }); | ||
| test("detectBridgeInstallMethod prefers Homebrew cellar installs and marks git checkouts as git", () => { | ||
| assert.equal( | ||
| detectBridgeInstallMethod({ | ||
| rootDir: "/opt/homebrew/Cellar/helm/0.2.0/libexec", | ||
| packageName: "@devlln/helm", | ||
| hasGitDir: false, | ||
| }), | ||
| "homebrew" | ||
| ); | ||
| assert.equal( | ||
| detectBridgeInstallMethod({ | ||
| rootDir: "/Users/devlin/GitHub/helm-dev", | ||
| packageName: "@devlln/helm", | ||
| hasGitDir: true, | ||
| }), | ||
| "git" | ||
| ); | ||
| }); | ||
| test("shouldEnableBridgeAutoUpdate skips local checkouts unless forced", () => { | ||
| assert.equal( | ||
| shouldEnableBridgeAutoUpdate({ | ||
| env: {}, | ||
| installMethod: "git", | ||
| packageName: "@devlln/helm", | ||
| }), | ||
| false | ||
| ); | ||
| assert.equal( | ||
| shouldEnableBridgeAutoUpdate({ | ||
| env: { HELM_BRIDGE_AUTO_UPDATE: "1" }, | ||
| installMethod: "git", | ||
| packageName: "@devlln/helm", | ||
| }), | ||
| true | ||
| ); | ||
| assert.equal( | ||
| shouldEnableBridgeAutoUpdate({ | ||
| env: {}, | ||
| installMethod: "npm", | ||
| packageName: "@devlln/helm", | ||
| }), | ||
| true | ||
| ); | ||
| }); | ||
| test("buildBridgeUpdateCommand runs the packaged update script with the detected method", () => { | ||
| assert.deepEqual( | ||
| buildBridgeUpdateCommand({ | ||
| rootDir: "/opt/homebrew/Cellar/helm/0.2.0/libexec", | ||
| installMethod: "homebrew", | ||
| }), | ||
| { | ||
| command: "/opt/homebrew/Cellar/helm/0.2.0/libexec/scripts/helm-update.sh", | ||
| args: ["--yes", "--source", "bridge-auto", "--method", "homebrew"], | ||
| } | ||
| ); | ||
| }); | ||
| test("checkForBridgeUpdate starts the updater when registry version is newer", async () => { | ||
| const updates: Array<{ command: string; args: string[] }> = []; | ||
| const result = await checkForBridgeUpdate({ | ||
| rootDir: "/usr/local/lib/node_modules/@devlln/helm", | ||
| packageInfo: { name: "@devlln/helm", version: "0.1.10" }, | ||
| hasGitDir: false, | ||
| env: {}, | ||
| fetchLatestVersion: async () => "0.2.0", | ||
| runUpdate: (command, args) => { | ||
| updates.push({ command, args }); | ||
| }, | ||
| scriptExists: () => true, | ||
| }); | ||
| assert.equal(result.status, "started"); | ||
| assert.deepEqual(updates, [ | ||
| { | ||
| command: "/usr/local/lib/node_modules/@devlln/helm/scripts/helm-update.sh", | ||
| args: ["--yes", "--source", "bridge-auto", "--method", "npm"], | ||
| }, | ||
| ]); | ||
| }); |
| import { spawn } from "node:child_process"; | ||
| import { existsSync, readFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
| export type BridgeInstallMethod = "npm" | "homebrew" | "git" | "unknown"; | ||
| export interface BridgePackageInfo { | ||
| name: string; | ||
| version: string; | ||
| } | ||
| export interface BridgeUpdateCommand { | ||
| command: string; | ||
| args: string[]; | ||
| } | ||
| export interface BridgeUpdateCheckResult { | ||
| status: "disabled" | "current" | "started" | "skipped"; | ||
| reason?: string; | ||
| currentVersion?: string; | ||
| latestVersion?: string; | ||
| installMethod?: BridgeInstallMethod; | ||
| } | ||
| export interface BridgeUpdateCheckOptions { | ||
| rootDir: string; | ||
| env?: NodeJS.ProcessEnv | Record<string, string | undefined>; | ||
| packageInfo?: BridgePackageInfo | null; | ||
| hasGitDir?: boolean; | ||
| installMethod?: BridgeInstallMethod; | ||
| fetchLatestVersion?: (packageName: string) => Promise<string | null>; | ||
| runUpdate?: (command: string, args: string[]) => void; | ||
| scriptExists?: (path: string) => boolean; | ||
| logger?: Pick<Console, "log" | "warn">; | ||
| } | ||
| export interface StartBridgeAutoUpdaterOptions extends BridgeUpdateCheckOptions { | ||
| setTimeoutFn?: typeof setTimeout; | ||
| setIntervalFn?: typeof setInterval; | ||
| } | ||
| export interface BridgeAutoUpdaterHandle { | ||
| stop(): void; | ||
| checkNow(): Promise<BridgeUpdateCheckResult>; | ||
| } | ||
| const PUBLIC_PACKAGE_NAME = "@devlln/helm"; | ||
| const DEFAULT_INITIAL_DELAY_MS = 30_000; | ||
| const DEFAULT_INTERVAL_MS = 6 * 60 * 60 * 1000; | ||
| function versionSegments(version: string): number[] { | ||
| const core = version.trim().replace(/^v/i, "").split("-", 1)[0] ?? ""; | ||
| const rawSegments = core.split(".").slice(0, 3); | ||
| const segments = rawSegments.map((segment) => { | ||
| const value = Number.parseInt(segment, 10); | ||
| return Number.isFinite(value) ? value : 0; | ||
| }); | ||
| while (segments.length < 3) { | ||
| segments.push(0); | ||
| } | ||
| return segments; | ||
| } | ||
| export function compareSemver(lhs: string, rhs: string): number { | ||
| const lhsSegments = versionSegments(lhs); | ||
| const rhsSegments = versionSegments(rhs); | ||
| for (let index = 0; index < 3; index += 1) { | ||
| const lhsValue = lhsSegments[index] ?? 0; | ||
| const rhsValue = rhsSegments[index] ?? 0; | ||
| if (lhsValue < rhsValue) { | ||
| return -1; | ||
| } | ||
| if (lhsValue > rhsValue) { | ||
| return 1; | ||
| } | ||
| } | ||
| return 0; | ||
| } | ||
| export function detectBridgeInstallMethod(input: { | ||
| rootDir: string; | ||
| packageName?: string | null; | ||
| hasGitDir?: boolean; | ||
| }): BridgeInstallMethod { | ||
| if (/\/Cellar\/helm\/[^/]+\/libexec\/?$/.test(input.rootDir) || input.rootDir.includes("/Cellar/helm/")) { | ||
| return "homebrew"; | ||
| } | ||
| if (input.hasGitDir) { | ||
| return "git"; | ||
| } | ||
| if (input.packageName === PUBLIC_PACKAGE_NAME || input.packageName === "@devlin/helm") { | ||
| return "npm"; | ||
| } | ||
| return "unknown"; | ||
| } | ||
| export function shouldEnableBridgeAutoUpdate(input: { | ||
| env: NodeJS.ProcessEnv | Record<string, string | undefined>; | ||
| installMethod: BridgeInstallMethod; | ||
| packageName?: string | null; | ||
| }): boolean { | ||
| const override = input.env.HELM_BRIDGE_AUTO_UPDATE?.trim().toLowerCase(); | ||
| if (override === "0" || override === "false" || override === "off" || override === "no") { | ||
| return false; | ||
| } | ||
| if (override === "1" || override === "true" || override === "on" || override === "yes") { | ||
| return true; | ||
| } | ||
| return input.packageName === PUBLIC_PACKAGE_NAME | ||
| && (input.installMethod === "npm" || input.installMethod === "homebrew"); | ||
| } | ||
| export function buildBridgeUpdateCommand(input: { | ||
| rootDir: string; | ||
| installMethod: BridgeInstallMethod; | ||
| }): BridgeUpdateCommand { | ||
| return { | ||
| command: join(input.rootDir, "scripts", "helm-update.sh"), | ||
| args: ["--yes", "--source", "bridge-auto", "--method", input.installMethod], | ||
| }; | ||
| } | ||
| function readPackageInfo(rootDir: string): BridgePackageInfo | null { | ||
| try { | ||
| const parsed = JSON.parse(readFileSync(join(rootDir, "package.json"), "utf8")) as { | ||
| name?: unknown; | ||
| version?: unknown; | ||
| }; | ||
| if (typeof parsed.name !== "string" || typeof parsed.version !== "string") { | ||
| return null; | ||
| } | ||
| return { name: parsed.name, version: parsed.version }; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| async function fetchNpmLatestVersion(packageName: string): Promise<string | null> { | ||
| const encodedName = encodeURIComponent(packageName).replace(/^%40/, "@"); | ||
| const response = await fetch(`https://registry.npmjs.org/${encodedName}/latest`, { | ||
| headers: { | ||
| accept: "application/json", | ||
| }, | ||
| }); | ||
| if (!response.ok) { | ||
| return null; | ||
| } | ||
| const body = await response.json() as { version?: unknown }; | ||
| return typeof body.version === "string" ? body.version : null; | ||
| } | ||
| function runDetachedUpdate(command: string, args: string[]): void { | ||
| const child = spawn(command, args, { | ||
| detached: true, | ||
| stdio: "ignore", | ||
| env: process.env, | ||
| }); | ||
| child.unref(); | ||
| } | ||
| export async function checkForBridgeUpdate(options: BridgeUpdateCheckOptions): Promise<BridgeUpdateCheckResult> { | ||
| const env = options.env ?? process.env; | ||
| const packageInfo = options.packageInfo ?? readPackageInfo(options.rootDir); | ||
| if (!packageInfo) { | ||
| return { status: "disabled", reason: "package-info-unavailable" }; | ||
| } | ||
| const hasGitDir = options.hasGitDir ?? existsSync(join(options.rootDir, ".git")); | ||
| const installMethod = options.installMethod ?? detectBridgeInstallMethod({ | ||
| rootDir: options.rootDir, | ||
| packageName: packageInfo.name, | ||
| hasGitDir, | ||
| }); | ||
| if (!shouldEnableBridgeAutoUpdate({ env, installMethod, packageName: packageInfo.name })) { | ||
| return { | ||
| status: "disabled", | ||
| reason: "auto-update-disabled", | ||
| currentVersion: packageInfo.version, | ||
| installMethod, | ||
| }; | ||
| } | ||
| const updateCommand = buildBridgeUpdateCommand({ rootDir: options.rootDir, installMethod }); | ||
| const scriptExists = options.scriptExists ?? existsSync; | ||
| if (!scriptExists(updateCommand.command)) { | ||
| return { | ||
| status: "disabled", | ||
| reason: "update-script-missing", | ||
| currentVersion: packageInfo.version, | ||
| installMethod, | ||
| }; | ||
| } | ||
| const fetchLatestVersion = options.fetchLatestVersion ?? fetchNpmLatestVersion; | ||
| let latestVersion: string | null = null; | ||
| try { | ||
| latestVersion = await fetchLatestVersion(PUBLIC_PACKAGE_NAME); | ||
| } catch (error) { | ||
| options.logger?.warn(`[bridge] Helm auto-update check failed: ${error instanceof Error ? error.message : String(error)}`); | ||
| return { | ||
| status: "skipped", | ||
| reason: "latest-version-fetch-failed", | ||
| currentVersion: packageInfo.version, | ||
| installMethod, | ||
| }; | ||
| } | ||
| if (!latestVersion) { | ||
| return { | ||
| status: "skipped", | ||
| reason: "latest-version-unavailable", | ||
| currentVersion: packageInfo.version, | ||
| installMethod, | ||
| }; | ||
| } | ||
| if (compareSemver(packageInfo.version, latestVersion) >= 0) { | ||
| return { | ||
| status: "current", | ||
| currentVersion: packageInfo.version, | ||
| latestVersion, | ||
| installMethod, | ||
| }; | ||
| } | ||
| const runUpdate = options.runUpdate ?? runDetachedUpdate; | ||
| runUpdate(updateCommand.command, updateCommand.args); | ||
| options.logger?.log(`[bridge] Helm ${packageInfo.version} is older than ${latestVersion}; started ${installMethod} update.`); | ||
| return { | ||
| status: "started", | ||
| currentVersion: packageInfo.version, | ||
| latestVersion, | ||
| installMethod, | ||
| }; | ||
| } | ||
| function numberFromEnv( | ||
| env: NodeJS.ProcessEnv | Record<string, string | undefined>, | ||
| key: string, | ||
| fallback: number | ||
| ): number { | ||
| const raw = env[key]; | ||
| if (raw === undefined || raw.trim() === "") { | ||
| return fallback; | ||
| } | ||
| const value = Number.parseInt(raw, 10); | ||
| return Number.isFinite(value) && value >= 0 ? value : fallback; | ||
| } | ||
| export function startBridgeAutoUpdater(options: StartBridgeAutoUpdaterOptions): BridgeAutoUpdaterHandle { | ||
| const env = options.env ?? process.env; | ||
| let stopped = false; | ||
| let running = false; | ||
| let timeoutHandle: ReturnType<typeof setTimeout> | null = null; | ||
| let intervalHandle: ReturnType<typeof setInterval> | null = null; | ||
| const setTimeoutFn = options.setTimeoutFn ?? setTimeout; | ||
| const setIntervalFn = options.setIntervalFn ?? setInterval; | ||
| const checkNow = async (): Promise<BridgeUpdateCheckResult> => { | ||
| if (stopped || running) { | ||
| return { status: "skipped", reason: stopped ? "stopped" : "already-running" }; | ||
| } | ||
| running = true; | ||
| try { | ||
| return await checkForBridgeUpdate(options); | ||
| } finally { | ||
| running = false; | ||
| } | ||
| }; | ||
| const initialDelayMS = numberFromEnv(env, "HELM_BRIDGE_AUTO_UPDATE_INITIAL_DELAY_MS", DEFAULT_INITIAL_DELAY_MS); | ||
| const intervalMS = numberFromEnv(env, "HELM_BRIDGE_AUTO_UPDATE_INTERVAL_MS", DEFAULT_INTERVAL_MS); | ||
| timeoutHandle = setTimeoutFn(() => { | ||
| void checkNow(); | ||
| }, initialDelayMS); | ||
| timeoutHandle.unref?.(); | ||
| if (intervalMS > 0) { | ||
| intervalHandle = setIntervalFn(() => { | ||
| void checkNow(); | ||
| }, intervalMS); | ||
| intervalHandle.unref?.(); | ||
| } | ||
| return { | ||
| stop() { | ||
| stopped = true; | ||
| if (timeoutHandle) { | ||
| clearTimeout(timeoutHandle); | ||
| } | ||
| if (intervalHandle) { | ||
| clearInterval(intervalHandle); | ||
| } | ||
| }, | ||
| checkNow, | ||
| }; | ||
| } |
| #!/usr/bin/env bash | ||
| set -euo pipefail | ||
| ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | ||
| BRIDGE_DIR="$ROOT_DIR/bridge" | ||
| PACKAGE_NAME="${HELM_NPM_PACKAGE_NAME:-@devlln/helm}" | ||
| HOMEBREW_FORMULA="${HELM_HOMEBREW_FORMULA:-devlln/helm/helm}" | ||
| METHOD="auto" | ||
| SOURCE="manual" | ||
| YES=0 | ||
| DRY_RUN=0 | ||
| RESTART=1 | ||
| usage() { | ||
| cat <<'EOF' | ||
| Usage: scripts/helm-update.sh [options] | ||
| Update the installed Helm bridge package and restart the launchd bridge service. | ||
| Options: | ||
| --method auto|npm|homebrew|git Select the update mechanism. Default: auto. | ||
| --yes Do not prompt before updating. | ||
| --dry-run Print the update commands without running them. | ||
| --no-restart Update without restarting the bridge service. | ||
| --source NAME Label the caller in logs. Default: manual. | ||
| -h, --help Show this help. | ||
| EOF | ||
| } | ||
| while [[ $# -gt 0 ]]; do | ||
| case "$1" in | ||
| --method) | ||
| METHOD="${2:-}" | ||
| shift 2 | ||
| ;; | ||
| --yes|-y) | ||
| YES=1 | ||
| shift | ||
| ;; | ||
| --dry-run) | ||
| DRY_RUN=1 | ||
| shift | ||
| ;; | ||
| --no-restart) | ||
| RESTART=0 | ||
| shift | ||
| ;; | ||
| --source) | ||
| SOURCE="${2:-}" | ||
| shift 2 | ||
| ;; | ||
| -h|--help) | ||
| usage | ||
| exit 0 | ||
| ;; | ||
| *) | ||
| echo "Unsupported argument: $1" >&2 | ||
| usage >&2 | ||
| exit 2 | ||
| ;; | ||
| esac | ||
| done | ||
| require_cmd() { | ||
| if [[ "$DRY_RUN" -eq 1 ]]; then | ||
| return | ||
| fi | ||
| if ! command -v "$1" >/dev/null 2>&1; then | ||
| echo "Missing required command: $1" >&2 | ||
| exit 1 | ||
| fi | ||
| } | ||
| run_cmd() { | ||
| if [[ "$DRY_RUN" -eq 1 ]]; then | ||
| printf '[helm-update] would run:' | ||
| printf ' %q' "$@" | ||
| printf '\n' | ||
| return 0 | ||
| fi | ||
| "$@" | ||
| } | ||
| detect_method() { | ||
| if [[ "$ROOT_DIR" == *"/Cellar/helm/"* ]] && command -v brew >/dev/null 2>&1; then | ||
| echo "homebrew" | ||
| return | ||
| fi | ||
| if [[ -d "$ROOT_DIR/.git" ]]; then | ||
| echo "git" | ||
| return | ||
| fi | ||
| if command -v npm >/dev/null 2>&1; then | ||
| echo "npm" | ||
| return | ||
| fi | ||
| echo "unknown" | ||
| } | ||
| resolve_installed_root() { | ||
| local helm_bin | ||
| helm_bin="$(command -v helm || true)" | ||
| if [[ -z "$helm_bin" ]]; then | ||
| printf '%s\n' "$ROOT_DIR" | ||
| return | ||
| fi | ||
| python3 - "$helm_bin" "$ROOT_DIR" <<'PY' | ||
| import os | ||
| import sys | ||
| helm_bin, fallback = sys.argv[1:] | ||
| try: | ||
| real = os.path.realpath(helm_bin) | ||
| root = os.path.abspath(os.path.join(os.path.dirname(real), "..")) | ||
| if os.path.exists(os.path.join(root, "scripts", "bridge-service.sh")): | ||
| print(root) | ||
| raise SystemExit(0) | ||
| except OSError: | ||
| pass | ||
| print(fallback) | ||
| PY | ||
| } | ||
| restart_bridge_service() { | ||
| if [[ "$RESTART" -eq 0 ]]; then | ||
| return | ||
| fi | ||
| local installed_root | ||
| installed_root="$(resolve_installed_root)" | ||
| local service_script="$installed_root/scripts/bridge-service.sh" | ||
| if [[ ! -f "$service_script" ]]; then | ||
| echo "[helm-update] Bridge service script not found; skipping service restart." >&2 | ||
| return | ||
| fi | ||
| if [[ "$DRY_RUN" -eq 1 ]]; then | ||
| run_cmd "$service_script" restart | ||
| return | ||
| fi | ||
| if "$service_script" restart; then | ||
| return | ||
| fi | ||
| echo "[helm-update] Bridge service restart failed. Run 'helm bridge service restart' after the update." >&2 | ||
| } | ||
| METHOD="${METHOD:-auto}" | ||
| if [[ "$METHOD" == "auto" ]]; then | ||
| METHOD="$(detect_method)" | ||
| fi | ||
| case "$METHOD" in | ||
| npm|homebrew|git) | ||
| ;; | ||
| unknown) | ||
| echo "Could not detect how Helm was installed. Reinstall with npm or Homebrew, or pass --method." >&2 | ||
| exit 1 | ||
| ;; | ||
| *) | ||
| echo "Unsupported update method: $METHOD" >&2 | ||
| usage >&2 | ||
| exit 2 | ||
| ;; | ||
| esac | ||
| echo "[helm-update] source=${SOURCE} method=${METHOD} root=${ROOT_DIR}" | ||
| if [[ "$YES" -eq 0 && "$DRY_RUN" -eq 0 ]]; then | ||
| if [[ ! -t 0 ]]; then | ||
| echo "Refusing to update without --yes from a non-interactive shell." >&2 | ||
| exit 1 | ||
| fi | ||
| read -r -p "Update Helm using ${METHOD} and restart the bridge service? [y/N] " reply | ||
| case "$reply" in | ||
| y|Y|yes|YES) | ||
| ;; | ||
| *) | ||
| echo "Update cancelled." | ||
| exit 1 | ||
| ;; | ||
| esac | ||
| fi | ||
| case "$METHOD" in | ||
| npm) | ||
| require_cmd npm | ||
| run_cmd npm install -g "${PACKAGE_NAME}@latest" | ||
| ;; | ||
| homebrew) | ||
| require_cmd brew | ||
| run_cmd brew update | ||
| if ! run_cmd brew upgrade "$HOMEBREW_FORMULA"; then | ||
| run_cmd brew reinstall "$HOMEBREW_FORMULA" | ||
| fi | ||
| ;; | ||
| git) | ||
| require_cmd git | ||
| require_cmd npm | ||
| run_cmd git -C "$ROOT_DIR" pull --ff-only | ||
| run_cmd npm --prefix "$BRIDGE_DIR" install | ||
| run_cmd npm --prefix "$BRIDGE_DIR" run build | ||
| ;; | ||
| esac | ||
| restart_bridge_service | ||
| echo "[helm-update] update complete" |
+28
-5
@@ -18,2 +18,3 @@ #!/usr/bin/env node | ||
| helm platforms [--json] | ||
| helm update [--method auto|npm|homebrew|git] [--dry-run] | ||
| helm help [bridge] | ||
@@ -25,2 +26,3 @@ | ||
| platforms Detect the local runtimes, shell integration, Tailscale state, and Mac-app build support that Helm can use. | ||
| update Update the installed Helm bridge package and restart the bridge service. | ||
@@ -33,2 +35,3 @@ Compatibility aliases: | ||
| helm down -> helm bridge down | ||
| helm upgrade -> helm update | ||
@@ -43,2 +46,3 @@ Examples: | ||
| helm platforms --json | ||
| helm update --dry-run | ||
| `); | ||
@@ -55,10 +59,14 @@ } | ||
| helm bridge status | ||
| helm bridge service <install|uninstall|start|stop|restart|status|print-plist> | ||
| helm bridge update [update options] | ||
| helm bridge down | ||
| Bridge commands: | ||
| setup Run the guided Helm bridge setup flow. | ||
| up Start the local bridge and Codex app-server helper. | ||
| pair Start the bridge if needed, then print the pairing QR and setup link. | ||
| status Show bridge health, pairing details, and voice-provider availability. | ||
| down Stop the local prototype bridge stack. | ||
| setup Run the guided Helm bridge setup flow. | ||
| up Start the local bridge and Codex app-server helper. | ||
| pair Start the bridge if needed, then print the pairing QR and setup link. | ||
| status Show bridge health, pairing details, and voice-provider availability. | ||
| service Manage the launchd bridge service. | ||
| update Update Helm and restart the bridge service. | ||
| down Stop the local prototype bridge stack. | ||
| `); | ||
@@ -113,2 +121,6 @@ } | ||
| function runUpdate(args) { | ||
| runScript("helm-update.sh", args); | ||
| } | ||
| function runBridge(args) { | ||
@@ -138,2 +150,9 @@ const [subcommand, ...bridgeArgs] = args; | ||
| return; | ||
| case "service": | ||
| runScript("bridge-service.sh", bridgeArgs); | ||
| return; | ||
| case "update": | ||
| case "upgrade": | ||
| runUpdate(bridgeArgs); | ||
| return; | ||
| case "down": | ||
@@ -177,2 +196,6 @@ case "stop": | ||
| break; | ||
| case "update": | ||
| case "upgrade": | ||
| runUpdate(rawArgs); | ||
| break; | ||
| case "up": | ||
@@ -179,0 +202,0 @@ case "start": |
| { | ||
| "name": "codex-voice-remote-bridge", | ||
| "version": "0.1.10", | ||
| "version": "0.2.0", | ||
| "lockfileVersion": 3, | ||
@@ -9,3 +9,3 @@ "requires": true, | ||
| "name": "codex-voice-remote-bridge", | ||
| "version": "0.1.10", | ||
| "version": "0.2.0", | ||
| "dependencies": { | ||
@@ -12,0 +12,0 @@ "dotenv": "^16.6.1", |
| { | ||
| "name": "codex-voice-remote-bridge", | ||
| "version": "0.1.10", | ||
| "version": "0.2.0", | ||
| "private": true, | ||
@@ -5,0 +5,0 @@ "type": "module", |
@@ -141,2 +141,8 @@ import test from "node:test"; | ||
| ): Promise<JSONValue | undefined>; | ||
| ensureAppServerThreadLoadedForDelivery( | ||
| threadId: string, | ||
| options?: { forceResume?: boolean } | ||
| ): Promise<void>; | ||
| canRefreshCodexDesktopThreadRoute(): boolean; | ||
| refreshCodexDesktopThreadRoute(threadId: string, reason?: string): Promise<boolean>; | ||
| codexDesktopQueuedFollowUpsWithAppendedMessage( | ||
@@ -360,6 +366,8 @@ currentMessages: TestQueuedFollowUp[], | ||
| test("idle Codex desktop turn falls back to app-server when desktop IPC has no loaded client", async () => { | ||
| test("idle Codex desktop turn falls back through app-server and refreshes Codex.app when desktop IPC has no loaded client", async () => { | ||
| const client = new CodexAppServerClient("ws://127.0.0.1:0"); | ||
| const hooks = client as unknown as CodexClientPrivateHooks; | ||
| const requestCalls: Array<{ method: string; params?: JSONValue }> = []; | ||
| const ensureCalls: Array<{ threadId: string; options?: { forceResume?: boolean } }> = []; | ||
| const refreshCalls: Array<{ threadId: string; reason?: string }> = []; | ||
@@ -387,5 +395,12 @@ hooks.loadThreadDeliverySummary = async () => ({ | ||
| }; | ||
| hooks.ensureAppServerThreadLoadedForDelivery = async (threadId, options) => { | ||
| ensureCalls.push({ threadId, options }); | ||
| }; | ||
| hooks.canRefreshCodexDesktopThreadRoute = () => true; | ||
| hooks.refreshCodexDesktopThreadRoute = async (threadId, reason) => { | ||
| refreshCalls.push({ threadId, reason }); | ||
| return true; | ||
| }; | ||
| hooks.request = async (method: string, params?: JSONValue) => { | ||
| requestCalls.push({ method, params }); | ||
| assert.equal(method, "turn/start"); | ||
| return { | ||
@@ -400,2 +415,89 @@ ok: true, | ||
| assert.deepEqual(result, { | ||
| ok: true, | ||
| mode: "appServerStartAfterDesktopIpcNoClientWithDesktopRefresh", | ||
| threadId: "thread-1", | ||
| }); | ||
| assert.deepEqual(ensureCalls, [ | ||
| { threadId: "thread-1", options: { forceResume: true } }, | ||
| ]); | ||
| assert.equal(requestCalls.length, 1); | ||
| assert.equal(requestCalls[0]?.method, "turn/start"); | ||
| assert.deepEqual(requestCalls[0]?.params, { | ||
| threadId: "thread-1", | ||
| input: [ | ||
| { | ||
| type: "text", | ||
| text: "from mobile", | ||
| text_elements: [], | ||
| }, | ||
| ], | ||
| }); | ||
| assert.deepEqual(refreshCalls, [ | ||
| { threadId: "thread-1", reason: "desktop-ipc-no-client" }, | ||
| ]); | ||
| }); | ||
| test("idle app-server thread loads and retries text start when direct start reports thread not loaded", async () => { | ||
| const client = new CodexAppServerClient("ws://127.0.0.1:0"); | ||
| const hooks = client as unknown as CodexClientPrivateHooks; | ||
| const requestCalls: Array<{ method: string; params?: JSONValue }> = []; | ||
| const ensureCalls: Array<{ threadId: string; options?: { forceResume?: boolean } }> = []; | ||
| let rejectedFirstTextStart = false; | ||
| hooks.loadThreadDeliverySummary = async () => ({ | ||
| sourceKind: "appServer", | ||
| status: "idle", | ||
| }); | ||
| hooks.readThreadDeliverySnapshot = async () => ({ | ||
| hasTurnData: true, | ||
| turnCount: 0, | ||
| matchingUserTextCount: 0, | ||
| updatedAt: 123_000, | ||
| threadStatus: "idle", | ||
| activeTurnId: null, | ||
| }); | ||
| hooks.steerTurnViaCodexDesktopIpc = async () => { | ||
| throw new Error("Codex Desktop IPC thread-follower-steer-turn failed: no-client-found"); | ||
| }; | ||
| hooks.startTurnViaCodexDesktopIpc = async () => { | ||
| throw new Error("Codex Desktop IPC thread-follower-start-turn failed: no-client-found"); | ||
| }; | ||
| hooks.shouldStartViaAppServer = async () => true; | ||
| hooks.shouldPreferCLIResumeFallback = async () => false; | ||
| hooks.shouldPreferShellRelayFirst = async () => false; | ||
| hooks.ensureAppServerThreadLoadedForDelivery = async (threadId, options) => { | ||
| ensureCalls.push({ threadId, options }); | ||
| }; | ||
| hooks.request = async (method: string, params?: JSONValue) => { | ||
| requestCalls.push({ method, params }); | ||
| if ( | ||
| !rejectedFirstTextStart | ||
| && requestCalls.length === 1 | ||
| && | ||
| method === "turn/start" | ||
| && params | ||
| && typeof params === "object" | ||
| && !Array.isArray(params) | ||
| && Array.isArray(params.input) | ||
| && params.input.length > 0 | ||
| ) { | ||
| rejectedFirstTextStart = true; | ||
| throw new Error("thread not loaded: thread-1"); | ||
| } | ||
| return { | ||
| ok: true, | ||
| }; | ||
| }; | ||
| const result = await client.startTurn("thread-1", "from mobile", { | ||
| deliveryMode: "steer", | ||
| }); | ||
| assert.deepEqual(ensureCalls, [ | ||
| { | ||
| threadId: "thread-1", | ||
| options: { forceResume: true }, | ||
| }, | ||
| ]); | ||
| assert.deepEqual(requestCalls, [ | ||
@@ -415,6 +517,19 @@ { | ||
| }, | ||
| { | ||
| method: "turn/start", | ||
| params: { | ||
| threadId: "thread-1", | ||
| input: [ | ||
| { | ||
| type: "text", | ||
| text: "from mobile", | ||
| text_elements: [], | ||
| }, | ||
| ], | ||
| }, | ||
| }, | ||
| ]); | ||
| assert.deepEqual(result, { | ||
| ok: true, | ||
| mode: "appServerStartAfterDesktopIpcNoClient", | ||
| mode: "appServerStartAfterThreadLoadRetry", | ||
| threadId: "thread-1", | ||
@@ -424,2 +539,57 @@ }); | ||
| test("missing delivery summary still retries text start for unloaded app-server threads", async () => { | ||
| const client = new CodexAppServerClient("ws://127.0.0.1:0"); | ||
| const hooks = client as unknown as CodexClientPrivateHooks; | ||
| const requestCalls: Array<{ method: string; params?: JSONValue }> = []; | ||
| const ensureCalls: Array<{ threadId: string; options?: { forceResume?: boolean } }> = []; | ||
| hooks.loadThreadDeliverySummary = async () => null; | ||
| hooks.readThreadDeliverySnapshot = async () => null; | ||
| hooks.shouldStartViaAppServer = async () => true; | ||
| hooks.shouldPreferCLIResumeFallback = async () => false; | ||
| hooks.shouldPreferShellRelayFirst = async () => false; | ||
| hooks.ensureAppServerThreadLoadedForDelivery = async (threadId, options) => { | ||
| ensureCalls.push({ threadId, options }); | ||
| }; | ||
| hooks.request = async (method: string, params?: JSONValue) => { | ||
| requestCalls.push({ method, params }); | ||
| if (method === "turn/start" && requestCalls.length === 1) { | ||
| throw new Error("thread not loaded: thread-1"); | ||
| } | ||
| return { | ||
| ok: true, | ||
| }; | ||
| }; | ||
| const result = await client.startTurn("thread-1", "from mobile", { | ||
| deliveryMode: "steer", | ||
| }); | ||
| assert.deepEqual(ensureCalls, [ | ||
| { | ||
| threadId: "thread-1", | ||
| options: { forceResume: true }, | ||
| }, | ||
| ]); | ||
| assert.deepEqual(requestCalls.map((call) => call.method), [ | ||
| "turn/start", | ||
| "turn/start", | ||
| ]); | ||
| assert.deepEqual(requestCalls[1]?.params, { | ||
| threadId: "thread-1", | ||
| input: [ | ||
| { | ||
| type: "text", | ||
| text: "from mobile", | ||
| text_elements: [], | ||
| }, | ||
| ], | ||
| }); | ||
| assert.deepEqual(result, { | ||
| ok: true, | ||
| mode: "appServerStartAfterThreadLoadRetry", | ||
| threadId: "thread-1", | ||
| }); | ||
| }); | ||
| test("running Codex desktop steer falls back to app-server steer when desktop IPC has no loaded client", async () => { | ||
@@ -426,0 +596,0 @@ const client = new CodexAppServerClient("ws://127.0.0.1:0"); |
@@ -0,6 +1,12 @@ | ||
| import { dirname, resolve } from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import { startBridgeAutoUpdater } from "./bridgeAutoUpdater.js"; | ||
| import { BridgeServer } from "./bridgeServer.js"; | ||
| async function main(): Promise<void> { | ||
| const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); | ||
| const server = new BridgeServer(); | ||
| await server.start(); | ||
| startBridgeAutoUpdater({ rootDir, logger: console }); | ||
| console.log("[bridge] listening"); | ||
@@ -15,2 +21,1 @@ } | ||
| }); | ||
+2
-1
| { | ||
| "name": "@devlln/helm", | ||
| "version": "0.1.10", | ||
| "version": "0.2.0", | ||
| "private": false, | ||
@@ -39,2 +39,3 @@ "description": "Helm CLI bridge installer and runtime helpers.", | ||
| "scripts/helm-runtime-wrapper.sh", | ||
| "scripts/helm-update.sh", | ||
| "scripts/helm_bootstrap_codex_thread.py", | ||
@@ -41,0 +42,0 @@ "scripts/helm_codex_wrapper_plan.py", |
+14
-0
@@ -52,2 +52,16 @@ <p align="center"> | ||
| ## Updates | ||
| Published npm and Homebrew installs check for bridge updates automatically while the launchd bridge service is running. Local git checkouts do not auto-update unless `HELM_BRIDGE_AUTO_UPDATE=1` is set. | ||
| Manual update commands: | ||
| ```bash | ||
| helm update | ||
| helm update --dry-run | ||
| helm update --method homebrew | ||
| helm update --method npm | ||
| helm update --method git | ||
| ``` | ||
| Helm can detect: | ||
@@ -54,0 +68,0 @@ |
@@ -40,2 +40,3 @@ #!/usr/bin/env bash | ||
| link_script "$ROOT_DIR/scripts/bridge-service.sh" "helm-bridge-service" | ||
| link_script "$ROOT_DIR/scripts/helm-update.sh" "helm-update" | ||
| link_script "$ROOT_DIR/scripts/print-pairing-qr.sh" "helm-pairing-qr" | ||
@@ -75,2 +76,3 @@ link_script "$ROOT_DIR/scripts/detect-helm-platforms.sh" "helm-platforms" | ||
| helm-bridge-service | ||
| helm-update | ||
| helm-pairing-qr | ||
@@ -104,5 +106,7 @@ helm-platforms | ||
| helm-pairing-qr | ||
| 6. In helm on iPhone, scan the pairing QR. | ||
| 7. Start Codex CLI, Claude Code, or Grok CLI sessions normally. | ||
| 8. If Ollama is installed, start local model sessions with: | ||
| 6. Update the installed bridge manually if needed: | ||
| helm update | ||
| 7. In helm on iPhone, scan the pairing QR. | ||
| 8. Start Codex CLI, Claude Code, or Grok CLI sessions normally. | ||
| 9. If Ollama is installed, start local model sessions with: | ||
| helm-gemma | ||
@@ -109,0 +113,0 @@ helm-qwen |
Sorry, the diff of this file is too big to display
Explicitly Unlicensed Item
LicenseSomething was found which is explicitly marked as unlicensed.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 3 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Explicitly Unlicensed Item
LicenseSomething was found which is explicitly marked as unlicensed.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
879217
3.66%75
4.17%22735
3.47%205
7.33%64
16.36%10
11.11%