@lifesi/genesis-cli 0.1.2 → 0.1.4

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 CHANGED
@@ -1,30 +1,24 @@
1
1
  import type { CredentialStore, SavedLogin } from './store.js';
2
- /**
3
- * Browser/device login via LifeID (WorkOS AuthKit CLI Auth, RFC 8628).
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
- clientId?: string;
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 interface DeviceCode {
18
- device_code: string;
19
- user_code: string;
20
- verification_uri: string;
21
- verification_uri_complete: string;
22
- expires_in: number;
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 pollDevice(c: AuthConfig, d: DeviceCode): Promise<SavedLogin>;
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
- /** Credential provider for the SDK: saved login, refreshed if within 60s of expiry. */
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 { clientId: env.GENESIS_CLIENT_ID, authApi: (env.GENESIS_AUTH_API ?? 'https://api.workos.com').replace(/\/+$/, ''), fetch: f };
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 needClient(c) {
5
- if (!c.clientId)
6
- throw new Error('Login is not configured: set GENESIS_CLIENT_ID to the LifeID AuthKit client id (staging), or use GENESIS_API_KEY.');
7
- return c.clientId;
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 post(c, path, body) {
10
- const res = await c.fetch(`${c.authApi}${path}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: new URLSearchParams(body).toString() });
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
- export async function startDevice(c) {
15
- const r = await post(c, '/user_management/authorize/device', { client_id: needClient(c) });
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
- export function jwtExp(token) {
20
+ function tokenClaims(token) {
21
21
  try {
22
- return JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()).exp;
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
- function toSaved(c, j) {
29
- return { access_token: j.access_token, refresh_token: j.refresh_token, user: j.user, organization_id: j.organization_id, expires_at: jwtExp(j.access_token), client_id: needClient(c) };
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
- export async function pollDevice(c, d) {
32
- const sleep = c.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
33
- let interval = (d.interval || 5) * 1000;
34
- const deadline = Date.now() + (d.expires_in || 300) * 1000;
35
- while (Date.now() < deadline) {
36
- await sleep(interval);
37
- const r = await post(c, '/user_management/authenticate', { grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: d.device_code, client_id: needClient(c) });
38
- if (r.ok)
39
- return toSaved(c, r.json);
40
- const err = r.json.error;
41
- if (err === 'authorization_pending')
42
- continue;
43
- if (err === 'slow_down') {
44
- interval += 5000;
45
- continue;
46
- }
47
- if (err === 'access_denied')
48
- throw new Error('Login was denied in the browser.');
49
- if (err === 'expired_token')
50
- break;
51
- throw new Error(`Login failed (${r.status}): ${r.json.error_description ?? err ?? 'unknown error'}`);
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
- if (!s.refresh_token)
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 body = { grant_type: 'refresh_token', refresh_token: s.refresh_token, client_id: s.client_id };
59
- if (organizationId)
60
- body.organization_id = organizationId;
61
- const r = await post(c, '/user_management/authenticate', body);
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
- /** Credential provider for the SDK: saved login, refreshed if within 60s of expiry. */
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
- const exp = s.expires_at ?? jwtExp(s.access_token);
73
- if (exp && exp - 60 < now()) {
74
- s = await refresh(c, s, s.organization_id);
75
- await store.set(s);
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/fleets.js CHANGED
@@ -21,6 +21,24 @@ export async function readFoundryKey(input = process.stdin) {
21
21
  }
22
22
  export function registerFleets(program, client, emit, keyInput = readFoundryKey) {
23
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)));
24
42
  const instances = fleets.command('instances').description('Actual admitted Henry Host status');
25
43
  instances.command('list').option('--limit <number>').option('--after <instance_id>').action(async (options) => {
26
44
  emit(await (await client()).fleets.instances.list({ ...(options.limit ? { limit: Number(options.limit) } : {}), ...(options.after ? { after: options.after } : {}) }));
@@ -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
+ }
@@ -13,7 +13,7 @@ export async function closeHivesNodeTransport() {
13
13
  export function hivesOperationBudget(id) {
14
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
15
  return 100_000;
16
- if (id === 'hives_cancel_deployment' || id === 'hives_delete_deployment')
16
+ if (id === 'hives_cancel_deployment' || id === 'hives_delete_deployment' || id === 'hives_stop_deployment')
17
17
  return 330_000;
18
18
  if (['hives_create_deployment', 'hives_upload_deployment', 'hives_invoke_deployment', 'hives_invoke_function', 'hives_invoke_function_async'].includes(id))
19
19
  return 40_000;
@@ -1,3 +1,4 @@
1
+ import { registerHivesMarketplace } from './hives-marketplace.js';
1
2
  import { createReadStream } from 'node:fs';
2
3
  import { stat, writeFile } from 'node:fs/promises';
3
4
  import { readFunctionJson } from './function-input.js';
@@ -26,6 +27,7 @@ function number(value, min, max) { if (value === undefined)
26
27
  return undefined; if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < min || Number(value) > max)
27
28
  throw new Error('Invalid bounded numeric option.'); return Number(value); }
28
29
  export function registerHivesResources(hives, deps) {
30
+ registerHivesMarketplace(hives, deps);
29
31
  const output = (data) => deps.emit(data, () => JSON.stringify(data, null, 2));
30
32
  const read = deps.readRequest ?? readFunctionJson;
31
33
  const cells = hives.command('cells').description('Owned GCS buckets and objects; uncertain mutations require readback/reconciliation');
@@ -63,6 +65,12 @@ export function registerHivesResources(hives, deps) {
63
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) })));
64
66
  for (const action of ['replay', 'delete-dead-letter'])
65
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))));
66
74
  const runtime = hives.command('runtimes').description('Compiled serving implementation metadata; not available capacity');
67
75
  runtime.command('list').action(async () => output(await (await deps.client()).hives.runtimes.list()));
68
76
  runtime.command('project <project_id>').action(async (project) => output(await (await deps.client()).hives.runtimes.project(project)));
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ 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, pollDevice, refresh, savedLoginProvider, startDevice } from './auth.js';
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';
@@ -32,7 +32,7 @@ export function buildProgram(deps = {}) {
32
32
  const auth = { ...authConfig(env, f), sleep: deps.sleep };
33
33
  const program = new Command('genesis')
34
34
  .description('One CLI for every Genesis service.')
35
- .version('0.1.2')
35
+ .version('0.1.4')
36
36
  .option('--json', 'machine-readable JSON output')
37
37
  .option('--api-key <key>', 'use this API key (default: GENESIS_API_KEY, then your `genesis login`)')
38
38
  .option('--base-url <url>', 'gateway URL (default: GENESIS_BASE_URL or https://console.life.one/api)')
@@ -48,7 +48,7 @@ export function buildProgram(deps = {}) {
48
48
  const opts = {
49
49
  apiKey: o.apiKey ?? env.GENESIS_API_KEY, token: env.GENESIS_TOKEN,
50
50
  baseUrl: o.baseUrl ?? env.GENESIS_BASE_URL, routing: o.direct || env.GENESIS_ROUTING === 'direct' ? 'direct' : 'gateway',
51
- fetch: f, userAgent: 'genesis-cli/0.1.2',
51
+ fetch: f, userAgent: 'genesis-cli/0.1.4',
52
52
  credentials: store ? savedLoginProvider(auth, store) : undefined,
53
53
  };
54
54
  return new Genesis(hivesTransport ? { ...opts, fetch: createHivesNodeTransport(opts, deps.signal) } : opts);
@@ -77,16 +77,28 @@ export function buildProgram(deps = {}) {
77
77
  return s.organization_id;
78
78
  return (await gen.whoami()).org;
79
79
  };
80
+ const silos = program.command('silos').description('Serverless PostgreSQL');
81
+ const silosProjects = silos.command('projects').description('Projects in your verified organization');
82
+ silosProjects.command('list').action(async () => out.log(JSON.stringify(await (await client()).silos.projects.list(), null, 2)));
83
+ silosProjects.command('get <project_id>').action(async (id) => out.log(JSON.stringify(await (await client()).silos.projects.get(id), null, 2)));
84
+ silosProjects.command('create').requiredOption('--name <name>', 'Project name').requiredOption('--slug <slug>', 'Unique project slug')
85
+ .action(async (options) => out.log(JSON.stringify(await (await client()).silos.projects.create({ name: options.name, slug: options.slug }), null, 2)));
80
86
  registerDials(program, { client, orgId, emit });
81
87
  // ---- auth ------------------------------------------------------------
82
88
  program.command('login').description('Sign in with LifeID in your browser').option('--no-browser', 'just print the link')
83
89
  .action(async (o) => {
84
90
  const store = await getStore();
85
- const d = await startDevice(auth);
86
- out.err(`\n Your code: ${c.bold(d.user_code)}\n Opening ${d.verification_uri_complete}\n ${c.dim('Waiting for you to approve in the browser…')}\n`);
87
- if (o.browser)
88
- (deps.openBrowser ?? defaultOpen)(d.verification_uri_complete);
89
- const s = await pollDevice(auth, d);
91
+ const attempt = await startLogin(auth, { signal: deps.signal });
92
+ let s;
93
+ try {
94
+ out.err(`\n Open ${attempt.browserUrl}\n ${c.dim('Waiting for LifeID sign-in in the browser…')}\n`);
95
+ if (o.browser)
96
+ (deps.openBrowser ?? defaultOpen)(attempt.browserUrl);
97
+ s = await completeLogin(auth, attempt);
98
+ }
99
+ finally {
100
+ attempt.close();
101
+ }
90
102
  await store.set(s);
91
103
  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}.`);
92
104
  });
