@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.
@@ -0,0 +1,164 @@
1
+ import { chmod, mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { catalogTarget, explicitlyUnsupportedCredential, inspectClone, publishedCredentialFromRuntime, resolveCloneSelector, } from '../automation.js';
4
+ import { DeckClient, } from '../client.js';
5
+ async function selectClone(client, deckId, selector) {
6
+ const [deck, catalog] = await Promise.all([
7
+ client.getDeck(deckId),
8
+ client.getCatalog(),
9
+ ]);
10
+ const clone = resolveCloneSelector(deck, selector);
11
+ return {
12
+ deck,
13
+ clone,
14
+ catalog,
15
+ target: catalogTarget(catalog, clone.vendor),
16
+ };
17
+ }
18
+ function lineValue(value) {
19
+ const serialized = JSON.stringify(value);
20
+ return serialized === undefined ? 'null' : serialized;
21
+ }
22
+ function printInspection(inspection) {
23
+ console.log(`Clone ${inspection.clone.vendor} "${inspection.clone.name}" `
24
+ + `(${inspection.clone.id}) in deck "${inspection.deck.name}"`);
25
+ console.log(` status: ${inspection.runtime.cloneStatus} `
26
+ + `(runtime=${inspection.runtime.status})`);
27
+ console.log(` service: ${inspection.runtime.serviceUrl ?? '—'}`);
28
+ console.log(` zerg: ${inspection.runtime.zergId ?? '—'}`);
29
+ if (inspection.runtime.socketUrl) {
30
+ console.log(` socket: ${inspection.runtime.socketUrl}`);
31
+ }
32
+ if (inspection.runtime.errorMessage) {
33
+ console.log(` error: ${inspection.runtime.errorMessage}`);
34
+ }
35
+ const credential = inspection.managedCredential;
36
+ if (credential) {
37
+ console.log(` managed credential: ${credential.label} `
38
+ + `(${credential.purpose}; ${credential.header}: ${credential.scheme} <token>)`);
39
+ console.log(` reveal: zcd credentials --deck ${inspection.deck.id} `
40
+ + `--clone ${inspection.clone.id}`);
41
+ }
42
+ else {
43
+ console.log(' managed credential: not advertised');
44
+ }
45
+ const fields = inspection.publications.modules.flatMap(module => module.fields);
46
+ console.log(` publications: ${inspection.publications.status} `
47
+ + `(${inspection.publications.modules.length} modules, ${fields.length} fields)`);
48
+ if (inspection.publications.error) {
49
+ console.log(` error: ${inspection.publications.error}`);
50
+ }
51
+ for (const module of inspection.publications.modules) {
52
+ console.log(` ${module.moduleName}`);
53
+ for (const field of module.fields) {
54
+ const value = field.available ? lineValue(field.value) : '<unavailable>';
55
+ console.log(` ${field.name}: ${value}`);
56
+ }
57
+ }
58
+ if (inspection.qualification) {
59
+ console.log(` qualification: ${inspection.qualification.status} `
60
+ + `(${inspection.qualification.score}/100)`);
61
+ }
62
+ else {
63
+ console.log(' qualification: not qualified');
64
+ }
65
+ }
66
+ function printCredential(selected, credential, label = selected.target?.managedCredential?.label ?? 'Managed clone credential', published = false) {
67
+ console.log(`${label} for ${selected.clone.vendor} (${selected.clone.id})`);
68
+ console.log(` ${credential.authentication.header}: `
69
+ + [
70
+ credential.authentication.scheme,
71
+ credential.authentication.token,
72
+ ].filter(Boolean).join(' '));
73
+ if (credential.login) {
74
+ console.log(` login username: ${credential.login.username}`);
75
+ console.log(` login path: ${credential.login.path}`);
76
+ }
77
+ console.log(published
78
+ ? 'This is a public synthetic credential for the simulation environment.'
79
+ : 'Treat this output as a secret.');
80
+ }
81
+ export function registerAutomationCommands(program) {
82
+ program
83
+ .command('dump')
84
+ .description('Export one running clone data snapshot as stable JSON')
85
+ .requiredOption('--deck <id>', 'deck id')
86
+ .requiredOption('--clone <vendor-or-uuid>', 'unique vendor or exact clone UUID')
87
+ .option('--out <path>', 'write JSON to a private local file instead of stdout')
88
+ .action(async (opts) => {
89
+ const client = DeckClient.fromConfig();
90
+ const deck = await client.getDeck(opts.deck);
91
+ const clone = resolveCloneSelector(deck, opts.clone);
92
+ const dump = await client.getCloneDataDump(deck.id, clone.id);
93
+ const serialized = `${JSON.stringify(dump, null, 2)}\n`;
94
+ if (!opts.out) {
95
+ process.stdout.write(serialized);
96
+ return;
97
+ }
98
+ await mkdir(dirname(opts.out), { recursive: true });
99
+ await writeFile(opts.out, serialized, { encoding: 'utf8', mode: 0o600 });
100
+ // writeFile's mode applies only when creating a file. Normalize an
101
+ // existing output as well so repeated exports stay private.
102
+ await chmod(opts.out, 0o600);
103
+ console.log(`Wrote ${dump.clone.vendor} clone dump to ${opts.out} `
104
+ + `(${dump.sizeBytes} source bytes, ${dump.dataHash})`);
105
+ });
106
+ program
107
+ .command('inspect')
108
+ .description('Inspect one clone runtime, publications, and qualification')
109
+ .requiredOption('--deck <id>', 'deck id')
110
+ .requiredOption('--clone <vendor-or-uuid>', 'unique vendor or exact clone UUID')
111
+ .option('--json', 'emit stable schema-versioned JSON')
112
+ .action(async (opts) => {
113
+ const client = DeckClient.fromConfig();
114
+ const selected = await selectClone(client, opts.deck, opts.clone);
115
+ const runtime = await client.getRuntime(selected.deck.id, selected.clone.id);
116
+ const inspection = inspectClone(selected.deck, selected.clone, runtime, selected.target);
117
+ if (opts.json) {
118
+ console.log(JSON.stringify(inspection, null, 2));
119
+ return;
120
+ }
121
+ printInspection(inspection);
122
+ });
123
+ program
124
+ .command('credentials')
125
+ .description('Read one clone published or managed API/SDK/app credential')
126
+ .requiredOption('--deck <id>', 'deck id')
127
+ .requiredOption('--clone <vendor-or-uuid>', 'unique vendor or exact clone UUID')
128
+ .option('--json', 'emit stable schema-versioned JSON')
129
+ .action(async (opts) => {
130
+ const client = DeckClient.fromConfig();
131
+ const selected = await selectClone(client, opts.deck, opts.clone);
132
+ let published = null;
133
+ try {
134
+ const runtime = await client.getRuntime(selected.deck.id, selected.clone.id);
135
+ published = publishedCredentialFromRuntime(selected.clone.vendor, runtime);
136
+ }
137
+ catch (error) {
138
+ // A publication-only clone must never fall through to the privileged
139
+ // reveal route. Legacy managed clones retain their local reveal path
140
+ // when runtime discovery is temporarily unavailable.
141
+ if (explicitlyUnsupportedCredential(selected.target))
142
+ throw error;
143
+ }
144
+ if (published) {
145
+ if (opts.json) {
146
+ console.log(JSON.stringify(published.credential, null, 2));
147
+ return;
148
+ }
149
+ printCredential(selected, published.credential, published.label, true);
150
+ return;
151
+ }
152
+ if (explicitlyUnsupportedCredential(selected.target)) {
153
+ throw new Error(`Clone '${selected.clone.vendor}' does not publish a complete `
154
+ + 'simulation credential and does not advertise a Deck-managed credential.');
155
+ }
156
+ const credential = await client.revealCredentials(selected.deck.id, selected.clone.id);
157
+ if (opts.json) {
158
+ console.log(JSON.stringify(credential, null, 2));
159
+ return;
160
+ }
161
+ printCredential(selected, credential);
162
+ });
163
+ }
164
+ //# sourceMappingURL=automation.js.map
@@ -0,0 +1,310 @@
1
+ import { cloneSummary, deckSummary, normalizeCatalogForJson, } from '../automation.js';
2
+ import { DeckClient, probeZerg, } from '../client.js';
3
+ import { diagnosticsHaveFailures, isTerminalRun, runHasFailures, uniqueStrings, } from '../fleet.js';
4
+ import { boundHit, smokeFor } from '../vendors.js';
5
+ import { submitCloneStartRun, waitForCloneStartRun, } from './fleet.js';
6
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
7
+ export function parseVendors(list) {
8
+ return list
9
+ .split(',')
10
+ .map((v) => v.trim().toLowerCase())
11
+ .filter(Boolean);
12
+ }
13
+ export function parseCloneTargets(list, catalog) {
14
+ const targets = uniqueStrings(parseVendors(list));
15
+ const available = new Set(catalog.targets.map((target) => target.id));
16
+ const unknown = targets.filter((target) => !available.has(target));
17
+ if (unknown.length > 0) {
18
+ throw new Error(`Unknown clone target${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}. Run \`zcd catalog\` to list targets.`);
19
+ }
20
+ return targets;
21
+ }
22
+ /** Resolve which deck to act on: explicit id, else the sole deck, else error. */
23
+ export async function resolveDeckId(client, explicit) {
24
+ if (explicit)
25
+ return explicit;
26
+ const decks = await client.listDecks();
27
+ if (decks.length === 0)
28
+ throw new Error('No decks found. Create one with `zcd up`.');
29
+ if (decks.length === 1)
30
+ return decks[0].id;
31
+ const names = decks.map((d) => ` ${d.id} ${d.name} (${d.clone_count ?? d.clones?.length ?? 0} clones)`).join('\n');
32
+ throw new Error(`Multiple decks — pass --deck <id>:\n${names}`);
33
+ }
34
+ export async function pollClone(client, deckId, cloneId, timeoutMs) {
35
+ const deadline = Date.now() + timeoutMs;
36
+ let rt = {};
37
+ while (Date.now() < deadline) {
38
+ rt = await client.getRuntime(deckId, cloneId);
39
+ const st = rt.cloneStatus || rt.status;
40
+ if (st === 'running' || st === 'error' || st === 'stopped')
41
+ return rt;
42
+ await sleep(6000);
43
+ }
44
+ return rt;
45
+ }
46
+ export async function seedClone(client, deckId, cloneId, reset) {
47
+ // A freshly-provisioned clone reports "running" (Fly's internal health check)
48
+ // before its public IP finishes routing (~90s on a fresh dedicated v4), so an
49
+ // immediate seed can spuriously time out. Retry the authenticated CyberDeck
50
+ // proxy; the clone-control credential remains server-side.
51
+ let last = 'seed failed';
52
+ for (let attempt = 0; attempt < 9; attempt++) {
53
+ try {
54
+ const result = await client.seedClone(deckId, cloneId, reset);
55
+ return `seeded ${result.records ?? '?'} records`;
56
+ }
57
+ catch (error) {
58
+ last = `seed failed (${error instanceof Error ? error.message : 'request failed'})`;
59
+ }
60
+ if (attempt < 8)
61
+ await sleep(15000);
62
+ }
63
+ return last;
64
+ }
65
+ export function registerDeckCommands(program) {
66
+ // ── catalog ─────────────────────────────────────────────────────────────
67
+ program
68
+ .command('catalog')
69
+ .description('List every clone target available to a deck')
70
+ .option('--json', 'emit stable schema-versioned JSON')
71
+ .action(async (opts) => {
72
+ const catalog = await DeckClient.fromConfig().getCatalog();
73
+ if (opts.json) {
74
+ console.log(JSON.stringify(normalizeCatalogForJson(catalog), null, 2));
75
+ return;
76
+ }
77
+ for (const clone of catalog.targets) {
78
+ const connector = clone.connectorId ? `connector=${clone.connectorId}` : 'legacy';
79
+ console.log(`${clone.id.padEnd(18)} ${clone.vendorEvokes.padEnd(42)} :${clone.port} ${connector}`);
80
+ }
81
+ });
82
+ // ── up ──────────────────────────────────────────────────────────────────
83
+ program
84
+ .command('up')
85
+ .description('Spin up a new deck with a set of zerg-clones')
86
+ .option('--clones <list>', 'comma-separated clone target ids (e.g. zlunk,zarcher)')
87
+ .option('--all', 'include every unique target in the server catalog')
88
+ .option('--name <name>', 'deck name')
89
+ .option('--seed', 'seed prebaked synthetic data once each clone is running')
90
+ .option('--no-wait', 'return immediately without waiting for clones to run')
91
+ .option('--timeout <seconds>', 'per-clone wait timeout', '420')
92
+ .action(async (opts) => {
93
+ const client = DeckClient.fromConfig();
94
+ const catalog = await client.getCatalog();
95
+ if (opts.all && opts.clones !== undefined) {
96
+ throw new Error('Choose either --all or --clones, not both.');
97
+ }
98
+ if (!opts.all && opts.clones === undefined) {
99
+ throw new Error('Pass --all or --clones <list>.');
100
+ }
101
+ const vendors = opts.all
102
+ ? uniqueStrings(catalog.targets.map(target => target.id))
103
+ : parseCloneTargets(opts.clones ?? '', catalog);
104
+ if (vendors.length === 0) {
105
+ throw new Error(opts.all ? 'The clone catalog is empty.' : '--clones is empty');
106
+ }
107
+ const name = opts.name || `zcd ${new Date().toISOString().slice(0, 19).replace('T', ' ')}`;
108
+ const deck = await client.createDeck(name);
109
+ console.log(`Created deck "${deck.name}" (${deck.id})`);
110
+ console.log(` ${client.baseUrl}/app/decks/${deck.id}\n`);
111
+ const clones = [];
112
+ for (const vendor of vendors) {
113
+ const clone = await client.addClone(deck.id, vendor);
114
+ clones.push(clone);
115
+ console.log(` + ${vendor} added (${clone.id})`);
116
+ }
117
+ const submitted = await submitCloneStartRun(client, deck.id, clones.map(clone => clone.id));
118
+ if (!submitted.run) {
119
+ console.log(`\nPreflight: ${submitted.preflight.status}`);
120
+ for (const check of submitted.preflight.checks) {
121
+ console.log(` ${check.status} ${check.code} — ${check.message}`);
122
+ }
123
+ process.exitCode = 2;
124
+ return;
125
+ }
126
+ console.log(`\nQueued clone start operation=${submitted.run.run.id}`);
127
+ if (opts.wait === false) {
128
+ console.log('Not waiting (--no-wait). Check with `zcd operation`.');
129
+ return;
130
+ }
131
+ console.log('\nWaiting for clones to reach running…');
132
+ const timeoutMs = Math.max(30, Number(opts.timeout) || 420) * 1000;
133
+ const result = await waitForCloneStartRun(client, deck.id, submitted.run, timeoutMs);
134
+ console.log('');
135
+ for (const item of result.clones) {
136
+ console.log(` • ${item.vendor.padEnd(10)} `
137
+ + `${item.status.padEnd(9)} ${item.stage}`);
138
+ if (opts.seed && item.status === 'succeeded') {
139
+ const msg = await seedClone(client, deck.id, item.cloneId, true);
140
+ console.log(` ${msg}`);
141
+ }
142
+ if (item.error) {
143
+ console.log(` error: ${item.error.code}: ${item.error.message}`);
144
+ }
145
+ }
146
+ console.log(`\nDone. deck=${deck.id}`);
147
+ if (runHasFailures(result) || !isTerminalRun(result.run.status)) {
148
+ process.exitCode = 1;
149
+ }
150
+ });
151
+ // ── seed ────────────────────────────────────────────────────────────────
152
+ program
153
+ .command('seed')
154
+ .description('Seed prebaked synthetic data into a deck\'s running clones')
155
+ .option('--deck <id>', 'deck id')
156
+ .option('--reset', 'clear existing data first (idempotent re-seed)')
157
+ .action(async (opts) => {
158
+ const client = DeckClient.fromConfig();
159
+ const deckId = await resolveDeckId(client, opts.deck);
160
+ const deck = await client.getDeck(deckId);
161
+ for (const clone of deck.clones ?? []) {
162
+ const rt = await client.getRuntime(deckId, clone.id);
163
+ const url = rt.serviceUrl || clone.service_url;
164
+ if (!url || (rt.cloneStatus || rt.status) !== 'running') {
165
+ console.log(` • ${clone.vendor.padEnd(10)} skipped (not running)`);
166
+ continue;
167
+ }
168
+ const msg = await seedClone(client, deckId, clone.id, Boolean(opts.reset));
169
+ console.log(` • ${clone.vendor.padEnd(10)} ${msg}`);
170
+ }
171
+ });
172
+ // ── status ──────────────────────────────────────────────────────────────
173
+ program
174
+ .command('status')
175
+ .description('Show deploy/health status of a deck\'s clones + their zergs')
176
+ .option('--deck <id>', 'deck id')
177
+ .option('--json', 'emit stable schema-versioned JSON')
178
+ .action(async (opts) => {
179
+ const client = DeckClient.fromConfig();
180
+ const deckId = await resolveDeckId(client, opts.deck);
181
+ const diagnostics = await client.getDiagnostics(deckId);
182
+ if (opts.json) {
183
+ console.log(JSON.stringify(diagnostics, null, 2));
184
+ }
185
+ else {
186
+ console.log(`Deck "${diagnostics.deck.name}" (${diagnostics.deck.id}) — `
187
+ + `${diagnostics.clones.length} clones\n`);
188
+ for (const clone of diagnostics.clones) {
189
+ const url = clone.runtime?.serviceUrl ?? clone.serviceUrl ?? '—';
190
+ console.log(` • ${clone.vendor.padEnd(18)} `
191
+ + `${clone.status.padEnd(10)} ${url}`);
192
+ if (clone.latestFailure) {
193
+ console.log(` ${clone.latestFailure.code}: `
194
+ + clone.latestFailure.message);
195
+ }
196
+ if (clone.error) {
197
+ console.log(` ${clone.error.code}: ${clone.error.message}`);
198
+ }
199
+ }
200
+ }
201
+ if (diagnosticsHaveFailures(diagnostics))
202
+ process.exitCode = 1;
203
+ });
204
+ // ── check ───────────────────────────────────────────────────────────────
205
+ program
206
+ .command('check')
207
+ .description('Ping each clone\'s vendor API + its zerg; exit nonzero on failure (CI-friendly)')
208
+ .option('--deck <id>', 'deck id')
209
+ .action(async (opts) => {
210
+ const client = DeckClient.fromConfig();
211
+ const catalog = await client.getCatalog();
212
+ const targets = new Map(catalog.targets.map((target) => [target.id, target]));
213
+ const deckId = await resolveDeckId(client, opts.deck);
214
+ const deck = await client.getDeck(deckId);
215
+ let failures = 0;
216
+ for (const clone of deck.clones ?? []) {
217
+ const rt = await client.getRuntime(deckId, clone.id);
218
+ const url = rt.serviceUrl || clone.service_url;
219
+ const smoke = smokeFor(clone.vendor, targets.get(clone.vendor)?.vendorEvokes);
220
+ console.log(`\n${clone.vendor} (${smoke.evokes})`);
221
+ if (!url || (rt.cloneStatus || rt.status) !== 'running') {
222
+ console.log(' ✗ not running');
223
+ failures++;
224
+ continue;
225
+ }
226
+ const results = await smoke.smoke(boundHit(url));
227
+ for (const r of results) {
228
+ console.log(` ${r.ok ? '✓' : '✗'} ${r.name} — ${r.detail}`);
229
+ if (!r.ok)
230
+ failures++;
231
+ }
232
+ if (rt.socketUrl) {
233
+ const z = await probeZerg(rt.socketUrl);
234
+ const ok = z.connected && z.contractVersion === 2 && z.manifest === 'cyber-clone';
235
+ console.log(` ${ok ? '✓' : '✗'} zerg :3333 — ${z.connected ? `contract v${z.contractVersion}, manifest=${z.manifest}, status=${z.serviceStatus}` : z.error}`);
236
+ if (!ok)
237
+ failures++;
238
+ }
239
+ }
240
+ console.log(`\n${failures === 0 ? '✓ all checks passed' : `✗ ${failures} check(s) failed`}`);
241
+ if (failures > 0)
242
+ process.exitCode = 1;
243
+ });
244
+ // ── ls ──────────────────────────────────────────────────────────────────
245
+ program
246
+ .command('ls')
247
+ .description('List decks (or a deck\'s clones with --deck)')
248
+ .option('--deck <id>', 'show clones for this deck')
249
+ .option('--json', 'emit stable schema-versioned JSON')
250
+ .action(async (opts) => {
251
+ const client = DeckClient.fromConfig();
252
+ if (opts.deck) {
253
+ const deck = await client.getDeck(opts.deck);
254
+ if (opts.json) {
255
+ console.log(JSON.stringify({
256
+ schemaVersion: 1,
257
+ deck: deckSummary(deck),
258
+ clones: (deck.clones ?? []).map(cloneSummary),
259
+ }, null, 2));
260
+ return;
261
+ }
262
+ console.log(`Deck "${deck.name}" (${deck.id})`);
263
+ for (const c of deck.clones ?? []) {
264
+ console.log(` ${c.vendor.padEnd(10)} ${c.status.padEnd(9)} ${c.id}`);
265
+ }
266
+ return;
267
+ }
268
+ const decks = await client.listDecks();
269
+ if (opts.json) {
270
+ console.log(JSON.stringify({
271
+ schemaVersion: 1,
272
+ decks: decks.map(deck => ({
273
+ ...deckSummary(deck),
274
+ cloneCount: deck.clone_count ?? deck.clones?.length ?? 0,
275
+ })),
276
+ }, null, 2));
277
+ return;
278
+ }
279
+ if (decks.length === 0) {
280
+ console.log('No decks.');
281
+ return;
282
+ }
283
+ for (const d of decks) {
284
+ console.log(` ${d.id} ${d.name} (${d.clone_count ?? d.clones?.length ?? 0} clones)`);
285
+ }
286
+ });
287
+ // ── down ────────────────────────────────────────────────────────────────
288
+ program
289
+ .command('down')
290
+ .description('Tear down a deck: remove its clones and delete the deck')
291
+ .option('--deck <id>', 'deck id')
292
+ .option('--yes', 'skip confirmation')
293
+ .action(async (opts) => {
294
+ const client = DeckClient.fromConfig();
295
+ const deckId = await resolveDeckId(client, opts.deck);
296
+ const deck = await client.getDeck(deckId);
297
+ if (!opts.yes) {
298
+ console.log(`This will delete deck "${deck.name}" and its ${deck.clones?.length ?? 0} clones. Re-run with --yes.`);
299
+ return;
300
+ }
301
+ for (const clone of deck.clones ?? []) {
302
+ await client.deleteClone(deckId, clone.id);
303
+ console.log(` - removed ${clone.vendor} (${clone.id})`);
304
+ }
305
+ await client.deleteDeck(deckId);
306
+ console.log(`Deleted deck ${deckId}.`);
307
+ console.log('Each clone\'s container is being torn down (ZergCloud destroys the zerg → zstack destroys the Fly app + releases its machine/IPs); teardown is async, so the Fly app disappears shortly after.');
308
+ });
309
+ }
310
+ //# sourceMappingURL=decks.js.map