archsync 1.0.0 → 1.0.1

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 (50) hide show
  1. package/README.md +67 -0
  2. package/dist/archsync.cjs +2 -0
  3. package/package.json +8 -4
  4. package/bin/cli.js +0 -91
  5. package/src/__tests__/e2e-workflow.test.js +0 -66
  6. package/src/__tests__/hashEngine.test.js +0 -109
  7. package/src/__tests__/impact.test.js +0 -137
  8. package/src/__tests__/parsers.test.js +0 -496
  9. package/src/__tests__/scan-pipeline.test.js +0 -332
  10. package/src/__tests__/schemaBuilder.test.js +0 -145
  11. package/src/__tests__/workspace.test.js +0 -178
  12. package/src/commands/backup.js +0 -54
  13. package/src/commands/connect.js +0 -129
  14. package/src/commands/diff.js +0 -228
  15. package/src/commands/export.js +0 -125
  16. package/src/commands/impactReport.js +0 -50
  17. package/src/commands/import.js +0 -126
  18. package/src/commands/init.js +0 -80
  19. package/src/commands/login.js +0 -116
  20. package/src/commands/plugin.js +0 -28
  21. package/src/commands/push.js +0 -194
  22. package/src/commands/register.js +0 -127
  23. package/src/commands/scan.js +0 -498
  24. package/src/commands/serve.js +0 -133
  25. package/src/commands/setup.js +0 -233
  26. package/src/commands/status.js +0 -56
  27. package/src/commands/validate.js +0 -245
  28. package/src/commands/watch.js +0 -70
  29. package/src/core/credentialStore.js +0 -76
  30. package/src/core/hashEngine.js +0 -34
  31. package/src/core/impactEngine.js +0 -192
  32. package/src/core/monorepoDetector.js +0 -41
  33. package/src/core/pluginManager.js +0 -40
  34. package/src/core/relationshipEngine.js +0 -917
  35. package/src/core/requestSigning.js +0 -16
  36. package/src/core/schemaBuilder.js +0 -230
  37. package/src/core/schemaDeduplicator.js +0 -54
  38. package/src/core/supabaseClient.js +0 -68
  39. package/src/core/workspaceDetector.js +0 -113
  40. package/src/parsers/astParser.js +0 -274
  41. package/src/parsers/configParser.js +0 -49
  42. package/src/parsers/dependencyGraph.js +0 -31
  43. package/src/parsers/flutterParser.js +0 -98
  44. package/src/parsers/goParser.js +0 -99
  45. package/src/parsers/index.js +0 -211
  46. package/src/parsers/javaParser.js +0 -89
  47. package/src/parsers/nodeParser.js +0 -429
  48. package/src/parsers/pythonParser.js +0 -109
  49. package/src/parsers/reactParser.js +0 -368
  50. package/src/parsers/smartComment.js +0 -144
