stackcanvas 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,1042 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { readFileSync as readFileSync4 } from "fs";
5
+ import { fileURLToPath } from "url";
6
+ import { dirname as dirname2, join as join4 } from "path";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+
9
+ // ../server/src/canvas-server.ts
10
+ import { existsSync as existsSync2, readFileSync as readFileSync3, statSync } from "fs";
11
+ import { extname, join as join3, resolve, sep as sep2 } from "path";
12
+ import { Hono } from "hono";
13
+ import { serve } from "@hono/node-server";
14
+ import { WebSocketServer, WebSocket } from "ws";
15
+ import { z } from "zod";
16
+
17
+ // ../core/src/derive.ts
18
+ function deriveEdges(nodes) {
19
+ const byAddress = new Set(nodes.map((n) => n.id));
20
+ const byPhysicalId = /* @__PURE__ */ new Map();
21
+ for (const n of nodes) {
22
+ const pid = n.attributes["id"];
23
+ if (typeof pid === "string" && pid.length > 0) byPhysicalId.set(pid, n.id);
24
+ }
25
+ const edges = /* @__PURE__ */ new Map();
26
+ const add = (source, target) => {
27
+ if (source === target) return;
28
+ const id = `${source}->${target}`;
29
+ edges.set(id, { id, source, target });
30
+ };
31
+ for (const n of nodes) {
32
+ for (const dep of n.dependsOn) if (byAddress.has(dep)) add(dep, n.id);
33
+ for (const [key, v] of Object.entries(n.attributes)) {
34
+ if (key === "id" || typeof v !== "string") continue;
35
+ const target = byPhysicalId.get(v);
36
+ if (target) add(target, n.id);
37
+ }
38
+ }
39
+ return [...edges.values()];
40
+ }
41
+ var DEFAULT_CONTAINMENT_RULES = [
42
+ { containerType: "aws_vpc", memberAttr: "vpc_id", kind: "vpc" },
43
+ { containerType: "aws_subnet", memberAttr: "subnet_id", kind: "subnet" }
44
+ ];
45
+ function deriveContainment(g, rules = DEFAULT_CONTAINMENT_RULES) {
46
+ const groups = [...g.groups];
47
+ const nodes = g.nodes.map((n) => ({ ...n }));
48
+ for (const rule of rules) {
49
+ const groupByPhysicalId = /* @__PURE__ */ new Map();
50
+ for (const n of nodes.filter((n2) => n2.type === rule.containerType)) {
51
+ const pid = n.attributes["id"];
52
+ if (typeof pid !== "string") continue;
53
+ const gid = `${rule.kind}:${n.id}`;
54
+ groups.push({ id: gid, label: n.name, kind: rule.kind, parent: n.group });
55
+ groupByPhysicalId.set(pid, gid);
56
+ n.group = gid;
57
+ }
58
+ for (const n of nodes) {
59
+ if (n.type === rule.containerType) continue;
60
+ const ref = n.attributes[rule.memberAttr];
61
+ if (typeof ref === "string" && groupByPhysicalId.has(ref))
62
+ n.group = groupByPhysicalId.get(ref);
63
+ }
64
+ }
65
+ return { ...g, nodes, groups };
66
+ }
67
+
68
+ // ../core/src/parse-state.ts
69
+ function shortProvider(providerName) {
70
+ if (!providerName) return "unknown";
71
+ const last = providerName.split("/").pop() ?? providerName;
72
+ return last;
73
+ }
74
+ function maskSensitiveElement(v, s) {
75
+ if (s === true) return "\u2022\u2022\u2022";
76
+ if (Array.isArray(s) && Array.isArray(v)) return v.map((el, i) => maskSensitiveElement(el, s[i]));
77
+ if (s && typeof s === "object" && v && typeof v === "object" && !Array.isArray(v))
78
+ return maskSensitive(v, s);
79
+ return v;
80
+ }
81
+ function maskSensitive(values, sensitive) {
82
+ if (!sensitive || typeof sensitive !== "object") return values;
83
+ const s = sensitive;
84
+ const out = {};
85
+ for (const [k, v] of Object.entries(values)) {
86
+ if (s[k] === true) out[k] = "\u2022\u2022\u2022";
87
+ else if (Array.isArray(s[k]) && Array.isArray(v))
88
+ out[k] = v.map((el, i) => maskSensitiveElement(el, s[k][i]));
89
+ else if (s[k] && typeof s[k] === "object" && v && typeof v === "object" && !Array.isArray(v))
90
+ out[k] = maskSensitive(v, s[k]);
91
+ else out[k] = v;
92
+ }
93
+ return out;
94
+ }
95
+ function walkModule(mod, parentGroup, nodes, groups) {
96
+ let groupId = parentGroup;
97
+ if (mod.address) {
98
+ groupId = mod.address;
99
+ groups.push({
100
+ id: mod.address,
101
+ label: mod.address.split(".").pop() ?? mod.address,
102
+ kind: "module",
103
+ parent: parentGroup
104
+ });
105
+ }
106
+ for (const r of mod.resources ?? []) {
107
+ if (r.mode !== "managed") continue;
108
+ nodes.push({
109
+ id: r.address,
110
+ type: r.type,
111
+ name: r.name,
112
+ provider: shortProvider(r.provider_name),
113
+ group: groupId,
114
+ attributes: maskSensitive(r.values ?? {}, r.sensitive_values),
115
+ status: "noop",
116
+ dependsOn: r.depends_on ?? []
117
+ });
118
+ }
119
+ for (const child of mod.child_modules ?? []) walkModule(child, groupId, nodes, groups);
120
+ }
121
+ function parseState(showJson) {
122
+ const nodes = [];
123
+ const groups = [];
124
+ const root = showJson?.values?.root_module;
125
+ if (root) walkModule(root, null, nodes, groups);
126
+ const base = { nodes, edges: [], groups };
127
+ const contained = deriveContainment(base);
128
+ return { ...contained, edges: deriveEdges(contained.nodes) };
129
+ }
130
+
131
+ // ../core/src/apply-plan.ts
132
+ function maskChange(value, sensitive) {
133
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
134
+ return maskSensitive(value, sensitive);
135
+ }
136
+ function statusOf(actions) {
137
+ if (actions.includes("create") && actions.includes("delete")) return "replace";
138
+ if (actions.includes("create")) return "create";
139
+ if (actions.includes("delete")) return "delete";
140
+ if (actions.includes("update")) return "update";
141
+ return "noop";
142
+ }
143
+ function diffAttrs(rawBefore, rawAfter, maskedBefore, maskedAfter) {
144
+ const rb = rawBefore ?? {};
145
+ const ra = rawAfter ?? {};
146
+ const mb = maskedBefore ?? {};
147
+ const ma = maskedAfter ?? {};
148
+ const keys = [.../* @__PURE__ */ new Set([...Object.keys(rb), ...Object.keys(ra)])].sort();
149
+ const out = [];
150
+ for (const k of keys) {
151
+ if (JSON.stringify(rb[k]) !== JSON.stringify(ra[k]))
152
+ out.push({ key: k, before: mb[k] ?? null, after: ma[k] ?? null });
153
+ }
154
+ return out;
155
+ }
156
+ function moduleOf(address) {
157
+ const parts = address.split(".");
158
+ if (parts[0] !== "module") return null;
159
+ const chain = [];
160
+ for (let i = 0; i + 1 < parts.length && parts[i] === "module"; i += 2)
161
+ chain.push(`module.${parts[i + 1]}`);
162
+ return chain.length ? chain.join(".") : null;
163
+ }
164
+ function applyPlan(g, planJson) {
165
+ const changes = planJson?.resource_changes ?? [];
166
+ const nodes = new Map(
167
+ g.nodes.map((n) => [n.id, { ...n, status: "noop", attributeDiff: void 0 }])
168
+ );
169
+ const groups = [...g.groups];
170
+ for (const c of changes) {
171
+ const status = statusOf(c.change?.actions ?? []);
172
+ if (status === "noop") continue;
173
+ const before = maskChange(c.change?.before, c.change?.before_sensitive);
174
+ const after = maskChange(c.change?.after, c.change?.after_sensitive);
175
+ const existing = nodes.get(c.address);
176
+ if (existing) {
177
+ existing.status = status;
178
+ existing.attributeDiff = diffAttrs(c.change?.before, c.change?.after, before, after);
179
+ } else if (status === "create") {
180
+ const group = moduleOf(c.address);
181
+ if (group && !groups.some((x) => x.id === group)) {
182
+ const parentChain = group.split(".").slice(0, -2).join(".");
183
+ groups.push({
184
+ id: group,
185
+ label: group.split(".").pop() ?? group,
186
+ kind: "module",
187
+ parent: parentChain.startsWith("module.") ? parentChain : null
188
+ });
189
+ }
190
+ nodes.set(c.address, {
191
+ id: c.address,
192
+ type: c.type,
193
+ name: c.name,
194
+ provider: shortProvider(c.provider_name),
195
+ group,
196
+ attributes: after ?? {},
197
+ status: "create",
198
+ attributeDiff: diffAttrs(null, c.change?.after, null, after),
199
+ dependsOn: []
200
+ });
201
+ }
202
+ }
203
+ const merged = [...nodes.values()];
204
+ return { nodes: merged, edges: deriveEdges(merged), groups };
205
+ }
206
+
207
+ // ../core/src/summarize.ts
208
+ function summarizeGraph(g) {
209
+ const byType = /* @__PURE__ */ new Map();
210
+ for (const n of g.nodes) byType.set(n.type, (byType.get(n.type) ?? 0) + 1);
211
+ const typeLines = [...byType.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([t, c]) => ` ${t}: ${c}`);
212
+ const changes = g.nodes.filter((n) => n.status !== "noop").map((n) => ` ${n.status}: ${n.id}`).sort();
213
+ return [
214
+ `${g.nodes.length} resources, ${g.edges.length} edges, ${g.groups.length} groups.`,
215
+ "By type:",
216
+ ...typeLines,
217
+ changes.length ? "Pending plan changes:" : "No pending plan changes.",
218
+ ...changes
219
+ ].join("\n");
220
+ }
221
+
222
+ // ../core/src/compose.ts
223
+ function composeGraphs(snapshots) {
224
+ const nodes = /* @__PURE__ */ new Map();
225
+ const groups = /* @__PURE__ */ new Map();
226
+ const conflicts = [];
227
+ for (const snap of snapshots) {
228
+ for (const n of snap.graph.nodes) {
229
+ if (nodes.has(n.id)) {
230
+ conflicts.push({ id: n.id, origin: snap.origin });
231
+ continue;
232
+ }
233
+ nodes.set(n.id, { ...n, origin: snap.origin });
234
+ }
235
+ for (const g of snap.graph.groups) {
236
+ if (groups.has(g.id)) {
237
+ conflicts.push({ id: g.id, origin: snap.origin });
238
+ continue;
239
+ }
240
+ groups.set(g.id, { ...g, origin: snap.origin });
241
+ }
242
+ }
243
+ const edgesById = /* @__PURE__ */ new Map();
244
+ for (const snap of snapshots) {
245
+ for (const e of snap.graph.edges) {
246
+ if (!edgesById.has(e.id)) edgesById.set(e.id, e);
247
+ }
248
+ }
249
+ const edges = [...edgesById.values()].filter((e) => nodes.has(e.source) && nodes.has(e.target));
250
+ let stale;
251
+ if (snapshots.length === 1) {
252
+ stale = snapshots[0].stale;
253
+ } else {
254
+ const parts = snapshots.filter((s) => s.stale !== null).map((s) => `${s.origin}: ${s.stale}`);
255
+ stale = parts.length > 0 ? parts.join("; ") : null;
256
+ }
257
+ return {
258
+ graph: { nodes: [...nodes.values()], edges, groups: [...groups.values()] },
259
+ stale,
260
+ conflicts
261
+ };
262
+ }
263
+
264
+ // ../server/src/find-port.ts
265
+ import net from "net";
266
+ async function findPort(start) {
267
+ for (let port = start; port < start + 50; port++) {
268
+ const free = await new Promise((resolve2) => {
269
+ const srv = net.createServer();
270
+ srv.once("error", () => resolve2(false));
271
+ srv.listen(port, "127.0.0.1", () => srv.close(() => resolve2(true)));
272
+ });
273
+ if (free) return port;
274
+ }
275
+ throw new Error(`No free port found in ${start}..${start + 49}`);
276
+ }
277
+
278
+ // ../server/src/intent-queue.ts
279
+ var IntentQueue = class {
280
+ queue = [];
281
+ waiters = [];
282
+ push(intent) {
283
+ const waiter = this.waiters.shift();
284
+ if (waiter) waiter(intent);
285
+ else this.queue.push(intent);
286
+ }
287
+ take(timeoutMs) {
288
+ const queued = this.queue.shift();
289
+ if (queued) return Promise.resolve(queued);
290
+ return new Promise((resolve2) => {
291
+ const timer = setTimeout(() => {
292
+ this.waiters = this.waiters.filter((w) => w !== waiter);
293
+ resolve2(null);
294
+ }, timeoutMs);
295
+ const waiter = (i) => {
296
+ clearTimeout(timer);
297
+ resolve2(i);
298
+ };
299
+ this.waiters.push(waiter);
300
+ });
301
+ }
302
+ };
303
+
304
+ // ../server/src/telemetry.ts
305
+ import { randomUUID } from "crypto";
306
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
307
+ import { homedir } from "os";
308
+ import { dirname, join } from "path";
309
+ function nodesBucket(n) {
310
+ if (n <= 0) return "0";
311
+ if (n <= 10) return "1-10";
312
+ if (n <= 50) return "11-50";
313
+ if (n <= 200) return "51-200";
314
+ return "200+";
315
+ }
316
+ var TELEMETRY_SCHEMA_VERSION = 1;
317
+ var DEFAULT_ENDPOINT = "https://t.stackcanvas.dev/e";
318
+ var defaultTransport = (input, init) => globalThis.fetch(input, init);
319
+ function todayUtc() {
320
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
321
+ }
322
+ function mapPlatform(p) {
323
+ if (p === "darwin" || p === "linux" || p === "win32") return p;
324
+ return "other";
325
+ }
326
+ function nodeMajor() {
327
+ return Number(process.versions.node.split(".")[0]);
328
+ }
329
+ function capCount(n) {
330
+ if (!Number.isFinite(n)) return 0;
331
+ return Math.max(0, Math.min(50, Math.trunc(n)));
332
+ }
333
+ function capPayload(props) {
334
+ if (props.event !== "intent_sent") return props;
335
+ return {
336
+ event: "intent_sent",
337
+ add: capCount(props.add),
338
+ modify: capCount(props.modify),
339
+ remove: capCount(props.remove),
340
+ adopt: capCount(props.adopt),
341
+ investigate: capCount(props.investigate)
342
+ };
343
+ }
344
+ var TelemetryClient = class {
345
+ configPath;
346
+ endpoint;
347
+ fetchImpl;
348
+ env;
349
+ appVersion;
350
+ constructor(opts) {
351
+ this.env = opts.env ?? process.env;
352
+ const configDir = this.env.STACKCANVAS_CONFIG_DIR || join(homedir(), ".stackcanvas");
353
+ this.configPath = opts.configPath ?? join(configDir, "config.json");
354
+ this.endpoint = opts.endpoint ?? DEFAULT_ENDPOINT;
355
+ this.fetchImpl = opts.fetchImpl ?? defaultTransport;
356
+ this.appVersion = opts.appVersion;
357
+ }
358
+ /** Never throws: unreadable/corrupt config is treated as an empty config. */
359
+ readConfig() {
360
+ try {
361
+ const raw = readFileSync(this.configPath, "utf8");
362
+ const parsed = JSON.parse(raw);
363
+ if (parsed && typeof parsed === "object") return parsed;
364
+ return {};
365
+ } catch {
366
+ return {};
367
+ }
368
+ }
369
+ /** Atomic write (.tmp + rename); never throws — a write failure just means
370
+ * the banner/consent may re-prompt next launch, which is acceptable. */
371
+ writeConfig(cfg) {
372
+ try {
373
+ const dir = dirname(this.configPath);
374
+ mkdirSync(dir, { recursive: true });
375
+ const tmp = `${this.configPath}.tmp`;
376
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2));
377
+ renameSync(tmp, this.configPath);
378
+ } catch {
379
+ }
380
+ }
381
+ /** env overrides > config > 'unset'. DO_NOT_TRACK=1 / STACKCANVAS_TELEMETRY=0
382
+ * always win, regardless of what's on disk. STACKCANVAS_TELEMETRY=1 is
383
+ * deliberately NOT handled here — it only un-hides the consent banner
384
+ * (PR#5 UI concern), it never grants consent by itself. */
385
+ getConsent() {
386
+ if (this.env.DO_NOT_TRACK === "1" || this.env.STACKCANVAS_TELEMETRY === "0") return "disabled_env";
387
+ const consent = this.readConfig().telemetry?.consent;
388
+ if (consent === "granted") return "granted";
389
+ if (consent === "denied") return "denied";
390
+ return "unset";
391
+ }
392
+ /** grant: mints a fresh anonId, emits 'install' exactly once ever (tracked
393
+ * via installReportedAt, which survives a later deny).
394
+ * deny: deletes anonId, leaves installReportedAt untouched. */
395
+ setConsent(granted) {
396
+ const cfg = this.readConfig();
397
+ const prevInstallReportedAt = cfg.telemetry?.installReportedAt;
398
+ const today = todayUtc();
399
+ if (granted) {
400
+ const shouldReportInstall = !prevInstallReportedAt;
401
+ const telemetry = {
402
+ consent: "granted",
403
+ anonId: randomUUID(),
404
+ decidedAt: today,
405
+ ...shouldReportInstall ? { installReportedAt: today } : { installReportedAt: prevInstallReportedAt }
406
+ };
407
+ this.writeConfig({ ...cfg, telemetry });
408
+ if (shouldReportInstall) this.emit({ event: "install" });
409
+ } else {
410
+ const telemetry = {
411
+ consent: "denied",
412
+ decidedAt: today,
413
+ ...prevInstallReportedAt ? { installReportedAt: prevInstallReportedAt } : {}
414
+ };
415
+ this.writeConfig({ ...cfg, telemetry });
416
+ }
417
+ }
418
+ /** No-op unless consent === 'granted'. Fire-and-forget: no retries, no
419
+ * queue, no logging (stdout is the MCP transport). Never throws. */
420
+ emit(props) {
421
+ if (this.getConsent() !== "granted") return;
422
+ const anonId = this.readConfig().telemetry?.anonId;
423
+ if (!anonId) return;
424
+ const envelope = {
425
+ schema: TELEMETRY_SCHEMA_VERSION,
426
+ anon_id: anonId,
427
+ day: todayUtc(),
428
+ app_version: this.appVersion,
429
+ platform: mapPlatform(process.platform),
430
+ node_major: nodeMajor(),
431
+ payload: capPayload(props)
432
+ };
433
+ try {
434
+ const result = this.fetchImpl(this.endpoint, {
435
+ method: "POST",
436
+ body: JSON.stringify(envelope),
437
+ signal: AbortSignal.timeout(3e3)
438
+ });
439
+ void Promise.resolve(result).catch(() => {
440
+ });
441
+ } catch {
442
+ }
443
+ }
444
+ };
445
+
446
+ // ../server/src/providers/terraform.ts
447
+ import { execFile } from "child_process";
448
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
449
+ import { basename, join as join2, sep } from "path";
450
+ import { promisify } from "util";
451
+ import chokidar from "chokidar";
452
+ var execFileAsync = promisify(execFile);
453
+ async function defaultProbe(bin) {
454
+ try {
455
+ await execFileAsync(bin, ["version"], { timeout: 1e4 });
456
+ return true;
457
+ } catch {
458
+ return false;
459
+ }
460
+ }
461
+ async function resolveTfBinary(explicit, opts = {}) {
462
+ if (explicit) return explicit;
463
+ const envBin = process.env.STACKCANVAS_TF_BIN;
464
+ if (envBin) return envBin;
465
+ const probe = opts.probe ?? defaultProbe;
466
+ if (await probe("terraform")) return "terraform";
467
+ if (await probe("tofu")) return "tofu";
468
+ return null;
469
+ }
470
+ function createShowRunner(getBinary) {
471
+ return async (cwd, planPath) => {
472
+ const bin = getBinary();
473
+ if (bin === null)
474
+ throw new Error("No terraform or tofu binary found in PATH. Install one or set STACKCANVAS_TF_BIN.");
475
+ const args = ["show", "-json", ...planPath ? [planPath] : []];
476
+ try {
477
+ const { stdout } = await execFileAsync(bin, args, { cwd, maxBuffer: 256 * 1024 * 1024 });
478
+ return stdout;
479
+ } catch (err) {
480
+ const e = err;
481
+ if (e.code === "ENOENT")
482
+ throw new Error(`${bin} binary not found in PATH. Install Terraform or add it to PATH.`);
483
+ throw new Error(`${bin} show failed: ${err.message}`);
484
+ }
485
+ };
486
+ }
487
+ function binaryKind(binaryUsed) {
488
+ if (!binaryUsed) return "unknown";
489
+ const name = basename(binaryUsed).toLowerCase().replace(/\.exe$/, "");
490
+ if (name === "terraform") return "terraform";
491
+ if (name === "tofu") return "tofu";
492
+ return "unknown";
493
+ }
494
+ var TerraformProvider = class {
495
+ origin = "terraform";
496
+ dir;
497
+ /** Always true — terraform state is local and cheap to read, so it keeps
498
+ * today's zero-config auto-refresh-on-start behavior. */
499
+ refreshOnStart = true;
500
+ /** Resolved by init() (and re-resolved by refresh() while still null); null
501
+ * = none found (or runShow injected, which skips detection entirely).
502
+ * Telemetry's `tf_bin` event property reads this via `binaryKind()`. */
503
+ binaryUsed = null;
504
+ get label() {
505
+ return this.binaryUsed ? `Terraform (${this.dir}) via ${this.binaryUsed}` : `Terraform (${this.dir})`;
506
+ }
507
+ run;
508
+ injectedRunShow;
509
+ explicitBinary;
510
+ binaryResolveAttempted = false;
511
+ graph = { nodes: [], edges: [], groups: [] };
512
+ stale = null;
513
+ planJson = null;
514
+ planPath = null;
515
+ watcher = null;
516
+ refreshTimer = null;
517
+ disposed = false;
518
+ pushFn = null;
519
+ debounceMs;
520
+ constructor(opts) {
521
+ this.dir = opts.dir;
522
+ this.injectedRunShow = opts.runShow !== void 0;
523
+ this.explicitBinary = opts.binary;
524
+ this.run = opts.runShow ?? createShowRunner(() => this.binaryUsed);
525
+ this.debounceMs = opts.debounceMs ?? 300;
526
+ }
527
+ snapshot() {
528
+ return { origin: this.origin, graph: this.graph, stale: this.stale };
529
+ }
530
+ /** Re-resolves `binaryUsed` if binary detection hasn't been attempted yet
531
+ * and no runShow was injected (which bypasses detection entirely). Called
532
+ * from both init() (once, up front) and refresh() (only while still null,
533
+ * so installing terraform/tofu after a failed resolution recovers on the
534
+ * next refresh without a restart — resolveTfBinary itself has no cache,
535
+ * so a fresh probe is exactly what re-running it gives us). */
536
+ async resolveBinary() {
537
+ this.binaryResolveAttempted = true;
538
+ if (this.injectedRunShow) return;
539
+ this.binaryUsed = await resolveTfBinary(this.explicitBinary);
540
+ }
541
+ /** Validate config and detect tooling: resolves the terraform/tofu binary
542
+ * (skipped when runShow was injected) and (re)creates the chokidar
543
+ * watcher, awaiting its 'ready' event: inotify (Linux) delivers no events
544
+ * for writes that land before the watcher is ready, so callers that await
545
+ * init() before mutating the watched directory don't lose that first
546
+ * change — the same guarantee today's canvas-server.ts got by awaiting
547
+ * 'ready' directly in start(). SourceProvider.watch() itself must stay
548
+ * synchronous per the interface, so this is the one place that can still
549
+ * block on it. Never throws for a missing binary — that surfaces as
550
+ * `stale` on refresh(), same as before this detection was added.
551
+ * Idempotent: binary resolution runs once; the watcher setup is a no-op
552
+ * once the watcher exists. */
553
+ async init() {
554
+ if (!this.binaryResolveAttempted) await this.resolveBinary();
555
+ if (this.watcher) return;
556
+ const planPath = join2(this.dir, ".stackcanvas", "plan.json");
557
+ const watcher = chokidar.watch(this.dir, {
558
+ ignoreInitial: true,
559
+ ignored: (path, stats) => {
560
+ if (path.includes(`${sep}.terraform${sep}`) || path.endsWith(`${sep}.terraform`)) return true;
561
+ if (stats && !stats.isDirectory() && !path.endsWith(".tfstate") && path !== planPath) return true;
562
+ return false;
563
+ }
564
+ });
565
+ watcher.on("all", () => this.scheduleRefresh());
566
+ watcher.on("error", (err) => {
567
+ this.stale = err.message;
568
+ if (!this.disposed) this.pushFn?.(this.snapshot());
569
+ });
570
+ await new Promise((res) => watcher.once("ready", () => res()));
571
+ this.watcher = watcher;
572
+ this.disposed = false;
573
+ }
574
+ /** `force` ignored (no cache to bypass); `onProgress`, if passed, is never
575
+ * invoked — a terraform `show` read has no meaningful sub-steps to report. */
576
+ async refresh(_opts) {
577
+ if (this.binaryUsed === null && !this.injectedRunShow) await this.resolveBinary();
578
+ try {
579
+ const stateJson = JSON.parse(await this.run(this.dir));
580
+ let g = parseState(stateJson);
581
+ if (this.planJson) g = applyPlan(g, this.planJson);
582
+ this.graph = g;
583
+ this.stale = null;
584
+ } catch (err) {
585
+ this.stale = err.message;
586
+ }
587
+ return this.snapshot();
588
+ }
589
+ /** Arms the delivery callback for (debounced) watcher-driven snapshots.
590
+ * Watching itself starts in init() (see above); this just registers where
591
+ * pushes land, so it's trivially idempotent and safe to call repeatedly. */
592
+ watch(push) {
593
+ this.pushFn = push;
594
+ }
595
+ /** Terraform-specific — deliberately NOT on SourceProvider. Same body as
596
+ * today's CanvasServer.loadPlan: .json → readFileSync+JSON.parse, else
597
+ * run(dir, path). THROWS on failure (MCP load_plan relies on it), then
598
+ * returns the refreshed snapshot. */
599
+ async loadPlan(path) {
600
+ if (path.endsWith(".json")) this.planJson = JSON.parse(readFileSync2(path, "utf8"));
601
+ else this.planJson = JSON.parse(await this.run(this.dir, path));
602
+ this.planPath = path;
603
+ return this.refresh();
604
+ }
605
+ scheduleRefresh() {
606
+ if (this.refreshTimer) clearTimeout(this.refreshTimer);
607
+ this.refreshTimer = setTimeout(() => {
608
+ void (async () => {
609
+ try {
610
+ const autoPlan = join2(this.dir, ".stackcanvas", "plan.json");
611
+ if (this.planPath === null && existsSync(autoPlan)) await this.loadPlan(autoPlan);
612
+ else if (this.planPath?.endsWith(".json") && existsSync(this.planPath))
613
+ this.planJson = JSON.parse(readFileSync2(this.planPath, "utf8"));
614
+ const snap = await this.refresh();
615
+ if (!this.disposed) this.pushFn?.(snap);
616
+ } catch (err) {
617
+ this.stale = err.message;
618
+ if (!this.disposed) this.pushFn?.(this.snapshot());
619
+ }
620
+ })();
621
+ }, this.debounceMs);
622
+ }
623
+ async dispose() {
624
+ if (this.refreshTimer) clearTimeout(this.refreshTimer);
625
+ this.refreshTimer = null;
626
+ this.disposed = true;
627
+ await this.watcher?.close();
628
+ this.watcher = null;
629
+ }
630
+ };
631
+
632
+ // ../server/src/canvas-server.ts
633
+ var intentSchema = z.object({
634
+ add: z.array(z.object({
635
+ type: z.string().min(1),
636
+ name: z.string().optional(),
637
+ wishes: z.string().optional(),
638
+ connect_to: z.array(z.string())
639
+ })),
640
+ modify: z.array(z.object({ address: z.string().min(1), wishes: z.string() })),
641
+ remove: z.array(z.object({ address: z.string().min(1) }))
642
+ });
643
+ var MIME = {
644
+ ".html": "text/html",
645
+ ".js": "text/javascript",
646
+ ".css": "text/css",
647
+ ".svg": "image/svg+xml",
648
+ ".json": "application/json",
649
+ ".ico": "image/x-icon"
650
+ };
651
+ var BROWSER_TELEMETRY_EVENTS = /* @__PURE__ */ new Set(["drift_opened"]);
652
+ var CanvasServer = class {
653
+ dir;
654
+ uiDist;
655
+ fixedPort;
656
+ portRangeStart;
657
+ graph = { nodes: [], edges: [], groups: [] };
658
+ stale = null;
659
+ httpServer = null;
660
+ onGraphChange = [];
661
+ wss = null;
662
+ intents = new IntentQueue();
663
+ agentStatus = "idle";
664
+ telemetry;
665
+ tf;
666
+ providers;
667
+ extraProviders;
668
+ snapshots = /* @__PURE__ */ new Map();
669
+ conflicts = [];
670
+ constructor(opts) {
671
+ this.dir = opts.dir;
672
+ this.uiDist = opts.uiDist;
673
+ this.fixedPort = opts.port;
674
+ this.portRangeStart = opts.portRangeStart ?? 4680;
675
+ this.telemetry = opts.telemetry ?? new TelemetryClient({ appVersion: "0.1.0" });
676
+ this.tf = new TerraformProvider({ dir: opts.dir, runShow: opts.runTerraformShow, binary: opts.tfBinary });
677
+ this.providers = [this.tf];
678
+ this.extraProviders = opts.extraProviders ?? [];
679
+ this.subscribe((graph, stale) => this.broadcast({ type: "graph", graph, stale }));
680
+ }
681
+ getGraph() {
682
+ return this.graph;
683
+ }
684
+ getStale() {
685
+ return this.stale;
686
+ }
687
+ subscribe(fn) {
688
+ this.onGraphChange.push(fn);
689
+ }
690
+ awaitIntent(timeoutMs) {
691
+ return this.intents.take(timeoutMs);
692
+ }
693
+ setAgentStatus(s) {
694
+ this.agentStatus = s;
695
+ this.broadcast({ type: "agent_status", status: s });
696
+ }
697
+ /** Kept public, same signature as today. Targets the typed `tf` reference
698
+ * directly — plan concepts never leak onto the SourceProvider interface. */
699
+ async loadPlan(path) {
700
+ this.onSnapshot(await this.tf.loadPlan(path));
701
+ }
702
+ recompose() {
703
+ const snaps = this.providers.map((p) => this.snapshots.get(p.origin)).filter((s) => s !== void 0);
704
+ const composed = composeGraphs(snaps);
705
+ this.graph = composed.graph;
706
+ this.stale = composed.stale;
707
+ this.conflicts = composed.conflicts;
708
+ if (this.conflicts.length > 0)
709
+ console.error(`stackcanvas: dropped id conflicts during compose: ${JSON.stringify(this.conflicts)}`);
710
+ for (const fn of this.onGraphChange) fn(this.graph, this.stale);
711
+ }
712
+ onSnapshot(s) {
713
+ this.snapshots.set(s.origin, s);
714
+ this.recompose();
715
+ }
716
+ /** @experimental Registers (or replaces, if `p.origin` is already present)
717
+ * a SourceProvider: awaits init(), refreshes it up front when
718
+ * `refreshOnStart` is true (mirroring refreshGraph()'s own filter),
719
+ * subscribes to watch(), then recomposes so the change — a populated
720
+ * graph, or the still-empty last-good graph for a live provider that
721
+ * hasn't scanned yet — reaches WS subscribers immediately. */
722
+ async addProvider(p) {
723
+ const existingIdx = this.providers.findIndex((x) => x.origin === p.origin);
724
+ if (existingIdx !== -1) {
725
+ await this.providers[existingIdx].dispose();
726
+ this.providers.splice(existingIdx, 1);
727
+ this.snapshots.delete(p.origin);
728
+ }
729
+ await p.init();
730
+ if (p.refreshOnStart) this.snapshots.set(p.origin, await p.refresh());
731
+ this.providers.push(p);
732
+ p.watch((s) => this.onSnapshot(s));
733
+ this.recompose();
734
+ }
735
+ /** @experimental Disposes and drops `origin` from composition; no-op if
736
+ * it isn't currently registered. */
737
+ async removeProvider(origin) {
738
+ const idx = this.providers.findIndex((p2) => p2.origin === origin);
739
+ if (idx === -1) return;
740
+ const [p] = this.providers.splice(idx, 1);
741
+ await p.dispose();
742
+ this.snapshots.delete(origin);
743
+ this.recompose();
744
+ }
745
+ broadcast(msg) {
746
+ const data = JSON.stringify(msg);
747
+ for (const client of this.wss?.clients ?? [])
748
+ if (client.readyState === WebSocket.OPEN) client.send(data);
749
+ }
750
+ /** Kept public name/signature — tests and callers rely on it. Refreshes
751
+ * only providers where `refreshOnStart` is true; a `refreshOnStart: false`
752
+ * provider (e.g. a future live-scan source) is skipped here — its
753
+ * snapshot is only ever surfaced via addProvider() or an explicit,
754
+ * provider-targeted `refresh({force: true})` — so this never triggers an
755
+ * implicit scan-like call. */
756
+ async refreshGraph() {
757
+ const targets = this.providers.filter((p) => p.refreshOnStart);
758
+ const snaps = await Promise.all(targets.map((p) => p.refresh()));
759
+ for (const s of snaps) this.snapshots.set(s.origin, s);
760
+ this.recompose();
761
+ }
762
+ buildApp() {
763
+ const app = new Hono();
764
+ app.use("/api/*", async (c, next) => {
765
+ if (c.req.method !== "POST") return next();
766
+ const host = c.req.header("host") ?? "";
767
+ const hostname = host.split(":")[0];
768
+ if (hostname !== "127.0.0.1" && hostname !== "localhost")
769
+ return c.json({ error: "forbidden origin" }, 403);
770
+ const origin = c.req.header("origin");
771
+ if (origin) {
772
+ try {
773
+ const originUrl = new URL(origin);
774
+ if (originUrl.protocol !== "http:" || originUrl.hostname !== "127.0.0.1" && originUrl.hostname !== "localhost") return c.json({ error: "forbidden origin" }, 403);
775
+ } catch {
776
+ return c.json({ error: "forbidden origin" }, 403);
777
+ }
778
+ }
779
+ return next();
780
+ });
781
+ app.get("/api/graph", (c) => c.json(this.graph));
782
+ app.get("/api/meta", (c) => c.json({
783
+ dir: this.dir,
784
+ stale: this.stale,
785
+ providers: this.providers.map((p) => {
786
+ const snap = this.snapshots.get(p.origin);
787
+ const entry = { origin: p.origin, label: p.label, stale: snap?.stale ?? null };
788
+ if (snap?.meta?.scannedAt !== void 0) entry.scannedAt = snap.meta.scannedAt;
789
+ if (snap?.meta?.errors !== void 0) entry.errors = snap.meta.errors;
790
+ return entry;
791
+ }),
792
+ conflicts: this.conflicts
793
+ }));
794
+ app.post("/api/intent", async (c) => {
795
+ const parsed = intentSchema.safeParse(await c.req.json().catch(() => null));
796
+ if (!parsed.success) return c.json({ error: "invalid intent" }, 400);
797
+ this.intents.push(parsed.data);
798
+ this.telemetry.emit({
799
+ event: "intent_sent",
800
+ add: parsed.data.add.length,
801
+ modify: parsed.data.modify.length,
802
+ remove: parsed.data.remove.length,
803
+ adopt: 0,
804
+ investigate: 0
805
+ });
806
+ return c.json({ queued: true }, 202);
807
+ });
808
+ app.get("/api/telemetry", (c) => c.json({ consent: this.telemetry.getConsent() }));
809
+ app.post("/api/telemetry", async (c) => {
810
+ const body = await c.req.json().catch(() => null);
811
+ if (!body || typeof body.granted !== "boolean") return c.json({ error: "invalid body" }, 400);
812
+ this.telemetry.setConsent(body.granted);
813
+ return c.json({ consent: this.telemetry.getConsent() }, 200);
814
+ });
815
+ app.post("/api/telemetry/event", async (c) => {
816
+ const body = await c.req.json().catch(() => null);
817
+ const name = body?.name;
818
+ if (typeof name !== "string" || !BROWSER_TELEMETRY_EVENTS.has(name))
819
+ return c.json({ error: "unknown event" }, 400);
820
+ if (this.telemetry.getConsent() === "granted") {
821
+ this.telemetry.emit({ event: "drift_opened", nodes_bucket: nodesBucket(this.graph.nodes.length) });
822
+ }
823
+ return c.json({ ok: true }, 200);
824
+ });
825
+ if (this.uiDist) {
826
+ const dist = resolve(this.uiDist);
827
+ app.get("*", (c) => {
828
+ const decoded = decodeURIComponent(c.req.path);
829
+ const reqPath = decoded === "/" ? "/index.html" : decoded;
830
+ const file = resolve(dist, "." + reqPath);
831
+ const contained = file === dist || file.startsWith(dist + sep2);
832
+ if (contained && existsSync2(file) && statSync(file).isFile()) {
833
+ return c.body(readFileSync3(file), 200, {
834
+ "content-type": MIME[extname(file)] ?? "application/octet-stream"
835
+ });
836
+ }
837
+ return c.body(readFileSync3(join3(dist, "index.html")), 200, { "content-type": "text/html" });
838
+ });
839
+ }
840
+ return app;
841
+ }
842
+ bindServer(port, app) {
843
+ return new Promise((resolve2, reject) => {
844
+ const srv = serve({ fetch: app.fetch, port, hostname: "127.0.0.1" });
845
+ const onError = (err) => {
846
+ srv.removeListener("listening", onListening);
847
+ reject(err);
848
+ };
849
+ const onListening = () => {
850
+ srv.removeListener("error", onError);
851
+ resolve2(srv);
852
+ };
853
+ srv.once("error", onError);
854
+ srv.once("listening", onListening);
855
+ });
856
+ }
857
+ async start() {
858
+ if (this.httpServer) throw new Error("CanvasServer already started");
859
+ if (!existsSync2(this.dir)) throw new Error(`Directory not found: ${this.dir}`);
860
+ await this.tf.init();
861
+ await this.refreshGraph();
862
+ const explicitPort = this.fixedPort !== void 0;
863
+ let port = this.fixedPort ?? await findPort(this.portRangeStart);
864
+ const app = this.buildApp();
865
+ const maxAttempts = 10;
866
+ let httpServer;
867
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
868
+ try {
869
+ httpServer = await this.bindServer(port, app);
870
+ break;
871
+ } catch (err) {
872
+ const e = err;
873
+ if (e.code === "EADDRINUSE" && !explicitPort && attempt < maxAttempts - 1) {
874
+ port++;
875
+ continue;
876
+ }
877
+ throw err;
878
+ }
879
+ }
880
+ this.httpServer = httpServer;
881
+ this.wss = new WebSocketServer({ noServer: true });
882
+ this.httpServer.on("upgrade", (req, socket, head) => {
883
+ if (req.url === "/ws") {
884
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
885
+ ws.send(JSON.stringify({ type: "graph", graph: this.graph, stale: this.stale }));
886
+ ws.send(JSON.stringify({ type: "agent_status", status: this.agentStatus }));
887
+ });
888
+ } else socket.destroy();
889
+ });
890
+ this.tf.watch((s) => this.onSnapshot(s));
891
+ this.telemetry.emit({
892
+ event: "canvas_opened",
893
+ nodes_bucket: nodesBucket(this.graph.nodes.length),
894
+ tf_bin: binaryKind(this.tf.binaryUsed)
895
+ });
896
+ await Promise.all(this.extraProviders.map((p) => this.addProvider(p)));
897
+ return { port, url: `http://127.0.0.1:${port}` };
898
+ }
899
+ async stop() {
900
+ await Promise.all(this.providers.map((p) => p.dispose()));
901
+ for (const c of this.wss?.clients ?? []) c.terminate();
902
+ this.wss?.close();
903
+ this.wss = null;
904
+ await new Promise((resolve2) => {
905
+ if (!this.httpServer) return resolve2();
906
+ const srv = this.httpServer;
907
+ srv.closeAllConnections?.();
908
+ srv.close(() => resolve2());
909
+ });
910
+ this.httpServer = null;
911
+ }
912
+ };
913
+
914
+ // src/server.ts
915
+ import { existsSync as existsSync3, readdirSync } from "fs";
916
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
917
+ import { z as z2 } from "zod";
918
+
919
+ // src/open-browser.ts
920
+ import { execFile as execFile2 } from "child_process";
921
+ function openBrowser(url) {
922
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
923
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
924
+ execFile2(cmd, args, () => {
925
+ });
926
+ }
927
+
928
+ // src/version.ts
929
+ var VERSION = "0.2.0";
930
+
931
+ // src/server.ts
932
+ function looksLikeTerraformRoot(dir) {
933
+ if (!existsSync3(dir)) return false;
934
+ const entries = readdirSync(dir);
935
+ return entries.some((f) => f.endsWith(".tf") || f.endsWith(".tfstate"));
936
+ }
937
+ var ok = (msg) => ({ content: [{ type: "text", text: msg }] });
938
+ var fail = (msg) => ({ content: [{ type: "text", text: msg }], isError: true });
939
+ function createMcpServer(deps = {}) {
940
+ const makeCanvas = deps.makeCanvas ?? ((dir) => new CanvasServer({ dir }));
941
+ const open = deps.openBrowser ?? openBrowser;
942
+ let canvas = null;
943
+ let url = null;
944
+ const mcp = new McpServer({ name: "stackcanvas", version: VERSION });
945
+ mcp.registerTool("open_canvas", {
946
+ description: "Start (or reuse) the stackcanvas live infrastructure canvas for a Terraform root directory. Opens the UI in the browser and returns its URL.",
947
+ inputSchema: { dir: z2.string().describe("Absolute path to the Terraform root directory") }
948
+ }, async ({ dir }) => {
949
+ if (!looksLikeTerraformRoot(dir))
950
+ return fail(`${dir} does not look like a Terraform root (no .tf or .tfstate files). Pass the directory that contains the Terraform configuration.`);
951
+ if (canvas && canvas.dir !== dir) {
952
+ await canvas.stop();
953
+ canvas = null;
954
+ }
955
+ if (!canvas) {
956
+ const next = makeCanvas(dir);
957
+ try {
958
+ const started = await next.start();
959
+ url = started.url;
960
+ } catch (err) {
961
+ await next.stop().catch(() => {
962
+ });
963
+ return fail(`Failed to start canvas: ${err.message}`);
964
+ }
965
+ canvas = next;
966
+ canvas.setAgentStatus("idle");
967
+ open(url);
968
+ }
969
+ 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\` (or \`tofu plan \u2026\` with OpenTofu) to show the plan diff, then call await_canvas_intent to receive user edits.`);
970
+ });
971
+ mcp.registerTool("load_plan", {
972
+ description: "Register a Terraform plan for diff highlighting on the canvas. Accepts a JSON plan (terraform show -json output) or a binary plan file.",
973
+ inputSchema: { path: z2.string().describe("Path to plan file (.json preferred)") }
974
+ }, async ({ path }) => {
975
+ if (!canvas) return fail("No canvas open. Call open_canvas first.");
976
+ canvas.setAgentStatus("planning");
977
+ try {
978
+ await canvas.loadPlan(path);
979
+ return ok("Plan loaded; canvas now highlights pending changes.");
980
+ } catch (err) {
981
+ return fail(`Failed to load plan: ${err.message}`);
982
+ } finally {
983
+ canvas.setAgentStatus("idle");
984
+ }
985
+ });
986
+ mcp.registerTool("get_graph_summary", {
987
+ description: "Get a compact text summary of the current infrastructure graph.",
988
+ inputSchema: {}
989
+ }, async () => {
990
+ if (!canvas) return fail("No canvas open. Call open_canvas first.");
991
+ return ok(summarizeGraph(canvas.getGraph()));
992
+ });
993
+ mcp.registerTool("await_canvas_intent", {
994
+ description: "Block until the user clicks Apply on the canvas, then return their requested changes as intent JSON: {intent: {add, modify, remove} | null}. null = timeout, call again in a loop to keep waiting. After receiving an intent, write the Terraform code for it.",
995
+ inputSchema: {
996
+ timeoutSeconds: z2.number().positive().max(3600).default(45).describe("How long to wait before returning {intent: null}. Default 45s: MCP clients commonly abort requests after 60s, so keep this under your client's request timeout and call the tool in a loop instead of passing large values.")
997
+ }
998
+ }, async ({ timeoutSeconds }) => {
999
+ if (!canvas) return fail("No canvas open. Call open_canvas first.");
1000
+ canvas.setAgentStatus("idle");
1001
+ const intent = await canvas.awaitIntent(timeoutSeconds * 1e3);
1002
+ if (intent) canvas.setAgentStatus("writing");
1003
+ return ok(JSON.stringify({ intent }));
1004
+ });
1005
+ return mcp;
1006
+ }
1007
+
1008
+ // src/cli.ts
1009
+ var uiDist = join4(dirname2(fileURLToPath(import.meta.url)), "..", "ui-dist");
1010
+ function arg(name) {
1011
+ const i = process.argv.indexOf(`--${name}`);
1012
+ return i >= 0 ? process.argv[i + 1] : void 0;
1013
+ }
1014
+ async function main() {
1015
+ const telemetry = new TelemetryClient({ appVersion: VERSION });
1016
+ if (process.argv[2] === "serve") {
1017
+ const dir = arg("dir") ?? process.cwd();
1018
+ const fixture = arg("fixture");
1019
+ const port = arg("port");
1020
+ const tfBin = arg("tf-bin");
1021
+ if (port !== void 0 && Number.isNaN(Number(port))) {
1022
+ console.error("invalid --port");
1023
+ process.exit(1);
1024
+ }
1025
+ const server = new CanvasServer({
1026
+ dir,
1027
+ uiDist,
1028
+ port: port ? Number(port) : void 0,
1029
+ runTerraformShow: fixture ? async () => readFileSync4(fixture, "utf8") : void 0,
1030
+ tfBinary: tfBin,
1031
+ telemetry
1032
+ });
1033
+ const { url } = await server.start();
1034
+ console.log(`stackcanvas serving ${dir} at ${url}`);
1035
+ return;
1036
+ }
1037
+ const mcp = createMcpServer({
1038
+ makeCanvas: (dir) => new CanvasServer({ dir, uiDist, telemetry })
1039
+ });
1040
+ await mcp.connect(new StdioServerTransport());
1041
+ }
1042
+ void main();