diff --git a/README.md b/README.md index 1be795b..ff93ee9 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,14 @@ What's provider-specific is only the curation layer, shipped as **provider packs AWS ships first. Adding a GCP/Azure/other pack is pure data and makes a great first PR. +## OpenTofu + +stackcanvas works with [OpenTofu](https://opentofu.org) as a drop-in replacement +for Terraform: it looks for a `terraform` binary on `PATH` first, then falls +back to `tofu`. Override the choice with `--tf-bin ` on `stackcanvas serve`, +or set `STACKCANVAS_TF_BIN` (e.g. in your MCP client's `.mcp.json` `env` block) +to pin it — both take precedence over auto-detection. + ## Tools | Tool | Purpose | diff --git a/packages/mcp/src/cli.ts b/packages/mcp/src/cli.ts index de72db9..33c049a 100644 --- a/packages/mcp/src/cli.ts +++ b/packages/mcp/src/cli.ts @@ -27,6 +27,7 @@ async function main(): Promise { const dir = arg('dir') ?? process.cwd() const fixture = arg('fixture') const port = arg('port') + const tfBin = arg('tf-bin') if (port !== undefined && Number.isNaN(Number(port))) { console.error('invalid --port') process.exit(1) @@ -36,6 +37,7 @@ async function main(): Promise { uiDist, port: port ? Number(port) : undefined, runTerraformShow: fixture ? async () => readFileSync(fixture, 'utf8') : undefined, + tfBinary: tfBin, telemetry, }) const { url } = await server.start() diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 87a4ef6..81627f9 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -53,7 +53,8 @@ export function createMcpServer(deps: McpDeps = {}): McpServer { } return ok(`Canvas running at ${url}. The graph live-updates as tfstate changes. ` + 'Run `terraform plan -out=tfplan && terraform show -json tfplan > .stackcanvas/plan.json` ' - + 'to show the plan diff, then call await_canvas_intent to receive user edits.') + + '(or `tofu plan …` with OpenTofu) to show the plan diff, then call await_canvas_intent ' + + 'to receive user edits.') }) mcp.registerTool('load_plan', { diff --git a/packages/server/src/canvas-server.test.ts b/packages/server/src/canvas-server.test.ts index 9fe8176..d362253 100644 --- a/packages/server/src/canvas-server.test.ts +++ b/packages/server/src/canvas-server.test.ts @@ -248,3 +248,26 @@ test('a refreshOnStart:false provider is never refreshed by start()/refreshGraph live.pushSnapshot({ origin: 'fake-live', graph: fakeGraph('fake_thing.x'), stale: null }) expect(server.getGraph().nodes.some(n => n.id === 'fake_thing.x')).toBe(true) }) + +// --- P2-12 OpenTofu binary resolution tests (issue #26) — new portRangeStart +// base per the distinct-base convention. tfBinary is passed explicit (used +// verbatim by resolveTfBinary, no probe), so this stays hermetic regardless +// of whether terraform/tofu are actually installed on the host. --- + +test('tfBinary option flows through to TerraformProvider.binaryUsed and the /api/meta label', async () => { + server = new CanvasServer({ dir: makeDir(), tfBinary: 'sc-test-fake-binary', portRangeStart: 22680 }) + const { url } = await server.start() + const meta = await (await fetch(`${url}/api/meta`)).json() + const tf = meta.providers.find((p: { origin: string }) => p.origin === 'terraform') + expect(tf.label).toBe(`Terraform (${server.dir}) via sc-test-fake-binary`) +}) + +test('without tfBinary and an injected runTerraformShow, the label carries no binary suffix (unchanged)', async () => { + server = new CanvasServer({ + dir: makeDir(), runTerraformShow: async () => stateFixture, portRangeStart: 22680, + }) + const { url } = await server.start() + const meta = await (await fetch(`${url}/api/meta`)).json() + const tf = meta.providers.find((p: { origin: string }) => p.origin === 'terraform') + expect(tf.label).toBe(`Terraform (${server.dir})`) +}) diff --git a/packages/server/src/canvas-server.ts b/packages/server/src/canvas-server.ts index f29245e..3d47880 100644 --- a/packages/server/src/canvas-server.ts +++ b/packages/server/src/canvas-server.ts @@ -13,7 +13,7 @@ import { import { findPort } from './find-port.js' import { IntentQueue } from './intent-queue.js' import { nodesBucket, TelemetryClient } from './telemetry.js' -import { TerraformProvider, type TerraformShowRunner } from './providers/terraform.js' +import { binaryKind, TerraformProvider, type TerraformShowRunner } from './providers/terraform.js' const intentSchema = z.object({ add: z.array(z.object({ @@ -46,6 +46,9 @@ export interface CanvasServerOptions { /** First port findPort probes when no fixed port is given (default 4680). * Tests use distinct bases so parallel suites never contend. */ portRangeStart?: number + /** Explicit terraform/tofu binary; forwarded to TerraformProvider (used + * verbatim, skipping PATH detection — see resolveTfBinary). */ + tfBinary?: string /** Injectable for tests; defaults to a TelemetryClient reading/writing * ~/.stackcanvas/config.json (or STACKCANVAS_CONFIG_DIR if set). */ telemetry?: TelemetryClient @@ -83,7 +86,7 @@ export class CanvasServer { this.fixedPort = opts.port this.portRangeStart = opts.portRangeStart ?? 4680 this.telemetry = opts.telemetry ?? new TelemetryClient({ appVersion: '0.1.0' }) - this.tf = new TerraformProvider({ dir: opts.dir, runShow: opts.runTerraformShow }) + this.tf = new TerraformProvider({ dir: opts.dir, runShow: opts.runTerraformShow, binary: opts.tfBinary }) this.providers = [this.tf] this.extraProviders = opts.extraProviders ?? [] this.subscribe((graph, stale) => this.broadcast({ type: 'graph', graph, stale })) @@ -329,12 +332,13 @@ export class CanvasServer { // One canvas_opened per successful start() — covers both `stackcanvas // serve` and the MCP open_canvas path (which calls start() once per new // canvas and never re-calls it for a reused one, since start() throws on - // a second call). tf_bin degrades to 'unknown' until resolveTfBinary / - // TerraformProvider ships with the OpenTofu-detection increment. + // a second call). tf_bin reads TerraformProvider.binaryUsed (resolved by + // tf.init()/refreshGraph() above); it degrades to 'unknown' whenever + // binaryUsed is null — no binary detected, or runShow was injected. this.telemetry.emit({ event: 'canvas_opened', nodes_bucket: nodesBucket(this.graph.nodes.length), - tf_bin: 'unknown', + tf_bin: binaryKind(this.tf.binaryUsed), }) // Pure sugar over addProvider(): a refreshOnStart provider is refreshed // via addProvider's own conditional refresh, a refreshOnStart:false one diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3310612..0c3b509 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,7 +1,7 @@ export { CanvasServer } from './canvas-server.js' export type { CanvasServerOptions } from './canvas-server.js' -export { TerraformProvider, defaultRunner } from './providers/terraform.js' -export type { TerraformProviderOptions, TerraformShowRunner } from './providers/terraform.js' +export { binaryKind, createShowRunner, defaultRunner, resolveTfBinary, TerraformProvider } from './providers/terraform.js' +export type { ResolveTfBinaryOptions, TerraformProviderOptions, TerraformShowRunner } from './providers/terraform.js' export { IntentQueue } from './intent-queue.js' export { nodesBucket, TELEMETRY_SCHEMA_VERSION, TelemetryClient } from './telemetry.js' export type { diff --git a/packages/server/src/providers/terraform.test.ts b/packages/server/src/providers/terraform.test.ts index f0b078b..9888c94 100644 --- a/packages/server/src/providers/terraform.test.ts +++ b/packages/server/src/providers/terraform.test.ts @@ -1,9 +1,9 @@ -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, test, vi } from 'vitest' import type { ProviderSnapshot } from '@stackcanvas/core' -import { defaultRunner, TerraformProvider } from './terraform.js' +import { binaryKind, defaultRunner, resolveTfBinary, TerraformProvider } from './terraform.js' const stateFixture = readFileSync( new URL('../../../core/test/fixtures/state.json', import.meta.url), 'utf8', @@ -157,3 +157,147 @@ test('dispose() is idempotent — calling it twice resolves cleanly', async () = await provider.dispose() await expect(provider.dispose()).resolves.toBeUndefined() }) + +// --------------------------------------------------------------------------- +// resolveTfBinary / createShowRunner / binaryKind (P2-12, issue #26) +// --------------------------------------------------------------------------- + +afterEach(() => { + vi.unstubAllEnvs() +}) + +test('resolveTfBinary: explicit argument wins over everything, without probing', async () => { + const probe = vi.fn(async () => true) + const bin = await resolveTfBinary('/custom/path/mytf', { probe }) + expect(bin).toBe('/custom/path/mytf') + expect(probe).not.toHaveBeenCalled() +}) + +test('resolveTfBinary: STACKCANVAS_TF_BIN env wins over PATH probing', async () => { + vi.stubEnv('STACKCANVAS_TF_BIN', 'my-tofu-fork') + const probe = vi.fn(async () => true) + const bin = await resolveTfBinary(undefined, { probe }) + expect(bin).toBe('my-tofu-fork') + expect(probe).not.toHaveBeenCalled() +}) + +test('resolveTfBinary: explicit argument wins over STACKCANVAS_TF_BIN too', async () => { + vi.stubEnv('STACKCANVAS_TF_BIN', 'env-bin') + const probe = vi.fn(async () => true) + const bin = await resolveTfBinary('explicit-bin', { probe }) + expect(bin).toBe('explicit-bin') + expect(probe).not.toHaveBeenCalled() +}) + +test('resolveTfBinary: PATH fallback probes terraform before tofu, stopping on first pass', async () => { + const calls: string[] = [] + const probe = vi.fn(async (bin: string) => { calls.push(bin); return bin === 'terraform' }) + const bin = await resolveTfBinary(undefined, { probe }) + expect(bin).toBe('terraform') + expect(calls).toEqual(['terraform']) // tofu never probed once terraform passes +}) + +test('resolveTfBinary: falls through to tofu when the terraform probe fails silently', async () => { + const calls: string[] = [] + const probe = vi.fn(async (bin: string) => { calls.push(bin); return bin === 'tofu' }) + const bin = await resolveTfBinary(undefined, { probe }) + expect(bin).toBe('tofu') + expect(calls).toEqual(['terraform', 'tofu']) +}) + +test('resolveTfBinary: returns null when neither terraform nor tofu probes succeed', async () => { + const probe = vi.fn(async () => false) + const bin = await resolveTfBinary(undefined, { probe }) + expect(bin).toBeNull() +}) + +test('resolveTfBinary performs a fresh probe on every call — no internal caching, so a later ' + + 'call recovers once a binary becomes available (re-probe recovery)', async () => { + let installed = false + const probe = vi.fn(async (bin: string) => installed && bin === 'terraform') + expect(await resolveTfBinary(undefined, { probe })).toBeNull() + installed = true + expect(await resolveTfBinary(undefined, { probe })).toBe('terraform') +}) + +test('binaryKind maps a resolved binary to terraform/tofu/unknown by basename', () => { + expect(binaryKind('terraform')).toBe('terraform') + expect(binaryKind('/usr/local/bin/terraform')).toBe('terraform') + expect(binaryKind('terraform.exe')).toBe('terraform') + expect(binaryKind('tofu')).toBe('tofu') + expect(binaryKind('/opt/homebrew/bin/tofu')).toBe('tofu') + expect(binaryKind('some-custom-wrapper')).toBe('unknown') + expect(binaryKind(null)).toBe('unknown') +}) + +// --------------------------------------------------------------------------- +// TerraformProvider binary detection wiring (P2-12) — hermetic PATH swap via +// real (fake) executables, so it exercises the real execFile probe/runner +// wiring without depending on terraform/tofu actually being installed. +// --------------------------------------------------------------------------- + +function writeFakeBinary(dir: string, name: string): void { + const path = join(dir, name) + // Responds to `version` (the resolveTfBinary probe) and `show -json ...` + // (the runner) with just enough to satisfy parseState. Uses only shell + // builtins (echo/if/exit) — no external PATH-resolved commands — so it + // works even when PATH is stubbed down to just this fake-bin dir. + writeFileSync(path, [ + '#!/bin/sh', + 'if [ "$1" = "version" ]; then exit 0; fi', + 'echo \'{"format_version":"1.0","values":{"root_module":{"resources":[]}}}\'', + '', + ].join('\n')) + chmodSync(path, 0o755) +} + +test('TerraformProvider: no injected runShow and no binary on PATH resolves to ' + + 'stale + binaryUsed null', async () => { + const dir = makeDir() + const emptyPathDir = mkdtempSync(join(tmpdir(), 'sc-emptypath-')) + vi.stubEnv('PATH', emptyPathDir) // hermetic: real terraform/tofu on this machine is unreachable + provider = new TerraformProvider({ dir }) + const snap = await provider.refresh() + expect(provider.binaryUsed).toBeNull() + expect(snap.stale).toBe('No terraform or tofu binary found in PATH. Install one or set STACKCANVAS_TF_BIN.') + expect(provider.label).toBe(`Terraform (${dir})`) +}, 15000) + +test('TerraformProvider: re-probes and recovers once a binary appears on PATH, no restart needed', async () => { + const dir = makeDir() + const emptyPathDir = mkdtempSync(join(tmpdir(), 'sc-emptypath-')) + vi.stubEnv('PATH', emptyPathDir) + provider = new TerraformProvider({ dir }) + const first = await provider.refresh() + expect(first.stale).not.toBeNull() + expect(provider.binaryUsed).toBeNull() + + const fakeBinDir = mkdtempSync(join(tmpdir(), 'sc-fakebin-')) + writeFakeBinary(fakeBinDir, 'terraform') + vi.stubEnv('PATH', fakeBinDir) + + const second = await provider.refresh() + expect(second.stale).toBeNull() + expect(provider.binaryUsed).toBe('terraform') + expect(provider.label).toBe(`Terraform (${dir}) via terraform`) +}, 15000) + +test('TerraformProvider: an injected runShow skips probing entirely — binaryUsed stays null', async () => { + const dir = makeDir() + provider = new TerraformProvider({ dir, runShow: async () => stateFixture }) + await provider.refresh() + expect(provider.binaryUsed).toBeNull() + expect(provider.label).toBe(`Terraform (${dir})`) +}) + +test('TerraformProvider: an explicit binary option is used verbatim, skipping probing', async () => { + const dir = makeDir() + const fakeBinDir = mkdtempSync(join(tmpdir(), 'sc-fakebin-')) + writeFakeBinary(fakeBinDir, 'tofu') + vi.stubEnv('PATH', fakeBinDir) + provider = new TerraformProvider({ dir, binary: 'tofu' }) + const snap = await provider.refresh() + expect(provider.binaryUsed).toBe('tofu') + expect(snap.stale).toBeNull() + expect(provider.label).toBe(`Terraform (${dir}) via tofu`) +}) diff --git a/packages/server/src/providers/terraform.ts b/packages/server/src/providers/terraform.ts index ca85a84..a8caae1 100644 --- a/packages/server/src/providers/terraform.ts +++ b/packages/server/src/providers/terraform.ts @@ -1,6 +1,6 @@ import { execFile } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' -import { join, sep } from 'node:path' +import { basename, join, sep } from 'node:path' import { promisify } from 'node:util' import chokidar, { type FSWatcher } from 'chokidar' import { @@ -14,10 +14,8 @@ const execFileAsync = promisify(execFile) export type TerraformShowRunner = (cwd: string, planPath?: string) => Promise /** Kept exported with today's exact behavior (hardcoded 'terraform') for - * backcompat. OpenTofu-aware binary resolution (resolveTfBinary / - * createShowRunner / TerraformProvider.binaryUsed) is a later increment of - * this same spec chapter — not part of this PR, which only extracts today's - * terraform-only path verbatim. */ + * backcompat — no OpenTofu fallback. + * @deprecated in favor of `createShowRunner`, which is binary-detection-aware. */ export const defaultRunner: TerraformShowRunner = async (cwd, planPath) => { const args = ['show', '-json', ...(planPath ? [planPath] : [])] try { @@ -31,10 +29,87 @@ export const defaultRunner: TerraformShowRunner = async (cwd, planPath) => { } } +export interface ResolveTfBinaryOptions { + /** Test seam: overrides the real `execFile version` probe used for + * the 'terraform' / 'tofu' PATH-fallback candidates. */ + probe?: (bin: string) => Promise +} + +/** Real probe: a candidate passes if `execFile(bin, ['version'])` resolves. */ +async function defaultProbe(bin: string): Promise { + try { + await execFileAsync(bin, ['version'], { timeout: 10_000 }) + return true + } catch { + return false + } +} + +/** Probe order: explicit ?? $STACKCANVAS_TF_BIN ?? 'terraform' ?? 'tofu' ?? null. + * `explicit` and `STACKCANVAS_TF_BIN` are used verbatim — no probe — so a + * wrong value surfaces later as the runner's own ENOENT message, which + * names it. Only the 'terraform'/'tofu' PATH-fallback candidates are probed + * via `execFile(bin, ['version'])`; probe failures are silent, falling to + * the next candidate. This is the ONLY tf/tofu resolver in the codebase + * (supersedes the release-engineering section's `resolveTerraformBin`). + * Performs a fresh probe on every call — no internal caching — so a caller + * that re-invokes it after `binaryUsed` went stale (e.g. TerraformProvider's + * refresh()) picks up a binary installed since the last attempt without a + * restart. */ +export async function resolveTfBinary( + explicit?: string, + opts: ResolveTfBinaryOptions = {}, +): Promise { + if (explicit) return explicit + const envBin = process.env.STACKCANVAS_TF_BIN + if (envBin) return envBin + const probe = opts.probe ?? defaultProbe + if (await probe('terraform')) return 'terraform' + if (await probe('tofu')) return 'tofu' + return null +} + +/** Binary-detection-aware runner: `getBinary()` is read at call time (not + * captured), so a `TerraformProvider` can swap in a freshly re-resolved + * binary between calls without recreating the runner. Error mapping and + * `maxBuffer` match `defaultRunner`'s today. */ +export function createShowRunner(getBinary: () => string | null): TerraformShowRunner { + return async (cwd, planPath) => { + const bin = getBinary() + if (bin === null) + throw new Error('No terraform or tofu binary found in PATH. Install one or set STACKCANVAS_TF_BIN.') + const args = ['show', '-json', ...(planPath ? [planPath] : [])] + try { + const { stdout } = await execFileAsync(bin, args, { cwd, maxBuffer: 256 * 1024 * 1024 }) + return stdout + } catch (err) { + const e = err as NodeJS.ErrnoException + if (e.code === 'ENOENT') + throw new Error(`${bin} binary not found in PATH. Install Terraform or add it to PATH.`) + throw new Error(`${bin} show failed: ${(err as Error).message}`) + } + } +} + +/** Maps a resolved binary (name or path, as stored in `binaryUsed`) to the + * telemetry `tf_bin` vocabulary by basename — `.exe` tolerated for Windows, + * anything else (including null/unresolved) is 'unknown'. */ +export function binaryKind(binaryUsed: string | null): 'terraform' | 'tofu' | 'unknown' { + if (!binaryUsed) return 'unknown' + const name = basename(binaryUsed).toLowerCase().replace(/\.exe$/, '') + if (name === 'terraform') return 'terraform' + if (name === 'tofu') return 'tofu' + return 'unknown' +} + export interface TerraformProviderOptions { dir: string - /** Injectable for tests — same contract as CanvasServerOptions.runTerraformShow today. */ + /** Injectable for tests — same contract as CanvasServerOptions.runTerraformShow today. + * When provided, binary detection is skipped entirely: `binaryUsed` stays + * `null` and `label` carries no ` via ` suffix. */ runShow?: TerraformShowRunner + /** Explicit binary name/path; skips detection (used verbatim — see resolveTfBinary). */ + binary?: string /** Watcher debounce, default 300 (today's value). */ debounceMs?: number } @@ -45,9 +120,19 @@ export class TerraformProvider implements SourceProvider { /** Always true — terraform state is local and cheap to read, so it keeps * today's zero-config auto-refresh-on-start behavior. */ readonly refreshOnStart = true - readonly label: string + /** Resolved by init() (and re-resolved by refresh() while still null); null + * = none found (or runShow injected, which skips detection entirely). + * Telemetry's `tf_bin` event property reads this via `binaryKind()`. */ + binaryUsed: string | null = null + + get label(): string { + return this.binaryUsed ? `Terraform (${this.dir}) via ${this.binaryUsed}` : `Terraform (${this.dir})` + } private run: TerraformShowRunner + private readonly injectedRunShow: boolean + private readonly explicitBinary: string | undefined + private binaryResolveAttempted = false private graph: GraphModel = { nodes: [], edges: [], groups: [] } private stale: string | null = null private planJson: unknown = null @@ -60,28 +145,42 @@ export class TerraformProvider implements SourceProvider { constructor(opts: TerraformProviderOptions) { this.dir = opts.dir - this.run = opts.runShow ?? defaultRunner + this.injectedRunShow = opts.runShow !== undefined + this.explicitBinary = opts.binary + this.run = opts.runShow ?? createShowRunner(() => this.binaryUsed) this.debounceMs = opts.debounceMs ?? 300 - // Binary-suffixed label ("... via tofu") lands with the OpenTofu PR that - // introduces `binaryUsed`; today's label matches the constant text the - // rest of the codebase never asserted on before this PR either. - this.label = `Terraform (${this.dir})` } private snapshot(): ProviderSnapshot { return { origin: this.origin, graph: this.graph, stale: this.stale } } - /** Validate config and detect tooling (no-op today — binary detection is a - * later increment). Also (re)creates the chokidar watcher and awaits its - * 'ready' event: inotify (Linux) delivers no events for writes that land - * before the watcher is ready, so callers that await init() before - * mutating the watched directory don't lose that first change — the same - * guarantee today's canvas-server.ts got by awaiting 'ready' directly in - * start(). SourceProvider.watch() itself must stay synchronous per the - * interface, so this is the one place that can still block on it. - * Idempotent: a no-op once the watcher exists. */ + /** Re-resolves `binaryUsed` if binary detection hasn't been attempted yet + * and no runShow was injected (which bypasses detection entirely). Called + * from both init() (once, up front) and refresh() (only while still null, + * so installing terraform/tofu after a failed resolution recovers on the + * next refresh without a restart — resolveTfBinary itself has no cache, + * so a fresh probe is exactly what re-running it gives us). */ + private async resolveBinary(): Promise { + this.binaryResolveAttempted = true + if (this.injectedRunShow) return + this.binaryUsed = await resolveTfBinary(this.explicitBinary) + } + + /** Validate config and detect tooling: resolves the terraform/tofu binary + * (skipped when runShow was injected) and (re)creates the chokidar + * watcher, awaiting its 'ready' event: inotify (Linux) delivers no events + * for writes that land before the watcher is ready, so callers that await + * init() before mutating the watched directory don't lose that first + * change — the same guarantee today's canvas-server.ts got by awaiting + * 'ready' directly in start(). SourceProvider.watch() itself must stay + * synchronous per the interface, so this is the one place that can still + * block on it. Never throws for a missing binary — that surfaces as + * `stale` on refresh(), same as before this detection was added. + * Idempotent: binary resolution runs once; the watcher setup is a no-op + * once the watcher exists. */ async init(): Promise { + if (!this.binaryResolveAttempted) await this.resolveBinary() if (this.watcher) return const planPath = join(this.dir, '.stackcanvas', 'plan.json') // chokidar v4 dropped glob support, so watch `dir` recursively and filter @@ -109,6 +208,12 @@ export class TerraformProvider implements SourceProvider { async refresh( _opts?: { force?: boolean; onProgress?: (p: ScanProgress) => void }, ): Promise { + // Re-probe recovery: a prior resolution that found nothing (binaryUsed + // still null) is retried on every refresh, so installing terraform/tofu + // later recovers without a restart. A once-resolved binaryUsed is not + // re-probed here — a binary vanishing mid-session surfaces as `stale` + // from the runner's own ENOENT message instead. + if (this.binaryUsed === null && !this.injectedRunShow) await this.resolveBinary() try { const stateJson = JSON.parse(await this.run(this.dir)) let g = parseState(stateJson) diff --git a/packages/server/src/telemetry-routes.test.ts b/packages/server/src/telemetry-routes.test.ts index 73c896d..9ff32c3 100644 --- a/packages/server/src/telemetry-routes.test.ts +++ b/packages/server/src/telemetry-routes.test.ts @@ -111,10 +111,34 @@ test('canvas_opened is emitted from start() once consent is granted', async () = expect(envelope.payload).toEqual({ event: 'canvas_opened', nodes_bucket: nodesBucket(server.getGraph().nodes.length), - tf_bin: 'unknown', // resolveTfBinary/TerraformProvider ships with the source-provider section + // An injected runTerraformShow skips binary detection entirely (see + // TerraformProvider), so binaryUsed stays null and binaryKind() maps + // that to 'unknown' — this is the resolved value now, not a hardcode. + tf_bin: 'unknown', }) }) +// P2-12 (issue #26): tf_bin now reads TerraformProvider.binaryUsed via +// binaryKind() instead of a hardcoded 'unknown'. tfBinary is passed explicit +// so binaryUsed is set verbatim (no probe) — hermetic regardless of whether +// terraform/tofu are actually installed on the host running this test. +test('canvas_opened tf_bin reflects the resolved binary kind, not a hardcoded value', async () => { + const fetchImpl = vi.fn(async () => new Response(null, { status: 204 })) + const telemetry = makeTelemetryClient(fetchImpl) + telemetry.setConsent(true) + fetchImpl.mockClear() + + server = new CanvasServer({ + dir: makeDir(), tfBinary: 'tofu', portRangeStart: 20680, telemetry, + }) + await server.start() + + expect(fetchImpl).toHaveBeenCalledTimes(1) + const [, init] = fetchImpl.mock.calls[0]! + const envelope = JSON.parse(String(init!.body)) as { payload: TelemetryProps } + expect(envelope.payload).toMatchObject({ event: 'canvas_opened', tf_bin: 'tofu' }) +}) + test('canvas_opened is NOT emitted from start() when consent is unset', async () => { const fetchImpl = vi.fn(async () => new Response(null, { status: 204 })) const telemetry = makeTelemetryClient(fetchImpl)