@lifesi/genesis-cli 0.1.0 → 0.1.2
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/dist/beacons.js +45 -1
- package/dist/dials.js +64 -2
- package/dist/fleets.js +8 -1
- package/dist/flocks.d.ts +3 -0
- package/dist/flocks.js +46 -0
- package/dist/function-input.d.ts +3 -0
- package/dist/function-input.js +49 -0
- package/dist/hives-node-transport.d.ts +6 -0
- package/dist/hives-node-transport.js +147 -0
- package/dist/hives-operations.d.ts +9 -0
- package/dist/hives-operations.js +61 -0
- package/dist/hives-resources.d.ts +9 -0
- package/dist/hives-resources.js +71 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +267 -9
- package/dist/runner.js +15 -0
- package/dist/secret-input.d.ts +14 -0
- package/dist/secret-input.js +85 -0
- package/dist/wasm-input.d.ts +2 -0
- package/dist/wasm-input.js +31 -0
- package/package.json +8 -2
package/dist/beacons.js
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { createPrivateKey, sign } from 'node:crypto';
|
|
1
3
|
export function registerBeacons(program, client, emit) {
|
|
2
|
-
const
|
|
4
|
+
const beacons = program.command('beacons').description('Beacons: Agent-native mesh');
|
|
5
|
+
beacons.command('fleets').description('Provision Bridge metadata in your verified selection')
|
|
6
|
+
.command('provision').requiredOption('--request-id <uuid>', 'Stable UUID; reuse with the same name on retry')
|
|
7
|
+
.option('--name <name>', 'Optional fleet name')
|
|
8
|
+
.action(async (opts) => {
|
|
9
|
+
const result = await (await client()).beacons.fleets.provision(opts.requestId, opts.name === undefined ? {} : { name: opts.name });
|
|
10
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
11
|
+
});
|
|
12
|
+
const devices = beacons.command('devices').description('Manage Bridge devices in your signed-in organization');
|
|
13
|
+
devices.command('challenge <fleet-id>').requiredOption('--proof-key <key>', 'Base64url Ed25519 public key').requiredOption('--tunnel-key <key>', 'Base64url WireGuard public key').option('--transport <transport>', 't1_full or t2_sandboxed', 't1_full')
|
|
14
|
+
.action(async (fleetId, opts) => {
|
|
15
|
+
if (opts.transport !== 't1_full' && opts.transport !== 't2_sandboxed')
|
|
16
|
+
throw new Error('Unsupported device transport.');
|
|
17
|
+
const result = await (await client()).beacons.devices.challenge({ fleet_id: fleetId, proof_key: opts.proofKey, tunnel_key: opts.tunnelKey, transport: opts.transport });
|
|
18
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
19
|
+
});
|
|
20
|
+
devices.command('enroll <challenge-file>').requiredOption('--key-file <path>', 'Local Ed25519 PEM private key; never uploaded')
|
|
21
|
+
.action(async (challengeFile, opts) => {
|
|
22
|
+
const challenge = JSON.parse(await readFile(challengeFile, 'utf8'));
|
|
23
|
+
const fields = ['version', 'id', 'nonce', 'issuer', 'organization', 'principal', 'workspace', 'project', 'environment', 'binding_revision', 'transport', 'fleet_id', 'proof_key', 'tunnel_key', 'issued_at', 'expires_at'];
|
|
24
|
+
if (Object.keys(challenge).length !== fields.length || fields.some(field => challenge[field] === undefined))
|
|
25
|
+
throw new Error('Invalid challenge file.');
|
|
26
|
+
const canonical = Object.fromEntries(fields.map(field => [field, challenge[field]]));
|
|
27
|
+
const key = createPrivateKey(await readFile(opts.keyFile));
|
|
28
|
+
if (key.asymmetricKeyType !== 'ed25519')
|
|
29
|
+
throw new Error('An Ed25519 device key is required.');
|
|
30
|
+
const signature = sign(null, Buffer.from('Genesis Beacons device enrollment v1\n' + JSON.stringify(canonical)), key).toString('base64url');
|
|
31
|
+
const result = await (await client()).beacons.devices.enroll({ challenge, signature });
|
|
32
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
33
|
+
});
|
|
34
|
+
devices.command('revocations <id>').description('Poll durable transport teardown commands').action(async (id) => {
|
|
35
|
+
const result = await (await client()).beacons.devices.revocations(id);
|
|
36
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
37
|
+
});
|
|
38
|
+
devices.command('ack <id> <version>').requiredOption('--action <action>', 'Applied interface_torn_down or peer_key_removed; apply transport action first').action(async (id, version, opts) => {
|
|
39
|
+
const numeric = Number(version);
|
|
40
|
+
if (!Number.isSafeInteger(numeric) || numeric < 1)
|
|
41
|
+
throw new Error('Invalid revocation version.');
|
|
42
|
+
if (opts.action !== 'interface_torn_down' && opts.action !== 'peer_key_removed')
|
|
43
|
+
throw new Error('Invalid transport action.');
|
|
44
|
+
const result = await (await client()).beacons.devices.acknowledge(id, numeric, { action: opts.action });
|
|
45
|
+
emit(result, () => JSON.stringify(result, null, 2));
|
|
46
|
+
});
|
|
3
47
|
devices.command('list', { isDefault: true }).action(async () => {
|
|
4
48
|
const result = await (await client()).beacons.devices.list();
|
|
5
49
|
emit(result, () => JSON.stringify(result, null, 2));
|
package/dist/dials.js
CHANGED
|
@@ -13,13 +13,75 @@ export function registerDials(program, dependencies) {
|
|
|
13
13
|
const emit = (data) => dependencies.emit(data, () => JSON.stringify(data, null, 2));
|
|
14
14
|
const numbers = dials.command('numbers').description('Carrier number inventory');
|
|
15
15
|
numbers.command('list', { isDefault: true }).action(async () => { const { client, scope } = await context(); emit(await client.dials.numbers.list(scope)); });
|
|
16
|
-
numbers.command('search').option('--area-code <code>', 'three digit area code').
|
|
16
|
+
numbers.command('search').option('--area-code <code>', 'three digit area code').option('--filters <json-or-@file>', 'carrier search filters, including capabilities and rate center').action(async (options) => {
|
|
17
|
+
const { client, scope } = await context();
|
|
18
|
+
const text = options.filters?.startsWith('@') ? await readFile(options.filters.slice(1), 'utf8') : options.filters;
|
|
19
|
+
const filters = text ? JSON.parse(text) : {};
|
|
20
|
+
if (!filters || typeof filters !== 'object' || Array.isArray(filters))
|
|
21
|
+
throw new Error('Search filters must be a JSON object.');
|
|
22
|
+
const allowed = new Set(['areaCode', 'exchangeCode', 'localCallingArea', 'contains', 'startsWith', 'endsWith', 'repeatingDigit', 'repeatingDigitCount', 'region', 'locality', 'postalCode', 'rateCenter', 'lata', 'nearNumber', 'nearLatLong', 'distance', 'excludeAddressRequired', 'excludeLocalAddressRequired', 'excludeForeignAddressRequired', 'beta', 'voice', 'sms', 'mms', 'fax', 'numberType', 'limit']);
|
|
23
|
+
if (Object.keys(filters).some(key => !allowed.has(key)))
|
|
24
|
+
throw new Error('Unsupported Dials search filter. Scope is selected by the command context.');
|
|
25
|
+
emit(await client.dials.numbers.search(scope, { ...filters, ...(options.areaCode ? { areaCode: options.areaCode } : {}) }));
|
|
26
|
+
});
|
|
27
|
+
numbers.command('reconciliations').action(async () => { const { client, scope } = await context(); emit(await client.dials.numbers.reconciliations(scope)); });
|
|
28
|
+
numbers.command('drift').action(async () => { const { client, scope } = await context(); emit(await client.dials.numbers.drift(scope)); });
|
|
29
|
+
dials.command('overview').action(async () => { const { client, scope } = await context(); emit(await client.dials.overview.summary(scope)); });
|
|
30
|
+
dials.command('operating-surface').action(async () => { const { client, scope } = await context(); emit(await client.dials.overview.operatingSurface(scope)); });
|
|
31
|
+
for (const [name, list, create] of [['people', 'dials_listHumanProfiles', 'dials_createHumanProfile'], ['agents', 'dials_listAgents', 'dials_createAgent'], ['voice', 'dials_listVoiceProfiles', 'dials_createVoiceProfile']]) {
|
|
32
|
+
const profiles = dials.command(name).description('Saved profile configuration in the selected scope');
|
|
33
|
+
profiles.command('list', { isDefault: true }).action(async () => { const { client, scope } = await context(); emit(await client.call(list, scope)); });
|
|
34
|
+
profiles.command('create').requiredOption('--body <json-or-@file>', 'Unassigned profile fields and actorId; same-name POST updates the stored profile').action(async (options) => {
|
|
35
|
+
const { client, scope } = await context();
|
|
36
|
+
const text = options.body.startsWith('@') ? await readFile(options.body.slice(1), 'utf8') : options.body;
|
|
37
|
+
const body = JSON.parse(text);
|
|
38
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || 'phoneNumberId' in body)
|
|
39
|
+
throw new Error('Supply unassigned profile fields; use numbers assign after saving.');
|
|
40
|
+
emit(await client.call(create, { body: { ...body, scope } }));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const readBody = async (value) => {
|
|
44
|
+
const body = JSON.parse(value.startsWith('@') ? await readFile(value.slice(1), 'utf8') : value);
|
|
45
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
46
|
+
throw new Error('Dials body must be a JSON object.');
|
|
47
|
+
return body;
|
|
48
|
+
};
|
|
49
|
+
const messages = dials.command('messages').description('Saved messages, templates and pending registration configuration');
|
|
50
|
+
messages.command('list', { isDefault: true }).action(async () => { const { client, scope } = await context(); emit(await client.dials.messages.list(scope)); });
|
|
51
|
+
messages.command('send').requiredOption('--body <json-or-@file>', 'Explicit approvals, actor, sender, destination, body and idempotency key')
|
|
52
|
+
.action(async (options) => { const { client, scope } = await context(); const body = await readBody(options.body); emit(await client.call('dials_createOutboundMessage', { body: { ...body, scope } })); });
|
|
53
|
+
for (const [name, list, create] of [
|
|
54
|
+
['templates', 'dials_listMessageTemplates', 'dials_createMessageTemplate'],
|
|
55
|
+
['brands', 'dials_listMessagingBrands', 'dials_createMessagingBrand'],
|
|
56
|
+
['campaigns', 'dials_listMessagingCampaigns', 'dials_createMessagingCampaign'],
|
|
57
|
+
]) {
|
|
58
|
+
const group = messages.command(name).description('Scoped saved configuration; creation is not provider approval');
|
|
59
|
+
group.command('list', { isDefault: true }).action(async () => { const { client, scope } = await context(); emit(await client.call(list, scope)); });
|
|
60
|
+
group.command('create').requiredOption('--body <json-or-@file>', 'Configuration fields and actorId')
|
|
61
|
+
.action(async (options) => { const { client, scope } = await context(); const body = await readBody(options.body); emit(await client.call(create, { body: { ...body, scope } })); });
|
|
62
|
+
if (name === 'campaigns')
|
|
63
|
+
group.command('assign').requiredOption('--campaign <id>', 'Owned campaign ID').requiredOption('--body <json-or-@file>', 'Owned phoneNumberId and actorId')
|
|
64
|
+
.action(async (options) => { const { client, scope } = await context(); const body = await readBody(options.body); emit(await client.call('dials_assignCampaignNumber', { campaign_id: options.campaign, body: { ...body, scope } })); });
|
|
65
|
+
}
|
|
66
|
+
const routing = dials.command('routing').description('Managed owned-number inbound policies; workflow documents are separate');
|
|
67
|
+
routing.command('list', { isDefault: true }).action(async () => { const { client, scope } = await context(); emit(await client.dials.routing.list(scope)); });
|
|
68
|
+
routing.command('create').requiredOption('--body <json-or-@file>', 'Owned source, supported action, priority and persisted original UUID; creates an enabled policy')
|
|
69
|
+
.action(async (options) => { const { client, scope } = await context(); const body = await readBody(options.body); emit(await client.call('dials_createRoutingRule', { body: { ...body, scope } })); });
|
|
70
|
+
routing.command('disable').requiredOption('--rule <id>', 'Owned managed policy ID; record is retained')
|
|
71
|
+
.action(async (options) => { const { client, scope } = await context(); emit(await client.dials.routing.disable(options.rule, { scope })); });
|
|
72
|
+
const workflows = dials.command('workflows').description('Persisted routing descriptors; execution is verified separately');
|
|
73
|
+
workflows.command('list', { isDefault: true }).action(async () => { const { client, scope } = await context(); emit(await client.dials.workflows.list(scope)); });
|
|
74
|
+
workflows.command('deploy').requiredOption('--body <json-or-@file>', 'Workflow graph and actorId')
|
|
75
|
+
.action(async (options) => { const { client, scope } = await context(); const body = await readBody(options.body); emit(await client.call('dials_deployWorkflow', { body: { ...body, scope } })); });
|
|
76
|
+
numbers.command('delete').requiredOption('--number <id>', 'Owned number ID to release at the carrier').requiredOption('--actor <id>', 'Actual actorId').option('--reason <text>', 'Release reason').option('--platform <id>', 'Selected data platform; omitted defaults to dials')
|
|
77
|
+
.action(async (options) => { const { client, scope } = await context(); emit(await client.dials.numbers.delete({ ...scope, ...(options.platform !== undefined ? { platformId: options.platform } : {}), number_id: options.number, actorId: options.actor, ...(options.reason !== undefined ? { reason: options.reason } : {}) })); });
|
|
17
78
|
const calls = dials.command('calls').description('Call activity and approved outbound calls');
|
|
18
79
|
calls.command('list', { isDefault: true }).action(async () => { const { client, scope } = await context(); emit(await client.dials.calls.list(scope)); });
|
|
19
80
|
// @file keeps structured approval and idempotency fields out of shell history.
|
|
20
81
|
for (const [parent, name, operation] of [
|
|
82
|
+
[numbers, 'caller-name', 'dials_updateNumberCallerName'], [numbers, 'reconcile', 'dials_reconcileNumberInventory'],
|
|
21
83
|
[numbers, 'provision', 'dials_provisionNumber'], [numbers, 'assign', 'dials_assignNumber'], [numbers, 'release', 'dials_releaseNumber'], [numbers, 'e911', 'dials_registerEmergencyAddress'],
|
|
22
|
-
[calls, 'validate', 'dials_validateOutboundCall'], [calls, 'create', 'dials_createOutboundCall'],
|
|
84
|
+
[calls, 'validate', 'dials_validateOutboundCall'], [calls, 'create', 'dials_createOutboundCall'], [calls, 'recover', 'dials_recoverOutboundCall'],
|
|
23
85
|
]) {
|
|
24
86
|
parent.command(name).requiredOption('--body <json-or-@file>', 'JSON input including actorId and required operation fields')
|
|
25
87
|
.action(async (options) => {
|
package/dist/fleets.js
CHANGED
|
@@ -20,7 +20,14 @@ export async function readFoundryKey(input = process.stdin) {
|
|
|
20
20
|
return key;
|
|
21
21
|
}
|
|
22
22
|
export function registerFleets(program, client, emit, keyInput = readFoundryKey) {
|
|
23
|
-
const fleets = program.command('fleets').description('Fleets: managed
|
|
23
|
+
const fleets = program.command('fleets').description('Fleets: managed Henry instances and tasks');
|
|
24
|
+
const instances = fleets.command('instances').description('Actual admitted Henry Host status');
|
|
25
|
+
instances.command('list').option('--limit <number>').option('--after <instance_id>').action(async (options) => {
|
|
26
|
+
emit(await (await client()).fleets.instances.list({ ...(options.limit ? { limit: Number(options.limit) } : {}), ...(options.after ? { after: options.after } : {}) }));
|
|
27
|
+
});
|
|
28
|
+
instances.command('get <instance_id>').action(async (id) => emit(await (await client()).fleets.instances.get(id)));
|
|
29
|
+
instances.command('stop <instance_id>').description('Stop admitted Henry compute and retain its data').option('--idempotency-key <uuid>', 'canonical UUID4 to retain after an uncertain reply').action(async (id, options) => emit(await (await client()).fleets.instances.stop(id, { idempotencyKey: options.idempotencyKey })));
|
|
30
|
+
instances.command('start <instance_id>').description('Connect already admitted Henry compute').option('--idempotency-key <uuid>', 'canonical UUID4 to retain after an uncertain reply').action(async (id, options) => emit(await (await client()).fleets.instances.start(id, { idempotencyKey: options.idempotencyKey })));
|
|
24
31
|
const connection = fleets.command('connection').description('Organization-owned Foundry connection');
|
|
25
32
|
connection.command('get').action(async () => emit(await (await client()).fleets.connection.get()));
|
|
26
33
|
connection.command('set').description('Read a Foundry key from stdin and encrypt it with Genesis KMS').action(async () => {
|
package/dist/flocks.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import type { Genesis } from '@lifesi/genesis';
|
|
3
|
+
export declare function registerFlocks(program: Command, client: () => Promise<Genesis>, emit: (data: unknown, pretty: () => string) => void, env?: Record<string, string | undefined>): void;
|
package/dist/flocks.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export function registerFlocks(program, client, emit, env = process.env) {
|
|
2
|
+
const flocks = program.command('flocks').description('Flocks: Agents on your hardware');
|
|
3
|
+
const secret = (name) => {
|
|
4
|
+
const value = env[name];
|
|
5
|
+
if (!value)
|
|
6
|
+
throw new Error(`Set ${name} in the environment; do not pass Flocks credentials as command-line arguments.`);
|
|
7
|
+
return value;
|
|
8
|
+
};
|
|
9
|
+
const show = async (run) => {
|
|
10
|
+
const data = await run(await client());
|
|
11
|
+
emit(data, () => JSON.stringify(data, null, 2));
|
|
12
|
+
};
|
|
13
|
+
flocks.command('readiness').description('Check whether node enrollment is currently available').action(() => show(g => g.flocks.readiness.get()));
|
|
14
|
+
flocks.command('nodes').description('List your org nodes').action(() => show(g => g.flocks.nodes.list()));
|
|
15
|
+
flocks.command('join-token').option('--ttl <seconds>', 'lifetime (60–600 seconds)', '600').action((opts) => {
|
|
16
|
+
const ttl = Number(opts.ttl);
|
|
17
|
+
if (!Number.isInteger(ttl) || ttl < 60 || ttl > 600)
|
|
18
|
+
throw new Error('TTL must be 60–600 seconds');
|
|
19
|
+
return show(g => g.flocks.joinTokens.create(ttl));
|
|
20
|
+
});
|
|
21
|
+
flocks.command('rename <id> <nickname>').action((id, nickname) => show(g => g.flocks.nodes.rename(id, nickname)));
|
|
22
|
+
flocks.command('revoke <id>').action((id) => show(g => g.flocks.nodes.revoke(id)));
|
|
23
|
+
flocks.command('enroll <name> <platform> <cpus>').description('Enroll this node with FLOCKS_JOIN_TOKEN from the environment').action((name, platform, cpus) => {
|
|
24
|
+
const cpu_count = Number(cpus);
|
|
25
|
+
if (!Number.isInteger(cpu_count) || cpu_count < 1 || cpu_count > 65536)
|
|
26
|
+
throw new Error('CPUs must be 1–65536');
|
|
27
|
+
const token = secret('FLOCKS_JOIN_TOKEN');
|
|
28
|
+
return show(g => g.flocks.nodes.enroll({ token, name, platform, cpu_count }));
|
|
29
|
+
});
|
|
30
|
+
flocks.command('heartbeat <id>').description('Send presence using FLOCKS_NODE_BINDING from the environment').action((id) => {
|
|
31
|
+
const binding = secret('FLOCKS_NODE_BINDING');
|
|
32
|
+
return show(g => g.flocks.nodes.heartbeat(id, binding));
|
|
33
|
+
});
|
|
34
|
+
flocks.command('exchange-join').description('Exchange FLOCKS_JOIN_TOKEN and FLOCKS_NODE_NONCE; save the returned key privately before activation').action(() => show(g => g.flocks.joinTokens.exchange({ token: secret('FLOCKS_JOIN_TOKEN'), node_nonce: secret('FLOCKS_NODE_NONCE') })));
|
|
35
|
+
flocks.command('activate <id> <name> <platform> <cpus>').description('Activate the saved node key with FLOCKS_NODE_NONCE').action((id, name, platform, cpus) => {
|
|
36
|
+
const cpu_count = Number(cpus);
|
|
37
|
+
if (!Number.isInteger(cpu_count) || cpu_count < 1 || cpu_count > 65536)
|
|
38
|
+
throw new Error('CPUs must be 1–65536');
|
|
39
|
+
return show(g => g.flocks.nodes.activate(id, { name, platform, cpu_count, node_nonce: secret('FLOCKS_NODE_NONCE') }));
|
|
40
|
+
});
|
|
41
|
+
flocks.command('rotate <id> <action>').description('prepare→persist→commit, or cancel; use saved FLOCKS_NODE_NONCE/FLOCKS_ROTATION_NONCE').action((id, action) => {
|
|
42
|
+
if (!['prepare', 'commit', 'cancel'].includes(action))
|
|
43
|
+
throw new Error('Action must be prepare, commit or cancel');
|
|
44
|
+
return show(g => g.flocks.nodes.rotateCredential(id, { action: action, node_nonce: secret('FLOCKS_NODE_NONCE'), operation_nonce: secret('FLOCKS_ROTATION_NONCE') }));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function readFunctionFile(path: string, limit: number): Promise<Uint8Array>;
|
|
2
|
+
export declare function readFunctionJson(path: string, limit: number): Promise<Record<string, unknown>>;
|
|
3
|
+
export declare function writeFunctionOutput(path: string, bytes: Uint8Array): Promise<void>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { open, writeFile } from 'node:fs/promises';
|
|
2
|
+
export async function readFunctionFile(path, limit) {
|
|
3
|
+
let file;
|
|
4
|
+
try {
|
|
5
|
+
file = await open(path, 'r');
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
throw new Error('Could not open the function input file. Check its path and permissions.');
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
const stat = await file.stat();
|
|
12
|
+
if (!stat.isFile() || stat.size > limit)
|
|
13
|
+
throw new Error('Choose a regular function input file within the documented size limit.');
|
|
14
|
+
const bytes = new Uint8Array(limit + 1);
|
|
15
|
+
let size = 0;
|
|
16
|
+
for (;;) {
|
|
17
|
+
const { bytesRead } = await file.read(bytes, size, bytes.length - size, null);
|
|
18
|
+
if (!bytesRead)
|
|
19
|
+
break;
|
|
20
|
+
size += bytesRead;
|
|
21
|
+
if (size > limit)
|
|
22
|
+
throw new Error('Function input file exceeds the documented size limit.');
|
|
23
|
+
}
|
|
24
|
+
return bytes.slice(0, size);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
await file.close();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function readFunctionJson(path, limit) {
|
|
31
|
+
const bytes = await readFunctionFile(path, limit);
|
|
32
|
+
try {
|
|
33
|
+
const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
34
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
35
|
+
throw new Error();
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error('Function request file must contain a JSON object. No input value is echoed.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function writeFunctionOutput(path, bytes) {
|
|
43
|
+
try {
|
|
44
|
+
await writeFile(path, bytes, { flag: 'wx', mode: 0o600 });
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error('Could not create the output file. Choose a new path with write permission. Existing files are preserved.');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type GenesisOptions } from '@lifesi/genesis';
|
|
2
|
+
export declare function closeHivesNodeTransport(): Promise<void>;
|
|
3
|
+
export declare function hivesOperationBudget(id: string): number;
|
|
4
|
+
/** Hives leaf transport. Caller fetch overrides stay authoritative; only native
|
|
5
|
+
* global fetch gets our Agent. Every operation retains an overall body deadline. */
|
|
6
|
+
export declare function createHivesNodeTransport(opts: GenesisOptions, callerSignal?: AbortSignal): typeof fetch;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Agent } from 'undici';
|
|
2
|
+
import { Genesis, operationTable } from '@lifesi/genesis';
|
|
3
|
+
// Node's native fetch accepts a compatible per-request dispatcher. Never change
|
|
4
|
+
// the global dispatcher or the portable SDK: other products retain their transport.
|
|
5
|
+
const agentOptions = { headersTimeout: 340_000, bodyTimeout: 340_000, connect: { timeout: 5_000 }, connections: 4, pipelining: 1 };
|
|
6
|
+
let agent;
|
|
7
|
+
const nativeAgent = () => agent ??= new Agent(agentOptions);
|
|
8
|
+
export async function closeHivesNodeTransport() {
|
|
9
|
+
const current = agent;
|
|
10
|
+
agent = undefined;
|
|
11
|
+
await current?.destroy();
|
|
12
|
+
}
|
|
13
|
+
export function hivesOperationBudget(id) {
|
|
14
|
+
if (['hives_list_buckets', 'hives_create_bucket', 'hives_get_bucket', 'hives_delete_bucket', 'hives_list_objects', 'hives_upload_object', 'hives_upload_object_by_key', 'hives_delete_object', 'hives_delete_object_by_key', 'hives_download_object_by_key', 'hives_get_cors_config', 'hives_set_cors_config', 'hives_generate_presigned_url', 'hives_reconcile'].includes(id))
|
|
15
|
+
return 100_000;
|
|
16
|
+
if (id === 'hives_cancel_deployment' || id === 'hives_delete_deployment')
|
|
17
|
+
return 330_000;
|
|
18
|
+
if (['hives_create_deployment', 'hives_upload_deployment', 'hives_invoke_deployment', 'hives_invoke_function', 'hives_invoke_function_async'].includes(id))
|
|
19
|
+
return 40_000;
|
|
20
|
+
if (['hives_create_function', 'hives_update_function', 'hives_delete_function'].includes(id))
|
|
21
|
+
return 100_000;
|
|
22
|
+
return 20_000;
|
|
23
|
+
}
|
|
24
|
+
const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
25
|
+
const templatePattern = (value) => new RegExp('^' + value.split(/(\{[^}]+\})/).map((part) => part.startsWith('{') ? '[^/]+' : escape(part)).join('') + '$');
|
|
26
|
+
/** Hives leaf transport. Caller fetch overrides stay authoritative; only native
|
|
27
|
+
* global fetch gets our Agent. Every operation retains an overall body deadline. */
|
|
28
|
+
export function createHivesNodeTransport(opts, callerSignal) {
|
|
29
|
+
const supplied = opts.fetch ?? globalThis.fetch;
|
|
30
|
+
const underlying = supplied.bind(globalThis);
|
|
31
|
+
const useNative = supplied === globalThis.fetch;
|
|
32
|
+
const resolver = new Genesis(opts);
|
|
33
|
+
const routes = Object.entries(operationTable)
|
|
34
|
+
.filter(([, op]) => op.service === 'hives' && !op.internal)
|
|
35
|
+
.flatMap(([id, op]) => {
|
|
36
|
+
const direct = resolver.routing === 'direct';
|
|
37
|
+
let base;
|
|
38
|
+
try {
|
|
39
|
+
base = new URL(direct ? (opts.upstreams?.hives ?? op.upstream.base) : resolver.baseUrl);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new Error('Invalid Hives service address.');
|
|
43
|
+
}
|
|
44
|
+
const path = direct ? op.upstream.path : op.path;
|
|
45
|
+
const prefix = base.pathname.replace(/\/+$/, '');
|
|
46
|
+
const paths = [path];
|
|
47
|
+
if (!direct && path.startsWith('/v1/hives/'))
|
|
48
|
+
paths.push(path.replace('/v1/hives/', '/hives/v1/'));
|
|
49
|
+
return paths.map((p) => ({ id, origin: base.origin, method: direct ? op.upstream.method : op.method, pattern: templatePattern(prefix + p) }));
|
|
50
|
+
});
|
|
51
|
+
return (async (input, init = {}) => {
|
|
52
|
+
let url;
|
|
53
|
+
try {
|
|
54
|
+
url = new URL(input instanceof Request ? input.url : String(input));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error('Invalid Hives request address.');
|
|
58
|
+
}
|
|
59
|
+
const method = (init.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
|
60
|
+
const route = routes.find((entry) => entry.origin === url.origin && entry.method === method && entry.pattern.test(url.pathname));
|
|
61
|
+
if (!route)
|
|
62
|
+
return underlying(input, init);
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
let message = 'Hives transport failed. Refresh the resource and retry.';
|
|
65
|
+
const safeError = () => new Error(message);
|
|
66
|
+
const signals = [...new Set([callerSignal, init.signal, input instanceof Request ? input.signal : undefined].filter((signal) => !!signal))];
|
|
67
|
+
const canceled = () => {
|
|
68
|
+
message = 'Hives request canceled before completion. Refresh the resource before retrying.';
|
|
69
|
+
controller.abort();
|
|
70
|
+
};
|
|
71
|
+
for (const signal of signals)
|
|
72
|
+
signal.addEventListener('abort', canceled, { once: true });
|
|
73
|
+
const timer = setTimeout(() => {
|
|
74
|
+
message = 'Hives request deadline exceeded. Refresh the resource before retrying.';
|
|
75
|
+
controller.abort();
|
|
76
|
+
}, hivesOperationBudget(route.id));
|
|
77
|
+
timer.unref();
|
|
78
|
+
let abortReject = () => { };
|
|
79
|
+
let reader;
|
|
80
|
+
let bodyController;
|
|
81
|
+
const aborted = () => {
|
|
82
|
+
abortReject(safeError());
|
|
83
|
+
bodyController?.error(safeError());
|
|
84
|
+
void reader?.cancel().catch(() => { });
|
|
85
|
+
cleanup();
|
|
86
|
+
};
|
|
87
|
+
controller.signal.addEventListener('abort', aborted, { once: true });
|
|
88
|
+
const abortPromise = new Promise((_, reject) => { abortReject = reject; });
|
|
89
|
+
void abortPromise.catch(() => { });
|
|
90
|
+
const cleanup = () => {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
for (const signal of signals)
|
|
93
|
+
signal.removeEventListener('abort', canceled);
|
|
94
|
+
controller.signal.removeEventListener('abort', aborted);
|
|
95
|
+
};
|
|
96
|
+
if (signals.some((signal) => signal.aborted))
|
|
97
|
+
canceled();
|
|
98
|
+
try {
|
|
99
|
+
if (controller.signal.aborted)
|
|
100
|
+
throw safeError();
|
|
101
|
+
const requestInit = {
|
|
102
|
+
...init, signal: controller.signal, cache: 'no-store',
|
|
103
|
+
redirect: init.redirect === 'manual' ? 'manual' : 'error',
|
|
104
|
+
...(useNative ? { dispatcher: nativeAgent() } : {}),
|
|
105
|
+
};
|
|
106
|
+
const pending = underlying(input, requestInit);
|
|
107
|
+
// A caller transport may ignore AbortSignal. Reject on our budget anyway,
|
|
108
|
+
// and discard any late response rather than presenting an acknowledgement.
|
|
109
|
+
void pending.then((response) => { if (controller.signal.aborted)
|
|
110
|
+
void response.body?.cancel().catch(() => { }); }, () => { });
|
|
111
|
+
const response = await Promise.race([pending, abortPromise]);
|
|
112
|
+
if (controller.signal.aborted)
|
|
113
|
+
throw safeError();
|
|
114
|
+
if (!response.body) {
|
|
115
|
+
cleanup();
|
|
116
|
+
return response;
|
|
117
|
+
}
|
|
118
|
+
reader = response.body.getReader();
|
|
119
|
+
const body = new ReadableStream({
|
|
120
|
+
start(value) { bodyController = value; },
|
|
121
|
+
async pull(value) {
|
|
122
|
+
try {
|
|
123
|
+
const next = await reader.read();
|
|
124
|
+
if (controller.signal.aborted)
|
|
125
|
+
return;
|
|
126
|
+
if (next.done) {
|
|
127
|
+
cleanup();
|
|
128
|
+
value.close();
|
|
129
|
+
}
|
|
130
|
+
else
|
|
131
|
+
value.enqueue(next.value);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
cleanup();
|
|
135
|
+
value.error(safeError());
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
async cancel() { cleanup(); await reader.cancel().catch(() => { }); },
|
|
139
|
+
});
|
|
140
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
cleanup();
|
|
144
|
+
throw safeError();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import type { Genesis } from '@lifesi/genesis';
|
|
3
|
+
type Deps = {
|
|
4
|
+
client: () => Promise<Genesis>;
|
|
5
|
+
emit: (data: unknown, pretty: () => string) => void;
|
|
6
|
+
readRequest?: (path: string, limit: number) => Promise<Record<string, unknown>>;
|
|
7
|
+
};
|
|
8
|
+
export declare function registerHivesOperations(hives: Command, deps: Deps): void;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { registerHivesResources } from './hives-resources.js';
|
|
2
|
+
import { Option } from 'commander';
|
|
3
|
+
import { readFunctionJson } from './function-input.js';
|
|
4
|
+
function metricsOptions(command) {
|
|
5
|
+
return command.option('--start <time>', 'inclusive UTC timestamp').option('--end <time>', 'exclusive UTC timestamp')
|
|
6
|
+
.addOption(new Option('--resolution <value>', 'time bucket').choices(['minute', 'fiveminutes', 'fifteenminutes', 'hour', 'day']))
|
|
7
|
+
.addOption(new Option('--aggregation <value>', 'supported aggregation').choices(['sum', 'avg', 'min', 'max', 'count', 'p50', 'p90', 'p95', 'p99']));
|
|
8
|
+
}
|
|
9
|
+
const integer = (value, name, max) => {
|
|
10
|
+
if (value === undefined)
|
|
11
|
+
return undefined;
|
|
12
|
+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1 || Number(value) > max) {
|
|
13
|
+
throw new Error(`${name} must be an integer from 1 to ${max}.`);
|
|
14
|
+
}
|
|
15
|
+
return Number(value);
|
|
16
|
+
};
|
|
17
|
+
const paging = (command) => command.option('--page <number>').option('--per-page <number>');
|
|
18
|
+
const pagination = (o) => ({ page: integer(o.page, '--page', 10000), per_page: integer(o.perPage, '--per-page', 100) });
|
|
19
|
+
export function registerHivesOperations(hives, deps) {
|
|
20
|
+
registerHivesResources(hives, deps);
|
|
21
|
+
const output = (data) => deps.emit(data, () => JSON.stringify(data, null, 2));
|
|
22
|
+
const read = deps.readRequest ?? readFunctionJson;
|
|
23
|
+
const settings = hives.command('settings').description('Stored project preferences and actual engine capabilities');
|
|
24
|
+
settings.command('get <project_id>').action(async (project) => output(await (await deps.client()).hives.settings.get(project)));
|
|
25
|
+
settings.command('update <project_id>').requiredOption('--request-file <path>', 'JSON settings patch')
|
|
26
|
+
.action(async (project, o) => output(await (await deps.client()).hives.settings.update(project, await read(o.requestFile, 64 * 1024))));
|
|
27
|
+
const crons = hives.command('crons').description('Waggle schedules and persisted run outcomes');
|
|
28
|
+
paging(crons.command('list <project_id>')).action(async (project, o) => output(await (await deps.client()).hives.crons.list(project, pagination(o))));
|
|
29
|
+
crons.command('get <project_id> <cron_id>').action(async (project, cron) => output(await (await deps.client()).hives.crons.get(project, cron)));
|
|
30
|
+
crons.command('create <project_id>').requiredOption('--request-file <path>', 'JSON name, schedule, handler, timezone and enabled preference')
|
|
31
|
+
.action(async (project, o) => {
|
|
32
|
+
const body = await read(o.requestFile, 64 * 1024);
|
|
33
|
+
if (typeof body.name !== 'string' || typeof body.schedule !== 'string' || typeof body.handler !== 'string')
|
|
34
|
+
throw new Error('Cron JSON requires name, schedule and handler strings.');
|
|
35
|
+
output(await (await deps.client()).hives.crons.create(project, { ...body, name: body.name, schedule: body.schedule, handler: body.handler }));
|
|
36
|
+
});
|
|
37
|
+
crons.command('update <project_id> <cron_id>').requiredOption('--request-file <path>', 'JSON schedule patch')
|
|
38
|
+
.action(async (project, cron, o) => output(await (await deps.client()).hives.crons.update(project, cron, await read(o.requestFile, 64 * 1024))));
|
|
39
|
+
for (const action of ['delete', 'pause', 'resume', 'trigger']) {
|
|
40
|
+
crons.command(`${action} <project_id> <cron_id>`)
|
|
41
|
+
.description(action === 'trigger' ? 'Admit one run; inspect runs for completion. Does not retry.' : `${action} an owned schedule`)
|
|
42
|
+
.action(async (project, cron) => output(await (await deps.client()).hives.crons[action](project, cron)));
|
|
43
|
+
}
|
|
44
|
+
paging(crons.command('runs <project_id> <cron_id>')).addOption(new Option('--status <state>', 'persisted run status').choices(['pending', 'running', 'success', 'failed', 'timeout', 'cancelled']))
|
|
45
|
+
.option('--since <time>').option('--until <time>')
|
|
46
|
+
.action(async (project, cron, o) => output(await (await deps.client()).hives.crons.runs(project, cron, { ...pagination(o), status: o.status, since: o.since, until: o.until })));
|
|
47
|
+
const metrics = hives.command('metrics').description('Retained completed API observations; unavailable measurements remain null');
|
|
48
|
+
for (const kind of ['project', 'requests', 'latency', 'errors', 'resources']) {
|
|
49
|
+
metricsOptions(metrics.command(`${kind} <project_id>`))
|
|
50
|
+
.action(async (project, q) => output(await (await deps.client()).hives.metrics[kind](project, q)));
|
|
51
|
+
}
|
|
52
|
+
metrics.command('dashboard <project_id>').addOption(new Option('--period <value>').choices(['1h', '24h', '7d', '30d']))
|
|
53
|
+
.action(async (project, q) => output(await (await deps.client()).hives.metrics.dashboard(project, q)));
|
|
54
|
+
for (const kind of ['function', 'invocations']) {
|
|
55
|
+
metricsOptions(metrics.command(`${kind} <project_id> <function_id>`))
|
|
56
|
+
.action(async (project, id, q) => output(await (await deps.client()).hives.metrics[kind](project, id, q)));
|
|
57
|
+
}
|
|
58
|
+
metricsOptions(metrics.command('deployment <project_id> <deployment_id>'))
|
|
59
|
+
.action(async (project, id, q) => output(await (await deps.client()).hives.metrics.deployment(project, id, q)));
|
|
60
|
+
metrics.command('realtime <project_id>').action(async (project) => output(await (await deps.client()).hives.metrics.realtime(project)));
|
|
61
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import type { Genesis } from '@lifesi/genesis';
|
|
3
|
+
type Deps = {
|
|
4
|
+
client: () => Promise<Genesis>;
|
|
5
|
+
emit: (data: unknown, pretty: () => string) => void;
|
|
6
|
+
readRequest?: (path: string, limit: number) => Promise<Record<string, unknown>>;
|
|
7
|
+
};
|
|
8
|
+
export declare function registerHivesResources(hives: Command, deps: Deps): void;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { readFunctionJson } from './function-input.js';
|
|
4
|
+
async function bytes(path) {
|
|
5
|
+
const metadata = await stat(path);
|
|
6
|
+
if (!metadata.isFile() || metadata.size > 10 * 1024 * 1024)
|
|
7
|
+
throw new Error('Choose a regular file of at most 10 MiB.');
|
|
8
|
+
const stream = createReadStream(path);
|
|
9
|
+
const chunks = [];
|
|
10
|
+
let total = 0;
|
|
11
|
+
try {
|
|
12
|
+
for await (const chunk of stream) {
|
|
13
|
+
const value = Buffer.from(chunk);
|
|
14
|
+
total += value.byteLength;
|
|
15
|
+
if (total > 10 * 1024 * 1024)
|
|
16
|
+
throw new Error('Object file exceeds 10 MiB.');
|
|
17
|
+
chunks.push(value);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
stream.destroy();
|
|
22
|
+
}
|
|
23
|
+
return Uint8Array.from(Buffer.concat(chunks, total));
|
|
24
|
+
}
|
|
25
|
+
function number(value, min, max) { if (value === undefined)
|
|
26
|
+
return undefined; if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < min || Number(value) > max)
|
|
27
|
+
throw new Error('Invalid bounded numeric option.'); return Number(value); }
|
|
28
|
+
export function registerHivesResources(hives, deps) {
|
|
29
|
+
const output = (data) => deps.emit(data, () => JSON.stringify(data, null, 2));
|
|
30
|
+
const read = deps.readRequest ?? readFunctionJson;
|
|
31
|
+
const cells = hives.command('cells').description('Owned GCS buckets and objects; uncertain mutations require readback/reconciliation');
|
|
32
|
+
cells.command('list <project_id>').option('--page <number>').option('--per-page <number>').action(async (project, o) => output(await (await deps.client()).hives.cells.list(project, { page: number(o.page, 1, 10000), per_page: number(o.perPage, 1, 100) })));
|
|
33
|
+
cells.command('get <project_id> <bucket_id>').action(async (project, id) => output(await (await deps.client()).hives.cells.get(project, id)));
|
|
34
|
+
cells.command('create <project_id>').requiredOption('--request-file <path>', 'JSON name, region, visibility and CORS').action(async (project, o) => output(await (await deps.client()).hives.cells.create(project, await read(o.requestFile, 512 * 1024))));
|
|
35
|
+
for (const action of ['remove', 'reconcile'])
|
|
36
|
+
cells.command(`${action} <project_id> <bucket_id>`).action(async (project, id) => output(await (await deps.client()).hives.cells[action](project, id)));
|
|
37
|
+
const cors = cells.command('cors');
|
|
38
|
+
cors.command('get <project_id> <bucket_id>').action(async (project, id) => output(await (await deps.client()).hives.cells.cors.get(project, id)));
|
|
39
|
+
cors.command('set <project_id> <bucket_id>').requiredOption('--request-file <path>', 'JSON object with rules array').action(async (project, id, o) => { const body = await read(o.requestFile, 512 * 1024); if (!Array.isArray(body.rules))
|
|
40
|
+
throw new Error('CORS JSON requires rules.'); output(await (await deps.client()).hives.cells.cors.set(project, id, body.rules)); });
|
|
41
|
+
const objects = cells.command('objects');
|
|
42
|
+
objects.command('list <project_id> <bucket_id>').option('--prefix <value>').option('--delimiter <value>').option('--continuation-token <value>').option('--max-keys <number>').action(async (project, id, o) => output(await (await deps.client()).hives.cells.objects.list(project, id, { prefix: o.prefix, delimiter: o.delimiter, continuation_token: o.continuationToken, max_keys: number(o.maxKeys, 1, 1000) })));
|
|
43
|
+
for (const action of ['put', 'put-flat'])
|
|
44
|
+
objects.command(`${action} <project_id> <bucket_id> <key>`).requiredOption('--file <path>', 'Exact binary bytes up to 10 MiB').option('--content-type <value>').action(async (project, id, key, o) => { const client = await deps.client(); output(await client.hives.cells.objects[action === 'put' ? 'put' : 'putFlat'](project, id, key, await bytes(o.file), { content_type: o.contentType })); });
|
|
45
|
+
for (const action of ['remove', 'remove-flat'])
|
|
46
|
+
objects.command(`${action} <project_id> <bucket_id> <key>`).action(async (project, id, key) => { await (await deps.client()).hives.cells.objects[action === 'remove' ? 'remove' : 'removeFlat'](project, id, key); output({ acknowledged: true }); });
|
|
47
|
+
objects.command('download <project_id> <bucket_id> <key>').requiredOption('--output-file <path>', 'Create a new file; existing files are never overwritten').action(async (project, id, key, o) => { const response = await (await deps.client()).hives.cells.objects.download(project, id, key); const data = new Uint8Array(await response.arrayBuffer()); await writeFile(o.outputFile, data, { flag: 'wx', mode: 0o600 }); output({ saved: true, bytes: data.byteLength }); });
|
|
48
|
+
cells.command('presign <project_id> <bucket_id>').requiredOption('--request-file <path>', 'Explicit GET/PUT capability request; PUT requires exact content_length').action(async (project, id, o) => output(await (await deps.client()).hives.cells.presign(project, id, await read(o.requestFile, 64 * 1024))));
|
|
49
|
+
const queues = hives.command('queues').description('Buzz durable messages and possessed temporary delivery receipts');
|
|
50
|
+
for (const action of ['list'])
|
|
51
|
+
queues.command(`${action} <project_id>`).action(async (project) => output(await (await deps.client()).hives.queues[action](project)));
|
|
52
|
+
for (const action of ['get', 'stats', 'consumers', 'pause', 'resume', 'purge', 'remove', 'dead-letter-stats', 'purge-dead-letters']) {
|
|
53
|
+
const names = { 'dead-letter-stats': 'deadLetterStats', 'purge-dead-letters': 'purgeDeadLetters' };
|
|
54
|
+
queues.command(`${action} <project_id> <queue_id>`).action(async (project, id) => { const client = await deps.client(); const method = action in names ? names[action] : action; output(await client.hives.queues[method](project, id)); });
|
|
55
|
+
}
|
|
56
|
+
queues.command('create <project_id>').requiredOption('--request-file <path>').action(async (project, o) => output(await (await deps.client()).hives.queues.create(project, await read(o.requestFile, 512 * 1024))));
|
|
57
|
+
queues.command('configure <project_id> <queue_id>').requiredOption('--request-file <path>').action(async (project, id, o) => output(await (await deps.client()).hives.queues.configure(project, id, await read(o.requestFile, 512 * 1024))));
|
|
58
|
+
queues.command('send <project_id> <queue_id>').requiredOption('--request-file <path>', 'Exactly body or body_base64; decoded body 256 KiB').action(async (project, id, o) => output(await (await deps.client()).hives.queues.send(project, id, await read(o.requestFile, 512 * 1024))));
|
|
59
|
+
queues.command('receive <project_id> <queue_id>').option('--max-messages <number>').option('--wait-seconds <number>').action(async (project, id, o) => output(await (await deps.client()).hives.queues.receive(project, id, { maxMessages: number(o.maxMessages, 1, 10), waitSeconds: number(o.waitSeconds, 0, 10) })));
|
|
60
|
+
for (const action of ['ack', 'nack'])
|
|
61
|
+
queues.command(`${action} <project_id> <queue_id> <message_id>`).requiredOption('--receipt-file <path>', 'JSON receiptHandle; not a credential/header/query').action(async (project, id, message, o) => { const body = await read(o.receiptFile, 4096); if (typeof body.receiptHandle !== 'string')
|
|
62
|
+
throw new Error('Receipt JSON requires receiptHandle.'); output(await (await deps.client()).hives.queues[action](project, id, message, body.receiptHandle)); });
|
|
63
|
+
queues.command('dead-letters <project_id> <queue_id>').option('--limit <number>').option('--offset <number>').action(async (project, id, o) => output(await (await deps.client()).hives.queues.deadLetters(project, id, { limit: number(o.limit, 1, 10), offset: number(o.offset, 0, 8192) })));
|
|
64
|
+
for (const action of ['replay', 'delete-dead-letter'])
|
|
65
|
+
queues.command(`${action} <project_id> <queue_id> <message_id>`).action(async (project, id, message) => output(await (await deps.client()).hives.queues[action === 'replay' ? 'replay' : 'deleteDeadLetter'](project, id, message)));
|
|
66
|
+
const runtime = hives.command('runtimes').description('Compiled serving implementation metadata; not available capacity');
|
|
67
|
+
runtime.command('list').action(async () => output(await (await deps.client()).hives.runtimes.list()));
|
|
68
|
+
runtime.command('project <project_id>').action(async (project) => output(await (await deps.client()).hives.runtimes.project(project)));
|
|
69
|
+
runtime.command('get <name>').action(async (name) => output(await (await deps.client()).hives.runtimes.get(name)));
|
|
70
|
+
runtime.command('select').requiredOption('--wasi <value>').requiredOption('--runner <value>').requiredOption('--artifact <value>').action(async (input) => output(await (await deps.client()).hives.runtimes.select(input)));
|
|
71
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { type CredentialStore } from './store.js';
|
|
3
3
|
import { type Out } from './output.js';
|
|
4
|
+
import { type SecretInputOptions } from './secret-input.js';
|
|
4
5
|
export interface Deps {
|
|
5
6
|
out?: Out;
|
|
6
7
|
store?: () => Promise<CredentialStore>;
|
|
7
8
|
fetch?: typeof fetch;
|
|
9
|
+
/** Cancel Hives calls through response-body completion; other products retain their transport. */
|
|
10
|
+
signal?: AbortSignal;
|
|
8
11
|
env?: Record<string, string | undefined>;
|
|
9
12
|
openBrowser?: (url: string) => void;
|
|
10
13
|
sleep?: (ms: number) => Promise<void>;
|
|
14
|
+
readSecretInput?: (options: SecretInputOptions, limit: number) => Promise<string>;
|
|
15
|
+
readWasmFile?: (path: string) => Promise<Blob>;
|
|
16
|
+
writeBytes?: (bytes: Uint8Array) => void;
|
|
17
|
+
writeOutputFile?: (path: string, bytes: Uint8Array) => Promise<void>;
|
|
18
|
+
readHivesRequest?: (path: string, limit: number) => Promise<Record<string, unknown>>;
|
|
11
19
|
}
|
|
12
20
|
export declare function buildProgram(deps?: Deps): Command;
|
|
13
21
|
export declare function main(argv: string[], deps?: Deps): Promise<number>;
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { registerBeacons } from './beacons.js';
|
|
2
2
|
import { registerDials } from './dials.js';
|
|
3
3
|
import { Command, CommanderError } from 'commander';
|
|
4
|
-
import { Genesis, GenesisError, services } from '@lifesi/genesis';
|
|
4
|
+
import { Genesis, GenesisError, hivesResponseBytes, MAX_FUNCTION_BODY_BYTES, MAX_FUNCTION_JSON_BYTES, services } from '@lifesi/genesis';
|
|
5
5
|
import { authConfig, pollDevice, refresh, savedLoginProvider, startDevice } from './auth.js';
|
|
6
6
|
import { keychainStore } from './store.js';
|
|
7
7
|
import { c, listOf, stdio, table } from './output.js';
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
9
|
import { addHenryCommands } from './henry.js';
|
|
10
10
|
import { registerFleets } from './fleets.js';
|
|
11
|
+
import { registerFlocks } from './flocks.js';
|
|
11
12
|
import { registerRunner } from './runner.js';
|
|
13
|
+
import { createHivesNodeTransport } from './hives-node-transport.js';
|
|
14
|
+
import { registerHivesOperations } from './hives-operations.js';
|
|
15
|
+
import { readFunctionFile, readFunctionJson, writeFunctionOutput } from './function-input.js';
|
|
16
|
+
import { basename } from 'node:path';
|
|
17
|
+
import { readWasmFile } from './wasm-input.js';
|
|
18
|
+
import { readSecretInput, validateSecret, validateSecretValue, parseSecretBatch } from './secret-input.js';
|
|
12
19
|
function defaultOpen(url) {
|
|
13
20
|
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
14
21
|
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
@@ -25,7 +32,7 @@ export function buildProgram(deps = {}) {
|
|
|
25
32
|
const auth = { ...authConfig(env, f), sleep: deps.sleep };
|
|
26
33
|
const program = new Command('genesis')
|
|
27
34
|
.description('One CLI for every Genesis service.')
|
|
28
|
-
.version('0.1.
|
|
35
|
+
.version('0.1.2')
|
|
29
36
|
.option('--json', 'machine-readable JSON output')
|
|
30
37
|
.option('--api-key <key>', 'use this API key (default: GENESIS_API_KEY, then your `genesis login`)')
|
|
31
38
|
.option('--base-url <url>', 'gateway URL (default: GENESIS_BASE_URL or https://console.life.one/api)')
|
|
@@ -35,18 +42,32 @@ export function buildProgram(deps = {}) {
|
|
|
35
42
|
.exitOverride()
|
|
36
43
|
.configureOutput({ writeOut: (s) => out.log(s.trimEnd()), writeErr: (s) => out.err(s.trimEnd()) });
|
|
37
44
|
const g = () => program.opts();
|
|
38
|
-
const client = async () => {
|
|
45
|
+
const client = async (hivesTransport = false) => {
|
|
39
46
|
const o = g();
|
|
40
47
|
const store = await getStore().catch(() => undefined);
|
|
41
48
|
const opts = {
|
|
42
49
|
apiKey: o.apiKey ?? env.GENESIS_API_KEY, token: env.GENESIS_TOKEN,
|
|
43
50
|
baseUrl: o.baseUrl ?? env.GENESIS_BASE_URL, routing: o.direct || env.GENESIS_ROUTING === 'direct' ? 'direct' : 'gateway',
|
|
44
|
-
fetch: f, userAgent: 'genesis-cli/0.1.
|
|
51
|
+
fetch: f, userAgent: 'genesis-cli/0.1.2',
|
|
45
52
|
credentials: store ? savedLoginProvider(auth, store) : undefined,
|
|
46
53
|
};
|
|
47
|
-
return new Genesis(opts);
|
|
54
|
+
return new Genesis(hivesTransport ? { ...opts, fetch: createHivesNodeTransport(opts, deps.signal) } : opts);
|
|
48
55
|
};
|
|
49
56
|
const emit = (data, pretty) => out.log(g().json ? JSON.stringify(data, null, 2) : pretty());
|
|
57
|
+
const positiveInteger = (value, option, maximum) => {
|
|
58
|
+
if (value === undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1 || Number(value) > maximum) {
|
|
61
|
+
throw new Error(`${option} must be an integer from 1 to ${maximum}.`);
|
|
62
|
+
}
|
|
63
|
+
return Number(value);
|
|
64
|
+
};
|
|
65
|
+
const projectName = (name) => {
|
|
66
|
+
if (name.length < 3 || name.length > 64 || !/^[a-z][a-z0-9-]*[a-z0-9]$/.test(name)) {
|
|
67
|
+
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.');
|
|
68
|
+
}
|
|
69
|
+
return name;
|
|
70
|
+
};
|
|
50
71
|
registerRunner(program, client, emit);
|
|
51
72
|
const orgId = async (gen) => {
|
|
52
73
|
if (g().org)
|
|
@@ -156,17 +177,253 @@ export function buildProgram(deps = {}) {
|
|
|
156
177
|
const r = await gen.foundry.chat({ model: o.model, messages });
|
|
157
178
|
emit(r, () => r.choices?.[0]?.message?.content ?? JSON.stringify(r));
|
|
158
179
|
});
|
|
180
|
+
const hivesClient = () => client(true);
|
|
159
181
|
// ---- hives -----------------------------------------------------------
|
|
160
|
-
const hives = program.command('hives').description('Hives: projects, deployments, functions');
|
|
182
|
+
const hives = program.command('hives').description('Hives: projects, secrets, deployments, functions, schedules and activity');
|
|
183
|
+
registerHivesOperations(hives, { client: hivesClient, emit, readRequest: deps.readHivesRequest });
|
|
184
|
+
const deployments = hives.command('deployments').description('Real WASM artifacts and deployment lifecycle');
|
|
185
|
+
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']));
|
|
186
|
+
deployments.command('list <project_id>')
|
|
187
|
+
.option('--page <number>', 'page (1–10000)').option('--per-page <number>', 'items (1–100)')
|
|
188
|
+
.option('--status <status>', 'persisted deployment status').option('--author <id>', 'creator ID')
|
|
189
|
+
.action(async (project, o) => {
|
|
190
|
+
emitDeployment(await (await hivesClient()).hives.deployments.list(project, {
|
|
191
|
+
page: positiveInteger(o.page, '--page', 10000), per_page: positiveInteger(o.perPage, '--per-page', 100), status: o.status, author: o.author,
|
|
192
|
+
}));
|
|
193
|
+
});
|
|
194
|
+
deployments.command('get <project_id> <deployment_id>')
|
|
195
|
+
.action(async (project, deployment) => emitDeployment(await (await hivesClient()).hives.deployments.get(project, deployment)));
|
|
196
|
+
deployments.command('upload <project_id> <wasm_file>').description('Upload actual WASM bytes; Git options are labels only')
|
|
197
|
+
.option('--git-sha <sha>', 'commit label (7–40 characters)').option('--git-branch <branch>', 'branch label').option('--git-message <message>', 'commit label')
|
|
198
|
+
.action(async (project, path, o) => {
|
|
199
|
+
const file = await (deps.readWasmFile ?? readWasmFile)(path);
|
|
200
|
+
emitDeployment(await (await hivesClient()).hives.deployments.upload(project, file, {
|
|
201
|
+
filename: basename(path), git_sha: o.gitSha, git_branch: o.gitBranch, git_message: o.gitMessage,
|
|
202
|
+
}));
|
|
203
|
+
});
|
|
204
|
+
deployments.command('reuse <project_id> <module_hash>').description('Deploy a real artifact already owned by this org and project')
|
|
205
|
+
.option('--no-auto-promote', 'keep the completed deployment ready for explicit promotion')
|
|
206
|
+
.action(async (project, module_hash, o) => {
|
|
207
|
+
if (!/^[a-f0-9]{64}$/.test(module_hash))
|
|
208
|
+
throw new Error('module_hash must be the lowercase SHA-256 returned by a real Hives upload.');
|
|
209
|
+
emitDeployment(await (await hivesClient()).hives.deployments.create(project, { module_hash, auto_promote: o.autoPromote }));
|
|
210
|
+
});
|
|
211
|
+
deployments.command('promote <project_id> <deployment_id>')
|
|
212
|
+
.action(async (project, deployment) => emitDeployment(await (await hivesClient()).hives.deployments.promote(project, deployment)));
|
|
213
|
+
deployments.command('rollback <project_id> <deployment_id>').description('Activate this owned previous deployment')
|
|
214
|
+
.option('--reason <text>', 'audit reason')
|
|
215
|
+
.action(async (project, deployment, o) => emitDeployment(await (await hivesClient()).hives.deployments.rollback(project, deployment, { reason: o.reason })));
|
|
216
|
+
deployments.command('cancel <project_id> <deployment_id>')
|
|
217
|
+
.action(async (project, deployment) => emitDeployment(await (await hivesClient()).hives.deployments.cancel(project, deployment)));
|
|
218
|
+
deployments.command('delete <project_id> <deployment_id>').description('Delete a deployment that is not currently active')
|
|
219
|
+
.action(async (project, deployment) => { const result = await (await hivesClient()).hives.deployments.delete(project, deployment); emit(result, () => `Deleted deployment ${deployment}.`); });
|
|
220
|
+
deployments.command('logs <project_id> <deployment_id>').description('Read real persisted deployment logs')
|
|
221
|
+
.option('--page <number>', 'page (1–10000)').option('--per-page <number>', 'items (1–100)')
|
|
222
|
+
.option('--level <level>', 'minimum level').option('--search <text>', 'search log messages')
|
|
223
|
+
.option('--start <iso>', 'RFC 3339 start time').option('--end <iso>', 'RFC 3339 end time')
|
|
224
|
+
.action(async (project, deployment, o) => {
|
|
225
|
+
for (const timestamp of [o.start, o.end])
|
|
226
|
+
if (timestamp !== undefined && !Number.isFinite(Date.parse(timestamp)))
|
|
227
|
+
throw new Error('Log timestamps must use RFC 3339.');
|
|
228
|
+
const result = await (await hivesClient()).hives.deployments.logs(project, deployment, {
|
|
229
|
+
page: positiveInteger(o.page, '--page', 10000), per_page: positiveInteger(o.perPage, '--per-page', 100),
|
|
230
|
+
level: o.level, search: o.search, start: o.start, end: o.end,
|
|
231
|
+
});
|
|
232
|
+
emit(result, () => table(result.logs, ['timestamp', 'level', 'message', 'source']));
|
|
233
|
+
});
|
|
234
|
+
const emitFunction = (value) => emit(value, () => table(listOf(value).length ? listOf(value) : [value], ['id', 'name', 'status', 'handler', 'memory_mb', 'timeout_ms']));
|
|
235
|
+
const functions = hives.command('functions').description('Owned WASM functions; invocation returns the completed raw guest response');
|
|
236
|
+
functions.command('list <project_id>').option('--page <n>').option('--per-page <n>').option('--status <status>').option('--search <text>')
|
|
237
|
+
.action(async (project, o) => emitFunction(await (await hivesClient()).hives.functions.list(project, {
|
|
238
|
+
page: positiveInteger(o.page, '--page', 1000000), per_page: positiveInteger(o.perPage, '--per-page', 100), status: o.status, search: o.search,
|
|
239
|
+
})));
|
|
240
|
+
functions.command('get <project_id> <function_id>').action(async (project, id) => emitFunction(await (await hivesClient()).hives.functions.get(project, id)));
|
|
241
|
+
functions.command('create <project_id> <name> <module_hash>').option('--request-file <file>', 'JSON metadata and encrypted env overrides; input values are never printed')
|
|
242
|
+
.action(async (project, name, hash, o) => {
|
|
243
|
+
if (!/^[a-f0-9]{64}$/.test(hash))
|
|
244
|
+
throw new Error('Use a full lowercase artifact hash from an owned Hives upload.');
|
|
245
|
+
const extra = o.requestFile ? await readFunctionJson(o.requestFile, 256 * 1024) : {};
|
|
246
|
+
emitFunction(await (await hivesClient()).hives.functions.create(project, { ...extra, name, handler: `wasm://sha256/${hash}` }));
|
|
247
|
+
});
|
|
248
|
+
functions.command('update <project_id> <function_id>').requiredOption('--request-file <file>', 'JSON supported metadata/env/remove_env fields; values are never printed')
|
|
249
|
+
.action(async (project, id, o) => emitFunction(await (await hivesClient()).hives.functions.update(project, id, await readFunctionJson(o.requestFile, 256 * 1024))));
|
|
250
|
+
functions.command('delete <project_id> <function_id>').action(async (project, id) => {
|
|
251
|
+
const result = await (await hivesClient()).hives.functions.delete(project, id);
|
|
252
|
+
emit(result, () => `Deleted function ${id}.`);
|
|
253
|
+
});
|
|
254
|
+
function invocationCommand(command, kind) {
|
|
255
|
+
command.description('Complete real guest execution; write exact bytes, including executed HTTP error bodies')
|
|
256
|
+
.option('--request-file <file>', 'JSON method/path/headers/body or body_base64')
|
|
257
|
+
.option('--body-file <file>', 'Exact binary body (up to 10 MiB; serialized guest envelope may reach its cap earlier)')
|
|
258
|
+
.option('--method <method>', 'guest HTTP method', 'POST').option('--path <path>', 'guest path including query', '/')
|
|
259
|
+
.option('--output <file>', 'create a new private file containing exact response bytes')
|
|
260
|
+
.action(async (project, id, o) => {
|
|
261
|
+
if (o.requestFile && o.bodyFile)
|
|
262
|
+
throw new Error('Choose --request-file or --body-file.');
|
|
263
|
+
if (g().json && o.output)
|
|
264
|
+
throw new Error('Choose --json or --output.');
|
|
265
|
+
let request = o.requestFile ? await readFunctionJson(o.requestFile, MAX_FUNCTION_JSON_BYTES) : { method: o.method, path: o.path };
|
|
266
|
+
if (o.bodyFile)
|
|
267
|
+
request.body_base64 = Buffer.from(await readFunctionFile(o.bodyFile, MAX_FUNCTION_BODY_BYTES)).toString('base64');
|
|
268
|
+
const response = await (await hivesClient()).hives[kind].invoke(project, id, request);
|
|
269
|
+
const bytes = await hivesResponseBytes(response);
|
|
270
|
+
if (g().json)
|
|
271
|
+
out.log(JSON.stringify({ status: response.status, headers: Object.fromEntries(response.headers), body_base64: Buffer.from(bytes).toString('base64') }));
|
|
272
|
+
else {
|
|
273
|
+
out.err(JSON.stringify({ status: response.status, headers: Object.fromEntries(response.headers), bytes: bytes.length }));
|
|
274
|
+
if (o.output)
|
|
275
|
+
await (deps.writeOutputFile ?? writeFunctionOutput)(o.output, bytes);
|
|
276
|
+
else
|
|
277
|
+
(deps.writeBytes ?? ((value) => { process.stdout.write(value); }))(bytes);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
invocationCommand(functions.command('invoke <project_id> <function_id>'), 'functions');
|
|
282
|
+
invocationCommand(deployments.command('invoke <project_id> <deployment_id>'), 'deployments');
|
|
283
|
+
const secrets = hives.command('secrets').description('Project secrets encrypted with Genesis KMS');
|
|
284
|
+
const readInput = async (options, limit) => {
|
|
285
|
+
if (Boolean(options.file) === Boolean(options.stdin))
|
|
286
|
+
throw new Error('Choose exactly one input source: a file or --stdin. Secret values are not accepted as command arguments.');
|
|
287
|
+
return (deps.readSecretInput ?? readSecretInput)(options, limit);
|
|
288
|
+
};
|
|
289
|
+
const emitSecret = (data) => emit(data, () => table(listOf(data), ['id', 'name', 'version', 'description', 'updated_at']));
|
|
290
|
+
const context = hives.command('context').description('Explicit Hives binding for the current verified interactive Genesis selection');
|
|
291
|
+
context.command('get').action(async () => {
|
|
292
|
+
const r = await (await hivesClient()).hives.context.get();
|
|
293
|
+
emit(r, () => table([r.project], ['id', 'name', 'slug', 'kind', 'status']));
|
|
294
|
+
});
|
|
295
|
+
context.command('create <name>').option('--kind <kind>', 'worker|function|site|api', 'worker').option('--description <text>', 'project description')
|
|
296
|
+
.action(async (name, o) => {
|
|
297
|
+
if (!['worker', 'function', 'site', 'api'].includes(o.kind))
|
|
298
|
+
throw new Error('Choose worker, function, site or api.');
|
|
299
|
+
if (o.description && o.description.length > 500)
|
|
300
|
+
throw new Error('Project descriptions must contain at most 500 characters.');
|
|
301
|
+
const r = await (await hivesClient()).hives.context.set({ create: { name: projectName(name), kind: o.kind, description: o.description } });
|
|
302
|
+
emit(r, () => table([r.project], ['id', 'name', 'slug', 'kind', 'status']));
|
|
303
|
+
});
|
|
304
|
+
context.command('link <project_id>').description('Link a Hives project owned by the verified org')
|
|
305
|
+
.action(async (id) => { const r = await (await hivesClient()).hives.context.set({ link: { project_id: id } }); emit(r, () => table([r.project], ['id', 'name', 'slug', 'kind', 'status'])); });
|
|
306
|
+
context.command('detach').description('Remove this selection binding; retain the Hives project and its resources')
|
|
307
|
+
.action(async () => { const r = await (await hivesClient()).hives.context.detach(); emit(r, () => 'Detached selection; retained Hives project ' + r.hivesProjectId + '.'); });
|
|
308
|
+
const environment = hives.command('environment').description('Encrypted project environment; names-only metadata and explicit KMS reveal');
|
|
309
|
+
environment.command('list <project_id>').option('--page <number>', 'page number (1–10000)').option('--per-page <number>', 'items per page (1–100)')
|
|
310
|
+
.action(async (id, o) => {
|
|
311
|
+
const r = await (await hivesClient()).hives.environment.list(id, { page: positiveInteger(o.page, '--page', 10000), per_page: positiveInteger(o.perPage, '--per-page', 100) });
|
|
312
|
+
emit(r, () => table(r.data, ['name']));
|
|
313
|
+
});
|
|
314
|
+
environment.command('upsert <project_id>').description('Encrypt a flat JSON map; empty string values are allowed')
|
|
315
|
+
.option('--file <path>', 'UTF-8 JSON input file').option('--stdin', 'read JSON from stdin')
|
|
316
|
+
.action(async (id, o) => {
|
|
317
|
+
const text = await readInput(o, 2 * 1024 * 1024);
|
|
318
|
+
let body;
|
|
319
|
+
try {
|
|
320
|
+
body = JSON.parse(text);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
throw new Error('Environment input must be a flat JSON map of names to string values.');
|
|
324
|
+
}
|
|
325
|
+
const r = await (await hivesClient()).hives.environment.upsert(id, body);
|
|
326
|
+
emit(r, () => table(r.updated, ['name']));
|
|
327
|
+
});
|
|
328
|
+
environment.command('reveal <project_id> <key>').description('Explicit audit-logged environment reveal; requires --stdout')
|
|
329
|
+
.option('--stdout', 'write sensitive plaintext to stdout (avoid retained output)')
|
|
330
|
+
.action(async (id, key, o) => {
|
|
331
|
+
if (!o.stdout)
|
|
332
|
+
throw new Error('Environment reveal requires --stdout. Avoid logging or retaining the plaintext.');
|
|
333
|
+
const r = await (await hivesClient()).hives.environment.reveal(id, key);
|
|
334
|
+
emit(r, () => r.value);
|
|
335
|
+
});
|
|
336
|
+
secrets.command('list <project_id>').description('List secret metadata without values')
|
|
337
|
+
.option('--page <number>', 'page number (1–10000)').option('--per-page <number>', 'items per page (1–100)')
|
|
338
|
+
.action(async (id, o) => {
|
|
339
|
+
const q = { page: positiveInteger(o.page, '--page', 10000), per_page: positiveInteger(o.perPage, '--per-page', 100) };
|
|
340
|
+
emitSecret(await (await hivesClient()).hives.secrets.list(id, q));
|
|
341
|
+
});
|
|
342
|
+
secrets.command('get <project_id> <secret_id>').description('Inspect secret metadata without its value')
|
|
343
|
+
.action(async (project, secret) => {
|
|
344
|
+
const r = await (await hivesClient()).hives.secrets.get(project, secret);
|
|
345
|
+
emit(r, () => table([r], ['id', 'name', 'version', 'description', 'updated_at']));
|
|
346
|
+
});
|
|
347
|
+
secrets.command('create <project_id> <name>').description('Create a secret; plaintext is read from a file or stdin')
|
|
348
|
+
.option('--value-file <path>', 'UTF-8 file containing the value').option('--stdin', 'read value from stdin')
|
|
349
|
+
.option('--description <text>', 'description (up to 500 characters)')
|
|
350
|
+
.action(async (project, name, o) => {
|
|
351
|
+
const body = validateSecret({ name, value: await readInput({ file: o.valueFile, stdin: o.stdin }, 65536), description: o.description });
|
|
352
|
+
const r = await (await hivesClient()).hives.secrets.create(project, body);
|
|
353
|
+
emit(r, () => table([r], ['id', 'name', 'version']));
|
|
354
|
+
});
|
|
355
|
+
secrets.command('update <project_id> <secret_id>').description('Replace a secret value, creating a new version')
|
|
356
|
+
.option('--value-file <path>', 'UTF-8 file containing the new value').option('--stdin', 'read value from stdin')
|
|
357
|
+
.action(async (project, secret, o) => {
|
|
358
|
+
const value = validateSecretValue(await readInput({ file: o.valueFile, stdin: o.stdin }, 65536));
|
|
359
|
+
const r = await (await hivesClient()).hives.secrets.update(project, secret, { value });
|
|
360
|
+
emit(r, () => table([r], ['id', 'name', 'version']));
|
|
361
|
+
});
|
|
362
|
+
secrets.command('delete <project_id> <secret_id>').description('Delete a secret in your authenticated org')
|
|
363
|
+
.action(async (project, secret) => { const r = await (await hivesClient()).hives.secrets.delete(project, secret); emit(r, () => `Deleted secret ${secret}.`); });
|
|
364
|
+
secrets.command('reveal <project_id> <secret_id>').description('Explicit, audit-logged plaintext reveal; requires --stdout')
|
|
365
|
+
.option('--stdout', 'write sensitive plaintext to stdout (avoid logs and retained output)')
|
|
366
|
+
.action(async (project, secret, o) => {
|
|
367
|
+
if (!o.stdout)
|
|
368
|
+
throw new Error('Reveal requires --stdout. This writes sensitive plaintext to your terminal or pipe; avoid logging or retaining it.');
|
|
369
|
+
const r = await (await hivesClient()).hives.secrets.reveal(project, secret);
|
|
370
|
+
emit(r, () => r.value);
|
|
371
|
+
});
|
|
372
|
+
secrets.command('bulk <project_id>').description('PUT a JSON array of secret names and values; existing names receive new versions')
|
|
373
|
+
.option('--file <path>', 'UTF-8 JSON input file').option('--stdin', 'read JSON from stdin')
|
|
374
|
+
.action(async (project, o) => {
|
|
375
|
+
const batch = parseSecretBatch(await readInput(o, 256 * 1024));
|
|
376
|
+
const r = await (await hivesClient()).hives.secrets.bulkUpsert(project, batch);
|
|
377
|
+
emit(r, () => table(r.secrets, ['id', 'name', 'version']));
|
|
378
|
+
});
|
|
161
379
|
const projects = hives.command('projects').description('Hives projects');
|
|
162
|
-
projects.command('list', { isDefault: true }).
|
|
163
|
-
|
|
380
|
+
projects.command('list', { isDefault: true }).option('--search <text>', 'filter by project name')
|
|
381
|
+
.option('--page <number>', 'page number (1–10000)').option('--per-page <number>', 'items per page (1–100)')
|
|
382
|
+
.action(async (o) => {
|
|
383
|
+
const r = await (await hivesClient()).hives.projects.list({ search: o.search,
|
|
384
|
+
page: positiveInteger(o.page, '--page', 10000), per_page: positiveInteger(o.perPage, '--per-page', 100) });
|
|
164
385
|
emit(r, () => table(listOf(r), ['id', 'name', 'slug', 'region', 'total_deployments', 'active_instances']));
|
|
165
386
|
});
|
|
166
387
|
projects.command('get <project_id>').action(async (id) => {
|
|
167
|
-
const r = await (await
|
|
388
|
+
const r = await (await hivesClient()).hives.projects.get(id);
|
|
168
389
|
emit(r, () => JSON.stringify(r, null, 2));
|
|
169
390
|
});
|
|
391
|
+
projects.command('create <name>').description('Create a project in your authenticated org')
|
|
392
|
+
.option('--kind <kind>', 'worker, function, site or api', 'worker')
|
|
393
|
+
.option('--description <text>', 'project description (up to 500 characters)')
|
|
394
|
+
.action(async (name, o) => {
|
|
395
|
+
if (!['worker', 'function', 'site', 'api'].includes(o.kind))
|
|
396
|
+
throw new Error('--kind must be worker, function, site or api.');
|
|
397
|
+
if (o.description && o.description.length > 500)
|
|
398
|
+
throw new Error('--description must be at most 500 characters.');
|
|
399
|
+
const r = await (await hivesClient()).hives.projects.create({ name: projectName(name), kind: o.kind,
|
|
400
|
+
...(o.description === undefined ? {} : { description: o.description }) });
|
|
401
|
+
emit(r, () => table([r], ['id', 'name', 'slug', 'status']));
|
|
402
|
+
});
|
|
403
|
+
projects.command('rename <project_id> <name>').description('Rename a project in your authenticated org')
|
|
404
|
+
.action(async (id, name) => {
|
|
405
|
+
const r = await (await hivesClient()).hives.projects.update(id, { name: projectName(name) });
|
|
406
|
+
emit(r, () => table([r], ['id', 'name', 'slug', 'status']));
|
|
407
|
+
});
|
|
408
|
+
projects.command('activity <project_id>').description('Read persisted project activity')
|
|
409
|
+
.option('--action <action>', 'filter by action')
|
|
410
|
+
.option('--actor-type <type>', 'filter by actor type')
|
|
411
|
+
.option('--resource-type <type>', 'filter by resource type')
|
|
412
|
+
.option('--since <timestamp>', 'inclusive start timestamp in ISO 8601')
|
|
413
|
+
.option('--until <timestamp>', 'inclusive end timestamp in ISO 8601')
|
|
414
|
+
.option('--page <number>', 'page number (1–10000)')
|
|
415
|
+
.option('--per-page <number>', 'items per page (1–100)')
|
|
416
|
+
.action(async (id, o) => {
|
|
417
|
+
for (const [option, value] of [['--since', o.since], ['--until', o.until]]) {
|
|
418
|
+
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)))) {
|
|
419
|
+
throw new Error(`${option} must be an ISO 8601 timestamp.`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const r = await (await hivesClient()).hives.projects.activity(id, { action: o.action,
|
|
423
|
+
actor_type: o.actorType, resource_type: o.resourceType, since: o.since, until: o.until,
|
|
424
|
+
page: positiveInteger(o.page, '--page', 10000), per_page: positiveInteger(o.perPage, '--per-page', 100) });
|
|
425
|
+
emit(r, () => table(listOf(r), ['created_at', 'action', 'actor_id', 'resource_type', 'resource_name']));
|
|
426
|
+
});
|
|
170
427
|
// ---- relays ----------------------------------------------------------
|
|
171
428
|
const relays = program.command('relays').description('Relays: DNS zones and records');
|
|
172
429
|
relays.command('zones').description('List DNS zones').option('--search <q>').action(async (o) => {
|
|
@@ -188,6 +445,7 @@ export function buildProgram(deps = {}) {
|
|
|
188
445
|
});
|
|
189
446
|
addHenryCommands(program, client, value => out.log(JSON.stringify(value, null, 2)));
|
|
190
447
|
registerFleets(program, client, (data) => emit(data, () => JSON.stringify(data, null, 2)));
|
|
448
|
+
registerFlocks(program, client, (data) => emit(data, () => JSON.stringify(data, null, 2)), env);
|
|
191
449
|
registerBeacons(program, client, emit);
|
|
192
450
|
// ---- status ----------------------------------------------------------
|
|
193
451
|
program.command('status').description('Which Genesis services are wired up').action(async () => {
|
package/dist/runner.js
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
export function registerRunner(program, client, emit) {
|
|
3
3
|
const runner = program.command('runner').description('Runner: agent task execution');
|
|
4
4
|
const output = (value) => emit(value, () => JSON.stringify(value, null, 2));
|
|
5
|
+
runner.command('permission-grant').description('Request only your current native human Sandbox execution permission; preserve operation/scope/expiry on retry')
|
|
6
|
+
.requiredOption('--organization <id>', 'current approved Life organization assertion')
|
|
7
|
+
.requiredOption('--project <id>', 'current approved project assertion')
|
|
8
|
+
.requiredOption('--workspace <id>', 'current approved workspace assertion')
|
|
9
|
+
.requiredOption('--operation-id <uuid>', 'stable canonical UUIDv4 for this immutable authorization operation')
|
|
10
|
+
.requiredOption('--request-id <uuid>', 'fresh canonical UUIDv4 correlation for this attempt')
|
|
11
|
+
.requiredOption('--expires-at-ms <ms>', 'exact retained future integer expiry, at most 30 minutes and current session/access expiry')
|
|
12
|
+
.action(async (o) => {
|
|
13
|
+
if (!/^[1-9]\d*$/.test(o.expiresAtMs) || !Number.isSafeInteger(Number(o.expiresAtMs)))
|
|
14
|
+
throw new TypeError('Runner permission expiry must be a positive safe integer literal');
|
|
15
|
+
const input = { schemaVersion: 1, organizationId: o.organization, projectId: o.project,
|
|
16
|
+
workspaceId: o.workspace, platformId: 'pf_console', fleetId: null, resourceEnvironment: 'sandbox',
|
|
17
|
+
operationId: o.operationId, requestId: o.requestId, expiresAtMs: Number(o.expiresAtMs) };
|
|
18
|
+
output(await (await client()).runner.permission.grant(input));
|
|
19
|
+
});
|
|
5
20
|
runner.command('catalog').description('Authorized workspaces and execution routes')
|
|
6
21
|
.action(async () => output(await (await client()).runner.catalog()));
|
|
7
22
|
const jobs = runner.command('jobs').description('Jobs belonging to your verified identity');
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
import { MAX_WASM_BYTES } from '@lifesi/genesis';
|
|
3
|
+
/** Read only the explicitly requested regular file, with a bounded allocation. */
|
|
4
|
+
export async function readWasmFile(path) {
|
|
5
|
+
let file;
|
|
6
|
+
try {
|
|
7
|
+
file = await open(path, 'r');
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
throw new Error('Could not open the WASM file. Check its path and permissions.');
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const stat = await file.stat();
|
|
14
|
+
if (!stat.isFile() || stat.size < 4 || stat.size > MAX_WASM_BYTES)
|
|
15
|
+
throw new Error('Choose a regular WASM file of 4 bytes to 10 MiB.');
|
|
16
|
+
const bytes = new Uint8Array(MAX_WASM_BYTES + 1);
|
|
17
|
+
let size = 0;
|
|
18
|
+
for (;;) {
|
|
19
|
+
const { bytesRead } = await file.read(bytes, size, bytes.length - size, null);
|
|
20
|
+
if (!bytesRead)
|
|
21
|
+
break;
|
|
22
|
+
size += bytesRead;
|
|
23
|
+
if (size > MAX_WASM_BYTES)
|
|
24
|
+
throw new Error('WASM file exceeds 10 MiB.');
|
|
25
|
+
}
|
|
26
|
+
return new Blob([bytes.slice(0, size)], { type: 'application/wasm' });
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
await file.close();
|
|
30
|
+
}
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lifesi/genesis-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/l1felabs/genesis-api.git",
|
|
7
|
+
"directory": "packages/cli"
|
|
8
|
+
},
|
|
4
9
|
"description": "genesis: one CLI for every Genesis service",
|
|
5
10
|
"type": "module",
|
|
6
11
|
"bin": {
|
|
@@ -12,7 +17,8 @@
|
|
|
12
17
|
],
|
|
13
18
|
"dependencies": {
|
|
14
19
|
"commander": "^14.0.0",
|
|
15
|
-
"
|
|
20
|
+
"undici": "6.29.0",
|
|
21
|
+
"@lifesi/genesis": "0.1.2"
|
|
16
22
|
},
|
|
17
23
|
"optionalDependencies": {
|
|
18
24
|
"@napi-rs/keyring": "^1.2.0"
|