graphlin 0.1.1 → 0.1.3

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphlin",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Local architecture and activity viewer with passive, bounded event hooks.",
5
5
  "author": {
6
6
  "name": "Graphlin contributors"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphlin",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Local architecture and activity diagrams from observable coding-agent work.",
5
5
  "author": {
6
6
  "name": "Graphlin contributors"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphlin",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Live architecture and activity diagrams from observable coding-agent work.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -76,6 +76,7 @@
76
76
  "./runtime/core/tool-discovery.mjs",
77
77
  "./runtime/daemon/auth.mjs",
78
78
  "./runtime/daemon/connection-info.mjs",
79
+ "./runtime/daemon/dashboard-info.mjs",
79
80
  "./runtime/daemon/demo.mjs",
80
81
  "./runtime/daemon/diagnostics.mjs",
81
82
  "./runtime/daemon/export.mjs",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "graphlin",
4
- "version": "0.1.1",
4
+ "version": "0.1.3",
5
5
  "description": "Local architecture and activity diagrams from observable coding-agent work.",
6
6
  "extensions": {
7
7
  "com.openai": {
@@ -0,0 +1,175 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ const REGISTRY = 'https://registry.npmjs.org/graphlin/latest';
6
+ const CACHE_MS = 30 * 60 * 1000;
7
+ const MAX_REGISTRY_BYTES = 32 * 1024;
8
+ const MAX_COMMAND_BYTES = 8192;
9
+ const PACKAGE = new URL('../../package.json', import.meta.url);
10
+ const CONTROLS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/;
11
+ const INSTRUCTIONS = [
12
+ 'Press Ctrl+C in the terminal running Graphlin to stop the viewer.',
13
+ 'Run the update command to restart the viewer in this project with the same Graphlin data directory.',
14
+ 'Start a new Claude Code or Codex agent session after the viewer restarts.',
15
+ ];
16
+ const DEMO_INSTRUCTIONS = [
17
+ 'Press Ctrl+C in the terminal running Graphlin to stop the demo viewer.',
18
+ 'Run the update command to restart the offline demo with the same Graphlin data directory.',
19
+ ];
20
+
21
+ const quote = value => `'${value.replaceAll("'", "'\"'\"'")}'`;
22
+ function absolute(value) {
23
+ if (typeof value !== 'string' || !path.isAbsolute(value) ||
24
+ Buffer.byteLength(value) > 4096 || CONTROLS.test(value)) throw new Error('dashboard_info_unavailable');
25
+ return path.resolve(value);
26
+ }
27
+
28
+ function stableVersion(value) {
29
+ if (typeof value !== 'string' || value.length > 80) return null;
30
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value);
31
+ return match ? match.slice(1, 4).map(part => BigInt(part)) : null;
32
+ }
33
+
34
+ // null means unsupported/invalid, never "already latest". Build metadata does
35
+ // not affect stable SemVer precedence; numeric components are not lexical.
36
+ export function compareStableVersions(first, second) {
37
+ const a = stableVersion(first), b = stableVersion(second);
38
+ if (!a || !b) return null;
39
+ for (let index = 0; index < 3; index++) {
40
+ if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1;
41
+ }
42
+ return 0;
43
+ }
44
+
45
+ async function runningVersion(read) {
46
+ try {
47
+ const text = await read(PACKAGE, 'utf8');
48
+ if (Buffer.byteLength(text) > 64 * 1024) return null;
49
+ const metadata = JSON.parse(text);
50
+ return metadata.name === 'graphlin' && stableVersion(metadata.version) ? metadata.version : null;
51
+ } catch { return null; }
52
+ }
53
+
54
+ function branchInfo(projectRoot, execute) {
55
+ return new Promise(resolve => {
56
+ const unavailable = () => resolve({ status: 'unavailable' });
57
+ try {
58
+ execute('git', ['-c', 'core.fsmonitor=false', 'symbolic-ref', '--quiet', '--short', 'HEAD'], {
59
+ cwd: projectRoot, shell: false, timeout: 750, killSignal: 'SIGKILL',
60
+ maxBuffer: 4096, encoding: 'utf8',
61
+ // Do not inherit credentials, Git overrides, or global config. This
62
+ // local ref lookup never runs a shell, hook, installer or remote.
63
+ env: {
64
+ PATH: process.env.PATH || '/usr/bin:/bin', LC_ALL: 'C',
65
+ GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
66
+ GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0',
67
+ },
68
+ }, (error, stdout, stderr) => {
69
+ if (error) {
70
+ if (error.code === 1 && !error.killed) return resolve({ status: 'detached' });
71
+ if (error.code === 128 && !error.killed && typeof stderr === 'string' &&
72
+ stderr.includes('not a git repository')) return resolve({ status: 'not_git' });
73
+ return unavailable();
74
+ }
75
+ const name = typeof stdout === 'string' ? stdout.replace(/\r?\n$/, '') : '';
76
+ if (!name || Buffer.byteLength(name) > 1024 || CONTROLS.test(name)) return unavailable();
77
+ resolve({ status: 'branch', name });
78
+ });
79
+ } catch { unavailable(); }
80
+ });
81
+ }
82
+
83
+ async function registryVersion(fetchImpl, timeoutMs) {
84
+ const controller = new AbortController();
85
+ let timer;
86
+ try {
87
+ return await Promise.race([
88
+ (async () => {
89
+ // Fixed destination and headers: project paths, branch names, daemon
90
+ // state, credentials and registry overrides never enter this request.
91
+ const response = await fetchImpl(REGISTRY, {
92
+ method: 'GET', headers: { Accept: 'application/json' },
93
+ redirect: 'error', credentials: 'omit', referrerPolicy: 'no-referrer',
94
+ signal: controller.signal,
95
+ });
96
+ if (!response.ok || Number(response.headers.get('content-length')) > MAX_REGISTRY_BYTES ||
97
+ !response.body) throw new Error('registry_unavailable');
98
+ const reader = response.body.getReader();
99
+ const chunks = [];
100
+ let length = 0;
101
+ try {
102
+ while (true) {
103
+ const { done, value } = await reader.read();
104
+ if (done) break;
105
+ length += value.byteLength;
106
+ if (length > MAX_REGISTRY_BYTES) throw new Error('registry_unavailable');
107
+ chunks.push(Buffer.from(value));
108
+ }
109
+ } finally {
110
+ void reader.cancel().catch(() => {});
111
+ reader.releaseLock();
112
+ }
113
+ const metadata = JSON.parse(Buffer.concat(chunks, length).toString('utf8'));
114
+ if (metadata.name !== 'graphlin' || !stableVersion(metadata.version)) throw new Error('registry_unavailable');
115
+ return metadata.version;
116
+ })(),
117
+ new Promise((_, reject) => {
118
+ timer = setTimeout(() => {
119
+ controller.abort();
120
+ reject(new Error('registry_unavailable'));
121
+ }, timeoutMs);
122
+ }),
123
+ ]);
124
+ } catch {
125
+ // Raw network errors and response bodies may contain private content.
126
+ return null;
127
+ } finally {
128
+ clearTimeout(timer);
129
+ controller.abort();
130
+ }
131
+ }
132
+
133
+ // Trusted startup context only; dependencies are injectable for offline tests.
134
+ // Creating the provider performs no registry request or Git subprocess.
135
+ export function createDashboardInfoProvider({ projectRoot, dataDir, mode = 'live' }, {
136
+ fetch: fetchImpl = globalThis.fetch, execFile: execute = execFile,
137
+ readFile: read = readFile, now = Date.now, timeoutMs = 1500,
138
+ } = {}) {
139
+ const version = runningVersion(read);
140
+ let cached, pending;
141
+ async function latestVersion() {
142
+ if (cached && now() < cached.expiresAt) return cached.latest;
143
+ if (!pending) {
144
+ pending = registryVersion(fetchImpl, timeoutMs).then(latest => {
145
+ cached = { latest, expiresAt: now() + CACHE_MS };
146
+ return latest;
147
+ }).finally(() => { pending = null; });
148
+ }
149
+ return pending;
150
+ }
151
+ return async () => {
152
+ // Metadata that cannot be displayed safely disables this optional endpoint,
153
+ // never startup, capture, or the rest of the viewer.
154
+ if (!['live', 'demo'].includes(mode)) throw new Error('dashboard_info_unavailable');
155
+ const root = absolute(projectRoot), directory = absolute(dataDir);
156
+ const demo = mode === 'demo';
157
+ const command = `${demo ? '' : `cd ${quote(root)} && `}GRAPHLIN_DATA_DIR=${quote(directory)} npx --yes graphlin@latest${demo ? ' demo' : ''}`;
158
+ // Never truncate a shell argument or offer instructions without a usable
159
+ // command. Quoting can expand otherwise valid paths beyond the UI limit.
160
+ const guide = Buffer.byteLength(command) <= MAX_COMMAND_BYTES
161
+ ? { command, instructions: [...(demo ? DEMO_INSTRUCTIONS : INSTRUCTIONS)] } : {};
162
+ const [current, branch, latest] = await Promise.all([
163
+ version, branchInfo(root, execute), latestVersion(),
164
+ ]);
165
+ if (!current) throw new Error('dashboard_info_unavailable');
166
+ const comparison = compareStableVersions(latest, current);
167
+ return {
168
+ projectRoot: root, mode, branch, version: current,
169
+ update: {
170
+ status: comparison === null ? 'unavailable' : comparison > 0 ? 'available' : 'current',
171
+ current, ...(latest ? { latest } : {}), ...guide,
172
+ },
173
+ };
174
+ };
175
+ }
@@ -10,6 +10,7 @@ import { createAuth } from './auth.mjs';
10
10
  import { createPersistence } from './persistence.mjs';
11
11
  import { exportSnapshot } from './export.mjs';
12
12
  import { createDiagnostics } from './diagnostics.mjs';
13
+ import { createDashboardInfoProvider } from './dashboard-info.mjs';
13
14
 
14
15
  const WEB = new URL('../web/', import.meta.url);
15
16
  const assets = new Map([
@@ -52,10 +53,13 @@ async function bodyJSON(req) {
52
53
  }
53
54
 
54
55
  export async function startServer({ projectRoot, dataDir, policy: policyOptions,
55
- decisionService, apiKey: configuredKey, mode = 'live', port = 0 } = {}) {
56
+ decisionService, apiKey: configuredKey, mode = 'live', port = 0, dashboardInfoDependencies } = {}) {
56
57
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw runtimeError('invalid_port');
57
58
  if (!['live', 'demo'].includes(mode)) throw runtimeError('invalid_mode');
58
59
  const paths = await projectPaths(projectRoot, dataDir, { create: true });
60
+ const dashboardInfo = createDashboardInfoProvider({
61
+ projectRoot: paths.projectRoot, dataDir: paths.dataDir, mode,
62
+ }, dashboardInfoDependencies);
59
63
  const lock = await acquireLock(paths);
60
64
  let finished;
61
65
  const whenClosed = new Promise(resolve => { finished = resolve; });
@@ -157,6 +161,10 @@ export async function startServer({ projectRoot, dataDir, policy: policyOptions,
157
161
  return;
158
162
  }
159
163
  if (!auth.authorized(req)) return json(res, 401, { error: 'authentication_required' });
164
+ if (req.method === 'GET' && req.url === '/api/about') {
165
+ try { return json(res, 200, await dashboardInfo()); }
166
+ catch { return json(res, 503, { error: 'dashboard_info_unavailable' }); }
167
+ }
160
168
  if (req.method === 'GET' && req.url === '/api/diagnostics') {
161
169
  return json(res, 200, diagnostics.snapshot());
162
170
  }