@@ -1,498 +0,0 @@
1
- import chalk from 'chalk';
2
- import ora from 'ora';
3
- import fs from 'fs';
4
- import path from 'path';
5
- import { execSync } from 'child_process';
6
- import { glob } from 'glob';
7
- import { readConfig } from '../core/supabaseClient.js';
8
- import { parseFile, moduleFallbackEntity } from '../parsers/index.js';
9
- import { buildSchema } from '../core/schemaBuilder.js';
10
- import { buildRelationships } from '../core/relationshipEngine.js';
11
- import { hashSchema } from '../core/hashEngine.js';
12
- import {
13
- detectWorkspaceProjects,
14
- includePatternsFor,
15
- WORKSPACE_EXCLUDES,
16
- } from '../core/workspaceDetector.js';
17
- import { analyzeImpact, embedWarnings } from '../core/impactEngine.js';
18
- import { printImpactWarnings } from './impactReport.js';
19
-
20
- const LOCAL_SCHEMA_FILE = '.archsync-schema.json';
21
-
22
- /**
23
- * Run `git diff --name-only HEAD~1` in cwd and return absolute paths of changed files.
24
- * Returns null if git is unavailable or there is no history.
25
- */
26
- function getGitChangedFiles(cwd) {
27
- try {
28
- const output = execSync('git diff --name-only HEAD~1', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
29
- return output
30
- .split('\n')
31
- .map(f => f.trim())
32
- .filter(Boolean)
33
- .map(f => path.resolve(cwd, f));
34
- } catch {
35
- // No git history or git not available
36
- return null;
37
- }
38
- }
39
-
40
- export async function scanCommand(options) {
41
- const debug = options.debug || false;
42
- const incremental = options.incremental || false;
43
-
44
- console.log(chalk.blue.bold('\nšŸ” ArchSync CLI — Scanning Codebase\n'));
45
-
46
- if (options.workspace) {
47
- return workspaceScan(options);
48
- }
49
-
50
- const config = readConfig(options.dir);
51
- if (!config) {
52
- // No project config — if this directory holds several projects
53
- // (backend + app + admin …), fall through to workspace mode instead
54
- // of demanding an init nobody ran yet.
55
- const projects = detectWorkspaceProjects(path.resolve(options.dir || '.'));
56
- if (projects.length >= 2) {
57
- console.log(chalk.cyan('No .archsync.json found, but this looks like a multi-project workspace:'));
58
- for (const p of projects) {
59
- console.log(chalk.gray(` • ${p.name} (${p.framework}) → system "${p.system}"`));
60
- }
61
- console.log(chalk.cyan('Running workspace scan. Use `archsync scan --workspace` to do this explicitly.\n'));
62
- return workspaceScan(options);
63
- }
64
- console.log(chalk.red('āŒ No .archsync.json found. Run `archsync init` first.'));
65
- process.exit(1);
66
- }
67
-
68
- const cwd = path.resolve(options.dir || '.');
69
- const schemaPath = path.resolve(cwd, LOCAL_SCHEMA_FILE);
70
- const spinner = ora('Discovering files...').start();
71
-
72
- const includePatterns = config.scan?.include || [options.include || '**/*.{js,ts,jsx,tsx,dart,py,go,java,kt}'];
73
- const excludePatterns = config.scan?.exclude || [options.exclude || '**/node_modules/**'];
74
-
75
- // ─── Incremental mode ─────────────────────────────────────
76
- let incrementalMode = false;
77
- let cachedSchema = null;
78
- let changedFiles = null;
79
-
80
- if (incremental) {
81
- changedFiles = getGitChangedFiles(cwd);
82
- if (!changedFiles) {
83
- spinner.warn('No git history found — falling back to full scan.');
84
- } else if (changedFiles.length === 0) {
85
- spinner.info('No changed files since last commit. Schema is up to date.');
86
- return;
87
- } else if (fs.existsSync(schemaPath)) {
88
- try {
89
- cachedSchema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
90
- incrementalMode = true;
91
- spinner.text = `Incremental: scanning ${changedFiles.length} changed file(s)...`;
92
- } catch {
93
- spinner.warn('Cached schema unreadable — falling back to full scan.');
94
- }
95
- } else {
96
- spinner.warn('No cached schema found — falling back to full scan.');
97
- }
98
- }
99
-
100
- // ─── File discovery ────────────────────────────────────────
101
- let files = [];
102
-
103
- if (incrementalMode) {
104
- // Only scan the git-changed files that match include patterns
105
- const includeExts = /\.(js|ts|jsx|tsx|dart|prisma)$/;
106
- files = changedFiles.filter(f => {
107
- if (!includeExts.test(f)) return false;
108
- // Apply exclude patterns
109
- const rel = path.relative(cwd, f);
110
- const isExcluded = rel.includes('node_modules') || rel.includes('dist') || rel.includes('build');
111
- return !isExcluded;
112
- }).filter(f => fs.existsSync(f));
113
- } else {
114
- for (const pattern of includePatterns) {
115
- const matched = await glob(pattern, {
116
- cwd,
117
- ignore: excludePatterns,
118
- absolute: true,
119
- });
120
- files.push(...matched);
121
- }
122
- files = [...new Set(files)];
123
- }
124
-
125
- spinner.text = `Parsing ${files.length} files${incrementalMode ? ' (incremental)' : ''}...`;
126
-
127
- const parsedFiles = [];
128
- let errorCount = 0;
129
- const errorFiles = [];
130
-
131
- for (const file of files) {
132
- try {
133
- const result = parseFile(file, {
134
- ast: options.ast || config.scan?.ast,
135
- framework: config.framework,
136
- });
137
- // CodeSee-style: a source file with no recognised entities still
138
- // becomes a `module` node so import arrows can reach it.
139
- if (result.entities.length === 0) {
140
- const fallback = moduleFallbackEntity(file, config.defaultSystem);
141
- if (fallback) result.entities.push(fallback);
142
- }
143
- // `--system <label>` from init is AUTHORITATIVE for this project.
144
- // Parsers guess systems from path heuristics ('app', 'web', …) and
145
- // would otherwise scatter one project across several systems.
146
- // Genuine backend entities (route handlers, cloud functions) keep
147
- // their label so cross-system API matching still works.
148
- if (config.defaultSystem) {
149
- for (const entity of result.entities) {
150
- if (entity.system !== 'backend' || config.defaultSystem === 'backend') {
151
- entity.system = config.defaultSystem;
152
- }
153
- }
154
- }
155
- if (result.entities.length > 0 || result.relations.length > 0) {
156
- parsedFiles.push(result);
157
- }
158
- } catch (err) {
159
- errorCount++;
160
- errorFiles.push({ file, error: err.message });
161
- }
162
- }
163
-
164
- spinner.text = 'Building schema & relationships...';
165
-
166
- let schema;
167
-
168
- if (incrementalMode && cachedSchema) {
169
- // Merge: remove stale entries from changed files, then add freshly parsed ones
170
- const changedFileSet = new Set(changedFiles.map(f => f.replace(/\\/g, '/')));
171
-
172
- const staleNodeIds = new Set(
173
- (cachedSchema.nodes || [])
174
- .filter(n => {
175
- const src = n.metadata?.sourceFile || '';
176
- return changedFileSet.has(src.replace(/\\/g, '/'));
177
- })
178
- .map(n => n.id)
179
- );
180
-
181
- const survivingNodes = (cachedSchema.nodes || []).filter(n => !staleNodeIds.has(n.id));
182
- const survivingEdges = (cachedSchema.edges || []).filter(
183
- e => !staleNodeIds.has(e.source) && !staleNodeIds.has(e.target)
184
- );
185
-
186
- // Build a mini-schema from only the changed files. Cross-file
187
- // relations are resolved inside buildSchema against the fresh nodes.
188
- const freshRelations = buildRelationships(parsedFiles, debug);
189
- const freshSchema = buildSchema(parsedFiles, { ...config, debug }, freshRelations);
190
-
191
- schema = {
192
- ...cachedSchema,
193
- nodes: [...survivingNodes, ...freshSchema.nodes],
194
- edges: [...survivingEdges, ...freshSchema.edges],
195
- meta: {
196
- ...cachedSchema.meta,
197
- nodeCount: survivingNodes.length + freshSchema.nodes.length,
198
- edgeCount: survivingEdges.length + freshSchema.edges.length,
199
- incrementalAt: new Date().toISOString(),
200
- },
201
- };
202
- } else {
203
- // Infer cross-file relationships (route→controller→service→model)
204
- // first — PASS 0 composes public route paths that the schema builder
205
- // must see — then resolve every relation to node ids in buildSchema.
206
- const inferredRelations = buildRelationships(parsedFiles, debug);
207
- schema = buildSchema(parsedFiles, { ...config, debug }, inferredRelations);
208
- }
209
-
210
- schema.hash = hashSchema(schema);
211
-
212
- // Impact analysis against the previous snapshot. Warnings are embedded
213
- // AFTER hashing so an unchanged architecture keeps a stable hash.
214
- let previousSchema = null;
215
- if (fs.existsSync(schemaPath)) {
216
- try { previousSchema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8')); } catch { /* corrupt cache — skip */ }
217
- }
218
- const impactWarnings = analyzeImpact(previousSchema, schema);
219
- embedWarnings(schema, impactWarnings);
220
-
221
- // Save locally
222
- fs.writeFileSync(schemaPath, JSON.stringify(schema, null, 2));
223
-
224
- spinner.succeed(`Scan complete!${incrementalMode ? chalk.gray(' (incremental)') : ''}`);
225
-
226
- // ─── Summary Report ──────────────────────────────────────
227
- console.log(chalk.gray('\n╔══════════════════════════════════════╗'));
228
- console.log(chalk.gray('ā•‘') + chalk.white.bold(' Scan Results Summary ') + chalk.gray('ā•‘'));
229
- console.log(chalk.gray('╠══════════════════════════════════════╣'));
230
- console.log(chalk.gray('ā•‘') + chalk.white(` Files scanned: ${chalk.bold(String(files.length).padStart(6))} `) + chalk.gray('ā•‘'));
231
- console.log(chalk.gray('ā•‘') + chalk.white(` Files parsed: ${chalk.bold(String(parsedFiles.length).padStart(6))} `) + chalk.gray('ā•‘'));
232
- console.log(chalk.gray('ā•‘') + chalk.green(` Entities found: ${chalk.bold(String(schema.nodes.length).padStart(6))} `) + chalk.gray('ā•‘'));
233
- console.log(chalk.gray('ā•‘') + chalk.blue(` Relations: ${chalk.bold(String(schema.edges.length).padStart(6))} `) + chalk.gray('ā•‘'));
234
- if (incrementalMode) {
235
- console.log(chalk.gray('ā•‘') + chalk.cyan(` Mode: incremental `) + chalk.gray('ā•‘'));
236
- }
237
- if (errorCount > 0) {
238
- console.log(chalk.gray('ā•‘') + chalk.yellow(` Parse errors: ${chalk.bold(String(errorCount).padStart(6))} `) + chalk.gray('ā•‘'));
239
- }
240
- console.log(chalk.gray('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•'));
241
-
242
- // Entity breakdown by type
243
- const typeCounts = {};
244
- for (const node of schema.nodes) {
245
- typeCounts[node.entityType] = (typeCounts[node.entityType] || 0) + 1;
246
- }
247
- console.log(chalk.gray('\n─── Entity Breakdown ───────────────'));
248
- const typeIcons = {
249
- route: 'šŸ›£ ', controller: 'šŸŽ®', service: 'āš™ļø ', model: 'šŸ“¦', database: 'šŸ—„ ',
250
- middleware: 'šŸ›” ', mount: 'šŸ“Œ', screen: 'šŸ“±', widget: '🧩', api: '🌐',
251
- worker: 'šŸ‘·', trigger: '⚔', config: 'šŸ”§', module: 'šŸ“‚',
252
- };
253
- for (const [type, count] of Object.entries(typeCounts).sort((a, b) => b[1] - a[1])) {
254
- const icon = typeIcons[type] || ' ';
255
- console.log(chalk.white(` ${icon} ${type.padEnd(15)} ${chalk.bold(count)}`));
256
- }
257
-
258
- // Relation breakdown by type
259
- const relCounts = {};
260
- for (const edge of schema.edges) {
261
- relCounts[edge.relation] = (relCounts[edge.relation] || 0) + 1;
262
- }
263
- if (Object.keys(relCounts).length > 0) {
264
- console.log(chalk.gray('\n─── Relation Breakdown ─────────────'));
265
- const relIcons = {
266
- handles: '→ ', uses: '↗ ', queries: '⇢ ', extends: '⊃ ',
267
- protected_by: 'šŸ›” ', calls: 'šŸ“”', consumes: '↓ ', stored_in: 'šŸ’¾',
268
- };
269
- for (const [rel, count] of Object.entries(relCounts).sort((a, b) => b[1] - a[1])) {
270
- const icon = relIcons[rel] || ' ';
271
- console.log(chalk.white(` ${icon} ${rel.padEnd(15)} ${chalk.bold(count)}`));
272
- }
273
- }
274
-
275
- // Entity by system
276
- const sysCounts = {};
277
- for (const node of schema.nodes) {
278
- sysCounts[node.system] = (sysCounts[node.system] || 0) + 1;
279
- }
280
- if (Object.keys(sysCounts).length > 1) {
281
- console.log(chalk.gray('\n─── Entities by System ─────────────'));
282
- for (const [sys, count] of Object.entries(sysCounts).sort((a, b) => b[1] - a[1])) {
283
- console.log(chalk.white(` ${sys.padEnd(15)} ${chalk.bold(count)}`));
284
- }
285
- }
286
-
287
- // Debug output
288
- if (debug) {
289
- console.log(chalk.gray('\n─── Debug: Parse Errors ────────────'));
290
- if (errorFiles.length > 0) {
291
- for (const { file, error } of errorFiles) {
292
- console.log(chalk.red(` āœ— ${path.relative('.', file)}: ${error}`));
293
- }
294
- } else {
295
- console.log(chalk.green(' No parse errors'));
296
- }
297
-
298
- console.log(chalk.gray('\n─── Debug: Files with Entities ─────'));
299
- for (const pf of parsedFiles.slice(0, 50)) {
300
- const entTypes = [...new Set((pf.entities || []).map(e => e.entityType))].join(', ');
301
- console.log(chalk.gray(` ${path.relative('.', pf.filePath)} → ${chalk.white(entTypes)} (${pf.entities.length})`));
302
- }
303
- if (parsedFiles.length > 50) {
304
- console.log(chalk.gray(` ... and ${parsedFiles.length - 50} more files`));
305
- }
306
-
307
- console.log(chalk.gray('\n─── Debug: Sample Relations ────────'));
308
- for (const edge of schema.edges.slice(0, 30)) {
309
- const src = schema.nodes.find(n => n.id === edge.source);
310
- const tgt = schema.nodes.find(n => n.id === edge.target);
311
- console.log(chalk.gray(` ${src?.text || '?'} → ${tgt?.text || '?'} [${edge.relation}]`));
312
- }
313
- if (schema.edges.length > 30) {
314
- console.log(chalk.gray(` ... and ${schema.edges.length - 30} more edges`));
315
- }
316
- }
317
-
318
- printImpactWarnings(impactWarnings, { heading: 'Impact since last scan' });
319
-
320
- console.log(chalk.gray(`\n Schema saved to: ${schemaPath}`));
321
- console.log(chalk.gray(` Hash: ${schema.hash}`));
322
-
323
- if (schema.edges.length === 0) {
324
- console.log(chalk.yellow('\n⚠ No relations detected. Try running with --debug for details.'));
325
- }
326
-
327
- console.log(chalk.blue('\nNext: ') + chalk.gray('archsync push -b main'));
328
- }
329
-
330
- // ════════════════════════════════════════════════════════════════════
331
- // WORKSPACE SCAN — scan several sibling projects (Node backend, Flutter
332
- // app, React admin, …) in one pass and cross-link them.
333
- // ════════════════════════════════════════════════════════════════════
334
- async function workspaceScan(options) {
335
- const debug = options.debug || false;
336
- const cwd = path.resolve(options.dir || '.');
337
-
338
- const projects = detectWorkspaceProjects(cwd);
339
- if (projects.length === 0) {
340
- console.log(chalk.red('āŒ No projects found in this workspace.'));
341
- console.log(chalk.gray(' A project is a folder with a package.json or pubspec.yaml.'));
342
- process.exit(1);
343
- }
344
-
345
- console.log(chalk.white.bold('Workspace projects:'));
346
- for (const p of projects) {
347
- console.log(chalk.white(` šŸ“ ${p.name.padEnd(20)} ${chalk.gray(p.framework.padEnd(8))} → system ${chalk.cyan(p.system)}`));
348
- }
349
-
350
- const spinner = ora('Scanning projects...').start();
351
- const allParsed = [];
352
- let totalFiles = 0;
353
- let errorCount = 0;
354
- const errorFiles = [];
355
-
356
- for (const project of projects) {
357
- spinner.text = `Scanning ${project.name} (${project.framework})...`;
358
-
359
- let files = [];
360
- for (const pattern of includePatternsFor(project.framework)) {
361
- const matched = await glob(pattern, {
362
- cwd: project.path,
363
- ignore: WORKSPACE_EXCLUDES,
364
- absolute: true,
365
- });
366
- files.push(...matched);
367
- }
368
- files = [...new Set(files)];
369
- totalFiles += files.length;
370
-
371
- for (const file of files) {
372
- try {
373
- const result = parseFile(file, {
374
- ast: options.ast,
375
- framework: project.framework,
376
- });
377
- // CodeSee-style: every source file becomes at least a `module` node
378
- if (result.entities.length === 0) {
379
- const fallback = moduleFallbackEntity(file, project.system);
380
- if (fallback) result.entities.push(fallback);
381
- }
382
- if (result.entities.length > 0 || result.relations.length > 0) {
383
- // The workspace layout is authoritative for system labels —
384
- // path heuristics inside parsers can't know that ./admin is
385
- // the React admin and ./mobile_app the Flutter app.
386
- for (const entity of result.entities) entity.system = project.system;
387
- allParsed.push(result);
388
- }
389
- } catch (err) {
390
- errorCount++;
391
- errorFiles.push({ file, error: err.message });
392
- }
393
- }
394
- }
395
-
396
- spinner.text = 'Linking systems...';
397
- const relations = buildRelationships(allParsed, debug);
398
- const schema = buildSchema(allParsed, { defaultSystem: 'backend', debug }, relations);
399
- schema.meta.workspace = {
400
- root: cwd,
401
- projects: projects.map(p => ({ name: p.name, framework: p.framework, system: p.system })),
402
- };
403
- schema.hash = hashSchema(schema);
404
-
405
- // Impact analysis against the previous combined snapshot (embedded after
406
- // hashing so an unchanged architecture keeps a stable hash). Because the
407
- // workspace graph spans systems, a removed backend route flags the
408
- // Flutter screens and React pages that call it.
409
- const combinedPath = path.join(cwd, LOCAL_SCHEMA_FILE);
410
- let previousSchema = null;
411
- if (fs.existsSync(combinedPath)) {
412
- try { previousSchema = JSON.parse(fs.readFileSync(combinedPath, 'utf-8')); } catch { /* corrupt cache — skip */ }
413
- }
414
- const impactWarnings = analyzeImpact(previousSchema, schema);
415
- embedWarnings(schema, impactWarnings);
416
-
417
- // Combined schema (multiSystem: true → canvas honours per-node systems)
418
- fs.writeFileSync(combinedPath, JSON.stringify(schema, null, 2));
419
-
420
- // Per-system schemas for the canvas's multi-file workspace import
421
- const perSystemPaths = [];
422
- for (const system of schema.systems) {
423
- const sysNodes = schema.nodes.filter(n => n.system === system);
424
- const nodeIds = new Set(sysNodes.map(n => n.id));
425
- const sysEdges = schema.edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target));
426
- const sysSchema = {
427
- system,
428
- generatedBy: schema.meta?.source || 'archsync-cli',
429
- nodes: sysNodes,
430
- edges: sysEdges,
431
- // Warnings that originate in or impact this system, so a
432
- // single-system import still shows them in the right place.
433
- warnings: (schema.warnings || []).filter(
434
- w => w.system === system || (w.affectedSystems || []).includes(system)
435
- ),
436
- };
437
- const sysPath = path.join(cwd, `${system}.archsync-schema.json`);
438
- fs.writeFileSync(sysPath, JSON.stringify(sysSchema, null, 2));
439
- perSystemPaths.push(sysPath);
440
- }
441
-
442
- spinner.succeed('Workspace scan complete!');
443
-
444
- const crossEdges = schema.edges.filter(e => e.isCrossSystem);
445
-
446
- console.log(chalk.gray('\n╔══════════════════════════════════════╗'));
447
- console.log(chalk.gray('ā•‘') + chalk.white.bold(' Workspace Scan Summary ') + chalk.gray('ā•‘'));
448
- console.log(chalk.gray('╠══════════════════════════════════════╣'));
449
- console.log(chalk.gray('ā•‘') + chalk.white(` Projects: ${chalk.bold(String(projects.length).padStart(5))} `) + chalk.gray('ā•‘'));
450
- console.log(chalk.gray('ā•‘') + chalk.white(` Files scanned: ${chalk.bold(String(totalFiles).padStart(5))} `) + chalk.gray('ā•‘'));
451
- console.log(chalk.gray('ā•‘') + chalk.green(` Entities found: ${chalk.bold(String(schema.nodes.length).padStart(5))} `) + chalk.gray('ā•‘'));
452
- console.log(chalk.gray('ā•‘') + chalk.blue(` Relations: ${chalk.bold(String(schema.edges.length).padStart(5))} `) + chalk.gray('ā•‘'));
453
- console.log(chalk.gray('ā•‘') + chalk.magenta(` Cross-system: ${chalk.bold(String(crossEdges.length).padStart(5))} `) + chalk.gray('ā•‘'));
454
- if (errorCount > 0) {
455
- console.log(chalk.gray('ā•‘') + chalk.yellow(` Parse errors: ${chalk.bold(String(errorCount).padStart(5))} `) + chalk.gray('ā•‘'));
456
- }
457
- console.log(chalk.gray('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•'));
458
-
459
- // Per-system entity counts
460
- console.log(chalk.gray('\n─── Entities by System ─────────────'));
461
- for (const system of schema.systems) {
462
- const count = schema.nodes.filter(n => n.system === system).length;
463
- console.log(chalk.white(` ${system.padEnd(15)} ${chalk.bold(count)}`));
464
- }
465
-
466
- // Cross-system links are the headline feature — always show them
467
- if (crossEdges.length > 0) {
468
- console.log(chalk.gray('\n─── Cross-System Links ─────────────'));
469
- const nodeById = new Map(schema.nodes.map(n => [n.id, n]));
470
- for (const edge of crossEdges.slice(0, 20)) {
471
- const src = nodeById.get(edge.source);
472
- const tgt = nodeById.get(edge.target);
473
- console.log(chalk.magenta(` ${src?.system}:${src?.text} → ${tgt?.system}:${tgt?.text} [${edge.relation}]`));
474
- }
475
- if (crossEdges.length > 20) {
476
- console.log(chalk.gray(` ... and ${crossEdges.length - 20} more`));
477
- }
478
- } else {
479
- console.log(chalk.yellow('\n⚠ No cross-system links found. Frontend API calls may not match any backend route.'));
480
- console.log(chalk.gray(' Run with --debug to trace endpoint matching.'));
481
- }
482
-
483
- printImpactWarnings(impactWarnings, { heading: 'Impact since last scan' });
484
-
485
- if (debug && errorFiles.length > 0) {
486
- console.log(chalk.gray('\n─── Debug: Parse Errors ────────────'));
487
- for (const { file, error } of errorFiles) {
488
- console.log(chalk.red(` āœ— ${path.relative(cwd, file)}: ${error}`));
489
- }
490
- }
491
-
492
- console.log(chalk.gray(`\n Combined schema: ${combinedPath}`));
493
- for (const p of perSystemPaths) {
494
- console.log(chalk.gray(` Per-system schema: ${p}`));
495
- }
496
- console.log(chalk.gray(` Hash: ${schema.hash}`));
497
- console.log(chalk.blue('\nNext: ') + chalk.gray('open the ArchSync canvas and import the schema file(s), or `archsync push`.'));
498
- }
@@ -1,133 +0,0 @@
1
- import http from 'http';
2
- import fs from 'fs';
3
- import path from 'path';
4
- import chalk from 'chalk';
5
-
6
- const LOCAL_SCHEMA_FILE = '.archsync-schema.json';
7
- const DEFAULT_PORT = 4317;
8
-
9
- /**
10
- * archsync serve — the localhost code bridge.
11
- *
12
- * Lets the ArchSync canvas show REAL code for any scanned member without the
13
- * source ever leaving this machine:
14
- *
15
- * GET /v1/health → { ok, root }
16
- * GET /v1/schema → the local .archsync-schema.json
17
- * GET /v1/file?path=<abs>&from=<n>&to=<n> → { path, from, to, total, code }
18
- *
19
- * Security model (zero-trust by construction):
20
- * • Binds strictly to 127.0.0.1 — never reachable from another machine.
21
- * • Only serves files that appear in the scanned schema (allowlist),
22
- * so even local callers can't read arbitrary paths.
23
- * • Read-only: no mutation endpoints exist.
24
- * • CORS restricted to localhost origins (the canvas dev/prod ports).
25
- */
26
- export async function serveCommand(options) {
27
- const dir = path.resolve(options.dir || '.');
28
- const port = parseInt(options.port || DEFAULT_PORT, 10);
29
-
30
- // ── Build the file allowlist from the scanned schema ──────────────
31
- const schemaPath = path.join(dir, LOCAL_SCHEMA_FILE);
32
- let schema = null;
33
- const allowedFiles = new Set();
34
-
35
- const loadSchema = () => {
36
- try {
37
- schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
38
- allowedFiles.clear();
39
- for (const n of schema.nodes || []) {
40
- const sf = n.metadata?.sourceFile;
41
- if (sf) allowedFiles.add(path.resolve(sf));
42
- }
43
- return true;
44
- } catch {
45
- return false;
46
- }
47
- };
48
-
49
- if (!loadSchema()) {
50
- console.log(chalk.red(`\nāœ– No ${LOCAL_SCHEMA_FILE} found in ${dir}`));
51
- console.log(chalk.gray(' Run `archsync scan` first.\n'));
52
- process.exitCode = 1;
53
- return;
54
- }
55
-
56
- // Re-read the schema when it changes (after a re-scan)
57
- fs.watchFile(schemaPath, { interval: 2000 }, () => {
58
- if (loadSchema()) console.log(chalk.gray(' schema reloaded'));
59
- });
60
-
61
- const isLocalOrigin = (origin) =>
62
- !origin || /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
63
-
64
- const server = http.createServer((req, res) => {
65
- const origin = req.headers.origin;
66
- // CORS — localhost origins only
67
- if (isLocalOrigin(origin)) {
68
- res.setHeader('Access-Control-Allow-Origin', origin || '*');
69
- res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
70
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
71
- }
72
- if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
73
- if (req.method !== 'GET') { res.writeHead(405); res.end(); return; }
74
-
75
- const url = new URL(req.url, `http://127.0.0.1:${port}`);
76
- const json = (status, body) => {
77
- res.writeHead(status, { 'Content-Type': 'application/json' });
78
- res.end(JSON.stringify(body));
79
- };
80
-
81
- if (url.pathname === '/v1/health') {
82
- return json(200, { ok: true, root: dir, files: allowedFiles.size });
83
- }
84
-
85
- if (url.pathname === '/v1/schema') {
86
- return json(200, schema);
87
- }
88
-
89
- if (url.pathname === '/v1/file') {
90
- const reqPath = url.searchParams.get('path') || '';
91
- const resolved = path.resolve(reqPath);
92
- // Allowlist check — only scanned files, nothing else, ever.
93
- if (!allowedFiles.has(resolved)) {
94
- return json(403, { error: 'File is not part of the scanned schema' });
95
- }
96
- let source;
97
- try { source = fs.readFileSync(resolved, 'utf-8'); }
98
- catch { return json(404, { error: 'File not readable' }); }
99
-
100
- const lines = source.split('\n');
101
- const from = Math.max(1, parseInt(url.searchParams.get('from') || '1', 10));
102
- const to = Math.min(lines.length, parseInt(url.searchParams.get('to') || String(from + 30), 10));
103
- return json(200, {
104
- path: resolved,
105
- from,
106
- to,
107
- total: lines.length,
108
- code: lines.slice(from - 1, to).join('\n'),
109
- });
110
- }
111
-
112
- json(404, { error: 'Not found' });
113
- });
114
-
115
- // Strictly localhost — reject everything else by construction
116
- server.listen(port, '127.0.0.1', () => {
117
- console.log(chalk.blue.bold('\nšŸ”Œ ArchSync local code bridge\n'));
118
- console.log(` Listening on ${chalk.white(`http://127.0.0.1:${port}`)} ${chalk.gray('(localhost only)')}`);
119
- console.log(` Serving ${chalk.white(allowedFiles.size)} scanned files ${chalk.gray('(allowlisted, read-only)')}`);
120
- console.log(` Schema ${chalk.gray(schemaPath)}`);
121
- console.log(chalk.gray('\n The canvas at localhost:5173 will now show live code.'));
122
- console.log(chalk.gray(' Press Ctrl+C to stop.\n'));
123
- });
124
-
125
- server.on('error', (err) => {
126
- if (err.code === 'EADDRINUSE') {
127
- console.log(chalk.yellow(`\n⚠ Port ${port} is in use — is another serve running? Try --port ${port + 1}\n`));
128
- } else {
129
- console.log(chalk.red(`\nāœ– ${err.message}\n`));
130
- }
131
- process.exitCode = 1;
132
- });
133
- }