@cccarv82/freya 1.0.4 → 1.0.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/cli/index.js +39 -6
  2. package/cli/web.js +857 -0
  3. package/package.json +2 -2
package/cli/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const path = require('path');
4
4
 
5
5
  const { cmdInit } = require('./init');
6
+ const { cmdWeb } = require('./web');
6
7
 
7
8
  function usage() {
8
9
  return `
@@ -10,6 +11,7 @@ freya - F.R.E.Y.A. CLI
10
11
 
11
12
  Usage:
12
13
  freya init [dir] [--force] [--here|--in-place] [--force-data] [--force-logs]
14
+ freya web [--port <n>] [--dir <path>] [--no-open] [--dev]
13
15
 
14
16
  Defaults:
15
17
  - If no [dir] is provided, creates ./freya
@@ -22,23 +24,38 @@ Examples:
22
24
  freya init --here --force # update agents/scripts, keep data/logs
23
25
  freya init --here --force-data # overwrite data/ too (danger)
24
26
  npx @cccarv82/freya init
27
+
28
+ freya web
29
+ freya web --dir ./freya --port 3872
30
+ freya web --dev # seeds demo data/logs for quick testing
25
31
  `;
26
32
  }
27
33
 
28
34
  function parseArgs(argv) {
29
35
  const args = [];
30
36
  const flags = new Set();
31
-
32
- for (const a of argv) {
33
- if (a.startsWith('--')) flags.add(a);
34
- else args.push(a);
37
+ const kv = {};
38
+
39
+ for (let i = 0; i < argv.length; i++) {
40
+ const a = argv[i];
41
+ if (a.startsWith('--')) {
42
+ const next = argv[i + 1];
43
+ if (next && !next.startsWith('--')) {
44
+ kv[a] = next;
45
+ i++;
46
+ } else {
47
+ flags.add(a);
48
+ }
49
+ } else {
50
+ args.push(a);
51
+ }
35
52
  }
36
53
 
37
- return { args, flags };
54
+ return { args, flags, kv };
38
55
  }
39
56
 
40
57
  async function run(argv) {
41
- const { args, flags } = parseArgs(argv);
58
+ const { args, flags, kv } = parseArgs(argv);
42
59
  const command = args[0];
43
60
 
44
61
  if (!command || command === 'help' || flags.has('--help') || flags.has('-h')) {
@@ -61,6 +78,22 @@ async function run(argv) {
61
78
  return;
62
79
  }
63
80
 
81
+ if (command === 'web') {
82
+ const port = Number(kv['--port'] || 3872);
83
+ const dir = kv['--dir'] ? path.resolve(process.cwd(), kv['--dir']) : null;
84
+ const open = !flags.has('--no-open');
85
+ const dev = flags.has('--dev');
86
+
87
+ if (!Number.isFinite(port) || port <= 0) {
88
+ process.stderr.write('Invalid --port\n');
89
+ process.exitCode = 1;
90
+ return;
91
+ }
92
+
93
+ await cmdWeb({ port, dir, open, dev });
94
+ return;
95
+ }
96
+
64
97
  process.stderr.write(`Unknown command: ${command}\n`);
65
98
  process.stdout.write(usage());
66
99
  process.exitCode = 1;
package/cli/web.js ADDED
@@ -0,0 +1,857 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { spawn } = require('child_process');
7
+
8
+ function guessNpmCmd() {
9
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm';
10
+ }
11
+
12
+ function guessOpenCmd() {
13
+ // Minimal cross-platform opener without extra deps
14
+ if (process.platform === 'win32') return { cmd: 'cmd', args: ['/c', 'start', ''] };
15
+ if (process.platform === 'darwin') return { cmd: 'open', args: [] };
16
+ return { cmd: 'xdg-open', args: [] };
17
+ }
18
+
19
+ function exists(p) {
20
+ try {
21
+ fs.accessSync(p);
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ function newestFile(dir, prefix) {
29
+ if (!exists(dir)) return null;
30
+ const files = fs
31
+ .readdirSync(dir)
32
+ .filter((f) => f.startsWith(prefix) && f.endsWith('.md'))
33
+ .map((f) => ({ f, p: path.join(dir, f) }))
34
+ .filter((x) => {
35
+ try {
36
+ return fs.statSync(x.p).isFile();
37
+ } catch {
38
+ return false;
39
+ }
40
+ })
41
+ .sort((a, b) => {
42
+ try {
43
+ return fs.statSync(b.p).mtimeMs - fs.statSync(a.p).mtimeMs;
44
+ } catch {
45
+ return 0;
46
+ }
47
+ });
48
+ return files[0]?.p || null;
49
+ }
50
+
51
+ function safeJson(res, code, obj) {
52
+ const body = JSON.stringify(obj);
53
+ res.writeHead(code, {
54
+ 'Content-Type': 'application/json; charset=utf-8',
55
+ 'Cache-Control': 'no-store',
56
+ 'Content-Length': Buffer.byteLength(body)
57
+ });
58
+ res.end(body);
59
+ }
60
+
61
+ function readBody(req) {
62
+ return new Promise((resolve, reject) => {
63
+ const chunks = [];
64
+ req.on('data', (c) => chunks.push(c));
65
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
66
+ req.on('error', reject);
67
+ });
68
+ }
69
+
70
+ function run(cmd, args, cwd) {
71
+ return new Promise((resolve) => {
72
+ const child = spawn(cmd, args, { cwd, shell: false, env: process.env });
73
+ let stdout = '';
74
+ let stderr = '';
75
+ child.stdout.on('data', (d) => {
76
+ stdout += d.toString();
77
+ });
78
+ child.stderr.on('data', (d) => {
79
+ stderr += d.toString();
80
+ });
81
+ child.on('close', (code) => resolve({ code: code ?? 0, stdout, stderr }));
82
+ });
83
+ }
84
+
85
+ function openBrowser(url) {
86
+ const { cmd, args } = guessOpenCmd();
87
+ try {
88
+ spawn(cmd, [...args, url], { detached: true, stdio: 'ignore' }).unref();
89
+ } catch {
90
+ // ignore
91
+ }
92
+ }
93
+
94
+ async function pickDirectoryNative() {
95
+ if (process.platform === 'win32') {
96
+ // PowerShell FolderBrowserDialog
97
+ const ps = [
98
+ '-NoProfile',
99
+ '-Command',
100
+ [
101
+ 'Add-Type -AssemblyName System.Windows.Forms;',
102
+ '$f = New-Object System.Windows.Forms.FolderBrowserDialog;',
103
+ "$f.Description = 'Select your FREYA workspace folder';",
104
+ '$f.ShowNewFolderButton = $true;',
105
+ 'if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.SelectedPath }'
106
+ ].join(' ')
107
+ ];
108
+ const r = await run('powershell', ps, process.cwd());
109
+ if (r.code !== 0) throw new Error((r.stderr || r.stdout || '').trim() || 'Failed to open folder picker');
110
+ const out = (r.stdout || '').trim();
111
+ return out || null;
112
+ }
113
+
114
+ if (process.platform === 'darwin') {
115
+ const r = await run('osascript', ['-e', 'POSIX path of (choose folder with prompt "Select your FREYA workspace folder")'], process.cwd());
116
+ if (r.code !== 0) throw new Error((r.stderr || r.stdout || '').trim() || 'Failed to open folder picker');
117
+ const out = (r.stdout || '').trim();
118
+ return out || null;
119
+ }
120
+
121
+ // Linux: prefer zenity, then kdialog
122
+ if (exists('/usr/bin/zenity') || exists('/bin/zenity') || exists('/usr/local/bin/zenity')) {
123
+ const r = await run('zenity', ['--file-selection', '--directory', '--title=Select your FREYA workspace folder'], process.cwd());
124
+ if (r.code !== 0) return null;
125
+ return (r.stdout || '').trim() || null;
126
+ }
127
+ if (exists('/usr/bin/kdialog') || exists('/bin/kdialog') || exists('/usr/local/bin/kdialog')) {
128
+ const r = await run('kdialog', ['--getexistingdirectory', '.', 'Select your FREYA workspace folder'], process.cwd());
129
+ if (r.code !== 0) return null;
130
+ return (r.stdout || '').trim() || null;
131
+ }
132
+
133
+ return null;
134
+ }
135
+
136
+ function html() {
137
+ // Aesthetic: “Noir control room” — dark glass, crisp typography, intentional hierarchy.
138
+ return `<!doctype html>
139
+ <html>
140
+ <head>
141
+ <meta charset="utf-8" />
142
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
143
+ <title>FREYA Web</title>
144
+ <style>
145
+ :root {
146
+ --bg: #070a10;
147
+ --bg2: #0a1020;
148
+ --panel: rgba(255,255,255,.04);
149
+ --panel2: rgba(255,255,255,.06);
150
+ --line: rgba(180,210,255,.16);
151
+ --text: #e9f0ff;
152
+ --muted: rgba(233,240,255,.72);
153
+ --faint: rgba(233,240,255,.52);
154
+ --accent: #5eead4;
155
+ --accent2: #60a5fa;
156
+ --danger: #fb7185;
157
+ --ok: #34d399;
158
+ --warn: #fbbf24;
159
+ --shadow: 0 30px 70px rgba(0,0,0,.55);
160
+ --radius: 16px;
161
+ }
162
+
163
+ * { box-sizing: border-box; }
164
+ html, body { height: 100%; }
165
+ body {
166
+ margin: 0;
167
+ color: var(--text);
168
+ background:
169
+ radial-gradient(900px 560px at 18% 12%, rgba(94,234,212,.14), transparent 60%),
170
+ radial-gradient(820px 540px at 72% 6%, rgba(96,165,250,.14), transparent 60%),
171
+ radial-gradient(900px 700px at 70% 78%, rgba(251,113,133,.08), transparent 60%),
172
+ linear-gradient(180deg, var(--bg), var(--bg2));
173
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Helvetica Neue", Arial;
174
+ overflow-x: hidden;
175
+ }
176
+
177
+ /* subtle noise */
178
+ body:before {
179
+ content: "";
180
+ position: fixed;
181
+ inset: 0;
182
+ pointer-events: none;
183
+ background-image:
184
+ linear-gradient(transparent 0, transparent 2px, rgba(255,255,255,.02) 3px),
185
+ radial-gradient(circle at 10% 10%, rgba(255,255,255,.06), transparent 35%),
186
+ radial-gradient(circle at 90% 30%, rgba(255,255,255,.04), transparent 35%);
187
+ background-size: 100% 6px, 900px 900px, 900px 900px;
188
+ mix-blend-mode: overlay;
189
+ opacity: .12;
190
+ }
191
+
192
+ header {
193
+ position: sticky;
194
+ top: 0;
195
+ z-index: 10;
196
+ backdrop-filter: blur(14px);
197
+ background: rgba(7,10,16,.56);
198
+ border-bottom: 1px solid var(--line);
199
+ }
200
+
201
+ .top {
202
+ max-width: 1140px;
203
+ margin: 0 auto;
204
+ padding: 14px 18px;
205
+ display: flex;
206
+ align-items: center;
207
+ justify-content: space-between;
208
+ gap: 16px;
209
+ }
210
+
211
+ .brand {
212
+ display: flex;
213
+ align-items: baseline;
214
+ gap: 12px;
215
+ letter-spacing: .16em;
216
+ text-transform: uppercase;
217
+ font-weight: 700;
218
+ font-size: 12px;
219
+ color: var(--muted);
220
+ }
221
+
222
+ .badge {
223
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
224
+ font-size: 12px;
225
+ padding: 6px 10px;
226
+ border-radius: 999px;
227
+ border: 1px solid var(--line);
228
+ background: rgba(255,255,255,.03);
229
+ color: var(--faint);
230
+ }
231
+
232
+ .wrap {
233
+ max-width: 1140px;
234
+ margin: 0 auto;
235
+ padding: 18px;
236
+ }
237
+
238
+ .hero {
239
+ display: grid;
240
+ grid-template-columns: 1.2fr .8fr;
241
+ gap: 16px;
242
+ align-items: start;
243
+ margin-bottom: 16px;
244
+ }
245
+
246
+ @media (max-width: 980px) {
247
+ .hero { grid-template-columns: 1fr; }
248
+ }
249
+
250
+ .card {
251
+ border: 1px solid var(--line);
252
+ background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.02));
253
+ border-radius: var(--radius);
254
+ box-shadow: var(--shadow);
255
+ padding: 14px;
256
+ position: relative;
257
+ overflow: hidden;
258
+ }
259
+
260
+ .card:before {
261
+ content: "";
262
+ position: absolute;
263
+ inset: -2px;
264
+ background:
265
+ radial-gradient(900px 220px at 25% 0%, rgba(94,234,212,.12), transparent 60%),
266
+ radial-gradient(900px 220px at 90% 30%, rgba(96,165,250,.10), transparent 60%);
267
+ opacity: .55;
268
+ pointer-events: none;
269
+ }
270
+
271
+ .card > * { position: relative; }
272
+
273
+ h2 {
274
+ margin: 0 0 8px;
275
+ font-size: 14px;
276
+ letter-spacing: .08em;
277
+ text-transform: uppercase;
278
+ color: var(--muted);
279
+ }
280
+
281
+ .sub {
282
+ margin: 0 0 10px;
283
+ font-size: 12px;
284
+ color: var(--faint);
285
+ line-height: 1.35;
286
+ }
287
+
288
+ label {
289
+ display: block;
290
+ font-size: 12px;
291
+ color: var(--muted);
292
+ margin-bottom: 6px;
293
+ }
294
+
295
+ .field {
296
+ display: grid;
297
+ grid-template-columns: 1fr auto;
298
+ gap: 10px;
299
+ align-items: center;
300
+ }
301
+
302
+ input {
303
+ width: 100%;
304
+ padding: 12px 12px;
305
+ border-radius: 12px;
306
+ border: 1px solid rgba(180,210,255,.22);
307
+ background: rgba(7,10,16,.55);
308
+ color: var(--text);
309
+ outline: none;
310
+ }
311
+
312
+ input::placeholder { color: rgba(233,240,255,.38); }
313
+
314
+ .btns {
315
+ display: flex;
316
+ flex-wrap: wrap;
317
+ gap: 10px;
318
+ margin-top: 10px;
319
+ }
320
+
321
+ button {
322
+ border: 1px solid rgba(180,210,255,.22);
323
+ border-radius: 12px;
324
+ background: rgba(255,255,255,.04);
325
+ color: var(--text);
326
+ padding: 10px 12px;
327
+ cursor: pointer;
328
+ transition: transform .08s ease, background .16s ease, border-color .16s ease;
329
+ font-weight: 600;
330
+ letter-spacing: .01em;
331
+ }
332
+
333
+ button:hover { transform: translateY(-1px); background: rgba(255,255,255,.06); border-color: rgba(180,210,255,.32); }
334
+ button:active { transform: translateY(0); }
335
+
336
+ .primary {
337
+ background: linear-gradient(135deg, rgba(94,234,212,.18), rgba(96,165,250,.16));
338
+ border-color: rgba(94,234,212,.28);
339
+ }
340
+
341
+ .ghost { background: rgba(255,255,255,.02); }
342
+
343
+ .danger { border-color: rgba(251,113,133,.45); background: rgba(251,113,133,.12); }
344
+
345
+ .pill {
346
+ display: inline-flex;
347
+ align-items: center;
348
+ gap: 8px;
349
+ padding: 8px 10px;
350
+ border-radius: 999px;
351
+ border: 1px solid rgba(180,210,255,.18);
352
+ background: rgba(0,0,0,.18);
353
+ font-size: 12px;
354
+ color: var(--faint);
355
+ }
356
+
357
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--warn); box-shadow: 0 0 0 5px rgba(251,191,36,.14); }
358
+ .dot.ok { background: var(--ok); box-shadow: 0 0 0 5px rgba(52,211,153,.12); }
359
+ .dot.err { background: var(--danger); box-shadow: 0 0 0 5px rgba(251,113,133,.12); }
360
+
361
+ .two {
362
+ display: grid;
363
+ grid-template-columns: 1fr 1fr;
364
+ gap: 12px;
365
+ }
366
+ @media (max-width: 980px) { .two { grid-template-columns: 1fr; } }
367
+
368
+ .hr { height: 1px; background: rgba(180,210,255,.14); margin: 12px 0; }
369
+
370
+ .log {
371
+ border-radius: 14px;
372
+ border: 1px solid rgba(180,210,255,.18);
373
+ background: rgba(7,10,16,.55);
374
+ padding: 12px;
375
+ min-height: 220px;
376
+ max-height: 420px;
377
+ overflow: auto;
378
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
379
+ font-size: 12px;
380
+ line-height: 1.35;
381
+ white-space: pre-wrap;
382
+ color: rgba(233,240,255,.84);
383
+ }
384
+
385
+ .hint {
386
+ font-size: 12px;
387
+ color: var(--faint);
388
+ margin-top: 6px;
389
+ line-height: 1.35;
390
+ }
391
+
392
+ .footer {
393
+ margin-top: 12px;
394
+ font-size: 12px;
395
+ color: rgba(233,240,255,.45);
396
+ }
397
+
398
+ a { color: var(--accent2); text-decoration: none; }
399
+ a:hover { text-decoration: underline; }
400
+ </style>
401
+ </head>
402
+ <body>
403
+ <header>
404
+ <div class="top">
405
+ <div class="brand">FREYA <span style="opacity:.55">•</span> web console</div>
406
+ <div class="badge" id="status">ready</div>
407
+ </div>
408
+ </header>
409
+
410
+ <div class="wrap">
411
+ <div class="hero">
412
+ <div class="card">
413
+ <h2>1) Workspace</h2>
414
+ <p class="sub">Escolha onde está (ou onde será criada) sua workspace da FREYA. Se você já tem uma workspace antiga, use <b>Update</b> — seus <b>data/logs</b> ficam preservados.</p>
415
+
416
+ <label>Workspace dir</label>
417
+ <div class="field">
418
+ <input id="dir" placeholder="./freya" />
419
+ <button class="ghost" onclick="pickDir()">Browse…</button>
420
+ </div>
421
+ <div class="hint">Dica: a workspace contém <code>data/</code>, <code>logs/</code> e <code>scripts/</code>.</div>
422
+
423
+ <div class="btns">
424
+ <button class="primary" onclick="doInit()">Init</button>
425
+ <button onclick="doUpdate()">Update</button>
426
+ <button onclick="doHealth()">Health</button>
427
+ <button onclick="doMigrate()">Migrate</button>
428
+ </div>
429
+
430
+ <div class="footer">Atalho: <code>freya web --dir ./freya</code> (porta padrão 3872).</div>
431
+ </div>
432
+
433
+ <div class="card">
434
+ <h2>2) Publish</h2>
435
+ <p class="sub">Configure webhooks (opcional) para publicar relatórios com 1 clique. Ideal para mandar status no Teams/Discord.</p>
436
+
437
+ <label>Discord webhook URL</label>
438
+ <input id="discord" placeholder="https://discord.com/api/webhooks/..." />
439
+
440
+ <div style="height:10px"></div>
441
+ <label>Teams webhook URL</label>
442
+ <input id="teams" placeholder="https://..." />
443
+
444
+ <div class="hr"></div>
445
+ <div class="btns">
446
+ <button onclick="publish('discord')">Publish last → Discord</button>
447
+ <button onclick="publish('teams')">Publish last → Teams</button>
448
+ </div>
449
+ <div class="hint">O publish usa o texto do último relatório gerado. Para MVP, limitamos em ~1800 caracteres (evita limites de webhook). Depois a gente melhora para anexos/chunks.</div>
450
+ </div>
451
+ </div>
452
+
453
+ <div class="two">
454
+ <div class="card">
455
+ <h2>3) Generate</h2>
456
+ <p class="sub">Gere relatórios e use o preview/log abaixo para validar. Depois, publique ou copie.</p>
457
+
458
+ <div class="btns">
459
+ <button class="primary" onclick="runReport('status')">Executive</button>
460
+ <button class="primary" onclick="runReport('sm-weekly')">SM Weekly</button>
461
+ <button class="primary" onclick="runReport('blockers')">Blockers</button>
462
+ <button class="ghost" onclick="runReport('daily')">Daily</button>
463
+ </div>
464
+
465
+ <div class="hint" id="last"></div>
466
+ </div>
467
+
468
+ <div class="card">
469
+ <h2>Output</h2>
470
+ <div class="pill"><span class="dot" id="dot"></span><span id="pill">idle</span></div>
471
+ <div style="height:10px"></div>
472
+ <div class="log" id="out"></div>
473
+ <div class="footer">Dica: se o report foi salvo em arquivo, ele aparece em “Last report”.</div>
474
+ </div>
475
+ </div>
476
+ </div>
477
+
478
+ <script>
479
+ const $ = (id) => document.getElementById(id);
480
+ const state = { lastReportPath: null, lastText: '' };
481
+
482
+ function setPill(kind, text) {
483
+ const dot = $('dot');
484
+ dot.classList.remove('ok','err');
485
+ if (kind === 'ok') dot.classList.add('ok');
486
+ if (kind === 'err') dot.classList.add('err');
487
+ $('pill').textContent = text;
488
+ }
489
+
490
+ function setOut(text) {
491
+ state.lastText = text || '';
492
+ $('out').textContent = text || '';
493
+ }
494
+
495
+ function setLast(p) {
496
+ state.lastReportPath = p;
497
+ $('last').textContent = p ? ('Last report: ' + p) : '';
498
+ }
499
+
500
+ function saveLocal() {
501
+ localStorage.setItem('freya.dir', $('dir').value);
502
+ localStorage.setItem('freya.discord', $('discord').value);
503
+ localStorage.setItem('freya.teams', $('teams').value);
504
+ }
505
+
506
+ function loadLocal() {
507
+ $('dir').value = localStorage.getItem('freya.dir') || './freya';
508
+ $('discord').value = localStorage.getItem('freya.discord') || '';
509
+ $('teams').value = localStorage.getItem('freya.teams') || '';
510
+ }
511
+
512
+ async function api(p, body) {
513
+ const res = await fetch(p, {
514
+ method: body ? 'POST' : 'GET',
515
+ headers: body ? { 'Content-Type': 'application/json' } : {},
516
+ body: body ? JSON.stringify(body) : undefined
517
+ });
518
+ const json = await res.json();
519
+ if (!res.ok) throw new Error(json.error || 'Request failed');
520
+ return json;
521
+ }
522
+
523
+ function dirOrDefault() {
524
+ const d = $('dir').value.trim();
525
+ return d || './freya';
526
+ }
527
+
528
+ async function pickDir() {
529
+ try {
530
+ setPill('run','opening picker…');
531
+ const r = await api('/api/pick-dir', {});
532
+ if (r && r.dir) $('dir').value = r.dir;
533
+ saveLocal();
534
+ setPill('ok','ready');
535
+ } catch (e) {
536
+ setPill('err','picker unavailable');
537
+ setOut(String(e && e.message ? e.message : e));
538
+ }
539
+ }
540
+
541
+ async function doInit() {
542
+ try {
543
+ saveLocal();
544
+ setPill('run','init…');
545
+ setOut('');
546
+ const r = await api('/api/init', { dir: dirOrDefault() });
547
+ setOut(r.output);
548
+ setLast(null);
549
+ setPill('ok','init ok');
550
+ } catch (e) {
551
+ setPill('err','init failed');
552
+ setOut(String(e && e.message ? e.message : e));
553
+ }
554
+ }
555
+
556
+ async function doUpdate() {
557
+ try {
558
+ saveLocal();
559
+ setPill('run','update…');
560
+ setOut('');
561
+ const r = await api('/api/update', { dir: dirOrDefault() });
562
+ setOut(r.output);
563
+ setLast(null);
564
+ setPill('ok','update ok');
565
+ } catch (e) {
566
+ setPill('err','update failed');
567
+ setOut(String(e && e.message ? e.message : e));
568
+ }
569
+ }
570
+
571
+ async function doHealth() {
572
+ try {
573
+ saveLocal();
574
+ setPill('run','health…');
575
+ setOut('');
576
+ const r = await api('/api/health', { dir: dirOrDefault() });
577
+ setOut(r.output);
578
+ setLast(null);
579
+ setPill('ok','health ok');
580
+ } catch (e) {
581
+ setPill('err','health failed');
582
+ setOut(String(e && e.message ? e.message : e));
583
+ }
584
+ }
585
+
586
+ async function doMigrate() {
587
+ try {
588
+ saveLocal();
589
+ setPill('run','migrate…');
590
+ setOut('');
591
+ const r = await api('/api/migrate', { dir: dirOrDefault() });
592
+ setOut(r.output);
593
+ setLast(null);
594
+ setPill('ok','migrate ok');
595
+ } catch (e) {
596
+ setPill('err','migrate failed');
597
+ setOut(String(e && e.message ? e.message : e));
598
+ }
599
+ }
600
+
601
+ async function runReport(name) {
602
+ try {
603
+ saveLocal();
604
+ setPill('run', name + '…');
605
+ setOut('');
606
+ const r = await api('/api/report', { dir: dirOrDefault(), script: name });
607
+ setOut(r.output);
608
+ setLast(r.reportPath || null);
609
+ if (r.reportText) state.lastText = r.reportText;
610
+ setPill('ok', name + ' ok');
611
+ } catch (e) {
612
+ setPill('err', name + ' failed');
613
+ setOut(String(e && e.message ? e.message : e));
614
+ }
615
+ }
616
+
617
+ async function publish(target) {
618
+ try {
619
+ saveLocal();
620
+ if (!state.lastText) throw new Error('Generate a report first.');
621
+ const webhookUrl = target === 'discord' ? $('discord').value.trim() : $('teams').value.trim();
622
+ if (!webhookUrl) throw new Error('Configure the webhook URL first.');
623
+ setPill('run','publishing…');
624
+ await api('/api/publish', { webhookUrl, text: state.lastText });
625
+ setPill('ok','published');
626
+ } catch (e) {
627
+ setPill('err','publish failed');
628
+ setOut(String(e && e.message ? e.message : e));
629
+ }
630
+ }
631
+
632
+ loadLocal();
633
+ </script>
634
+ </body>
635
+ </html>`;
636
+ }
637
+
638
+ function ensureDir(p) {
639
+ fs.mkdirSync(p, { recursive: true });
640
+ }
641
+
642
+ function isoDate(d = new Date()) {
643
+ return d.toISOString().slice(0, 10);
644
+ }
645
+
646
+ function isoNow() {
647
+ return new Date().toISOString();
648
+ }
649
+
650
+ function seedDevWorkspace(workspaceDir) {
651
+ // Only create if missing; never overwrite user content.
652
+ ensureDir(path.join(workspaceDir, 'data', 'tasks'));
653
+ ensureDir(path.join(workspaceDir, 'data', 'career'));
654
+ ensureDir(path.join(workspaceDir, 'data', 'blockers'));
655
+ ensureDir(path.join(workspaceDir, 'data', 'Clients', 'acme', 'rocket'));
656
+ ensureDir(path.join(workspaceDir, 'logs', 'daily'));
657
+
658
+ const taskLog = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
659
+ if (!exists(taskLog)) {
660
+ fs.writeFileSync(taskLog, JSON.stringify({
661
+ schemaVersion: 1,
662
+ tasks: [
663
+ { id: 't-demo-1', description: 'Preparar update executivo', category: 'DO_NOW', status: 'PENDING', createdAt: isoNow(), priority: 'high' },
664
+ { id: 't-demo-2', description: 'Revisar PR de integração Teams', category: 'SCHEDULE', status: 'PENDING', createdAt: isoNow(), priority: 'medium' },
665
+ { id: 't-demo-3', description: 'Rodar retro e registrar aprendizados', category: 'DO_NOW', status: 'COMPLETED', createdAt: isoNow(), completedAt: isoNow(), priority: 'low' }
666
+ ]
667
+ }, null, 2) + '\n', 'utf8');
668
+ }
669
+
670
+ const careerLog = path.join(workspaceDir, 'data', 'career', 'career-log.json');
671
+ if (!exists(careerLog)) {
672
+ fs.writeFileSync(careerLog, JSON.stringify({
673
+ schemaVersion: 1,
674
+ entries: [
675
+ { id: 'c-demo-1', date: isoDate(), type: 'Achievement', description: 'Publicou o CLI @cccarv82/freya com init/web', tags: ['shipping', 'tooling'], source: 'dev-seed' },
676
+ { id: 'c-demo-2', date: isoDate(), type: 'Feedback', description: 'Feedback: UX do painel web está “muito promissor”', tags: ['product'], source: 'dev-seed' }
677
+ ]
678
+ }, null, 2) + '\n', 'utf8');
679
+ }
680
+
681
+ const blockerLog = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
682
+ if (!exists(blockerLog)) {
683
+ fs.writeFileSync(blockerLog, JSON.stringify({
684
+ schemaVersion: 1,
685
+ blockers: [
686
+ { id: 'b-demo-1', title: 'Webhook do Teams falhando em ambientes com 2FA', description: 'Ajustar token / payload', createdAt: isoNow(), status: 'OPEN', severity: 'HIGH', nextAction: 'Validar payload e limites' },
687
+ { id: 'b-demo-2', title: 'Definir template de status report por cliente', description: 'Padronizar headings', createdAt: isoNow(), status: 'MITIGATING', severity: 'MEDIUM' }
688
+ ]
689
+ }, null, 2) + '\n', 'utf8');
690
+ }
691
+
692
+ const projectStatus = path.join(workspaceDir, 'data', 'Clients', 'acme', 'rocket', 'status.json');
693
+ if (!exists(projectStatus)) {
694
+ fs.writeFileSync(projectStatus, JSON.stringify({
695
+ schemaVersion: 1,
696
+ client: 'Acme',
697
+ project: 'Rocket',
698
+ currentStatus: 'Green — progressing as planned',
699
+ lastUpdated: isoNow(),
700
+ active: true,
701
+ history: [
702
+ { date: isoNow(), type: 'Status', content: 'Launched stage 1', source: 'dev-seed' },
703
+ { date: isoNow(), type: 'Risk', content: 'Potential delay on vendor dependency', source: 'dev-seed' }
704
+ ]
705
+ }, null, 2) + '\n', 'utf8');
706
+ }
707
+
708
+ const dailyLog = path.join(workspaceDir, 'logs', 'daily', `${isoDate()}.md`);
709
+ if (!exists(dailyLog)) {
710
+ fs.writeFileSync(dailyLog, `# Daily Log ${isoDate()}\n\n## [09:15] Raw Input\nReunião com a Acme. Tudo verde, mas preciso alinhar com fornecedor.\n\n## [16:40] Raw Input\nTerminei o relatório SM e publiquei no Discord.\n`, 'utf8');
711
+ }
712
+
713
+ return {
714
+ seeded: true,
715
+ paths: { taskLog, careerLog, blockerLog, projectStatus, dailyLog }
716
+ };
717
+ }
718
+
719
+ async function cmdWeb({ port, dir, open, dev }) {
720
+ const host = '127.0.0.1';
721
+
722
+ const server = http.createServer(async (req, res) => {
723
+ try {
724
+ if (!req.url) return safeJson(res, 404, { error: 'Not found' });
725
+
726
+ if (req.method === 'GET' && req.url === '/') {
727
+ const body = html();
728
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
729
+ res.end(body);
730
+ return;
731
+ }
732
+
733
+ if (req.url.startsWith('/api/')) {
734
+ const raw = await readBody(req);
735
+ const payload = raw ? JSON.parse(raw) : {};
736
+
737
+ const workspaceDir = path.resolve(process.cwd(), payload.dir || dir || './freya');
738
+
739
+ if (req.url === '/api/pick-dir') {
740
+ const picked = await pickDirectoryNative();
741
+ return safeJson(res, 200, { dir: picked });
742
+ }
743
+
744
+ if (req.url === '/api/init') {
745
+ const pkg = '@cccarv82/freya';
746
+ const r = await run('npx', [pkg, 'init', workspaceDir], process.cwd());
747
+ return safeJson(res, r.code === 0 ? 200 : 400, { output: (r.stdout + r.stderr).trim() });
748
+ }
749
+
750
+ if (req.url === '/api/update') {
751
+ const pkg = '@cccarv82/freya';
752
+ fs.mkdirSync(workspaceDir, { recursive: true });
753
+ const r = await run('npx', [pkg, 'init', '--here'], workspaceDir);
754
+ return safeJson(res, r.code === 0 ? 200 : 400, { output: (r.stdout + r.stderr).trim() });
755
+ }
756
+
757
+ const npmCmd = guessNpmCmd();
758
+
759
+ if (req.url === '/api/health') {
760
+ const r = await run(npmCmd, ['run', 'health'], workspaceDir);
761
+ return safeJson(res, r.code === 0 ? 200 : 400, { output: (r.stdout + r.stderr).trim() });
762
+ }
763
+
764
+ if (req.url === '/api/migrate') {
765
+ const r = await run(npmCmd, ['run', 'migrate'], workspaceDir);
766
+ return safeJson(res, r.code === 0 ? 200 : 400, { output: (r.stdout + r.stderr).trim() });
767
+ }
768
+
769
+ if (req.url === '/api/report') {
770
+ const script = payload.script;
771
+ if (!script) return safeJson(res, 400, { error: 'Missing script' });
772
+
773
+ const r = await run(npmCmd, ['run', script], workspaceDir);
774
+ const out = (r.stdout + r.stderr).trim();
775
+
776
+ const reportsDir = path.join(workspaceDir, 'docs', 'reports');
777
+ const prefixMap = {
778
+ blockers: 'blockers-',
779
+ 'sm-weekly': 'sm-weekly-',
780
+ status: 'executive-',
781
+ daily: null
782
+ };
783
+ const prefix = prefixMap[script] || null;
784
+ const reportPath = prefix ? newestFile(reportsDir, prefix) : null;
785
+ const reportText = reportPath && exists(reportPath) ? fs.readFileSync(reportPath, 'utf8') : null;
786
+
787
+ // Prefer showing the actual report content when available.
788
+ const output = reportText ? reportText : out;
789
+
790
+ return safeJson(res, r.code === 0 ? 200 : 400, { output, reportPath, reportText });
791
+ }
792
+
793
+ if (req.url === '/api/publish') {
794
+ const webhookUrl = payload.webhookUrl;
795
+ const text = payload.text;
796
+ if (!webhookUrl) return safeJson(res, 400, { error: 'Missing webhookUrl' });
797
+ if (!text) return safeJson(res, 400, { error: 'Missing text' });
798
+
799
+ // Minimal webhook post: Discord expects {content}, Teams expects {text}
800
+ const u = new URL(webhookUrl);
801
+ const isDiscord = u.hostname.includes('discord.com') || u.hostname.includes('discordapp.com');
802
+ const body = JSON.stringify(isDiscord ? { content: text.slice(0, 1800) } : { text: text.slice(0, 1800) });
803
+
804
+ const options = {
805
+ method: 'POST',
806
+ hostname: u.hostname,
807
+ path: u.pathname + u.search,
808
+ headers: {
809
+ 'Content-Type': 'application/json',
810
+ 'Content-Length': Buffer.byteLength(body)
811
+ }
812
+ };
813
+
814
+ const proto = u.protocol === 'https:' ? require('https') : require('http');
815
+ const req2 = proto.request(options, (r2) => {
816
+ const chunks = [];
817
+ r2.on('data', (c) => chunks.push(c));
818
+ r2.on('end', () => {
819
+ if (r2.statusCode >= 200 && r2.statusCode < 300) return safeJson(res, 200, { ok: true });
820
+ return safeJson(res, 400, { error: `Webhook error ${r2.statusCode}: ${Buffer.concat(chunks).toString('utf8')}` });
821
+ });
822
+ });
823
+ req2.on('error', (e) => safeJson(res, 400, { error: e.message }));
824
+ req2.write(body);
825
+ req2.end();
826
+ return;
827
+ }
828
+
829
+ return safeJson(res, 404, { error: 'Not found' });
830
+ }
831
+
832
+ safeJson(res, 404, { error: 'Not found' });
833
+ } catch (e) {
834
+ safeJson(res, 500, { error: e.message || String(e) });
835
+ }
836
+ });
837
+
838
+ await new Promise((resolve) => server.listen(port, host, resolve));
839
+
840
+ const url = `http://${host}:${port}/`;
841
+
842
+ // Optional dev seed (safe: only creates files if missing)
843
+ if (dev) {
844
+ const target = dir ? path.resolve(process.cwd(), dir) : path.join(process.cwd(), 'freya');
845
+ try {
846
+ seedDevWorkspace(target);
847
+ process.stdout.write(`Dev seed: created demo files in ${target}\n`);
848
+ } catch (e) {
849
+ process.stdout.write(`Dev seed failed: ${e.message || String(e)}\n`);
850
+ }
851
+ }
852
+
853
+ process.stdout.write(`FREYA web running at ${url}\n`);
854
+ if (open) openBrowser(url);
855
+ }
856
+
857
+ module.exports = { cmdWeb };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cccarv82/freya",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Personal AI Assistant with local-first persistence",
5
5
  "scripts": {
6
6
  "health": "node scripts/validate-data.js",
@@ -10,7 +10,7 @@
10
10
  "daily": "node scripts/generate-daily-summary.js",
11
11
  "status": "node scripts/generate-executive-report.js",
12
12
  "blockers": "node scripts/generate-blockers-report.js",
13
- "test": "node tests/unit/test-package-config.js && node tests/unit/test-cli-init.js && node tests/unit/test-fs-utils.js && node tests/unit/test-task-schema.js && node tests/unit/test-daily-generation.js && node tests/unit/test-report-generation.js && node tests/unit/test-oracle-retrieval.js && node tests/unit/test-task-completion.js && node tests/unit/test-migrate-data.js && node tests/unit/test-blockers-validation.js && node tests/unit/test-blockers-report.js && node tests/unit/test-sm-weekly-report.js && node tests/integration/test-ingestor-task.js"
13
+ "test": "node tests/unit/test-package-config.js && node tests/unit/test-cli-init.js && node tests/unit/test-cli-web-help.js && node tests/unit/test-fs-utils.js && node tests/unit/test-task-schema.js && node tests/unit/test-daily-generation.js && node tests/unit/test-report-generation.js && node tests/unit/test-oracle-retrieval.js && node tests/unit/test-task-completion.js && node tests/unit/test-migrate-data.js && node tests/unit/test-blockers-validation.js && node tests/unit/test-blockers-report.js && node tests/unit/test-sm-weekly-report.js && node tests/integration/test-ingestor-task.js"
14
14
  },
15
15
  "keywords": [],
16
16
  "author": "",