@zergai/cyberdeck 0.1.0-beta.1
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/COMMERCIAL-LICENSING.md +27 -0
- package/LICENSE.md +375 -0
- package/NOTICE +17 -0
- package/README.md +284 -0
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/dist/automation.js +161 -0
- package/dist/client.js +493 -0
- package/dist/commands/auth.js +193 -0
- package/dist/commands/automation.js +164 -0
- package/dist/commands/decks.js +310 -0
- package/dist/commands/fleet.js +393 -0
- package/dist/commands/oracle.js +143 -0
- package/dist/commands/portfolio.js +19 -0
- package/dist/commands/scenarios.js +207 -0
- package/dist/commands/sdk.js +229 -0
- package/dist/commands/workbench.js +225 -0
- package/dist/config.js +48 -0
- package/dist/fleet.js +88 -0
- package/dist/index.js +43 -0
- package/dist/portfolio.js +148 -0
- package/dist/runtime.js +165 -0
- package/dist/vendors.js +65 -0
- package/package.json +51 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeckClient — thin HTTP wrapper over the ZergCyberDeck API + a Socket.IO probe
|
|
3
|
+
* for a running zerg. Authenticated deck calls send the CLI's bearer token;
|
|
4
|
+
* catalog discovery is intentionally public and never sends a credential.
|
|
5
|
+
*/
|
|
6
|
+
import { io } from 'socket.io-client';
|
|
7
|
+
import { loadConfig } from './config.js';
|
|
8
|
+
import { parseCloneCredential, parseCloneRuntime, } from './runtime.js';
|
|
9
|
+
import { parseDeckPortfolio } from './portfolio.js';
|
|
10
|
+
const FLEET_REQUEST_TIMEOUT_MS = 300_000;
|
|
11
|
+
/** Raw HTTP GET/POST to any URL (used for clone SOC endpoints + /__sim/seed). */
|
|
12
|
+
export async function hit(method, url, opts = {}) {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 15000);
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(url, {
|
|
17
|
+
method,
|
|
18
|
+
headers: opts.headers,
|
|
19
|
+
body: opts.body,
|
|
20
|
+
signal: controller.signal,
|
|
21
|
+
});
|
|
22
|
+
const text = await res.text();
|
|
23
|
+
let json = null;
|
|
24
|
+
try {
|
|
25
|
+
json = JSON.parse(text);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
json = null;
|
|
29
|
+
}
|
|
30
|
+
return { status: res.status, json, text };
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
return { status: 0, json: null, text: String(err instanceof Error ? err.message : err) };
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export class DeckClient {
|
|
40
|
+
baseUrl;
|
|
41
|
+
token;
|
|
42
|
+
constructor(baseUrl, token) {
|
|
43
|
+
this.baseUrl = baseUrl;
|
|
44
|
+
this.token = token;
|
|
45
|
+
}
|
|
46
|
+
static fromConfig() {
|
|
47
|
+
const cfg = loadConfig();
|
|
48
|
+
return new DeckClient(cfg.baseUrl, cfg.token);
|
|
49
|
+
}
|
|
50
|
+
async req(method, path, body, authenticated = true, timeoutMs = 30_000, redactedError, extraHeaders = {}) {
|
|
51
|
+
const token = this.token;
|
|
52
|
+
if (authenticated && !token) {
|
|
53
|
+
throw new Error('Not authenticated — run `zcd login` first.');
|
|
54
|
+
}
|
|
55
|
+
const headers = { ...extraHeaders };
|
|
56
|
+
if (authenticated)
|
|
57
|
+
headers.Authorization = `Bearer ${token}`;
|
|
58
|
+
if (body !== undefined)
|
|
59
|
+
headers['Content-Type'] = 'application/json';
|
|
60
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
61
|
+
method,
|
|
62
|
+
headers,
|
|
63
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
64
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
65
|
+
});
|
|
66
|
+
const text = await res.text();
|
|
67
|
+
let json;
|
|
68
|
+
try {
|
|
69
|
+
json = JSON.parse(text);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
json = text;
|
|
73
|
+
}
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
if (redactedError) {
|
|
76
|
+
throw new Error(`${method} ${path} → ${res.status}: ${redactedError}`);
|
|
77
|
+
}
|
|
78
|
+
const detail = typeof json === 'string' ? json : JSON.stringify(json);
|
|
79
|
+
throw new Error(`${method} ${path} → ${res.status}: ${detail.slice(0, 300)}`);
|
|
80
|
+
}
|
|
81
|
+
return json;
|
|
82
|
+
}
|
|
83
|
+
async getCatalog() {
|
|
84
|
+
const response = await this.req('GET', '/api/catalog', undefined, false);
|
|
85
|
+
if (!isCloneCatalog(response)) {
|
|
86
|
+
throw new Error(`GET /api/catalog returned an invalid schema from ${this.baseUrl}`);
|
|
87
|
+
}
|
|
88
|
+
return response;
|
|
89
|
+
}
|
|
90
|
+
getIdentity() {
|
|
91
|
+
return this.req('GET', '/api/me');
|
|
92
|
+
}
|
|
93
|
+
revokeToken() {
|
|
94
|
+
return this.req('DELETE', '/api/auth/cli-token');
|
|
95
|
+
}
|
|
96
|
+
listScenarioDefinitions(kind = 'scenario', includeDeleted = false) {
|
|
97
|
+
const query = new URLSearchParams({ kind });
|
|
98
|
+
if (includeDeleted)
|
|
99
|
+
query.set('includeDeleted', 'true');
|
|
100
|
+
return this.req('GET', `/api/scenarios?${query.toString()}`);
|
|
101
|
+
}
|
|
102
|
+
getScenarioDefinition(id, includeDeleted = false) {
|
|
103
|
+
const suffix = includeDeleted ? '?includeDeleted=true' : '';
|
|
104
|
+
return this.req('GET', `/api/scenarios/${id}${suffix}`);
|
|
105
|
+
}
|
|
106
|
+
createScenarioDefinition(input) {
|
|
107
|
+
return this.req('POST', '/api/scenarios', input);
|
|
108
|
+
}
|
|
109
|
+
updateScenarioDefinition(id, input) {
|
|
110
|
+
return this.req('PATCH', `/api/scenarios/${id}/draft`, input);
|
|
111
|
+
}
|
|
112
|
+
publishScenarioDefinition(id, expectedVersion) {
|
|
113
|
+
return this.req('POST', `/api/scenarios/${id}/publish`, { expectedVersion });
|
|
114
|
+
}
|
|
115
|
+
deleteScenarioDefinition(id) {
|
|
116
|
+
return this.req('DELETE', `/api/scenarios/${id}`);
|
|
117
|
+
}
|
|
118
|
+
restoreScenarioDefinition(id) {
|
|
119
|
+
return this.req('POST', `/api/scenarios/${id}/restore`, {});
|
|
120
|
+
}
|
|
121
|
+
launchScenarioRun(deckId, input) {
|
|
122
|
+
return this.req('POST', `/api/decks/${encodeURIComponent(deckId)}/runs`, input, true, 120_000);
|
|
123
|
+
}
|
|
124
|
+
listScenarioRuns(deckId) {
|
|
125
|
+
return this.req('GET', `/api/decks/${encodeURIComponent(deckId)}/runs`);
|
|
126
|
+
}
|
|
127
|
+
getScenarioRun(deckId, runId) {
|
|
128
|
+
return this.req('GET', `/api/decks/${encodeURIComponent(deckId)}/runs/${encodeURIComponent(runId)}`);
|
|
129
|
+
}
|
|
130
|
+
controlScenarioRun(deckId, runId, desired) {
|
|
131
|
+
return this.req('POST', `/api/decks/${encodeURIComponent(deckId)}/runs/${encodeURIComponent(runId)}/control`, { desired });
|
|
132
|
+
}
|
|
133
|
+
listDecks() {
|
|
134
|
+
return this.req('GET', '/api/decks');
|
|
135
|
+
}
|
|
136
|
+
createDeck(name) {
|
|
137
|
+
return this.req('POST', '/api/decks', { name });
|
|
138
|
+
}
|
|
139
|
+
getDeck(id) {
|
|
140
|
+
return this.req('GET', `/api/decks/${id}`);
|
|
141
|
+
}
|
|
142
|
+
deleteDeck(id) {
|
|
143
|
+
return this.req('DELETE', `/api/decks/${id}`);
|
|
144
|
+
}
|
|
145
|
+
addClone(deckId, vendor) {
|
|
146
|
+
return this.req('POST', `/api/decks/${deckId}/clones`, { vendor });
|
|
147
|
+
}
|
|
148
|
+
setClone(deckId, cloneId, status) {
|
|
149
|
+
return this.req('PATCH', `/api/decks/${deckId}/clones/${cloneId}`, { status });
|
|
150
|
+
}
|
|
151
|
+
resetClone(deckId, cloneId) {
|
|
152
|
+
return this.req('POST', `/api/decks/${deckId}/clones/${cloneId}/reset`, {});
|
|
153
|
+
}
|
|
154
|
+
deleteClone(deckId, cloneId) {
|
|
155
|
+
return this.req('DELETE', `/api/decks/${deckId}/clones/${cloneId}`);
|
|
156
|
+
}
|
|
157
|
+
async getRuntime(deckId, cloneId) {
|
|
158
|
+
const path = `/api/decks/${deckId}/clones/${cloneId}/runtime`;
|
|
159
|
+
const response = await this.req('GET', path, undefined, true, 30_000, 'runtime request failed');
|
|
160
|
+
return parseCloneRuntime(response, `GET ${path}`);
|
|
161
|
+
}
|
|
162
|
+
getWorkbench(deckId, cloneId) {
|
|
163
|
+
return this.req('GET', `/api/decks/${deckId}/clones/${cloneId}/workbench`, undefined, true, 60_000, 'clone workbench request failed');
|
|
164
|
+
}
|
|
165
|
+
async getCloneDataDump(deckId, cloneId) {
|
|
166
|
+
const path = `/api/decks/${deckId}/clones/${cloneId}/data-dump`;
|
|
167
|
+
const response = await this.req('GET', path, undefined, true, 60_000, 'clone data dump request failed');
|
|
168
|
+
return parseCloneDataDump(response, `GET ${path}`);
|
|
169
|
+
}
|
|
170
|
+
async listOracleSets(deckId, limit = 50) {
|
|
171
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
|
172
|
+
throw new Error('Oracle set list limit must be an integer between 1 and 100.');
|
|
173
|
+
}
|
|
174
|
+
const path = `/api/decks/${deckId}/oracle-sets?limit=${limit}`;
|
|
175
|
+
const response = await this.req('GET', path);
|
|
176
|
+
if (!isRecord(response) || !Array.isArray(response.oracleSets)) {
|
|
177
|
+
throw new Error(`GET ${path} returned an invalid oracle set list schema`);
|
|
178
|
+
}
|
|
179
|
+
return response.oracleSets.map((value, index) => parseOracleSetRecord(value, `GET ${path} oracleSets[${index}]`));
|
|
180
|
+
}
|
|
181
|
+
async getOracleSet(deckId, oracleId) {
|
|
182
|
+
const path = `/api/decks/${deckId}/oracle-sets/${oracleId}`;
|
|
183
|
+
return parseOracleSetDetail(await this.req('GET', path), `GET ${path}`);
|
|
184
|
+
}
|
|
185
|
+
async createOracleSet(deckId, input) {
|
|
186
|
+
const path = `/api/decks/${deckId}/oracle-sets`;
|
|
187
|
+
return parseOracleSetDetail(await this.req('POST', path, input), `POST ${path}`);
|
|
188
|
+
}
|
|
189
|
+
async materializeOracleSet(deckId, oracleId) {
|
|
190
|
+
const path = `/api/decks/${deckId}/oracle-sets/${oracleId}/materialize`;
|
|
191
|
+
return parseOracleSetDetail(await this.req('POST', path, {}), `POST ${path}`);
|
|
192
|
+
}
|
|
193
|
+
async transitionOracleSet(deckId, oracleId, input) {
|
|
194
|
+
const path = `/api/decks/${deckId}/oracle-sets/${oracleId}/transition`;
|
|
195
|
+
return parseOracleSetDetail(await this.req('POST', path, input), `POST ${path}`);
|
|
196
|
+
}
|
|
197
|
+
submitWorkbenchRequirement(deckId, cloneId, requirement) {
|
|
198
|
+
return this.req('POST', `/api/decks/${deckId}/clones/${cloneId}/workbench/requirements`, { requirement }, true, 60_000, 'clone requirement request failed');
|
|
199
|
+
}
|
|
200
|
+
runWorkbenchAction(deckId, cloneId, action, body = {}) {
|
|
201
|
+
return this.req('POST', `/api/decks/${deckId}/clones/${cloneId}/workbench/actions`, { action, ...body }, true, action === 'hot_apply' ? 660_000 : 60_000, 'clone workbench action failed');
|
|
202
|
+
}
|
|
203
|
+
getCloneFileContent(deckId, cloneId, path) {
|
|
204
|
+
return this.req('GET', `/api/decks/${deckId}/clones/${cloneId}/files/content?path=${encodeURIComponent(path)}`, undefined, true, 60_000, 'clone source read failed');
|
|
205
|
+
}
|
|
206
|
+
async revealCredentials(deckId, cloneId) {
|
|
207
|
+
const path = `/api/decks/${deckId}/clones/${cloneId}/sdk-credentials`;
|
|
208
|
+
const response = await this.req('POST', path, undefined, true, 30_000, 'credential request failed');
|
|
209
|
+
return parseCloneCredential(response, `POST ${path}`);
|
|
210
|
+
}
|
|
211
|
+
seedClone(deckId, cloneId, reset) {
|
|
212
|
+
return this.req('POST', `/api/decks/${deckId}/clones/${cloneId}/seed`, { reset });
|
|
213
|
+
}
|
|
214
|
+
getDiagnostics(deckId, options = {}) {
|
|
215
|
+
const query = new URLSearchParams();
|
|
216
|
+
if (options.cloneId)
|
|
217
|
+
query.set('cloneId', options.cloneId);
|
|
218
|
+
if (options.tail !== undefined)
|
|
219
|
+
query.set('tail', String(options.tail));
|
|
220
|
+
const suffix = query.size > 0 ? `?${query.toString()}` : '';
|
|
221
|
+
return this.req('GET', `/api/decks/${deckId}/diagnostics${suffix}`, undefined, true, options.cloneId ? 30_000 : FLEET_REQUEST_TIMEOUT_MS);
|
|
222
|
+
}
|
|
223
|
+
async getPortfolio(deckId) {
|
|
224
|
+
const path = `/api/decks/${deckId}/portfolio`;
|
|
225
|
+
return parseDeckPortfolio(await this.req('GET', path), `GET ${path}`);
|
|
226
|
+
}
|
|
227
|
+
preflightClones(deckId, cloneIds) {
|
|
228
|
+
return this.req('POST', `/api/decks/${deckId}/clones/preflight`, { cloneIds }, true, FLEET_REQUEST_TIMEOUT_MS);
|
|
229
|
+
}
|
|
230
|
+
createCloneStartRun(deckId, cloneIds, idempotencyKey) {
|
|
231
|
+
return this.req('POST', `/api/decks/${deckId}/clone-start-runs`, { cloneIds }, true, FLEET_REQUEST_TIMEOUT_MS, undefined, { 'Idempotency-Key': idempotencyKey });
|
|
232
|
+
}
|
|
233
|
+
getCloneStartRun(deckId, runId) {
|
|
234
|
+
return this.req('GET', `/api/decks/${deckId}/clone-start-runs/${runId}`);
|
|
235
|
+
}
|
|
236
|
+
listCloneStartRuns(deckId, limit) {
|
|
237
|
+
return this.req('GET', `/api/decks/${deckId}/clone-start-runs?limit=${limit}`);
|
|
238
|
+
}
|
|
239
|
+
retryFailedCloneStartRun(deckId, runId) {
|
|
240
|
+
return this.req('POST', `/api/decks/${deckId}/clone-start-runs/${runId}/retry-failed`, {}, true, FLEET_REQUEST_TIMEOUT_MS);
|
|
241
|
+
}
|
|
242
|
+
getCloneLogs(deckId, cloneId, options = {}) {
|
|
243
|
+
const query = new URLSearchParams();
|
|
244
|
+
if (options.tail !== undefined)
|
|
245
|
+
query.set('tail', String(options.tail));
|
|
246
|
+
if (options.since)
|
|
247
|
+
query.set('since', options.since);
|
|
248
|
+
const suffix = query.size > 0 ? `?${query.toString()}` : '';
|
|
249
|
+
return this.req('GET', `/api/decks/${deckId}/clones/${cloneId}/logs${suffix}`, undefined, true, 30_000, 'log request failed');
|
|
250
|
+
}
|
|
251
|
+
startConformance(deckId, cloneId, seed) {
|
|
252
|
+
return this.req('POST', `/api/decks/${deckId}/clones/${cloneId}/conformance`, { seed });
|
|
253
|
+
}
|
|
254
|
+
getConformanceRun(deckId, cloneId, runId, timeoutMs) {
|
|
255
|
+
return this.req('GET', `/api/decks/${deckId}/clones/${cloneId}/conformance/${runId}`, undefined, true, timeoutMs);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function isRecord(value) {
|
|
259
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
260
|
+
}
|
|
261
|
+
const ORACLE_SET_STATUSES = new Set([
|
|
262
|
+
'draft',
|
|
263
|
+
'materializing',
|
|
264
|
+
'awaiting_parameter_review',
|
|
265
|
+
'parameters_approved',
|
|
266
|
+
'executing',
|
|
267
|
+
'awaiting_answer_review',
|
|
268
|
+
'answers_approved',
|
|
269
|
+
'sealing',
|
|
270
|
+
'ready',
|
|
271
|
+
'rejected',
|
|
272
|
+
'failed',
|
|
273
|
+
'stale',
|
|
274
|
+
]);
|
|
275
|
+
function isOracleSetStatus(value) {
|
|
276
|
+
return typeof value === 'string' && ORACLE_SET_STATUSES.has(value);
|
|
277
|
+
}
|
|
278
|
+
function isNullableString(value) {
|
|
279
|
+
return value === null || typeof value === 'string';
|
|
280
|
+
}
|
|
281
|
+
function isSha256OrNull(value) {
|
|
282
|
+
return value === null || (typeof value === 'string' && /^[a-f0-9]{64}$/.test(value));
|
|
283
|
+
}
|
|
284
|
+
function parseOracleSetRecord(value, label) {
|
|
285
|
+
if (!isRecord(value)
|
|
286
|
+
|| typeof value.id !== 'string'
|
|
287
|
+
|| typeof value.workspaceId !== 'string'
|
|
288
|
+
|| typeof value.deckId !== 'string'
|
|
289
|
+
|| (value.subjectKind !== 'scenario-run' && value.subjectKind !== 'clone-dump')
|
|
290
|
+
|| typeof value.subjectId !== 'string'
|
|
291
|
+
|| !isOracleSetStatus(value.status)
|
|
292
|
+
|| !Number.isSafeInteger(value.currentVersion)
|
|
293
|
+
|| Number(value.currentVersion) < 1
|
|
294
|
+
|| !isSha256OrNull(value.planHash)
|
|
295
|
+
|| !isSha256OrNull(value.executionHash)
|
|
296
|
+
|| !isSha256OrNull(value.baselineHash)
|
|
297
|
+
|| !isRecord(value.artifact)
|
|
298
|
+
|| !Number.isSafeInteger(value.artifact.sizeBytes)
|
|
299
|
+
|| Number(value.artifact.sizeBytes) < 1
|
|
300
|
+
|| typeof value.artifact.sha256 !== 'string'
|
|
301
|
+
|| !/^[a-f0-9]{64}$/.test(value.artifact.sha256)
|
|
302
|
+
|| typeof value.artifact.contentType !== 'string'
|
|
303
|
+
|| !isNullableString(value.createdByEmail)
|
|
304
|
+
|| !isNullableString(value.updatedByEmail)
|
|
305
|
+
|| !isNullableString(value.failureCode)
|
|
306
|
+
|| !isNullableString(value.failureMessage)
|
|
307
|
+
|| typeof value.createdAt !== 'string'
|
|
308
|
+
|| !Number.isFinite(Date.parse(value.createdAt))
|
|
309
|
+
|| typeof value.updatedAt !== 'string'
|
|
310
|
+
|| !Number.isFinite(Date.parse(value.updatedAt))) {
|
|
311
|
+
throw new Error(`${label} returned an invalid oracle set schema`);
|
|
312
|
+
}
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
315
|
+
function parseOracleSetDocument(value, label) {
|
|
316
|
+
if (!isRecord(value)
|
|
317
|
+
|| value.schemaVersion !== 1
|
|
318
|
+
|| value.kind !== 'cybersim/oracle-set'
|
|
319
|
+
|| typeof value.oracleSetId !== 'string'
|
|
320
|
+
|| !Number.isSafeInteger(value.version)
|
|
321
|
+
|| Number(value.version) < 1
|
|
322
|
+
|| !isOracleSetStatus(value.status)
|
|
323
|
+
|| typeof value.draftHash !== 'string'
|
|
324
|
+
|| !/^[a-f0-9]{64}$/.test(value.draftHash)
|
|
325
|
+
|| !isRecord(value.subject)
|
|
326
|
+
|| !Array.isArray(value.questions)) {
|
|
327
|
+
throw new Error(`${label} returned an invalid oracle document schema`);
|
|
328
|
+
}
|
|
329
|
+
return value;
|
|
330
|
+
}
|
|
331
|
+
function parseOracleSetDetail(value, label) {
|
|
332
|
+
if (!isRecord(value)) {
|
|
333
|
+
throw new Error(`${label} returned an invalid oracle set detail schema`);
|
|
334
|
+
}
|
|
335
|
+
const oracleSet = parseOracleSetRecord(value.oracleSet, `${label} oracleSet`);
|
|
336
|
+
const document = parseOracleSetDocument(value.document, `${label} document`);
|
|
337
|
+
if (document.oracleSetId !== oracleSet.id
|
|
338
|
+
|| document.version !== oracleSet.currentVersion
|
|
339
|
+
|| document.status !== oracleSet.status) {
|
|
340
|
+
throw new Error(`${label} returned inconsistent oracle set projections`);
|
|
341
|
+
}
|
|
342
|
+
return { oracleSet, document };
|
|
343
|
+
}
|
|
344
|
+
function parseCloneDataDump(value, label) {
|
|
345
|
+
if (!isRecord(value)
|
|
346
|
+
|| (value.schemaVersion !== 1 && value.schemaVersion !== 2)
|
|
347
|
+
|| typeof value.capturedAt !== 'string'
|
|
348
|
+
|| typeof value.dataHash !== 'string'
|
|
349
|
+
|| !/^sha256:[a-f0-9]{64}$/.test(value.dataHash)
|
|
350
|
+
|| !Number.isSafeInteger(value.sizeBytes)
|
|
351
|
+
|| value.sizeBytes < 0
|
|
352
|
+
|| !isRecord(value.deck)
|
|
353
|
+
|| typeof value.deck.id !== 'string'
|
|
354
|
+
|| typeof value.deck.name !== 'string'
|
|
355
|
+
|| !isRecord(value.clone)
|
|
356
|
+
|| typeof value.clone.id !== 'string'
|
|
357
|
+
|| typeof value.clone.vendor !== 'string'
|
|
358
|
+
|| typeof value.clone.name !== 'string'
|
|
359
|
+
|| !isRecord(value.snapshot)) {
|
|
360
|
+
throw new Error(`${label} returned an invalid clone data dump schema`);
|
|
361
|
+
}
|
|
362
|
+
if (value.schemaVersion === 2) {
|
|
363
|
+
const deployment = value.deployment;
|
|
364
|
+
if (typeof value.subjectHash !== 'string'
|
|
365
|
+
|| !/^sha256:[a-f0-9]{64}$/.test(value.subjectHash)
|
|
366
|
+
|| !isRecord(deployment)
|
|
367
|
+
|| deployment.bindingStatus !== 'bound'
|
|
368
|
+
|| !Number.isSafeInteger(deployment.generation)
|
|
369
|
+
|| deployment.generation < 1
|
|
370
|
+
|| typeof deployment.operationRunId !== 'string'
|
|
371
|
+
|| typeof deployment.operationItemId !== 'string'
|
|
372
|
+
|| !['app', 'zerg'].includes(String(deployment.runtimeMode))
|
|
373
|
+
|| typeof deployment.imageId !== 'string'
|
|
374
|
+
|| typeof deployment.imageDigest !== 'string'
|
|
375
|
+
|| !/^sha256:[a-f0-9]{64}$/.test(deployment.imageDigest)
|
|
376
|
+
|| typeof deployment.sourceCommit !== 'string'
|
|
377
|
+
|| !/^[a-f0-9]{40}$/.test(deployment.sourceCommit)
|
|
378
|
+
|| typeof deployment.inventorySha256 !== 'string'
|
|
379
|
+
|| !/^sha256:[a-f0-9]{64}$/.test(deployment.inventorySha256)) {
|
|
380
|
+
throw new Error(`${label} returned an invalid clone data dump schema`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return value;
|
|
384
|
+
}
|
|
385
|
+
function isCloneTarget(value) {
|
|
386
|
+
if (!isRecord(value) || !isRecord(value.capabilities))
|
|
387
|
+
return false;
|
|
388
|
+
const capabilities = value.capabilities;
|
|
389
|
+
return (typeof value.id === 'string'
|
|
390
|
+
&& (typeof value.connectorId === 'string' || value.connectorId === null)
|
|
391
|
+
&& typeof value.displayName === 'string'
|
|
392
|
+
&& typeof value.vendorEvokes === 'string'
|
|
393
|
+
&& typeof value.category === 'string'
|
|
394
|
+
&& typeof value.port === 'number'
|
|
395
|
+
&& typeof value.protocol === 'string'
|
|
396
|
+
&& (value.managedCredential === undefined
|
|
397
|
+
|| value.managedCredential === null
|
|
398
|
+
|| isManagedCloneCredential(value.managedCredential))
|
|
399
|
+
&& typeof value.maturity === 'string'
|
|
400
|
+
&& typeof value.conformanceAvailable === 'boolean'
|
|
401
|
+
&& typeof capabilities.query === 'boolean'
|
|
402
|
+
&& typeof capabilities.mutate === 'boolean'
|
|
403
|
+
&& typeof capabilities.ingest === 'boolean'
|
|
404
|
+
&& typeof capabilities.export === 'boolean'
|
|
405
|
+
&& typeof capabilities.ui === 'boolean');
|
|
406
|
+
}
|
|
407
|
+
function isSafeDescriptorText(value) {
|
|
408
|
+
return (typeof value === 'string'
|
|
409
|
+
&& value.trim().length > 0
|
|
410
|
+
&& value.length <= 160
|
|
411
|
+
&& !/[\u0000-\u001f\u007f]/u.test(value));
|
|
412
|
+
}
|
|
413
|
+
function isManagedCloneCredential(value) {
|
|
414
|
+
return (isRecord(value)
|
|
415
|
+
&& ((value.kind === 'sdk-token' && value.purpose === 'sdk')
|
|
416
|
+
|| (value.kind === 'api-token' && value.purpose === 'api')
|
|
417
|
+
|| (value.kind === 'console-token' && value.purpose === 'app'))
|
|
418
|
+
&& isSafeDescriptorText(value.label)
|
|
419
|
+
&& isSafeDescriptorText(value.scheme)
|
|
420
|
+
&& isSafeDescriptorText(value.header));
|
|
421
|
+
}
|
|
422
|
+
function isCloneCatalog(value) {
|
|
423
|
+
return (isRecord(value)
|
|
424
|
+
&& value.schemaVersion === 1
|
|
425
|
+
&& Array.isArray(value.targets)
|
|
426
|
+
&& value.targets.every(isCloneTarget));
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Connect to a running zerg's Socket.IO service (:3333) and report whether it
|
|
430
|
+
* speaks the zerg contract + which manifest it's running. Mirrors what ZDE does
|
|
431
|
+
* on attach.
|
|
432
|
+
*/
|
|
433
|
+
export function probeZerg(socketUrl, timeoutMs = 15000) {
|
|
434
|
+
const origin = socketUrl.replace(/\/socket\.io\/?$/, '');
|
|
435
|
+
return new Promise((resolve) => {
|
|
436
|
+
const sock = io(origin, {
|
|
437
|
+
path: '/socket.io/',
|
|
438
|
+
transports: ['websocket'],
|
|
439
|
+
reconnection: false,
|
|
440
|
+
timeout: timeoutMs,
|
|
441
|
+
});
|
|
442
|
+
const finish = (r) => {
|
|
443
|
+
try {
|
|
444
|
+
sock.close();
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
// ignore
|
|
448
|
+
}
|
|
449
|
+
resolve(r);
|
|
450
|
+
};
|
|
451
|
+
const timer = setTimeout(() => finish({ connected: false, error: 'connect timeout' }), timeoutMs + 3000);
|
|
452
|
+
const ack = (event, data) => new Promise((res) => {
|
|
453
|
+
let settled = false;
|
|
454
|
+
const t = setTimeout(() => {
|
|
455
|
+
if (!settled) {
|
|
456
|
+
settled = true;
|
|
457
|
+
res({});
|
|
458
|
+
}
|
|
459
|
+
}, 10000);
|
|
460
|
+
sock.emit(event, data, (resp) => {
|
|
461
|
+
if (!settled) {
|
|
462
|
+
settled = true;
|
|
463
|
+
clearTimeout(t);
|
|
464
|
+
res(resp ?? {});
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
sock.on('connect_error', (e) => {
|
|
469
|
+
clearTimeout(timer);
|
|
470
|
+
finish({ connected: false, error: String(e?.message || e) });
|
|
471
|
+
});
|
|
472
|
+
sock.on('connect', () => {
|
|
473
|
+
void (async () => {
|
|
474
|
+
const cmds = await ack('fetch_zerg_commands', {});
|
|
475
|
+
const upd = await ack('request_zerg_update', { tail: 1 });
|
|
476
|
+
const probe = await ack('probe_status', {});
|
|
477
|
+
clearTimeout(timer);
|
|
478
|
+
const snap = upd.snapshot ?? {};
|
|
479
|
+
const manifest = snap.manifest;
|
|
480
|
+
finish({
|
|
481
|
+
connected: true,
|
|
482
|
+
contractVersion: cmds.contract_version,
|
|
483
|
+
supportedEvents: Array.isArray(cmds.supported_events) ? cmds.supported_events.length : undefined,
|
|
484
|
+
manifest: manifest?.name ?? probe.manifest_name,
|
|
485
|
+
serviceStatus: snap.service_status,
|
|
486
|
+
zergInitialized: snap.zerg_initialized,
|
|
487
|
+
available: probe.available,
|
|
488
|
+
});
|
|
489
|
+
})();
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { DeckClient } from '../client.js';
|
|
2
|
+
import { configFilePath, loadConfig, saveConfig } from '../config.js';
|
|
3
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
|
+
async function readJsonResponse(response) {
|
|
5
|
+
const text = await response.text();
|
|
6
|
+
try {
|
|
7
|
+
const value = JSON.parse(text);
|
|
8
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// The status and bounded response text below are more useful than a JSON parse error.
|
|
14
|
+
}
|
|
15
|
+
return { detail: text.slice(0, 200) };
|
|
16
|
+
}
|
|
17
|
+
function responseDetail(payload) {
|
|
18
|
+
for (const key of ['error_description', 'statusMessage', 'detail', 'error']) {
|
|
19
|
+
if (typeof payload[key] === 'string')
|
|
20
|
+
return payload[key];
|
|
21
|
+
}
|
|
22
|
+
return JSON.stringify(payload).slice(0, 200);
|
|
23
|
+
}
|
|
24
|
+
async function openBrowser(url) {
|
|
25
|
+
const { spawn } = await import('node:child_process');
|
|
26
|
+
const command = process.platform === 'darwin'
|
|
27
|
+
? 'open'
|
|
28
|
+
: process.platform === 'win32'
|
|
29
|
+
? 'cmd'
|
|
30
|
+
: 'xdg-open';
|
|
31
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
32
|
+
await new Promise((resolve) => {
|
|
33
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
34
|
+
let settled = false;
|
|
35
|
+
const finish = () => {
|
|
36
|
+
if (settled)
|
|
37
|
+
return;
|
|
38
|
+
settled = true;
|
|
39
|
+
resolve();
|
|
40
|
+
};
|
|
41
|
+
child.once('spawn', () => {
|
|
42
|
+
child.unref();
|
|
43
|
+
finish();
|
|
44
|
+
});
|
|
45
|
+
child.once('error', (error) => {
|
|
46
|
+
console.warn(`Could not open a browser automatically: ${error.message}`);
|
|
47
|
+
finish();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
async function startDeviceAuthorization(baseUrl, workspace) {
|
|
52
|
+
const response = await fetch(`${baseUrl}/api/auth/cli-device/start`, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'Content-Type': 'application/json' },
|
|
55
|
+
body: JSON.stringify(workspace ? { workspace } : {}),
|
|
56
|
+
signal: AbortSignal.timeout(30_000),
|
|
57
|
+
});
|
|
58
|
+
const payload = await readJsonResponse(response);
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
throw new Error(`login failed: ${response.status} ${responseDetail(payload)}`);
|
|
61
|
+
}
|
|
62
|
+
if (typeof payload.device_code !== 'string'
|
|
63
|
+
|| typeof payload.user_code !== 'string'
|
|
64
|
+
|| typeof payload.verification_uri !== 'string'
|
|
65
|
+
|| typeof payload.verification_uri_complete !== 'string'
|
|
66
|
+
|| typeof payload.expires_in !== 'number'
|
|
67
|
+
|| typeof payload.interval !== 'number') {
|
|
68
|
+
throw new Error('login failed: device authorization server returned an invalid response');
|
|
69
|
+
}
|
|
70
|
+
return payload;
|
|
71
|
+
}
|
|
72
|
+
async function pollDeviceAuthorization(baseUrl, start) {
|
|
73
|
+
const deadline = Date.now() + Math.max(1, start.expires_in) * 1000;
|
|
74
|
+
let intervalMs = Math.max(0, start.interval) * 1000;
|
|
75
|
+
while (Date.now() < deadline) {
|
|
76
|
+
const response = await fetch(`${baseUrl}/api/auth/cli-device/token`, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'Content-Type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({ device_code: start.device_code }),
|
|
80
|
+
signal: AbortSignal.timeout(Math.max(1, Math.min(30_000, deadline - Date.now()))),
|
|
81
|
+
});
|
|
82
|
+
const payload = await readJsonResponse(response);
|
|
83
|
+
const oauthError = typeof payload.error === 'string' ? payload.error : undefined;
|
|
84
|
+
if (oauthError === 'authorization_pending') {
|
|
85
|
+
await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (oauthError === 'slow_down') {
|
|
89
|
+
intervalMs += 5000;
|
|
90
|
+
await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (oauthError === 'access_denied')
|
|
94
|
+
throw new Error('Authorization denied.');
|
|
95
|
+
if (oauthError === 'expired_token')
|
|
96
|
+
throw new Error('Authorization expired; run `zcd login` again.');
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
throw new Error(`login failed: ${response.status} ${responseDetail(payload)}`);
|
|
99
|
+
}
|
|
100
|
+
if (typeof payload.access_token !== 'string'
|
|
101
|
+
|| payload.token_type !== 'Bearer'
|
|
102
|
+
|| typeof payload.expires_in !== 'number'
|
|
103
|
+
|| typeof payload.scope !== 'string'
|
|
104
|
+
|| typeof payload.workspace !== 'object'
|
|
105
|
+
|| payload.workspace === null
|
|
106
|
+
|| Array.isArray(payload.workspace)) {
|
|
107
|
+
throw new Error('login failed: token endpoint returned an invalid response');
|
|
108
|
+
}
|
|
109
|
+
const workspace = payload.workspace;
|
|
110
|
+
if (typeof workspace.id !== 'string' || typeof workspace.name !== 'string') {
|
|
111
|
+
throw new Error('login failed: token endpoint returned an invalid workspace');
|
|
112
|
+
}
|
|
113
|
+
return payload;
|
|
114
|
+
}
|
|
115
|
+
throw new Error('Authorization expired; run `zcd login` again.');
|
|
116
|
+
}
|
|
117
|
+
export function registerAuthCommands(program) {
|
|
118
|
+
program
|
|
119
|
+
.command('login')
|
|
120
|
+
.description('Authenticate in a browser and mint a workspace-bound personal token')
|
|
121
|
+
.option('--url <url>', 'Deck base URL (default: from config / ZERGCYBERDECK_BASE_URL)')
|
|
122
|
+
.option('--workspace <name>', 'request access to this workspace (name or id)')
|
|
123
|
+
.option('--no-browser', 'print the verification URL without opening a browser')
|
|
124
|
+
.action(async (opts) => {
|
|
125
|
+
const cfg = loadConfig();
|
|
126
|
+
const baseUrl = (opts.url || cfg.baseUrl).replace(/\/+$/, '');
|
|
127
|
+
const workspace = opts.workspace || process.env.ZERGCYBERDECK_WORKSPACE;
|
|
128
|
+
const start = await startDeviceAuthorization(baseUrl, workspace);
|
|
129
|
+
console.log(`Open ${start.verification_uri}`);
|
|
130
|
+
console.log(`User code: ${start.user_code}`);
|
|
131
|
+
if (opts.browser)
|
|
132
|
+
await openBrowser(start.verification_uri_complete);
|
|
133
|
+
const approved = await pollDeviceAuthorization(baseUrl, start);
|
|
134
|
+
const expiresAt = Math.floor(Date.now() / 1000) + approved.expires_in;
|
|
135
|
+
saveConfig({
|
|
136
|
+
baseUrl,
|
|
137
|
+
token: approved.access_token,
|
|
138
|
+
expiresAt,
|
|
139
|
+
workspace: approved.workspace,
|
|
140
|
+
});
|
|
141
|
+
console.log(`Logged in to ${baseUrl} (workspace: ${approved.workspace.name})`);
|
|
142
|
+
console.log(`Token saved to ${configFilePath()} (expires ${new Date(expiresAt * 1000).toISOString()})`);
|
|
143
|
+
});
|
|
144
|
+
program
|
|
145
|
+
.command('whoami')
|
|
146
|
+
.description('Show the current CLI identity + target deck')
|
|
147
|
+
.action(async () => {
|
|
148
|
+
const cfg = loadConfig();
|
|
149
|
+
if (!cfg.token) {
|
|
150
|
+
console.log('Not logged in. Run: zcd login');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const identity = await DeckClient.fromConfig().getIdentity();
|
|
154
|
+
console.log(`deck: ${cfg.baseUrl}`);
|
|
155
|
+
console.log(`sub: ${identity.sub}`);
|
|
156
|
+
console.log(`email: ${identity.email}`);
|
|
157
|
+
console.log(`workspace: ${identity.workspace_name ?? '?'}${identity.workspace_link_id ? ` (${identity.workspace_link_id})` : ''}`);
|
|
158
|
+
console.log(`role: ${identity.workspace_role ?? '?'}`);
|
|
159
|
+
console.log(`superuser: ${identity.is_superuser}`);
|
|
160
|
+
console.log(`scopes: ${identity.scopes.join(', ')}`);
|
|
161
|
+
if (cfg.expiresAt)
|
|
162
|
+
console.log(`expires: ${new Date(cfg.expiresAt * 1000).toISOString()}`);
|
|
163
|
+
});
|
|
164
|
+
program
|
|
165
|
+
.command('logout')
|
|
166
|
+
.description('Revoke the current personal token and remove it from this machine')
|
|
167
|
+
.action(async () => {
|
|
168
|
+
const cfg = loadConfig();
|
|
169
|
+
if (!cfg.token) {
|
|
170
|
+
console.log('Not logged in.');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
let revocationError;
|
|
174
|
+
try {
|
|
175
|
+
await DeckClient.fromConfig().revokeToken();
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
revocationError = error;
|
|
179
|
+
}
|
|
180
|
+
saveConfig({ baseUrl: cfg.baseUrl });
|
|
181
|
+
if (process.env.ZERGCYBERDECK_TOKEN) {
|
|
182
|
+
console.warn('ZERGCYBERDECK_TOKEN is still set in this process environment; remove it at its source.');
|
|
183
|
+
}
|
|
184
|
+
if (revocationError) {
|
|
185
|
+
const detail = revocationError instanceof Error
|
|
186
|
+
? revocationError.message
|
|
187
|
+
: String(revocationError);
|
|
188
|
+
throw new Error(`Local credential removed, but server revocation failed: ${detail}`);
|
|
189
|
+
}
|
|
190
|
+
console.log(`Logged out of ${cfg.baseUrl}.`);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=auth.js.map
|