morffy 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tally Barak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # morffy
2
+
3
+ A read-only visualizer that shows how a system-architecture diagram **evolves
4
+ over a timeline**. It pairs a Visio-style graph canvas with a Gantt-style
5
+ timeline, both driven by a single AI-generated JSON/YAML file.
6
+
7
+ See [`SPEC.md`](SPEC.md) for the full design.
8
+
9
+ ## Running the app
10
+
11
+ There are two ways to view a diagram.
12
+
13
+ ### 1. Browser file picker (no setup)
14
+
15
+ Open the app and click **Load file** (top bar or empty state) to pick a JSON or
16
+ YAML diagram from disk.
17
+
18
+ ### 2. CLI — serve a file by path (with live reload)
19
+
20
+ ```sh
21
+ morffy <diagram.json|yaml> [--port 4173] [--no-open] [--no-watch]
22
+ ```
23
+
24
+ The CLI serves the app and **auto-loads** the given file. It also **watches the
25
+ file** — edit it on disk and the diagram refreshes on screen automatically
26
+ (disable with `--no-watch`).
27
+
28
+ > [!IMPORTANT]
29
+ > **The CLI serves a built app, so a build must exist first.**
30
+ > When you install the published npm package this is already done for you — it
31
+ > ships a prebuilt `dist/`, so `npx morffy diagram.json` just works.
32
+ >
33
+ > When working **from this repo**, build first (the CLI will tell you if you
34
+ > forget):
35
+ >
36
+ > ```sh
37
+ > npm run serve -- diagram.json # build, then serve (one step)
38
+ > # or, if dist/ is already built:
39
+ > npm run serve:built -- diagram.json # serve without rebuilding
40
+ > ```
41
+ >
42
+ > Note: the CLI watches your **diagram file**, not the app source. After editing
43
+ > app source, rebuild (`npm run build`) to see those changes. For live app-source
44
+ > reloading, use the Vite dev server (`npm run dev`) instead.
45
+
46
+ ## Development
47
+
48
+ ```sh
49
+ npm install
50
+ npm run dev # vite dev server (http://localhost:5173)
51
+ ```
52
+
53
+ ### Quality gate
54
+
55
+ ```sh
56
+ npm run typecheck # tsc -b --noEmit
57
+ npm test # vitest (unit + CLI integration)
58
+ npm run test:e2e # playwright
59
+ npm run lint # eslint
60
+ npm run build # tsc -b && vite build
61
+ npm run format # prettier --write
62
+ ```
package/bin/morffy.js ADDED
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * morffy CLI — serve the built app against a diagram file by path.
4
+ *
5
+ * morffy <diagram.json|yaml> [--port 4173] [--no-open]
6
+ * morffy --file <path> [--port 4173]
7
+ *
8
+ * Morffy is a client-side SPA; there is no server-side rendering. This CLI is a
9
+ * tiny zero-dependency static server that:
10
+ *
11
+ * 1. serves the built `dist/` bundle,
12
+ * 2. exposes the chosen diagram file's RAW TEXT at `/__morffy/diagram`
13
+ * (with the original filename in a header so the app picks JSON vs YAML),
14
+ * 3. exposes `/__morffy/config.json` describing what to auto-load,
15
+ * 4. exposes `/__morffy/events` (Server-Sent Events) and watches the data file
16
+ * with chokidar — on a change it pushes a `reload` event so the app
17
+ * re-fetches and live-refreshes the diagram (disable with `--no-watch`).
18
+ *
19
+ * The app's boot effect (see AppShell) fetches `/__morffy/config.json`; when a
20
+ * diagram is configured it auto-loads it and subscribes to `/__morffy/events`.
21
+ * Under plain static hosting / dev those endpoints 404, so the app stays empty
22
+ * exactly as before.
23
+ */
24
+ import { createServer } from 'node:http';
25
+ import { readFile, stat } from 'node:fs/promises';
26
+ import { createReadStream } from 'node:fs';
27
+ import { extname, join, resolve, basename, dirname } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import chokidar from 'chokidar';
30
+
31
+ const __dirname = dirname(fileURLToPath(import.meta.url));
32
+ const DIST_DIR = resolve(__dirname, '..', 'dist');
33
+
34
+ const MIME = {
35
+ '.html': 'text/html; charset=utf-8',
36
+ '.js': 'text/javascript; charset=utf-8',
37
+ '.mjs': 'text/javascript; charset=utf-8',
38
+ '.css': 'text/css; charset=utf-8',
39
+ '.json': 'application/json; charset=utf-8',
40
+ '.svg': 'image/svg+xml',
41
+ '.png': 'image/png',
42
+ '.jpg': 'image/jpeg',
43
+ '.jpeg': 'image/jpeg',
44
+ '.gif': 'image/gif',
45
+ '.ico': 'image/x-icon',
46
+ '.woff': 'font/woff',
47
+ '.woff2': 'font/woff2',
48
+ '.ttf': 'font/ttf',
49
+ '.map': 'application/json; charset=utf-8',
50
+ };
51
+
52
+ function parseArgs(argv) {
53
+ const opts = { file: null, port: 4173, open: true, watch: true };
54
+ for (let i = 0; i < argv.length; i++) {
55
+ const a = argv[i];
56
+ if (a === '--help' || a === '-h') opts.help = true;
57
+ else if (a === '--version' || a === '-v') opts.version = true;
58
+ else if (a === '--port' || a === '-p') opts.port = Number(argv[++i]);
59
+ else if (a === '--no-open') opts.open = false;
60
+ else if (a === '--no-watch') opts.watch = false;
61
+ else if (a === '--file' || a === '-f') opts.file = argv[++i];
62
+ else if (a.startsWith('--port=')) opts.port = Number(a.slice('--port='.length));
63
+ else if (a.startsWith('--file=')) opts.file = a.slice('--file='.length);
64
+ else if (!a.startsWith('-') && !opts.file) opts.file = a;
65
+ }
66
+ return opts;
67
+ }
68
+
69
+ const HELP = `morffy — serve the architecture-timeline visualizer against a diagram file.
70
+
71
+ Usage:
72
+ morffy <diagram.json|yaml> [options]
73
+ morffy --file <path> [options]
74
+
75
+ Options:
76
+ -f, --file <path> Diagram file to load (JSON or YAML). Also positional.
77
+ -p, --port <n> Port to listen on (default: 4173).
78
+ --no-open Do not open the browser automatically.
79
+ --no-watch Do not live-reload the app when the file changes.
80
+ -h, --help Show this help.
81
+ -v, --version Print the morffy version.
82
+
83
+ Examples:
84
+ morffy ./architecture.json
85
+ morffy --file evolution.yaml --port 8080 --no-open
86
+ `;
87
+
88
+ function send(res, status, body, headers = {}) {
89
+ res.writeHead(status, { 'Cache-Control': 'no-store', ...headers });
90
+ res.end(body);
91
+ }
92
+
93
+ /** Resolve a URL path to a file inside dist/, guarding against traversal. */
94
+ function safeDistPath(urlPath) {
95
+ const clean = decodeURIComponent(urlPath.split('?')[0]).replace(/^\/+/, '');
96
+ const full = resolve(DIST_DIR, clean);
97
+ if (full !== DIST_DIR && !full.startsWith(DIST_DIR + '/')) return null; // traversal
98
+ return full;
99
+ }
100
+
101
+ async function main() {
102
+ const opts = parseArgs(process.argv.slice(2));
103
+
104
+ if (opts.help) {
105
+ process.stdout.write(HELP);
106
+ return;
107
+ }
108
+ if (opts.version) {
109
+ const pkg = JSON.parse(await readFile(resolve(__dirname, '..', 'package.json'), 'utf8'));
110
+ process.stdout.write(`${pkg.version}\n`);
111
+ return;
112
+ }
113
+
114
+ // The app must be built first.
115
+ try {
116
+ await stat(join(DIST_DIR, 'index.html'));
117
+ } catch {
118
+ process.stderr.write(
119
+ `morffy: no build found at ${DIST_DIR}.\n` +
120
+ `Build first, then serve: npm run build && npm run serve:built -- <file>\n` +
121
+ `Or do both in one step: npm run serve -- <file>\n` +
122
+ `(The published npm package ships a prebuilt dist, so end users never build.)\n`,
123
+ );
124
+ process.exitCode = 1;
125
+ return;
126
+ }
127
+
128
+ let dataPath = null;
129
+ let dataName = null;
130
+ if (opts.file) {
131
+ dataPath = resolve(process.cwd(), opts.file);
132
+ try {
133
+ await stat(dataPath);
134
+ } catch {
135
+ process.stderr.write(`morffy: diagram file not found: ${dataPath}\n`);
136
+ process.exitCode = 1;
137
+ return;
138
+ }
139
+ dataName = basename(dataPath);
140
+ }
141
+
142
+ // Open Server-Sent-Events connections, notified when the file changes.
143
+ const sseClients = new Set();
144
+ const broadcast = (event) => {
145
+ for (const client of sseClients) {
146
+ try {
147
+ client.write(`event: ${event}\ndata: ${dataName ?? ''}\n\n`);
148
+ } catch {
149
+ sseClients.delete(client);
150
+ }
151
+ }
152
+ };
153
+
154
+ const server = createServer(async (req, res) => {
155
+ try {
156
+ const url = req.url || '/';
157
+
158
+ // --- morffy control endpoints -------------------------------------
159
+ if (url === '/__morffy/config.json') {
160
+ return send(
161
+ res,
162
+ 200,
163
+ JSON.stringify({ autoLoad: Boolean(dataPath), filename: dataName, watch: opts.watch }),
164
+ { 'Content-Type': MIME['.json'] },
165
+ );
166
+ }
167
+ if (url.split('?')[0] === '/__morffy/events') {
168
+ res.writeHead(200, {
169
+ 'Content-Type': 'text/event-stream',
170
+ 'Cache-Control': 'no-store',
171
+ Connection: 'keep-alive',
172
+ });
173
+ res.write('event: ready\ndata: connected\n\n');
174
+ sseClients.add(res);
175
+ req.on('close', () => sseClients.delete(res));
176
+ return;
177
+ }
178
+ if (url.split('?')[0] === '/__morffy/diagram') {
179
+ if (!dataPath) return send(res, 404, 'no diagram configured');
180
+ const text = await readFile(dataPath);
181
+ const type = /\.ya?ml$/i.test(dataName) ? 'text/yaml; charset=utf-8' : MIME['.json'];
182
+ return send(res, 200, text, {
183
+ 'Content-Type': type,
184
+ 'X-Morffy-Filename': dataName,
185
+ });
186
+ }
187
+
188
+ // --- static dist/ assets ------------------------------------------
189
+ const pathname = url.split('?')[0];
190
+ let filePath = safeDistPath(pathname === '/' ? '/index.html' : pathname);
191
+ if (!filePath) return send(res, 403, 'forbidden');
192
+
193
+ let info = null;
194
+ try {
195
+ info = await stat(filePath);
196
+ } catch {
197
+ info = null;
198
+ }
199
+
200
+ // SPA fallback: unknown non-asset routes serve index.html.
201
+ if (!info || info.isDirectory()) {
202
+ filePath = join(DIST_DIR, 'index.html');
203
+ }
204
+
205
+ const type = MIME[extname(filePath).toLowerCase()] || 'application/octet-stream';
206
+ res.writeHead(200, { 'Content-Type': type });
207
+ createReadStream(filePath).pipe(res);
208
+ } catch (err) {
209
+ send(res, 500, `morffy: ${String(err)}`);
210
+ }
211
+ });
212
+
213
+ let watcher = null;
214
+ server.listen(opts.port, () => {
215
+ const at = `http://localhost:${opts.port}`;
216
+ if (dataPath) process.stdout.write(`morffy: serving ${dataName} at ${at}\n`);
217
+ else process.stdout.write(`morffy: serving (no diagram — use the Load file button) at ${at}\n`);
218
+
219
+ // Watch the data file and live-reload the app on every change.
220
+ if (dataPath && opts.watch) {
221
+ watcher = chokidar.watch(dataPath, {
222
+ ignoreInitial: true,
223
+ // Wait for the writer to finish (editors save in bursts) but stay snappy.
224
+ awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 },
225
+ });
226
+ watcher
227
+ .on('change', () => {
228
+ process.stdout.write(`morffy: ${dataName} changed — reloading\n`);
229
+ broadcast('reload');
230
+ })
231
+ .on('add', () => broadcast('reload')) // re-created after an atomic save
232
+ .on('error', () => {});
233
+ process.stdout.write('morffy: watching for changes (use --no-watch to disable)\n');
234
+ }
235
+
236
+ if (opts.open) openBrowser(at);
237
+ });
238
+
239
+ const shutdown = () => {
240
+ if (watcher) watcher.close();
241
+ server.close();
242
+ process.exit(0);
243
+ };
244
+ process.on('SIGINT', shutdown);
245
+ process.on('SIGTERM', shutdown);
246
+
247
+ server.on('error', (err) => {
248
+ if (err && err.code === 'EADDRINUSE') {
249
+ process.stderr.write(`morffy: port ${opts.port} is in use. Try --port <n>.\n`);
250
+ process.exitCode = 1;
251
+ } else {
252
+ process.stderr.write(`morffy: ${String(err)}\n`);
253
+ process.exitCode = 1;
254
+ }
255
+ });
256
+ }
257
+
258
+ /** Best-effort browser open; never fatal. */
259
+ function openBrowser(url) {
260
+ import('node:child_process')
261
+ .then(({ spawn }) => {
262
+ const cmd =
263
+ process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
264
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
265
+ spawn(cmd, args, { stdio: 'ignore', detached: true })
266
+ .on('error', () => {})
267
+ .unref();
268
+ })
269
+ .catch(() => {});
270
+ }
271
+
272
+ main();