openvoidnet 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/README.md +41 -0
- package/bin/voidnet.js +407 -0
- package/package.json +31 -0
- package/src/env.js +55 -0
- package/src/out.js +42 -0
- package/src/probes.js +46 -0
- package/src/update.js +106 -0
- package/src/words.js +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# openvoidnet
|
|
2
|
+
|
|
3
|
+
Voidnet publisher CLI. Manage your Voidnet apps from the terminal.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm i -g openvoidnet
|
|
7
|
+
voidnet login
|
|
8
|
+
voidnet apps list
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Login
|
|
12
|
+
|
|
13
|
+
Browser approval via Void Accounts (recommended):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
voidnet login
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Headless and CI — paste a console-issued token:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
voidnet login --token vnp-pub-...
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Commands
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
voidnet apps list [--format=json]
|
|
29
|
+
voidnet logs tail --app <appId> [--request-id <uuid>]
|
|
30
|
+
voidnet keys list <appId>
|
|
31
|
+
voidnet keys rotate <appId>
|
|
32
|
+
voidnet logout
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`logs tail --request-id` filters by the join ID the gateway forwards as
|
|
36
|
+
`X-Voidnet-Request-Id` on every routed call — match one call against Usage Logs.
|
|
37
|
+
|
|
38
|
+
Browser login grants read + verify scopes only. Publishing and key rotation
|
|
39
|
+
require a deliberately console-issued token.
|
|
40
|
+
|
|
41
|
+
Proprietary — Voidnet ecosystem. Not open source.
|
package/bin/voidnet.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { resolveEnv } from '../src/env.js';
|
|
6
|
+
import { makeOut, resolveMode } from '../src/out.js';
|
|
7
|
+
import { gateMinimum, noteUpdate, readPkgVersion } from '../src/update.js';
|
|
8
|
+
import { serviceName, adapterName } from '../src/words.js';
|
|
9
|
+
|
|
10
|
+
const ENV = resolveEnv({ silent: true });
|
|
11
|
+
const MODE = resolveMode();
|
|
12
|
+
const out = makeOut(MODE);
|
|
13
|
+
|
|
14
|
+
const DOCS_SIGNIN = 'https://docs.openvoidnet.com/docs/concepts/voidnet-cli#sign-in';
|
|
15
|
+
const NOT_LOGGED_IN = `Not logged in. Run: voidnet login — ${DOCS_SIGNIN}`;
|
|
16
|
+
|
|
17
|
+
function announceEnv() {
|
|
18
|
+
out.diag(`env=${ENV.name} mgmt=${ENV.mgmtBase} auth=${ENV.authBase} live=${ENV.liveBase}`, { verboseOnly: true });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function loadConfig() {
|
|
22
|
+
try {
|
|
23
|
+
if (!existsSync(ENV.configPath)) return { env: ENV.name, token: null };
|
|
24
|
+
const cfg = JSON.parse(readFileSync(ENV.configPath, 'utf8'));
|
|
25
|
+
// Unmarked, foreign-env, or tokenless files all mean one thing: logged out.
|
|
26
|
+
// Re-login mints fresh. No cryptic states leak to humans.
|
|
27
|
+
if (cfg.env !== ENV.name || !cfg.token) return { env: ENV.name, token: null };
|
|
28
|
+
return cfg;
|
|
29
|
+
} catch (err) {
|
|
30
|
+
out.diag(`config unreadable, treating as logged out: ${String(err && err.message || err)}`, { verboseOnly: true });
|
|
31
|
+
return { env: ENV.name, token: null };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function saveConfig(cfg) {
|
|
36
|
+
mkdirSync(dirname(ENV.configPath), { recursive: true });
|
|
37
|
+
writeFileSync(ENV.configPath, JSON.stringify({ ...cfg, env: ENV.name }, null, 2), { mode: 0o600 });
|
|
38
|
+
out.diag(`config saved path=${ENV.configPath}`, { verboseOnly: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function authed(path, opts = {}) {
|
|
42
|
+
const cfg = loadConfig();
|
|
43
|
+
if (!cfg.token) {
|
|
44
|
+
out.error(NOT_LOGGED_IN);
|
|
45
|
+
}
|
|
46
|
+
const url = `${ENV.mgmtBase}${path}`;
|
|
47
|
+
out.diag(`request ${opts.method || 'GET'} ${url}`, { verboseOnly: true });
|
|
48
|
+
const res = await fetch(url, {
|
|
49
|
+
...opts,
|
|
50
|
+
headers: { Authorization: `Bearer ${cfg.token}`, 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
|
51
|
+
});
|
|
52
|
+
const data = await res.json().catch(() => ({}));
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
// Dead or revoked token: same fix as never logged in.
|
|
55
|
+
if (res.status === 401) out.error(NOT_LOGGED_IN);
|
|
56
|
+
out.error(`Request failed (status=${res.status} code=${data.code || data.error || 'unknown'}). — ${DOCS_SIGNIN}`);
|
|
57
|
+
}
|
|
58
|
+
return data;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const [cmd, sub, ...rest] = process.argv.slice(2);
|
|
62
|
+
const tokenIdx = process.argv.indexOf('--token');
|
|
63
|
+
|
|
64
|
+
const KNOWN = new Set(['login', 'logout', 'apps', 'logs', 'keys', 'help', 'whoami', 'status', 'doctor']);
|
|
65
|
+
if (!cmd || cmd === 'help' || process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
66
|
+
printHelp(sub || null);
|
|
67
|
+
} else if (process.argv.includes('--version') || cmd === 'version') {
|
|
68
|
+
printVersion();
|
|
69
|
+
} else if (!KNOWN.has(cmd)) {
|
|
70
|
+
out.error(`unknown command "${cmd}". Run voidnet --help.`);
|
|
71
|
+
} else {
|
|
72
|
+
announceEnv();
|
|
73
|
+
const pkgVersion = readPkgVersion(import.meta.url);
|
|
74
|
+
const vv = (m) => out.diag(m, { verboseOnly: true });
|
|
75
|
+
const meta = await gateMinimum({ pkgVersion, mgmtBase: ENV.mgmtBase, homeDir: homedir(), onVerbose: vv });
|
|
76
|
+
await dispatch(cmd, sub, rest, tokenIdx);
|
|
77
|
+
noteUpdate({ pkgVersion, meta });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function printHelp(topic) {
|
|
81
|
+
if (topic === 'advanced') {
|
|
82
|
+
out.data(`Advanced environment control:
|
|
83
|
+
--env production|development, --base/--auth-base/--live-base URLs
|
|
84
|
+
VOIDNET_ENV, VOIDNET_MGMT_BASE, VOIDNET_AUTH_BASE, VOIDNET_LIVE_BASE
|
|
85
|
+
Resolution: flags > VOIDNET_* env > baked default (production).
|
|
86
|
+
Config: ~/.config/voidnet/cli.json vs cli.development.json (per env, never shared).`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (topic === 'login') {
|
|
90
|
+
out.data(`voidnet login [--app <id>...]
|
|
91
|
+
Browser approval via Void Accounts. See: voidnet help advanced for environments.`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
out.data(`Usage:
|
|
95
|
+
voidnet [command] [flags]
|
|
96
|
+
|
|
97
|
+
Auth commands:
|
|
98
|
+
login Sign in (browser approval, or --token for CI)
|
|
99
|
+
logout Revoke and remove local credentials
|
|
100
|
+
|
|
101
|
+
App commands:
|
|
102
|
+
apps List apps and status
|
|
103
|
+
|
|
104
|
+
Observability:
|
|
105
|
+
logs Tail usage logs, filterable by request ID
|
|
106
|
+
status Probe auth, management, gateway reachability
|
|
107
|
+
doctor Diagnose local setup with fix for every failure
|
|
108
|
+
|
|
109
|
+
Key commands:
|
|
110
|
+
keys List fingerprints, rotate per-app keys
|
|
111
|
+
|
|
112
|
+
Other commands:
|
|
113
|
+
whoami Show env, targets, and login state
|
|
114
|
+
version Print CLI version
|
|
115
|
+
|
|
116
|
+
Global flags:
|
|
117
|
+
--json, --quiet, --verbose
|
|
118
|
+
|
|
119
|
+
Run voidnet help advanced for environments.`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function printVersion() {
|
|
123
|
+
out.data(`voidnet ${readPkgVersion(import.meta.url)}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function dispatch(cmd, sub, rest, tokenIdx) {
|
|
127
|
+
if (cmd === 'login' && tokenIdx >= 0) {
|
|
128
|
+
const token = process.argv[tokenIdx + 1];
|
|
129
|
+
if (!token || !token.startsWith('vnp-pub-')) {
|
|
130
|
+
out.error('usage: voidnet login --token vnp-pub-... [--env production|development]');
|
|
131
|
+
}
|
|
132
|
+
saveConfig({ token });
|
|
133
|
+
out.ok(`Signed in. Token stored (last4=${token.slice(-4)}).`);
|
|
134
|
+
} else if (cmd === 'login') {
|
|
135
|
+
await browserLogin();
|
|
136
|
+
} else if (cmd === 'logout') {
|
|
137
|
+
const cfg = loadConfig();
|
|
138
|
+
if (cfg.token) {
|
|
139
|
+
try {
|
|
140
|
+
await fetch(`${ENV.mgmtBase}/api/publisher/v1/auth/revoke-self`, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { Authorization: `Bearer ${cfg.token}` },
|
|
143
|
+
});
|
|
144
|
+
out.diag('server-side token revoked');
|
|
145
|
+
} catch (err) {
|
|
146
|
+
out.diag(`revoke call failed (local config still removed): ${String(err && err.message || err)}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
saveConfig({ token: null });
|
|
150
|
+
out.ok('Signed out.');
|
|
151
|
+
} else if (cmd === 'whoami') {
|
|
152
|
+
const cfg = loadConfig();
|
|
153
|
+
if (!cfg.token) {
|
|
154
|
+
if (MODE === 'json') out.json({ login: false });
|
|
155
|
+
else out.data('login=no');
|
|
156
|
+
out.error(NOT_LOGGED_IN);
|
|
157
|
+
}
|
|
158
|
+
if (MODE === 'json') {
|
|
159
|
+
const payload = { login: true, last4: String(cfg.token).slice(-4), mgmt: ENV.mgmtBase };
|
|
160
|
+
if (process.argv.includes('--verify')) {
|
|
161
|
+
const data = await authed('/api/publisher/v1/apps');
|
|
162
|
+
out.json({ ...payload, verified: true, apps: (data.apps || []).length });
|
|
163
|
+
} else {
|
|
164
|
+
out.json(payload);
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
out.data(`Signed in. Token •••• ${String(cfg.token).slice(-4)}.\nTarget: ${ENV.mgmtBase}`);
|
|
169
|
+
if (process.argv.includes('--verify')) {
|
|
170
|
+
const data = await authed('/api/publisher/v1/apps');
|
|
171
|
+
out.data(`verified=yes apps=${(data.apps || []).length}`);
|
|
172
|
+
}
|
|
173
|
+
} else if (cmd === 'status') {
|
|
174
|
+
const { probe } = await import('../src/probes.js');
|
|
175
|
+
const vv = (m) => out.diag(m, { verboseOnly: true });
|
|
176
|
+
const rows = [
|
|
177
|
+
await probe(serviceName('auth'), `${ENV.authBase}/api/auth/session/status`, { onVerbose: vv }),
|
|
178
|
+
await probe(serviceName('management'), `${ENV.mgmtBase}/api/publisher/v1/apps`, { onVerbose: vv }),
|
|
179
|
+
await probe(serviceName('gateway'), `${ENV.liveBase}/health`, { expectJson: true, onVerbose: vv }),
|
|
180
|
+
];
|
|
181
|
+
if (MODE === 'json') {
|
|
182
|
+
out.json({ rows });
|
|
183
|
+
if (rows.some((r) => !r.ok)) process.exit(1);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
for (const r of rows) {
|
|
187
|
+
out.data(`${r.ok ? 'ok' : 'FAIL'} ${r.name} ${r.detail} ${r.latencyMs}ms`);
|
|
188
|
+
}
|
|
189
|
+
if (rows.some((r) => !r.ok)) {
|
|
190
|
+
out.error('status: one or more probes failed (rows above name the cause)');
|
|
191
|
+
}
|
|
192
|
+
out.ok('status: all probes green');
|
|
193
|
+
} else if (cmd === 'doctor') {
|
|
194
|
+
const { probe, checkNode } = await import('../src/probes.js');
|
|
195
|
+
const { execSync } = await import('node:child_process');
|
|
196
|
+
const { statSync } = await import('node:fs');
|
|
197
|
+
const rows = [];
|
|
198
|
+
const vv2 = (m) => out.diag(m, { verboseOnly: true });
|
|
199
|
+
rows.push(checkNode(20, 9, { onVerbose: vv2 }));
|
|
200
|
+
try {
|
|
201
|
+
const hits = execSync('which -a voidnet 2>/dev/null || true', { encoding: 'utf8' })
|
|
202
|
+
.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
203
|
+
const shadowed = hits.some((h) => h.includes('.local/bin') && !h.includes('fnm') && !h.includes('node-versions'));
|
|
204
|
+
const base = (process.argv[1] || 'voidnet').split('/').pop() || 'voidnet';
|
|
205
|
+
rows.push({
|
|
206
|
+
name: 'binary',
|
|
207
|
+
ok: !shadowed,
|
|
208
|
+
detail: shadowed ? `shadowed entries found — run hash -r, check PATH order: ${hits.join(',')}` : `binary=${base}`,
|
|
209
|
+
latencyMs: 0,
|
|
210
|
+
});
|
|
211
|
+
out.diag(`binary check entries=${hits.length} shadowed=${shadowed}`, { verboseOnly: true });
|
|
212
|
+
} catch (err) {
|
|
213
|
+
rows.push({ name: 'binary', ok: true, detail: 'which unavailable, skipped', latencyMs: 0 });
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const st = statSync(ENV.configPath);
|
|
217
|
+
const mode = (st.mode & 0o777).toString(8);
|
|
218
|
+
rows.push({
|
|
219
|
+
name: 'config-perms',
|
|
220
|
+
ok: mode === '600',
|
|
221
|
+
detail: mode === '600' ? `mode=600 path=${ENV.configPath}` : `mode=${mode} — run chmod 600 ${ENV.configPath}`,
|
|
222
|
+
latencyMs: 0,
|
|
223
|
+
});
|
|
224
|
+
} catch {
|
|
225
|
+
rows.push({ name: 'config-perms', ok: true, detail: 'no config file yet — run voidnet login', latencyMs: 0 });
|
|
226
|
+
}
|
|
227
|
+
const cfg = loadConfig();
|
|
228
|
+
rows.push({
|
|
229
|
+
name: 'login',
|
|
230
|
+
ok: Boolean(cfg.token),
|
|
231
|
+
detail: cfg.token ? `logged in last4=${String(cfg.token).slice(-4)}` : 'not logged in — run voidnet login',
|
|
232
|
+
latencyMs: 0,
|
|
233
|
+
});
|
|
234
|
+
rows.push(await probe(serviceName('auth'), `${ENV.authBase}/api/auth/session/status`, { onVerbose: vv2 }));
|
|
235
|
+
rows.push(await probe(serviceName('management'), `${ENV.mgmtBase}/api/publisher/v1/apps`, { onVerbose: vv2 }));
|
|
236
|
+
rows.push(await probe(serviceName('gateway'), `${ENV.liveBase}/health`, { expectJson: true, onVerbose: vv2 }));
|
|
237
|
+
const failed = rows.filter((r) => !r.ok).length;
|
|
238
|
+
if (MODE === 'json') {
|
|
239
|
+
out.json({ rows, failed });
|
|
240
|
+
if (failed > 0) process.exit(1);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
for (const r of rows) {
|
|
244
|
+
out.data(`${r.ok ? 'ok' : 'FAIL'} ${r.name} ${r.detail}${r.latencyMs ? ` ${r.latencyMs}ms` : ''}`);
|
|
245
|
+
}
|
|
246
|
+
if (failed > 0) {
|
|
247
|
+
out.error(`doctor: ${failed} check(s) failed (fix commands above)`);
|
|
248
|
+
}
|
|
249
|
+
out.ok('doctor: all checks green');
|
|
250
|
+
} else if (cmd === 'apps' && (sub === 'list' || !sub)) {
|
|
251
|
+
const data = await authed('/api/publisher/v1/apps');
|
|
252
|
+
if (MODE === 'json' || process.argv.includes('--format=json')) {
|
|
253
|
+
out.json(data);
|
|
254
|
+
} else {
|
|
255
|
+
out.data('ID NAME STATUS KIND SERVER_URL');
|
|
256
|
+
for (const a of data.apps || []) {
|
|
257
|
+
out.data(`${a.id} ${a.app_name} ${a.status} ${adapterName(a.adapter_type)} ${a.server_url}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
out.diag(`apps rows=${(data.apps || []).length}`, { verboseOnly: true });
|
|
261
|
+
} else if (cmd === 'logs' && sub === 'tail') {
|
|
262
|
+
const appIdx = process.argv.indexOf('--app');
|
|
263
|
+
const reqIdx = process.argv.indexOf('--request-id');
|
|
264
|
+
const app = appIdx >= 0 ? process.argv[appIdx + 1] : null;
|
|
265
|
+
if (!app) {
|
|
266
|
+
out.error('usage: voidnet logs tail --app <appId> [--request-id <uuid>]');
|
|
267
|
+
}
|
|
268
|
+
const q = reqIdx >= 0 ? `?request_id=${encodeURIComponent(process.argv[reqIdx + 1])}` : '';
|
|
269
|
+
const data = await authed(`/api/publisher/v1/apps/${app}/metering${q}`);
|
|
270
|
+
if (MODE === 'json') {
|
|
271
|
+
out.json(data);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
for (const r of data.records || []) {
|
|
275
|
+
out.data(`${r.timestamp} ${r.requestId} ${r.operationType} ${r.toolName || '-'} ${r.error ? 'ERROR' : 'ok'} ${r.durationMs}ms`);
|
|
276
|
+
}
|
|
277
|
+
out.diag(`logs rows=${(data.records || []).length} total=${data.pagination?.total ?? '?'}`, { verboseOnly: true });
|
|
278
|
+
} else if (cmd === 'keys' && sub === 'list') {
|
|
279
|
+
const app = rest[0];
|
|
280
|
+
if (!app) {
|
|
281
|
+
out.error('usage: voidnet keys list <appId>');
|
|
282
|
+
}
|
|
283
|
+
const data = await authed(`/api/publisher/v1/apps/${app}/keys`);
|
|
284
|
+
if (MODE === 'json') {
|
|
285
|
+
out.json(data);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
for (const k of data.keys || []) {
|
|
289
|
+
out.data(`id=${k.id} last4=${k.last4} active=${k.is_active} created=${k.created_at}`);
|
|
290
|
+
}
|
|
291
|
+
} else if (cmd === 'keys' && sub === 'rotate') {
|
|
292
|
+
const app = rest[0];
|
|
293
|
+
if (!app) {
|
|
294
|
+
out.error('usage: voidnet keys rotate <appId>');
|
|
295
|
+
}
|
|
296
|
+
const data = await authed(`/api/publisher/v1/apps/${app}/keys`, { method: 'POST' });
|
|
297
|
+
out.data(`rotated last4=${data.last4}. Full value below — copy now, never shown again.`);
|
|
298
|
+
out.data(data.api_key);
|
|
299
|
+
} else {
|
|
300
|
+
out.error(`unknown command "${cmd}${sub ? ` ${sub}` : ''}". Run voidnet --help.`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function openBrowser(url) {
|
|
305
|
+
const platform = process.platform;
|
|
306
|
+
const opener = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open';
|
|
307
|
+
out.diag(`opening browser: ${url}`);
|
|
308
|
+
import('node:child_process').then(({ execFile }) => {
|
|
309
|
+
execFile(opener, [url], (err) => {
|
|
310
|
+
if (err) {
|
|
311
|
+
out.diag(`auto-open failed (${opener}). Open this URL manually:`);
|
|
312
|
+
out.data(url);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function browserLogin() {
|
|
319
|
+
const [{ randomBytes, createHash }, { createServer }] = await Promise.all([
|
|
320
|
+
import('node:crypto'),
|
|
321
|
+
import('node:http'),
|
|
322
|
+
]);
|
|
323
|
+
const mgmtBase = ENV.mgmtBase;
|
|
324
|
+
const authBase = ENV.authBase;
|
|
325
|
+
|
|
326
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
327
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
328
|
+
const state = randomBytes(16).toString('hex');
|
|
329
|
+
const appFlags = [];
|
|
330
|
+
for (let i = 0; i < process.argv.length; i++) {
|
|
331
|
+
if (process.argv[i] === '--app' && process.argv[i + 1]) appFlags.push(process.argv[i + 1]);
|
|
332
|
+
}
|
|
333
|
+
if (appFlags.length > 0) {
|
|
334
|
+
out.diag(`login scoped to ${appFlags.length} app(s); consent preselects them, server enforces the subset`);
|
|
335
|
+
}
|
|
336
|
+
out.diag(`starting loopback listener mgmt=${mgmtBase} auth=${authBase}`, { verboseOnly: true });
|
|
337
|
+
|
|
338
|
+
const { code, redirectUri, gotState } = await new Promise((resolve, reject) => {
|
|
339
|
+
let boundPort = 0;
|
|
340
|
+
const server = createServer((req, res) => {
|
|
341
|
+
const u = new URL(req.url, 'http://127.0.0.1');
|
|
342
|
+
if (u.pathname !== '/callback') {
|
|
343
|
+
res.writeHead(404);
|
|
344
|
+
res.end();
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
348
|
+
res.end('<h1>Signed in. Return to the terminal.</h1>');
|
|
349
|
+
const done = { code: u.searchParams.get('code'), redirectUri: `http://127.0.0.1:${boundPort}/callback`, gotState: u.searchParams.get('state') };
|
|
350
|
+
server.close();
|
|
351
|
+
resolve(done);
|
|
352
|
+
});
|
|
353
|
+
server.on('error', reject);
|
|
354
|
+
server.listen(0, '127.0.0.1', () => {
|
|
355
|
+
boundPort = server.address().port;
|
|
356
|
+
const redirect = `http://127.0.0.1:${boundPort}/callback`;
|
|
357
|
+
const appHint = appFlags.map((a) => `&app_ids=${encodeURIComponent(a)}`).join('');
|
|
358
|
+
const authUrl = `${authBase}/api/auth/oauth/authorize?response_type=code&client_id=voidnet-cli&redirect_uri=${encodeURIComponent(redirect)}&scope=publisher_login&state=${state}&code_challenge=${challenge}&code_challenge_method=S256${appHint}`;
|
|
359
|
+
openBrowser(authUrl);
|
|
360
|
+
out.diag('waiting for browser approval (5 minutes)...');
|
|
361
|
+
setTimeout(() => {
|
|
362
|
+
server.close();
|
|
363
|
+
reject(new Error('Timed out waiting for browser approval'));
|
|
364
|
+
}, 5 * 60 * 1000).unref?.();
|
|
365
|
+
});
|
|
366
|
+
}).catch((err) => {
|
|
367
|
+
out.error(`login failed: ${err.message}`);
|
|
368
|
+
process.exit(1);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
if (!code || gotState !== state) {
|
|
372
|
+
out.error('login failed: missing code or state mismatch (possible CSRF — aborted)');
|
|
373
|
+
process.exit(1);
|
|
374
|
+
}
|
|
375
|
+
out.diag('approval received, exchanging code...');
|
|
376
|
+
|
|
377
|
+
const tokenRes = await fetch(`${authBase}/api/auth/oauth/token`, {
|
|
378
|
+
method: 'POST',
|
|
379
|
+
headers: { 'Content-Type': 'application/json' },
|
|
380
|
+
body: JSON.stringify({
|
|
381
|
+
grant_type: 'authorization_code',
|
|
382
|
+
client_id: 'voidnet-cli',
|
|
383
|
+
code,
|
|
384
|
+
redirect_uri: redirectUri,
|
|
385
|
+
code_verifier: verifier,
|
|
386
|
+
}),
|
|
387
|
+
});
|
|
388
|
+
const tokenData = await tokenRes.json().catch(() => ({}));
|
|
389
|
+
if (!tokenRes.ok || !tokenData.access_token) {
|
|
390
|
+
out.error(`code exchange failed: ${tokenData.error || tokenData.code || tokenRes.status}`);
|
|
391
|
+
process.exit(1);
|
|
392
|
+
}
|
|
393
|
+
out.diag('accounts token issued, exchanging for publisher token...');
|
|
394
|
+
|
|
395
|
+
const exRes = await fetch(`${mgmtBase}/api/publisher/v1/auth/exchange`, {
|
|
396
|
+
method: 'POST',
|
|
397
|
+
headers: { 'Content-Type': 'application/json' },
|
|
398
|
+
body: JSON.stringify({ accounts_token: tokenData.access_token, app_ids: appFlags.length > 0 ? appFlags : undefined }),
|
|
399
|
+
});
|
|
400
|
+
const exData = await exRes.json().catch(() => ({}));
|
|
401
|
+
if (!exRes.ok || !exData.token) {
|
|
402
|
+
out.error(`publisher exchange failed: ${exData.code || exData.error || exRes.status}`);
|
|
403
|
+
process.exit(1);
|
|
404
|
+
}
|
|
405
|
+
saveConfig({ token: exData.token });
|
|
406
|
+
out.ok(`logged in. token last4=${exData.last4} scopes=${(exData.scopes || []).join(',')}`);
|
|
407
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openvoidnet",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Voidnet publisher CLI — manage your Voidnet apps from the terminal with browser-based login.",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+ssh://git@github.com/roshaan-akther/void.git",
|
|
10
|
+
"directory": "packages/voidnet-cli"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"bin": {
|
|
14
|
+
"voidnet": "bin/voidnet.js"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"dev": "VOIDNET_ENV=development node bin/voidnet.js",
|
|
18
|
+
"test": "node test/env.test.mjs && node test/output.test.mjs && node test/update.test.mjs && node test/words.test.mjs"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin/voidnet.js",
|
|
22
|
+
"src/",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20.9.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/env.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
|
|
4
|
+
// Environment table: the only place hosts are defined. No includes()
|
|
5
|
+
// heuristics anywhere — unknown hosts fail loud instead of guessing.
|
|
6
|
+
export const ENVS = {
|
|
7
|
+
production: {
|
|
8
|
+
mgmtBase: 'https://openvoidnet.com',
|
|
9
|
+
authBase: 'https://accounts.openvoidnet.com',
|
|
10
|
+
liveBase: 'https://api.openvoidnet.com',
|
|
11
|
+
configFile: 'cli.json',
|
|
12
|
+
},
|
|
13
|
+
development: {
|
|
14
|
+
mgmtBase: 'http://localhost:3000',
|
|
15
|
+
authBase: 'http://localhost:3020',
|
|
16
|
+
liveBase: 'http://localhost:8090',
|
|
17
|
+
configFile: 'cli.development.json',
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function flagValue(argv, name) {
|
|
22
|
+
const i = argv.indexOf(name);
|
|
23
|
+
return i >= 0 && argv[i + 1] ? argv[i + 1] : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Resolve order: explicit flags > VOIDNET_* env > baked default (production).
|
|
27
|
+
// Files without an env marker are ignored loudly — a token minted for one env
|
|
28
|
+
// never addresses another, even by stale config.
|
|
29
|
+
// noteworthy is true when anything deviates from a plain production run:
|
|
30
|
+
// callers announce only then, so default runs stay silent.
|
|
31
|
+
export function resolveEnv({ argv = process.argv, env = process.env, silent = false } = {}) {
|
|
32
|
+
const explicit = flagValue(argv, '--env');
|
|
33
|
+
let name = explicit || env.VOIDNET_ENV || 'production';
|
|
34
|
+
if (name !== 'production' && name !== 'development') {
|
|
35
|
+
console.error(`[voidnet] invalid env "${name}". Use production or development.`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
const table = ENVS[name];
|
|
39
|
+
const mgmtBase = flagValue(argv, '--base') || env.VOIDNET_MGMT_BASE || table.mgmtBase;
|
|
40
|
+
const authBase = flagValue(argv, '--auth-base') || env.VOIDNET_AUTH_BASE || table.authBase;
|
|
41
|
+
const liveBase = flagValue(argv, '--live-base') || env.VOIDNET_LIVE_BASE || table.liveBase;
|
|
42
|
+
const configPath = join(homedir(), '.config', 'voidnet', table.configFile);
|
|
43
|
+
const noteworthy =
|
|
44
|
+
name !== 'production' ||
|
|
45
|
+
explicit !== null ||
|
|
46
|
+
flagValue(argv, '--base') !== null ||
|
|
47
|
+
flagValue(argv, '--auth-base') !== null ||
|
|
48
|
+
flagValue(argv, '--live-base') !== null ||
|
|
49
|
+
env.VOIDNET_ENV !== undefined ||
|
|
50
|
+
env.VOIDNET_MGMT_BASE !== undefined ||
|
|
51
|
+
env.VOIDNET_AUTH_BASE !== undefined ||
|
|
52
|
+
env.VOIDNET_LIVE_BASE !== undefined;
|
|
53
|
+
if (!silent) console.log(`[voidnet] env=${name} mgmt=${mgmtBase} auth=${authBase} live=${liveBase}`);
|
|
54
|
+
return { name, mgmtBase, authBase, liveBase, configPath, noteworthy };
|
|
55
|
+
}
|
package/src/out.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Unified output system: every command speaks through this module.
|
|
2
|
+
// stdout = data only, always pipe-safe. stderr = diagnostics, errors, identity.
|
|
3
|
+
// Modes: --json > --quiet > --verbose > default. No command invents output.
|
|
4
|
+
export function resolveMode(argv = process.argv) {
|
|
5
|
+
if (argv.includes('--json')) return 'json';
|
|
6
|
+
if (argv.includes('--quiet')) return 'quiet';
|
|
7
|
+
if (argv.includes('--verbose')) return 'verbose';
|
|
8
|
+
return 'default';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function makeOut(mode) {
|
|
12
|
+
const loud = mode === 'verbose';
|
|
13
|
+
return {
|
|
14
|
+
mode,
|
|
15
|
+
// Data: stdout, never prefixed, pipe-safe.
|
|
16
|
+
data(text) {
|
|
17
|
+
console.log(text);
|
|
18
|
+
},
|
|
19
|
+
json(payload) {
|
|
20
|
+
console.log(JSON.stringify({ data: payload }, null, 2));
|
|
21
|
+
},
|
|
22
|
+
// Diagnostics: stderr, prefixed, verbose-gated where marked.
|
|
23
|
+
diag(message, { verboseOnly = false } = {}) {
|
|
24
|
+
if (verboseOnly && !loud) return;
|
|
25
|
+
if (mode === 'quiet' && !verboseOnly) {
|
|
26
|
+
// quiet keeps errors only; diag lines drop. Errors use error() below.
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
console.error(`[voidnet] ${message}`);
|
|
30
|
+
},
|
|
31
|
+
// Errors: stderr always, named shape, mapped exit code.
|
|
32
|
+
error(message, code = 1) {
|
|
33
|
+
console.error(`[voidnet] ${message}`);
|
|
34
|
+
process.exit(code);
|
|
35
|
+
},
|
|
36
|
+
// One-line success note on stderr (never pollutes data streams).
|
|
37
|
+
ok(message) {
|
|
38
|
+
if (mode === 'quiet') return;
|
|
39
|
+
console.error(`[voidnet] ${message}`);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/probes.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Shared probe harness for status + doctor. Every probe is bounded,
|
|
2
|
+
// named, and returns a row — never throws, never hangs past its budget.
|
|
3
|
+
// Chatter goes through onVerbose so quiet runs show rows only.
|
|
4
|
+
export async function probe(name, url, { timeoutMs = 3000, expectJson = false, onVerbose = null } = {}) {
|
|
5
|
+
const started = Date.now();
|
|
6
|
+
const say = (m) => { if (onVerbose) onVerbose(m); };
|
|
7
|
+
say(`probe start name=${name}`);
|
|
8
|
+
try {
|
|
9
|
+
const controller = new AbortController();
|
|
10
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
11
|
+
let res;
|
|
12
|
+
try {
|
|
13
|
+
res = await fetch(url, { signal: controller.signal });
|
|
14
|
+
} finally {
|
|
15
|
+
clearTimeout(timer);
|
|
16
|
+
}
|
|
17
|
+
const latencyMs = Date.now() - started;
|
|
18
|
+
let detail = `http=${res.status}`;
|
|
19
|
+
if (expectJson) {
|
|
20
|
+
const body = await res.json().catch(() => null);
|
|
21
|
+
if (body && typeof body.status === 'string') detail += ` status=${body.status}`;
|
|
22
|
+
}
|
|
23
|
+
const ok = res.status < 500;
|
|
24
|
+
say(`probe done name=${name} ok=${ok} latency=${latencyMs}ms`);
|
|
25
|
+
return { name, ok, detail, latencyMs };
|
|
26
|
+
} catch (err) {
|
|
27
|
+
const latencyMs = Date.now() - started;
|
|
28
|
+
const reason = err && err.name === 'AbortError' ? 'timeout' : String((err && err.message) || err);
|
|
29
|
+
say(`probe failed name=${name} reason=${reason}`);
|
|
30
|
+
return { name, ok: false, detail: reason, latencyMs };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function checkNode(minMajor = 20, minMinor = 9, { onVerbose = null } = {}) {
|
|
35
|
+
const match = /^v(\d+)\.(\d+)/.exec(process.version || '');
|
|
36
|
+
const major = match ? Number(match[1]) : 0;
|
|
37
|
+
const minor = match ? Number(match[2]) : 0;
|
|
38
|
+
const ok = major > minMajor || (major === minMajor && minor >= minMinor);
|
|
39
|
+
if (onVerbose) onVerbose(`runtime check node=${process.version} ok=${ok}`);
|
|
40
|
+
return {
|
|
41
|
+
name: 'runtime',
|
|
42
|
+
ok,
|
|
43
|
+
detail: ok ? `node=${process.version}` : `node=${process.version}, need >=${minMajor}.${minMinor} — upgrade Node`,
|
|
44
|
+
latencyMs: 0,
|
|
45
|
+
};
|
|
46
|
+
}
|
package/src/update.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
const FETCH_TIMEOUT_MS = 3000;
|
|
6
|
+
|
|
7
|
+
function cachePath(homeDir) {
|
|
8
|
+
return `${homeDir}/.config/voidnet/update-cache.json`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function readCache(path) {
|
|
12
|
+
try {
|
|
13
|
+
if (!existsSync(path)) return null;
|
|
14
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
15
|
+
} catch (err) {
|
|
16
|
+
console.error(`[voidnet] update cache unreadable: ${String(err && err.message || err)}`);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeCache(path, payload) {
|
|
22
|
+
try {
|
|
23
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
24
|
+
writeFileSync(path, JSON.stringify({ ...payload, checkedAt: Date.now() }), { mode: 0o600 });
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.error(`[voidnet] update cache write failed: ${String(err && err.message || err)}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function newer(a, b) {
|
|
31
|
+
const pa = String(a).split('.').map(Number);
|
|
32
|
+
const pb = String(b).split('.').map(Number);
|
|
33
|
+
for (let i = 0; i < 3; i++) {
|
|
34
|
+
if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) > (pb[i] || 0);
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function fetchMeta(mgmtBase) {
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(`${mgmtBase}/.well-known/voidnet.json`, { signal: controller.signal });
|
|
44
|
+
if (!res.ok) return null;
|
|
45
|
+
return await res.json().catch(() => null);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
} finally {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Minimum-version gate: runs before the command. Fails loud (exit 3) when the
|
|
54
|
+
// installed CLI is below minimum. Silence only on check failure, never on verdict.
|
|
55
|
+
export async function gateMinimum({ pkgVersion, mgmtBase, homeDir, cache, onVerbose = null }) {
|
|
56
|
+
const say = (m) => { if (onVerbose) onVerbose(m); };
|
|
57
|
+
if (process.argv.includes('--no-update-check') || process.env.VOIDNET_NO_UPDATE_CHECK === '1') {
|
|
58
|
+
say('update check disabled by flag/env');
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const path = cache?.path || cachePath(homeDir);
|
|
62
|
+
let meta = cache?.meta || null;
|
|
63
|
+
if (!meta) {
|
|
64
|
+
const cached = readCache(path);
|
|
65
|
+
if (cached && Date.now() - (cached.checkedAt || 0) < CACHE_TTL_MS && cached.cli) {
|
|
66
|
+
meta = cached;
|
|
67
|
+
say('update metadata from cache');
|
|
68
|
+
} else {
|
|
69
|
+
const fresh = await fetchMeta(mgmtBase);
|
|
70
|
+
if (fresh && fresh.cli) {
|
|
71
|
+
meta = fresh;
|
|
72
|
+
writeCache(path, fresh);
|
|
73
|
+
say('update metadata refreshed');
|
|
74
|
+
} else {
|
|
75
|
+
say('update metadata unreachable — proceeding without version gate');
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const minimum = meta.cli.minimum;
|
|
81
|
+
if (minimum && newer(minimum, pkgVersion)) {
|
|
82
|
+
console.error(`[voidnet] CLI ${pkgVersion} is below minimum ${minimum}. Update: npm i -g openvoidnet`);
|
|
83
|
+
process.exit(3);
|
|
84
|
+
}
|
|
85
|
+
return meta;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Newer-version notice: runs after successful commands, stderr only.
|
|
89
|
+
export function noteUpdate({ pkgVersion, meta }) {
|
|
90
|
+
if (!meta || !meta.cli || !meta.cli.latest) return;
|
|
91
|
+
if (newer(meta.cli.latest, pkgVersion)) {
|
|
92
|
+
const extra = meta.cli.message ? ` ${meta.cli.message}` : '';
|
|
93
|
+
console.error(`[voidnet] update available: voidnet ${meta.cli.latest} (you have ${pkgVersion}). npm i -g openvoidnet.${extra}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const __test = { newer };
|
|
98
|
+
|
|
99
|
+
export function readPkgVersion(metaUrl) {
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(readFileSync(new URL('../package.json', metaUrl), 'utf8')).version || '0.0.0';
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error(`[voidnet] version unreadable: ${String(err && err.message || err)}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/words.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// User vocabulary: the only words that may reach human eyes.
|
|
2
|
+
// Internal names (auth, management, gateway, env, mcp, table columns) never
|
|
3
|
+
// appear in output directly — every display string passes through here.
|
|
4
|
+
// Tests assert these exact values appear in status/doctor rows.
|
|
5
|
+
export const SERVICE_NAMES = {
|
|
6
|
+
auth: 'Sign-in service',
|
|
7
|
+
management: 'Publisher API',
|
|
8
|
+
gateway: 'Gateway',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function serviceName(key) {
|
|
12
|
+
const name = SERVICE_NAMES[key];
|
|
13
|
+
if (!name) {
|
|
14
|
+
console.error(`[voidnet] unknown service key "${key}" — refusing to guess a label`);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
return name;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Marketplace vocabulary for app kinds. Raw adapter codes never reach humans;
|
|
21
|
+
// unknown future kinds pass through verbatim rather than failing the command.
|
|
22
|
+
export const ADAPTER_NAMES = {
|
|
23
|
+
mcp: 'tool',
|
|
24
|
+
llm: 'model',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function adapterName(code) {
|
|
28
|
+
return ADAPTER_NAMES[code] || String(code);
|
|
29
|
+
}
|