graphlin 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/.claude-plugin/plugin.json +12 -0
- package/.codex-plugin/plugin.json +29 -0
- package/.mcp.json +9 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/adapters/README.md +32 -0
- package/adapters/claude/hooks.json +10 -0
- package/adapters/claude/profile.json +18 -0
- package/adapters/codex/hooks.json +9 -0
- package/adapters/codex/profile.json +22 -0
- package/adapters/kiro/profile.json +8 -0
- package/mcp.json +11 -0
- package/package.json +114 -0
- package/plugin.json +20 -0
- package/runtime/collector/index.mjs +23 -0
- package/runtime/core/candidates.mjs +300 -0
- package/runtime/core/common.mjs +69 -0
- package/runtime/core/evidence.mjs +150 -0
- package/runtime/core/graph.mjs +398 -0
- package/runtime/core/index.mjs +4 -0
- package/runtime/core/lexical.mjs +255 -0
- package/runtime/core/privacy.mjs +206 -0
- package/runtime/core/tool-discovery.mjs +122 -0
- package/runtime/daemon/auth.mjs +50 -0
- package/runtime/daemon/connection-info.mjs +249 -0
- package/runtime/daemon/demo.mjs +195 -0
- package/runtime/daemon/diagnostics.mjs +404 -0
- package/runtime/daemon/export.mjs +7 -0
- package/runtime/daemon/ipc.mjs +28 -0
- package/runtime/daemon/lock.mjs +137 -0
- package/runtime/daemon/manager.mjs +320 -0
- package/runtime/daemon/paths.mjs +108 -0
- package/runtime/daemon/persistence.mjs +64 -0
- package/runtime/daemon/server.mjs +292 -0
- package/runtime/daemon/settings.mjs +103 -0
- package/runtime/jev/fixture.mjs +99 -0
- package/runtime/jev/index.mjs +784 -0
- package/runtime/jev/questions.mjs +268 -0
- package/runtime/jev/wire.mjs +152 -0
- package/runtime/pipeline.mjs +1071 -0
- package/runtime/web/app.js +2596 -0
- package/runtime/web/index.html +265 -0
- package/runtime/web/layout.js +336 -0
- package/runtime/web/sidebar.js +525 -0
- package/runtime/web/sketch.js +347 -0
- package/runtime/web/style.css +593 -0
- package/schemas/bundle.schema.json +243 -0
- package/schemas/event.schema.json +108 -0
- package/schemas/graph.schema.json +449 -0
- package/schemas/patch.schema.json +111 -0
- package/scripts/arguments.mjs +37 -0
- package/scripts/build-packages.mjs +160 -0
- package/scripts/collect.sh +23 -0
- package/scripts/collector.mjs +11 -0
- package/scripts/control.mjs +80 -0
- package/scripts/daemon.mjs +28 -0
- package/scripts/graphlin.mjs +112 -0
- package/scripts/onboarding.mjs +413 -0
- package/scripts/validate-packages.mjs +118 -0
- package/skills/graphlin/SKILL.md +103 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cp, mkdir, readFile, readdir, lstat, writeFile, rm, rename, chmod } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { publicPackageFiles, validatePackage } from './validate-packages.mjs';
|
|
7
|
+
|
|
8
|
+
const SOURCE = fileURLToPath(new URL('../', import.meta.url));
|
|
9
|
+
const SOURCE_DIRECTORIES = ['.claude-plugin', '.codex-plugin', 'skills', 'scripts', 'runtime', 'schemas', 'adapters'];
|
|
10
|
+
|
|
11
|
+
async function assertDestination(destination, { directory = false } = {}) {
|
|
12
|
+
const absolute = path.resolve(destination), root = path.parse(absolute).root;
|
|
13
|
+
const components = absolute.slice(root.length).split(path.sep).filter(Boolean);
|
|
14
|
+
let current = root;
|
|
15
|
+
for (let i = 0; i < components.length; i++) {
|
|
16
|
+
current = path.join(current, components[i]);
|
|
17
|
+
let info;
|
|
18
|
+
try { info = await lstat(current); }
|
|
19
|
+
catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
20
|
+
if (info.isSymbolicLink()) throw new Error('package_destination_symlink_rejected');
|
|
21
|
+
if ((i < components.length - 1 || directory) && !info.isDirectory()) {
|
|
22
|
+
throw new Error('invalid_package_destination');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function destinationDirectory(directory) {
|
|
27
|
+
await assertDestination(directory, { directory: true });
|
|
28
|
+
await mkdir(directory, { recursive: true });
|
|
29
|
+
await assertDestination(directory, { directory: true });
|
|
30
|
+
}
|
|
31
|
+
async function publishFile(filename, contents) {
|
|
32
|
+
await assertDestination(filename);
|
|
33
|
+
const temporary = path.join(path.dirname(filename), `.${path.basename(filename)}-${randomUUID()}.tmp`);
|
|
34
|
+
try {
|
|
35
|
+
// The exclusive temporary write cannot truncate a linked destination.
|
|
36
|
+
// Rename replaces a directory entry and never follows the final symlink.
|
|
37
|
+
await writeFile(temporary, contents, { flag: 'wx', mode: 0o644 });
|
|
38
|
+
await assertDestination(filename);
|
|
39
|
+
await rename(temporary, filename);
|
|
40
|
+
} finally { await rm(temporary, { force: true }); }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function assertSourceTree(directory) {
|
|
44
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
45
|
+
const name = path.join(directory, entry.name);
|
|
46
|
+
if (entry.isSymbolicLink()) throw new Error('package_symlink_rejected');
|
|
47
|
+
if (entry.isDirectory()) await assertSourceTree(name);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function copy(source, destination) {
|
|
51
|
+
const info = await lstat(source);
|
|
52
|
+
if (info.isSymbolicLink()) throw new Error('package_symlink_rejected');
|
|
53
|
+
if (info.isDirectory()) await assertSourceTree(source);
|
|
54
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
55
|
+
await cp(source, destination, { recursive: true, dereference: false });
|
|
56
|
+
}
|
|
57
|
+
export async function buildPackages({ outputDir = path.join(SOURCE, 'dist'), sourceDir = SOURCE } = {}) {
|
|
58
|
+
const source = path.resolve(sourceDir), output = path.resolve(outputDir);
|
|
59
|
+
if (output === source || source.startsWith(`${output}${path.sep}`)) throw new Error('unsafe_output_directory');
|
|
60
|
+
if (SOURCE_DIRECTORIES.some(file => output === path.join(source, file) || output.startsWith(`${path.join(source, file)}${path.sep}`))) {
|
|
61
|
+
throw new Error('unsafe_output_directory');
|
|
62
|
+
}
|
|
63
|
+
// Preflight every output before replacing even the first generated profile.
|
|
64
|
+
// Parent links are rejected too, including links above outputDir.
|
|
65
|
+
await assertDestination(output, { directory: true });
|
|
66
|
+
for (const profile of ['portable', 'claude', 'codex']) {
|
|
67
|
+
const target = path.join(output, profile, 'graphlin');
|
|
68
|
+
await assertDestination(target, { directory: true });
|
|
69
|
+
await assertDestination(path.join(target, '.graphlin-package'));
|
|
70
|
+
}
|
|
71
|
+
const kiro = path.join(output, 'kiro');
|
|
72
|
+
await assertDestination(kiro, { directory: true });
|
|
73
|
+
await assertDestination(path.join(kiro, 'profile.json'));
|
|
74
|
+
await assertDestination(path.join(kiro, 'README.md'));
|
|
75
|
+
const marketplaceDirectory = path.join(output, 'codex', '.agents', 'plugins');
|
|
76
|
+
const marketplaceFile = path.join(marketplaceDirectory, 'marketplace.json');
|
|
77
|
+
await assertDestination(marketplaceDirectory, { directory: true });
|
|
78
|
+
await assertDestination(marketplaceFile);
|
|
79
|
+
const files = await publicPackageFiles(source);
|
|
80
|
+
for (const directory of SOURCE_DIRECTORIES) {
|
|
81
|
+
const filename = path.join(source, directory), info = await lstat(filename);
|
|
82
|
+
if (info.isSymbolicLink()) throw new Error('package_symlink_rejected');
|
|
83
|
+
await assertSourceTree(filename);
|
|
84
|
+
}
|
|
85
|
+
await validatePackage(source);
|
|
86
|
+
await destinationDirectory(output);
|
|
87
|
+
const built = [];
|
|
88
|
+
for (const profile of ['portable', 'claude', 'codex']) {
|
|
89
|
+
const parent = path.join(output, profile), target = path.join(parent, 'graphlin');
|
|
90
|
+
await destinationDirectory(parent);
|
|
91
|
+
const temporary = path.join(parent, `.graphlin-${randomUUID()}`);
|
|
92
|
+
await mkdir(temporary);
|
|
93
|
+
try {
|
|
94
|
+
const profileFiles = files.filter(file => profile === 'claude' || file !== '.claude-plugin/plugin.json');
|
|
95
|
+
for (const file of profileFiles) await copy(path.join(source, file), path.join(temporary, file));
|
|
96
|
+
const metadata = JSON.parse(await readFile(path.join(temporary, 'package.json'), 'utf8'));
|
|
97
|
+
metadata.files = metadata.files.filter(file => profileFiles.includes(file.slice(2)));
|
|
98
|
+
// Generated plugins are runnable distributions. Checkout-only testing,
|
|
99
|
+
// evaluation, packaging, and release commands would point to absent files.
|
|
100
|
+
metadata.scripts = Object.fromEntries(['start', 'demo', 'doctor', 'validate', 'prepack']
|
|
101
|
+
.filter(name => metadata.scripts[name]).map(name => [name, metadata.scripts[name]]));
|
|
102
|
+
await writeFile(path.join(temporary, 'package.json'), `${JSON.stringify(metadata, null, 2)}\n`);
|
|
103
|
+
if (profile === 'codex') {
|
|
104
|
+
await writeFile(path.join(temporary, '.mcp.json'), `${JSON.stringify({
|
|
105
|
+
mcpServers: { graphlin: { type: 'stdio', command: 'node', args: ['${PLUGIN_ROOT}/scripts/control.mjs'] } },
|
|
106
|
+
}, null, 2)}\n`);
|
|
107
|
+
}
|
|
108
|
+
if (profile === 'portable') {
|
|
109
|
+
const manifest = JSON.parse(await readFile(path.join(temporary, 'plugin.json'), 'utf8'));
|
|
110
|
+
delete manifest.extensions;
|
|
111
|
+
await writeFile(path.join(temporary, 'plugin.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
112
|
+
}
|
|
113
|
+
for (const file of ['collect.sh', 'graphlin.mjs', 'control.mjs']) {
|
|
114
|
+
await chmod(path.join(temporary, 'scripts', file), 0o755);
|
|
115
|
+
}
|
|
116
|
+
await writeFile(path.join(temporary, 'PACKAGE-NOTES.md'),
|
|
117
|
+
`# Graphlin ${profile} package\n\nNode.js 22.14+; macOS/Linux. Self-contained source bundle; no dependency install.\n` +
|
|
118
|
+
'Run scripts/graphlin.mjs --help for local controls. This package does not install itself.\n' +
|
|
119
|
+
'Hook activation/trust has not been certified. Kiro is inactive and experimental.\n' +
|
|
120
|
+
'See adapters/README.md and skills/graphlin/SKILL.md for privacy and coverage.\n');
|
|
121
|
+
await validatePackage(temporary);
|
|
122
|
+
await assertDestination(target, { directory: true });
|
|
123
|
+
await assertDestination(path.join(target, '.graphlin-package'));
|
|
124
|
+
const exists = await lstat(target).catch(() => null);
|
|
125
|
+
if (exists) {
|
|
126
|
+
if (exists.isSymbolicLink() || (await readFile(path.join(target, '.graphlin-package'), 'utf8').catch(() => '')) !== profile) {
|
|
127
|
+
throw new Error('unmanaged_output_directory');
|
|
128
|
+
}
|
|
129
|
+
await rm(target, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
await writeFile(path.join(temporary, '.graphlin-package'), profile);
|
|
132
|
+
await assertDestination(target, { directory: true });
|
|
133
|
+
await rename(temporary, target);
|
|
134
|
+
built.push({ profile, directory: target });
|
|
135
|
+
} finally { await rm(temporary, { recursive: true, force: true }); }
|
|
136
|
+
}
|
|
137
|
+
await destinationDirectory(kiro);
|
|
138
|
+
await publishFile(path.join(kiro, 'profile.json'), await readFile(path.join(source, 'adapters/kiro/profile.json')));
|
|
139
|
+
await publishFile(path.join(kiro, 'README.md'), '# Kiro: inactive experimental profile\n\nNo active hooks or installable plugin are generated. See profile.json.\n');
|
|
140
|
+
await destinationDirectory(marketplaceDirectory);
|
|
141
|
+
await publishFile(marketplaceFile, `${JSON.stringify({
|
|
142
|
+
name: 'graphlin-local',
|
|
143
|
+
interface: { displayName: 'Graphlin local' },
|
|
144
|
+
plugins: [{
|
|
145
|
+
name: 'graphlin',
|
|
146
|
+
source: { source: 'local', path: './graphlin' },
|
|
147
|
+
policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
|
|
148
|
+
category: 'Productivity',
|
|
149
|
+
}],
|
|
150
|
+
}, null, 2)}\n`);
|
|
151
|
+
return built;
|
|
152
|
+
}
|
|
153
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
154
|
+
try {
|
|
155
|
+
const args = process.argv.slice(2);
|
|
156
|
+
if (args.length && (args.length !== 2 || !['--out', '--output'].includes(args[0]))) throw new Error('invalid_arguments');
|
|
157
|
+
const result = await buildPackages(args.length ? { outputDir: args[1] } : {});
|
|
158
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
159
|
+
} catch { process.stderr.write('Graphlin package generation failed.\n'); process.exitCode = 1; }
|
|
160
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Keep import/runtime/launcher errors out of the host conversation as well.
|
|
3
|
+
exec 2>/dev/null
|
|
4
|
+
base=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd) || exit 0
|
|
5
|
+
entry="$base/collector.mjs"
|
|
6
|
+
[ -r "$entry" ] || exit 0
|
|
7
|
+
runtime=${GRAPHLIN_NODE:-node}
|
|
8
|
+
command -v "$runtime" >/dev/null 2>&1 || exit 0
|
|
9
|
+
exec 3<&0
|
|
10
|
+
"$runtime" "$entry" "${1:-claude}" <&3 >/dev/null 2>&1 &
|
|
11
|
+
collector_pid=$!
|
|
12
|
+
# The watchdog also bounds a broken executable before the JS deadline starts.
|
|
13
|
+
(
|
|
14
|
+
sleep 1
|
|
15
|
+
kill -TERM "$collector_pid" 2>/dev/null || exit 0
|
|
16
|
+
sleep 0.2
|
|
17
|
+
kill -KILL "$collector_pid" 2>/dev/null
|
|
18
|
+
) >/dev/null 2>&1 &
|
|
19
|
+
watchdog_pid=$!
|
|
20
|
+
wait "$collector_pid" 2>/dev/null
|
|
21
|
+
kill -KILL "$watchdog_pid" 2>/dev/null
|
|
22
|
+
wait "$watchdog_pid" 2>/dev/null
|
|
23
|
+
exit 0
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// This entry point has no output, no persistence, no daemon startup, and no
|
|
2
|
+
// remote transport. An outer guarded launcher also covers import/runtime errors.
|
|
3
|
+
import { collect, readHook } from '../runtime/collector/index.mjs';
|
|
4
|
+
const deadline = setTimeout(() => process.exit(0), 350);
|
|
5
|
+
try {
|
|
6
|
+
if (Number(process.versions.node.split('.')[0]) >= 22) {
|
|
7
|
+
const payload = await readHook();
|
|
8
|
+
if (payload) await collect(payload, { host: process.argv[2] || 'claude' });
|
|
9
|
+
}
|
|
10
|
+
} catch { /* Passive hooks always fail open. */ }
|
|
11
|
+
finally { clearTimeout(deadline); process.exit(0); }
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { startDaemon, stopDaemon, daemonStatus, doctor, publicError } from '../runtime/daemon/manager.mjs';
|
|
3
|
+
|
|
4
|
+
const METHODS = { start: input => startDaemon({ ...input, background: true }),
|
|
5
|
+
stop: stopDaemon, status: daemonStatus, doctor };
|
|
6
|
+
const SCHEMA = { type: 'object', properties: { projectRoot: { type: 'string', minLength: 1, maxLength: 4096 } },
|
|
7
|
+
required: ['projectRoot'], additionalProperties: false };
|
|
8
|
+
const TOOLS = Object.keys(METHODS).map(name => ({
|
|
9
|
+
name,
|
|
10
|
+
description: {
|
|
11
|
+
start: 'Start or reopen Graphlin for an explicit project and return a one-use viewer URL. Reuses saved project consent and credentials; metadata only without consent. Omit policy fields to reuse the current policy. MCP launch runs in the background; use the CLI for foreground operation.',
|
|
12
|
+
stop: 'Stop the Graphlin daemon for this canonical project.',
|
|
13
|
+
status: 'Get safe daemon status; does not activate hooks or call a remote service.',
|
|
14
|
+
doctor: 'Check local configuration, credential presence, host versions, daemon health, and observed hook delivery. Returns next steps; never sends a remote request or exposes a key.',
|
|
15
|
+
}[name],
|
|
16
|
+
inputSchema: name === 'start' ? { ...SCHEMA, properties: { ...SCHEMA.properties,
|
|
17
|
+
allowSource: { type: 'boolean' }, persistEvidence: { type: 'boolean' },
|
|
18
|
+
displayEvidence: { type: 'boolean' } } } : SCHEMA,
|
|
19
|
+
annotations: { readOnlyHint: name === 'status' || name === 'doctor',
|
|
20
|
+
destructiveHint: false, openWorldHint: name === 'start' },
|
|
21
|
+
}));
|
|
22
|
+
let initialized = false;
|
|
23
|
+
const send = message => new Promise((resolve, reject) => {
|
|
24
|
+
process.stdout.write(`${JSON.stringify(message)}\n`, error => error ? reject(error) : resolve());
|
|
25
|
+
});
|
|
26
|
+
const error = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
27
|
+
async function handle(line) {
|
|
28
|
+
let message;
|
|
29
|
+
try { message = JSON.parse(line); } catch { await error(null, -32700, 'Parse error'); return; }
|
|
30
|
+
if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string' ||
|
|
31
|
+
(message.id !== undefined && typeof message.id !== 'string' && typeof message.id !== 'number')) {
|
|
32
|
+
await error(null, -32600, 'Invalid request'); return;
|
|
33
|
+
}
|
|
34
|
+
if (message.id === undefined) return; // All notifications are deliberately silent.
|
|
35
|
+
const result = value => send({ jsonrpc: '2.0', id: message.id, result: value });
|
|
36
|
+
if (message.method === 'initialize') {
|
|
37
|
+
if (initialized) return error(message.id, -32600, 'Already initialized');
|
|
38
|
+
initialized = true;
|
|
39
|
+
const supported = ['2024-11-05', '2025-03-26', '2025-06-18'];
|
|
40
|
+
return result({ protocolVersion: supported.includes(message.params?.protocolVersion) ? message.params.protocolVersion : '2025-06-18',
|
|
41
|
+
capabilities: { tools: { listChanged: false } },
|
|
42
|
+
serverInfo: { name: 'graphlin', version: '0.1.0' },
|
|
43
|
+
instructions: 'Controls only. Passive host hooks provide observations when separately activated. No drawing calls after each action.' });
|
|
44
|
+
}
|
|
45
|
+
if (message.method === 'ping') return result({});
|
|
46
|
+
if (!initialized) return error(message.id, -32002, 'Initialize first');
|
|
47
|
+
if (message.method === 'tools/list') return result({ tools: TOOLS });
|
|
48
|
+
if (message.method !== 'tools/call') return error(message.id, -32601, 'Method not found');
|
|
49
|
+
const name = message.params?.name, input = message.params?.arguments;
|
|
50
|
+
if (!Object.hasOwn(METHODS, name ?? '') || !input || Array.isArray(input) || typeof input !== 'object') {
|
|
51
|
+
return error(message.id, -32602, 'Invalid tool arguments');
|
|
52
|
+
}
|
|
53
|
+
const allowed = new Set(name === 'start' ? ['projectRoot', 'allowSource', 'persistEvidence', 'displayEvidence'] : ['projectRoot']);
|
|
54
|
+
if (typeof input.projectRoot !== 'string' || !input.projectRoot || input.projectRoot.length > 4096 ||
|
|
55
|
+
Object.keys(input).some(key => !allowed.has(key)) ||
|
|
56
|
+
Object.entries(input).some(([key, value]) => key !== 'projectRoot' && typeof value !== 'boolean')) {
|
|
57
|
+
return error(message.id, -32602, 'Invalid tool arguments');
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const value = await METHODS[name](input);
|
|
61
|
+
await result({ content: [{ type: 'text', text: JSON.stringify(value) }], isError: false });
|
|
62
|
+
} catch (failure) {
|
|
63
|
+
await result({ content: [{ type: 'text', text: JSON.stringify({ error: publicError(failure) }) }], isError: true });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
let buffer = '', discarding = false;
|
|
67
|
+
process.stdin.setEncoding('utf8');
|
|
68
|
+
for await (const chunk of process.stdin) {
|
|
69
|
+
buffer += chunk.toString('utf8');
|
|
70
|
+
let newline;
|
|
71
|
+
while ((newline = buffer.indexOf('\n')) !== -1) {
|
|
72
|
+
const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
|
|
73
|
+
if (discarding) { discarding = false; continue; }
|
|
74
|
+
if (Buffer.byteLength(line) > 64 * 1024) await error(null, -32600, 'Request too large');
|
|
75
|
+
else if (line.trim()) await handle(line).catch(() => error(null, -32603, 'Internal error'));
|
|
76
|
+
}
|
|
77
|
+
if (Buffer.byteLength(buffer) > 64 * 1024) {
|
|
78
|
+
buffer = ''; discarding = true; await error(null, -32600, 'Request too large');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { startServer } from '../runtime/daemon/server.mjs';
|
|
2
|
+
import { parseArguments } from './arguments.mjs';
|
|
3
|
+
|
|
4
|
+
process.umask(0o077);
|
|
5
|
+
let server;
|
|
6
|
+
try {
|
|
7
|
+
const options = parseArguments(process.argv.slice(2), { worker: true });
|
|
8
|
+
const policy = { transmitSource: options.allowSource, persistEvidence: options.persistEvidence,
|
|
9
|
+
displayEvidence: options.displayEvidence };
|
|
10
|
+
let decisionService;
|
|
11
|
+
if (options.mode === 'demo') {
|
|
12
|
+
const { demoDecisionService } = await import('../runtime/daemon/demo.mjs');
|
|
13
|
+
decisionService = demoDecisionService();
|
|
14
|
+
}
|
|
15
|
+
server = await startServer({ ...options, policy, decisionService });
|
|
16
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
|
|
17
|
+
void server.close().then(() => process.exit(0), () => process.exit(1));
|
|
18
|
+
});
|
|
19
|
+
if (options.mode === 'demo') {
|
|
20
|
+
const { replayDemo } = await import('../runtime/daemon/demo.mjs');
|
|
21
|
+
await replayDemo(server.pipeline, options.projectRoot);
|
|
22
|
+
}
|
|
23
|
+
const stopped = await server.whenClosed;
|
|
24
|
+
if (!stopped.ok) process.exitCode = 1;
|
|
25
|
+
} catch {
|
|
26
|
+
await server?.close().catch(() => {});
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { startDaemon, runForeground, stopDaemon, daemonStatus, doctor, exportDaemon, diagnosticLogs, publicError } from '../runtime/daemon/manager.mjs';
|
|
3
|
+
import { parseArguments } from './arguments.mjs';
|
|
4
|
+
import { initOnboarding, uninstallOnboarding, needsOnboarding, openViewer, agentInstructions } from './onboarding.mjs';
|
|
5
|
+
import { readSettings } from '../runtime/daemon/settings.mjs';
|
|
6
|
+
import { projectPaths } from '../runtime/daemon/paths.mjs';
|
|
7
|
+
|
|
8
|
+
const HELP = `Graphlin — local architecture and activity viewer (Node.js 22.14+, macOS/Linux).
|
|
9
|
+
graphlin Guided setup if needed, then foreground start
|
|
10
|
+
init [--host claude|codex|both] [--allow-source|--no-source] [--replace-key]
|
|
11
|
+
start --project PATH [--allow-source|--no-source] [--persist-evidence] [--no-display-evidence] [--background] [--no-open]
|
|
12
|
+
open --project PATH Open/reopen the viewer; start detached if needed
|
|
13
|
+
uninstall [--host claude|codex|both]
|
|
14
|
+
stop --project PATH
|
|
15
|
+
status --project PATH
|
|
16
|
+
doctor --project PATH
|
|
17
|
+
demo [--data-dir PATH] [--background]
|
|
18
|
+
export --project PATH
|
|
19
|
+
logs --project PATH [--file PATH]
|
|
20
|
+
All commands accept --project PATH and --data-dir PATH (or GRAPHLIN_DATA_DIR).
|
|
21
|
+
init installs for your user account using the host CLIs; it saves project consent
|
|
22
|
+
and offers a masked key prompt only in a terminal. Non-interactive init requires
|
|
23
|
+
--host and --allow-source or --no-source; source also needs a saved/environment key.
|
|
24
|
+
init --replace-key replaces a saved key at the masked prompt; it requires a terminal.
|
|
25
|
+
uninstall removes only Graphlin host plugins for all projects, retaining keys,
|
|
26
|
+
history, packages and marketplace registrations; it resets current project consent.
|
|
27
|
+
Start and demo stay in this terminal by default; Ctrl+C gracefully stops the
|
|
28
|
+
joined instance. --background explicitly detaches. Repeated starts for the same
|
|
29
|
+
canonical project/data directory reuse its port and issue a fresh one-use URL.
|
|
30
|
+
Omitted policy flags reuse current/saved consent. Without consent: metadata only.
|
|
31
|
+
--no-source explicitly opts out; --allow-source permits sanitized source/public
|
|
32
|
+
intent to TypeSafe using TYPESAFE_API_KEY or the privately saved key. Approved evidence
|
|
33
|
+
is displayed by default; excerpts are persisted only with --persist-evidence.
|
|
34
|
+
Credentials, environment files, excluded paths, binary and oversized files are
|
|
35
|
+
filtered locally. Replay history is bounded to 7 days; an expired stopped-daemon
|
|
36
|
+
snapshot is deleted on the next startup. Current snapshots are private and bounded.
|
|
37
|
+
Policy changes require stop/start. Hooks never install themselves. Interactive
|
|
38
|
+
starts open a browser automatically; --no-open suppresses this.
|
|
39
|
+
Demo uses local fixture answers and makes no remote requests.
|
|
40
|
+
Diagnostics retain bounded metadata in a private 300-record/512 KiB ring and two
|
|
41
|
+
1 MiB JSONL files. Source, prompts, and credentials are never logged. Safe paths
|
|
42
|
+
and labels require source permission and their display/persistence settings.
|
|
43
|
+
logs also works stopped, with paths and labels hidden; --file matches retained
|
|
44
|
+
artifact identity, including files since deleted.
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const args = process.argv.slice(2);
|
|
49
|
+
if (args.includes('--help') || args[0] === 'help') process.stdout.write(HELP);
|
|
50
|
+
else {
|
|
51
|
+
const options = parseArguments(args);
|
|
52
|
+
const print = result => process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
53
|
+
const interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
54
|
+
let result;
|
|
55
|
+
if (['start', 'demo', 'open', 'init', 'uninstall'].includes(options.command)) {
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const interrupt = () => controller.abort();
|
|
58
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, interrupt);
|
|
59
|
+
try {
|
|
60
|
+
if (options.command === 'init') result = await initOnboarding({ ...options, signal: controller.signal });
|
|
61
|
+
else if (options.command === 'uninstall') result = await uninstallOnboarding({ ...options, signal: controller.signal });
|
|
62
|
+
else {
|
|
63
|
+
if (options.guided && await needsOnboarding(options)) {
|
|
64
|
+
const setup = await initOnboarding({ ...options, signal: controller.signal });
|
|
65
|
+
Object.assign(options, setup.policy);
|
|
66
|
+
}
|
|
67
|
+
if (options.command === 'demo') {
|
|
68
|
+
const { createDemoProject } = await import('../runtime/daemon/demo.mjs');
|
|
69
|
+
options.projectRoot = await createDemoProject(options.dataDir);
|
|
70
|
+
options.mode = 'demo'; options.allowSource = true;
|
|
71
|
+
}
|
|
72
|
+
let browser = Promise.resolve();
|
|
73
|
+
let instructions;
|
|
74
|
+
if (interactive && options.command !== 'demo') {
|
|
75
|
+
const paths = await projectPaths(options.projectRoot, options.dataDir);
|
|
76
|
+
const saved = await readSettings(paths);
|
|
77
|
+
instructions = agentInstructions({ ...paths, hosts: saved.installation?.hosts?.length ? saved.installation.hosts : undefined });
|
|
78
|
+
}
|
|
79
|
+
const ready = value => {
|
|
80
|
+
print(options.command === 'demo' ? { ...value, projectRoot: options.projectRoot } : value);
|
|
81
|
+
if (instructions) process.stderr.write(instructions);
|
|
82
|
+
if (options.openBrowser !== false && (interactive || options.command === 'open')) {
|
|
83
|
+
browser = openViewer(value.url, { signal: controller.signal }).catch(() => {
|
|
84
|
+
process.stderr.write('Could not open a browser automatically. Open the printed one-use URL; run graphlin open for a fresh URL if it expires.\n');
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
if (options.background || options.command === 'open') ready(await startDaemon({ ...options, background: true }));
|
|
89
|
+
else await runForeground({ ...options, signal: controller.signal }, ready);
|
|
90
|
+
await browser;
|
|
91
|
+
}
|
|
92
|
+
} finally {
|
|
93
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.removeListener(signal, interrupt);
|
|
94
|
+
}
|
|
95
|
+
} else if (options.command === 'stop') result = await stopDaemon(options);
|
|
96
|
+
else if (options.command === 'status') result = await daemonStatus(options);
|
|
97
|
+
else if (options.command === 'doctor') result = await doctor(options);
|
|
98
|
+
else if (options.command === 'export') result = await exportDaemon(options);
|
|
99
|
+
else if (options.command === 'logs') result = await diagnosticLogs(options);
|
|
100
|
+
else throw new Error('unknown_command');
|
|
101
|
+
if (result !== undefined) print(result);
|
|
102
|
+
}
|
|
103
|
+
} catch (error) {
|
|
104
|
+
const argumentErrors = new Set(['duplicate_argument', 'invalid_argument', 'invalid_port', 'conflicting_arguments',
|
|
105
|
+
'unknown_argument', 'invalid_host', 'unknown_command']);
|
|
106
|
+
const message = error?.onboarding ? error.message : argumentErrors.has(error?.message) ? error.message : publicError(error);
|
|
107
|
+
const nextStep = message === 'policy_restart_required'
|
|
108
|
+
? 'Stop the current viewer with Ctrl+C or graphlin stop, then run Graphlin again to apply the saved settings.'
|
|
109
|
+
: 'Run with --help for usage.';
|
|
110
|
+
process.stderr.write(`Graphlin: ${message}. ${nextStep}\n`);
|
|
111
|
+
process.exitCode = error?.code === 'cancelled' ? 130 : 1;
|
|
112
|
+
}
|