@@ -215,6 +227,8 @@ export function buildProgram(deps = {}) {
215
227
  .action(async (project, deployment, o) => emitDeployment(await (await hivesClient()).hives.deployments.rollback(project, deployment, { reason: o.reason })));
216
228
  deployments.command('cancel <project_id> <deployment_id>')
217
229
  .action(async (project, deployment) => emitDeployment(await (await hivesClient()).hives.deployments.cancel(project, deployment)));
230
+ deployments.command('stop <project_id> <deployment_id>').description('Stop the runtime; read its state before explicitly reconciling an uncertain result')
231
+ .action(async (project, deployment) => emitDeployment(await (await hivesClient()).hives.deployments.stop(project, deployment)));
218
232
  deployments.command('delete <project_id> <deployment_id>').description('Delete a deployment that is not currently active')
219
233
  .action(async (project, deployment) => { const result = await (await hivesClient()).hives.deployments.delete(project, deployment); emit(result, () => `Deleted deployment ${deployment}.`); });
220
234
  deployments.command('logs <project_id> <deployment_id>').description('Read real persisted deployment logs')
@@ -430,6 +444,14 @@ export function buildProgram(deps = {}) {
430
444
  const r = await (await client()).relays.zones.list(o.search ? { search: o.search } : {});
431
445
  emit(r, () => table((r.zones ?? []), ['name', 'type', 'record_count', 'dnssec_enabled', 'status']));
432
446
  });
