diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index abe3da75e5..71caffb6c9 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -63,6 +63,19 @@ export const Flag = { OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), + // altimate_change start — pilot flag for the Workspaces feature (post-scan prompt + + // altimate link subcommand). Read as a getter so tests and the runtime `--` middleware + // can flip it between plugin activation and command execution. + // + // Opt-in only — deliberately does NOT inherit ``OPENCODE_EXPERIMENTAL`` (as + // ``enabledByExperimental`` would). The pilot ships behind its own explicit + // gate so users already opted into other experimental features don't get + // this one turned on for them. (Kilo cycle 6.) + get ALTIMATE_WORKSPACE() { + return truthy("ALTIMATE_WORKSPACE") + }, + // altimate_change end + // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. get OPENCODE_DISABLE_PROJECT_CONFIG() { diff --git a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts index c18b890d02..d37888b04d 100644 --- a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts +++ b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts @@ -12,6 +12,108 @@ // session loop. import type { Hooks, PluginInput } from "@opencode-ai/plugin" import * as OnboardingTelemetry from "../telemetry/onboarding" +// altimate_change start — AI-8398 workspaces trigger. Reaches into the same +// EventV2 bridge the server/routes/tui.ts uses to publish TuiEvent.CommandExecute +// so the workspace TuiPlugin (packages/opencode/src/plugin/tui/altimate/workspace.tsx) +// runs its post-scan flow. Feature-flagged via Flag.ALTIMATE_WORKSPACE. +import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import { AltimateApi } from "@/altimate/api/client" +import { AppRuntime } from "@/effect/app-runtime" +import { EventV2Bridge } from "@/event-v2-bridge" +import { TuiEvent } from "@/server/tui-event" +import { Event as SessionEvent } from "@/session/status" +import { Log } from "@/altimate/util/log" + +const workspaceLog = Log.create({ service: "altimate-workspace" }) + +/** + * Publish the workspace-postScan command AFTER the session goes idle, not on + * `project_scan`'s tool.execute.after. Rationale: project_scan tool RETURNS while + * the LLM is still generating the activation-menu text; the dialog paints in + * that window but user interactions queue behind the streaming. Waiting for + * session.idle costs a few seconds of latency but sidesteps the race entirely — + * the dialog appears once things are quiet. + * + * One-shot per sessionID: pending sessions live in a Set, and when a session + * emits idle its id is removed. When the Set drains, the EventV2 listener is + * torn down via the unsubscribe returned by ``events.listen()`` so a + * permanently-installed no-op handler isn't left behind for the process + * lifetime (m4 in the consensus review). A pending arm is dropped if a + * second project_scan fires in the same session. + */ +const pendingWorkspacePromptSessions = new Set() +/** The unsubscribe returned by ``events.listen()`` is an Effect (not a plain + * function) — running it removes the listener. Store the Effect and execute + * it through ``AppRuntime.runPromise`` on teardown; earlier code cast it to + * ``() => void`` and called it directly, which threw because Effects are not + * callable as functions. (cubic-dev-ai round 3.) */ +let workspacePromptUnsubscribe: Effect.Effect | null = null +// Guard against two concurrent scans passing the ``!workspacePromptUnsubscribe`` +// check before either install completes — both would then install a listener +// and the later assignment would overwrite the first disposer, leaking the +// first listener for the process lifetime. Store the in-flight install as a +// shared promise so concurrent callers await the same result. (CR round 2.) +let workspacePromptInstall: Promise | null = null + +async function armWorkspacePromptOnSessionIdle(sessionID: string): Promise { + pendingWorkspacePromptSessions.add(sessionID) + if (workspacePromptUnsubscribe) return + if (workspacePromptInstall) return workspacePromptInstall + + // Capture the set of pending sessions at install-time so an install + // failure drains EVERY caller that awaited this install, not just the + // one whose sessionID we happen to be handling. Later waiters would + // otherwise see success from the shared promise and stop retrying, + // leaving permanently-stale entries in the pending set. (cubic round 3.) + const armingSessions = new Set(pendingWorkspacePromptSessions) + + workspacePromptInstall = (async () => { + try { + const unsubscribe = await AppRuntime.runPromise( + EventV2Bridge.Service.use((events) => + events.listen((event) => + Effect.gen(function* () { + if (event.type !== SessionEvent.Idle.type) return + const sid = (event.data as { sessionID?: string } | undefined)?.sessionID + if (!sid || !pendingWorkspacePromptSessions.has(sid)) return + pendingWorkspacePromptSessions.delete(sid) + yield* events.publish(TuiEvent.CommandExecute, { + command: "altimate.workspace.postScan", + }) + // Once the Set drains, tear the listener down. A later scan + // that adds a new pending session re-arms it from scratch. + // ``teardown`` is an Effect — run it through the app runtime, + // don't call it as a function. (cubic round 3.) + if (pendingWorkspacePromptSessions.size === 0 && workspacePromptUnsubscribe) { + const teardown = workspacePromptUnsubscribe + workspacePromptUnsubscribe = null + AppRuntime.runPromise(teardown).catch((err) => { + workspaceLog.warn("session-idle listener teardown failed", { + err: String(err), + }) + }) + } + }), + ), + ), + ) + workspacePromptUnsubscribe = unsubscribe + } catch (err) { + // Install failed — drop every session that was waiting on this install + // so the next scan retries from scratch. Dropping only the current + // caller's ID would leave later waiters (already resolved by the + // shared install promise) with permanently-stale pending entries. + // (cubic round 3.) + for (const sid of armingSessions) pendingWorkspacePromptSessions.delete(sid) + workspaceLog.warn("session-idle listener install failed", { err: String(err) }) + } finally { + workspacePromptInstall = null + } + })() + return workspacePromptInstall +} +// altimate_change end const ONBOARD_CONNECT = "onboard-connect" @@ -134,6 +236,14 @@ export async function OnboardingTelemetryPlugin(_input: PluginInput): Promise false))) { + void armWorkspacePromptOnSessionIdle(input.sessionID) + } + // altimate_change end return } diff --git a/packages/opencode/src/altimate/tools/project-scan.ts b/packages/opencode/src/altimate/tools/project-scan.ts index 130338cee4..b98a89d2e6 100644 --- a/packages/opencode/src/altimate/tools/project-scan.ts +++ b/packages/opencode/src/altimate/tools/project-scan.ts @@ -173,8 +173,13 @@ export async function detectGit(): Promise { * SSH-form remotes (`git@github.com:owner/repo.git`) have no userinfo * concept and are left untouched. URLs we can't parse are dropped to * undefined (better to lose the breadcrumb than leak creds). + * + * Exported so the workspace TuiPlugin (packages/opencode/src/plugin/tui/ + * altimate/workspace.tsx) can reuse the exact same scrubbing rules when + * deriving the project's git remote for the post-scan prompt — the alt + * of duplicating the logic risks the two callers drifting. */ -function stripGitRemoteCredentials(url: string): string | undefined { +export function stripGitRemoteCredentials(url: string): string | undefined { if (!url) return undefined // SSH form: `git@host:path` — no creds to strip. if (/^[\w.-]+@[\w.-]+:/.test(url) && !url.includes("://")) return url diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts new file mode 100644 index 0000000000..10a429e385 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -0,0 +1,384 @@ +// altimate_change - new file +// +// Wire client for the workspace-binding endpoints in altimate-backend +// (/datamate-project-bindings/*, added by AI-8398). Shared between the TUI +// plugin (packages/opencode/src/plugin/tui/altimate/workspace.tsx) and the +// `altimate link` CLI subcommand (packages/opencode/src/cli/cmd/link.ts) so +// the two entry points can't drift on request shape / error handling. +// +// Reads AltimateApi credentials on every call so an account switch is picked +// up immediately without a plugin restart. All FastAPI HTTPException.detail +// bodies come out as `{"detail": }` — we parse the object form +// for 409/412 and surface it as a typed error rather than a bare status code. +import { AltimateApi } from "@/altimate/api/client" + +const REQUEST_TIMEOUT_MS = 15_000 + +export interface DatamateRef { + id: number + name: string +} + +export interface Binding { + id: number + datamate_id: number + datamate_name: string + /** Either ``repo_remote`` OR ``project_path`` is populated (at least one). */ + repo_remote: string | null + project_path: string | null + created_at?: string +} + +/** Project identifier passed to create/bind endpoints. At least one field is + * required by the backend's CHECK constraint; the CLI's resolveProjectIdentifier + * always populates ``projectPath`` and populates ``repoRemote`` when available. */ +export interface ProjectIdentifier { + repoRemote?: string + projectPath?: string +} + +export interface CreateAndBindResponse { + datamate: DatamateRef + binding: Binding + manage_url: string +} + +export interface BindingResponse { + binding: Binding +} + +export interface GetBindingResponse { + binding: Binding + datamate: DatamateRef +} + +/** Which identifier arm the pre-check lookup actually matched on. Callers use + * this to pick the correct rebind endpoint (``/by-remote`` vs ``/by-path``) + * regardless of what the CURRENT identifier has — a repo whose remote was + * renamed still resolves via its ``project_path``, and a later ``rebindByRemote`` + * would 404 because no binding exists under the new remote. (M3) */ +export type MatchedIdentifier = "remote" | "path" + +export interface ProjectBindingLookup extends GetBindingResponse { + matchedBy: MatchedIdentifier +} + +export interface ConflictDetail { + message: string + existing_datamate_id?: number + existing_datamate_name?: string | null + repo_remote?: string + project_path?: string +} + +export interface PreconditionDetail { + message: string + actual_current_datamate_id?: number + expected_current_datamate_id?: number +} + +export class NotConfiguredError extends Error { + constructor() { + super("Altimate credentials not configured — sign in first.") + this.name = "NotConfiguredError" + } +} + +export class ConflictError extends Error { + constructor(public readonly detail: ConflictDetail) { + super(detail.message) + this.name = "ConflictError" + } +} + +export class PreconditionFailedError extends Error { + constructor(public readonly detail: PreconditionDetail) { + super(detail.message) + this.name = "PreconditionFailedError" + } +} + +export class NotFoundError extends Error { + constructor(msg = "Not found") { + super(msg) + this.name = "NotFoundError" + } +} + +export class ForbiddenError extends Error { + constructor(msg = "Forbidden") { + super(msg) + this.name = "ForbiddenError" + } +} + +export class WorkspaceApiError extends Error { + constructor( + msg: string, + public readonly status?: number, + ) { + super(msg) + this.name = "WorkspaceApiError" + } +} + +async function creds(): Promise<{ url: string; instance: string; apiKey: string }> { + if (!(await AltimateApi.isConfigured())) throw new NotConfiguredError() + const c = await AltimateApi.getCredentials() + return { url: c.altimateUrl, instance: c.altimateInstanceName, apiKey: c.altimateApiKey } +} + +async function req( + method: string, + subpath: string, + opts: { + body?: unknown + query?: Record + /** Override the base path prefix. Defaults to + * ``/datamate-project-bindings`` (this module's namespace). Pass e.g. + * ``/datamates`` to hit the sibling datamates_router through the same + * timeout / typed-error / empty-body machinery. */ + base?: string + /** If true, a 2xx with an empty body returns ``undefined`` typed as T + * instead of throwing. Only set for endpoints known to return 204 or a + * bare 200 with no payload. */ + allowEmptyBody?: boolean + } = {}, +): Promise { + const { url, instance, apiKey } = await creds() + const qs = opts.query ? "?" + new URLSearchParams(opts.query).toString() : "" + const basePath = opts.base ?? "/datamate-project-bindings" + const target = `${url}${basePath}${subpath}${qs}` + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + let res: Response + let text: string + try { + res = await fetch(target, { + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "x-tenant": instance, + }, + signal: controller.signal, + ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}), + }) + // Keep the AbortController timeout ACTIVE while we read the response body. + // ``fetch()`` resolves after headers arrive; a server can send headers then + // stall the body stream indefinitely, so pulling the body inside the same + // try/finally is the difference between our 15s cap and hanging until TCP + // gives up. (CR round 2.) Do NOT wrap in ``.catch(() => "")`` — that + // swallows the AbortError from the timeout firing during the body read + // and turns a stalled response into a false "empty body". Rejection + // rethrows into the outer catch and is classified there. (cubic round 3.) + text = await res.text() + } catch (err) { + // Distinguish "we hit our 15s abort" from "network stack failed" so the + // caller can decide differently (retry, longer timeout, offline banner). + // The abort fires equally when it kills the fetch OR the body read. (m8) + const name = (err as { name?: string } | undefined)?.name + if (name === "AbortError") { + throw new WorkspaceApiError( + `Request to ${target} timed out after ${Math.round(REQUEST_TIMEOUT_MS / 1000)}s`, + ) + } + const msg = err instanceof Error ? err.message : String(err) + throw new WorkspaceApiError(`Cannot reach ${target}: ${msg}`) + } finally { + clearTimeout(timeout) + } + let json: unknown = undefined + if (text) { + try { + json = JSON.parse(text) + } catch { + /* non-JSON body — surface as opaque via status code below */ + } + } + const detail = (json as { detail?: unknown } | undefined)?.detail + if (res.status === 404) throw new NotFoundError(typeof detail === "string" ? detail : "Not found") + if (res.status === 403) throw new ForbiddenError(typeof detail === "string" ? detail : "Forbidden") + if (res.status === 409) { + const d = + typeof detail === "object" && detail !== null + ? (detail as ConflictDetail) + : { message: typeof detail === "string" ? detail : "Conflict" } + throw new ConflictError(d) + } + if (res.status === 412) { + const d = + typeof detail === "object" && detail !== null + ? (detail as PreconditionDetail) + : { message: typeof detail === "string" ? detail : "Precondition failed" } + throw new PreconditionFailedError(d) + } + if (!res.ok) { + throw new WorkspaceApiError( + typeof detail === "string" ? detail : `Request failed with status ${res.status}`, + res.status, + ) + } + // A 2xx with an empty (or unparseable) body is not the same as a resource. + // Callers dereference the return immediately (``.binding``, ``.datamate``, + // ``.manage_url``), so silently handing back ``undefined as T`` produces a + // ``TypeError`` inside caller code that the typed-error switches can't + // classify. Surface it as a WorkspaceApiError instead — unless the caller + // opted in via ``allowEmptyBody`` (e.g. 204 endpoints). Use ``== null`` so a + // literal ``JSON.parse("null")`` (which sets json to null, not undefined) + // is treated as an empty body too — otherwise ``null as T`` reaches callers + // and .foo throws in a way the typed switches can't classify. (m7 + CR) + if (json == null && !opts.allowEmptyBody) { + throw new WorkspaceApiError( + `Empty ${res.status} body from ${target} — expected JSON payload`, + res.status, + ) + } + return json as T +} + +export namespace WorkspaceApi { + /** Server-authoritative pre-check by git remote. Returns null on 404. */ + export async function getBindingForRemote(remote: string): Promise { + try { + return await req("GET", "/by-remote", { query: { repo_remote: remote } }) + } catch (err) { + if (err instanceof NotFoundError) return null + throw err + } + } + + /** Symmetric pre-check by absolute project directory path (for projects + * without a git remote). Returns null on 404. */ + export async function getBindingForPath(projectPath: string): Promise { + try { + return await req("GET", "/by-path", { query: { project_path: projectPath } }) + } catch (err) { + if (err instanceof NotFoundError) return null + throw err + } + } + + /** Tries remote first (stronger identity), then path. Returns the first hit + * TAGGED with which identifier matched, so a caller that later rebinds + * picks the right endpoint even if the current identifier's remote has + * changed since the binding was created (M3). Both fields on the + * identifier are optional but at least one must be present. */ + export async function getBindingForProject(id: ProjectIdentifier): Promise { + if (id.repoRemote) { + const hit = await getBindingForRemote(id.repoRemote) + if (hit) return { ...hit, matchedBy: "remote" } + } + if (id.projectPath) { + const hit = await getBindingForPath(id.projectPath) + if (hit) return { ...hit, matchedBy: "path" } + } + return null + } + + export async function createAndBind(input: { + name: string + identifier: ProjectIdentifier + description?: string + }): Promise { + return req("POST", "/", { + body: { + name: input.name, + repo_remote: input.identifier.repoRemote ?? null, + project_path: input.identifier.projectPath ?? null, + description: input.description ?? null, + }, + }) + } + + export async function bindExisting( + datamateId: number, + identifier: ProjectIdentifier, + ): Promise { + return req("POST", "/bind", { + body: { + datamate_id: datamateId, + repo_remote: identifier.repoRemote ?? null, + project_path: identifier.projectPath ?? null, + }, + }) + } + + export async function rebindByRemote(input: { + remote: string + targetDatamateId: number + expectedCurrentDatamateId?: number + }): Promise { + return req("PUT", "/by-remote", { + body: { + repo_remote: input.remote, + target_datamate_id: input.targetDatamateId, + ...(input.expectedCurrentDatamateId !== undefined + ? { expected_current_datamate_id: input.expectedCurrentDatamateId } + : {}), + }, + }) + } + + /** Path-identified rebind — symmetric to ``rebindByRemote`` for projects + * without a git remote. */ + export async function rebindByPath(input: { + projectPath: string + targetDatamateId: number + expectedCurrentDatamateId?: number + }): Promise { + return req("PUT", "/by-path", { + body: { + project_path: input.projectPath, + target_datamate_id: input.targetDatamateId, + ...(input.expectedCurrentDatamateId !== undefined + ? { expected_current_datamate_id: input.expectedCurrentDatamateId } + : {}), + }, + }) + } + + /** Populates the "link to existing workspace" picker. Reuses the existing + * ``/datamates/`` list endpoint on the datamates_router — routed through + * the shared ``req()`` machinery so it inherits the 15s abort, typed + * error mapping, empty-body guard, and detail-parsing everyone else + * gets. (M5) Filters out non-integer / non-positive ids so a corrupt row + * doesn't reach the picker as a "NaN" label that the caller then binds + * against. */ + export async function listDatamates(): Promise { + // Accept THREE response envelopes — today's ``{datamates: [...]}``, a + // bare ``[...]``, and a generic ``{data: [...]}`` — so a backend + // contract change (or compat layer) doesn't silently empty the picker. + // (cubic-dev-ai round 3.) + type Row = { id: number | string; name: string } + const body = await req("GET", "/", { + base: "/datamates", + }) + let rows: Row[] + if (Array.isArray(body)) { + rows = body + } else if (body && typeof body === "object") { + // Guard each envelope field with Array.isArray — a non-array + // ``datamates`` or ``data`` value (object / string / null) would + // otherwise slip through and throw on ``.map`` below, taking the + // picker down before it renders. (cubic round 4.) + rows = Array.isArray(body.datamates) + ? body.datamates + : Array.isArray(body.data) + ? body.data + : [] + } else { + rows = [] + } + // Filter valid row objects BEFORE map (Kilo cycle 5) — a single ``null`` + // (or non-object) element in an otherwise-valid array would otherwise + // throw ``TypeError: Cannot read properties of null`` on ``d.id`` before + // the post-map filter can drop it. That's the exact picker-down failure + // the round-3/4 envelope guards were added to prevent, just from a + // per-element rather than per-envelope malformed value. + return rows + .filter((d): d is Row => d !== null && typeof d === "object") + .map((d) => ({ id: Number(d.id), name: d.name })) + .filter((d) => Number.isInteger(d.id) && d.id > 0 && typeof d.name === "string") + } +} diff --git a/packages/opencode/src/altimate/workspace/browser-handoff.ts b/packages/opencode/src/altimate/workspace/browser-handoff.ts new file mode 100644 index 0000000000..b87ef133bd --- /dev/null +++ b/packages/opencode/src/altimate/workspace/browser-handoff.ts @@ -0,0 +1,503 @@ +// altimate_change - new file +// +// Browser-based workspace creation handoff. CLI opens Ralph's SaaS approval +// modal at ``.ws.myaltimate.com/create-and-link`` with the current +// project's context, user approves, the SaaS creates a workspace and delivers +// its ID back to the CLI via a loopback callback. The CLI then binds the +// current project to that workspace via the existing +// ``POST /datamate-project-bindings/bind`` endpoint. +// +// This module deliberately DUPLICATES the loopback listener pattern from +// ``altimate/plugin/altimate.ts`` rather than sharing a helper — the two flows +// are similar enough that a naive extraction would trade duplication for +// coupling on state/global lifecycle. Refactor to a shared helper is a +// follow-up ticket once both flows have prod experience; the port range +// (7317..7325) is walked independently by each listener instance so a live +// OAuth server on 7317 forces workspace-handoff to bind 7318 without either +// close operation affecting the other. +// +// See docs `workspace-browser-handoff-plan-v3.md` for the design context. +import { createServer, type Server } from "http" +import { randomBytes } from "crypto" +import open from "open" + +import { AltimateApi } from "@/altimate/api/client" +import { Log } from "@/altimate/util/log" + +import type { ProjectIdentifier } from "./api-client" + +// Freemium is the only deployment served by the workspace stack today. When +// altimate-backend goes multi-deployment (enterprise), extend this to a small +// mapping. Returning null means "not supported here" — the CLI hides the +// browser-handoff option entirely rather than open a broken URL. +const FREEMIUM_API_HOST = "api.myaltimate.com" +const FREEMIUM_WORKSPACE_HOST = "ws.myaltimate.com" + +/** DNS-label-shaped tenant guard for the freemium subdomain. Credentials + * only require ``altimateInstanceName`` to be a non-empty string, so a tenant + * like ``evil.example/path?x=`` would otherwise be interpolated straight into + * the origin, opening the handoff URL — carrying the project path, remote, + * callback address, CSRF state, and telemetry context — at + * ``https://evil.example`` (m3 in the consensus review). Reject anything that + * would not survive a round-trip through URL parsing back to the same host. */ +const TENANT_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i + +// Loopback port range for the workspace-bound callback. Shared with the OAuth +// sign-in listener in altimate.ts — each listener walks independently, so a +// live OAuth server on 7317 forces us to 7318 (or later) transparently. +const CALLBACK_PORT_MIN = 7317 +const CALLBACK_PORT_MAX = 7325 + +const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000 + +/** Loopback success page. We land the user back on the SaaS workspace page via + * top-level navigation — matches the OAuth sign-in pattern in altimate.ts, + * which is proven in prod. The rationale over a subresource fetch: HTTPS→HTTP + * loopback subresource fetches trigger Chrome/Safari Private Network Access + * checks (preflight OPTIONS with Access-Control-Request-Private-Network); a + * top-level navigation from an HTTP 302 or ``window.location.href`` bypasses + * PNA entirely. Meta refresh + JS assign for belt-and-suspenders. */ +function deliverySuccessHtml(manageUrl: string): string { + const safe = escapeHtml(manageUrl) + return `Altimate Code + + +

