@meshxdata/fops 0.1.31 → 0.1.34

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.

Potentially problematic release.


This version of @meshxdata/fops might be problematic. Click here for more details.

Files changed (27) hide show
  1. package/CHANGELOG.md +372 -0
  2. package/package.json +1 -1
  3. package/src/commands/lifecycle.js +16 -0
  4. package/src/electron/icon.png +0 -0
  5. package/src/electron/main.js +24 -0
  6. package/src/plugins/bundled/fops-plugin-embeddings/index.js +9 -0
  7. package/src/plugins/bundled/fops-plugin-embeddings/lib/indexer.js +1 -1
  8. package/src/plugins/bundled/fops-plugin-file/demo/landscape.yaml +67 -0
  9. package/src/plugins/bundled/fops-plugin-file/demo/orders_bad.csv +6 -0
  10. package/src/plugins/bundled/fops-plugin-file/demo/orders_good.csv +7 -0
  11. package/src/plugins/bundled/fops-plugin-file/demo/orders_reference.csv +6 -0
  12. package/src/plugins/bundled/fops-plugin-file/demo/orders_renamed.aligned.csv +6 -0
  13. package/src/plugins/bundled/fops-plugin-file/demo/orders_renamed.csv +6 -0
  14. package/src/plugins/bundled/fops-plugin-file/demo/rules.json +8 -0
  15. package/src/plugins/bundled/fops-plugin-file/demo/run.sh +110 -0
  16. package/src/plugins/bundled/fops-plugin-file/index.js +140 -24
  17. package/src/plugins/bundled/fops-plugin-file/lib/embed-index.js +7 -0
  18. package/src/plugins/bundled/fops-plugin-file/lib/match.js +11 -4
  19. package/src/plugins/bundled/fops-plugin-foundation/index.js +1715 -2
  20. package/src/plugins/bundled/fops-plugin-foundation/lib/align.js +183 -0
  21. package/src/plugins/bundled/fops-plugin-foundation/lib/apply.js +83 -41
  22. package/src/plugins/bundled/fops-plugin-foundation/lib/client.js +40 -4
  23. package/src/plugins/bundled/fops-plugin-foundation/lib/stack-apply.js +4 -1
  24. package/src/plugins/bundled/fops-plugin-foundation/lib/tools-write.js +46 -0
  25. package/src/plugins/bundled/fops-plugin-foundation-graphql/index.js +39 -1
  26. package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/resolvers/data-object.js +9 -6
  27. package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/resolvers/data-product.js +9 -6
