m8flow 1.1.5 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib/logger.js +84 -30
  2. package/lib/run.js +67 -69
  3. package/package.json +1 -1
package/lib/logger.js CHANGED
@@ -1,19 +1,25 @@
1
1
  /**
2
- * Unified logger — wraps chalk + ora so every other module
3
- * imports from one place and the style stays consistent.
2
+ * Unified logger — consistent symbol language, correct box alignment.
3
+ *
4
+ * Symbol convention:
5
+ * ✔ success / complete
6
+ * ℹ info / neutral
7
+ * ⚠ warning
8
+ * ✖ error / fatal
9
+ * spinning dots — active task (ora)
4
10
  */
5
11
  import chalk from 'chalk';
6
12
  import ora from 'ora';
7
13
 
8
14
  // ── Palette ───────────────────────────────────────────────────────────────────
9
15
  const c = {
10
- brand: chalk.hex('#6366f1'), // indigo — M8Flow brand colour
11
- ok: chalk.hex('#22c55e'), // green
12
- warn: chalk.hex('#f59e0b'), // amber
13
- error: chalk.hex('#ef4444'), // red
14
- dim: chalk.dim,
15
- bold: chalk.bold,
16
- white: chalk.white,
16
+ brand: chalk.hex('#6366f1'),
17
+ ok: chalk.hex('#22c55e'),
18
+ warn: chalk.hex('#f59e0b'),
19
+ error: chalk.hex('#ef4444'),
20
+ info: chalk.hex('#38bdf8'),
21
+ dim: chalk.dim,
22
+ bold: chalk.bold,
17
23
  };
18
24
 
19
25
  // ── Symbols ───────────────────────────────────────────────────────────────────
@@ -21,10 +27,23 @@ export const sym = {
21
27
  ok: c.ok('✔'),
22
28
  warn: c.warn('⚠'),
23
29
  error: c.error('✖'),
30
+ info: c.info('ℹ'),
24
31
  arrow: c.brand('›'),
25
- dot: c.dim('·'),
26
32
  };
27
33
 
34
+ // ── Strip ANSI for accurate visible-width measurement ─────────────────────────
35
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
36
+
37
+ function visibleLen(s) {
38
+ return s.replace(ANSI_RE, '').length;
39
+ }
40
+
41
+ /** Pad `str` so its VISIBLE length equals `width`, then return the padded string. */
42
+ function padVisible(str, width) {
43
+ const extra = width - visibleLen(str);
44
+ return extra > 0 ? str + ' '.repeat(extra) : str;
45
+ }
46
+
28
47
  // ── Banner ────────────────────────────────────────────────────────────────────
