@north-light/sdk 0.3.414 → 0.3.415

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.
Files changed (40) hide show
  1. package/dist/client.d.ts +14 -0
  2. package/dist/client.js +72 -33
  3. package/dist/crtr.d.ts +24 -0
  4. package/dist/crtr.js +50 -0
  5. package/dist/env-command.d.ts +15 -0
  6. package/dist/env-command.js +138 -0
  7. package/dist/error-codes.d.ts +1 -1
  8. package/dist/error-codes.js +2 -0
  9. package/dist/errors.d.ts +15 -0
  10. package/dist/errors.js +38 -24
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.js +2 -2
  13. package/dist/keygen-cli.js +6 -1
  14. package/dist/keygen-command.js +15 -41
  15. package/dist/oauth/index.d.ts +19 -1
  16. package/dist/oauth/index.js +111 -25
  17. package/dist/resources/request.d.ts +5 -0
  18. package/dist/resources/request.js +31 -0
  19. package/dist/resources/runs.d.ts +7 -0
  20. package/dist/resources/runs.js +37 -17
  21. package/dist/testing/clock.d.ts +7 -0
  22. package/dist/testing/clock.js +12 -0
  23. package/dist/testing/cloud.d.ts +19 -0
  24. package/dist/testing/cloud.js +48 -14
  25. package/dist/testing/conformance/index.d.ts +2 -0
  26. package/dist/testing/conformance/index.js +3 -0
  27. package/dist/testing/conformance/keyed-write.d.ts +43 -0
  28. package/dist/testing/conformance/keyed-write.js +124 -0
  29. package/dist/testing/index.d.ts +2 -0
  30. package/dist/testing/index.js +1 -0
  31. package/dist/testing/notices.d.ts +7 -1
  32. package/dist/testing/notices.js +7 -3
  33. package/dist/testing/run.d.ts +2 -0
  34. package/dist/testing/run.js +5 -5
  35. package/dist/testing/users.d.ts +9 -3
  36. package/dist/testing/users.js +11 -3
  37. package/dist/types.d.ts +8 -0
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. 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 node = this.nodes.retrieve(id, streamOptions);
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, { ...requestOptions, timeout }));
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, options) {
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, options) {
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
- async call(operation, authenticate = true) {
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 result.body?.cancel();
387
- await this.onUnauthorized().catch(throwMapped);
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
+ }
@@ -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];
@@ -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 === 'request_aborted'
60
- ? APIUserAbortError
61
- : error.status === 504 || error.code === 'request_timeout'
62
- ? APIConnectionTimeoutError
63
- : error.code === 'daemon_unavailable' || error.code === 'transport_error' || error.code === 'daemon_request_interrupted' || error.code === 'daemon_health_unavailable'
64
- ? APIConnectionError
65
- : error.status === 400
66
- ? BadRequestError
67
- : error.status === 401
68
- ? AuthenticationError
69
- : error.status === 403
70
- ? PermissionDeniedError
71
- : error.status === 404
72
- ? NotFoundError
73
- : error.status === 409
74
- ? ConflictError
75
- : error.status === 413 || error.status === 422
76
- ? UnprocessableEntityError
77
- : error.status === 402 || error.status === 429
78
- ? RateLimitError
79
- : error.status >= 500
80
- ? InternalServerError
81
- : APIError;
82
- return new Constructor(error.status, error.code, error.message, error.details, error.headers, {
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
  }