@meshxdata/fops 0.1.32 → 0.1.35

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.
Files changed (29) hide show
  1. package/CHANGELOG.md +372 -0
  2. package/package.json +1 -2
  3. package/src/agent/llm.js +3 -3
  4. package/src/commands/lifecycle.js +16 -0
  5. package/src/plugins/bundled/fops-plugin-dai-ttyd/fops.plugin.json +6 -0
  6. package/src/plugins/bundled/fops-plugin-dai-ttyd/index.js +182 -0
  7. package/src/plugins/bundled/fops-plugin-dai-ttyd/lib/client.js +164 -0
  8. package/src/plugins/bundled/fops-plugin-dai-ttyd/package.json +1 -0
  9. package/src/plugins/bundled/fops-plugin-embeddings/index.js +3 -1
  10. package/src/plugins/bundled/fops-plugin-embeddings/lib/indexer.js +1 -1
  11. package/src/plugins/bundled/fops-plugin-file/demo/landscape.yaml +67 -0
  12. package/src/plugins/bundled/fops-plugin-file/demo/orders_bad.csv +6 -0
  13. package/src/plugins/bundled/fops-plugin-file/demo/orders_good.csv +7 -0
  14. package/src/plugins/bundled/fops-plugin-file/demo/orders_reference.csv +6 -0
  15. package/src/plugins/bundled/fops-plugin-file/demo/orders_renamed.aligned.csv +6 -0
  16. package/src/plugins/bundled/fops-plugin-file/demo/orders_renamed.csv +6 -0
  17. package/src/plugins/bundled/fops-plugin-file/demo/rules.json +8 -0
  18. package/src/plugins/bundled/fops-plugin-file/demo/run.sh +110 -0
  19. package/src/plugins/bundled/fops-plugin-file/index.js +140 -24
  20. package/src/plugins/bundled/fops-plugin-file/lib/embed-index.js +7 -0
  21. package/src/plugins/bundled/fops-plugin-file/lib/match.js +11 -4
  22. package/src/plugins/bundled/fops-plugin-foundation/index.js +1574 -101
  23. package/src/plugins/bundled/fops-plugin-foundation/lib/align.js +42 -4
  24. package/src/plugins/bundled/fops-plugin-foundation/lib/apply.js +83 -41
  25. package/src/plugins/bundled/fops-plugin-foundation/lib/stack-apply.js +4 -1
  26. package/src/plugins/bundled/fops-plugin-foundation-graphql/index.js +39 -1
  27. package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/resolvers/data-object.js +9 -6
  28. package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/resolvers/data-product.js +9 -6
  29. package/src/ui/tui/App.js +1 -1
@@ -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
 
@@ -260,28 +280,40 @@ export function register(api) {
260
280
  .command("align <source> [target]")
261
281
  .description("Align source column names to target schema columns (exact → embedding → levenshtein)")
262
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)")
263
290
  .option("--json", "Output raw JSON")
