@lifesi/genesis-cli 0.1.2 → 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/fleets.js +18 -0
- package/dist/hives-marketplace.d.ts +10 -0
- package/dist/hives-marketplace.js +29 -0
- package/dist/hives-resources.js +8 -0
- package/dist/index.js +12 -6
- package/dist/lifeid-native.d.ts +24 -0
- package/dist/lifeid-native.js +113 -0
- package/dist/store.d.ts +10 -0
- package/package.json +2 -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/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
|
+
}
|
package/dist/hives-resources.js
CHANGED
|
@@ -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,
|
|
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';
|
|
@@ -82,11 +82,17 @@ export function buildProgram(deps = {}) {
|
|
|
82
82
|
program.command('login').description('Sign in with LifeID in your browser').option('--no-browser', 'just print the link')
|
|
83
83
|
.action(async (o) => {
|
|
84
84
|
const store = await getStore();
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
(
|
|
89
|
-
|
|
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
|
+
}
|
|
90
96
|
await store.set(s);
|
|
91
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}.`);
|
|
92
98
|
});
|
|
@@ -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.
|
|
3
|
+
"version": "0.1.3",
|
|
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.
|
|
21
|
+
"@lifesi/genesis": "0.1.3"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
24
|
"@napi-rs/keyring": "^1.2.0"
|