@north-light/sdk 0.3.414 → 0.3.416
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/client.d.ts +14 -0
- package/dist/client.js +72 -33
- package/dist/crtr.d.ts +24 -0
- package/dist/crtr.js +50 -0
- package/dist/env-command.d.ts +15 -0
- package/dist/env-command.js +138 -0
- package/dist/error-codes.d.ts +1 -1
- package/dist/error-codes.js +2 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +38 -24
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/keygen-cli.js +6 -1
- package/dist/keygen-command.js +15 -41
- package/dist/oauth/index.d.ts +19 -1
- package/dist/oauth/index.js +111 -25
- package/dist/resources/request.d.ts +5 -0
- package/dist/resources/request.js +31 -0
- package/dist/resources/runs.d.ts +7 -0
- package/dist/resources/runs.js +37 -17
- package/dist/testing/clock.d.ts +7 -0
- package/dist/testing/clock.js +12 -0
- package/dist/testing/cloud.d.ts +19 -0
- package/dist/testing/cloud.js +48 -14
- package/dist/testing/conformance/index.d.ts +2 -0
- package/dist/testing/conformance/index.js +3 -0
- package/dist/testing/conformance/keyed-write.d.ts +43 -0
- package/dist/testing/conformance/keyed-write.js +124 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/notices.d.ts +7 -1
- package/dist/testing/notices.js +7 -3
- package/dist/testing/run.d.ts +2 -0
- package/dist/testing/run.js +5 -5
- package/dist/testing/users.d.ts +9 -3
- package/dist/testing/users.js +11 -3
- package/dist/types.d.ts +8 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +7 -3
package/dist/client.d.ts
CHANGED
|
@@ -132,6 +132,14 @@ export declare class Crouter {
|
|
|
132
132
|
api_version: string;
|
|
133
133
|
}>;
|
|
134
134
|
};
|
|
135
|
+
/** The caller's month-to-date spend (`usage.get`): user-paid model and provider usage, app-paid usage, the grant's
|
|
136
|
+
* `spend_cap`, and `reset_at`. Once user-paid spend reaches `spend_cap`, user-paid model calls answer `cap_exhausted`
|
|
137
|
+
* (a `RateLimitError`, status 402, `userAction: 'raise_cap'`) until `reset_at`. `month` is `YYYY-MM` (UTC; default: current). */
|
|
138
|
+
readonly usage: {
|
|
139
|
+
get: (params?: {
|
|
140
|
+
month?: string;
|
|
141
|
+
}, options?: RequestOptions) => Promise<import('@north-light/api').SpendUsageDTO>;
|
|
142
|
+
};
|
|
135
143
|
private readonly eventFetch;
|
|
136
144
|
private readonly bearerToken;
|
|
137
145
|
private connectionToken;
|
|
@@ -153,10 +161,16 @@ export declare class Crouter {
|
|
|
153
161
|
private authStatus;
|
|
154
162
|
private stream;
|
|
155
163
|
private events;
|
|
164
|
+
/** Opening a stream under the call's deadline: the open (token hook included) is abandoned and aborted at
|
|
165
|
+
* `deadlineAt`. Once open, a stream has no deadline: it lives until it ends or `signal` aborts. */
|
|
166
|
+
private openWithin;
|
|
156
167
|
private outcome;
|
|
157
168
|
private waitForOutcome;
|
|
158
169
|
private createAndWait;
|
|
159
170
|
private parse;
|
|
171
|
+
/** Runs `operation` with the connection token, refreshing it once after a 401. `options` carries the call's one
|
|
172
|
+
* deadline (resolved by `deadline`): the token fetch, the refresh and every attempt share it, and `operation`
|
|
173
|
+
* receives the options with the deadline resolved. */
|
|
160
174
|
private call;
|
|
161
175
|
}
|
|
162
176
|
export {};
|
package/dist/client.js
CHANGED
|
@@ -18,7 +18,7 @@ import { Shares } from './resources/shares.js';
|
|
|
18
18
|
import { StructuredRequests } from './resources/structured-requests.js';
|
|
19
19
|
import { Memory } from './resources/memory.js';
|
|
20
20
|
import { Uploads } from './resources/uploads.js';
|
|
21
|
-
import { heldReadOptions } from './resources/request.js';
|
|
21
|
+
import { deadline, heldReadOptions, withinDeadline } from './resources/request.js';
|
|
22
22
|
import { resolveJsonSchema } from './schema.js';
|
|
23
23
|
import { SDK_VERSION } from './version.js';
|
|
24
24
|
const LOCAL_SOCKET_FETCH = Symbol.for('@north-light/api/local-socket-fetch');
|
|
@@ -71,6 +71,10 @@ export class Crouter {
|
|
|
71
71
|
* `wait`. To follow many people's grants, use `oauth.watchGrants`. */
|
|
72
72
|
grant;
|
|
73
73
|
health;
|
|
74
|
+
/** The caller's month-to-date spend (`usage.get`): user-paid model and provider usage, app-paid usage, the grant's
|
|
75
|
+
* `spend_cap`, and `reset_at`. Once user-paid spend reaches `spend_cap`, user-paid model calls answer `cap_exhausted`
|
|
76
|
+
* (a `RateLimitError`, status 402, `userAction: 'raise_cap'`) until `reset_at`. `month` is `YYYY-MM` (UTC; default: current). */
|
|
77
|
+
usage;
|
|
74
78
|
eventFetch;
|
|
75
79
|
bearerToken;
|
|
76
80
|
connectionToken;
|
|
@@ -154,34 +158,34 @@ export class Crouter {
|
|
|
154
158
|
const request = (method, path, body, options) => this.request(method, path, body, options);
|
|
155
159
|
const nodes = new NodesResource(request);
|
|
156
160
|
const phaseOneNodes = {
|
|
157
|
-
create: (params, options) => this.call(() => this.client.request('POST', routes.nodes(), serializeCreate(params), options)),
|
|
161
|
+
create: (params, options) => this.call(deadline(options), (options) => this.client.request('POST', routes.nodes(), serializeCreate(params), options)),
|
|
158
162
|
retrieve: async (id, options) => {
|
|
159
163
|
const validId = nodeId(id);
|
|
160
|
-
return this.call(() => this.client.request('GET', routes.node(validId), undefined, options));
|
|
164
|
+
return this.call(deadline(options), (options) => this.client.request('GET', routes.node(validId), undefined, options));
|
|
161
165
|
},
|
|
162
|
-
list: (query, options) => this.call(() => this.client.request('GET', withQuery(routes.nodes(), query), undefined, options)),
|
|
166
|
+
list: (query, options) => this.call(deadline(options), (options) => this.client.request('GET', withQuery(routes.nodes(), query), undefined, options)),
|
|
163
167
|
outcome: (id, options) => this.outcome(id, options),
|
|
164
168
|
waitForOutcome: (id, options) => this.waitForOutcome(id, options),
|
|
165
169
|
createAndWait: (params, options) => this.createAndWait(params, options),
|
|
166
170
|
parse: (params, options) => this.parse(params, options),
|
|
167
171
|
message: async (id, body, options) => {
|
|
168
172
|
const validId = nodeId(id);
|
|
169
|
-
return this.call(() => this.client.request('POST', routes.nodeMessages(validId), messageBody(body), options));
|
|
173
|
+
return this.call(deadline(options), (options) => this.client.request('POST', routes.nodeMessages(validId), messageBody(body), options));
|
|
170
174
|
},
|
|
171
175
|
cancel: async (id, body, options) => {
|
|
172
176
|
const validId = nodeId(id);
|
|
173
|
-
return this.call(() => this.client.request('POST', routes.nodeClose(validId), body ?? {}, options));
|
|
177
|
+
return this.call(deadline(options), (options) => this.client.request('POST', routes.nodeClose(validId), body ?? {}, options));
|
|
174
178
|
},
|
|
175
179
|
interrupt: async (id, options) => {
|
|
176
180
|
const validId = nodeId(id);
|
|
177
|
-
return this.call(() => this.client.request('POST', routes.nodeInterrupt(validId), {}, options));
|
|
181
|
+
return this.call(deadline(options), (options) => this.client.request('POST', routes.nodeInterrupt(validId), {}, options));
|
|
178
182
|
},
|
|
179
183
|
stream: (params, options) => this.stream(params, options),
|
|
180
184
|
events: (id, options) => this.events(id, options),
|
|
181
185
|
reports: {
|
|
182
186
|
list: async (id, query, options) => {
|
|
183
187
|
const validId = nodeId(id);
|
|
184
|
-
return this.call(() => this.client.request('GET', withQuery(routes.nodeReports(validId), query), undefined, options));
|
|
188
|
+
return this.call(deadline(options), (options) => this.client.request('GET', withQuery(routes.nodeReports(validId), query), undefined, options));
|
|
185
189
|
},
|
|
186
190
|
},
|
|
187
191
|
};
|
|
@@ -196,17 +200,17 @@ export class Crouter {
|
|
|
196
200
|
this.profiles = {
|
|
197
201
|
ensure: async (name, params, options) => {
|
|
198
202
|
const validName = pathSegment(name, 'profile name');
|
|
199
|
-
return this.call(() => this.client.request('PUT', routes.profile(validName), params ?? {}, options));
|
|
203
|
+
return this.call(deadline(options), (options) => this.client.request('PUT', routes.profile(validName), params ?? {}, options));
|
|
200
204
|
},
|
|
201
205
|
retrieve: async (name, options) => {
|
|
202
206
|
const validName = pathSegment(name, 'profile name');
|
|
203
|
-
return this.call(() => this.client.request('GET', routes.profile(validName), undefined, options));
|
|
207
|
+
return this.call(deadline(options), (options) => this.client.request('GET', routes.profile(validName), undefined, options));
|
|
204
208
|
},
|
|
205
|
-
list: (options) => this.call(() => this.client.request('GET', routes.profiles(), undefined, options)),
|
|
209
|
+
list: (options) => this.call(deadline(options), (options) => this.client.request('GET', routes.profiles(), undefined, options)),
|
|
206
210
|
};
|
|
207
211
|
this.system = {
|
|
208
|
-
status: (options) => this.call(() => this.client.request('GET', routes.status(), undefined, options)),
|
|
209
|
-
health: (options) => this.call(() => this.client.request('GET', routes.healthz(), undefined, options)),
|
|
212
|
+
status: (options) => this.call(deadline(options), (options) => this.client.request('GET', routes.status(), undefined, options)),
|
|
213
|
+
health: (options) => this.call(deadline(options), (options) => this.client.request('GET', routes.healthz(), undefined, options)),
|
|
210
214
|
};
|
|
211
215
|
this.auth = {
|
|
212
216
|
status: (params, options) => this.authStatus(params, options),
|
|
@@ -225,10 +229,11 @@ export class Crouter {
|
|
|
225
229
|
return this.request('GET', withQuery('/v1/grant', { wait, since }), undefined, held);
|
|
226
230
|
} };
|
|
227
231
|
this.health = { get: (options) => this.request('GET', '/v1/health', undefined, options) };
|
|
232
|
+
this.usage = { get: (params = {}, options) => this.request('GET', withQuery('/v1/usage', { month: params.month }), undefined, options) };
|
|
228
233
|
}
|
|
229
234
|
/** Establish NDJSON through the JSON client's cold-socket/retry transport; leave the live body unread. */
|
|
230
235
|
async openCustomDeliveries(path, options) {
|
|
231
|
-
const response = await this.call(() => this.client.rawResponse('GET', path, {
|
|
236
|
+
const response = await this.call(undefined, () => this.client.rawResponse('GET', path, {
|
|
232
237
|
headers: { accept: 'application/x-ndjson', ...options.headers }, signal: options.signal,
|
|
233
238
|
timeout: 0, maxRetries: options.maxRetries,
|
|
234
239
|
}));
|
|
@@ -240,7 +245,7 @@ export class Crouter {
|
|
|
240
245
|
const baseURL = this.baseURL;
|
|
241
246
|
if (!baseURL)
|
|
242
247
|
throw new CrouterError('Run events require an app listener baseURL');
|
|
243
|
-
return this.call(() => this.eventFetch(`${baseURL.replace(/\/$/, '')}/v1/runs/${encodeURIComponent(id)}/events?after=${after}`, {
|
|
248
|
+
return this.call(undefined, () => this.eventFetch(`${baseURL.replace(/\/$/, '')}/v1/runs/${encodeURIComponent(id)}/events?after=${after}`, {
|
|
244
249
|
headers: { 'accept': 'text/event-stream', ...(this.bearerToken ? { Authorization: `Bearer ${this.bearerToken}` } : {}),
|
|
245
250
|
...(after ? { 'Last-Event-ID': String(after) } : {}) }, signal,
|
|
246
251
|
}));
|
|
@@ -254,7 +259,7 @@ export class Crouter {
|
|
|
254
259
|
if (typeof path !== 'string' || path === '')
|
|
255
260
|
throw new TypeError('path is required');
|
|
256
261
|
const url = `${baseURL.replace(/\/$/, '')}/v1/files/download?${new URLSearchParams({ path })}`;
|
|
257
|
-
const response = await this.call(async () => {
|
|
262
|
+
const response = await this.call(undefined, async () => {
|
|
258
263
|
try {
|
|
259
264
|
return await this.eventFetch(url, {
|
|
260
265
|
headers: { ...(this.bearerToken ? { Authorization: `Bearer ${this.bearerToken}` } : {}), ...(options.range ? { Range: options.range } : {}) },
|
|
@@ -273,7 +278,7 @@ export class Crouter {
|
|
|
273
278
|
}
|
|
274
279
|
/** Sends one request to any daemon route, for routes the namespaces above do not wrap. */
|
|
275
280
|
request(method, path, body, options) {
|
|
276
|
-
return this.call(() => this.client.request(method, path, body, options), path !== '/v1/health');
|
|
281
|
+
return this.call(deadline(options), (options) => this.client.request(method, path, body, options), path !== '/v1/health');
|
|
277
282
|
}
|
|
278
283
|
async authStatus(params = {}, options) {
|
|
279
284
|
if (this.baseURL === undefined && (!isNodeRuntime() || !await socketIsReachable(this.socketPath))) {
|
|
@@ -281,7 +286,7 @@ export class Crouter {
|
|
|
281
286
|
}
|
|
282
287
|
let daemon;
|
|
283
288
|
try {
|
|
284
|
-
daemon = await this.call(() => this.client.request('GET', routes.status(), undefined, options));
|
|
289
|
+
daemon = await this.call(deadline(options), (options) => this.client.request('GET', routes.status(), undefined, options));
|
|
285
290
|
}
|
|
286
291
|
catch (error) {
|
|
287
292
|
if (!(error instanceof APIError))
|
|
@@ -308,7 +313,7 @@ export class Crouter {
|
|
|
308
313
|
if (daemon.startup_phase !== 'ready') {
|
|
309
314
|
return { connected: true, daemon: daemonStatus, model: null, next_step: null, instructions: null };
|
|
310
315
|
}
|
|
311
|
-
const model = await this.call(() => this.client.request('GET', withQuery(routes.modelAuthReadiness(), params), undefined, options));
|
|
316
|
+
const model = await this.call(deadline(options), (options) => this.client.request('GET', withQuery(routes.modelAuthReadiness(), params), undefined, options));
|
|
312
317
|
if (model.credential !== 'ready') {
|
|
313
318
|
return {
|
|
314
319
|
connected: true,
|
|
@@ -322,20 +327,44 @@ export class Crouter {
|
|
|
322
327
|
}
|
|
323
328
|
stream(params, options) {
|
|
324
329
|
const node = this.nodes.create(params, options);
|
|
325
|
-
const { timeout: _timeout, ...streamOptions } = options ?? {};
|
|
330
|
+
const { timeout: _timeout, budgetMs, deadlineAt, ...streamOptions } = options ?? {};
|
|
331
|
+
const opening = deadline({ budgetMs, deadlineAt })?.deadlineAt;
|
|
326
332
|
return new NodeStream(node, async (signal) => {
|
|
327
333
|
const created = await node;
|
|
328
|
-
return this.call(() => this.client.getNodeEvents(created.node_id, {}, { ...streamOptions, signal }));
|
|
334
|
+
return this.openWithin(opening, signal, (signal) => this.call(undefined, () => this.client.getNodeEvents(created.node_id, {}, { ...streamOptions, signal })));
|
|
329
335
|
}, options?.signal);
|
|
330
336
|
}
|
|
331
337
|
events(id, options = {}) {
|
|
332
|
-
const { after, ...streamOptions } = options;
|
|
333
|
-
const
|
|
338
|
+
const { after, budgetMs, deadlineAt, ...streamOptions } = options;
|
|
339
|
+
const opening = deadline({ budgetMs, deadlineAt })?.deadlineAt;
|
|
340
|
+
const node = this.nodes.retrieve(id, { ...streamOptions, ...(opening === undefined ? {} : { deadlineAt: opening }) });
|
|
334
341
|
return new NodeStream(node, async (signal) => {
|
|
335
342
|
const existing = await node;
|
|
336
|
-
return this.call(() => this.client.getNodeEvents(existing.node_id, { after }, { ...streamOptions, signal }));
|
|
343
|
+
return this.openWithin(opening, signal, (signal) => this.call(undefined, () => this.client.getNodeEvents(existing.node_id, { after }, { ...streamOptions, signal })));
|
|
337
344
|
}, options.signal);
|
|
338
345
|
}
|
|
346
|
+
/** Opening a stream under the call's deadline: the open (token hook included) is abandoned and aborted at
|
|
347
|
+
* `deadlineAt`. Once open, a stream has no deadline: it lives until it ends or `signal` aborts. */
|
|
348
|
+
async openWithin(deadlineAt, signal, open) {
|
|
349
|
+
if (deadlineAt === undefined)
|
|
350
|
+
return open(signal);
|
|
351
|
+
const controller = new AbortController();
|
|
352
|
+
const onAbort = () => controller.abort(signal.reason);
|
|
353
|
+
if (signal.aborted)
|
|
354
|
+
onAbort();
|
|
355
|
+
else
|
|
356
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
357
|
+
try {
|
|
358
|
+
return await withinDeadline(open(controller.signal), deadlineAt);
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
controller.abort(error);
|
|
362
|
+
throw error instanceof APIError ? mapError(error) : error;
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
signal.removeEventListener('abort', onAbort);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
339
368
|
async outcome(id, options = {}) {
|
|
340
369
|
const validId = nodeId(id);
|
|
341
370
|
const { wait, ...requestOptions } = options;
|
|
@@ -343,16 +372,19 @@ export class Crouter {
|
|
|
343
372
|
throw new RangeError('wait must be an integer between 0 and 25');
|
|
344
373
|
}
|
|
345
374
|
const timeout = wait === undefined ? requestOptions.timeout : wait * 1_000 + (requestOptions.timeout ?? this.timeout);
|
|
346
|
-
return this.call(() => this.client.request('GET', withQuery(routes.nodeOutcome(validId), { wait }), undefined,
|
|
375
|
+
return this.call(deadline({ ...requestOptions, timeout }), (options) => this.client.request('GET', withQuery(routes.nodeOutcome(validId), { wait }), undefined, options));
|
|
347
376
|
}
|
|
348
|
-
async waitForOutcome(id,
|
|
377
|
+
async waitForOutcome(id, unbounded) {
|
|
378
|
+
// One deadline for the whole wait: every poll shares it instead of restarting `budgetMs`.
|
|
379
|
+
const options = deadline(unbounded);
|
|
349
380
|
for (;;) {
|
|
350
381
|
const response = await this.outcome(id, { ...options, wait: POLL_WAIT_SECONDS });
|
|
351
382
|
if (response.state === 'settled' && response.outcome !== null)
|
|
352
383
|
return response.outcome;
|
|
353
384
|
}
|
|
354
385
|
}
|
|
355
|
-
async createAndWait(params,
|
|
386
|
+
async createAndWait(params, unbounded) {
|
|
387
|
+
const options = deadline(unbounded);
|
|
356
388
|
const node = await this.nodes.create(params, options);
|
|
357
389
|
return this.waitForOutcome(node.node_id, options);
|
|
358
390
|
}
|
|
@@ -366,25 +398,32 @@ export class Crouter {
|
|
|
366
398
|
}
|
|
367
399
|
return { ...outcome, output_parsed: null };
|
|
368
400
|
}
|
|
369
|
-
|
|
401
|
+
/** Runs `operation` with the connection token, refreshing it once after a 401. `options` carries the call's one
|
|
402
|
+
* deadline (resolved by `deadline`): the token fetch, the refresh and every attempt share it, and `operation`
|
|
403
|
+
* receives the options with the deadline resolved. */
|
|
404
|
+
async call(options, operation, authenticate = true) {
|
|
405
|
+
const deadlineAt = options?.deadlineAt;
|
|
370
406
|
for (let attempt = 0;; attempt++) {
|
|
371
407
|
// A token refresh refused by the directory surfaces with its status class, like any other API error.
|
|
372
408
|
if (authenticate && this.tokenSource)
|
|
373
|
-
this.connectionToken = await this.tokenSource().catch(throwMapped);
|
|
409
|
+
this.connectionToken = await withinDeadline(this.tokenSource(), deadlineAt).catch(throwMapped);
|
|
374
410
|
let result;
|
|
375
411
|
try {
|
|
376
|
-
result = await operation();
|
|
412
|
+
result = await operation(options ?? {});
|
|
377
413
|
}
|
|
378
414
|
catch (error) {
|
|
379
415
|
if (authenticate && attempt === 0 && error instanceof APIError && error.status === 401 && this.onUnauthorized) {
|
|
380
|
-
await this.onUnauthorized().catch(throwMapped);
|
|
416
|
+
await withinDeadline(this.onUnauthorized(), deadlineAt).catch(throwMapped);
|
|
381
417
|
continue;
|
|
382
418
|
}
|
|
383
419
|
throw mapError(error);
|
|
384
420
|
}
|
|
385
421
|
if (authenticate && attempt === 0 && result instanceof Response && result.status === 401 && this.onUnauthorized) {
|
|
386
|
-
await
|
|
387
|
-
|
|
422
|
+
await withinDeadline((async () => { try {
|
|
423
|
+
await result.body?.cancel();
|
|
424
|
+
}
|
|
425
|
+
catch { /* discarded */ } })(), deadlineAt).catch(() => { });
|
|
426
|
+
await withinDeadline(this.onUnauthorized(), deadlineAt).catch(throwMapped);
|
|
388
427
|
continue;
|
|
389
428
|
}
|
|
390
429
|
return result;
|
package/dist/crtr.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** How long a `crtr` call from an SDK command may run before the command gives up. */
|
|
2
|
+
export declare const CRTR_TIMEOUT_MS = 60000;
|
|
3
|
+
/** The join step crtr's `not_joined` answer names when it carries none of its own. */
|
|
4
|
+
export declare const DEFAULT_JOIN_STEP = "Open https://account.northlight.ai/account/machines, choose Create join token, then run `crtr sys join --directory https://account.northlight.ai --token <token>`.";
|
|
5
|
+
export type CrtrOutcome = {
|
|
6
|
+
kind: 'ok';
|
|
7
|
+
stdout: string;
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'missing';
|
|
10
|
+
}
|
|
11
|
+
/** crtr answered `not_joined`: this machine was never joined to an account, so no `cloud` leaf exists. */
|
|
12
|
+
| {
|
|
13
|
+
kind: 'not_joined';
|
|
14
|
+
joinStep: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'failed';
|
|
17
|
+
message: string;
|
|
18
|
+
};
|
|
19
|
+
export declare const parseJson: (text: string) => unknown;
|
|
20
|
+
/** Runs `crtr <argv>` once: execFile, no shell, the caller's environment. */
|
|
21
|
+
export declare function runCrtr(argv: string[], timeoutMs: number): Promise<CrtrOutcome>;
|
|
22
|
+
/** "This machine's crtr isn't joined …, so <what>." then the join step. */
|
|
23
|
+
export declare const notJoinedLines: (what: string, joinStep: string) => string[];
|
|
24
|
+
export declare const say: (lines: string[]) => void;
|
package/dist/crtr.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
/** How long a `crtr` call from an SDK command may run before the command gives up. */
|
|
3
|
+
export const CRTR_TIMEOUT_MS = 60_000;
|
|
4
|
+
/** The join step crtr's `not_joined` answer names when it carries none of its own. */
|
|
5
|
+
export const DEFAULT_JOIN_STEP = 'Open https://account.northlight.ai/account/machines, choose Create join token, then run `crtr sys join --directory https://account.northlight.ai --token <token>`.';
|
|
6
|
+
const seconds = (ms) => {
|
|
7
|
+
const n = Math.max(1, Math.round(ms / 1000));
|
|
8
|
+
return `${n} second${n === 1 ? '' : 's'}`;
|
|
9
|
+
};
|
|
10
|
+
export const parseJson = (text) => {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(text.trim());
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const lastLine = (text) => text.split('\n').map((line) => line.trim()).filter(Boolean).at(-1);
|
|
19
|
+
/** The JSON error that ends `text`, even when other lines (a warning, a notice) precede it. */
|
|
20
|
+
const errorBody = (text) => {
|
|
21
|
+
const body = parseJson(text.slice(text.lastIndexOf('\n{') + 1));
|
|
22
|
+
return body && typeof body === 'object' && !Array.isArray(body) ? body : undefined;
|
|
23
|
+
};
|
|
24
|
+
const text = (value) => (typeof value === 'string' && value ? value : undefined);
|
|
25
|
+
/** Runs `crtr <argv>` once: execFile, no shell, the caller's environment. */
|
|
26
|
+
export function runCrtr(argv, timeoutMs) {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
execFile('crtr', argv, { timeout: timeoutMs, env: process.env, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
|
|
29
|
+
const err = error;
|
|
30
|
+
if (err?.code === 'ENOENT')
|
|
31
|
+
return resolve({ kind: 'missing' });
|
|
32
|
+
if (!err)
|
|
33
|
+
return resolve({ kind: 'ok', stdout });
|
|
34
|
+
if (err.killed)
|
|
35
|
+
return resolve({ kind: 'failed', message: `crtr did not answer within ${seconds(timeoutMs)}.` });
|
|
36
|
+
const bodies = [errorBody(stdout), errorBody(stderr)];
|
|
37
|
+
const notJoined = bodies.find((body) => (text(body?.error) ?? text(body?.code)) === 'not_joined');
|
|
38
|
+
if (notJoined)
|
|
39
|
+
return resolve({ kind: 'not_joined', joinStep: text(notJoined.next) ?? DEFAULT_JOIN_STEP });
|
|
40
|
+
const message = bodies.map((body) => text(body?.message)).find(Boolean);
|
|
41
|
+
resolve({ kind: 'failed', message: message ?? lastLine(stderr) ?? lastLine(stdout) ?? err.message });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** "This machine's crtr isn't joined …, so <what>." then the join step. */
|
|
46
|
+
export const notJoinedLines = (what, joinStep) => [
|
|
47
|
+
`This machine's crtr isn't joined to your crouter cloud account, so ${what}.`,
|
|
48
|
+
joinStep,
|
|
49
|
+
];
|
|
50
|
+
export const say = (lines) => console.error(lines.join('\n'));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const ENV_USAGE = "Usage: crouter-sdk env --app <client id> [--out <path>]";
|
|
2
|
+
/** The variables `env` owns, in the order it writes them. `CROUTER_KEY_FILE` is never one. */
|
|
3
|
+
declare const NAMES: readonly ["CROUTER_ISSUER", "CROUTER_CLIENT_ID", "APP_ORIGIN"];
|
|
4
|
+
type Name = typeof NAMES[number];
|
|
5
|
+
export interface EnvOptions {
|
|
6
|
+
/** How long `crtr cloud apps get` may run before env gives up. */
|
|
7
|
+
crtrTimeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
/** Rewrites each assignment to a name in `values`, appends the names it had none for, and keeps every other line. */
|
|
10
|
+
export declare function updateEnvText(text: string, values: Record<Name, string>): string;
|
|
11
|
+
/** The last `CROUTER_KEY_FILE` the file assigns, or undefined. */
|
|
12
|
+
export declare function keyFileOf(text: string): string | undefined;
|
|
13
|
+
/** The `crouter-sdk env` command. Returns the process exit code. */
|
|
14
|
+
export declare function runEnv(argv: string[], options?: EnvOptions): Promise<number>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { mkdir, open, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { CRTR_TIMEOUT_MS, notJoinedLines, parseJson, runCrtr, say } from './crtr.js';
|
|
4
|
+
export const ENV_USAGE = 'Usage: crouter-sdk env --app <client id> [--out <path>]';
|
|
5
|
+
const DEFAULT_KEY_FILE = 'local/app-key.json';
|
|
6
|
+
/** The variables `env` owns, in the order it writes them. `CROUTER_KEY_FILE` is never one. */
|
|
7
|
+
const NAMES = ['CROUTER_ISSUER', 'CROUTER_CLIENT_ID', 'APP_ORIGIN'];
|
|
8
|
+
function parseArgs(args) {
|
|
9
|
+
if (args[0] !== 'env')
|
|
10
|
+
throw new Error(ENV_USAGE);
|
|
11
|
+
let path = 'local/app.env';
|
|
12
|
+
let app;
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
for (let i = 1; i < args.length; i++) {
|
|
15
|
+
const flag = args[i];
|
|
16
|
+
const value = args[++i];
|
|
17
|
+
if (seen.has(flag) || (flag !== '--out' && flag !== '--app') || !value || value.startsWith('--'))
|
|
18
|
+
throw new Error(ENV_USAGE);
|
|
19
|
+
seen.add(flag);
|
|
20
|
+
if (flag === '--out')
|
|
21
|
+
path = value;
|
|
22
|
+
else
|
|
23
|
+
app = value;
|
|
24
|
+
}
|
|
25
|
+
if (!app)
|
|
26
|
+
throw new Error(ENV_USAGE);
|
|
27
|
+
return { path, app };
|
|
28
|
+
}
|
|
29
|
+
/** One `NAME=value` assignment line, with an optional `export ` prefix. */
|
|
30
|
+
const ASSIGNMENT = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=)(.*)$/;
|
|
31
|
+
/** A value is written bare when a dotenv reader and a shell both read it back unchanged. */
|
|
32
|
+
const formatValue = (value) => (/^[A-Za-z0-9_\-.:/@%+,~]*$/.test(value) ? value : JSON.stringify(value));
|
|
33
|
+
/** The value of a dotenv assignment's right-hand side: quotes removed, a trailing comment dropped. */
|
|
34
|
+
function readValue(raw) {
|
|
35
|
+
const text = raw.trim();
|
|
36
|
+
const quote = text[0];
|
|
37
|
+
if ((quote === '"' || quote === "'") && text.indexOf(quote, 1) > 0) {
|
|
38
|
+
const inner = text.slice(1, text.indexOf(quote, 1));
|
|
39
|
+
if (quote === "'")
|
|
40
|
+
return inner;
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(`"${inner}"`);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return inner;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return text.replace(/\s+#.*$/, '');
|
|
49
|
+
}
|
|
50
|
+
/** Rewrites each assignment to a name in `values`, appends the names it had none for, and keeps every other line. */
|
|
51
|
+
export function updateEnvText(text, values) {
|
|
52
|
+
const lines = text === '' ? [] : text.replace(/\n$/, '').split('\n');
|
|
53
|
+
const written = new Set();
|
|
54
|
+
const out = lines.map((line) => {
|
|
55
|
+
const match = ASSIGNMENT.exec(line);
|
|
56
|
+
const name = match?.[2];
|
|
57
|
+
if (!match || !name || !NAMES.includes(name))
|
|
58
|
+
return line;
|
|
59
|
+
written.add(name);
|
|
60
|
+
return `${match[1]}${name}${match[3]}${formatValue(values[name])}`;
|
|
61
|
+
});
|
|
62
|
+
for (const name of NAMES)
|
|
63
|
+
if (!written.has(name))
|
|
64
|
+
out.push(`${name}=${formatValue(values[name])}`);
|
|
65
|
+
return `${out.join('\n')}\n`;
|
|
66
|
+
}
|
|
67
|
+
/** The last `CROUTER_KEY_FILE` the file assigns, or undefined. */
|
|
68
|
+
export function keyFileOf(text) {
|
|
69
|
+
let found;
|
|
70
|
+
for (const line of text.split('\n')) {
|
|
71
|
+
const match = ASSIGNMENT.exec(line);
|
|
72
|
+
if (match?.[2] === 'CROUTER_KEY_FILE')
|
|
73
|
+
found = readValue(match[4] ?? '');
|
|
74
|
+
}
|
|
75
|
+
return found || undefined;
|
|
76
|
+
}
|
|
77
|
+
function envOf(answer) {
|
|
78
|
+
const env = answer?.env;
|
|
79
|
+
if (!env || typeof env !== 'object')
|
|
80
|
+
return undefined;
|
|
81
|
+
const values = {};
|
|
82
|
+
for (const name of NAMES) {
|
|
83
|
+
const value = env[name];
|
|
84
|
+
if (typeof value !== 'string' || !value)
|
|
85
|
+
return undefined;
|
|
86
|
+
values[name] = value;
|
|
87
|
+
}
|
|
88
|
+
return values;
|
|
89
|
+
}
|
|
90
|
+
const byHand = (path) => `Set ${NAMES.join(', ')} in ${path} by hand: \`crtr cloud apps get --app-id <client id>\` shows them under env.`;
|
|
91
|
+
/** The `crouter-sdk env` command. Returns the process exit code. */
|
|
92
|
+
export async function runEnv(argv, options = {}) {
|
|
93
|
+
const { path, app } = parseArgs(argv);
|
|
94
|
+
const outcome = await runCrtr(['cloud', 'apps', 'get', '--app-id', app, '--json'], options.crtrTimeoutMs ?? CRTR_TIMEOUT_MS);
|
|
95
|
+
if (outcome.kind === 'missing') {
|
|
96
|
+
say(["Couldn't find crtr, so nothing was written.", byHand(path)]);
|
|
97
|
+
return 2;
|
|
98
|
+
}
|
|
99
|
+
if (outcome.kind === 'not_joined') {
|
|
100
|
+
say(notJoinedLines('nothing was written', outcome.joinStep));
|
|
101
|
+
return 2;
|
|
102
|
+
}
|
|
103
|
+
if (outcome.kind === 'failed') {
|
|
104
|
+
say([`Couldn't read ${app}: ${outcome.message}`, 'Nothing was written.']);
|
|
105
|
+
return 2;
|
|
106
|
+
}
|
|
107
|
+
const values = envOf(parseJson(outcome.stdout));
|
|
108
|
+
if (!values) {
|
|
109
|
+
const last = outcome.stdout.split('\n').map((line) => line.trim()).filter(Boolean).at(-1);
|
|
110
|
+
say([`crtr answered without the app's env: ${last ?? '(no output)'}`, 'Nothing was written.']);
|
|
111
|
+
return 2;
|
|
112
|
+
}
|
|
113
|
+
const existing = await readFile(path, 'utf8').catch((error) => {
|
|
114
|
+
if (error.code === 'ENOENT')
|
|
115
|
+
return undefined;
|
|
116
|
+
throw error;
|
|
117
|
+
});
|
|
118
|
+
const next = updateEnvText(existing ?? '', values);
|
|
119
|
+
if (existing === undefined) {
|
|
120
|
+
await mkdir(dirname(path), { recursive: true });
|
|
121
|
+
const file = await open(path, 'wx', 0o600);
|
|
122
|
+
try {
|
|
123
|
+
await file.writeFile(next);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await file.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else if (next !== existing) {
|
|
130
|
+
await writeFile(path, next);
|
|
131
|
+
}
|
|
132
|
+
say([
|
|
133
|
+
`Set in ${path}:`,
|
|
134
|
+
...NAMES.map((name) => ` ${name}=${values[name]}`),
|
|
135
|
+
`Key file: ${keyFileOf(next) ?? DEFAULT_KEY_FILE} (set CROUTER_KEY_FILE to change it).`,
|
|
136
|
+
]);
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
package/dist/error-codes.d.ts
CHANGED
|
@@ -2,6 +2,6 @@ export { errorCodes } from '@north-light/api';
|
|
|
2
2
|
export type { ErrorCode, ErrorEnvelope, ErrorOrigin, ErrorType } from '@north-light/api';
|
|
3
3
|
export { mapError } from './errors.js';
|
|
4
4
|
/** Codes the SDK raises itself rather than reading from a daemon or directory response. */
|
|
5
|
-
export declare const sdkErrorCodes: readonly ["crouter_error", "id_token_invalid", "oauth_nonce_missing", "oauth_state_mismatch", "oauth_exchange_mismatch", "not_connected", "refusal_handler_reentry", "run_idle", "waiting_on_user", "request_aborted", "request_timeout", "connection_error", "transport_error", "daemon_unavailable", "daemon_request_interrupted", "daemon_health_unavailable", "runtime_version_unsupported", "invalid_response", "stream_error", "stream_ended"];
|
|
5
|
+
export declare const sdkErrorCodes: readonly ["crouter_error", "id_token_invalid", "oauth_nonce_missing", "oauth_state_mismatch", "oauth_exchange_mismatch", "not_connected", "refusal_handler_reentry", "run_idle", "waiting_on_user", "request_aborted", "request_timeout", "deadline_exceeded", "connection_error", "transport_error", "daemon_unavailable", "daemon_request_interrupted", "daemon_health_unavailable", "runtime_version_unsupported", "invalid_response", "stream_error", "stream_ended"];
|
|
6
6
|
/** A code the SDK raises itself. An `APIError`'s `code` is one of these or an `ErrorCode` from the contract. */
|
|
7
7
|
export type SdkErrorCode = typeof sdkErrorCodes[number];
|
package/dist/error-codes.js
CHANGED
|
@@ -25,6 +25,8 @@ export const sdkErrorCodes = [
|
|
|
25
25
|
'request_aborted',
|
|
26
26
|
/** The request exceeded its timeout (`APIConnectionTimeoutError`). */
|
|
27
27
|
'request_timeout',
|
|
28
|
+
/** The call's overall deadline passed, or a retry wait would have passed it (`DeadlineExceededError`). */
|
|
29
|
+
'deadline_exceeded',
|
|
28
30
|
/** The daemon could not be reached, or the connection failed (`APIConnectionError`). */
|
|
29
31
|
'connection_error',
|
|
30
32
|
'transport_error',
|
package/dist/errors.d.ts
CHANGED
|
@@ -50,6 +50,21 @@ export declare class APIConnectionError extends APIError {
|
|
|
50
50
|
/** The request exceeded its timeout before the daemon answered. */
|
|
51
51
|
export declare class APIConnectionTimeoutError extends APIConnectionError {
|
|
52
52
|
}
|
|
53
|
+
/** The call's overall deadline (`budgetMs` / `deadlineAt`) passed, or the next retry wait would have passed it.
|
|
54
|
+
* `status` 504, `code` `deadline_exceeded`. `deadlineAt` is the instant (epoch ms); `reason` is `elapsed` (time ran out)
|
|
55
|
+
* or `retry_after` (the server asked for a wait longer than the time left, so the call stopped without sleeping;
|
|
56
|
+
* `retryAfterS` is that request). `cause` is the last failure, when there was one. */
|
|
57
|
+
export declare class DeadlineExceededError extends APIConnectionTimeoutError {
|
|
58
|
+
get deadlineAt(): number;
|
|
59
|
+
get reason(): 'elapsed' | 'retry_after';
|
|
60
|
+
get retryAfterRequestS(): number | undefined;
|
|
61
|
+
}
|
|
62
|
+
/** `DeadlineExceededError.details`. */
|
|
63
|
+
export interface DeadlineDetails {
|
|
64
|
+
deadline_at: number;
|
|
65
|
+
reason: 'elapsed' | 'retry_after';
|
|
66
|
+
retry_after_s?: number;
|
|
67
|
+
}
|
|
53
68
|
/** The caller's `AbortSignal` fired. */
|
|
54
69
|
export declare class APIUserAbortError extends APIError {
|
|
55
70
|
}
|
package/dist/errors.js
CHANGED
|
@@ -46,6 +46,15 @@ export class APIConnectionError extends APIError {
|
|
|
46
46
|
/** The request exceeded its timeout before the daemon answered. */
|
|
47
47
|
export class APIConnectionTimeoutError extends APIConnectionError {
|
|
48
48
|
}
|
|
49
|
+
/** The call's overall deadline (`budgetMs` / `deadlineAt`) passed, or the next retry wait would have passed it.
|
|
50
|
+
* `status` 504, `code` `deadline_exceeded`. `deadlineAt` is the instant (epoch ms); `reason` is `elapsed` (time ran out)
|
|
51
|
+
* or `retry_after` (the server asked for a wait longer than the time left, so the call stopped without sleeping;
|
|
52
|
+
* `retryAfterS` is that request). `cause` is the last failure, when there was one. */
|
|
53
|
+
export class DeadlineExceededError extends APIConnectionTimeoutError {
|
|
54
|
+
get deadlineAt() { return this.details.deadline_at; }
|
|
55
|
+
get reason() { return this.details.reason; }
|
|
56
|
+
get retryAfterRequestS() { return this.details.retry_after_s; }
|
|
57
|
+
}
|
|
49
58
|
/** The caller's `AbortSignal` fired. */
|
|
50
59
|
export class APIUserAbortError extends APIError {
|
|
51
60
|
}
|
|
@@ -56,32 +65,37 @@ export function mapError(error) {
|
|
|
56
65
|
}
|
|
57
66
|
const Constructor = error.code === 'runtime_version_unsupported'
|
|
58
67
|
? RuntimeVersionError
|
|
59
|
-
: error.code === '
|
|
60
|
-
?
|
|
61
|
-
: error.
|
|
62
|
-
?
|
|
63
|
-
: error.
|
|
64
|
-
?
|
|
65
|
-
: error.
|
|
66
|
-
?
|
|
67
|
-
: error.status ===
|
|
68
|
-
?
|
|
69
|
-
: error.status ===
|
|
70
|
-
?
|
|
71
|
-
: error.status ===
|
|
72
|
-
?
|
|
73
|
-
: error.status ===
|
|
74
|
-
?
|
|
75
|
-
: error.status ===
|
|
76
|
-
?
|
|
77
|
-
: error.status ===
|
|
78
|
-
?
|
|
79
|
-
: error.status
|
|
80
|
-
?
|
|
81
|
-
:
|
|
82
|
-
|
|
68
|
+
: error.code === 'deadline_exceeded'
|
|
69
|
+
? DeadlineExceededError
|
|
70
|
+
: error.code === 'request_aborted'
|
|
71
|
+
? APIUserAbortError
|
|
72
|
+
: error.status === 504 || error.code === 'request_timeout'
|
|
73
|
+
? APIConnectionTimeoutError
|
|
74
|
+
: error.code === 'daemon_unavailable' || error.code === 'transport_error' || error.code === 'daemon_request_interrupted' || error.code === 'daemon_health_unavailable'
|
|
75
|
+
? APIConnectionError
|
|
76
|
+
: error.status === 400
|
|
77
|
+
? BadRequestError
|
|
78
|
+
: error.status === 401
|
|
79
|
+
? AuthenticationError
|
|
80
|
+
: error.status === 403
|
|
81
|
+
? PermissionDeniedError
|
|
82
|
+
: error.status === 404
|
|
83
|
+
? NotFoundError
|
|
84
|
+
: error.status === 409
|
|
85
|
+
? ConflictError
|
|
86
|
+
: error.status === 413 || error.status === 422
|
|
87
|
+
? UnprocessableEntityError
|
|
88
|
+
: error.status === 402 || error.status === 429
|
|
89
|
+
? RateLimitError
|
|
90
|
+
: error.status >= 500
|
|
91
|
+
? InternalServerError
|
|
92
|
+
: APIError;
|
|
93
|
+
const mapped = new Constructor(error.status, error.code, error.message, error.details, error.headers, {
|
|
83
94
|
type: error.type, param: error.param, origin: error.origin, retryable: error.retryable,
|
|
84
95
|
retry_after_s: error.retryAfterS, reset_at: error.resetAt, request_id: error.requestId, run_id: error.runId,
|
|
85
96
|
question_id: error.questionId, scope: error.scope, user_action: error.userAction,
|
|
86
97
|
});
|
|
98
|
+
if (error.cause !== undefined)
|
|
99
|
+
Object.defineProperty(mapped, 'cause', { value: error.cause, configurable: true, writable: true });
|
|
100
|
+
return mapped;
|
|
87
101
|
}
|