@almyty/client 1.2.0 → 1.5.0
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/README.md +3 -3
- package/dist/client.d.ts +38 -1
- package/dist/client.js +170 -39
- package/dist/credentials.d.ts +24 -2
- package/dist/credentials.js +28 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -29,8 +29,8 @@ almyty is the full-stack platform for AI agents, agnostic by design: any LLM, an
|
|
|
29
29
|
API turned into tools, served over MCP, A2A, UTCP, and Agent Skills. Open source,
|
|
30
30
|
no lock-in.
|
|
31
31
|
|
|
32
|
-
- Website
|
|
33
|
-
- Docs
|
|
34
|
-
- Source
|
|
32
|
+
- Website: https://almyty.com
|
|
33
|
+
- Docs: https://docs.almyty.com
|
|
34
|
+
- Source: https://github.com/almyty-inc/almyty
|
|
35
35
|
|
|
36
36
|
Apache-2.0 © Almyty Inc.
|
package/dist/client.d.ts
CHANGED
|
@@ -80,6 +80,21 @@ export interface CodingSession {
|
|
|
80
80
|
}
|
|
81
81
|
/** Callback for stream events. */
|
|
82
82
|
export type StreamEventHandler = (event: StreamEvent) => void;
|
|
83
|
+
/** Whether a rejection is a caller-requested abort rather than a failure. */
|
|
84
|
+
export declare function isAbortError(err: unknown): boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Turn one SSE frame's lines into a StreamEvent, or null when the frame
|
|
87
|
+
* carries nothing usable (a keep-alive comment, or malformed JSON).
|
|
88
|
+
*
|
|
89
|
+
* Server frames are not uniform. Run events arrive wrapped as
|
|
90
|
+
* `{type, data, timestamp}`; coding and pipeline events put their
|
|
91
|
+
* fields at the top level and may also carry a `data` object. So the
|
|
92
|
+
* envelope's own `data` object is flattened onto the result and the
|
|
93
|
+
* top-level fields are kept. Reading `event.data.content` off an
|
|
94
|
+
* `llm.chunk` returned undefined before this, which is why a streaming
|
|
95
|
+
* reply used to arrive as one block once the run had already finished.
|
|
96
|
+
*/
|
|
97
|
+
export declare function parseSseFrame(lines: string[]): StreamEvent | null;
|
|
83
98
|
export declare class AlmytyClient {
|
|
84
99
|
private readonly baseUrl;
|
|
85
100
|
private readonly token;
|
|
@@ -89,8 +104,11 @@ export declare class AlmytyClient {
|
|
|
89
104
|
/**
|
|
90
105
|
* Connect to an SSE endpoint and call handler for each event.
|
|
91
106
|
* Returns when the stream ends or a terminal event is received.
|
|
107
|
+
*
|
|
108
|
+
* `init` lets a caller POST (the workflow pipeline stream does);
|
|
109
|
+
* omitted, this is a GET.
|
|
92
110
|
*/
|
|
93
|
-
streamSSE(path: string, handler: StreamEventHandler, signal?: AbortSignal): Promise<void>;
|
|
111
|
+
streamSSE(path: string, handler: StreamEventHandler, signal?: AbortSignal, init?: RequestInit): Promise<void>;
|
|
94
112
|
private unwrap;
|
|
95
113
|
listAgents(): Promise<AgentInfo[]>;
|
|
96
114
|
getAgent(id: string): Promise<AgentInfo>;
|
|
@@ -159,6 +177,16 @@ export declare class GatewayClient {
|
|
|
159
177
|
conversationId?: string;
|
|
160
178
|
}): Promise<AgentRun>;
|
|
161
179
|
getRun(runId: string): Promise<AgentRun>;
|
|
180
|
+
/**
|
|
181
|
+
* Stream a workflow agent's pipeline as it executes.
|
|
182
|
+
*
|
|
183
|
+
* The unified endpoint answers POST /:org/:agent/stream with SSE:
|
|
184
|
+
* execution.started, node.started, node.output, node.completed,
|
|
185
|
+
* node.skipped, then execution.completed or execution.failed. Without
|
|
186
|
+
* this a multi-node pipeline is a blocking POST with nothing to show
|
|
187
|
+
* while it runs.
|
|
188
|
+
*/
|
|
189
|
+
streamInvoke(input: Record<string, any>, handler: StreamEventHandler, signal?: AbortSignal): Promise<void>;
|
|
162
190
|
/**
|
|
163
191
|
* Stream run events via SSE. Calls handler for each event
|
|
164
192
|
* (llm.started, llm.chunk, llm.response, tool.started, tool.result,
|
|
@@ -175,9 +203,18 @@ export declare class GatewayClient {
|
|
|
175
203
|
}>>;
|
|
176
204
|
sendRunInput(runId: string, input: string): Promise<void>;
|
|
177
205
|
cancelRun(runId: string): Promise<void>;
|
|
206
|
+
/**
|
|
207
|
+
* Cancel a workflow execution.
|
|
208
|
+
*
|
|
209
|
+
* The workflow counterpart of cancelRun. A workflow run is an execution,
|
|
210
|
+
* not a run, so cancelRun could never stop one -- a Ctrl-C that did not
|
|
211
|
+
* also drop the SSE connection left the pipeline running and billing.
|
|
212
|
+
*/
|
|
213
|
+
cancelExecution(executionId: string): Promise<void>;
|
|
178
214
|
pollRun(runId: string, options?: {
|
|
179
215
|
intervalMs?: number;
|
|
180
216
|
timeoutMs?: number;
|
|
181
217
|
onStep?: (run: AgentRun) => void;
|
|
218
|
+
signal?: AbortSignal;
|
|
182
219
|
}): Promise<AgentRun>;
|
|
183
220
|
}
|
package/dist/client.js
CHANGED
|
@@ -6,7 +6,70 @@
|
|
|
6
6
|
* autonomous run management, and polling.
|
|
7
7
|
*/
|
|
8
8
|
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'timeout']);
|
|
9
|
-
const TERMINAL_EVENT_TYPES = new Set([
|
|
9
|
+
const TERMINAL_EVENT_TYPES = new Set([
|
|
10
|
+
'run.completed',
|
|
11
|
+
'run.failed',
|
|
12
|
+
'run.cancelled',
|
|
13
|
+
'coding.exit',
|
|
14
|
+
// The workflow pipeline stream's own terminators.
|
|
15
|
+
'execution.completed',
|
|
16
|
+
'execution.failed',
|
|
17
|
+
'done',
|
|
18
|
+
]);
|
|
19
|
+
/** Whether a rejection is a caller-requested abort rather than a failure. */
|
|
20
|
+
export function isAbortError(err) {
|
|
21
|
+
const e = err;
|
|
22
|
+
return !!e && (e.name === 'AbortError' || e.code === 'ABORT_ERR');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Turn one SSE frame's lines into a StreamEvent, or null when the frame
|
|
26
|
+
* carries nothing usable (a keep-alive comment, or malformed JSON).
|
|
27
|
+
*
|
|
28
|
+
* Server frames are not uniform. Run events arrive wrapped as
|
|
29
|
+
* `{type, data, timestamp}`; coding and pipeline events put their
|
|
30
|
+
* fields at the top level and may also carry a `data` object. So the
|
|
31
|
+
* envelope's own `data` object is flattened onto the result and the
|
|
32
|
+
* top-level fields are kept. Reading `event.data.content` off an
|
|
33
|
+
* `llm.chunk` returned undefined before this, which is why a streaming
|
|
34
|
+
* reply used to arrive as one block once the run had already finished.
|
|
35
|
+
*/
|
|
36
|
+
export function parseSseFrame(lines) {
|
|
37
|
+
let eventType = 'message';
|
|
38
|
+
const dataLines = [];
|
|
39
|
+
for (const line of lines) {
|
|
40
|
+
// A line starting with ':' is a comment. The server sends
|
|
41
|
+
// ': keep-alive' every 15s on the coding stream.
|
|
42
|
+
if (line.startsWith(':'))
|
|
43
|
+
continue;
|
|
44
|
+
if (line.startsWith('event:')) {
|
|
45
|
+
eventType = line.slice(6).trim();
|
|
46
|
+
}
|
|
47
|
+
else if (line.startsWith('data:')) {
|
|
48
|
+
const value = line.slice(5);
|
|
49
|
+
dataLines.push(value.startsWith(' ') ? value.slice(1) : value);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!dataLines.length)
|
|
53
|
+
return null;
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(dataLines.join('\n'));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
62
|
+
return null;
|
|
63
|
+
const envelope = parsed;
|
|
64
|
+
const inner = envelope.data;
|
|
65
|
+
const flat = inner && typeof inner === 'object' && !Array.isArray(inner)
|
|
66
|
+
? { ...envelope, ...inner }
|
|
67
|
+
: envelope;
|
|
68
|
+
return {
|
|
69
|
+
type: typeof envelope.type === 'string' ? envelope.type : eventType,
|
|
70
|
+
data: flat,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
10
73
|
export class AlmytyClient {
|
|
11
74
|
baseUrl;
|
|
12
75
|
token;
|
|
@@ -27,13 +90,27 @@ export class AlmytyClient {
|
|
|
27
90
|
...this.headers(),
|
|
28
91
|
...(init.headers || {}),
|
|
29
92
|
};
|
|
30
|
-
|
|
93
|
+
let res;
|
|
94
|
+
try {
|
|
95
|
+
res = await fetch(url, { ...init, headers });
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
// A transport failure carries no status, so callers that want to
|
|
99
|
+
// say something useful about it need the cause and the host.
|
|
100
|
+
throw Object.assign(new Error(err?.message || 'Network request failed'), {
|
|
101
|
+
url,
|
|
102
|
+
cause: err,
|
|
103
|
+
networkError: true,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
31
106
|
if (!res.ok) {
|
|
32
|
-
if (res.status === 401) {
|
|
33
|
-
throw new Error('Authentication failed. Run: npx @almyty/auth login');
|
|
34
|
-
}
|
|
35
107
|
const text = await res.text().catch(() => '');
|
|
36
|
-
|
|
108
|
+
// Status and body ride on the error. A CLI cannot turn
|
|
109
|
+
// "API error 400: {...}" into a sentence a user can act on without
|
|
110
|
+
// them, and parsing the message string back apart is worse.
|
|
111
|
+
throw Object.assign(new Error(res.status === 401
|
|
112
|
+
? 'Authentication failed. Run: npx @almyty/auth login'
|
|
113
|
+
: `API error ${res.status}: ${text}`), { status: res.status, body: text, url });
|
|
37
114
|
}
|
|
38
115
|
if (res.status === 204)
|
|
39
116
|
return null;
|
|
@@ -42,16 +119,24 @@ export class AlmytyClient {
|
|
|
42
119
|
/**
|
|
43
120
|
* Connect to an SSE endpoint and call handler for each event.
|
|
44
121
|
* Returns when the stream ends or a terminal event is received.
|
|
122
|
+
*
|
|
123
|
+
* `init` lets a caller POST (the workflow pipeline stream does);
|
|
124
|
+
* omitted, this is a GET.
|
|
45
125
|
*/
|
|
46
|
-
async streamSSE(path, handler, signal) {
|
|
126
|
+
async streamSSE(path, handler, signal, init = {}) {
|
|
47
127
|
const url = `${this.baseUrl}${path}`;
|
|
48
128
|
const res = await fetch(url, {
|
|
49
|
-
|
|
129
|
+
...init,
|
|
130
|
+
headers: { ...this.headers(), Accept: 'text/event-stream', ...(init.headers || {}) },
|
|
50
131
|
signal,
|
|
51
132
|
});
|
|
52
133
|
if (!res.ok) {
|
|
53
134
|
const text = await res.text().catch(() => '');
|
|
54
|
-
throw new Error(`SSE ${res.status}: ${text}`)
|
|
135
|
+
throw Object.assign(new Error(`SSE ${res.status}: ${text}`), {
|
|
136
|
+
status: res.status,
|
|
137
|
+
body: text,
|
|
138
|
+
url,
|
|
139
|
+
});
|
|
55
140
|
}
|
|
56
141
|
const body = res.body;
|
|
57
142
|
if (!body)
|
|
@@ -59,44 +144,55 @@ export class AlmytyClient {
|
|
|
59
144
|
const reader = body.getReader();
|
|
60
145
|
const decoder = new TextDecoder();
|
|
61
146
|
let buffer = '';
|
|
147
|
+
// Frame state lives outside the read loop. It used to be declared
|
|
148
|
+
// per chunk, so any frame whose terminating blank line arrived in
|
|
149
|
+
// the next chunk was dropped -- which is most of them on a busy
|
|
150
|
+
// stream, and is why streamed tokens never reached the CLI.
|
|
151
|
+
let frame = [];
|
|
62
152
|
try {
|
|
63
|
-
|
|
153
|
+
for (;;) {
|
|
64
154
|
const { value, done } = await reader.read();
|
|
65
155
|
if (done)
|
|
66
156
|
break;
|
|
67
157
|
buffer += decoder.decode(value, { stream: true });
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (dataLines.length) {
|
|
83
|
-
const raw = dataLines.join('\n');
|
|
84
|
-
try {
|
|
85
|
-
const data = JSON.parse(raw);
|
|
86
|
-
const event = { type: data.type || eventType, data };
|
|
87
|
-
handler(event);
|
|
88
|
-
if (TERMINAL_EVENT_TYPES.has(event.type))
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
catch { /* skip malformed */ }
|
|
92
|
-
dataLines = [];
|
|
93
|
-
eventType = 'message';
|
|
158
|
+
let nl = buffer.indexOf('\n');
|
|
159
|
+
while (nl !== -1) {
|
|
160
|
+
// CRLF is legal in SSE and a proxy may rewrite to it. Without
|
|
161
|
+
// stripping the carriage return, no line ever compares equal
|
|
162
|
+
// to '' and not one frame is ever dispatched.
|
|
163
|
+
const line = buffer.slice(0, nl).replace(/\r$/, '');
|
|
164
|
+
buffer = buffer.slice(nl + 1);
|
|
165
|
+
if (line === '') {
|
|
166
|
+
const event = parseSseFrame(frame);
|
|
167
|
+
frame = [];
|
|
168
|
+
if (event) {
|
|
169
|
+
handler(event);
|
|
170
|
+
if (TERMINAL_EVENT_TYPES.has(event.type))
|
|
171
|
+
return;
|
|
94
172
|
}
|
|
95
173
|
}
|
|
174
|
+
else {
|
|
175
|
+
frame.push(line);
|
|
176
|
+
}
|
|
177
|
+
nl = buffer.indexOf('\n');
|
|
96
178
|
}
|
|
97
179
|
}
|
|
180
|
+
// A server that closes without a trailing blank line still sent a
|
|
181
|
+
// frame worth reading.
|
|
182
|
+
if (buffer)
|
|
183
|
+
frame.push(buffer.replace(/\r$/, ''));
|
|
184
|
+
const tail = parseSseFrame(frame);
|
|
185
|
+
if (tail)
|
|
186
|
+
handler(tail);
|
|
98
187
|
}
|
|
99
188
|
finally {
|
|
189
|
+
// Releasing the lock does not close the connection. A terminal
|
|
190
|
+
// event returns from the loop above with the body unread and the
|
|
191
|
+
// socket still open, and an SSE endpoint holds its end open too,
|
|
192
|
+
// so the handle keeps Node's event loop alive: `almyty chat` would
|
|
193
|
+
// not exit after a streamed turn, and a REPL leaked one connection
|
|
194
|
+
// per answer. Cancelling the body is what actually closes it.
|
|
195
|
+
await reader.cancel().catch(() => undefined);
|
|
100
196
|
reader.releaseLock();
|
|
101
197
|
}
|
|
102
198
|
}
|
|
@@ -320,12 +416,29 @@ export class GatewayClient {
|
|
|
320
416
|
output: run.output,
|
|
321
417
|
error: run.error,
|
|
322
418
|
steps: run.steps,
|
|
419
|
+
totalCost: run.totalCost,
|
|
420
|
+
totalTokens: run.totalTokens,
|
|
323
421
|
};
|
|
324
422
|
}
|
|
325
423
|
async getRun(runId) {
|
|
326
424
|
const data = await this.client.request(`${this.prefix}/runs/${encodeURIComponent(runId)}`);
|
|
327
425
|
return (data?.data ?? data);
|
|
328
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* Stream a workflow agent's pipeline as it executes.
|
|
429
|
+
*
|
|
430
|
+
* The unified endpoint answers POST /:org/:agent/stream with SSE:
|
|
431
|
+
* execution.started, node.started, node.output, node.completed,
|
|
432
|
+
* node.skipped, then execution.completed or execution.failed. Without
|
|
433
|
+
* this a multi-node pipeline is a blocking POST with nothing to show
|
|
434
|
+
* while it runs.
|
|
435
|
+
*/
|
|
436
|
+
async streamInvoke(input, handler, signal) {
|
|
437
|
+
await this.client.streamSSE(`${this.prefix}/stream`, handler, signal, {
|
|
438
|
+
method: 'POST',
|
|
439
|
+
body: JSON.stringify({ input }),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
329
442
|
/**
|
|
330
443
|
* Stream run events via SSE. Calls handler for each event
|
|
331
444
|
* (llm.started, llm.chunk, llm.response, tool.started, tool.result,
|
|
@@ -339,9 +452,14 @@ export class GatewayClient {
|
|
|
339
452
|
// Stream ended — get final state
|
|
340
453
|
return this.getRun(runId);
|
|
341
454
|
}
|
|
342
|
-
catch {
|
|
455
|
+
catch (err) {
|
|
456
|
+
// An abort is what the caller asked for, not a transport failure.
|
|
457
|
+
// Falling back to polling here kept a cancelled run under watch
|
|
458
|
+
// for the full five-minute poll window.
|
|
459
|
+
if (signal?.aborted || isAbortError(err))
|
|
460
|
+
throw err;
|
|
343
461
|
// SSE failed — fall back to polling until completion
|
|
344
|
-
return this.pollRun(runId);
|
|
462
|
+
return this.pollRun(runId, { signal });
|
|
345
463
|
}
|
|
346
464
|
}
|
|
347
465
|
async getConversationMessages(conversationId) {
|
|
@@ -354,12 +472,25 @@ export class GatewayClient {
|
|
|
354
472
|
async cancelRun(runId) {
|
|
355
473
|
await this.client.request(`${this.prefix}/runs/${encodeURIComponent(runId)}/cancel`, { method: 'POST' });
|
|
356
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Cancel a workflow execution.
|
|
477
|
+
*
|
|
478
|
+
* The workflow counterpart of cancelRun. A workflow run is an execution,
|
|
479
|
+
* not a run, so cancelRun could never stop one -- a Ctrl-C that did not
|
|
480
|
+
* also drop the SSE connection left the pipeline running and billing.
|
|
481
|
+
*/
|
|
482
|
+
async cancelExecution(executionId) {
|
|
483
|
+
await this.client.request(`${this.prefix}/executions/${encodeURIComponent(executionId)}/cancel`, { method: 'POST' });
|
|
484
|
+
}
|
|
357
485
|
async pollRun(runId, options = {}) {
|
|
358
486
|
const intervalMs = options.intervalMs ?? 1500;
|
|
359
487
|
const timeoutMs = options.timeoutMs ?? 5 * 60_000;
|
|
360
488
|
const deadline = Date.now() + timeoutMs;
|
|
361
489
|
let lastStepCount = -1;
|
|
362
490
|
while (Date.now() < deadline) {
|
|
491
|
+
if (options.signal?.aborted) {
|
|
492
|
+
throw Object.assign(new Error('Aborted'), { name: 'AbortError' });
|
|
493
|
+
}
|
|
363
494
|
const run = await this.getRun(runId);
|
|
364
495
|
if (Array.isArray(run.steps) && run.steps.length !== lastStepCount) {
|
|
365
496
|
lastStepCount = run.steps.length;
|
|
@@ -370,6 +501,6 @@ export class GatewayClient {
|
|
|
370
501
|
}
|
|
371
502
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
372
503
|
}
|
|
373
|
-
throw new Error(`Run ${runId} did not finish within ${Math.round(timeoutMs / 1000)}s`);
|
|
504
|
+
throw Object.assign(new Error(`Run ${runId} did not finish within ${Math.round(timeoutMs / 1000)}s`), { runId, pollTimeout: true });
|
|
374
505
|
}
|
|
375
506
|
}
|
package/dist/credentials.d.ts
CHANGED
|
@@ -10,14 +10,36 @@ export interface StoredCredentials {
|
|
|
10
10
|
token: string;
|
|
11
11
|
email?: string;
|
|
12
12
|
frontendUrl?: string;
|
|
13
|
+
/**
|
|
14
|
+
* When the token stops working, from the JWT's own `exp` claim.
|
|
15
|
+
*
|
|
16
|
+
* `@almyty/auth` writes it; this reader did not carry the field, so
|
|
17
|
+
* every CLI other than `auth` could not tell an expired credential
|
|
18
|
+
* from a live one and discovered the difference on its first API call
|
|
19
|
+
* — as a 401 from whatever the user was actually trying to do.
|
|
20
|
+
*/
|
|
21
|
+
expiresAt?: string;
|
|
13
22
|
}
|
|
23
|
+
/** Past its `exp`, treating a malformed or absent value as "no idea, assume live". */
|
|
24
|
+
export declare function credentialsExpired(creds: Pick<StoredCredentials, 'expiresAt'>): boolean;
|
|
14
25
|
export declare function loadCredentials(): StoredCredentials | null;
|
|
15
26
|
/**
|
|
16
|
-
* Resolve credentials from env or file. Returns null if nothing
|
|
27
|
+
* Resolve credentials from env or file. Returns null if nothing usable
|
|
28
|
+
* was found — an expired stored credential counts as nothing, because
|
|
29
|
+
* using it produces a 401 on the user's actual request rather than a
|
|
30
|
+
* sentence telling them to log in again.
|
|
31
|
+
*
|
|
32
|
+
* `ALMYTY_TOKEN` is never expiry-checked: it did not come from `auth
|
|
33
|
+
* login`, so there is no claim to check and no file to correct.
|
|
17
34
|
*/
|
|
18
35
|
export declare function resolveCredentials(): StoredCredentials | null;
|
|
19
36
|
/**
|
|
20
|
-
* Resolve credentials or exit
|
|
37
|
+
* Resolve credentials or exit.
|
|
38
|
+
*
|
|
39
|
+
* Exits 3, which is "not authenticated" in the exit-code table every
|
|
40
|
+
* almyty CLI shares (0 ok, 1 unexpected, 2 usage, 3 not authenticated,
|
|
41
|
+
* 4 not found, 5 the operation ran and failed). It exited 1 before, so
|
|
42
|
+
* a script could not tell a stale login from a crash.
|
|
21
43
|
*/
|
|
22
44
|
export declare function resolveCredentialsOrExit(): StoredCredentials;
|
|
23
45
|
/**
|
package/dist/credentials.js
CHANGED
|
@@ -8,6 +8,13 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
export const CREDENTIALS_FILE = join(homedir(), '.almyty', 'credentials.json');
|
|
11
|
+
/** Past its `exp`, treating a malformed or absent value as "no idea, assume live". */
|
|
12
|
+
export function credentialsExpired(creds) {
|
|
13
|
+
if (!creds.expiresAt)
|
|
14
|
+
return false;
|
|
15
|
+
const at = Date.parse(creds.expiresAt);
|
|
16
|
+
return Number.isFinite(at) && at <= Date.now();
|
|
17
|
+
}
|
|
11
18
|
export function loadCredentials() {
|
|
12
19
|
try {
|
|
13
20
|
if (!existsSync(CREDENTIALS_FILE))
|
|
@@ -19,7 +26,13 @@ export function loadCredentials() {
|
|
|
19
26
|
}
|
|
20
27
|
}
|
|
21
28
|
/**
|
|
22
|
-
* Resolve credentials from env or file. Returns null if nothing
|
|
29
|
+
* Resolve credentials from env or file. Returns null if nothing usable
|
|
30
|
+
* was found — an expired stored credential counts as nothing, because
|
|
31
|
+
* using it produces a 401 on the user's actual request rather than a
|
|
32
|
+
* sentence telling them to log in again.
|
|
33
|
+
*
|
|
34
|
+
* `ALMYTY_TOKEN` is never expiry-checked: it did not come from `auth
|
|
35
|
+
* login`, so there is no claim to check and no file to correct.
|
|
23
36
|
*/
|
|
24
37
|
export function resolveCredentials() {
|
|
25
38
|
const envToken = process.env.ALMYTY_TOKEN;
|
|
@@ -27,21 +40,32 @@ export function resolveCredentials() {
|
|
|
27
40
|
if (envToken)
|
|
28
41
|
return { url: envUrl, token: envToken };
|
|
29
42
|
const stored = loadCredentials();
|
|
30
|
-
if (stored?.token)
|
|
43
|
+
if (stored?.token && !credentialsExpired(stored))
|
|
31
44
|
return stored;
|
|
32
45
|
return null;
|
|
33
46
|
}
|
|
34
47
|
/**
|
|
35
|
-
* Resolve credentials or exit
|
|
48
|
+
* Resolve credentials or exit.
|
|
49
|
+
*
|
|
50
|
+
* Exits 3, which is "not authenticated" in the exit-code table every
|
|
51
|
+
* almyty CLI shares (0 ok, 1 unexpected, 2 usage, 3 not authenticated,
|
|
52
|
+
* 4 not found, 5 the operation ran and failed). It exited 1 before, so
|
|
53
|
+
* a script could not tell a stale login from a crash.
|
|
36
54
|
*/
|
|
37
55
|
export function resolveCredentialsOrExit() {
|
|
38
56
|
const creds = resolveCredentials();
|
|
39
57
|
if (creds)
|
|
40
58
|
return creds;
|
|
59
|
+
const stored = loadCredentials();
|
|
60
|
+
if (stored?.token && credentialsExpired(stored)) {
|
|
61
|
+
console.error(`Your login expired on ${stored.expiresAt}. Run:`);
|
|
62
|
+
console.error(' npx @almyty/auth login');
|
|
63
|
+
process.exit(3);
|
|
64
|
+
}
|
|
41
65
|
console.error('Not authenticated. Run one of:');
|
|
42
66
|
console.error(' npx @almyty/auth login');
|
|
43
67
|
console.error(' export ALMYTY_TOKEN=<your-token>');
|
|
44
|
-
process.exit(
|
|
68
|
+
process.exit(3);
|
|
45
69
|
}
|
|
46
70
|
/**
|
|
47
71
|
* Extract the default org slug from a JWT token.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { AlmytyClient, GatewayClient } from './client.js';
|
|
1
|
+
export { AlmytyClient, GatewayClient, parseSseFrame, isAbortError } from './client.js';
|
|
2
2
|
export type { AgentInfo, AgentTool, AgentRun, PipelineNode, RunLimits, StreamEvent, StreamEventHandler, RunnerSummary, RunnerCodingAgent, CodingSession, } from './client.js';
|
|
3
|
-
export { loadCredentials, resolveCredentials, resolveCredentialsOrExit, getOrgSlugFromToken, CREDENTIALS_FILE, } from './credentials.js';
|
|
3
|
+
export { loadCredentials, resolveCredentials, resolveCredentialsOrExit, getOrgSlugFromToken, credentialsExpired, CREDENTIALS_FILE, } from './credentials.js';
|
|
4
4
|
export type { StoredCredentials } from './credentials.js';
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { AlmytyClient, GatewayClient } from './client.js';
|
|
2
|
-
export { loadCredentials, resolveCredentials, resolveCredentialsOrExit, getOrgSlugFromToken, CREDENTIALS_FILE, } from './credentials.js';
|
|
1
|
+
export { AlmytyClient, GatewayClient, parseSseFrame, isAbortError } from './client.js';
|
|
2
|
+
export { loadCredentials, resolveCredentials, resolveCredentialsOrExit, getOrgSlugFromToken, credentialsExpired, CREDENTIALS_FILE, } from './credentials.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@almyty/client",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -37,5 +37,8 @@
|
|
|
37
37
|
},
|
|
38
38
|
"bugs": {
|
|
39
39
|
"url": "https://github.com/almyty-inc/almyty/issues"
|
|
40
|
+
},
|
|
41
|
+
"overrides": {
|
|
42
|
+
"postcss": "^8.5.23"
|
|
40
43
|
}
|
|
41
44
|
}
|