@synapsedx/cli 0.0.0-stage → 0.3.0-dev.20261012
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 +35 -2
- package/bin/synapsedx.js +469 -0
- package/package.json +13 -4
- package/test/cli.test.js +108 -0
- package/test-policy.json +7 -0
package/README.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `synapsedx` CLI — zero-dependency command line (Node 18+)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Apache-2.0. Part of [SynapseDX](../../README.md) Tier-1 PaaS. No `npm install` needed —
|
|
4
|
+
single file, only Node's built-in `fetch`.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
node tools/cli/bin/synapsedx.js --help
|
|
8
|
+
# hoặc link toàn cục:
|
|
9
|
+
npm link ./tools/cli # rồi gọi `synapsedx ...` ở mọi nơi
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Nhanh
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
export SYNAPSEDX_BASE_URL=http://localhost:8080
|
|
16
|
+
synapsedx login --email demo@acme.local
|
|
17
|
+
synapsedx orgs
|
|
18
|
+
synapsedx accounts create prod Production
|
|
19
|
+
synapsedx authz <user-id> workflow:Execute "*" --context team=etl
|
|
20
|
+
synapsedx workflows create --template webhook-to-log --name demo
|
|
21
|
+
synapsedx audit --limit 20
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quy ước
|
|
25
|
+
- Auth: `--base > $SYNAPSEDX_BASE_URL > ~/.synapsedx/config.json > http://localhost:8080`;
|
|
26
|
+
token/key: `--token/--apikey > env > config`. `synapsedx login` / `synapsedx use-key` lưu vào
|
|
27
|
+
`~/.synapsedx/config.json` (file 0600, secrets không bao giờ in ra — `config` redact).
|
|
28
|
+
- Ưu tiên env (`$SYNAPSEDX_TOKEN`, `$SYNAPSEDX_API_KEY`, `$SYNAPSEDX_REFRESH_TOKEN`) hoặc
|
|
29
|
+
config hơn `--token/--apikey` trên dòng lệnh (flag lộ trong `ps`/shell history).
|
|
30
|
+
- Refresh token cũng được lưu (để `refresh` / `logout-all` dùng lại); cũng có thể truyền qua
|
|
31
|
+
`$SYNAPSEDX_REFRESH_TOKEN`.
|
|
32
|
+
- `--json` in raw JSON (script-friendly); mặc định in bảng gọn.
|
|
33
|
+
- Mọi lệnh đều gọi gateway (`/v1/*`) nên tôn trọng cùng auth/rate-limit.
|
|
34
|
+
- Full lệnh: `login refresh logout-all verify use-key logout config whoami orgs accounts
|
|
35
|
+
users roles policies attach assign authz sim audit keys sa workflows executions templates`
|
|
36
|
+
(`synapsedx help`, xem `usage:` từng lệnh).
|
package/bin/synapsedx.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Copyright 2026 SynapseDX Contributors
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* synapsedx CLI — zero dependencies, Node 18+ (global fetch).
|
|
6
|
+
*/
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const VERSION = (() => {
|
|
14
|
+
try {
|
|
15
|
+
return require('../package.json').version || '0.0.0';
|
|
16
|
+
} catch {
|
|
17
|
+
return '0.0.0';
|
|
18
|
+
}
|
|
19
|
+
})();
|
|
20
|
+
const CONF_DIR = path.join(os.homedir(), '.synapsedx');
|
|
21
|
+
const CONF_FILE = path.join(CONF_DIR, 'config.json');
|
|
22
|
+
|
|
23
|
+
// ---------- config ----------
|
|
24
|
+
function loadConfig() {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(fs.readFileSync(CONF_FILE, 'utf8'));
|
|
27
|
+
} catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function saveConfig(cfg) {
|
|
32
|
+
fs.mkdirSync(CONF_DIR, { recursive: true, mode: 0o700 });
|
|
33
|
+
// Credentials on disk: owner-only (0600). Token/API key in this file is a
|
|
34
|
+
// bearer secret — never print it (see cmdConfig redaction below).
|
|
35
|
+
fs.writeFileSync(CONF_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
36
|
+
try { fs.chmodSync(CONF_FILE, 0o600); } catch { /* non-POSIX (win) */ }
|
|
37
|
+
}
|
|
38
|
+
function baseUrl(opts) {
|
|
39
|
+
return (opts.base || process.env.SYNAPSEDX_BASE_URL || loadConfig().baseUrl || 'http://localhost:8080').replace(/\/$/, '');
|
|
40
|
+
}
|
|
41
|
+
function authHeaders(opts) {
|
|
42
|
+
const cfg = loadConfig();
|
|
43
|
+
const token = opts.token || process.env.SYNAPSEDX_TOKEN || cfg.token;
|
|
44
|
+
const apiKey = opts.apikey || process.env.SYNAPSEDX_API_KEY || cfg.apiKey;
|
|
45
|
+
const h = { 'Content-Type': 'application/json', Connection: 'close' };
|
|
46
|
+
if (token) h.Authorization = `Bearer ${token}`;
|
|
47
|
+
if (apiKey) h['X-API-Key'] = apiKey;
|
|
48
|
+
return h;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------- http ----------
|
|
52
|
+
async function req(method, p, body, opts) {
|
|
53
|
+
let r;
|
|
54
|
+
try {
|
|
55
|
+
r = await fetch(baseUrl(opts) + p, {
|
|
56
|
+
method,
|
|
57
|
+
headers: authHeaders(opts),
|
|
58
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
59
|
+
});
|
|
60
|
+
} catch (e) {
|
|
61
|
+
throw new Error(`network ${p}: ${e.message || e}`);
|
|
62
|
+
}
|
|
63
|
+
const text = await r.text();
|
|
64
|
+
let data = null;
|
|
65
|
+
try {
|
|
66
|
+
data = text ? JSON.parse(text) : null;
|
|
67
|
+
} catch {
|
|
68
|
+
data = text;
|
|
69
|
+
}
|
|
70
|
+
if (!r.ok) throw new Error(`HTTP ${r.status} ${p}: ${typeof data === 'string' ? data : JSON.stringify(data)}`);
|
|
71
|
+
return data;
|
|
72
|
+
}
|
|
73
|
+
const GET = (p, o) => req('GET', p, undefined, o);
|
|
74
|
+
const POST = (p, b, o) => req('POST', p, b, o);
|
|
75
|
+
const DEL = (p, o) => req('DELETE', p, undefined, o);
|
|
76
|
+
|
|
77
|
+
// ---------- output ----------
|
|
78
|
+
function out(data, opts, pick) {
|
|
79
|
+
if (opts.json) {
|
|
80
|
+
console.log(JSON.stringify(data, null, 2));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (data === null || data === undefined) {
|
|
84
|
+
console.log('(empty)');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (Array.isArray(data)) {
|
|
88
|
+
if (!data.length) {
|
|
89
|
+
console.log('(empty)');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const cols = pick || Object.keys(data[0]).filter((k) => !/document|context/i.test(k));
|
|
93
|
+
console.log(cols.join('\t'));
|
|
94
|
+
for (const row of data) {
|
|
95
|
+
console.log(cols.map((c) => fmt(row[c])).join('\t'));
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (typeof data === 'object') {
|
|
100
|
+
for (const [k, v] of Object.entries(data)) console.log(`${k}: ${fmt(v)}`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
console.log(String(data));
|
|
104
|
+
}
|
|
105
|
+
function fmt(v) {
|
|
106
|
+
if (v === null || v === undefined) return '-';
|
|
107
|
+
if (typeof v === 'object') return JSON.stringify(v);
|
|
108
|
+
const s = String(v);
|
|
109
|
+
return s.length > 40 ? s.slice(0, 37) + '...' : s;
|
|
110
|
+
}
|
|
111
|
+
function need(v, name) {
|
|
112
|
+
if (!v) {
|
|
113
|
+
console.error(`missing <${name}>`);
|
|
114
|
+
process.exit(2);
|
|
115
|
+
}
|
|
116
|
+
return v;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------- args ----------
|
|
120
|
+
function parse(argv) {
|
|
121
|
+
const args = [];
|
|
122
|
+
const opts = { json: false };
|
|
123
|
+
for (let i = 0; i < argv.length; i++) {
|
|
124
|
+
const a = argv[i];
|
|
125
|
+
if (a === '--json') opts.json = true;
|
|
126
|
+
else if (a === '--base' || a === '--token' || a === '--apikey') opts[a.slice(2)] = argv[++i];
|
|
127
|
+
else if (a.startsWith('--base=')) opts.base = a.slice(7);
|
|
128
|
+
else if (a.startsWith('--token=')) opts.token = a.slice(8);
|
|
129
|
+
else if (a.startsWith('--apikey=')) opts.apikey = a.slice(9);
|
|
130
|
+
else if (a.startsWith('--context=')) (opts.context ??= {}), Object.assign(opts.context, parseKV(a.slice(10)));
|
|
131
|
+
else if (a === '--context') {
|
|
132
|
+
const v = argv[++i];
|
|
133
|
+
if (!v) throw new Error('missing value for --context k=v');
|
|
134
|
+
(opts.context ??= {}), Object.assign(opts.context, parseKV(v));
|
|
135
|
+
}
|
|
136
|
+
else if (a === '--file') opts.file = argv[++i];
|
|
137
|
+
else if (a.startsWith('--file=')) opts.file = a.slice(7);
|
|
138
|
+
else if (a.startsWith('--limit=')) opts.limit = a.slice(8);
|
|
139
|
+
else if (a === '--limit') opts.limit = argv[++i];
|
|
140
|
+
else if (a.startsWith('--actor=')) opts.actor = a.slice(8);
|
|
141
|
+
else if (a === '--actor') opts.actor = argv[++i];
|
|
142
|
+
else if (a.startsWith('--name=')) opts.name = a.slice(7);
|
|
143
|
+
else if (a === '--name') opts.name = argv[++i];
|
|
144
|
+
else if (a.startsWith('--template=')) opts.template = a.slice(11);
|
|
145
|
+
else if (a === '--template') opts.template = argv[++i];
|
|
146
|
+
else if (a.startsWith('--email=')) opts.email = a.slice(8);
|
|
147
|
+
else if (a === '--email') opts.email = argv[++i];
|
|
148
|
+
else if (a.startsWith('--org=')) opts.org = a.slice(6);
|
|
149
|
+
else if (a === '--org') opts.org = argv[++i];
|
|
150
|
+
else if (a === '--label') opts.label = argv[++i];
|
|
151
|
+
else if (a.startsWith('--label=')) opts.label = a.slice(8);
|
|
152
|
+
else args.push(a);
|
|
153
|
+
}
|
|
154
|
+
return { args, opts };
|
|
155
|
+
}
|
|
156
|
+
function parseKV(s) {
|
|
157
|
+
const i = s.indexOf('=');
|
|
158
|
+
if (i < 0) throw new Error(`bad --context '${s}' (want k=v)`);
|
|
159
|
+
return { [s.slice(0, i)]: s.slice(i + 1) };
|
|
160
|
+
}
|
|
161
|
+
function readJson(file) {
|
|
162
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------- commands ----------
|
|
166
|
+
async function cmdLogin(o) {
|
|
167
|
+
const email = o.email || o._[0];
|
|
168
|
+
need(email, 'email');
|
|
169
|
+
const org = o.org || o._[1] || 'acme';
|
|
170
|
+
const r = await POST('/v1/auth/login', { email, orgSlug: org }, o);
|
|
171
|
+
const cfg = loadConfig();
|
|
172
|
+
cfg.baseUrl = baseUrl(o);
|
|
173
|
+
cfg.token = r.accessToken;
|
|
174
|
+
if (r.refreshToken) cfg.refreshToken = r.refreshToken;
|
|
175
|
+
delete cfg.apiKey;
|
|
176
|
+
saveConfig(cfg);
|
|
177
|
+
if (!o.json) console.log(`logged in as ${r.user.email} (token saved to ${CONF_FILE})`);
|
|
178
|
+
else console.log(JSON.stringify(r, null, 2));
|
|
179
|
+
}
|
|
180
|
+
async function cmdUseKey(o) {
|
|
181
|
+
const key = o._[0];
|
|
182
|
+
need(key, 'key');
|
|
183
|
+
const cfg = loadConfig();
|
|
184
|
+
cfg.baseUrl = baseUrl(o);
|
|
185
|
+
cfg.apiKey = key;
|
|
186
|
+
delete cfg.token;
|
|
187
|
+
saveConfig(cfg);
|
|
188
|
+
if (!o.json) console.log(`api key saved to ${CONF_FILE}`);
|
|
189
|
+
}
|
|
190
|
+
async function cmdMe(o) {
|
|
191
|
+
out(await GET('/v1/auth/me', o), o);
|
|
192
|
+
}
|
|
193
|
+
async function cmdOrgs(o) {
|
|
194
|
+
const [sub, ...rest] = o._;
|
|
195
|
+
if (!sub || sub === 'list') return out(await GET('/v1/orgs', o), o, ['slug', 'display_name', 'id']);
|
|
196
|
+
if (sub === 'create') {
|
|
197
|
+
const [slug, name] = rest;
|
|
198
|
+
need(slug, 'slug');
|
|
199
|
+
return out(await POST('/v1/orgs', { slug, displayName: name || slug }, o), o);
|
|
200
|
+
}
|
|
201
|
+
throw new Error(`unknown orgs subcommand '${sub}'`);
|
|
202
|
+
}
|
|
203
|
+
async function orgId(o, maybeId) {
|
|
204
|
+
if (maybeId) return maybeId;
|
|
205
|
+
const orgs = await GET('/v1/orgs', o);
|
|
206
|
+
if (!orgs.length) throw new Error('no organizations (login first?)');
|
|
207
|
+
const bySlug = orgs.find((x) => x.slug === (process.env.DEMO_ORG_SLUG || 'acme'));
|
|
208
|
+
return (bySlug || orgs[0]).id;
|
|
209
|
+
}
|
|
210
|
+
async function cmdAccounts(o) {
|
|
211
|
+
const [sub, ...rest] = o._;
|
|
212
|
+
if (sub === 'create') {
|
|
213
|
+
const org = rest.length > 1 ? rest[0] : await orgId(o);
|
|
214
|
+
const slug = rest.length > 1 ? rest[1] : rest[0];
|
|
215
|
+
const name = rest.length > 2 ? rest[2] : slug;
|
|
216
|
+
need(slug, 'slug');
|
|
217
|
+
return out(await POST(`/v1/orgs/${org}/accounts`, { slug, displayName: name }, o), o);
|
|
218
|
+
}
|
|
219
|
+
const org = sub && sub !== 'list' ? sub : await orgId(o);
|
|
220
|
+
return out(await GET(`/v1/orgs/${encodeURIComponent(org)}/accounts`, o), o, ['slug', 'display_name', 'id']);
|
|
221
|
+
}
|
|
222
|
+
async function cmdUsers(o) {
|
|
223
|
+
const [sub, ...rest] = o._;
|
|
224
|
+
if (sub === 'create') {
|
|
225
|
+
const [org, email, name] = rest;
|
|
226
|
+
need(org, 'org'); need(email, 'email');
|
|
227
|
+
return out(await POST(`/v1/orgs/${encodeURIComponent(org)}/users`, { email, displayName: name || email }, o), o);
|
|
228
|
+
}
|
|
229
|
+
if (sub === 'policies') {
|
|
230
|
+
need(rest[0], 'user-id');
|
|
231
|
+
return out(await GET(`/v1/users/${encodeURIComponent(rest[0])}/policies`, o), o);
|
|
232
|
+
}
|
|
233
|
+
const org = await orgId(o, sub && sub !== 'list' ? sub : undefined);
|
|
234
|
+
return out(await GET(`/v1/orgs/${encodeURIComponent(org)}/users`, o), o, ['email', 'display_name', 'id']);
|
|
235
|
+
}
|
|
236
|
+
async function cmdRefresh(o) {
|
|
237
|
+
const cfg = loadConfig();
|
|
238
|
+
const rt = o._[0] || process.env.SYNAPSEDX_REFRESH_TOKEN || cfg.refreshToken;
|
|
239
|
+
need(rt, 'refresh-token (arg | $SYNAPSEDX_REFRESH_TOKEN)');
|
|
240
|
+
const r = await POST('/v1/auth/refresh', { refreshToken: rt }, o);
|
|
241
|
+
cfg.baseUrl = baseUrl(o);
|
|
242
|
+
cfg.token = r.accessToken;
|
|
243
|
+
if (r.refreshToken) cfg.refreshToken = r.refreshToken;
|
|
244
|
+
delete cfg.apiKey;
|
|
245
|
+
saveConfig(cfg);
|
|
246
|
+
if (!o.json) console.log(`token rotated (saved to ${CONF_FILE})`);
|
|
247
|
+
else console.log(JSON.stringify(r, null, 2));
|
|
248
|
+
}
|
|
249
|
+
async function cmdLogoutAll(o) {
|
|
250
|
+
const cfg = loadConfig();
|
|
251
|
+
const rt = o._[0] || process.env.SYNAPSEDX_REFRESH_TOKEN || cfg.refreshToken;
|
|
252
|
+
need(rt, 'refresh-token (arg | $SYNAPSEDX_REFRESH_TOKEN)');
|
|
253
|
+
const r = await POST('/v1/auth/logout-all', { refreshToken: rt }, o);
|
|
254
|
+
delete cfg.refreshToken;
|
|
255
|
+
saveConfig(cfg);
|
|
256
|
+
out(r, o);
|
|
257
|
+
}
|
|
258
|
+
async function cmdVerify(o) {
|
|
259
|
+
out(await GET('/v1/auth/verify', o), o);
|
|
260
|
+
}
|
|
261
|
+
async function cmdRoles(o) {
|
|
262
|
+
const [sub, ...rest] = o._;
|
|
263
|
+
if (sub === 'create') {
|
|
264
|
+
const [account, slug] = rest;
|
|
265
|
+
need(account, 'account'); need(slug, 'slug');
|
|
266
|
+
return out(await POST(`/v1/accounts/${encodeURIComponent(account)}/roles`, { slug }, o), o);
|
|
267
|
+
}
|
|
268
|
+
if (sub === 'delete') {
|
|
269
|
+
need(rest[0], 'role-id');
|
|
270
|
+
return out(await DEL(`/v1/roles/${encodeURIComponent(rest[0])}`, o), o);
|
|
271
|
+
}
|
|
272
|
+
need(sub, 'account');
|
|
273
|
+
return out(await GET(`/v1/accounts/${encodeURIComponent(sub)}/roles`, o), o, ['slug', 'id']);
|
|
274
|
+
}
|
|
275
|
+
async function cmdPolicies(o) {
|
|
276
|
+
const [sub, ...rest] = o._;
|
|
277
|
+
if (sub === 'create') {
|
|
278
|
+
const [account, slug] = rest;
|
|
279
|
+
need(account, 'account'); need(slug, 'slug'); need(o.file, '--file policy.json');
|
|
280
|
+
return out(await POST(`/v1/accounts/${encodeURIComponent(account)}/policies`, { slug, document: readJson(o.file) }, o), o);
|
|
281
|
+
}
|
|
282
|
+
if (sub === 'delete') {
|
|
283
|
+
need(rest[0], 'policy-id');
|
|
284
|
+
return out(await DEL(`/v1/policies/${encodeURIComponent(rest[0])}`, o), o);
|
|
285
|
+
}
|
|
286
|
+
need(sub, 'account');
|
|
287
|
+
return out(await GET(`/v1/accounts/${encodeURIComponent(sub)}/policies`, o), o, ['slug', 'id']);
|
|
288
|
+
}
|
|
289
|
+
async function cmdAttach(o) {
|
|
290
|
+
need(o._[0], 'role-id'); need(o._[1], 'policy-id');
|
|
291
|
+
return out(await POST(`/v1/roles/${encodeURIComponent(o._[0])}/attach`, { policyId: o._[1] }, o), o);
|
|
292
|
+
}
|
|
293
|
+
async function cmdAssign(o) {
|
|
294
|
+
need(o._[0], 'user-id'); need(o._[1], 'role-id');
|
|
295
|
+
return out(await POST(`/v1/users/${encodeURIComponent(o._[0])}/assign`, { roleId: o._[1] }, o), o);
|
|
296
|
+
}
|
|
297
|
+
async function cmdAuthz(o) {
|
|
298
|
+
const [user, action, resource] = o._;
|
|
299
|
+
need(user, 'user-id'); need(action, 'action'); need(resource, 'resource');
|
|
300
|
+
return out(await POST('/v1/authorize', { userId: user, action, resource, context: o.context }, o), o);
|
|
301
|
+
}
|
|
302
|
+
async function cmdSim(o) {
|
|
303
|
+
need(o.file, '--file policy.json');
|
|
304
|
+
const [action, resource] = o._;
|
|
305
|
+
need(action, 'action'); need(resource, 'resource');
|
|
306
|
+
const raw = readJson(o.file);
|
|
307
|
+
const docs = Array.isArray(raw) ? raw : [raw];
|
|
308
|
+
return out(await POST('/v1/authorize/dry-run', { documents: docs, action, resource, context: o.context }, o), o);
|
|
309
|
+
}
|
|
310
|
+
async function cmdAudit(o) {
|
|
311
|
+
const qs = `?limit=${o.limit || 50}${o.actor ? `&actor=${encodeURIComponent(o.actor)}` : ''}`;
|
|
312
|
+
return out(await GET(`/v1/audit${qs}`, o), o, ['created_at', 'actor_id', 'action', 'decision']);
|
|
313
|
+
}
|
|
314
|
+
async function cmdKeys(o) {
|
|
315
|
+
const [sub, ...rest] = o._;
|
|
316
|
+
if (sub === 'create') {
|
|
317
|
+
need(rest[0], 'account');
|
|
318
|
+
const r = await POST(`/v1/accounts/${encodeURIComponent(rest[0])}/keys`, { name: o.name || o.label || 'cli', scopes: ['*'] }, o);
|
|
319
|
+
if (!o.json) console.log(`SAVE THIS KEY (shown once):\n${r.apiKey}\nid: ${r.id}`);
|
|
320
|
+
else console.log(JSON.stringify(r, null, 2));
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (sub === 'revoke' || sub === 'delete') {
|
|
324
|
+
need(rest[0], 'key-id');
|
|
325
|
+
return out(await DEL(`/v1/keys/${encodeURIComponent(rest[0])}`, o), o);
|
|
326
|
+
}
|
|
327
|
+
if (sub === 'verify') {
|
|
328
|
+
need(rest[0] || o.apikey, 'api-key (arg or --apikey)');
|
|
329
|
+
return out(await POST('/v1/keys/verify', { apiKey: rest[0] || o.apikey }, o), o);
|
|
330
|
+
}
|
|
331
|
+
throw new Error(`usage: keys create <account> [--name N] | keys revoke <key-id> | keys verify <api-key>`);
|
|
332
|
+
}
|
|
333
|
+
async function cmdSa(o) {
|
|
334
|
+
const [sub, ...rest] = o._;
|
|
335
|
+
if (sub === 'create') {
|
|
336
|
+
need(rest[0], 'account'); need(rest[1], 'slug');
|
|
337
|
+
return out(await POST(`/v1/accounts/${encodeURIComponent(rest[0])}/service-accounts`, { slug: rest[1] }, o), o);
|
|
338
|
+
}
|
|
339
|
+
if (sub === 'token') {
|
|
340
|
+
need(rest[0], 'sa-id');
|
|
341
|
+
const r = await POST(`/v1/service-accounts/${encodeURIComponent(rest[0])}/token`, {}, o);
|
|
342
|
+
if (!o.json) console.log(`SAVE THIS TOKEN (short-lived):\n${r.accessToken}`);
|
|
343
|
+
else console.log(JSON.stringify(r, null, 2));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
need(sub, 'account');
|
|
347
|
+
return out(await GET(`/v1/accounts/${encodeURIComponent(sub)}/service-accounts`, o), o, ['slug', 'id']);
|
|
348
|
+
}
|
|
349
|
+
async function cmdWorkflows(o) {
|
|
350
|
+
const [sub, ...rest] = o._;
|
|
351
|
+
if (!sub || sub === 'list') {
|
|
352
|
+
const r = await GET('/v1/workflows', o);
|
|
353
|
+
return out(Array.isArray(r) ? r : r.data || r, o, ['id', 'name', 'active']);
|
|
354
|
+
}
|
|
355
|
+
if (sub === 'create') {
|
|
356
|
+
if (o.template) {
|
|
357
|
+
return out(await POST('/v1/workflows', { template: o.template, name: o.name }, o), o);
|
|
358
|
+
}
|
|
359
|
+
if (o.file) {
|
|
360
|
+
const raw = readJson(o.file);
|
|
361
|
+
// Accept either a full body ({template|definition, name}) or a bare definition.
|
|
362
|
+
const body = raw && (raw.template || raw.definition) ? raw : { name: o.name || 'synapsedx-workflow', definition: raw };
|
|
363
|
+
if (o.name && !body.name) body.name = o.name;
|
|
364
|
+
return out(await POST('/v1/workflows', body, o), o);
|
|
365
|
+
}
|
|
366
|
+
throw new Error(`workflows create needs --template T [--name N] or --file definition.json [--name N]`);
|
|
367
|
+
}
|
|
368
|
+
if (['get', 'activate', 'deactivate'].includes(sub)) {
|
|
369
|
+
need(rest[0], 'workflow-id');
|
|
370
|
+
const method = sub === 'get' ? 'GET' : 'POST';
|
|
371
|
+
const suf = sub === 'get' ? '' : `/${sub}`;
|
|
372
|
+
const id = encodeURIComponent(rest[0]);
|
|
373
|
+
if (method === 'GET') return out(await GET(`/v1/workflows/${id}`, o), o);
|
|
374
|
+
return out(await req('POST', `/v1/workflows/${id}${suf}`, {}, o), o);
|
|
375
|
+
}
|
|
376
|
+
if (sub === 'exec' || sub === 'execute') {
|
|
377
|
+
need(rest[0], 'workflow-id');
|
|
378
|
+
return out(await POST(`/v1/workflows/${encodeURIComponent(rest[0])}/execute`, {}, o), o);
|
|
379
|
+
}
|
|
380
|
+
throw new Error(`unknown workflows subcommand '${sub}'`);
|
|
381
|
+
}
|
|
382
|
+
async function cmdTemplates(o) {
|
|
383
|
+
const [sub, ...rest] = o._;
|
|
384
|
+
if (sub && sub !== 'list') {
|
|
385
|
+
return out(await GET(`/v1/templates/${encodeURIComponent(sub)}`, o), o);
|
|
386
|
+
}
|
|
387
|
+
if (sub === 'list' && rest[0]) {
|
|
388
|
+
return out(await GET(`/v1/templates/${encodeURIComponent(rest[0])}`, o), o);
|
|
389
|
+
}
|
|
390
|
+
return out(await GET('/v1/templates', o), o, ['name', 'description']);
|
|
391
|
+
}
|
|
392
|
+
async function cmdExecutions(o) {
|
|
393
|
+
const [sub, ...rest] = o._;
|
|
394
|
+
if (sub && sub !== 'list') {
|
|
395
|
+
return out(await GET(`/v1/executions/${encodeURIComponent(sub)}`, o), o);
|
|
396
|
+
}
|
|
397
|
+
const qs = o.limit ? `?limit=${encodeURIComponent(o.limit)}` : '';
|
|
398
|
+
const r = await GET(`/v1/executions${qs}`, o);
|
|
399
|
+
return out(Array.isArray(r) ? r : r.data || r, o, ['id', 'status', 'workflowId']);
|
|
400
|
+
}
|
|
401
|
+
async function cmdConfig() {
|
|
402
|
+
const cfg = loadConfig();
|
|
403
|
+
const redacted = { file: CONF_FILE, baseUrl: cfg.baseUrl };
|
|
404
|
+
if (cfg.token) redacted.token = '*** (saved)';
|
|
405
|
+
if (cfg.apiKey) redacted.apiKey = '*** (saved)';
|
|
406
|
+
if (cfg.refreshToken) redacted.refreshToken = '*** (saved)';
|
|
407
|
+
console.log(JSON.stringify(redacted, null, 2));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const COMMANDS = {
|
|
411
|
+
login: { fn: cmdLogin, use: 'login --email E [--org ORG] [--base URL]', doc: 'Dev login, save token + refresh' },
|
|
412
|
+
refresh: { fn: cmdRefresh, use: 'refresh [refresh-token]', doc: 'Rotate access token (atomic server-side)' },
|
|
413
|
+
'logout-all': { fn: cmdLogoutAll, use: 'logout-all [refresh-token]', doc: 'Revoke ALL sessions of token owner' },
|
|
414
|
+
verify: { fn: cmdVerify, use: 'verify', doc: 'Check current Bearer / API key' },
|
|
415
|
+
'use-key': { fn: cmdUseKey, use: 'use-key <api-key>', doc: 'Save API key instead of token' },
|
|
416
|
+
logout: { fn: async () => { saveConfig({ baseUrl: loadConfig().baseUrl }); console.log('logged out'); }, use: 'logout', doc: 'Clear saved credentials' },
|
|
417
|
+
config: { fn: cmdConfig, use: 'config', doc: 'Show saved config (secrets redacted)' },
|
|
418
|
+
whoami: { fn: cmdMe, use: 'whoami', doc: 'Current user (/v1/auth/me)' },
|
|
419
|
+
orgs: { fn: cmdOrgs, use: 'orgs [list|create <slug> [name]]', doc: 'Organizations' },
|
|
420
|
+
accounts: { fn: cmdAccounts, use: 'accounts [org] [list] | accounts create [org] <slug> [name]', doc: 'Accounts' },
|
|
421
|
+
users: { fn: cmdUsers, use: 'users [org] | users create <org> <email> [name] | users policies <user-id>', doc: 'Users + effective policies' },
|
|
422
|
+
roles: { fn: cmdRoles, use: 'roles <account> [list] | roles create <account> <slug> | roles delete <id>', doc: 'Roles' },
|
|
423
|
+
policies: { fn: cmdPolicies, use: 'policies <account> [list] | policies create <account> <slug> --file p.json | policies delete <id>', doc: 'Policies' },
|
|
424
|
+
attach: { fn: cmdAttach, use: 'attach <role-id> <policy-id>', doc: 'Attach policy to role' },
|
|
425
|
+
assign: { fn: cmdAssign, use: 'assign <user-id> <role-id>', doc: 'Assign role to user' },
|
|
426
|
+
authz: { fn: cmdAuthz, use: 'authz <user> <action> <resource> [--context k=v]', doc: 'Authorize check' },
|
|
427
|
+
sim: { fn: cmdSim, use: 'sim --file policy.json <action> <resource> [--context k=v]', doc: 'Policy simulator' },
|
|
428
|
+
audit: { fn: cmdAudit, use: 'audit [--limit N] [--actor ID]', doc: 'Audit log' },
|
|
429
|
+
keys: { fn: cmdKeys, use: 'keys create <account> [--name N] | keys revoke <key-id> | keys verify <api-key>', doc: 'API keys' },
|
|
430
|
+
sa: { fn: cmdSa, use: 'sa <account> [list] | sa create <account> <slug> | sa token <sa-id>', doc: 'Service accounts' },
|
|
431
|
+
workflows: { fn: cmdWorkflows, use: 'workflows [list] | workflows create --template T [--name N] | workflows create --file def.json [--name N] | workflows get|activate|deactivate|exec <id>', doc: 'Workflows (n8n)' },
|
|
432
|
+
executions: { fn: cmdExecutions, use: 'executions [list] [--limit N] | executions <id>', doc: 'Workflow executions' },
|
|
433
|
+
templates: { fn: cmdTemplates, use: 'templates [list|<name>]', doc: 'Built-in templates' },
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
function help() {
|
|
437
|
+
console.log(`synapsedx v${VERSION} — SynapseDX CLI (Apache-2.0)
|
|
438
|
+
|
|
439
|
+
Usage: synapsedx [--base URL] [--token T | --apikey K] [--json] <command> [args]
|
|
440
|
+
|
|
441
|
+
Auth defaults: --base > $SYNAPSEDX_BASE_URL > ~/.synapsedx/config.json > http://localhost:8080
|
|
442
|
+
`);
|
|
443
|
+
const names = Object.keys(COMMANDS);
|
|
444
|
+
const w = Math.max(...names.map((n) => n.length));
|
|
445
|
+
for (const n of names) console.log(` ${n.padEnd(w)} ${COMMANDS[n].doc}\n usage: synapsedx ${COMMANDS[n].use}`);
|
|
446
|
+
console.log('\nExamples:\n synapsedx login --email demo@acme.local\n synapsedx orgs create acme2 "ACME 2"\n synapsedx authz <uid> workflow:Execute "*" --context team=etl\n synapsedx --json workflows list');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function main() {
|
|
450
|
+
const { args, opts } = parse(process.argv.slice(2));
|
|
451
|
+
const [cmd, ...rest] = args;
|
|
452
|
+
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h' || cmd === 'version' || cmd === '--version') {
|
|
453
|
+
if (cmd === 'version' || cmd === '--version') return console.log(`synapsedx v${VERSION}`);
|
|
454
|
+
return help();
|
|
455
|
+
}
|
|
456
|
+
const c = COMMANDS[cmd];
|
|
457
|
+
if (!c) {
|
|
458
|
+
console.error(`unknown command '${cmd}' — try: synapsedx help`);
|
|
459
|
+
process.exit(2);
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
await c.fn({ ...opts, _: rest });
|
|
463
|
+
} catch (e) {
|
|
464
|
+
console.error(`error: ${e.message || e}`);
|
|
465
|
+
process.exit(1);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synapsedx/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0-dev.20261012",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"description": "synapsedx CLI - zero-dependency command line for Tier-1 PaaS (Node 18+)",
|
|
6
|
+
"bin": {
|
|
7
|
+
"synapsedx": "./bin/synapsedx.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test test/*.test.js || echo \"no tests\""
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
}
|
|
15
|
+
}
|
package/test/cli.test.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 SynapseDX Contributors
|
|
3
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* CLI offline tests — no platform needed (help/version/errors only).
|
|
5
|
+
*/
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const { describe, it, before, after } = require('node:test');
|
|
9
|
+
const assert = require('node:assert/strict');
|
|
10
|
+
const { execFile } = require('node:child_process');
|
|
11
|
+
const http = require('node:http');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const BIN = path.join(__dirname, '..', 'bin', 'synapsedx.js');
|
|
17
|
+
|
|
18
|
+
function run(...args) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
execFile(process.execPath, [BIN, ...args], { timeout: 15000 }, (err, stdout, stderr) => {
|
|
21
|
+
resolve({ code: err ? err.code || 1 : 0, stdout: String(stdout), stderr: String(stderr) });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Stub "gateway": records the last request, answers canned JSON.
|
|
27
|
+
let last = {};
|
|
28
|
+
let stubBase = '';
|
|
29
|
+
let stub;
|
|
30
|
+
before(
|
|
31
|
+
() =>
|
|
32
|
+
new Promise((resolve) => {
|
|
33
|
+
stub = http
|
|
34
|
+
.createServer((req, res) => {
|
|
35
|
+
let body = '';
|
|
36
|
+
req.on('data', (c) => (body += c));
|
|
37
|
+
req.on('end', () => {
|
|
38
|
+
last = { method: req.method, path: req.url, body: body ? JSON.parse(body) : null };
|
|
39
|
+
res.setHeader('Content-Type', 'application/json');
|
|
40
|
+
res.end(JSON.stringify({ ok: true }));
|
|
41
|
+
});
|
|
42
|
+
})
|
|
43
|
+
.listen(0, '127.0.0.1', () => {
|
|
44
|
+
stubBase = `http://127.0.0.1:${stub.address().port}`;
|
|
45
|
+
resolve();
|
|
46
|
+
});
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
after(() => new Promise((resolve) => stub.close(() => resolve())));
|
|
50
|
+
|
|
51
|
+
describe('synapsedx cli offline', () => {
|
|
52
|
+
it('--version exits 0', async () => {
|
|
53
|
+
const r = await run('--version');
|
|
54
|
+
assert.equal(r.code, 0);
|
|
55
|
+
assert.match(r.stdout, /synapsedx v\d+\.\d+\.\d+/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('help lists commands', async () => {
|
|
59
|
+
const r = await run('help');
|
|
60
|
+
assert.equal(r.code, 0);
|
|
61
|
+
for (const cmd of ['login', 'refresh', 'logout-all', 'verify', 'orgs', 'users', 'authz', 'workflows', 'executions', 'templates', 'audit', 'keys', 'sa']) {
|
|
62
|
+
assert.ok(r.stdout.includes(cmd), cmd);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('unknown command exits 2', async () => {
|
|
67
|
+
const r = await run('nope-nope');
|
|
68
|
+
assert.equal(r.code, 2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('missing arg exits 2', async () => {
|
|
72
|
+
const r = await run('attach', 'only-one');
|
|
73
|
+
assert.equal(r.code, 2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('workflows create without template/file explains usage', async () => {
|
|
77
|
+
const r = await run('workflows', 'create');
|
|
78
|
+
assert.equal(r.code, 1);
|
|
79
|
+
assert.match(r.stderr + r.stdout, /--template|--file/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('workflows create --file posts definition body', async () => {
|
|
83
|
+
const f = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'cli-')), 'def.json');
|
|
84
|
+
fs.writeFileSync(f, JSON.stringify({ nodes: [], connections: {} }));
|
|
85
|
+
const r = await run('--base', stubBase, 'workflows', 'create', '--file', f, '--name', 'w1');
|
|
86
|
+
assert.equal(r.code, 0);
|
|
87
|
+
assert.equal(last.method, 'POST');
|
|
88
|
+
assert.equal(last.path, '/v1/workflows');
|
|
89
|
+
assert.equal(last.body.name, 'w1');
|
|
90
|
+
assert.deepEqual(last.body.definition, { nodes: [], connections: {} });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('users create + policies hit encoded paths', async () => {
|
|
94
|
+
const r = await run('--base', stubBase, 'users', 'create', 'org1', 'a@b.c', 'A');
|
|
95
|
+
assert.equal(r.code, 0);
|
|
96
|
+
assert.equal(last.path, '/v1/orgs/org1/users');
|
|
97
|
+
assert.equal(last.body.email, 'a@b.c');
|
|
98
|
+
const r2 = await run('--base', stubBase, 'users', 'policies', 'u 1');
|
|
99
|
+
assert.equal(r2.code, 0);
|
|
100
|
+
assert.equal(last.path, '/v1/users/u%201/policies');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('unreachable base exits 1 with synapsedx error', async () => {
|
|
104
|
+
const r = await run('--base', 'http://127.0.0.1:1', 'orgs');
|
|
105
|
+
assert.equal(r.code, 1);
|
|
106
|
+
assert.match(r.stderr + r.stdout, /network|HTTP/);
|
|
107
|
+
});
|
|
108
|
+
});
|
package/test-policy.json
ADDED