@@ -41,11 +41,31 @@ export function register(api) {
41
41
 
42
42
  api.registerCommand((program) => {
43
43
  program
44
- .command("apply <file>")
45
- .description("Apply a landscape file (FCL/HCL/YAML) — supports stack: and mesh: blocks")
44
+ .command("apply [file]")
45
+ .description("Apply a landscape file (FCL/HCL/YAML) — supports stack: and mesh: blocks. Use - or omit to read from stdin.")
46
46
  .option("--dry-run", "Preview changes without applying")
47
47
  .option("--no-restart", "Don't restart the stack after applying changes")
48
48
  .action(async (file, opts) => {
49
+ const fromStdin = !file || file === "-";
50
+
51
+ if (fromStdin) {
52
+ // Read YAML from stdin
53
+ const chunks = [];
54
+ for await (const chunk of process.stdin) chunks.push(chunk);
55
+ const content = Buffer.concat(chunks).toString("utf8").trim();
56
+ if (!content) {
57
+ console.log(WARN("\n No content received on stdin\n"));
58
+ return;
59
+ }
60
+ const { parseYamlContent, applyLandscape } = await import("./lib/apply.js");
61
+ const { operations, fileName } = parseYamlContent(content);
62
+ const c = new FoundationClient(api.config, {
63
+ getCredentials: typeof api.getFoundationCredentials === "function" ? api.getFoundationCredentials : undefined,
64
+ });
65
+ await applyLandscape(c, operations, { dryRun: opts.dryRun, fileName });
66
+ return;
67
+ }
68
+
49
69
  const resolved = path.resolve(file);
50
70
  const ext = path.extname(resolved).toLowerCase();
51
71
 
@@ -254,6 +274,1698 @@ export function register(api) {
254
274
  process.exit(1);
255
275
  }
256
276
  });
277
+
278
+ // ── align ─────────────────────────────────────────────────────────────
279
+ foundation
280
+ .command("align <source> [target]")
281
+ .description("Align source column names to target schema columns (exact → embedding → levenshtein)")
282
+ .option("--threshold <number>", "Similarity threshold 0–1 (default 0.7)", "0.7")
283
+ .option("--drop-unmapped-source", "Drop source columns with no match (default: keep)")
284
+ .option("--fill-missing-expected", "Add unmapped expected columns with empty values to preserve schema")
285
+ .option("--output <file>", "Apply the transformation and write transformed CSV(s) here")
286
+ .option("--create <name>", "Create a new Foundation DataObject entity after applying")
287
+ .option("--overwrite <identifier>", "Update config of an existing Foundation DataObject after applying")
288
+ .option("--no-semantic", "Skip MiniLM embeddings — use exact + Levenshtein only (avoids ONNX load in scripts)")
289
+ .option("--fcl [file]", "Write a landscape YAML patch with the rename_column pipeline step (omit file → stdout)")
290
+ .option("--json", "Output raw JSON")
291
+ .action(async (source, target, opts) => {
292
+ const { alignColumns } = await import("./lib/align.js");
293
+ const { fetchEntityColumns } = await import("./lib/tools-write.js");
294
+ const { statSync, readdirSync, readFileSync, writeFileSync } = await import("node:fs");
295
+ const threshold = Number.parseFloat(opts.threshold) || 0.7;
296
+ const dropUnmappedSource = !!opts.dropUnmappedSource;
297
+ const fillMissingExpected = !!opts.fillMissingExpected;
298
+
299
+ // Resolve source columns — directory of CSVs, comma-separated names, or entity identifier
300
+ const isDirectory = (s) => { try { return statSync(s).isDirectory(); } catch { return false; } };
301
+ const isFile = (s) => { try { return statSync(s).isFile(); } catch { return false; } };
302
+ const isIdentifier = (s) => s && !/,/.test(s) && !/\s/.test(s.trim());
303
+
304
+ let sourceCols;
305
+ let sourceCsvFiles = []; // track actual CSV paths for --output
306
+ if (isDirectory(source)) {
307
+ const csvFiles = readdirSync(source).filter((f) => /\.csv$/i.test(f));
308
+ if (csvFiles.length === 0) {
309
+ console.error(ERR(` ✗ No CSV files found in: ${source}`));
310
+ process.exit(1);
311
+ }
312
+ sourceCsvFiles = csvFiles.map((f) => path.join(source, f));
313
+ const seen = new Set();
314
+ for (const file of sourceCsvFiles) {
315
+ try {
316
+ const firstLine = readFileSync(file, "utf8").split("\n")[0]?.trim();
317
+ if (!firstLine) continue;
318
+ for (const col of firstLine.split(",").map((c) => c.trim().replace(/^["']|["']$/g, ""))) {
319
+ if (col) seen.add(col);
320
+ }
321
+ } catch { /* skip unreadable file */ }
322
+ }
323
+ sourceCols = [...seen];
324
+ console.log(DIM(` Reading ${csvFiles.length} CSV file(s) from ${source} — ${sourceCols.length} unique columns`));
325
+ } else if (isFile(source)) {
326
+ sourceCsvFiles = [path.resolve(source)];
327
+ const firstLine = readFileSync(source, "utf8").split("\n")[0]?.trim();
328
+ sourceCols = firstLine ? firstLine.split(",").map((c) => c.trim().replace(/^["']|["']$/g, "")).filter(Boolean) : [];
329
+ console.log(DIM(` Reading ${source} — ${sourceCols.length} columns`));
330
+ } else if (isIdentifier(source)) {
331
+ sourceCols = await fetchEntityColumns(client, source.trim(), "data_object");
332
+ } else {
333
+ sourceCols = source.split(",").map((c) => c.trim()).filter(Boolean);
334
+ }
335
+ if (!sourceCols || sourceCols.length === 0) {
336
+ console.error(ERR(` ✗ Could not resolve source columns from: ${source}`));
337
+ process.exit(1);
338
+ }
339
+
340
+ // Get embeddings service (used for both target inference and column alignment)
341
+ let embSvc;
342
+ try {
343
+ embSvc = typeof api.getService === "function" ? api.getService("embeddings:search") : null;
344
+ } catch { /* embeddings not available */ }
345
+ const embedTexts = (!opts.noSemantic && embSvc?.embedTexts) ? embSvc.embedTexts.bind(embSvc) : null;
346
+
347
+ // Resolve target — explicit arg, or auto-infer by schema column overlap
348
+ let targetCols;
349
+ let resolvedTarget = target;
350
+ if (!target) {
351
+ let dpIdentifier = null;
352
+ let dpName = null;
353
+ try {
354
+ const res = await client.get("/data/data_product/list?per_page=200");
355
+ const { parseListResponse: parseDP } = await import("./lib/api-spec.js");
356
+ const products = parseDP(res).filter((p) => p.identifier);
357
+ if (products.length === 0) {
358
+ console.error(ERR(" ✗ No data products found. Specify a target explicitly."));
359
+ process.exit(1);
360
+ }
361
+ if (products.length === 1) {
362
+ dpIdentifier = products[0].identifier;
363
+ dpName = products[0].name || dpIdentifier;
364
+ } else {
365
+ // Fetch all schemas concurrently
366
+ const withCols = await Promise.all(products.map(async (p) => {
367
+ try {
368
+ const cols = await fetchEntityColumns(client, p.identifier, "data_product");
369
+ return { id: p.identifier, name: p.name || p.identifier, cols: cols || [] };
370
+ } catch {
371
+ return { id: p.identifier, name: p.name || p.identifier, cols: [] };
372
+ }
373
+ }));
374
+ const candidates = withCols.filter((p) => p.cols.length > 0);
375
+ if (candidates.length === 0) {
376
+ console.error(ERR(" ✗ Could not resolve target schemas. Specify a target explicitly."));
377
+ process.exit(1);
378
+ }
379
+
380
+ // Signal 1: Jaccard on normalised column names
381
+ const srcNorm = sourceCols.map((c) => c.toLowerCase().replace(/[^a-z0-9]/g, ""));
382
+ const srcSet = new Set(srcNorm);
383
+ const jaccardScore = (cols) => {
384
+ const expSet = new Set(cols.map((c) => c.toLowerCase().replace(/[^a-z0-9]/g, "")));
385
+ let inter = 0;
386
+ for (const c of srcSet) if (expSet.has(c)) inter++;
387
+ const union = srcSet.size + expSet.size - inter;
388
+ return union > 0 ? inter / union : 0;
389
+ };
390
+
391
+ // Signal 2: MiniLM bipartite column match (when embeddings available)
392
+ let embScores = null;
393
+ if (typeof embedTexts === "function") {
394
+ try {
395
+ // Plain word split for target selection — no suffix boost needed here.
396
+ // Suffix boost is for within-product mapping; here it would make
397
+ // any _id column (cust_id, span_id, user_id) equally similar.
398
+ const toHuman = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim() || s;
399
+ const allRefCols = candidates.flatMap((p) => p.cols);
400
+ const allTexts = [...sourceCols.map(toHuman), ...allRefCols.map(toHuman)];
401
+ const allVecs = await embedTexts(allTexts);
402
+ if (allVecs?.length === allTexts.length) {
403
+ const srcVecs = allVecs.slice(0, sourceCols.length);
404
+ let offset = sourceCols.length;
405
+ embScores = candidates.map((p) => {
406
+ const refVecs = allVecs.slice(offset, offset + p.cols.length);
407
+ offset += p.cols.length;
408
+ // Greedy bipartite: average cosine of best-matched pairs
409
+ const M = srcVecs.length, N = refVecs.length;
410
+ const pairs = [];
411
+ for (let i = 0; i < M; i++) {
412
+ for (let j = 0; j < N; j++) {
413
+ let dot = 0, na = 0, nb = 0;
414
+ for (let k = 0; k < srcVecs[i].length; k++) {
415
+ dot += srcVecs[i][k] * refVecs[j][k];
416
+ na += srcVecs[i][k] ** 2;
417
+ nb += refVecs[j][k] ** 2;
418
+ }
419
+ const sim = (na && nb) ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
420
+ pairs.push({ i, j, sim });
421
+ }
422
+ }
423
+ pairs.sort((a, b) => b.sim - a.sim);
424
+ const usedI = new Set(), usedJ = new Set();
425
+ let sum = 0, matched = 0;
426
+ for (const { i, j, sim } of pairs) {
427
+ if (usedI.has(i) || usedJ.has(j)) continue;
428
+ usedI.add(i); usedJ.add(j);
429
+ sum += sim; matched++;
430
+ if (matched === Math.min(M, N)) break;
431
+ }
432
+ return matched > 0 ? sum / Math.max(M, N) : 0;
433
+ });
434
+ }
435
+ } catch { /* embeddings unavailable — fall through to Jaccard only */ }
436
+ }
437
+
438
+ // Signal 3: Levenshtein bipartite match score across column pairs
439
+ const { levenshteinRatio } = await import("./lib/align.js");
440
+ const levScore = (cols) => {
441
+ const src = sourceCols.map((c) => c.toLowerCase().replace(/[^a-z0-9]/g, ""));
442
+ const exp = cols.map((c) => c.toLowerCase().replace(/[^a-z0-9]/g, ""));
443
+ const pairs = [];
444
+ for (let i = 0; i < src.length; i++)
445
+ for (let j = 0; j < exp.length; j++)
446
+ pairs.push({ i, j, sim: levenshteinRatio(src[i], exp[j]) });
447
+ pairs.sort((a, b) => b.sim - a.sim);
448
+ const usedI = new Set(), usedJ = new Set();
449
+ let sum = 0, matched = 0;
450
+ for (const { i, j, sim } of pairs) {
451
+ if (usedI.has(i) || usedJ.has(j)) continue;
452
+ usedI.add(i); usedJ.add(j);
453
+ sum += sim; matched++;
454
+ if (matched === Math.min(src.length, exp.length)) break;
455
+ }
456
+ return matched > 0 ? sum / Math.max(src.length, exp.length) : 0;
457
+ };
458
+
459
+ // Signal 4: column count similarity
460
+ const sizeScore = (cols) => 1 - Math.abs(sourceCols.length - cols.length) / Math.max(sourceCols.length, cols.length, 1);
461
+
462
+ // RRF fusion (k=60) — dense ranking: equal scores share same rank
463
+ const K = 60;
464
+ const rank = (arr) => {
465
+ const sortedVals = [...new Set(arr.map((x) => x.v))].sort((a, b) => b - a);
466
+ return arr.map((x) => ({ id: x.id, rank: sortedVals.indexOf(x.v) }));
467
+ };
468
+ const jList = candidates.map((p) => ({ id: p.id, v: jaccardScore(p.cols) }));
469
+ const lList = candidates.map((p) => ({ id: p.id, v: levScore(p.cols) }));
470
+ const sList = candidates.map((p) => ({ id: p.id, v: sizeScore(p.cols) }));
471
+ const lists = [rank(jList), rank(lList), rank(sList)];
472
+ if (embScores) {
473
+ const eList = candidates.map((p, i) => ({ id: p.id, v: embScores[i] }));
474
+ lists.push(rank(eList));
475
+ }
476
+ const rrfMap = new Map();
477
+ for (const ranked of lists) {
478
+ for (const { id, rank: r } of ranked) {
479
+ rrfMap.set(id, (rrfMap.get(id) || 0) + 1 / (K + r + 1));
480
+ }
481
+ }
482
+ // Sort by RRF descending; use size similarity as tiebreaker
483
+ candidates.sort((a, b) => {
484
+ const diff = (rrfMap.get(b.id) || 0) - (rrfMap.get(a.id) || 0);
485
+ if (Math.abs(diff) > 1e-6) return diff;
486
+ return sizeScore(b.cols) - sizeScore(a.cols);
487
+ });
488
+ dpIdentifier = candidates[0].id;
489
+ dpName = candidates[0].name;
490
+ targetCols = candidates[0].cols;
491
+ }
492
+ } catch (e) {
493
+ console.error(ERR(` ✗ Could not list data products: ${e.message}`));
494
+ process.exitCode = 1;
495
+ return;
496
+ }
497
+ console.log(DIM(` Auto-selected target: ${dpName} (${dpIdentifier})`));
498
+ resolvedTarget = dpIdentifier;
499
+ if (!targetCols) targetCols = await fetchEntityColumns(client, dpIdentifier, "data_product");
500
+ } else if (isIdentifier(target)) {
501
+ targetCols = await fetchEntityColumns(client, target.trim(), "data_product");
502
+ } else {
503
+ targetCols = target.split(",").map((c) => c.trim()).filter(Boolean);
504
+ }
505
+ if (!targetCols || targetCols.length === 0) {
506
+ console.error(ERR(` ✗ Could not resolve target columns from: ${resolvedTarget}`));
507
+ process.exit(1);
508
+ }
509
+
510
+ const result = await alignColumns(sourceCols, targetCols, { embedTexts, threshold });
511
+
512
+ // Extend the suggested transformation with unmapped handling decisions
513
+ if (!result.suggestedTransformation) result.suggestedTransformation = { transform: "rename_column", mapping: {} };
514
+ if (dropUnmappedSource && result.unmappedSource.length > 0) {
515
+ result.suggestedTransformation.drop_columns = result.unmappedSource;
516
+ }
517
+ if (fillMissingExpected && result.unmappedExpected.length > 0) {
518
+ result.suggestedTransformation.fill_columns = result.unmappedExpected;
519
+ }
520
+
521
+ if (opts.json) {
522
+ console.log(JSON.stringify(result, null, 2));
523
+ return;
524
+ }
525
+
526
+ // ── Formatted table output ────────────────────────────────────────────
527
+ const title = `Column Alignment: ${source} → ${resolvedTarget}`;
528
+ banner(title);
529
+ console.log();
530
+
531
+ // Always show full greedy table: confirmed (above threshold) + suggestions (below)
532
+ const allMappings = await alignColumns(sourceCols, targetCols, { embedTexts, threshold: 0 });
533
+ const confirmedSet = new Set(result.mappings.map((m) => m.sourceColumn));
534
+
535
+ const COL_W = 20, EXP_W = 24, MET_W = 12;
536
+ console.log(DIM(` ${"Source".padEnd(COL_W)} ${"Expected".padEnd(EXP_W)} ${"Method".padEnd(MET_W)} Confidence`));
537
+ console.log(DIM(" " + "─".repeat(COL_W + EXP_W + MET_W + 20)));
538
+ for (const m of allMappings.mappings) {
539
+ const conf = `${Math.round(m.confidence * 100)}%`;
540
+ const isConfirmed = confirmedSet.has(m.sourceColumn);
541
+ if (isConfirmed) {
542
+ const methodColor = m.method === "exact" ? OK : m.method === "embedding" ? ACCENT : WARN;
543
+ console.log(
544
+ ` ${m.sourceColumn.slice(0, COL_W - 1).padEnd(COL_W)} ${m.expectedColumn.slice(0, EXP_W - 1).padEnd(EXP_W)} ${methodColor(m.method.padEnd(MET_W))} ${conf}`,
545
+ );
546
+ } else {
547
+ console.log(DIM(
548
+ ` ${m.sourceColumn.slice(0, COL_W - 1).padEnd(COL_W)} ${m.expectedColumn.slice(0, EXP_W - 1).padEnd(EXP_W)} ${m.method.padEnd(MET_W)} ${conf}`,
549
+ ));
550
+ }
551
+ }
552
+ // Columns with no match at all
553
+ for (const src of allMappings.unmappedSource) {
554
+ console.log(DIM(` ${src.slice(0, COL_W - 1).padEnd(COL_W)} ${"—".padEnd(EXP_W)} ${"unmatched".padEnd(MET_W)} —`));
555
+ }
556
+
557
+ const hasSuggestions = allMappings.mappings.some((m) => !confirmedSet.has(m.sourceColumn));
558
+ if (hasSuggestions) {
559
+ console.log(DIM(`\n Dimmed rows are suggestions below the ${threshold} threshold.`));
560
+ const lowestConf = Math.min(...allMappings.mappings.filter((m) => !confirmedSet.has(m.sourceColumn)).map((m) => m.confidence));
561
+ console.log(DIM(` Use --threshold ${Math.max(0.1, Math.ceil(lowestConf * 10) / 10 - 0.1).toFixed(1)} to accept all, or specify a value.`));
562
+ }
563
+
564
+ if (allMappings.unmappedExpected.length > 0) {
565
+ const action = fillMissingExpected ? chalk.yellow("fill with NaN") : chalk.dim("skip");
566
+ console.log(`\n Unmapped expected [${action}]: ${DIM(allMappings.unmappedExpected.join(", "))}`);
567
+ }
568
+
569
+ if (Object.keys(result.suggestedTransformation.mapping).length > 0) {
570
+ console.log(ACCENT("\n Suggested rename_column step:"));
571
+ console.log(` ${JSON.stringify(result.suggestedTransformation.mapping, null, 2).replace(/\n/g, "\n ")}`);
572
+ }
573
+
574
+ // ── FCL patch output ──────────────────────────────────────────────────
575
+ if (opts.fcl !== undefined) {
576
+ const mapping = { ...(result.suggestedTransformation?.mapping || {}) };
577
+
578
+ if (Object.keys(mapping).length === 0 && (result.unmappedSource || []).length === 0) {
579
+ console.log(WARN(" No confirmed mappings to write — use --threshold to lower the bar."));
580
+ } else {
581
+ const toFile = typeof opts.fcl === "string";
582
+ const dpId = resolvedTarget || "YOUR_DATA_PRODUCT_IDENTIFIER";
583
+
584
+ // Try to resolve the data object ref from the existing builder
585
+ let inputRef = "YOUR_DATA_OBJECT_REF";
586
+ let inputType = "data_object";
587
+ if (resolvedTarget) {
588
+ try {
589
+ const { qs } = await import("./lib/helpers.js");
590
+ const builder = await client.get(`/data/data_product/compute/builder${qs({ identifier: resolvedTarget })}`);
591
+ const firstInput = Object.values(builder?.inputs || {})[0];
592
+ if (firstInput?.identifier) {
593
+ inputRef = firstInput.identifier;
594
+ inputType = firstInput.input_type === "data_product" ? "data_product" : "data_object";
595
+ }
596
+ } catch { /* builder not available — leave placeholder */ }
597
+ }
598
+
599
+ const mappingLines = Object.entries(mapping)
600
+ .map(([from, to]) => from === to
601
+ ? ` ${from}: ${to} # new column — rename or remove`
602
+ : ` ${from}: ${to}`)
603
+ .join("\n");
604
+ const applyHint = toFile ? `fops apply ${opts.fcl}` : "fops apply <file>";
605
+ const yaml = [
606
+ `# Landscape patch — apply with: ${applyHint}`,
607
+ `# Adds a rename_column step to the data product pipeline.`,
608
+ `# Review the mapping before applying — dimmed suggestions not included.`,
609
+ `data_product:`,
610
+ ` ${dpId}:`,
611
+ ` pipeline:`,
612
+ ` inputs:`,
613
+ ` - type: ${inputType}`,
614
+ ` ref: ${inputRef}`,
615
+ ` transforms:`,
616
+ ` - transform: rename_column`,
617
+ ` mapping:`,
618
+ mappingLines,
619
+ ].join("\n") + "\n";
620
+ if (toFile) {
621
+ const { writeFileSync: wfs } = await import("node:fs");
622
+ const fclPath = path.resolve(opts.fcl);
623
+ wfs(fclPath, yaml);
624
+ console.log(OK(`\n ✓ FCL patch written: ${fclPath}`));
625
+ console.log(DIM(` Edit the inputs.ref, then: fops apply ${fclPath}`));
626
+ } else {
627
+ process.stdout.write(yaml);
628
+ }
629
+ }
630
+ }
631
+
632
+ // ── Apply transformation to CSV(s) ────────────────────────────────────
633
+ if (opts.output && sourceCsvFiles.length > 0) {
634
+ const Papa = (await import("papaparse")).default;
635
+ const renameMap = result.suggestedTransformation.mapping || {};
636
+ const dropCols = new Set(dropUnmappedSource ? result.unmappedSource : []);
637
+ const fillCols = fillMissingExpected ? result.unmappedExpected : [];
638
+
639
+ let outputPath = path.resolve(opts.output);
640
+ // If multiple source files and output is a directory, write each there
641
+ const outputIsDir = sourceCsvFiles.length > 1 || isDirectory(opts.output);
642
+ if (outputIsDir) {
643
+ const { mkdirSync } = await import("node:fs");
644
+ mkdirSync(outputPath, { recursive: true });
645
+ }
646
+
647
+ for (const csvFile of sourceCsvFiles) {
648
+ const raw = readFileSync(csvFile, "utf8");
649
+ const parsed = Papa.parse(raw, { header: true, skipEmptyLines: true });
650
+ const origHeaders = parsed.meta.fields || [];
651
+
652
+ // Build output header: renamed mapped cols + kept unmapped source + fill cols
653
+ const outHeaders = [
654
+ ...origHeaders
655
+ .filter((h) => !dropCols.has(h))
656
+ .map((h) => renameMap[h] || h),
657
+ ...fillCols,
658
+ ];
659
+
660
+ const outRows = parsed.data.map((row) => {
661
+ const out = {};
662
+ for (const h of origHeaders) {
663
+ if (dropCols.has(h)) continue;
664
+ out[renameMap[h] || h] = row[h] ?? "";
665
+ }
666
+ for (const h of fillCols) out[h] = "";
667
+ return out;
668
+ });
669
+
670
+ const csv = Papa.unparse({ fields: outHeaders, data: outRows });
671
+ const dest = outputIsDir ? path.join(outputPath, path.basename(csvFile)) : outputPath;
672
+ writeFileSync(dest, csv);
673
+ console.log(OK(` ✓ Written: ${dest}`));
674
+ outputPath = dest; // track final path for --create/--overwrite
675
+ }
676
+
677
+ // ── Create or overwrite Foundation DataObject ─────────────────────
678
+ if (opts.create) {
679
+ try {
680
+ const body = {
681
+ entity: { name: opts.create, label: opts.create.slice(0, 3).toLowerCase() },
682
+ entity_info: { owner: api.config?.email || "" },
683
+ };
684
+ const created = await client.post("/data/data_object", body);
685
+ const newId = created?.identifier || created?.entity?.identifier;
686
+ if (newId) {
687
+ await client.put(`/data/data_object/config?identifier=${newId}`, {
688
+ data_object_type: "csv",
689
+ path: outputPath,
690
+ has_header: true,
691
+ });
692
+ console.log(OK(` ✓ DataObject created: ${newId}`));
693
+ }
694
+ } catch (e) {
695
+ console.error(ERR(` ✗ Failed to create DataObject: ${e.message}`));
696
+ }
697
+ } else if (opts.overwrite) {
698
+ try {
699
+ await client.put(`/data/data_object/config?identifier=${opts.overwrite}`, {
700
+ data_object_type: "csv",
701
+ path: outputPath,
702
+ has_header: true,
703
+ });
704
+ console.log(OK(` ✓ DataObject updated: ${opts.overwrite}`));
705
+ } catch (e) {
706
+ console.error(ERR(` ✗ Failed to update DataObject: ${e.message}`));
707
+ }
708
+ }
709
+ } else if ((opts.create || opts.overwrite) && !opts.output) {
710
+ console.log(WARN(" --create/--overwrite require --output to specify the transformed CSV path."));
711
+ }
712
+
713
+ console.log();
714
+ });
715
+
716
+ // ── run ───────────────────────────────────────────────────────────────────
717
+ foundation
718
+ .command("run")
719
+ .description("Open the Foundation UI in an Electron window")
720
+ .option("--url <url>", "Override the frontend URL")
721
+ .action(async (opts) => {
722
+ const { spawn } = await import("node:child_process");
723
+ const { readFileSync } = await import("node:fs");
724
+ const { findComposeRoot } = await import("./lib/tools-write.js");
725
+
726
+ // Resolve frontend URL: flag → env → .env file → default
727
+ let frontendUrl = opts.url || process.env.FOUNDATION_PUBLIC_URL;
728
+ if (!frontendUrl) {
729
+ const root = program._fopsRoot || findComposeRoot();
730
+ if (root) {
731
+ try {
732
+ const envContent = readFileSync(`${root}/.env`, "utf8");
733
+ const m = envContent.match(/^FOUNDATION_PUBLIC_URL=(.+)$/m);
734
+ if (m) frontendUrl = m[1].trim();
735
+ } catch { /* .env not found */ }
736
+ }
737
+ }
738
+ frontendUrl = frontendUrl || "http://127.0.0.1:3002";
739
+
740
+ const { writeFileSync, existsSync, realpathSync } = await import("node:fs");
741
+ const { tmpdir, homedir } = await import("node:os");
742
+ const { join, dirname } = await import("node:path");
743
+ const { execSync } = await import("node:child_process");
744
+
745
+ const composeRoot = program._fopsRoot || findComposeRoot() || "";
746
+
747
+ // Resolve icon from the fops install
748
+ let iconPath = "";
749
+ try {
750
+ const pluginsNodeModules = join(homedir(), ".fops", "plugins", "node_modules");
751
+ const fopsRoot = dirname(realpathSync(pluginsNodeModules));
752
+ const src = join(fopsRoot, "src", "electron", "icon.png");
753
+ if (existsSync(src)) iconPath = src;
754
+ } catch { /* icon is optional */ }
755
+
756
+ // ── Swift WKWebView app (uses Safari's WebKit engine + Keychain) ─────
757
+ const swiftScript = join(tmpdir(), "fops-webkit-main.swift");
758
+ // Control panel JS injected into the WKWebView page
759
+ const controlJS = `
760
+ (function() {
761
+ var API = "http://127.0.0.1:9001";
762
+ var style = document.createElement("style");
763
+ style.textContent = "#fops-ctrl{position:fixed;bottom:18px;right:18px;z-index:2147483647;font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:12px;color:#e2e8f0;user-select:none}" +
764
+ "#fops-pill{display:flex;align-items:center;gap:8px;background:rgba(15,15,20,0.85);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,0.12);border-radius:999px;padding:6px 12px 6px 10px;cursor:default;box-shadow:0 4px 16px rgba(0,0,0,0.4)}" +
765
+ "#fops-panel{display:none;flex-direction:column;gap:1px;background:rgba(15,15,20,0.92);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,0.12);border-radius:10px;padding:6px;margin-bottom:6px;box-shadow:0 4px 20px rgba(0,0,0,0.5);min-width:300px}" +
766
+ "#fops-ctrl:hover #fops-panel{display:flex}" +
767
+ ".fops-svc{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px}" +
768
+ ".fops-svc:hover{background:rgba(255,255,255,0.06)}" +
769
+ ".fops-svc-name{flex:1;font-size:11px;color:#cbd5e1}" +
770
+ ".fops-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}" +
771
+ ".fops-btn{padding:2px 8px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);color:#e2e8f0;font-size:10px;cursor:pointer}" +
772
+ ".fops-btn:hover{background:rgba(255,255,255,0.18)}" +
773
+ ".fops-btn:disabled{opacity:0.4;cursor:default}";
774
+ document.head.appendChild(style);
775
+
776
+ var ctrl = document.createElement("div"); ctrl.id = "fops-ctrl";
777
+ var panel = document.createElement("div"); panel.id = "fops-panel";
778
+ var pill = document.createElement("div"); pill.id = "fops-pill";
779
+ var dot = document.createElement("span"); dot.className = "fops-dot"; dot.style.background = "#64748b";
780
+ var lbl = document.createElement("span"); lbl.textContent = "Checking\\u2026";
781
+ var mainBtn = document.createElement("button"); mainBtn.className = "fops-btn"; mainBtn.style.display = "none";
782
+ pill.append(dot, lbl, mainBtn);
783
+ ctrl.append(panel, pill);
784
+ document.body.appendChild(ctrl);
785
+
786
+ var status = "checking", busy = false;
787
+
788
+ function setStatus(s) {
789
+ status = s;
790
+ if (s === "running") { dot.style.background="#22c55e"; lbl.textContent="Running"; mainBtn.textContent="Stop"; mainBtn.disabled=false; mainBtn.style.display=""; }
791
+ else if (s === "stopped") { dot.style.background="#ef4444"; lbl.textContent="Stopped"; mainBtn.textContent="Start"; mainBtn.disabled=false; mainBtn.style.display=""; }
792
+ else if (s === "starting") { dot.style.background="#f59e0b"; lbl.textContent="Starting\\u2026"; mainBtn.textContent="Starting\\u2026"; mainBtn.disabled=true; mainBtn.style.display=""; }
793
+ else if (s === "stopping") { dot.style.background="#f59e0b"; lbl.textContent="Stopping\\u2026"; mainBtn.textContent="Stopping\\u2026"; mainBtn.disabled=true; mainBtn.style.display=""; }
794
+ else { dot.style.background="#64748b"; lbl.textContent="Checking\\u2026"; mainBtn.style.display="none"; }
795
+ }
796
+
797
+ function send(action, payload) {
798
+ window.webkit.messageHandlers.fopsStack.postMessage({action: action, payload: payload || ""});
799
+ }
800
+
801
+ function poll() {
802
+ fetch(API, {mode:"no-cors"}).then(function(){ setStatus("running"); }).catch(function(){ setStatus("stopped"); });
803
+ }
804
+
805
+ mainBtn.onclick = function() {
806
+ if (busy) return; busy = true;
807
+ if (status === "running") { setStatus("stopping"); send("stop"); }
808
+ else { setStatus("starting"); send("start"); }
809
+ setTimeout(function() { busy = false; poll(); }, 4000);
810
+ };
811
+
812
+ window._fopsServices = function(json) {
813
+ panel.innerHTML = "";
814
+ var svcs = [];
815
+ try { svcs = JSON.parse(json); } catch(e) {}
816
+ if (!Array.isArray(svcs)) svcs = [svcs].filter(Boolean);
817
+ svcs.forEach(function(svc) {
818
+ var name = svc.Service || svc.Name || "?";
819
+ var running = (svc.State || "").toLowerCase() === "running";
820
+ var row = document.createElement("div"); row.className = "fops-svc";
821
+ var d = document.createElement("span"); d.className = "fops-dot"; d.style.background = running ? "#22c55e" : "#ef4444";
822
+ var n = document.createElement("span"); n.className = "fops-svc-name"; n.textContent = name;
823
+ var rb = document.createElement("button"); rb.className = "fops-btn"; rb.textContent = "\\u21BA";
824
+ var sb = document.createElement("button"); sb.className = "fops-btn"; sb.textContent = "\\u25A0";
825
+ rb.onclick = function() { rb.disabled=sb.disabled=true; rb.textContent="\\u2026"; send("restart", name); setTimeout(function(){ send("services"); }, 3000); };
826
+ sb.onclick = function() { rb.disabled=sb.disabled=true; sb.textContent="\\u2026"; send("stopSvc", name); setTimeout(function(){ send("services"); }, 2000); };
827
+ row.append(d, n, rb, sb);
828
+ panel.appendChild(row);
829
+ });
830
+ };
831
+
832
+ ctrl.addEventListener("mouseenter", function() { send("services"); });
833
+
834
+ poll();
835
+ setInterval(poll, 5000);
836
+ })();
837
+ `;
838
+
839
+ writeFileSync(swiftScript, `
840
+ import AppKit
841
+ import WebKit
842
+
843
+ ProcessInfo.processInfo.processName = "Foundation"
844
+
845
+ class Handler: NSObject, WKScriptMessageHandler {
846
+ var exec: (String, String) -> Void = { _, _ in }
847
+ func userContentController(_ c: WKUserContentController, didReceive msg: WKScriptMessage) {
848
+ guard let d = msg.body as? [String: String] else { return }
849
+ exec(d["action"] ?? "", d["payload"] ?? "")
850
+ }
851
+ }
852
+
853
+ class WD: NSObject, NSWindowDelegate {
854
+ func windowWillClose(_ n: Notification) { NSApp.terminate(nil) }
855
+ }
856
+
857
+ // Allow popups (Google OAuth, etc.) to open in a new window
858
+ class UI: NSObject, WKUIDelegate {
859
+ func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
860
+ for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
861
+ configuration.websiteDataStore = WKWebsiteDataStore.default()
862
+ let popWin = NSWindow(contentRect: NSRect(x:0,y:0,width:900,height:700),
863
+ styleMask: [.titled,.closable,.resizable], backing: .buffered, defer: false)
864
+ let popWV = WKWebView(frame: popWin.contentLayoutRect, configuration: configuration)
865
+ popWV.autoresizingMask = [.width, .height]
866
+ popWin.contentView = popWV
867
+ popWin.center()
868
+ popWin.makeKeyAndOrderFront(nil)
869
+ return popWV
870
+ }
871
+ }
872
+
873
+ let foundationURL = ProcessInfo.processInfo.environment["FOUNDATION_URL"] ?? "http://127.0.0.1:3002"
874
+ let composeRoot = ProcessInfo.processInfo.environment["FOUNDATION_COMPOSE_ROOT"] ?? ""
875
+ let iconPath = ProcessInfo.processInfo.environment["FOUNDATION_ICON"] ?? ""
876
+
877
+ func docker() -> String {
878
+ for p in ["/opt/homebrew/bin/docker", "/usr/local/bin/docker", "/usr/bin/docker"] {
879
+ if FileManager.default.isExecutableFile(atPath: p) { return p }
880
+ }
881
+ return "docker"
882
+ }
883
+
884
+ func compose(_ args: [String], completion: ((String) -> Void)? = nil) {
885
+ let p = Process(); let pipe = Pipe()
886
+ p.executableURL = URL(fileURLWithPath: docker())
887
+ p.arguments = ["compose"] + args
888
+ if !composeRoot.isEmpty { p.currentDirectoryURL = URL(fileURLWithPath: composeRoot) }
889
+ if completion != nil { p.standardOutput = pipe; p.standardError = pipe }
890
+ try? p.run()
891
+ if let completion = completion {
892
+ p.waitUntilExit()
893
+ let out = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
894
+ DispatchQueue.main.async { completion(out) }
895
+ }
896
+ }
897
+
898
+ // Use default (persistent) data store so Keychain autofill works
899
+ let cfg = WKWebViewConfiguration()
900
+ cfg.websiteDataStore = WKWebsiteDataStore.default()
901
+ let handler = Handler()
902
+ cfg.userContentController.add(handler, name: "fopsStack")
903
+ cfg.userContentController.addUserScript(WKUserScript(
904
+ source: ${JSON.stringify(controlJS)},
905
+ injectionTime: .atDocumentEnd, forMainFrameOnly: true))
906
+
907
+ let ui = UI()
908
+ let wv = WKWebView(frame: NSRect(x:0,y:0,width:1400,height:900), configuration: cfg)
909
+ wv.autoresizingMask = [.width, .height]
910
+ wv.uiDelegate = ui
911
+
912
+ handler.exec = { action, payload in
913
+ switch action {
914
+ case "start":
915
+ compose(["up", "-d"])
916
+ case "stop":
917
+ compose(["down"])
918
+ case "restart":
919
+ compose(["restart", payload])
920
+ case "stopSvc":
921
+ compose(["stop", payload])
922
+ case "services":
923
+ compose(["ps", "--format", "json"]) { out in
924
+ let escaped = out.replacingOccurrences(of: "\\\\", with: "\\\\\\\\")
925
+ .replacingOccurrences(of: "\\"", with: "\\\\\\"")
926
+ .replacingOccurrences(of: "\\n", with: "\\\\n")
927
+ wv.evaluateJavaScript("window._fopsServices(\\"[\\(escaped)]\\")", completionHandler: nil)
928
+ }
929
+ default: break
930
+ }
931
+ }
932
+
933
+ let app = NSApplication.shared
934
+ app.setActivationPolicy(.regular)
935
+
936
+ // ── App menu with Cmd+Q ───────────────────────────────────────────────────
937
+ let menuBar = NSMenu()
938
+ let appItem = NSMenuItem()
939
+ menuBar.addItem(appItem)
940
+ let appMenu = NSMenu()
941
+ appItem.submenu = appMenu
942
+ appMenu.addItem(NSMenuItem(title: "Quit Foundation", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
943
+ app.mainMenu = menuBar
944
+
945
+ // ── Dock icon — padded so it matches other macOS app proportions ──────────
946
+ if !iconPath.isEmpty, let src = NSImage(contentsOfFile: iconPath) {
947
+ let size: CGFloat = 512
948
+ let pad: CGFloat = size * 0.15
949
+ let inner = size - pad * 2
950
+ let padded = NSImage(size: NSSize(width: size, height: size))
951
+ padded.lockFocus()
952
+ src.draw(in: NSRect(x: pad, y: pad, width: inner, height: inner),
953
+ from: .zero, operation: .sourceOver, fraction: 1)
954
+ padded.unlockFocus()
955
+ app.applicationIconImage = padded
956
+ }
957
+
958
+ let wd = WD()
959
+ let win = NSWindow(contentRect: NSRect(x:0,y:0,width:1400,height:900),
960
+ styleMask: [.titled,.closable,.miniaturizable,.resizable], backing: .buffered, defer: false)
961
+ win.title = "Foundation"
962
+ win.contentView = wv
963
+ win.delegate = wd
964
+ win.center()
965
+ win.makeKeyAndOrderFront(nil)
966
+
967
+ app.activate(ignoringOtherApps: true)
968
+ wv.load(URLRequest(url: URL(string: foundationURL)!))
969
+ app.run()
970
+ `);
971
+
972
+ console.log(ACCENT(` Opening Foundation at ${frontendUrl}`));
973
+
974
+ const child = spawn("swift", [swiftScript], {
975
+ stdio: "ignore",
976
+ detached: true,
977
+ env: { ...process.env, FOUNDATION_URL: frontendUrl, FOUNDATION_ICON: iconPath, FOUNDATION_COMPOSE_ROOT: composeRoot },
978
+ });
979
+ child.unref();
980
+ });
981
+
982
+ // ── tray ──────────────────────────────────────────────────────────────────
983
+ foundation
984
+ .command("tray")
985
+ .description("Run a macOS menu bar tray for the Foundation stack")
986
+ .option("--url <url>", "Override the backend API URL")
987
+ .action(async (opts) => {
988
+ const { spawn } = await import("node:child_process");
989
+ const { writeFileSync, existsSync, realpathSync, readFileSync } = await import("node:fs");
990
+ const { tmpdir, homedir } = await import("node:os");
991
+ const { join, dirname } = await import("node:path");
992
+ const { findComposeRoot } = await import("./lib/tools-write.js");
993
+
994
+ const composeRoot = program._fopsRoot || findComposeRoot() || "";
995
+
996
+ let apiUrl = opts.url || process.env.FOPS_API_URL || "http://127.0.0.1:9001";
997
+
998
+ // Current fops version
999
+ let fopsVersion = "0.0.0";
1000
+ try {
1001
+ const pluginsNodeModules2 = join(homedir(), ".fops", "plugins", "node_modules");
1002
+ const fopsRoot2 = dirname(realpathSync(pluginsNodeModules2));
1003
+ const pkgJson = JSON.parse(readFileSync(join(fopsRoot2, "package.json"), "utf8"));
1004
+ fopsVersion = pkgJson.version || "0.0.0";
1005
+ } catch { /* fallback */ }
1006
+
1007
+ // Resolve icon
1008
+ let iconPath = "";
1009
+ try {
1010
+ const pluginsNodeModules = join(homedir(), ".fops", "plugins", "node_modules");
1011
+ const fopsRoot = dirname(realpathSync(pluginsNodeModules));
1012
+ const src = join(fopsRoot, "src", "electron", "icon.png");
1013
+ if (existsSync(src)) iconPath = src;
1014
+ } catch { /* icon optional */ }
1015
+
1016
+ // Discover installed plugins for the Plugins submenu
1017
+ let fopsPluginsJson = "[]";
1018
+ try {
1019
+ const { discoverPlugins } = await import("../../discovery.js");
1020
+ const { validateManifest } = await import("../../manifest.js");
1021
+ const candidates = discoverPlugins();
1022
+ const pluginList = candidates.map((c) => {
1023
+ const manifest = validateManifest(c.path);
1024
+ return { id: c.id, name: manifest?.name || c.id, source: c.source };
1025
+ }).filter((p) => p.id);
1026
+ fopsPluginsJson = JSON.stringify(pluginList);
1027
+ } catch { /* optional */ }
1028
+
1029
+ // ── Windows tray (PowerShell NotifyIcon) ─────────────────────────────
1030
+ if (process.platform === "win32") {
1031
+ const ps1Tray = join(tmpdir(), "fops-tray.ps1");
1032
+ writeFileSync(ps1Tray, `
1033
+ Add-Type -AssemblyName System.Windows.Forms
1034
+ Add-Type -AssemblyName System.Drawing
1035
+
1036
+ $script:apiUrl = $env:FOUNDATION_API_URL
1037
+ $script:composeRoot = $env:FOUNDATION_COMPOSE_ROOT
1038
+ $script:foundationUrl = if ($env:FOUNDATION_URL) { $env:FOUNDATION_URL } else { "http://127.0.0.1:3002" }
1039
+ $script:allPlugins = @()
1040
+ try { $script:allPlugins = $env:FOPS_PLUGINS | ConvertFrom-Json } catch {}
1041
+
1042
+ function Get-Docker {
1043
+ foreach ($p in @("C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe")) {
1044
+ if (Test-Path $p) { return $p }
1045
+ }
1046
+ $d = (Get-Command docker -ErrorAction SilentlyContinue).Source
1047
+ if ($d) { return $d }
1048
+ return "docker"
1049
+ }
1050
+
1051
+ function Get-Fops {
1052
+ $f = (Get-Command fops -ErrorAction SilentlyContinue).Source
1053
+ if ($f) { return $f }
1054
+ return "fops"
1055
+ }
1056
+
1057
+ function Invoke-Compose {
1058
+ param([string[]]$CArgs, [switch]$Capture)
1059
+ $docker = Get-Docker
1060
+ $proj = @()
1061
+ if ($script:composeRoot) { $proj = @("--project-directory", $script:composeRoot) }
1062
+ if ($Capture) {
1063
+ & $docker compose @proj @CArgs 2>$null
1064
+ } else {
1065
+ & $docker compose @proj @CArgs 2>$null | Out-Null
1066
+ }
1067
+ }
1068
+
1069
+ function Read-PluginEnabled($id) {
1070
+ $cfgPath = "$env:USERPROFILE\\.fops.json"
1071
+ if (!(Test-Path $cfgPath)) { return $true }
1072
+ try {
1073
+ $cfg = Get-Content $cfgPath -Raw | ConvertFrom-Json
1074
+ $entry = $cfg.plugins.entries.$id
1075
+ if ($null -eq $entry) { return $true }
1076
+ return $entry.enabled -ne $false
1077
+ } catch { return $true }
1078
+ }
1079
+
1080
+ # ── Tray icon ──────────────────────────────────────────────────────────────────
1081
+ $tray = New-Object System.Windows.Forms.NotifyIcon
1082
+ $tray.Icon = [System.Drawing.SystemIcons]::Application
1083
+ $tray.Text = "Foundation"
1084
+ $tray.Visible = $true
1085
+
1086
+ $menu = New-Object System.Windows.Forms.ContextMenuStrip
1087
+
1088
+ $statusItem = [System.Windows.Forms.ToolStripLabel]::new("Checking...")
1089
+ $statusItem.Enabled = $false
1090
+ $menu.Items.Add($statusItem) | Out-Null
1091
+ $menu.Items.Add([System.Windows.Forms.ToolStripSeparator]::new()) | Out-Null
1092
+
1093
+ $startItem = [System.Windows.Forms.ToolStripMenuItem]::new("Start")
1094
+ $startItem.add_Click({ Invoke-Compose "up","-d"; Update-Status })
1095
+ $stopItem = [System.Windows.Forms.ToolStripMenuItem]::new("Stop")
1096
+ $stopItem.add_Click({ Invoke-Compose "down"; Update-Status })
1097
+ $restartItem = [System.Windows.Forms.ToolStripMenuItem]::new("Restart")
1098
+ $restartItem.add_Click({ Invoke-Compose "restart"; Update-Status })
1099
+ $menu.Items.Add($startItem) | Out-Null
1100
+ $menu.Items.Add($stopItem) | Out-Null
1101
+ $menu.Items.Add($restartItem) | Out-Null
1102
+ $menu.Items.Add([System.Windows.Forms.ToolStripSeparator]::new()) | Out-Null
1103
+
1104
+ # Compose submenu
1105
+ $composeItem = [System.Windows.Forms.ToolStripMenuItem]::new("Compose")
1106
+ $composeItem.add_DropDownOpening({
1107
+ $composeItem.DropDownItems.Clear()
1108
+ $raw = Invoke-Compose "ps","--format","json" -Capture
1109
+ $services = @()
1110
+ try { $services = ($raw -join "") | ConvertFrom-Json } catch {}
1111
+ if (!$services) { $services = @() }
1112
+ if ($services -isnot [array]) { $services = @($services) }
1113
+ if ($services.Count -eq 0) {
1114
+ $n = [System.Windows.Forms.ToolStripMenuItem]::new("No services found")
1115
+ $n.Enabled = $false; $composeItem.DropDownItems.Add($n) | Out-Null; return
1116
+ }
1117
+ foreach ($svc in $services) {
1118
+ $name = if ($svc.Service) { $svc.Service } else { $svc.Name }
1119
+ $state = if ($svc.State) { $svc.State } else { "" }
1120
+ $label = if ($name -like "foundation-*") { $name.Substring(11) } else { $name }
1121
+ $dot = if ($state -eq "running") { [char]0x25CF + " " } else { [char]0x25CB + " " }
1122
+ $svcItem = [System.Windows.Forms.ToolStripMenuItem]::new($dot + $label)
1123
+
1124
+ $logsI = [System.Windows.Forms.ToolStripMenuItem]::new("Logs")
1125
+ $n2 = $name
1126
+ $logsI.add_Click({ Start-Process powershell -ArgumentList "-NoExit","-Command","& '$(Get-Docker)' compose $(if ($script:composeRoot) { '--project-directory ' + $script:composeRoot }) logs -f --tail=200 $n2" }.GetNewClosure())
1127
+ $svcItem.DropDownItems.Add($logsI) | Out-Null
1128
+
1129
+ $startI = [System.Windows.Forms.ToolStripMenuItem]::new("Start"); $startI.Enabled = ($state -ne "running")
1130
+ $startI.add_Click({ Invoke-Compose "start",$n2 }.GetNewClosure()); $svcItem.DropDownItems.Add($startI) | Out-Null
1131
+
1132
+ $stopI = [System.Windows.Forms.ToolStripMenuItem]::new("Stop"); $stopI.Enabled = ($state -eq "running")
1133
+ $stopI.add_Click({ Invoke-Compose "stop",$n2 }.GetNewClosure()); $svcItem.DropDownItems.Add($stopI) | Out-Null
1134
+
1135
+ $restI = [System.Windows.Forms.ToolStripMenuItem]::new("Restart")
1136
+ $restI.add_Click({ Invoke-Compose "restart",$n2 }.GetNewClosure()); $svcItem.DropDownItems.Add($restI) | Out-Null
1137
+
1138
+ $exI = [System.Windows.Forms.ToolStripMenuItem]::new("Exec Shell")
1139
+ $exI.add_Click({ Start-Process powershell -ArgumentList "-NoExit","-Command","& '$(Get-Docker)' compose $(if ($script:composeRoot) { '--project-directory ' + $script:composeRoot }) exec $n2 bash" }.GetNewClosure())
1140
+ $svcItem.DropDownItems.Add($exI) | Out-Null
1141
+
1142
+ $composeItem.DropDownItems.Add($svcItem) | Out-Null
1143
+ }
1144
+ })
1145
+ $menu.Items.Add($composeItem) | Out-Null
1146
+
1147
+ # Plugins submenu
1148
+ $pluginsItem = [System.Windows.Forms.ToolStripMenuItem]::new("Plugins")
1149
+ $pluginsItem.add_DropDownOpening({
1150
+ $pluginsItem.DropDownItems.Clear()
1151
+ if ($script:allPlugins.Count -eq 0) {
1152
+ $n = [System.Windows.Forms.ToolStripMenuItem]::new("No plugins installed")
1153
+ $n.Enabled = $false; $pluginsItem.DropDownItems.Add($n) | Out-Null; return
1154
+ }
1155
+ foreach ($plugin in $script:allPlugins) {
1156
+ $enabled = Read-PluginEnabled $plugin.id
1157
+ $prefix = if ($enabled) { [char]0x2713 + " " } else { " " }
1158
+ $pItem = [System.Windows.Forms.ToolStripMenuItem]::new($prefix + $plugin.name)
1159
+ $pid2 = $plugin.id
1160
+ $pItem.add_Click({
1161
+ $fops = Get-Fops
1162
+ $isOn = Read-PluginEnabled $pid2
1163
+ $subCmd = if ($isOn) { "disable" } else { "enable" }
1164
+ & $fops plugin $subCmd $pid2
1165
+ }.GetNewClosure())
1166
+ $pluginsItem.DropDownItems.Add($pItem) | Out-Null
1167
+ }
1168
+ })
1169
+ $menu.Items.Add($pluginsItem) | Out-Null
1170
+
1171
+ $menu.Items.Add([System.Windows.Forms.ToolStripSeparator]::new()) | Out-Null
1172
+
1173
+ $openItem = [System.Windows.Forms.ToolStripMenuItem]::new("Open Foundation...")
1174
+ $openItem.add_Click({ Start-Process $script:foundationUrl })
1175
+ $menu.Items.Add($openItem) | Out-Null
1176
+
1177
+ $doctorItem = [System.Windows.Forms.ToolStripMenuItem]::new("Doctor")
1178
+ $doctorItem.add_Click({
1179
+ $fops = Get-Fops
1180
+ $cd = if ($script:composeRoot) { "cd '$($script:composeRoot)'; " } else { "" }
1181
+ Start-Process powershell -ArgumentList "-NoExit","-Command","${cd}& '$fops' doctor"
1182
+ })
1183
+ $menu.Items.Add($doctorItem) | Out-Null
1184
+
1185
+ $menu.Items.Add([System.Windows.Forms.ToolStripSeparator]::new()) | Out-Null
1186
+
1187
+ $quitItem = [System.Windows.Forms.ToolStripMenuItem]::new("Quit")
1188
+ $quitItem.add_Click({
1189
+ $tray.Visible = $false
1190
+ [System.Windows.Forms.Application]::Exit()
1191
+ })
1192
+ $menu.Items.Add($quitItem) | Out-Null
1193
+
1194
+ $tray.ContextMenuStrip = $menu
1195
+
1196
+ function Update-Status {
1197
+ $raw = Invoke-Compose "ps","--format","json","--status","running" -Capture
1198
+ $running = $raw -and ($raw -match "\\{")
1199
+ if ($running) {
1200
+ $statusItem.Text = [char]0x25CF + " Running"
1201
+ $startItem.Enabled = $false; $stopItem.Enabled = $true; $restartItem.Enabled = $true
1202
+ } else {
1203
+ $statusItem.Text = [char]0x25CB + " Stopped"
1204
+ $startItem.Enabled = $true; $stopItem.Enabled = $false; $restartItem.Enabled = $false
1205
+ }
1206
+ }
1207
+ Update-Status
1208
+
1209
+ $timer = [System.Windows.Forms.Timer]::new()
1210
+ $timer.Interval = 8000
1211
+ $timer.add_Tick({ Update-Status })
1212
+ $timer.Start()
1213
+
1214
+ [System.Windows.Forms.Application]::Run()
1215
+ $timer.Stop()
1216
+ $tray.Visible = $false
1217
+ `);
1218
+ console.log(ACCENT(" Foundation tray started — look for the icon in your taskbar"));
1219
+ const winChild = spawn("powershell", [
1220
+ "-WindowStyle", "Hidden",
1221
+ "-NonInteractive",
1222
+ "-ExecutionPolicy", "Bypass",
1223
+ "-File", ps1Tray,
1224
+ ], {
1225
+ stdio: "ignore",
1226
+ detached: true,
1227
+ env: trayEnv,
1228
+ windowsHide: true,
1229
+ });
1230
+ winChild.unref();
1231
+ return;
1232
+ }
1233
+
1234
+ // ── macOS tray (Swift) ────────────────────────────────────────────────
1235
+ const swiftTray = join(tmpdir(), "fops-tray.swift");
1236
+ writeFileSync(swiftTray, `
1237
+ import AppKit
1238
+ import Foundation
1239
+
1240
+ ProcessInfo.processInfo.processName = "Foundation"
1241
+
1242
+ // ── Helpers ──────────────────────────────────────────────────────────────────
1243
+
1244
+ func docker() -> String {
1245
+ for p in ["/opt/homebrew/bin/docker", "/usr/local/bin/docker", "/usr/bin/docker"] {
1246
+ if FileManager.default.isExecutableFile(atPath: p) { return p }
1247
+ }
1248
+ return "docker"
1249
+ }
1250
+
1251
+ func git() -> String {
1252
+ for p in ["/opt/homebrew/bin/git", "/usr/bin/git"] {
1253
+ if FileManager.default.isExecutableFile(atPath: p) { return p }
1254
+ }
1255
+ return "git"
1256
+ }
1257
+
1258
+ let composeRoot = ProcessInfo.processInfo.environment["FOUNDATION_COMPOSE_ROOT"] ?? ""
1259
+ let iconPath = ProcessInfo.processInfo.environment["FOUNDATION_ICON"] ?? ""
1260
+ let fopsVersion = ProcessInfo.processInfo.environment["FOPS_VERSION"] ?? "0.0.0"
1261
+
1262
+ // ── Plugin registry ───────────────────────────────────────────────────────────
1263
+
1264
+ struct PluginEntry {
1265
+ let id: String
1266
+ let name: String
1267
+ let source: String
1268
+ }
1269
+
1270
+ let allPlugins: [PluginEntry] = {
1271
+ let raw = ProcessInfo.processInfo.environment["FOPS_PLUGINS"] ?? "[]"
1272
+ guard let data = raw.data(using: .utf8),
1273
+ let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
1274
+ return arr.compactMap { obj in
1275
+ guard let id = obj["id"] as? String, let name = obj["name"] as? String else { return nil }
1276
+ return PluginEntry(id: id, name: name, source: (obj["source"] as? String) ?? "")
1277
+ }
1278
+ }()
1279
+
1280
+ func readPluginEnabled(_ id: String) -> Bool {
1281
+ let home = ProcessInfo.processInfo.environment["HOME"] ?? ""
1282
+ guard !home.isEmpty,
1283
+ let data = try? Data(contentsOf: URL(fileURLWithPath: home + "/.fops.json")),
1284
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1285
+ let plugins = json["plugins"] as? [String: Any],
1286
+ let entries = plugins["entries"] as? [String: Any],
1287
+ let entry = entries[id] as? [String: Any],
1288
+ let enabled = entry["enabled"] as? Bool else { return true }
1289
+ return enabled
1290
+ }
1291
+
1292
+ func run(_ exe: String, _ args: [String], cwd: String? = nil, capture: Bool = false, done: ((String, Bool) -> Void)? = nil) {
1293
+ let p = Process(); let pipe = Pipe()
1294
+ p.executableURL = URL(fileURLWithPath: exe)
1295
+ p.arguments = args
1296
+ if let d = cwd ?? (composeRoot.isEmpty ? nil : composeRoot) { p.currentDirectoryURL = URL(fileURLWithPath: d) }
1297
+ p.standardOutput = capture ? pipe : FileHandle.nullDevice
1298
+ p.standardError = FileHandle.nullDevice
1299
+ DispatchQueue.global().async {
1300
+ guard (try? p.run()) != nil else { DispatchQueue.main.async { done?("", false) }; return }
1301
+ p.waitUntilExit()
1302
+ let out = capture ? (String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "") : ""
1303
+ DispatchQueue.main.async { done?(out, p.terminationStatus == 0) }
1304
+ }
1305
+ }
1306
+
1307
+ func compose(_ args: [String], capture: Bool = false, done: ((String, Bool) -> Void)? = nil) {
1308
+ run(docker(), ["compose"] + args, capture: capture, done: done)
1309
+ }
1310
+
1311
+ // ── Service discovery ─────────────────────────────────────────────────────────
1312
+
1313
+ struct Service {
1314
+ let name: String // docker compose service name e.g. "foundation-backend"
1315
+ let label: String // short label e.g. "backend"
1316
+ let state: String // "running" | "exited" | …
1317
+ let dir: String // submodule dir for git ops (may be empty)
1318
+ }
1319
+
1320
+ // Parse one or more JSON objects/arrays from docker compose ps output
1321
+ func parseServices(_ raw: String) -> [Service] {
1322
+ var services: [Service] = []
1323
+ // docker compose ps --format json emits one JSON object per line (NDJSON) or a JSON array
1324
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
1325
+ var jsonData: Data? = nil
1326
+ if trimmed.hasPrefix("[") {
1327
+ jsonData = trimmed.data(using: .utf8)
1328
+ } else {
1329
+ // NDJSON — wrap lines into array
1330
+ let lines = trimmed.components(separatedBy: "\\n").filter { $0.hasPrefix("{") }
1331
+ jsonData = ("[" + lines.joined(separator: ",") + "]").data(using: .utf8)
1332
+ }
1333
+ guard let data = jsonData,
1334
+ let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
1335
+ for obj in arr {
1336
+ let name = (obj["Service"] as? String) ?? (obj["Name"] as? String) ?? ""
1337
+ let state = (obj["State"] as? String) ?? ""
1338
+ guard !name.isEmpty else { continue }
1339
+ // Strip "foundation-" prefix for the label
1340
+ let label = name.hasPrefix("foundation-") ? String(name.dropFirst("foundation-".count)) : name
1341
+ // Resolve submodule dir: submodules live inside composeRoot
1342
+ // e.g. foundation-frontend-dev → foundation-frontend, foundation-backend-broker → foundation-backend
1343
+ let dir: String
1344
+ if composeRoot.isEmpty {
1345
+ dir = ""
1346
+ } else {
1347
+ let base = URL(fileURLWithPath: composeRoot)
1348
+ let fm2 = FileManager.default
1349
+ // Try progressively shorter suffixes until we find a directory
1350
+ var candidate = "foundation-" + label
1351
+ var found = ""
1352
+ while true {
1353
+ let path = base.appendingPathComponent(candidate).standardized.path
1354
+ if fm2.fileExists(atPath: path + "/.git") { found = path; break }
1355
+ // strip last -component
1356
+ guard let dash = candidate.lastIndex(of: "-") else { break }
1357
+ candidate = String(candidate[candidate.startIndex..<dash])
1358
+ }
1359
+ dir = found
1360
+ }
1361
+ services.append(Service(name: name, label: label, state: state, dir: dir))
1362
+ }
1363
+ return services
1364
+ }
1365
+
1366
+ // ── isRunning via docker compose ps ──────────────────────────────────────────
1367
+
1368
+ func isRunning(completion: @escaping (Bool) -> Void) {
1369
+ compose(["ps", "--format", "json", "--status", "running"], capture: true) { out, ok in
1370
+ let up = !out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && out.contains("{")
1371
+ completion(up)
1372
+ }
1373
+ }
1374
+
1375
+ // ── Open Terminal with a command ──────────────────────────────────────────────
1376
+
1377
+ func openTerminal(command: String) {
1378
+ let escaped = command.replacingOccurrences(of: "\\\\", with: "\\\\\\\\")
1379
+ .replacingOccurrences(of: "\\"", with: "\\\\\\"")
1380
+ let script = "tell application \\"Terminal\\" to do script \\"\\(escaped)\\""
1381
+ let p = Process()
1382
+ p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
1383
+ p.arguments = ["-e", script]
1384
+ try? p.run()
1385
+ }
1386
+
1387
+ // ── App setup ─────────────────────────────────────────────────────────────────
1388
+
1389
+ let app = NSApplication.shared
1390
+ app.setActivationPolicy(.accessory)
1391
+
1392
+ let bar = NSStatusBar.system
1393
+ let trayItem = bar.statusItem(withLength: NSStatusItem.squareLength)
1394
+
1395
+ if !iconPath.isEmpty, let img = NSImage(contentsOfFile: iconPath) {
1396
+ img.size = NSSize(width: 18, height: 18)
1397
+ img.isTemplate = false
1398
+ trayItem.button?.image = img
1399
+ } else {
1400
+ trayItem.button?.title = "F"
1401
+ }
1402
+
1403
+ // ── Menu skeleton ─────────────────────────────────────────────────────────────
1404
+
1405
+ let menu = NSMenu()
1406
+
1407
+ let statusMenuItem = NSMenuItem(title: "Checking…", action: nil, keyEquivalent: "")
1408
+ statusMenuItem.isEnabled = false
1409
+ menu.addItem(statusMenuItem)
1410
+
1411
+ menu.addItem(NSMenuItem.separator())
1412
+
1413
+ let startItem = NSMenuItem(title: "Start", action: #selector(AppDelegate.startStack), keyEquivalent: "")
1414
+ let stopItem = NSMenuItem(title: "Stop", action: #selector(AppDelegate.stopStack), keyEquivalent: "")
1415
+ let restartItem = NSMenuItem(title: "Restart", action: #selector(AppDelegate.restartStack), keyEquivalent: "")
1416
+ menu.addItem(startItem)
1417
+ menu.addItem(stopItem)
1418
+ menu.addItem(restartItem)
1419
+
1420
+ menu.addItem(NSMenuItem.separator())
1421
+
1422
+ // Compose > (services populated dynamically)
1423
+ let composeItem = NSMenuItem(title: "Compose", action: nil, keyEquivalent: "")
1424
+ let composeMenu = NSMenu(title: "Compose")
1425
+ composeItem.submenu = composeMenu
1426
+ menu.addItem(composeItem)
1427
+
1428
+ // Plugins > (populated dynamically)
1429
+ let pluginsItem = NSMenuItem(title: "Plugins", action: nil, keyEquivalent: "")
1430
+ let pluginsMenu = NSMenu(title: "Plugins")
1431
+ pluginsItem.submenu = pluginsMenu
1432
+ menu.addItem(pluginsItem)
1433
+
1434
+ menu.addItem(NSMenuItem.separator())
1435
+
1436
+ let openItem = NSMenuItem(title: "Open Foundation…", action: #selector(AppDelegate.openBrowser), keyEquivalent: "o")
1437
+ menu.addItem(openItem)
1438
+
1439
+ // Frontend mode toggle — title updated dynamically in refresh()
1440
+ let frontendModeItem = NSMenuItem(title: "Frontend: checking…", action: #selector(AppDelegate.switchFrontendMode), keyEquivalent: "")
1441
+ menu.addItem(frontendModeItem)
1442
+
1443
+ let doctorItem = NSMenuItem(title: "Doctor", action: #selector(AppDelegate.runDoctor), keyEquivalent: "")
1444
+ menu.addItem(doctorItem)
1445
+
1446
+ menu.addItem(NSMenuItem.separator())
1447
+
1448
+ // Update item — hidden until a newer version is detected
1449
+ let updateItem = NSMenuItem(title: "", action: #selector(AppDelegate.runUpdate), keyEquivalent: "")
1450
+ updateItem.isHidden = true
1451
+ menu.addItem(updateItem)
1452
+
1453
+ menu.addItem(NSMenuItem.separator())
1454
+ menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
1455
+
1456
+ trayItem.menu = menu
1457
+
1458
+ // ── AppDelegate ───────────────────────────────────────────────────────────────
1459
+
1460
+ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1461
+ var running = false
1462
+ var busy = false
1463
+
1464
+ func applicationDidFinishLaunching(_ n: Notification) {
1465
+ composeMenu.delegate = self
1466
+ pluginsMenu.delegate = pluginsMenuDelegate
1467
+ refresh()
1468
+ let pollTimer = Timer(timeInterval: 8, repeats: true) { _ in self.refresh() }
1469
+ RunLoop.main.add(pollTimer, forMode: .common)
1470
+ // Check for updates on launch, then every hour
1471
+ checkForUpdate()
1472
+ let updateTimer = Timer(timeInterval: 3600, repeats: true) { _ in self.checkForUpdate() }
1473
+ RunLoop.main.add(updateTimer, forMode: .common)
1474
+ }
1475
+
1476
+ func checkForUpdate() {
1477
+ guard let url = URL(string: "https://registry.npmjs.org/@meshxdata/fops/latest") else { return }
1478
+ let task = URLSession.shared.dataTask(with: url) { data, _, _ in
1479
+ guard let data = data,
1480
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1481
+ let latest = json["version"] as? String else { return }
1482
+ DispatchQueue.main.async {
1483
+ if self.isNewer(latest, than: fopsVersion) {
1484
+ updateItem.title = "⬆ Update available: v\\(latest)"
1485
+ updateItem.isHidden = false
1486
+ // Also pulse the tray button once to draw attention
1487
+ self.pulseUpdateBadge()
1488
+ } else {
1489
+ updateItem.isHidden = true
1490
+ }
1491
+ }
1492
+ }
1493
+ task.resume()
1494
+ }
1495
+
1496
+ // Simple semver comparison: returns true if a > b
1497
+ func isNewer(_ a: String, than b: String) -> Bool {
1498
+ let av = a.split(separator: ".").compactMap { Int($0) }
1499
+ let bv = b.split(separator: ".").compactMap { Int($0) }
1500
+ for i in 0..<max(av.count, bv.count) {
1501
+ let x = i < av.count ? av[i] : 0
1502
+ let y = i < bv.count ? bv[i] : 0
1503
+ if x != y { return x > y }
1504
+ }
1505
+ return false
1506
+ }
1507
+
1508
+ func pulseUpdateBadge() {
1509
+ var count = 0
1510
+ let pulse = Timer(timeInterval: 0.4, repeats: true) { t in
1511
+ trayItem.button?.title = count % 2 == 0 ? "⬆" : ""
1512
+ if !iconPath.isEmpty, let img = NSImage(contentsOfFile: iconPath) {
1513
+ img.size = NSSize(width: 18, height: 18)
1514
+ trayItem.button?.image = count % 2 == 0 ? nil : img
1515
+ }
1516
+ count += 1
1517
+ if count >= 6 { // 3 flashes
1518
+ t.invalidate()
1519
+ trayItem.button?.title = ""
1520
+ if !iconPath.isEmpty, let img = NSImage(contentsOfFile: iconPath) {
1521
+ img.size = NSSize(width: 18, height: 18)
1522
+ trayItem.button?.image = img
1523
+ }
1524
+ }
1525
+ }
1526
+ RunLoop.main.add(pulse, forMode: .common)
1527
+ }
1528
+
1529
+ @objc func runUpdate() {
1530
+ let npm = "/opt/homebrew/bin/npm"
1531
+ openTerminal(command: npm + " install -g @meshxdata/fops")
1532
+ }
1533
+
1534
+ // Rebuild Compose submenu each time it opens
1535
+ func menuWillOpen(_ m: NSMenu) {
1536
+ guard m === composeMenu else { return }
1537
+ m.removeAllItems()
1538
+ let loading = NSMenuItem(title: "Loading services…", action: nil, keyEquivalent: "")
1539
+ loading.isEnabled = false
1540
+ m.addItem(loading)
1541
+
1542
+ compose(["ps", "--format", "json"], capture: true) { out, _ in
1543
+ m.removeAllItems()
1544
+ let services = parseServices(out)
1545
+ if services.isEmpty {
1546
+ let none = NSMenuItem(title: "No services found", action: nil, keyEquivalent: "")
1547
+ none.isEnabled = false
1548
+ m.addItem(none)
1549
+ return
1550
+ }
1551
+ for svc in services {
1552
+ let dot = svc.state == "running" ? "● " : "○ "
1553
+ let svcItem = NSMenuItem(title: dot + svc.label, action: nil, keyEquivalent: "")
1554
+ let sub = NSMenu()
1555
+
1556
+ let logsItem = NSMenuItem(title: "Logs", action: #selector(AppDelegate.noop), keyEquivalent: "")
1557
+ logsItem.representedObject = svc.name
1558
+ logsItem.target = self
1559
+ logsItem.action = #selector(AppDelegate.openLogs(_:))
1560
+ sub.addItem(logsItem)
1561
+
1562
+ let startSvc = NSMenuItem(title: "Start", action: #selector(AppDelegate.startService(_:)), keyEquivalent: "")
1563
+ startSvc.representedObject = svc.name
1564
+ startSvc.target = self
1565
+ startSvc.isEnabled = svc.state != "running"
1566
+ sub.addItem(startSvc)
1567
+
1568
+ let stopSvc = NSMenuItem(title: "Stop", action: #selector(AppDelegate.stopService(_:)), keyEquivalent: "")
1569
+ stopSvc.representedObject = svc.name
1570
+ stopSvc.target = self
1571
+ stopSvc.isEnabled = svc.state == "running"
1572
+ sub.addItem(stopSvc)
1573
+
1574
+ let restartSvc = NSMenuItem(title: "Restart", action: #selector(AppDelegate.restartService(_:)), keyEquivalent: "")
1575
+ restartSvc.representedObject = svc.name
1576
+ restartSvc.target = self
1577
+ sub.addItem(restartSvc)
1578
+
1579
+ let buildItem = NSMenuItem(title: "Build", action: #selector(AppDelegate.buildService(_:)), keyEquivalent: "")
1580
+ buildItem.representedObject = svc.name
1581
+ buildItem.target = self
1582
+ sub.addItem(buildItem)
1583
+
1584
+ let execItem = NSMenuItem(title: "Exec Shell", action: #selector(AppDelegate.execService(_:)), keyEquivalent: "")
1585
+ execItem.representedObject = svc.name
1586
+ execItem.target = self
1587
+ sub.addItem(execItem)
1588
+
1589
+ // Switch Branch submenu — populated lazily when opened
1590
+ let fm = FileManager.default
1591
+ if !svc.dir.isEmpty {
1592
+ sub.addItem(NSMenuItem.separator())
1593
+ let branchParent = NSMenuItem(title: "Switch Branch", action: nil, keyEquivalent: "")
1594
+ let branchMenu = NSMenu()
1595
+ let branchDelegate = BranchMenuDelegate(dir: svc.dir, svc: svc.name)
1596
+ branchMenu.delegate = branchDelegate
1597
+ branchParent.submenu = branchMenu
1598
+ // Keep delegate alive via the menu item
1599
+ branchParent.representedObject = branchDelegate
1600
+ sub.addItem(branchParent)
1601
+ }
1602
+
1603
+ svcItem.submenu = sub
1604
+ m.addItem(svcItem)
1605
+ }
1606
+ }
1607
+ }
1608
+
1609
+ @objc func noop() {}
1610
+
1611
+ // ── Stack-level actions ───────────────────────────────────────────────────
1612
+
1613
+ func refresh() {
1614
+ isRunning { up in
1615
+ self.running = up
1616
+ statusMenuItem.title = up ? "● Running" : "○ Stopped"
1617
+ startItem.isEnabled = !up && !self.busy
1618
+ stopItem.isEnabled = up && !self.busy
1619
+ restartItem.isEnabled = up && !self.busy
1620
+ }
1621
+ self.refreshFrontendMode()
1622
+ }
1623
+
1624
+ func refreshFrontendMode() {
1625
+ compose(["ps", "--format", "json", "--status", "running"], capture: true) { out, _ in
1626
+ let services = parseServices(out)
1627
+ let names = services.map { $0.name }
1628
+ let devRunning = names.contains("foundation-frontend-dev")
1629
+ let prodRunning = names.contains("foundation-frontend")
1630
+ if devRunning {
1631
+ frontendModeItem.title = "Frontend: Dev (switch to Prod)"
1632
+ frontendModeItem.isEnabled = true
1633
+ } else if prodRunning {
1634
+ frontendModeItem.title = "Frontend: Prod (switch to Dev)"
1635
+ frontendModeItem.isEnabled = true
1636
+ } else {
1637
+ frontendModeItem.title = "Frontend: stopped"
1638
+ frontendModeItem.isEnabled = false
1639
+ }
1640
+ }
1641
+ }
1642
+
1643
+ func setBusy(_ b: Bool, label: String) {
1644
+ busy = b
1645
+ statusMenuItem.title = label
1646
+ [startItem, stopItem, restartItem].forEach { $0.isEnabled = false }
1647
+ }
1648
+
1649
+ @objc func startStack() {
1650
+ guard !busy else { return }
1651
+ setBusy(true, label: "Starting…")
1652
+ compose(["up", "-d"]) { _, _ in self.busy = false; self.refresh() }
1653
+ }
1654
+
1655
+ @objc func stopStack() {
1656
+ guard !busy else { return }
1657
+ setBusy(true, label: "Stopping…")
1658
+ compose(["down"]) { _, _ in self.busy = false; self.refresh() }
1659
+ }
1660
+
1661
+ @objc func restartStack() {
1662
+ guard !busy else { return }
1663
+ setBusy(true, label: "Restarting…")
1664
+ compose(["restart"]) { _, _ in self.busy = false; self.refresh() }
1665
+ }
1666
+
1667
+ var spinnerTimer: Timer?
1668
+ var spinnerFrame = 0
1669
+ let spinnerFrames = ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"]
1670
+
1671
+ func startSpinner(label: String) {
1672
+ frontendModeItem.isEnabled = false
1673
+ frontendModeItem.title = "Frontend: \\(label)"
1674
+ // Use AttributedTitle to render grey text
1675
+ let attrs: [NSAttributedString.Key: Any] = [
1676
+ .foregroundColor: NSColor.secondaryLabelColor
1677
+ ]
1678
+ frontendModeItem.attributedTitle = NSAttributedString(string: "Frontend: \\(label)", attributes: attrs)
1679
+ spinnerFrame = 0
1680
+ spinnerTimer?.invalidate()
1681
+ trayItem.button?.image = nil
1682
+ let t = Timer(timeInterval: 0.1, repeats: true) { _ in
1683
+ let frame = self.spinnerFrames[self.spinnerFrame % self.spinnerFrames.count]
1684
+ trayItem.button?.title = frame
1685
+ let str = NSAttributedString(string: "\\(frame) Frontend: \\(label)", attributes: attrs)
1686
+ frontendModeItem.attributedTitle = str
1687
+ self.spinnerFrame += 1
1688
+ }
1689
+ RunLoop.main.add(t, forMode: .common)
1690
+ spinnerTimer = t
1691
+ }
1692
+
1693
+ func stopSpinner() {
1694
+ spinnerTimer?.invalidate()
1695
+ spinnerTimer = nil
1696
+ frontendModeItem.attributedTitle = nil
1697
+ trayItem.button?.title = ""
1698
+ if !iconPath.isEmpty, let img = NSImage(contentsOfFile: iconPath) {
1699
+ img.size = NSSize(width: 18, height: 18)
1700
+ trayItem.button?.image = img
1701
+ } else {
1702
+ trayItem.button?.title = "F"
1703
+ }
1704
+ }
1705
+
1706
+ @objc func switchFrontendMode() {
1707
+ frontendModeItem.isEnabled = false
1708
+ compose(["ps", "--format", "json", "--status", "running"], capture: true) { out, _ in
1709
+ let names = parseServices(out).map { $0.name }
1710
+ if names.contains("foundation-frontend-dev") {
1711
+ self.startSpinner(label: "switching to Prod…")
1712
+ compose(["stop", "foundation-frontend-dev"]) { _, _ in
1713
+ run(docker(), ["compose", "--profile", "frontend-prod", "up", "-d", "foundation-frontend"]) { _, _ in
1714
+ self.stopSpinner()
1715
+ self.refreshFrontendMode()
1716
+ }
1717
+ }
1718
+ } else if names.contains("foundation-frontend") {
1719
+ self.startSpinner(label: "switching to Dev…")
1720
+ compose(["stop", "foundation-frontend"]) { _, _ in
1721
+ compose(["up", "-d", "foundation-frontend-dev"]) { _, _ in
1722
+ self.stopSpinner()
1723
+ self.refreshFrontendMode()
1724
+ }
1725
+ }
1726
+ }
1727
+ }
1728
+ }
1729
+
1730
+ @objc func openBrowser() {
1731
+ let url = ProcessInfo.processInfo.environment["FOUNDATION_URL"] ?? "http://127.0.0.1:3002"
1732
+ if let u = URL(string: url) { NSWorkspace.shared.open(u) }
1733
+ }
1734
+
1735
+ @objc func checkoutBranch(_ sender: NSMenuItem) {
1736
+ guard let arr = sender.representedObject as? [String], arr.count == 3 else { return }
1737
+ let dir = arr[0]; let svc = arr[1]; let branch = arr[2]
1738
+ run(git(), ["checkout", branch], cwd: dir) { _, ok in
1739
+ if ok { compose(["up", "-d", "--build", svc]) }
1740
+ }
1741
+ }
1742
+
1743
+ @objc func runDoctor() {
1744
+ let fops = "/opt/homebrew/bin/fops"
1745
+ let cd = composeRoot.isEmpty ? "" : "cd \\"\\(composeRoot)\\" && "
1746
+ openTerminal(command: cd + fops + " doctor")
1747
+ }
1748
+
1749
+ // ── Per-service actions ───────────────────────────────────────────────────
1750
+
1751
+ @objc func openLogs(_ sender: NSMenuItem) {
1752
+ guard let svc = sender.representedObject as? String else { return }
1753
+ let cmd = docker() + " compose" + (composeRoot.isEmpty ? "" : " --project-directory \\"\\(composeRoot)\\"") + " logs -f --tail=200 " + svc
1754
+ openTerminal(command: cmd)
1755
+ }
1756
+
1757
+ @objc func startService(_ sender: NSMenuItem) {
1758
+ guard let svc = sender.representedObject as? String else { return }
1759
+ compose(["start", svc])
1760
+ }
1761
+
1762
+ @objc func stopService(_ sender: NSMenuItem) {
1763
+ guard let svc = sender.representedObject as? String else { return }
1764
+ compose(["stop", svc])
1765
+ }
1766
+
1767
+ @objc func restartService(_ sender: NSMenuItem) {
1768
+ guard let svc = sender.representedObject as? String else { return }
1769
+ compose(["restart", svc])
1770
+ }
1771
+
1772
+ @objc func buildService(_ sender: NSMenuItem) {
1773
+ guard let svc = sender.representedObject as? String else { return }
1774
+ // svc is the docker compose service name e.g. "foundation-backend" — strip prefix for fops build target
1775
+ let target = svc.hasPrefix("foundation-") ? String(svc.dropFirst("foundation-".count)) : svc
1776
+ let fops = "/opt/homebrew/bin/fops"
1777
+ let cd = composeRoot.isEmpty ? "" : "cd \\"\\(composeRoot)\\" && "
1778
+ let cmd = cd + fops + " build " + target
1779
+ openTerminal(command: cmd)
1780
+ }
1781
+
1782
+ @objc func execService(_ sender: NSMenuItem) {
1783
+ guard let svc = sender.representedObject as? String else { return }
1784
+ let proj = composeRoot.isEmpty ? "" : " --project-directory \\"\\(composeRoot)\\""
1785
+ // Try bash first, fall back to sh
1786
+ let cmd = docker() + " compose\\(proj) exec \\(svc) bash 2>/dev/null || " + docker() + " compose\\(proj) exec \\(svc) sh"
1787
+ openTerminal(command: cmd)
1788
+ }
1789
+
1790
+ @objc func togglePlugin(_ sender: NSMenuItem) {
1791
+ guard let id = sender.representedObject as? String else { return }
1792
+ let enabled = readPluginEnabled(id)
1793
+ let subCmd = enabled ? "disable" : "enable"
1794
+ // Try homebrew path, fall back to PATH
1795
+ let fopsBin: String
1796
+ if FileManager.default.isExecutableFile(atPath: "/opt/homebrew/bin/fops") {
1797
+ fopsBin = "/opt/homebrew/bin/fops"
1798
+ } else if FileManager.default.isExecutableFile(atPath: "/usr/local/bin/fops") {
1799
+ fopsBin = "/usr/local/bin/fops"
1800
+ } else {
1801
+ fopsBin = "fops"
1802
+ }
1803
+ run(fopsBin, ["plugin", subCmd, id])
1804
+ }
1805
+
1806
+ }
1807
+
1808
+ // ── BranchMenuDelegate ────────────────────────────────────────────────────────
1809
+ // Fetches branches lazily when the submenu opens, then checks out on click.
1810
+
1811
+ class BranchMenuDelegate: NSObject, NSMenuDelegate {
1812
+ let dir: String
1813
+ let svc: String
1814
+ init(dir: String, svc: String) { self.dir = dir; self.svc = svc }
1815
+
1816
+ func menuWillOpen(_ menu: NSMenu) {
1817
+ menu.removeAllItems()
1818
+ let loading = NSMenuItem(title: "Fetching branches…", action: nil, keyEquivalent: "")
1819
+ loading.isEnabled = false
1820
+ menu.addItem(loading)
1821
+
1822
+ DispatchQueue.global().async {
1823
+ // fetch to get latest remote branches
1824
+ let fp = Process()
1825
+ fp.executableURL = URL(fileURLWithPath: git())
1826
+ fp.arguments = ["fetch", "--all", "--prune"]
1827
+ fp.currentDirectoryURL = URL(fileURLWithPath: self.dir)
1828
+ fp.standardOutput = FileHandle.nullDevice
1829
+ fp.standardError = FileHandle.nullDevice
1830
+ try? fp.run(); fp.waitUntilExit()
1831
+
1832
+ // list all branches (local + remote), deduplicated
1833
+ let bp = Process(); let pipe = Pipe()
1834
+ bp.executableURL = URL(fileURLWithPath: git())
1835
+ bp.arguments = ["branch", "-a", "--format=%(refname:short)"]
1836
+ bp.currentDirectoryURL = URL(fileURLWithPath: self.dir)
1837
+ bp.standardOutput = pipe
1838
+ bp.standardError = FileHandle.nullDevice
1839
+ try? bp.run(); bp.waitUntilExit()
1840
+ let raw = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
1841
+
1842
+ // Current branch — use rev-parse so detached HEAD returns "HEAD"
1843
+ let cb = Process(); let cbPipe = Pipe()
1844
+ cb.executableURL = URL(fileURLWithPath: git())
1845
+ cb.arguments = ["rev-parse", "--abbrev-ref", "HEAD"]
1846
+ cb.currentDirectoryURL = URL(fileURLWithPath: self.dir)
1847
+ cb.standardOutput = cbPipe
1848
+ cb.standardError = FileHandle.nullDevice
1849
+ try? cb.run(); cb.waitUntilExit()
1850
+ let current = (String(data: cbPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "")
1851
+ .trimmingCharacters(in: .whitespacesAndNewlines)
1852
+ let isDetached = current == "HEAD" || current.isEmpty
1853
+
1854
+ // If detached, find which branches contain the current commit
1855
+ var containsBranches = Set<String>()
1856
+ if isDetached {
1857
+ let cp = Process(); let cpipe = Pipe()
1858
+ cp.executableURL = URL(fileURLWithPath: git())
1859
+ cp.arguments = ["branch", "-a", "--contains", "HEAD", "--format=%(refname:short)"]
1860
+ cp.currentDirectoryURL = URL(fileURLWithPath: self.dir)
1861
+ cp.standardOutput = cpipe
1862
+ cp.standardError = FileHandle.nullDevice
1863
+ try? cp.run(); cp.waitUntilExit()
1864
+ let cout = String(data: cpipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
1865
+ for line in cout.components(separatedBy: "\\n") {
1866
+ var b = line.trimmingCharacters(in: .whitespacesAndNewlines)
1867
+ if b.isEmpty || b.contains("HEAD") { continue }
1868
+ if b.hasPrefix("origin/") { b = String(b.dropFirst("origin/".count)) }
1869
+ containsBranches.insert(b)
1870
+ }
1871
+ }
1872
+
1873
+ // Deduplicate: strip "origin/" prefix, ignore HEAD refs
1874
+ var seen = Set<String>()
1875
+ var branches: [String] = []
1876
+ for line in raw.components(separatedBy: "\\n") {
1877
+ var b = line.trimmingCharacters(in: .whitespacesAndNewlines)
1878
+ if b.isEmpty || b.contains("HEAD") { continue }
1879
+ if b.hasPrefix("origin/") { b = String(b.dropFirst("origin/".count)) }
1880
+ if seen.insert(b).inserted { branches.append(b) }
1881
+ }
1882
+ branches.sort()
1883
+
1884
+ DispatchQueue.main.async {
1885
+ menu.removeAllItems()
1886
+
1887
+ // Show detached HEAD notice
1888
+ if isDetached {
1889
+ let h = NSMenuItem(title: "⚠ Detached HEAD", action: nil, keyEquivalent: "")
1890
+ h.isEnabled = false
1891
+ menu.addItem(h)
1892
+ menu.addItem(NSMenuItem.separator())
1893
+ }
1894
+
1895
+ if branches.isEmpty {
1896
+ let none = NSMenuItem(title: "No branches found", action: nil, keyEquivalent: "")
1897
+ none.isEnabled = false
1898
+ menu.addItem(none)
1899
+ return
1900
+ }
1901
+ for branch in branches {
1902
+ let isCurrent = !isDetached && branch == current
1903
+ let isPinned = isDetached && containsBranches.contains(branch)
1904
+ let prefix = isCurrent ? "✓ " : (isPinned ? "◆ " : " ")
1905
+ let item = NSMenuItem(title: prefix + branch, action: #selector(AppDelegate.checkoutBranch(_:)), keyEquivalent: "")
1906
+ item.representedObject = [self.dir, self.svc, branch] as AnyObject
1907
+ item.target = NSApp.delegate as? AppDelegate
1908
+ item.isEnabled = !isCurrent
1909
+ menu.addItem(item)
1910
+ }
1911
+ }
1912
+ }
1913
+ }
1914
+ }
1915
+
1916
+ // ── PluginMenuDelegate ────────────────────────────────────────────────────────
1917
+ // Populates the Plugins submenu lazily; clicking a plugin item toggles it.
1918
+
1919
+ class PluginMenuDelegate: NSObject, NSMenuDelegate {
1920
+ func menuWillOpen(_ menu: NSMenu) {
1921
+ menu.removeAllItems()
1922
+ if allPlugins.isEmpty {
1923
+ let none = NSMenuItem(title: "No plugins installed", action: nil, keyEquivalent: "")
1924
+ none.isEnabled = false
1925
+ menu.addItem(none)
1926
+ return
1927
+ }
1928
+ for plugin in allPlugins {
1929
+ let enabled = readPluginEnabled(plugin.id)
1930
+ let prefix = enabled ? "✓ " : " "
1931
+ let item = NSMenuItem(
1932
+ title: prefix + plugin.name,
1933
+ action: #selector(AppDelegate.togglePlugin(_:)),
1934
+ keyEquivalent: ""
1935
+ )
1936
+ item.representedObject = plugin.id as AnyObject
1937
+ item.target = NSApp.delegate as? AppDelegate
1938
+ menu.addItem(item)
1939
+ }
1940
+ }
1941
+ }
1942
+
1943
+ let pluginsMenuDelegate = PluginMenuDelegate()
1944
+
1945
+ let delegate = AppDelegate()
1946
+ app.delegate = delegate
1947
+ app.run()
1948
+ `);
1949
+
1950
+ const trayEnv = {
1951
+ ...process.env,
1952
+ FOUNDATION_API_URL: apiUrl,
1953
+ FOUNDATION_URL: "http://127.0.0.1:3002",
1954
+ FOUNDATION_ICON: iconPath,
1955
+ FOUNDATION_COMPOSE_ROOT: composeRoot,
1956
+ FOPS_VERSION: fopsVersion,
1957
+ FOPS_PLUGINS: fopsPluginsJson,
1958
+ };
1959
+
1960
+ console.log(ACCENT(" Foundation tray started — look for the icon in your menu bar"));
1961
+
1962
+ const child = spawn("swift", [swiftTray], {
1963
+ stdio: "ignore",
1964
+ detached: true,
1965
+ env: trayEnv,
1966
+ });
1967
+ child.unref();
1968
+ });
257
1969
  });
258
1970
 
259
1971
  // ─── Landscape cache state (used by tools + service) ────────────────────
@@ -479,6 +2191,7 @@ foundation_entity is only for: SQL query action, or if foundation_graphql return
479
2191
  "foundation_iam",
480
2192
  "foundation_iam_manage",
481
2193
  "foundation_graphql",
2194
+ "foundation_align",
482
2195
  "memory",
483
2196
  ],
484
2197
  maxIterations: resolveFoundationMaxIterations(),