447
+ relays.command('zone <zone>').description('Read one DNS zone').action(async (zone) => {
448
+ const result = await (await client()).relays.zones.get(zone);
449
+ emit(result, () => JSON.stringify(result, null, 2));
450
+ });
451
+ relays.command('dnssec <zone>').description('Read DNSSEC status and authoritative key availability').action(async (zone) => {
452
+ const result = await (await client()).relays.zones.dnssec(zone);
453
+ emit(result, () => JSON.stringify(result, null, 2));
454
+ });
433
455
  const records = relays.command('records').description('DNS records in a zone');
434
456
  records.command('list <zone>', { isDefault: true }).option('--type <type>').action(async (zone, o) => {
435
457
  const r = await (await client()).relays.records.list(zone, o.type ? { type: o.type } : {});
@@ -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/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,6 @@
1
1
  {
2
2
  "name": "@lifesi/genesis-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/l1felabs/genesis-api.git",
@@ -18,7 +18,7 @@
18
18
  "dependencies": {
19
19
  "commander": "^14.0.0",
20
20
  "undici": "6.29.0",
21
- "@lifesi/genesis": "0.1.2"
21
+ "@lifesi/genesis": "0.1.4"
22
22
  },
23
23
  "optionalDependencies": {
24
24
  "@napi-rs/keyring": "^1.2.0"