archsync 1.0.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/bin/cli.js +91 -0
- package/package.json +57 -0
- package/src/__tests__/e2e-workflow.test.js +66 -0
- package/src/__tests__/hashEngine.test.js +109 -0
- package/src/__tests__/impact.test.js +137 -0
- package/src/__tests__/parsers.test.js +496 -0
- package/src/__tests__/scan-pipeline.test.js +332 -0
- package/src/__tests__/schemaBuilder.test.js +145 -0
- package/src/__tests__/workspace.test.js +178 -0
- package/src/commands/backup.js +54 -0
- package/src/commands/connect.js +129 -0
- package/src/commands/diff.js +228 -0
- package/src/commands/export.js +125 -0
- package/src/commands/impactReport.js +50 -0
- package/src/commands/import.js +126 -0
- package/src/commands/init.js +80 -0
- package/src/commands/login.js +116 -0
- package/src/commands/plugin.js +28 -0
- package/src/commands/push.js +194 -0
- package/src/commands/register.js +127 -0
- package/src/commands/scan.js +498 -0
- package/src/commands/serve.js +133 -0
- package/src/commands/setup.js +233 -0
- package/src/commands/status.js +56 -0
- package/src/commands/validate.js +245 -0
- package/src/commands/watch.js +70 -0
- package/src/core/credentialStore.js +76 -0
- package/src/core/hashEngine.js +34 -0
- package/src/core/impactEngine.js +192 -0
- package/src/core/monorepoDetector.js +41 -0
- package/src/core/pluginManager.js +40 -0
- package/src/core/relationshipEngine.js +917 -0
- package/src/core/requestSigning.js +16 -0
- package/src/core/schemaBuilder.js +230 -0
- package/src/core/schemaDeduplicator.js +54 -0
- package/src/core/supabaseClient.js +68 -0
- package/src/core/workspaceDetector.js +113 -0
- package/src/parsers/astParser.js +274 -0
- package/src/parsers/configParser.js +49 -0
- package/src/parsers/dependencyGraph.js +31 -0
- package/src/parsers/flutterParser.js +98 -0
- package/src/parsers/goParser.js +99 -0
- package/src/parsers/index.js +211 -0
- package/src/parsers/javaParser.js +89 -0
- package/src/parsers/nodeParser.js +429 -0
- package/src/parsers/pythonParser.js +109 -0
- package/src/parsers/reactParser.js +368 -0
- package/src/parsers/smartComment.js +144 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { createClient } from '@supabase/supabase-js';
|
|
7
|
+
import { readConfig } from '../core/supabaseClient.js';
|
|
8
|
+
|
|
9
|
+
const ARCHSYNC_DIR = path.join(os.homedir(), '.archsync');
|
|
10
|
+
const GLOBAL_CONFIG_FILE = path.join(ARCHSYNC_DIR, 'config.json');
|
|
11
|
+
|
|
12
|
+
const REQUIRED_TABLES = ['projects', 'nodes', 'edges', 'schema_commits'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Lightweight schema migration list for the CLI.
|
|
16
|
+
* Each entry contains a unique id and SQL to run.
|
|
17
|
+
*/
|
|
18
|
+
const MIGRATIONS = [
|
|
19
|
+
{
|
|
20
|
+
id: '001_core_tables',
|
|
21
|
+
name: 'Core tables (projects, nodes, edges)',
|
|
22
|
+
sql: [
|
|
23
|
+
`CREATE TABLE IF NOT EXISTS projects (
|
|
24
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
25
|
+
name text NOT NULL,
|
|
26
|
+
owner_id uuid,
|
|
27
|
+
org_id uuid,
|
|
28
|
+
created_at timestamptz DEFAULT now()
|
|
29
|
+
)`,
|
|
30
|
+
`CREATE TABLE IF NOT EXISTS nodes (
|
|
31
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
32
|
+
project_id uuid REFERENCES projects(id) ON DELETE CASCADE,
|
|
33
|
+
user_id uuid,
|
|
34
|
+
org_id uuid,
|
|
35
|
+
entity_type text,
|
|
36
|
+
system text,
|
|
37
|
+
text text,
|
|
38
|
+
x float8 DEFAULT 0,
|
|
39
|
+
y float8 DEFAULT 0,
|
|
40
|
+
w float8 DEFAULT 160,
|
|
41
|
+
h float8 DEFAULT 60,
|
|
42
|
+
style jsonb DEFAULT '{}',
|
|
43
|
+
data jsonb DEFAULT '{}',
|
|
44
|
+
metadata jsonb DEFAULT '{}',
|
|
45
|
+
is_manual boolean DEFAULT false,
|
|
46
|
+
created_at timestamptz DEFAULT now(),
|
|
47
|
+
updated_at timestamptz DEFAULT now()
|
|
48
|
+
)`,
|
|
49
|
+
`CREATE TABLE IF NOT EXISTS edges (
|
|
50
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
51
|
+
project_id uuid REFERENCES projects(id) ON DELETE CASCADE,
|
|
52
|
+
user_id uuid,
|
|
53
|
+
org_id uuid,
|
|
54
|
+
source uuid,
|
|
55
|
+
target uuid,
|
|
56
|
+
relation text,
|
|
57
|
+
created_at timestamptz DEFAULT now()
|
|
58
|
+
)`,
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: '002_schema_commits',
|
|
63
|
+
name: 'Schema commits table',
|
|
64
|
+
sql: [
|
|
65
|
+
`CREATE TABLE IF NOT EXISTS schema_commits (
|
|
66
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
67
|
+
project_id uuid REFERENCES projects(id) ON DELETE CASCADE,
|
|
68
|
+
branch text NOT NULL DEFAULT 'main',
|
|
69
|
+
commit_hash text,
|
|
70
|
+
schema_json jsonb,
|
|
71
|
+
environment text DEFAULT 'dev',
|
|
72
|
+
created_by uuid,
|
|
73
|
+
created_at timestamptz DEFAULT now()
|
|
74
|
+
)`,
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: '003_migrations_tracking',
|
|
79
|
+
name: 'Migrations tracking table',
|
|
80
|
+
sql: [
|
|
81
|
+
`CREATE TABLE IF NOT EXISTS archsync_migrations (
|
|
82
|
+
id text PRIMARY KEY,
|
|
83
|
+
name text NOT NULL,
|
|
84
|
+
applied_at timestamptz DEFAULT now()
|
|
85
|
+
)`,
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
function loadGlobalConfig() {
|
|
91
|
+
if (!fs.existsSync(GLOBAL_CONFIG_FILE)) return null;
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(fs.readFileSync(GLOBAL_CONFIG_FILE, 'utf-8'));
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function getAppliedMigrations(client) {
|
|
100
|
+
const { data, error } = await client
|
|
101
|
+
.from('archsync_migrations')
|
|
102
|
+
.select('id');
|
|
103
|
+
if (error) return new Set();
|
|
104
|
+
return new Set((data || []).map(r => r.id));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function markApplied(client, id, name) {
|
|
108
|
+
await client.from('archsync_migrations').upsert({ id, name }, { onConflict: 'id' });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function checkTablesExist(client) {
|
|
112
|
+
const results = {};
|
|
113
|
+
for (const table of REQUIRED_TABLES) {
|
|
114
|
+
const { error } = await client.from(table).select('id').limit(0);
|
|
115
|
+
results[table] = !error || (error.code !== '42P01' && error.code !== 'PGRST200');
|
|
116
|
+
}
|
|
117
|
+
return results;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function setupCommand(options) {
|
|
121
|
+
console.log(chalk.blue.bold('\nš ArchSync CLI ā Database Setup (archsync db:setup)\n'));
|
|
122
|
+
|
|
123
|
+
// Resolve credentials: CLI flags > global config > local .archsync.json
|
|
124
|
+
let supabaseUrl = options.url;
|
|
125
|
+
let supabaseKey = options.key;
|
|
126
|
+
|
|
127
|
+
if (!supabaseUrl || !supabaseKey) {
|
|
128
|
+
const globalCfg = loadGlobalConfig();
|
|
129
|
+
if (globalCfg) {
|
|
130
|
+
supabaseUrl = supabaseUrl || globalCfg.supabaseUrl;
|
|
131
|
+
supabaseKey = supabaseKey || globalCfg.supabaseKey;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!supabaseUrl || !supabaseKey) {
|
|
136
|
+
const localCfg = readConfig(options.dir);
|
|
137
|
+
if (localCfg?.supabase) {
|
|
138
|
+
supabaseUrl = supabaseUrl || localCfg.supabase.url;
|
|
139
|
+
supabaseKey = supabaseKey || localCfg.supabase.anonKey;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!supabaseUrl || !supabaseKey) {
|
|
144
|
+
console.log(chalk.red('ā No Supabase credentials found.'));
|
|
145
|
+
console.log(chalk.yellow(' Run `archsync connect` first, or provide --url and --key flags.'));
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const spinner = ora('Connecting to databaseā¦').start();
|
|
150
|
+
|
|
151
|
+
let client;
|
|
152
|
+
try {
|
|
153
|
+
client = createClient(supabaseUrl.trim().replace(/\/$/, ''), supabaseKey.trim());
|
|
154
|
+
// Sanity-check connection
|
|
155
|
+
const { error } = await client.from('projects').select('id').limit(0);
|
|
156
|
+
if (error && error.code !== '42P01' && error.code !== 'PGRST200') {
|
|
157
|
+
// 42P01 = table doesn't exist yet, that's fine
|
|
158
|
+
throw new Error(error.message);
|
|
159
|
+
}
|
|
160
|
+
spinner.succeed('Connected to database');
|
|
161
|
+
} catch (err) {
|
|
162
|
+
spinner.fail('Failed to connect');
|
|
163
|
+
console.log(chalk.red(`\n Error: ${err.message}`));
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// āāā Schema check āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
168
|
+
const spinner2 = ora('Checking schemaā¦').start();
|
|
169
|
+
const tableStatus = await checkTablesExist(client);
|
|
170
|
+
const missing = REQUIRED_TABLES.filter(t => !tableStatus[t]);
|
|
171
|
+
if (missing.length === 0) {
|
|
172
|
+
spinner2.succeed('Schema looks good ā all required tables exist');
|
|
173
|
+
} else {
|
|
174
|
+
spinner2.warn(`Missing tables: ${missing.join(', ')}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// āāā Run migrations āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
178
|
+
const spinner3 = ora('Running pending migrationsā¦').start();
|
|
179
|
+
|
|
180
|
+
let appliedIds;
|
|
181
|
+
try {
|
|
182
|
+
appliedIds = await getAppliedMigrations(client);
|
|
183
|
+
} catch {
|
|
184
|
+
appliedIds = new Set();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let appliedCount = 0;
|
|
188
|
+
let skippedCount = 0;
|
|
189
|
+
const errors = [];
|
|
190
|
+
|
|
191
|
+
for (const migration of MIGRATIONS) {
|
|
192
|
+
if (appliedIds.has(migration.id)) {
|
|
193
|
+
skippedCount++;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
// Execute each SQL statement
|
|
199
|
+
for (const sql of migration.sql) {
|
|
200
|
+
const { error } = await client.rpc('exec_sql', { sql }).catch(() => ({ error: null }));
|
|
201
|
+
if (error) {
|
|
202
|
+
console.warn(chalk.yellow(`\n Warning: rpc exec_sql failed for ${migration.id}: ${error.message}`));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
await markApplied(client, migration.id, migration.name).catch(() => {});
|
|
206
|
+
appliedCount++;
|
|
207
|
+
} catch (err) {
|
|
208
|
+
errors.push({ id: migration.id, error: err.message });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (errors.length > 0) {
|
|
213
|
+
spinner3.warn(`Migrations completed with ${errors.length} error(s)`);
|
|
214
|
+
for (const { id, error } of errors) {
|
|
215
|
+
console.log(chalk.red(` ā ${id}: ${error}`));
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
spinner3.succeed(`Migrations complete ā ${appliedCount} applied, ${skippedCount} skipped`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// āāā Summary āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
222
|
+
console.log(chalk.gray('\nāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā'));
|
|
223
|
+
console.log(chalk.gray('ā') + chalk.white.bold(' Database Setup Summary ') + chalk.gray('ā'));
|
|
224
|
+
console.log(chalk.gray('ā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā£'));
|
|
225
|
+
for (const table of REQUIRED_TABLES) {
|
|
226
|
+
const status = tableStatus[table] ? chalk.green('ā') : chalk.yellow('?');
|
|
227
|
+
console.log(chalk.gray('ā') + ` ${status} ${table.padEnd(30)} ` + chalk.gray('ā'));
|
|
228
|
+
}
|
|
229
|
+
console.log(chalk.gray('ā') + chalk.green(` Migrations applied: ${String(appliedCount).padStart(3)} `) + chalk.gray('ā'));
|
|
230
|
+
console.log(chalk.gray('āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā'));
|
|
231
|
+
|
|
232
|
+
console.log(chalk.blue('\nNext: ') + chalk.gray('archsync scan && archsync push'));
|
|
233
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { readConfig, getSupabaseClient, fetchLatestSchema } from '../core/supabaseClient.js';
|
|
5
|
+
import { hashSchema, schemasEqual } from '../core/hashEngine.js';
|
|
6
|
+
|
|
7
|
+
const LOCAL_SCHEMA_FILE = '.archsync-schema.json';
|
|
8
|
+
|
|
9
|
+
export async function statusCommand() {
|
|
10
|
+
console.log(chalk.blue.bold('\nš ArchSync CLI ā Status\n'));
|
|
11
|
+
|
|
12
|
+
const config = readConfig();
|
|
13
|
+
if (!config) {
|
|
14
|
+
console.log(chalk.red('ā No .archsync.json found. Run `archsync init` first.'));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
console.log(chalk.gray('āāā Configuration āāāāāāāāāāāāāāāāāā'));
|
|
19
|
+
console.log(chalk.white(` Framework: ${chalk.bold(config.framework)}`));
|
|
20
|
+
console.log(chalk.white(` Project ID: ${chalk.bold(config.projectId || 'Not linked')}`));
|
|
21
|
+
console.log(chalk.white(` Supabase: ${config.supabase?.url ? chalk.green('Configured') : chalk.yellow('Not configured')}`));
|
|
22
|
+
console.log(chalk.white(` AST Mode: ${config.scan?.ast ? chalk.green('Enabled') : chalk.gray('Disabled')}`));
|
|
23
|
+
|
|
24
|
+
const schemaPath = path.resolve(LOCAL_SCHEMA_FILE);
|
|
25
|
+
if (fs.existsSync(schemaPath)) {
|
|
26
|
+
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
|
|
27
|
+
console.log(chalk.gray('\nāāā Local Schema āāāāāāāāāāāāāāāāāāā'));
|
|
28
|
+
console.log(chalk.white(` Nodes: ${chalk.bold(schema.nodes?.length || 0)}`));
|
|
29
|
+
console.log(chalk.white(` Edges: ${chalk.bold(schema.edges?.length || 0)}`));
|
|
30
|
+
console.log(chalk.white(` Hash: ${chalk.gray(schema.hash || 'N/A')}`));
|
|
31
|
+
console.log(chalk.white(` Generated: ${chalk.gray(schema.meta?.generatedAt || 'Unknown')}`));
|
|
32
|
+
|
|
33
|
+
// Check sync status
|
|
34
|
+
if (config.projectId && config.supabase?.url) {
|
|
35
|
+
try {
|
|
36
|
+
const client = getSupabaseClient(config);
|
|
37
|
+
const remote = await fetchLatestSchema(client, config.projectId);
|
|
38
|
+
if (remote) {
|
|
39
|
+
const inSync = schemasEqual(schema, remote);
|
|
40
|
+
console.log(chalk.gray('\nāāā Sync Status āāāāāāāāāāāāāāāāāāāā'));
|
|
41
|
+
console.log(chalk.white(` Status: ${inSync ? chalk.green.bold('ā
In Sync') : chalk.yellow.bold('ā Out of Sync')}`));
|
|
42
|
+
} else {
|
|
43
|
+
console.log(chalk.gray('\nāāā Sync Status āāāāāāāāāāāāāāāāāāāā'));
|
|
44
|
+
console.log(chalk.yellow(` Status: No remote schema (run archsync push)`));
|
|
45
|
+
}
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.log(chalk.gray('\nāāā Sync Status āāāāāāāāāāāāāāāāāāāā'));
|
|
48
|
+
console.log(chalk.red(` Status: Error: ${err.message}`));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
console.log(chalk.gray('\n No local schema. Run `archsync scan` first.'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log('');
|
|
56
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
const LOCAL_SCHEMA_FILE = '.archsync-schema.json';
|
|
6
|
+
|
|
7
|
+
// āāā Validation rules āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
8
|
+
|
|
9
|
+
function findOrphanNodes(nodes, edges) {
|
|
10
|
+
const connected = new Set();
|
|
11
|
+
for (const edge of edges) {
|
|
12
|
+
connected.add(edge.source);
|
|
13
|
+
connected.add(edge.target);
|
|
14
|
+
}
|
|
15
|
+
return nodes.filter(n => !connected.has(n.id));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function findDuplicateEntities(nodes) {
|
|
19
|
+
const seen = new Map(); // name+type ā first node
|
|
20
|
+
const duplicates = [];
|
|
21
|
+
for (const node of nodes) {
|
|
22
|
+
const key = `${node.entityType}::${node.text || node.name || node.id}`;
|
|
23
|
+
if (seen.has(key)) {
|
|
24
|
+
duplicates.push({ original: seen.get(key), duplicate: node });
|
|
25
|
+
} else {
|
|
26
|
+
seen.set(key, node);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return duplicates;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function findCircularDependencies(nodes, edges) {
|
|
33
|
+
// Build adjacency list
|
|
34
|
+
const adj = new Map();
|
|
35
|
+
for (const node of nodes) adj.set(node.id, []);
|
|
36
|
+
for (const edge of edges) {
|
|
37
|
+
if (adj.has(edge.source)) {
|
|
38
|
+
adj.get(edge.source).push(edge.target);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const visited = new Set();
|
|
43
|
+
const inStack = new Set();
|
|
44
|
+
const cycles = [];
|
|
45
|
+
|
|
46
|
+
function dfs(id, stack) {
|
|
47
|
+
if (inStack.has(id)) {
|
|
48
|
+
// Found a cycle ā record the cycle path
|
|
49
|
+
const cycleStart = stack.indexOf(id);
|
|
50
|
+
cycles.push(stack.slice(cycleStart));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (visited.has(id)) return;
|
|
54
|
+
visited.add(id);
|
|
55
|
+
inStack.add(id);
|
|
56
|
+
stack.push(id);
|
|
57
|
+
for (const neighbour of (adj.get(id) || [])) {
|
|
58
|
+
dfs(neighbour, stack);
|
|
59
|
+
}
|
|
60
|
+
stack.pop();
|
|
61
|
+
inStack.delete(id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const node of nodes) {
|
|
65
|
+
if (!visited.has(node.id)) {
|
|
66
|
+
dfs(node.id, []);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Deduplicate cycles by sorted key
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
return cycles.filter(cycle => {
|
|
73
|
+
const key = [...cycle].sort().join(',');
|
|
74
|
+
if (seen.has(key)) return false;
|
|
75
|
+
seen.add(key);
|
|
76
|
+
return true;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function findMissingRequiredFields(nodes) {
|
|
81
|
+
const missing = [];
|
|
82
|
+
for (const node of nodes) {
|
|
83
|
+
const problems = [];
|
|
84
|
+
if (!node.id) problems.push('id');
|
|
85
|
+
if (!node.entityType) problems.push('entityType');
|
|
86
|
+
if (!node.system) problems.push('system');
|
|
87
|
+
if (!node.text && !node.name) problems.push('text/name');
|
|
88
|
+
if (problems.length > 0) {
|
|
89
|
+
missing.push({ node, missingFields: problems });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return missing;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function findDanglingEdges(nodes, edges) {
|
|
96
|
+
const nodeIds = new Set(nodes.map(n => n.id));
|
|
97
|
+
return edges.filter(e => !nodeIds.has(e.source) || !nodeIds.has(e.target));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// āāā Reporting helpers āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
101
|
+
|
|
102
|
+
function nodeLabel(node) {
|
|
103
|
+
const name = node.text || node.name || node.id || '?';
|
|
104
|
+
const type = node.entityType || 'unknown';
|
|
105
|
+
return `[${type}] ${name}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// āāā Command āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
109
|
+
|
|
110
|
+
export async function validateCommand(options) {
|
|
111
|
+
console.log(chalk.blue.bold('\nā
ArchSync CLI ā Schema Validation\n'));
|
|
112
|
+
|
|
113
|
+
const schemaPath = path.resolve(LOCAL_SCHEMA_FILE);
|
|
114
|
+
if (!fs.existsSync(schemaPath)) {
|
|
115
|
+
console.log(chalk.red('ā No local schema found. Run `archsync scan` first.'));
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let schema;
|
|
120
|
+
try {
|
|
121
|
+
schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.log(chalk.red(`ā Failed to parse schema file: ${err.message}`));
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const nodes = schema.nodes || [];
|
|
128
|
+
const edges = schema.edges || [];
|
|
129
|
+
|
|
130
|
+
const warnings = [];
|
|
131
|
+
const errors = [];
|
|
132
|
+
|
|
133
|
+
// āā Rule 1: Missing required fields āāāāāāāāāāāāāāāāāāāāāā
|
|
134
|
+
const missingFields = findMissingRequiredFields(nodes);
|
|
135
|
+
for (const { node, missingFields: fields } of missingFields) {
|
|
136
|
+
errors.push({
|
|
137
|
+
rule: 'missing-required-fields',
|
|
138
|
+
message: `Node is missing required field(s): ${fields.join(', ')}`,
|
|
139
|
+
node,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// āā Rule 2: Duplicate entities āāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
144
|
+
const duplicates = findDuplicateEntities(nodes);
|
|
145
|
+
for (const { original, duplicate } of duplicates) {
|
|
146
|
+
warnings.push({
|
|
147
|
+
rule: 'duplicate-entity',
|
|
148
|
+
message: `Duplicate entity: ${nodeLabel(duplicate)} (also at ${original.metadata?.sourceFile || '?'})`,
|
|
149
|
+
node: duplicate,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// āā Rule 3: Orphan nodes āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
154
|
+
if (nodes.length > 1) {
|
|
155
|
+
const orphans = findOrphanNodes(nodes, edges);
|
|
156
|
+
for (const node of orphans) {
|
|
157
|
+
warnings.push({
|
|
158
|
+
rule: 'orphan-node',
|
|
159
|
+
message: `Orphan node with no edges: ${nodeLabel(node)}`,
|
|
160
|
+
node,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// āā Rule 4: Circular dependencies āāāāāāāāāāāāāāāāāāāāāāāāā
|
|
166
|
+
const cycles = findCircularDependencies(nodes, edges);
|
|
167
|
+
for (const cycle of cycles) {
|
|
168
|
+
const names = cycle.map(id => {
|
|
169
|
+
const n = nodes.find(n => n.id === id);
|
|
170
|
+
return n ? (n.text || n.name || id) : id;
|
|
171
|
+
});
|
|
172
|
+
warnings.push({
|
|
173
|
+
rule: 'circular-dependency',
|
|
174
|
+
message: `Circular dependency detected: ${names.join(' ā ')} ā ${names[0]}`,
|
|
175
|
+
cycle,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// āā Rule 5: Dangling edges (edges pointing to missing nodes)
|
|
180
|
+
const dangling = findDanglingEdges(nodes, edges);
|
|
181
|
+
for (const edge of dangling) {
|
|
182
|
+
errors.push({
|
|
183
|
+
rule: 'dangling-edge',
|
|
184
|
+
message: `Edge references missing node(s): ${edge.source} ā ${edge.target} [${edge.relation}]`,
|
|
185
|
+
edge,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// āā Rule 6: Empty schema āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
190
|
+
if (nodes.length === 0) {
|
|
191
|
+
warnings.push({
|
|
192
|
+
rule: 'empty-schema',
|
|
193
|
+
message: 'Schema has no nodes. Run `archsync scan` to populate it.',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// āāā Report āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
198
|
+
console.log(chalk.gray('āāā Validation Report āāāāāāāāāāāāāāāāāāāāāāāāāāāāā'));
|
|
199
|
+
console.log(chalk.gray(` Nodes checked: ${nodes.length}`));
|
|
200
|
+
console.log(chalk.gray(` Edges checked: ${edges.length}`));
|
|
201
|
+
console.log('');
|
|
202
|
+
|
|
203
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
204
|
+
console.log(chalk.green(' Schema is valid. No issues found.'));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (warnings.length > 0) {
|
|
208
|
+
console.log(chalk.yellow(` WARNINGS (${warnings.length})`));
|
|
209
|
+
console.log(chalk.yellow(' ' + 'ā'.repeat(46)));
|
|
210
|
+
for (const w of warnings) {
|
|
211
|
+
console.log(chalk.yellow(` ā [${w.rule}] ${w.message}`));
|
|
212
|
+
if (w.node?.metadata?.sourceFile) {
|
|
213
|
+
console.log(chalk.gray(` at: ${w.node.metadata.sourceFile}`));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
console.log('');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (errors.length > 0) {
|
|
220
|
+
console.log(chalk.red(` ERRORS (${errors.length})`));
|
|
221
|
+
console.log(chalk.red(' ' + 'ā'.repeat(46)));
|
|
222
|
+
for (const e of errors) {
|
|
223
|
+
console.log(chalk.red(` ā [${e.rule}] ${e.message}`));
|
|
224
|
+
if (e.node?.metadata?.sourceFile) {
|
|
225
|
+
console.log(chalk.gray(` at: ${e.node.metadata.sourceFile}`));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
console.log('');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// āāā Summary āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
232
|
+
console.log(chalk.gray('āāā Summary āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā'));
|
|
233
|
+
console.log(chalk.yellow(` Warnings: ${warnings.length}`));
|
|
234
|
+
console.log(errors.length > 0
|
|
235
|
+
? chalk.red(` Errors: ${errors.length}`)
|
|
236
|
+
: chalk.green(` Errors: ${errors.length}`)
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
if (errors.length > 0) {
|
|
240
|
+
console.log(chalk.red('\n Validation failed. Fix errors before pushing.\n'));
|
|
241
|
+
process.exit(1);
|
|
242
|
+
} else {
|
|
243
|
+
console.log(chalk.green('\n Validation passed.\n'));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { watch } from 'chokidar';
|
|
5
|
+
import { readConfig } from '../core/supabaseClient.js';
|
|
6
|
+
import { scanCommand } from './scan.js';
|
|
7
|
+
|
|
8
|
+
export async function watchCommand(options) {
|
|
9
|
+
console.log(chalk.blue.bold('\nšļø ArchSync CLI ā Watch Mode\n'));
|
|
10
|
+
|
|
11
|
+
const config = readConfig(options.dir);
|
|
12
|
+
if (!config) {
|
|
13
|
+
console.log(chalk.red('ā No .archsync.json found. Run `archsync init` first.'));
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const debounce = parseInt(options.debounce || '2000', 10);
|
|
18
|
+
const watchDir = path.resolve(options.dir || '.');
|
|
19
|
+
const includePatterns = config.scan?.include || ['**/*.{js,ts,jsx,tsx,dart}'];
|
|
20
|
+
const excludePatterns = config.scan?.exclude || ['**/node_modules/**'];
|
|
21
|
+
|
|
22
|
+
console.log(chalk.gray(` Watching: ${watchDir}`));
|
|
23
|
+
console.log(chalk.gray(` Debounce: ${debounce}ms`));
|
|
24
|
+
console.log(chalk.gray(` Press Ctrl+C to stop\n`));
|
|
25
|
+
|
|
26
|
+
let timer = null;
|
|
27
|
+
let scanning = false;
|
|
28
|
+
|
|
29
|
+
const triggerScan = () => {
|
|
30
|
+
if (scanning) return;
|
|
31
|
+
if (timer) clearTimeout(timer);
|
|
32
|
+
|
|
33
|
+
timer = setTimeout(async () => {
|
|
34
|
+
scanning = true;
|
|
35
|
+
console.log(chalk.gray(`\n[${new Date().toLocaleTimeString()}] Change detected, rescanning...`));
|
|
36
|
+
try {
|
|
37
|
+
await scanCommand({ dir: options.dir || '.', ast: config.scan?.ast });
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.log(chalk.red(` Scan error: ${err.message}`));
|
|
40
|
+
}
|
|
41
|
+
scanning = false;
|
|
42
|
+
}, debounce);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const watcher = watch(includePatterns, {
|
|
46
|
+
cwd: watchDir,
|
|
47
|
+
ignored: excludePatterns,
|
|
48
|
+
ignoreInitial: true,
|
|
49
|
+
persistent: true,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
watcher
|
|
53
|
+
.on('add', (filePath) => {
|
|
54
|
+
console.log(chalk.green(` + ${filePath}`));
|
|
55
|
+
triggerScan();
|
|
56
|
+
})
|
|
57
|
+
.on('change', (filePath) => {
|
|
58
|
+
console.log(chalk.yellow(` ~ ${filePath}`));
|
|
59
|
+
triggerScan();
|
|
60
|
+
})
|
|
61
|
+
.on('unlink', (filePath) => {
|
|
62
|
+
console.log(chalk.red(` - ${filePath}`));
|
|
63
|
+
triggerScan();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Initial scan
|
|
67
|
+
console.log(chalk.gray('Running initial scan...'));
|
|
68
|
+
await scanCommand({ dir: options.dir || '.', ast: config.scan?.ast });
|
|
69
|
+
console.log(chalk.green('\nā
Watching for changes...'));
|
|
70
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
const ARCHSYNC_DIR = path.join(os.homedir(), '.archsync');
|
|
6
|
+
const CREDENTIALS_FILE = path.join(ARCHSYNC_DIR, 'credentials.json');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ensures ~/.archsync/ directory exists.
|
|
10
|
+
*/
|
|
11
|
+
function ensureDir() {
|
|
12
|
+
if (!fs.existsSync(ARCHSYNC_DIR)) {
|
|
13
|
+
fs.mkdirSync(ARCHSYNC_DIR, { recursive: true, mode: 0o700 });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Save credentials to ~/.archsync/credentials.json.
|
|
19
|
+
* @param {string} apiKey - Supabase access token or API key
|
|
20
|
+
* @param {string} userId - User UUID
|
|
21
|
+
* @param {string} email - User email (optional, for display)
|
|
22
|
+
*/
|
|
23
|
+
export function saveCredentials(apiKey, userId, email = '') {
|
|
24
|
+
ensureDir();
|
|
25
|
+
const credentials = {
|
|
26
|
+
apiKey,
|
|
27
|
+
userId,
|
|
28
|
+
email,
|
|
29
|
+
savedAt: new Date().toISOString(),
|
|
30
|
+
};
|
|
31
|
+
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Load credentials from ~/.archsync/credentials.json.
|
|
36
|
+
* Returns null if file doesn't exist or is malformed.
|
|
37
|
+
* @returns {{ apiKey: string, userId: string, email: string, savedAt: string } | null}
|
|
38
|
+
*/
|
|
39
|
+
export function loadCredentials() {
|
|
40
|
+
if (!fs.existsSync(CREDENTIALS_FILE)) return null;
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf-8'));
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Remove ~/.archsync/credentials.json (logout).
|
|
50
|
+
*/
|
|
51
|
+
export function clearCredentials() {
|
|
52
|
+
if (fs.existsSync(CREDENTIALS_FILE)) {
|
|
53
|
+
fs.unlinkSync(CREDENTIALS_FILE);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check if a valid credential exists (either stored file or env var).
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
export function isAuthenticated() {
|
|
62
|
+
if (process.env.ARCHSYNC_API_KEY) return true;
|
|
63
|
+
const creds = loadCredentials();
|
|
64
|
+
return !!(creds && creds.apiKey);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the API key from env or stored credentials.
|
|
69
|
+
* Env var ARCHSYNC_API_KEY takes precedence (for CI).
|
|
70
|
+
* @returns {string | null}
|
|
71
|
+
*/
|
|
72
|
+
export function getApiKey() {
|
|
73
|
+
if (process.env.ARCHSYNC_API_KEY) return process.env.ARCHSYNC_API_KEY;
|
|
74
|
+
const creds = loadCredentials();
|
|
75
|
+
return creds?.apiKey || null;
|
|
76
|
+
}
|