@lifesi/genesis-cli 0.0.0-stage → 0.1.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/bin/genesis.js +3 -0
- package/dist/auth.d.ts +30 -0
- package/dist/auth.js +79 -0
- package/dist/beacons.d.ts +3 -0
- package/dist/beacons.js +20 -0
- package/dist/dials.d.ts +7 -0
- package/dist/dials.js +49 -0
- package/dist/fleets.d.ts +4 -0
- package/dist/fleets.js +44 -0
- package/dist/function-input.d.ts +3 -0
- package/dist/function-input.js +49 -0
- package/dist/henry.d.ts +3 -0
- package/dist/henry.js +31 -0
- package/dist/hives-node-transport.d.ts +6 -0
- package/dist/hives-node-transport.js +145 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +430 -0
- package/dist/output.d.ts +16 -0
- package/dist/output.js +27 -0
- package/dist/runner.d.ts +4 -0
- package/dist/runner.js +40 -0
- package/dist/secret-input.d.ts +14 -0
- package/dist/secret-input.js +85 -0
- package/dist/store.d.ts +23 -0
- package/dist/store.js +33 -0
- package/dist/wasm-input.d.ts +2 -0
- package/dist/wasm-input.js +31 -0
- package/package.json +21 -3
- package/README.md +0 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { registerBeacons } from './beacons.js';
|
|
2
|
+
import { registerDials } from './dials.js';
|
|
3
|
+
import { Command, CommanderError } from 'commander';
|
|
4
|
+
import { Genesis, GenesisError, hivesResponseBytes, MAX_FUNCTION_BODY_BYTES, MAX_FUNCTION_JSON_BYTES, services } from '@lifesi/genesis';
|
|
5
|
+
import { authConfig, pollDevice, refresh, savedLoginProvider, startDevice } from './auth.js';
|
|
6
|
+
import { keychainStore } from './store.js';
|
|
7
|
+
import { c, listOf, stdio, table } from './output.js';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { addHenryCommands } from './henry.js';
|
|
10
|
+
import { registerFleets } from './fleets.js';
|
|
11
|
+
import { registerRunner } from './runner.js';
|
|
12
|
+
import { createHivesNodeTransport } from './hives-node-transport.js';
|
|
13
|
+
import { readFunctionFile, readFunctionJson, writeFunctionOutput } from './function-input.js';
|
|
14
|
+
import { basename } from 'node:path';
|
|
15
|
+
import { readWasmFile } from './wasm-input.js';
|
|
16
|
+
import { readSecretInput, validateSecret, validateSecretValue, parseSecretBatch } from './secret-input.js';
|
|
17
|
+
function defaultOpen(url) {
|
|
18
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
19
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
20
|
+
try {
|
|
21
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => { }).unref();
|
|
22
|
+
}
|
|
23
|
+
catch { /* print-only fallback */ }
|
|
24
|
+
}
|
|
25
|
+
export function buildProgram(deps = {}) {
|
|
26
|
+
const out = deps.out ?? stdio;
|
|
27
|
+
const env = deps.env ?? process.env;
|
|
28
|
+
const f = deps.fetch ?? fetch;
|
|
29
|
+
const getStore = deps.store ?? keychainStore;
|
|
30
|
+
const auth = { ...authConfig(env, f), sleep: deps.sleep };
|
|
31
|
+
const program = new Command('genesis')
|
|
32
|
+
.description('One CLI for every Genesis service.')
|
|
33
|
+
.version('0.1.1')
|
|
34
|
+
.option('--json', 'machine-readable JSON output')
|
|
35
|
+
.option('--api-key <key>', 'use this API key (default: GENESIS_API_KEY, then your `genesis login`)')
|
|
36
|
+
.option('--base-url <url>', 'gateway URL (default: GENESIS_BASE_URL or https://console.life.one/api)')
|
|
37
|
+
.option('--direct', 'skip the gateway and call each service host directly (for local dev)')
|
|
38
|
+
.option('--org <org_id>', 'organization to act on')
|
|
39
|
+
.showHelpAfterError()
|
|
40
|
+
.exitOverride()
|
|
41
|
+
.configureOutput({ writeOut: (s) => out.log(s.trimEnd()), writeErr: (s) => out.err(s.trimEnd()) });
|
|
42
|
+
const g = () => program.opts();
|
|
43
|
+
const client = async (hivesTransport = false) => {
|
|
44
|
+
const o = g();
|
|
45
|
+
const store = await getStore().catch(() => undefined);
|
|
46
|
+
const opts = {
|
|
47
|
+
apiKey: o.apiKey ?? env.GENESIS_API_KEY, token: env.GENESIS_TOKEN,
|
|
48
|
+
baseUrl: o.baseUrl ?? env.GENESIS_BASE_URL, routing: o.direct || env.GENESIS_ROUTING === 'direct' ? 'direct' : 'gateway',
|
|
49
|
+
fetch: f, userAgent: 'genesis-cli/0.1.1',
|
|
50
|
+
credentials: store ? savedLoginProvider(auth, store) : undefined,
|
|
51
|
+
};
|
|
52
|
+
return new Genesis(hivesTransport ? { ...opts, fetch: createHivesNodeTransport(opts, deps.signal) } : opts);
|
|
53
|
+
};
|
|
54
|
+
const emit = (data, pretty) => out.log(g().json ? JSON.stringify(data, null, 2) : pretty());
|
|
55
|
+
const positiveInteger = (value, option, maximum) => {
|
|
56
|
+
if (value === undefined)
|
|
57
|
+
return undefined;
|
|
58
|
+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1 || Number(value) > maximum) {
|
|
59
|
+
throw new Error(`${option} must be an integer from 1 to ${maximum}.`);
|
|
60
|
+
}
|
|
61
|
+
return Number(value);
|
|
62
|
+
};
|
|
63
|
+
const projectName = (name) => {
|
|
64
|
+
if (name.length < 3 || name.length > 64 || !/^[a-z][a-z0-9-]*[a-z0-9]$/.test(name)) {
|
|
65
|
+
throw new Error('Project name must be 3–64 lowercase letters, numbers or hyphens, start with a letter and end with a letter or number.');
|
|
66
|
+
}
|
|
67
|
+
return name;
|
|
68
|
+
};
|
|
69
|
+
registerRunner(program, client, emit);
|
|
70
|
+
const orgId = async (gen) => {
|
|
71
|
+
if (g().org)
|
|
72
|
+
return g().org;
|
|
73
|
+
const s = await (await getStore().catch(() => undefined))?.get();
|
|
74
|
+
if (s?.organization_id && !g().apiKey && !env.GENESIS_API_KEY)
|
|
75
|
+
return s.organization_id;
|
|
76
|
+
return (await gen.whoami()).org;
|
|
77
|
+
};
|
|
78
|
+
registerDials(program, { client, orgId, emit });
|
|
79
|
+
// ---- auth ------------------------------------------------------------
|
|
80
|
+
program.command('login').description('Sign in with LifeID in your browser').option('--no-browser', 'just print the link')
|
|
81
|
+
.action(async (o) => {
|
|
82
|
+
const store = await getStore();
|
|
83
|
+
const d = await startDevice(auth);
|
|
84
|
+
out.err(`\n Your code: ${c.bold(d.user_code)}\n Opening ${d.verification_uri_complete}\n ${c.dim('Waiting for you to approve in the browser…')}\n`);
|
|
85
|
+
if (o.browser)
|
|
86
|
+
(deps.openBrowser ?? defaultOpen)(d.verification_uri_complete);
|
|
87
|
+
const s = await pollDevice(auth, d);
|
|
88
|
+
await store.set(s);
|
|
89
|
+
emit({ user: s.user, organization_id: s.organization_id, store: store.kind }, () => `${c.green('✓')} Signed in as ${c.bold(s.user?.email ?? s.user?.id ?? 'you')}${s.organization_id ? ` (${s.organization_id})` : ''}. Token saved to your ${store.kind === 'os-keychain' ? 'OS keychain' : store.kind}.`);
|
|
90
|
+
});
|
|
91
|
+
program.command('logout').description('Forget your saved login').action(async () => {
|
|
92
|
+
const had = await (await getStore()).clear();
|
|
93
|
+
emit({ logged_out: had }, () => had ? `${c.green('✓')} Signed out.` : 'You were not signed in.');
|
|
94
|
+
});
|
|
95
|
+
program.command('whoami').description('Show who you are signed in as').action(async () => {
|
|
96
|
+
const gen = await client();
|
|
97
|
+
const saved = await (await getStore().catch(() => undefined))?.get();
|
|
98
|
+
try {
|
|
99
|
+
const p = await gen.whoami();
|
|
100
|
+
emit({ ...p, user: saved?.user }, () => [
|
|
101
|
+
`${c.bold('Org')} ${p.org}`, `${c.bold('Principal')} ${p.principal ?? '-'}`,
|
|
102
|
+
`${c.bold('Via')} ${p.kind}`, `${c.bold('Scopes')} ${p.scopes.join(', ') || '-'}`,
|
|
103
|
+
...(saved?.user?.email ? [`${c.bold('User')} ${saved.user.email}`] : [])
|
|
104
|
+
].join('\n'));
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
if (!saved || g().apiKey || env.GENESIS_API_KEY)
|
|
108
|
+
throw e;
|
|
109
|
+
// Saved login exists but LifeID verify isn't reachable yet (auth-v4-backend PR #30 not deployed).
|
|
110
|
+
emit({ user: saved.user, organization_id: saved.organization_id, verified: false, reason: e.message }, () => `${c.bold('User')} ${saved.user?.email ?? saved.user?.id}\n${c.bold('Org')} ${saved.organization_id ?? '-'}\n${c.yellow('!')} ${c.dim('From your saved login; LifeID verify unavailable: ' + e.message)}`);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
const orgs = program.command('orgs').description('Your organizations');
|
|
114
|
+
orgs.command('list', { isDefault: true }).description('Show your current organization').action(async () => {
|
|
115
|
+
const s = await (await getStore()).get();
|
|
116
|
+
if (!s)
|
|
117
|
+
throw new Error('Not signed in. Run `genesis login`.');
|
|
118
|
+
const rows = s.organization_id ? [{ id: s.organization_id, current: '*' }] : [];
|
|
119
|
+
emit({ current: s.organization_id ?? null, organizations: rows }, () => `${table(rows, ['id', 'current'])}\n${c.dim('Listing every org you belong to needs a LifeID memberships endpoint (not built yet). Switch with `genesis orgs switch <org_id>`.')}`);
|
|
120
|
+
});
|
|
121
|
+
orgs.command('switch <org_id>').description('Switch your login to another organization').action(async (id) => {
|
|
122
|
+
const store = await getStore();
|
|
123
|
+
const s = await store.get();
|
|
124
|
+
if (!s)
|
|
125
|
+
throw new Error('Not signed in. Run `genesis login`.');
|
|
126
|
+
const n = await refresh(auth, s, id);
|
|
127
|
+
await store.set(n);
|
|
128
|
+
emit({ organization_id: n.organization_id }, () => `${c.green('✓')} Now using ${c.bold(n.organization_id ?? id)}.`);
|
|
129
|
+
});
|
|
130
|
+
// ---- keys ------------------------------------------------------------
|
|
131
|
+
const keys = program.command('keys').description('Org API keys (LifeID / WorkOS)');
|
|
132
|
+
keys.command('list').action(async () => {
|
|
133
|
+
const gen = await client();
|
|
134
|
+
const org = await orgId(gen);
|
|
135
|
+
const r = await gen.keys.list(org);
|
|
136
|
+
emit(r, () => table(r.data, ['id', 'name', 'obfuscated_value', 'scopes', 'last_used_at', 'expires_at']));
|
|
137
|
+
});
|
|
138
|
+
keys.command('create <name>').description('Create a key (shown once), or with --blinds hand it straight to Blinds so nobody ever sees it')
|
|
139
|
+
.option('--scopes <list>', 'comma-separated scopes, e.g. hives:read,relays:write')
|
|
140
|
+
.option('--blinds', 'store the key in Blinds; the raw key is never shown to anyone')
|
|
141
|
+
.option('--expires <iso>', 'expiry, e.g. 2027-01-01T00:00:00Z')
|
|
142
|
+
.action(async (name, o) => {
|
|
143
|
+
const gen = await client();
|
|
144
|
+
const org = await orgId(gen);
|
|
145
|
+
const k = await gen.keys.create(org, { name, custody: o.blinds ? 'blinds' : 'reveal', ...(o.scopes ? { scopes: o.scopes.split(',').map((x) => x.trim()).filter(Boolean) } : {}), ...(o.expires ? { expires_at: o.expires } : {}) });
|
|
146
|
+
emit(k, () => o.blinds
|
|
147
|
+
? `${c.green('✓')} Key ${c.bold(k.id)} created and stored in Blinds as ${c.bold(String(k.blinds_credential_ref))}.\n ${c.dim('No one, human or agent, will ever see the raw key.')}`
|
|
148
|
+
: `${c.green('✓')} Key ${c.bold(k.id)} created. Copy it now; it won't be shown again:\n\n ${c.bold(String(k.value))}\n`);
|
|
149
|
+
});
|
|
150
|
+
keys.command('revoke <key_id>').action(async (id) => {
|
|
151
|
+
const gen = await client();
|
|
152
|
+
await gen.keys.revoke(id);
|
|
153
|
+
emit({ revoked: id }, () => `${c.green('✓')} Revoked ${id} ${c.dim('(takes effect within 60s)')}`);
|
|
154
|
+
});
|
|
155
|
+
// ---- foundry ---------------------------------------------------------
|
|
156
|
+
const foundry = program.command('foundry').description('Foundry: models and inference');
|
|
157
|
+
foundry.command('models').action(async () => {
|
|
158
|
+
const r = await (await client()).foundry.models.list();
|
|
159
|
+
const rows = listOf(r).map((m) => ({ id: m.id, owned_by: m.owned_by ?? m.provider ?? '' }));
|
|
160
|
+
emit(r, () => table(rows, ['id', 'owned_by']));
|
|
161
|
+
});
|
|
162
|
+
foundry.command('chat <prompt...>').description('Chat with a model').option('-m, --model <model>', 'model id', env.GENESIS_MODEL)
|
|
163
|
+
.option('--no-stream', 'wait for the full answer').option('-s, --system <text>', 'system prompt')
|
|
164
|
+
.action(async (prompt, o) => {
|
|
165
|
+
if (!o.model)
|
|
166
|
+
throw new Error('Pick a model with --model (see `genesis foundry models`) or set GENESIS_MODEL.');
|
|
167
|
+
const gen = await client();
|
|
168
|
+
const messages = [...(o.system ? [{ role: 'system', content: o.system }] : []), { role: 'user', content: prompt.join(' ') }];
|
|
169
|
+
if (o.stream && !g().json) {
|
|
170
|
+
for await (const d of gen.foundry.chatStream({ model: o.model, messages }))
|
|
171
|
+
process.stdout.write(d);
|
|
172
|
+
process.stdout.write('\n');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const r = await gen.foundry.chat({ model: o.model, messages });
|
|
176
|
+
emit(r, () => r.choices?.[0]?.message?.content ?? JSON.stringify(r));
|
|
177
|
+
});
|
|
178
|
+
const hivesClient = () => client(true);
|
|
179
|
+
// ---- hives -----------------------------------------------------------
|
|
180
|
+
const hives = program.command('hives').description('Hives: projects, secrets, deployments, functions');
|
|
181
|
+
const deployments = hives.command('deployments').description('Real WASM artifacts and deployment lifecycle');
|
|
182
|
+
const emitDeployment = (data) => emit(data, () => table(data && typeof data === 'object' && !Array.isArray(data) && !('data' in data) ? [data] : listOf(data), ['id', 'version', 'status', 'module_hash', 'created_at']));
|
|
183
|
+
deployments.command('list <project_id>')
|
|
184
|
+
.option('--page <number>', 'page (1–10000)').option('--per-page <number>', 'items (1–100)')
|
|
185
|
+
.option('--status <status>', 'persisted deployment status').option('--author <id>', 'creator ID')
|
|
186
|
+
.action(async (project, o) => {
|
|
187
|
+
emitDeployment(await (await hivesClient()).hives.deployments.list(project, {
|
|
188
|
+
page: positiveInteger(o.page, '--page', 10000), per_page: positiveInteger(o.perPage, '--per-page', 100), status: o.status, author: o.author,
|
|
189
|
+
}));
|
|
190
|
+
});
|
|
191
|
+
deployments.command('get <project_id> <deployment_id>')
|
|
192
|
+
.action(async (project, deployment) => emitDeployment(await (await hivesClient()).hives.deployments.get(project, deployment)));
|
|
193
|
+
deployments.command('upload <project_id> <wasm_file>').description('Upload actual WASM bytes; Git options are labels only')
|
|
194
|
+
.option('--git-sha <sha>', 'commit label (7–40 characters)').option('--git-branch <branch>', 'branch label').option('--git-message <message>', 'commit label')
|
|
195
|
+
.action(async (project, path, o) => {
|
|
196
|
+
const file = await (deps.readWasmFile ?? readWasmFile)(path);
|
|
197
|
+
emitDeployment(await (await hivesClient()).hives.deployments.upload(project, file, {
|
|
198
|
+
filename: basename(path), git_sha: o.gitSha, git_branch: o.gitBranch, git_message: o.gitMessage,
|
|
199
|
+
}));
|
|
200
|
+
});
|
|
201
|
+
deployments.command('reuse <project_id> <module_hash>').description('Deploy a real artifact already owned by this org and project')
|
|
202
|
+
.option('--no-auto-promote', 'keep the completed deployment ready for explicit promotion')
|
|
203
|
+
.action(async (project, module_hash, o) => {
|
|
204
|
+
if (!/^[a-f0-9]{64}$/.test(module_hash))
|
|
205
|
+
throw new Error('module_hash must be the lowercase SHA-256 returned by a real Hives upload.');
|
|
206
|
+
emitDeployment(await (await hivesClient()).hives.deployments.create(project, { module_hash, auto_promote: o.autoPromote }));
|
|
207
|
+
});
|
|
208
|
+
deployments.command('promote <project_id> <deployment_id>')
|
|
209
|
+
.action(async (project, deployment) => emitDeployment(await (await hivesClient()).hives.deployments.promote(project, deployment)));
|
|
210
|
+
deployments.command('rollback <project_id> <deployment_id>').description('Activate this owned previous deployment')
|
|
211
|
+
.option('--reason <text>', 'audit reason')
|
|
212
|
+
.action(async (project, deployment, o) => emitDeployment(await (await hivesClient()).hives.deployments.rollback(project, deployment, { reason: o.reason })));
|
|
213
|
+
deployments.command('cancel <project_id> <deployment_id>')
|
|
214
|
+
.action(async (project, deployment) => emitDeployment(await (await hivesClient()).hives.deployments.cancel(project, deployment)));
|
|
215
|
+
deployments.command('delete <project_id> <deployment_id>').description('Delete a deployment that is not currently active')
|
|
216
|
+
.action(async (project, deployment) => { const result = await (await hivesClient()).hives.deployments.delete(project, deployment); emit(result, () => `Deleted deployment ${deployment}.`); });
|
|
217
|
+
deployments.command('logs <project_id> <deployment_id>').description('Read real persisted deployment logs')
|
|
218
|
+
.option('--page <number>', 'page (1–10000)').option('--per-page <number>', 'items (1–100)')
|
|
219
|
+
.option('--level <level>', 'minimum level').option('--search <text>', 'search log messages')
|
|
220
|
+
.option('--start <iso>', 'RFC 3339 start time').option('--end <iso>', 'RFC 3339 end time')
|
|
221
|
+
.action(async (project, deployment, o) => {
|
|
222
|
+
for (const timestamp of [o.start, o.end])
|
|
223
|
+
if (timestamp !== undefined && !Number.isFinite(Date.parse(timestamp)))
|
|
224
|
+
throw new Error('Log timestamps must use RFC 3339.');
|
|
225
|
+
const result = await (await hivesClient()).hives.deployments.logs(project, deployment, {
|
|
226
|
+
page: positiveInteger(o.page, '--page', 10000), per_page: positiveInteger(o.perPage, '--per-page', 100),
|
|
227
|
+
level: o.level, search: o.search, start: o.start, end: o.end,
|
|
228
|
+
});
|
|
229
|
+
emit(result, () => table(result.logs, ['timestamp', 'level', 'message', 'source']));
|
|
230
|
+
});
|
|
231
|
+
const emitFunction = (value) => emit(value, () => table(listOf(value).length ? listOf(value) : [value], ['id', 'name', 'status', 'handler', 'memory_mb', 'timeout_ms']));
|
|
232
|
+
const functions = hives.command('functions').description('Owned WASM functions; invocation returns the completed raw guest response');
|
|
233
|
+
functions.command('list <project_id>').option('--page <n>').option('--per-page <n>').option('--status <status>').option('--search <text>')
|
|
234
|
+
.action(async (project, o) => emitFunction(await (await hivesClient()).hives.functions.list(project, {
|
|
235
|
+
page: positiveInteger(o.page, '--page', 1000000), per_page: positiveInteger(o.perPage, '--per-page', 100), status: o.status, search: o.search,
|
|
236
|
+
})));
|
|
237
|
+
functions.command('get <project_id> <function_id>').action(async (project, id) => emitFunction(await (await hivesClient()).hives.functions.get(project, id)));
|
|
238
|
+
functions.command('create <project_id> <name> <module_hash>').option('--request-file <file>', 'JSON metadata and encrypted env overrides; input values are never printed')
|
|
239
|
+
.action(async (project, name, hash, o) => {
|
|
240
|
+
if (!/^[a-f0-9]{64}$/.test(hash))
|
|
241
|
+
throw new Error('Use a full lowercase artifact hash from an owned Hives upload.');
|
|
242
|
+
const extra = o.requestFile ? await readFunctionJson(o.requestFile, 256 * 1024) : {};
|
|
243
|
+
emitFunction(await (await hivesClient()).hives.functions.create(project, { ...extra, name, handler: `wasm://sha256/${hash}` }));
|
|
244
|
+
});
|
|
245
|
+
functions.command('update <project_id> <function_id>').requiredOption('--request-file <file>', 'JSON supported metadata/env/remove_env fields; values are never printed')
|
|
246
|
+
.action(async (project, id, o) => emitFunction(await (await hivesClient()).hives.functions.update(project, id, await readFunctionJson(o.requestFile, 256 * 1024))));
|
|
247
|
+
functions.command('delete <project_id> <function_id>').action(async (project, id) => {
|
|
248
|
+
const result = await (await hivesClient()).hives.functions.delete(project, id);
|
|
249
|
+
emit(result, () => `Deleted function ${id}.`);
|
|
250
|
+
});
|
|
251
|
+
function invocationCommand(command, kind) {
|
|
252
|
+
command.description('Complete real guest execution; write exact bytes, including executed HTTP error bodies')
|
|
253
|
+
.option('--request-file <file>', 'JSON method/path/headers/body or body_base64')
|
|
254
|
+
.option('--body-file <file>', 'Exact binary body (up to 10 MiB; serialized guest envelope may reach its cap earlier)')
|
|
255
|
+
.option('--method <method>', 'guest HTTP method', 'POST').option('--path <path>', 'guest path including query', '/')
|
|
256
|
+
.option('--output <file>', 'create a new private file containing exact response bytes')
|
|
257
|
+
.action(async (project, id, o) => {
|
|
258
|
+
if (o.requestFile && o.bodyFile)
|
|
259
|
+
throw new Error('Choose --request-file or --body-file.');
|
|
260
|
+
if (g().json && o.output)
|
|
261
|
+
throw new Error('Choose --json or --output.');
|
|
262
|
+
let request = o.requestFile ? await readFunctionJson(o.requestFile, MAX_FUNCTION_JSON_BYTES) : { method: o.method, path: o.path };
|
|
263
|
+
if (o.bodyFile)
|
|
264
|
+
request.body_base64 = Buffer.from(await readFunctionFile(o.bodyFile, MAX_FUNCTION_BODY_BYTES)).toString('base64');
|
|
265
|
+
const response = await (await hivesClient()).hives[kind].invoke(project, id, request);
|
|
266
|
+
const bytes = await hivesResponseBytes(response);
|
|
267
|
+
if (g().json)
|
|
268
|
+
out.log(JSON.stringify({ status: response.status, headers: Object.fromEntries(response.headers), body_base64: Buffer.from(bytes).toString('base64') }));
|
|
269
|
+
else {
|
|
270
|
+
out.err(JSON.stringify({ status: response.status, headers: Object.fromEntries(response.headers), bytes: bytes.length }));
|
|
271
|
+
if (o.output)
|
|
272
|
+
await (deps.writeOutputFile ?? writeFunctionOutput)(o.output, bytes);
|
|
273
|
+
else
|
|
274
|
+
(deps.writeBytes ?? ((value) => { process.stdout.write(value); }))(bytes);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
invocationCommand(functions.command('invoke <project_id> <function_id>'), 'functions');
|
|
279
|
+
invocationCommand(deployments.command('invoke <project_id> <deployment_id>'), 'deployments');
|
|
280
|
+
const secrets = hives.command('secrets').description('Project secrets encrypted with Genesis KMS');
|
|
281
|
+
const readInput = async (options, limit) => {
|
|
282
|
+
if (Boolean(options.file) === Boolean(options.stdin))
|
|
283
|
+
throw new Error('Choose exactly one input source: a file or --stdin. Secret values are not accepted as command arguments.');
|
|
284
|
+
return (deps.readSecretInput ?? readSecretInput)(options, limit);
|
|
285
|
+
};
|
|
286
|
+
const emitSecret = (data) => emit(data, () => table(listOf(data), ['id', 'name', 'version', 'description', 'updated_at']));
|
|
287
|
+
secrets.command('list <project_id>').description('List secret metadata without values')
|
|
288
|
+
.option('--page <number>', 'page number (1–10000)').option('--per-page <number>', 'items per page (1–100)')
|
|
289
|
+
.action(async (id, o) => {
|
|
290
|
+
const q = { page: positiveInteger(o.page, '--page', 10000), per_page: positiveInteger(o.perPage, '--per-page', 100) };
|
|
291
|
+
emitSecret(await (await hivesClient()).hives.secrets.list(id, q));
|
|
292
|
+
});
|
|
293
|
+
secrets.command('get <project_id> <secret_id>').description('Inspect secret metadata without its value')
|
|
294
|
+
.action(async (project, secret) => {
|
|
295
|
+
const r = await (await hivesClient()).hives.secrets.get(project, secret);
|
|
296
|
+
emit(r, () => table([r], ['id', 'name', 'version', 'description', 'updated_at']));
|
|
297
|
+
});
|
|
298
|
+
secrets.command('create <project_id> <name>').description('Create a secret; plaintext is read from a file or stdin')
|
|
299
|
+
.option('--value-file <path>', 'UTF-8 file containing the value').option('--stdin', 'read value from stdin')
|
|
300
|
+
.option('--description <text>', 'description (up to 500 characters)')
|
|
301
|
+
.action(async (project, name, o) => {
|
|
302
|
+
const body = validateSecret({ name, value: await readInput({ file: o.valueFile, stdin: o.stdin }, 65536), description: o.description });
|
|
303
|
+
const r = await (await hivesClient()).hives.secrets.create(project, body);
|
|
304
|
+
emit(r, () => table([r], ['id', 'name', 'version']));
|
|
305
|
+
});
|
|
306
|
+
secrets.command('update <project_id> <secret_id>').description('Replace a secret value, creating a new version')
|
|
307
|
+
.option('--value-file <path>', 'UTF-8 file containing the new value').option('--stdin', 'read value from stdin')
|
|
308
|
+
.action(async (project, secret, o) => {
|
|
309
|
+
const value = validateSecretValue(await readInput({ file: o.valueFile, stdin: o.stdin }, 65536));
|
|
310
|
+
const r = await (await hivesClient()).hives.secrets.update(project, secret, { value });
|
|
311
|
+
emit(r, () => table([r], ['id', 'name', 'version']));
|
|
312
|
+
});
|
|
313
|
+
secrets.command('delete <project_id> <secret_id>').description('Delete a secret in your authenticated org')
|
|
314
|
+
.action(async (project, secret) => { const r = await (await hivesClient()).hives.secrets.delete(project, secret); emit(r, () => `Deleted secret ${secret}.`); });
|
|
315
|
+
secrets.command('reveal <project_id> <secret_id>').description('Explicit, audit-logged plaintext reveal; requires --stdout')
|
|
316
|
+
.option('--stdout', 'write sensitive plaintext to stdout (avoid logs and retained output)')
|
|
317
|
+
.action(async (project, secret, o) => {
|
|
318
|
+
if (!o.stdout)
|
|
319
|
+
throw new Error('Reveal requires --stdout. This writes sensitive plaintext to your terminal or pipe; avoid logging or retaining it.');
|
|
320
|
+
const r = await (await hivesClient()).hives.secrets.reveal(project, secret);
|
|
321
|
+
emit(r, () => r.value);
|
|
322
|
+
});
|
|
323
|
+
secrets.command('bulk <project_id>').description('PUT a JSON array of secret names and values; existing names receive new versions')
|
|
324
|
+
.option('--file <path>', 'UTF-8 JSON input file').option('--stdin', 'read JSON from stdin')
|
|
325
|
+
.action(async (project, o) => {
|
|
326
|
+
const batch = parseSecretBatch(await readInput(o, 256 * 1024));
|
|
327
|
+
const r = await (await hivesClient()).hives.secrets.bulkUpsert(project, batch);
|
|
328
|
+
emit(r, () => table(r.secrets, ['id', 'name', 'version']));
|
|
329
|
+
});
|
|
330
|
+
const projects = hives.command('projects').description('Hives projects');
|
|
331
|
+
projects.command('list', { isDefault: true }).option('--search <text>', 'filter by project name')
|
|
332
|
+
.option('--page <number>', 'page number (1–10000)').option('--per-page <number>', 'items per page (1–100)')
|
|
333
|
+
.action(async (o) => {
|
|
334
|
+
const r = await (await hivesClient()).hives.projects.list({ search: o.search,
|
|
335
|
+
page: positiveInteger(o.page, '--page', 10000), per_page: positiveInteger(o.perPage, '--per-page', 100) });
|
|
336
|
+
emit(r, () => table(listOf(r), ['id', 'name', 'slug', 'region', 'total_deployments', 'active_instances']));
|
|
337
|
+
});
|
|
338
|
+
projects.command('get <project_id>').action(async (id) => {
|
|
339
|
+
const r = await (await hivesClient()).hives.projects.get(id);
|
|
340
|
+
emit(r, () => JSON.stringify(r, null, 2));
|
|
341
|
+
});
|
|
342
|
+
projects.command('create <name>').description('Create a project in your authenticated org')
|
|
343
|
+
.option('--kind <kind>', 'worker, function, site or api', 'worker')
|
|
344
|
+
.option('--description <text>', 'project description (up to 500 characters)')
|
|
345
|
+
.action(async (name, o) => {
|
|
346
|
+
if (!['worker', 'function', 'site', 'api'].includes(o.kind))
|
|
347
|
+
throw new Error('--kind must be worker, function, site or api.');
|
|
348
|
+
if (o.description && o.description.length > 500)
|
|
349
|
+
throw new Error('--description must be at most 500 characters.');
|
|
350
|
+
const r = await (await hivesClient()).hives.projects.create({ name: projectName(name), kind: o.kind,
|
|
351
|
+
...(o.description === undefined ? {} : { description: o.description }) });
|
|
352
|
+
emit(r, () => table([r], ['id', 'name', 'slug', 'status']));
|
|
353
|
+
});
|
|
354
|
+
projects.command('rename <project_id> <name>').description('Rename a project in your authenticated org')
|
|
355
|
+
.action(async (id, name) => {
|
|
356
|
+
const r = await (await hivesClient()).hives.projects.update(id, { name: projectName(name) });
|
|
357
|
+
emit(r, () => table([r], ['id', 'name', 'slug', 'status']));
|
|
358
|
+
});
|
|
359
|
+
projects.command('activity <project_id>').description('Read persisted project activity')
|
|
360
|
+
.option('--action <action>', 'filter by action')
|
|
361
|
+
.option('--actor-type <type>', 'filter by actor type')
|
|
362
|
+
.option('--resource-type <type>', 'filter by resource type')
|
|
363
|
+
.option('--since <timestamp>', 'inclusive start timestamp in ISO 8601')
|
|
364
|
+
.option('--until <timestamp>', 'inclusive end timestamp in ISO 8601')
|
|
365
|
+
.option('--page <number>', 'page number (1–10000)')
|
|
366
|
+
.option('--per-page <number>', 'items per page (1–100)')
|
|
367
|
+
.action(async (id, o) => {
|
|
368
|
+
for (const [option, value] of [['--since', o.since], ['--until', o.until]]) {
|
|
369
|
+
if (value !== undefined && (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) || !Number.isFinite(Date.parse(value)))) {
|
|
370
|
+
throw new Error(`${option} must be an ISO 8601 timestamp.`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const r = await (await hivesClient()).hives.projects.activity(id, { action: o.action,
|
|
374
|
+
actor_type: o.actorType, resource_type: o.resourceType, since: o.since, until: o.until,
|
|
375
|
+
page: positiveInteger(o.page, '--page', 10000), per_page: positiveInteger(o.perPage, '--per-page', 100) });
|
|
376
|
+
emit(r, () => table(listOf(r), ['created_at', 'action', 'actor_id', 'resource_type', 'resource_name']));
|
|
377
|
+
});
|
|
378
|
+
// ---- relays ----------------------------------------------------------
|
|
379
|
+
const relays = program.command('relays').description('Relays: DNS zones and records');
|
|
380
|
+
relays.command('zones').description('List DNS zones').option('--search <q>').action(async (o) => {
|
|
381
|
+
const r = await (await client()).relays.zones.list(o.search ? { search: o.search } : {});
|
|
382
|
+
emit(r, () => table((r.zones ?? []), ['name', 'type', 'record_count', 'dnssec_enabled', 'status']));
|
|
383
|
+
});
|
|
384
|
+
const records = relays.command('records').description('DNS records in a zone');
|
|
385
|
+
records.command('list <zone>', { isDefault: true }).option('--type <type>').action(async (zone, o) => {
|
|
386
|
+
const r = await (await client()).relays.records.list(zone, o.type ? { type: o.type } : {});
|
|
387
|
+
emit(r, () => table((r.records ?? []), ['id', 'name', 'type', 'content', 'ttl']));
|
|
388
|
+
});
|
|
389
|
+
records.command('add <zone> <name> <type> <content>').option('--ttl <seconds>', 'TTL', '3600').action(async (zone, name, type, content, o) => {
|
|
390
|
+
const r = await (await client()).relays.records.create(zone, { name, type: type.toUpperCase(), content, ttl: Number(o.ttl) });
|
|
391
|
+
emit(r, () => `${c.green('✓')} Added ${type.toUpperCase()} ${name} → ${content} in ${zone}${r.id ? ` (id ${r.id})` : ''}`);
|
|
392
|
+
});
|
|
393
|
+
records.command('rm <zone> <record_id>').action(async (zone, id) => {
|
|
394
|
+
await (await client()).relays.records.delete(zone, Number(id));
|
|
395
|
+
emit({ deleted: Number(id) }, () => `${c.green('✓')} Deleted record ${id} from ${zone}`);
|
|
396
|
+
});
|
|
397
|
+
addHenryCommands(program, client, value => out.log(JSON.stringify(value, null, 2)));
|
|
398
|
+
registerFleets(program, client, (data) => emit(data, () => JSON.stringify(data, null, 2)));
|
|
399
|
+
registerBeacons(program, client, emit);
|
|
400
|
+
// ---- status ----------------------------------------------------------
|
|
401
|
+
program.command('status').description('Which Genesis services are wired up').action(async () => {
|
|
402
|
+
emit(services, () => table(services.map((s) => ({ service: s.name, status: s.status, ops: s.operations, upstream: s.upstream, note: s.statusNote })), ['service', 'status', 'ops', 'upstream', 'note']));
|
|
403
|
+
});
|
|
404
|
+
return program;
|
|
405
|
+
}
|
|
406
|
+
export async function main(argv, deps = {}) {
|
|
407
|
+
const out = deps.out ?? stdio;
|
|
408
|
+
const program = buildProgram(deps);
|
|
409
|
+
try {
|
|
410
|
+
await program.parseAsync(argv);
|
|
411
|
+
return 0;
|
|
412
|
+
}
|
|
413
|
+
catch (e) {
|
|
414
|
+
if (e instanceof CommanderError)
|
|
415
|
+
return e.exitCode;
|
|
416
|
+
const json = program.opts().json;
|
|
417
|
+
if (e instanceof GenesisError) {
|
|
418
|
+
if (json)
|
|
419
|
+
out.err(JSON.stringify({ error: { type: e.type, status: e.status, message: e.message, request_id: e.requestId } }));
|
|
420
|
+
else
|
|
421
|
+
out.err(`${c.red('✗')} ${e.message}${e.status ? c.dim(` (${e.status} ${e.type}${e.requestId ? `, request ${e.requestId}` : ''})`) : ''}`);
|
|
422
|
+
return 1;
|
|
423
|
+
}
|
|
424
|
+
const msg = e.message ?? String(e);
|
|
425
|
+
const net = /fetch failed|ENOTFOUND|ECONNREFUSED/.test(msg + (e.cause?.code ?? ''));
|
|
426
|
+
out.err(json ? JSON.stringify({ error: { type: net ? 'network' : 'error', message: msg } })
|
|
427
|
+
: `${c.red('✗')} ${net ? `Could not reach the Genesis API gateway (${msg}). Try --direct to call service hosts directly.` : msg}`);
|
|
428
|
+
return 1;
|
|
429
|
+
}
|
|
430
|
+
}
|
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface Out {
|
|
2
|
+
log(s: string): void;
|
|
3
|
+
err(s: string): void;
|
|
4
|
+
}
|
|
5
|
+
export declare const stdio: Out;
|
|
6
|
+
export declare const c: {
|
|
7
|
+
bold: (s: string) => string;
|
|
8
|
+
dim: (s: string) => string;
|
|
9
|
+
green: (s: string) => string;
|
|
10
|
+
yellow: (s: string) => string;
|
|
11
|
+
red: (s: string) => string;
|
|
12
|
+
};
|
|
13
|
+
/** Render rows as an aligned table. */
|
|
14
|
+
export declare function table(rows: Record<string, unknown>[], cols: string[]): string;
|
|
15
|
+
/** Pull the array out of the various list envelopes ({data}, {zones}, {records}, bare array). */
|
|
16
|
+
export declare function listOf(x: any, ...keys: string[]): any[];
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const stdio = { log: (s) => process.stdout.write(s + '\n'), err: (s) => process.stderr.write(s + '\n') };
|
|
2
|
+
const tty = () => !!process.stdout.isTTY && !process.env.NO_COLOR;
|
|
3
|
+
export const c = {
|
|
4
|
+
bold: (s) => (tty() ? `\x1b[1m${s}\x1b[0m` : s),
|
|
5
|
+
dim: (s) => (tty() ? `\x1b[2m${s}\x1b[0m` : s),
|
|
6
|
+
green: (s) => (tty() ? `\x1b[32m${s}\x1b[0m` : s),
|
|
7
|
+
yellow: (s) => (tty() ? `\x1b[33m${s}\x1b[0m` : s),
|
|
8
|
+
red: (s) => (tty() ? `\x1b[31m${s}\x1b[0m` : s),
|
|
9
|
+
};
|
|
10
|
+
/** Render rows as an aligned table. */
|
|
11
|
+
export function table(rows, cols) {
|
|
12
|
+
if (!rows.length)
|
|
13
|
+
return c.dim('(none)');
|
|
14
|
+
const cell = (v) => (v === undefined || v === null ? '' : Array.isArray(v) ? v.join(',') : String(v));
|
|
15
|
+
const w = cols.map((k) => Math.max(k.length, ...rows.map((r) => cell(r[k]).length)));
|
|
16
|
+
const line = (vals) => vals.map((v, i) => v.padEnd(w[i])).join(' ').trimEnd();
|
|
17
|
+
return [c.bold(line(cols.map((k) => k.toUpperCase()))), ...rows.map((r) => line(cols.map((k) => cell(r[k]))))].join('\n');
|
|
18
|
+
}
|
|
19
|
+
/** Pull the array out of the various list envelopes ({data}, {zones}, {records}, bare array). */
|
|
20
|
+
export function listOf(x, ...keys) {
|
|
21
|
+
if (Array.isArray(x))
|
|
22
|
+
return x;
|
|
23
|
+
for (const k of [...keys, 'data', 'items', 'results'])
|
|
24
|
+
if (Array.isArray(x?.[k]))
|
|
25
|
+
return x[k];
|
|
26
|
+
return [];
|
|
27
|
+
}
|
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import type { Genesis } from '@lifesi/genesis';
|
|
3
|
+
/** Register only Runner commands. The authenticated credential determines org and subject. */
|
|
4
|
+
export declare function registerRunner(program: Command, client: () => Promise<Genesis>, emit: (data: unknown, pretty: () => string) => void): void;
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Register only Runner commands. The authenticated credential determines org and subject. */
|
|
2
|
+
export function registerRunner(program, client, emit) {
|
|
3
|
+
const runner = program.command('runner').description('Runner: agent task execution');
|
|
4
|
+
const output = (value) => emit(value, () => JSON.stringify(value, null, 2));
|
|
5
|
+
runner.command('catalog').description('Authorized workspaces and execution routes')
|
|
6
|
+
.action(async () => output(await (await client()).runner.catalog()));
|
|
7
|
+
const jobs = runner.command('jobs').description('Jobs belonging to your verified identity');
|
|
8
|
+
jobs.command('list', { isDefault: true }).description('Newest 100 jobs')
|
|
9
|
+
.action(async () => output(await (await client()).runner.jobs.list()));
|
|
10
|
+
jobs.command('start').description('Start a bounded task; refs must come from catalog')
|
|
11
|
+
.requiredOption('--task-id <id>', 'opaque task ID')
|
|
12
|
+
.requiredOption('--text <text>', 'task instructions (at most 32768 characters)')
|
|
13
|
+
.requiredOption('--workspace <ref>', 'authorized workspace key')
|
|
14
|
+
.requiredOption('--kind <kind>', 'external-harness or internal-model')
|
|
15
|
+
.requiredOption('--route <id>', 'authorized execution route')
|
|
16
|
+
.requiredOption('--idempotency-key <key>', 'stable key for retries of the same task')
|
|
17
|
+
.option('--duration-ms <ms>', 'maximum execution duration', '600000')
|
|
18
|
+
.option('--output-bytes <bytes>', 'maximum output size', '1048576')
|
|
19
|
+
.option('--max-events <count>', 'retained event limit', '200')
|
|
20
|
+
.action(async (o) => {
|
|
21
|
+
if (!['external-harness', 'internal-model'].includes(o.kind))
|
|
22
|
+
throw new TypeError('Runner kind must be external-harness or internal-model');
|
|
23
|
+
const body = {
|
|
24
|
+
task: { id: o.taskId, text: o.text }, workspaceRef: o.workspace,
|
|
25
|
+
executionRef: { kind: o.kind, routeId: o.route },
|
|
26
|
+
limits: { maxDurationMs: Number(o.durationMs), maxOutputBytes: Number(o.outputBytes), maxEvents: Number(o.maxEvents) },
|
|
27
|
+
idempotencyKey: o.idempotencyKey,
|
|
28
|
+
};
|
|
29
|
+
output(await (await client()).runner.jobs.start(body));
|
|
30
|
+
});
|
|
31
|
+
jobs.command('get <job_id>').description('Read the authoritative snapshot')
|
|
32
|
+
.action(async (id) => output(await (await client()).runner.jobs.get(id)));
|
|
33
|
+
jobs.command('events <job_id>').description('Read one event page; continue with nextAfterSeq while hasMore')
|
|
34
|
+
.option('--after-seq <seq>', 'exclusive event cursor', '0')
|
|
35
|
+
.action(async (id, o) => output(await (await client()).runner.jobs.events(id, Number(o.afterSeq))));
|
|
36
|
+
jobs.command('result <job_id>').description('Read the verified completed provider deliverable')
|
|
37
|
+
.action(async (id) => output(await (await client()).runner.jobs.result(id)));
|
|
38
|
+
jobs.command('cancel <job_id>').description('Request cancellation; then watch status and cleanupPending')
|
|
39
|
+
.action(async (id) => output(await (await client()).runner.jobs.cancel(id)));
|
|
40
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface SecretInputOptions {
|
|
2
|
+
file?: string;
|
|
3
|
+
stdin?: boolean;
|
|
4
|
+
}
|
|
5
|
+
export interface SecretInput {
|
|
6
|
+
name: string;
|
|
7
|
+
value: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Read plaintext only from an explicitly selected file or stdin, never argv. */
|
|
11
|
+
export declare function readSecretInput(options: SecretInputOptions, limit: number): Promise<string>;
|
|
12
|
+
export declare function validateSecretValue(value: string): string;
|
|
13
|
+
export declare function validateSecret(input: unknown): SecretInput;
|
|
14
|
+
export declare function parseSecretBatch(text: string): SecretInput[];
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
/** Read plaintext only from an explicitly selected file or stdin, never argv. */
|
|
3
|
+
export async function readSecretInput(options, limit) {
|
|
4
|
+
if (Boolean(options.file) === Boolean(options.stdin))
|
|
5
|
+
throw new Error('Choose exactly one input source: a file or --stdin. Secret values are not accepted as command arguments.');
|
|
6
|
+
const chunks = [];
|
|
7
|
+
let size = 0;
|
|
8
|
+
const append = (chunk) => {
|
|
9
|
+
size += chunk.byteLength;
|
|
10
|
+
if (size > limit)
|
|
11
|
+
throw new Error(`Secret input exceeds ${limit} UTF-8 bytes. Use a smaller value or batch.`);
|
|
12
|
+
chunks.push(chunk);
|
|
13
|
+
};
|
|
14
|
+
if (options.file) {
|
|
15
|
+
let file;
|
|
16
|
+
try {
|
|
17
|
+
file = await open(options.file, 'r');
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new Error('Could not open the secret input file. Check its path and permissions.');
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
for (;;) {
|
|
24
|
+
const buffer = new Uint8Array(Math.min(8192, limit + 1));
|
|
25
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, null);
|
|
26
|
+
if (!bytesRead)
|
|
27
|
+
break;
|
|
28
|
+
append(buffer.subarray(0, bytesRead));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await file.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
for await (const chunk of process.stdin)
|
|
37
|
+
append(typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk);
|
|
38
|
+
}
|
|
39
|
+
const bytes = new Uint8Array(size);
|
|
40
|
+
let offset = 0;
|
|
41
|
+
for (const chunk of chunks) {
|
|
42
|
+
bytes.set(chunk, offset);
|
|
43
|
+
offset += chunk.byteLength;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new Error('Secret input must be valid UTF-8 text.');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function validateSecretValue(value) {
|
|
53
|
+
if (!value.length || new TextEncoder().encode(value).length > 65536)
|
|
54
|
+
throw new Error('Secret values must contain 1–65,536 UTF-8 bytes.');
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
export function validateSecret(input) {
|
|
58
|
+
if (!input || typeof input !== 'object')
|
|
59
|
+
throw new Error('Each secret needs a name and value.');
|
|
60
|
+
const { name, value, description } = input;
|
|
61
|
+
if (typeof name !== 'string' || name.length > 255 || !/^[A-Za-z][A-Za-z0-9_]*$/.test(name))
|
|
62
|
+
throw new Error('Secret names must start with a letter and contain only letters, numbers and underscores (up to 255 characters).');
|
|
63
|
+
if (typeof value !== 'string')
|
|
64
|
+
throw new Error('Each secret value must be a string.');
|
|
65
|
+
if (description !== undefined && description !== null && (typeof description !== 'string' || description.length > 500))
|
|
66
|
+
throw new Error('Secret descriptions must contain at most 500 characters.');
|
|
67
|
+
return { name, value: validateSecretValue(value), ...(typeof description === 'string' ? { description } : {}) };
|
|
68
|
+
}
|
|
69
|
+
export function parseSecretBatch(text) {
|
|
70
|
+
let data;
|
|
71
|
+
try {
|
|
72
|
+
data = JSON.parse(text);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new Error('Bulk input must be a JSON array with secret name and value fields.');
|
|
76
|
+
}
|
|
77
|
+
if (!Array.isArray(data) || !data.length)
|
|
78
|
+
throw new Error('Bulk input must be a non-empty JSON array.');
|
|
79
|
+
const batch = data.map(validateSecret);
|
|
80
|
+
if (new Set(batch.map((secret) => secret.name)).size !== batch.length)
|
|
81
|
+
throw new Error('Each secret name must appear once in a bulk update.');
|
|
82
|
+
if (new TextEncoder().encode(JSON.stringify(batch)).length > 256 * 1024)
|
|
83
|
+
throw new Error('Bulk input must fit within 256 KiB. Split it into smaller batches.');
|
|
84
|
+
return batch;
|
|
85
|
+
}
|