From b049306b6ea18a3e0033f6e7aeb064e5fc8bbb11 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 29 Jun 2026 18:45:10 -0700 Subject: [PATCH] Make `hexclave exec` support local dashboard --- apps/e2e/tests/general/cli.test.ts | 93 ++++--------- packages/cli/src/commands/dev.ts | 119 +--------------- packages/cli/src/commands/exec.ts | 9 +- .../src/lib/local-dashboard-client.test.ts | 98 +++++++++++++ .../cli/src/lib/local-dashboard-client.ts | 64 +++++++++ packages/cli/src/lib/local-dashboard.ts | 129 ++++++++++++++++++ .../src/ai/unified-prompts/reminders.ts | 3 +- 7 files changed, 326 insertions(+), 189 deletions(-) create mode 100644 packages/cli/src/lib/local-dashboard-client.test.ts create mode 100644 packages/cli/src/lib/local-dashboard-client.ts create mode 100644 packages/cli/src/lib/local-dashboard.ts diff --git a/apps/e2e/tests/general/cli.test.ts b/apps/e2e/tests/general/cli.test.ts index fa5df660a..b7852bb9c 100644 --- a/apps/e2e/tests/general/cli.test.ts +++ b/apps/e2e/tests/general/cli.test.ts @@ -1,5 +1,4 @@ import { StackAdminApp } from "@hexclave/js"; -import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import { Result } from "@hexclave/shared/dist/utils/results"; import { execFile } from "child_process"; import * as fs from "fs"; @@ -8,8 +7,6 @@ import * as path from "path"; import { afterAll, beforeAll, describe } from "vitest"; import { it, niceFetch, STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_ADMIN_KEY, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_SERVER_KEY } from "../helpers"; -const isLocalEmulator = getEnvVariable("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", "") === "true"; - const CLI_BIN = path.resolve("packages/cli/dist/index.js"); function extractConfigObjectString(content: string): string { @@ -360,95 +357,57 @@ describe("Stack CLI", () => { expect(stderr).toContain("Config file not found"); }); - it("exec --config-file errors when emulator PCK file is missing", async ({ expect }) => { - // The file exists on disk but the emulator PCK file isn't where the CLI - // expects. PCK lookup fires before any network call so this fails fast. - const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-fake-emulator-")); - const configFile = path.join(tmpDir, `cfg-pck-missing-${crypto.randomUUID()}.config.ts`); + it("exec --config-file errors when no local dashboard session exists", async ({ expect }) => { + const devEnvStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-no-dashboard-")); + const configFile = path.join(tmpDir, `cfg-no-dashboard-${crypto.randomUUID()}.config.ts`); fs.writeFileSync(configFile, ""); try { const { stderr, exitCode } = await runCli( ["exec", "--config-file", configFile, "return 1"], { - STACK_EMULATOR_HOME: fakeEmulatorHome, - STACK_EMULATOR_READY_TIMEOUT_MS: "0", + STACK_DEV_ENVS_PATH: path.join(devEnvStateDir, "dev-envs.json"), }, ); expect(exitCode).toBe(1); - expect(stderr).toContain("Development environment publishable client key not found"); + expect(stderr).toContain("No local dashboard session found"); } finally { - fs.rmSync(fakeEmulatorHome, { recursive: true }); + fs.rmSync(devEnvStateDir, { recursive: true }); } }); - it("exec --config-file errors when emulator API is unreachable", async ({ expect }) => { - // PCK file present but the API URL points at a port nothing is listening - // on — fetch fails with a clear error. READY_TIMEOUT_MS=0 keeps the retry - // loop from waiting. - const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-fake-emulator-")); - const configFile = path.join(tmpDir, `cfg-unreachable-${crypto.randomUUID()}.config.ts`); + it("exec --config-file runs against the local dashboard project from dev-env state", async ({ expect }) => { + expect(createdProjectId).toBeDefined(); + const devEnvStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-dashboard-state-")); + const configFile = path.join(tmpDir, `cfg-dashboard-state-${crypto.randomUUID()}.config.ts`); fs.writeFileSync(configFile, ""); - try { - const pckDir = path.join(fakeEmulatorHome, "run", "vm"); - fs.mkdirSync(pckDir, { recursive: true }); - fs.writeFileSync(path.join(pckDir, "internal-pck"), "pck_stub_for_test"); - const { stderr, exitCode } = await runCli( - ["exec", "--config-file", configFile, "return 1"], - { - STACK_EMULATOR_HOME: fakeEmulatorHome, - STACK_EMULATOR_API_URL: "http://127.0.0.1:1", - STACK_EMULATOR_READY_TIMEOUT_MS: "0", + const statePath = path.join(devEnvStateDir, "dev-envs.json"); + fs.writeFileSync(statePath, JSON.stringify({ + version: 1, + anonymousRefreshToken: refreshToken, + projectsByConfigPath: { + [configFile]: { + projectId: createdProjectId, + teamId: "team_e2e_placeholder", + publishableClientKey: "pck_e2e_placeholder", + secretServerKey: "ssk_e2e_placeholder", + apiBaseUrl: STACK_BACKEND_BASE_URL, + updatedAtMillis: Date.now(), }, - ); - expect(exitCode).toBe(1); - expect(stderr).toContain("Cannot reach development environment"); - } finally { - fs.rmSync(fakeEmulatorHome, { recursive: true }); - } - }); - - // Positive happy-path: only runs when the backend is in local-emulator mode - // (the password sign-in for local-emulator@hexclave.com only succeeds - // there). Mints a project against the local-emulator backend keyed by an - // absolute config-file path, then runs `stack exec --config-file ` - // and expects it to resolve the same project. - it.runIf(isLocalEmulator)("exec --config-file runs against the local emulator backend", async ({ expect }) => { - const emulatorConfigPath = path.join(tmpDir, `stack-emulator-${crypto.randomUUID()}.config.ts`); - fs.writeFileSync(emulatorConfigPath, ""); - const projectRes = await niceFetch(`${STACK_BACKEND_BASE_URL}/api/v1/internal/local-emulator/project`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-stack-access-type": "server", - "x-stack-project-id": "internal", - "x-stack-publishable-client-key": STACK_INTERNAL_PROJECT_CLIENT_KEY, - "x-stack-secret-server-key": STACK_INTERNAL_PROJECT_SERVER_KEY, }, - body: JSON.stringify({ absolute_file_path: emulatorConfigPath }), - }); - if (projectRes.status !== 200) { - throw new Error(`Failed to mint local emulator project: ${projectRes.status} ${JSON.stringify(projectRes.body)}`); - } - - const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-emu-positive-")); + }), { mode: 0o600 }); try { - const pckDir = path.join(fakeEmulatorHome, "run", "vm"); - fs.mkdirSync(pckDir, { recursive: true }); - fs.writeFileSync(path.join(pckDir, "internal-pck"), STACK_INTERNAL_PROJECT_CLIENT_KEY); const { stdout, stderr, exitCode } = await runCli( - ["exec", "--config-file", emulatorConfigPath, "return 1+1"], + ["exec", "--config-file", configFile, "return 1+1"], { - STACK_EMULATOR_HOME: fakeEmulatorHome, - STACK_EMULATOR_API_URL: STACK_BACKEND_BASE_URL, + STACK_DEV_ENVS_PATH: statePath, }, ); if (exitCode !== 0) { throw new Error(`CLI exited ${exitCode}. stderr: ${stderr}`); } - expect(exitCode).toBe(0); expect(stdout.trim()).toBe("2"); } finally { - fs.rmSync(fakeEmulatorHome, { recursive: true }); + fs.rmSync(devEnvStateDir, { recursive: true }); } }); diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index cff20efb9..c2c6bb4c3 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -8,6 +8,7 @@ import { forwardSignals } from "../lib/child-process.js"; import { resolveConfigFilePathOption } from "../lib/config-file-path.js"; import { devEnvStatePath, ensureLocalDashboardSecret, readDevEnvState, recordLocalDashboardProcess } from "../lib/dev-env-state.js"; import { CliError } from "../lib/errors.js"; +import { DASHBOARD_PORT_ENV_VAR, dashboardPort, dashboardRequest, dashboardUrl, createRemoteDevelopmentEnvironmentSession, type DashboardSessionResponse } from "../lib/local-dashboard.js"; import { cliVersion } from "../lib/own-package.js"; import { maybeReexecToLatest, REEXEC_MARKER_ENV } from "../lib/self-update.js"; @@ -21,13 +22,6 @@ type DevOptions = { autoUpdate?: boolean, }; -type SessionResponse = { - session_id: string, - env: Record, - project_id: string, - onboarding_outstanding: boolean, -}; - type ConfigSyncEventBase = { config_file_path: string, created_at_millis: number, @@ -50,8 +44,6 @@ type HeartbeatResponse = { const HEARTBEAT_INTERVAL_MS = 5_000; const HEARTBEAT_STOP_POLL_MS = 100; const DASHBOARD_RESTART_MIN_UPTIME_MS = 5_000; -const DEFAULT_DASHBOARD_PORT = 26700; -const DASHBOARD_PORT_ENV_VAR = "NEXT_PUBLIC_HEXCLAVE_LOCAL_DASHBOARD_PORT"; const DASHBOARD_START_TIMEOUT_MS = 60_000; const DASHBOARD_STOP_TIMEOUT_MS = 10_000; const DASHBOARD_FORCE_STOP_TIMEOUT_MS = 2_000; @@ -86,7 +78,7 @@ type ProgressLogger = { }; type DashboardSessionState = { - session: SessionResponse, + session: DashboardSessionResponse, dashboardReachableSinceMs: number, }; @@ -106,25 +98,6 @@ function splitDevCommandArgs(commandArgs: string[]): ChildCommand { return { command, args: commandArgs.slice(1) }; } -function dashboardPort(): number { - const rawPort = process.env[DASHBOARD_PORT_ENV_VAR]; - if (rawPort == null || rawPort.length === 0) { - return DEFAULT_DASHBOARD_PORT; - } - if (!/^[0-9]+$/.test(rawPort)) { - throw new CliError(`${DASHBOARD_PORT_ENV_VAR} must be an integer between 1 and 65535.`); - } - const port = Number(rawPort); - if (!Number.isSafeInteger(port) || port < 1 || port > 65535) { - throw new CliError(`${DASHBOARD_PORT_ENV_VAR} must be an integer between 1 and 65535.`); - } - return port; -} - -function dashboardUrl(port = dashboardPort()): string { - return `http://127.0.0.1:${port}`; -} - export function devDashboardCommandFromEnv(env: NodeJS.ProcessEnv): string | undefined { const command = env[DEV_DASHBOARD_COMMAND_ENV_VAR]?.trim(); return command == null || command.length === 0 ? undefined : command; @@ -172,7 +145,7 @@ function openUrlInBrowser(url: string): boolean { } } -function maybeOpenOnboardingPage(session: SessionResponse, port: number): void { +function maybeOpenOnboardingPage(session: DashboardSessionResponse, port: number): void { if (!session.onboarding_outstanding) { return; } @@ -576,68 +549,10 @@ async function startDashboardIfNeeded(options: { apiBaseUrl: string, secret: str } } -async function dashboardRequest(path: string, options: RequestInit, secret: string, port: number): Promise { - const url = `${dashboardUrl(port)}${path}`; - try { - return await fetch(url, { - ...options, - headers: { - Authorization: `Bearer ${secret}`, - ...options.headers, - }, - }); - } catch (error) { - throw new CliError(`Failed to reach local Hexclave dashboard at ${url}: ${errorMessage(error)}`); - } -} - -function isStringRecord(value: unknown): value is Record { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.values(value).every((entry) => typeof entry === "string") - ); -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -async function responseErrorMessage(response: Response): Promise { - const text = await response.text(); - if (text.length === 0) return "empty response body"; - - try { - const parsed: unknown = JSON.parse(text); - if (isRecord(parsed)) { - const error = parsed.error; - if (typeof error === "string") return error; - if (isRecord(error) && typeof error.message === "string") return error.message; - } - } catch { - // Fall back to the raw response below. - } - - return text; -} - -function isSessionResponse(value: unknown): value is SessionResponse { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - "session_id" in value && - typeof value.session_id === "string" && - "project_id" in value && - typeof value.project_id === "string" && - "onboarding_outstanding" in value && - typeof value.onboarding_outstanding === "boolean" && - "env" in value && - isStringRecord(value.env) - ); -} - function isConfigSyncEvent(value: unknown): value is ConfigSyncEvent { if ( !isRecord(value) || @@ -738,32 +653,6 @@ async function logPendingBrowserSecretConfirmationCodesUntilStopped(options: { } } -async function createRemoteDevelopmentEnvironmentSession(options: { - apiBaseUrl: string, - configFilePath: string, - port: number, - secret: string, -}): Promise { - const response = await dashboardRequest("/api/remote-development-environment/sessions", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - api_base_url: options.apiBaseUrl, - config_path: options.configFilePath, - }), - }, options.secret, options.port); - if (!response.ok) { - throw new CliError(`Failed to register development environment session (${response.status}): ${await responseErrorMessage(response)}`); - } - const body: unknown = await response.json(); - if (!isSessionResponse(body)) { - throw new CliError("Local dashboard returned an invalid development environment session response."); - } - return body; -} - const APP_COMMAND_WRAPPER_PARENT_PID_ENV_VAR = "HEXCLAVE_DEV_APP_COMMAND_PARENT_PID"; const APP_COMMAND_WRAPPER_COMMAND_ENV_VAR = "HEXCLAVE_DEV_APP_COMMAND"; const APP_COMMAND_WRAPPER_ARGS_ENV_VAR = "HEXCLAVE_DEV_APP_COMMAND_ARGS_JSON"; @@ -889,7 +778,7 @@ async function restartDashboardForHeartbeat(options: { dashboardReachableSinceMs: number, port: number, secret: string, -}): Promise { +}): Promise { const dashboardUptimeMs = performance.now() - options.dashboardReachableSinceMs; if (dashboardUptimeMs < DASHBOARD_RESTART_MIN_UPTIME_MS) { throw new CliError(`Local Hexclave dashboard stopped before it had been running for ${DASHBOARD_RESTART_MIN_UPTIME_MS / 1000} seconds. Not restarting to avoid a restart loop.`); diff --git a/packages/cli/src/commands/exec.ts b/packages/cli/src/commands/exec.ts index 0da61feb1..107a5ec4a 100644 --- a/packages/cli/src/commands/exec.ts +++ b/packages/cli/src/commands/exec.ts @@ -1,9 +1,8 @@ import { Command } from "commander"; -import { isProjectAuthWithRefreshToken, resolveAuth, resolveLocalEmulatorAuth, type ProjectAuthWithRefreshToken } from "../lib/auth.js"; -import { lookupLocalEmulatorProjectIdByPath } from "../lib/local-emulator-client.js"; +import { isProjectAuthWithRefreshToken, resolveAuth, type ProjectAuthWithRefreshToken } from "../lib/auth.js"; +import { resolveLocalDashboardAuthByConfigPath } from "../lib/local-dashboard-client.js"; import { getAdminProject } from "../lib/app.js"; import { CliError } from "../lib/errors.js"; -import { resolveConfigFilePathOption } from "../lib/config-file-path.js"; function getErrorMessage(err: unknown): string { if (err instanceof Error) { @@ -68,9 +67,7 @@ export function registerExecCommand(program: Command) { } auth = cloudAuth; } else { - const absPath = resolveConfigFilePathOption(target.configFile, { mustExist: true }); - const projectId = await lookupLocalEmulatorProjectIdByPath(absPath); - auth = await resolveLocalEmulatorAuth(projectId); + auth = await resolveLocalDashboardAuthByConfigPath(target.configFile); } const project = await getAdminProject(auth); diff --git a/packages/cli/src/lib/local-dashboard-client.test.ts b/packages/cli/src/lib/local-dashboard-client.test.ts new file mode 100644 index 000000000..5368c0496 --- /dev/null +++ b/packages/cli/src/lib/local-dashboard-client.test.ts @@ -0,0 +1,98 @@ +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, describe, expect, it } from "vitest"; +import { writeDevEnvState } from "./dev-env-state"; +import { resolveLocalDashboardAuthByConfigPath } from "./local-dashboard-client"; + +let tempDir: string | undefined; + +function useTempStateFile(): string { + tempDir = mkdtempSync(join(tmpdir(), "stack-local-dashboard-client-")); + process.env.STACK_DEV_ENVS_PATH = join(tempDir, "dev-envs.json"); + return tempDir; +} + +afterEach(() => { + delete process.env.STACK_DEV_ENVS_PATH; + delete process.env.NEXT_PUBLIC_HEXCLAVE_LOCAL_DASHBOARD_PORT; + if (tempDir != null) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } +}); + +describe("resolveLocalDashboardAuthByConfigPath", () => { + it("resolves project auth from dashboard dev-env state", async () => { + const dir = useTempStateFile(); + const configPath = join(dir, "hexclave.config.ts"); + writeFileSync(configPath, ""); + writeDevEnvState({ + version: 1, + anonymousRefreshToken: "rt_dev", + projectsByConfigPath: { + [configPath]: { + projectId: "proj_dev", + teamId: "team_dev", + publishableClientKey: "pck_dev", + secretServerKey: "ssk_dev", + apiBaseUrl: "http://127.0.0.1:8102", + updatedAtMillis: 1, + }, + }, + }); + + await expect(resolveLocalDashboardAuthByConfigPath(configPath)).resolves.toMatchObject({ + apiUrl: "http://127.0.0.1:8102", + dashboardUrl: "http://127.0.0.1:26700", + refreshToken: "rt_dev", + projectId: "proj_dev", + }); + }); + + it("uses the configured local dashboard port in returned auth", async () => { + const dir = useTempStateFile(); + const configPath = join(dir, "hexclave.config.ts"); + process.env.NEXT_PUBLIC_HEXCLAVE_LOCAL_DASHBOARD_PORT = "9101"; + writeFileSync(configPath, ""); + writeDevEnvState({ + version: 1, + anonymousRefreshToken: "rt_dev", + projectsByConfigPath: { + [configPath]: { + projectId: "proj_dev", + teamId: "team_dev", + publishableClientKey: "pck_dev", + secretServerKey: "ssk_dev", + apiBaseUrl: "http://127.0.0.1:8102", + updatedAtMillis: 1, + }, + }, + }); + + await expect(resolveLocalDashboardAuthByConfigPath(configPath)).resolves.toMatchObject({ + dashboardUrl: "http://127.0.0.1:9101", + }); + }); + + it("requires the dashboard anonymous session from state", async () => { + const dir = useTempStateFile(); + const configPath = join(dir, "hexclave.config.ts"); + writeFileSync(configPath, ""); + writeDevEnvState({ + version: 1, + projectsByConfigPath: { + [configPath]: { + projectId: "proj_dev", + teamId: "team_dev", + publishableClientKey: "pck_dev", + secretServerKey: "ssk_dev", + apiBaseUrl: "http://127.0.0.1:8102", + updatedAtMillis: 1, + }, + }, + }); + + await expect(resolveLocalDashboardAuthByConfigPath(configPath)).rejects.toThrow(/no development-environment user session/); + }); +}); diff --git a/packages/cli/src/lib/local-dashboard-client.ts b/packages/cli/src/lib/local-dashboard-client.ts new file mode 100644 index 000000000..c5c6e24cb --- /dev/null +++ b/packages/cli/src/lib/local-dashboard-client.ts @@ -0,0 +1,64 @@ +import { DEFAULT_PUBLISHABLE_CLIENT_KEY, type ProjectAuthWithRefreshToken } from "./auth.js"; +import { resolveConfigFilePathOption } from "./config-file-path.js"; +import { readDevEnvState } from "./dev-env-state.js"; +import { CliError } from "./errors.js"; +import { closeRemoteDevelopmentEnvironmentSession, createRemoteDevelopmentEnvironmentSession, dashboardPort, dashboardUrl } from "./local-dashboard.js"; + +type DashboardProjectState = { + projectId: string, + apiBaseUrl: string, +}; + +function dashboardSecretForPort(port: number): string { + const secret = readDevEnvState().localDashboardsByPort?.[String(port)]?.secret; + if (secret == null || secret.length === 0) { + throw new CliError(`No local dashboard session found on port ${port}. Start your development environment with \`hexclave dev --config-file -- \` and try again.`); + } + return secret; +} + +async function registerDashboardSession(configFilePath: string, port: number, secret: string): Promise { + const session = await createRemoteDevelopmentEnvironmentSession({ + apiBaseUrl: readDevEnvState().anonymousApiBaseUrl, + configFilePath, + port, + secret, + }); + await closeRemoteDevelopmentEnvironmentSession(session.session_id, secret, port); +} + +function findDashboardProject(configFilePath: string): DashboardProjectState | null { + const project = readDevEnvState().projectsByConfigPath[configFilePath]; + if (project == null) return null; + return { + projectId: project.projectId, + apiBaseUrl: project.apiBaseUrl, + }; +} + +export async function resolveLocalDashboardAuthByConfigPath(configFile: string): Promise { + const configFilePath = resolveConfigFilePathOption(configFile, { mustExist: true }); + let project = findDashboardProject(configFilePath); + if (project == null) { + const port = dashboardPort(); + const secret = dashboardSecretForPort(port); + await registerDashboardSession(configFilePath, port, secret); + project = findDashboardProject(configFilePath); + } + + const state = readDevEnvState(); + if (project == null) { + throw new CliError(`Local dashboard did not register a development-environment project for ${configFilePath}.`); + } + if (state.anonymousRefreshToken == null || state.anonymousRefreshToken.length === 0) { + throw new CliError("Local dashboard has no development-environment user session yet. Run `hexclave dev --config-file -- ` first."); + } + + return { + apiUrl: project.apiBaseUrl, + dashboardUrl: dashboardUrl(), + publishableClientKey: DEFAULT_PUBLISHABLE_CLIENT_KEY, + refreshToken: state.anonymousRefreshToken, + projectId: project.projectId, + }; +} diff --git a/packages/cli/src/lib/local-dashboard.ts b/packages/cli/src/lib/local-dashboard.ts new file mode 100644 index 000000000..a53d0c4c8 --- /dev/null +++ b/packages/cli/src/lib/local-dashboard.ts @@ -0,0 +1,129 @@ +import { DEFAULT_API_URL } from "./auth.js"; +import { CliError } from "./errors.js"; + +export const DEFAULT_DASHBOARD_PORT = 26700; +export const DASHBOARD_PORT_ENV_VAR = "NEXT_PUBLIC_HEXCLAVE_LOCAL_DASHBOARD_PORT"; + +export type DashboardSessionResponse = { + session_id: string, + env: Record, + project_id: string, + onboarding_outstanding: boolean, +}; + +export function dashboardPort(): number { + const rawPort = process.env[DASHBOARD_PORT_ENV_VAR]; + if (rawPort == null || rawPort.length === 0) { + return DEFAULT_DASHBOARD_PORT; + } + if (!/^[0-9]+$/.test(rawPort)) { + throw new CliError(`${DASHBOARD_PORT_ENV_VAR} must be an integer between 1 and 65535.`); + } + const port = Number(rawPort); + if (!Number.isSafeInteger(port) || port < 1 || port > 65535) { + throw new CliError(`${DASHBOARD_PORT_ENV_VAR} must be an integer between 1 and 65535.`); + } + return port; +} + +export function dashboardUrl(port = dashboardPort()): string { + return `http://127.0.0.1:${port}`; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export async function dashboardRequest(path: string, options: RequestInit, secret: string, port: number): Promise { + const url = `${dashboardUrl(port)}${path}`; + try { + return await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${secret}`, + ...options.headers, + }, + }); + } catch (error) { + throw new CliError(`Failed to reach local Hexclave dashboard at ${url}: ${errorMessage(error)}`); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export async function responseErrorMessage(response: Response): Promise { + const text = await response.text(); + if (text.length === 0) return "empty response body"; + + try { + const parsed: unknown = JSON.parse(text); + if (isRecord(parsed)) { + const error = parsed.error; + if (typeof error === "string") return error; + if (isRecord(error) && typeof error.message === "string") return error.message; + } + } catch { + // Fall back to the raw response below. + } + + return text; +} + +function isStringRecord(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).every((entry) => typeof entry === "string") + ); +} + +function isDashboardSessionResponse(value: unknown): value is DashboardSessionResponse { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + "session_id" in value && + typeof value.session_id === "string" && + "project_id" in value && + typeof value.project_id === "string" && + "onboarding_outstanding" in value && + typeof value.onboarding_outstanding === "boolean" && + "env" in value && + isStringRecord(value.env) + ); +} + +export async function createRemoteDevelopmentEnvironmentSession(options: { + apiBaseUrl?: string, + configFilePath: string, + port: number, + secret: string, +}): Promise { + const response = await dashboardRequest("/api/remote-development-environment/sessions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + api_base_url: options.apiBaseUrl ?? DEFAULT_API_URL, + config_path: options.configFilePath, + }), + }, options.secret, options.port); + if (!response.ok) { + throw new CliError(`Failed to register development environment session (${response.status}): ${await responseErrorMessage(response)}`); + } + const body: unknown = await response.json(); + if (!isDashboardSessionResponse(body)) { + throw new CliError("Local dashboard returned an invalid development environment session response."); + } + return body; +} + +export async function closeRemoteDevelopmentEnvironmentSession(sessionId: string, secret: string, port: number): Promise { + return await dashboardRequest(`/api/remote-development-environment/sessions/${encodeURIComponent(sessionId)}`, { + method: "DELETE", + }, secret, port); +} diff --git a/packages/shared/src/ai/unified-prompts/reminders.ts b/packages/shared/src/ai/unified-prompts/reminders.ts index 6d65fd60e..64e37cc0a 100644 --- a/packages/shared/src/ai/unified-prompts/reminders.ts +++ b/packages/shared/src/ai/unified-prompts/reminders.ts @@ -26,7 +26,8 @@ export const remindersPrompt = deindent` - There is a \`useHexclaveApp()\` hook as a named export from the package itself that serves as a shortcut to get the current Hexclave App object from the React context. Similarly, the \`useUser(...args)\` named export is short for \`useHexclaveApp().useUser(...args)\`. - Other - Hexclave also has a REST API with near-full feature parity with the SDK. It can be used for both client and server-side code. - - If available, always prefer editing the \`hexclave.config.ts\` file directly over asking the user to make changes on the dashboard. When implementing new features, you can always update the config file, and then tell the user about the changes you've made. + - If available, always prefer editing the \`hexclave.config.ts\` file directly over asking the user to make changes on the dashboard. When implementing new features, you can always update the config file, and then tell the user about the changes you've made. The config file is automatically synced when using the local dashboard/dev environment with \`npx @hexclave/cli dev --config-file \`. - Hexclave's config files allow dot notation for nested properties. For example, the config \`{ auth: { allowSignUp: true }, "auth.password": { allowSignIn: true } }\` is the same as \`{ auth: { allowSignUp: true, password: { allowSignIn: true } } }\`. + - You can use the \`npx @hexclave/cli exec \` command to run JavaScript with a pre-configured HexclaveServerApp available as \`hexclaveServerApp\`. This allows you to read and write from and to the Hexclave project as you would on the dashboard, but from the CLI. To read and write project configuration, see the note on the config file above. - Hexclave was formerly known as Stack Auth. You may still see references to it as Stack Auth in some places. `;