diff --git a/packages/opencode/src/altimate/integrations/backend.ts b/packages/opencode/src/altimate/integrations/backend.ts new file mode 100644 index 0000000000..2bef4ca818 --- /dev/null +++ b/packages/opencode/src/altimate/integrations/backend.ts @@ -0,0 +1,195 @@ +// altimate_change - new file +// +// Client for the workspace connections API of the Altimate backend: list, create, update and delete +// the caller's own connections, plus the integrations catalog. Credentials come from the existing +// Altimate login; the integrations-only environment variables point this client at another backend. +import { AltimateApi } from "../api/client" + +export interface BackendConfig { + apiUrl: string + tenant: string + token: string +} + +export class BackendError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string | null, + ) { + super(message) + this.name = "BackendError" + } +} + +/** A connection as the connections API returns it. */ +export interface Connection { + id: number + name: string + integration_id: string + config?: Record + is_valid: boolean + last_validated_at?: string | null + created_at?: string | null + updated_at?: string | null + is_owner?: boolean +} + +/** The login used for backend calls, or null when there is none. */ +export async function backendConfig(): Promise { + const env = { url: process.env.ALTIMATE_INTEGRATIONS_API_URL, tenant: process.env.ALTIMATE_INTEGRATIONS_TENANT, token: process.env.ALTIMATE_INTEGRATIONS_TOKEN } + if (env.url && env.tenant && env.token) return { apiUrl: env.url.replace(/\/+$/, ""), tenant: env.tenant, token: env.token } + const creds = await AltimateApi.getCredentials().catch((e) => { + if (e instanceof Error && /credentials not found/i.test(e.message)) return null + throw new Error(`Could not read your Altimate login (${e instanceof Error ? e.message : e}). Fix or remove the credentials file, then sign in again.`) + }) + const apiUrl = (creds?.altimateUrl || "").replace(/\/+$/, "") + const token = creds?.altimateApiKey || "" + const tenant = creds?.altimateInstanceName || "" + if (!apiUrl || !token || !tenant) return null + return { apiUrl, tenant, token } +} + +/** The entries of a catalog response: a bare list, or one wrapped in `items`, `integrations` or `data`. */ +export function catalogEntries(data: unknown): any[] { + const list = Array.isArray(data) ? data : isObject(data) ? (data.items ?? data.integrations ?? data.data ?? []) : [] + return Array.isArray(list) ? list : [] +} + +const FAILURE_TEXT: Record = { + 400: "The request was rejected", + 404: "The requested resource was not found", + 409: "The saved credential changed, reload and try again", + 422: "The connection details were not accepted", +} + +const isObject = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v) + +/** The backend's error code: `detail.code`, or the type of the first validation error. */ +export function failureCode(data: any): string | null { + const detail = data?.detail ?? data + if (Array.isArray(detail)) { + const first = detail.find(isObject) + return typeof first?.type === "string" ? first.type : null + } + return typeof detail?.code === "string" ? detail.code : null +} + +/** The backend's own message and code when it sends them, so the user sees why a call failed. */ +export function describeFailure(status: number, data: any): string { + const detail = data?.detail ?? data + const code = failureCode(data) + const listed = Array.isArray(detail) ? detail.filter(isObject).map((e) => (typeof e.msg === "string" ? e.msg : "")).filter(Boolean) : [] + const text = listed.length + ? listed.join("; ") + : typeof detail?.message === "string" && detail.message + ? detail.message + : typeof detail === "string" && detail + ? detail + : (FAILURE_TEXT[status] ?? `status ${status}`) + return code ? `${text} (${code})` : text +} + +async function call(cfg: BackendConfig, method: string, path: string, body?: unknown): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 20_000) + try { + const res = await fetch(`${cfg.apiUrl}${path}`, { + method, + signal: controller.signal, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${cfg.token}`, + "x-tenant": cfg.tenant, + ...AltimateApi.clientHeaders(), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) + const text = await res.text() + let data: any = null + let unreadable = false + try { + data = text ? JSON.parse(text) : null + } catch { + unreadable = true + } + // A success that cannot be read is not an empty answer: an empty list would read as "no connections". + if (res.ok && unreadable) throw new BackendError(`The Altimate API answered ${res.status} with a body that is not JSON`, res.status, null) + if (!res.ok) { + throw new BackendError(describeFailure(res.status, data), res.status, failureCode(data)) + } + return data as T + } catch (e) { + if (e instanceof BackendError) throw e + throw new BackendError(`Could not reach the Altimate API: ${e instanceof Error ? e.message : String(e)}`, 0, null) + } finally { + clearTimeout(timer) + } +} + +/** Every connection the caller can see, their own and those shared with them. */ +export async function listVisibleConnections(cfg: BackendConfig): Promise { + return connectionList(await call<{ connections?: Connection[] }>(cfg, "GET", "/connections/?include_shared=true")) +} +/** The list in a connections answer. An answer without one is an error: an empty list would read as "no connections". */ +function connectionList(res: { connections?: Connection[] } | null): Connection[] { + if (!Array.isArray(res?.connections)) throw new BackendError("The Altimate API answered without a list of connections", 200, null) + return res.connections +} +/** The caller's own connections only: nothing shared with them. */ +export const ownConnections = (all: Connection[]): Connection[] => all.filter((c) => c.is_owner !== false) +export async function listOwnConnections(cfg: BackendConfig): Promise { + return ownConnections(connectionList(await call<{ connections?: Connection[] }>(cfg, "GET", "/connections/?include_shared=false"))) +} +export const createConnection = (cfg: BackendConfig, body: { name: string; integration_id: string; config: Record }) => + call(cfg, "POST", "/connections/", body) +export const updateConnection = (cfg: BackendConfig, id: number, body: { config: Record }) => + call(cfg, "PATCH", `/connections/${id}`, body) +export const deleteConnection = (cfg: BackendConfig, id: number) => call(cfg, "DELETE", `/connections/${id}`) +/** The workspace integrations catalog: what each provider's web form asks for. */ +export const integrationsCatalog = (cfg: BackendConfig) => call(cfg, "GET", "/datamate_integrations/") + +/** One integration entry of a workspace, as the datamates API stores it. */ +export interface DatamateIntegrationEntry { + id: string + tools: Array<{ key: string }> + connection_id?: string | number | null + connection_ids?: string[] | null + [extra: string]: unknown +} +export interface DatamateRecord { + id: string + name: string + integrations: DatamateIntegrationEntry[] + /** False when the caller is not the workspace's owner: only the owner can change its integrations. */ + is_owner?: boolean + /** "private" keeps an attached connection to its owner; any other value lets the workspace's members use it. */ + privacy?: string +} + +/** A workspace with its integrations list. */ +export async function getDatamate(cfg: BackendConfig, id: string): Promise { + let d: any + try { + const res = await call(cfg, "GET", `/datamates/${encodeURIComponent(id)}/summary`) + d = res?.datamate ?? res + } catch (e) { + if (!(e instanceof BackendError) || e.status !== 404) throw e + // A backend without the summary route still lists its workspaces. The integrations list is replaced whole, so a listed workspace without one is not trusted. + const res = await call(cfg, "GET", "/datamates/") + const all = Array.isArray(res) ? res : (res?.datamates ?? res?.data ?? []) + d = Array.isArray(all) ? all.find((x: any) => String(x?.id) === id) : undefined + if (!d) throw e + if (!Array.isArray(d.integrations)) throw new BackendError("The Altimate API listed the workspace without its integrations", 200, null) + } + // An answer that is not a workspace record is an error: building the next list from it could drop the workspace's integrations. + if (!isObject(d) || (d.id == null && d.name == null && !("integrations" in d))) throw new BackendError("The Altimate API answered without the workspace record", 200, null) + // The list is required in the workspace record and written back whole, so a missing or null one is as malformed as a non-list. + if (!Array.isArray(d.integrations)) throw new BackendError("The Altimate API answered with workspace integrations that are not a list", 200, null) + return { ...d, id: String(d.id ?? id), name: d.name ?? "", integrations: d.integrations } +} +/** Replaces the workspace's whole integrations list. */ +export const updateDatamateIntegrations = (cfg: BackendConfig, id: string, integrations: DatamateIntegrationEntry[]) => + call(cfg, "PATCH", `/datamates/${encodeURIComponent(id)}`, { integrations }) + +export * as IntegrationsBackend from "./backend" diff --git a/packages/opencode/src/altimate/integrations/core.ts b/packages/opencode/src/altimate/integrations/core.ts new file mode 100644 index 0000000000..01a7b7769b --- /dev/null +++ b/packages/opencode/src/altimate/integrations/core.ts @@ -0,0 +1,1277 @@ +// altimate_change - new file +// +// One-click local integrations: detection of existing credentials, local storage and the sync of a +// credential to the caller's own workspace connection. Offline (mock or demo) the workspace side is +// simulated: nothing is sent anywhere, the request that would be made is recorded in a local file. +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { AsyncLocalStorage } from "node:async_hooks" +import childProcess, { spawnSync } from "node:child_process" +import { Flock } from "@opencode-ai/core/util/flock" +import { Global } from "../../global" +import { IntegrationsBackend as backend } from "./backend" +import { IntegrationsCredentials as creds } from "./credentials" +import { AltimateApi } from "../api/client" +import { resolveBinding, resolveBindingOutcome } from "../workspace/state" +import { workspaceLabel } from "../workspace/workspace-name" + +export type ProviderId = "github" | "jira" | "linear" +export const PROVIDER_IDS: ProviderId[] = ["github", "jira", "linear"] +export const PROVIDER_NAMES: Record = { github: "GitHub", jira: "Jira", linear: "Linear" } +export const INTEGRATION_IDS: Record = { github: "github", jira: "jira", linear: "linear" } + +export type SourceKind = "cli" | "env" | "mcp" | "mcp-remote" | "browser" +export interface Source { + kind: SourceKind + label: string +} +export type ProviderState = "connected" | "expired" | "rejected" | "detected" | "available" | "not_found" +export type SyncState = "synced" | "local" | "pending" | "blocked" + +export interface Detection { + id: ProviderId + source: Source + account: string | null + syncable: boolean + note: string | null +} + +export interface ProviderStatus { + id: ProviderId + name: string + state: ProviderState + source: Source | null + account: string | null + syncable: boolean + sync: SyncState + updatedAt: string | null + expiresAt: string | null + note: string | null + /** False for a connection made by hand in the web app: shown, but this tool does not manage it. */ + managed?: boolean + /** A credential found on this machine while the row shows a connection made by hand in the web app: it can still be connected here. */ + found?: { source: Source; account: string | null } +} + +export interface StatusResult { + workspace: WorkspaceInfo + providers: ProviderStatus[] + /** Why the workspace could not be read; the states shown then come from this machine and may be out of date. */ + workspaceUnchecked?: string +} + +export interface StoredProvider { + detection?: Detection + registered?: boolean + account?: string | null + source?: Source + synced?: boolean + updatedAt?: string + /** ISO time the access token stops working. Never a secret. */ + expiresAt?: string | null + /** Workspace connection id when the credential was saved to the workspace. */ + connectionId?: number | null + /** Credential form: non-secret values typed in but not yet connected. Secrets live in the secret store. */ + drafts?: Record + /** Credential form: the non-secret values of the connected credentials. */ + values?: Record + /** Credential form: names of the secret fields kept under `credentialKey`. */ + secretFields?: string[] + variant?: string + validatedAt?: string + /** Changes whenever the credential is saved, so a step prepared from an older save can tell it was replaced. */ + credentialStamp?: string + /** The last validation was refused by the provider. */ + rejected?: boolean + /** Kept on this machine while the provider could not be reached to check it. */ + unverified?: boolean +} +export interface Store { + blocked: Partial> + providers: Partial> +} + +const REJECTED_NOTE = "Token rejected, update it" +const REMOVED_NOTE = "The workspace connection was removed. Add the credentials again." +export const demoMode = () => process.env.ALTIMATE_INTEGRATIONS_DEMO === "1" +export const mockMode = () => process.env.ALTIMATE_INTEGRATIONS_MOCK === "1" || demoMode() +/** Backend login when real mode applies: signed in to Altimate and no mock switch set. */ +export async function realConfig(): Promise { + if (mockMode()) return null + return backend.backendConfig() +} +export const EXPIRY_SKEW_MS = 60_000 +/** A token at or past `expiresAt - 60s` counts as expired. */ +export function isExpired(expiresAt: string | null | undefined, now = Date.now()): boolean { + if (!expiresAt) return false + const at = Date.parse(expiresAt) + return !Number.isNaN(at) && at <= now + EXPIRY_SKEW_MS +} +export const dataDir = () => process.env.ALTIMATE_INTEGRATIONS_DIR || Global.Path.data +export const homeDir = () => process.env.ALTIMATE_INTEGRATIONS_HOME || os.homedir() +const statePath = () => path.join(dataDir(), "integrations.json") +const remotePath = () => path.join(dataDir(), "integrations-remote-mock.json") +const secretsPath = () => path.join(dataDir(), "integrations-secrets.json") +const KEYCHAIN_SERVICE = "altimate-code-integrations" +const useKeychain = () => process.platform === "darwin" && !process.env.ALTIMATE_INTEGRATIONS_DIR + +export function isProvider(value: string): value is ProviderId { + return (PROVIDER_IDS as string[]).includes(value) +} + +export function readJson(file: string, fallback: T): T { + try { + return JSON.parse(fs.readFileSync(file, "utf8")) as T + } catch { + return fallback + } +} +/** Like readJson, but only a missing file gives the fallback. A file that exists and cannot be read or parsed is an error: the next write would otherwise replace it with an empty one and lose the policy or secrets it held. */ +export function readJsonStrict(file: string, fallback: T): T { + let text: string + try { + text = fs.readFileSync(file, "utf8") + } catch (e) { + if ((e as NodeJS.ErrnoException)?.code === "ENOENT") return fallback + throw new Error(`Could not read ${file} (${e instanceof Error ? e.message : String(e)}). Fix or remove it, then run the command again.`) + } + const fail = (why: string) => new Error(`Could not read ${file} (${why}). Fix or remove it, then run the command again.`) + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch (e) { + throw fail(e instanceof Error ? e.message : String(e)) + } + if (typeof fallback === "object" && fallback !== null && !Array.isArray(fallback) && (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))) throw fail("expected a JSON object") + return parsed as T +} +/** Writes through a temp file in the same directory and renames it over the target, so a reader or a crash never sees half a file. The mode is set when the file is created; the temp file never outlives a failed write. */ +export function writeJson(file: string, value: unknown, mode = 0o666) { + fs.mkdirSync(path.dirname(file), { recursive: true }) + const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp` + const fd = fs.openSync(tmp, "wx", mode) + try { + try { + fs.writeFileSync(fd, JSON.stringify(value, null, 2)) + } finally { + fs.closeSync(fd) + } + fs.renameSync(tmp, file) + } catch (e) { + fs.rmSync(tmp, { force: true }) + throw e + } +} + +const lockScope = new AsyncLocalStorage() +/** A lock whose holder stopped refreshing it is taken over after this long; kept well under the shortest time a caller waits on the CLI, so a killed run does not block the next one. */ +const LOCK_STALE_MS = 12_000 +/** How long a call waits for the lock before it gives up; the plugin allows more than this plus the network budget of the call. */ +export const LOCK_WAIT_MS = 30_000 +/** Runs `fn` with other CLI processes kept out. The lock is held for everything `fn` does, including workspace network calls, not only local file reads and writes. Re-entrant within the running task. */ +export async function withLock(fn: () => T | Promise): Promise { + if (lockScope.getStore()) return fn() + return Flock.withLock("altimate-integrations", () => lockScope.run(true, async () => fn()), { dir: path.join(dataDir(), "locks"), staleMs: LOCK_STALE_MS, timeoutMs: LOCK_WAIT_MS }) +} +/** Loads the state, lets `fn` change it, and saves it, all under the lock. */ +export function updateStore(fn: (store: Store) => T): Promise { + return withLock(() => { + const store = loadStore() + const result = fn(store) + saveStore(store) + return result + }) +} + +export function loadStore(): Store { + const raw = readJsonStrict>(statePath(), {}) + return { blocked: raw.blocked ?? {}, providers: raw.providers ?? {} } +} +export function saveStore(store: Store) { + writeJson(statePath(), store, 0o600) +} + +// ---------------------------------------------------------------- secrets + +/** Secret-store account for a credential field; the draft and the connected copy are kept apart. */ +export const secretKey = (kind: "draft" | "cred", id: ProviderId, field: string) => `${kind}:${id}:${field}` +/** The fields a provider is known to hold as secrets, before the workspace catalog is read. */ +export const BUILTIN_SECRET_FIELDS: Record = { + github: ["github_token"], + jira: ["jiraApiToken", "jiraPatToken"], + linear: ["linearApiKey"], +} + +/** How long one `security` call may take; tests shorten it. */ +export const keychainLimits = { timeoutMs: 10_000 } + +interface SecurityResult { + status: number | null + stdout: string + stderr: string + error?: Error +} +/** Runs the macOS `security` tool without blocking the event loop, so the lock's refresh timer keeps firing while the keychain answers. Output is only collected when asked for; `input` goes to stdin. */ +function runSecurity(args: string[], opts: { input?: string; stdout?: boolean; stderr?: boolean } = {}): Promise { + return new Promise((resolve) => { + let out = "" + let err = "" + let settled = false + const done = (r: SecurityResult) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve(r) + } + let child: ReturnType + try { + child = childProcess.spawn("security", args, { + stdio: [opts.input !== undefined ? "pipe" : "ignore", opts.stdout ? "pipe" : "ignore", opts.stderr ? "pipe" : "ignore"], + }) + } catch (e) { + resolve({ status: null, stdout: "", stderr: "", error: e instanceof Error ? e : new Error(String(e)) }) + return + } + const timer = setTimeout(() => { + child.kill("SIGKILL") + done({ status: null, stdout: out, stderr: err, error: new Error("timed out") }) + }, keychainLimits.timeoutMs) + child.stdout?.setEncoding("utf8") + child.stdout?.on("data", (d: string) => (out += d)) + child.stderr?.setEncoding("utf8") + child.stderr?.on("data", (d: string) => (err += d)) + child.on("error", (e) => done({ status: null, stdout: out, stderr: err, error: e })) + child.on("close", (code) => done({ status: code, stdout: out, stderr: err })) + if (opts.input !== undefined) { + child.stdin?.on("error", () => {}) + child.stdin?.end(opts.input) + } + }) +} + +export async function storeSecret(id: string, secret: string) { + // A token is one line. A line break cannot be quoted for `security -i` either, so such a value is refused rather than parked in a file. + if (/[\r\n]/.test(secret)) throw new Error("The value is not a token: it contains a line break") + if (useKeychain()) { + if (!/^[A-Za-z0-9:._-]+$/.test(id)) throw new Error("The credential id is not valid") + // The secret goes to `security -i` on stdin so it never shows in the process list. On macOS it is kept in the keychain only; a failed write is an error, never a plaintext file. + const quote = (v: string) => `"${v.replace(/[\\"]/g, "\\$&")}"` + const r = await runSecurity(["-i"], { input: `add-generic-password -U -s ${quote(KEYCHAIN_SERVICE)} -a ${quote(id)} -w ${quote(secret)}\n` }) + if (r.status !== 0) throw new Error("Could not save the credential to the macOS keychain, so nothing was stored. Unlock the keychain and try again.") + return + } + await withLock(() => { + const all = readJsonStrict>(secretsPath(), {}) + all[id] = secret + writeJson(secretsPath(), all, 0o600) + }) +} +/** `security` exits with this when the item does not exist. */ +const KEYCHAIN_ITEM_NOT_FOUND = 44 +// Only the exit code is trusted: other failures (a missing keychain, exit 50) print similar "could not be found" text. +const keychainItemMissing = (r: SecurityResult) => r.status === KEYCHAIN_ITEM_NOT_FOUND +const keychainDetail = (r: SecurityResult) => (r.error ? `: ${r.error.message}` : r.status != null ? ` (exit ${r.status})` : "") + +export async function readSecret(id: string): Promise { + if (useKeychain()) { + const r = await runSecurity(["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", id, "-w"], { stdout: true }) + if (r.status === 0) return r.stdout.trim() + // Only a missing item means no secret: a locked, failing or timed-out keychain must not read as empty, or the caller would replace or delete what is stored. + if (!keychainItemMissing(r)) throw new Error(`Could not read a stored credential from the macOS keychain${keychainDetail(r)}. Unlock the keychain and try again.`) + } + return readJsonStrict>(secretsPath(), {})[id] +} +export async function deleteSecret(id: string) { + if (useKeychain()) { + const r = await runSecurity(["delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", id]) + if (r.status !== 0 && !keychainItemMissing(r)) throw new Error(`Could not remove a stored credential from the macOS keychain${keychainDetail(r)}`) + } + await withLock(() => { + const all = readJsonStrict>(secretsPath(), {}) + if (id in all) { + delete all[id] + writeJson(secretsPath(), all, 0o600) + } + }) +} +/** Removes every draft and connected secret kept for a provider's credential form, of every variant it can have; fields in `keep` keep their connected secret. */ +export async function purgeCredentialSecrets(id: ProviderId, rec?: StoredProvider, includeDrafts = true, keep: Set = new Set()) { + const catalog = await creds.catalogSecretFields(id) + await withLock(async () => { + for (const field of new Set([...BUILTIN_SECRET_FIELDS[id], ...catalog, ...(rec?.secretFields ?? [])])) { + if (includeDrafts) await deleteSecret(secretKey("draft", id, field)) + if (!keep.has(field)) await deleteSecret(secretKey("cred", id, field)) + } + }) +} + +/** Claude MCP server maps the user set up themselves: user level and this project's entry in ~/.claude.json. A repository's own `.mcp.json` is not among them, as whoever controls the repository can write it; it only signals presence in `detectFromClaudeConfigs`. */ +export function mcpServerMaps(home: string, cwd: string): { scope: "user" | "project"; servers: Record }[] { + const claude = readJson(path.join(home, ".claude.json"), {}) + return [ + { scope: "user", servers: claude.mcpServers ?? {} }, + { scope: "project", servers: claude.projects?.[cwd]?.mcpServers ?? {} }, + ] +} +export const providerMatchesMcp = (id: ProviderId, name: string, server: McpServer) => + MCP_MATCHERS[id].test([name, server.command, server.url, ...(server.args ?? [])].filter(Boolean).join(" ")) + +// ---------------------------------------------------------------- detection + +export interface McpServer { + command?: string + args?: string[] + url?: string + type?: string + env?: Record +} + +const MCP_MATCHERS: Record = { + github: /github/i, + jira: /atlassian|jira/i, + linear: /linear/i, +} + +const PROJECT_MCP_ORIGIN = ".mcp.json" + +/** Detections from parsed Claude MCP server maps. Pure: reads nothing from disk. */ +export function detectFromMcpServers(servers: Record, origin: string): Detection[] { + const found: Detection[] = [] + for (const [name, server] of Object.entries(servers ?? {})) { + if (!server || typeof server !== "object") continue + const haystack = [name, server.command, server.url, ...(server.args ?? [])].filter(Boolean).join(" ") + for (const id of PROVIDER_IDS) { + if (!MCP_MATCHERS[id].test(haystack)) continue + const remote = Boolean(server.url) || server.type === "http" || server.type === "sse" + const hasToken = creds.mcpEnvHasToken(id, server.env) + if (remote) { + found.push({ + id, + source: { kind: "mcp-remote", label: `Claude MCP (${name})` }, + account: null, + syncable: false, + note: "usable in Claude; connect in browser to sync", + }) + } else if (hasToken && origin === PROJECT_MCP_ORIGIN) { + found.push({ + id, + source: { kind: "mcp-remote", label: `Claude MCP (${name})` }, + account: null, + syncable: false, + note: "usable in Claude; its token is not read from a repository file, set it in your environment to sync", + }) + } else if (hasToken) { + found.push({ + id, + source: { kind: "mcp", label: `Claude MCP (${name})` }, + account: null, + syncable: true, + note: `token from ${origin}`, + }) + } + } + } + return found +} + +export function detectFromClaudeConfigs(home: string, cwd: string): Detection[] { + const out: Detection[] = [] + const claude = readJson(path.join(home, ".claude.json"), {}) + out.push(...detectFromMcpServers(claude.mcpServers ?? {}, "~/.claude.json")) + out.push(...detectFromMcpServers(claude.projects?.[cwd]?.mcpServers ?? {}, "~/.claude.json")) + try { + const project = JSON.parse(fs.readFileSync(path.join(cwd, ".mcp.json"), "utf8")) + out.push(...detectFromMcpServers(project.mcpServers ?? {}, PROJECT_MCP_ORIGIN)) + } catch {} + return out +} + +/** Reads `server` and `login` from a jira-cli config without a YAML dependency. */ +export function parseJiraCliConfig(text: string): { server?: string; login?: string } { + const pick = (key: string) => { + const m = text.match(new RegExp(`^${key}:\\s*['"]?([^'"\\n]+?)['"]?\\s*$`, "m")) + return m?.[1] + } + return { server: pick("server"), login: pick("login") } +} + +function onPath(bin: string): boolean { + const r = spawnSync(process.platform === "win32" ? "where" : "which", [bin], { stdio: "ignore", timeout: 2000 }) + return r.status === 0 +} + +function detectGithub(): Detection | undefined { + const auth = spawnSync("gh", ["auth", "status"], { stdio: "ignore", timeout: 5000 }) + if (auth.status === 0) { + const who = spawnSync("gh", ["api", "user", "--jq", ".login"], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }) + const login = who.status === 0 ? who.stdout.trim() : "" + return { id: "github", source: { kind: "cli", label: "gh CLI" }, account: login || null, syncable: true, note: null } + } + if (process.env.GITHUB_TOKEN || process.env.GH_TOKEN) { + return { id: "github", source: { kind: "env", label: process.env.GITHUB_TOKEN ? "GITHUB_TOKEN" : "GH_TOKEN" }, account: null, syncable: true, note: null } + } +} + +function detectJira(home: string): Detection | undefined { + try { + const cfg = parseJiraCliConfig(fs.readFileSync(path.join(home, ".config/.jira/.config.yml"), "utf8")) + if (cfg.server || cfg.login) + return { id: "jira", source: { kind: "cli", label: "jira-cli" }, account: cfg.login ?? null, syncable: true, note: cfg.server ? `site ${cfg.server}` : null } + } catch {} + if (process.env.JIRA_API_TOKEN) + return { id: "jira", source: { kind: "env", label: "JIRA_API_TOKEN" }, account: process.env.JIRA_USER ?? null, syncable: true, note: null } + if (onPath("acli")) return { id: "jira", source: { kind: "cli", label: "acli" }, account: null, syncable: true, note: null } +} + +function detectLinear(): Detection | undefined { + if (process.env.LINEAR_API_KEY) + return { id: "linear", source: { kind: "env", label: "LINEAR_API_KEY" }, account: null, syncable: true, note: null } + if (onPath("linear")) return { id: "linear", source: { kind: "cli", label: "linear CLI" }, account: null, syncable: true, note: null } +} + +export function demoDetections(): Detection[] { + return [ + { id: "github", source: { kind: "cli", label: "gh CLI" }, account: "demo-user", syncable: true, note: null }, + { id: "jira", source: { kind: "cli", label: "jira-cli" }, account: "demo-user@example.com", syncable: true, note: "site https://demo.atlassian.net" }, + { + id: "linear", + source: { kind: "mcp-remote", label: "Claude MCP (linear)" }, + account: null, + syncable: false, + note: "usable in Claude; connect in browser to sync", + }, + ] +} + +/** Best detection per provider: direct sources win over Claude MCP servers. */ +export function scan(cwd = process.cwd()): Detection[] { + if (demoMode()) return demoDetections() + const home = homeDir() + const direct = [detectGithub(), detectJira(home), detectLinear()].filter((d): d is Detection => Boolean(d)) + const mcp = detectFromClaudeConfigs(home, cwd) + const best = new Map() + for (const d of [...direct, ...mcp.sort((a, b) => Number(b.syncable) - Number(a.syncable))]) if (!best.has(d.id)) best.set(d.id, d) + return PROVIDER_IDS.flatMap((id) => (best.has(id) ? [best.get(id)!] : [])) +} + +export function recordDetections(found: Detection[]): Promise { + return updateStore((store) => { + for (const id of PROVIDER_IDS) { + const d = found.find((f) => f.id === id) + const prev = store.providers[id] ?? {} + if (d) store.providers[id] = { ...prev, detection: d, updatedAt: new Date().toISOString() } + else if (!prev.registered && !prev.drafts) delete store.providers[id] + else store.providers[id] = { ...prev, detection: undefined } + } + return store + }) +} + +// ---------------------------------------------------------------- status + +export interface WorkspaceInfo { + name: string + tenant: string + /** Whether this directory is linked to a workspace. */ + linked: boolean + /** The linked workspace id; null when not linked or offline. */ + id: number | null + /** Whether an Altimate login exists; without one everything stays on this machine. */ + signedIn: boolean +} + +export function workspaceInfo(): WorkspaceInfo { + if (mockMode()) return { name: "Altimate Demo", tenant: "altimate", linked: true, id: null, signedIn: true } + return { name: "not signed in", tenant: "", linked: false, id: null, signedIn: false } +} + +/** The workspace being talked to: the real tenant and, when this directory is linked, the linked workspace's name. */ +export async function liveWorkspaceInfo(): Promise { + const cfg = await realConfig() + if (!cfg) return workspaceInfo() + const binding = await resolveBinding(process.cwd()).catch(() => null) + return { name: binding?.datamateName || cfg.tenant, tenant: cfg.tenant, linked: Boolean(binding), id: binding?.datamateId ?? null, signedIn: true } +} + +function orderStatus(p: ProviderStatus): ProviderStatus { + return { id: p.id, name: p.name, state: p.state, source: p.source, account: p.account, syncable: p.syncable, sync: p.sync, updatedAt: p.updatedAt, expiresAt: p.expiresAt, note: p.note, ...(p.managed === false ? { managed: false } : {}), ...(p.found ? { found: p.found } : {}) } +} + +export function buildStatus(store: Store, overlay: Detection[] = [], now = Date.now()): StatusResult { + const providers = PROVIDER_IDS.map((id): ProviderStatus => orderStatus(compute(id))) + function compute(id: ProviderId): ProviderStatus { + const rec = store.providers[id] + const det = rec?.detection ?? overlay.find((d) => d.id === id) + const blocked = Boolean(store.blocked[id]) + const base = { id, name: PROVIDER_NAMES[id], updatedAt: rec?.updatedAt ?? null, expiresAt: rec?.registered ? (rec.expiresAt ?? null) : null } + if (rec?.registered) { + const source = rec.source ?? det?.source ?? null + const expired = isExpired(rec.expiresAt, now) + const rejected = !expired && Boolean(rec.rejected) + return { + ...base, + state: expired ? "expired" : rejected ? "rejected" : "connected", + source, + account: rec.account ?? det?.account ?? null, + syncable: true, + sync: blocked ? "blocked" : rec.synced ? "synced" : "local", + note: rejected ? REJECTED_NOTE : rec.unverified ? "not verified yet, the provider could not be reached" : blocked ? "kept local by policy" : expired ? "access expired, reconnect to continue" : null, + } + } + if (!det) return { ...base, state: "not_found", source: null, account: null, syncable: true, sync: blocked ? "blocked" : "local", note: null } + if (det.source.kind === "mcp-remote") + return { ...base, state: "available", source: det.source, account: det.account, syncable: false, sync: blocked ? "blocked" : "local", note: det.note } + return { + ...base, + state: "detected", + source: det.source, + account: det.account, + syncable: det.syncable, + sync: blocked ? "blocked" : "pending", + note: blocked ? "kept local by policy" : det.note, + } + } + return { workspace: workspaceInfo(), providers } +} + +export function status(): StatusResult { + return buildStatus(loadStore(), demoMode() ? demoDetections() : []) +} + +/** One provider as the workspace shows it, derived from the caller's own connections. */ +export interface RemoteProvider { + integration_id: string + connection_id: number | null + name: string + account: string | null + /** True when the connection shown is the one this tool made (named after this machine). */ + managed: boolean + /** The connection this tool made for the provider, shown or not; null when there is none. */ + managedId?: number | null + /** Expiry and validity of the connection this tool made, which can differ from the one shown. */ + managedExpiresAt?: string | null + managedValid?: boolean | null + source: { kind?: string; label?: string } | null + expires_at: string | null + expired: boolean + is_valid: boolean | null +} + +/** The name this tool gives its connection for a provider on this machine. */ +export const managedName = (id: ProviderId, host = os.hostname()) => `${PROVIDER_NAMES[id]} (from ${host})` + +/** The expiry the workspace keeps in the connection's config, when it has one. */ +const connectionExpiry = (c: backend.Connection): string | null => { + const v = c.config?.expires_at + return typeof v === "string" && Number.isFinite(Date.parse(v)) ? v : null +} + +const validatedAt = (c: backend.Connection) => Date.parse(c.last_validated_at ?? "") || 0 + +/** The connection this tool manages for a provider: matched on integration and name. */ +export function findManaged(connections: backend.Connection[], id: ProviderId, host = os.hostname()): backend.Connection | undefined { + const name = managedName(id, host) + return connections.filter((c) => c.integration_id === INTEGRATION_IDS[id] && c.name === name).sort((a, b) => b.id - a.id)[0] +} + +/** + * The remote state of each provider from the caller's own connections. What is shown comes from the + * most recently validated one (the managed one on a tie); `managedId` is the connection this tool + * made, which update, disconnect and the removed-connection check follow. + */ +export function deriveRemote(connections: backend.Connection[], host = os.hostname()): RemoteProvider[] { + const out: RemoteProvider[] = [] + for (const id of PROVIDER_IDS) { + const own = connections.filter((c) => c.integration_id === INTEGRATION_IDS[id]) + const managed = findManaged(connections, id, host) + const pick = own.sort((a, b) => validatedAt(b) - validatedAt(a) || Number(b.id === managed?.id) - Number(a.id === managed?.id) || b.id - a.id)[0] + if (!pick) continue + out.push({ + integration_id: id, + connection_id: pick.id, + name: pick.name, + account: null, + managed: pick.id === managed?.id, + managedId: managed?.id ?? null, + managedExpiresAt: managed ? connectionExpiry(managed) : null, + managedValid: managed ? managed.is_valid : null, + source: null, + expires_at: connectionExpiry(pick), + expired: false, + is_valid: pick.is_valid, + }) + } + return out +} + +/** Local status merged with the workspace view of this user's connections (real mode only). */ +export function mergeRemote(local: StatusResult, remote: RemoteProvider[], now = Date.now(), detections: Detection[] = []): StatusResult { + const providers = local.providers.map((orig): ProviderStatus => { + const r = remote.find((x) => x.integration_id === orig.id) + const managedId = r ? (r.managedId !== undefined ? r.managedId : r.managed ? r.connection_id : null) : null + // Saved to the workspace and no longer there: the credential is kept nowhere, so it cannot read as connected on its own. + const gone = orig.sync === "synced" && orig.state !== "detected" && managedId == null + // A credential still found on this machine is offered again, so it can be connected once more. + const det = gone ? detections.find((d) => d.id === orig.id && d.source.kind !== "mcp-remote") : undefined + const removed: Partial = det + ? { state: "detected", source: det.source, account: det.account, sync: "local", expiresAt: null, note: "The workspace connection was removed. Connect it again." } + : { state: "not_found", source: null, account: null, sync: "local", expiresAt: null, note: REMOVED_NOTE } + if (gone && r?.connection_id == null) return orderStatus({ ...orig, ...removed } as ProviderStatus) + // Another connection of the caller's is still there: it is what the workspace shows for the provider. + let p: ProviderStatus = gone ? ({ ...orig, ...removed, note: null } as ProviderStatus) : orig + if (!r || r.connection_id == null) return p + // A local rejected flag on a record kept in the workspace is stale once the workspace says otherwise: the workspace connection is what decides. + if (p.state === "rejected") { + if (orig.sync !== "synced") return p + p = { ...p, state: "connected", note: null } + } + const remoteExpired = r.expired || isExpired(r.expires_at, now) + // An expired copy is reported as expired; only a live one the workspace marked invalid counts as rejected. + const remoteRejected = !remoteExpired && r.is_valid === false + const rs = r.source?.label ? { kind: (r.source.kind as SourceKind) ?? "cli", label: r.source.label } : null + if (p.state === "connected" || p.state === "expired") { + // Only the connection this tool made holds this credential; a hand-made one says nothing about it. + if (managedId == null) return p + // A synced credential is held by the workspace, so once the workspace reports an expiry it replaces the older local value; with none reported, the local one stands. + if (orig.sync === "synced") { + // Expiry and validity come from the connection this tool made; the one shown may be a hand-made one. + const mExpiresAt = r.managedExpiresAt !== undefined ? r.managedExpiresAt : r.expires_at + const mValid = r.managedValid !== undefined ? r.managedValid : r.is_valid + const mExpired = isExpired(mExpiresAt, now) || (r.managed && r.expired) + const known = mExpiresAt != null || mExpired + const expired = known ? mExpired : p.state === "expired" + const rejectedNow = !expired && mValid === false + return orderStatus({ + ...p, + state: expired ? "expired" : rejectedNow ? "rejected" : "connected", + expiresAt: mExpiresAt ?? p.expiresAt, + note: expired ? "access expired, reconnect to continue" : rejectedNow ? REJECTED_NOTE : p.state === "connected" ? p.note : null, + }) + } + // A credential kept on this machine has its own expiry; the workspace only adds to it. + const localFresh = p.state === "connected" + const remoteKnown = r.expires_at != null + const expired = !localFresh && (remoteExpired || !remoteKnown) + const note = localFresh + ? remoteExpired + ? "workspace copy expired, reconnect to update it" + : p.note + : expired + ? "access expired, reconnect to continue" + : "local token expired, the workspace connection is still active" + const rejected = !expired && remoteRejected + return orderStatus({ + ...p, + state: expired ? "expired" : rejected ? "rejected" : "connected", + expiresAt: localFresh ? p.expiresAt : expired ? (p.expiresAt ?? r.expires_at) : r.expires_at, + sync: p.sync === "blocked" ? p.sync : "synced", + note: rejected ? REJECTED_NOTE : note, + }) + } + return orderStatus({ + ...p, + state: remoteExpired ? "expired" : remoteRejected ? "rejected" : "connected", + source: rs ?? { kind: "browser", label: "Altimate workspace" }, + account: r.account ?? p.account, + syncable: true, + sync: "synced", + expiresAt: r.expires_at, + note: remoteExpired ? "access expired, reconnect to continue" : remoteRejected ? REJECTED_NOTE : null, + ...(managedId != null ? {} : { managed: false }), + // A hand-made connection is shown, and what was found here stays offered next to it. + ...(managedId == null && p.state === "detected" && p.source && p.sync !== "blocked" ? { found: { source: p.source, account: p.account } } : {}), + }) + }) + return { ...local, providers } +} + +export async function statusRemote(): Promise { + const local = status() + const unchecked = (e: unknown): StatusResult => ({ ...local, workspace: workspaceInfo(), workspaceUnchecked: e instanceof Error ? e.message : String(e) }) + // An unreadable login is the same as an unreadable workspace: what is shown comes from this machine alone. + let cfg: backend.BackendConfig | null + try { + cfg = await realConfig() + } catch (e) { + return unchecked(e) + } + if (!cfg) return local + try { + const remote = deriveRemote(await backend.listOwnConnections(cfg)) + const detections = Object.values(loadStore().providers).flatMap((rec) => (rec?.detection ? [rec.detection] : [])) + return { ...mergeRemote(local, remote, Date.now(), detections), workspace: await liveWorkspaceInfo() } + } catch (e) { + return { ...unchecked(e), workspace: await liveWorkspaceInfo().catch(() => workspaceInfo()) } + } +} + +// ---------------------------------------------------------------- policy + +export function setBlocked(ids: ProviderId[], blocked: boolean): Promise { + return updateStore((store) => { + for (const id of ids) { + if (blocked) store.blocked[id] = true + else delete store.blocked[id] + } + return store + }) +} +/** + * Blocks providers in one locked step with the uploads. One whose managed workspace copy exists + * (recorded, or found by looking) is refused, since there is no local copy to keep. When the + * workspace cannot be read the block is still set, as it only stops later uploads, and `unchecked` says so. + */ +export async function blockProviders(ids: ProviderId[]): Promise<{ blocked: ProviderId[]; refused: ProviderId[]; unchecked: ProviderId[] }> { + return withLock(async () => { + const refused: ProviderId[] = [] + const unchecked: ProviderId[] = [] + for (const id of ids) { + if (isSynced(id)) { + refused.push(id) + continue + } + try { + if (await managedInWorkspace(id)) refused.push(id) + } catch { + unchecked.push(id) + } + } + const blocked = ids.filter((id) => !refused.includes(id)) + await setBlocked(blocked, true) + return { blocked, refused, unchecked } + }) +} +export const isBlocked = (id: ProviderId) => Boolean(loadStore().blocked[id]) +/** The credential was saved to the workspace by this tool: its secret is not kept on this machine, only the non-secret fields and status. */ +export const isSynced = (id: ProviderId) => { + const rec = loadStore().providers[id] + return Boolean(rec?.registered && rec.synced) +} + +/** This tool's workspace connection for the provider exists in the workspace. Throws when the workspace cannot be read, so a caller never mistakes a failed lookup for an absent connection. */ +export async function managedInWorkspace(id: ProviderId): Promise { + const cfg = await realConfig() + return cfg ? Boolean(findManaged(await backend.listOwnConnections(cfg), id)) : false +} + +// ---------------------------------------------------------------- mock workspace sync + +export interface SyncStep { + label: string + ok: boolean +} +export interface SyncOutcome { + id: ProviderId + result: "synced" | "kept_local" | "skipped" | "failed" + reason?: string + steps: SyncStep[] + /** Attached to a workspace that is not private, so its members can use the connection. */ + shared?: true + request?: { method: "POST"; path: "/connections"; body: { name: string; integration_id: string; config: Record }; privacy: "private" } +} + +/** Pure description of the request that would be sent. */ +export function buildConnectionRequest(id: ProviderId, account: string | null, host = os.hostname()) { + return { + method: "POST" as const, + path: "/connections" as const, + privacy: "private" as const, + body: { + name: `${PROVIDER_NAMES[id]} (from ${host})`, + integration_id: INTEGRATION_IDS[id], + config: { account, credential: "", privacy: "private" }, + }, + } +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +const ALREADY_IN_WORKSPACE = "already in the workspace" +const INVALID_IN_WORKSPACE = "the workspace connection is marked invalid; run update" + +/** Attaches this tool's workspace connection for a provider that has nothing to upload from this machine; `fallback` is the outcome when there is none to attach. */ +async function attachWithoutLocal(id: ProviderId, rec: StoredProvider | undefined, fallback: SyncOutcome, onStep: ((label: string, done: boolean) => void) | undefined, share: boolean): Promise { + const signedIn = await realConfig() + if (!signedIn) return fallback + try { + if (!(await managedInWorkspace(id))) return fallback + } catch (e) { + return { id, result: "failed", reason: `Could not check your workspace for an existing connection: ${e instanceof Error ? e.message : String(e)}`, steps: [] } + } + return syncReal(id, signedIn, undefined, rec, null, onStep, true, share) +} + +/** `skipSynced` leaves a connection this tool already saved to the workspace as it is (it is only attached to the linked workspace), for runs that cover every provider. `share` allows attaching to a workspace that is not private. */ +export async function syncProvider(id: ProviderId, onStep?: (label: string, done: boolean) => void, pace = 0, skipSynced = false, share = false): Promise { + const store = loadStore() + const rec = store.providers[id] + // A sync before any detect still finds what is on this machine. + const det = rec?.detection ?? (rec?.registered ? undefined : scan().find((d) => d.id === id)) + if (!rec?.registered && !det) { + const nothing: SyncOutcome = { id, result: "skipped", reason: "nothing detected or connected", steps: [] } + // A run over every provider still attaches a workspace connection this tool made, when nothing for the provider is on this machine. + if (!skipSynced || store.blocked[id]) return nothing + return attachWithoutLocal(id, rec, nothing, onStep, share) + } + if (store.blocked[id]) { + // Blocked means the credential never leaves the machine; nothing is registered here without one. + return { id, result: "kept_local", reason: "blocked by policy", steps: [] } + } + if (!rec?.registered && det && !det.syncable) { + const notSyncable: SyncOutcome = { id, result: "skipped", reason: det.note ?? "not syncable", steps: [] } + // What was found here cannot be uploaded, but a workspace connection this tool made is still attached by a run over every provider. + return skipSynced ? attachWithoutLocal(id, rec, notSyncable, onStep, share) : notSyncable + } + const account = rec?.account ?? det?.account ?? null + const cfg = await realConfig() + const alreadySynced = skipSynced && Boolean(rec?.registered && rec.synced) + if (cfg) { + let attachOnly = alreadySynced + // A run over every provider leaves a workspace connection this tool made as it is, even when this machine has no record of it. + if (skipSynced && !attachOnly && !rec?.registered) { + try { + attachOnly = await managedInWorkspace(id) + } catch (e) { + return { id, result: "failed", reason: `Could not check your workspace for an existing connection: ${e instanceof Error ? e.message : String(e)}`, steps: [] } + } + } + return syncReal(id, cfg, det, rec, account, onStep, attachOnly, share) + } + if (!mockMode()) return { id, result: "skipped", reason: NOT_SIGNED_IN, steps: [] } + if (alreadySynced) return { id, result: "skipped", reason: "already in the workspace", steps: [] } + const request = buildConnectionRequest(id, account) + const steps: SyncStep[] = [] + for (const label of [ + "Validating credential", + "Testing credential", + `Upsert connection "${request.body.name}"`, + "Enable on workspace", + ]) { + onStep?.(label, false) + if (pace) await sleep(pace) + steps.push({ label, ok: true }) + onStep?.(label, true) + } + // The block is read again under the lock that records the sync: one set while the steps ran stops it. + const blockedMeanwhile = await withLock(async () => { + if (loadStore().blocked[id]) return true + const remote = readJson>(remotePath(), {}) + remote[id] = { ...request, enabled: true, workspace: workspaceInfo().name, recordedAt: new Date().toISOString() } + writeJson(remotePath(), remote) + await updateStore((next) => { + const cur = next.providers[id] ?? {} + next.providers[id] = { ...cur, registered: true, account, source: cur.source ?? det?.source, synced: true, updatedAt: new Date().toISOString() } + }) + return false + }) + if (blockedMeanwhile) return { id, result: "kept_local", reason: "blocked by policy", steps: [] } + return { id, result: "synced", steps, request } +} + +/** The backend wants the subdomain only: `https://acme.atlassian.net/` becomes `acme`. */ +export function normaliseJiraDomain(value: string): string { + return value + .trim() + .replace(/^[a-z]+:\/\//i, "") + .replace(/[/?#].*$/, "") + .replace(/\.atlassian\.net(:\d+)?$/i, "") +} + +/** Adds the variant key the backend needs to pick the Jira Server field set. Catalog fields only otherwise. */ +export function withVariant(id: ProviderId, variant: string | undefined, config: Record): Record { + return id === "jira" && isJiraServer(variant) ? { ...config, type: "server" } : config +} +/** The workspace catalog may name the Server / Data Center variant differently; any id naming "server" is it. */ +export const isJiraServer = (variant: string | undefined) => /server/i.test(variant ?? "") + +/** The connection config to upload for a provider: the saved credential when it was connected here (`saved`), else what connect would read from this machine. Never printed. */ +export async function collectConfig(id: ProviderId, rec: StoredProvider | undefined): Promise<{ config: Record; saved?: true } | { reason: string }> { + if (rec?.registered && rec.variant) { + const config: Record = { ...rec.values } + // Only a required secret has to be there; an optional one that was never filled in is simply left out. + const fields = (await creds.loadCatalog(id, true)).find((v) => v.id === rec.variant)?.fields + for (const field of rec.secretFields ?? []) { + const value = await readSecret(secretKey("cred", id, field)) + if (!value) { + if (fields?.find((f) => f.name === field)?.required === false) continue + return { reason: "no local credential to upload" } + } + config[field] = value + } + return { config: withVariant(id, rec.variant, config), saved: true } + } + return creds.detectedConfig(id) +} + +/** Whether the saved record is still the one a step started from: a connect, sync or disconnect in between changes it. */ +export const sameRecord = (cur: StoredProvider | undefined, rec: StoredProvider | undefined) => + Boolean(cur?.registered) === Boolean(rec?.registered) && + Boolean(cur?.synced) === Boolean(rec?.synced) && + cur?.variant === rec?.variant && + (cur?.connectionId ?? null) === (rec?.connectionId ?? null) && + cur?.validatedAt === rec?.validatedAt && + cur?.credentialStamp === rec?.credentialStamp && + JSON.stringify(cur?.secretFields ?? []) === JSON.stringify(rec?.secretFields ?? []) && + JSON.stringify(Object.entries(cur?.values ?? {}).sort()) === JSON.stringify(Object.entries(rec?.values ?? {}).sort()) + +export interface UpsertResult { + connectionId: number + created: boolean + isValid: boolean + message: string | null + /** What happened to the workspace attach; absent when the connection was saved invalid. */ + workspace?: { attached: string; workspaceId?: string; kept?: true; shared?: true } | { notLinked: true } | { error: string } +} + +export const NOT_LINKED_LABEL = "Not linked to a workspace: run altimate link" +export const ATTACH_FAILED_PREFIX = "Saved the connection but could not attach it to the workspace: " + +export const NOT_SIGNED_IN = "Not signed in to Altimate, kept local." + +const INVALID_MESSAGE = "The workspace saved the connection but marked it invalid. Check the fields and run update." +const REFUSED_MESSAGE = "The workspace refused the connection as invalid. Check the fields and try again." +export const RECORD_WRITE_FAILED = (error: string, retry: string) => + `Saved to your workspace, but the record on this machine could not be written (${error}). ${retry} to update the same connection.` +export const LOCAL_CLEANUP_FAILED_PREFIX = "Saved to the workspace, but the local copy could not be removed: " +export const REMOVE_FAILED_WARNING = (error: string) => ` It could not be removed from the workspace, remove it there: ${error}` +/** What to tell the user when the workspace marked a connection invalid: a new one was removed again, an updated one is left marked. */ +export const rejectionMessage = (res: Pick) => (res.created ? REFUSED_MESSAGE : INVALID_MESSAGE) + +/** + * Saves the credential as this tool's own connection: updated in place when it exists (matched on + * integration and name among the caller's own connections), created otherwise. + */ +export async function upsertManaged(cfg: backend.BackendConfig, id: ProviderId, config: Record, share = false): Promise { + const visible = await backend.listVisibleConnections(cfg) + const existing = findManaged(backend.ownConnections(visible), id) + const saved = existing + ? await backend.updateConnection(cfg, existing.id, { config }) + : await backend.createConnection(cfg, { name: managedName(id), integration_id: INTEGRATION_IDS[id], config }) + const isValid = saved.is_valid !== false + const result: UpsertResult = { connectionId: saved.id, created: !existing, isValid, message: isValid ? null : rejectionMessage({ created: !existing }) } + if (!isValid) return result + return { ...result, workspace: await attachToBoundWorkspace(cfg, id, saved.id, visible.map((c) => c.id), share) } +} + +/** Attaches the connection to the workspace this directory is linked to, reporting instead of throwing. `share` allows a workspace that is not private. */ +export async function attachToBoundWorkspace(cfg: backend.BackendConfig, id: ProviderId, connectionId: number, live?: (number | string)[], share = false): Promise> { + try { + const outcome = await resolveBindingOutcome(process.cwd()) + if (outcome.status === "unbound") return { notLinked: true } + // A link remembered from earlier may have been moved since: nothing is written to a workspace that cannot be confirmed. + if (outcome.status === "unknown" || (outcome.status === "bound" && outcome.stale)) { + // Without an Altimate login (only the integrations backend variables are set) there is nothing to look a link up with. + if (!(await AltimateApi.isConfigured())) return { notLinked: true } + return { error: `${outcome.status === "unknown" ? "could not tell whether this directory is linked to a workspace" : "could not confirm which workspace this directory is linked to"}, so the connection was not attached to one` } + } + const binding = outcome.binding + const { kept, shared } = await attachToWorkspace(cfg, id, connectionId, String(binding.datamateId), live, share) + return { attached: binding.datamateName, workspaceId: String(binding.datamateId), ...(kept ? { kept: true as const } : {}), ...(shared ? { shared: true as const } : {}) } + } catch (e) { + return { error: e instanceof Error ? e.message : String(e) } + } +} + +const READ_ONLY_TOOL = /(^|_)(get|list|search)_/ + +/** + * The workspace's integrations list with the connection attached to the provider's entry. An + * existing entry keeps its tools and any other primary connection; a new one enables only the read-only tools. Other entries pass + * through untouched. + */ +export function attachToIntegrations( + entries: backend.DatamateIntegrationEntry[], + providerIntegrationId: string, + connectionId: number | string, + catalogToolKeys: string[], + liveConnectionIds?: (number | string)[], +): backend.DatamateIntegrationEntry[] { + const live = liveConnectionIds && new Set(liveConnectionIds.map(String)) + const conn = String(connectionId) + if (!entries.some((e) => String(e.id) === providerIntegrationId)) { + return [...entries, { id: providerIntegrationId, tools: catalogToolKeys.filter((k) => READ_ONLY_TOOL.test(k)).map((key) => ({ key })), connection_id: conn }] + } + return entries.map((e) => { + if (String(e.id) !== providerIntegrationId) return e + // An entry already wired to another live connection keeps it; only an empty one, a deleted one, or one on this connection is set. + const stale = live && e.connection_id != null && !live.has(String(e.connection_id)) + const next = e.connection_id == null || stale || String(e.connection_id) === conn ? { ...e, connection_id: conn } : { ...e } + if (Array.isArray(e.connection_ids)) { + const kept = stale ? e.connection_ids.filter((c) => String(c) !== String(e.connection_id)) : e.connection_ids + next.connection_ids = kept.map(String).includes(conn) ? kept : [...kept, conn] + } + return next + }) +} + +export const SHARED_WORKSPACE_REFUSAL = (name: string, id: ProviderId) => + `workspace "${name}" is shared, so attaching would let its members use this connection; it stays saved to your account only. Run altimate integrations sync ${id} --share to attach it` + +/** `kept` when the workspace's entry kept a different live connection instead of taking this one; `shared` when the workspace is not private. */ +async function attachToWorkspace(cfg: backend.BackendConfig, id: ProviderId, connectionId: number, datamateId: string, liveIds: (number | string)[] | undefined, share: boolean): Promise<{ kept: boolean; shared: boolean }> { + const integrationId = INTEGRATION_IDS[id] + const first = await backend.getDatamate(cfg, datamateId) + if (first.is_owner === false) + throw new Error(`only the owner of workspace "${first.name}" can attach connections to it; your connection is saved to your account. Link a workspace you own, or sign in as its owner`) + const conn = String(connectionId) + const usesThis = (entries: backend.DatamateIntegrationEntry[]) => + entries.some((e) => String(e.id) === integrationId && (String(e.connection_id ?? "") === conn || (Array.isArray(e.connection_ids) && e.connection_ids.map(String).includes(conn)))) + let keys: string[] = [] + if (!first.integrations.some((e) => String(e.id) === integrationId)) { + const catalog = await backend.integrationsCatalog(cfg) + const list: any[] = backend.catalogEntries(catalog) + const found = list.find((c) => String(c?.id) === integrationId) + if (!found) throw new Error(`the workspace catalog has no entry for ${PROVIDER_NAMES[id]}, so the connection was not attached to workspace "${first.name}"`) + keys = (found.tools ?? []).map((t: any) => t?.key).filter((k: unknown): k is string => typeof k === "string") + } + // The backend replaces the whole list, so it is read again right before writing and only this entry changes. + const datamate = await backend.getDatamate(cfg, datamateId) + const live = liveIds ?? (await backend.listVisibleConnections(cfg)).map((c) => c.id) + const next = attachToIntegrations(datamate.integrations, integrationId, connectionId, keys, live) + // Attaching widens the connection to the workspace's audience, so a workspace that does not say it is private (in this latest read) only gets it + // when asked. Members can use it only when the entry ends up naming it: a connection already there widens nothing more, and neither does an entry kept on another one. + const shared = datamate.privacy !== "private" && usesThis(next) + if (shared && !share && !usesThis(datamate.integrations)) throw new Error(SHARED_WORKSPACE_REFUSAL(datamate.name, id)) + await backend.updateDatamateIntegrations(cfg, datamateId, next) + const entry = next.find((e) => String(e.id) === integrationId) + return { kept: entry?.connection_id != null && String(entry.connection_id) !== conn, shared } +} + +/** The integrations list without references to the connection; other entries pass through untouched. */ +export function detachFromIntegrations(entries: backend.DatamateIntegrationEntry[], connectionId: number | string): backend.DatamateIntegrationEntry[] { + const conn = String(connectionId) + return entries.map((e) => { + const next = { ...e } + if (e.connection_id != null && String(e.connection_id) === conn) next.connection_id = null + if (Array.isArray(e.connection_ids)) next.connection_ids = e.connection_ids.filter((c) => String(c) !== conn) + return next + }) +} + +/** Removes the connection from the linked workspace's integrations before it is deleted. Returns why it could not, or null; the delete goes on either way. */ +async function detachFromBoundWorkspace(cfg: backend.BackendConfig, connectionId: number): Promise { + try { + const outcome = await resolveBindingOutcome(process.cwd()) + if (outcome.status === "unbound") return null + if (outcome.status === "unknown") return "could not tell whether this directory is linked to a workspace, so the connection was not detached from one" + if (outcome.stale) return "could not confirm which workspace this directory is linked to, so the connection was not detached from one" + const binding = outcome.binding + const datamateId = String(binding.datamateId) + const datamate = await backend.getDatamate(cfg, datamateId) + if (datamate.is_owner === false) return `only the owner of workspace "${datamate.name}" can detach connections from it` + const refs = (e: backend.DatamateIntegrationEntry) => String(e.connection_id) === String(connectionId) || (e.connection_ids ?? []).map(String).includes(String(connectionId)) + if (!datamate.integrations.some(refs)) return null + await backend.updateDatamateIntegrations(cfg, datamateId, detachFromIntegrations(datamate.integrations, connectionId)) + return null + } catch (e) { + return e instanceof Error ? e.message : String(e) + } +} + +async function syncReal( + id: ProviderId, + cfg: backend.BackendConfig, + det: Detection | undefined, + rec: StoredProvider | undefined, + account: string | null, + onStep?: (label: string, done: boolean) => void, + attachOnly = false, + share = false, +): Promise { + // A keychain that cannot be read fails this provider only; it is not taken as having nothing to upload. + const collected = attachOnly + ? { reason: ALREADY_IN_WORKSPACE } + : await collectConfig(id, rec).catch((e: unknown) => ({ unreadable: e instanceof Error ? e.message : String(e) })) + if ("unreadable" in collected) return { id, result: "failed", reason: collected.unreadable, steps: [] } + const steps: SyncStep[] = [] + const step = (l: string) => { + steps.push({ label: l, ok: true }) + onStep?.(l, true) + } + const reportWorkspace = (w: NonNullable): SyncOutcome => { + if ("error" in w) return { id, result: "failed", reason: ATTACH_FAILED_PREFIX + w.error, steps } + if ("attached" in w) step(w.kept ? `Kept the workspace's existing connection in ${workspaceLabel(w.attached, w.workspaceId)}` : `Attached to ${workspaceLabel(w.attached, w.workspaceId)}`) + else { + steps.push({ label: NOT_LINKED_LABEL, ok: false }) + onStep?.(NOT_LINKED_LABEL, false) + } + return { id, result: "synced", steps, ...("attached" in w && w.shared ? { shared: true as const } : {}) } + } + if ("reason" in collected) { + // Nothing local to upload, but a connection already in the workspace may still need attaching to it: the one this machine saved, or one it has no record of. + const knownFor = (r: StoredProvider | undefined) => + r?.registered ? (r.synced && r.connectionId ? r.connectionId : undefined) : collected.reason === ALREADY_IN_WORKSPACE ? null : undefined + if (knownFor(rec) === undefined) return { id, result: "skipped", reason: collected.reason, steps: [] } + try { + // The record, the blocked flag and the workspace's connections are all read under the lock that the attach runs in. + return await withLock(async (): Promise => { + const store = loadStore() + if (store.blocked[id]) return { id, result: "kept_local", reason: "blocked by policy", steps: [] } + const known = knownFor(store.providers[id]) + if (known === undefined) return { id, result: "skipped", reason: collected.reason, steps: [] } + const visible = await backend.listVisibleConnections(cfg) + const own = backend.ownConnections(visible) + // The workspace decides whether the connection is usable, not the flag kept here from an earlier check. + const found = known == null ? findManaged(own, id) : own.find((c) => c.id === known && c.integration_id === INTEGRATION_IDS[id]) + // A connection this machine saved that is gone from the workspace is not attached; it was deleted there. + if (!found) return { id, result: "skipped", reason: known == null ? collected.reason : REMOVED_NOTE, steps: [] } + if (found.is_valid === false) return { id, result: "skipped", reason: INVALID_IN_WORKSPACE, steps: [] } + step("Connection already in the workspace") + return reportWorkspace(await attachToBoundWorkspace(cfg, id, found.id, visible.map((c) => c.id), share)) + }) + } catch (e) { + return { id, result: "failed", reason: e instanceof Error ? e.message : String(e), steps } + } + } + // The provider accepts the credential before anything is uploaded, as connect checks it; a refused or unreachable provider uploads nothing. + const check = await creds.validate(id, collected.config.type === "server" ? "server" : "", collected.config as Record) + if (!check.ok) { + // As connect does: a provider refusing the credential saved on this machine marks that record rejected; a detected one is not stored, so nothing is marked. + if (check.rejected && rec?.registered && rec.variant && rec.secretFields?.length) { + await updateStore((next) => { + // The check ran outside the lock: a record that was replaced since is not the credential that was refused. + if (sameRecord(next.providers[id], rec)) next.providers[id] = { ...next.providers[id], rejected: true, validatedAt: new Date().toISOString() } + }) + } + return { id, result: "failed", reason: check.message ?? "Could not validate the credentials", steps } + } + // A known expiry carries over only for the saved credential; a detected token may have been replaced since it was recorded. + const expiresAt = check.expiresAt ?? (collected.saved ? rec?.expiresAt : null) ?? null + const upload = expiresAt ? { ...collected.config, expires_at: expiresAt } : collected.config + try { + // The blocked check, the record check and the upload share one lock with Keep local and Disconnect. The config was prepared before the lock, so the record is compared again here. + return await withLock(async (): Promise => { + const now = loadStore() + if (now.blocked[id]) return { id, result: "kept_local", reason: "blocked by policy", steps: [] } + const cur = now.providers[id] + if (!sameRecord(cur, rec)) + return { id, result: "failed", reason: "the connection changed while syncing, run sync again", steps: [] } + const res = await upsertManaged(cfg, id, upload, share) + if (!res.isValid) { + // A workspace that refuses a new connection keeps nothing of it; the local copy stays. + let warning = "" + if (res.created) { + try { + await backend.deleteConnection(cfg, res.connectionId) + } catch (e) { + warning = REMOVE_FAILED_WARNING(e instanceof Error ? e.message : String(e)) + } + } + return { id, result: "failed", reason: rejectionMessage(res) + warning, steps } + } + step(res.created ? "Created connection" : "Updated connection") + try { + await updateStore((next) => { + next.providers[id] = { + ...next.providers[id], + registered: true, + account: check.account ?? account, + validatedAt: new Date().toISOString(), + source: next.providers[id]?.source ?? det?.source, + synced: true, + rejected: false, + unverified: false, + connectionId: res.connectionId, + expiresAt, + updatedAt: new Date().toISOString(), + } + }) + } catch (e) { + return { id, result: "failed", reason: RECORD_WRITE_FAILED(e instanceof Error ? e.message : String(e), "Run sync again"), steps } + } + // The connection is in the workspace and accepted: no secret stays on this machine, the same as when it is connected there directly. + try { + await purgeCredentialSecrets(id, rec, false) + await deleteSecret(id) + } catch (e) { + // The workspace holds the connection and the record says so; only the local cleanup is left to redo, which running sync for this provider does. + steps.push({ label: `${LOCAL_CLEANUP_FAILED_PREFIX}${e instanceof Error ? e.message : String(e)}. Run altimate integrations sync ${id} to remove it.`, ok: false }) + } + return reportWorkspace(res.workspace ?? { notLinked: true }) + }) + } catch (e) { + return { id, result: "failed", reason: e instanceof Error ? e.message : String(e), steps } + } +} + +export interface DisconnectResult { + removedLocal: boolean + removedRemote: number + /** False when a connection could not be taken out of the linked workspace's list before it was deleted. */ + detached: boolean + detachMessage?: string + /** Set when the login could not be read, so the workspace was not checked for copies of this connection. */ + workspaceUnchecked?: string +} + +/** Removes the workspace copies first and the local credential only once that worked, so a failure can be retried. */ +export function disconnect(id: ProviderId): Promise { + // One locked step, so an upload cannot land between the workspace delete and the local purge. + return withLock(async () => { + // An unreadable login only matters for a credential saved in the workspace; a local one is removed without it. + const synced = isSynced(id) + let workspaceUnchecked: string | undefined + const login = await realConfig().catch((e) => { + if (synced) throw e + workspaceUnchecked = e instanceof Error ? e.message : String(e) + return null + }) + if (synced && !login && !mockMode()) + throw new Error(`${PROVIDER_NAMES[id]} is saved in your workspace. Sign in to Altimate to remove it, then disconnect again.`) + const { removed, detachError } = await removeManaged(id, login) + const removedLocal = await disconnectLocal(id) + await creds.discardDraftSecrets(id) + return { removedLocal, removedRemote: removed, detached: detachError == null, ...(detachError ? { detachMessage: detachError } : {}), ...(workspaceUnchecked ? { workspaceUnchecked } : {}) } + }) +} + +function disconnectLocal(id: ProviderId): Promise { + return withLock(async () => { + const rec = loadStore().providers[id] + const had = Boolean(rec?.registered) + // Secrets go first: if the keychain refuses, the record still says what is stored, so a retry can finish. + await deleteSecret(id) + await purgeCredentialSecrets(id, rec) + const store = loadStore() + const cur = store.providers[id] + if (cur) store.providers[id] = { detection: cur.detection, updatedAt: new Date().toISOString() } + saveStore(store) + return (await removeMockRemote(id)) || had + }) +} + +/** Removes the recorded stand-in for the workspace copy that mock and demo modes keep in a local file. */ +export function removeMockRemote(id: ProviderId): Promise { + return withLock(() => { + const remote = readJson>(remotePath(), {}) + if (!(id in remote)) return false + delete remote[id] + writeJson(remotePath(), remote) + return true + }) +} + +/** + * Removes the provider's connections that this tool made in the workspace (named after this + * machine), so a disconnect does not leave a copy behind. Connections made by hand in the web app + * are left alone. Returns how many went. + */ +export async function disconnectRemote(id: ProviderId, cfg?: backend.BackendConfig | null): Promise { + return (await removeManaged(id, cfg === undefined ? await realConfig() : cfg)).removed +} + +/** Like disconnectRemote, and also says why a connection could not be detached from the linked workspace. The workspace list is replaced whole, so a failed detach leaves a dangling reference. */ +export async function removeManaged(id: ProviderId, config: backend.BackendConfig | null): Promise<{ removed: number; detachError: string | null }> { + if (!config) return { removed: 0, detachError: null } + const name = managedName(id) + const managed = (await backend.listOwnConnections(config)).filter((c) => c.integration_id === INTEGRATION_IDS[id] && c.name === name) + let detachError: string | null = null + for (const c of managed) { + detachError = (await detachFromBoundWorkspace(config, c.id)) ?? detachError + await backend.deleteConnection(config, c.id) + } + return { removed: managed.length, detachError } +} + +export * as IntegrationsCore from "./core" diff --git a/packages/opencode/src/altimate/integrations/credentials.ts b/packages/opencode/src/altimate/integrations/credentials.ts new file mode 100644 index 0000000000..f96a46d215 --- /dev/null +++ b/packages/opencode/src/altimate/integrations/credentials.ts @@ -0,0 +1,811 @@ +// altimate_change - new file +// +// Credentials flow for the integrations: the fields each provider asks for (the same ones the +// workspace web form uses), draft values, reading a secret from the clipboard, validation straight +// against the provider and the connect/update steps that keep a credential local or share it with +// the workspace. A secret is never returned, printed or logged: callers only ever see a masked tail. +import fs from "node:fs" +import path from "node:path" +import { createHash, randomUUID } from "node:crypto" +import { spawnSync } from "node:child_process" +import { IntegrationsCore as core } from "./core" +import { IntegrationsBackend as backend } from "./backend" +import { workspaceLabel } from "../workspace/workspace-name" +import type { ProviderId, Source, StoredProvider } from "./core" + +export interface FieldDef { + name: string + label: string + secret: boolean + required: boolean +} +export interface VariantDef { + id: string + label: string + fields: FieldDef[] +} +export type Catalog = Partial> + +export const BUILTIN_CATALOG: Record = { + github: [{ id: "token", label: "Personal access token", fields: [{ name: "github_token", label: "Personal access token", secret: true, required: true }] }], + jira: [ + { + id: "cloud", + label: "Jira Cloud", + fields: [ + { name: "jiraDomain", label: "Site", secret: false, required: true }, + { name: "jiraEmail", label: "Email", secret: false, required: true }, + { name: "jiraApiToken", label: "API token", secret: true, required: true }, + ], + }, + { + id: "server", + label: "Jira Server / Data Center", + fields: [ + { name: "jiraBaseUrl", label: "Base URL", secret: false, required: true }, + { name: "jiraPatToken", label: "Personal access token", secret: true, required: true }, + ], + }, + ], + linear: [{ id: "key", label: "Personal API key", fields: [{ name: "linearApiKey", label: "Personal API key", secret: true, required: true }] }], +} + +// ---------------------------------------------------------------- catalog + +/** The saved catalog is per workspace login, so one tenant's fields are never shown for another. */ +const catalogPath = (cfg: backend.BackendConfig) => + path.join(core.dataDir(), `integrations-catalog-${createHash("sha256").update(`${cfg.apiUrl}\n${cfg.tenant}`).digest("hex").slice(0, 12)}.json`) +// Bookkeeping the backend keeps beside the credential; never asked of the user. +const IGNORED_FIELDS = new Set(["auth_type", "privacy", "expires_at", "type", "account", "source"]) + +const humanise = (name: string) => + name + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .replace(/^./, (c) => c.toUpperCase()) +const isObject = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v) + +const isKnownSecretField = (name: string) => Object.values(core.BUILTIN_SECRET_FIELDS).some((names) => names.includes(name)) + +function parseFields(config: unknown): FieldDef[] { + const entries: [string, Record][] = Array.isArray(config) + ? config.filter(isObject).map((f) => [String(f.key ?? f.name ?? f.id ?? ""), f]) + : isObject(config) + ? Object.entries(config).map(([k, v]) => [k, isObject(v) ? v : {}]) + : [] + const seen = new Set() + return entries + .filter(([name]) => name && !IGNORED_FIELDS.has(name) && !seen.has(name) && Boolean(seen.add(name))) + .map(([name, f]) => ({ + name, + // The catalog names a field by `key` and shows it as `name`; a map names it by its key. + label: String((f.key ? f.name : undefined) ?? f.label ?? f.title ?? f.display_name ?? humanise(name)) + .trim() + .replace(/\s*:+$/, ""), + secret: f.encrypted === true || f.secret === true || f.is_secret === true || f.type === "password" || isKnownSecretField(name), + required: f.required === true, + })) +} + +/** + * Reads the workspace catalog response; tolerates a bare list or one wrapped in `items`/`integrations`/`data`. + * An entry's `config` is its default variant and `additionalConfigVariants` (or `variants`) lists the others. + */ +export function parseCatalog(data: unknown): Catalog { + const out: Catalog = {} + for (const entry of backend.catalogEntries(data)) { + if (!isObject(entry) || typeof entry.id !== "string" || !core.isProvider(entry.id)) continue + const id = entry.id + const first = BUILTIN_CATALOG[id][0] + const base = parseFields(entry.config) + const extra = entry.additionalConfigVariants ?? entry.variants + const rawVariants = Array.isArray(extra) ? extra : isObject(extra) ? Object.entries(extra).map(([k, v]) => ({ id: k, ...(isObject(v) ? v : {}) })) : [] + const variants: VariantDef[] = rawVariants.filter(isObject).flatMap((v) => { + const vid = String(v.id ?? v.key ?? v.value ?? v.name ?? "") + const own = parseFields(v.config ?? v.fields) + // A browser sign-in variant has no fields a person can type; this flow never offers it. + if (/oauth/i.test(vid) || String(v.type ?? "").toLowerCase() === "oauth") return [] + return vid && own.length ? [{ id: vid, label: String(v.label ?? v.title ?? v.name ?? humanise(vid)), fields: own }] : [] + }) + const all = base.length ? [{ id: first.id, label: first.label, fields: base }, ...variants.filter((v) => v.id !== first.id)] : variants + if (all.length) out[id] = all + } + return out +} + +/** The catalog to use: the workspace's when signed in (the last one saved for that login if it cannot be reached), else the built-in table. */ +export async function loadCatalog(id: ProviderId, offline = false): Promise { + // The form fields need no login: one that cannot be read leaves the built-in catalog. + const cfg = await core.realConfig().catch(() => null) + if (!cfg) return BUILTIN_CATALOG[id] + // Keeping a credential local does not need a fresh catalog, so only the saved one is used then. + if (!offline) + try { + const fresh = parseCatalog(await backend.integrationsCatalog(cfg)) + if (Object.keys(fresh).length) { + await core.withLock(() => core.writeJson(catalogPath(cfg), { ...core.readJson(catalogPath(cfg), {}), ...fresh })) + if (fresh[id]) return fresh[id]! + } + } catch {} + return core.readJson(catalogPath(cfg), {})[id] ?? BUILTIN_CATALOG[id] +} + +// ---------------------------------------------------------------- reading values on this machine + +interface Found { + value: string + source: Source +} + +const FIELD_SOURCES: Record = { + github_token: { env: ["GITHUB_TOKEN", "GH_TOKEN"], mcp: /^(GITHUB_(PERSONAL_ACCESS_)?TOKEN|GH_TOKEN)$/i }, + jiraApiToken: { env: ["JIRA_API_TOKEN", "ATLASSIAN_API_TOKEN"], mcp: /^(JIRA_API_TOKEN|ATLASSIAN_API_TOKEN)$/i }, + jiraPatToken: { env: ["JIRA_PAT_TOKEN", "JIRA_PERSONAL_TOKEN", "JIRA_PAT"], mcp: /^(JIRA_PAT_TOKEN|JIRA_PERSONAL_TOKEN|JIRA_PAT)$/i }, + linearApiKey: { env: ["LINEAR_API_KEY"], mcp: /^LINEAR_(API_KEY|TOKEN)$/i }, + jiraDomain: { env: ["JIRA_DOMAIN"], mcp: /^(JIRA_(URL|HOST|DOMAIN)|ATLASSIAN_(SITE|URL))$/i }, + jiraEmail: { env: ["JIRA_USER", "JIRA_EMAIL"], mcp: /^(JIRA_(USERNAME|EMAIL|USER)|ATLASSIAN_EMAIL)$/i }, + jiraBaseUrl: { env: ["JIRA_BASE_URL", "JIRA_URL"], mcp: /^JIRA_(URL|BASE_URL)$/i }, +} + +/** A value that is only a reference to another environment variable, such as ${NAME} or $NAME, holds no credential until it is resolved. */ +const isEnvReference = (value: string) => { + const v = value.trim() + return v.length === 0 || /^\$\{[^}]*\}$/.test(v) || /^\$[A-Za-z_][A-Za-z0-9_]*$/.test(v) +} + +/** Whether an MCP server's environment holds a credential this flow reads for the provider, not just any value. */ +export function mcpEnvHasToken(id: ProviderId, env: Record | undefined): boolean { + const patterns = core.BUILTIN_SECRET_FIELDS[id].flatMap((field) => FIELD_SOURCES[field]?.mcp ?? []) + return Object.entries(env ?? {}).some(([key, value]) => typeof value === "string" && !isEnvReference(value) && patterns.some((p) => p.test(key))) +} + +function fromMcpEnv(id: ProviderId, field: string): Found | undefined { + const pattern = FIELD_SOURCES[field]?.mcp + if (!pattern) return + for (const { scope, servers } of core.mcpServerMaps(core.homeDir(), process.cwd())) { + for (const [name, server] of Object.entries(servers ?? {})) { + if (!server || typeof server !== "object" || !core.providerMatchesMcp(id, name, server)) continue + for (const [key, value] of Object.entries(server.env ?? {})) + if (pattern.test(key) && typeof value === "string" && !isEnvReference(value)) return { value: value.trim(), source: { kind: "mcp", label: `Claude MCP (${name}, ${scope})` } } + } + } +} + +function fromEnv(field: string): Found | undefined { + for (const name of FIELD_SOURCES[field]?.env ?? []) { + const value = process.env[name]?.trim() + if (value) return { value, source: { kind: "env", label: name } } + } +} + +function jiraCliConfig(): { server?: string; login?: string } { + try { + return core.parseJiraCliConfig(fs.readFileSync(path.join(core.homeDir(), ".config/.jira/.config.yml"), "utf8")) + } catch { + return {} + } +} + +function gitEmail(): string | undefined { + const r = spawnSync("git", ["config", "user.email"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"], env: process.env }) + return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : undefined +} + +function detectField(id: ProviderId, field: string): Found | undefined { + if (core.demoMode()) return undefined + if (field === "github_token") { + const r = spawnSync("gh", ["auth", "token"], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"], env: process.env }) + if (r.status === 0 && r.stdout.trim()) return { value: r.stdout.trim(), source: { kind: "cli", label: "gh CLI" } } + } + const cli = jiraCliConfig() + const jiraCli: Source = { kind: "cli", label: "jira-cli" } + if (field === "jiraDomain" && cli.server) return { value: core.normaliseJiraDomain(cli.server), source: jiraCli } + if (field === "jiraBaseUrl" && cli.server) return { value: cli.server.trim().replace(/\/+$/, ""), source: jiraCli } + if (field === "jiraEmail" && cli.login) return { value: cli.login, source: jiraCli } + const found = fromEnv(field) ?? fromMcpEnv(id, field) + if (found && field === "jiraDomain") return { ...found, value: core.normaliseJiraDomain(found.value) } + if (found) return found + if (field === "jiraEmail") { + const email = gitEmail() + if (email) return { value: email, source: { kind: "cli", label: "git config" } } + } +} + +/** Cloud when the site is empty, a bare subdomain or on atlassian.net; anything else is Server / Data Center, including an address with a scheme or a port and a host without a dot such as localhost. */ +export function jiraVariantFor(site: string | undefined): "cloud" | "server" { + const s = (site ?? "").trim() + if (!s) return "cloud" + const host = s.replace(/^[a-z]+:\/\//i, "").replace(/[/?#].*$/, "") + if (/\.atlassian\.net(:\d+)?$/i.test(host)) return "cloud" + if (/^[a-z]+:\/\//i.test(s) || /:\d+$/.test(host) || /^localhost$/i.test(host)) return "server" + return host.includes(".") ? "server" : "cloud" +} + +const tokenUrls: Record = { + github: "https://github.com/settings/tokens/new?scopes=repo,read:user&description=Altimate", + "jira:cloud": "https://id.atlassian.com/manage-profile/security/api-tokens", + "jira:server": null, + linear: "https://linear.app/settings/account/security", +} +export const tokenUrl = (id: ProviderId, variant: string): string | null => (id === "jira" ? tokenUrls[core.isJiraServer(variant) ? "jira:server" : "jira:cloud"] : tokenUrls[id]) + +export interface Resolved { + id: ProviderId + variant: string + variants: VariantDef[] + def: VariantDef + values: Record + origin: Record + detected: Source[] + /** Why a token found on this machine was not filled in, by field name. */ + notes: Record +} + +/** The value of every field of the chosen variant: drafts first, then the connected copy, then this machine. `detectedOnly` reads this machine alone. */ +export async function resolve(id: ProviderId, variantId?: string, offline = false, detectedOnly = false): Promise { + const variants = await loadCatalog(id, offline) + const rec = detectedOnly ? undefined : core.loadStore().providers[id] + let want = variantId + if (!want && id === "jira") { + const site = rec?.drafts?.jiraBaseUrl || rec?.drafts?.jiraDomain || rec?.values?.jiraBaseUrl || rec?.values?.jiraDomain || detectField(id, "jiraBaseUrl")?.value || process.env.JIRA_DOMAIN + want = jiraVariantFor(site) + } + const key = want?.toLowerCase() + const match = key ? (variants.find((v) => v.id.toLowerCase() === key || v.label.toLowerCase() === key) ?? variants.find((v) => v.id.toLowerCase().includes(key) || v.label.toLowerCase().includes(key))) : undefined + // A variant that was asked for and does not exist is an error: falling back to the first one would test and save a different credential type. + if (variantId && !match) throw new Error(`${core.PROVIDER_NAMES[id]} has no variant "${variantId}". Variants: ${variants.map((v) => v.id).join(", ")}.`) + const def = match || variants[0] + const values: Record = {} + const origin: Resolved["origin"] = {} + const detected: Source[] = [] + const foundFrom: Record = {} + const notes: Record = {} + for (const f of def.fields) { + const draft = detectedOnly ? undefined : f.secret ? await core.readSecret(core.secretKey("draft", id, f.name)) : rec?.drafts?.[f.name] + if (draft) { + values[f.name] = f.name === "jiraDomain" ? core.normaliseJiraDomain(draft) : draft + origin[f.name] = "draft" + continue + } + const stored = detectedOnly ? undefined : f.secret ? await core.readSecret(core.secretKey("cred", id, f.name)) : rec?.values?.[f.name] + if (stored) { + values[f.name] = f.name === "jiraDomain" ? core.normaliseJiraDomain(stored) : stored + origin[f.name] = "stored" + continue + } + const found = detectField(id, f.name) + if (found) { + values[f.name] = found.value + origin[f.name] = "detected" + foundFrom[f.name] = found.source + detected.push(found.source) + } + } + if (id === "jira") dropMismatchedJiraToken(def, values, origin, detected, foundFrom, notes) + return { id, variant: def.id, variants, def, values, origin, detected, notes } +} + +const JIRA_SITE_FIELDS = new Set(["jiraDomain", "jiraBaseUrl"]) +/** The environment and jira-cli's own config are one place (jira-cli reads its token from the environment); each MCP server is its own. */ +const sourceGroup = (s: Source) => (s.kind === "mcp" ? `mcp:${s.label}` : "machine") +const sameSource = (a: Source, b: Source) => sourceGroup(a) === sourceGroup(b) + +/** A site found on this machine is only paired with a token found in the same place, so a token is never sent to a site that came from somewhere else. A typed or stored site is the user's own and pairs with any token. */ +function dropMismatchedJiraToken(def: VariantDef, values: Record, origin: Resolved["origin"], detected: Source[], foundFrom: Record, notes: Record) { + const site = def.fields.find((f) => JIRA_SITE_FIELDS.has(f.name) && foundFrom[f.name]) + if (!site) return + for (const f of def.fields) { + const from = foundFrom[f.name] + if (!f.secret || !from || sameSource(from, foundFrom[site.name])) continue + delete values[f.name] + delete origin[f.name] + detected.splice(detected.indexOf(from), 1) + notes[f.name] = `Found in ${from.label}, but the site came from ${foundFrom[site.name].label}, so the token was not filled in` + } +} + +/** The config to upload for a provider found on this machine, read the same way connect reads it. Typed-in drafts and stored copies are never part of it. */ +export async function detectedConfig(id: ProviderId): Promise<{ config: Record } | { reason: string }> { + const r = await resolve(id, undefined, false, true) + const missing = r.def.fields.filter((f) => f.required && !r.values[f.name]) + if (missing.length) return { reason: [`Missing: ${missing.map((f) => f.label).join(", ")}`, ...missing.map((f) => r.notes[f.name]).filter(Boolean)].join(". ") } + const config: Record = {} + for (const f of r.def.fields) if (r.values[f.name]) config[f.name] = r.values[f.name] + return { config: core.withVariant(id, r.variant, config) } +} + +// eslint-disable-next-line no-control-regex +const UNPRINTABLE = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/ +/** The last four characters of a secret, or nothing when they hold a control or direction character that must not reach a terminal. */ +export const maskTail = (secret: string) => (secret.length <= 8 || UNPRINTABLE.test(secret.slice(-4)) ? "••••" : `••••${secret.slice(-4)}`) + +export interface FieldView { + name: string + label: string + secret: boolean + required: boolean + value: string | null + hasValue: boolean + masked: string | null + note?: string +} +export interface FieldsResult { + provider: ProviderId + variant: string + variants: string[] + fields: FieldView[] + tokenUrl: string | null +} + +export async function describeFields(id: ProviderId, variantId?: string): Promise { + const r = await resolve(id, variantId) + return { + provider: id, + variant: r.variant, + variants: r.variants.map((v) => v.id), + fields: r.def.fields.map((f) => { + const v = r.values[f.name] + return { + name: f.name, + label: f.label, + secret: f.secret, + required: f.required, + value: f.secret ? null : (v ?? null), + hasValue: Boolean(v), + masked: f.secret && v ? maskTail(v) : null, + ...(r.notes[f.name] ? { note: r.notes[f.name] } : {}), + } + }), + tokenUrl: tokenUrl(id, r.variant), + } +} + +// ---------------------------------------------------------------- drafts + +async function findField(id: ProviderId, name: string): Promise { + const known = new Map() + for (const v of [...(await loadCatalog(id)), ...BUILTIN_CATALOG[id]]) for (const f of v.fields) if (!known.has(f.name)) known.set(f.name, f) + const f = known.get(name) + if (!f) throw new Error(`${core.PROVIDER_NAMES[id]} has no field "${name}". Fields: ${[...known.keys()].join(", ")}.`) + return f +} + +/** Stores a non-secret value typed in for a provider, until it is connected. */ +export async function setDraft(id: ProviderId, name: string, value: string) { + const f = await findField(id, name) + if (f.secret) throw new Error(`"${name}" is a secret. Use: altimate integrations set-secret ${id} ${name} --from-clipboard`) + await core.updateStore((store) => { + const rec = store.providers[id] ?? {} + const trimmed = name === "jiraDomain" ? core.normaliseJiraDomain(value) : value.trim() + const drafts = { ...rec.drafts } + if (trimmed) drafts[name] = trimmed + else delete drafts[name] + store.providers[id] = { ...rec, drafts: Object.keys(drafts).length ? drafts : undefined } + }) + return { provider: id, field: name } +} + +/** The names of every secret field the provider can ask for, across its catalog variants. */ +export async function catalogSecretFields(id: ProviderId): Promise> { + const names = new Set() + for (const v of [...(await loadCatalog(id, true)), ...BUILTIN_CATALOG[id]]) for (const f of v.fields) if (f.secret) names.add(f.name) + return names +} +const draftSecretNames = async (id: ProviderId) => new Set([...core.BUILTIN_SECRET_FIELDS[id], ...(core.loadStore().providers[id]?.secretFields ?? []), ...(await catalogSecretFields(id))]) +/** Deletes the pasted secret drafts of every field the provider can ask for: those of each catalog variant and of the connected credential. */ +export async function discardDraftSecrets(id: ProviderId) { + const names = await draftSecretNames(id) + await core.withLock(async () => { + for (const name of names) await core.deleteSecret(core.secretKey("draft", id, name)) + }) +} + +/** Forgets everything typed or pasted into the form and not yet connected: its fields and secrets. A connected credential and its status are kept, so this also serves a cancelled update. */ +export async function discardDrafts(id: ProviderId) { + const names = await draftSecretNames(id) + await core.withLock(async () => { + for (const name of names) await core.deleteSecret(core.secretKey("draft", id, name)) + await core.updateStore((store) => { + const rec = store.providers[id] + if (rec?.drafts) store.providers[id] = { ...rec, drafts: undefined } + }) + }) + return { provider: id } +} + +export type SecretSource = { kind: "clipboard" } | { kind: "env"; name: string } | { kind: "stdin" } +export interface SecretDeps { + clipboard?: () => string | undefined | Promise + stdin?: () => Promise +} + +/** Reads the system clipboard. Nothing but the text itself leaves this function. */ +export function readClipboard(): string | undefined { + const attempts: [string, string[]][] = + process.platform === "darwin" + ? [["pbpaste", []]] + : process.platform === "win32" + ? [["powershell", ["-NoProfile", "-Command", "Get-Clipboard"]]] + : [ + ["wl-paste", ["--no-newline"]], + ["xclip", ["-o", "-selection", "clipboard"]], + ["xsel", ["-ob"]], + ] + for (const [bin, args] of attempts) { + const r = spawnSync(bin, args, { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }) + if (r.status === 0 && r.stdout) return r.stdout + } +} + +async function readStdin() { + const chunks: Buffer[] = [] + for await (const c of process.stdin) chunks.push(Buffer.from(c)) + return Buffer.concat(chunks).toString("utf8") +} + +const MIN_TOKEN_LENGTH = 8 + +/** Reads a secret from the chosen source and keeps it as a draft in the secret store. Returns the masked tail only. */ +export async function setSecretDraft(id: ProviderId, name: string, source: SecretSource, deps: SecretDeps = {}) { + const f = await findField(id, name) + if (!f.secret) throw new Error(`"${name}" is not a secret. Use: altimate integrations set ${id} ${name} `) + const raw = + source.kind === "clipboard" ? await (deps.clipboard ?? readClipboard)() : source.kind === "env" ? process.env[source.name] : await (deps.stdin ?? readStdin)() + const value = (raw ?? "").trim() + const where = source.kind === "clipboard" ? "Clipboard" : source.kind === "env" ? `Environment variable ${source.name}` : "Standard input" + if (!value) throw new Error(source.kind === "clipboard" ? "Clipboard doesn't contain a token" : `${where} is empty`) + if (/\s/.test(value) || (source.kind === "clipboard" && (value.length < MIN_TOKEN_LENGTH || value.includes("://")))) + throw new Error(source.kind === "clipboard" ? "Clipboard doesn't contain a token" : `${where} doesn't contain a token`) + await core.storeSecret(core.secretKey("draft", id, name), value) + return { provider: id, field: name, masked: maskTail(value) } +} + +// ---------------------------------------------------------------- validation + +export interface Validation { + ok: boolean + /** The provider refused the credential (401/403). False when it could not be reached. */ + rejected: boolean + /** The provider could not be reached or failed; false when it answered and the input is what is wrong. */ + unavailable: boolean + account: string | null + expiresAt: string | null + message: string | null +} + +const githubApi = () => (process.env.ALTIMATE_INTEGRATIONS_GITHUB_API || "https://api.github.com").replace(/\/+$/, "") +const linearApi = () => process.env.ALTIMATE_INTEGRATIONS_LINEAR_API || "https://api.linear.app/graphql" +const jiraCloudBase = (site: string) => (process.env.ALTIMATE_INTEGRATIONS_JIRA_API || `https://${site}.atlassian.net`).replace(/\/+$/, "") + +/** GitHub sends the expiry as `2026-11-01 00:00:00 UTC`. */ +export function parseGithubExpiry(header: string | null): string | null { + if (!header) return null + const at = Date.parse(header.trim().replace(" UTC", "Z").replace(" ", "T")) + return Number.isNaN(at) ? null : new Date(at).toISOString() +} + +async function request(url: string, init: RequestInit) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 15_000) + try { + const res = await fetch(url, { ...init, signal: controller.signal }) + const text = await res.text() + let data: any = null + try { + data = text ? JSON.parse(text) : null + } catch {} + return { res, data } + } finally { + clearTimeout(timer) + } +} + +const ok = (account: string | null, expiresAt: string | null = null): Validation => ({ ok: true, rejected: false, unavailable: false, account, expiresAt, message: null }) +const refused = (message: string): Validation => ({ ok: false, rejected: true, unavailable: false, account: null, expiresAt: null, message }) +const unreachable = (message: string): Validation => ({ ok: false, rejected: false, unavailable: true, account: null, expiresAt: null, message }) +const badInput = (message: string): Validation => ({ ok: false, rejected: false, unavailable: false, account: null, expiresAt: null, message }) + +/** Checks the credential straight against the provider; nothing goes to Altimate. */ +export async function validate(id: ProviderId, variant: string, v: Record): Promise { + const name = core.PROVIDER_NAMES[id] + try { + if (id === "github") { + const { res, data } = await request(`${githubApi()}/user`, { headers: { Authorization: `Bearer ${v.github_token}`, Accept: "application/vnd.github+json", "User-Agent": "altimate-code" } }) + // GitHub also answers 403 when it is rate limiting: that says nothing about the token. + if (res.status === 403 && (res.headers.get("x-ratelimit-remaining") === "0" || res.headers.has("retry-after") || /rate limit/i.test(String(data?.message ?? "")))) + return unreachable("GitHub is rate limiting requests, try again later") + if (res.status === 401 || res.status === 403) return refused(`GitHub rejected the token (${res.status})${data?.message ? `: ${data.message}` : ""}`) + if (!res.ok) return unreachable(`GitHub answered ${res.status}`) + if (typeof data?.login !== "string" || !data.login) return badInput("GitHub answered without an account, check the token") + return ok(data.login, parseGithubExpiry(res.headers.get("github-authentication-token-expiration"))) + } + if (id === "jira") { + let url: string + let auth: string + if (core.isJiraServer(variant)) { + const base = (v.jiraBaseUrl ?? "").replace(/\/+$/, "") + const local = /^http:\/\/(127\.0\.0\.1|localhost)(:|\/|$)/i.test(base) + if (!/^https:\/\//i.test(base) && !local) return badInput("The Jira base URL must start with https://") + url = `${base}/rest/api/2/myself` + auth = `Bearer ${v.jiraPatToken}` + } else { + const site = core.normaliseJiraDomain(v.jiraDomain ?? "") + if (!/^[a-z0-9-]+$/i.test(site)) return badInput(`"${v.jiraDomain}" is not a Jira site name, use the part before .atlassian.net`) + url = `${jiraCloudBase(site)}/rest/api/3/myself` + auth = `Basic ${Buffer.from(`${v.jiraEmail}:${v.jiraApiToken}`).toString("base64")}` + } + const { res, data } = await request(url, { headers: { Authorization: auth, Accept: "application/json" } }) + if (res.status === 401 || res.status === 403) return refused(`Jira rejected the credentials (${res.status})`) + if (!res.ok) return unreachable(`Jira answered ${res.status}`) + const who = data?.emailAddress || data?.displayName || data?.name + if (typeof who !== "string" || !who) return badInput("Jira answered without an account, check the site") + return ok(who) + } + const { res, data } = await request(linearApi(), { + method: "POST", + headers: { Authorization: v.linearApiKey, "Content-Type": "application/json" }, + body: JSON.stringify({ query: "{ viewer { email name } }" }), + }) + const authError = data?.errors?.some((e: any) => e?.extensions?.code === "AUTHENTICATION_ERROR") + if (res.status === 401 || res.status === 403 || authError) return refused(`Linear rejected the API key (${authError ? 401 : res.status})`) + if (!res.ok || !data?.data?.viewer) return unreachable(`Linear answered ${res.status}`) + return ok(data.data.viewer.email || data.data.viewer.name || null) + } catch (e) { + return unreachable(`Could not reach ${name}: ${e instanceof Error ? e.message : String(e)}`) + } +} + +// ---------------------------------------------------------------- connect and update + +export interface CredentialResult { + provider: ProviderId + status: "connected" | "rejected" | "missing_fields" | "failed" + sync: "synced" | "local" + account: string | null + expiresAt: string | null + message: string | null + code?: string + missing?: string[] + /** Attached to a workspace that is not private, so its members can use the connection. */ + shared?: true +} + +export interface CredentialOptions { + id: ProviderId + localOnly?: boolean + /** Requires an existing connection and keeps its sync mode. */ + update?: boolean + variant?: string + /** Allows attaching to a linked workspace that is not private, whose members can then use the connection. */ + share?: boolean + onStep?: (label: string) => void +} + +const MANUAL_SOURCE: Source = { kind: "cli", label: "Claude Code" } +const CHANGED_WHILE_CONNECTING = "The connection changed while it was being checked, run it again." + +export async function connectCredentials(opts: CredentialOptions): Promise { + const { id } = opts + const name = core.PROVIDER_NAMES[id] + const base = { provider: id, account: null, expiresAt: null } + const rec = core.loadStore().providers[id] + const local = (r: Omit & Partial): CredentialResult => ({ ...base, ...r }) + // A connection that exists only in the workspace can be updated or moved local too. + let remoteManaged = false + // A failed call changes nothing, so it reports where the credential already is: in the workspace, or only here. + const syncState = (): "synced" | "local" => (rec?.synced || remoteManaged ? "synced" : "local") + if (!rec?.registered && opts.update) { + try { + remoteManaged = await core.managedInWorkspace(id) + } catch (e) { + return local({ status: "failed", sync: syncState(), message: `Could not check your workspace for an existing connection: ${e instanceof Error ? e.message : String(e)}` }) + } + } + if (opts.update && !rec?.registered && !remoteManaged) + return local({ status: "failed", sync: "local", message: `${name} is not connected yet. Use connect first.`, code: "not_connected" }) + + const blocked = core.isBlocked(id) + const r = await resolve(id, opts.variant ?? (opts.update ? rec?.variant : undefined), Boolean(opts.localOnly) || blocked) + const missing = r.def.fields.filter((f) => f.required && !r.values[f.name]) + if (missing.length) + return local({ status: "missing_fields", sync: syncState(), message: [`Missing: ${missing.map((f) => f.label).join(", ")}`, ...missing.map((f) => r.notes[f.name]).filter(Boolean)].join(". "), code: "missing_fields", missing: missing.map((f) => f.name) }) + + opts.onStep?.("Testing connection…") + const check = await validate(id, r.variant, r.values) + const secretFields = r.def.fields.filter((f) => f.secret).map((f) => f.name) + // Keep local still works when the provider cannot be reached: the credential is kept, flagged as not verified yet. + const unverified = !check.ok && !check.rejected && check.unavailable && Boolean(opts.localOnly) + if (!check.ok && !unverified) { + if (check.rejected) { + // The provider refused what was typed: it is not kept, and the connection that was working is left as it was. + for (const f of secretFields) if (r.origin[f] === "draft") await core.deleteSecret(core.secretKey("draft", id, f)) + // Only a re-test of exactly the stored values (every field, not just the secrets) shows that credential has stopped working. + if (rec?.registered && secretFields.length && r.variant === rec.variant && r.def.fields.every((f) => !r.values[f.name] || r.origin[f.name] === "stored")) { + await core.updateStore((store) => { + // The check ran outside the lock: a record that was replaced since is not the credential that was refused. + if (core.sameRecord(store.providers[id], rec)) store.providers[id] = { ...store.providers[id], rejected: true, validatedAt: new Date().toISOString() } + }) + } + } + return local({ status: check.rejected ? "rejected" : "failed", sync: syncState(), message: check.message ?? "Could not validate the credentials" }) + } + + const wantSync = opts.localOnly ? false : opts.update ? Boolean(rec?.synced) || remoteManaged : true + const cfg = wantSync && !blocked ? await core.realConfig() : null + const handDone = Object.values(r.origin).includes("draft") || Object.values(r.origin).includes("stored") + const detectedSource = r.detected[0] + const plain = Object.fromEntries(r.def.fields.filter((f) => !f.secret).map((f) => [f.name, r.values[f.name]]).filter(([, v]) => v)) + const stamp = new Date().toISOString() + const sameCredential = Boolean(rec?.registered) && secretFields.length > 0 && r.variant === rec?.variant && secretFields.every((f) => !r.values[f] || r.origin[f] === "stored") + const common = { + registered: true, + account: check.ok ? check.account : (rec?.account ?? null), + variant: r.variant, + values: plain, + secretFields, + validatedAt: check.ok ? stamp : undefined, + credentialStamp: randomUUID(), + unverified: !check.ok, + rejected: false, + // An offline check learns no expiry; the one already known stays only when the very same credential is being kept. + expiresAt: check.ok ? check.expiresAt : sameCredential ? rec?.expiresAt : undefined, + drafts: undefined, + } + const record = (synced: boolean, extra: Partial = {}) => + core.updateStore((store) => { + store.providers[id] = { ...store.providers[id], ...common, source: handDone || !detectedSource ? MANUAL_SOURCE : detectedSource, synced, updatedAt: stamp, ...extra } + }) + /** Clears what was entered in the form. What it follows has already happened, so a failure comes back as a note instead of stopping the rest. */ + const clearDrafts = async (): Promise => { + try { + await discardDraftSecrets(id) + return null + } catch (e) { + return `What was entered could not be cleared from this machine: ${e instanceof Error ? e.message : String(e)}` + } + } + + if (!cfg) { + // Keeping a connection local moves it: the whole decision and move happen in one locked step with the uploads, so no workspace copy can appear after it was looked for. + return await core.withLock(async (): Promise => { + // The checks above ran outside the lock: a record that was changed since is not the one this was checked against. + if (!core.sameRecord(core.loadStore().providers[id], rec)) return local({ status: "failed", sync: syncState(), message: CHANGED_WHILE_CONNECTING }) + let detachNote: string | null = null + let removeFailure: string | null = null + const wasBlocked = core.isBlocked(id) + const signedIn = await core.realConfig() + let hadCopy = Boolean(core.loadStore().providers[id]?.synced) || remoteManaged + if (opts.localOnly && signedIn && !hadCopy) { + try { + hadCopy = await core.managedInWorkspace(id) + } catch (e) { + return local({ status: "failed", sync: syncState(), message: `Could not check your workspace for an existing connection: ${e instanceof Error ? e.message : String(e)}` }) + } + } + if (hadCopy && !signedIn && !core.mockMode()) + return local({ status: "failed", sync: "synced", message: `${name} is saved in your workspace. Sign in to Altimate first.` }) + if (opts.localOnly && !wasBlocked) await core.setBlocked([id], true) + // The local copy is made durable before the workspace copy is touched, so a failed write never leaves the credential nowhere. + // A write that fails part way puts back every secret this attempt touched and leaves the previous record as it was. + const written = new Set() + const undo: [string, string | undefined][] = [] + const put = async (key: string, value: string) => { + undo.push([key, await core.readSecret(key)]) + await core.storeSecret(key, value) + } + try { + for (const f of secretFields) if (r.values[f]) { + await put(core.secretKey("cred", id, f), r.values[f]) + written.add(f) + } + if (secretFields[0] && r.values[secretFields[0]]) await put(id, r.values[secretFields[0]]) + } catch (e) { + for (const [key, prev] of undo.reverse()) { + try { + if (prev === undefined) await core.deleteSecret(key) + else await core.storeSecret(key, prev) + } catch {} + } + if (opts.localOnly && !wasBlocked) await core.setBlocked([id], false) + return local({ status: "failed", sync: hadCopy ? "synced" : "local", message: e instanceof Error ? e.message : String(e) }) + } + // Switching variant must not leave the other variant's stored secret behind; only the old fields not just written are purged. + // A failed purge only leaves the old variant's secrets behind, so the record is still saved and the leftover is reported. + let purgeNote: string | null = null + try { + await core.purgeCredentialSecrets(id, rec, false, written) + } catch (e) { + purgeNote = `The previous credential could not be removed from this machine: ${e instanceof Error ? e.message : String(e)}` + } + await record(false, { connectionId: null }) + // A draft left behind does not stop the move: the workspace copy is still removed below. + const draftNote = await clearDrafts() + if (signedIn && hadCopy) { + try { + detachNote = (await core.removeManaged(id, signedIn)).detachError + } catch (e) { + removeFailure = e instanceof Error ? e.message : String(e) + } + } + if (hadCopy && !signedIn) await core.removeMockRemote(id) + const notSignedIn = wantSync && !blocked + const notes = [ + removeFailure ? "The credential is kept on this machine, but the workspace copy is still there." : null, + purgeNote, + draftNote, + unverified ? `${check.message} (kept on this machine, not verified yet)` : null, + notSignedIn ? core.NOT_SIGNED_IN : blocked && wantSync ? "Sync is blocked for this provider, kept local." : null, + removeFailure ? core.REMOVE_FAILED_WARNING(removeFailure).trim() : null, + detachNote ? `Could not detach it from the linked workspace: ${detachNote}` : null, + ].filter(Boolean) + // A workspace copy that could not be removed means the move is not done: it is reported as failed, and connecting again with --local-only retries the removal. + return { ...base, provider: id, status: removeFailure ? "failed" : "connected", sync: removeFailure ? "synced" : "local", account: common.account ?? null, expiresAt: common.expiresAt ?? null, message: notes.length ? notes.join(" ") : null } + }) + } + + opts.onStep?.("Saving to your workspace…") + const config: Record = {} + for (const f of r.def.fields) if (r.values[f.name]) config[f.name] = r.values[f.name] + if (check.expiresAt) config.expires_at = check.expiresAt + try { + // The blocked check and the upload share one lock with Keep local, so a move to this machine cannot interleave with an upload. + return await core.withLock(async (): Promise => { + if (!core.sameRecord(core.loadStore().providers[id], rec)) return local({ status: "failed", sync: syncState(), message: CHANGED_WHILE_CONNECTING }) + if (core.isBlocked(id)) return local({ status: "failed", sync: syncState(), message: "Sync is blocked for this provider, kept local." }) + const res = await core.upsertManaged(cfg, id, core.withVariant(id, r.variant, config), opts.share === true) + if (!res.isValid) { + const rejected = (sync: "synced" | "local"): CredentialResult => ({ ...base, provider: id, status: "rejected", sync, account: check.account, expiresAt: common.expiresAt ?? null, message: core.rejectionMessage(res) }) + // An existing connection was updated in place and cannot be reverted, so it stays marked rejected for update to fix; the local copy of a local credential is untouched. + if (!res.created && !(rec?.registered && !rec.synced)) { + await record(true, { connectionId: res.connectionId, rejected: true }) + const draftNote = await clearDrafts() + const refusal = rejected("synced") + return draftNote ? { ...refusal, message: `${refusal.message} ${draftNote}` } : refusal + } + // A new connection the workspace refuses is removed again and nothing is stored. + let warning = "" + if (res.created) { + try { + await backend.deleteConnection(cfg, res.connectionId) + } catch (e) { + warning = core.REMOVE_FAILED_WARNING(e instanceof Error ? e.message : String(e)) + } + } + const draftNote = await clearDrafts() + const refusal = rejected(rec?.synced ? "synced" : "local") + return { ...refusal, message: [(refusal.message ?? "") + warning, draftNote].filter(Boolean).join(" ") } + } + // The connection lives in the workspace and was accepted; no secret is kept on this machine. + // The record is saved first: the workspace has the connection, so a cleanup that fails afterwards is reported, not turned into a failed connect. + try { + await record(true, { connectionId: res.connectionId }) + } catch (e) { + return { ...base, provider: id, status: "failed", sync: "synced", account: check.account, expiresAt: common.expiresAt ?? null, message: core.RECORD_WRITE_FAILED(e instanceof Error ? e.message : String(e), "Connect again") } + } + let leftover: string | null = null + try { + await core.purgeCredentialSecrets(id, rec, false) + await core.deleteSecret(id) + await discardDraftSecrets(id) + } catch (e) { + leftover = `Saved to the workspace, but the local copy of the credential could not be removed: ${e instanceof Error ? e.message : String(e)}` + } + const message = [workspaceNote(res.workspace), leftover].filter(Boolean).join(" ") || null + const shared = res.workspace && "attached" in res.workspace && res.workspace.shared ? { shared: true as const } : {} + return { ...base, provider: id, status: "connected", sync: "synced", account: check.account, expiresAt: common.expiresAt ?? null, message, ...shared } + }) + } catch (e) { + return local({ status: "failed", sync: syncState(), message: e instanceof Error ? e.message : String(e) }) + } +} + +function workspaceNote(w: core.UpsertResult["workspace"]): string | null { + if (!w) return null + if ("error" in w) return core.ATTACH_FAILED_PREFIX + w.error + if ("notLinked" in w) return core.NOT_LINKED_LABEL + const where = workspaceLabel(w.attached, w.workspaceId) + const audience = w.shared ? `; its members can use this connection` : "" + return `${w.kept ? `Kept the workspace's existing connection in ${where}` : `Attached to ${where}`}${audience}` +} + +export * as IntegrationsCredentials from "./credentials" diff --git a/packages/opencode/src/cli/cmd/integrations.ts b/packages/opencode/src/cli/cmd/integrations.ts new file mode 100644 index 0000000000..493eb90aa8 --- /dev/null +++ b/packages/opencode/src/cli/cmd/integrations.ts @@ -0,0 +1,376 @@ +// altimate_change - new file +// +// `integrations`: one-click local integrations for GitHub, Jira and Linear. Detects existing +// credentials, collects the workspace's own credentials (fields, drafts, clipboard, validation) and +// keeps them local or saves them as the caller's own workspace connection. Signed in, it talks to the +// real workspace; without a login the credentials stay on this machine (or a stand-in workspace in mock and demo modes). +import type { Argv } from "yargs" +import os from "node:os" +import open from "open" +import { cmd } from "./cmd" +import { UI } from "../ui" +import { IntegrationsCore as core } from "../../altimate/integrations/core" +import { IntegrationsCredentials as creds } from "../../altimate/integrations/credentials" +import type { ProviderId, ProviderStatus, StatusResult } from "../../altimate/integrations/core" + +const S = UI.Style +const out = (text = "") => console.log(text) +/** Text from config, a provider or the network, safe to print: escape sequences, control characters and bidi marks are removed. */ +// eslint-disable-next-line no-control-regex +const ANSI = /\u001b\[[0-?]*[ -\/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g +// eslint-disable-next-line no-control-regex +const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g +export const clean = (value: unknown) => String(value ?? "").replace(ANSI, "").replace(UNSAFE, " ").replace(/ {2,}/g, " ").trim() +const json = (value: unknown) => console.log(JSON.stringify(value, null, 2)) +const jsonOption = { describe: "print machine-readable JSON", type: "boolean", default: false } as const +const shareOption = { describe: "attach to a linked workspace that is not private, letting its members use this connection", type: "boolean", default: false } as const + +const STATE_VIEW: Record = { + connected: { icon: "●", color: S.TEXT_SUCCESS, label: "connected" }, + expired: { icon: "⚠", color: S.TEXT_WARNING, label: "expired" }, + rejected: { icon: "⚠", color: S.TEXT_WARNING, label: "rejected" }, + detected: { icon: "◐", color: S.TEXT_WARNING, label: "detected" }, + available: { icon: "○", color: S.TEXT_INFO, label: "available" }, + not_found: { icon: "·", color: S.TEXT_DIM, label: "not found" }, +} +const SYNC_LABEL: Record = { + synced: "synced to workspace", + local: "local only", + pending: "will sync on next sync", + blocked: "blocked, kept local", +} + +function printStatus(result: StatusResult, title: string) { + out() + out(`${S.TEXT_HIGHLIGHT_BOLD}◆ ${title}${S.TEXT_NORMAL} ${S.TEXT_DIM}${result.workspace.linked ? `workspace ${clean(result.workspace.name)}` : "workspace not linked"}${result.workspace.tenant ? ` (${clean(result.workspace.tenant)})` : ""}${S.TEXT_NORMAL}`) + if (result.workspaceUnchecked) out(` ${S.TEXT_WARNING}Could not check the workspace, so these states come from this machine and may be out of date: ${clean(result.workspaceUnchecked)}${S.TEXT_NORMAL}`) + out() + for (const p of result.providers) { + const v = STATE_VIEW[p.state] + const src = p.source ? `${clean(p.source.label)}${p.account ? ` · ${clean(p.account)}` : ""}` : "-" + out(` ${v.color}${v.icon}${S.TEXT_NORMAL} ${S.TEXT_NORMAL_BOLD}${p.name.padEnd(8)}${S.TEXT_NORMAL} ${v.color}${v.label.padEnd(10)}${S.TEXT_NORMAL} ${src}`) + if (p.state !== "not_found") out(` ${S.TEXT_DIM}${SYNC_LABEL[p.sync]}${p.expiresAt && p.state === "connected" ? ` · expires ${clean(p.expiresAt)}` : ""}${p.note ? ` · ${clean(p.note)}` : ""}${S.TEXT_NORMAL}`) + } + out() +} + +function parseProviders(value: unknown, all: boolean | undefined): ProviderId[] { + if (all || value === "all") return core.PROVIDER_IDS + if (typeof value === "string" && core.isProvider(value)) return [value] + throw new Error(`Specify a provider (${core.PROVIDER_IDS.join(", ")}) or --all.`) +} + +function fail(error: unknown, asJson = false) { + const message = clean(error instanceof Error ? error.message : String(error)) + if (asJson) json({ status: "failed", message }) + else UI.error(message) + process.exitCode = 1 +} + +const StatusCommand = cmd({ + command: "status", + describe: "show the GitHub, Jira and Linear integrations", + builder: (yargs: Argv) => yargs.option("json", jsonOption), + handler: async (args) => { + try { + const result = await core.statusRemote() + if (args.json) return json(result) + printStatus(result, "Integrations") + } catch (e) { + fail(e, args.json) + } + }, +}) + +const DetectCommand = cmd({ + command: "detect", + describe: "scan this machine for existing credentials (read-only, secrets are never printed)", + builder: (yargs: Argv) => yargs.option("json", jsonOption), + handler: async (args) => { + try { + await core.recordDetections(core.scan()) + const result = await core.statusRemote() + if (args.json) return json(result) + printStatus(result, "Detected integrations") + } catch (e) { + fail(e, args.json) + } + }, +}) + +/** Who can use a synced connection: only the user, unless it was attached to a workspace that is not private. */ +export const syncedTo = (o: Pick) => `synced to workspace${o.shared ? ", shared with its members" : " (private)"}` + +function renderSync(o: core.SyncOutcome) { + const name = core.PROVIDER_NAMES[o.id] + if (o.result === "kept_local") return out(` ${S.TEXT_WARNING}⊘${S.TEXT_NORMAL} ${name}: kept local (${clean(o.reason)})`) + if (o.result === "failed") return out(` ${S.TEXT_DANGER}✗${S.TEXT_NORMAL} ${name}: sync failed (${clean(o.reason)})`) + if (o.result === "skipped") return out(` ${S.TEXT_DIM}- ${name}: skipped (${clean(o.reason)})${S.TEXT_NORMAL}`) + out(` ${S.TEXT_SUCCESS}✓${S.TEXT_NORMAL} ${name} ${syncedTo(o)}`) + for (const st of o.steps) if (!st.ok && st.label.startsWith(core.LOCAL_CLEANUP_FAILED_PREFIX)) out(` ${S.TEXT_WARNING}!${S.TEXT_NORMAL} ${clean(st.label)}`) +} + +async function runSync(ids: ProviderId[], asJson: boolean, skipSynced = false, share = false) { + const outcomes: core.SyncOutcome[] = [] + for (const id of ids) { + if (!asJson && !core.isBlocked(id)) out(`\n${S.TEXT_NORMAL_BOLD}${core.PROVIDER_NAMES[id]}${S.TEXT_NORMAL}`) + const o = await core.syncProvider( + id, + asJson ? undefined : (label, done) => done && out(` ${S.TEXT_SUCCESS}✓${S.TEXT_NORMAL} ${label}`), + asJson ? 0 : 250, + skipSynced, + share, + ) + outcomes.push(o) + if (!asJson) renderSync(o) + } + if (asJson) json({ workspace: await core.liveWorkspaceInfo(), results: outcomes }) + else out() + return outcomes +} + +/** A run over every provider, or one provider asked to keep a saved connection, never overwrites a connection already synced to the workspace. */ +export const skipsSynced = (args: { provider?: string; all?: boolean; keepSynced?: boolean }) => !args.provider || Boolean(args.keepSynced) + +const SyncCommand = cmd({ + command: "sync [provider]", + describe: "push detected and connected credentials to the workspace", + builder: (yargs: Argv) => + yargs + .positional("provider", { type: "string", choices: core.PROVIDER_IDS }) + .option("all", { describe: "sync every provider", type: "boolean", default: false }) + .option("keep-synced", { describe: "attach a connection already saved to the workspace instead of overwriting it, as sync --all does", type: "boolean", default: false }) + .option("share", shareOption) + .option("json", jsonOption), + handler: async (args) => { + try { + const outcomes = await runSync(args.provider || args.all ? parseProviders(args.provider, args.all) : core.PROVIDER_IDS, args.json, skipsSynced(args), args.share) + if (outcomes.some((o) => o.result === "failed")) process.exitCode = 1 + } catch (e) { + fail(e, args.json) + } + }, +}) + +function policyCommand(name: "block" | "unblock") { + const blocking = name === "block" + return cmd({ + command: `${name} [provider]`, + describe: blocking ? "keep a provider's credentials on this machine only" : "allow a provider to sync to the workspace again", + builder: (yargs: Argv) => + yargs + .positional("provider", { type: "string", choices: core.PROVIDER_IDS }) + .option("all", { describe: "apply to every provider", type: "boolean", default: false }) + .option("json", jsonOption), + handler: async (args) => { + try { + const wanted = parseProviders(args.provider, args.all) + // A connection saved in the workspace has no copy here to keep, so it cannot be blocked. One only the workspace holds counts too. If the workspace cannot be read the block is still set (it only stops later uploads) and a warning says so. + const { blocked: ids, refused: held, unchecked } = blocking ? await core.blockProviders(wanted) : { blocked: wanted, refused: [] as ProviderId[], unchecked: [] as ProviderId[] } + if (!blocking) await core.setBlocked(ids, false) + if (held.length) process.exitCode = 1 + const warning = unchecked.length ? `Could not check the workspace for ${unchecked.map((id) => core.PROVIDER_NAMES[id]).join(", ")}; blocked here anyway. If a copy is already saved there it stays until you disconnect it.` : null + const refusal = (id: ProviderId) => `${core.PROVIDER_NAMES[id]} is saved in your workspace only. To keep it on this machine run: altimate integrations connect ${id} --local-only` + if (args.json) return json({ policy: name === "block" ? "blocked" : "allowed", providers: ids, ...(held.length ? { refused: held, message: held.map(refusal).join(" ") } : {}), ...(warning ? { warning } : {}) }) + for (const id of ids) + out(`${blocking ? S.TEXT_WARNING + "⊘" : S.TEXT_SUCCESS + "✓"}${S.TEXT_NORMAL} ${core.PROVIDER_NAMES[id]} ${blocking ? "will stay on this machine" : "can sync to the workspace"}`) + for (const id of held) UI.error(refusal(id)) + if (warning) out(`${S.TEXT_WARNING}${warning}${S.TEXT_NORMAL}`) + } catch (e) { + fail(e, args.json) + } + }, + }) +} + +const DisconnectCommand = cmd({ + command: "disconnect ", + describe: "remove the local credential and the workspace connection", + builder: (yargs: Argv) => + yargs.positional("provider", { type: "string", choices: core.PROVIDER_IDS, demandOption: true }).option("json", jsonOption), + handler: async (args) => { + try { + const id = parseProviders(args.provider, false)[0] + const { removedLocal, removedRemote, detached, detachMessage, workspaceUnchecked } = await core.disconnect(id) + const removed = removedLocal || removedRemote > 0 + if (args.json) return json({ provider: id, status: "disconnected", removed, removedFromWorkspace: removedRemote, detached, ...(workspaceUnchecked ? { workspaceChecked: false, workspaceNote: `The workspace was not checked for a copy of this connection: ${workspaceUnchecked}` } : {}), ...(detachMessage ? { message: `Could not detach it from the linked workspace: ${detachMessage}` } : {}) }) + out(`${S.TEXT_SUCCESS}✓${S.TEXT_NORMAL} ${core.PROVIDER_NAMES[id]} disconnected${removed ? "" : S.TEXT_DIM + " (nothing was stored)" + S.TEXT_NORMAL}`) + if (workspaceUnchecked) out(` ${S.TEXT_WARNING}The workspace was not checked for a copy of this connection (${clean(workspaceUnchecked)}). Sign in and disconnect again to remove it.${S.TEXT_NORMAL}`) + if (!detached) out(` ${S.TEXT_WARNING}Could not detach it from the linked workspace: ${clean(detachMessage)}. Check the workspace's integrations.${S.TEXT_NORMAL}`) + } catch (e) { + fail(e, args.json) + } + }, +}) + +const variantOption = { describe: "credential variant (Jira: cloud or server)", type: "string" } as const + +async function runCredentials(args: any, update: boolean) { + try { + const id = parseProviders(args.provider, false)[0] + const name = core.PROVIDER_NAMES[id] + const r = await creds.connectCredentials({ + id, + update, + localOnly: args["local-only"], + variant: args.variant, + share: args.share, + onStep: args.json ? undefined : (label) => out(` ${S.TEXT_DIM}${label}${S.TEXT_NORMAL}`), + }) + if (r.status !== "connected") process.exitCode = 1 + if (args.json) return json({ provider: r.provider, status: r.status, sync: r.sync, account: r.account, expiresAt: r.expiresAt, message: r.message, ...(r.code ? { code: r.code } : {}), ...(r.missing ? { missing: r.missing } : {}), ...(r.shared ? { shared: true } : {}) }) + if (r.status === "connected") { + out(`${S.TEXT_SUCCESS}✓${S.TEXT_NORMAL} ${name} ${update ? "updated" : "connected"}${r.account ? ` as ${S.TEXT_NORMAL_BOLD}${clean(r.account)}${S.TEXT_NORMAL}` : ""}`) + out(` ${S.TEXT_DIM}${r.sync === "synced" ? `synced to your workspace from ${clean(os.hostname())}` : "kept local, not synced"}${r.expiresAt ? ` · expires ${clean(r.expiresAt)}` : ""}${r.message ? ` · ${clean(r.message)}` : ""}${S.TEXT_NORMAL}`) + } else out(`${S.TEXT_DANGER}✗${S.TEXT_NORMAL} ${name}: ${clean(r.message ?? r.status)}`) + } catch (e) { + fail(e, args.json) + } +} + +const credentialOptions = (yargs: Argv) => + yargs + .positional("provider", { type: "string", choices: core.PROVIDER_IDS, demandOption: true }) + .option("json", jsonOption) + .option("variant", variantOption) + +const ConnectCommand = cmd({ + command: "connect ", + describe: "connect a provider with its own credentials", + builder: (yargs: Argv) => + credentialOptions(yargs).option("share", shareOption).option("local-only", { describe: "keep the credential on this machine", type: "boolean", default: false }), + handler: async (args) => runCredentials(args, false), +}) + +const UpdateCommand = cmd({ + command: "update ", + describe: "re-check and save new credentials for a connected provider, keeping its sync mode", + builder: (yargs: Argv) => credentialOptions(yargs).option("share", shareOption), + handler: async (args) => runCredentials(args, true), +}) + +const FieldsCommand = cmd({ + command: "fields ", + describe: "the credential fields for a provider, pre-filled where possible (secrets show only a masked tail)", + builder: (yargs: Argv) => credentialOptions(yargs), + handler: async (args) => { + try { + const id = parseProviders(args.provider, false)[0] + const r = await creds.describeFields(id, args.variant) + if (args.json) return json(r) + out(`\n${S.TEXT_HIGHLIGHT_BOLD}◆ ${core.PROVIDER_NAMES[id]}${S.TEXT_NORMAL} ${S.TEXT_DIM}${clean(r.variant)}${r.variants.length > 1 ? ` (also: ${r.variants.filter((v) => v !== r.variant).map(clean).join(", ")})` : ""}${S.TEXT_NORMAL}\n`) + for (const f of r.fields) { + const shown = f.secret ? (f.hasValue ? `✓ ${clean(f.masked)}` : "not set") : (clean(f.value) || "not set") + out(` ${clean(f.label)}${f.required ? "" : " (optional)"}: ${shown}`) + if (f.note) out(` ${S.TEXT_DIM}${clean(f.note)}${S.TEXT_NORMAL}`) + } + if (r.tokenUrl) out(`\n ${S.TEXT_DIM}Create a token: ${clean(r.tokenUrl)}${S.TEXT_NORMAL}`) + out() + } catch (e) { + fail(e, args.json) + } + }, +}) + +const SetCommand = cmd({ + command: "set ", + describe: "store a non-secret credential field (secrets use set-secret)", + builder: (yargs: Argv) => + yargs + .positional("provider", { type: "string", choices: core.PROVIDER_IDS, demandOption: true }) + .positional("field", { type: "string", demandOption: true }) + .positional("value", { type: "string", demandOption: true }) + .option("json", jsonOption), + handler: async (args) => { + try { + const id = parseProviders(args.provider, false)[0] + const r = await creds.setDraft(id, String(args.field), String(args.value)) + if (args.json) return json(r) + out(`${S.TEXT_SUCCESS}✓${S.TEXT_NORMAL} ${core.PROVIDER_NAMES[id]} ${r.field} saved`) + } catch (e) { + fail(e, args.json) + } + }, +}) + +const SetSecretCommand = cmd({ + command: "set-secret ", + describe: "store a secret credential field from the clipboard, an environment variable or stdin", + builder: (yargs: Argv) => + yargs + .positional("provider", { type: "string", choices: core.PROVIDER_IDS, demandOption: true }) + .positional("field", { type: "string", demandOption: true }) + .option("from-clipboard", { describe: "read the secret from the clipboard", type: "boolean", default: false }) + .option("from-env", { describe: "read the secret from this environment variable", type: "string" }) + .option("stdin", { describe: "read the secret from standard input", type: "boolean", default: false }) + .option("json", jsonOption), + handler: async (args) => { + try { + const id = parseProviders(args.provider, false)[0] + const chosen = [args["from-clipboard"], Boolean(args["from-env"]), args.stdin].filter(Boolean).length + if (chosen !== 1) throw new Error("Choose exactly one of --from-clipboard, --from-env VAR or --stdin.") + const source: creds.SecretSource = args["from-clipboard"] ? { kind: "clipboard" } : args["from-env"] ? { kind: "env", name: args["from-env"] } : { kind: "stdin" } + const r = await creds.setSecretDraft(id, String(args.field), source) + if (args.json) return json(r) + out(`${S.TEXT_SUCCESS}✓${S.TEXT_NORMAL} ${core.PROVIDER_NAMES[id]} ${r.field} saved ${S.TEXT_DIM}${clean(r.masked)}${S.TEXT_NORMAL}`) + } catch (e) { + fail(e, args.json) + } + }, +}) + +const DiscardCommand = cmd({ + command: "discard ", + describe: "forget the fields and secrets typed in for a provider and not yet connected, such as after a cancelled connect or update", + builder: (yargs: Argv) => yargs.positional("provider", { type: "string", choices: core.PROVIDER_IDS, demandOption: true }).option("json", jsonOption), + handler: async (args) => { + try { + const id = parseProviders(args.provider, false)[0] + const r = await creds.discardDrafts(id) + if (args.json) return json({ ...r, status: "discarded" }) + out(`${S.TEXT_SUCCESS}✓${S.TEXT_NORMAL} ${core.PROVIDER_NAMES[id]} entries cleared`) + } catch (e) { + fail(e, args.json) + } + }, +}) + +const OpenTokenPageCommand = cmd({ + command: "open-token-page ", + describe: "open the page where the provider's token is created", + builder: (yargs: Argv) => credentialOptions(yargs).option("open", { describe: "open the browser (on by default; with --json only when --open is given)", type: "boolean" }), + handler: async (args) => { + try { + const id = parseProviders(args.provider, false)[0] + const { variant, tokenUrl } = await creds.describeFields(id, args.variant) + if (!tokenUrl) throw new Error(`${core.PROVIDER_NAMES[id]} (${variant}) has no token page. Create the token in your Jira profile.`) + if (args.open ?? !args.json) await open(tokenUrl).catch(() => undefined) + if (args.json) return json({ provider: id, variant, tokenUrl }) + out(tokenUrl) + } catch (e) { + fail(e, args.json) + } + }, +}) + +export const IntegrationsCommand = cmd({ + command: "integrations", + describe: "connect GitHub, Jira and Linear and sync them to your workspace", + builder: (yargs: Argv) => + yargs + .command(StatusCommand) + .command(DetectCommand) + .command(ConnectCommand) + .command(UpdateCommand) + .command(FieldsCommand) + .command(SetCommand) + .command(SetSecretCommand) + .command(DiscardCommand) + .command(OpenTokenPageCommand) + .command(SyncCommand) + .command(policyCommand("block")) + .command(policyCommand("unblock")) + .command(DisconnectCommand) + .demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 3f52c1a655..f2a0152b03 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -55,6 +55,9 @@ import { CheckCommand } from "./cli/cmd/check" // altimate_change start — tool: model-free data tool access import { ToolCommand } from "./cli/cmd/tool" // altimate_change end +// altimate_change start — integrations: one-click local integrations command +import { IntegrationsCommand } from "./cli/cmd/integrations" +// altimate_change end // altimate_change start — learn: playbook learning loop command import { LearnCommand } from "./cli/cmd/learn" // altimate_change end @@ -104,6 +107,7 @@ const CLI_COMMAND_NAMES = new Set([ "review", "pr", "session", "plugin", "plug", "db", "trace", "recap", "skill", "check", "completion", "tool", // altimate_change start — attribute learning commands to their CLI entry point "learn", + "integrations", // altimate_change end // registered conditionally below (workspace / local-install builds) "link", "workspace", "workspace-serve", "workspace-client", @@ -262,6 +266,9 @@ let cli = yargs(args) // altimate_change start — tool: model-free data tool access .command(ToolCommand) // altimate_change end + // altimate_change start — integrations: one-click local integrations + .command(IntegrationsCommand) + // altimate_change end // altimate_change start — learn: register playbook learning command .command(LearnCommand) // altimate_change end diff --git a/packages/opencode/test/altimate/integrations.test.ts b/packages/opencode/test/altimate/integrations.test.ts new file mode 100644 index 0000000000..080eba3410 --- /dev/null +++ b/packages/opencode/test/altimate/integrations.test.ts @@ -0,0 +1,2742 @@ +import { afterAll, afterEach, describe, expect, spyOn, test, beforeEach } from "bun:test" +import fs from "node:fs" +import os from "node:os" +import childProcess from "node:child_process" +import { EventEmitter } from "node:events" +import { PassThrough } from "node:stream" +import path from "node:path" +import { IntegrationsCore as core } from "../../src/altimate/integrations/core" +import { skipsSynced, clean, syncedTo } from "../../src/cli/cmd/integrations" +import { IntegrationsBackend as backend } from "../../src/altimate/integrations/backend" +import { IntegrationsCredentials as creds } from "../../src/altimate/integrations/credentials" +import { AltimateApi } from "../../src/altimate/api/client" +import * as workspaceState from "../../src/altimate/workspace/state" + +const envBefore = { ...process.env } +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "integrations-test-")) +process.env.ALTIMATE_INTEGRATIONS_DIR = tmp +process.env.ALTIMATE_INTEGRATIONS_MOCK = "1" + +const extraDirs: string[] = [] +const osTmp = (prefix: string) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)) + extraDirs.push(dir) + return dir +} + +afterAll(() => { + for (const dir of extraDirs) fs.rmSync(dir, { recursive: true, force: true }) + for (const k of Object.keys(process.env)) if (!(k in envBefore)) delete process.env[k] + for (const [k, v] of Object.entries(envBefore)) if (v !== undefined) process.env[k] = v + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +beforeEach(() => { + for (const f of fs.readdirSync(tmp)) fs.rmSync(path.join(tmp, f), { recursive: true, force: true }) + delete process.env.ALTIMATE_INTEGRATIONS_DEMO +}) + +describe("sameRecord", () => { + const base = { registered: true, synced: false, variant: "v", connectionId: null, validatedAt: "t", credentialStamp: "a", secretFields: ["token"], values: { url: "x" } } + test("a credential saved again, or changed in any field, is a different record", () => { + expect(core.sameRecord(base, { ...base })).toBe(true) + expect(core.sameRecord({ ...base, values: { url: "x" } }, { ...base, updatedAt: "later" })).toBe(true) + expect(core.sameRecord({ ...base, credentialStamp: "b" }, base)).toBe(false) + expect(core.sameRecord({ ...base, values: { url: "y" } }, base)).toBe(false) + expect(core.sameRecord({ ...base, secretFields: ["other"] }, base)).toBe(false) + }) +}) + +describe("detection parsing", () => { + test("claude.json and .mcp.json servers", () => { + const home = fs.mkdtempSync(path.join(tmp, "home-")) + const cwd = fs.mkdtempSync(path.join(tmp, "proj-")) + fs.writeFileSync( + path.join(home, ".claude.json"), + JSON.stringify({ + mcpServers: { github: { command: "npx", args: ["@modelcontextprotocol/server-github"], env: { GITHUB_TOKEN: "x" } } }, + projects: { [cwd]: { mcpServers: { linear: { type: "http", url: "https://mcp.linear.app/mcp" } } } }, + }), + ) + fs.writeFileSync(path.join(cwd, ".mcp.json"), JSON.stringify({ mcpServers: { atlassian: { type: "sse", url: "https://mcp.atlassian.com/v1/sse" } } })) + const found = core.detectFromClaudeConfigs(home, cwd) + const by = Object.fromEntries(found.map((d) => [d.id, d])) + expect(by.github.source.kind).toBe("mcp") + expect(by.github.syncable).toBe(true) + expect(by.linear.source.kind).toBe("mcp-remote") + expect(by.linear.syncable).toBe(false) + expect(by.jira.source.kind).toBe("mcp-remote") + expect(by.jira.note).toBe("usable in Claude; connect in browser to sync") + }) + + test("an MCP server env value counts as a credential only when it is one the provider reads", () => { + const github = (env: Record) => core.detectFromMcpServers({ github: { command: "npx", args: ["server-github"], env } }, "~/.claude.json") + expect(github({ LOG_LEVEL: "debug", GITHUB_API_URL: "https://x" })).toEqual([]) + expect(github({ GITHUB_PERSONAL_ACCESS_TOKEN: " " })).toEqual([]) + expect(github({ GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_x" })[0].syncable).toBe(true) + expect(core.detectFromMcpServers({ linear: { command: "npx", args: ["linear-mcp"], env: { PLACEHOLDER: "x" } } }, "~/.claude.json")).toEqual([]) + }) + + test("jira-cli config", () => { + expect(core.parseJiraCliConfig("server: https://a.atlassian.net\nlogin: me@a.com\n")).toEqual({ + server: "https://a.atlassian.net", + login: "me@a.com", + }) + }) +}) + +describe("status", () => { + test("shape and states in demo mode", () => { + process.env.ALTIMATE_INTEGRATIONS_DEMO = "1" + const s = core.status() + expect(Object.keys(s)).toEqual(["workspace", "providers"]) + expect(Object.keys(s.providers[0])).toEqual(["id", "name", "state", "source", "account", "syncable", "sync", "updatedAt", "expiresAt", "note"]) + expect(s.providers.map((p) => [p.id, p.state, p.sync, p.syncable])).toEqual([ + ["github", "detected", "pending", true], + ["jira", "detected", "pending", true], + ["linear", "available", "local", false], + ]) + expect(core.status().providers[0].account).toBe("demo-user") + }) + + test("a signed-out user is not shown a linked workspace; mock mode keeps the demo one", () => { + expect(core.workspaceInfo()).toEqual({ name: "Altimate Demo", tenant: "altimate", linked: true, id: null, signedIn: true }) + delete process.env.ALTIMATE_INTEGRATIONS_MOCK + try { + expect(core.workspaceInfo()).toEqual({ name: "not signed in", tenant: "", linked: false, id: null, signedIn: false }) + expect(core.status().workspace.signedIn).toBe(false) + } finally { + process.env.ALTIMATE_INTEGRATIONS_MOCK = "1" + } + }) + + test("not_found without detections", () => { + expect(core.status().providers.every((p) => p.state === "not_found")).toBe(true) + }) +}) + +describe("block policy and sync", () => { + test("blocked providers stay local, unblocked sync", async () => { + process.env.ALTIMATE_INTEGRATIONS_DEMO = "1" + await core.recordDetections(core.scan()) + await core.setBlocked(["github"], true) + expect(core.status().providers[0].sync).toBe("blocked") + expect((await core.syncProvider("github")).result).toBe("kept_local") + await core.setBlocked(["github"], false) + const o = await core.syncProvider("github") + expect(o.result).toBe("synced") + expect(o.request?.body.name).toStartWith("GitHub (from ") + expect(o.request?.privacy).toBe("private") + const p = core.status().providers[0] + expect([p.state, p.sync]).toEqual(["connected", "synced"]) + expect((await core.syncProvider("linear")).result).toBe("skipped") + await core.disconnect("github") + expect(core.status().providers[0].state).toBe("detected") + }) +}) + +describe("a block set while a mock sync runs", () => { + test("stops the sync from being recorded", async () => { + process.env.ALTIMATE_INTEGRATIONS_DEMO = "1" + await core.recordDetections(core.scan()) + const o = await core.syncProvider("github", (label, done) => { + if (done && label === "Enable on workspace") core.saveStore({ ...core.loadStore(), blocked: { github: true } }) + }) + expect([o.result, o.reason]).toEqual(["kept_local", "blocked by policy"]) + expect(core.loadStore().providers.github?.synced).toBeFalsy() + expect(fs.existsSync(path.join(tmp, "integrations-remote-mock.json"))).toBe(false) + }) +}) + +const connected = async (id: core.ProviderId, expiresAt: string | null, extra: Partial = {}, secret: string | null = "tok_" + id) => { + if (secret) await core.storeSecret(id, secret) + const store = core.loadStore() + store.providers[id] = { registered: true, account: "octo", source: { kind: "cli", label: "test" }, synced: false, expiresAt, updatedAt: new Date().toISOString(), ...extra } + core.saveStore(store) +} + +const HOST = os.hostname() + +/** A stand-in for the existing connections endpoints (own list, create, update, delete). */ +function connectionsStub() { + const state = { + rows: [] as any[], + next: 21, + valid: true, + fail: null as null | { status: number; body: unknown }, + listFails: false, + } + const seed = (row: Record) => { + const full = { id: state.next++, user_id: 1, is_valid: true, is_owner: true, config: {}, last_validated_at: null, created_at: "2030-01-01T00:00:00Z", ...row } + state.rows.push(full) + return full + } + const handle = (req: Request, url: URL, body: any): Response | undefined => { + if (!url.pathname.startsWith("/connections")) return + if (state.fail && req.method !== "GET") return Response.json(state.fail.body, { status: state.fail.status }) + const id = Number(url.pathname.split("/")[2]) + if (state.listFails && req.method === "GET" && url.pathname === "/connections/") return Response.json({ detail: "unavailable" }, { status: 503 }) + if (req.method === "GET" && url.pathname === "/connections/") + return Response.json({ connections: state.rows.filter((r) => !r.deleted && (url.searchParams.get("include_shared") === "true" || r.is_owner)) }) + if (req.method === "POST" && url.pathname === "/connections/") { + if (!body?.name?.trim()) return Response.json({ detail: [{ loc: ["body", "name"], msg: "Name can't be blank", type: "blank_field" }] }, { status: 422 }) + return Response.json(seed({ name: body.name, integration_id: body.integration_id, config: body.config, is_valid: state.valid, last_validated_at: state.valid ? "2030-06-01T00:00:00Z" : null }), { status: 201 }) + } + const row = state.rows.find((r) => r.id === id && r.is_owner && !r.deleted) + if (!row) return Response.json({ detail: "Connection not found" }, { status: 404 }) + if (req.method === "PATCH" && url.pathname === `/connections/${id}`) { + Object.assign(row, { config: body.config, is_valid: state.valid, updated_at: "2030-07-01T00:00:00Z" }) + return Response.json(row) + } + if (req.method === "DELETE" && url.pathname === `/connections/${id}`) { + row.deleted = true + return Response.json({ ok: true }) + } + } + return { state, seed, handle } +} + +describe("expiry", () => { + test("isExpired uses a 60 second skew", () => { + const now = Date.parse("2030-01-01T00:00:00Z") + expect(core.isExpired(null, now)).toBe(false) + expect(core.isExpired("2030-01-01T00:02:00Z", now)).toBe(false) + expect(core.isExpired("2030-01-01T00:01:00Z", now)).toBe(true) + expect(core.isExpired("2029-12-31T00:00:00Z", now)).toBe(true) + }) + + test("status reports expired and expiresAt", async () => { + await connected("github", new Date(Date.now() + 3_600_000).toISOString()) + let p = core.status().providers[0] + expect([p.state, p.expiresAt === null]).toEqual(["connected", false]) + await core.updateStore((st) => { + st.providers.github!.expiresAt = new Date(Date.now() - 1000).toISOString() + }) + p = core.status().providers[0] + expect(p.state).toBe("expired") + }) + +}) + +describe("keychain", () => { + const realDir = process.env.ALTIMATE_INTEGRATIONS_DIR + const platform = Object.getOwnPropertyDescriptor(process, "platform")! + const calls: { cmd: string; args: string[]; input?: string }[] = [] + const kills: string[] = [] + let spawn: ReturnType | undefined + let hold: Promise | undefined + const on = (status = 0, stdout = "", stderr = "") => { + hold = undefined + spawn?.mockRestore() + delete process.env.ALTIMATE_INTEGRATIONS_DIR + Object.defineProperty(process, "platform", { value: "darwin" }) + calls.length = 0 + kills.length = 0 + spawn = spyOn(childProcess, "spawn").mockImplementation(((cmd: string, args: string[]) => { + const child: any = new EventEmitter() + const chunks: string[] = [] + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.stdin = new PassThrough() + child.stdin.on("data", (d: Buffer) => chunks.push(d.toString())) + child.kill = (signal: string) => kills.push(signal) > 0 + const entry: { cmd: string; args: string[]; input?: string } = { cmd, args } + calls.push(entry) + const finish = () => { + if (chunks.length) entry.input = chunks.join("") + child.stdout.end(stdout) + child.stderr.end(stderr) + child.emit("close", status) + } + if (hold) hold.then(() => setTimeout(finish, 0)) + else setTimeout(finish, 0) + return child + }) as any) + } + afterEach(() => { + spawn?.mockRestore() + spawn = undefined + hold = undefined + process.env.ALTIMATE_INTEGRATIONS_DIR = realDir + Object.defineProperty(process, "platform", platform) + }) + + test("a secret goes to security on stdin, quoted, and never in argv", async () => { + on() + await core.storeSecret("cred:github:github_token", 'a"b\\c') + expect(calls).toHaveLength(1) + expect(calls[0].cmd).toBe("security") + expect(calls[0].args).toEqual(["-i"]) + expect(calls[0].input).toBe('add-generic-password -U -s "altimate-code-integrations" -a "cred:github:github_token" -w "a\\"b\\\\c"\n') + }) + + test("read and delete use the keychain by account", async () => { + on(0, "stored-value\n") + expect(await core.readSecret("cred:github:github_token")).toBe("stored-value") + await core.deleteSecret("cred:github:github_token") + expect(calls.map((c) => [c.cmd, ...c.args])).toEqual([ + ["security", "find-generic-password", "-s", "altimate-code-integrations", "-a", "cred:github:github_token", "-w"], + ["security", "delete-generic-password", "-s", "altimate-code-integrations", "-a", "cred:github:github_token"], + ]) + }) + + test("a slow keychain call does not block the event loop", async () => { + on(0, "v\n") + let release!: () => void + hold = new Promise((r) => (release = r)) + let ticked = false + const read = core.readSecret("cred:github:github_token") + const store = core.storeSecret("cred:github:github_token", "x") + await new Promise((r) => + setTimeout(() => { + ticked = true + r() + }, 20), + ) + expect(ticked).toBe(true) + expect(calls).toHaveLength(2) + release() + expect(await read).toBe("v") + await store + }) + + test("a failed keychain delete is an error unless the item was already absent", async () => { + const del = () => core.deleteSecret("cred:github:github_token") + on(0) + await del() + on(44) + await del() + on(1) + await expect(del()).rejects.toThrow("macOS keychain") + on(51) + await expect(del()).rejects.toThrow("exit 51") + }) + + test("a disconnect the keychain refuses keeps the record, so it can be retried", async () => { + // The data dir falls back to the suite's temporary XDG location here, never the real one. + on(1) + await core.updateStore((s) => { + s.providers.github = { registered: true, synced: false, account: "me", variant: "token", values: {}, secretFields: ["github_token"], updatedAt: null } as any + }) + await expect(core.disconnect("github")).rejects.toThrow("macOS keychain") + expect(core.loadStore().providers.github?.registered).toBe(true) + await core.updateStore((s) => { + delete s.providers.github + }) + }) + + test("a keychain read is empty only when the item is missing; any other failure is an error", async () => { + const read = () => core.readSecret("cred:github:github_token") + on(44) + expect(await read()).toBeUndefined() + on(36) + await expect(read()).rejects.toThrow("Could not read a stored credential from the macOS keychain (exit 36)") + expect(calls.map((c) => c.args[0])).toEqual(["find-generic-password"]) + // A missing keychain prints "could not be found" too, but it is not a missing item. + on(50, "", "security: SecKeychainSearchCopyNext: The specified keychain could not be found.") + await expect(read()).rejects.toThrow("exit 50") + on(50, "", "The specified keychain could not be found.") + await expect(core.deleteSecret("cred:github:github_token")).rejects.toThrow("exit 50") + }) + + test("a keychain call that never answers is killed and reported, for reads and writes", async () => { + const limit = core.keychainLimits.timeoutMs + core.keychainLimits.timeoutMs = 20 + try { + on(0, "never") + hold = new Promise(() => {}) + await expect(core.readSecret("cred:github:github_token")).rejects.toThrow("timed out") + await expect(core.storeSecret("cred:github:github_token", "x")).rejects.toThrow("Could not save the credential to the macOS keychain") + expect(calls).toHaveLength(2) + expect(kills).toEqual(["SIGKILL", "SIGKILL"]) + } finally { + core.keychainLimits.timeoutMs = limit + } + }) + + test("a failed keychain write is an error", async () => { + on(1) + await expect(core.storeSecret("cred:github:github_token", "x")).rejects.toThrow("macOS keychain") + }) + + test("an account id outside the allowed characters is refused before security runs", async () => { + on() + for (const id of ["a\nadd-generic-password -a x", 'a"b', "a b", ""]) await expect(core.storeSecret(id, "x")).rejects.toThrow("not valid") + expect(calls).toEqual([]) + }) +}) + +describe("state file", () => { + test("is written for the owner only", () => { + core.saveStore({ blocked: {}, providers: {} }) + expect(fs.statSync(path.join(tmp, "integrations.json")).mode & 0o777).toBe(0o600) + }) +}) + +describe("state files that are not objects", () => { + test("an array, null or scalar root is an error and is left as it was", async () => { + const file = path.join(tmp, "integrations-secrets.json") + for (const text of ["[]", "null", "3", '"x"']) { + fs.writeFileSync(file, text) + await expect(core.storeSecret("cred:github:github_token", "x")).rejects.toThrow("expected a JSON object") + expect(fs.readFileSync(file, "utf8")).toBe(text) + } + fs.rmSync(file) + }) +}) + +describe("secret storage", () => { + test("a value with a line break is refused as not a token", async () => { + await expect(core.storeSecret("github", "abc\ndef")).rejects.toThrow("not a token") + expect(await core.readSecret("github")).toBeUndefined() + }) +}) + +describe("workspace connections", () => { + const calls: { method: string; path: string; query: string; auth: string | null; tenant: string | null; body: any }[] = [] + const api = connectionsStub() + let server: ReturnType + const saved = { ...process.env } + let githubApiBefore: string | undefined + + beforeEach(async () => { + calls.length = 0 + api.state.rows.length = 0 + api.state.next = 21 + api.state.valid = true + api.state.fail = null + api.state.listFails = false + server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url) + const body = req.method === "POST" || req.method === "PATCH" ? await req.json().catch(() => null) : null + if (url.pathname === "/user") { + const auth = req.headers.get("authorization") + if (auth === "Bearer gh_bad") return Response.json({ message: "Bad credentials" }, { status: 401 }) + // A token without an expiry is answered without the expiration header. + if (auth === "Bearer gh_no_expiry") return Response.json({ login: "octo" }) + return Response.json({ login: "octo" }, { headers: { "github-authentication-token-expiration": "2031-01-02 03:04:05 UTC" } }) + } + calls.push({ method: req.method, path: url.pathname, query: url.search, auth: req.headers.get("authorization"), tenant: req.headers.get("x-tenant"), body }) + return api.handle(req, url, body) ?? new Response("no", { status: 404 }) + }, + }) + delete process.env.ALTIMATE_INTEGRATIONS_MOCK + process.env.ALTIMATE_INTEGRATIONS_API_URL = `http://127.0.0.1:${server.port}` + process.env.ALTIMATE_INTEGRATIONS_TOKEN = "bearer-1" + process.env.ALTIMATE_INTEGRATIONS_TENANT = "acme" + process.env.ALTIMATE_INTEGRATIONS_HOME = fs.mkdtempSync(path.join(tmp, "empty-")) + process.env.GITHUB_TOKEN = "gh_env_token" + githubApiBefore = process.env.ALTIMATE_INTEGRATIONS_GITHUB_API + process.env.ALTIMATE_INTEGRATIONS_GITHUB_API = `http://127.0.0.1:${server.port}` + spyOn(workspaceState, "resolveBindingOutcome").mockResolvedValue({ status: "unbound" }) + await core.recordDetections([{ id: "github", source: { kind: "env", label: "GITHUB_TOKEN" }, account: null, syncable: true, note: null }]) + }) + afterEach(() => { + ;(workspaceState.resolveBindingOutcome as any).mockRestore?.() + server.stop(true) + process.env.ALTIMATE_INTEGRATIONS_MOCK = "1" + for (const k of ["ALTIMATE_INTEGRATIONS_API_URL", "ALTIMATE_INTEGRATIONS_TOKEN", "ALTIMATE_INTEGRATIONS_TENANT", "ALTIMATE_INTEGRATIONS_HOME"]) delete process.env[k] + if (githubApiBefore === undefined) delete process.env.ALTIMATE_INTEGRATIONS_GITHUB_API + else process.env.ALTIMATE_INTEGRATIONS_GITHUB_API = githubApiBefore + if (saved.GITHUB_TOKEN === undefined) delete process.env.GITHUB_TOKEN + else process.env.GITHUB_TOKEN = saved.GITHUB_TOKEN + }) + const writes = () => calls.filter((c) => c.method !== "GET") + + test("sync of a replaced detected token does not carry over the old token's expiry", async () => { + expect((await core.syncProvider("github")).result).toBe("synced") + expect(core.loadStore().providers.github?.expiresAt).toBe("2031-01-02T03:04:05.000Z") + process.env.GITHUB_TOKEN = "gh_no_expiry" + expect((await core.syncProvider("github")).result).toBe("synced") + expect(writes()[1].body).toEqual({ config: { github_token: "gh_no_expiry" } }) + }) + + test("sync creates the managed connection, then updates it in place", async () => { + const first = await core.syncProvider("github") + expect(first.result).toBe("synced") + expect(writes()).toHaveLength(1) + expect(writes()[0]).toMatchObject({ method: "POST", path: "/connections/", auth: "Bearer bearer-1", tenant: "acme" }) + expect(writes()[0].body).toEqual({ name: `GitHub (from ${HOST})`, integration_id: "github", config: { github_token: "gh_env_token", expires_at: "2031-01-02T03:04:05.000Z" } }) + expect(calls.find((c) => c.path.startsWith("/connections"))).toMatchObject({ method: "GET", path: "/connections/", query: "?include_shared=true" }) + expect(calls.filter((c) => c.method === "GET" && c.path === "/connections/")).toHaveLength(1) + expect(core.loadStore().providers.github).toMatchObject({ registered: true, synced: true, connectionId: 21 }) + + process.env.GITHUB_TOKEN = "gh_env_token_2" + const second = await core.syncProvider("github") + expect(second.result).toBe("synced") + expect(second.steps).toEqual([{ label: "Updated connection", ok: true }, { label: "Not linked to a workspace: run altimate link", ok: false }]) + expect(writes()[1]).toMatchObject({ method: "PATCH", path: "/connections/21", auth: "Bearer bearer-1", tenant: "acme" }) + expect(writes()[1].body).toEqual({ config: { github_token: "gh_env_token_2", expires_at: "2031-01-02T03:04:05.000Z" } }) + expect(api.state.rows).toHaveLength(1) + }) + + test("a new connection the workspace marks invalid is removed again and nothing is stored", async () => { + api.state.valid = false + const o = await core.syncProvider("github") + expect(o.result).toBe("failed") + expect(o.reason).toContain("refused") + expect(api.state.rows.filter((r) => !r.deleted)).toHaveLength(0) + expect(core.loadStore().providers.github?.registered).toBeUndefined() + api.state.valid = true + expect((await core.syncProvider("github")).result).toBe("synced") + expect(writes().map((c) => c.method)).toEqual(["POST", "DELETE", "POST"]) + expect(core.status().providers[0].state).toBe("connected") + }) + + test("backend errors keep the message (code) format", async () => { + api.state.fail = { status: 422, body: { detail: [{ loc: ["body", "config", "jiraEmail"], msg: "Email is not a valid email address", type: "value_error.format" }] } } + const o = await core.syncProvider("github") + expect([o.result, o.reason]).toEqual(["failed", "Email is not a valid email address (value_error.format)"]) + api.state.fail = { status: 400, body: { detail: "Fill in the required connection fields: Api Key" } } + expect((await core.syncProvider("github")).reason).toBe("Fill in the required connection fields: Api Key") + api.state.fail = { status: 409, body: { detail: "Reload this connection before saving: the stored github_token has changed" } } + expect((await core.syncProvider("github")).reason).toContain("Reload this connection") + expect(core.status().providers[0].state).not.toBe("connected") + }) + + test("status prefers the managed connection, else the latest validated own one, and skips shared ones", async () => { + api.seed({ name: "Old", integration_id: "jira", last_validated_at: "2030-01-01T00:00:00Z" }) + api.seed({ name: "Hand made", integration_id: "jira", last_validated_at: "2030-05-01T00:00:00Z" }) + api.seed({ name: "Teammate", integration_id: "linear", is_owner: false }) + api.seed({ name: `GitHub (from ${HOST})`, integration_id: "github", is_valid: false }) + api.seed({ name: "GitHub (from elsewhere)", integration_id: "github", last_validated_at: "2031-01-01T00:00:00Z" }) + const derived = core.deriveRemote(await backend.listOwnConnections((await backend.backendConfig())!)) + expect(derived.map((d) => [d.integration_id, d.name, d.managed, d.managedId])).toEqual([ + ["github", "GitHub (from elsewhere)", false, 24], + ["jira", "Hand made", false, null], + ]) + const s = await core.statusRemote() + expect(Object.keys(s.providers[0])).toEqual(["id", "name", "state", "source", "account", "syncable", "sync", "updatedAt", "expiresAt", "note"]) + expect(Object.keys(s.providers[1])).toContain("managed") + expect(s.providers.map((p) => [p.id, p.state, p.sync, p.source?.label ?? null])).toEqual([ + ["github", "connected", "synced", "Altimate workspace"], + ["jira", "connected", "synced", "Altimate workspace"], + ["linear", "not_found", "local", null], + ]) + }) + + test("a newer hand-made connection stays what is shown while the deleted-elsewhere check follows the managed one", async () => { + await core.syncProvider("github") + api.seed({ name: "GitHub (from elsewhere)", integration_id: "github", last_validated_at: "2031-01-01T00:00:00Z" }) + expect((await core.statusRemote()).providers[0]).toMatchObject({ state: "connected", sync: "synced" }) + api.state.rows.find((r) => r.name === `GitHub (from ${HOST})`)!.is_valid = false + const shown = (await core.statusRemote()).providers[0] + expect([shown.state, shown.managed]).toEqual(["rejected", undefined]) + api.state.rows.find((r) => r.name === `GitHub (from ${HOST})`)!.deleted = true + const gone = (await core.statusRemote()).providers[0] + expect([gone.state, gone.sync, gone.managed]).toEqual(["connected", "synced", false]) + api.state.rows.find((r) => r.name === "GitHub (from elsewhere)")!.deleted = true + const none = (await core.statusRemote()).providers[0] + // gh still holds the token here, so it is offered again rather than shown as missing. + expect([none.state, none.sync]).toEqual(["detected", "local"]) + }) + + test("a synced credential deleted in the workspace is offered again when it is still found here", () => { + const now = Date.parse("2026-11-03T00:00:00Z") + const detection = { id: "github" as const, source: { kind: "cli" as const, label: "gh CLI" }, account: "me", syncable: true, note: null } + const store = { blocked: {}, providers: { github: { detection, registered: true, synced: true, connectionId: 9, account: "me", variant: "token", values: {}, secretFields: ["github_token"], updatedAt: null, validatedAt: null } } } as any + const local = core.buildStatus(store, [], now) + const found = core.mergeRemote(local, [], now, [detection]).providers.find((p) => p.id === "github")! + expect([found.state, found.sync, found.source?.label, found.account]).toEqual(["detected", "local", "gh CLI", "me"]) + const gone = core.mergeRemote(local, [], now).providers.find((p) => p.id === "github")! + expect([gone.state, gone.note]).toEqual(["not_found", "The workspace connection was removed. Add the credentials again."]) + }) + + test("a synced record reports the managed connection's validity, whichever connection is shown", () => { + const now = Date.parse("2030-01-01T00:00:00Z") + const mine = { id: 3, user_id: 1, name: `Linear (from ${HOST})`, integration_id: "linear", is_valid: false, last_validated_at: "2029-01-01T00:00:00Z" } as any + const hand = { id: 4, user_id: 1, name: "Linear", integration_id: "linear", is_valid: true, last_validated_at: "2029-06-01T00:00:00Z" } as any + const remote = core.deriveRemote([mine, hand]) + expect([remote[0].connection_id, remote[0].managedId]).toEqual([4, 3]) + const rec = { registered: true, synced: true, account: "me", source: { kind: "cli", label: "test" }, updatedAt: new Date(now).toISOString() } as core.StoredProvider + const store = { blocked: {}, providers: { linear: rec } } as core.Store + const state = (rows: any[]) => core.mergeRemote(core.buildStatus(store, [], now), core.deriveRemote(rows), now).providers.find((p) => p.id === "linear")!.state + expect(state([mine, hand])).toBe("rejected") + expect(state([{ ...mine, is_valid: true }, { ...hand, is_valid: false }])).toBe("connected") + }) + + test("a synced record's expiry comes from the managed connection, not the one shown", () => { + const now = Date.parse("2030-01-01T00:00:00Z") + const mine = { id: 3, user_id: 1, name: `Linear (from ${HOST})`, integration_id: "linear", is_valid: true, last_validated_at: "2029-01-01T00:00:00Z", config: { expires_at: "2029-12-31T00:00:00Z" } } as any + const hand = { id: 4, user_id: 1, name: "Linear", integration_id: "linear", is_valid: true, last_validated_at: "2029-06-01T00:00:00Z", config: { expires_at: "2031-01-01T00:00:00Z" } } as any + const rec = { registered: true, synced: true, account: "me", source: { kind: "cli", label: "test" }, updatedAt: new Date(now).toISOString() } as core.StoredProvider + const store = { blocked: {}, providers: { linear: rec } } as core.Store + const p = core.mergeRemote(core.buildStatus(store, [], now), core.deriveRemote([mine, hand]), now).providers.find((x) => x.id === "linear")! + expect([p.state, p.expiresAt]).toEqual(["expired", "2029-12-31T00:00:00Z"]) + }) + + test("sync --keep-synced takes the attach-only path that sync --all takes", async () => { + expect(skipsSynced({ provider: undefined, all: true })).toBe(true) + expect(skipsSynced({ provider: "github" })).toBe(false) + expect(skipsSynced({ provider: "github", keepSynced: true })).toBe(true) + await core.syncProvider("github") + const before = writes().length + process.env.GITHUB_TOKEN = "gh_env_token_other" + const kept = await core.syncProvider("github", undefined, 0, skipsSynced({ provider: "github", keepSynced: true })) + expect(kept.steps[0]).toEqual({ label: "Connection already in the workspace", ok: true }) + expect(writes()).toHaveLength(before) + await core.setBlocked(["github"], true) + expect((await core.syncProvider("github", undefined, 0, true)).result).toBe("kept_local") + }) + + test("a saved credential whose config cannot be collected fails that provider's sync with the reason, instead of stopping the run", async () => { + await connected("github", null, { variant: "token", values: {}, secretFields: ["github_token"] }) + const unreadable = "Could not read a stored credential from the macOS keychain (exit 36). Unlock the keychain and try again." + const catalog = spyOn(creds, "loadCatalog").mockRejectedValue(new Error(unreadable)) + try { + const o = await core.syncProvider("github") + expect([o.result, o.reason]).toEqual(["failed", unreadable]) + expect(writes()).toEqual([]) + } finally { + catalog.mockRestore() + } + }) + + test("sync to a shared workspace attaches nothing without --share and reports why; with it the outcome is marked shared", async () => { + await core.syncProvider("github") + const spies = [ + spyOn(workspaceState, "resolveBindingOutcome").mockResolvedValue({ status: "bound", binding: { datamateId: 5, datamateName: "WS" } } as any), + spyOn(backend, "getDatamate").mockResolvedValue({ id: "5", name: "WS", is_owner: true, privacy: "public", integrations: [{ id: "github", tools: [], connection_id: null }] } as any), + ] + const update = spyOn(backend, "updateDatamateIntegrations").mockResolvedValue(undefined as any) + try { + const refused = await core.syncProvider("github", undefined, 0, true) + expect([refused.result, refused.reason]).toEqual(["failed", core.ATTACH_FAILED_PREFIX + core.SHARED_WORKSPACE_REFUSAL("WS", "github")]) + expect(update).not.toHaveBeenCalled() + const shared = await core.syncProvider("github", undefined, 0, true, true) + expect([shared.result, shared.shared]).toEqual(["synced", true]) + expect([syncedTo(shared), syncedTo(refused)]).toEqual(["synced to workspace, shared with its members", "synced to workspace (private)"]) + expect(update).toHaveBeenCalledTimes(1) + } finally { + for (const sp of [...spies, update]) sp.mockRestore() + } + }) + + test("attach-only runs under the lock and names the kept workspace inert, with its id", async () => { + await core.syncProvider("github") + const events: string[] = [] + const spies = [ + spyOn(workspaceState, "resolveBindingOutcome").mockResolvedValue({ status: "bound", binding: { datamateId: 5, datamateName: "WS\nx" } } as any), + spyOn(backend, "getDatamate").mockResolvedValue({ id: "5", name: "WS", is_owner: true, privacy: "private", integrations: [{ id: "github", tools: [], connection_id: "999" }] } as any), + spyOn(backend, "updateDatamateIntegrations").mockImplementation((async () => void events.push("patch")) as any), + ] + api.seed({ name: "Teammate's", integration_id: "github", is_owner: false }) + api.state.rows[api.state.rows.length - 1].id = 999 + let release = () => {} + let acquired!: () => void + const ready = new Promise((resolve) => (acquired = resolve)) + const holder = core.withLock(() => { + acquired() + return new Promise((r) => (release = r)) + }) + await ready + try { + const pending = core.syncProvider("github", undefined, 0, true) + await Bun.sleep(400) + expect(events).toEqual([]) + release() + await holder + const out = await pending + expect(events).toEqual(["patch"]) + const kept = out.steps.find((st) => st.label.startsWith("Kept"))!.label + expect(kept).toContain("(id 5)") + expect(kept).not.toContain("\n") + } finally { + release() + for (const sp of spies) sp.mockRestore() + } + }) + + test("sync over every provider leaves a saved connection alone and only attaches it", async () => { + await core.syncProvider("github") + const before = writes().length + process.env.GITHUB_TOKEN = "gh_env_token_other" + const all = await core.syncProvider("github", undefined, 0, true) + expect(all.result).toBe("synced") + expect(all.steps[0]).toEqual({ label: "Connection already in the workspace", ok: true }) + expect(writes()).toHaveLength(before) + expect((await core.syncProvider("github")).steps[0].label).toBe("Updated connection") + expect(writes()).toHaveLength(before + 1) + }) + + test("block still sets the policy when the workspace cannot be read, and says it was not checked", async () => { + api.state.listFails = true + expect(await core.blockProviders(["github"])).toEqual({ blocked: ["github"], refused: [], unchecked: ["github"] }) + expect(core.isBlocked("github")).toBe(true) + }) + + test("block refuses a provider whose managed connection is in the workspace", async () => { + api.seed({ name: `Linear (from ${HOST})`, integration_id: "linear" }) + expect(await core.blockProviders(["linear", "github"])).toEqual({ blocked: ["github"], refused: ["linear"], unchecked: [] }) + expect(core.isBlocked("linear")).toBe(false) + }) + + test("disconnect waits for a running upload instead of interleaving with it", async () => { + await core.syncProvider("github") + let release = () => {} + let acquired!: () => void + const ready = new Promise((resolve) => (acquired = resolve)) + const holder = core.withLock(() => { + acquired() + return new Promise((r) => (release = r)) + }) + await ready + const pending = core.disconnect("github") + await Bun.sleep(400) + expect(calls.filter((c) => c.method === "DELETE")).toEqual([]) + release() + await holder + expect((await pending).removedRemote).toBe(1) + expect(core.isSynced("github")).toBe(false) + }) + + test("sync only uploads a detected or registered credential, never a typed-in draft", async () => { + await core.storeSecret(core.secretKey("draft", "github", "github_token"), "draft_never_validated") + const o = await core.syncProvider("github") + expect(o.result).toBe("synced") + expect(writes()[0].body.config).toEqual({ github_token: "gh_env_token", expires_at: "2031-01-02T03:04:05.000Z" }) + }) + + test("sync moves a local credential to the workspace and leaves no secret here", async () => { + await core.storeSecret(core.secretKey("cred", "github", "github_token"), "gh_local_tok") + await core.storeSecret("github", "gh_local_tok") + const store = core.loadStore() + store.providers.github = { registered: true, synced: false, variant: "token", secretFields: ["github_token"], values: {} } + core.saveStore(store) + expect((await core.syncProvider("github")).result).toBe("synced") + expect(writes()[0].body.config).toEqual({ github_token: "gh_local_tok", expires_at: "2031-01-02T03:04:05.000Z" }) + expect(await core.readSecret(core.secretKey("cred", "github", "github_token"))).toBeUndefined() + expect(await core.readSecret("github")).toBeUndefined() + expect(await core.readSecret("github")).toBeUndefined() + }) + + test("a sync that lost the race to Keep local uploads nothing", async () => { + await core.setBlocked(["github"], true) + const o = await core.syncProvider("github") + expect(o.result).toBe("kept_local") + expect(writes()).toEqual([]) + }) + + test("a block that lands while an attach-only sync waits for the lock keeps the credential local and says why", async () => { + await core.syncProvider("github") + let acquired!: () => void + const ready = new Promise((resolve) => (acquired = resolve)) + const holder = core.withLock(async () => { + acquired() + await Bun.sleep(300) + await core.setBlocked(["github"], true) + }) + await ready + const o = await core.syncProvider("github", undefined, 0, true) + await holder + expect([o.result, o.reason]).toEqual(["kept_local", "blocked by policy"]) + }) + + test("a refusal of a record that was replaced while the provider was being asked does not mark the new record rejected", async () => { + const keep = async () => { + await core.storeSecret(core.secretKey("cred", "github", "github_token"), "old_token_value") + await core.updateStore((st) => { + st.providers.github = { registered: true, synced: false, account: "me", variant: "token", values: {}, secretFields: ["github_token"], validatedAt: "2030-01-01T00:00:00Z" } + }) + } + const refused = { ok: false, rejected: true, unavailable: false, account: null, expiresAt: null, message: "GitHub rejected the token (401)" } + await keep() + const replaced = spyOn(creds, "validate").mockImplementation(async () => { + await core.updateStore((st) => { + st.providers.github = { ...st.providers.github!, validatedAt: "2030-02-02T00:00:00Z" } + }) + return refused + }) + try { + expect((await core.syncProvider("github")).result).toBe("failed") + expect(core.loadStore().providers.github?.rejected).toBeFalsy() + replaced.mockImplementation(async () => refused) + expect((await core.syncProvider("github")).result).toBe("failed") + expect(core.loadStore().providers.github?.rejected).toBe(true) + } finally { + replaced.mockRestore() + } + }) + + test("a link that cannot be looked up is reported, not read as not linked; without a login there is nothing to look up", async () => { + const outcome = workspaceState.resolveBindingOutcome as any + outcome.mockResolvedValue({ status: "unknown" }) + const login = spyOn(AltimateApi, "isConfigured").mockResolvedValue(true) + try { + const o = await core.syncProvider("github") + expect([o.result, o.reason]).toEqual(["failed", expect.stringContaining(core.ATTACH_FAILED_PREFIX)]) + expect(o.reason).toContain("could not tell whether this directory is linked") + await core.disconnect("github") + login.mockResolvedValue(false) + const bare = await core.syncProvider("github") + expect([bare.result, bare.steps.at(-1)?.label]).toEqual(["synced", core.NOT_LINKED_LABEL]) + } finally { + login.mockRestore() + } + }) + + test("a local cleanup that fails after the workspace accepted the connection is reported on a synced result", async () => { + await core.storeSecret("github", "leftover_token") + const rename = fs.renameSync + const spy = spyOn(fs, "renameSync").mockImplementation(((from: string, to: string) => { + if (String(to).endsWith("integrations-secrets.json")) throw new Error("disk full") + return rename(from, to) + }) as any) + try { + const o = await core.syncProvider("github") + expect(o.result).toBe("synced") + expect(o.steps.find((st) => !st.ok)?.label).toContain("local copy could not be removed: disk full") + } finally { + spy.mockRestore() + } + expect(core.isSynced("github")).toBe(true) + }) + + test("a record that cannot be written after the workspace accepted the upload is reported, and a retry updates the same connection", async () => { + await core.storeSecret("github", "leftover_token") + const rename = fs.renameSync + const spy = spyOn(fs, "renameSync").mockImplementation(((from: string, to: string) => { + if (String(to).endsWith("integrations.json")) throw new Error("disk full") + return rename(from, to) + }) as any) + let first: core.SyncOutcome + try { + first = await core.syncProvider("github") + } finally { + spy.mockRestore() + } + expect(first.result).toBe("failed") + expect(first.reason).toContain("Saved to your workspace, but the record on this machine could not be written (disk full)") + expect(core.isSynced("github")).toBe(false) + const again = await core.syncProvider("github") + expect(again.result).toBe("synced") + expect(again.steps[0]?.label).toBe("Updated connection") + }) + + test("disconnect says when the connection could not be detached from the linked workspace", async () => { + api.seed({ name: `Linear (from ${HOST})`, integration_id: "linear" }) + const spy = spyOn(workspaceState, "resolveBindingOutcome").mockResolvedValue({ status: "bound", binding: { datamateId: 5, datamateName: "WS" } } as any) + try { + const r = await core.disconnect("linear") + expect([r.removedRemote, r.detached, typeof r.detachMessage]).toEqual([1, false, "string"]) + expect(api.state.rows.filter((x) => !x.deleted)).toHaveLength(0) + } finally { + spy.mockRestore() + } + }) + + test("disconnect warns when it cannot tell whether the directory is linked, and not when it is unlinked", async () => { + const spy = spyOn(workspaceState, "resolveBindingOutcome") + try { + api.seed({ name: `Linear (from ${HOST})`, integration_id: "linear" }) + spy.mockResolvedValue({ status: "unknown" }) + const unknown = await core.disconnect("linear") + expect([unknown.removedRemote, unknown.detached, unknown.detachMessage]).toEqual([1, false, expect.stringContaining("could not tell")]) + api.seed({ name: `Linear (from ${HOST})`, integration_id: "linear" }) + spy.mockResolvedValue({ status: "unbound" }) + const unbound = await core.disconnect("linear") + expect([unbound.removedRemote, unbound.detached]).toEqual([1, true]) + } finally { + spy.mockRestore() + } + }) + + test("a synced record marked rejected locally is connected once the workspace shows the connection valid", async () => { + await core.syncProvider("github") + await core.updateStore((st) => { + st.providers.github!.rejected = true + }) + const row = api.state.rows.find((r) => r.name === `GitHub (from ${HOST})`)! + row.is_valid = false + expect((await core.statusRemote()).providers[0].state).toBe("rejected") + row.is_valid = true + expect((await core.statusRemote()).providers[0]).toMatchObject({ state: "connected", sync: "synced", note: null }) + }) + + test("sync over every provider leaves a workspace connection alone when this machine has no record of it", async () => { + api.seed({ name: `GitHub (from ${HOST})`, integration_id: "github", config: { github_token: "from_web" } }) + const all = await core.syncProvider("github", undefined, 0, true) + expect(all.result).toBe("synced") + expect(all.steps[0]).toEqual({ label: "Connection already in the workspace", ok: true }) + expect(writes()).toEqual([]) + expect(core.loadStore().providers.github?.registered).toBeFalsy() + }) + + test("sync over every provider does not attach a workspace connection the workspace marks invalid", async () => { + api.seed({ name: `GitHub (from ${HOST})`, integration_id: "github", is_valid: false }) + const all = await core.syncProvider("github", undefined, 0, true) + expect(all.result).toBe("skipped") + expect(all.reason).toBe("the workspace connection is marked invalid; run update") + expect(writes()).toEqual([]) + }) + + test("sync over every provider attaches a workspace connection when nothing for the provider is detected here", async () => { + api.seed({ name: `Linear (from ${HOST})`, integration_id: "linear" }) + const all = await core.syncProvider("linear", undefined, 0, true) + expect([all.result, all.steps[0]]).toEqual(["synced", { label: "Connection already in the workspace", ok: true }]) + expect(writes()).toEqual([]) + expect((await core.syncProvider("jira", undefined, 0, true)).result).toBe("skipped") + expect(writes()).toEqual([]) + }) + + test("the status of a synced record takes the workspace connection's expiry on the real path", async () => { + const row = api.seed({ name: `Linear (from ${HOST})`, integration_id: "linear", config: { expires_at: "2031-01-01T00:00:00Z" } }) + await connected("linear", "2029-12-31T00:00:00Z", { account: "me", synced: true }) + await core.updateStore((st) => { + st.providers.linear!.connectionId = row.id + }) + const linear = (await core.statusRemote()).providers.find((p) => p.id === "linear")! + expect([linear.state, linear.expiresAt]).toEqual(["connected", "2031-01-01T00:00:00Z"]) + }) + + test("a synced record fixed in the workspace is attached by a run over every provider", async () => { + const row = api.seed({ name: `GitHub (from ${HOST})`, integration_id: "github" }) + const store = core.loadStore() + store.providers.github = { registered: true, synced: true, rejected: true, connectionId: row.id, variant: "default", secretFields: ["github_token"], values: {} } as any + core.saveStore(store) + const all = await core.syncProvider("github", undefined, 0, true) + expect(all.result).toBe("synced") + }) + + test("a synced record takes its expiry from the workspace over an older local one", () => { + const now = Date.parse("2030-01-01T00:00:00Z") + const remote: core.RemoteProvider[] = [{ integration_id: "linear", connection_id: 3, name: "Linear", account: null, managed: true, source: null, expires_at: "2031-01-01T00:00:00Z", expired: false, is_valid: true }] + return connected("linear", "2029-12-31T00:00:00Z", { account: "me", synced: true }).then(() => { + const merged = core.mergeRemote(core.buildStatus(core.loadStore(), [], now), remote, now).providers.find((p) => p.id === "linear")! + expect([merged.state, merged.expiresAt]).toEqual(["connected", "2031-01-01T00:00:00Z"]) + const gone = core.mergeRemote(core.buildStatus(core.loadStore(), [], Date.parse("2029-06-01T00:00:00Z")), [{ ...remote[0], expires_at: "2029-07-01T00:00:00Z" }], now).providers.find((p) => p.id === "linear")! + expect([gone.state, gone.expiresAt]).toEqual(["expired", "2029-07-01T00:00:00Z"]) + }) + }) + + test("a sync prepared before a same-variant local update uploads nothing", async () => { + await connected("github", null, { account: "me", variant: "token", secretFields: ["github_token"], values: {} }) + await core.storeSecret(core.secretKey("cred", "github", "github_token"), "gh_local_tok") + const original = creds.validate + const spy = spyOn(creds, "validate").mockImplementation(async (...args: Parameters) => { + const checked = await original(...args) + await core.updateStore((st) => { + st.providers.github = { ...st.providers.github, validatedAt: "2031-01-01T00:00:00Z" } + }) + return checked + }) + try { + const out = await core.syncProvider("github") + expect(out.result).toBe("failed") + expect(writes()).toEqual([]) + } finally { + spy.mockRestore() + } + }) + + test("a sync prepared before the record changed uploads nothing", async () => { + const original = creds.detectedConfig + const spy = spyOn(creds, "detectedConfig").mockImplementation(async (id: any) => { + const prepared = await original(id) + await core.updateStore((st) => { + st.providers.github = { ...st.providers.github, registered: true, synced: true, connectionId: 99 } + }) + return prepared + }) + try { + const out = await core.syncProvider("github") + expect(out.result).toBe("failed") + expect(writes()).toEqual([]) + } finally { + spy.mockRestore() + } + }) + + test("sync records the provider check: validatedAt and account on success, rejected for a refused stored credential", async () => { + const ok = await core.syncProvider("github") + expect(ok.result).toBe("synced") + const rec = core.loadStore().providers.github! + expect([rec.account, typeof rec.validatedAt, rec.rejected]).toEqual(["octo", "string", false]) + + await connected("github", null, { account: "me", variant: "token", secretFields: ["github_token"], values: {} }, null) + await core.storeSecret(core.secretKey("cred", "github", "github_token"), "gh_bad") + const before = writes().length + const refused = await core.syncProvider("github") + expect(refused.result).toBe("failed") + expect(writes().length).toBe(before) + const after = core.loadStore().providers.github! + expect([after.rejected, typeof after.validatedAt, after.registered, after.synced]).toEqual([true, "string", true, false]) + }) + + test("sync uploads nothing when the provider rejects the detected credential, or cannot be reached", async () => { + process.env.GITHUB_TOKEN = "gh_bad" + const refused = await core.syncProvider("github") + expect([refused.result, refused.reason]).toEqual(["failed", "GitHub rejected the token (401): Bad credentials"]) + expect(writes()).toEqual([]) + expect(core.loadStore().providers.github?.registered).toBeFalsy() + process.env.GITHUB_TOKEN = "gh_env_token" + process.env.ALTIMATE_INTEGRATIONS_GITHUB_API = "http://127.0.0.1:1" + const down = await core.syncProvider("github") + expect(down.result).toBe("failed") + expect(down.reason).toContain("Could not reach GitHub") + expect(writes()).toEqual([]) + }) + + test("a synced connection carries the known expiry, so the workspace view shows it", async () => { + expect((await core.syncProvider("github")).result).toBe("synced") + expect((writes()[0].body.config as any).expires_at).toBe("2031-01-02T03:04:05.000Z") + expect((await core.statusRemote()).providers[0]).toMatchObject({ state: "connected", sync: "synced", expiresAt: "2031-01-02T03:04:05.000Z" }) + }) + + test("sync over every provider says a synced connection deleted in the workspace is gone, not already there", async () => { + await core.syncProvider("github") + api.state.rows.find((r) => r.name === `GitHub (from ${HOST})`)!.deleted = true + const all = await core.syncProvider("github", undefined, 0, true) + expect([all.result, all.reason]).toEqual(["skipped", "The workspace connection was removed. Add the credentials again."]) + }) + + test("sync over every provider attaches a workspace connection even when the only thing found here is a remote MCP server", async () => { + await core.recordDetections([{ id: "linear", source: { kind: "mcp-remote", label: "Claude MCP" }, account: null, syncable: false, note: "usable in Claude; connect in browser to sync" }]) + api.seed({ name: `Linear (from ${HOST})`, integration_id: "linear" }) + const all = await core.syncProvider("linear", undefined, 0, true) + expect([all.result, all.steps[0]]).toEqual(["synced", { label: "Connection already in the workspace", ok: true }]) + const plain = await core.syncProvider("linear") + expect([plain.result, plain.reason]).toEqual(["skipped", "usable in Claude; connect in browser to sync"]) + }) + + test("a credential found here is still offered next to a hand-made workspace connection", async () => { + api.seed({ name: "Hand made", integration_id: "github", last_validated_at: "2030-05-01T00:00:00Z" }) + const shown = (await core.statusRemote()).providers[0] + expect([shown.state, shown.managed, shown.found]).toEqual(["connected", false, { source: { kind: "env", label: "GITHUB_TOKEN" }, account: null }]) + const plain = (await core.statusRemote()).providers[1] + expect(plain.found).toBeUndefined() + }) + + test("purging a provider's secrets covers every variant, not only the connected one", async () => { + await core.storeSecret(core.secretKey("cred", "jira", "jiraApiToken"), "cloud-token") + await core.storeSecret(core.secretKey("cred", "jira", "jiraPatToken"), "server-token") + await core.purgeCredentialSecrets("jira", { registered: true, secretFields: ["jiraPatToken"] } as any, false) + expect(await core.readSecret(core.secretKey("cred", "jira", "jiraApiToken"))).toBeFalsy() + expect(await core.readSecret(core.secretKey("cred", "jira", "jiraPatToken"))).toBeFalsy() + }) + + test("purging a provider's secrets keeps the fields just written", async () => { + await core.storeSecret(core.secretKey("cred", "jira", "jiraApiToken"), "new-token") + await core.storeSecret(core.secretKey("cred", "jira", "jiraPatToken"), "old-token") + await core.purgeCredentialSecrets("jira", undefined, false, new Set(["jiraApiToken"])) + expect(await core.readSecret(core.secretKey("cred", "jira", "jiraApiToken"))).toBe("new-token") + expect(await core.readSecret(core.secretKey("cred", "jira", "jiraPatToken"))).toBeFalsy() + }) + + test("a refused new connection that cannot be removed still reports the refusal", async () => { + api.state.valid = false + const del = spyOn(backend, "deleteConnection").mockRejectedValue(new Error("delete down")) + try { + const out = await core.syncProvider("github") + expect(out.result).toBe("failed") + expect(out.reason).toContain("refused") + expect(out.reason).toContain("It could not be removed from the workspace, remove it there") + } finally { + del.mockRestore() + } + }) + + test("a fresh local connection wins over the remote one", async () => { + const now = Date.parse("2030-01-01T00:00:00Z") + const remote: core.RemoteProvider[] = [{ integration_id: "linear", connection_id: 3, name: "Linear", account: null, managed: true, source: null, expires_at: null, expired: false, is_valid: false }] + await connected("linear", "2030-01-01T01:00:00Z", { account: "me" }) + const merged = core.mergeRemote(core.buildStatus(core.loadStore(), [], now), remote, now).providers.find((p) => p.id === "linear")! + expect([merged.state, merged.sync, merged.note]).toEqual(["rejected", "synced", "Token rejected, update it"]) + const hand = core.mergeRemote(core.buildStatus(core.loadStore(), [], now), [{ ...remote[0], managed: false, is_valid: true }], now).providers.find((p) => p.id === "linear")! + expect([hand.state, hand.sync]).toEqual(["connected", "local"]) + }) + + test("disconnect deletes only the managed connections", async () => { + api.seed({ name: `Linear (from ${HOST})`, integration_id: "linear" }) + api.seed({ name: `Linear (from ${HOST})`, integration_id: "linear" }) + api.seed({ name: "Linear", integration_id: "linear" }) + api.seed({ name: `GitHub (from ${HOST})`, integration_id: "github" }) + api.seed({ name: "Linear (from another-host)", integration_id: "linear" }) + expect(await core.disconnectRemote("linear")).toBe(2) + expect(calls.filter((c) => c.method === "DELETE").map((c) => c.path)).toEqual(["/connections/21", "/connections/22"]) + expect(api.state.rows.filter((r) => !r.deleted).map((r) => r.name)).toEqual(["Linear", `GitHub (from ${HOST})`, "Linear (from another-host)"]) + }) + + test("a failed workspace delete keeps the local credential so disconnect can be retried", async () => { + await connected("linear", null) + api.seed({ name: `Linear (from ${HOST})`, integration_id: "linear" }) + api.state.fail = { status: 500, body: { detail: "boom" } } + await expect(core.disconnect("linear")).rejects.toThrow() + expect(core.status().providers.find((p) => p.id === "linear")!.state).toBe("connected") + expect(await core.readSecret("linear")).toBe("tok_linear") + api.state.fail = null + expect(await core.disconnect("linear")).toEqual({ removedLocal: true, removedRemote: 1, detached: true }) + expect(core.status().providers.find((p) => p.id === "linear")!.state).toBe("not_found") + expect(await core.readSecret("linear")).toBeUndefined() + }) + + test("upsertManaged reports when the project is not linked to a workspace", async () => { + const cfg = (await backend.backendConfig())! + const res = await core.upsertManaged(cfg, "github", { github_token: "t" }) + expect(res.workspace).toEqual({ notLinked: true }) + expect(calls.filter((c) => c.path.startsWith("/datamates/"))).toEqual([]) + }) + + test("workspace info reports an unlinked project", async () => { + expect(await core.liveWorkspaceInfo()).toEqual({ name: "acme", tenant: "acme", linked: false, id: null, signedIn: true }) + }) + + test("an already saved connection with no local secret is verified and reported as not linked", async () => { + const row = api.seed({ name: `GitHub (from ${HOST})`, integration_id: "github" }) + const store = core.loadStore() + store.providers.github = { registered: true, synced: true, connectionId: row.id, variant: "default", secretFields: ["github_token"], values: {} } as any + core.saveStore(store) + delete process.env.GITHUB_TOKEN + const out = await core.syncProvider("github") + expect(out.result).toBe("synced") + expect(out.steps).toEqual([{ label: "Connection already in the workspace", ok: true }, { label: "Not linked to a workspace: run altimate link", ok: false }]) + expect(writes()).toEqual([]) + }) + + test("a saved connection that is gone from the workspace is still skipped", async () => { + const store = core.loadStore() + store.providers.github = { registered: true, synced: true, connectionId: 999, variant: "default", secretFields: ["github_token"], values: {} } as any + core.saveStore(store) + delete process.env.GITHUB_TOKEN + expect((await core.syncProvider("github")).result).toBe("skipped") + }) + + test("only the existing connections endpoints are called", async () => { + await core.syncProvider("github") + await core.statusRemote() + await core.disconnectRemote("github") + expect(calls.length).toBeGreaterThan(3) + expect(calls.filter((c) => c.path.startsWith("/integrations/"))).toEqual([]) + expect(calls.every((c) => c.auth === "Bearer bearer-1" && c.tenant === "acme")).toBe(true) + expect(calls.filter((c) => !c.path.startsWith("/datamate-project-bindings/")).every((c) => c.path === "/datamate_integrations/" || c.path === "/connections/" || /^\/connections\/\d+$/.test(c.path))).toBe(true) + }) + + test("the CLI exits non-zero on a failed sync, reports step outcomes, and refuses to block a synced connection", async () => { + const cli = path.join(import.meta.dir, "../../src/index.ts") + const run = async (...args: string[]) => { + const p = Bun.spawn([process.execPath, "run", cli, "integrations", ...args], { env: { ...process.env, ALTIMATE_INTEGRATIONS_DIR: tmp }, stdout: "pipe", stderr: "pipe" }) + const [stdout] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]) + return { code: await p.exited, data: JSON.parse(stdout) } + } + api.state.valid = false + const failed = await run("sync", "github", "--json") + expect([failed.code, failed.data.results[0].result]).toEqual([1, "failed"]) + expect(failed.data.results[0].steps).toEqual([]) + api.state.valid = true + expect((await run("sync", "github", "--json")).code).toBe(0) + const blocked = await run("block", "github", "--json") + expect([blocked.code, blocked.data.refused, blocked.data.providers]).toEqual([1, ["github"], []]) + expect(core.isBlocked("github")).toBe(false) + api.seed({ name: `Linear (from ${HOST})`, integration_id: "linear" }) + const remoteOnly = await run("block", "linear", "--json") + expect([remoteOnly.code, remoteOnly.data.refused, remoteOnly.data.providers]).toEqual([1, ["linear"], []]) + expect(core.isBlocked("linear")).toBe(false) + }, 60_000) + + test("a hand-made connection is shown as not managed", async () => { + api.seed({ name: "Hand made", integration_id: "jira" }) + const jira = (await core.statusRemote()).providers.find((p) => p.id === "jira")! + expect([jira.state, jira.managed]).toEqual(["connected", false]) + }) + + test("a local expiry is not erased by a workspace copy of unknown expiry", async () => { + const now = Date.parse("2030-01-01T00:00:00Z") + const remote: core.RemoteProvider[] = [{ integration_id: "linear", connection_id: 3, name: "Linear", account: null, managed: true, source: null, expires_at: null, expired: false, is_valid: false }] + await connected("linear", "2029-12-31T00:00:00Z", { account: "me", synced: true }) + const merged = core.mergeRemote(core.buildStatus(core.loadStore(), [], now), remote, now).providers.find((p) => p.id === "linear")! + expect([merged.state, merged.expiresAt]).toEqual(["expired", "2029-12-31T00:00:00Z"]) + }) + + test("without a signed-in backend nothing is removed", async () => { + expect(await core.disconnectRemote("linear", null)).toEqual(0) + }) + + test("status says so when the workspace could not be read, instead of showing local states as current", async () => { + await connected("github", null, { synced: true, connectionId: 21 }, null) + expect((await core.statusRemote()).workspaceUnchecked).toBeUndefined() + api.state.listFails = true + const r = await core.statusRemote() + expect(r.workspaceUnchecked).toContain("unavailable") + expect(r.providers.find((p) => p.id === "github")?.state).toBe("connected") + }) + + test("a link remembered from earlier is not attached to or detached from", async () => { + const outcome = workspaceState.resolveBindingOutcome as any + outcome.mockResolvedValue({ status: "bound", stale: true, binding: { datamateId: "9", datamateName: "Old" } }) + const login = spyOn(AltimateApi, "isConfigured").mockResolvedValue(true) + try { + const cfg = (await backend.backendConfig())! + const attached = await core.attachToBoundWorkspace(cfg, "github", 21) + expect(attached).toEqual({ error: expect.stringContaining("could not confirm which workspace") }) + api.seed({ name: `Linear (from ${HOST})`, integration_id: "linear" }) + const gone = await core.disconnect("linear") + expect([gone.removedRemote, gone.detached, gone.detachMessage]).toEqual([1, false, expect.stringContaining("could not confirm which workspace")]) + expect(calls.some((c) => c.path.startsWith("/datamates"))).toBe(false) + expect(api.state.rows.filter((x) => !x.deleted)).toHaveLength(0) + login.mockResolvedValue(false) + expect(await core.attachToBoundWorkspace(cfg, "github", 21)).toEqual({ notLinked: true }) + } finally { + login.mockRestore() + } + }) + + test("a state file that cannot be parsed is an error and is not replaced by an empty one", async () => { + const file = path.join(tmp, "integrations.json") + fs.writeFileSync(file, "{not json") + expect(() => core.loadStore()).toThrow(/Could not read .*integrations\.json/) + await expect(core.setBlocked(["github"], true)).rejects.toThrow("Could not read") + expect(fs.readFileSync(file, "utf8")).toBe("{not json") + fs.rmSync(file) + expect(core.loadStore()).toEqual({ blocked: {}, providers: {} }) + }) + + test("a secrets file that cannot be parsed is not replaced when another secret is stored", async () => { + const file = path.join(tmp, "integrations-secrets.json") + fs.writeFileSync(file, "[oops") + await expect(core.storeSecret("cred:github:github_token", "x")).rejects.toThrow("Could not read") + expect(fs.readFileSync(file, "utf8")).toBe("[oops") + }) + + test("an unreadable Altimate login does not stop the local paths, but does stop removing a workspace copy", async () => { + const login = spyOn(AltimateApi, "getCredentials").mockRejectedValue(new SyntaxError("Unexpected token n in JSON")) + for (const k of ["ALTIMATE_INTEGRATIONS_API_URL", "ALTIMATE_INTEGRATIONS_TENANT", "ALTIMATE_INTEGRATIONS_TOKEN"]) delete process.env[k] + try { + expect((await creds.loadCatalog("github", true))[0].fields.map((f) => f.name)).toContain("github_token") + await connected("github", null, { synced: false }) + expect(await core.disconnect("github")).toMatchObject({ removedLocal: true, removedRemote: 0, detached: true }) + await connected("linear", null, { synced: true, connectionId: 3 }) + await expect(core.disconnect("linear")).rejects.toThrow("Could not read your Altimate login") + const st = await core.statusRemote() + expect(st.workspaceUnchecked).toContain("Could not read your Altimate login") + expect(st.providers.find((p) => p.id === "linear")?.state).toBe("connected") + expect(core.loadStore().providers.linear?.registered).toBe(true) + } finally { + login.mockRestore() + } + }) +}) + +describe("failure descriptions", () => { + test("validation lists, string details and codes", () => { + expect(backend.describeFailure(422, { detail: [{ loc: ["body", "name"], msg: "Name can't be blank", type: "blank_field" }, { loc: ["body", "config"], msg: "Settings can't be empty", type: "empty_field" }] })).toBe( + "Name can't be blank; Settings can't be empty (blank_field)", + ) + expect(backend.describeFailure(400, { detail: "Fill in the required connection fields: Domain" })).toBe("Fill in the required connection fields: Domain") + expect(backend.describeFailure(409, { detail: { code: "stored_secret_changed" } })).toBe("The saved credential changed, reload and try again (stored_secret_changed)") + expect(backend.describeFailure(404, { detail: { code: "gone", message: "Connection not found" } })).toBe("Connection not found (gone)") + expect(backend.describeFailure(500, null)).toBe("status 500") + }) + + test("a 404 does not claim a connection was missing, as it can come from a workspace or the catalog", () => { + expect(backend.describeFailure(404, null)).toBe("The requested resource was not found") + }) + + test("a success whose body is not JSON is an error, not an empty list", async () => { + const odd = Bun.serve({ port: 0, fetch: () => new Response("gateway", { status: 200 }) }) + try { + const cfg = { apiUrl: `http://127.0.0.1:${odd.port}`, tenant: "acme", token: "t" } + await expect(backend.listOwnConnections(cfg)).rejects.toThrow(/not JSON/) + await expect(backend.listVisibleConnections(cfg)).rejects.toThrow(/not JSON/) + } finally { + odd.stop(true) + } + }) +}) + +describe("backend answers that are not what they should be", () => { + const answer = async (body: unknown, run: (cfg: backend.BackendConfig) => Promise, status = 200) => { + const odd = Bun.serve({ port: 0, fetch: () => (body === null ? new Response("", { status }) : Response.json(body, { status })) }) + try { + return await run({ apiUrl: `http://127.0.0.1:${odd.port}`, tenant: "acme", token: "t" }) + } finally { + odd.stop(true) + } + } + + test("an empty answer or one without a connections list is an error, not no connections", async () => { + for (const body of [null, {}, { connections: null }]) { + await expect(answer(body, backend.listOwnConnections)).rejects.toThrow("without a list of connections") + await expect(answer(body, backend.listVisibleConnections)).rejects.toThrow("without a list of connections") + } + expect(await answer({ connections: [] }, backend.listOwnConnections)).toEqual([]) + }) + + test("a workspace answer that is not a record, or has no integrations list, is an error", async () => { + await expect(answer(null, (cfg) => backend.getDatamate(cfg, "7"))).rejects.toThrow("without the workspace record") + await expect(answer({ ok: true }, (cfg) => backend.getDatamate(cfg, "7"))).rejects.toThrow("without the workspace record") + await expect(answer({ id: 7, integrations: null }, (cfg) => backend.getDatamate(cfg, "7"))).rejects.toThrow("not a list") + await expect(answer({ datamate: { id: 7, name: "W" } }, (cfg) => backend.getDatamate(cfg, "7"))).rejects.toThrow("not a list") + expect(await answer({ id: 7, name: "W", integrations: [] }, (cfg) => backend.getDatamate(cfg, "7"))).toMatchObject({ id: "7", name: "W", integrations: [] }) + for (const integrations of [{ unexpected: true }, "github", 3]) + await expect(answer({ datamate: { id: 7, name: "W", integrations } }, (cfg) => backend.getDatamate(cfg, "7"))).rejects.toThrow("not a list") + }) + + test("a backend without the summary route is read from the workspace list, and only when the list carries the integrations", async () => { + const routes = (list: unknown) => + Bun.serve({ port: 0, fetch: (req) => (new URL(req.url).pathname === "/datamates/" ? Response.json(list) : Response.json({ detail: "nope" }, { status: 404 })) }) + for (const [list, expected] of [ + [[{ id: 7, name: "W", integrations: [{ id: "github", tools: [] }] }], { id: "7", integrations: [{ id: "github", tools: [] }] }], + [[{ id: 7, name: "W" }], null], + [[{ id: 8, name: "Other", integrations: [] }], null], + ] as const) { + const srv = routes(list) + try { + const run = backend.getDatamate({ apiUrl: `http://127.0.0.1:${srv.port}`, tenant: "acme", token: "t" }, "7") + if (expected) expect(await run).toMatchObject(expected) + else await expect(run).rejects.toThrow() + } finally { + srv.stop(true) + } + } + }) +}) + +describe("jira domain and cli flags", () => { + test("normaliseJiraDomain sends the subdomain only", () => { + for (const v of ["acme.atlassian.net", "https://acme.atlassian.net", "https://acme.atlassian.net/jira/", " acme.atlassian.net ", "acme", "https://acme.atlassian.net:443/", "acme.atlassian.net:8443"]) + expect(core.normaliseJiraDomain(v)).toBe("acme") + }) + + test("the browser sign-in flags are gone and the token command is removed", () => { + const cli = path.join(import.meta.dir, "../../src/index.ts") + const run = (...args: string[]) => + Bun.spawnSync(["bun", "run", cli, "integrations", ...args], { env: { ...process.env, ALTIMATE_INTEGRATIONS_DIR: tmp, ALTIMATE_INTEGRATIONS_MOCK: "1" }, stdout: "pipe", stderr: "pipe" }) + const all = (r: ReturnType) => r.stderr.toString() + r.stdout.toString() + expect(all(run("--help"))).not.toContain("token ") + const help = all(run("connect", "--help")) + for (const flag of ["--oauth", "--reauth", "--no-frontend", "--port"]) expect(help).not.toContain(flag) + expect(help).toContain("--local-only") + }, 60_000) +}) + +describe("credentials flow", () => { + const calls: { method: string; path: string; query: string; auth: string | null; body: any }[] = [] + let server: ReturnType + const api = connectionsStub() + let catalog: unknown = [] + let onUser: (() => void) | null = null + const saved = { ...process.env } + const home = osTmp("integ-home-") + const bin = osTmp("integ-bin-") + const basic = (u: string, t: string) => `Basic ${Buffer.from(`${u}:${t}`).toString("base64")}` + const GOOD = "good_token_a1b2" + // The detectors spawn these binaries with short timeouts, and a freshly written executable can be + // slow on its first run (macOS scans it). Each stub is written and run once; a test only swaps the + // body it sources and links the stub in or out of PATH. + const stubs = osTmp("integ-stubs-") + for (const name of ["gh", "git"]) { + const stub = path.join(stubs, name) + fs.writeFileSync(stub, `#!/bin/sh\n[ -f "${stub}.body" ] && . "${stub}.body"\n`, { mode: 0o755 }) + Bun.spawnSync([stub]) + } + const fake = (name: string, body: string | null) => { + const file = path.join(bin, name) + const stub = path.join(stubs, name) + fs.rmSync(file, { force: true }) + if (body === null) return fs.rmSync(`${stub}.body`, { force: true }) + fs.writeFileSync(`${stub}.body`, `${body}\n`) + fs.symlinkSync(stub, file) + } + const real = () => { + delete process.env.ALTIMATE_INTEGRATIONS_MOCK + process.env.ALTIMATE_INTEGRATIONS_API_URL = `http://127.0.0.1:${server.port}` + process.env.ALTIMATE_INTEGRATIONS_TOKEN = "bearer-1" + process.env.ALTIMATE_INTEGRATIONS_TENANT = "acme" + } + const mock = () => { + process.env.ALTIMATE_INTEGRATIONS_MOCK = "1" + for (const k of ["ALTIMATE_INTEGRATIONS_API_URL", "ALTIMATE_INTEGRATIONS_TOKEN", "ALTIMATE_INTEGRATIONS_TENANT"]) delete process.env[k] + } + const clip = (text: string | undefined) => ({ clipboard: () => text }) + const token = async (id: core.ProviderId, field: string, value = GOOD) => creds.setSecretDraft(id, field, { kind: "clipboard" }, clip(value)) + const synced = () => calls.filter((c) => c.method !== "GET" && c.path.startsWith("/connections")) + + beforeEach(async () => { + calls.length = 0 + api.state.rows.length = 0 + api.state.next = 21 + api.state.valid = true + api.state.fail = null + api.state.listFails = false + catalog = [] + onUser = null + fs.rmSync(home, { recursive: true, force: true }) + fs.mkdirSync(home) + fake("gh", null) + fake("git", null) + mock() + process.env.ALTIMATE_INTEGRATIONS_HOME = home + process.env.PATH = bin + for (const k of ["GITHUB_TOKEN", "GH_TOKEN", "JIRA_API_TOKEN", "JIRA_USER", "JIRA_DOMAIN", "LINEAR_API_KEY", "ATLASSIAN_API_TOKEN"]) delete process.env[k] + }) + const base = () => `http://127.0.0.1:${server.port}` + const startServer = () => { + server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url) + const auth = req.headers.get("authorization") + const body = req.method === "POST" || req.method === "PATCH" ? await req.json().catch(() => null) : null + calls.push({ method: req.method, path: url.pathname, query: url.search, auth, body }) + const deny = () => Response.json({ message: "Bad credentials" }, { status: 401 }) + if (url.pathname === "/user") onUser?.() + if (url.pathname === "/user") + return auth === `Bearer ${GOOD}` ? Response.json({ login: "octo" }, { headers: { "github-authentication-token-expiration": "2031-01-02 03:04:05 UTC" } }) : deny() + if (url.pathname === "/rest/api/3/myself") return auth === basic("me@acme.com", GOOD) ? Response.json({ emailAddress: "me@acme.com" }) : deny() + if (url.pathname === "/rest/api/2/myself") return auth === `Bearer ${GOOD}` ? Response.json({ displayName: "Server Me" }) : deny() + if (url.pathname === "/graphql") return auth === GOOD ? Response.json({ data: { viewer: { email: "lin@acme.com", name: "Lin" } } }) : Response.json({ errors: [{ extensions: { code: "AUTHENTICATION_ERROR" } }] }, { status: 401 }) + if (url.pathname === "/datamate_integrations/") return Response.json(catalog) + const mine = api.handle(req, url, body) + if (mine) return mine + return new Response("no", { status: 404 }) + }, + }) + process.env.ALTIMATE_INTEGRATIONS_GITHUB_API = base() + process.env.ALTIMATE_INTEGRATIONS_JIRA_API = base() + process.env.ALTIMATE_INTEGRATIONS_LINEAR_API = `${base()}/graphql` + } + startServer() + afterAll(() => { + server.stop(true) + process.env.PATH = saved.PATH + delete process.env.ALTIMATE_INTEGRATIONS_HOME + for (const k of ["ALTIMATE_INTEGRATIONS_GITHUB_API", "ALTIMATE_INTEGRATIONS_JIRA_API", "ALTIMATE_INTEGRATIONS_LINEAR_API"]) delete process.env[k] + mock() + }) + + describe("fields", () => { + test("built-in table when nothing else is known", async () => { + const gh = await creds.describeFields("github") + expect(gh).toEqual({ + provider: "github", + variant: "token", + variants: ["token"], + fields: [{ name: "github_token", label: "Personal access token", secret: true, required: true, value: null, hasValue: false, masked: null }], + tokenUrl: "https://github.com/settings/tokens/new?scopes=repo,read:user&description=Altimate", + }) + const jira = await creds.describeFields("jira") + expect([jira.variant, jira.variants, jira.fields.map((f) => f.name), jira.tokenUrl]).toEqual(["cloud", ["cloud", "server"], ["jiraDomain", "jiraEmail", "jiraApiToken"], "https://id.atlassian.com/manage-profile/security/api-tokens"]) + expect((await creds.describeFields("jira", "server")).tokenUrl).toBeNull() + const lin = await creds.describeFields("linear") + expect([lin.variant, lin.fields.map((f) => [f.name, f.secret]), lin.tokenUrl]).toEqual(["key", [["linearApiKey", true]], "https://linear.app/settings/account/security"]) + }) + + test("jira variant follows the site and the form is pre-filled", async () => { + const cfg = path.join(home, ".config/.jira") + fs.mkdirSync(cfg, { recursive: true }) + fs.writeFileSync(path.join(cfg, ".config.yml"), "server: https://acme.atlassian.net\nlogin: me@acme.com\n") + const cloud = await creds.describeFields("jira") + expect(cloud.variant).toBe("cloud") + expect(cloud.fields.map((f) => [f.name, f.value])).toEqual([["jiraDomain", "acme"], ["jiraEmail", "me@acme.com"], ["jiraApiToken", null]]) + + fs.writeFileSync(path.join(cfg, ".config.yml"), "server: https://jira.corp.example/\nlogin: me@corp.example\n") + const server = await creds.describeFields("jira") + expect(server.variant).toBe("server") + expect(server.fields.map((f) => [f.name, f.value])).toEqual([["jiraBaseUrl", "https://jira.corp.example"], ["jiraPatToken", null]]) + + fs.writeFileSync(path.join(cfg, ".config.yml"), "server: https://acme.atlassian.net\n") + fake("git", 'echo git@example.com') + expect((await creds.describeFields("jira")).fields[1].value).toBe("git@example.com") + await creds.setDraft("jira", "jiraEmail", "draft@example.com") + expect((await creds.describeFields("jira")).fields[1].value).toBe("draft@example.com") + }) + + test("secrets report only a masked tail", async () => { + fake("gh", '[ "$2" = token ] && echo ghp_secretvalue_a1b2') + const f = (await creds.describeFields("github")).fields[0] + expect([f.value, f.hasValue, f.masked]).toEqual([null, true, "••••a1b2"]) + expect(JSON.stringify(await creds.describeFields("github"))).not.toContain("secretvalue") + }) + + test("jiraVariantFor", () => { + expect([undefined, "", "acme", "https://a.atlassian.net/x", "https://jira.corp.example"].map(creds.jiraVariantFor)).toEqual(["cloud", "cloud", "cloud", "cloud", "server"]) + expect(["localhost", "http://localhost:8080", "jira-internal:8443", "http://jira-internal", "https://acme.atlassian.net:443"].map(creds.jiraVariantFor)).toEqual(["server", "server", "server", "server", "cloud"]) + }) + + test("workspace catalog is read, cached and used when offline", async () => { + catalog = [ + { + id: "github", + config: [ + { name: "Token", key: "github_token", type: "password", required: true, encrypted: true }, + { name: "Organization", key: "github_org", type: "text", required: false, encrypted: false }, + { name: "Auth type", key: "auth_type", type: "text", required: false, encrypted: false }, + ], + }, + { + id: "jira", + config: [ + { name: "Site", key: "jiraDomain", type: "text", required: true, encrypted: false }, + { name: "Token", key: "jiraApiToken", type: "password", required: true, encrypted: true }, + ], + additionalConfigVariants: [{ id: "server", name: "Server", config: [{ name: "URL", key: "jiraBaseUrl", type: "text", required: true, encrypted: false }] }], + }, + { id: "other", config: [] }, + ] + real() + const gh = await creds.describeFields("github") + expect(gh.fields.map((f) => [f.name, f.label, f.secret, f.required])).toEqual([["github_token", "Token", true, true], ["github_org", "Organization", false, false]]) + const jira = await creds.describeFields("jira", "server") + expect([jira.variant, jira.variants, jira.fields.map((f) => [f.name, f.label])]).toEqual(["server", ["cloud", "server"], [["jiraBaseUrl", "URL"]]]) + expect(calls.some((c) => c.path === "/datamate_integrations/" && c.auth === "Bearer bearer-1")).toBe(true) + + expect(fs.readdirSync(tmp).filter((f) => f.startsWith("integrations-catalog-"))).toHaveLength(1) + // Another tenant on the same backend does not see this cache. + process.env.ALTIMATE_INTEGRATIONS_TENANT = "other" + catalog = [] + expect((await creds.describeFields("github")).fields.map((f) => f.name)).toEqual(["github_token"]) + real() + mock() + expect((await creds.describeFields("github")).fields.map((f) => f.name)).toEqual(["github_token"]) + // An empty catalog while signed in falls back to the cache too. + real() + catalog = [] + expect((await creds.describeFields("github")).fields.map((f) => f.name)).toEqual(["github_token", "github_org"]) + }) + + test("parseCatalog reads a wrapped list and a variants map", () => { + const parsed = creds.parseCatalog({ items: [{ id: "linear", config: [{ name: "linearApiKey", encrypted: true, required: true }] }, { id: "jira", variants: { server: { config: { jiraBaseUrl: { required: true } } } } }] }) + expect(parsed.linear![0].fields).toEqual([{ name: "linearApiKey", label: "Linear Api Key", secret: true, required: true }]) + expect(parsed.jira![0]).toMatchObject({ id: "server", fields: [{ name: "jiraBaseUrl", secret: false, required: true }] }) + }) + }) + + describe("set and set-secret", () => { + test("a typed Jira site is reduced to the subdomain before it is stored", async () => { + for (const typed of ["https://acme.atlassian.net", "acme.atlassian.net/", " http://Acme.atlassian.net/jira?x=1 "]) { + await creds.setDraft("jira", "jiraDomain", typed) + expect(core.loadStore().providers.jira?.drafts?.jiraDomain).toBe(typed.includes("Acme") ? "Acme" : "acme") + expect((await creds.resolve("jira")).values.jiraDomain).toBe(typed.includes("Acme") ? "Acme" : "acme") + } + }) + + test("set stores a non-secret draft and rejects secrets and unknown fields", async () => { + await creds.setDraft("jira", "jiraDomain", " acme ") + expect(core.loadStore().providers.jira?.drafts).toEqual({ jiraDomain: "acme" }) + await expect(creds.setDraft("jira", "jiraApiToken", "x")).rejects.toThrow("is a secret") + await expect(creds.setDraft("jira", "nope", "x")).rejects.toThrow('no field "nope"') + // A scan does not drop a draft. + await core.recordDetections([]) + expect(core.loadStore().providers.jira?.drafts).toEqual({ jiraDomain: "acme" }) + }) + + test("set-secret reads the injected clipboard and returns only the masked tail", async () => { + const r = await creds.setSecretDraft("github", "github_token", { kind: "clipboard" }, clip(` ghp_abcdefgh_${GOOD}\n`)) + expect(r).toEqual({ provider: "github", field: "github_token", masked: "••••a1b2" }) + expect(JSON.stringify(r)).not.toContain("abcdefgh") + expect(await core.readSecret(core.secretKey("draft", "github", "github_token"))).toBe(`ghp_abcdefgh_${GOOD}`) + expect(fs.existsSync(path.join(tmp, "integrations.json")) ? fs.readFileSync(path.join(tmp, "integrations.json"), "utf8") : "").not.toContain("abcdefgh") + }) + + test("clipboard content that is not a token is refused", async () => { + for (const bad of [undefined, "", " \n", "two words", "line1\nline2", "short"]) + await expect(creds.setSecretDraft("github", "github_token", { kind: "clipboard" }, clip(bad))).rejects.toThrow("Clipboard doesn't contain a token") + await expect(creds.setSecretDraft("github", "github_token", { kind: "clipboard" }, clip("a b")).catch((e) => e.message)).resolves.not.toContain("a b") + expect(await core.readSecret(core.secretKey("draft", "github", "github_token"))).toBeUndefined() + await expect(creds.setSecretDraft("jira", "jiraEmail", { kind: "clipboard" }, clip("x"))).rejects.toThrow("not a secret") + }) + + test("secrets can come from an environment variable or stdin", async () => { + process.env.OPT2_TEST_TOKEN = "env_token_zzzz" + expect(await creds.setSecretDraft("linear", "linearApiKey", { kind: "env", name: "OPT2_TEST_TOKEN" })).toEqual({ provider: "linear", field: "linearApiKey", masked: "••••zzzz" }) + await expect(creds.setSecretDraft("linear", "linearApiKey", { kind: "env", name: "OPT2_MISSING" })).rejects.toThrow("is empty") + expect((await creds.setSecretDraft("linear", "linearApiKey", { kind: "stdin" }, { stdin: async () => "stdin_token_9999\n" })).masked).toBe("••••9999") + delete process.env.OPT2_TEST_TOKEN + }) + }) + + describe("where Jira values may come from", () => { + const JIRA_ENV = ["JIRA_PAT_TOKEN", "JIRA_PERSONAL_TOKEN", "JIRA_PAT", "JIRA_BASE_URL", "JIRA_URL", "JIRA_API_TOKEN", "ATLASSIAN_API_TOKEN"] + const project = osTmp("integ-project-") + let cwd: ReturnType | undefined + const plant = (env: Record) => { + fs.writeFileSync(path.join(project, ".mcp.json"), JSON.stringify({ mcpServers: { jira: { command: "npx", args: ["mcp-jira"], env } } })) + cwd = spyOn(process, "cwd").mockReturnValue(project) + } + const userMcp = (env: Record) => fs.writeFileSync(path.join(home, ".claude.json"), JSON.stringify({ mcpServers: { jira: { command: "npx", args: ["mcp-jira"], env } } })) + const jiraCli = () => { + fs.mkdirSync(path.join(home, ".config/.jira"), { recursive: true }) + fs.writeFileSync(path.join(home, ".config/.jira/.config.yml"), `server: ${base()}\nlogin: me@corp.example\n`) + } + afterEach(() => { + cwd?.mockRestore() + cwd = undefined + fs.rmSync(path.join(project, ".mcp.json"), { force: true }) + for (const k of JIRA_ENV) delete process.env[k] + }) + + test("a planted project file cannot point the user's token at another host", async () => { + plant({ JIRA_URL: base() }) + process.env.JIRA_PAT_TOKEN = GOOD + expect((await creds.describeFields("jira", "server")).fields.map((f) => [f.name, f.value, f.hasValue])).toEqual([["jiraBaseUrl", null, false], ["jiraPatToken", null, true]]) + expect(await creds.detectedConfig("jira")).toEqual({ reason: "Missing: Site, Email, API token" }) + const r = await creds.connectCredentials({ id: "jira", variant: "server", localOnly: true }) + expect(r.status).toBe("missing_fields") + expect(calls.filter((c) => c.path.includes("myself"))).toEqual([]) + }) + + test("a token planted in a project file is not used", async () => { + plant({ JIRA_PAT_TOKEN: GOOD }) + process.env.JIRA_BASE_URL = base() + expect((await creds.detectedConfig("jira"))).toEqual({ reason: "Missing: Personal access token" }) + expect(calls).toEqual([]) + }) + + test("a project file still counts as present, without being syncable", () => { + plant({ JIRA_PAT_TOKEN: GOOD }) + const found = core.detectFromClaudeConfigs(home, project).find((d) => d.id === "jira")! + expect([found.source.kind, found.syncable]).toEqual(["mcp-remote", false]) + }) + + test("a site from one source is not paired with a token from another", async () => { + jiraCli() + userMcp({ JIRA_PAT_TOKEN: GOOD }) + expect(await creds.detectedConfig("jira")).toEqual({ reason: expect.stringContaining("Missing: Personal access token") }) + const connect = await creds.connectCredentials({ id: "jira", variant: "server", localOnly: true }) + expect([connect.status, connect.message]).toEqual(["missing_fields", expect.stringContaining("the token was not filled in")]) + fs.rmSync(path.join(home, ".config"), { recursive: true, force: true }) + userMcp({ JIRA_URL: base() }) + process.env.JIRA_PAT_TOKEN = GOOD + expect(await creds.detectedConfig("jira")).toEqual({ reason: expect.stringContaining("Missing: Personal access token") }) + }) + + test("same-named servers in the user and project scopes are separate sources", async () => { + fs.writeFileSync(path.join(home, ".claude.json"), JSON.stringify({ + mcpServers: { jira: { command: "npx", args: ["mcp-jira"], env: { JIRA_PAT_TOKEN: GOOD } } }, + projects: { [project]: { mcpServers: { jira: { command: "npx", args: ["mcp-jira"], env: { JIRA_URL: base() } } } } }, + })) + cwd = spyOn(process, "cwd").mockReturnValue(project) + expect(await creds.detectedConfig("jira")).toEqual({ reason: expect.stringContaining("Missing: Personal access token") }) + expect(calls.filter((c) => c.path.includes("myself"))).toEqual([]) + const f = await creds.describeFields("jira", "server") + expect(f.fields.find((x) => x.name === "jiraPatToken")?.note).toContain("Claude MCP (jira, user)") + }) + + test("a site and a token from the same source are used together", async () => { + process.env.JIRA_BASE_URL = base() + process.env.JIRA_PAT_TOKEN = GOOD + expect(await creds.detectedConfig("jira")).toMatchObject({ config: { jiraBaseUrl: base(), jiraPatToken: GOOD } }) + delete process.env.JIRA_BASE_URL + delete process.env.JIRA_PAT_TOKEN + userMcp({ JIRA_URL: base(), JIRA_PAT_TOKEN: GOOD }) + expect(await creds.detectedConfig("jira")).toMatchObject({ config: { jiraBaseUrl: base(), jiraPatToken: GOOD } }) + }) + + test("a site the user typed pairs with a detected token", async () => { + process.env.JIRA_PAT_TOKEN = GOOD + await creds.setDraft("jira", "jiraBaseUrl", base()) + const f = await creds.describeFields("jira", "server") + expect(f.fields.map((x) => [x.name, x.value, x.hasValue])).toEqual([["jiraBaseUrl", base(), true], ["jiraPatToken", null, true]]) + }) + }) + + describe("validators", () => { + const cases: [core.ProviderId, string, Record, Record, string][] = [ + ["github", "token", { github_token: GOOD }, { github_token: "bad" }, "octo"], + ["jira", "cloud", { jiraDomain: "acme", jiraEmail: "me@acme.com", jiraApiToken: GOOD }, { jiraDomain: "acme", jiraEmail: "me@acme.com", jiraApiToken: "bad" }, "me@acme.com"], + ["jira", "server", { jiraBaseUrl: base(), jiraPatToken: GOOD }, { jiraBaseUrl: base(), jiraPatToken: "bad" }, "Server Me"], + ["linear", "key", { linearApiKey: GOOD }, { linearApiKey: "bad" }, "lin@acme.com"], + ] + for (const [id, variant, good, bad, account] of cases) { + test(`${id} ${variant}: accepted and rejected`, async () => { + const ok = await creds.validate(id, variant, good) + expect([ok.ok, ok.account]).toEqual([true, account]) + const no = await creds.validate(id, variant, bad) + expect([no.ok, no.rejected]).toEqual([false, true]) + expect(no.message).toMatch(/rejected/) + }) + } + test("github reports the token expiry and an unreachable provider is not a rejection", async () => { + expect((await creds.validate("github", "token", { github_token: GOOD })).expiresAt).toBe("2031-01-02T03:04:05.000Z") + process.env.ALTIMATE_INTEGRATIONS_GITHUB_API = "http://127.0.0.1:1" + const r = await creds.validate("github", "token", { github_token: GOOD }) + expect([r.ok, r.rejected]).toEqual([false, false]) + process.env.ALTIMATE_INTEGRATIONS_GITHUB_API = base() + }) + test("a GitHub rate-limit 403 is not a rejected token and keeps the typed draft", async () => { + const limited = Bun.serve({ port: 0, fetch: () => Response.json({ message: "API rate limit exceeded" }, { status: 403, headers: { "x-ratelimit-remaining": "0" } }) }) + const retry = Bun.serve({ port: 0, fetch: () => Response.json({ message: "slow down" }, { status: 403, headers: { "retry-after": "30" } }) }) + try { + for (const s of [limited, retry]) { + process.env.ALTIMATE_INTEGRATIONS_GITHUB_API = `http://127.0.0.1:${s.port}` + const r = await creds.validate("github", "token", { github_token: GOOD }) + expect([r.ok, r.rejected, r.unavailable]).toEqual([false, false, true]) + } + process.env.ALTIMATE_INTEGRATIONS_GITHUB_API = `http://127.0.0.1:${limited.port}` + await token("github", "github_token") + const c = await creds.connectCredentials({ id: "github" }) + expect(c.status).toBe("failed") + expect(await core.readSecret(core.secretKey("draft", "github", "github_token"))).toBe(GOOD) + } finally { + process.env.ALTIMATE_INTEGRATIONS_GITHUB_API = base() + limited.stop(true) + retry.stop(true) + } + }) + test("a 403 without rate-limit signs is still a rejected token", async () => { + const denied = Bun.serve({ port: 0, fetch: () => Response.json({ message: "Resource not accessible" }, { status: 403 }) }) + try { + process.env.ALTIMATE_INTEGRATIONS_GITHUB_API = `http://127.0.0.1:${denied.port}` + expect((await creds.validate("github", "token", { github_token: GOOD })).rejected).toBe(true) + } finally { + process.env.ALTIMATE_INTEGRATIONS_GITHUB_API = base() + denied.stop(true) + } + }) + test("a variant that matches nothing is an error, not the first variant", async () => { + await expect(creds.connectCredentials({ id: "jira", variant: "srevre" })).rejects.toThrow(/no variant "srevre".*cloud, server/) + await expect(creds.describeFields("jira", "srevre")).rejects.toThrow(/no variant/) + expect((await creds.describeFields("jira", "server")).variant).toBe("server") + expect((await creds.describeFields("jira")).variant).toBe("cloud") + }) + test("a plain-http Jira server URL is refused", async () => { + expect((await creds.validate("jira", "server", { jiraBaseUrl: "http://jira.corp.example", jiraPatToken: GOOD })).message).toContain("https") + }) + }) + + describe("connect and update", () => { + test("missing fields are listed", async () => { + const r = await creds.connectCredentials({ id: "jira" }) + expect([r.status, r.code, r.missing]).toEqual(["missing_fields", "missing_fields", ["jiraDomain", "jiraEmail", "jiraApiToken"]]) + expect(r.message).toBe("Missing: Site, Email, API token") + }) + + test("local-only validates, stores locally and calls no backend", async () => { + real() + await token("github", "github_token") + const before = calls.length + const r = await creds.connectCredentials({ id: "github", localOnly: true }) + expect(r).toMatchObject({ provider: "github", status: "connected", sync: "local", account: "octo", expiresAt: "2031-01-02T03:04:05.000Z" }) + expect(calls.slice(before).map((c) => c.path)).toEqual(["/user", "/connections/"]) + expect(synced()).toEqual([]) + expect(await core.readSecret(core.secretKey("cred", "github", "github_token"))).toBe(GOOD) + expect(await core.readSecret("github")).toBe(GOOD) + expect(await core.readSecret(core.secretKey("draft", "github", "github_token"))).toBeUndefined() + const rec = core.loadStore().providers.github! + expect([rec.registered, rec.synced, rec.rejected, rec.account, typeof rec.validatedAt]).toEqual([true, false, false, "octo", "string"]) + expect(JSON.stringify(rec)).not.toContain(GOOD) + const p = core.status().providers[0] + expect([p.state, p.sync, p.account, p.expiresAt]).toEqual(["connected", "blocked", "octo", "2031-01-02T03:04:05.000Z"]) + expect(await core.readSecret("github")).toBeTruthy() + }) + + test("a 2xx without an account is not accepted as a valid credential", async () => { + const blank = Bun.serve({ port: 0, fetch: () => Response.json({}) }) + process.env.ALTIMATE_INTEGRATIONS_GITHUB_API = `http://127.0.0.1:${blank.port}` + process.env.ALTIMATE_INTEGRATIONS_JIRA_API = `http://127.0.0.1:${blank.port}` + try { + const gh = await creds.validate("github", "token", { github_token: GOOD }) + expect([gh.ok, gh.rejected]).toEqual([false, false]) + const jira = await creds.validate("jira", "cloud", { jiraDomain: "acme", jiraEmail: "me@acme.com", jiraApiToken: GOOD }) + expect([jira.ok, jira.rejected]).toEqual([false, false]) + } finally { + blank.stop(true) + process.env.ALTIMATE_INTEGRATIONS_GITHUB_API = base() + process.env.ALTIMATE_INTEGRATIONS_JIRA_API = base() + } + }) + + test("a catalog id naming the server variant gets the server type and token page", () => { + expect(core.withVariant("jira", "jira_server", { a: 1 })).toEqual({ a: 1, type: "server" }) + expect(core.withVariant("jira", "cloud", { a: 1 })).toEqual({ a: 1 }) + expect(creds.tokenUrl("jira", "jira_server")).toBeNull() + }) + + test("state writes are atomic and private", () => { + const file = path.join(tmp, "atomic.json") + core.writeJson(file, { a: 1 }, 0o600) + expect(fs.statSync(file).mode & 0o777).toBe(0o600) + expect(fs.readdirSync(tmp).filter((f) => f.endsWith(".tmp"))).toEqual([]) + }) + + test("sync reads a detected Jira credential through the connect path", async () => { + real() + const cfg = path.join(home, ".config/.jira") + fs.mkdirSync(cfg, { recursive: true }) + fs.writeFileSync(path.join(cfg, ".config.yml"), "server: https://acme.atlassian.net\nlogin: me@acme.com\n") + process.env.ATLASSIAN_API_TOKEN = GOOD + try { + expect(await core.collectConfig("jira", undefined)).toEqual({ config: { jiraDomain: "acme", jiraEmail: "me@acme.com", jiraApiToken: GOOD } }) + delete process.env.ATLASSIAN_API_TOKEN + expect(await core.collectConfig("jira", undefined)).toEqual({ reason: "Missing: API token" }) + } finally { + delete process.env.ATLASSIAN_API_TOKEN + } + }) + + test("detected credentials are used without typing", async () => { + fake("gh", '[ "$2" = token ] && echo ' + GOOD) + const r = await creds.connectCredentials({ id: "github", localOnly: true }) + expect([r.status, r.account]).toEqual(["connected", "octo"]) + expect(core.loadStore().providers.github?.source).toEqual({ kind: "cli", label: "gh CLI" }) + }) + + test("a rejected token stores nothing", async () => { + await token("github", "github_token", "bad_token_0000") + const r = await creds.connectCredentials({ id: "github", localOnly: true }) + expect([r.status, r.sync]).toEqual(["rejected", "local"]) + expect(r.message).toContain("GitHub rejected the token (401)") + expect(core.loadStore().providers.github?.registered).toBeUndefined() + expect(await core.readSecret("github")).toBeUndefined() + expect(await core.readSecret(core.secretKey("draft", "github", "github_token"))).toBeUndefined() + expect(core.status().providers[0].state).toBe("not_found") + }) + + test("a rejected token does not linger to override what is detected", async () => { + await token("github", "github_token", "bad_token_0000") + expect((await creds.connectCredentials({ id: "github", localOnly: true })).status).toBe("rejected") + fake("gh", '[ "$2" = token ] && echo ' + GOOD) + const r = await creds.connectCredentials({ id: "github", localOnly: true }) + expect([r.status, r.account]).toEqual(["connected", "octo"]) + }) + + test("keep local on a connection that exists only in the workspace removes that copy and holds sync", async () => { + real() + api.seed({ name: `GitHub (from ${os.hostname()})`, integration_id: "github", is_valid: false }) + await token("github", "github_token") + const r = await creds.connectCredentials({ id: "github", localOnly: true }) + expect([r.status, r.sync]).toEqual(["connected", "local"]) + expect(api.state.rows.filter((x) => !x.deleted)).toHaveLength(0) + const p = (await core.statusRemote()).providers[0] + expect([p.state, p.sync]).toEqual(["connected", "blocked"]) + }) + + test("update with local-only keeps a synced connection local instead of uploading", async () => { + real() + await token("github", "github_token") + await creds.connectCredentials({ id: "github" }) + await token("github", "github_token") + const r = await creds.connectCredentials({ id: "github", update: true, localOnly: true }) + expect([r.status, r.sync]).toEqual(["connected", "local"]) + expect(synced().map((c) => c.method)).toEqual(["POST", "DELETE"]) + expect(core.isBlocked("github")).toBe(true) + }) + + test("sync is held before the workspace copy is removed, and stays held when the removal fails", async () => { + real() + await token("github", "github_token") + await creds.connectCredentials({ id: "github" }) + let heldAtDelete = null as boolean | null + const original = backend.deleteConnection + const spy = spyOn(backend, "deleteConnection").mockImplementation(async (cfg, id) => { + heldAtDelete = core.isBlocked("github") + return original(cfg, id) + }) + try { + await token("github", "github_token") + expect((await creds.connectCredentials({ id: "github", localOnly: true })).status).toBe("connected") + expect(heldAtDelete).toBe(true) + } finally { + spy.mockRestore() + } + await core.setBlocked(["github"], false) + const failing = spyOn(backend, "deleteConnection").mockRejectedValue(new Error("boom")) + try { + await creds.connectCredentials({ id: "github" }) + await token("github", "github_token") + const r = await creds.connectCredentials({ id: "github", localOnly: true }) + expect([r.status, r.sync, core.isBlocked("github")]).toEqual(["failed", "synced", true]) + expect(r.message).toContain("could not be removed from the workspace") + expect(await core.readSecret("github")).toBeTruthy() + } finally { + failing.mockRestore() + } + }) + + test("a draft that cannot be cleared on Keep local still removes the workspace copy, and says so", async () => { + real() + await token("github", "github_token") + await creds.connectCredentials({ id: "github" }) + await token("github", "github_token") + const original = core.deleteSecret + const spy = spyOn(core, "deleteSecret").mockImplementation(async (key: string) => { + if (key.startsWith("draft")) throw new Error("exit 51") + return original(key) + }) + try { + const r = await creds.connectCredentials({ id: "github", localOnly: true }) + expect([r.status, r.sync, core.loadStore().providers.github?.synced]).toEqual(["connected", "local", false]) + expect(r.message).toContain("What was entered could not be cleared from this machine: exit 51") + expect(api.state.rows.filter((row: any) => !row.deleted)).toEqual([]) + } finally { + spy.mockRestore() + } + }) + + test("a record that cannot be written after the upload says the workspace has the connection, and connecting again updates it", async () => { + real() + await token("github", "github_token") + const rename = fs.renameSync + const spy = spyOn(fs, "renameSync").mockImplementation(((from: string, to: string) => { + if (String(to).endsWith("integrations.json")) throw new Error("disk full") + return rename(from, to) + }) as any) + try { + const r = await creds.connectCredentials({ id: "github" }) + expect([r.status, r.sync]).toEqual(["failed", "synced"]) + expect(r.message).toContain("Saved to your workspace, but the record on this machine could not be written (disk full)") + expect(r.message).toContain("Connect again") + } finally { + spy.mockRestore() + } + const create = spyOn(backend, "createConnection") + try { + await token("github", "github_token") + const r = await creds.connectCredentials({ id: "github" }) + expect([r.status, r.sync]).toEqual(["connected", "synced"]) + expect(create).not.toHaveBeenCalled() + } finally { + create.mockRestore() + } + }) + + test("an MCP env value that is only an unresolved reference does not count as a token", () => { + expect(creds.mcpEnvHasToken("github", { GITHUB_TOKEN: "${MISSING_TOKEN}" })).toBe(false) + expect(creds.mcpEnvHasToken("github", { GITHUB_TOKEN: " $MISSING_TOKEN " })).toBe(false) + expect(creds.mcpEnvHasToken("github", { GITHUB_TOKEN: "ghp_real" })).toBe(true) + }) + + test("a local cleanup that fails after the upload is a warning on a connected result, and the record says synced", async () => { + real() + await core.storeSecret("github", "leftover_token") + await token("github", "github_token") + const rename = fs.renameSync + const spy = spyOn(fs, "renameSync").mockImplementation(((from: string, to: string) => { + if (String(to).endsWith("integrations-secrets.json")) throw new Error("disk full") + return rename(from, to) + }) as any) + try { + const r = await creds.connectCredentials({ id: "github" }) + expect([r.status, r.sync]).toEqual(["connected", "synced"]) + expect(r.message).toContain("local copy of the credential could not be removed: disk full") + } finally { + spy.mockRestore() + } + expect(core.isSynced("github")).toBe(true) + }) + + test("Keep local removes nothing from the workspace when the local copy cannot be stored", async () => { + real() + api.seed({ name: `GitHub (from ${os.hostname()})`, integration_id: "github" }) + await token("github", "github_token") + const del = spyOn(backend, "deleteConnection") + const store = spyOn(core, "storeSecret").mockRejectedValue(new Error("keychain locked")) + try { + const r = await creds.connectCredentials({ id: "github", localOnly: true }) + expect([r.status, r.message]).toEqual(["failed", "keychain locked"]) + expect(del).not.toHaveBeenCalled() + expect(api.state.rows.filter((x) => !x.deleted)).toHaveLength(1) + expect(core.isBlocked("github")).toBe(false) + expect(core.loadStore().providers.github?.registered).toBeUndefined() + } finally { + store.mockRestore() + del.mockRestore() + } + }) + + test("a local connect that fails part way restores the secrets it wrote", async () => { + await core.storeSecret(core.secretKey("cred", "jira", "jiraApiToken"), "old-token") + await creds.setDraft("jira", "jiraDomain", "acme") + await creds.setDraft("jira", "jiraEmail", "me@acme.com") + await token("jira", "jiraApiToken") + const original = core.storeSecret + let calls = 0 + const store = spyOn(core, "storeSecret").mockImplementation(async (id: string, secret: string) => { + if (++calls === 2) throw new Error("keychain locked") + return original(id, secret) + }) + try { + const r = await creds.connectCredentials({ id: "jira", localOnly: true }) + expect(r.status).toBe("failed") + } finally { + store.mockRestore() + } + expect(await core.readSecret(core.secretKey("cred", "jira", "jiraApiToken"))).toBe("old-token") + }) + + test("a catalog that omits the secret flags still treats the built-in secret fields as secret", () => { + const parsed = creds.parseCatalog([{ id: "github", config: [{ name: "github_token", required: true }, { name: "note" }] }]) + expect(parsed.github![0].fields.map((f) => [f.name, f.secret])).toEqual([["github_token", true], ["note", false]]) + }) + + test("a Jira variant naming the server is validated as the server", async () => { + const v = await creds.validate("jira", "jira_server", { jiraBaseUrl: base(), jiraPatToken: GOOD }) + expect([v.ok, v.account]).toEqual([true, "Server Me"]) + }) + + test("a refused replacement of a non-secret field leaves the working connection alone", async () => { + await creds.setDraft("jira", "jiraDomain", "acme") + await creds.setDraft("jira", "jiraEmail", "me@acme.com") + await token("jira", "jiraApiToken") + expect((await creds.connectCredentials({ id: "jira", localOnly: true })).status).toBe("connected") + await creds.setDraft("jira", "jiraEmail", "other@acme.com") + expect((await creds.connectCredentials({ id: "jira", update: true })).status).toBe("rejected") + const p = core.status().providers[1] + expect([p.state, core.loadStore().providers.jira?.rejected]).toEqual(["connected", false]) + expect(core.loadStore().providers.jira?.values?.jiraEmail).toBe("me@acme.com") + }) + + test("keep local stores a credential unverified when the provider is unreachable; connect stores nothing", async () => { + const down = Bun.serve({ port: 0, fetch: () => new Response("down", { status: 503 }) }) + process.env.ALTIMATE_INTEGRATIONS_GITHUB_API = `http://127.0.0.1:${down.port}` + try { + real() + await token("github", "github_token") + const failed = await creds.connectCredentials({ id: "github" }) + expect([failed.status, failed.message]).toEqual(["failed", "GitHub answered 503"]) + expect([core.loadStore().providers.github?.registered, synced()]).toEqual([undefined, []]) + const r = await creds.connectCredentials({ id: "github", localOnly: true }) + expect([r.status, r.sync, r.account]).toEqual(["connected", "local", null]) + expect(r.message).toContain("not verified yet") + const p = core.status().providers[0] + expect([p.state, p.note]).toEqual(["connected", "not verified yet, the provider could not be reached"]) + expect(await core.readSecret("github")).toBeTruthy() + } finally { + down.stop(true) + process.env.ALTIMATE_INTEGRATIONS_GITHUB_API = base() + } + await token("github", "github_token") + expect((await creds.connectCredentials({ id: "github", update: true })).status).toBe("connected") + expect(core.loadStore().providers.github?.unverified).toBe(false) + }) + + test("a connect whose record changed while the credential was being checked stores nothing", async () => { + real() + for (const localOnly of [true, false]) { + await token("github", "github_token") + onUser = () => core.saveStore({ blocked: {}, providers: { github: { registered: true, synced: false, variant: "token", validatedAt: "2030-01-01T00:00:00Z" } } }) + const before = calls.length + const r = await creds.connectCredentials({ id: "github", localOnly }) + expect([r.status, r.message]).toEqual(["failed", "The connection changed while it was being checked, run it again."]) + expect(synced()).toEqual([]) + expect(await core.readSecret(core.secretKey("cred", "github", "github_token"))).toBeUndefined() + expect(calls.slice(before).some((c) => c.method !== "GET" && c.path.startsWith("/connections"))).toBe(false) + onUser = null + core.saveStore({ blocked: {}, providers: {} }) + await creds.discardDrafts("github") + } + }) + + test("a connect prepared from an earlier credential is refused when the credential was saved again meanwhile", async () => { + real() + await token("github", "github_token") + expect((await creds.connectCredentials({ id: "github", localOnly: true })).status).toBe("connected") + const first = core.loadStore().providers.github! + await token("github", "github_token") + onUser = () => core.updateStore((st) => void (st.providers.github = { ...st.providers.github, credentialStamp: "saved-elsewhere" })) + const r = await creds.connectCredentials({ id: "github", localOnly: true }) + onUser = null + expect(r.status).toBe("failed") + expect(r.message).toBe("The connection changed while it was being checked, run it again.") + expect(core.loadStore().providers.github!.credentialStamp).toBe("saved-elsewhere") + expect(first.credentialStamp).toBeDefined() + core.saveStore({ blocked: {}, providers: {} }) + await creds.discardDrafts("github") + }) + + test("a local save whose old secrets cannot be purged is still saved, and says what is left", async () => { + real() + await token("github", "github_token") + const purge = spyOn(core, "purgeCredentialSecrets").mockRejectedValue(new Error("keychain locked")) + try { + const r = await creds.connectCredentials({ id: "github", localOnly: true }) + expect([r.status, r.sync]).toEqual(["connected", "local"]) + expect(r.message).toContain("The previous credential could not be removed from this machine: keychain locked") + expect(core.loadStore().providers.github?.registered).toBe(true) + expect(await core.readSecret(core.secretKey("cred", "github", "github_token"))).toBe(GOOD) + } finally { + purge.mockRestore() + } + }) + + test("an offline re-save of the same stored token keeps its known expiry; a new token does not inherit it", async () => { + real() + await token("github", "github_token") + await creds.connectCredentials({ id: "github", localOnly: true }) + const known = "2031-01-02T03:04:05.000Z" + expect(core.loadStore().providers.github?.expiresAt).toBe(known) + const down = Bun.serve({ port: 0, fetch: () => new Response("down", { status: 503 }) }) + process.env.ALTIMATE_INTEGRATIONS_GITHUB_API = `http://127.0.0.1:${down.port}` + try { + const same = await creds.connectCredentials({ id: "github", localOnly: true }) + expect(same.message).toContain("not verified yet") + expect(same.expiresAt).toBe(known) + expect(core.loadStore().providers.github?.expiresAt).toBe(known) + await token("github", "github_token", "another_token_c3d4") + await creds.connectCredentials({ id: "github", localOnly: true }) + expect(core.loadStore().providers.github?.expiresAt ?? null).toBeNull() + } finally { + down.stop(true) + process.env.ALTIMATE_INTEGRATIONS_GITHUB_API = base() + } + }) + + test("a variant asked for by its exact id wins over one whose name contains it", async () => { + catalog = [ + { + id: "jira", + config: [{ name: "jiraDomain", label: "Site", required: true }], + additionalConfigVariants: [ + { id: "server-oauth-less", label: "Server extra", config: [{ name: "jiraBaseUrl", label: "URL", required: true }] }, + { id: "server", label: "Server", config: [{ name: "jiraBaseUrl", label: "URL", required: true }, { name: "jiraPatToken", label: "PAT", encrypted: true, required: true }] }, + ], + }, + ] + real() + expect((await creds.resolve("jira", "server")).def.id).toBe("server") + }) + + test("a rejected input is not stored unverified", async () => { + await creds.setDraft("jira", "jiraDomain", "acme") + await creds.setDraft("jira", "jiraEmail", "me@acme.com") + await token("jira", "jiraApiToken", "bad_token_0000") + expect((await creds.connectCredentials({ id: "jira", localOnly: true })).status).toBe("rejected") + await creds.setDraft("jira", "jiraDomain", "not a site!") + await token("jira", "jiraApiToken") + expect((await creds.connectCredentials({ id: "jira", localOnly: true })).status).toBe("failed") + expect(core.loadStore().providers.jira?.registered).toBeUndefined() + }) + + test("a failed state write leaves no temp file behind", () => { + const target = path.join(tmp, "is-a-directory") + fs.mkdirSync(target) + expect(() => core.writeJson(target, { a: 1 }, 0o600)).toThrow() + expect(fs.readdirSync(tmp).filter((f) => f.endsWith(".tmp"))).toEqual([]) + }) + + test("two processes updating the same state never lose an update", async () => { + const counter = path.join(tmp, "counter.json") + const src = path.join(import.meta.dir, "../../src/altimate/integrations/core.ts") + const script = `const core = await import(${JSON.stringify(src)}); + for (let i = 0; i < 40; i++) await core.withLock(async () => { const n = core.readJson(${JSON.stringify(counter)}, { n: 0 }).n; await new Promise((r) => setTimeout(r, 2)); core.writeJson(${JSON.stringify(counter)}, { n: n + 1 }) })` + const run = () => Bun.spawn([process.execPath, "-e", script], { env: { ...process.env, ALTIMATE_INTEGRATIONS_DIR: tmp }, cwd: path.join(import.meta.dir, "../.."), stdout: "pipe", stderr: "pipe" }) + const procs = [run(), run()] + const codes = await Promise.all(procs.map((p) => p.exited)) + expect(codes).toEqual([0, 0]) + expect(core.readJson(counter, { n: 0 }).n).toBe(80) + }, 60_000) + + test("discard clears typed fields and pasted secrets", async () => { + await token("github", "github_token") + await creds.setDraft("jira", "jiraDomain", "acme") + await token("jira", "jiraApiToken") + real() + catalog = [{ id: "jira", config: [{ name: "Site", key: "jiraDomain", type: "text", required: true }], additionalConfigVariants: [{ id: "extra", name: "Custom", config: [{ name: "Secret", key: "customSecret", type: "password", required: true }] }] }] + await creds.loadCatalog("jira") + await core.storeSecret(core.secretKey("draft", "jira", "customSecret"), "pasted_for_custom_field") + await creds.discardDrafts("github") + await creds.discardDrafts("jira") + expect(await core.readSecret(core.secretKey("draft", "jira", "customSecret"))).toBeUndefined() + expect(await core.readSecret(core.secretKey("draft", "github", "github_token"))).toBeUndefined() + expect(await core.readSecret(core.secretKey("draft", "jira", "jiraApiToken"))).toBeUndefined() + expect(core.loadStore().providers.jira?.drafts).toBeUndefined() + }) + + test("sync creates the managed connection with the catalog field names and keeps no secret locally", async () => { + real() + await creds.setDraft("jira", "jiraDomain", "acme") + await creds.setDraft("jira", "jiraEmail", "me@acme.com") + await token("jira", "jiraApiToken") + const r = await creds.connectCredentials({ id: "jira" }) + expect([r.status, r.sync, r.account]).toEqual(["connected", "synced", "me@acme.com"]) + const sent = synced() + expect(sent).toHaveLength(1) + expect(sent[0]).toMatchObject({ method: "POST", path: "/connections/" }) + expect(sent[0].body).toEqual({ name: `Jira (from ${os.hostname()})`, integration_id: "jira", config: { jiraDomain: "acme", jiraEmail: "me@acme.com", jiraApiToken: GOOD } }) + expect(await core.readSecret("jira")).toBeUndefined() + expect(core.loadStore().providers.jira).toMatchObject({ registered: true, synced: true, connectionId: 21 }) + }) + + test("jira server sends the variant type with its catalog fields", async () => { + real() + await creds.setDraft("jira", "jiraBaseUrl", base()) + await token("jira", "jiraPatToken") + const r = await creds.connectCredentials({ id: "jira", variant: "server" }) + expect([r.status, r.sync]).toEqual(["connected", "synced"]) + expect(synced()[0].body.config).toEqual({ jiraBaseUrl: base(), jiraPatToken: GOOD, type: "server" }) + }) + + test("a new connection the workspace marks invalid is removed and nothing is stored", async () => { + real() + api.state.valid = false + await token("linear", "linearApiKey") + const r = await creds.connectCredentials({ id: "linear" }) + expect([r.status, r.sync]).toEqual(["rejected", "local"]) + expect(r.message).toContain("refused") + expect(synced().map((c) => c.method)).toEqual(["POST", "DELETE"]) + expect(api.state.rows.filter((x) => !x.deleted)).toHaveLength(0) + expect(core.loadStore().providers.linear?.registered).toBeUndefined() + expect(await core.readSecret(core.secretKey("draft", "linear", "linearApiKey"))).toBeUndefined() + expect(await core.readSecret(core.secretKey("cred", "linear", "linearApiKey"))).toBeUndefined() + expect(core.status().providers[2].state).not.toBe("connected") + }) + + test("a replacement the workspace marks invalid leaves the connection marked rejected until update fixes it", async () => { + real() + await token("linear", "linearApiKey") + expect((await creds.connectCredentials({ id: "linear" })).status).toBe("connected") + api.state.valid = false + await token("linear", "linearApiKey") + const bad = await creds.connectCredentials({ id: "linear", update: true }) + expect([bad.status, bad.sync]).toEqual(["rejected", "synced"]) + expect(bad.message).toContain("marked it invalid") + expect(api.state.rows.filter((x) => !x.deleted)).toHaveLength(1) + expect(await core.readSecret(core.secretKey("draft", "linear", "linearApiKey"))).toBeUndefined() + expect(core.status().providers[2].state).toBe("rejected") + api.state.valid = true + await token("linear", "linearApiKey") + expect((await creds.connectCredentials({ id: "linear", update: true })).status).toBe("connected") + expect(synced().map((c) => c.method)).toEqual(["POST", "PATCH", "PATCH"]) + expect(core.status().providers[2].state).toBe("connected") + }) + + test("a backend refusal is reported as message (code)", async () => { + real() + api.state.fail = { status: 422, body: { detail: [{ loc: ["body", "config", "jiraEmail"], msg: "Email is not a valid email address", type: "value_error.format" }] } } + await token("linear", "linearApiKey") + const r = await creds.connectCredentials({ id: "linear" }) + expect([r.status, r.message]).toEqual(["failed", "Email is not a valid email address (value_error.format)"]) + expect(core.loadStore().providers.linear?.registered).toBeUndefined() + }) + + test("signed out, or blocked, behaves as local-only and says so", async () => { + await token("github", "github_token") + const r = await creds.connectCredentials({ id: "github" }) + expect([r.status, r.sync, r.message]).toEqual(["connected", "local", "Not signed in to Altimate, kept local."]) + await core.setBlocked(["linear"], true) + real() + await token("linear", "linearApiKey") + const b = await creds.connectCredentials({ id: "linear" }) + expect([b.sync, synced().length]).toEqual(["local", 0]) + }) + + test("update needs a connection and keeps a local connection local", async () => { + real() + expect((await creds.connectCredentials({ id: "github", update: true })).code).toBe("not_connected") + await token("github", "github_token") + await creds.connectCredentials({ id: "github", localOnly: true }) + await token("github", "github_token") + const r = await creds.connectCredentials({ id: "github", update: true }) + expect([r.status, r.sync, synced().length]).toEqual(["connected", "local", 0]) + }) + + test("update of a synced connection updates the same connection in place", async () => { + real() + await token("github", "github_token") + await creds.connectCredentials({ id: "github" }) + await token("github", "github_token") + const r = await creds.connectCredentials({ id: "github", update: true }) + expect([r.status, r.sync]).toEqual(["connected", "synced"]) + const [first, second] = synced() + expect([first.method, first.path, second.method, second.path]).toEqual(["POST", "/connections/", "PATCH", "/connections/21"]) + expect(second.body).toEqual({ config: { github_token: GOOD, expires_at: "2031-01-02T03:04:05.000Z" } }) + expect(api.state.rows).toHaveLength(1) + }) + + test("a credential kept local is never uploaded until sync is allowed", async () => { + await token("github", "github_token") + await creds.connectCredentials({ id: "github", localOnly: true }) + real() + expect((await core.syncProvider("github")).result).toBe("kept_local") + expect(synced()).toEqual([]) + await core.setBlocked(["github"], false) + const o = await core.syncProvider("github") + expect(o.result).toBe("synced") + expect(synced()[0].body.config).toEqual({ github_token: GOOD, expires_at: "2031-01-02T03:04:05.000Z" }) + }) + + test("signed out, sync does not pretend to reach the workspace", async () => { + delete process.env.ALTIMATE_INTEGRATIONS_MOCK + await token("github", "github_token") + await creds.connectCredentials({ id: "github" }) + const o = await core.syncProvider("github") + expect([o.result, o.reason]).toEqual(["skipped", core.NOT_SIGNED_IN]) + expect(core.status().providers[0].sync).toBe("local") + expect(fs.existsSync(path.join(tmp, "integrations-remote-mock.json"))).toBe(false) + }) + + test("keeping a synced connection local removes the workspace copy and holds sync", async () => { + real() + await token("github", "github_token") + await creds.connectCredentials({ id: "github" }) + expect(api.state.rows.filter((r) => !r.deleted)).toHaveLength(1) + await token("github", "github_token") + const r = await creds.connectCredentials({ id: "github", localOnly: true }) + expect([r.status, r.sync]).toEqual(["connected", "local"]) + expect(api.state.rows.filter((x) => !x.deleted)).toHaveLength(0) + expect(await core.readSecret(core.secretKey("cred", "github", "github_token"))).toBe(GOOD) + const s = await core.statusRemote() + expect([s.providers[0].state, s.providers[0].sync]).toEqual(["connected", "blocked"]) + expect(await core.readSecret("github")).toBeTruthy() + }) + + test("keeping a synced connection local while signed out changes nothing", async () => { + real() + await token("github", "github_token") + await creds.connectCredentials({ id: "github" }) + mock() + delete process.env.ALTIMATE_INTEGRATIONS_MOCK + await token("github", "github_token") + const r = await creds.connectCredentials({ id: "github", localOnly: true }) + expect(r.status).toBe("failed") + expect(core.loadStore().providers.github?.synced).toBe(true) + expect(core.isBlocked("github")).toBe(false) + }) + + test("a synced connection cannot be blocked: there is no local copy to keep", async () => { + real() + await token("github", "github_token") + await creds.connectCredentials({ id: "github" }) + expect(core.isSynced("github")).toBe(true) + expect(core.isSynced("jira")).toBe(false) + }) + + test("a remote-only managed connection can be updated", async () => { + real() + api.seed({ name: `GitHub (from ${os.hostname()})`, integration_id: "github", is_valid: false }) + await token("github", "github_token") + const r = await creds.connectCredentials({ id: "github", update: true }) + expect([r.status, r.sync]).toEqual(["connected", "synced"]) + expect(synced().map((c) => c.method)).toEqual(["PATCH"]) + expect(api.state.rows).toHaveLength(1) + }) + + test("Keep local on a connection only the workspace holds refuses when the workspace cannot be read", async () => { + real() + api.seed({ name: `GitHub (from ${os.hostname()})`, integration_id: "github" }) + api.state.listFails = true + await token("github", "github_token") + const r = await creds.connectCredentials({ id: "github", localOnly: true }) + expect(r.status).toBe("failed") + expect(r.message).toContain("Could not check your workspace") + expect(core.loadStore().providers.github?.registered).toBeUndefined() + expect(core.isBlocked("github")).toBe(false) + expect(api.state.rows.filter((x) => !x.deleted)).toHaveLength(1) + }) + + test("Keep local on a connection only the workspace holds removes it and keeps the secret here", async () => { + real() + api.seed({ name: `GitHub (from ${os.hostname()})`, integration_id: "github" }) + await token("github", "github_token") + const r = await creds.connectCredentials({ id: "github", localOnly: true }) + expect([r.status, r.sync]).toEqual(["connected", "local"]) + expect(api.state.rows.filter((x) => !x.deleted)).toHaveLength(0) + expect(core.isBlocked("github")).toBe(true) + expect(await core.readSecret("github")).toBeTruthy() + }) + + test("Keep local on a local record finds a workspace copy uploaded after it was recorded", async () => { + real() + await token("github", "github_token") + await creds.connectCredentials({ id: "github", localOnly: true }) + await core.setBlocked(["github"], false) + api.seed({ name: `GitHub (from ${os.hostname()})`, integration_id: "github" }) + await token("github", "github_token") + const r = await creds.connectCredentials({ id: "github", localOnly: true }) + expect([r.status, r.sync]).toEqual(["connected", "local"]) + expect(api.state.rows.filter((x) => !x.deleted)).toHaveLength(0) + expect(core.isBlocked("github")).toBe(true) + }) + + test("a workspace refusal keeps typed non-secret values and drops secret drafts", async () => { + real() + api.state.valid = false + await creds.setDraft("jira", "jiraDomain", "acme") + await creds.setDraft("jira", "jiraEmail", "me@acme.com") + await token("jira", "jiraApiToken") + const r = await creds.connectCredentials({ id: "jira" }) + expect(r.status).toBe("rejected") + expect(core.loadStore().providers.jira?.drafts).toEqual({ jiraDomain: "acme", jiraEmail: "me@acme.com" }) + expect(await core.readSecret(core.secretKey("draft", "jira", "jiraApiToken"))).toBeUndefined() + }) + + test("a clipboard URL is not taken as a token", async () => { + await expect(creds.setSecretDraft("github", "github_token", { kind: "clipboard" }, clip("https://github.com/settings/tokens"))).rejects.toThrow("Clipboard doesn't contain a token") + }) + + test("a failed upload keeps the local secret", async () => { + real() + await token("github", "github_token") + await creds.connectCredentials({ id: "github", localOnly: true }) + await core.setBlocked(["github"], false) + api.state.fail = { status: 500, body: { detail: "boom" } } + const o = await core.syncProvider("github") + expect(o.result).toBe("failed") + expect(await core.readSecret("github")).toBeTruthy() + }) + + test("a failed update of a credential the workspace holds says it is still synced", async () => { + real() + await token("github", "github_token") + expect((await creds.connectCredentials({ id: "github" })).sync).toBe("synced") + await token("github", "github_token") + api.state.fail = { status: 500, body: { detail: "boom" } } + const r = await creds.connectCredentials({ id: "github", update: true }) + expect([r.status, r.sync, core.loadStore().providers.github?.synced]).toEqual(["failed", "synced", true]) + }) + + test("connect to a shared workspace attaches only with --share, and an update of an attached connection says it is shared", async () => { + real() + const getDatamate = spyOn(backend, "getDatamate").mockResolvedValue({ id: "5", name: "WS", is_owner: true, privacy: "public", integrations: [] } as any) + const spies = [ + spyOn(workspaceState, "resolveBindingOutcome").mockResolvedValue({ status: "bound", binding: { datamateId: 5, datamateName: "WS" } } as any), + spyOn(backend, "integrationsCatalog").mockResolvedValue([{ id: "github", tools: [] }] as any), + getDatamate, + ] + const update = spyOn(backend, "updateDatamateIntegrations").mockResolvedValue(undefined as any) + try { + await token("github", "github_token") + const refused = await creds.connectCredentials({ id: "github" }) + expect([refused.status, refused.sync, refused.shared, refused.message]).toEqual(["connected", "synced", undefined, core.ATTACH_FAILED_PREFIX + core.SHARED_WORKSPACE_REFUSAL("WS", "github")]) + expect(update).not.toHaveBeenCalled() + // The command the refusal names attaches the connection already saved to the account. + const viaSync = await core.syncProvider("github", undefined, 0, true, true) + expect([viaSync.result, viaSync.shared]).toEqual(["synced", true]) + expect(update).toHaveBeenCalledTimes(1) + await token("github", "github_token") + const shared = await creds.connectCredentials({ id: "github", update: true, share: true }) + expect([shared.status, shared.shared]).toEqual(["connected", true]) + expect(shared.message).toContain("its members can use this connection") + expect(update).toHaveBeenCalledTimes(2) + // Once the workspace uses the connection, an update without --share keeps it attached and says it is shared. + const id = core.loadStore().providers.github!.connectionId + getDatamate.mockResolvedValue({ id: "5", name: "WS", is_owner: true, privacy: "public", integrations: [{ id: "github", tools: [], connection_id: String(id) }] } as any) + await token("github", "github_token") + const again = await creds.connectCredentials({ id: "github", update: true }) + expect([again.status, again.shared]).toEqual(["connected", true]) + expect(again.message).toContain("its members can use this connection") + } finally { + for (const sp of [...spies, update]) sp.mockRestore() + } + }) + + test("a hand-made connection cannot be updated from here", async () => { + real() + api.seed({ name: "Hand made", integration_id: "github", is_valid: false }) + await token("github", "github_token") + expect((await creds.connectCredentials({ id: "github", update: true })).code).toBe("not_connected") + }) + + test("a workspace connection removed elsewhere stops reading as synced", async () => { + real() + await token("github", "github_token") + await creds.connectCredentials({ id: "github" }) + api.state.rows.forEach((r) => (r.deleted = true)) + const p = (await core.statusRemote()).providers[0] + expect([p.state, p.sync]).toEqual(["not_found", "local"]) + }) + + test("disconnect while signed out refuses when the credential is saved in the workspace", async () => { + real() + await token("github", "github_token") + await creds.connectCredentials({ id: "github" }) + delete process.env.ALTIMATE_INTEGRATIONS_TOKEN + await expect(core.disconnect("github")).rejects.toThrow("Sign in to Altimate") + expect(core.loadStore().providers.github?.registered).toBe(true) + }) + + test("disconnect removes the credential secrets", async () => { + await token("github", "github_token") + await creds.connectCredentials({ id: "github", localOnly: true }) + await core.disconnect("github") + expect(await core.readSecret(core.secretKey("cred", "github", "github_token"))).toBeUndefined() + expect(await core.readSecret("github")).toBeUndefined() + }) + }) + + describe("rejected status", () => { + test("a refused replacement leaves the working connection alone", async () => { + await token("github", "github_token") + await creds.connectCredentials({ id: "github", localOnly: true }) + await token("github", "github_token", "bad_token_0000") + expect((await creds.connectCredentials({ id: "github", update: true })).status).toBe("rejected") + expect(core.status().providers[0].state).toBe("connected") + expect(await core.readSecret("github")).toBeTruthy() + expect(await core.readSecret(core.secretKey("draft", "github", "github_token"))).toBeUndefined() + }) + + test("a stored credential the provider now refuses is rejected until it is updated", async () => { + await token("github", "github_token") + await creds.connectCredentials({ id: "github", localOnly: true }) + await core.storeSecret(core.secretKey("cred", "github", "github_token"), "bad_token_0000") + expect((await creds.connectCredentials({ id: "github", update: true })).status).toBe("rejected") + const p = core.status().providers[0] + expect([p.state, p.note, p.account]).toEqual(["rejected", "Token rejected, update it", "octo"]) + await token("github", "github_token") + expect((await creds.connectCredentials({ id: "github", update: true })).status).toBe("connected") + expect(core.status().providers[0].state).toBe("connected") + }) + + test("a workspace connection marked invalid is rejected", async () => { + real() + api.seed({ name: `GitHub (from ${os.hostname()})`, integration_id: "github", is_valid: false }) + api.seed({ name: "Hand made", integration_id: "jira", last_validated_at: "2030-01-01T00:00:00Z" }) + api.seed({ name: "Teammate", integration_id: "linear", is_owner: false }) + const s = await core.statusRemote() + expect(s.providers.map((p) => [p.id, p.state, p.note])).toEqual([ + ["github", "rejected", "Token rejected, update it"], + ["jira", "connected", null], + ["linear", "not_found", null], + ]) + expect(calls.find((c) => c.path === "/connections/")?.query).toBe("?include_shared=false") + }) + }) + + test("the CLI shows the credential commands and no browser flow", () => { + const cli = path.join(import.meta.dir, "../../src/index.ts") + const run = (...args: string[]) => + Bun.spawnSync([process.execPath, "run", cli, "integrations", ...args], { env: { ...process.env, PATH: saved.PATH, ALTIMATE_INTEGRATIONS_DIR: tmp, ALTIMATE_INTEGRATIONS_MOCK: "1", ALTIMATE_INTEGRATIONS_HOME: home }, stdout: "pipe", stderr: "pipe" }) + const fields = run("fields", "linear", "--json") + expect(JSON.parse(fields.stdout.toString()).variant).toBe("key") + expect(JSON.parse(run("status", "--json").stdout.toString()).workspace.signedIn).toBe(true) + expect(run("expire", "github").exitCode).not.toBe(0) + const help = run("connect", "--help") + const text = help.stderr.toString() + help.stdout.toString() + expect(text).toContain("--local-only") + for (const flag of ["--oauth", "--reauth", "--frontend"]) expect(text).not.toContain(flag) + expect(JSON.parse(run("block", "--json").stdout.toString())).toMatchObject({ status: "failed" }) + const bad = run("set", "linear", "linearApiKey", "x", "--json") + expect(JSON.parse(bad.stdout.toString())).toMatchObject({ status: "failed" }) + expect(bad.stdout.toString()).toContain("is a secret") + expect(run("open-token-page", "jira", "--variant", "server", "--no-open", "--json").stdout.toString()).toContain("no token page") + }, 60_000) +}) + +describe("catalog fields a person fills in", () => { + test("skips backend bookkeeping, repeated fields and browser sign-in variants", () => { + const catalog = creds.parseCatalog([ + { + id: "linear", + config: [ + { key: "linearApiKey", name: "Linear API Key", encrypted: true }, + { key: "expires_at", name: "Expires at" }, + { key: "auth_type", name: "Auth type" }, + { key: "expires_at", name: "Expires at" }, + ], + }, + { + id: "jira", + config: [ + { key: "jiraDomain", name: "Domain:" }, + { key: "jiraEmail", name: "Email" }, + { key: "jiraApiToken", name: "Api Key", encrypted: true }, + ], + additionalConfigVariants: [ + { id: "oauth", type: "oauth", config: [{ key: "jiraOAuthAccessToken", encrypted: true }] }, + { id: "server", config: [{ key: "jiraBaseUrl", name: "Base URL" }, { key: "jiraPatToken", encrypted: true }] }, + ], + }, + ]) + expect(catalog.linear?.[0]?.fields.map((f) => f.name)).toEqual(["linearApiKey"]) + expect(catalog.jira?.map((v) => v.id)).toEqual(["cloud", "server"]) + expect(catalog.jira?.[0]?.fields.find((f) => f.name === "jiraDomain")?.label).toEqual("Domain") + }) +}) + +describe("attachToIntegrations", () => { + const keys = ["get_file", "list_issues", "search_code", "delete_file", "create_repository"] + test("adds a missing entry with read-only tools only", () => { + const out = core.attachToIntegrations([{ id: "jira", tools: [{ key: "x" }], connection_id: null }], "github", 7, keys) + expect(out[0]).toEqual({ id: "jira", tools: [{ key: "x" }], connection_id: null }) + expect(out[1]).toEqual({ id: "github", tools: [{ key: "get_file" }, { key: "list_issues" }, { key: "search_code" }], connection_id: "7" }) + }) + test("sets the connection on an existing entry and extends a connection list", () => { + const other = { id: "linear", tools: [], connection_id: "1", connection_ids: null, extra: true } + const out = core.attachToIntegrations([{ id: "jira", tools: [{ key: "t" }], connection_id: null, connection_ids: ["3"] }, other], "jira", 9, keys) + expect(out[0]).toEqual({ id: "jira", tools: [{ key: "t" }], connection_id: "9", connection_ids: ["3", "9"] }) + expect(out[1]).toBe(other) + }) +}) + +describe("attach keeps another connection", () => { + test("an entry on another connection keeps it, an empty one or this one is set", () => { + const keys: string[] = [] + const other = core.attachToIntegrations([{ id: "jira", tools: [], connection_id: "3" }], "jira", 9, keys) + expect(other[0].connection_id).toBe("3") + expect(core.attachToIntegrations([{ id: "jira", tools: [], connection_id: null }], "jira", 9, keys)[0].connection_id).toBe("9") + expect(core.attachToIntegrations([{ id: "jira", tools: [], connection_id: "9" }], "jira", 9, keys)[0].connection_id).toBe("9") + const shared = core.attachToIntegrations([{ id: "jira", tools: [], connection_id: "3", connection_ids: ["3"] }], "jira", 9, keys)[0] + expect([shared.connection_id, shared.connection_ids]).toEqual(["3", ["3", "9"]]) + }) + test("an entry on a connection that no longer exists takes this one", () => { + const gone = core.attachToIntegrations([{ id: "jira", tools: [], connection_id: "3" }], "jira", 9, [], [9, 4]) + expect(gone[0].connection_id).toBe("9") + const live = core.attachToIntegrations([{ id: "jira", tools: [], connection_id: "3" }], "jira", 9, [], [9, 3]) + expect(live[0].connection_id).toBe("3") + }) + test("an entry on a connection shared by another member is kept; a deleted one is taken over, with its id dropped from the list", async () => { + const entry = { id: "jira", tools: [], connection_id: "3", connection_ids: ["3", "4"] } + const updates: any[] = [] + const spies = [ + spyOn(workspaceState, "resolveBindingOutcome").mockResolvedValue({ status: "bound", binding: { datamateId: 5, datamateName: "WS" } } as any), + spyOn(backend, "getDatamate").mockResolvedValue({ id: "5", name: "WS", is_owner: true, privacy: "private", integrations: [entry] } as any), + spyOn(backend, "updateDatamateIntegrations").mockImplementation((async (_c: any, _id: any, list: any) => void updates.push(list)) as any), + ] + const listing = (ids: Array<[number, boolean]>) => spyOn(backend, "listVisibleConnections").mockResolvedValue(ids.map(([id, own]) => ({ id, is_owner: own })) as any) + try { + const cfg = { url: "http://x", tenant: "t", token: "k" } as any + let list = listing([[3, false], [4, true], [9, true]]) + expect(await core.attachToBoundWorkspace(cfg, "jira", 9)).toMatchObject({ kept: true }) + expect(updates[0][0].connection_id).toBe("3") + list.mockRestore() + list = listing([[4, true], [9, true]]) + expect(await core.attachToBoundWorkspace(cfg, "jira", 9)).not.toHaveProperty("kept") + expect([updates[1][0].connection_id, updates[1][0].connection_ids]).toEqual(["9", ["4", "9"]]) + list.mockRestore() + } finally { + for (const sp of spies) sp.mockRestore() + } + }) + test("attach is an error when the workspace catalog has no entry for the provider", async () => { + const spies = [ + spyOn(workspaceState, "resolveBindingOutcome").mockResolvedValue({ status: "bound", binding: { datamateId: 5, datamateName: "WS" } } as any), + spyOn(backend, "getDatamate").mockResolvedValue({ id: "5", name: "WS", is_owner: true, privacy: "private", integrations: [] } as any), + spyOn(backend, "integrationsCatalog").mockResolvedValue([{ id: "somethingelse", tools: [] }] as any), + spyOn(backend, "listVisibleConnections").mockResolvedValue([] as any), + ] + const update = spyOn(backend, "updateDatamateIntegrations").mockResolvedValue(undefined as any) + try { + const cfg = { url: "http://x", tenant: "t", token: "k" } as any + expect(await core.attachToBoundWorkspace(cfg, "jira", 9)).toEqual({ error: expect.stringContaining("no entry for Jira") }) + expect(update).not.toHaveBeenCalled() + } finally { + for (const sp of [...spies, update]) sp.mockRestore() + } + }) + test("attach reports when the workspace kept its own connection", async () => { + const spies = [ + spyOn(workspaceState, "resolveBindingOutcome").mockResolvedValue({ status: "bound", binding: { datamateId: 5, datamateName: "WS" } } as any), + spyOn(backend, "getDatamate").mockResolvedValue({ id: "5", name: "WS", is_owner: true, privacy: "private", integrations: [{ id: "jira", tools: [], connection_id: "3" }] } as any), + spyOn(backend, "updateDatamateIntegrations").mockResolvedValue(undefined as any), + ] + try { + const cfg = { url: "http://x", tenant: "t", token: "k" } as any + expect(await core.attachToBoundWorkspace(cfg, "jira", 9, [9, 3])).toEqual({ attached: "WS", workspaceId: "5", kept: true }) + expect(await core.attachToBoundWorkspace(cfg, "jira", 9, [9])).toEqual({ attached: "WS", workspaceId: "5" }) + } finally { + for (const sp of spies) sp.mockRestore() + } + }) + test("a workspace that is not private gets the connection only with --share, and says its members can use it", async () => { + const getDatamate = spyOn(backend, "getDatamate") + const spies = [ + spyOn(workspaceState, "resolveBindingOutcome").mockResolvedValue({ status: "bound", binding: { datamateId: 5, datamateName: "WS" } } as any), + spyOn(backend, "listVisibleConnections").mockResolvedValue([] as any), + getDatamate, + ] + const update = spyOn(backend, "updateDatamateIntegrations").mockResolvedValue(undefined as any) + try { + const cfg = { url: "http://x", tenant: "t", token: "k" } as any + // A missing privacy is treated as shared: nothing is widened on a guess. + for (const privacy of ["public", "restricted", undefined]) { + getDatamate.mockResolvedValue({ id: "5", name: "WS", is_owner: true, privacy, integrations: [{ id: "jira", tools: [], connection_id: null }] } as any) + expect(await core.attachToBoundWorkspace(cfg, "jira", 9)).toEqual({ error: core.SHARED_WORKSPACE_REFUSAL("WS", "jira") }) + expect(update).not.toHaveBeenCalled() + } + getDatamate.mockResolvedValue({ id: "5", name: "WS", is_owner: true, privacy: "public", integrations: [{ id: "jira", tools: [], connection_id: null }] } as any) + expect(await core.attachToBoundWorkspace(cfg, "jira", 9, undefined, true)).toEqual({ attached: "WS", workspaceId: "5", shared: true }) + // An entry that keeps another live connection does not take this one, so this one is not shared. + getDatamate.mockResolvedValue({ id: "5", name: "WS", is_owner: true, privacy: "public", integrations: [{ id: "jira", tools: [], connection_id: "3" }] } as any) + expect(await core.attachToBoundWorkspace(cfg, "jira", 9, [9, 3], true)).toEqual({ attached: "WS", workspaceId: "5", kept: true }) + expect(update).toHaveBeenCalledTimes(2) + // Without --share that kept entry is not refused either: the write changes nothing members can use. + expect(await core.attachToBoundWorkspace(cfg, "jira", 9, [9, 3])).toEqual({ attached: "WS", workspaceId: "5", kept: true }) + expect(update).toHaveBeenCalledTimes(3) + getDatamate.mockResolvedValue({ id: "5", name: "WS", is_owner: true, privacy: "private", integrations: [{ id: "jira", tools: [], connection_id: null }] } as any) + expect(await core.attachToBoundWorkspace(cfg, "jira", 9)).toEqual({ attached: "WS", workspaceId: "5" }) + update.mockClear() + // A workspace made shared between the first read and the one right before writing gets nothing. + getDatamate.mockResolvedValueOnce({ id: "5", name: "WS", is_owner: true, privacy: "private", integrations: [{ id: "jira", tools: [], connection_id: null }] } as any) + getDatamate.mockResolvedValueOnce({ id: "5", name: "WS", is_owner: true, privacy: "public", integrations: [{ id: "jira", tools: [], connection_id: null }] } as any) + expect(await core.attachToBoundWorkspace(cfg, "jira", 9)).toEqual({ error: core.SHARED_WORKSPACE_REFUSAL("WS", "jira") }) + expect(update).not.toHaveBeenCalled() + } finally { + for (const sp of [...spies, update]) sp.mockRestore() + } + }) + test("detach leaves an entry on another connection alone", () => { + const out = core.detachFromIntegrations([{ id: "jira", tools: [], connection_id: "3" }], 9) + expect(out[0].connection_id).toBe("3") + }) +}) + +describe("collectConfig", () => { + test("an optional secret with no stored value is left out; a required one is needed", async () => { + const catalog = spyOn(creds, "loadCatalog").mockResolvedValue([ + { id: "v", label: "V", fields: [{ name: "plain", label: "P", secret: false, required: true }, { name: "must", label: "M", secret: true, required: true }, { name: "extra", label: "E", secret: true, required: false }] }, + ] as any) + try { + const rec = { registered: true, variant: "v", values: { plain: "p" }, secretFields: ["must", "extra"] } as any + await core.storeSecret(core.secretKey("cred", "github", "must"), "m1") + expect(await core.collectConfig("github", rec)).toEqual({ config: { plain: "p", must: "m1" }, saved: true }) + await core.deleteSecret(core.secretKey("cred", "github", "must")) + expect(await core.collectConfig("github", rec)).toEqual({ reason: "no local credential to upload" }) + } finally { + catalog.mockRestore() + } + }) +}) + +describe("detachFromIntegrations", () => { + test("clears only this connection and leaves other entries alone", () => { + const other = { id: "linear", tools: [], connection_id: "5" } + const out = core.detachFromIntegrations([{ id: "jira", tools: [{ key: "t" }], connection_id: "9", connection_ids: ["3", "9"] }, other], 9) + expect(out).toEqual([{ id: "jira", tools: [{ key: "t" }], connection_id: null, connection_ids: ["3"] }, other]) + }) +}) + +describe("printed text and catalog shape", () => { + test("clean removes escape sequences, control and bidi characters", () => { + expect(clean("a\u001b[31mred\u001b[0m\nb\r\u0007c\u202ed")).toBe("ared b c d") + expect(clean("\u001b]0;title\u0007ok")).toBe("ok") + expect(clean(null)).toBe("") + }) + + test("the masked tail is dropped when it holds a control or direction character", () => { + expect(creds.maskTail("abcdefghij1234")).toBe("••••1234") + expect(creds.maskTail("abc")).toBe("••••") + for (const tail of ["12\u001b[3", "12\u202e34", "1\u00852\u0007", "12\u200f4", "12\u20283"]) expect(creds.maskTail(`abcdefghij${tail}`)).toBe("••••") + }) + + test("catalogEntries unwraps a list, items, integrations and data alike", () => { + const entry = { id: "github", tools: [{ key: "read_repo" }] } + for (const body of [[entry], { items: [entry] }, { integrations: [entry] }, { data: [entry] }]) { + expect(backend.catalogEntries(body)).toEqual([entry]) + expect(Object.keys(creds.parseCatalog(body))).toEqual(Object.keys(creds.parseCatalog([entry]))) + } + expect(backend.catalogEntries({ items: "x" })).toEqual([]) + expect(backend.catalogEntries(null)).toEqual([]) + }) +}) + +describe("saved login", () => { + const saved = { url: process.env.ALTIMATE_INTEGRATIONS_API_URL, tenant: process.env.ALTIMATE_INTEGRATIONS_TENANT, token: process.env.ALTIMATE_INTEGRATIONS_TOKEN } + beforeEach(() => { + delete process.env.ALTIMATE_INTEGRATIONS_API_URL + delete process.env.ALTIMATE_INTEGRATIONS_TENANT + delete process.env.ALTIMATE_INTEGRATIONS_TOKEN + }) + afterEach(() => { + for (const [k, v] of [["ALTIMATE_INTEGRATIONS_API_URL", saved.url], ["ALTIMATE_INTEGRATIONS_TENANT", saved.tenant], ["ALTIMATE_INTEGRATIONS_TOKEN", saved.token]] as const) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + }) + + test("a missing credentials file is signed out, an unreadable one is an error", async () => { + const missing = spyOn(AltimateApi, "getCredentials").mockRejectedValue(new Error("Altimate credentials not found at /x/altimate.json")) + try { + expect(await backend.backendConfig()).toBeNull() + } finally { + missing.mockRestore() + } + const corrupt = spyOn(AltimateApi, "getCredentials").mockRejectedValue(new SyntaxError("Unexpected token } in JSON")) + try { + await expect(backend.backendConfig()).rejects.toThrow("Could not read your Altimate login (Unexpected token } in JSON)") + } finally { + corrupt.mockRestore() + } + }) +})