@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.
- package/CHANGELOG.md +372 -0
- package/package.json +1 -1
- package/src/commands/lifecycle.js +16 -0
- package/src/electron/icon.png +0 -0
- package/src/electron/main.js +24 -0
- package/src/plugins/bundled/fops-plugin-embeddings/index.js +9 -0
- package/src/plugins/bundled/fops-plugin-embeddings/lib/indexer.js +1 -1
- package/src/plugins/bundled/fops-plugin-file/demo/landscape.yaml +67 -0
- package/src/plugins/bundled/fops-plugin-file/demo/orders_bad.csv +6 -0
- package/src/plugins/bundled/fops-plugin-file/demo/orders_good.csv +7 -0
- package/src/plugins/bundled/fops-plugin-file/demo/orders_reference.csv +6 -0
- package/src/plugins/bundled/fops-plugin-file/demo/orders_renamed.aligned.csv +6 -0
- package/src/plugins/bundled/fops-plugin-file/demo/orders_renamed.csv +6 -0
- package/src/plugins/bundled/fops-plugin-file/demo/rules.json +8 -0
- package/src/plugins/bundled/fops-plugin-file/demo/run.sh +110 -0
- package/src/plugins/bundled/fops-plugin-file/index.js +140 -24
- package/src/plugins/bundled/fops-plugin-file/lib/embed-index.js +7 -0
- package/src/plugins/bundled/fops-plugin-file/lib/match.js +11 -4
- package/src/plugins/bundled/fops-plugin-foundation/index.js +1715 -2
- package/src/plugins/bundled/fops-plugin-foundation/lib/align.js +183 -0
- package/src/plugins/bundled/fops-plugin-foundation/lib/apply.js +83 -41
- package/src/plugins/bundled/fops-plugin-foundation/lib/client.js +40 -4
- package/src/plugins/bundled/fops-plugin-foundation/lib/stack-apply.js +4 -1
- package/src/plugins/bundled/fops-plugin-foundation/lib/tools-write.js +46 -0
- package/src/plugins/bundled/fops-plugin-foundation-graphql/index.js +39 -1
- package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/resolvers/data-object.js +9 -6
- 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
|
|
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(),
|