@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.
- package/CHANGELOG.md +372 -0
- package/package.json +1 -2
- package/src/agent/llm.js +3 -3
- package/src/commands/lifecycle.js +16 -0
- package/src/plugins/bundled/fops-plugin-dai-ttyd/fops.plugin.json +6 -0
- package/src/plugins/bundled/fops-plugin-dai-ttyd/index.js +182 -0
- package/src/plugins/bundled/fops-plugin-dai-ttyd/lib/client.js +164 -0
- package/src/plugins/bundled/fops-plugin-dai-ttyd/package.json +1 -0
- package/src/plugins/bundled/fops-plugin-embeddings/index.js +3 -1
- 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 +1574 -101
- package/src/plugins/bundled/fops-plugin-foundation/lib/align.js +42 -4
- package/src/plugins/bundled/fops-plugin-foundation/lib/apply.js +83 -41
- package/src/plugins/bundled/fops-plugin-foundation/lib/stack-apply.js +4 -1
- 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
- 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
|
|
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
|
|
314
|
+
for (const file of sourceCsvFiles) {
|
|
283
315
|
try {
|
|
284
|
-
const firstLine = readFileSync(
|
|
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
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
console.error(ERR(" ✗
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
390
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
429
|
-
const {
|
|
430
|
-
const {
|
|
431
|
-
const {
|
|
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
|
-
|
|
434
|
-
let iconPath;
|
|
747
|
+
// Resolve icon from the fops install
|
|
748
|
+
let iconPath = "";
|
|
435
749
|
try {
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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
|
-
|
|
459
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
});
|