@lifesi/genesis-cli 0.1.1 → 0.1.3
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/auth.d.ts +14 -20
- package/dist/auth.js +181 -53
- package/dist/beacons.js +45 -1
- package/dist/dials.js +48 -1
- package/dist/fleets.js +26 -1
- package/dist/flocks.d.ts +3 -0
- package/dist/flocks.js +46 -0
- package/dist/hives-marketplace.d.ts +10 -0
- package/dist/hives-marketplace.js +29 -0
- package/dist/hives-node-transport.js +2 -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 +79 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +65 -9
- package/dist/lifeid-native.d.ts +24 -0
- package/dist/lifeid-native.js +113 -0
- package/dist/runner.js +15 -0
- package/dist/store.d.ts +10 -0
- package/package.json +7 -2
package/dist/auth.d.ts
CHANGED
|
@@ -1,30 +1,24 @@
|
|
|
1
1
|
import type { CredentialStore, SavedLogin } from './store.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* Docs: https://workos.com/docs/authkit/cli-auth
|
|
5
|
-
* POST {authApi}/user_management/authorize/device {client_id}
|
|
6
|
-
* POST {authApi}/user_management/authenticate {grant_type: device_code | refresh_token, ...}
|
|
7
|
-
* client_id is a public identifier (not a secret). Set GENESIS_CLIENT_ID to the LifeID (staging) AuthKit client.
|
|
8
|
-
* GENESIS_AUTH_API lets this point at an auth.life.one proxy once one exists; default is WorkOS directly.
|
|
9
|
-
*/
|
|
2
|
+
import { type NativeAttempt } from './lifeid-native.js';
|
|
3
|
+
/** LifeID's admitted native product bridge, never a direct AuthKit bearer. */
|
|
10
4
|
export interface AuthConfig {
|
|
11
|
-
|
|
5
|
+
productId?: string;
|
|
6
|
+
redirectUri?: string;
|
|
7
|
+
audience?: string;
|
|
12
8
|
authApi: string;
|
|
13
9
|
fetch: typeof fetch;
|
|
14
10
|
sleep?: (ms: number) => Promise<void>;
|
|
15
11
|
}
|
|
16
12
|
export declare function authConfig(env?: NodeJS.ProcessEnv, f?: typeof fetch): AuthConfig;
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
interval: number;
|
|
24
|
-
}
|
|
25
|
-
export declare function startDevice(c: AuthConfig): Promise<DeviceCode>;
|
|
13
|
+
export declare function startLogin(c: AuthConfig, options?: {
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
organizationHint?: string;
|
|
17
|
+
}): Promise<NativeAttempt>;
|
|
18
|
+
/** Scheduling/consistency only. The LifeID server and Genesis verifier own authority. */
|
|
26
19
|
export declare function jwtExp(token: string): number | undefined;
|
|
27
|
-
export declare function
|
|
20
|
+
export declare function parseLifeSession(c: AuthConfig, value: unknown, now?: number): SavedLogin;
|
|
21
|
+
export declare function completeLogin(c: AuthConfig, attempt: NativeAttempt): Promise<SavedLogin>;
|
|
28
22
|
export declare function refresh(c: AuthConfig, s: SavedLogin, organizationId?: string): Promise<SavedLogin>;
|
|
29
|
-
/**
|
|
23
|
+
/** OS-keychain bridge session; one committed rotation per store, never raw AuthKit. */
|
|
30
24
|
export declare function savedLoginProvider(c: AuthConfig, store: CredentialStore, now?: () => number): () => Promise<string | undefined>;
|
package/dist/auth.js
CHANGED
|
@@ -1,78 +1,206 @@
|
|
|
1
|
+
import { LIFEID_ISSUER, startNativeAttempt, validateRegistration } from './lifeid-native.js';
|
|
1
2
|
export function authConfig(env = process.env, f = fetch) {
|
|
2
|
-
return {
|
|
3
|
+
return { productId: env.GENESIS_AUTH_PRODUCT, redirectUri: env.GENESIS_AUTH_REDIRECT_URI,
|
|
4
|
+
audience: env.GENESIS_AUTH_AUDIENCE, authApi: LIFEID_ISSUER, fetch: f };
|
|
3
5
|
}
|
|
4
|
-
function
|
|
5
|
-
if (!c.
|
|
6
|
-
throw new Error('
|
|
7
|
-
|
|
6
|
+
function registration(c) {
|
|
7
|
+
if (c.authApi !== LIFEID_ISSUER || !c.productId || !c.redirectUri || !c.audience) {
|
|
8
|
+
throw new Error('LifeID native login is not admitted here. Configure GENESIS_AUTH_PRODUCT, GENESIS_AUTH_REDIRECT_URI and GENESIS_AUTH_AUDIENCE from the registered LifeID profile, or use GENESIS_API_KEY.');
|
|
9
|
+
}
|
|
10
|
+
const r = { productId: c.productId, redirectUri: c.redirectUri, audience: c.audience };
|
|
11
|
+
validateRegistration(r);
|
|
12
|
+
return r;
|
|
8
13
|
}
|
|
9
|
-
async function
|
|
10
|
-
|
|
11
|
-
const json = await res.json().catch(() => ({}));
|
|
12
|
-
return { ok: res.ok, status: res.status, json };
|
|
14
|
+
export async function startLogin(c, options = {}) {
|
|
15
|
+
return startNativeAttempt(registration(c), options);
|
|
13
16
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (!r.ok)
|
|
17
|
-
throw new Error(`Could not start login (${r.status}): ${r.json.error_description ?? r.json.message ?? r.json.error ?? 'unknown error'}`);
|
|
18
|
-
return r.json;
|
|
17
|
+
function object(v) {
|
|
18
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
function tokenClaims(token) {
|
|
21
21
|
try {
|
|
22
|
-
|
|
22
|
+
const parts = token.split('.');
|
|
23
|
+
if (parts.length !== 3 || !parts.every(p => /^[A-Za-z0-9_-]+$/.test(p)) || token.length > 16_384)
|
|
24
|
+
return undefined;
|
|
25
|
+
return object(JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')));
|
|
23
26
|
}
|
|
24
27
|
catch {
|
|
25
28
|
return undefined;
|
|
26
29
|
}
|
|
27
30
|
}
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
/** Scheduling/consistency only. The LifeID server and Genesis verifier own authority. */
|
|
32
|
+
export function jwtExp(token) {
|
|
33
|
+
const exp = tokenClaims(token)?.exp;
|
|
34
|
+
return typeof exp === 'number' && Number.isSafeInteger(exp) ? exp : undefined;
|
|
30
35
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
36
|
+
function id(v, prefix) {
|
|
37
|
+
return typeof v === 'string' && new RegExp(`^${prefix}_[0-9A-HJKMNP-TV-Z]{26}$`).test(v);
|
|
38
|
+
}
|
|
39
|
+
function secret(v) {
|
|
40
|
+
return typeof v === 'string' && v.length > 0 && v.length <= 16_384 && !/[\s\x00-\x1f\x7f]/.test(v);
|
|
41
|
+
}
|
|
42
|
+
class LifeIdRequestError extends Error {
|
|
43
|
+
}
|
|
44
|
+
function invalidEnvelope() { throw new LifeIdRequestError('LifeID returned an invalid or mismatched session. Run `genesis login` again.'); }
|
|
45
|
+
export function parseLifeSession(c, value, now = Date.now() / 1000) {
|
|
46
|
+
const r = registration(c);
|
|
47
|
+
const body = object(value), s = object(body?.session), p = object(body?.principal);
|
|
48
|
+
if (!s || !p || p.kind !== 'human' || !id(p.id, 'h') || s.principalKind !== 'human'
|
|
49
|
+
|| !id(s.id, 'sess') || !id(s.organizationId, 'o') || s.issuer !== LIFEID_ISSUER
|
|
50
|
+
|| s.audience !== r.audience || !secret(s.accessToken) || !secret(body?.refresh_token)
|
|
51
|
+
|| typeof s.issuedAt !== 'string' || typeof s.expiresAt !== 'string')
|
|
52
|
+
invalidEnvelope();
|
|
53
|
+
const issued = Date.parse(s.issuedAt) / 1000, expires = Date.parse(s.expiresAt) / 1000;
|
|
54
|
+
const claims = tokenClaims(s.accessToken);
|
|
55
|
+
const exp = jwtExp(s.accessToken);
|
|
56
|
+
const audiences = typeof claims?.aud === 'string' ? [claims.aud] : claims?.aud;
|
|
57
|
+
if (!Number.isFinite(issued) || !Number.isFinite(expires) || issued > now + 60 || expires <= now || expires <= issued
|
|
58
|
+
|| !claims || !exp || exp <= now || claims.iss !== LIFEID_ISSUER || !Array.isArray(audiences)
|
|
59
|
+
|| !audiences.every(a => typeof a === 'string') || !audiences.includes(r.audience)
|
|
60
|
+
|| claims.kind !== 'human' || claims.sid !== s.id || claims.sub !== p.id || claims.tid !== p.id || claims.org !== s.organizationId
|
|
61
|
+
|| (s.tenantId !== undefined && s.tenantId !== null && s.tenantId !== p.id)
|
|
62
|
+
|| claims.cnf !== undefined || claims.cap !== undefined || claims.obo !== undefined)
|
|
63
|
+
invalidEnvelope();
|
|
64
|
+
// LifeID's account scope is the principal; the selected organization stays separate.
|
|
65
|
+
// Optional response projections are not required to retain committed rotation.
|
|
66
|
+
return { access_token: s.accessToken, refresh_token: body.refresh_token,
|
|
67
|
+
user: { id: p.id, ...(typeof p.email === 'string' ? { email: p.email } : {}) },
|
|
68
|
+
organization_id: s.organizationId, expires_at: Math.min(expires, exp), client_id: r.productId,
|
|
69
|
+
auth: { kind: 'lifeid-bridge', issuer: LIFEID_ISSUER, audience: r.audience, product_id: r.productId,
|
|
70
|
+
redirect_uri: r.redirectUri, principal_id: p.id, session_id: s.id } };
|
|
71
|
+
}
|
|
72
|
+
/** Bound headers AND body, forbid redirects, and never echo a provider payload. */
|
|
73
|
+
async function post(c, path, body, signal) {
|
|
74
|
+
registration(c);
|
|
75
|
+
if (signal?.aborted)
|
|
76
|
+
throw new LifeIdRequestError('LifeID request cancelled. No automatic retry was made.');
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
let reader;
|
|
79
|
+
let response;
|
|
80
|
+
let rejectDeadline;
|
|
81
|
+
const deadline = new Promise((_, reject) => { rejectDeadline = reject; });
|
|
82
|
+
const abort = () => { controller.abort(); rejectDeadline(new LifeIdRequestError('LifeID request cancelled. No automatic retry was made.')); };
|
|
83
|
+
const timer = setTimeout(() => {
|
|
84
|
+
controller.abort();
|
|
85
|
+
rejectDeadline(new LifeIdRequestError('LifeID request timed out. No automatic retry was made.'));
|
|
86
|
+
}, 15_000);
|
|
87
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
88
|
+
if (signal?.aborted)
|
|
89
|
+
abort();
|
|
90
|
+
try {
|
|
91
|
+
return await Promise.race([deadline, (async () => {
|
|
92
|
+
if (controller.signal.aborted)
|
|
93
|
+
throw new LifeIdRequestError('LifeID request cancelled.');
|
|
94
|
+
response = await c.fetch(`${LIFEID_ISSUER}${path}`, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
95
|
+
body: JSON.stringify(body), redirect: 'error', cache: 'no-store', signal: controller.signal });
|
|
96
|
+
if (controller.signal.aborted) {
|
|
97
|
+
void response.body?.cancel().catch(() => { });
|
|
98
|
+
throw new LifeIdRequestError('LifeID request cancelled.');
|
|
99
|
+
}
|
|
100
|
+
if (!response.ok || !/^application\/json(?:;|$)/i.test(response.headers.get('content-type') ?? '')) {
|
|
101
|
+
void response.body?.cancel().catch(() => { });
|
|
102
|
+
throw new LifeIdRequestError(`LifeID request failed (${response.status}). Run \`genesis login\` again.`);
|
|
103
|
+
}
|
|
104
|
+
reader = response.body?.getReader();
|
|
105
|
+
if (!reader)
|
|
106
|
+
invalidEnvelope();
|
|
107
|
+
const chunks = [];
|
|
108
|
+
let size = 0;
|
|
109
|
+
while (true) {
|
|
110
|
+
const part = await reader.read();
|
|
111
|
+
if (part.done)
|
|
112
|
+
break;
|
|
113
|
+
size += part.value.byteLength;
|
|
114
|
+
if (size > 32_768) {
|
|
115
|
+
void reader.cancel().catch(() => { });
|
|
116
|
+
invalidEnvelope();
|
|
117
|
+
}
|
|
118
|
+
chunks.push(part.value);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
invalidEnvelope();
|
|
125
|
+
}
|
|
126
|
+
})()]);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (error instanceof LifeIdRequestError)
|
|
130
|
+
throw error;
|
|
131
|
+
throw new Error('LifeID transport failed. No automatic retry was made.');
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
signal?.removeEventListener('abort', abort);
|
|
136
|
+
if (controller.signal.aborted)
|
|
137
|
+
void reader?.cancel().catch(() => { });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export async function completeLogin(c, attempt) {
|
|
141
|
+
try {
|
|
142
|
+
const r = registration(c);
|
|
143
|
+
if (r.productId !== attempt.registration.productId || r.redirectUri !== attempt.registration.redirectUri
|
|
144
|
+
|| r.audience !== attempt.registration.audience)
|
|
145
|
+
throw new Error('LifeID login registration changed.');
|
|
146
|
+
const code = await attempt.code;
|
|
147
|
+
const body = await post(c, '/v4/lifeid/exchange', { life_code: code, product_id: r.productId,
|
|
148
|
+
redirect_uri: r.redirectUri, code_verifier: attempt.codeVerifier }, attempt.signal);
|
|
149
|
+
return parseLifeSession(c, body);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
attempt.close();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function requireSaved(c, s) {
|
|
156
|
+
const r = registration(c), a = s.auth;
|
|
157
|
+
if (!a || a.kind !== 'lifeid-bridge' || a.issuer !== LIFEID_ISSUER || a.audience !== r.audience
|
|
158
|
+
|| a.product_id !== r.productId || a.redirect_uri !== r.redirectUri || s.client_id !== r.productId
|
|
159
|
+
|| !id(s.organization_id, 'o') || !id(a.principal_id, 'h') || !id(a.session_id, 'sess')) {
|
|
160
|
+
throw new Error('Saved login is not an admitted LifeID bridge session. Run `genesis login` again.');
|
|
161
|
+
}
|
|
162
|
+
const claims = tokenClaims(s.access_token), aud = claims?.aud;
|
|
163
|
+
if (!claims || claims.iss !== a.issuer || !(aud === a.audience || (Array.isArray(aud) && aud.includes(a.audience)))
|
|
164
|
+
|| claims.kind !== 'human' || claims.sid !== a.session_id || claims.sub !== a.principal_id || claims.tid !== a.principal_id
|
|
165
|
+
|| claims.org !== s.organization_id || claims.cnf !== undefined || claims.cap !== undefined || claims.obo !== undefined) {
|
|
166
|
+
throw new Error('Saved LifeID session is inconsistent. Run `genesis login` again.');
|
|
52
167
|
}
|
|
53
|
-
throw new Error('Login code expired. Run `genesis login` again.');
|
|
54
168
|
}
|
|
55
169
|
export async function refresh(c, s, organizationId) {
|
|
56
|
-
|
|
170
|
+
requireSaved(c, s);
|
|
171
|
+
if (organizationId && organizationId !== s.organization_id) {
|
|
172
|
+
throw new Error('Changing organization requires a new verified LifeID login; refresh cannot select an organization.');
|
|
173
|
+
}
|
|
174
|
+
if (!secret(s.refresh_token))
|
|
57
175
|
throw new Error('Session expired. Run `genesis login`.');
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (!r.ok)
|
|
63
|
-
throw new Error(r.json.error === 'invalid_grant' ? 'Session expired. Run `genesis login`.' : `Could not refresh session (${r.status}): ${r.json.error_description ?? r.json.error}`);
|
|
64
|
-
return { ...toSaved({ ...c, clientId: s.client_id }, r.json), user: r.json.user ?? s.user };
|
|
176
|
+
const n = parseLifeSession(c, await post(c, '/v4/auth/refresh', { refresh_token: s.refresh_token }));
|
|
177
|
+
if (n.organization_id !== s.organization_id || n.auth?.principal_id !== s.auth?.principal_id)
|
|
178
|
+
invalidEnvelope();
|
|
179
|
+
return n;
|
|
65
180
|
}
|
|
66
|
-
|
|
181
|
+
const rotations = new WeakMap();
|
|
182
|
+
/** OS-keychain bridge session; one committed rotation per store, never raw AuthKit. */
|
|
67
183
|
export function savedLoginProvider(c, store, now = () => Date.now() / 1000) {
|
|
68
184
|
return async () => {
|
|
69
185
|
let s = await store.get();
|
|
70
186
|
if (!s)
|
|
71
187
|
return undefined;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
188
|
+
requireSaved(c, s);
|
|
189
|
+
const exp = jwtExp(s.access_token);
|
|
190
|
+
const deadline = Math.min(s.expires_at ?? 0, exp ?? 0);
|
|
191
|
+
if (!Number.isFinite(deadline) || deadline - 60 <= now()) {
|
|
192
|
+
let pending = rotations.get(store);
|
|
193
|
+
if (!pending) {
|
|
194
|
+
pending = (async () => { const next = await refresh(c, s); await store.set(next); return next; })();
|
|
195
|
+
rotations.set(store, pending);
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
s = await pending;
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
if (rotations.get(store) === pending)
|
|
202
|
+
rotations.delete(store);
|
|
203
|
+
}
|
|
76
204
|
}
|
|
77
205
|
return s.access_token;
|
|
78
206
|
};
|
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
|
@@ -28,13 +28,60 @@ export function registerDials(program, dependencies) {
|
|
|
28
28
|
numbers.command('drift').action(async () => { const { client, scope } = await context(); emit(await client.dials.numbers.drift(scope)); });
|
|
29
29
|
dials.command('overview').action(async () => { const { client, scope } = await context(); emit(await client.dials.overview.summary(scope)); });
|
|
30
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 } : {}) })); });
|
|
31
78
|
const calls = dials.command('calls').description('Call activity and approved outbound calls');
|
|
32
79
|
calls.command('list', { isDefault: true }).action(async () => { const { client, scope } = await context(); emit(await client.dials.calls.list(scope)); });
|
|
33
80
|
// @file keeps structured approval and idempotency fields out of shell history.
|
|
34
81
|
for (const [parent, name, operation] of [
|
|
35
82
|
[numbers, 'caller-name', 'dials_updateNumberCallerName'], [numbers, 'reconcile', 'dials_reconcileNumberInventory'],
|
|
36
83
|
[numbers, 'provision', 'dials_provisionNumber'], [numbers, 'assign', 'dials_assignNumber'], [numbers, 'release', 'dials_releaseNumber'], [numbers, 'e911', 'dials_registerEmergencyAddress'],
|
|
37
|
-
[calls, 'validate', 'dials_validateOutboundCall'], [calls, 'create', 'dials_createOutboundCall'],
|
|
84
|
+
[calls, 'validate', 'dials_validateOutboundCall'], [calls, 'create', 'dials_createOutboundCall'], [calls, 'recover', 'dials_recoverOutboundCall'],
|
|
38
85
|
]) {
|
|
39
86
|
parent.command(name).requiredOption('--body <json-or-@file>', 'JSON input including actorId and required operation fields')
|
|
40
87
|
.action(async (options) => {
|
package/dist/fleets.js
CHANGED
|
@@ -20,7 +20,32 @@ 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 agents = fleets.command('agents').description('Persistent Henry agents on Fleets-managed compute');
|
|
25
|
+
agents.command('list').action(async () => emit(await (await client()).fleets.agents.list()));
|
|
26
|
+
agents.command('launch <name>').option('--memory <provider>', 'memory provider (minds, files or custom)', 'minds').option('--namespace <namespace>').option('--endpoint <url>').option('--model <model>').option('--instructions <text>').requiredOption('--idempotency-key <key>', 'stable key to reuse on retry').action(async (name, options) => {
|
|
27
|
+
const provider = options.memory;
|
|
28
|
+
if (!['minds', 'files', 'custom'].includes(provider))
|
|
29
|
+
throw new Error('Memory provider must be minds, files, or custom.');
|
|
30
|
+
if (provider === 'custom' && !options.endpoint)
|
|
31
|
+
throw new Error('Custom memory requires --endpoint.');
|
|
32
|
+
emit(await (await client()).fleets.agents.launch({ name, memory: { provider, ...(options.namespace ? { namespace: options.namespace } : {}), ...(options.endpoint ? { endpoint: options.endpoint } : {}) }, ...(options.model ? { model: options.model } : {}), ...(options.instructions ? { instructions: options.instructions } : {}), idempotency_key: options.idempotencyKey }));
|
|
33
|
+
});
|
|
34
|
+
agents.command('get <agent_id>').action(async (id) => emit(await (await client()).fleets.agents.get(id)));
|
|
35
|
+
agents.command('command <agent_id> <input>').option('--delivery <mode>', 'queue, steer or interrupt', 'queue').action(async (id, input, options) => {
|
|
36
|
+
if (!['queue', 'steer', 'interrupt'].includes(options.delivery))
|
|
37
|
+
throw new Error('Delivery must be queue, steer, or interrupt.');
|
|
38
|
+
emit(await (await client()).fleets.agents.command(id, { input, delivery: options.delivery }));
|
|
39
|
+
});
|
|
40
|
+
agents.command('stop <agent_id>').action(async (id) => emit(await (await client()).fleets.agents.stop(id)));
|
|
41
|
+
agents.command('recover <agent_id>').action(async (id) => emit(await (await client()).fleets.agents.recover(id)));
|
|
42
|
+
const instances = fleets.command('instances').description('Actual admitted Henry Host status');
|
|
43
|
+
instances.command('list').option('--limit <number>').option('--after <instance_id>').action(async (options) => {
|
|
44
|
+
emit(await (await client()).fleets.instances.list({ ...(options.limit ? { limit: Number(options.limit) } : {}), ...(options.after ? { after: options.after } : {}) }));
|
|
45
|
+
});
|
|
46
|
+
instances.command('get <instance_id>').action(async (id) => emit(await (await client()).fleets.instances.get(id)));
|
|
47
|
+
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 })));
|
|
48
|
+
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
49
|
const connection = fleets.command('connection').description('Organization-owned Foundry connection');
|
|
25
50
|
connection.command('get').action(async () => emit(await (await client()).fleets.connection.get()));
|
|
26
51
|
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,10 @@
|
|
|
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
|
+
/** Environment values belong in a bounded private JSON file, never command-line options. */
|
|
9
|
+
export declare function registerHivesMarketplace(hives: Command, deps: Deps): void;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Option } from 'commander';
|
|
2
|
+
import { readFunctionJson } from './function-input.js';
|
|
3
|
+
function number(value) { if (value === undefined)
|
|
4
|
+
return undefined; if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)))
|
|
5
|
+
throw new Error('Invalid page option.'); return Number(value); }
|
|
6
|
+
/** Environment values belong in a bounded private JSON file, never command-line options. */
|
|
7
|
+
export function registerHivesMarketplace(hives, deps) {
|
|
8
|
+
const m = hives.command('marketplace').description('Existing Hives packages, publication and persisted installations');
|
|
9
|
+
const output = (data) => deps.emit(data, () => JSON.stringify(data, null, 2));
|
|
10
|
+
const read = deps.readRequest ?? readFunctionJson;
|
|
11
|
+
const api = async () => (await deps.client()).hives.marketplace;
|
|
12
|
+
m.command('list <project_id>').option('--query <value>').option('--category <value>').option('--free-only').option('--verified-only')
|
|
13
|
+
.addOption(new Option('--sort <field>').choices(['name', 'installs', 'stars', 'created', 'updated']))
|
|
14
|
+
.addOption(new Option('--order <direction>').choices(['asc', 'desc'])).option('--page <number>').option('--page-size <number>')
|
|
15
|
+
.action(async (project, o) => output(await (await api()).list(project, { q: o.query, category: o.category, free_only: o.freeOnly, verified_only: o.verifiedOnly, sort: o.sort, order: o.order, page: number(o.page), page_size: number(o.pageSize) })));
|
|
16
|
+
m.command('get <project_id> <slug>').option('--version <semver>').action(async (project, slug, o) => output(await (await api()).get(project, slug, { version: o.version })));
|
|
17
|
+
m.command('versions <project_id> <slug>').option('--page <number>').option('--page-size <number>').action(async (project, slug, o) => output(await (await api()).versions(project, slug, { page: number(o.page), page_size: number(o.pageSize) })));
|
|
18
|
+
for (const action of ['categories', 'featured'])
|
|
19
|
+
m.command(`${action} <project_id>`).action(async (project) => output(await (await api())[action](project)));
|
|
20
|
+
m.command('publish <project_id>').requiredOption('--request-file <path>', 'JSON publication with an owned uploaded module_hash and immutable version')
|
|
21
|
+
.action(async (project, o) => output(await (await api()).publish(project, await read(o.requestFile, 512 * 1024))));
|
|
22
|
+
for (const action of ['install', 'update'])
|
|
23
|
+
m.command(`${action} <project_id> <slug>`).requiredOption('--request-file <path>', 'JSON version and env_overrides; use {} for defaults')
|
|
24
|
+
.action(async (project, slug, o) => output(await (await api())[action](project, slug, await read(o.requestFile, 512 * 1024))));
|
|
25
|
+
m.command('installed <project_id>').option('--page <number>').option('--page-size <number>').action(async (project, o) => output(await (await api()).installed(project, { page: number(o.page), page_size: number(o.pageSize) })));
|
|
26
|
+
m.command('installation <project_id> <installation_id>').action(async (project, id) => output(await (await api()).getInstallation(project, id)));
|
|
27
|
+
for (const action of ['reconcile', 'uninstall'])
|
|
28
|
+
m.command(`${action} <project_id> <installation_id>`).action(async (project, id) => output(await (await api())[action](project, id)));
|
|
29
|
+
}
|
|
@@ -11,6 +11,8 @@ export async function closeHivesNodeTransport() {
|
|
|
11
11
|
await current?.destroy();
|
|
12
12
|
}
|
|
13
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;
|
|
14
16
|
if (id === 'hives_cancel_deployment' || id === 'hives_delete_deployment')
|
|
15
17
|
return 330_000;
|
|
16
18
|
if (['hives_create_deployment', 'hives_upload_deployment', 'hives_invoke_deployment', 'hives_invoke_function', 'hives_invoke_function_async'].includes(id))
|
|
@@ -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,79 @@
|
|
|
1
|
+
import { registerHivesMarketplace } from './hives-marketplace.js';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { readFunctionJson } from './function-input.js';
|
|
5
|
+
async function bytes(path) {
|
|
6
|
+
const metadata = await stat(path);
|
|
7
|
+
if (!metadata.isFile() || metadata.size > 10 * 1024 * 1024)
|
|
8
|
+
throw new Error('Choose a regular file of at most 10 MiB.');
|
|
9
|
+
const stream = createReadStream(path);
|
|
10
|
+
const chunks = [];
|
|
11
|
+
let total = 0;
|
|
12
|
+
try {
|
|
13
|
+
for await (const chunk of stream) {
|
|
14
|
+
const value = Buffer.from(chunk);
|
|
15
|
+
total += value.byteLength;
|
|
16
|
+
if (total > 10 * 1024 * 1024)
|
|
17
|
+
throw new Error('Object file exceeds 10 MiB.');
|
|
18
|
+
chunks.push(value);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
stream.destroy();
|
|
23
|
+
}
|
|
24
|
+
return Uint8Array.from(Buffer.concat(chunks, total));
|
|
25
|
+
}
|
|
26
|
+
function number(value, min, max) { if (value === undefined)
|
|
27
|
+
return undefined; if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < min || Number(value) > max)
|
|
28
|
+
throw new Error('Invalid bounded numeric option.'); return Number(value); }
|
|
29
|
+
export function registerHivesResources(hives, deps) {
|
|
30
|
+
registerHivesMarketplace(hives, deps);
|
|
31
|
+
const output = (data) => deps.emit(data, () => JSON.stringify(data, null, 2));
|
|
32
|
+
const read = deps.readRequest ?? readFunctionJson;
|
|
33
|
+
const cells = hives.command('cells').description('Owned GCS buckets and objects; uncertain mutations require readback/reconciliation');
|
|
34
|
+
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) })));
|
|
35
|
+
cells.command('get <project_id> <bucket_id>').action(async (project, id) => output(await (await deps.client()).hives.cells.get(project, id)));
|
|
36
|
+
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))));
|
|
37
|
+
for (const action of ['remove', 'reconcile'])
|
|
38
|
+
cells.command(`${action} <project_id> <bucket_id>`).action(async (project, id) => output(await (await deps.client()).hives.cells[action](project, id)));
|
|
39
|
+
const cors = cells.command('cors');
|
|
40
|
+
cors.command('get <project_id> <bucket_id>').action(async (project, id) => output(await (await deps.client()).hives.cells.cors.get(project, id)));
|
|
41
|
+
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))
|
|
42
|
+
throw new Error('CORS JSON requires rules.'); output(await (await deps.client()).hives.cells.cors.set(project, id, body.rules)); });
|
|
43
|
+
const objects = cells.command('objects');
|
|
44
|
+
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) })));
|
|
45
|
+
for (const action of ['put', 'put-flat'])
|
|
46
|
+
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 })); });
|
|
47
|
+
for (const action of ['remove', 'remove-flat'])
|
|
48
|
+
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 }); });
|
|
49
|
+
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 }); });
|
|
50
|
+
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))));
|
|
51
|
+
const queues = hives.command('queues').description('Buzz durable messages and possessed temporary delivery receipts');
|
|
52
|
+
for (const action of ['list'])
|
|
53
|
+
queues.command(`${action} <project_id>`).action(async (project) => output(await (await deps.client()).hives.queues[action](project)));
|
|
54
|
+
for (const action of ['get', 'stats', 'consumers', 'pause', 'resume', 'purge', 'remove', 'dead-letter-stats', 'purge-dead-letters']) {
|
|
55
|
+
const names = { 'dead-letter-stats': 'deadLetterStats', 'purge-dead-letters': 'purgeDeadLetters' };
|
|
56
|
+
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)); });
|
|
57
|
+
}
|
|
58
|
+
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))));
|
|
59
|
+
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))));
|
|
60
|
+
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))));
|
|
61
|
+
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) })));
|
|
62
|
+
for (const action of ['ack', 'nack'])
|
|
63
|
+
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')
|
|
64
|
+
throw new Error('Receipt JSON requires receiptHandle.'); output(await (await deps.client()).hives.queues[action](project, id, message, body.receiptHandle)); });
|
|
65
|
+
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) })));
|
|
66
|
+
for (const action of ['replay', 'delete-dead-letter'])
|
|
67
|
+
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)));
|
|
68
|
+
const volumes = hives.command('volumes').description('Persistent local storage; refresh saved state after an uncertain acknowledgement');
|
|
69
|
+
volumes.command('list <project_id>').option('--page <number>').option('--per-page <number>').action(async (project, o) => output(await (await deps.client()).hives.volumes.list(project, { page: number(o.page, 1, 10000), per_page: number(o.perPage, 1, 100) })));
|
|
70
|
+
for (const action of ['get', 'unmount', 'remove'])
|
|
71
|
+
volumes.command(`${action} <project_id> <volume_id>`).action(async (project, id) => output(await (await deps.client()).hives.volumes[action](project, id)));
|
|
72
|
+
volumes.command('create <project_id>').requiredOption('--request-file <path>', 'JSON name and size_mib (32–1024); local read_write_once only').action(async (project, o) => output(await (await deps.client()).hives.volumes.create(project, await read(o.requestFile, 4096))));
|
|
73
|
+
volumes.command('mount <project_id> <volume_id>').requiredOption('--request-file <path>', 'JSON target_id, mount_path and explicit read_only; preserve these values when reconnecting').action(async (project, id, o) => output(await (await deps.client()).hives.volumes.mount(project, id, await read(o.requestFile, 4096))));
|
|
74
|
+
const runtime = hives.command('runtimes').description('Compiled serving implementation metadata; not available capacity');
|
|
75
|
+
runtime.command('list').action(async () => output(await (await deps.client()).hives.runtimes.list()));
|
|
76
|
+
runtime.command('project <project_id>').action(async (project) => output(await (await deps.client()).hives.runtimes.project(project)));
|
|
77
|
+
runtime.command('get <name>').action(async (name) => output(await (await deps.client()).hives.runtimes.get(name)));
|
|
78
|
+
runtime.command('select').requiredOption('--wasi <value>').requiredOption('--runner <value>').requiredOption('--artifact <value>').action(async (input) => output(await (await deps.client()).hives.runtimes.select(input)));
|
|
79
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface Deps {
|
|
|
15
15
|
readWasmFile?: (path: string) => Promise<Blob>;
|
|
16
16
|
writeBytes?: (bytes: Uint8Array) => void;
|
|
17
17
|
writeOutputFile?: (path: string, bytes: Uint8Array) => Promise<void>;
|
|
18
|
+
readHivesRequest?: (path: string, limit: number) => Promise<Record<string, unknown>>;
|
|
18
19
|
}
|
|
19
20
|
export declare function buildProgram(deps?: Deps): Command;
|
|
20
21
|
export declare function main(argv: string[], deps?: Deps): Promise<number>;
|
package/dist/index.js
CHANGED
|
@@ -2,14 +2,16 @@ import { registerBeacons } from './beacons.js';
|
|
|
2
2
|
import { registerDials } from './dials.js';
|
|
3
3
|
import { Command, CommanderError } from 'commander';
|
|
4
4
|
import { Genesis, GenesisError, hivesResponseBytes, MAX_FUNCTION_BODY_BYTES, MAX_FUNCTION_JSON_BYTES, services } from '@lifesi/genesis';
|
|
5
|
-
import { authConfig,
|
|
5
|
+
import { authConfig, completeLogin, refresh, savedLoginProvider, startLogin } 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';
|
|
12
13
|
import { createHivesNodeTransport } from './hives-node-transport.js';
|
|
14
|
+
import { registerHivesOperations } from './hives-operations.js';
|
|
13
15
|
import { readFunctionFile, readFunctionJson, writeFunctionOutput } from './function-input.js';
|
|
14
16
|
import { basename } from 'node:path';
|
|
15
17
|
import { readWasmFile } from './wasm-input.js';
|
|
@@ -30,7 +32,7 @@ export function buildProgram(deps = {}) {
|
|
|
30
32
|
const auth = { ...authConfig(env, f), sleep: deps.sleep };
|
|
31
33
|
const program = new Command('genesis')
|
|
32
34
|
.description('One CLI for every Genesis service.')
|
|
33
|
-
.version('0.1.
|
|
35
|
+
.version('0.1.2')
|
|
34
36
|
.option('--json', 'machine-readable JSON output')
|
|
35
37
|
.option('--api-key <key>', 'use this API key (default: GENESIS_API_KEY, then your `genesis login`)')
|
|
36
38
|
.option('--base-url <url>', 'gateway URL (default: GENESIS_BASE_URL or https://console.life.one/api)')
|
|
@@ -46,7 +48,7 @@ export function buildProgram(deps = {}) {
|
|
|
46
48
|
const opts = {
|
|
47
49
|
apiKey: o.apiKey ?? env.GENESIS_API_KEY, token: env.GENESIS_TOKEN,
|
|
48
50
|
baseUrl: o.baseUrl ?? env.GENESIS_BASE_URL, routing: o.direct || env.GENESIS_ROUTING === 'direct' ? 'direct' : 'gateway',
|
|
49
|
-
fetch: f, userAgent: 'genesis-cli/0.1.
|
|
51
|
+
fetch: f, userAgent: 'genesis-cli/0.1.2',
|
|
50
52
|
credentials: store ? savedLoginProvider(auth, store) : undefined,
|
|
51
53
|
};
|
|
52
54
|
return new Genesis(hivesTransport ? { ...opts, fetch: createHivesNodeTransport(opts, deps.signal) } : opts);
|
|
@@ -80,11 +82,17 @@ export function buildProgram(deps = {}) {
|
|
|
80
82
|
program.command('login').description('Sign in with LifeID in your browser').option('--no-browser', 'just print the link')
|
|
81
83
|
.action(async (o) => {
|
|
82
84
|
const store = await getStore();
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
(
|
|
87
|
-
|
|
85
|
+
const attempt = await startLogin(auth, { signal: deps.signal });
|
|
86
|
+
let s;
|
|
87
|
+
try {
|
|
88
|
+
out.err(`\n Open ${attempt.browserUrl}\n ${c.dim('Waiting for LifeID sign-in in the browser…')}\n`);
|
|
89
|
+
if (o.browser)
|
|
90
|
+
(deps.openBrowser ?? defaultOpen)(attempt.browserUrl);
|
|
91
|
+
s = await completeLogin(auth, attempt);
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
attempt.close();
|
|
95
|
+
}
|
|
88
96
|
await store.set(s);
|
|
89
97
|
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
98
|
});
|
|
@@ -177,7 +185,8 @@ export function buildProgram(deps = {}) {
|
|
|
177
185
|
});
|
|
178
186
|
const hivesClient = () => client(true);
|
|
179
187
|
// ---- hives -----------------------------------------------------------
|
|
180
|
-
const hives = program.command('hives').description('Hives: projects, secrets, deployments, functions');
|
|
188
|
+
const hives = program.command('hives').description('Hives: projects, secrets, deployments, functions, schedules and activity');
|
|
189
|
+
registerHivesOperations(hives, { client: hivesClient, emit, readRequest: deps.readHivesRequest });
|
|
181
190
|
const deployments = hives.command('deployments').description('Real WASM artifacts and deployment lifecycle');
|
|
182
191
|
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
192
|
deployments.command('list <project_id>')
|
|
@@ -284,6 +293,52 @@ export function buildProgram(deps = {}) {
|
|
|
284
293
|
return (deps.readSecretInput ?? readSecretInput)(options, limit);
|
|
285
294
|
};
|
|
286
295
|
const emitSecret = (data) => emit(data, () => table(listOf(data), ['id', 'name', 'version', 'description', 'updated_at']));
|
|
296
|
+
const context = hives.command('context').description('Explicit Hives binding for the current verified interactive Genesis selection');
|
|
297
|
+
context.command('get').action(async () => {
|
|
298
|
+
const r = await (await hivesClient()).hives.context.get();
|
|
299
|
+
emit(r, () => table([r.project], ['id', 'name', 'slug', 'kind', 'status']));
|
|
300
|
+
});
|
|
301
|
+
context.command('create <name>').option('--kind <kind>', 'worker|function|site|api', 'worker').option('--description <text>', 'project description')
|
|
302
|
+
.action(async (name, o) => {
|
|
303
|
+
if (!['worker', 'function', 'site', 'api'].includes(o.kind))
|
|
304
|
+
throw new Error('Choose worker, function, site or api.');
|
|
305
|
+
if (o.description && o.description.length > 500)
|
|
306
|
+
throw new Error('Project descriptions must contain at most 500 characters.');
|
|
307
|
+
const r = await (await hivesClient()).hives.context.set({ create: { name: projectName(name), kind: o.kind, description: o.description } });
|
|
308
|
+
emit(r, () => table([r.project], ['id', 'name', 'slug', 'kind', 'status']));
|
|
309
|
+
});
|
|
310
|
+
context.command('link <project_id>').description('Link a Hives project owned by the verified org')
|
|
311
|
+
.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'])); });
|
|
312
|
+
context.command('detach').description('Remove this selection binding; retain the Hives project and its resources')
|
|
313
|
+
.action(async () => { const r = await (await hivesClient()).hives.context.detach(); emit(r, () => 'Detached selection; retained Hives project ' + r.hivesProjectId + '.'); });
|
|
314
|
+
const environment = hives.command('environment').description('Encrypted project environment; names-only metadata and explicit KMS reveal');
|
|
315
|
+
environment.command('list <project_id>').option('--page <number>', 'page number (1–10000)').option('--per-page <number>', 'items per page (1–100)')
|
|
316
|
+
.action(async (id, o) => {
|
|
317
|
+
const r = await (await hivesClient()).hives.environment.list(id, { page: positiveInteger(o.page, '--page', 10000), per_page: positiveInteger(o.perPage, '--per-page', 100) });
|
|
318
|
+
emit(r, () => table(r.data, ['name']));
|
|
319
|
+
});
|
|
320
|
+
environment.command('upsert <project_id>').description('Encrypt a flat JSON map; empty string values are allowed')
|
|
321
|
+
.option('--file <path>', 'UTF-8 JSON input file').option('--stdin', 'read JSON from stdin')
|
|
322
|
+
.action(async (id, o) => {
|
|
323
|
+
const text = await readInput(o, 2 * 1024 * 1024);
|
|
324
|
+
let body;
|
|
325
|
+
try {
|
|
326
|
+
body = JSON.parse(text);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
throw new Error('Environment input must be a flat JSON map of names to string values.');
|
|
330
|
+
}
|
|
331
|
+
const r = await (await hivesClient()).hives.environment.upsert(id, body);
|
|
332
|
+
emit(r, () => table(r.updated, ['name']));
|
|
333
|
+
});
|
|
334
|
+
environment.command('reveal <project_id> <key>').description('Explicit audit-logged environment reveal; requires --stdout')
|
|
335
|
+
.option('--stdout', 'write sensitive plaintext to stdout (avoid retained output)')
|
|
336
|
+
.action(async (id, key, o) => {
|
|
337
|
+
if (!o.stdout)
|
|
338
|
+
throw new Error('Environment reveal requires --stdout. Avoid logging or retaining the plaintext.');
|
|
339
|
+
const r = await (await hivesClient()).hives.environment.reveal(id, key);
|
|
340
|
+
emit(r, () => r.value);
|
|
341
|
+
});
|
|
287
342
|
secrets.command('list <project_id>').description('List secret metadata without values')
|
|
288
343
|
.option('--page <number>', 'page number (1–10000)').option('--per-page <number>', 'items per page (1–100)')
|
|
289
344
|
.action(async (id, o) => {
|
|
@@ -396,6 +451,7 @@ export function buildProgram(deps = {}) {
|
|
|
396
451
|
});
|
|
397
452
|
addHenryCommands(program, client, value => out.log(JSON.stringify(value, null, 2)));
|
|
398
453
|
registerFleets(program, client, (data) => emit(data, () => JSON.stringify(data, null, 2)));
|
|
454
|
+
registerFlocks(program, client, (data) => emit(data, () => JSON.stringify(data, null, 2)), env);
|
|
399
455
|
registerBeacons(program, client, emit);
|
|
400
456
|
// ---- status ----------------------------------------------------------
|
|
401
457
|
program.command('status').description('Which Genesis services are wired up').action(async () => {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const LIFEID_ISSUER = "https://auth.life.one";
|
|
2
|
+
export declare const GENESIS_CLI_PRODUCT = "genesis-cli";
|
|
3
|
+
export declare const CALLBACK_PATH = "/lifeid/callback";
|
|
4
|
+
/** Exact Auth-owned admission metadata; supplying it does not register a client. */
|
|
5
|
+
export interface NativeRegistration {
|
|
6
|
+
productId: string;
|
|
7
|
+
redirectUri: string;
|
|
8
|
+
audience: string;
|
|
9
|
+
}
|
|
10
|
+
export interface NativeAttempt {
|
|
11
|
+
readonly browserUrl: string;
|
|
12
|
+
readonly codeVerifier: string;
|
|
13
|
+
readonly registration: NativeRegistration;
|
|
14
|
+
readonly code: Promise<string>;
|
|
15
|
+
readonly signal: AbortSignal;
|
|
16
|
+
close(): void;
|
|
17
|
+
}
|
|
18
|
+
export declare function validateRegistration(r: NativeRegistration): number;
|
|
19
|
+
/** Bind the sole registered port before the caller opens or prints the browser URL. */
|
|
20
|
+
export declare function startNativeAttempt(registration: NativeRegistration, options?: {
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
organizationHint?: string;
|
|
24
|
+
}): Promise<NativeAttempt>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
export const LIFEID_ISSUER = 'https://auth.life.one';
|
|
4
|
+
export const GENESIS_CLI_PRODUCT = 'genesis-cli';
|
|
5
|
+
export const CALLBACK_PATH = '/lifeid/callback';
|
|
6
|
+
export function validateRegistration(r) {
|
|
7
|
+
const m = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{3,4})\/lifeid\/callback$/.exec(r.redirectUri);
|
|
8
|
+
const port = m ? Number(m[1]) : 0;
|
|
9
|
+
if (r.productId !== GENESIS_CLI_PRODUCT || !m || port < 1024 || port > 65535
|
|
10
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(r.audience)) {
|
|
11
|
+
throw new Error('LifeID native login requires the exact admitted Genesis product, loopback callback and audience.');
|
|
12
|
+
}
|
|
13
|
+
return port;
|
|
14
|
+
}
|
|
15
|
+
/** Bind the sole registered port before the caller opens or prints the browser URL. */
|
|
16
|
+
export async function startNativeAttempt(registration, options = {}) {
|
|
17
|
+
registration = { ...registration };
|
|
18
|
+
const port = validateRegistration(registration);
|
|
19
|
+
const timeout = options.timeoutMs ?? 300_000;
|
|
20
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 300_000)
|
|
21
|
+
throw new Error('Invalid login deadline.');
|
|
22
|
+
if (options.signal?.aborted)
|
|
23
|
+
throw new Error('LifeID login cancelled.');
|
|
24
|
+
if (options.organizationHint && !/^o_[0-9A-HJKMNP-TV-Z]{26}$/.test(options.organizationHint)) {
|
|
25
|
+
throw new Error('Invalid LifeID organization hint.');
|
|
26
|
+
}
|
|
27
|
+
const verifier = randomBytes(48).toString('base64url');
|
|
28
|
+
const controller = new AbortController();
|
|
29
|
+
let settled = false;
|
|
30
|
+
let resolveCode;
|
|
31
|
+
let rejectCode;
|
|
32
|
+
const code = new Promise((resolve, reject) => { resolveCode = resolve; rejectCode = reject; });
|
|
33
|
+
// Binding/open-browser errors may happen before the caller awaits the code.
|
|
34
|
+
void code.catch(() => { });
|
|
35
|
+
const server = createServer({ maxHeaderSize: 4096 }, (req, res) => {
|
|
36
|
+
const reject = (status) => {
|
|
37
|
+
res.writeHead(status, { 'content-type': 'text/plain', 'cache-control': 'no-store', connection: 'close' });
|
|
38
|
+
res.end('Invalid LifeID callback.');
|
|
39
|
+
};
|
|
40
|
+
// A malformed request cannot consume a pending code. No body is read/logged.
|
|
41
|
+
if (settled || req.method !== 'GET' || req.headers.host !== `127.0.0.1:${port}`
|
|
42
|
+
|| req.headers.origin !== undefined || req.headers['transfer-encoding'] !== undefined
|
|
43
|
+
|| (req.headers['content-length'] !== undefined && req.headers['content-length'] !== '0')) {
|
|
44
|
+
reject(400);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const m = /^\/lifeid\/callback\?life_code=([A-Za-z0-9_-]{43})$/.exec(req.url ?? '');
|
|
48
|
+
if (!m) {
|
|
49
|
+
reject(400);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
settled = true;
|
|
53
|
+
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store',
|
|
54
|
+
'referrer-policy': 'no-referrer', 'content-security-policy': "default-src 'none'", connection: 'close' });
|
|
55
|
+
res.end('LifeID callback received. Return to Genesis CLI.');
|
|
56
|
+
server.close();
|
|
57
|
+
resolveCode(m[1]);
|
|
58
|
+
});
|
|
59
|
+
server.requestTimeout = 5000;
|
|
60
|
+
server.headersTimeout = 5000;
|
|
61
|
+
server.keepAliveTimeout = 1;
|
|
62
|
+
server.maxConnections = 4;
|
|
63
|
+
const close = () => {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
66
|
+
server.close();
|
|
67
|
+
server.closeAllConnections();
|
|
68
|
+
if (!settled) {
|
|
69
|
+
settled = true;
|
|
70
|
+
rejectCode(new Error('LifeID login cancelled.'));
|
|
71
|
+
}
|
|
72
|
+
controller.abort();
|
|
73
|
+
};
|
|
74
|
+
const onAbort = () => close();
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
if (!settled) {
|
|
77
|
+
settled = true;
|
|
78
|
+
rejectCode(new Error('LifeID login timed out. Run `genesis login` again.'));
|
|
79
|
+
}
|
|
80
|
+
close();
|
|
81
|
+
}, timeout);
|
|
82
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
83
|
+
try {
|
|
84
|
+
await new Promise((resolve, reject) => {
|
|
85
|
+
const aborted = () => { cleanup(); reject(new Error('LifeID login cancelled.')); };
|
|
86
|
+
const cleanup = () => { server.removeListener('error', error); controller.signal.removeEventListener('abort', aborted); };
|
|
87
|
+
const error = () => { cleanup(); reject(new Error('The registered LifeID callback port is unavailable. No other port was tried.')); };
|
|
88
|
+
server.once('error', error);
|
|
89
|
+
controller.signal.addEventListener('abort', aborted, { once: true });
|
|
90
|
+
if (controller.signal.aborted) {
|
|
91
|
+
aborted();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
server.listen({ port, host: '127.0.0.1', signal: controller.signal }, () => { cleanup(); resolve(); });
|
|
95
|
+
});
|
|
96
|
+
if (controller.signal.aborted || options.signal?.aborted)
|
|
97
|
+
throw new Error('LifeID login cancelled.');
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
close();
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
server.on('error', () => close());
|
|
104
|
+
const url = new URL('/v4/lifeid/start', LIFEID_ISSUER);
|
|
105
|
+
url.searchParams.set('product_id', registration.productId);
|
|
106
|
+
url.searchParams.set('return_path', '/');
|
|
107
|
+
url.searchParams.set('code_challenge', createHash('sha256').update(verifier).digest('base64url'));
|
|
108
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
109
|
+
if (options.organizationHint)
|
|
110
|
+
url.searchParams.set('org_hint', options.organizationHint);
|
|
111
|
+
return { browserUrl: url.toString(), codeVerifier: verifier, registration: { ...registration }, code,
|
|
112
|
+
signal: controller.signal, close };
|
|
113
|
+
}
|
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');
|
package/dist/store.d.ts
CHANGED
|
@@ -12,6 +12,16 @@ export interface SavedLogin {
|
|
|
12
12
|
/** epoch seconds, from the access token's exp claim */
|
|
13
13
|
expires_at?: number;
|
|
14
14
|
client_id: string;
|
|
15
|
+
/** LifeID bridge provenance. Legacy raw AuthKit credentials are never reused. */
|
|
16
|
+
auth?: {
|
|
17
|
+
kind: 'lifeid-bridge';
|
|
18
|
+
issuer: string;
|
|
19
|
+
audience: string;
|
|
20
|
+
product_id: string;
|
|
21
|
+
redirect_uri: string;
|
|
22
|
+
principal_id: string;
|
|
23
|
+
session_id: string;
|
|
24
|
+
};
|
|
15
25
|
}
|
|
16
26
|
export interface CredentialStore {
|
|
17
27
|
get(): Promise<SavedLogin | undefined>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lifesi/genesis-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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": {
|
|
@@ -13,7 +18,7 @@
|
|
|
13
18
|
"dependencies": {
|
|
14
19
|
"commander": "^14.0.0",
|
|
15
20
|
"undici": "6.29.0",
|
|
16
|
-
"@lifesi/genesis": "0.1.
|
|
21
|
+
"@lifesi/genesis": "0.1.3"
|
|
17
22
|
},
|
|
18
23
|
"optionalDependencies": {
|
|
19
24
|
"@napi-rs/keyring": "^1.2.0"
|