264
291
  .action(async (source, target, opts) => {
265
292
  const { alignColumns } = await import("./lib/align.js");
266
293
  const { fetchEntityColumns } = await import("./lib/tools-write.js");
267
- const { statSync, readdirSync, readFileSync } = await import("node:fs");
294
+ const { statSync, readdirSync, readFileSync, writeFileSync } = await import("node:fs");
268
295
  const threshold = Number.parseFloat(opts.threshold) || 0.7;
296
+ const dropUnmappedSource = !!opts.dropUnmappedSource;
297
+ const fillMissingExpected = !!opts.fillMissingExpected;
269
298
 
270
299
  // Resolve source columns — directory of CSVs, comma-separated names, or entity identifier
271
300
  const isDirectory = (s) => { try { return statSync(s).isDirectory(); } catch { return false; } };
301
+ const isFile = (s) => { try { return statSync(s).isFile(); } catch { return false; } };
272
302
  const isIdentifier = (s) => s && !/,/.test(s) && !/\s/.test(s.trim());
273
303
 
274
304
  let sourceCols;
305
+ let sourceCsvFiles = []; // track actual CSV paths for --output
275
306
  if (isDirectory(source)) {
276
307
  const csvFiles = readdirSync(source).filter((f) => /\.csv$/i.test(f));
277
308
  if (csvFiles.length === 0) {
278
309
  console.error(ERR(` ✗ No CSV files found in: ${source}`));
279
310
  process.exit(1);
280
311
  }
312
+ sourceCsvFiles = csvFiles.map((f) => path.join(source, f));
281
313
  const seen = new Set();
282
- for (const file of csvFiles) {
314
+ for (const file of sourceCsvFiles) {
283
315
  try {
284
- const firstLine = readFileSync(path.join(source, file), "utf8").split("\n")[0]?.trim();
316
+ const firstLine = readFileSync(file, "utf8").split("\n")[0]?.trim();
285
317
  if (!firstLine) continue;
286
318
  for (const col of firstLine.split(",").map((c) => c.trim().replace(/^["']|["']$/g, ""))) {
287
319
  if (col) seen.add(col);
@@ -290,6 +322,11 @@ export function register(api) {
290
322
  }
291
323
  sourceCols = [...seen];
292
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`));
293
330
  } else if (isIdentifier(source)) {
294
331
  sourceCols = await fetchEntityColumns(client, source.trim(), "data_object");
295
332
  } else {
@@ -305,50 +342,161 @@ export function register(api) {
305
342
  try {
306
343
  embSvc = typeof api.getService === "function" ? api.getService("embeddings:search") : null;
307
344
  } catch { /* embeddings not available */ }
308
- const embedTexts = embSvc?.embedTexts ? embSvc.embedTexts.bind(embSvc) : null;
345
+ const embedTexts = (!opts.noSemantic && embSvc?.embedTexts) ? embSvc.embedTexts.bind(embSvc) : null;
309
346
 
310
- // Resolve target — explicit arg, or auto-infer from landscape
347
+ // Resolve target — explicit arg, or auto-infer by schema column overlap
311
348
  let targetCols;
312
349
  let resolvedTarget = target;
313
350
  if (!target) {
314
- // Try embedding search first: query = unique source column names joined
315
- const query = sourceCols.join(" ");
316
351
  let dpIdentifier = null;
317
352
  let dpName = null;
318
- if (embSvc?.search && embSvc.hasEntries("landscape")) {
319
- try {
320
- const hits = await embSvc.search(query, { topK: 10, source: "landscape" });
321
- const dpHit = hits.find((h) => h.metadata?.entityType === "data_product");
322
- if (dpHit) {
323
- dpIdentifier = dpHit.metadata.identifier;
324
- dpName = dpHit.title?.replace(/^product\//, "") || dpIdentifier;
325
- }
326
- } catch { /* fall through */ }
327
- }
328
- // Fallback: list all data products, auto-select if only one
329
- if (!dpIdentifier) {
330
- try {
331
- const res = await client.get("/data/data_product/list?per_page=100");
332
- const products = res?.data || res?.items || res || [];
333
- if (products.length === 1) {
334
- dpIdentifier = products[0].identifier;
335
- dpName = products[0].name || dpIdentifier;
336
- } else if (products.length > 1) {
337
- console.error(ERR(" ✗ Multiple data products found — specify a target:"));
338
- for (const p of products) console.error(DIM(` ${p.identifier} ${p.name || ""}`));
339
- process.exit(1);
340
- } else {
341
- console.error(ERR(" ✗ No data products found in the landscape. Specify a target explicitly."));
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."));
342
377
  process.exit(1);
343
378
  }
344
- } catch (e) {
345
- console.error(ERR(` ✗ Could not list data products: ${e.message}`));
346
- process.exit(1);
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;
347
491
  }
492
+ } catch (e) {
493
+ console.error(ERR(` ✗ Could not list data products: ${e.message}`));
494
+ process.exitCode = 1;
495
+ return;
348
496
  }
349
497
  console.log(DIM(` Auto-selected target: ${dpName} (${dpIdentifier})`));
350
498
  resolvedTarget = dpIdentifier;
351
- targetCols = await fetchEntityColumns(client, dpIdentifier, "data_product");
499
+ if (!targetCols) targetCols = await fetchEntityColumns(client, dpIdentifier, "data_product");
352
500
  } else if (isIdentifier(target)) {
353
501
  targetCols = await fetchEntityColumns(client, target.trim(), "data_product");
354
502
  } else {
@@ -361,42 +509,207 @@ export function register(api) {
361
509
 
362
510
  const result = await alignColumns(sourceCols, targetCols, { embedTexts, threshold });
363
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
+
364
521
  if (opts.json) {
365
522
  console.log(JSON.stringify(result, null, 2));
366
523
  return;
367
524
  }
368
525
 
369
- // Formatted table output
526
+ // ── Formatted table output ────────────────────────────────────────────
370
527
  const title = `Column Alignment: ${source} → ${resolvedTarget}`;
371
528
  banner(title);
372
529
  console.log();
373
530
 
374
- if (result.mappings.length === 0) {
375
- console.log(WARN(" No mappings found above threshold."));
376
- } else {
377
- const COL_W = 20, EXP_W = 24, MET_W = 12;
378
- console.log(DIM(` ${"Source".padEnd(COL_W)} ${"Expected".padEnd(EXP_W)} ${"Method".padEnd(MET_W)} Confidence`));
379
- console.log(DIM(" " + "".repeat(COL_W + EXP_W + MET_W + 20)));
380
- for (const m of result.mappings) {
381
- const conf = `${Math.round(m.confidence * 100)}%`;
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) {
382
542
  const methodColor = m.method === "exact" ? OK : m.method === "embedding" ? ACCENT : WARN;
383
543
  console.log(
384
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}`,
385
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
+ ));
386
550
  }
387
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
+ }
388
556
 
389
- if (result.unmappedSource.length > 0) {
390
- console.log(WARN(`\n Unmapped source: ${result.unmappedSource.join(", ")}`));
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.`));
391
562
  }
392
- if (result.unmappedExpected.length > 0) {
393
- console.log(WARN(` Unmapped expected: ${result.unmappedExpected.join(", ")}`));
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(", "))}`);
394
567
  }
395
568
 
396
- if (result.suggestedTransformation) {
569
+ if (Object.keys(result.suggestedTransformation.mapping).length > 0) {
397
570
  console.log(ACCENT("\n Suggested rename_column step:"));
398
571
  console.log(` ${JSON.stringify(result.suggestedTransformation.mapping, null, 2).replace(/\n/g, "\n ")}`);
399
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
+
400
713
  console.log();
401
714
  });
402
715
 
@@ -406,7 +719,6 @@ export function register(api) {
406
719
  .description("Open the Foundation UI in an Electron window")
407
720
  .option("--url <url>", "Override the frontend URL")
408
721
  .action(async (opts) => {
409
- const { createRequire } = await import("node:module");
410
722
  const { spawn } = await import("node:child_process");
411
723
  const { readFileSync } = await import("node:fs");
412
724
  const { findComposeRoot } = await import("./lib/tools-write.js");
@@ -425,71 +737,1232 @@ export function register(api) {
425
737
  }
426
738
  frontendUrl = frontendUrl || "http://127.0.0.1:3002";
427
739
 
428
- // Resolve electron binary + fops root (via realpathSync to follow node_modules symlink)
429
- const { writeFileSync, realpathSync } = await import("node:fs");
430
- const { tmpdir } = await import("node:os");
431
- const { join, resolve, dirname } = await import("node:path");
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() || "";
432
746
 
433
- let electronBin;
434
- let iconPath;
747
+ // Resolve icon from the fops install
748
+ let iconPath = "";
435
749
  try {
436
- const req = createRequire(import.meta.url);
437
- electronBin = req("electron");
438
- // Resolve real path of electron pkg to find the fops install root
439
- const realElectronPkg = realpathSync(req.resolve("electron/package.json"));
440
- const fopsRoot = resolve(dirname(realElectronPkg), "../..");
441
- iconPath = join(fopsRoot, "src/electron/icon.png");
442
- } catch {
443
- console.error(ERR(" ✗ Electron is not installed. Run: npm install -g electron"));
444
- process.exit(1);
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)
445
928
  }
929
+ default: break
930
+ }
931
+ }
446
932
 
447
- // Write main script to a temp file — the plugin runs from ~/.fops/plugins/
448
- // so relative path resolution from import.meta.url is not reliable
449
- const mainScript = join(tmpdir(), "fops-electron-main.js");
450
- writeFileSync(mainScript, `
451
- const { app, BrowserWindow, nativeImage } = require("electron");
452
- const url = process.env.FOUNDATION_URL || "http://127.0.0.1:3002";
453
- const iconPath = process.env.FOUNDATION_ICON;
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
+ }
454
957
 
455
- // Enable macOS platform authenticator (passkeys + TouchID)
456
- app.commandLine.appendSwitch("enable-features", "WebAuthenticationTouchId,WebAuthenticationCable");
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
+ `);
457
971
 
458
- app.whenReady().then(() => {
459
- if (iconPath) {
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 }
460
1072
  try {
461
- const icon = nativeImage.createFromPath(iconPath);
462
- if (process.platform === "darwin") app.dock.setIcon(icon);
463
- } catch {}
464
- }
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
+ }
465
1079
 
466
- const win = new BrowserWindow({
467
- width: 1400, height: 900, title: "Foundation",
468
- icon: iconPath || undefined,
469
- webPreferences: {
470
- nodeIntegration: false,
471
- contextIsolation: true,
472
- },
473
- });
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)
474
1123
 
475
- // Allow Google OAuth and other auth popups to open
476
- win.webContents.setWindowOpenHandler(({ url: popupUrl }) => {
477
- return { action: "allow" };
478
- });
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
479
1128
 
480
- win.loadURL(url);
481
- win.setMenuBarVisibility(false);
482
- });
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
483
1131
 
484
- app.on("window-all-closed", () => app.quit());
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
485
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
+ }
486
1233
 
487
- console.log(ACCENT(` Opening Foundation at ${frontendUrl}`));
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"));
488
1961
 
489
- const child = spawn(electronBin, [mainScript], {
1962
+ const child = spawn("swift", [swiftTray], {
490
1963
  stdio: "ignore",
491
- env: { ...process.env, FOUNDATION_URL: frontendUrl, FOUNDATION_ICON: iconPath || "" },
492
1964
  detached: true,
1965
+ env: trayEnv,
493
1966
  });
494
1967
  child.unref();
495
1968
  });