29
48
  export function banner(version) {
30
49
  console.log();
@@ -42,9 +61,9 @@ export function banner(version) {
42
61
  // ── Step spinner ──────────────────────────────────────────────────────────────
43
62
  export function spin(text) {
44
63
  const spinner = ora({
45
- text: ` ${text}`,
46
- spinner: 'dots',
47
- color: 'magenta',
64
+ text: ` ${text}`,
65
+ spinner: 'dots',
66
+ color: 'magenta',
48
67
  prefixText: ' ',
49
68
  }).start();
50
69
 
@@ -52,6 +71,7 @@ export function spin(text) {
52
71
  succeed: (msg) => spinner.succeed(chalk.white(` ${msg ?? text}`)),
53
72
  fail: (msg) => spinner.fail(c.error(` ${msg ?? text}`)),
54
73
  warn: (msg) => spinner.warn(c.warn(` ${msg ?? text}`)),
74
+ info: (msg) => spinner.info(c.info(` ${msg ?? text}`)),
55
75
  update: (msg) => { spinner.text = ` ${msg}`; },
56
76
  stop: () => spinner.stop(),
57
77
  };
@@ -59,28 +79,62 @@ export function spin(text) {
59
79
 
60
80
  // ── Simple log helpers ────────────────────────────────────────────────────────
61
81
  export const log = {
62
- info: (...a) => console.log(` ${sym.arrow} `, ...a),
63
- ok: (...a) => console.log(` ${sym.ok} `, ...a),
64
- warn: (...a) => console.warn(` ${sym.warn} `, ...a),
65
- error: (...a) => console.error(` ${sym.error} `, ...a),
66
- section: (t) => console.log(`\n ${c.bold(c.brand(t))}\n`),
82
+ ok: (...a) => console.log(` ${sym.ok} `, ...a),
83
+ info: (...a) => console.log(` ${sym.info} `, ...a),
84
+ warn: (...a) => console.warn(` ${sym.warn} `, ...a),
85
+ error: (...a) => console.error(` ${sym.error} `, ...a),
86
+ section: (t) => {
87
+ console.log();
88
+ console.log(` ${c.bold(c.brand('─── ' + t))}`);
89
+ console.log();
90
+ },
67
91
  blank: () => console.log(),
68
92
  dim: (...a) => console.log(` ${c.dim(a.join(' '))}`),
93
+ tree: (last, msg) => console.log(` ${c.dim(last ? '└─' : '├─')} ${msg}`),
69
94
  };
70
95
 
71
- // ── Ready box ─────────────────────────────────────────────────────────────────
72
- export function readyBox(frontendUrl, backendUrl) {
73
- const line = (label, url) =>
74
- ` ${c.dim(label.padEnd(12))} ${c.brand(url)}`;
96
+ // ── Ready box — pixel-perfect alignment via visible-width padding ──────────────
97
+ export function readyBox(frontendUrl, backendUrl, timings = {}) {
98
+ const W = 46; // visible content width inside the box (between the ║ chars)
99
+ const br = c.brand;
100
+
101
+ /** Build one padded box row — content is measured BEFORE ANSI codes */
102
+ const row = (content) => {
103
+ const inner = padVisible(content, W);
104
+ return br(' ║') + inner + br('║');
105
+ };
106
+
107
+ const sep = br(' ╠' + '═'.repeat(W) + '╣');
108
+ const top = br(' ╔' + '═'.repeat(W) + '╗');
109
+ const bot = br(' ╚' + '═'.repeat(W) + '╝');
110
+
111
+ const appLine = ` ${c.dim('App')} ${c.dim('→')} ${c.brand(frontendUrl)}`;
112
+ const apiLine = ` ${c.dim('API')} ${c.dim('→')} ${c.brand(backendUrl)}`;
113
+
114
+ console.log();
115
+ console.log(top);
116
+ console.log(row(''));
117
+ console.log(row(` ✨ ${c.bold('M8Flow is running!')}`));
118
+ console.log(row(''));
119
+ console.log(sep);
120
+ console.log(row(appLine));
121
+ console.log(row(apiLine));
122
+ console.log(row(''));
123
+ if (timings.total) {
124
+ console.log(row(` ${c.dim('Started in')} ${c.ok(timings.total + 's')}`));
125
+ console.log(row(''));
126
+ }
127
+ console.log(row(` ${c.dim('Press Ctrl+C to stop')}`));
128
+ console.log(bot);
129
+ console.log();
130
+ }
75
131
 
132
+ // ── Shutdown sequence ─────────────────────────────────────────────────────────
133
+ export function printShutdown() {
76
134
  console.log();
77
- console.log(chalk.hex('#6366f1')(' ╔══════════════════════════════════════════════╗'));
78
- console.log(chalk.hex('#6366f1')(' ║') + c.bold(' ✨ M8Flow is running! ') + chalk.hex('#6366f1')('║'));
79
- console.log(chalk.hex('#6366f1')(' ║') + ' ' + chalk.hex('#6366f1')('║'));
80
- console.log(chalk.hex('#6366f1')('') + line(' App →', frontendUrl).padEnd(54) + chalk.hex('#6366f1')('║'));
81
- console.log(chalk.hex('#6366f1')(' ║') + line(' API →', backendUrl).padEnd(54) + chalk.hex('#6366f1')('║'));
82
- console.log(chalk.hex('#6366f1')(' ║') + ' ' + chalk.hex('#6366f1')('║'));
83
- console.log(chalk.hex('#6366f1')(' ║') + c.dim(' Press Ctrl+C to stop. ') + chalk.hex('#6366f1')('║'));
84
- console.log(chalk.hex('#6366f1')(' ╚══════════════════════════════════════════════╝'));
135
+ console.log(` ${c.dim('Shutting down M8Flow…')}`);
136
+ console.log(` ${c.dim('├─')} Stopping backend…`);
137
+ console.log(` ${c.dim('├─')} Stopping frontend…`);
138
+ console.log(` ${c.dim('└─')} ${c.ok('Done.')} Goodbye! 👋`);
85
139
  console.log();
86
140
  }
package/lib/run.js CHANGED
@@ -1,17 +1,5 @@
1
1
  /**
2
- * `m8flow run` — full startup orchestration.
3
- *
4
- * Steps:
5
- * 1. Validate Python
6
- * 2. Create storage dirs
7
- * 3. Ensure venv
8
- * 4. pip install (skip when requirements unchanged)
9
- * 5. Resolve ports (auto-shift if busy)
10
- * 6. Start FastAPI backend
11
- * 7. Serve React frontend
12
- * 8. Wait for backend health
13
- * 9. Open browser
14
- * 10. Keep alive until Ctrl+C
2
+ * `m8flow run` — full startup orchestration with timing metrics.
15
3
  */
16
4
  import path from 'path';
17
5
  import os from 'os';
@@ -24,7 +12,7 @@ import { checkPython, ensureVenv, installDeps } from './setup.js';
24
12
  import { spawnBackend } from './backend.js';
25
13
  import { serveStatic } from './server.js';
26
14
  import { resolvePort, waitForPort, killProcessOnPort } from './ports.js';
27
- import { banner, log, spin, readyBox } from './logger.js';
15
+ import { banner, log, spin, readyBox, printShutdown } from './logger.js';
28
16
 
29
17
  const require = createRequire(import.meta.url);
30
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -34,110 +22,126 @@ const BACKEND_DIR = path.join(PKG_DIR, 'bundled', 'backend');
34
22
  const FRONTEND_DIR = path.join(PKG_DIR, 'bundled', 'frontend-dist');
35
23
  const M8FLOW_HOME = path.join(os.homedir(), '.m8flow');
36
24
 
25
+ /** Returns elapsed seconds since `start` (Date.now()) as a string like "2.4s" */
26
+ function elapsed(start) { return ((Date.now() - start) / 1000).toFixed(1) + 's'; }
27
+
37
28
  export async function run({
38
29
  frontendPort = 3000,
39
30
  backendPort = 8000,
40
31
  openBrowser = true,
41
32
  verbose = false,
42
33
  } = {}) {
43
- // Ensure any unhandled failure prints cleanly and exits — no lingering process
44
34
  process.on('uncaughtException', (err) => {
45
35
  log.error(err.message);
46
36
  process.exit(1);
47
37
  });
48
38
 
39
+ const totalStart = Date.now();
49
40
  const pkg = require('../package.json');
50
41
  banner(pkg.version);
51
42
 
52
- // ── Guard: bundled assets present ────────────────────────────────────────
53
43
  assertBundled();
54
44
 
55
- // ── 1. Python ────────────────────────────────────────────────────────────
56
- log.section('Environment');
45
+ // ── Environment ──────────────────────────────────────────────────────────────
46
+ log.section('Checking environment');
47
+
48
+ let t = Date.now();
57
49
  const sp1 = spin('Locating Python 3.8+…');
58
50
  const pythonExe = await checkPython().catch((e) => { sp1.fail(e.message); throw e; });
59
- sp1.succeed(`Python found (${pythonExe})`);
51
+ sp1.succeed(`Python found (${pythonExe}) ${elapsed(t)}`);
60
52
 
61
- // ── 2. Storage dirs ───────────────────────────────────────────────────────
62
53
  ensureStorageDirs();
63
- log.ok(`Storage ready (${M8FLOW_HOME})`);
54
+ log.ok(`Storage dirs ready (${M8FLOW_HOME})`);
64
55
 
65
- // ── 3. Venv ───────────────────────────────────────────────────────────────
66
- const venvPython = await ensureVenv(pythonExe, M8FLOW_HOME);
56
+ // ── Runtime setup ─────────────────────────────────────────────────────────────
57
+ log.section('Preparing runtime');
67
58
 
68
- // ── 4. pip install ────────────────────────────────────────────────────────
59
+ t = Date.now();
60
+ const venvPython = await ensureVenv(pythonExe, M8FLOW_HOME);
69
61
  await installDeps(venvPython, BACKEND_DIR, M8FLOW_HOME);
70
62
 
71
- // ── 5. Ports — kill stale backends first ────────────────────────────────────
72
- log.section('Network');
63
+ // ── Resolving ports ───────────────────────────────────────────────────────────
64
+ log.section('Resolving ports');
73
65
 
74
- // Kill any leftover process from a previous session BEFORE checking port availability.
75
- // On Windows, Ctrl+C sometimes leaves the Python/uvicorn process running.
76
66
  await killProcessOnPort(backendPort);
77
67
  await killProcessOnPort(frontendPort);
78
68
 
79
69
  const fe = await resolvePort(frontendPort, 'frontend');
80
70
  const be = await resolvePort(backendPort, 'backend');
81
71
 
82
- if (fe.changed) log.warn(`Port ${fe.from} still busy after cleanup → using ${fe.port}`);
83
- if (be.changed) log.warn(`Port ${be.from} still busy after cleanup → using ${be.port}`);
72
+ if (fe.changed) log.warn(`Port ${fe.from} busy → using ${fe.port}`);
73
+ if (be.changed) log.warn(`Port ${be.from} busy → using ${be.port}`);
84
74
 
85
- log.ok(`Frontend → http://localhost:${fe.port}`);
86
- log.ok(`Backend → http://localhost:${be.port}`);
75
+ log.ok(`Frontend → http://localhost:${fe.port}`);
76
+ log.ok(`Backend → http://localhost:${be.port}`);
87
77
 
88
- // ── 6. Backend (Docker-first, venv fallback) ─────────────────────────────
89
- log.section('Starting servers');
90
- const sp6 = spin('Launching backend…');
91
- const { proc: backendProc, mode: backendMode } = await spawnBackend(
92
- venvPython, BACKEND_DIR, be.port, M8FLOW_HOME, verbose
93
- );
78
+ // ── Starting servers ──────────────────────────────────────────────────────────
79
+ log.section('Initializing ML engine');
94
80
 
95
- if (backendMode === 'docker') {
96
- sp6.update('Docker backend starting (warm no cold-start)…');
97
- } else {
98
- sp6.update('Starting FastAPI backend (venv)…');
99
- }
81
+ // Warn about Docker fallback BEFORE the spinner so it reads naturally
82
+ const { proc: backendProc, mode: backendMode } = await (async () => {
83
+ const { execa } = await import('execa');
84
+ const dockerOk = await execa('docker', ['info'], { reject: false, timeout: 3_000 })
85
+ .then(r => r.exitCode === 0).catch(() => false);
86
+ if (!dockerOk) {
87
+ log.info('Docker not found — falling back to Python venv');
88
+ }
89
+ return spawnBackend(venvPython, BACKEND_DIR, be.port, M8FLOW_HOME, verbose);
90
+ })();
91
+
92
+ const beStart = Date.now();
93
+ const sp6 = spin(
94
+ backendMode === 'docker'
95
+ ? 'Starting backend (Docker — warm environment)…'
96
+ : 'Starting backend (Python venv)…'
97
+ );
100
98
 
101
99
  backendProc.on('exit', (code) => {
102
- if (code !== 0 && code !== null) process.exit(1); // propagate crash
100
+ if (code !== 0 && code !== null) process.exit(1);
103
101
  });
104
102
 
105
- // ── 7. Frontend ───────────────────────────────────────────────────────────
103
+ const feStart = Date.now();
104
+ const sp7 = spin('Starting frontend server…');
106
105
  const httpServer = await serveStatic(FRONTEND_DIR, fe.port);
106
+ sp7.succeed(`Frontend ready ${elapsed(feStart)}`);
107
107
 
108
- // ── 8. Health-check backend ─────────────────────────────────────────────────
109
- // 120 s gives Docker image pull + container start enough headroom on first run.
110
- // The frontend shows its own "Initializing" overlay during this window.
111
- sp6.update('Waiting for backend to accept connections…');
108
+ // ── Health check ──────────────────────────────────────────────────────────────
109
+ sp6.update('Performing health checks…');
112
110
  await waitForPort(be.port, '127.0.0.1', 120_000).catch(() => {
113
111
  log.warn('Backend is taking longer than expected — the UI will retry automatically.');
114
112
  });
115
- sp6.succeed(`Backend is up (${backendMode === 'docker' ? '🐳 Docker — warm' : 'Python venv'})`);
116
- console.log();
113
+ sp6.succeed(
114
+ backendMode === 'docker'
115
+ ? `Backend ready (Docker — warm) ${elapsed(beStart)}`
116
+ : `Backend ready (Python venv) ${elapsed(beStart)}`
117
+ );
118
+
119
+ const totalTime = ((Date.now() - totalStart) / 1000).toFixed(1);
120
+ log.ok(`M8Flow launched in ${totalTime}s`);
117
121
 
118
- // ── 9. Open browser ───────────────────────────────────────────────────────
122
+ // ── Ready ─────────────────────────────────────────────────────────────────────
119
123
  const appUrl = `http://localhost:${fe.port}`;
120
124
  const apiUrl = `http://localhost:${be.port}`;
121
125
 
122
- readyBox(appUrl, apiUrl);
126
+ readyBox(appUrl, apiUrl, { total: totalTime });
127
+
128
+ log.info('Open the browser to start building pipelines');
129
+ log.info('Drag nodes onto the canvas to create ML workflows');
130
+ log.blank();
123
131
 
124
132
  if (openBrowser) {
125
133
  try {
126
134
  const { default: open } = await import('open');
127
135
  await open(appUrl);
128
136
  } catch {
129
- log.dim(`Open your browser at ${appUrl}`);
137
+ log.dim(`Navigate to ${appUrl}`);
130
138
  }
131
139
  }
132
140
 
133
- // ── 10. Graceful shutdown ─────────────────────────────────────────────────
134
- const shutdown = (signal) => {
135
- log.blank();
136
- log.info(`Received ${signal} — shutting down…`);
141
+ // ── Graceful shutdown ─────────────────────────────────────────────────────────
142
+ const shutdown = (_signal) => {
143
+ printShutdown();
137
144
 
138
- // On Windows, .kill() only kills the execa wrapper — the Python subprocess
139
- // often survives, leaving the port busy on the next run.
140
- // Use taskkill /T to kill the entire process tree.
141
145
  if (process.platform === 'win32' && backendProc.pid) {
142
146
  try { execSync(`taskkill /PID ${backendProc.pid} /F /T`, { stdio: 'ignore' }); } catch {}
143
147
  } else {
@@ -145,14 +149,12 @@ export async function run({
145
149
  }
146
150
 
147
151
  try { httpServer.close(); } catch {}
148
- log.ok('Goodbye!\n');
149
152
  process.exit(0);
150
153
  };
151
154
 
152
155
  process.on('SIGINT', () => shutdown('SIGINT'));
153
156
  process.on('SIGTERM', () => shutdown('SIGTERM'));
154
157
 
155
- // Keep the Node process alive
156
158
  await new Promise(() => {});
157
159
  }
158
160
 
@@ -168,14 +170,10 @@ function assertBundled() {
168
170
  const missing = [];
169
171
  if (!fs.existsSync(BACKEND_DIR)) missing.push('bundled/backend');
170
172
  if (!fs.existsSync(FRONTEND_DIR)) missing.push('bundled/frontend-dist');
171
-
172
173
  if (missing.length === 0) return;
173
-
174
174
  throw new Error(
175
175
  `Missing bundled assets: ${missing.join(', ')}\n\n` +
176
- ` If you cloned the repo, run:\n` +
177
- ` node cli/scripts/build.js\n\n` +
178
- ` Then install locally:\n` +
179
- ` cd cli && npm install -g .`
176
+ ` Run: node cli/scripts/build.js\n` +
177
+ ` Then: cd cli && npm install -g .`
180
178
  );
181
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "m8flow",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "description": "AI-powered visual machine learning workflow builder with local execution, pipeline orchestration, and zero-config setup.",
5
5
  "keywords": [
6
6
  "machine-learning",