Workspace ready

Returning you to the workspace page…

+

Continue if you're not redirected automatically.

+` +} + +const log = Log.create({ service: "altimate-workspace-handoff" }) + +function escapeHtml(s: string): string { + return s.replace( + /[&<>"']/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string, + ) +} + +/** JSON-encode + escape any ```` cannot + * close the surrounding inline ` +} + +export type HandoffFailureReason = + | "unavailable" // resolveWorkspaceWebUrl returned null (not freemium) + | "not_configured" // CLI credentials not present + | "timeout" // 15-min window expired + | "cancelled" // user hit Cancel in the browser + | "tenant_mismatch" // callback tenant != credentials tenant + | "port_exhausted" // 7317..7325 all EADDRINUSE + | "browser_open_failed" + | "aborted" // caller-provided AbortSignal fired + | "error" + +/** Snapshot of the credentials the handoff started against, returned to the + * caller so it can re-verify against fresh creds immediately before binding + * (M6 in the consensus review). Workspace ids are tenant-schema-local, so + * binding a callback validated for tenant A under tenant B (after an account + * switch mid-flow) would 404 or, worse, hit an unrelated workspace. */ +export interface CredentialFingerprint { + apiUrl: string + tenant: string +} + +export interface HandoffSuccess { + ok: true + workspaceId: number + tenant: string + /** Credentials the handoff resolved and validated the callback against. + * Callers must compare against ``AltimateApi.getCredentials()`` at bind + * time and refuse the bind if either field drifted. */ + credentials: CredentialFingerprint +} +export interface HandoffFailure { + ok: false + reason: HandoffFailureReason + message?: string + authorizeUrl?: string // set for browser_open_failed so caller can copy-paste +} +export type HandoffResult = HandoffSuccess | HandoffFailure + +/** Compute the workspace-stack URL for a given API host + tenant, or null if + * this deployment isn't supported (localhost, enterprise, custom domain). + * + * Dev escape hatch: ``ALTIMATE_WORKSPACE_WEB_URL`` overrides the tenant map + * lookup when set. The override is DEV-ONLY — it returns the URL as-is + * without tenant scoping (which is what a local ``altimate2.localhost:3003`` + * dev server needs). Production callers must not set it; if it is somehow + * present and points off-tenant, the CSRF ``state`` still gates the callback + * so no cross-workspace bind is possible. */ +export function resolveWorkspaceWebUrl(altimateUrl: string, tenant: string): URL | null { + const override = process.env["ALTIMATE_WORKSPACE_WEB_URL"] + if (override) { + try { + const u = new URL(override) + if (u.protocol !== "http:" && u.protocol !== "https:") return null + return u + } catch { + return null + } + } + try { + const apiHost = new URL(altimateUrl).host + if (apiHost !== FREEMIUM_API_HOST) return null + // DNS-label guard — see TENANT_LABEL_RE for rationale. Double-check by + // reconstructing the origin from the parsed URL: if the parser resolved + // to a different host (embedded slashes, port, path in the "tenant"), + // refuse rather than emit a URL that points off-domain. + if (!TENANT_LABEL_RE.test(tenant)) return null + const lower = tenant.toLowerCase() + const u = new URL(`https://${lower}.${FREEMIUM_WORKSPACE_HOST}`) + if (u.hostname !== `${lower}.${FREEMIUM_WORKSPACE_HOST}`) return null + return u + } catch { + return null + } +} + +interface HandoffPending { + state: string + expectedTenant: string + /** Base URL for the tenant's SaaS workspace stack, used to build the + * ``/w/:id`` bounce target that the loopback success HTML redirects to. */ + workspaceWebBase: URL + resolve: (v: HandoffSuccess) => void + reject: (err: Error & { handoffReason?: HandoffFailureReason }) => void +} + +function markReason(err: E, reason: HandoffFailureReason): E & { handoffReason: HandoffFailureReason } { + return Object.assign(err, { handoffReason: reason }) +} + +/** Start a per-flow loopback listener on the first available port in the + * shared 7317..7325 range. Own server, own pending map — no coupling to the + * OAuth listener in altimate.ts. */ +async function startListener(pending: HandoffPending): Promise<{ server: Server; port: number }> { + const server = createServer((req, res) => { + const port = (server.address() as { port?: number } | null)?.port ?? CALLBACK_PORT_MIN + const url = new URL(req.url || "/", `http://127.0.0.1:${port}`) + if (url.pathname !== "/workspace-bound") { + res.writeHead(404) + res.end("Not found") + return + } + + const respond = (status: number, body: string) => { + res.writeHead(status, { "Content-Type": "text/html" }) + res.end(body) + } + + // Validate state FIRST — a request without the right state can neither + // cancel nor deliver anything. + const state = url.searchParams.get("state") + if (!state || state !== pending.state) { + respond(400, htmlError("Invalid or unknown workspace-handoff state")) + return + } + + // Respond BEFORE resolving/rejecting the pending flow — the reject path + // closes the listener via closeListener(), which can race with the + // response flush and leave the client fetch hanging. Order matters. + const error = url.searchParams.get("error") + if (error) { + const reason: HandoffFailureReason = error === "cancelled" ? "cancelled" : "error" + // Cancel bounces the browser back to the SaaS workspace home so the user + // isn't stranded on the plain loopback page; hard errors keep the plain + // error card (there's no useful place to bounce them to). + const body = error === "cancelled" ? cancelHtml(pending.workspaceWebBase) : htmlError(error) + respond(200, body) + pending.reject(markReason(new Error(error), reason)) + return + } + + const workspaceIdRaw = url.searchParams.get("workspace_id") + const tenant = url.searchParams.get("tenant") + if (!workspaceIdRaw || !tenant) { + const msg = "Missing workspace_id or tenant in callback" + respond(400, htmlError(msg)) + pending.reject(markReason(new Error(msg), "error")) + return + } + + if (tenant !== pending.expectedTenant) { + // Cross-tenant defence: user created the workspace in a tenant that + // doesn't match the CLI's credentials. Refuse the bind — the workspace + // ID is tenant-schema-local so binding here would 404 or, worse, hit an + // unrelated workspace in the CLI's tenant. + const msg = `Workspace was created in tenant "${tenant}" but the CLI is signed into "${pending.expectedTenant}"` + respond(400, htmlError(msg)) + pending.reject(markReason(new Error(msg), "tenant_mismatch")) + return + } + + // Integer-only: floats like ``42.5`` are rejected server-side but produce + // a confusing failure the caller can't recover from. (m9 in the review.) + // Also reject non-canonical spellings — ``Number()`` happily coerces + // ``"1e2"``, ``"0x2a"``, and ``" 42 "`` into finite integers, so a + // callback URL carrying those forms would slip past ``isInteger`` and + // reach the bind payload. Requiring a plain decimal-digit string first + // is the tight gate. (cubic cycle 4/5.) + if (!/^[1-9][0-9]*$/.test(workspaceIdRaw)) { + const msg = `Invalid workspace_id: ${workspaceIdRaw}` + respond(400, htmlError(msg)) + pending.reject(markReason(new Error(msg), "error")) + return + } + const workspaceId = Number(workspaceIdRaw) + if (!Number.isInteger(workspaceId) || workspaceId <= 0) { + const msg = `Invalid workspace_id: ${workspaceIdRaw}` + respond(400, htmlError(msg)) + pending.reject(markReason(new Error(msg), "error")) + return + } + + // Bounce the browser back to the SaaS workspace page. Loopback constructs + // the URL itself (no need to trust a `return` query param) — the base is + // deterministic from the tenant we already validated above. + const manageUrl = `${pending.workspaceWebBase.toString().replace(/\/$/, "")}/w/${workspaceId}` + respond(200, deliverySuccessHtml(manageUrl)) + // Callback validated — but the SUCCESS payload carries the credentials + // snapshot the handoff was started against; the caller re-verifies + // against fresh creds before binding (M6). This module never binds. + pending.resolve({ + ok: true, + workspaceId, + tenant, + credentials: { apiUrl: "", tenant: pending.expectedTenant }, // apiUrl filled in by caller + }) + }) + + // Walk 7317..7325 — each server instance is independent, so a squatting + // OAuth listener on 7317 just makes us bind 7318. + const tried: number[] = [] + let lastErr: NodeJS.ErrnoException | undefined + for (let port = CALLBACK_PORT_MIN; port <= CALLBACK_PORT_MAX; port++) { + tried.push(port) + try { + await new Promise((resolve, reject) => { + const onErr = (err: NodeJS.ErrnoException) => reject(err) + server.once("error", onErr) + server.listen(port, "127.0.0.1", () => { + server.removeListener("error", onErr) + resolve() + }) + }) + return { server, port } + } catch (err) { + lastErr = err as NodeJS.ErrnoException + // Defensive cleanup in case any listeners linger after a rejected bind. + server.removeAllListeners("error") + // Only keep walking on EADDRINUSE — any other errno (EACCES, EBADF, …) + // is a real problem, not port squatting, so break out and report it + // faithfully rather than falsely claiming "all ports in use". (m5) + if (lastErr.code !== "EADDRINUSE") break + } + } + + server.close() + const code = lastErr?.code + throw markReason( + new Error( + code === "EADDRINUSE" + ? `Every port in ${CALLBACK_PORT_MIN}-${CALLBACK_PORT_MAX} is in use (tried ${tried.join(", ")}). Close what's using them (e.g. \`lsof -i :${CALLBACK_PORT_MIN}\`) and try again.` + : `Could not start the workspace-handoff server: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`, + ), + code === "EADDRINUSE" ? "port_exhausted" : "error", + ) +} + +export interface OpenBrowserHandoffInput { + identifier: ProjectIdentifier + projectName: string + /** Optional AbortSignal — if it fires the flow settles with + * ``{ok: false, reason: "aborted"}`` and tears down the listener. Lets a + * TUI supersede a stale handoff without leaking a port for the full + * 15-minute window. (m2) */ + signal?: AbortSignal +} + +/** Full browser-handoff flow. Returns the created/picked workspace ID on + * success, or a typed failure reason on any error path. Never throws — every + * error is expressed as ``{ok: false, reason}`` so the caller can toast the + * appropriate message. */ +export async function openWorkspaceBrowserHandoff(input: OpenBrowserHandoffInput): Promise { + return runHandoffWithOpener(input, (url) => open(url).then(() => undefined)) +} + +/** Same as ``openWorkspaceBrowserHandoff`` but takes the browser-open callback + * as a dependency so tests can inject a fake that fires the loopback callback + * synchronously instead of launching a real browser. Not exported from the + * package barrel — only tests import this directly. */ +export async function runHandoffWithOpener( + input: OpenBrowserHandoffInput, + openBrowser: (url: string) => Promise, +): Promise { + // Preflight is inside the same try/catch that owns the startup IIFE — a + // rejection from ``getCredentials()`` (malformed JSON, unresolved ${env:…} + // placeholder, schema mismatch) or from any other setup step converts to + // a HandoffResult instead of propagating as an unhandled rejection into + // the TUI's ``void runBrowserHandoff(...)`` call sites. (M4) + let creds: Awaited> + let webUrl: URL + try { + if (!(await AltimateApi.isConfigured().catch(() => false))) { + return { ok: false, reason: "not_configured" } + } + creds = await AltimateApi.getCredentials() + const resolved = resolveWorkspaceWebUrl(creds.altimateUrl, creds.altimateInstanceName) + if (!resolved) return { ok: false, reason: "unavailable" } + webUrl = resolved + } catch (err) { + return { + ok: false, + reason: "error", + message: err instanceof Error ? err.message : String(err), + } + } + + const state = randomBytes(16).toString("hex") + + // Register pending, then bind the listener. Timeout owns rejection with + // reason "timeout"; the listener's own reject paths mark their own reasons. + let listenerHandle: { server: Server; port: number } | undefined + const closeListener = () => { + if (listenerHandle) { + try { + listenerHandle.server.close() + } catch { + /* best effort */ + } + listenerHandle = undefined + } + } + + return new Promise((resolve) => { + let onAbort: (() => void) | null = null + const pending: HandoffPending = { + state, + expectedTenant: creds.altimateInstanceName, + workspaceWebBase: webUrl, + resolve: (v) => { + closeListener() + clearTimeout(timeoutHandle) + if (onAbort && input.signal) input.signal.removeEventListener("abort", onAbort) + // Fill in the apiUrl snapshot the listener couldn't set (it doesn't + // hold ``creds``); the tenant already went through the expectedTenant + // check inside the listener. + resolve({ ...v, credentials: { apiUrl: creds.altimateUrl, tenant: v.tenant } }) + }, + reject: (err) => { + closeListener() + clearTimeout(timeoutHandle) + if (onAbort && input.signal) input.signal.removeEventListener("abort", onAbort) + const reason = (err as { handoffReason?: HandoffFailureReason }).handoffReason ?? "error" + const authorizeUrl = (err as { authorizeUrl?: string }).authorizeUrl + resolve({ + ok: false, + reason, + message: err.message, + ...(authorizeUrl ? { authorizeUrl } : {}), + }) + }, + } + const timeoutHandle = setTimeout(() => { + pending.reject(markReason(new Error("Timed out waiting for browser workspace handoff"), "timeout")) + }, DEFAULT_TIMEOUT_MS) + // ``.unref()`` so the timer alone doesn't keep the CLI process alive + // once every other handle has exited. (m2) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(timeoutHandle as any)?.unref?.() + + // Wire the AbortSignal — if it fires the flow settles with + // ``reason: "aborted"`` and the listener is torn down immediately. + if (input.signal) { + if (input.signal.aborted) { + pending.reject(markReason(new Error("Handoff aborted"), "aborted")) + return + } + onAbort = () => pending.reject(markReason(new Error("Handoff aborted"), "aborted")) + input.signal.addEventListener("abort", onAbort, { once: true }) + } + + ;(async () => { + try { + listenerHandle = await startListener(pending) + // Capture the port to a local IMMEDIATELY — ``listenerHandle`` is + // cleared by ``closeListener`` on timeout, and a lazy ``import()`` + // below can straddle that clear. (M4 sub-case) + const boundPort = listenerHandle.port + + // Import buildCliContext lazily so this module doesn't pull altimate.ts + // into every consumer's import graph at load time. + const { buildCliContext } = await import("../plugin/altimate") + const cliContext = await buildCliContext().catch((err) => { + log.warn("buildCliContext failed; proceeding without", { err: String(err) }) + return "" + }) + + const redirect = `http://127.0.0.1:${boundPort}/workspace-bound` + const target = new URL("/create-and-link", webUrl) + target.searchParams.set("client", "altimate-code") + target.searchParams.set("redirect", redirect) + target.searchParams.set("state", state) + target.searchParams.set("project_name", input.projectName) + // Project path + remote go in the URL FRAGMENT, not the query, so + // they don't land in SaaS access logs, WAF logs, or browser history + // as query params. Same rationale as ``cli_context`` in altimate.ts + // (see altimate.ts:135-137). (m6) + const fragment = new URLSearchParams() + if (input.identifier.repoRemote) fragment.set("project_remote", input.identifier.repoRemote) + if (input.identifier.projectPath) fragment.set("project_path", input.identifier.projectPath) + if (cliContext) fragment.set("cli_context", cliContext) + const authorizeUrl = fragment.toString() + ? `${target.toString()}#${fragment.toString()}` + : target.toString() + + try { + await openBrowser(authorizeUrl) + } catch (err) { + // Browser open failed. Preserve the URL so the caller can copy-paste. + pending.reject( + Object.assign( + markReason(new Error(`Could not open browser: ${err instanceof Error ? err.message : String(err)}`), "browser_open_failed"), + { authorizeUrl }, + ), + ) + } + } catch (err) { + // ANY throw in this async IIFE — startListener rejection, the lazy + // ``import()``, ``buildCliContext()`` panic — funnels through + // pending.reject so ``settled`` resolves and the caller sees a + // ``HandoffResult`` instead of a 15-minute silent hang. (M4) + const reason = (err as { handoffReason?: HandoffFailureReason }).handoffReason ?? "error" + pending.reject(markReason(err as Error, reason)) + } + })() + }) +} diff --git a/packages/opencode/src/altimate/workspace/detect.ts b/packages/opencode/src/altimate/workspace/detect.ts new file mode 100644 index 0000000000..71a7d6cc74 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/detect.ts @@ -0,0 +1,75 @@ +// altimate_change - new file +// +// Cheap, sync git-remote detection for the workspace-binding flow. Shared by +// the TuiPlugin (packages/opencode/src/plugin/tui/altimate/workspace.tsx) and +// the `altimate link` CLI subcommand so both entry points identify projects +// identically. Reuses the credential-scrubbing helper the ProjectScan tool +// exports (../tools/project-scan.ts) so an HTTPS remote with embedded +// basic-auth (e.g. `https://:@github.com/...`) never +// reaches the server or the local cache in clear. +import { spawnSync } from "node:child_process" +import { realpathSync } from "node:fs" +import path from "node:path" +import { stripGitRemoteCredentials } from "../tools/project-scan" + +export function detectProjectRemote(directory: string): string | undefined { + try { + const r = spawnSync("git", ["remote", "get-url", "origin"], { + cwd: directory, + encoding: "utf8", + timeout: 3000, + }) + if (r.status !== 0 || !r.stdout) return undefined + return stripGitRemoteCredentials(r.stdout.trim()) + } catch { + return undefined + } +} + +/** Project identity for the binding system. Prefers ``repo_remote`` when the + * project has a git remote (stronger identity — survives directory moves); falls + * back to ``project_path`` (absolute, symlink-resolved directory path) for + * projects without one (materialized sample scaffolds, fresh scratch dirs). + * + * Both fields can be populated simultaneously; callers pick which to use for + * lookup or send both to create/bind (server uses whichever it needs for its + * partial unique index). At least one field is always populated — falling back + * to the raw ``directory`` argument keeps the "no remote AND unresolvable path" + * degenerate case from returning empty. */ +export function resolveProjectIdentifier(directory: string): { + repoRemote?: string + projectPath: string +} { + const repoRemote = detectProjectRemote(directory) + let projectPath: string + try { + projectPath = realpathSync(path.resolve(directory)) + } catch { + projectPath = path.resolve(directory) + } + return repoRemote ? { repoRemote, projectPath } : { projectPath } +} + +/** Sensible default workspace name from a remote URL — best-effort. Used to + * prefill the workspace-name prompt in the CreateDialog and `altimate link`. + * ``github.com/foo/bar.git`` → ``bar`` ; ``git@github.com:foo/bar.git`` → ``bar`` ; + * ``https://x/foo/bar.git/`` (trailing slash after .git) → ``bar``. */ +export function projectNameFromRemote(remote: string): string { + // Strip trailing slashes FIRST, then the ``.git`` suffix, then any + // trailing slashes the suffix strip exposed. Order matters — the + // previous ``.git$`` → ``/$`` pipeline missed ``.git/`` because the + // final ``/`` wasn't ``.git`` any more. (cubic round 3.) + const trimmed = remote + .replace(/[/]+$/, "") + .replace(/\.git$/, "") + .replace(/[/]+$/, "") + const parts = trimmed.split(/[/:]/) + return parts[parts.length - 1] || "workspace" +} + +/** Fallback name source when the project has no git remote — uses the directory's + * basename (e.g. ``/Users/x/sample-dbt-project`` → ``sample-dbt-project``). */ +export function projectNameFromPath(projectPath: string): string { + const base = path.basename(projectPath.replace(/\/$/, "")) + return base || "workspace" +} diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts new file mode 100644 index 0000000000..075f861c79 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -0,0 +1,217 @@ +// altimate_change - new file +// +// Local binding cache — offline fallback for the server-authoritative +// pre-check. Scoped to (tenant, apiUrl) at the top level so an account switch +// silently invalidates every cached entry (the switched-to session never sees +// another tenant's workspace names). +// +// Shared between the TuiPlugin and the `altimate link` CLI subcommand so both +// entry points see the same view of local state. File lives under +// ``Global.Path.state`` at 0o600 — chmod is applied post-write since +// ``Filesystem.writeJsonAtomic`` does not chmod (see filesystem.ts:294 for +// why; codex round-2 flagged this gap). +import { chmodSync, existsSync, readFileSync, realpathSync } from "node:fs" +import path from "node:path" +import { AltimateApi } from "@/altimate/api/client" +import { Global } from "@/global" +import { Filesystem } from "@/util/filesystem" +import { Log } from "@/altimate/util/log" + +const CACHE_VERSION = 1 + +const log = Log.create({ service: "altimate-workspace-state" }) + +export interface CachedBinding { + datamateId: number + datamateName: string + /** Either ``repoRemote`` or ``projectPath`` is populated (at least one). + * Mirrors the server-side binding row, which is identified by whichever + * fields it has. */ + repoRemote: string | null + projectPath: string | null + linkedAt: number +} + +interface CacheFile { + version: 1 + tenant: string + apiUrl: string + bindings: Record +} + +export function cachePath(): string { + return path.join(Global.Path.state, "altimate-workspace-bindings.json") +} + +/** Runtime shape check for a parsed cache file — the JSON blob comes from + * disk and could be anything (older CLI version, hand-edited, corrupted + * mid-write). The type assertion alone doesn't guard against e.g. + * ``{"version": 1, "bindings": null}`` which then throws on + * ``cache.bindings[k]``. Discard anything that fails the shape check so + * readers always get a valid ``CacheFile`` or null. (CR round 2.) */ +function isValidCacheFile(raw: unknown): raw is CacheFile { + if (!raw || typeof raw !== "object") return false + const r = raw as Record + if (r.version !== CACHE_VERSION) return false + if (typeof r.tenant !== "string" || !r.tenant) return false + if (typeof r.apiUrl !== "string" || !r.apiUrl) return false + if (!r.bindings || typeof r.bindings !== "object" || Array.isArray(r.bindings)) return false + for (const [k, v] of Object.entries(r.bindings)) { + if (typeof k !== "string") return false + if (!v || typeof v !== "object") return false + const b = v as Record + if (typeof b.datamateId !== "number" || !Number.isInteger(b.datamateId)) return false + if (typeof b.datamateName !== "string") return false + if (b.repoRemote !== null && typeof b.repoRemote !== "string") return false + if (b.projectPath !== null && typeof b.projectPath !== "string") return false + // At least one identity — otherwise the cached row can never be verified + // against a project and would surface as a "phantom" workspace on the + // offline-fallback render path. (cubic round 3.) + const hasIdentity = + (typeof b.repoRemote === "string" && b.repoRemote.length > 0) || + (typeof b.projectPath === "string" && b.projectPath.length > 0) + if (!hasIdentity) return false + if (typeof b.linkedAt !== "number") return false + } + return true +} + +function readCache(): CacheFile | null { + const p = cachePath() + if (!existsSync(p)) return null + try { + const raw = JSON.parse(readFileSync(p, "utf8")) as unknown + if (!isValidCacheFile(raw)) return null + return raw + } catch (err) { + log.warn("workspace binding cache is corrupt, discarding", { + code: (err as NodeJS.ErrnoException)?.code, + }) + return null + } +} + +/** True when every key in the cache is already the canonical form of itself + * (i.e. no earlier-CLI-build unresolved keys remain). Cheap side condition + * so we can skip the per-read migration once the cache has been rewritten. */ +function isCanonicalized(cache: CacheFile): boolean { + for (const k of Object.keys(cache.bindings)) { + if (canonicalizeKey(k) !== k) return false + } + return true +} + +/** One-shot migration: rewrite the cache with canonical keys, collapsing any + * pair that resolves to the same target (last-writer-wins by ``linkedAt``). + * After this runs the O(n) lookup-time rescan in ``readLocalBinding`` is + * dead code — every subsequent read hits the direct key lookup. */ +function migrateToCanonicalKeys(cache: CacheFile): CacheFile { + const migrated: Record = {} + for (const [k, v] of Object.entries(cache.bindings)) { + const canon = canonicalizeKey(k) + const existing = migrated[canon] + if (!existing || existing.linkedAt <= v.linkedAt) migrated[canon] = v + } + const next: CacheFile = { ...cache, bindings: migrated } + writeCache(next) + return next +} + +function writeCache(cache: CacheFile): void { + const p = cachePath() + Filesystem.writeJsonAtomic(p, cache) + // Best-effort chmod — if the process dies before this line the file exists + // with umask perms, and the next successful write repairs it. Acceptable + // window given the cache holds workspace names, not credentials. + try { + chmodSync(p, 0o600) + } catch (err) { + log.warn("could not chmod workspace binding cache", { + code: (err as NodeJS.ErrnoException)?.code, + }) + } +} + +/** Canonicalize a directory path so cache lookups survive symlink differences + * (macOS ``/tmp`` → ``/private/tmp`` is the common case). Writers and readers + * must both funnel through this or a shell-cwd write silently misses when the + * TUI's canonicalized ``state.path.directory`` looks it back up. */ +function canonicalizeKey(directory: string): string { + try { + return realpathSync(path.resolve(directory)) + } catch { + return path.resolve(directory) + } +} + +async function tenantKey(): Promise<{ tenant: string; apiUrl: string } | null> { + // Best-effort: ``AltimateApi.getCredentials`` can throw ``SyntaxError`` on + // a corrupt credentials JSON, ``ZodError`` on schema drift, or a raw + // ``Error`` on an unresolvable ``${env:...}`` reference — anything the + // credential-loader library can produce. This helper is the last gate + // between those errors and callers who treat their failures as fatal (the + // TUI's fire-and-forget bind path terminates on unhandled rejections), so + // swallow them and treat as "no credentials". (Kilo cycle 6.) + try { + if (!(await AltimateApi.isConfigured())) return null + const c = await AltimateApi.getCredentials() + return { tenant: c.altimateInstanceName, apiUrl: c.altimateUrl } + } catch (err) { + log.warn("could not resolve workspace credentials for cache scoping", { + err: String(err), + }) + return null + } +} + +/** Read the local binding for ``directory`` — only returns a hit when the + * cache's stored (tenant, apiUrl) matches the current credentials. Runs a + * one-shot migration to canonical keys on the first read that finds an + * unresolved key (macOS ``/tmp`` → ``/private/tmp``), then relies on direct + * lookup for the process's remaining lifetime. */ +export async function readLocalBinding(directory: string): Promise { + const key = await tenantKey() + if (!key) return null + let cache = readCache() + if (!cache) return null + if (cache.tenant !== key.tenant || cache.apiUrl !== key.apiUrl) return null + const canon = canonicalizeKey(directory) + const direct = cache.bindings[canon] + if (direct) return direct + // Cache miss: check if the cache still has any non-canonical keys and + // migrate the whole file once. After migration the lookup is a plain + // property access on every future read. + if (!isCanonicalized(cache)) { + cache = migrateToCanonicalKeys(cache) + return cache.bindings[canon] ?? null + } + return null +} + +export async function recordApprovedBinding( + directory: string, + binding: CachedBinding, +): Promise { + const key = await tenantKey() + if (!key) return + // Best-effort: cache persistence is a UX convenience, not the source of + // truth (the server-side binding is). If the state directory is read-only + // or the disk is full, callers otherwise report "link failed" and prompt + // duplicate retries against a workspace that IS bound server-side. + // (cubic round 3.) canonicalizeKey resolves symlinks so writes and reads + // funnel through the same key (macOS ``/tmp`` → ``/private/tmp``). + try { + const existing = readCache() + const cache: CacheFile = + existing && existing.tenant === key.tenant && existing.apiUrl === key.apiUrl + ? existing + : { version: CACHE_VERSION, tenant: key.tenant, apiUrl: key.apiUrl, bindings: {} } + cache.bindings[canonicalizeKey(directory)] = binding + writeCache(cache) + } catch (err) { + log.warn("could not persist workspace binding cache", { + code: (err as NodeJS.ErrnoException)?.code, + err: String(err), + }) + } +} diff --git a/packages/opencode/src/cli/cmd/link.ts b/packages/opencode/src/cli/cmd/link.ts new file mode 100644 index 0000000000..68e740d1e9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/link.ts @@ -0,0 +1,536 @@ +// altimate_change - new file +// +// On-demand "link this project to a workspace" subcommand. User invoked it +// explicitly, so we skip the Create/Link/Skip funnel the post-scan trigger +// uses and jump straight to a picker over the user's workspaces — the +// currently-linked one is marked, and "+ Create a new workspace" is the +// first row. New workspaces are auto-named from the git repo (or directory +// name for path-only projects) so the user never has to type anything. +// +// Deliberately shares the WorkspaceApi + state + detect modules with the +// TuiPlugin so the two entry points can't drift on request shape, project +// identity, or error handling. +import { cmd } from "./cmd" +import { UI } from "../ui" +import * as prompts from "@clack/prompts" +import open from "open" +import { AltimateApi } from "@/altimate/api/client" +import { + WorkspaceApi, + ConflictError, + ForbiddenError, + NotConfiguredError, + NotFoundError, + PreconditionFailedError, + type DatamateRef, + type MatchedIdentifier, + type ProjectBindingLookup, + type ProjectIdentifier, +} from "@/altimate/workspace/api-client" +import { + projectNameFromPath, + projectNameFromRemote, + resolveProjectIdentifier, +} from "@/altimate/workspace/detect" +import { + openWorkspaceBrowserHandoff, + resolveWorkspaceWebUrl, + type HandoffResult, +} from "@/altimate/workspace/browser-handoff" +import { recordApprovedBinding } from "@/altimate/workspace/state" + +const CREATE_NEW_SENTINEL = "__create_new__" +const SET_UP_IN_BROWSER_SENTINEL = "__browser_handoff__" + +export const LinkCommand = cmd({ + command: "link", + describe: "Link this project to an Altimate workspace", + builder: (yargs) => + yargs.option("directory", { + alias: "d", + describe: "Project directory (defaults to cwd)", + type: "string", + default: process.cwd(), + }), + handler: async (args) => { + if (!(await AltimateApi.isConfigured())) { + UI.error( + "Not signed in to Altimate. Run the TUI (altimate-code) and sign in first, then re-run `altimate-code link`.", + ) + process.exitCode = 1 + return + } + + const identifier = resolveProjectIdentifier(args.directory) + + prompts.intro("Link this project to a workspace") + if (identifier.repoRemote) prompts.log.info(`Project remote: ${identifier.repoRemote}`) + else prompts.log.info(`Project path: ${identifier.projectPath} (no git remote)`) + + // Pre-check for the currently-linked marker + workspace list. Both are + // fetched up-front so the picker can annotate the current binding. + // ``preCheckOk = false`` means the pre-check itself failed (network, + // 5xx) rather than "not linked" — used later to retry a 409 as a rebind + // instead of surfacing "already linked to X" with no next step. (m10) + let existing: ProjectBindingLookup | null = null + let preCheckOk = true + try { + existing = await WorkspaceApi.getBindingForProject(identifier) + } catch (err) { + if (err instanceof NotConfiguredError) { + UI.error(err.message) + process.exitCode = 1 + return + } + preCheckOk = false + prompts.log.warn( + `Could not reach the workspace service to look up existing bindings (${err instanceof Error ? err.message : String(err)}). Continuing without the currently-linked marker.`, + ) + } + + const spin = prompts.spinner() + spin.start("Loading workspaces...") + let list: DatamateRef[] + try { + list = await WorkspaceApi.listDatamates() + } catch (err) { + spin.stop("Could not load workspaces.", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + process.exitCode = 1 + return + } + spin.stop(`Found ${list.length} workspace${list.length === 1 ? "" : "s"}.`) + + const autoName = identifier.repoRemote + ? projectNameFromRemote(identifier.repoRemote) + : projectNameFromPath(identifier.projectPath) + const currentId = existing?.datamate.id + const currentName = existing?.datamate.name + + // Only offer the browser-based handoff when the deployment supports it + // (freemium only today). Enterprise / localhost / custom-domain callers + // silently fall back to the CLI-side quick create. + const creds = await AltimateApi.getCredentials() + const browserAvailable = + resolveWorkspaceWebUrl(creds.altimateUrl, creds.altimateInstanceName) !== null + + const options: Array<{ value: string; label: string; hint?: string }> = [ + // Only offer browser handoff for UNLINKED projects (CodeRabbit cycle 5). + // ``runBrowserHandoff`` creates a fresh workspace and calls + // ``bindExisting``, which 409s when there's already an active binding — + // leaving the browser-created workspace stranded and no rebind actually + // happening. If the project is already linked, the caller wants a + // rebind path (offered elsewhere in this menu), not create-and-bind. + // + // Also gate on ``preCheckOk`` (Kilo cycle 6): when the pre-check itself + // failed (network / 5xx), ``existing`` stays null but the project MAY + // be linked server-side. Offering the browser flow then would run the + // same 409 → stranded-workspace path. Better to hide the option until + // the caller can confirm the binding state. + ...(browserAvailable && !existing && preCheckOk + ? [ + { + value: SET_UP_IN_BROWSER_SENTINEL, + label: `+ Set up in browser "${autoName}"`, + hint: "Approve in the Altimate SaaS; CLI links your project automatically.", + }, + ] + : []), + { + value: CREATE_NEW_SENTINEL, + label: `+ Create a quick workspace "${autoName}" here`, + hint: existing + ? "Creates a new workspace and repoints this project to it (no browser step)." + : "No browser step; configure integrations later in the SaaS.", + }, + ...list.map((dm) => ({ + value: String(dm.id), + label: dm.id === currentId ? `● ${dm.name}` : ` ${dm.name}`, + hint: dm.id === currentId ? "currently linked here" : undefined, + })), + ] + + const pick = await prompts.select({ + message: existing + ? `Currently linked to "${currentName}". Pick a workspace (or create a new one):` + : "Pick a workspace to link (or create a new one):", + options, + initialValue: currentId !== undefined ? String(currentId) : CREATE_NEW_SENTINEL, + }) + + if (prompts.isCancel(pick)) { + prompts.outro("No changes.") + return + } + + if (pick === SET_UP_IN_BROWSER_SENTINEL) { + await runBrowserHandoff(identifier, autoName, args.directory) + return + } + + if (pick === CREATE_NEW_SENTINEL) { + await createThenBindOrRebind(identifier, autoName, args.directory, existing) + return + } + + const targetId = Number(pick) + if (targetId === currentId) { + prompts.outro(`Kept "${currentName}" — nothing changed.`) + return + } + + await bindOrRebind(identifier, targetId, existing, preCheckOk, args.directory) + }, +}) + +/** Browser-based create-and-bind flow. Same handoff module the TUI post-scan + * dialog uses; on success, the CLI calls the existing bind endpoint to link + * the current project to the newly-created workspace. When the project is + * already linked, bindExisting will 409; the caller re-runs and picks + * "+ Create a quick workspace here" instead to trigger the create-and-rebind + * path. (Full create-then-rebind via the browser flow is deferred — the + * SaaS approval screen doesn't yet know how to receive a "rebind after + * create" instruction from the CLI.) */ +async function runBrowserHandoff( + identifier: ProjectIdentifier, + projectName: string, + directory: string, +): Promise { + const spin = prompts.spinner() + spin.start("Waiting for browser approval...") + const result: HandoffResult = await openWorkspaceBrowserHandoff({ identifier, projectName }) + if (!result.ok) { + spin.stop(handoffFailureMessage(result), 1) + process.exitCode = 1 + return + } + // M6 in the consensus review: re-verify credentials before binding. The + // browser window can stay open for up to 15 minutes; an account switch in + // that window would otherwise bind a callback validated for tenant A + // under tenant B (workspace ids are tenant-schema-local). + try { + const fresh = await AltimateApi.getCredentials() + if ( + fresh.altimateInstanceName !== result.credentials.tenant || + fresh.altimateUrl !== result.credentials.apiUrl + ) { + spin.stop( + `Credentials changed while the browser was open (was ${result.credentials.tenant}, now ${fresh.altimateInstanceName}). Re-run to link this project.`, + 1, + ) + process.exitCode = 1 + return + } + } catch { + spin.stop("Lost Altimate credentials while the browser was open — sign in and re-run.", 1) + process.exitCode = 1 + return + } + spin.stop(`Workspace approved. Binding to project...`) + const bindSpin = prompts.spinner() + bindSpin.start("Linking workspace...") + try { + const res = await WorkspaceApi.bindExisting(result.workspaceId, identifier) + await recordApprovedBinding(directory, { + datamateId: res.binding.datamate_id, + datamateName: res.binding.datamate_name, + repoRemote: res.binding.repo_remote, + projectPath: res.binding.project_path, + linkedAt: Date.now(), + }) + bindSpin.stop(`Linked to "${res.binding.datamate_name}".`) + const manageUrl = await manageUrlFor(res.binding.datamate_id) + if (manageUrl) prompts.log.info(`Manage it at: ${manageUrl}`) + prompts.outro("Done.") + } catch (err) { + bindSpin.stop("Link failed.", 1) + if (err instanceof ConflictError) { + prompts.log.error( + `This project is already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Workspace "${projectName}" was created but is not linked — re-run \`altimate-code link\` and pick a different action to switch, or delete the new workspace in the SaaS.`, + ) + } else if (err instanceof NotFoundError) { + prompts.log.error("Workspace not found — the tenant or workspace may have changed.") + } else if (err instanceof ForbiddenError) { + prompts.log.error("Only the workspace owner can bind projects to it.") + } else { + prompts.log.error(err instanceof Error ? err.message : String(err)) + } + process.exitCode = 1 + } +} + +/** Best-effort manage-workspace URL for the current credentials. Returns null + * on BYOK / unresolvable deployments — callers omit the "Manage it at" line. */ +async function manageUrlFor(workspaceId: number): Promise { + try { + const creds = await AltimateApi.getCredentials() + const base = resolveWorkspaceWebUrl(creds.altimateUrl, creds.altimateInstanceName) + if (!base) return null + return `${base.toString().replace(/\/$/, "")}/w/${workspaceId}` + } catch { + return null + } +} + +function handoffFailureMessage(result: Extract): string { + switch (result.reason) { + case "unavailable": + return "Browser handoff isn't available for this deployment." + case "not_configured": + return "Altimate credentials not configured — sign in first." + case "timeout": + return "Timed out waiting for browser approval (15 min)." + case "cancelled": + return "Cancelled by user." + case "tenant_mismatch": + return result.message ?? "Workspace was set up in a different tenant than the CLI's credentials." + case "port_exhausted": + return result.message ?? "Loopback ports 7317-7325 all in use." + case "browser_open_failed": + return `Could not open browser${result.authorizeUrl ? `. Open manually: ${result.authorizeUrl}` : "."}` + case "aborted": + return result.message ?? "Browser handoff was cancelled." + default: + return result.message ?? "Browser handoff failed." + } +} + +/** "+ Create a quick workspace here" flow. When the project is already + * linked, this MUST rebind after create — otherwise the new workspace is a + * real (billable) SaaS resource the CLI knows nothing about and the project + * is still bound to the old workspace (M2 in the consensus review). When + * rebind fails, the error message tells the user the workspace was created + * and how to recover; we do NOT silently swallow the orphan. */ +async function createThenBindOrRebind( + identifier: ProjectIdentifier, + name: string, + directory: string, + existing: ProjectBindingLookup | null, +): Promise { + const spin = prompts.spinner() + spin.start(`Creating workspace "${name}"...`) + let created: Awaited> + try { + created = await WorkspaceApi.createAndBind({ name, identifier }) + } catch (err) { + spin.stop("Failed to create workspace.", 1) + // A 409 from create means someone else's binding on the same + // remote/path beat us. If the pre-check already knew about it, the user + // can pick from the list; if the pre-check missed it, this is the + // authoritative signal — surface it and hint the picker. + if (err instanceof ConflictError) { + prompts.log.error( + `This project is already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Re-run \`altimate-code link\` to switch to a different workspace.`, + ) + } else { + prompts.log.error(err instanceof Error ? err.message : String(err)) + } + process.exitCode = 1 + return + } + spin.stop(`Workspace "${created.datamate.name}" created.`) + + // If the project was already linked, the new workspace exists but the + // binding still points at the OLD workspace — rebind so the project is + // now bound to the freshly-created one. Otherwise createAndBind already + // wrote the binding as part of the atomic create; we're done. + if (existing) { + const rebindSpin = prompts.spinner() + rebindSpin.start(`Repointing project at "${created.datamate.name}"...`) + try { + await rebindByMatchedIdentifier({ + identifier, + targetDatamateId: created.datamate.id, + expectedCurrentDatamateId: existing.datamate.id, + matchedBy: existing.matchedBy, + }) + rebindSpin.stop(`Project is now linked to "${created.datamate.name}".`) + } catch (err) { + rebindSpin.stop("Could not repoint the project.", 1) + prompts.log.error( + `Workspace "${created.datamate.name}" was CREATED but could not be linked to this project. ${err instanceof Error ? err.message : String(err)} — re-run \`altimate-code link\` to retry (or delete the workspace in the SaaS).`, + ) + process.exitCode = 1 + return + } + } + // Prefer the canonicalized ``identifier.projectPath`` over the raw + // ``--directory`` argument so ``altimate-code link -d ./myproj`` and its + // symlink-resolved twin both write under the same cache key (Kilo cycle 6). + await recordApprovedBinding(identifier.projectPath ?? directory, { + datamateId: created.datamate.id, + datamateName: created.datamate.name, + repoRemote: created.binding.repo_remote, + projectPath: created.binding.project_path, + linkedAt: Date.now(), + }) + prompts.log.info(`Manage it at: ${created.manage_url}`) + // Guard against a server that hands back a non-http(s) manage_url — ``open`` + // delegates to the OS handler, so a rogue value could launch an unrelated + // application. Log a warning and skip the auto-open rather than trusting + // whatever protocol the URL parses to. + if (isSafeHttpUrl(created.manage_url)) { + await open(created.manage_url).catch(() => undefined) + } else { + prompts.log.warn(`Skipped auto-open: manage_url is not an http/https URL.`) + } + prompts.outro("Done.") +} + +/** True when the URL parses and its protocol is exactly ``http:`` or ``https:``. + * Used before handing a server-supplied URL to ``open()`` (which would otherwise + * dispatch to whatever OS scheme handler matches the protocol). */ +function isSafeHttpUrl(url: string): boolean { + try { + const u = new URL(url) + return u.protocol === "http:" || u.protocol === "https:" + } catch { + return false + } +} + +async function bindOrRebind( + identifier: ProjectIdentifier, + targetDatamateId: number, + existing: ProjectBindingLookup | null, + preCheckOk: boolean, + directory: string, +): Promise { + const isRebind = existing !== null + const spin = prompts.spinner() + spin.start(isRebind ? `Re-linking to workspace...` : `Linking to workspace...`) + try { + let res + if (isRebind) { + res = await rebindByMatchedIdentifier({ + identifier, + targetDatamateId, + expectedCurrentDatamateId: existing.datamate.id, + matchedBy: existing.matchedBy, + }) + } else { + // No known binding OR pre-check failed. Try bindExisting first — if the + // pre-check missed a real binding, the server will 409, and we retry as + // rebind when we're allowed to. (m10) + try { + res = await WorkspaceApi.bindExisting(targetDatamateId, identifier) + } catch (err) { + if (err instanceof ConflictError && !preCheckOk) { + // Pre-check failed and the server confirms this project IS linked + // already. Retry as an unconditional rebind — we don't have an + // ``expected_current_datamate_id`` (pre-check gave us nothing) so + // this is last-writer-wins. Callers who need optimistic concurrency + // should re-run once the network is back and the pre-check succeeds. + // + // Pick the rebind endpoint from the CONFLICT DETAIL, not from the + // current identifier — the existing binding may be keyed by a + // different identifier than the project's current one (path-keyed + // legacy binding + newly-added remote, or vice versa). Keying off + // the current identifier reproduces the M3 hazard on this fallback + // path. (Kilo cycle 6.) + spin.stop("Pre-check missed an existing binding — retrying as re-link.", 1) + const rebindSpin = prompts.spinner() + rebindSpin.start("Re-linking...") + try { + // detail.project_path present → the conflicting binding is + // path-keyed; use /by-path. Else the conflict was on repo_remote. + const conflictPath = err.detail.project_path + const conflictRemote = err.detail.repo_remote + if (conflictPath) { + res = await WorkspaceApi.rebindByPath({ + projectPath: conflictPath, + targetDatamateId, + }) + } else if (conflictRemote) { + res = await WorkspaceApi.rebindByRemote({ + remote: conflictRemote, + targetDatamateId, + }) + } else { + // Server didn't tell us which identifier owned the conflict — + // fall back to the current identifier's preference (better than + // nothing, but shouldn't happen with a well-formed 409 body). + res = identifier.repoRemote + ? await WorkspaceApi.rebindByRemote({ + remote: identifier.repoRemote, + targetDatamateId, + }) + : await WorkspaceApi.rebindByPath({ + projectPath: identifier.projectPath!, + targetDatamateId, + }) + } + rebindSpin.stop(`Re-linked to "${res.binding.datamate_name}".`) + } catch (retryErr) { + rebindSpin.stop("Re-link failed.", 1) + throw retryErr + } + } else { + throw err + } + } + } + // Prefer the canonicalized identifier over the raw --directory (Kilo cycle 6). + await recordApprovedBinding(identifier.projectPath ?? directory, { + datamateId: res.binding.datamate_id, + datamateName: res.binding.datamate_name, + repoRemote: res.binding.repo_remote, + projectPath: res.binding.project_path, + linkedAt: Date.now(), + }) + spin.stop( + isRebind + ? `Re-linked to "${res.binding.datamate_name}".` + : `Linked to "${res.binding.datamate_name}".`, + ) + const manageUrl = await manageUrlFor(res.binding.datamate_id) + if (manageUrl) prompts.log.info(`Manage it at: ${manageUrl}`) + prompts.outro("Done.") + } catch (err) { + spin.stop(isRebind ? `Re-link failed.` : `Link failed.`, 1) + if (err instanceof ConflictError) { + prompts.log.error( + `Already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Re-run \`altimate-code link\` to switch.`, + ) + } else if (err instanceof PreconditionFailedError) { + prompts.log.error("Someone else re-linked this project — re-run and try again.") + } else if (err instanceof NotFoundError) { + prompts.log.error("No existing binding to re-link. Re-run and pick again.") + } else if (err instanceof ForbiddenError) { + prompts.log.error("Only the workspace owner can attach projects to it.") + } else { + prompts.log.error(err instanceof Error ? err.message : String(err)) + } + process.exitCode = 1 + } +} + +/** Pick the rebind endpoint that matches which identifier the pre-check + * resolved the binding on — NOT which identifier the current call happens to + * carry. A repo whose remote was renamed still has a binding under its path; + * rebindByRemote against the new remote would 404 with no repair path from + * the CLI. (M3) */ +async function rebindByMatchedIdentifier(input: { + identifier: ProjectIdentifier + targetDatamateId: number + expectedCurrentDatamateId: number + matchedBy: MatchedIdentifier +}) { + if (input.matchedBy === "remote" && input.identifier.repoRemote) { + return WorkspaceApi.rebindByRemote({ + remote: input.identifier.repoRemote, + targetDatamateId: input.targetDatamateId, + expectedCurrentDatamateId: input.expectedCurrentDatamateId, + }) + } + if (input.matchedBy === "path" && input.identifier.projectPath) { + return WorkspaceApi.rebindByPath({ + projectPath: input.identifier.projectPath, + targetDatamateId: input.targetDatamateId, + expectedCurrentDatamateId: input.expectedCurrentDatamateId, + }) + } + throw new Error( + `Cannot rebind — the pre-check matched on ${input.matchedBy} but that field is not present on the current project identifier.`, + ) +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 69da571611..d2fe2b3506 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -10,6 +10,7 @@ import { UninstallCommand } from "./cli/cmd/uninstall" import { ModelsCommand } from "./cli/cmd/models" import { UI } from "./cli/ui" import { InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version" +import { Flag } from "@opencode-ai/core/flag/flag" import { FormatError } from "./cli/error" import { ServeCommand } from "./cli/cmd/serve" // altimate_change start — workspace-serve: dev-only workspace serve command @@ -44,6 +45,9 @@ import { SkillCommand } from "./cli/cmd/skill" // altimate_change start — check: deterministic SQL check command import { CheckCommand } from "./cli/cmd/check" // altimate_change end +// altimate_change start — link: workspace-binding subcommand +import { LinkCommand } from "./cli/cmd/link" +// altimate_change end import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" @@ -169,6 +173,15 @@ let cli = yargs(args) // altimate_change end // altimate_change start — check: register deterministic SQL check command .command(CheckCommand) + // altimate_change end + +// altimate_change start — link: gated on Flag.ALTIMATE_WORKSPACE (pilot) +// so the command isn't registered — and doesn't show in --help — for users +// who haven't opted in to the workspaces feature via ALTIMATE_WORKSPACE=1. +// (M1 in the consensus review.) +if (Flag.ALTIMATE_WORKSPACE) { + cli = cli.command(LinkCommand) +} // altimate_change end // altimate_change start — workspace-serve: register dev-only workspace serve command diff --git a/packages/opencode/src/plugin/tui/altimate/index.ts b/packages/opencode/src/plugin/tui/altimate/index.ts index 766e0593e5..8e90e364b0 100644 --- a/packages/opencode/src/plugin/tui/altimate/index.ts +++ b/packages/opencode/src/plugin/tui/altimate/index.ts @@ -9,10 +9,13 @@ // plugin list in ../internal.ts. import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" import type { RuntimeFlags } from "@/effect/runtime-flags" +import { Flag } from "@opencode-ai/core/flag/flag" import ProviderCredentials from "./provider-credentials" import PromptEnhance from "./prompt-enhance" import SkillOps from "./skill-ops" import TraceViewer from "./trace-viewer" +import Workspace from "./workspace" +import WorkspaceSidebar from "./workspace-sidebar" // Feature plugins are registered here as they are ported from the pre-merge sources on `main` // (see the ADR re-home plan). Each lives in its own file under this directory and default-exports @@ -21,7 +24,14 @@ import TraceViewer from "./trace-viewer" // import SkillOps from "./skill-ops" // import PromptEnhance from "./prompt-enhance" // import TraceViewer from "./trace-viewer" +// import Workspace from "./workspace" export function altimateTuiPlugins(_flags: Pick): BuiltinTuiPlugin[] { - return [ProviderCredentials, PromptEnhance, SkillOps, TraceViewer] + const base = [ProviderCredentials, PromptEnhance, SkillOps, TraceViewer] + // Workspace TUI plugin + right-pane sidebar tile are pilot-gated: only + // registered for users who opted into ALTIMATE_WORKSPACE. Otherwise the + // post-scan dialog, the altimate.workspace.link palette command, and the + // sidebar's 30s poll would ship to 100% of users regardless of the flag + // setting. (M1 in the consensus review.) + return Flag.ALTIMATE_WORKSPACE ? [...base, Workspace, WorkspaceSidebar] : base } // altimate_change end diff --git a/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx new file mode 100644 index 0000000000..da6fdddb3b --- /dev/null +++ b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx @@ -0,0 +1,128 @@ +// altimate_change - new file +// Right-pane sidebar tile that shows the workspace the current project +// directory is bound to (or "Not linked" with a hint). Reads from the local +// binding cache written by `../workspace.tsx` (post-scan dialog, on-demand +// picker, browser handoff). +// +// Deliberately read-only. All bind mutations live in workspace.tsx / link.ts; +// this tile just reflects state. +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" +import { createSignal, onCleanup, onMount, Show } from "solid-js" +import { readLocalBinding, type CachedBinding } from "@/altimate/workspace/state" +import { resolveWorkspaceWebUrl } from "@/altimate/workspace/browser-handoff" +import { AltimateApi } from "@/altimate/api/client" + +const id = "altimate:sidebar-workspace" + +/** Cache-file poll cadence. Longer than a "reactive" ideal but the cheapest + * option that does not require plumbing an event bus through the binding + * writers. Trade-off documented (m1 in the consensus review): a fresh bind + * surfaces within one interval instead of instantly; a mostly-idle CLI reads + * the small cache file twice per minute. In-flight guard below prevents + * overlap when the file grows / the disk is slow. */ +const POLL_MS = 30_000 + +/** Cached credential lookup — the API is a network round-trip candidate in + * the general case, but the credentials source here (local file) rarely + * changes within a single CLI process. We memoize the resolved manage-URL + * base per (apiUrl, tenant) pair for the life of the process; if the file + * changes mid-session, the binding cache invalidation (in state.ts) still + * catches it via its own (tenant, apiUrl) top-level scoping. */ +let cachedManageBase: { apiUrl: string; tenant: string; base: string | null } | null = null +async function resolveManageBase(): Promise { + try { + const creds = await AltimateApi.getCredentials() + if ( + cachedManageBase && + cachedManageBase.apiUrl === creds.altimateUrl && + cachedManageBase.tenant === creds.altimateInstanceName + ) { + return cachedManageBase.base + } + const url = resolveWorkspaceWebUrl(creds.altimateUrl, creds.altimateInstanceName) + const base = url ? url.toString().replace(/\/$/, "") : null + cachedManageBase = { apiUrl: creds.altimateUrl, tenant: creds.altimateInstanceName, base } + return base + } catch { + return null + } +} + +function View(props: { api: TuiPluginApi }) { + const theme = () => props.api.theme.current + const [binding, setBinding] = createSignal(null) + const [manageUrl, setManageUrl] = createSignal(null) + + let refreshInFlight = false + const refresh = async () => { + if (refreshInFlight) return + refreshInFlight = true + try { + const dir = props.api.state.path.directory + const b = await readLocalBinding(dir).catch(() => null) + setBinding(b) + if (!b) { + setManageUrl(null) + return + } + const base = await resolveManageBase() + setManageUrl(base ? `${base}/w/${b.datamateId}` : null) + } finally { + refreshInFlight = false + } + } + + onMount(() => { + void refresh() + const timer = setInterval(() => void refresh(), POLL_MS) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(timer as any)?.unref?.() + onCleanup(() => clearInterval(timer)) + }) + + return ( + + + Workspace + + + Not linked — run altimate-code link + + } + > + {(b) => ( + <> + {b().datamateName} + + {(u) => {u()}} + + + )} + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + // Below MCP (200) and above LSP (300) — workspace identity is high-signal + // when present, but not more useful than the connection status above. + order: 250, + slots: { + sidebar_content() { + return + }, + }, + }) +} + +const plugin: BuiltinTuiPlugin = { + id, + tui, +} + +export default plugin diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx new file mode 100644 index 0000000000..6b7380e85a --- /dev/null +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -0,0 +1,1169 @@ +// altimate_change start — fork TUI feature: Workspaces (pilot). +// +// Post-scan prompt to create or link a "Workspace" (server-side: a Datamate) +// to the current project, plus an on-demand "Link this project to a workspace" +// palette command. Server API lives in altimate-backend under +// /datamate-project-bindings/* (top-level router). Backend contract: +// +// POST /datamate-project-bindings/ create-and-bind (atomic) +// POST /datamate-project-bindings/bind attach to existing workspace +// PUT /datamate-project-bindings/by-remote atomic re-link (FOR UPDATE) +// GET /datamate-project-bindings/by-remote server-authoritative lookup +// +// Fork-owned plugin per docs/internal/2026-06-23-tui-fork-features-as-plugins-adr.md. +// Registered by ./index.ts's altimateTuiPlugins() aggregator; upstream +// packages/tui is not touched. Uses `api.ui.*`, `api.keymap.registerLayer`, +// `api.state.path.directory`, `api.kv` (persistent) — the real TuiPluginApi +// surface, not a made-up one (see codex round-2 report for the history). +// +// Trigger: the /altimate-workspace.postScan command is dispatched by the +// existing onboarding-telemetry.ts plugin's `tool.execute.after` hook when +// `project_scan` completes AND `AltimateApi.isConfigured()` returns true AND +// `Flag.ALTIMATE_WORKSPACE` is on. Dispatch travels via the existing +// `TuiEvent.CommandExecute` event bus. +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" +import { createHash } from "node:crypto" +import open from "open" +import { createSignal, onMount } from "solid-js" +import { + ConflictError, + ForbiddenError, + NotFoundError, + PreconditionFailedError, + WorkspaceApi, + type DatamateRef, + type MatchedIdentifier, + type ProjectBindingLookup, + type ProjectIdentifier, +} from "@/altimate/workspace/api-client" +import { + openWorkspaceBrowserHandoff, + resolveWorkspaceWebUrl, + type HandoffResult, +} from "@/altimate/workspace/browser-handoff" +import { + projectNameFromPath, + projectNameFromRemote, + resolveProjectIdentifier, +} from "@/altimate/workspace/detect" +import { readLocalBinding, recordApprovedBinding } from "@/altimate/workspace/state" +import { AltimateApi } from "@/altimate/api/client" +import { Log } from "@/altimate/util/log" + +const PLUGIN_ID = "altimate:workspace" + +const log = Log.create({ service: "altimate-workspace" }) + +/** True when the browser-based workspace-creation handoff is available for + * the current credentials (freemium only today). Wrapped so both the post-scan + * flow and the on-demand `altimate-code link` picker can hide the option + * consistently when the deployment isn't supported. */ +async function isBrowserHandoffAvailable(): Promise { + // Both credential calls can throw (corrupt JSON, schema drift, unresolved + // ``${env:...}`` reference). Callers use this in the sync arm of dialog + // rendering, so an unhandled rejection would take the TUI down. Fail + // closed — treat any credential error as "handoff unavailable". (CR cycle 6.) + try { + if (!(await AltimateApi.isConfigured().catch(() => false))) return false + const creds = await AltimateApi.getCredentials() + return resolveWorkspaceWebUrl(creds.altimateUrl, creds.altimateInstanceName) !== null + } catch { + return false + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Skip latch (TUI-only). Uses TuiPluginApi.kv — persistent across sessions +// via packages/tui/src/context/kv.tsx (state/kv.json). The `altimate link` +// subcommand deliberately bypasses this latch (it's user-initiated). +// ───────────────────────────────────────────────────────────────────────────── + +const SKIP_TTL_MS = 7 * 24 * 60 * 60 * 1000 +const KV_SKIP_PREFIX = "altimate.workspace.postScan.skip." + +/** (tenant, apiUrl) scope for the Skip latch — matches the local binding + * cache's top-level scoping. Without this a Skip in one Altimate account + * suppresses the post-scan prompt for the same project in every other + * account for 7 days. Sync-provided by the caller because ``recordSkip`` is + * invoked inside the dialog's synchronous ``onSelect`` handler. Null means + * "unscoped" (used only when credentials are unavailable). (cubic round 3.) */ +export interface LatchScope { + tenant: string + apiUrl: string +} + +/** Latch key from (tenant, apiUrl, primary identifier). Path-only projects + * also get a latch — sample-scaffold users are still users. */ +function skipKey(id: ProjectIdentifier, scope: LatchScope | null): string { + const primary = id.repoRemote ?? id.projectPath ?? "" + const scopeString = scope ? `${scope.tenant}|${scope.apiUrl}|` : "" + return ( + KV_SKIP_PREFIX + + createHash("sha1") + .update(scopeString + primary) + .digest("hex") + ) +} + +function isSkipActive( + api: TuiPluginApi, + id: ProjectIdentifier, + scope: LatchScope | null, + nowMs: number, +): boolean { + const rec = api.kv.get<{ skippedAt: number }>(skipKey(id, scope)) + if (!rec || typeof rec.skippedAt !== "number") return false + // Reject records timestamped in the future — a system-clock rewind after + // ``recordSkip`` would otherwise produce ``nowMs - rec.skippedAt < 0``, + // trivially below the 7-day TTL, and suppress the prompt indefinitely. + // Treat future timestamps as "corrupt, retry" so the next scan re-offers. + // (CodeRabbit cycle 6.) + const delta = nowMs - rec.skippedAt + if (delta < 0) return false + return delta < SKIP_TTL_MS +} + +function recordSkip( + api: TuiPluginApi, + id: ProjectIdentifier, + scope: LatchScope | null, + nowMs: number, +): void { + api.kv.set(skipKey(id, scope), { skippedAt: nowMs }) +} + +/** Best-effort ``LatchScope`` from the current CLI credentials. Returns null + * on any credential failure — the latch then falls back to an unscoped key. */ +async function currentLatchScope(): Promise { + try { + if (!(await AltimateApi.isConfigured().catch(() => false))) return null + const creds = await AltimateApi.getCredentials() + return { tenant: creds.altimateInstanceName, apiUrl: creds.altimateUrl } + } catch { + return null + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Dialog components — deliberate three-way selects; no LLM-generated copy. +// Every dialog closes via `api.ui.dialog.clear()` when the user picks a +// terminal action, so onboarding is never blocked by a stuck workspace prompt. +// ───────────────────────────────────────────────────────────────────────────── + +interface OfferProps { + api: TuiPluginApi + identifier: ProjectIdentifier + defaultName: string + suppressLatch?: boolean // altimate link on-demand skips the Skip latch + /** True when this deployment supports the browser-based workspace-creation + * handoff (i.e. ``resolveWorkspaceWebUrl`` returned non-null for the current + * credentials). Resolved by the caller so the dialog doesn't need to await + * on mount. When false, the "Set up in browser" option is hidden and the + * dialog falls back to the pre-browser-handoff behavior. */ + browserAvailable: boolean + /** (tenant, apiUrl) scope for the Skip latch. Resolved once by the caller + * so the sync ``onSelect`` handler can call ``recordSkip`` without a + * mid-render await. Null when creds are unavailable — latch falls back + * to unscoped. (cubic round 3.) */ + latchScope: LatchScope | null +} + +function OfferDialog(props: OfferProps) { + const identLabel = () => props.identifier.repoRemote ?? props.identifier.projectPath ?? "this project" + const options = [ + ...(props.browserAvailable + ? [ + { + title: "Set up in browser (recommended)", + value: "browser", + description: `Approve and name "${props.defaultName}" in the Altimate SaaS; the CLI links your project automatically.`, + }, + ] + : []), + { + title: "Create quick workspace here", + value: "create", + description: `Auto-named "${props.defaultName}" from this repo — no browser step. Configure integrations later in the SaaS.`, + }, + { + title: "Link to an existing workspace", + value: "link", + description: "Attach this project to a workspace you already own.", + }, + { + title: "Skip for now", + value: "skip", + description: props.suppressLatch ? "Close this prompt." : "Won't ask again for 7 days.", + }, + ] + const defaultValue = props.browserAvailable ? "browser" : "create" + return ( + { + if (option.value === "skip") { + if (!props.suppressLatch) + recordSkip(props.api, props.identifier, props.latchScope, Date.now()) + props.api.ui.dialog.clear() + return + } + if (option.value === "browser") { + void runBrowserHandoff(props.api, props.identifier, props.defaultName) + return + } + if (option.value === "create") { + // Local direct-create — the CLI-only fallback. The SaaS UI is the + // place to rename / configure; this branch establishes the binding + // without a browser round-trip. + void createAndBindInline(props.api, props.identifier, props.defaultName) + return + } + // link → picker (fresh-project attach path) + props.api.ui.dialog.replace(() => ( + + )) + }} + /> + ) +} + +/** Build the SaaS manage-workspace URL for a bound workspace. Deterministic + * from tenant + id, so any caller can construct it without an extra round-trip. + * Returns null when the current deployment isn't the freemium web (BYOK or + * unresolvable) — the confirmation dialog degrades to id-only in that case. */ +async function buildManageUrl(workspaceId: number): Promise { + try { + const creds = await AltimateApi.getCredentials() + const base = resolveWorkspaceWebUrl(creds.altimateUrl, creds.altimateInstanceName) + if (!base) return null + return `${base.toString().replace(/\/$/, "")}/w/${workspaceId}` + } catch { + return null + } +} + +interface LinkedProps { + api: TuiPluginApi + workspaceName: string + manageUrl: string | null + /** Verb for the title — "Linked" / "Re-linked" / "Created". Keeps the + * three success paths visually consistent while still labelling what + * just happened. */ + verb: "Linked" | "Re-linked" | "Created" +} + +/** Persistent confirmation card shown after a successful bind. Replaces the + * transient success toast so the user has an unmissable "yes, it worked" and + * a stable CTA back to the browser. Dismissable via Done or Esc. */ +function WorkspaceLinkedDialog(props: LinkedProps) { + const title = () => { + const suffix = props.manageUrl ? ` — ${props.manageUrl}` : "" + return `${props.verb} workspace "${props.workspaceName}"${suffix}` + } + const options = () => { + if (props.manageUrl) { + return [ + { + title: "Continue editing in browser", + value: "open", + description: "Open the workspace in your browser.", + }, + { title: "Done", value: "done", description: "Close this dialog." }, + ] + } + return [{ title: "Done", value: "done", description: "Close this dialog." }] + } + return ( + { + if (option.value === "open" && props.manageUrl) { + const url = props.manageUrl + // Guard before delegating to open() — a rogue manage_url with a + // non-http protocol would otherwise dispatch to an unrelated OS + // scheme handler. buildManageUrl only ever emits http(s) URLs from + // resolveWorkspaceWebUrl, but the guard survives future changes. + if (!isSafeHttpUrl(url)) { + props.api.ui.toast({ + variant: "warning", + message: `Refused to open a non-http URL: ${url}`, + duration: 15_000, + }) + } else { + open(url).catch(() => { + props.api.ui.toast({ + variant: "warning", + message: `Could not open browser. Copy this URL: ${url}`, + duration: 15_000, + }) + }) + } + } + props.api.ui.dialog.clear() + }} + /> + ) +} + +/** Show the persistent linked-confirmation dialog. Builds the manage URL + * best-effort; degrades gracefully on BYOK/unresolvable. */ +async function showLinkedConfirmation( + api: TuiPluginApi, + verb: LinkedProps["verb"], + workspaceId: number, + workspaceName: string, +): Promise { + const manageUrl = await buildManageUrl(workspaceId) + api.ui.dialog.replace(() => ( + + )) +} + +/** Post-scan / on-demand browser-handoff runner. Opens the SaaS approval + * modal, waits for the callback, and binds the current project to the + * returned workspace via the existing ``POST /bind`` endpoint. Every failure + * mode surfaces as a toast; the user can always fall back to another option + * by re-invoking the dialog. */ +async function runBrowserHandoff( + api: TuiPluginApi, + identifier: ProjectIdentifier, + projectName: string, +): Promise { + api.ui.dialog.clear() + api.ui.toast({ + variant: "info", + message: "Opening browser to set up your workspace — approve there, then check back here for the confirmation.", + }) + const result: HandoffResult = await openWorkspaceBrowserHandoff({ identifier, projectName }) + if (!result.ok) { + toastHandoffFailure(api, result) + return + } + // M6 in the consensus review: the browser window can stay open for up to + // 15 minutes. If the user signs out or switches tenant mid-flow, the + // WorkspaceApi client re-reads credentials on every call — so a callback + // validated for tenant A would then bind under tenant B, and workspace + // ids are tenant-schema-local (same integer, different workspace). Compare + // the credential fingerprint the handoff was validated against with the + // credentials we're about to bind under, and refuse if either drifted. + try { + const fresh = await AltimateApi.getCredentials() + if ( + fresh.altimateInstanceName !== result.credentials.tenant || + fresh.altimateUrl !== result.credentials.apiUrl + ) { + api.ui.toast({ + variant: "error", + message: `Your Altimate credentials changed while the browser was open (was ${result.credentials.tenant}, now ${fresh.altimateInstanceName}). Re-run to link this project.`, + duration: 15_000, + }) + return + } + } catch { + api.ui.toast({ + variant: "error", + message: "Lost Altimate credentials while the browser was open — sign in and re-run.", + }) + return + } + // Handoff succeeded and credentials are still consistent — bind the project + // to the returned workspace via the existing bind endpoint. Same code path + // as PickerDialog's attach mode. + try { + const res = await WorkspaceApi.bindExisting(result.workspaceId, identifier) + await recordApprovedBinding(api.state.path.directory, { + datamateId: res.binding.datamate_id, + datamateName: res.binding.datamate_name, + repoRemote: res.binding.repo_remote, + projectPath: res.binding.project_path, + linkedAt: Date.now(), + }) + await showLinkedConfirmation(api, "Linked", res.binding.datamate_id, res.binding.datamate_name) + } catch (err) { + if (err instanceof ConflictError) { + api.ui.toast({ + variant: "warning", + message: `This project is already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Run \`altimate-code link\` to change.`, + }) + } else if (err instanceof NotFoundError) { + api.ui.toast({ + variant: "error", + message: "Workspace not found — the tenant or workspace may have changed. Try again.", + }) + } else if (err instanceof ForbiddenError) { + api.ui.toast({ + variant: "error", + message: "Only the workspace owner can bind projects to it.", + }) + } else { + api.ui.toast({ + variant: "error", + message: err instanceof Error ? err.message : "Failed to bind workspace", + }) + } + } +} + +function toastHandoffFailure(api: TuiPluginApi, result: Extract): void { + switch (result.reason) { + case "unavailable": + // Should not happen if browserAvailable was checked, but guard anyway. + api.ui.toast({ + variant: "warning", + message: "Browser-based workspace setup isn't available for this deployment. Use \"Create quick workspace here\" instead.", + }) + break + case "not_configured": + api.ui.toast({ + variant: "error", + message: "Altimate credentials not configured — sign in first, then re-run.", + }) + break + case "timeout": + api.ui.toast({ + variant: "warning", + message: "Workspace setup timed out (15 min). Re-run when you're ready.", + }) + break + case "cancelled": + api.ui.toast({ + variant: "info", + message: "Workspace setup cancelled.", + }) + break + case "tenant_mismatch": + api.ui.toast({ + variant: "error", + message: result.message ?? "Workspace was set up under a different account than the CLI is signed into.", + }) + break + case "port_exhausted": + api.ui.toast({ + variant: "error", + message: result.message ?? "Local ports 7317-7325 all in use — free one and try again.", + }) + break + case "browser_open_failed": + api.ui.toast({ + variant: "error", + message: `Could not open browser. ${result.authorizeUrl ? `Open this URL manually: ${result.authorizeUrl}` : ""}`, + duration: 15_000, + }) + break + default: + api.ui.toast({ + variant: "error", + message: result.message ?? "Workspace setup failed.", + }) + } +} + +async function createAndBindInline( + api: TuiPluginApi, + identifier: ProjectIdentifier, + name: string, + /** When present, this project is already bound to another workspace. + * createAndBind succeeds but leaves the binding pointing at the OLD + * workspace; without this rebind step the new workspace is an orphaned + * (billable) SaaS resource the CLI knows nothing about (M2). */ + rebindFrom?: { expectedCurrentDatamateId: number; matchedBy: MatchedIdentifier }, +): Promise { + api.ui.dialog.clear() + let res: Awaited> + try { + res = await WorkspaceApi.createAndBind({ name, identifier }) + } catch (err) { + if (err instanceof ConflictError) { + api.ui.toast({ + variant: "warning", + message: `This project is already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Use the palette's "Link this project to a workspace" to change.`, + }) + } else { + api.ui.toast({ + variant: "error", + message: err instanceof Error ? err.message : "Failed to create workspace", + }) + } + return + } + + if (rebindFrom) { + // The atomic create-and-bind wrote a NEW binding for the new workspace, + // but the existing binding for THIS project's remote/path still points + // at the old workspace. Repoint via the matched-identifier rebind + // endpoint. If rebind fails, tell the user the workspace exists but + // the link didn't switch — do not silently orphan. + try { + await rebindByMatchedIdentifier({ + identifier, + targetDatamateId: res.datamate.id, + expectedCurrentDatamateId: rebindFrom.expectedCurrentDatamateId, + matchedBy: rebindFrom.matchedBy, + }) + } catch (err) { + api.ui.toast({ + variant: "error", + message: `Workspace "${res.datamate.name}" was CREATED but could not be linked to this project (${err instanceof Error ? err.message : String(err)}). Run \`altimate-code link\` to retry.`, + duration: 15_000, + }) + return + } + } + + // Post-success tail — this function is invoked fire-and-forget + // (``void createAndBindInline(...)``), so a bare rejection here would + // surface as an unhandled promise and terminate the TUI. Contain the + // fallout inside the function itself. ``recordApprovedBinding`` already + // swallows its own errors (state.ts is best-effort), but + // ``showLinkedConfirmation`` can reject on dialog-teardown races — the + // toast fallback keeps the user informed without taking the process down. + // (Kilo cycle 5.) + try { + await recordApprovedBinding(api.state.path.directory, { + datamateId: res.datamate.id, + datamateName: res.datamate.name, + repoRemote: res.binding.repo_remote, + projectPath: res.binding.project_path, + linkedAt: Date.now(), + }) + await showLinkedConfirmation(api, "Created", res.datamate.id, res.datamate.name) + } catch (err) { + // Log the failure so a regression in ``showLinkedConfirmation`` doesn't + // vanish silently, then fall back to a plain toast. Previously ``void err`` + // discarded the diagnostic — Kilo cycle 6 called it out. + log.warn("workspace post-create confirmation failed", { err: String(err) }) + api.ui.toast({ + variant: "info", + message: `Workspace "${res.datamate.name}" created and linked.`, + }) + } +} + +/** True when the URL parses and its protocol is exactly ``http:`` or ``https:``. + * Used before handing a server-supplied URL to ``open()`` (which would otherwise + * dispatch to whatever OS scheme handler matches the protocol). Kept exported + * as a top-level helper because both ``showLinkedConfirmation`` (below) and + * the on-demand link paths need the same guard. */ +function isSafeHttpUrl(url: string): boolean { + try { + const u = new URL(url) + return u.protocol === "http:" || u.protocol === "https:" + } catch { + return false + } +} + +/** Pick the rebind endpoint that matches which identifier the pre-check + * resolved the binding on. Shared with cli/cmd/link.ts through duplicated + * code (M3) — the modules deliberately don't cross-import so the CLI + * subcommand stays self-contained. */ +async function rebindByMatchedIdentifier(input: { + identifier: ProjectIdentifier + targetDatamateId: number + expectedCurrentDatamateId: number + matchedBy: MatchedIdentifier +}) { + if (input.matchedBy === "remote" && input.identifier.repoRemote) { + return WorkspaceApi.rebindByRemote({ + remote: input.identifier.repoRemote, + targetDatamateId: input.targetDatamateId, + expectedCurrentDatamateId: input.expectedCurrentDatamateId, + }) + } + if (input.matchedBy === "path" && input.identifier.projectPath) { + return WorkspaceApi.rebindByPath({ + projectPath: input.identifier.projectPath, + targetDatamateId: input.targetDatamateId, + expectedCurrentDatamateId: input.expectedCurrentDatamateId, + }) + } + throw new Error( + `Cannot rebind — pre-check matched on ${input.matchedBy} but that field is not present on the current project identifier.`, + ) +} + +interface AlreadyLinkedProps { + api: TuiPluginApi + identifier: ProjectIdentifier + workspaceName: string + workspaceId: number + hasDrift: boolean + driftedWas?: string | null + unverified?: boolean + /** Which identifier arm resolved the binding — remote-matched projects + * rebind via ``/by-remote``, path-matched via ``/by-path``. Not the same + * as ``identifier.repoRemote`` / ``identifier.projectPath``, which reflect + * the CURRENT project, not the binding's origin. Threaded into PickerDialog + * so a re-link picks the correct endpoint. (M3) */ + matchedBy: MatchedIdentifier +} + +function AlreadyLinkedDialog(props: AlreadyLinkedProps) { + // Title carries the primary context (workspace name + drift/unverified hint) + // since DialogSelect doesn't take a top-level description block. Verbose but + // it puts the critical info in the user's field of view before they pick. + const title = () => { + const parts: string[] = [`Project is linked to workspace "${props.workspaceName}"`] + const now = props.identifier.repoRemote ?? props.identifier.projectPath + if (props.hasDrift && props.driftedWas) parts.push(`(was ${props.driftedWas}, now ${now})`) + if (props.unverified) parts.push("(⚠ unverified — server unreachable, showing cached value)") + return parts.join(" ") + } + return ( + { + if (option.value === "attach" || option.value === "skip") { + props.api.ui.dialog.clear() + return + } + // relink → picker with the current workspace id as expected_current so + // a concurrent re-link by another client 412s cleanly. matchedBy + // determines which rebind endpoint the picker will call (M3). + props.api.ui.dialog.replace(() => ( + + )) + }} + /> + ) +} + +interface PickerProps { + api: TuiPluginApi + identifier: ProjectIdentifier + mode: "attach" | "relink" + expectedCurrentDatamateId?: number + /** Set for ``mode: "relink"`` — which identifier arm the pre-check matched + * on so we pick the correct rebind endpoint. (M3) */ + matchedBy?: MatchedIdentifier +} + +function PickerDialog(props: PickerProps) { + const [datamates, setDatamates] = createSignal(null) + const [loadError, setLoadError] = createSignal(null) + + onMount(async () => { + try { + const list = await WorkspaceApi.listDatamates() + setDatamates(list) + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to load workspaces" + setLoadError(msg) + props.api.ui.toast({ variant: "error", message: msg }) + props.api.ui.dialog.clear() + } + }) + + async function pick(datamateId: number) { + try { + if (props.mode === "attach") { + const res = await WorkspaceApi.bindExisting(datamateId, props.identifier) + await recordApprovedBinding(props.api.state.path.directory, { + datamateId: res.binding.datamate_id, + datamateName: res.binding.datamate_name, + repoRemote: res.binding.repo_remote, + projectPath: res.binding.project_path, + linkedAt: Date.now(), + }) + await showLinkedConfirmation( + props.api, + "Linked", + res.binding.datamate_id, + res.binding.datamate_name, + ) + return + } else { + // Rebind: pick the endpoint that matches which identifier the + // pre-check RESOLVED the binding on — not what the current identifier + // happens to carry. A repo whose remote was renamed still has its + // binding under its path; rebindByRemote against the new remote would + // 404 with no repair path from the TUI. (M3) + if (!props.matchedBy || !props.expectedCurrentDatamateId) { + throw new Error("relink picker opened without matchedBy / expectedCurrentDatamateId") + } + const res = await rebindByMatchedIdentifier({ + identifier: props.identifier, + targetDatamateId: datamateId, + expectedCurrentDatamateId: props.expectedCurrentDatamateId, + matchedBy: props.matchedBy, + }) + await recordApprovedBinding(props.api.state.path.directory, { + datamateId: res.binding.datamate_id, + datamateName: res.binding.datamate_name, + repoRemote: res.binding.repo_remote, + projectPath: res.binding.project_path, + linkedAt: Date.now(), + }) + await showLinkedConfirmation( + props.api, + "Re-linked", + res.binding.datamate_id, + res.binding.datamate_name, + ) + return + } + } catch (err) { + // Surface as a toast so the user sees the specific failure. Dialog closes + // either way — the user can re-invoke via /altimate.workspace.link. + let msg: string + if (err instanceof ConflictError) { + msg = `Already linked to "${err.detail.existing_datamate_name ?? "another workspace"}" — pick Re-link from the offer if you want to move it.` + } else if (err instanceof PreconditionFailedError) { + msg = "Someone else re-linked this project — reload and try again." + } else if (err instanceof NotFoundError) { + msg = "No existing binding for this remote to re-link. Try Create/Link from the offer instead." + } else if (err instanceof ForbiddenError) { + msg = "Only the workspace owner can attach projects to it." + } else { + msg = err instanceof Error ? err.message : "Failed to link workspace" + } + props.api.ui.toast({ variant: "error", message: msg }) + props.api.ui.dialog.clear() + } + } + + // While loading (or on error before dialog closes), render a placeholder + // row the user can dismiss with Enter. NOTE: DialogSelect's ``filtered()`` + // drops rows with ``disabled: true`` (packages/tui/src/ui/dialog-select.tsx), + // so the placeholder MUST be rendered without that flag — otherwise the + // picker shows an empty list, hiding both the loading state and the + // "no workspaces yet" hint. Selection is dispatched to ``value === -1`` + // in ``onSelect`` below and simply clears the dialog. (Kilo cycle 6.) + const options = () => { + const list = datamates() + if (!list) return [{ title: "Loading workspaces...", value: -1 }] + if (list.length === 0) + return [ + { + title: "No workspaces yet — cancel and pick 'Create a new workspace' instead.", + value: -1, + }, + ] + return list.map((dm: DatamateRef) => ({ title: dm.name, value: dm.id })) + } + + return ( + + title={props.mode === "attach" ? "Link to workspace" : "Re-link to workspace"} + options={options()} + onSelect={(option) => { + if (option.value === -1) { + props.api.ui.dialog.clear() + return + } + void pick(option.value) + }} + /> + ) +} + +// Sentinel value for the "+ Create a new workspace" row in the on-demand +// picker. Negative so it can't collide with any real datamate id (SERIAL PK). +const CREATE_NEW_SENTINEL = -2 + +interface OnDemandPickerProps { + api: TuiPluginApi + identifier: ProjectIdentifier + currentlyLinkedDatamateId?: number + currentlyLinkedDatamateName?: string + /** Which identifier arm the pre-check matched on. Required to pick the + * correct rebind endpoint when the user swaps workspaces or picks Create + * on an already-linked project. (M3) */ + matchedBy?: MatchedIdentifier + defaultName: string +} + +/** Picker-first flow for the on-demand `altimate.workspace.link` command. + * + * Skips the Create/Link/Skip funnel — the user already opted in by invoking + * the palette. Immediately lists workspaces; currently-linked one is marked; + * "+ Create a new workspace" is the first row. No Skip option (user chose to + * be here). Auto-names any new workspace from the git repo. */ +function OnDemandPickerDialog(props: OnDemandPickerProps) { + const [datamates, setDatamates] = createSignal(null) + + onMount(async () => { + try { + const list = await WorkspaceApi.listDatamates() + setDatamates(list) + } catch (err) { + props.api.ui.toast({ + variant: "error", + message: err instanceof Error ? err.message : "Failed to load workspaces", + }) + props.api.ui.dialog.clear() + } + }) + + const options = () => { + const list = datamates() + // No ``disabled: true`` — DialogSelect filters those out (Kilo cycle 6). + if (!list) return [{ title: "Loading workspaces...", value: -1 }] + // Deliberately short titles + hint in description so the dialog's narrow + // width doesn't truncate either. The "●" marker stays in the title (single + // char, cheap) so the currently-linked row is scannable at a glance. + return [ + { + title: "+ Create a new workspace", + value: CREATE_NEW_SENTINEL, + description: `Auto-named "${props.defaultName}" from this repo — rename in the SaaS.`, + }, + ...list.map((dm) => ({ + title: dm.id === props.currentlyLinkedDatamateId ? `● ${dm.name}` : ` ${dm.name}`, + value: dm.id, + description: dm.id === props.currentlyLinkedDatamateId ? "currently linked to this project" : undefined, + })), + ] + } + + return ( + + title="Link this project to a workspace" + options={options()} + current={props.currentlyLinkedDatamateId ?? CREATE_NEW_SENTINEL} + onSelect={(option) => { + if (option.value === -1) { + props.api.ui.dialog.clear() + return + } + if (option.value === CREATE_NEW_SENTINEL) { + // If already linked, thread the pre-check outcome so createAndBind + // is followed by a rebind — otherwise the new workspace is a real + // (billable) SaaS resource left orphaned while the project is + // still bound to the OLD workspace. (M2) + const rebindFrom = + props.currentlyLinkedDatamateId !== undefined && props.matchedBy + ? { + expectedCurrentDatamateId: props.currentlyLinkedDatamateId, + matchedBy: props.matchedBy, + } + : undefined + void createAndBindInline(props.api, props.identifier, props.defaultName, rebindFrom) + return + } + // Picked an existing workspace. + if (option.value === props.currentlyLinkedDatamateId) { + // No-op — user picked the workspace this project is already linked to. + props.api.ui.toast({ + variant: "info", + message: `Already linked to "${props.currentlyLinkedDatamateName}" — nothing changed.`, + }) + props.api.ui.dialog.clear() + return + } + const existing = + props.currentlyLinkedDatamateId !== undefined && props.matchedBy + ? { datamateId: props.currentlyLinkedDatamateId, matchedBy: props.matchedBy } + : undefined + void bindOrRebindInline(props.api, props.identifier, option.value, existing) + }} + /> + ) +} + +async function bindOrRebindInline( + api: TuiPluginApi, + identifier: ProjectIdentifier, + targetDatamateId: number, + /** Pre-check outcome. Absent means "not linked / pre-check missed" and + * we call bindExisting; present means "linked" and we rebind via the + * matched-identifier endpoint (M3). */ + existing: { datamateId: number; matchedBy: MatchedIdentifier } | undefined, +): Promise { + api.ui.dialog.clear() + const isRebind = existing !== undefined + try { + const res = await (async () => { + if (existing) { + return rebindByMatchedIdentifier({ + identifier, + targetDatamateId, + expectedCurrentDatamateId: existing.datamateId, + matchedBy: existing.matchedBy, + }) + } + return WorkspaceApi.bindExisting(targetDatamateId, identifier) + })() + await recordApprovedBinding(api.state.path.directory, { + datamateId: res.binding.datamate_id, + datamateName: res.binding.datamate_name, + repoRemote: res.binding.repo_remote, + projectPath: res.binding.project_path, + linkedAt: Date.now(), + }) + await showLinkedConfirmation( + api, + isRebind ? "Re-linked" : "Linked", + res.binding.datamate_id, + res.binding.datamate_name, + ) + } catch (err) { + let msg: string + if (err instanceof ConflictError) { + msg = `Already linked to "${err.detail.existing_datamate_name ?? "another workspace"}".` + } else if (err instanceof PreconditionFailedError) { + msg = "Someone else re-linked this project — reload and try again." + } else if (err instanceof NotFoundError) { + msg = "No existing binding to re-link. Try again." + } else if (err instanceof ForbiddenError) { + msg = "Only the workspace owner can attach projects to it." + } else { + msg = err instanceof Error ? err.message : "Failed to link workspace" + } + api.ui.toast({ variant: "error", message: msg }) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Flow orchestrators. +// +// `runFlow` — the post-scan trigger flow. Three-way Create/Link/Skip funnel +// for a user we're prompting FROM ZERO (they haven't opted in). Skip is a +// first-class outcome; Create branch auto-names. +// +// `runOnDemandPicker` — the palette / `/altimate.workspace.link` flow. User +// already opted in by invoking, so no Skip. Fetches the workspace list + +// pre-check binding, opens the picker with the currently-linked one marked. +// ───────────────────────────────────────────────────────────────────────────── + +async function runOnDemandPicker(api: TuiPluginApi, directory: string): Promise { + const identifier = resolveProjectIdentifier(directory) + // Pre-check for the currently-linked marker. Failures are non-fatal — we + // still show the picker without the "(currently linked here)" annotation. + let existing: ProjectBindingLookup | null = null + try { + existing = await WorkspaceApi.getBindingForProject(identifier) + } catch (err) { + log.warn("on-demand picker pre-check failed", { + err: err instanceof Error ? err.message : String(err), + }) + } + const defaultName = identifier.repoRemote + ? projectNameFromRemote(identifier.repoRemote) + : projectNameFromPath(identifier.projectPath) + api.ui.dialog.replace(() => ( + + )) +} + +async function runFlow( + api: TuiPluginApi, + directory: string, + opts: { suppressLatch?: boolean } = {}, +): Promise { + const identifier = resolveProjectIdentifier(directory) + // Resolve latch scope ONCE — passed to isSkipActive here + threaded into + // OfferDialog so its sync onSelect can call recordSkip without awaiting. + // (cubic round 3.) + const latchScope = await currentLatchScope() + // Path is always populated by resolveProjectIdentifier — projects without a + // git remote (sample dbt scaffolds, scratch dirs) still get a binding offer. + if (!opts.suppressLatch && isSkipActive(api, identifier, latchScope, Date.now())) { + log.info("workspace prompt suppressed by 7-day Skip latch", { + identifier: identifier.repoRemote ?? identifier.projectPath, + }) + return + } + + const defaultName = identifier.repoRemote + ? projectNameFromRemote(identifier.repoRemote) + : projectNameFromPath(identifier.projectPath) + + // Whether the browser-based handoff is available for this deployment. The + // OfferDialog hides the "Set up in browser" option when false, silently + // falling back to the pre-browser-handoff behavior. Compute here (once, + // async) so the dialog itself stays sync. + const browserAvailable = await isBrowserHandoffAvailable() + + let serverBinding: ProjectBindingLookup | null | undefined + try { + serverBinding = await WorkspaceApi.getBindingForProject(identifier) + } catch (err) { + log.warn("workspace pre-check server call failed, falling back to local cache", { + err: err instanceof Error ? err.message : String(err), + }) + serverBinding = undefined + } + + if (serverBinding) { + // Warm the local cache so an offline follow-up render is consistent. + await recordApprovedBinding(directory, { + datamateId: serverBinding.datamate.id, + datamateName: serverBinding.datamate.name, + repoRemote: serverBinding.binding.repo_remote, + projectPath: serverBinding.binding.project_path, + linkedAt: Date.now(), + }) + // Drift = the identifier the server matched on doesn't equal the + // corresponding identifier this project currently has. E.g. we matched + // on remote but the current remote differs from what the binding + // stored — the repo was renamed / remote swapped. The dialog surfaces + // this so the user isn't silently attached to a stale binding. (M3) + const boundIdent = + serverBinding.matchedBy === "remote" + ? serverBinding.binding.repo_remote + : serverBinding.binding.project_path + const currentIdent = + serverBinding.matchedBy === "remote" ? identifier.repoRemote : identifier.projectPath + const hasDrift = boundIdent != null && currentIdent != null && boundIdent !== currentIdent + api.ui.dialog.replace(() => ( + + )) + return + } + + if (serverBinding === null) { + // Server confirmed unbound → offer create-or-link. + api.ui.dialog.replace(() => ( + + )) + return + } + + // Server unreachable — fall back to the local cache (marked as unverified). + const local = await readLocalBinding(directory) + if (local) { + // Prefer whichever identifier the cache remembers as populated. Same + // ordering as the server-side pre-check: remote first, path fallback. + const cachedMatchedBy: MatchedIdentifier = local.repoRemote ? "remote" : "path" + const cachedIdent = local.repoRemote ?? local.projectPath ?? "" + const currentIdent = + cachedMatchedBy === "remote" ? identifier.repoRemote : identifier.projectPath + const hasDrift = cachedIdent !== "" && currentIdent != null && cachedIdent !== currentIdent + api.ui.dialog.replace(() => ( + + )) + return + } + // No local cache either → offer, but flag the server-unreachable state so the + // user can decide whether to proceed. + api.ui.toast({ + variant: "warning", + message: "Could not reach the Altimate workspace service — pre-check skipped.", + }) + api.ui.dialog.replace(() => ( + + )) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Plugin registration +// ───────────────────────────────────────────────────────────────────────────── + +/** Report a fire-and-forget flow failure. The keymap ``run()`` callbacks + * discard the returned promise with ``void``, so any rejection from + * ``recordApprovedBinding`` / ``readLocalBinding`` / anything else awaited + * inside would otherwise surface as an unhandled rejection and terminate + * the TUI process. Log + surface a toast so the user knows the workspace + * flow bailed. (CR round 2.) */ +function reportFlowFailure(api: TuiPluginApi, err: unknown): void { + log.error("workspace flow failed", { err: err instanceof Error ? err.message : String(err) }) + api.ui.toast({ + variant: "error", + message: "Workspace setup failed — see the CLI log for details.", + }) +} + +const tui: TuiPlugin = async (api) => { + api.keymap.registerLayer({ + commands: [ + { + name: "altimate.workspace.postScan", + title: "Post-scan workspace prompt", + category: "Altimate", + namespace: "internal", + run() { + runFlow(api, api.state.path.directory).catch((err) => reportFlowFailure(api, err)) + }, + }, + { + name: "altimate.workspace.link", + title: "Link this project to a workspace", + category: "Altimate", + namespace: "palette", + run() { + // User-initiated → jump straight to picker (currently-linked marked, + // "+ Create new" as the first row). No Skip funnel — they invoked. + runOnDemandPicker(api, api.state.path.directory).catch((err) => + reportFlowFailure(api, err), + ) + }, + }, + ], + }) +} + +export default { id: PLUGIN_ID, tui } satisfies BuiltinTuiPlugin + +// Exported for unit tests only. The shared logic (WorkspaceApi, cache, detect, +// project-name) lives in `@/altimate/workspace/*` and should be tested there; +// the plugin owns just the TUI-specific latch semantics. +export { isSkipActive, recordSkip } +// altimate_change end diff --git a/packages/opencode/test/altimate/plugin/workspace.test.ts b/packages/opencode/test/altimate/plugin/workspace.test.ts new file mode 100644 index 0000000000..02172c67e7 --- /dev/null +++ b/packages/opencode/test/altimate/plugin/workspace.test.ts @@ -0,0 +1,297 @@ +// altimate_change - new file +// Unit coverage for the pure-logic pieces of the workspace TuiPlugin +// (packages/opencode/src/plugin/tui/altimate/workspace.tsx). The JSX +// components and the AltimateApi credential fetch are not exercised here — +// they need a running TUI harness and are covered by the manual smoke plan. +// This file focuses on the deterministic layer: URL parsing, git detection, +// state read/write + chmod, latch semantics, and error classification. +import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test" +import { existsSync, mkdirSync, rmSync, statSync } from "node:fs" +import path from "node:path" +import os from "node:os" + +// Redirect Global.Path.state BEFORE importing the module under test so its +// module-level cachePath() resolves inside the sandbox. Restore the original +// XDG_STATE_HOME in afterAll so parallel test files aren't polluted by our +// process-scoped tempdir. (CR round 2 — test isolation.) +const ORIGINAL_XDG_STATE_HOME = process.env.XDG_STATE_HOME +const SANDBOX = path.join(os.tmpdir(), `altimate-workspace-test-${process.pid}-${Date.now()}`) +mkdirSync(path.join(SANDBOX, "state"), { recursive: true }) +process.env.XDG_STATE_HOME = path.join(SANDBOX, "state") +afterAll(() => { + if (ORIGINAL_XDG_STATE_HOME === undefined) delete process.env.XDG_STATE_HOME + else process.env.XDG_STATE_HOME = ORIGINAL_XDG_STATE_HOME + try { + rmSync(SANDBOX, { recursive: true, force: true }) + } catch { + /* best effort */ + } +}) + +const { isSkipActive, recordSkip } = await import( + "../../../src/plugin/tui/altimate/workspace" +) +const { projectNameFromRemote, detectProjectRemote } = await import( + "../../../src/altimate/workspace/detect" +) +const { cachePath, readLocalBinding, recordApprovedBinding } = await import( + "../../../src/altimate/workspace/state" +) + +// Stub AltimateApi.getCredentials / isConfigured — used by readLocalBinding +// and recordApprovedBinding for tenant/apiUrl scoping. Re-import allows +// per-test override of the module state. +import { AltimateApi } from "../../../src/altimate/api/client" +const originalIsConfigured = AltimateApi.isConfigured +const originalGetCreds = AltimateApi.getCredentials +type Creds = Awaited> +function stubCreds(tenant: string, apiUrl: string) { + ;(AltimateApi as unknown as { isConfigured: () => Promise }).isConfigured = async () => true + ;(AltimateApi as unknown as { getCredentials: () => Promise }).getCredentials = async () => + ({ + altimateInstanceName: tenant, + altimateUrl: apiUrl, + altimateApiKey: "dummy", + }) as Creds +} +function unstubCreds() { + ;(AltimateApi as unknown as { isConfigured: typeof originalIsConfigured }).isConfigured = originalIsConfigured + ;(AltimateApi as unknown as { getCredentials: typeof originalGetCreds }).getCredentials = originalGetCreds +} + +// Minimal TuiKV shim for latch tests. Reads/writes are process-local, matching +// what the plugin uses via api.kv in production. +function makeKv(): { get: (k: string, fb?: T) => T; set: (k: string, v: unknown) => void } { + const store = new Map() + return { + get: (k: string, fb?: T): T => (store.has(k) ? (store.get(k) as T) : (fb as T)), + set: (k: string, v: unknown) => { + store.set(k, v) + }, + } +} + +afterEach(() => { + unstubCreds() + // Wipe the cache file between tests so scoping tests don't bleed state. + try { + rmSync(cachePath(), { force: true }) + } catch { + /* not created by every test */ + } +}) + +// ───────────────────────────────────────────────────────────────────────────── +// projectNameFromRemote +// ───────────────────────────────────────────────────────────────────────────── + +describe("projectNameFromRemote", () => { + test("extracts repo name from HTTPS remote", () => { + expect(projectNameFromRemote("https://github.com/foo/bar.git")).toBe("bar") + }) + test("extracts repo name from SSH-form remote", () => { + expect(projectNameFromRemote("git@github.com:foo/bar.git")).toBe("bar") + }) + test("handles remote without .git suffix", () => { + expect(projectNameFromRemote("https://github.com/foo/bar")).toBe("bar") + }) + test("handles trailing slash", () => { + expect(projectNameFromRemote("https://github.com/foo/bar/")).toBe("bar") + }) + test("falls back for empty-ish inputs", () => { + expect(projectNameFromRemote("")).toBe("workspace") + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// detectProjectRemote — thin wrapper over git; only assert graceful failure +// (the git-not-a-repo case) since happy paths would need a live repo fixture. +// ───────────────────────────────────────────────────────────────────────────── + +describe("detectProjectRemote", () => { + test("returns undefined when directory is not a git repo", () => { + // Cubic round 3 caught that "empty dir under SANDBOX" still shares + // ``os.tmpdir()``'s ancestor chain — if any ancestor is a git worktree, + // ``git remote get-url`` walks up and returns that repo's remote. Set + // ``GIT_CEILING_DIRECTORIES`` to stop the walk at SANDBOX so this test + // is deterministic regardless of where ``os.tmpdir()`` lives on the + // runner. + const emptyDir = path.join(SANDBOX, `empty-${Date.now()}`) + mkdirSync(emptyDir, { recursive: true }) + const prevCeiling = process.env.GIT_CEILING_DIRECTORIES + process.env.GIT_CEILING_DIRECTORIES = SANDBOX + try { + const result = detectProjectRemote(emptyDir) + expect(result).toBeUndefined() + } finally { + if (prevCeiling === undefined) delete process.env.GIT_CEILING_DIRECTORIES + else process.env.GIT_CEILING_DIRECTORIES = prevCeiling + } + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Local state: cache + chmod + tenant scoping +// ───────────────────────────────────────────────────────────────────────────── + +describe("workspace binding cache", () => { + beforeEach(() => { + stubCreds("acme", "https://api.acme.example.com") + }) + + test("records and reads back a binding for the same directory + tenant", async () => { + await recordApprovedBinding("/work/proj-a", { + datamateId: 42, + datamateName: "Marketing", + repoRemote: "git@github.com:acme/proj-a.git", + projectPath: "/work/proj-a", + linkedAt: 1_700_000_000_000, + }) + + const read = await readLocalBinding("/work/proj-a") + expect(read).not.toBeNull() + expect(read!.datamateId).toBe(42) + expect(read!.datamateName).toBe("Marketing") + }) + + test("chmods the cache file to 0o600 after write", async () => { + await recordApprovedBinding("/work/proj-a", { + datamateId: 1, + datamateName: "X", + repoRemote: "git@github.com:acme/x.git", + projectPath: "/work/proj-a", + linkedAt: 1, + }) + expect(existsSync(cachePath())).toBe(true) + const mode = statSync(cachePath()).mode & 0o777 + expect(mode).toBe(0o600) + }) + + test("returns null when the cached tenant differs from current credentials", async () => { + await recordApprovedBinding("/work/proj-a", { + datamateId: 42, + datamateName: "Marketing", + repoRemote: "git@github.com:acme/proj-a.git", + projectPath: "/work/proj-a", + linkedAt: 1, + }) + + // Switch account → the cached binding must not be surfaced. + unstubCreds() + stubCreds("other-tenant", "https://api.acme.example.com") + + const read = await readLocalBinding("/work/proj-a") + expect(read).toBeNull() + }) + + test("returns null when the cached apiUrl differs from current credentials", async () => { + await recordApprovedBinding("/work/proj-a", { + datamateId: 42, + datamateName: "Marketing", + repoRemote: "git@github.com:acme/proj-a.git", + projectPath: "/work/proj-a", + linkedAt: 1, + }) + + unstubCreds() + stubCreds("acme", "https://different-host.example.com") + + const read = await readLocalBinding("/work/proj-a") + expect(read).toBeNull() + }) + + test("returns null when directory has no cached binding", async () => { + await recordApprovedBinding("/work/proj-a", { + datamateId: 42, + datamateName: "Marketing", + repoRemote: "git@github.com:acme/proj-a.git", + projectPath: "/work/proj-a", + linkedAt: 1, + }) + const read = await readLocalBinding("/work/proj-b") + expect(read).toBeNull() + }) + + test("returns null when credentials are missing entirely", async () => { + unstubCreds() + ;(AltimateApi as unknown as { isConfigured: () => Promise }).isConfigured = async () => false + const read = await readLocalBinding("/work/proj-a") + expect(read).toBeNull() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Skip latch (TuiKV shim) +// ───────────────────────────────────────────────────────────────────────────── + +describe("Skip latch", () => { + const ident = { repoRemote: "git@github.com:acme/proj-a.git", projectPath: "/work/proj-a" } + const scope = { tenant: "acme", apiUrl: "https://api.acme.example.com" } + + test("no record → not active", () => { + const api = { kv: makeKv() } as any + expect(isSkipActive(api, ident, scope, Date.now())).toBe(false) + }) + + test("recorded within 7 days → active", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordSkip(api, ident, scope, now) + expect(isSkipActive(api, ident, scope, now + 6 * 24 * 60 * 60 * 1000)).toBe(true) + }) + + test("recorded past 7 days → not active", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordSkip(api, ident, scope, now) + expect(isSkipActive(api, ident, scope, now + 8 * 24 * 60 * 60 * 1000)).toBe(false) + }) + + test("boundary at exactly 7 days → not active (>= rejects)", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordSkip(api, ident, scope, now) + expect(isSkipActive(api, ident, scope, now + 7 * 24 * 60 * 60 * 1000)).toBe(false) + }) + + test("different remotes have independent latches", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordSkip( + api, + { repoRemote: "git@github.com:acme/one.git", projectPath: "/w/one" }, + scope, + now, + ) + expect( + isSkipActive( + api, + { repoRemote: "git@github.com:acme/two.git", projectPath: "/w/two" }, + scope, + now, + ), + ).toBe(false) + }) + + test("path-only projects (no remote) also get a latch — key derives from path", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + const pathOnly = { projectPath: "/scratch/sample-dbt" } + recordSkip(api, pathOnly, scope, now) + expect(isSkipActive(api, pathOnly, scope, now + 3 * 24 * 60 * 60 * 1000)).toBe(true) + // A different path is not affected. + expect(isSkipActive(api, { projectPath: "/scratch/other" }, scope, now)).toBe(false) + }) + + test("different tenant scopes are independent latches (cubic round 3)", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordSkip(api, ident, { tenant: "acme", apiUrl: "https://api.acme.example.com" }, now) + expect( + isSkipActive(api, ident, { tenant: "other", apiUrl: "https://api.acme.example.com" }, now), + ).toBe(false) + expect( + isSkipActive(api, ident, { tenant: "acme", apiUrl: "https://api.other.example.com" }, now), + ).toBe(false) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/browser-handoff.test.ts b/packages/opencode/test/altimate/workspace/browser-handoff.test.ts new file mode 100644 index 0000000000..a889d57443 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/browser-handoff.test.ts @@ -0,0 +1,272 @@ +// altimate_change - new file +// Unit coverage for the browser-based workspace-creation handoff. +// (packages/opencode/src/altimate/workspace/browser-handoff.ts.) +// +// Uses ``runHandoffWithOpener`` (dependency-injected browser-open callback) +// so tests fire a synthetic callback at the live loopback listener instead of +// launching a real browser. The listener itself binds to 127.0.0.1, walks +// 7317..7325, and processes real HTTP requests — this is genuine end-to-end +// coverage for the callback validation path. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { createServer } from "node:net" + +import { AltimateApi } from "../../../src/altimate/api/client" +import { + openWorkspaceBrowserHandoff, + resolveWorkspaceWebUrl, + runHandoffWithOpener, +} from "../../../src/altimate/workspace/browser-handoff" + +// ── credential stubbing ───────────────────────────────────────────────────── +const originalIsConfigured = AltimateApi.isConfigured +const originalGetCreds = AltimateApi.getCredentials +type Creds = Awaited> +function stubCreds(tenant: string, apiUrl: string) { + ;(AltimateApi as unknown as { isConfigured: () => Promise }).isConfigured = + async () => true + ;(AltimateApi as unknown as { getCredentials: () => Promise }).getCredentials = + async () => + ({ + altimateInstanceName: tenant, + altimateUrl: apiUrl, + altimateApiKey: "dummy", + }) as Creds +} +function unstubCreds() { + ;(AltimateApi as unknown as { isConfigured: typeof originalIsConfigured }).isConfigured = + originalIsConfigured + ;(AltimateApi as unknown as { getCredentials: typeof originalGetCreds }).getCredentials = + originalGetCreds +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +/** Parse the authorize URL the CLI wants to open; extract the loopback port + * and CSRF state so tests can fire the crafted callback at the right address. */ +function parseHandoffUrl(url: string): { port: number; state: string; redirect: string } { + const u = new URL(url) + const redirect = u.searchParams.get("redirect")! + const state = u.searchParams.get("state")! + const port = Number(new URL(redirect).port) + return { port, state, redirect } +} + +async function fireCallback(redirect: string, params: Record): Promise { + const target = new URL(redirect) + for (const [k, v] of Object.entries(params)) target.searchParams.set(k, v) + const res = await fetch(target.toString(), { method: "GET" }) + // Drain body so the connection can close and let the CLI's `close()` + // proceed without hanging on lingering sockets. + await res.text().catch(() => "") +} + +// ───────────────────────────────────────────────────────────────────────────── +// resolveWorkspaceWebUrl — the deployment-support gate +// ───────────────────────────────────────────────────────────────────────────── + +describe("resolveWorkspaceWebUrl", () => { + test("freemium API host resolves to .ws.myaltimate.com", () => { + const url = resolveWorkspaceWebUrl("https://api.myaltimate.com", "acme") + expect(url).not.toBeNull() + expect(url!.toString()).toBe("https://acme.ws.myaltimate.com/") + }) + + test("localhost API returns null (browser flow not supported in dev)", () => { + expect(resolveWorkspaceWebUrl("http://localhost:5001", "acme")).toBeNull() + }) + + test("enterprise API host returns null", () => { + expect(resolveWorkspaceWebUrl("https://acme.getaltimate.com", "acme")).toBeNull() + }) + + test("malformed URL returns null instead of throwing", () => { + expect(resolveWorkspaceWebUrl("not-a-url", "acme")).toBeNull() + expect(resolveWorkspaceWebUrl("", "acme")).toBeNull() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// openWorkspaceBrowserHandoff — pre-flight failures (do not open a browser) +// ───────────────────────────────────────────────────────────────────────────── + +describe("openWorkspaceBrowserHandoff pre-flight", () => { + afterEach(() => unstubCreds()) + + test("returns {unavailable} for localhost credentials", async () => { + stubCreds("acme", "http://localhost:5001") + const result = await openWorkspaceBrowserHandoff({ + identifier: { repoRemote: "git@github.com:acme/x.git", projectPath: "/x" }, + projectName: "x", + }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe("unavailable") + }) + + test("returns {not_configured} when credentials are missing", async () => { + ;(AltimateApi as unknown as { isConfigured: () => Promise }).isConfigured = + async () => false + const result = await openWorkspaceBrowserHandoff({ + identifier: { projectPath: "/x" }, + projectName: "x", + }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe("not_configured") + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// End-to-end via runHandoffWithOpener — real loopback, injected browser-open +// ───────────────────────────────────────────────────────────────────────────── + +describe("runHandoffWithOpener end-to-end", () => { + beforeEach(() => stubCreds("acme", "https://api.myaltimate.com")) + afterEach(() => unstubCreds()) + + test("happy path: valid callback resolves with workspaceId + tenant", async () => { + const result = await runHandoffWithOpener( + { + identifier: { repoRemote: "git@github.com:acme/x.git", projectPath: "/x" }, + projectName: "x", + }, + async (url) => { + const { state, redirect } = parseHandoffUrl(url) + await fireCallback(redirect, { workspace_id: "42", state, tenant: "acme" }) + }, + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.workspaceId).toBe(42) + expect(result.tenant).toBe("acme") + } + }) + + test("tenant mismatch is refused", async () => { + const result = await runHandoffWithOpener( + { identifier: { projectPath: "/x" }, projectName: "x" }, + async (url) => { + const { state, redirect } = parseHandoffUrl(url) + await fireCallback(redirect, { workspace_id: "42", state, tenant: "not-acme" }) + }, + ) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe("tenant_mismatch") + }) + + test("?error=cancelled callback resolves as {cancelled}", async () => { + const result = await runHandoffWithOpener( + { identifier: { projectPath: "/x" }, projectName: "x" }, + async (url) => { + const { state, redirect } = parseHandoffUrl(url) + await fireCallback(redirect, { state, error: "cancelled", tenant: "acme" }) + }, + ) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe("cancelled") + }) + + test("missing workspace_id in callback resolves as {error}", async () => { + const result = await runHandoffWithOpener( + { identifier: { projectPath: "/x" }, projectName: "x" }, + async (url) => { + const { state, redirect } = parseHandoffUrl(url) + await fireCallback(redirect, { state, tenant: "acme" }) + }, + ) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe("error") + }) + + test("invalid workspace_id (non-numeric) resolves as {error}", async () => { + const result = await runHandoffWithOpener( + { identifier: { projectPath: "/x" }, projectName: "x" }, + async (url) => { + const { state, redirect } = parseHandoffUrl(url) + await fireCallback(redirect, { workspace_id: "not-a-number", state, tenant: "acme" }) + }, + ) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe("error") + }) + + test("browser open failure resolves as {browser_open_failed} with authorizeUrl", async () => { + const result = await runHandoffWithOpener( + { identifier: { projectPath: "/x" }, projectName: "x" }, + async () => { + throw new Error("mock: no browser available") + }, + ) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toBe("browser_open_failed") + expect(result.authorizeUrl).toContain("/create-and-link") + expect(result.authorizeUrl).toContain("client=altimate-code") + expect(result.authorizeUrl).toContain("project_name=x") + } + }) + + test("URL includes project_name in query and project_remote/project_path in fragment", async () => { + // Per m6 in the consensus review: project_path + project_remote MUST NOT + // be sent as query params (they'd land in browser history, SaaS/CDN/WAF + // access logs, and REST-log aggregators). Move them to the URL fragment + // instead — same reason cli_context lives in the fragment. + let observed = "" + await runHandoffWithOpener( + { + identifier: { repoRemote: "git@github.com:acme/foo.git", projectPath: "/w/foo" }, + projectName: "foo", + }, + async (url) => { + observed = url + // fire callback so the flow doesn't hang for 15 min + const { state, redirect } = parseHandoffUrl(url) + await fireCallback(redirect, { workspace_id: "1", state, tenant: "acme" }) + }, + ) + const u = new URL(observed) + // project_name is a display-safe label — the SaaS approval screen + // renders it in the modal — so it stays in the query. + expect(u.searchParams.get("project_name")).toBe("foo") + // project_remote + project_path MUST NOT be in the query. + expect(u.searchParams.get("project_remote")).toBeNull() + expect(u.searchParams.get("project_path")).toBeNull() + // They live in the fragment instead. + const frag = new URLSearchParams(u.hash.replace(/^#/, "")) + expect(frag.get("project_remote")).toBe("git@github.com:acme/foo.git") + expect(frag.get("project_path")).toBe("/w/foo") + expect(u.pathname).toBe("/create-and-link") + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Port walk: a squatting listener on 7317 forces handoff to 7318+ +// ───────────────────────────────────────────────────────────────────────────── + +describe("port walk", () => { + beforeEach(() => stubCreds("acme", "https://api.myaltimate.com")) + afterEach(() => unstubCreds()) + + test("stale listener on 7317 forces handoff to 7318", async () => { + const squatter = createServer() + await new Promise((resolve, reject) => { + squatter.once("error", reject) + squatter.listen(7317, "127.0.0.1", () => resolve()) + }) + + try { + let observedPort = -1 + const result = await runHandoffWithOpener( + { identifier: { projectPath: "/x" }, projectName: "x" }, + async (url) => { + const { port, state, redirect } = parseHandoffUrl(url) + observedPort = port + await fireCallback(redirect, { workspace_id: "1", state, tenant: "acme" }) + }, + ) + expect(result.ok).toBe(true) + expect(observedPort).toBeGreaterThan(7317) + expect(observedPort).toBeLessThanOrEqual(7325) + } finally { + await new Promise((r) => squatter.close(() => r())) + } + }) +})