@hardfin/cli 0.0.2-dev.9 → 0.1.0-dev.20

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 (3) hide show
  1. package/README.md +191 -62
  2. package/dist/cli.js +2713 -363
  3. package/package.json +14 -2
package/dist/cli.js CHANGED
@@ -2,12 +2,13 @@
2
2
  import { createRequire } from "node:module";
3
3
  import { Command, Option } from "commander";
4
4
  import { z } from "zod";
5
- import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
6
- import { dirname, join, resolve } from "node:path";
5
+ import { chmodSync, closeSync, existsSync, mkdirSync, openAsBlob, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
6
+ import { basename, dirname, join, resolve } from "node:path";
7
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
7
8
  import { arch, cpus, homedir, release, totalmem, type, version } from "node:os";
8
- import { spawnSync } from "node:child_process";
9
+ import { spawn, spawnSync } from "node:child_process";
9
10
  import { createServer } from "node:http";
10
- import { createHash, randomBytes } from "node:crypto";
11
+ import { createInterface } from "node:readline";
11
12
  //#region src/command/registry.ts
12
13
  /** ExitCode is what the process returns, and what an agent branches on. */
13
14
  const ExitCode = {
@@ -119,18 +120,28 @@ function toOrigin(apiUrl) {
119
120
  }
120
121
  //#endregion
121
122
  //#region src/output/writer.ts
123
+ /**
124
+ * toText reads a value a caller supplied, which arrives typed as unknown. An object would
125
+ * otherwise reach a request as the text "[object Object]".
126
+ */
127
+ function toText(value) {
128
+ if (typeof value === "string") return value;
129
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
130
+ if (value === void 0 || value === null) return "";
131
+ return JSON.stringify(value);
132
+ }
122
133
  /** writeData prints a command's result on stdout. */
123
134
  function writeData(value) {
124
135
  if (typeof value === "string") {
125
136
  process.stdout.write(value.endsWith("\n") ? value : `${value}\n`);
126
137
  return;
127
138
  }
128
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
139
+ process.stdout.write(`${JSON.stringify(value ?? null, null, 2)}\n`);
129
140
  }
130
141
  /** writeFailure prints why a command failed on stderr, as text or as JSON. */
131
- function writeFailure(message, isJSON, errors, requestId) {
142
+ function writeFailure(message, isJSON, errors, requestId, status) {
132
143
  if (!isJSON) {
133
- process.stderr.write(`error: ${message}\n`);
144
+ process.stderr.write(`error: ${message}${status === void 0 ? "" : ` (HTTP ${status})`}\n`);
134
145
  for (const entry of errors?.slice(1) ?? []) process.stderr.write(` ${entry.error} (${entry.statusCode})\n`);
135
146
  if (requestId) process.stderr.write(` request ${requestId}\n`);
136
147
  return;
@@ -140,7 +151,8 @@ function writeFailure(message, isJSON, errors, requestId) {
140
151
  error: message,
141
152
  statusCode: 0
142
153
  }],
143
- requestId: requestId ?? null
154
+ requestId: requestId ?? null,
155
+ status: status ?? null
144
156
  };
145
157
  process.stderr.write(`${JSON.stringify(payload, null, 2)}\n`);
146
158
  }
@@ -197,24 +209,34 @@ function toGuide(commands, version) {
197
209
  "## Commands",
198
210
  ""
199
211
  ];
200
- for (const command of commands) {
201
- lines.push(`### \`hardfin ${command.name}\``, "", command.description ?? command.summary, "");
202
- if (command.arguments.length > 0) {
203
- lines.push("| Argument | Required | Holds |", "| --- | --- | --- |");
204
- for (const argument of command.arguments) lines.push(`| \`${argument.name}\` | ${argument.required ? "yes" : "no"} | ${argument.description} |`);
205
- lines.push("");
206
- }
207
- if (command.flags.length > 0) {
208
- lines.push("| Flag | Takes | Does |", "| --- | --- | --- |");
209
- for (const flag of command.flags) {
210
- const name = flag.short ? `-${flag.short}, --${flag.name}` : `--${flag.name}`;
211
- lines.push(`| \`${name}\` | ${flag.valueName ?? "nothing"} | ${flag.description} |`);
212
- }
213
- lines.push("");
212
+ for (const command of commands.filter((command) => !command.hidden)) lines.push(...toCommandLines(command, []));
213
+ return lines.join("\n");
214
+ }
215
+ /** toCommandLines describes one command and everything nested under it. */
216
+ function toCommandLines(command, parents) {
217
+ const path = [...parents, command.name];
218
+ const lines = [
219
+ `### \`hardfin ${path.join(" ")}\``,
220
+ "",
221
+ command.description ?? command.summary,
222
+ ""
223
+ ];
224
+ if (command.arguments.length > 0) {
225
+ lines.push("| Argument | Required | Holds |", "| --- | --- | --- |");
226
+ for (const argument of command.arguments) lines.push(`| \`${argument.name}\` | ${argument.required ? "yes" : "no"} | ${argument.description} |`);
227
+ lines.push("");
228
+ }
229
+ if (command.flags.length > 0) {
230
+ lines.push("| Flag | Takes | Does |", "| --- | --- | --- |");
231
+ for (const flag of command.flags) {
232
+ const name = flag.short ? `-${flag.short}, --${flag.name}` : `--${flag.name}`;
233
+ lines.push(`| \`${name}\` | ${flag.valueName ?? "nothing"} | ${flag.description} |`);
214
234
  }
215
- for (const example of command.examples) lines.push(`${example.description}:`, "", "```sh", example.command, "```", "");
235
+ lines.push("");
216
236
  }
217
- return lines.join("\n");
237
+ for (const example of command.examples) lines.push(`${example.description}:`, "", "```sh", example.command, "```", "");
238
+ for (const subcommand of command.subcommands ?? []) lines.push(...toCommandLines(subcommand, path));
239
+ return lines;
218
240
  }
219
241
  async function runAgentGuide(input) {
220
242
  if (input.flags["json"] === true) {
@@ -237,10 +259,168 @@ function toSummary(command) {
237
259
  valueName: flag.valueName ?? null,
238
260
  repeatable: flag.repeatable ?? false
239
261
  })),
240
- examples: command.examples
262
+ examples: command.examples,
263
+ subcommands: command.subcommands?.filter((entry) => !entry.hidden).map(toSummary)
241
264
  };
242
265
  }
243
266
  //#endregion
267
+ //#region src/auth/jwt.ts
268
+ /**
269
+ * toClaims reads an access token's payload for display. Nothing here verifies the
270
+ * signature, because the API is what decides whether a token is good.
271
+ */
272
+ function toClaims(token) {
273
+ const payload = token.split(".")[1];
274
+ if (!payload) return {};
275
+ try {
276
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
277
+ return {
278
+ expiresAt: typeof decoded["exp"] === "number" ? decoded["exp"] * 1e3 : void 0,
279
+ issuedAt: typeof decoded["iat"] === "number" ? decoded["iat"] * 1e3 : void 0,
280
+ scopes: toScopes(decoded),
281
+ subject: typeof decoded["sub"] === "string" ? decoded["sub"] : void 0
282
+ };
283
+ } catch {
284
+ return {};
285
+ }
286
+ }
287
+ /** toScopes reads scp, which is where Hardfin puts an access token's scopes. */
288
+ function toScopes(decoded) {
289
+ if (Array.isArray(decoded["scp"])) return decoded["scp"].map(String);
290
+ return typeof decoded["scope"] === "string" ? decoded["scope"].split(" ") : void 0;
291
+ }
292
+ //#endregion
293
+ //#region src/credential/store.ts
294
+ const load = createRequire(import.meta.url);
295
+ /** The service a keyring entry is filed under, alongside the issuer it belongs to. */
296
+ const SERVICE = "hardfin-cli";
297
+ const FILE_MODE = 384;
298
+ const DIRECTORY_MODE$1 = 448;
299
+ /** toCredentialPath names the file the fallback keeps refresh tokens in. */
300
+ function toCredentialPath() {
301
+ const base = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state");
302
+ return join(base, "hardfin", "credentials.json");
303
+ }
304
+ /**
305
+ * keep writes a refresh token for one issuer. Only the refresh token is stored, so a stolen
306
+ * entry is revocable, and access tokens live in the process that fetched them.
307
+ *
308
+ * A rotation keeps the date of the original sign in, because that is what the server's
309
+ * family lifetime runs from. Callers hold the credential lock, which is what makes a write
310
+ * from another process merge rather than disappear.
311
+ */
312
+ function keep$1(issuer, refreshToken, expiresAt) {
313
+ const now = (/* @__PURE__ */ new Date()).toISOString();
314
+ const record = {
315
+ refreshToken,
316
+ signedInAt: toCredential(issuer)?.signedInAt ?? now,
317
+ renewedAt: now,
318
+ expiresAt: expiresAt === void 0 ? void 0 : new Date(expiresAt).toISOString()
319
+ };
320
+ const keyring = toKeyring(issuer);
321
+ if (keyring) try {
322
+ keyring.setPassword(JSON.stringify(record));
323
+ forgetFile(issuer);
324
+ return "keyring";
325
+ } catch {}
326
+ writeFile({
327
+ ...readFile(),
328
+ [issuer]: record
329
+ });
330
+ forgetKeyring(issuer);
331
+ return "file";
332
+ }
333
+ /**
334
+ * toCredential reads what is held for an issuer, and where it was held. Both backends are
335
+ * read, because a host that lost its keyring for a while wrote to the file instead, and the
336
+ * newer of the two is the one the server has not spent.
337
+ */
338
+ function toCredential(issuer) {
339
+ const fromKeyring = toKeyringCredential(issuer);
340
+ const held = readFile()[issuer];
341
+ const fromFile = held === void 0 ? void 0 : {
342
+ ...held,
343
+ backend: "file",
344
+ path: toCredentialPath()
345
+ };
346
+ if (!fromKeyring || !fromFile) return fromKeyring ?? fromFile;
347
+ return (fromFile.renewedAt ?? "") > (fromKeyring.renewedAt ?? "") ? fromFile : fromKeyring;
348
+ }
349
+ function toKeyringCredential(issuer) {
350
+ const keyring = toKeyring(issuer);
351
+ if (!keyring) return;
352
+ try {
353
+ const held = keyring.getPassword();
354
+ return held ? {
355
+ ...toRecord(held),
356
+ backend: "keyring"
357
+ } : void 0;
358
+ } catch {
359
+ return;
360
+ }
361
+ }
362
+ /** toRecord reads a stored entry, which older versions wrote as the bare token. */
363
+ function toRecord(held) {
364
+ if (!held.startsWith("{")) return { refreshToken: held };
365
+ try {
366
+ return JSON.parse(held);
367
+ } catch {
368
+ return { refreshToken: held };
369
+ }
370
+ }
371
+ /** forget removes whatever is held for an issuer, in both places. */
372
+ function forget(issuer) {
373
+ forgetKeyring(issuer);
374
+ forgetFile(issuer);
375
+ }
376
+ function forgetKeyring(issuer) {
377
+ const keyring = toKeyring(issuer);
378
+ if (!keyring) return;
379
+ try {
380
+ keyring.deletePassword();
381
+ } catch {}
382
+ }
383
+ function forgetFile(issuer) {
384
+ const held = readFile();
385
+ if (held[issuer] === void 0) return;
386
+ const remaining = Object.fromEntries(Object.entries(held).filter(([name]) => name !== issuer));
387
+ if (Object.keys(remaining).length === 0) {
388
+ rmSync(toCredentialPath(), { force: true });
389
+ return;
390
+ }
391
+ writeFile(remaining);
392
+ }
393
+ /** toKeyring opens the OS keyring, or answers undefined where the platform has none. */
394
+ function toKeyring(issuer) {
395
+ if (process.env["HARDFIN_CREDENTIAL_STORE"] === "file") return;
396
+ try {
397
+ const { Entry } = load("@napi-rs/keyring");
398
+ return new Entry(SERVICE, issuer);
399
+ } catch {
400
+ return;
401
+ }
402
+ }
403
+ function readFile() {
404
+ try {
405
+ const parsed = JSON.parse(readFileSync(toCredentialPath(), "utf8"));
406
+ if (typeof parsed !== "object" || parsed === null) return {};
407
+ return Object.fromEntries(Object.entries(parsed).map(([issuer, held]) => [issuer, typeof held === "string" ? { refreshToken: held } : held]));
408
+ } catch {
409
+ return {};
410
+ }
411
+ }
412
+ function writeFile(held) {
413
+ const path = toCredentialPath();
414
+ mkdirSync(dirname(path), {
415
+ recursive: true,
416
+ mode: DIRECTORY_MODE$1
417
+ });
418
+ const pending = `${path}.${process.pid}.tmp`;
419
+ writeFileSync(pending, `${JSON.stringify(held, null, 2)}\n`, { mode: FILE_MODE });
420
+ chmodSync(pending, FILE_MODE);
421
+ renameSync(pending, path);
422
+ }
423
+ //#endregion
244
424
  //#region src/auth/metadata.ts
245
425
  const METADATA_PATH = "/.well-known/oauth-authorization-server";
246
426
  const AuthorizationServerMetadata = z.looseObject({
@@ -260,8 +440,12 @@ var DiscoveryFailure = class extends Error {
260
440
  this.name = "DiscoveryFailure";
261
441
  }
262
442
  };
443
+ /** How long a discovery document is reused, which is the hour the server asks for. */
444
+ const CACHE_MS = 36e5;
263
445
  /** toMetadata reads what an authorization server says about itself. */
264
446
  async function toMetadata(issuer) {
447
+ const cached = toCached(issuer);
448
+ if (cached) return cached;
265
449
  const url = `${issuer.replace(/\/+$/, "")}${METADATA_PATH}`;
266
450
  let response;
267
451
  try {
@@ -273,8 +457,39 @@ async function toMetadata(issuer) {
273
457
  const parsed = AuthorizationServerMetadata.safeParse(await response.json().catch(() => void 0));
274
458
  if (!parsed.success) throw new DiscoveryFailure(`${url} does not describe an authorization server`);
275
459
  if (parsed.data.issuer.replace(/\/+$/, "") !== issuer.replace(/\/+$/, "")) throw new DiscoveryFailure(`${url} names issuer ${parsed.data.issuer}, which is not the host it was read from`);
460
+ keep(issuer, parsed.data);
276
461
  return parsed.data;
277
462
  }
463
+ /** toCachePath names where one server's document is kept, beside the credentials. */
464
+ function toCachePath(issuer) {
465
+ const name = createHash("sha256").update(issuer).digest("hex").slice(0, 16);
466
+ return join(dirname(toCredentialPath()), `metadata-${name}.json`);
467
+ }
468
+ function toCached(issuer) {
469
+ const path = toCachePath(issuer);
470
+ if (!existsSync(path)) return;
471
+ try {
472
+ const held = JSON.parse(readFileSync(path, "utf8"));
473
+ if ((held.readAt ?? 0) + CACHE_MS < Date.now()) return;
474
+ const parsed = AuthorizationServerMetadata.safeParse(held.document);
475
+ return parsed.success ? parsed.data : void 0;
476
+ } catch {
477
+ return;
478
+ }
479
+ }
480
+ function keep(issuer, document) {
481
+ const path = toCachePath(issuer);
482
+ try {
483
+ mkdirSync(dirname(path), {
484
+ recursive: true,
485
+ mode: 448
486
+ });
487
+ writeFileSync(path, JSON.stringify({
488
+ readAt: Date.now(),
489
+ document
490
+ }), { mode: 384 });
491
+ } catch {}
492
+ }
278
493
  //#endregion
279
494
  //#region src/auth/grant.ts
280
495
  const TokenResponse = z.looseObject({
@@ -282,6 +497,7 @@ const TokenResponse = z.looseObject({
282
497
  token_type: z.string(),
283
498
  expires_in: z.number().optional(),
284
499
  refresh_token: z.string().optional(),
500
+ refresh_token_expires_in: z.number().optional(),
285
501
  scope: z.string().optional()
286
502
  });
287
503
  /** GrantFailure is a token request the authorization server refused. */
@@ -295,7 +511,7 @@ var GrantFailure = class extends Error {
295
511
  };
296
512
  /** toTokensFromCode trades an authorization code for tokens. */
297
513
  async function toTokensFromCode(tokenUrl, clientId, code, redirectUri, pkce) {
298
- return await request$1(tokenUrl, {
514
+ return await toTokens(tokenUrl, {
299
515
  grant_type: "authorization_code",
300
516
  client_id: clientId,
301
517
  code,
@@ -305,7 +521,7 @@ async function toTokensFromCode(tokenUrl, clientId, code, redirectUri, pkce) {
305
521
  }
306
522
  /** toTokensFromRefresh trades a refresh token for a fresh pair. */
307
523
  async function toTokensFromRefresh(tokenUrl, clientId, refreshToken) {
308
- return await request$1(tokenUrl, {
524
+ return await toTokens(tokenUrl, {
309
525
  grant_type: "refresh_token",
310
526
  client_id: clientId,
311
527
  refresh_token: refreshToken
@@ -323,8 +539,8 @@ async function revoke(revocationUrl, clientId, token) {
323
539
  })
324
540
  });
325
541
  }
326
- /** The token endpoint answers a flat RFC 6749 body, not the API's envelope. */
327
- async function request$1(url, form) {
542
+ /** toTokens asks the token endpoint, which answers a flat RFC 6749 body, not the envelope. */
543
+ async function toTokens(url, form) {
328
544
  const response = await fetch(url, {
329
545
  method: "POST",
330
546
  headers: {
@@ -334,132 +550,26 @@ async function request$1(url, form) {
334
550
  body: new URLSearchParams(form)
335
551
  });
336
552
  const body = await response.json().catch(() => void 0);
337
- if (!response.ok) throw new GrantFailure(String(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : String(body["error_description"]));
553
+ if (!response.ok) throw new GrantFailure(toText(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : toText(body["error_description"]));
338
554
  const parsed = TokenResponse.safeParse(body);
339
555
  if (!parsed.success) throw new GrantFailure("invalid_response", "the token endpoint did not answer with a token");
340
556
  return {
341
557
  accessToken: parsed.data.access_token,
342
558
  refreshToken: parsed.data.refresh_token,
559
+ refreshExpiresAt: parsed.data.refresh_token_expires_in === void 0 ? void 0 : Date.now() + parsed.data.refresh_token_expires_in * 1e3,
343
560
  scope: parsed.data.scope,
344
561
  expiresAt: parsed.data.expires_in === void 0 ? void 0 : Date.now() + parsed.data.expires_in * 1e3
345
562
  };
346
563
  }
347
564
  //#endregion
348
- //#region src/credential/store.ts
349
- const load = createRequire(import.meta.url);
350
- /** The service a keyring entry is filed under, alongside the issuer it belongs to. */
351
- const SERVICE = "hardfin-cli";
352
- const FILE_MODE = 384;
353
- const DIRECTORY_MODE$1 = 448;
354
- /** toCredentialPath names the file the fallback keeps refresh tokens in. */
355
- function toCredentialPath() {
356
- const base = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state");
357
- return join(base, "hardfin", "credentials.json");
358
- }
359
- /**
360
- * keep writes a refresh token for one issuer. Only the refresh token is stored, so a stolen
361
- * entry is revocable, and access tokens live in the process that fetched them.
362
- *
363
- * A rotation keeps the date of the original sign in, because that is what the server's
364
- * family lifetime runs from. Callers hold the credential lock, which is what makes a write
365
- * from another process merge rather than disappear.
366
- */
367
- function keep(issuer, refreshToken) {
368
- const now = (/* @__PURE__ */ new Date()).toISOString();
369
- const record = {
370
- refreshToken,
371
- signedInAt: toCredential(issuer)?.signedInAt ?? now,
372
- renewedAt: now
373
- };
374
- const keyring = toKeyring(issuer);
375
- if (keyring) try {
376
- keyring.setPassword(JSON.stringify(record));
377
- return "keyring";
378
- } catch {}
379
- writeFile({
380
- ...readFile(),
381
- [issuer]: record
382
- });
383
- return "file";
384
- }
385
- /** toCredential reads what is held for an issuer, and where it was held. */
386
- function toCredential(issuer) {
387
- const keyring = toKeyring(issuer);
388
- if (keyring) try {
389
- const held = keyring.getPassword();
390
- if (held) return {
391
- ...toRecord(held),
392
- backend: "keyring"
393
- };
394
- } catch {}
395
- const held = readFile()[issuer];
396
- return held === void 0 ? void 0 : {
397
- ...held,
398
- backend: "file",
399
- path: toCredentialPath()
400
- };
401
- }
402
- /** toRecord reads a stored entry, which older versions wrote as the bare token. */
403
- function toRecord(held) {
404
- if (!held.startsWith("{")) return { refreshToken: held };
405
- try {
406
- return JSON.parse(held);
407
- } catch {
408
- return { refreshToken: held };
409
- }
410
- }
411
- /** forget removes whatever is held for an issuer, in both places. */
412
- function forget(issuer) {
413
- const keyring = toKeyring(issuer);
414
- if (keyring) try {
415
- keyring.deletePassword();
416
- } catch {}
417
- const held = readFile();
418
- if (held[issuer] === void 0) return;
419
- delete held[issuer];
420
- if (Object.keys(held).length === 0) {
421
- rmSync(toCredentialPath(), { force: true });
422
- return;
423
- }
424
- writeFile(held);
425
- }
426
- /** toKeyring opens the OS keyring, or answers undefined where the platform has none. */
427
- function toKeyring(issuer) {
428
- if (process.env["HARDFIN_CREDENTIAL_STORE"] === "file") return;
429
- try {
430
- const { Entry } = load("@napi-rs/keyring");
431
- return new Entry(SERVICE, issuer);
432
- } catch {
433
- return;
434
- }
435
- }
436
- function readFile() {
437
- try {
438
- const parsed = JSON.parse(readFileSync(toCredentialPath(), "utf8"));
439
- if (typeof parsed !== "object" || parsed === null) return {};
440
- return Object.fromEntries(Object.entries(parsed).map(([issuer, held]) => [issuer, typeof held === "string" ? { refreshToken: held } : held]));
441
- } catch {
442
- return {};
443
- }
444
- }
445
- function writeFile(held) {
446
- const path = toCredentialPath();
447
- mkdirSync(dirname(path), {
448
- recursive: true,
449
- mode: DIRECTORY_MODE$1
450
- });
451
- const pending = `${path}.${process.pid}.tmp`;
452
- writeFileSync(pending, `${JSON.stringify(held, null, 2)}\n`, { mode: FILE_MODE });
453
- chmodSync(pending, FILE_MODE);
454
- renameSync(pending, path);
455
- }
456
- //#endregion
457
565
  //#region src/credential/lock.ts
458
566
  /** A lock held longer than this belongs to a process that died, so it is taken. */
459
567
  const STALE_MS = 3e4;
460
568
  const WAIT_MS$1 = 1e4;
461
569
  const RETRY_MS = 25;
462
570
  const DIRECTORY_MODE = 448;
571
+ /** What this process wrote into the lock, which is how it knows the lock is still its own. */
572
+ let heldBy;
463
573
  /** toLockPath names the lock every process coordinates credential writes through. */
464
574
  function toLockPath() {
465
575
  return join(dirname(toCredentialPath()), "credentials.lock");
@@ -471,17 +581,23 @@ function tryAcquire() {
471
581
  recursive: true,
472
582
  mode: DIRECTORY_MODE
473
583
  });
584
+ const mark = `${process.pid}:${randomUUID()}`;
474
585
  try {
475
586
  const handle = openSync(path, "wx");
476
- writeSync(handle, String(process.pid));
587
+ writeSync(handle, mark);
477
588
  closeSync(handle);
589
+ heldBy = mark;
478
590
  return true;
479
591
  } catch {
480
- return isStale(path) ? steal(path) : false;
592
+ return isStale(path) ? steal(path, mark) : false;
481
593
  }
482
594
  }
483
- /** release lets the next process in. */
595
+ /** release lets the next process in, and only ever removes this process's own lock. */
484
596
  function release$1() {
597
+ const mark = heldBy;
598
+ if (mark === void 0) return;
599
+ heldBy = void 0;
600
+ if (toMark(toLockPath()) !== mark) return;
485
601
  rmSync(toLockPath(), { force: true });
486
602
  }
487
603
  /**
@@ -508,9 +624,29 @@ function isStale(path) {
508
624
  return true;
509
625
  }
510
626
  }
511
- function steal(path) {
627
+ /**
628
+ * steal takes over a lock whose holder is gone. Two processes can reach this at once, so
629
+ * the winner is whichever mark survives in the file, not whichever removed it.
630
+ */
631
+ function steal(path, mark) {
512
632
  rmSync(path, { force: true });
513
- return tryAcquire();
633
+ try {
634
+ const handle = openSync(path, "wx");
635
+ writeSync(handle, mark);
636
+ closeSync(handle);
637
+ } catch {
638
+ return false;
639
+ }
640
+ if (toMark(path) !== mark) return false;
641
+ heldBy = mark;
642
+ return true;
643
+ }
644
+ function toMark(path) {
645
+ try {
646
+ return readFileSync(path, "utf8");
647
+ } catch {
648
+ return;
649
+ }
514
650
  }
515
651
  //#endregion
516
652
  //#region src/auth/session.ts
@@ -536,6 +672,7 @@ async function toRequestCredential(settings) {
536
672
  value: settings.apiKey,
537
673
  kind: "api key"
538
674
  };
675
+ refuseForeignHost(settings);
539
676
  const stored = toCredential(settings.issuerUrl);
540
677
  if (!stored) throw new NoCredential("not authenticated. Run hardfin login, or set HARDFIN_API_KEY");
541
678
  return {
@@ -552,12 +689,74 @@ async function toAccessToken(settings, refreshToken) {
552
689
  const metadata = await toMetadata(settings.issuerUrl);
553
690
  return await withLock(async () => {
554
691
  const latest = toCredential(settings.issuerUrl)?.refreshToken ?? refreshToken;
555
- const tokens = await toTokensFromRefresh(metadata.token_endpoint, settings.clientId, latest);
556
- held.set(settings.issuerUrl, tokens);
557
- if (tokens.refreshToken && tokens.refreshToken !== latest) keep(settings.issuerUrl, tokens.refreshToken);
558
- return tokens;
692
+ try {
693
+ const tokens = toDated(await toTokensFromRefresh(metadata.token_endpoint, settings.clientId, latest));
694
+ held.set(settings.issuerUrl, tokens);
695
+ store(settings.issuerUrl, tokens, latest);
696
+ return tokens;
697
+ } catch (error) {
698
+ if (error instanceof GrantFailure && isDead(error.code)) {
699
+ forget(settings.issuerUrl);
700
+ throw new NoCredential(`the sign in for ${settings.issuerUrl} is no longer valid, so it was removed. Run hardfin login`);
701
+ }
702
+ throw error;
703
+ }
559
704
  });
560
705
  }
706
+ /**
707
+ * invalid_grant is the only refusal that says anything about the refresh token itself.
708
+ * invalid_client and unauthorized_client describe the client registration, which is server
709
+ * configuration and a setting a person can mistype, so a good credential survives them.
710
+ */
711
+ function isDead(code) {
712
+ return code === "invalid_grant";
713
+ }
714
+ /**
715
+ * toDated fills in when an access token expires. A token endpoint that states no expires_in
716
+ * would otherwise have this process refresh on every command, and every refresh spends a
717
+ * generation of the token family.
718
+ */
719
+ function toDated(tokens) {
720
+ if (tokens.expiresAt !== void 0) return tokens;
721
+ return {
722
+ ...tokens,
723
+ expiresAt: toClaims(tokens.accessToken).expiresAt
724
+ };
725
+ }
726
+ /**
727
+ * store writes what a rotation issued. The old token is spent either way, so failing to
728
+ * write the new one is worth saying out loud rather than failing a command that succeeded.
729
+ */
730
+ function store(issuer, tokens, previous) {
731
+ if (!tokens.refreshToken || tokens.refreshToken === previous) return;
732
+ try {
733
+ keep$1(issuer, tokens.refreshToken, tokens.refreshExpiresAt);
734
+ } catch (error) {
735
+ process.stderr.write(`warning: this sign in was renewed but could not be stored, so the next command will ask you to sign in again: ${error instanceof Error ? error.message : String(error)}\n`);
736
+ }
737
+ }
738
+ /**
739
+ * refuseForeignHost keeps an access token on the host that issued it. The API URL and the
740
+ * authorization server are configured separately, and a directory's config file can set
741
+ * either, so nothing else stops a token minted for Hardfin being sent somewhere else.
742
+ */
743
+ function refuseForeignHost(settings) {
744
+ const api = toHost(settings.apiUrl);
745
+ const issuer = toHost(settings.issuerUrl);
746
+ if (api === void 0 || issuer === void 0) throw new NoCredential(`${settings.apiUrl} is not a URL this CLI can call`);
747
+ if (api.host !== issuer.host) throw new NoCredential(`this sign in belongs to ${issuer.host}, so it is not sent to ${api.host}. Set HARDFIN_API_KEY to call another host, or point --issuer-url at the server that signs you in`);
748
+ if (api.protocol !== "https:" && !isLoopback(api.hostname)) throw new NoCredential(`${settings.apiUrl} is not https, so a sign in is not sent to it`);
749
+ }
750
+ function toHost(url) {
751
+ try {
752
+ return new URL(url);
753
+ } catch {
754
+ return;
755
+ }
756
+ }
757
+ function isLoopback(hostname) {
758
+ return hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" || hostname === "localhost";
759
+ }
561
760
  /** forgetHeldTokens drops the access tokens this process is holding. */
562
761
  function forgetHeldTokens() {
563
762
  held.clear();
@@ -580,18 +779,23 @@ var RequestFailure = class extends Error {
580
779
  /** request calls one /v2 endpoint and returns the envelope it answered with. */
581
780
  async function request(options) {
582
781
  const url = new URL(`${options.apiUrl}${toLeadingSlash(options.path)}`);
583
- if (options.query) url.search = options.query.toString();
782
+ for (const [name, value] of options.query ?? []) url.searchParams.append(name, value);
584
783
  const headers = {
585
784
  [options.credential.header]: options.credential.value,
586
785
  "X-API-Version": API_VERSION,
587
- Accept: "application/json"
786
+ Accept: options.downloads ? "*/*" : "application/json"
588
787
  };
589
- if (options.body !== void 0) headers["Content-Type"] = "application/json";
788
+ if (options.body !== void 0 && options.form === void 0) headers["Content-Type"] = "application/json";
590
789
  const response = await fetch(url, {
591
790
  method: options.method,
592
791
  headers,
593
- body: options.body === void 0 ? void 0 : JSON.stringify(options.body)
792
+ body: options.form ?? (options.body === void 0 ? void 0 : JSON.stringify(options.body))
594
793
  });
794
+ if (options.downloads && response.ok) return { data: {
795
+ bytes: Buffer.from(await response.arrayBuffer()),
796
+ contentType: response.headers.get("content-type"),
797
+ fileName: toFileName(response.headers.get("content-disposition"))
798
+ } };
595
799
  const envelope = toEnvelope(await response.text());
596
800
  if (!response.ok && envelope === void 0) throw new RequestFailure(response.status, [{
597
801
  error: toStatusMessage(response.status),
@@ -606,6 +810,11 @@ async function request(options) {
606
810
  }
607
811
  return envelope ?? { data: null };
608
812
  }
813
+ /** toFileName reads the name a download was offered under, when the server names one. */
814
+ function toFileName(disposition) {
815
+ const matched = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition ?? "");
816
+ return matched?.[1] ? decodeURIComponent(matched[1]) : void 0;
817
+ }
609
818
  function toLeadingSlash(path) {
610
819
  return path.startsWith("/") ? path : `/${path}`;
611
820
  }
@@ -688,6 +897,10 @@ async function runApi(input) {
688
897
  writeFailure("a path is required, such as /customer", input.isJSON);
689
898
  return ExitCode.USAGE;
690
899
  }
900
+ if (path.split("/").includes("..")) {
901
+ writeFailure("a path stays inside the API, so it cannot contain ..", input.isJSON);
902
+ return ExitCode.USAGE;
903
+ }
691
904
  const query = toQuery$1(input.flags["field"]);
692
905
  if (query === void 0) {
693
906
  writeFailure("each --field is key=value, such as -f limit=50", input.isJSON);
@@ -705,7 +918,7 @@ async function runApi(input) {
705
918
  writeData((await request({
706
919
  apiUrl: input.resolved.settings.apiUrl,
707
920
  credential: await toRequestCredential(input.resolved.settings),
708
- method: String(input.flags["method"] ?? "GET").toUpperCase(),
921
+ method: toText(input.flags["method"] ?? "GET").toUpperCase(),
709
922
  path,
710
923
  query,
711
924
  body
@@ -717,7 +930,7 @@ async function runApi(input) {
717
930
  return ExitCode.NOT_AUTHENTICATED;
718
931
  }
719
932
  if (error instanceof RequestFailure) {
720
- writeFailure(error.message, input.isJSON, error.errors, error.requestId);
933
+ writeFailure(error.message, input.isJSON, error.errors, error.requestId, error.status);
721
934
  return error.status === 401 ? ExitCode.NOT_AUTHENTICATED : ExitCode.ERROR;
722
935
  }
723
936
  writeFailure(error instanceof Error ? error.message : String(error), input.isJSON);
@@ -743,6 +956,132 @@ function toBody$1(source) {
743
956
  }
744
957
  }
745
958
  //#endregion
959
+ //#region src/command/completion.ts
960
+ const SHELLS = [
961
+ "bash",
962
+ "zsh",
963
+ "fish",
964
+ "powershell"
965
+ ];
966
+ const completionCommand = defineCommand({
967
+ name: "completion",
968
+ summary: "Print the shell script that completes hardfin commands",
969
+ description: "Writes a script for your shell. The script asks this CLI what may follow what you have typed, so completions never fall behind the commands.",
970
+ arguments: [{
971
+ name: "shell",
972
+ description: `The shell to write for: ${SHELLS.join(", ")}`,
973
+ required: true
974
+ }],
975
+ flags: [],
976
+ examples: [
977
+ {
978
+ description: "Complete in this shell, now",
979
+ command: "source <(hardfin completion zsh)"
980
+ },
981
+ {
982
+ description: "Complete in every new shell",
983
+ command: "hardfin completion zsh > ~/.hardfin-completion.zsh"
984
+ },
985
+ {
986
+ description: "Complete in bash",
987
+ command: "source <(hardfin completion bash)"
988
+ }
989
+ ],
990
+ run: runCompletion
991
+ });
992
+ /** The hidden command a completion script asks, which keeps one implementation for every shell. */
993
+ const completeCommand = defineCommand({
994
+ name: "__complete",
995
+ summary: "Answer what may follow the words typed so far",
996
+ hidden: true,
997
+ arguments: [{
998
+ name: "words",
999
+ description: "The words typed so far",
1000
+ required: false,
1001
+ variadic: true
1002
+ }],
1003
+ flags: [{
1004
+ name: "json",
1005
+ description: "Accepted for consistency, and ignored",
1006
+ schema: z.boolean()
1007
+ }],
1008
+ examples: [],
1009
+ run: async (input) => {
1010
+ writeData(toCandidates(input.commands, input.args).join("\n"));
1011
+ return ExitCode.OK;
1012
+ }
1013
+ });
1014
+ /**
1015
+ * toCandidates answers what may follow the words typed so far. A word starting with a dash
1016
+ * asks for the current command's flags, and anything else asks for its subcommands.
1017
+ */
1018
+ function toCandidates(commands, words) {
1019
+ const partial = words[words.length - 1] ?? "";
1020
+ const walked = toWalked(commands, words.slice(0, -1));
1021
+ if (partial.startsWith("-")) return toFlagNames(walked.command).filter((name) => name.startsWith(partial));
1022
+ if (walked.isUnknown) return [];
1023
+ return (walked.command?.subcommands ?? walked.remaining).map((command) => command.name).filter((name) => !name.startsWith("__")).filter((name) => name.startsWith(partial));
1024
+ }
1025
+ function toWalked(commands, words) {
1026
+ let remaining = commands;
1027
+ let command;
1028
+ for (const word of words) {
1029
+ if (word.startsWith("-") || command?.arguments.length) continue;
1030
+ const found = remaining.find((entry) => entry.name === word);
1031
+ if (!found) return {
1032
+ command,
1033
+ remaining,
1034
+ isUnknown: true
1035
+ };
1036
+ command = found;
1037
+ remaining = found.subcommands ?? [];
1038
+ }
1039
+ return {
1040
+ command,
1041
+ remaining,
1042
+ isUnknown: false
1043
+ };
1044
+ }
1045
+ function toFlagNames(command) {
1046
+ return [...(command?.flags ?? []).map((flag) => `--${flag.name}`), "--help"];
1047
+ }
1048
+ async function runCompletion(input) {
1049
+ const shell = input.args[0] ?? "";
1050
+ if (!SHELLS.includes(shell)) {
1051
+ writeFailure(`${shell || "no shell"} is not one this CLI writes for. Choose ${SHELLS.join(", ")}`, input.isJSON);
1052
+ return ExitCode.USAGE;
1053
+ }
1054
+ writeData(toScript(shell));
1055
+ return ExitCode.OK;
1056
+ }
1057
+ /** toScript writes a shell's completion, each one asking __complete for the candidates. */
1058
+ function toScript(shell) {
1059
+ if (shell === "bash") return `# hardfin completion for bash
1060
+ _hardfin_complete() {
1061
+ local words
1062
+ words=("\${COMP_WORDS[@]:1}")
1063
+ COMPREPLY=($(hardfin __complete -- "\${words[@]}" 2>/dev/null))
1064
+ }
1065
+ complete -F _hardfin_complete hardfin`;
1066
+ if (shell === "zsh") return `# hardfin completion for zsh
1067
+ _hardfin_complete() {
1068
+ local -a candidates
1069
+ candidates=(\${(f)"$(hardfin __complete -- \${words[2,-1]} 2>/dev/null)"})
1070
+ compadd -a candidates
1071
+ }
1072
+ compdef _hardfin_complete hardfin`;
1073
+ if (shell === "fish") return `# hardfin completion for fish
1074
+ complete -c hardfin -f -a "(hardfin __complete -- (commandline -opc)[2..-1] 2>/dev/null)"`;
1075
+ return `# hardfin completion for PowerShell
1076
+ Register-ArgumentCompleter -Native -CommandName hardfin -ScriptBlock {
1077
+ param($wordToComplete, $commandAst, $cursorPosition)
1078
+ $words = $commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object { $_.ToString() }
1079
+ hardfin __complete -- @words 2>$null | ForEach-Object {
1080
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
1081
+ }
1082
+ }`;
1083
+ }
1084
+ //#endregion
746
1085
  //#region src/command/config.ts
747
1086
  const configCommand = defineCommand({
748
1087
  name: "config",
@@ -825,8 +1164,8 @@ function toOpeners(url) {
825
1164
  return [["xdg-open", [url]]];
826
1165
  }
827
1166
  /** openBrowser asks the desktop to show a URL, and reports whether anything took it. */
828
- function openBrowser(url) {
829
- for (const [command, args] of toOpeners(url)) if (spawnSync(command, args, {
1167
+ function openBrowser(url, launch = spawnSync) {
1168
+ for (const [command, args] of toOpeners(url)) if (launch(command, args, {
830
1169
  stdio: "ignore",
831
1170
  cwd: command.endsWith(".exe") ? "/mnt/c" : void 0,
832
1171
  timeout: 1e4
@@ -834,6 +1173,74 @@ function openBrowser(url) {
834
1173
  return false;
835
1174
  }
836
1175
  //#endregion
1176
+ //#region src/auth/device.ts
1177
+ const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
1178
+ /** The interval a server gives is in seconds, and five more are added on a slow_down. */
1179
+ const DEFAULT_INTERVAL_SECONDS = 5;
1180
+ const SLOW_DOWN_SECONDS = 5;
1181
+ const DeviceResponse = z.looseObject({
1182
+ device_code: z.string(),
1183
+ user_code: z.string(),
1184
+ verification_uri: z.string(),
1185
+ verification_uri_complete: z.string().optional(),
1186
+ expires_in: z.number(),
1187
+ interval: z.number().optional()
1188
+ });
1189
+ /** toDeviceAuthorization asks for a code a person can type on another machine. */
1190
+ async function toDeviceAuthorization(endpoint, clientId, scope) {
1191
+ const response = await fetch(endpoint, {
1192
+ method: "POST",
1193
+ headers: {
1194
+ "Content-Type": "application/x-www-form-urlencoded",
1195
+ Accept: "application/json"
1196
+ },
1197
+ body: new URLSearchParams({
1198
+ client_id: clientId,
1199
+ scope
1200
+ })
1201
+ });
1202
+ const body = await response.json().catch(() => void 0);
1203
+ if (!response.ok) throw new GrantFailure(toText(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : toText(body["error_description"]));
1204
+ const parsed = DeviceResponse.safeParse(body);
1205
+ if (!parsed.success) throw new GrantFailure("invalid_response", "the device endpoint did not answer with a code");
1206
+ return {
1207
+ deviceCode: parsed.data.device_code,
1208
+ userCode: parsed.data.user_code,
1209
+ verificationUri: parsed.data.verification_uri,
1210
+ verificationUriComplete: parsed.data.verification_uri_complete,
1211
+ expiresAt: Date.now() + parsed.data.expires_in * 1e3,
1212
+ intervalMs: (parsed.data.interval ?? DEFAULT_INTERVAL_SECONDS) * 1e3
1213
+ };
1214
+ }
1215
+ /**
1216
+ * toTokensFromDevice waits for the person to approve on another machine. The server answers
1217
+ * authorization_pending until they do, and slow_down when this asked too often.
1218
+ */
1219
+ async function toTokensFromDevice(tokenUrl, clientId, device, sleep = toSleep) {
1220
+ let intervalMs = device.intervalMs;
1221
+ for (;;) {
1222
+ if (Date.now() > device.expiresAt) throw new GrantFailure("expired_token", "the code expired before it was approved");
1223
+ await sleep(intervalMs);
1224
+ try {
1225
+ return await toTokens(tokenUrl, {
1226
+ grant_type: DEVICE_GRANT,
1227
+ client_id: clientId,
1228
+ device_code: device.deviceCode
1229
+ });
1230
+ } catch (error) {
1231
+ if (!(error instanceof GrantFailure)) throw error;
1232
+ if (error.code === "slow_down") {
1233
+ intervalMs += SLOW_DOWN_SECONDS * 1e3;
1234
+ continue;
1235
+ }
1236
+ if (error.code !== "authorization_pending") throw error;
1237
+ }
1238
+ }
1239
+ }
1240
+ function toSleep(ms) {
1241
+ return new Promise((resolve) => setTimeout(resolve, ms));
1242
+ }
1243
+ //#endregion
837
1244
  //#region src/auth/loopback.ts
838
1245
  /** The path the browser is sent back to, which the client metadata document registers. */
839
1246
  const CALLBACK_PATH = "/callback";
@@ -869,8 +1276,12 @@ async function toListener(timeoutMs) {
869
1276
  }, timeoutMs);
870
1277
  return {
871
1278
  redirectUri: `http://${HOST}:${server.address().port}${CALLBACK_PATH}`,
872
- callback: callback.finally(() => close(server, timer)),
873
- close: () => close(server, timer)
1279
+ callback: callback.finally(() => {
1280
+ close(server, timer);
1281
+ }),
1282
+ close: () => {
1283
+ close(server, timer);
1284
+ }
874
1285
  };
875
1286
  }
876
1287
  function toCallback(request) {
@@ -924,11 +1335,11 @@ function toClipboardCommand() {
924
1335
  return ["xclip", ["-selection", "clipboard"]];
925
1336
  }
926
1337
  /** copyToClipboard puts text on the clipboard, and reports whether anything took it. */
927
- function copyToClipboard(text) {
1338
+ function copyToClipboard(text, copy = spawnSync) {
928
1339
  const command = toClipboardCommand();
929
1340
  if (!command) return false;
930
1341
  const [name, args] = command;
931
- const result = spawnSync(name, args, { input: text });
1342
+ const result = copy(name, args, { input: text });
932
1343
  return result.error === void 0 && result.status === 0;
933
1344
  }
934
1345
  //#endregion
@@ -964,20 +1375,21 @@ function toPastedCallback(pasted) {
964
1375
  * toPrompt watches the keyboard while the browser is away. Pressing c copies the URL, and
965
1376
  * pasting a code finishes the sign in on a machine whose browser cannot reach this listener.
966
1377
  */
967
- function toPrompt(url) {
1378
+ function toPrompt(url, input = process.stdin, copy = copyToClipboard) {
968
1379
  let settle = () => {};
969
1380
  let fail = () => {};
970
1381
  const pasted = new Promise((resolve, reject) => {
971
1382
  settle = resolve;
972
1383
  fail = reject;
973
1384
  });
974
- const input = process.stdin;
1385
+ pasted.catch(() => {});
975
1386
  if (!input.isTTY) return {
976
1387
  pasted,
977
1388
  close: () => {}
978
1389
  };
979
1390
  let typed = "";
980
1391
  const onData = (chunk) => {
1392
+ const isKeystroke = chunk.length === 1;
981
1393
  for (const character of chunk) {
982
1394
  if (character === CTRL_C) {
983
1395
  fail(/* @__PURE__ */ new Error("sign in was cancelled"));
@@ -994,8 +1406,8 @@ function toPrompt(url) {
994
1406
  process.stderr.write("\b \b");
995
1407
  continue;
996
1408
  }
997
- if ((character === "c" || character === "C") && typed === "") {
998
- process.stderr.write(copyToClipboard(url) ? "Copied the URL to your clipboard\n" : "Nothing on this host takes a clipboard\n");
1409
+ if (isKeystroke && (character === "c" || character === "C") && typed === "") {
1410
+ process.stderr.write(copy(url) ? "Copied the URL to your clipboard\n" : "Nothing on this host takes a clipboard\n");
999
1411
  continue;
1000
1412
  }
1001
1413
  typed += character;
@@ -1039,19 +1451,31 @@ const loginCommand = defineCommand({
1039
1451
  description: "Print the URL instead of opening it",
1040
1452
  schema: z.boolean()
1041
1453
  },
1454
+ {
1455
+ name: "device",
1456
+ description: "Approve on another machine, by typing a code, with no listener on this one",
1457
+ schema: z.boolean()
1458
+ },
1042
1459
  {
1043
1460
  name: "json",
1044
1461
  description: "Print machine-readable output, which is the default when stdout is not a terminal",
1045
1462
  schema: z.boolean()
1046
1463
  }
1047
1464
  ],
1048
- examples: [{
1049
- description: "Sign in",
1050
- command: "hardfin login"
1051
- }, {
1052
- description: "Sign in over SSH, opening the URL yourself",
1053
- command: "hardfin login --no-browser"
1054
- }],
1465
+ examples: [
1466
+ {
1467
+ description: "Sign in",
1468
+ command: "hardfin login"
1469
+ },
1470
+ {
1471
+ description: "Sign in over SSH, opening the URL yourself",
1472
+ command: "hardfin login --no-browser"
1473
+ },
1474
+ {
1475
+ description: "Approve from your phone or another machine",
1476
+ command: "hardfin login --device"
1477
+ }
1478
+ ],
1055
1479
  run: runLogin
1056
1480
  });
1057
1481
  async function runLogin(input) {
@@ -1059,13 +1483,15 @@ async function runLogin(input) {
1059
1483
  try {
1060
1484
  const clientId = settings.clientId;
1061
1485
  const metadata = await toMetadata(settings.issuerUrl);
1486
+ const scope = toText(input.flags["scope"] ?? DEFAULT_SCOPES);
1487
+ if (input.flags["device"] === true) return await runDeviceLogin(input, metadata, scope);
1062
1488
  const listener = await toListener(WAIT_MS);
1063
1489
  const pkce = toPkce();
1064
1490
  const state = toState();
1065
1491
  const url = toAuthorizationUrl(metadata.authorization_endpoint, {
1066
1492
  clientId,
1067
1493
  redirectUri: listener.redirectUri,
1068
- scope: String(input.flags["scope"] ?? DEFAULT_SCOPES),
1494
+ scope,
1069
1495
  state,
1070
1496
  challenge: pkce.challenge
1071
1497
  });
@@ -1073,9 +1499,13 @@ async function runLogin(input) {
1073
1499
  process.stderr.write(opened ? `Opening your browser to sign in. If it did not open:\n\n${url}\n\n` : `Open this URL to sign in:\n\n${url}\n\n`);
1074
1500
  const prompt = toPrompt(url);
1075
1501
  if (process.stdin.isTTY) process.stderr.write("Press c to copy the URL, or paste the code or redirect URL here: ");
1076
- const callback = await Promise.race([listener.callback, prompt.pasted]);
1077
- listener.close();
1078
- prompt.close();
1502
+ let callback;
1503
+ try {
1504
+ callback = await Promise.race([listener.callback, prompt.pasted]);
1505
+ } finally {
1506
+ listener.close();
1507
+ prompt.close();
1508
+ }
1079
1509
  process.stderr.write(callback.error ? "\n" : "\nApproved, finishing the sign in\n");
1080
1510
  if (callback.error) {
1081
1511
  writeFailure(`sign in was refused: ${callback.error}${callback.errorDescription ? `, ${callback.errorDescription}` : ""}`, input.isJSON);
@@ -1093,31 +1523,7 @@ async function runLogin(input) {
1093
1523
  writeFailure("the browser came back without an authorization code", input.isJSON);
1094
1524
  return ExitCode.ERROR;
1095
1525
  }
1096
- const tokens = await toTokensFromCode(metadata.token_endpoint, clientId, callback.code, listener.redirectUri, pkce);
1097
- const refreshToken = tokens.refreshToken;
1098
- if (!refreshToken) {
1099
- writeFailure("the authorization server issued no refresh token, so this sign in cannot be kept", input.isJSON);
1100
- return ExitCode.ERROR;
1101
- }
1102
- const backend = await withLock(() => keep(settings.issuerUrl, refreshToken));
1103
- if (input.isJSON) {
1104
- writeData({
1105
- signedIn: true,
1106
- issuer: metadata.issuer,
1107
- scope: tokens.scope ?? null,
1108
- storedIn: backend
1109
- });
1110
- return ExitCode.OK;
1111
- }
1112
- const stored = backend === "keyring" ? "your OS keyring" : toCredentialPath();
1113
- writeData([
1114
- `Signed in to ${metadata.issuer}`,
1115
- `Scope ${tokens.scope ?? "as granted"}`,
1116
- `Stored in ${stored}`,
1117
- "",
1118
- "Run hardfin status to see what this CLI is using"
1119
- ].join("\n"));
1120
- return ExitCode.OK;
1526
+ return await toSignedIn(input, metadata, await toTokensFromCode(metadata.token_endpoint, clientId, callback.code, listener.redirectUri, pkce));
1121
1527
  } catch (error) {
1122
1528
  if (error instanceof DiscoveryFailure || error instanceof GrantFailure) {
1123
1529
  writeFailure(error.message, input.isJSON);
@@ -1127,6 +1533,61 @@ async function runLogin(input) {
1127
1533
  return ExitCode.ERROR;
1128
1534
  }
1129
1535
  }
1536
+ /** runDeviceLogin waits while the person approves on a machine that has a browser. */
1537
+ async function runDeviceLogin(input, metadata, scope) {
1538
+ const settings = input.resolved.settings;
1539
+ if (!metadata.device_authorization_endpoint) {
1540
+ writeFailure(`${metadata.issuer} does not offer the device grant, so sign in without --device`, input.isJSON);
1541
+ return ExitCode.ERROR;
1542
+ }
1543
+ const device = await toDeviceAuthorization(metadata.device_authorization_endpoint, settings.clientId, scope);
1544
+ const url = device.verificationUriComplete ?? device.verificationUri;
1545
+ const opened = input.flags["noBrowser"] !== true && !process.env["HARDFIN_NO_BROWSER"] && openBrowser(url);
1546
+ process.stderr.write([
1547
+ "",
1548
+ ` Code ${device.userCode}`,
1549
+ ` At ${device.verificationUri}`,
1550
+ "",
1551
+ opened ? "Opened your browser there. Waiting for approval\n" : "Open that page on any machine, and enter the code. Waiting for approval\n"
1552
+ ].join("\n"));
1553
+ const prompt = toPrompt(url);
1554
+ try {
1555
+ return await toSignedIn(input, metadata, await Promise.race([toTokensFromDevice(metadata.token_endpoint, settings.clientId, device), prompt.pasted.then(toCancelled)]));
1556
+ } finally {
1557
+ prompt.close();
1558
+ }
1559
+ }
1560
+ /** toCancelled ends a device sign in that the keyboard interrupted. */
1561
+ function toCancelled() {
1562
+ throw new Error("sign in was cancelled");
1563
+ }
1564
+ /** toSignedIn stores what a sign in issued, whichever flow issued it. */
1565
+ async function toSignedIn(input, metadata, tokens) {
1566
+ const refreshToken = tokens.refreshToken;
1567
+ if (!refreshToken) {
1568
+ writeFailure("the authorization server issued no refresh token, so this sign in cannot be kept", input.isJSON);
1569
+ return ExitCode.ERROR;
1570
+ }
1571
+ const backend = await withLock(() => keep$1(input.resolved.settings.issuerUrl, refreshToken, tokens.refreshExpiresAt));
1572
+ if (input.isJSON) {
1573
+ writeData({
1574
+ signedIn: true,
1575
+ issuer: metadata.issuer,
1576
+ scope: tokens.scope ?? null,
1577
+ storedIn: backend
1578
+ });
1579
+ return ExitCode.OK;
1580
+ }
1581
+ const stored = backend === "keyring" ? "your OS keyring" : toCredentialPath();
1582
+ writeData([
1583
+ `Signed in to ${metadata.issuer}`,
1584
+ `Scope ${tokens.scope ?? "as granted"}`,
1585
+ `Stored in ${stored}`,
1586
+ "",
1587
+ "Run hardfin status to see what this CLI is using"
1588
+ ].join("\n"));
1589
+ return ExitCode.OK;
1590
+ }
1130
1591
  /** toAuthorizationUrl builds the URL the person approves this CLI at. */
1131
1592
  function toAuthorizationUrl(endpoint, request) {
1132
1593
  const url = new URL(endpoint);
@@ -1178,7 +1639,9 @@ async function runLogout(input) {
1178
1639
  } catch {
1179
1640
  revoked = false;
1180
1641
  }
1181
- await withLock(() => forget(settings.issuerUrl));
1642
+ await withLock(() => {
1643
+ forget(settings.issuerUrl);
1644
+ });
1182
1645
  forgetHeldTokens();
1183
1646
  writeData({
1184
1647
  signedOut: true,
@@ -1188,11 +1651,344 @@ async function runLogout(input) {
1188
1651
  return ExitCode.OK;
1189
1652
  }
1190
1653
  //#endregion
1654
+ //#region src/mcp/server.ts
1655
+ const PROTOCOL_VERSION = "2025-06-18";
1656
+ const GUIDE_URI = "hardfin://guide";
1657
+ const COMMANDS_URI = "hardfin://commands";
1658
+ /**
1659
+ * One tool, because every tool a server lists sits in the agent's context for the whole
1660
+ * session. The commands themselves are resources, which cost nothing until one is read.
1661
+ */
1662
+ const TOOL = {
1663
+ name: "hardfin",
1664
+ description: "Run a Hardfin CLI command against the Hardfin API. Pass the arguments as a list, such as [\"asset\", \"list\", \"--limit\", \"5\"]. Run [\"--help\"] for the commands, or read the hardfin://guide resource.",
1665
+ inputSchema: {
1666
+ type: "object",
1667
+ properties: { args: {
1668
+ type: "array",
1669
+ items: { type: "string" },
1670
+ description: "The arguments to hardfin, without the program name"
1671
+ } },
1672
+ required: ["args"]
1673
+ }
1674
+ };
1675
+ /** ProtocolFailure is a line this server answers with a JSON-RPC error rather than a result. */
1676
+ var ProtocolFailure = class extends Error {
1677
+ code;
1678
+ id;
1679
+ constructor(code, message, id) {
1680
+ super(message);
1681
+ this.name = "ProtocolFailure";
1682
+ this.code = code;
1683
+ this.id = id;
1684
+ }
1685
+ };
1686
+ /** toResponse answers one request, and answers nothing to a notification. */
1687
+ async function toResponse(request, commands, run) {
1688
+ const answer = (result) => ({
1689
+ jsonrpc: "2.0",
1690
+ id: request.id ?? null,
1691
+ result
1692
+ });
1693
+ switch (request.method) {
1694
+ case "initialize": return answer({
1695
+ protocolVersion: toProtocolVersion(request.params),
1696
+ capabilities: {
1697
+ tools: {},
1698
+ resources: {}
1699
+ },
1700
+ serverInfo: {
1701
+ name: "hardfin",
1702
+ version: version$1
1703
+ }
1704
+ });
1705
+ case "tools/list": return answer({ tools: [TOOL] });
1706
+ case "resources/list": return answer({ resources: [{
1707
+ uri: GUIDE_URI,
1708
+ name: "Hardfin CLI guide",
1709
+ description: "Every command, its flags, and the exit codes",
1710
+ mimeType: "text/markdown"
1711
+ }, {
1712
+ uri: COMMANDS_URI,
1713
+ name: "Hardfin CLI commands",
1714
+ description: "The command tree as JSON",
1715
+ mimeType: "application/json"
1716
+ }] });
1717
+ case "resources/read": {
1718
+ const uri = toText(request.params?.["uri"] ?? "");
1719
+ const resource = toResource(uri, commands);
1720
+ if (!resource) return {
1721
+ jsonrpc: "2.0",
1722
+ id: request.id ?? null,
1723
+ error: {
1724
+ code: -32002,
1725
+ message: `${uri} is not a resource this server offers`
1726
+ }
1727
+ };
1728
+ return answer(resource);
1729
+ }
1730
+ case "tools/call": return answer(await toToolResult(request.params, run));
1731
+ case "ping": return answer({});
1732
+ default:
1733
+ if (request.id === void 0 || request.id === null) return;
1734
+ return {
1735
+ jsonrpc: "2.0",
1736
+ id: request.id,
1737
+ error: {
1738
+ code: -32601,
1739
+ message: `${request.method} is not a method this server offers`
1740
+ }
1741
+ };
1742
+ }
1743
+ }
1744
+ /** The revisions this server speaks, newest first, which is what it may answer with. */
1745
+ const PROTOCOL_VERSIONS = [
1746
+ PROTOCOL_VERSION,
1747
+ "2025-03-26",
1748
+ "2024-11-05"
1749
+ ];
1750
+ /** toProtocolVersion answers a version this server implements, never one it was told. */
1751
+ function toProtocolVersion(params) {
1752
+ const asked = params?.["protocolVersion"];
1753
+ return typeof asked === "string" && PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSION;
1754
+ }
1755
+ function toResource(uri, commands) {
1756
+ if (uri === GUIDE_URI) return { contents: [{
1757
+ uri,
1758
+ mimeType: "text/markdown",
1759
+ text: toGuide(commands, version$1)
1760
+ }] };
1761
+ if (uri === COMMANDS_URI) return { contents: [{
1762
+ uri,
1763
+ mimeType: "application/json",
1764
+ text: JSON.stringify(toTree(commands), null, 2)
1765
+ }] };
1766
+ }
1767
+ /** toTree names every command and what it takes, without the schemas a tool list would carry. */
1768
+ function toTree(commands) {
1769
+ return commands.map((command) => ({
1770
+ name: command.name,
1771
+ summary: command.summary,
1772
+ arguments: command.arguments.map((argument) => argument.name),
1773
+ flags: command.flags.map((flag) => flag.valueName ? `--${flag.name} <${flag.valueName}>` : `--${flag.name}`),
1774
+ subcommands: command.subcommands ? toTree(command.subcommands) : void 0
1775
+ }));
1776
+ }
1777
+ /** Signing in needs a browser and a person, neither of which an agent's session has. */
1778
+ const REFUSED_COMMANDS = /* @__PURE__ */ new Set([
1779
+ "login",
1780
+ "logout",
1781
+ "mcp"
1782
+ ]);
1783
+ /** The global options, which sit before a command and take a value of their own. */
1784
+ const GLOBAL_OPTIONS_WITH_VALUES = /* @__PURE__ */ new Set(["--api-url", "--issuer-url"]);
1785
+ /**
1786
+ * toCommandName reads which command a call names. A global option sits before it, so
1787
+ * reading the first argument alone would let --issuer-url x logout through.
1788
+ */
1789
+ function toCommandName(args) {
1790
+ for (let index = 0; index < args.length; index += 1) {
1791
+ const argument = args[index] ?? "";
1792
+ if (GLOBAL_OPTIONS_WITH_VALUES.has(argument)) {
1793
+ index += 1;
1794
+ continue;
1795
+ }
1796
+ if (argument.startsWith("-")) continue;
1797
+ return argument;
1798
+ }
1799
+ }
1800
+ /** toIsAskingForHelp reports whether a call only wants to read about a command. */
1801
+ function toIsAskingForHelp(args) {
1802
+ return args.includes("--help") || args.includes("-h") || args[0] === "help";
1803
+ }
1804
+ async function toToolResult(params, run) {
1805
+ if (params?.["name"] !== TOOL.name) return {
1806
+ content: [{
1807
+ type: "text",
1808
+ text: `a call names its tool, and this server offers one, ${TOOL.name}`
1809
+ }],
1810
+ isError: true
1811
+ };
1812
+ const args = (params?.["arguments"])?.args;
1813
+ if (!Array.isArray(args) || args.some((entry) => typeof entry !== "string")) return {
1814
+ content: [{
1815
+ type: "text",
1816
+ text: "args must be a list of strings, such as [\"asset\", \"list\"]"
1817
+ }],
1818
+ isError: true
1819
+ };
1820
+ const refused = toRefusedArgument(args);
1821
+ if (refused) return {
1822
+ content: [{
1823
+ type: "text",
1824
+ text: refused
1825
+ }],
1826
+ isError: true
1827
+ };
1828
+ const asked = args;
1829
+ const command = toCommandName(asked) ?? "";
1830
+ if (REFUSED_COMMANDS.has(command) && !toIsAskingForHelp(asked)) return {
1831
+ content: [{
1832
+ type: "text",
1833
+ text: `${command} is run by a person at a terminal, not through this server. Run hardfin ${command} yourself, then call this tool again`
1834
+ }],
1835
+ isError: true
1836
+ };
1837
+ const outcome = await run(asked);
1838
+ return {
1839
+ content: [{
1840
+ type: "text",
1841
+ text: [outcome.stdout, outcome.stderr].filter((part) => part.trim() !== "").join("\n") || `hardfin exited ${outcome.code}`
1842
+ }],
1843
+ isError: outcome.code !== 0
1844
+ };
1845
+ }
1846
+ /** The most a command line can carry, below what the operating system refuses outright. */
1847
+ const ARGUMENT_LIMIT = 1e5;
1848
+ /** toRefusedArgument names an argument a command line cannot carry, rather than failing later. */
1849
+ function toRefusedArgument(args) {
1850
+ const withNul = args.findIndex((argument) => argument.includes("\0"));
1851
+ if (withNul >= 0) return `args[${withNul}] holds a NUL byte, which a command line cannot carry`;
1852
+ const total = args.reduce((carried, argument) => carried + argument.length, 0);
1853
+ if (total > ARGUMENT_LIMIT) return `these arguments are ${total} characters, and a command line carries at most ${ARGUMENT_LIMIT}`;
1854
+ }
1855
+ /** toCliRunner runs the CLI itself, so a tool call parses exactly as a terminal would. */
1856
+ function toCliRunner() {
1857
+ return (args) => new Promise((resolve) => {
1858
+ const child = spawn(process.execPath, [process.argv[1] ?? "", ...args], { env: {
1859
+ ...process.env,
1860
+ HARDFIN_NO_BROWSER: "1"
1861
+ } });
1862
+ let stdout = "";
1863
+ let stderr = "";
1864
+ child.stdout.on("data", (chunk) => {
1865
+ stdout += chunk.toString();
1866
+ });
1867
+ child.stderr.on("data", (chunk) => {
1868
+ stderr += chunk.toString();
1869
+ });
1870
+ child.on("close", (code) => {
1871
+ resolve({
1872
+ stdout,
1873
+ stderr,
1874
+ code: code ?? 1
1875
+ });
1876
+ });
1877
+ });
1878
+ }
1879
+ /** serve answers requests on stdin until the client closes it. */
1880
+ async function serve(commands, run = toCliRunner()) {
1881
+ const lines = createInterface({ input: process.stdin });
1882
+ const answering = /* @__PURE__ */ new Set();
1883
+ for await (const line of lines) {
1884
+ if (line.trim() === "") continue;
1885
+ const answered = toAnswer(line, commands, run).finally(() => answering.delete(answered));
1886
+ answering.add(answered);
1887
+ }
1888
+ await Promise.all(answering);
1889
+ }
1890
+ /** toAnswer replies to one line, and never rejects, because a rejection ends the server. */
1891
+ async function toAnswer(line, commands, run) {
1892
+ let response;
1893
+ try {
1894
+ response = await toResponse(toRequest(line), commands, run);
1895
+ } catch (error) {
1896
+ response = error instanceof ProtocolFailure ? toErrorResponse(error.id, error.code, error.message) : toErrorResponse(toRequestId(line), -32603, error instanceof Error ? error.message : String(error));
1897
+ }
1898
+ if (response) write(`${JSON.stringify(response)}\n`);
1899
+ }
1900
+ /** toRequest reads one line, refusing what the protocol does not allow to be a request. */
1901
+ function toRequest(line) {
1902
+ let parsed;
1903
+ try {
1904
+ parsed = JSON.parse(line);
1905
+ } catch {
1906
+ throw new ProtocolFailure(-32700, "this line is not JSON", null);
1907
+ }
1908
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new ProtocolFailure(-32600, "a request is a JSON object, and this server takes one per line", null);
1909
+ const request = parsed;
1910
+ if (typeof request.method !== "string") throw new ProtocolFailure(-32600, "a request names a method", toAllowedId(request.id));
1911
+ if (request.id !== void 0 && request.id !== null && typeof request.id !== "string" && typeof request.id !== "number") throw new ProtocolFailure(-32600, "an id is a string, a number, or null", null);
1912
+ return request;
1913
+ }
1914
+ function toAllowedId(id) {
1915
+ return typeof id === "string" || typeof id === "number" ? id : null;
1916
+ }
1917
+ function toErrorResponse(id, code, message) {
1918
+ return {
1919
+ jsonrpc: "2.0",
1920
+ id,
1921
+ error: {
1922
+ code,
1923
+ message
1924
+ }
1925
+ };
1926
+ }
1927
+ /** toRequestId reads the id of a line that could not be answered, so a client is not left waiting. */
1928
+ function toRequestId(line) {
1929
+ try {
1930
+ return toAllowedId(JSON.parse(line).id);
1931
+ } catch {
1932
+ return null;
1933
+ }
1934
+ }
1935
+ /** write puts a reply on stdout, and stops quietly when the client has gone. */
1936
+ function write(text) {
1937
+ try {
1938
+ process.stdout.write(text);
1939
+ } catch {}
1940
+ }
1941
+ //#endregion
1942
+ //#region src/command/mcp.ts
1943
+ const mcpCommand = defineCommand({
1944
+ name: "mcp",
1945
+ summary: "Serve this CLI to an agent over the Model Context Protocol",
1946
+ description: "Speaks the Model Context Protocol on standard input and output. It offers one tool, because every tool an agent is told about occupies its context for the whole session, and serves the commands as resources the agent reads only when it needs them.",
1947
+ arguments: [],
1948
+ flags: [{
1949
+ name: "json",
1950
+ description: "Accepted for consistency, and ignored, because the protocol decides the output",
1951
+ schema: z.boolean()
1952
+ }],
1953
+ examples: [{
1954
+ description: "Register with an agent",
1955
+ command: "hardfin mcp"
1956
+ }, {
1957
+ description: "Add it to Claude Code",
1958
+ command: "claude mcp add hardfin -- hardfin mcp"
1959
+ }],
1960
+ run: runMcp
1961
+ });
1962
+ async function runMcp(input) {
1963
+ await serve(input.commands);
1964
+ return ExitCode.OK;
1965
+ }
1966
+ //#endregion
1191
1967
  //#region src/command/operation.ts
1192
- const INPUT_FLAG = {
1193
- name: "input",
1194
- description: "A file holding the JSON request body, or - for stdin",
1195
- valueName: "file",
1968
+ /**
1969
+ * toEnum takes a value in any case, as the API does, and answers the spelling the document
1970
+ * lists, which is what a response always uses.
1971
+ */
1972
+ function toEnum(values) {
1973
+ return z.string().transform((value) => values.find((allowed) => allowed.toLowerCase() === value.toLowerCase()) ?? value).pipe(z.enum(values));
1974
+ }
1975
+ const UNSET_FLAG = {
1976
+ name: "unset",
1977
+ description: "A field to clear, named as its flag is, repeatable",
1978
+ valueName: "field",
1979
+ repeatable: true,
1980
+ schema: z.array(z.string())
1981
+ };
1982
+ const FILE_FLAG = {
1983
+ name: "file",
1984
+ description: "The file to upload",
1985
+ valueName: "path",
1986
+ schema: z.string()
1987
+ };
1988
+ const OUTPUT_FLAG = {
1989
+ name: "output",
1990
+ description: "Where to write the file, or - for standard output",
1991
+ valueName: "path",
1196
1992
  schema: z.string()
1197
1993
  };
1198
1994
  const JSON_FLAG = {
@@ -1202,15 +1998,28 @@ const JSON_FLAG = {
1202
1998
  };
1203
1999
  /** defineOperation turns one endpoint into the command that calls it. */
1204
2000
  function defineOperation(operation) {
1205
- const flags = [...operation.queryFlags, JSON_FLAG];
1206
- if (operation.takesBody) flags.splice(flags.length - 1, 0, INPUT_FLAG);
2001
+ const flags = [
2002
+ ...operation.queryFlags,
2003
+ ...operation.bodyFlags,
2004
+ ...operation.bodyFlags.some((flag) => flag.nullable) ? [UNSET_FLAG] : [],
2005
+ ...operation.upload ? [...operation.upload.fields, FILE_FLAG] : [],
2006
+ ...operation.downloads ? [OUTPUT_FLAG] : [],
2007
+ JSON_FLAG
2008
+ ];
1207
2009
  return {
1208
2010
  name: operation.name,
1209
2011
  summary: operation.summary,
1210
2012
  description: operation.description ?? operation.summary,
1211
2013
  arguments: operation.pathParameters,
1212
2014
  flags,
1213
- examples: [],
2015
+ examples: operation.example ? [{
2016
+ description: operation.summary,
2017
+ command: operation.example
2018
+ }] : [],
2019
+ endpoint: {
2020
+ method: operation.method,
2021
+ path: operation.path
2022
+ },
1214
2023
  run: (input) => runOperation(operation, input)
1215
2024
  };
1216
2025
  }
@@ -1220,23 +2029,42 @@ async function runOperation(operation, input) {
1220
2029
  writeFailure(`this command takes ${operation.pathParameters.length} argument(s)`, input.isJSON);
1221
2030
  return ExitCode.USAGE;
1222
2031
  }
1223
- let body;
1224
- if (typeof input.flags["input"] === "string") {
1225
- body = toBody(input.flags["input"]);
1226
- if (body === void 0) {
1227
- writeFailure(`${input.flags["input"]} does not hold JSON`, input.isJSON);
2032
+ const missing = operation.bodyFlags.concat(operation.upload?.fields ?? []).filter((flag) => flag.required && input.flags[toOptionKey(flag.name)] === void 0);
2033
+ if (missing.length > 0) {
2034
+ writeFailure(`this command needs ${missing.map((flag) => `--${flag.name}`).join(", ")}`, input.isJSON);
2035
+ return ExitCode.USAGE;
2036
+ }
2037
+ const body = toBody(operation, input.flags);
2038
+ if (body instanceof Error) {
2039
+ writeFailure(body.message, input.isJSON);
2040
+ return ExitCode.USAGE;
2041
+ }
2042
+ if (operation.bodyFlags.length > 0 && body === void 0) {
2043
+ writeFailure(`this command changes nothing unless a flag is given, such as ${operation.bodyFlags.slice(0, 3).map((flag) => `--${flag.name}`).join(", ")}. Run it with --help for the rest`, input.isJSON);
2044
+ return ExitCode.USAGE;
2045
+ }
2046
+ let form;
2047
+ if (operation.upload) {
2048
+ const built = await toForm(operation.upload, input.flags);
2049
+ if (built instanceof Error) {
2050
+ writeFailure(built.message, input.isJSON);
1228
2051
  return ExitCode.USAGE;
1229
2052
  }
2053
+ form = built;
1230
2054
  }
1231
2055
  try {
1232
- writeData((await request({
2056
+ const envelope = await request({
1233
2057
  apiUrl: input.resolved.settings.apiUrl,
1234
2058
  credential: await toRequestCredential(input.resolved.settings),
1235
2059
  method: operation.method,
1236
2060
  path,
1237
2061
  query: toQuery(operation, input.flags),
1238
- body
1239
- })).data);
2062
+ body,
2063
+ form,
2064
+ downloads: operation.downloads
2065
+ });
2066
+ if (operation.downloads) return toWritten(envelope.data, input);
2067
+ writeData(envelope.data);
1240
2068
  return ExitCode.OK;
1241
2069
  } catch (error) {
1242
2070
  if (error instanceof NoCredential) {
@@ -1244,13 +2072,36 @@ async function runOperation(operation, input) {
1244
2072
  return ExitCode.NOT_AUTHENTICATED;
1245
2073
  }
1246
2074
  if (error instanceof RequestFailure) {
1247
- writeFailure(error.message, input.isJSON, error.errors, error.requestId);
2075
+ writeFailure(error.message, input.isJSON, error.errors, error.requestId, error.status);
1248
2076
  return error.status === 401 ? ExitCode.NOT_AUTHENTICATED : ExitCode.ERROR;
1249
2077
  }
1250
2078
  writeFailure(error instanceof Error ? error.message : String(error), input.isJSON);
1251
2079
  return ExitCode.ERROR;
1252
2080
  }
1253
2081
  }
2082
+ /**
2083
+ * toWritten puts a downloaded file where it was asked for. A terminal is never written to,
2084
+ * because a person would otherwise have their session filled with a file's bytes.
2085
+ */
2086
+ function toWritten(download, input) {
2087
+ const asked = input.flags["output"];
2088
+ const path = typeof asked === "string" ? asked : download.fileName ?? "-";
2089
+ if (path === "-") {
2090
+ if (process.stdout.isTTY) {
2091
+ writeFailure("this answer is a file, so name where to write it with --output, or send it on with a pipe", input.isJSON);
2092
+ return ExitCode.USAGE;
2093
+ }
2094
+ process.stdout.write(download.bytes);
2095
+ return ExitCode.OK;
2096
+ }
2097
+ writeFileSync(path, download.bytes);
2098
+ writeData({
2099
+ written: path,
2100
+ bytes: download.bytes.length,
2101
+ contentType: download.contentType
2102
+ });
2103
+ return ExitCode.OK;
2104
+ }
1254
2105
  /** toPath fills the path template from the positional arguments, in order. */
1255
2106
  function toPath(operation, args) {
1256
2107
  if (args.length !== operation.pathParameters.length) return;
@@ -1264,7 +2115,9 @@ function toQuery(operation, flags) {
1264
2115
  for (const flag of operation.queryFlags) {
1265
2116
  const value = flags[toOptionKey(flag.name)];
1266
2117
  if (value === void 0) continue;
1267
- for (const entry of Array.isArray(value) ? value : [value]) query.append(flag.queryName, String(entry));
2118
+ const parsed = flag.schema.safeParse(value);
2119
+ const carried = parsed.success ? parsed.data : value;
2120
+ for (const entry of Array.isArray(carried) ? carried : [carried]) query.append(flag.queryName, toText(entry));
1268
2121
  }
1269
2122
  return query;
1270
2123
  }
@@ -1272,13 +2125,84 @@ function toQuery(operation, flags) {
1272
2125
  function toOptionKey(name) {
1273
2126
  return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
1274
2127
  }
1275
- function toBody(source) {
1276
- const text = source === "-" ? readFileSync(0, "utf8") : readFileSync(source, "utf8");
2128
+ /** toForm builds the file part and its fields, which an upload endpoint takes. */
2129
+ async function toForm(upload, flags) {
2130
+ const path = flags["file"];
2131
+ if (typeof path !== "string") return /* @__PURE__ */ new Error("this command needs --file, the file to upload");
2132
+ const form = new FormData();
1277
2133
  try {
1278
- return JSON.parse(text);
2134
+ form.append(upload.filePart, await openAsBlob(path), basename(path));
1279
2135
  } catch {
1280
- return;
2136
+ return /* @__PURE__ */ new Error(`${path} cannot be read`);
2137
+ }
2138
+ for (const field of upload.fields) {
2139
+ const value = flags[toOptionKey(field.name)];
2140
+ if (value !== void 0) {
2141
+ const parsed = field.schema.safeParse(value);
2142
+ form.append(field.jsonPath[0] ?? field.name, toText(parsed.success ? parsed.data : value));
2143
+ }
2144
+ }
2145
+ return form;
2146
+ }
2147
+ /** toBody builds the request body from the flags, one field at a time. */
2148
+ function toBody(operation, flags) {
2149
+ const body = {};
2150
+ let hasField = false;
2151
+ for (const flag of operation.bodyFlags) {
2152
+ const value = flags[toOptionKey(flag.name)];
2153
+ if (value === void 0) continue;
2154
+ if (flag.element) {
2155
+ const elements = toElements(flag, Array.isArray(value) ? value.map(toText) : [toText(value)]);
2156
+ if (elements instanceof Error) return elements;
2157
+ set(body, flag.jsonPath, elements);
2158
+ hasField = true;
2159
+ continue;
2160
+ }
2161
+ const parsed = flag.schema.safeParse(value);
2162
+ set(body, flag.jsonPath, parsed.success ? parsed.data : value);
2163
+ hasField = true;
2164
+ }
2165
+ const cleared = toCleared(operation, flags, body);
2166
+ if (cleared instanceof Error) return cleared;
2167
+ return hasField || cleared ? body : void 0;
2168
+ }
2169
+ /** toCleared sends null for each field named by --unset, which is how a field is cleared. */
2170
+ function toCleared(operation, flags, body) {
2171
+ const named = flags["unset"];
2172
+ const names = Array.isArray(named) ? named.map(toText) : named === void 0 ? [] : [toText(named)];
2173
+ for (const name of names) {
2174
+ const flag = operation.bodyFlags.find((entry) => entry.name === name.replace(/^--/, ""));
2175
+ if (!flag) return /* @__PURE__ */ new Error(`--unset names no field called ${name}`);
2176
+ if (!flag.nullable) return /* @__PURE__ */ new Error(`--${flag.name} cannot be cleared, because the API does not accept null for it`);
2177
+ set(body, flag.jsonPath, null);
2178
+ }
2179
+ return names.length > 0;
2180
+ }
2181
+ /** toElements reads the key=value pairs a repeated flag carries for one array element. */
2182
+ function toElements(flag, values) {
2183
+ const elements = [];
2184
+ for (const value of values) {
2185
+ const element = {};
2186
+ for (const pair of value.split(",")) {
2187
+ const split = pair.indexOf("=");
2188
+ if (split < 1) return /* @__PURE__ */ new Error(`--${flag.name} takes ${flag.element?.join("=, ") ?? ""}=, such as --${flag.name} ${flag.element?.[0] ?? "field"}=value. A value cannot contain a comma`);
2189
+ const key = pair.slice(0, split).trim();
2190
+ if (flag.element && !flag.element.includes(key)) return /* @__PURE__ */ new Error(`--${flag.name} has no field ${key}. It takes ${flag.element.join(", ")}`);
2191
+ if (key in element) return /* @__PURE__ */ new Error(`--${flag.name} names ${key} twice in one element, so which one it takes is not clear`);
2192
+ element[key] = pair.slice(split + 1).trim();
2193
+ }
2194
+ elements.push(element);
1281
2195
  }
2196
+ return elements;
2197
+ }
2198
+ /** set writes a value at its path, building the objects a nested field needs. */
2199
+ function set(body, path, value) {
2200
+ let holder = body;
2201
+ for (const name of path.slice(0, -1)) {
2202
+ holder[name] = holder[name] ?? {};
2203
+ holder = holder[name];
2204
+ }
2205
+ holder[path[path.length - 1] ?? ""] = value;
1282
2206
  }
1283
2207
  //#endregion
1284
2208
  //#region src/command/surface.generated.ts
@@ -1293,6 +2217,7 @@ const surfaceCommands = [
1293
2217
  defineOperation({
1294
2218
  name: "list",
1295
2219
  summary: "Get asset listing",
2220
+ example: "hardfin asset list --limit 10",
1296
2221
  method: "GET",
1297
2222
  path: "/asset",
1298
2223
  pathParameters: [],
@@ -1302,21 +2227,21 @@ const surfaceCommands = [
1302
2227
  queryName: "page",
1303
2228
  description: "The page to return, starting at 1",
1304
2229
  valueName: "number",
1305
- schema: z.coerce.number()
2230
+ schema: z.coerce.number().int()
1306
2231
  },
1307
2232
  {
1308
2233
  name: "limit",
1309
2234
  queryName: "limit",
1310
2235
  description: "The number of records per page, from 1 to 100",
1311
2236
  valueName: "number",
1312
- schema: z.coerce.number()
2237
+ schema: z.coerce.number().int().min(1).max(100)
1313
2238
  },
1314
2239
  {
1315
2240
  name: "sort-by",
1316
2241
  queryName: "sortBy",
1317
2242
  description: "The field to sort by",
1318
2243
  valueName: "value",
1319
- schema: z.enum([
2244
+ schema: toEnum([
1320
2245
  "serial",
1321
2246
  "project",
1322
2247
  "item",
@@ -1330,14 +2255,14 @@ const surfaceCommands = [
1330
2255
  queryName: "sortOrder",
1331
2256
  description: "The sort direction",
1332
2257
  valueName: "value",
1333
- schema: z.enum(["ASC", "DESC"])
2258
+ schema: toEnum(["ASC", "DESC"])
1334
2259
  },
1335
2260
  {
1336
2261
  name: "archived",
1337
2262
  queryName: "archived",
1338
2263
  description: "Whether to return unarchived records, archived records, or all of them",
1339
2264
  valueName: "value",
1340
- schema: z.enum([
2265
+ schema: toEnum([
1341
2266
  "all",
1342
2267
  "false",
1343
2268
  "true"
@@ -1354,9 +2279,9 @@ const surfaceCommands = [
1354
2279
  name: "for-asset-id",
1355
2280
  queryName: "forAssetId",
1356
2281
  description: "The IDs of the assets to list",
1357
- valueName: "value",
2282
+ valueName: "uuid",
1358
2283
  repeatable: true,
1359
- schema: z.array(z.string())
2284
+ schema: z.array(z.uuid())
1360
2285
  },
1361
2286
  {
1362
2287
  name: "for-asset-key",
@@ -1370,33 +2295,33 @@ const surfaceCommands = [
1370
2295
  name: "for-customer",
1371
2296
  queryName: "forCustomer",
1372
2297
  description: "The IDs of the customers whose assets to list",
1373
- valueName: "value",
2298
+ valueName: "uuid",
1374
2299
  repeatable: true,
1375
- schema: z.array(z.string())
2300
+ schema: z.array(z.uuid())
1376
2301
  },
1377
2302
  {
1378
2303
  name: "for-item",
1379
2304
  queryName: "forItem",
1380
2305
  description: "The IDs of the items whose assets to list",
1381
- valueName: "value",
2306
+ valueName: "uuid",
1382
2307
  repeatable: true,
1383
- schema: z.array(z.string())
2308
+ schema: z.array(z.uuid())
1384
2309
  },
1385
2310
  {
1386
2311
  name: "at-site",
1387
2312
  queryName: "atSite",
1388
2313
  description: "The IDs of the locations whose assets to list",
1389
- valueName: "value",
2314
+ valueName: "uuid",
1390
2315
  repeatable: true,
1391
- schema: z.array(z.string())
2316
+ schema: z.array(z.uuid())
1392
2317
  },
1393
2318
  {
1394
2319
  name: "at-customer-sites",
1395
2320
  queryName: "atCustomerSites",
1396
2321
  description: "The IDs of the customers whose sites to list assets at",
1397
- valueName: "value",
2322
+ valueName: "uuid",
1398
2323
  repeatable: true,
1399
- schema: z.array(z.string())
2324
+ schema: z.array(z.uuid())
1400
2325
  },
1401
2326
  {
1402
2327
  name: "with-functional-statuses",
@@ -1404,7 +2329,7 @@ const surfaceCommands = [
1404
2329
  description: "The functional statuses to list, where SCRAPPED lists scrapped assets",
1405
2330
  valueName: "value",
1406
2331
  repeatable: true,
1407
- schema: z.array(z.enum([
2332
+ schema: z.array(toEnum([
1408
2333
  "FUNCTIONAL",
1409
2334
  "NEEDS_REVIEW",
1410
2335
  "NON-FUNCTIONAL",
@@ -1417,7 +2342,7 @@ const surfaceCommands = [
1417
2342
  description: "The transit statuses to list",
1418
2343
  valueName: "value",
1419
2344
  repeatable: true,
1420
- schema: z.array(z.enum([
2345
+ schema: z.array(toEnum([
1421
2346
  "IN_TRANSIT",
1422
2347
  "IN_TRANSIT_TO_FIELD",
1423
2348
  "IN_TRANSIT_TO_INVENTORY",
@@ -1460,32 +2385,201 @@ const surfaceCommands = [
1460
2385
  name: "for-project",
1461
2386
  queryName: "forProject",
1462
2387
  description: "The IDs of the projects whose assets to list",
1463
- valueName: "value",
2388
+ valueName: "uuid",
1464
2389
  repeatable: true,
1465
- schema: z.array(z.string())
2390
+ schema: z.array(z.uuid())
1466
2391
  },
1467
2392
  {
1468
2393
  name: "scrapped",
1469
2394
  queryName: "scrapped",
1470
2395
  description: "Whether to list unscrapped assets, scrapped assets, or all of them",
1471
2396
  valueName: "value",
1472
- schema: z.enum([
2397
+ schema: toEnum([
1473
2398
  "all",
1474
2399
  "false",
1475
2400
  "true"
1476
2401
  ])
1477
2402
  }
1478
2403
  ],
1479
- takesBody: false
2404
+ bodyFlags: []
1480
2405
  }),
1481
2406
  defineOperation({
1482
2407
  name: "create",
1483
2408
  summary: "Create asset",
2409
+ example: "hardfin asset create --item-id <uuid> --serial <value>",
1484
2410
  method: "POST",
1485
2411
  path: "/asset",
1486
2412
  pathParameters: [],
1487
2413
  queryFlags: [],
1488
- takesBody: true
2414
+ bodyFlags: [
2415
+ {
2416
+ name: "allocated-indirect",
2417
+ jsonPath: ["allocatedIndirect"],
2418
+ description: "One unit's share of overhead, a cost component",
2419
+ valueName: "value",
2420
+ nullable: true,
2421
+ schema: z.string()
2422
+ },
2423
+ {
2424
+ name: "bill-of-materials",
2425
+ jsonPath: ["billOfMaterials"],
2426
+ description: "The parts cost of one unit, a cost component",
2427
+ valueName: "value",
2428
+ nullable: true,
2429
+ schema: z.string()
2430
+ },
2431
+ {
2432
+ name: "depreciation-model",
2433
+ jsonPath: ["depreciationModel"],
2434
+ description: "The method one unit is depreciated by, or null to clear it",
2435
+ valueName: "value",
2436
+ schema: toEnum([
2437
+ "DOUBLE_DECLINING",
2438
+ "STRAIGHT_LINE",
2439
+ "SUM_YEAR",
2440
+ "UNIT_OF_PRODUCTION"
2441
+ ])
2442
+ },
2443
+ {
2444
+ name: "description",
2445
+ jsonPath: ["description"],
2446
+ description: "A free-form description of the asset",
2447
+ valueName: "value",
2448
+ nullable: true,
2449
+ schema: z.string()
2450
+ },
2451
+ {
2452
+ name: "direct-labor",
2453
+ jsonPath: ["directLabor"],
2454
+ description: "The labor cost to build one unit, a cost component",
2455
+ valueName: "value",
2456
+ nullable: true,
2457
+ schema: z.string()
2458
+ },
2459
+ {
2460
+ name: "freight-inbound",
2461
+ jsonPath: ["freightInbound"],
2462
+ description: "The shipping cost to receive one unit, a cost component",
2463
+ valueName: "value",
2464
+ nullable: true,
2465
+ schema: z.string()
2466
+ },
2467
+ {
2468
+ name: "freight-outbound",
2469
+ jsonPath: ["freightOutbound"],
2470
+ description: "The shipping cost to deploy one unit, a deployment cost component",
2471
+ valueName: "value",
2472
+ nullable: true,
2473
+ schema: z.string()
2474
+ },
2475
+ {
2476
+ name: "functional-status",
2477
+ jsonPath: ["functionalStatus"],
2478
+ description: "The asset's starting functional status, FUNCTIONAL when absent, which cannot be SCRAPPED",
2479
+ valueName: "value",
2480
+ nullable: true,
2481
+ schema: toEnum([
2482
+ "FUNCTIONAL",
2483
+ "NEEDS_REVIEW",
2484
+ "NON-FUNCTIONAL",
2485
+ "SCRAPPED"
2486
+ ])
2487
+ },
2488
+ {
2489
+ name: "in-inventory-date",
2490
+ jsonPath: ["inInventoryDate"],
2491
+ description: "The day the asset entered inventory, which cannot be in the future",
2492
+ valueName: "value",
2493
+ schema: z.string()
2494
+ },
2495
+ {
2496
+ name: "in-service-date",
2497
+ jsonPath: ["inServiceDate"],
2498
+ description: "The day the asset was put into service, which Hardfin sets itself when absent",
2499
+ valueName: "value",
2500
+ nullable: true,
2501
+ schema: z.string()
2502
+ },
2503
+ {
2504
+ name: "installation",
2505
+ jsonPath: ["installation"],
2506
+ description: "The cost to install one unit, a deployment cost component",
2507
+ valueName: "value",
2508
+ nullable: true,
2509
+ schema: z.string()
2510
+ },
2511
+ {
2512
+ name: "interest",
2513
+ jsonPath: ["interest"],
2514
+ description: "The financing cost of one unit, a cost component",
2515
+ valueName: "value",
2516
+ nullable: true,
2517
+ schema: z.string()
2518
+ },
2519
+ {
2520
+ name: "item-id",
2521
+ jsonPath: ["itemId"],
2522
+ description: "The ID of the catalog item the asset is a unit of",
2523
+ valueName: "uuid",
2524
+ required: true,
2525
+ schema: z.uuid()
2526
+ },
2527
+ {
2528
+ name: "location-id",
2529
+ jsonPath: ["locationId"],
2530
+ description: "The ID of the location the asset enters inventory at",
2531
+ valueName: "uuid",
2532
+ schema: z.uuid()
2533
+ },
2534
+ {
2535
+ name: "salvage-value",
2536
+ jsonPath: ["salvageValue"],
2537
+ description: "The value one unit keeps at the end of its useful life, or null to clear it",
2538
+ valueName: "value",
2539
+ nullable: true,
2540
+ schema: z.string()
2541
+ },
2542
+ {
2543
+ name: "serial",
2544
+ jsonPath: ["serial"],
2545
+ description: "The asset's serial number, unique within its item",
2546
+ valueName: "value",
2547
+ required: true,
2548
+ schema: z.string()
2549
+ },
2550
+ {
2551
+ name: "simple-cost-basis",
2552
+ jsonPath: ["simpleCostBasis"],
2553
+ description: "A single cost for one unit, which cannot be sent together with the cost components",
2554
+ valueName: "value",
2555
+ nullable: true,
2556
+ schema: z.string()
2557
+ },
2558
+ {
2559
+ name: "tariffs",
2560
+ jsonPath: ["tariffs"],
2561
+ description: "The import duty paid on one unit, a cost component",
2562
+ valueName: "value",
2563
+ nullable: true,
2564
+ schema: z.string()
2565
+ },
2566
+ {
2567
+ name: "tax",
2568
+ jsonPath: ["tax"],
2569
+ description: "The tax paid on one unit, a cost component",
2570
+ valueName: "value",
2571
+ nullable: true,
2572
+ schema: z.string()
2573
+ },
2574
+ {
2575
+ name: "useful-life",
2576
+ jsonPath: ["usefulLife"],
2577
+ description: "The number of months one unit is depreciated over, or null to clear it",
2578
+ valueName: "number",
2579
+ nullable: true,
2580
+ schema: z.coerce.number().int()
2581
+ }
2582
+ ]
1489
2583
  }),
1490
2584
  {
1491
2585
  name: "move",
@@ -1502,11 +2596,28 @@ const surfaceCommands = [
1502
2596
  subcommands: [defineOperation({
1503
2597
  name: "create",
1504
2598
  summary: "Execute asset move",
2599
+ example: "hardfin asset move execute create --move assetId=<value>,deliverAt=<value>,deliverAtTimezone=<value>,destinationId=<value>,originId=<value>,shipAt=<value>,shipAtTimezone=<value>",
1505
2600
  method: "POST",
1506
2601
  path: "/asset/move/execute",
1507
2602
  pathParameters: [],
1508
2603
  queryFlags: [],
1509
- takesBody: true
2604
+ bodyFlags: [{
2605
+ name: "move",
2606
+ jsonPath: ["moves"],
2607
+ description: "The moves to carry out",
2608
+ valueName: "assetId=,deliverAt=,deliverAtTimezone=,destinationId=,originId=,shipAt=,shipAtTimezone=",
2609
+ repeatable: true,
2610
+ element: [
2611
+ "assetId",
2612
+ "deliverAt",
2613
+ "deliverAtTimezone",
2614
+ "destinationId",
2615
+ "originId",
2616
+ "shipAt",
2617
+ "shipAtTimezone"
2618
+ ],
2619
+ schema: z.array(z.string())
2620
+ }]
1510
2621
  })]
1511
2622
  }, {
1512
2623
  name: "plan",
@@ -1517,17 +2628,32 @@ const surfaceCommands = [
1517
2628
  subcommands: [defineOperation({
1518
2629
  name: "create",
1519
2630
  summary: "Plan asset move",
2631
+ example: "hardfin asset move plan create --move assetId=<value>,deliverAt=<value>,id=<value>,shipAt=<value>",
1520
2632
  method: "POST",
1521
2633
  path: "/asset/move/plan",
1522
2634
  pathParameters: [],
1523
2635
  queryFlags: [],
1524
- takesBody: true
2636
+ bodyFlags: [{
2637
+ name: "move",
2638
+ jsonPath: ["moves"],
2639
+ description: "The moves to plan",
2640
+ valueName: "assetId=,deliverAt=,id=,shipAt=",
2641
+ repeatable: true,
2642
+ element: [
2643
+ "assetId",
2644
+ "deliverAt",
2645
+ "id",
2646
+ "shipAt"
2647
+ ],
2648
+ schema: z.array(z.string())
2649
+ }]
1525
2650
  })]
1526
2651
  }]
1527
2652
  },
1528
2653
  defineOperation({
1529
2654
  name: "get",
1530
2655
  summary: "Get asset",
2656
+ example: "hardfin asset get ast_4f9xk2mq7plr8stz",
1531
2657
  method: "GET",
1532
2658
  path: "/asset/{assetKey}",
1533
2659
  pathParameters: [{
@@ -1536,11 +2662,12 @@ const surfaceCommands = [
1536
2662
  required: true
1537
2663
  }],
1538
2664
  queryFlags: [],
1539
- takesBody: false
2665
+ bodyFlags: []
1540
2666
  }),
1541
2667
  defineOperation({
1542
2668
  name: "update",
1543
2669
  summary: "Patch asset",
2670
+ example: "hardfin asset update ast_4f9xk2mq7plr8stz --metadata fieldId=<value>,value=<value>",
1544
2671
  method: "PATCH",
1545
2672
  path: "/asset/{assetKey}",
1546
2673
  pathParameters: [{
@@ -1549,7 +2676,62 @@ const surfaceCommands = [
1549
2676
  required: true
1550
2677
  }],
1551
2678
  queryFlags: [],
1552
- takesBody: true
2679
+ bodyFlags: [
2680
+ {
2681
+ name: "description",
2682
+ jsonPath: ["description"],
2683
+ description: "The asset's new description, or null to clear it",
2684
+ valueName: "value",
2685
+ nullable: true,
2686
+ schema: z.string()
2687
+ },
2688
+ {
2689
+ name: "functional-status",
2690
+ jsonPath: ["functionalStatus"],
2691
+ description: "The asset's new functional status, which cannot be SCRAPPED because scrapping has its own endpoint",
2692
+ valueName: "value",
2693
+ nullable: true,
2694
+ schema: toEnum([
2695
+ "FUNCTIONAL",
2696
+ "NEEDS_REVIEW",
2697
+ "NON-FUNCTIONAL",
2698
+ "SCRAPPED"
2699
+ ])
2700
+ },
2701
+ {
2702
+ name: "in-inventory-date",
2703
+ jsonPath: ["inInventoryDate"],
2704
+ description: "The day the asset entered inventory, which cannot be in the future",
2705
+ valueName: "value",
2706
+ nullable: true,
2707
+ schema: z.string()
2708
+ },
2709
+ {
2710
+ name: "initial-location-id",
2711
+ jsonPath: ["initialLocationId"],
2712
+ description: "The ID of the location the asset entered inventory at",
2713
+ valueName: "uuid",
2714
+ nullable: true,
2715
+ schema: z.uuid()
2716
+ },
2717
+ {
2718
+ name: "metadata",
2719
+ jsonPath: ["metadata"],
2720
+ description: "New values for the asset's custom fields, each naming its field",
2721
+ valueName: "fieldId=,value=",
2722
+ repeatable: true,
2723
+ element: ["fieldId", "value"],
2724
+ schema: z.array(z.string())
2725
+ },
2726
+ {
2727
+ name: "serial",
2728
+ jsonPath: ["serial"],
2729
+ description: "The asset's new serial number, which cannot be empty",
2730
+ valueName: "value",
2731
+ nullable: true,
2732
+ schema: z.string()
2733
+ }
2734
+ ]
1553
2735
  }),
1554
2736
  {
1555
2737
  name: "accounting",
@@ -1560,6 +2742,7 @@ const surfaceCommands = [
1560
2742
  subcommands: [defineOperation({
1561
2743
  name: "update",
1562
2744
  summary: "Update asset accounting",
2745
+ example: "hardfin asset accounting update ast_4f9xk2mq7plr8stz",
1563
2746
  method: "PATCH",
1564
2747
  path: "/asset/{assetKey}/accounting",
1565
2748
  pathParameters: [{
@@ -1568,7 +2751,132 @@ const surfaceCommands = [
1568
2751
  required: true
1569
2752
  }],
1570
2753
  queryFlags: [],
1571
- takesBody: true
2754
+ bodyFlags: [
2755
+ {
2756
+ name: "allocated-indirect",
2757
+ jsonPath: ["allocatedIndirect"],
2758
+ description: "One unit's share of overhead, a cost component",
2759
+ valueName: "value",
2760
+ nullable: true,
2761
+ schema: z.string()
2762
+ },
2763
+ {
2764
+ name: "bill-of-materials",
2765
+ jsonPath: ["billOfMaterials"],
2766
+ description: "The parts cost of one unit, a cost component",
2767
+ valueName: "value",
2768
+ nullable: true,
2769
+ schema: z.string()
2770
+ },
2771
+ {
2772
+ name: "depreciation-model",
2773
+ jsonPath: ["depreciationModel"],
2774
+ description: "The method one unit is depreciated by, or null to clear it",
2775
+ valueName: "value",
2776
+ schema: toEnum([
2777
+ "DOUBLE_DECLINING",
2778
+ "STRAIGHT_LINE",
2779
+ "SUM_YEAR",
2780
+ "UNIT_OF_PRODUCTION"
2781
+ ])
2782
+ },
2783
+ {
2784
+ name: "direct-labor",
2785
+ jsonPath: ["directLabor"],
2786
+ description: "The labor cost to build one unit, a cost component",
2787
+ valueName: "value",
2788
+ nullable: true,
2789
+ schema: z.string()
2790
+ },
2791
+ {
2792
+ name: "freight-inbound",
2793
+ jsonPath: ["freightInbound"],
2794
+ description: "The shipping cost to receive one unit, a cost component",
2795
+ valueName: "value",
2796
+ nullable: true,
2797
+ schema: z.string()
2798
+ },
2799
+ {
2800
+ name: "freight-outbound",
2801
+ jsonPath: ["freightOutbound"],
2802
+ description: "The shipping cost to deploy one unit, a deployment cost component",
2803
+ valueName: "value",
2804
+ nullable: true,
2805
+ schema: z.string()
2806
+ },
2807
+ {
2808
+ name: "in-service-date",
2809
+ jsonPath: ["inServiceDate"],
2810
+ description: "The day the asset was put into service and began depreciating, or null to clear it",
2811
+ valueName: "value",
2812
+ nullable: true,
2813
+ schema: z.string()
2814
+ },
2815
+ {
2816
+ name: "installation",
2817
+ jsonPath: ["installation"],
2818
+ description: "The cost to install one unit, a deployment cost component",
2819
+ valueName: "value",
2820
+ nullable: true,
2821
+ schema: z.string()
2822
+ },
2823
+ {
2824
+ name: "interest",
2825
+ jsonPath: ["interest"],
2826
+ description: "The financing cost of one unit, a cost component",
2827
+ valueName: "value",
2828
+ nullable: true,
2829
+ schema: z.string()
2830
+ },
2831
+ {
2832
+ name: "is-in-service-date-managed-automatically",
2833
+ jsonPath: ["isInServiceDateManagedAutomatically"],
2834
+ description: "Whether Hardfin sets the in-service date itself, which sending an in-service date turns off",
2835
+ negatable: true,
2836
+ nullable: true,
2837
+ schema: z.boolean()
2838
+ },
2839
+ {
2840
+ name: "salvage-value",
2841
+ jsonPath: ["salvageValue"],
2842
+ description: "The value one unit keeps at the end of its useful life, or null to clear it",
2843
+ valueName: "value",
2844
+ nullable: true,
2845
+ schema: z.string()
2846
+ },
2847
+ {
2848
+ name: "simple-cost-basis",
2849
+ jsonPath: ["simpleCostBasis"],
2850
+ description: "A single cost for one unit, which cannot be sent together with the cost components",
2851
+ valueName: "value",
2852
+ nullable: true,
2853
+ schema: z.string()
2854
+ },
2855
+ {
2856
+ name: "tariffs",
2857
+ jsonPath: ["tariffs"],
2858
+ description: "The import duty paid on one unit, a cost component",
2859
+ valueName: "value",
2860
+ nullable: true,
2861
+ schema: z.string()
2862
+ },
2863
+ {
2864
+ name: "tax",
2865
+ jsonPath: ["tax"],
2866
+ description: "The tax paid on one unit, a cost component",
2867
+ valueName: "value",
2868
+ nullable: true,
2869
+ schema: z.string()
2870
+ },
2871
+ {
2872
+ name: "useful-life",
2873
+ jsonPath: ["usefulLife"],
2874
+ description: "The number of months one unit is depreciated over, or null to clear it",
2875
+ valueName: "number",
2876
+ nullable: true,
2877
+ schema: z.coerce.number().int()
2878
+ }
2879
+ ]
1572
2880
  }), {
1573
2881
  name: "in-service-management",
1574
2882
  summary: "In service management commands",
@@ -1578,6 +2886,7 @@ const surfaceCommands = [
1578
2886
  subcommands: [defineOperation({
1579
2887
  name: "update",
1580
2888
  summary: "Toggle in service date management",
2889
+ example: "hardfin asset accounting in-service-management update ast_4f9xk2mq7plr8stz --automatic <value>",
1581
2890
  method: "PATCH",
1582
2891
  path: "/asset/{assetKey}/accounting/in-service-management",
1583
2892
  pathParameters: [{
@@ -1586,7 +2895,14 @@ const surfaceCommands = [
1586
2895
  required: true
1587
2896
  }],
1588
2897
  queryFlags: [],
1589
- takesBody: true
2898
+ bodyFlags: [{
2899
+ name: "automatic",
2900
+ jsonPath: ["automatic"],
2901
+ description: "Whether Hardfin sets the asset's in-service date itself",
2902
+ negatable: true,
2903
+ required: true,
2904
+ schema: z.boolean()
2905
+ }]
1590
2906
  })]
1591
2907
  }]
1592
2908
  },
@@ -1599,6 +2915,7 @@ const surfaceCommands = [
1599
2915
  subcommands: [defineOperation({
1600
2916
  name: "create",
1601
2917
  summary: "Create asset cost adjustment",
2918
+ example: "hardfin asset cost-adjustment create ast_4f9xk2mq7plr8stz --adjustment-type <value> --amount <value> --effective-date <value>",
1602
2919
  method: "POST",
1603
2920
  path: "/asset/{assetKey}/cost-adjustment",
1604
2921
  pathParameters: [{
@@ -1607,7 +2924,58 @@ const surfaceCommands = [
1607
2924
  required: true
1608
2925
  }],
1609
2926
  queryFlags: [],
1610
- takesBody: true
2927
+ bodyFlags: [
2928
+ {
2929
+ name: "adjustment-type",
2930
+ jsonPath: ["adjustmentType"],
2931
+ description: "Whether the adjustment adds to the asset's cost basis or writes it down",
2932
+ valueName: "value",
2933
+ required: true,
2934
+ schema: toEnum(["CAPITALIZATION", "IMPAIRMENT"])
2935
+ },
2936
+ {
2937
+ name: "amount",
2938
+ jsonPath: ["amount"],
2939
+ description: "How much the adjustment changes the cost basis by, which must be greater than zero",
2940
+ valueName: "value",
2941
+ required: true,
2942
+ schema: z.string()
2943
+ },
2944
+ {
2945
+ name: "effective-date",
2946
+ jsonPath: ["effectiveDate"],
2947
+ description: "The day the adjustment takes effect, which cannot be in the future",
2948
+ valueName: "value",
2949
+ required: true,
2950
+ schema: z.string()
2951
+ },
2952
+ {
2953
+ name: "notes",
2954
+ jsonPath: ["notes"],
2955
+ description: "Free-form detail about the adjustment, which a reason of OTHER requires",
2956
+ valueName: "value",
2957
+ nullable: true,
2958
+ schema: z.string()
2959
+ },
2960
+ {
2961
+ name: "reason",
2962
+ jsonPath: ["reason"],
2963
+ description: "Why the adjustment was made, which must be one its adjustment type allows",
2964
+ valueName: "value",
2965
+ required: true,
2966
+ schema: toEnum([
2967
+ "ADDITION",
2968
+ "BETTERMENT",
2969
+ "DAMAGE",
2970
+ "INSTALLATION",
2971
+ "LIFE_EXTENSION",
2972
+ "MARKET_DECLINE",
2973
+ "OBSOLESCENCE",
2974
+ "OTHER",
2975
+ "REGULATORY"
2976
+ ])
2977
+ }
2978
+ ]
1611
2979
  })]
1612
2980
  },
1613
2981
  {
@@ -1619,6 +2987,7 @@ const surfaceCommands = [
1619
2987
  subcommands: [defineOperation({
1620
2988
  name: "list",
1621
2989
  summary: "Get asset event list",
2990
+ example: "hardfin asset event list ast_4f9xk2mq7plr8stz",
1622
2991
  method: "GET",
1623
2992
  path: "/asset/{assetKey}/event",
1624
2993
  pathParameters: [{
@@ -1627,7 +2996,7 @@ const surfaceCommands = [
1627
2996
  required: true
1628
2997
  }],
1629
2998
  queryFlags: [],
1630
- takesBody: false
2999
+ bodyFlags: []
1631
3000
  })]
1632
3001
  },
1633
3002
  {
@@ -1639,6 +3008,7 @@ const surfaceCommands = [
1639
3008
  subcommands: [defineOperation({
1640
3009
  name: "list",
1641
3010
  summary: "Get asset event group listing",
3011
+ example: "hardfin asset event-group list ast_4f9xk2mq7plr8stz",
1642
3012
  method: "GET",
1643
3013
  path: "/asset/{assetKey}/event-group",
1644
3014
  pathParameters: [{
@@ -1659,10 +3029,11 @@ const surfaceCommands = [
1659
3029
  valueName: "value",
1660
3030
  schema: z.string()
1661
3031
  }],
1662
- takesBody: false
3032
+ bodyFlags: []
1663
3033
  }), defineOperation({
1664
3034
  name: "get",
1665
3035
  summary: "Get asset event group",
3036
+ example: "hardfin asset event-group get ast_4f9xk2mq7plr8stz aeg_3mx8kq2plr7stz4w",
1666
3037
  method: "GET",
1667
3038
  path: "/asset/{assetKey}/event-group/{eventGroupKey}",
1668
3039
  pathParameters: [{
@@ -1675,7 +3046,7 @@ const surfaceCommands = [
1675
3046
  required: true
1676
3047
  }],
1677
3048
  queryFlags: [],
1678
- takesBody: false
3049
+ bodyFlags: []
1679
3050
  })]
1680
3051
  },
1681
3052
  {
@@ -1687,6 +3058,7 @@ const surfaceCommands = [
1687
3058
  subcommands: [defineOperation({
1688
3059
  name: "list",
1689
3060
  summary: "Get asset files",
3061
+ example: "hardfin asset file list ast_4f9xk2mq7plr8stz",
1690
3062
  method: "GET",
1691
3063
  path: "/asset/{assetKey}/file",
1692
3064
  pathParameters: [{
@@ -1695,10 +3067,11 @@ const surfaceCommands = [
1695
3067
  required: true
1696
3068
  }],
1697
3069
  queryFlags: [],
1698
- takesBody: false
3070
+ bodyFlags: []
1699
3071
  }), defineOperation({
1700
3072
  name: "delete",
1701
3073
  summary: "Delete asset file",
3074
+ example: "hardfin asset file delete ast_4f9xk2mq7plr8stz file_7hq2mx9pkr4stz8w",
1702
3075
  method: "DELETE",
1703
3076
  path: "/asset/{assetKey}/file/{fileKey}",
1704
3077
  pathParameters: [{
@@ -1711,7 +3084,7 @@ const surfaceCommands = [
1711
3084
  required: true
1712
3085
  }],
1713
3086
  queryFlags: [],
1714
- takesBody: false
3087
+ bodyFlags: []
1715
3088
  })]
1716
3089
  },
1717
3090
  {
@@ -1723,6 +3096,7 @@ const surfaceCommands = [
1723
3096
  subcommands: [defineOperation({
1724
3097
  name: "list",
1725
3098
  summary: "Get asset functional status history",
3099
+ example: "hardfin asset functional-status list ast_4f9xk2mq7plr8stz",
1726
3100
  method: "GET",
1727
3101
  path: "/asset/{assetKey}/functional-status",
1728
3102
  pathParameters: [{
@@ -1731,7 +3105,7 @@ const surfaceCommands = [
1731
3105
  required: true
1732
3106
  }],
1733
3107
  queryFlags: [],
1734
- takesBody: false
3108
+ bodyFlags: []
1735
3109
  })]
1736
3110
  },
1737
3111
  {
@@ -1744,6 +3118,7 @@ const surfaceCommands = [
1744
3118
  defineOperation({
1745
3119
  name: "list",
1746
3120
  summary: "Get asset ownership history",
3121
+ example: "hardfin asset ownership list ast_4f9xk2mq7plr8stz",
1747
3122
  method: "GET",
1748
3123
  path: "/asset/{assetKey}/ownership",
1749
3124
  pathParameters: [{
@@ -1752,11 +3127,12 @@ const surfaceCommands = [
1752
3127
  required: true
1753
3128
  }],
1754
3129
  queryFlags: [],
1755
- takesBody: false
3130
+ bodyFlags: []
1756
3131
  }),
1757
3132
  defineOperation({
1758
3133
  name: "create",
1759
3134
  summary: "Create asset ownership",
3135
+ example: "hardfin asset ownership create ast_4f9xk2mq7plr8stz --customer-id <uuid> --date <value>",
1760
3136
  method: "POST",
1761
3137
  path: "/asset/{assetKey}/ownership",
1762
3138
  pathParameters: [{
@@ -1765,11 +3141,37 @@ const surfaceCommands = [
1765
3141
  required: true
1766
3142
  }],
1767
3143
  queryFlags: [],
1768
- takesBody: true
3144
+ bodyFlags: [
3145
+ {
3146
+ name: "customer-id",
3147
+ jsonPath: ["customerId"],
3148
+ description: "The ID of the customer that takes ownership of the asset",
3149
+ valueName: "uuid",
3150
+ required: true,
3151
+ schema: z.uuid()
3152
+ },
3153
+ {
3154
+ name: "date",
3155
+ jsonPath: ["date"],
3156
+ description: "The day the customer takes ownership, which cannot be in the future",
3157
+ valueName: "value",
3158
+ required: true,
3159
+ schema: z.string()
3160
+ },
3161
+ {
3162
+ name: "sale-price",
3163
+ jsonPath: ["salePrice"],
3164
+ description: "What the customer paid for the asset, which cannot be negative",
3165
+ valueName: "value",
3166
+ nullable: true,
3167
+ schema: z.string()
3168
+ }
3169
+ ]
1769
3170
  }),
1770
3171
  defineOperation({
1771
3172
  name: "clear",
1772
3173
  summary: "Clear the ownership an asset holds today",
3174
+ example: "hardfin asset ownership clear ast_4f9xk2mq7plr8stz --date <value>",
1773
3175
  method: "DELETE",
1774
3176
  path: "/asset/{assetKey}/ownership",
1775
3177
  pathParameters: [{
@@ -1778,11 +3180,19 @@ const surfaceCommands = [
1778
3180
  required: true
1779
3181
  }],
1780
3182
  queryFlags: [],
1781
- takesBody: true
3183
+ bodyFlags: [{
3184
+ name: "date",
3185
+ jsonPath: ["date"],
3186
+ description: "The day your organization takes the asset back, which cannot be in the future",
3187
+ valueName: "value",
3188
+ required: true,
3189
+ schema: z.string()
3190
+ }]
1782
3191
  }),
1783
3192
  defineOperation({
1784
3193
  name: "get",
1785
3194
  summary: "Get asset ownership segment",
3195
+ example: "hardfin asset ownership get ast_4f9xk2mq7plr8stz aown_8kq2mx7plr4stz9w",
1786
3196
  method: "GET",
1787
3197
  path: "/asset/{assetKey}/ownership/{segmentKey}",
1788
3198
  pathParameters: [{
@@ -1795,11 +3205,12 @@ const surfaceCommands = [
1795
3205
  required: true
1796
3206
  }],
1797
3207
  queryFlags: [],
1798
- takesBody: false
3208
+ bodyFlags: []
1799
3209
  }),
1800
3210
  defineOperation({
1801
3211
  name: "update",
1802
3212
  summary: "Patch asset ownership segment",
3213
+ example: "hardfin asset ownership update ast_4f9xk2mq7plr8stz aown_8kq2mx7plr4stz9w",
1803
3214
  method: "PATCH",
1804
3215
  path: "/asset/{assetKey}/ownership/{segmentKey}",
1805
3216
  pathParameters: [{
@@ -1812,11 +3223,37 @@ const surfaceCommands = [
1812
3223
  required: true
1813
3224
  }],
1814
3225
  queryFlags: [],
1815
- takesBody: true
3226
+ bodyFlags: [
3227
+ {
3228
+ name: "customer-id",
3229
+ jsonPath: ["customerId"],
3230
+ description: "The ID of the customer that owned the asset during the segment",
3231
+ valueName: "uuid",
3232
+ nullable: true,
3233
+ schema: z.uuid()
3234
+ },
3235
+ {
3236
+ name: "date",
3237
+ jsonPath: ["date"],
3238
+ description: "The day the segment's owner took ownership, which cannot be in the future",
3239
+ valueName: "value",
3240
+ nullable: true,
3241
+ schema: z.string()
3242
+ },
3243
+ {
3244
+ name: "sale-price",
3245
+ jsonPath: ["salePrice"],
3246
+ description: "What the owner paid for the asset, which cannot be negative",
3247
+ valueName: "value",
3248
+ nullable: true,
3249
+ schema: z.string()
3250
+ }
3251
+ ]
1816
3252
  }),
1817
3253
  defineOperation({
1818
3254
  name: "delete",
1819
3255
  summary: "Delete asset ownership segment",
3256
+ example: "hardfin asset ownership delete ast_4f9xk2mq7plr8stz aown_8kq2mx7plr4stz9w",
1820
3257
  method: "DELETE",
1821
3258
  path: "/asset/{assetKey}/ownership/{segmentKey}",
1822
3259
  pathParameters: [{
@@ -1829,7 +3266,7 @@ const surfaceCommands = [
1829
3266
  required: true
1830
3267
  }],
1831
3268
  queryFlags: [],
1832
- takesBody: false
3269
+ bodyFlags: []
1833
3270
  })
1834
3271
  ]
1835
3272
  },
@@ -1842,6 +3279,7 @@ const surfaceCommands = [
1842
3279
  subcommands: [defineOperation({
1843
3280
  name: "create",
1844
3281
  summary: "Scrap asset",
3282
+ example: "hardfin asset scrap create ast_4f9xk2mq7plr8stz --disposal-date <value>",
1845
3283
  method: "POST",
1846
3284
  path: "/asset/{assetKey}/scrap",
1847
3285
  pathParameters: [{
@@ -1850,7 +3288,32 @@ const surfaceCommands = [
1850
3288
  required: true
1851
3289
  }],
1852
3290
  queryFlags: [],
1853
- takesBody: true
3291
+ bodyFlags: [
3292
+ {
3293
+ name: "disposal-date",
3294
+ jsonPath: ["disposalDate"],
3295
+ description: "The day the asset was scrapped",
3296
+ valueName: "value",
3297
+ required: true,
3298
+ schema: z.string()
3299
+ },
3300
+ {
3301
+ name: "disposal-price",
3302
+ jsonPath: ["disposalPrice"],
3303
+ description: "What the scrapped asset was sold for, or null when it was not sold",
3304
+ valueName: "value",
3305
+ nullable: true,
3306
+ schema: z.string()
3307
+ },
3308
+ {
3309
+ name: "disposal-reason",
3310
+ jsonPath: ["disposalReason"],
3311
+ description: "Why the asset was scrapped",
3312
+ valueName: "value",
3313
+ nullable: true,
3314
+ schema: z.string()
3315
+ }
3316
+ ]
1854
3317
  })]
1855
3318
  },
1856
3319
  {
@@ -1862,6 +3325,7 @@ const surfaceCommands = [
1862
3325
  subcommands: [defineOperation({
1863
3326
  name: "create",
1864
3327
  summary: "Unscrap asset",
3328
+ example: "hardfin asset unscrap create ast_4f9xk2mq7plr8stz",
1865
3329
  method: "POST",
1866
3330
  path: "/asset/{assetKey}/unscrap",
1867
3331
  pathParameters: [{
@@ -1870,7 +3334,7 @@ const surfaceCommands = [
1870
3334
  required: true
1871
3335
  }],
1872
3336
  queryFlags: [],
1873
- takesBody: false
3337
+ bodyFlags: []
1874
3338
  })]
1875
3339
  },
1876
3340
  {
@@ -1882,6 +3346,7 @@ const surfaceCommands = [
1882
3346
  subcommands: [defineOperation({
1883
3347
  name: "list",
1884
3348
  summary: "Get asset URL links",
3349
+ example: "hardfin asset url-link list ast_4f9xk2mq7plr8stz",
1885
3350
  method: "GET",
1886
3351
  path: "/asset/{assetKey}/url-link",
1887
3352
  pathParameters: [{
@@ -1890,10 +3355,11 @@ const surfaceCommands = [
1890
3355
  required: true
1891
3356
  }],
1892
3357
  queryFlags: [],
1893
- takesBody: false
3358
+ bodyFlags: []
1894
3359
  }), defineOperation({
1895
3360
  name: "create",
1896
3361
  summary: "Create asset URL link",
3362
+ example: "hardfin asset url-link create ast_4f9xk2mq7plr8stz --url <value>",
1897
3363
  method: "POST",
1898
3364
  path: "/asset/{assetKey}/url-link",
1899
3365
  pathParameters: [{
@@ -1902,7 +3368,21 @@ const surfaceCommands = [
1902
3368
  required: true
1903
3369
  }],
1904
3370
  queryFlags: [],
1905
- takesBody: true
3371
+ bodyFlags: [{
3372
+ name: "name",
3373
+ jsonPath: ["name"],
3374
+ description: "The link's display name, or null to show the address instead",
3375
+ valueName: "value",
3376
+ nullable: true,
3377
+ schema: z.string()
3378
+ }, {
3379
+ name: "url",
3380
+ jsonPath: ["url"],
3381
+ description: "The address the link points to",
3382
+ valueName: "value",
3383
+ required: true,
3384
+ schema: z.string()
3385
+ }]
1906
3386
  })]
1907
3387
  },
1908
3388
  {
@@ -1914,6 +3394,7 @@ const surfaceCommands = [
1914
3394
  subcommands: [defineOperation({
1915
3395
  name: "create",
1916
3396
  summary: "Create asset useful life revision",
3397
+ example: "hardfin asset useful-life-revision create ast_4f9xk2mq7plr8stz --effective-date <value> --reason <value> --useful-life-months <number>",
1917
3398
  method: "POST",
1918
3399
  path: "/asset/{assetKey}/useful-life-revision",
1919
3400
  pathParameters: [{
@@ -1922,7 +3403,48 @@ const surfaceCommands = [
1922
3403
  required: true
1923
3404
  }],
1924
3405
  queryFlags: [],
1925
- takesBody: true
3406
+ bodyFlags: [
3407
+ {
3408
+ name: "effective-date",
3409
+ jsonPath: ["effectiveDate"],
3410
+ description: "The day the revised useful life takes effect, which cannot be in the future",
3411
+ valueName: "value",
3412
+ required: true,
3413
+ schema: z.string()
3414
+ },
3415
+ {
3416
+ name: "notes",
3417
+ jsonPath: ["notes"],
3418
+ description: "Free-form detail about the revision, which a reason of OTHER requires",
3419
+ valueName: "value",
3420
+ nullable: true,
3421
+ schema: z.string()
3422
+ },
3423
+ {
3424
+ name: "reason",
3425
+ jsonPath: ["reason"],
3426
+ description: "Why the useful life was revised",
3427
+ valueName: "value",
3428
+ required: true,
3429
+ schema: toEnum([
3430
+ "CHANGE_IN_USE",
3431
+ "DAMAGE",
3432
+ "OBSOLESCENCE",
3433
+ "OTHER",
3434
+ "REASSESSMENT",
3435
+ "REFURBISHMENT",
3436
+ "REGULATORY"
3437
+ ])
3438
+ },
3439
+ {
3440
+ name: "useful-life-months",
3441
+ jsonPath: ["usefulLifeMonths"],
3442
+ description: "The asset's revised useful life in months",
3443
+ valueName: "number",
3444
+ required: true,
3445
+ schema: z.coerce.number().int()
3446
+ }
3447
+ ]
1926
3448
  })]
1927
3449
  }
1928
3450
  ]
@@ -1937,6 +3459,7 @@ const surfaceCommands = [
1937
3459
  defineOperation({
1938
3460
  name: "list",
1939
3461
  summary: "Get customers",
3462
+ example: "hardfin customer list --limit 10",
1940
3463
  method: "GET",
1941
3464
  path: "/customer",
1942
3465
  pathParameters: [],
@@ -1946,14 +3469,14 @@ const surfaceCommands = [
1946
3469
  queryName: "page",
1947
3470
  description: "The page to return, starting at 1",
1948
3471
  valueName: "number",
1949
- schema: z.coerce.number()
3472
+ schema: z.coerce.number().int()
1950
3473
  },
1951
3474
  {
1952
3475
  name: "limit",
1953
3476
  queryName: "limit",
1954
3477
  description: "The number of records per page, from 1 to 100",
1955
3478
  valueName: "number",
1956
- schema: z.coerce.number()
3479
+ schema: z.coerce.number().int().min(1).max(100)
1957
3480
  },
1958
3481
  {
1959
3482
  name: "sort-by",
@@ -1967,14 +3490,14 @@ const surfaceCommands = [
1967
3490
  queryName: "sortOrder",
1968
3491
  description: "The sort direction",
1969
3492
  valueName: "value",
1970
- schema: z.enum(["ASC", "DESC"])
3493
+ schema: toEnum(["ASC", "DESC"])
1971
3494
  },
1972
3495
  {
1973
3496
  name: "archived",
1974
3497
  queryName: "archived",
1975
3498
  description: "Whether to return unarchived records, archived records, or all of them",
1976
3499
  valueName: "value",
1977
- schema: z.enum([
3500
+ schema: toEnum([
1978
3501
  "all",
1979
3502
  "false",
1980
3503
  "true"
@@ -2008,20 +3531,101 @@ const surfaceCommands = [
2008
3531
  schema: z.string()
2009
3532
  }
2010
3533
  ],
2011
- takesBody: false
3534
+ bodyFlags: []
2012
3535
  }),
2013
3536
  defineOperation({
2014
3537
  name: "create",
2015
3538
  summary: "Create customer",
3539
+ example: "hardfin customer create --name <value>",
2016
3540
  method: "POST",
2017
3541
  path: "/customer",
2018
3542
  pathParameters: [],
2019
3543
  queryFlags: [],
2020
- takesBody: true
3544
+ bodyFlags: [
3545
+ {
3546
+ name: "billing-address",
3547
+ jsonPath: ["billingAddress"],
3548
+ description: "The address invoices are sent to",
3549
+ valueName: "value",
3550
+ nullable: true,
3551
+ schema: z.string()
3552
+ },
3553
+ {
3554
+ name: "billing-contact-email",
3555
+ jsonPath: ["billingContact", "email"],
3556
+ description: "The billing contact's email address",
3557
+ valueName: "value",
3558
+ nullable: true,
3559
+ schema: z.string()
3560
+ },
3561
+ {
3562
+ name: "billing-contact-name",
3563
+ jsonPath: ["billingContact", "name"],
3564
+ description: "The billing contact's name",
3565
+ valueName: "value",
3566
+ nullable: true,
3567
+ schema: z.string()
3568
+ },
3569
+ {
3570
+ name: "billing-contact-phone",
3571
+ jsonPath: ["billingContact", "phone"],
3572
+ description: "The billing contact's phone number",
3573
+ valueName: "value",
3574
+ nullable: true,
3575
+ schema: z.string()
3576
+ },
3577
+ {
3578
+ name: "comment",
3579
+ jsonPath: ["comment"],
3580
+ description: "A free-form note about the customer",
3581
+ valueName: "value",
3582
+ nullable: true,
3583
+ schema: z.string()
3584
+ },
3585
+ {
3586
+ name: "domain",
3587
+ jsonPath: ["domain"],
3588
+ description: "The customer's web domain, used to look up its logo",
3589
+ valueName: "value",
3590
+ nullable: true,
3591
+ schema: z.string()
3592
+ },
3593
+ {
3594
+ name: "external-id",
3595
+ jsonPath: ["externalId"],
3596
+ description: "The customer's identifier in another system",
3597
+ valueName: "value",
3598
+ nullable: true,
3599
+ schema: z.string()
3600
+ },
3601
+ {
3602
+ name: "is-customer",
3603
+ jsonPath: ["isCustomer"],
3604
+ description: "Whether the company is a customer",
3605
+ negatable: true,
3606
+ schema: z.boolean()
3607
+ },
3608
+ {
3609
+ name: "is-supplier",
3610
+ jsonPath: ["isSupplier"],
3611
+ description: "Whether the company is a supplier",
3612
+ negatable: true,
3613
+ schema: z.boolean()
3614
+ },
3615
+ {
3616
+ name: "name",
3617
+ jsonPath: ["name"],
3618
+ description: "The customer's display name",
3619
+ valueName: "value",
3620
+ required: true,
3621
+ schema: z.string()
3622
+ }
3623
+ ]
2021
3624
  }),
2022
3625
  defineOperation({
2023
3626
  name: "get",
2024
3627
  summary: "Get customer",
3628
+ example: "hardfin customer get cust_V1StGXR8Z5jdHi6B",
2025
3629
  method: "GET",
2026
3630
  path: "/customer/{customerKey}",
2027
3631
  pathParameters: [{
@@ -2030,11 +3634,12 @@ const surfaceCommands = [
2030
3634
  required: true
2031
3635
  }],
2032
3636
  queryFlags: [],
2033
- takesBody: false
3637
+ bodyFlags: []
2034
3638
  }),
2035
3639
  defineOperation({
2036
3640
  name: "update",
2037
3641
  summary: "Patch customer",
3642
+ example: "hardfin customer update cust_V1StGXR8Z5jdHi6B",
2038
3643
  method: "PATCH",
2039
3644
  path: "/customer/{customerKey}",
2040
3645
  pathParameters: [{
@@ -2043,7 +3648,96 @@ const surfaceCommands = [
2043
3648
  required: true
2044
3649
  }],
2045
3650
  queryFlags: [],
2046
- takesBody: true
3651
+ bodyFlags: [
3652
+ {
3653
+ name: "billing-address",
3654
+ jsonPath: ["billingAddress"],
3655
+ description: "The address invoices are sent to",
3656
+ valueName: "value",
3657
+ nullable: true,
3658
+ schema: z.string()
3659
+ },
3660
+ {
3661
+ name: "billing-contact-email",
3662
+ jsonPath: ["billingContact", "email"],
3663
+ description: "The billing contact's email address",
3664
+ valueName: "value",
3665
+ nullable: true,
3666
+ schema: z.string()
3667
+ },
3668
+ {
3669
+ name: "billing-contact-name",
3670
+ jsonPath: ["billingContact", "name"],
3671
+ description: "The billing contact's name",
3672
+ valueName: "value",
3673
+ nullable: true,
3674
+ schema: z.string()
3675
+ },
3676
+ {
3677
+ name: "billing-contact-phone",
3678
+ jsonPath: ["billingContact", "phone"],
3679
+ description: "The billing contact's phone number",
3680
+ valueName: "value",
3681
+ nullable: true,
3682
+ schema: z.string()
3683
+ },
3684
+ {
3685
+ name: "comment",
3686
+ jsonPath: ["comment"],
3687
+ description: "A free-form note about the customer",
3688
+ valueName: "value",
3689
+ nullable: true,
3690
+ schema: z.string()
3691
+ },
3692
+ {
3693
+ name: "domain",
3694
+ jsonPath: ["domain"],
3695
+ description: "The customer's web domain, used to look up its logo",
3696
+ valueName: "value",
3697
+ nullable: true,
3698
+ schema: z.string()
3699
+ },
3700
+ {
3701
+ name: "external-id",
3702
+ jsonPath: ["externalId"],
3703
+ description: "The customer's identifier in another system",
3704
+ valueName: "value",
3705
+ nullable: true,
3706
+ schema: z.string()
3707
+ },
3708
+ {
3709
+ name: "is-archived",
3710
+ jsonPath: ["isArchived"],
3711
+ description: "Whether the customer is archived",
3712
+ negatable: true,
3713
+ nullable: true,
3714
+ schema: z.boolean()
3715
+ },
3716
+ {
3717
+ name: "is-customer",
3718
+ jsonPath: ["isCustomer"],
3719
+ description: "Whether the company is a customer",
3720
+ negatable: true,
3721
+ nullable: true,
3722
+ schema: z.boolean()
3723
+ },
3724
+ {
3725
+ name: "is-supplier",
3726
+ jsonPath: ["isSupplier"],
3727
+ description: "Whether the company is a supplier",
3728
+ negatable: true,
3729
+ nullable: true,
3730
+ schema: z.boolean()
3731
+ },
3732
+ {
3733
+ name: "name",
3734
+ jsonPath: ["name"],
3735
+ description: "The customer's display name",
3736
+ valueName: "value",
3737
+ nullable: true,
3738
+ schema: z.string()
3739
+ }
3740
+ ]
2047
3741
  })
2048
3742
  ]
2049
3743
  },
@@ -2056,14 +3750,43 @@ const surfaceCommands = [
2056
3750
  subcommands: [defineOperation({
2057
3751
  name: "create",
2058
3752
  summary: "Upload file",
3753
+ example: "hardfin file create --file-type <value> --for-entity <uuid> --file photo.jpg",
2059
3754
  method: "POST",
2060
3755
  path: "/file",
2061
3756
  pathParameters: [],
2062
3757
  queryFlags: [],
2063
- takesBody: true
3758
+ bodyFlags: [],
3759
+ upload: {
3760
+ filePart: "data",
3761
+ fields: [
3762
+ {
3763
+ name: "file-type",
3764
+ jsonPath: ["fileType"],
3765
+ description: "The kind of file uploaded, which is ASSET_FILE, the only kind the API uploads",
3766
+ valueName: "value",
3767
+ required: true,
3768
+ schema: z.string()
3769
+ },
3770
+ {
3771
+ name: "for-entity",
3772
+ jsonPath: ["forEntity"],
3773
+ description: "The ID of the asset the file is attached to",
3774
+ valueName: "uuid",
3775
+ required: true,
3776
+ schema: z.uuid()
3777
+ },
3778
+ {
3779
+ name: "is-public",
3780
+ jsonPath: ["isPublic"],
3781
+ description: "Whether any organization's API key may download the file, which is false unless sent as true",
3782
+ schema: z.boolean()
3783
+ }
3784
+ ]
3785
+ }
2064
3786
  }), defineOperation({
2065
3787
  name: "get",
2066
3788
  summary: "Get file",
3789
+ example: "hardfin file get file_7hq2mx9pkr4stz8w",
2067
3790
  method: "GET",
2068
3791
  path: "/file/{fileKey}",
2069
3792
  pathParameters: [{
@@ -2077,7 +3800,8 @@ const surfaceCommands = [
2077
3800
  description: "True when the file downloads as an attachment rather than opening inline",
2078
3801
  schema: z.boolean()
2079
3802
  }],
2080
- takesBody: false
3803
+ bodyFlags: [],
3804
+ downloads: true
2081
3805
  })]
2082
3806
  },
2083
3807
  {
@@ -2090,6 +3814,7 @@ const surfaceCommands = [
2090
3814
  defineOperation({
2091
3815
  name: "list",
2092
3816
  summary: "Get items",
3817
+ example: "hardfin item list --limit 10",
2093
3818
  method: "GET",
2094
3819
  path: "/item",
2095
3820
  pathParameters: [],
@@ -2099,7 +3824,7 @@ const surfaceCommands = [
2099
3824
  queryName: "type",
2100
3825
  description: "The item type to list, SERVICE, DEVICE, or BULK, or absent for every type",
2101
3826
  valueName: "value",
2102
- schema: z.enum([
3827
+ schema: toEnum([
2103
3828
  "BULK",
2104
3829
  "DEVICE",
2105
3830
  "SERVICE"
@@ -2117,21 +3842,21 @@ const surfaceCommands = [
2117
3842
  queryName: "page",
2118
3843
  description: "The page to return, starting at 1",
2119
3844
  valueName: "number",
2120
- schema: z.coerce.number()
3845
+ schema: z.coerce.number().int()
2121
3846
  },
2122
3847
  {
2123
3848
  name: "limit",
2124
3849
  queryName: "limit",
2125
3850
  description: "The number of records per page, from 1 to 100",
2126
3851
  valueName: "number",
2127
- schema: z.coerce.number()
3852
+ schema: z.coerce.number().int().min(1).max(100)
2128
3853
  },
2129
3854
  {
2130
3855
  name: "sort-by",
2131
3856
  queryName: "sortBy",
2132
3857
  description: "The field to sort by",
2133
3858
  valueName: "value",
2134
- schema: z.enum([
3859
+ schema: toEnum([
2135
3860
  "name",
2136
3861
  "sku",
2137
3862
  "type",
@@ -2143,14 +3868,14 @@ const surfaceCommands = [
2143
3868
  queryName: "sortOrder",
2144
3869
  description: "The sort direction",
2145
3870
  valueName: "value",
2146
- schema: z.enum(["ASC", "DESC"])
3871
+ schema: toEnum(["ASC", "DESC"])
2147
3872
  },
2148
3873
  {
2149
3874
  name: "archived",
2150
3875
  queryName: "archived",
2151
3876
  description: "Whether to return unarchived records, archived records, or all of them",
2152
3877
  valueName: "value",
2153
- schema: z.enum([
3878
+ schema: toEnum([
2154
3879
  "all",
2155
3880
  "false",
2156
3881
  "true"
@@ -2164,20 +3889,131 @@ const surfaceCommands = [
2164
3889
  schema: z.string()
2165
3890
  }
2166
3891
  ],
2167
- takesBody: false
3892
+ bodyFlags: []
2168
3893
  }),
2169
3894
  defineOperation({
2170
3895
  name: "create",
2171
3896
  summary: "Create item",
3897
+ example: "hardfin item create --name <value> --sku <value> --type <value>",
2172
3898
  method: "POST",
2173
3899
  path: "/item",
2174
3900
  pathParameters: [],
2175
3901
  queryFlags: [],
2176
- takesBody: true
3902
+ bodyFlags: [
3903
+ {
3904
+ name: "accepts-bulk-serials",
3905
+ jsonPath: ["acceptsBulkSerials"],
3906
+ description: "Whether a BULK item records serial numbers on its units, which SERVICE and DEVICE items ignore",
3907
+ negatable: true,
3908
+ schema: z.boolean()
3909
+ },
3910
+ {
3911
+ name: "description",
3912
+ jsonPath: ["description"],
3913
+ description: "A free-form description of the item",
3914
+ valueName: "value",
3915
+ nullable: true,
3916
+ schema: z.string()
3917
+ },
3918
+ {
3919
+ name: "name",
3920
+ jsonPath: ["name"],
3921
+ description: "The item's display name",
3922
+ valueName: "value",
3923
+ required: true,
3924
+ schema: z.string()
3925
+ },
3926
+ {
3927
+ name: "sku",
3928
+ jsonPath: ["sku"],
3929
+ description: "The item's stock keeping unit, unique within your organization",
3930
+ valueName: "value",
3931
+ required: true,
3932
+ schema: z.string()
3933
+ },
3934
+ {
3935
+ name: "type",
3936
+ jsonPath: ["type"],
3937
+ description: "SERVICE for a non-physical item, DEVICE for a physical item tracked by serial number, or BULK for a part tracked by quantity",
3938
+ valueName: "value",
3939
+ required: true,
3940
+ schema: toEnum([
3941
+ "BULK",
3942
+ "DEVICE",
3943
+ "SERVICE"
3944
+ ])
3945
+ },
3946
+ {
3947
+ name: "unit-of-measure",
3948
+ jsonPath: ["unitOfMeasure"],
3949
+ description: "The unit a BULK item's quantities are counted in, which SERVICE and DEVICE items ignore",
3950
+ valueName: "value",
3951
+ schema: toEnum([
3952
+ "BG",
3953
+ "BO",
3954
+ "BX",
3955
+ "C62",
3956
+ "CMK",
3957
+ "CMT",
3958
+ "CR",
3959
+ "CS",
3960
+ "CT",
3961
+ "DMQ",
3962
+ "DR",
3963
+ "DZN",
3964
+ "EA",
3965
+ "EN",
3966
+ "FOT",
3967
+ "FTK",
3968
+ "FTQ",
3969
+ "GLL",
3970
+ "GRM",
3971
+ "GRO",
3972
+ "H87",
3973
+ "INH",
3974
+ "INK",
3975
+ "INQ",
3976
+ "KG",
3977
+ "KGM",
3978
+ "KMT",
3979
+ "KT",
3980
+ "LBR",
3981
+ "LO",
3982
+ "LTR",
3983
+ "MGM",
3984
+ "MLT",
3985
+ "MMK",
3986
+ "MMT",
3987
+ "MTK",
3988
+ "MTQ",
3989
+ "MTR",
3990
+ "ONZ",
3991
+ "OZA",
3992
+ "PK",
3993
+ "PR",
3994
+ "PTI",
3995
+ "PX",
3996
+ "QTI",
3997
+ "RL",
3998
+ "RO",
3999
+ "SET",
4000
+ "SMI",
4001
+ "ST",
4002
+ "STN",
4003
+ "SV",
4004
+ "TNE",
4005
+ "TU",
4006
+ "YDK",
4007
+ "YDQ",
4008
+ "YRD"
4009
+ ])
4010
+ }
4011
+ ]
2177
4012
  }),
2178
4013
  defineOperation({
2179
4014
  name: "get",
2180
4015
  summary: "Get item",
4016
+ example: "hardfin item get item_7Hq2Lm9XcR4tWz8K",
2181
4017
  method: "GET",
2182
4018
  path: "/item/{itemKey}",
2183
4019
  pathParameters: [{
@@ -2186,11 +4022,12 @@ const surfaceCommands = [
2186
4022
  required: true
2187
4023
  }],
2188
4024
  queryFlags: [],
2189
- takesBody: false
4025
+ bodyFlags: []
2190
4026
  }),
2191
4027
  defineOperation({
2192
4028
  name: "update",
2193
4029
  summary: "Update item",
4030
+ example: "hardfin item update item_7Hq2Lm9XcR4tWz8K --field fieldId=<value>,order=<value>,section=<value>",
2194
4031
  method: "PATCH",
2195
4032
  path: "/item/{itemKey}",
2196
4033
  pathParameters: [{
@@ -2199,7 +4036,73 @@ const surfaceCommands = [
2199
4036
  required: true
2200
4037
  }],
2201
4038
  queryFlags: [],
2202
- takesBody: true
4039
+ bodyFlags: [
4040
+ {
4041
+ name: "accepts-bulk-serials",
4042
+ jsonPath: ["acceptsBulkSerials"],
4043
+ description: "Whether a BULK item records serial numbers on its units, read only beside type",
4044
+ negatable: true,
4045
+ nullable: true,
4046
+ schema: z.boolean()
4047
+ },
4048
+ {
4049
+ name: "description",
4050
+ jsonPath: ["description"],
4051
+ description: "The item's new description, or null to clear it",
4052
+ valueName: "value",
4053
+ nullable: true,
4054
+ schema: z.string()
4055
+ },
4056
+ {
4057
+ name: "field",
4058
+ jsonPath: ["fields"],
4059
+ description: "New positions for a DEVICE item's fields",
4060
+ valueName: "fieldId=,order=,section=",
4061
+ repeatable: true,
4062
+ element: [
4063
+ "fieldId",
4064
+ "order",
4065
+ "section"
4066
+ ],
4067
+ schema: z.array(z.string())
4068
+ },
4069
+ {
4070
+ name: "is-archived",
4071
+ jsonPath: ["isArchived"],
4072
+ description: "Whether the item is archived, which cannot be null",
4073
+ negatable: true,
4074
+ nullable: true,
4075
+ schema: z.boolean()
4076
+ },
4077
+ {
4078
+ name: "name",
4079
+ jsonPath: ["name"],
4080
+ description: "The item's new display name, which cannot be empty",
4081
+ valueName: "value",
4082
+ nullable: true,
4083
+ schema: z.string()
4084
+ },
4085
+ {
4086
+ name: "sku",
4087
+ jsonPath: ["sku"],
4088
+ description: "The item's new stock keeping unit, which cannot be empty and must be unique within your organization",
4089
+ valueName: "value",
4090
+ nullable: true,
4091
+ schema: z.string()
4092
+ },
4093
+ {
4094
+ name: "type",
4095
+ jsonPath: ["type"],
4096
+ description: "The type to convert the item to, when the item's assets and inventory history allow the conversion",
4097
+ valueName: "value",
4098
+ nullable: true,
4099
+ schema: toEnum([
4100
+ "BULK",
4101
+ "DEVICE",
4102
+ "SERVICE"
4103
+ ])
4104
+ }
4105
+ ]
2203
4106
  }),
2204
4107
  {
2205
4108
  name: "accounting",
@@ -2210,6 +4113,7 @@ const surfaceCommands = [
2210
4113
  subcommands: [defineOperation({
2211
4114
  name: "update",
2212
4115
  summary: "Update item accounting",
4116
+ example: "hardfin item accounting update item_7Hq2Lm9XcR4tWz8K",
2213
4117
  method: "PATCH",
2214
4118
  path: "/item/{itemKey}/accounting",
2215
4119
  pathParameters: [{
@@ -2218,7 +4122,116 @@ const surfaceCommands = [
2218
4122
  required: true
2219
4123
  }],
2220
4124
  queryFlags: [],
2221
- takesBody: true
4125
+ bodyFlags: [
4126
+ {
4127
+ name: "allocated-indirect",
4128
+ jsonPath: ["allocatedIndirect"],
4129
+ description: "One unit's share of overhead, a cost component",
4130
+ valueName: "value",
4131
+ nullable: true,
4132
+ schema: z.string()
4133
+ },
4134
+ {
4135
+ name: "bill-of-materials",
4136
+ jsonPath: ["billOfMaterials"],
4137
+ description: "The parts cost of one unit, a cost component",
4138
+ valueName: "value",
4139
+ nullable: true,
4140
+ schema: z.string()
4141
+ },
4142
+ {
4143
+ name: "depreciation-model",
4144
+ jsonPath: ["depreciationModel"],
4145
+ description: "The method one unit is depreciated by, or null to clear it",
4146
+ valueName: "value",
4147
+ schema: toEnum([
4148
+ "DOUBLE_DECLINING",
4149
+ "STRAIGHT_LINE",
4150
+ "SUM_YEAR",
4151
+ "UNIT_OF_PRODUCTION"
4152
+ ])
4153
+ },
4154
+ {
4155
+ name: "direct-labor",
4156
+ jsonPath: ["directLabor"],
4157
+ description: "The labor cost to build one unit, a cost component",
4158
+ valueName: "value",
4159
+ nullable: true,
4160
+ schema: z.string()
4161
+ },
4162
+ {
4163
+ name: "freight-inbound",
4164
+ jsonPath: ["freightInbound"],
4165
+ description: "The shipping cost to receive one unit, a cost component",
4166
+ valueName: "value",
4167
+ nullable: true,
4168
+ schema: z.string()
4169
+ },
4170
+ {
4171
+ name: "freight-outbound",
4172
+ jsonPath: ["freightOutbound"],
4173
+ description: "The shipping cost to deploy one unit, a deployment cost component",
4174
+ valueName: "value",
4175
+ nullable: true,
4176
+ schema: z.string()
4177
+ },
4178
+ {
4179
+ name: "installation",
4180
+ jsonPath: ["installation"],
4181
+ description: "The cost to install one unit, a deployment cost component",
4182
+ valueName: "value",
4183
+ nullable: true,
4184
+ schema: z.string()
4185
+ },
4186
+ {
4187
+ name: "interest",
4188
+ jsonPath: ["interest"],
4189
+ description: "The financing cost of one unit, a cost component",
4190
+ valueName: "value",
4191
+ nullable: true,
4192
+ schema: z.string()
4193
+ },
4194
+ {
4195
+ name: "salvage-value",
4196
+ jsonPath: ["salvageValue"],
4197
+ description: "The value one unit keeps at the end of its useful life, or null to clear it",
4198
+ valueName: "value",
4199
+ nullable: true,
4200
+ schema: z.string()
4201
+ },
4202
+ {
4203
+ name: "simple-cost-basis",
4204
+ jsonPath: ["simpleCostBasis"],
4205
+ description: "A single cost for one unit, which cannot be sent together with the cost components",
4206
+ valueName: "value",
4207
+ nullable: true,
4208
+ schema: z.string()
4209
+ },
4210
+ {
4211
+ name: "tariffs",
4212
+ jsonPath: ["tariffs"],
4213
+ description: "The import duty paid on one unit, a cost component",
4214
+ valueName: "value",
4215
+ nullable: true,
4216
+ schema: z.string()
4217
+ },
4218
+ {
4219
+ name: "tax",
4220
+ jsonPath: ["tax"],
4221
+ description: "The tax paid on one unit, a cost component",
4222
+ valueName: "value",
4223
+ nullable: true,
4224
+ schema: z.string()
4225
+ },
4226
+ {
4227
+ name: "useful-life",
4228
+ jsonPath: ["usefulLife"],
4229
+ description: "The number of months one unit is depreciated over, or null to clear it",
4230
+ valueName: "number",
4231
+ nullable: true,
4232
+ schema: z.coerce.number().int()
4233
+ }
4234
+ ]
2222
4235
  })]
2223
4236
  },
2224
4237
  {
@@ -2231,6 +4244,7 @@ const surfaceCommands = [
2231
4244
  defineOperation({
2232
4245
  name: "create",
2233
4246
  summary: "Create item field",
4247
+ example: "hardfin item field create item_7Hq2Lm9XcR4tWz8K --field-type <value> --label <value> --order <number>",
2234
4248
  method: "POST",
2235
4249
  path: "/item/{itemKey}/field",
2236
4250
  pathParameters: [{
@@ -2239,11 +4253,56 @@ const surfaceCommands = [
2239
4253
  required: true
2240
4254
  }],
2241
4255
  queryFlags: [],
2242
- takesBody: true
4256
+ bodyFlags: [
4257
+ {
4258
+ name: "field-type",
4259
+ jsonPath: ["fieldType"],
4260
+ description: "The kind of value the field holds",
4261
+ valueName: "value",
4262
+ required: true,
4263
+ schema: toEnum([
4264
+ "BOOLEAN",
4265
+ "DATE",
4266
+ "DATE_TIME",
4267
+ "INTEGER",
4268
+ "MULTILINE_TEXT",
4269
+ "NUMBER",
4270
+ "TEXT",
4271
+ "TIME"
4272
+ ])
4273
+ },
4274
+ {
4275
+ name: "label",
4276
+ jsonPath: ["label"],
4277
+ description: "The field's display name",
4278
+ valueName: "value",
4279
+ required: true,
4280
+ schema: z.string()
4281
+ },
4282
+ {
4283
+ name: "order",
4284
+ jsonPath: ["order"],
4285
+ description: "The field's position within its section, starting at 0",
4286
+ valueName: "number",
4287
+ required: true,
4288
+ nullable: true,
4289
+ schema: z.coerce.number().int()
4290
+ },
4291
+ {
4292
+ name: "section",
4293
+ jsonPath: ["section"],
4294
+ description: "The group the field is shown in, starting at 0",
4295
+ valueName: "number",
4296
+ required: true,
4297
+ nullable: true,
4298
+ schema: z.coerce.number().int()
4299
+ }
4300
+ ]
2243
4301
  }),
2244
4302
  defineOperation({
2245
4303
  name: "update",
2246
4304
  summary: "Update item field",
4305
+ example: "hardfin item field update item_7Hq2Lm9XcR4tWz8K pfield_2wn8kq4lxp7rtz3m",
2247
4306
  method: "PATCH",
2248
4307
  path: "/item/{itemKey}/field/{fieldKey}",
2249
4308
  pathParameters: [{
@@ -2256,11 +4315,19 @@ const surfaceCommands = [
2256
4315
  required: true
2257
4316
  }],
2258
4317
  queryFlags: [],
2259
- takesBody: true
4318
+ bodyFlags: [{
4319
+ name: "label",
4320
+ jsonPath: ["label"],
4321
+ description: "The field's new display name, which cannot be empty",
4322
+ valueName: "value",
4323
+ nullable: true,
4324
+ schema: z.string()
4325
+ }]
2260
4326
  }),
2261
4327
  defineOperation({
2262
4328
  name: "delete",
2263
4329
  summary: "Delete item field",
4330
+ example: "hardfin item field delete item_7Hq2Lm9XcR4tWz8K pfield_2wn8kq4lxp7rtz3m",
2264
4331
  method: "DELETE",
2265
4332
  path: "/item/{itemKey}/field/{fieldKey}",
2266
4333
  pathParameters: [{
@@ -2273,7 +4340,7 @@ const surfaceCommands = [
2273
4340
  required: true
2274
4341
  }],
2275
4342
  queryFlags: [],
2276
- takesBody: false
4343
+ bodyFlags: []
2277
4344
  })
2278
4345
  ]
2279
4346
  }
@@ -2289,6 +4356,7 @@ const surfaceCommands = [
2289
4356
  defineOperation({
2290
4357
  name: "list",
2291
4358
  summary: "Get location listing",
4359
+ example: "hardfin location list --limit 10",
2292
4360
  method: "GET",
2293
4361
  path: "/location",
2294
4362
  pathParameters: [],
@@ -2298,21 +4366,21 @@ const surfaceCommands = [
2298
4366
  queryName: "page",
2299
4367
  description: "The page to return, starting at 1",
2300
4368
  valueName: "number",
2301
- schema: z.coerce.number()
4369
+ schema: z.coerce.number().int()
2302
4370
  },
2303
4371
  {
2304
4372
  name: "limit",
2305
4373
  queryName: "limit",
2306
4374
  description: "The number of records per page, from 1 to 100",
2307
4375
  valueName: "number",
2308
- schema: z.coerce.number()
4376
+ schema: z.coerce.number().int().min(1).max(100)
2309
4377
  },
2310
4378
  {
2311
4379
  name: "sort-by",
2312
4380
  queryName: "sortBy",
2313
4381
  description: "The field to sort by",
2314
4382
  valueName: "value",
2315
- schema: z.enum([
4383
+ schema: toEnum([
2316
4384
  "name",
2317
4385
  "company",
2318
4386
  "assetCount"
@@ -2323,14 +4391,14 @@ const surfaceCommands = [
2323
4391
  queryName: "sortOrder",
2324
4392
  description: "The sort direction",
2325
4393
  valueName: "value",
2326
- schema: z.enum(["ASC", "DESC"])
4394
+ schema: toEnum(["ASC", "DESC"])
2327
4395
  },
2328
4396
  {
2329
4397
  name: "archived",
2330
4398
  queryName: "archived",
2331
4399
  description: "Whether to return unarchived records, archived records, or all of them",
2332
4400
  valueName: "value",
2333
- schema: z.enum([
4401
+ schema: toEnum([
2334
4402
  "all",
2335
4403
  "false",
2336
4404
  "true"
@@ -2341,7 +4409,7 @@ const surfaceCommands = [
2341
4409
  queryName: "isTransient",
2342
4410
  description: "Whether to return permanent locations (false), transient locations (true), or both (all)",
2343
4411
  valueName: "value",
2344
- schema: z.enum([
4412
+ schema: toEnum([
2345
4413
  "all",
2346
4414
  "false",
2347
4415
  "true"
@@ -2377,20 +4445,150 @@ const surfaceCommands = [
2377
4445
  schema: z.array(z.string())
2378
4446
  }
2379
4447
  ],
2380
- takesBody: false
4448
+ bodyFlags: []
2381
4449
  }),
2382
4450
  defineOperation({
2383
4451
  name: "create",
2384
4452
  summary: "Create location",
4453
+ example: "hardfin location create",
2385
4454
  method: "POST",
2386
4455
  path: "/location",
2387
4456
  pathParameters: [],
2388
4457
  queryFlags: [],
2389
- takesBody: true
4458
+ bodyFlags: [
4459
+ {
4460
+ name: "address-line1",
4461
+ jsonPath: ["address", "addressLine1"],
4462
+ description: "The first line of the street address",
4463
+ valueName: "value",
4464
+ nullable: true,
4465
+ schema: z.string()
4466
+ },
4467
+ {
4468
+ name: "address-line2",
4469
+ jsonPath: ["address", "addressLine2"],
4470
+ description: "The second line of the street address, such as a suite",
4471
+ valueName: "value",
4472
+ nullable: true,
4473
+ schema: z.string()
4474
+ },
4475
+ {
4476
+ name: "address-city",
4477
+ jsonPath: ["address", "city"],
4478
+ description: "The city",
4479
+ valueName: "value",
4480
+ nullable: true,
4481
+ schema: z.string()
4482
+ },
4483
+ {
4484
+ name: "address-country",
4485
+ jsonPath: ["address", "country"],
4486
+ description: "The country",
4487
+ valueName: "value",
4488
+ nullable: true,
4489
+ schema: z.string()
4490
+ },
4491
+ {
4492
+ name: "address-formatted-address",
4493
+ jsonPath: ["address", "formattedAddress"],
4494
+ description: "The whole address on one line",
4495
+ valueName: "value",
4496
+ nullable: true,
4497
+ schema: z.string()
4498
+ },
4499
+ {
4500
+ name: "address-postal-code",
4501
+ jsonPath: ["address", "postalCode"],
4502
+ description: "The postal or ZIP code",
4503
+ valueName: "value",
4504
+ nullable: true,
4505
+ schema: z.string()
4506
+ },
4507
+ {
4508
+ name: "address-state",
4509
+ jsonPath: ["address", "state"],
4510
+ description: "The state or region",
4511
+ valueName: "value",
4512
+ nullable: true,
4513
+ schema: z.string()
4514
+ },
4515
+ {
4516
+ name: "consignee",
4517
+ jsonPath: ["consignee"],
4518
+ description: "The ID of the customer a zone is designated for, such as for reservations, provisioning or a 3PL",
4519
+ valueName: "uuid",
4520
+ nullable: true,
4521
+ schema: z.uuid()
4522
+ },
4523
+ {
4524
+ name: "customer-id",
4525
+ jsonPath: ["customerId"],
4526
+ description: "The ID of the customer to assign a site to, or null for your organization's own site",
4527
+ valueName: "uuid",
4528
+ nullable: true,
4529
+ schema: z.uuid()
4530
+ },
4531
+ {
4532
+ name: "description",
4533
+ jsonPath: ["description"],
4534
+ description: "A free-form description of a zone",
4535
+ valueName: "value",
4536
+ nullable: true,
4537
+ schema: z.string()
4538
+ },
4539
+ {
4540
+ name: "is-inventory",
4541
+ jsonPath: ["isInventory"],
4542
+ description: "Whether assets at the location count as inventory for reporting",
4543
+ negatable: true,
4544
+ schema: z.boolean()
4545
+ },
4546
+ {
4547
+ name: "is-inventory-override",
4548
+ jsonPath: ["isInventoryOverride"],
4549
+ description: "Whether a zone sets its own isInventory rather than inheriting its parent site's, which a site refuses",
4550
+ negatable: true,
4551
+ schema: z.boolean()
4552
+ },
4553
+ {
4554
+ name: "is-transient",
4555
+ jsonPath: ["isTransient"],
4556
+ description: "Whether assets make only occasional or temporary stops at the location, which hides it from location lists by default",
4557
+ negatable: true,
4558
+ schema: z.boolean()
4559
+ },
4560
+ {
4561
+ name: "name",
4562
+ jsonPath: ["name"],
4563
+ description: "The location's display name",
4564
+ valueName: "value",
4565
+ schema: z.string()
4566
+ },
4567
+ {
4568
+ name: "parent-location-id",
4569
+ jsonPath: ["parentLocationId"],
4570
+ description: "The ID of the site a zone belongs to, required for a zone and refused for a site",
4571
+ valueName: "uuid",
4572
+ nullable: true,
4573
+ schema: z.uuid()
4574
+ },
4575
+ {
4576
+ name: "type",
4577
+ jsonPath: ["type"],
4578
+ description: "SITE for a site, or ZONE for a zone within a site",
4579
+ valueName: "value",
4580
+ schema: toEnum([
4581
+ "SITE",
4582
+ "UNKNOWN",
4583
+ "ZONE"
4584
+ ])
4585
+ }
4586
+ ]
2390
4587
  }),
2391
4588
  defineOperation({
2392
4589
  name: "get",
2393
4590
  summary: "Get location",
4591
+ example: "hardfin location get loc_4f9Xk2mQ7pLr8sTz",
2394
4592
  method: "GET",
2395
4593
  path: "/location/{locationKey}",
2396
4594
  pathParameters: [{
@@ -2399,11 +4597,12 @@ const surfaceCommands = [
2399
4597
  required: true
2400
4598
  }],
2401
4599
  queryFlags: [],
2402
- takesBody: false
4600
+ bodyFlags: []
2403
4601
  }),
2404
4602
  defineOperation({
2405
4603
  name: "update",
2406
4604
  summary: "Patch location",
4605
+ example: "hardfin location update loc_4f9Xk2mQ7pLr8sTz",
2407
4606
  method: "PATCH",
2408
4607
  path: "/location/{locationKey}",
2409
4608
  pathParameters: [{
@@ -2412,7 +4611,139 @@ const surfaceCommands = [
2412
4611
  required: true
2413
4612
  }],
2414
4613
  queryFlags: [],
2415
- takesBody: true
4614
+ bodyFlags: [
4615
+ {
4616
+ name: "address-line1",
4617
+ jsonPath: ["address", "addressLine1"],
4618
+ description: "The first line of the street address",
4619
+ valueName: "value",
4620
+ nullable: true,
4621
+ schema: z.string()
4622
+ },
4623
+ {
4624
+ name: "address-line2",
4625
+ jsonPath: ["address", "addressLine2"],
4626
+ description: "The second line of the street address, such as a suite",
4627
+ valueName: "value",
4628
+ nullable: true,
4629
+ schema: z.string()
4630
+ },
4631
+ {
4632
+ name: "address-city",
4633
+ jsonPath: ["address", "city"],
4634
+ description: "The city",
4635
+ valueName: "value",
4636
+ nullable: true,
4637
+ schema: z.string()
4638
+ },
4639
+ {
4640
+ name: "address-country",
4641
+ jsonPath: ["address", "country"],
4642
+ description: "The country",
4643
+ valueName: "value",
4644
+ nullable: true,
4645
+ schema: z.string()
4646
+ },
4647
+ {
4648
+ name: "address-formatted-address",
4649
+ jsonPath: ["address", "formattedAddress"],
4650
+ description: "The whole address on one line",
4651
+ valueName: "value",
4652
+ nullable: true,
4653
+ schema: z.string()
4654
+ },
4655
+ {
4656
+ name: "address-postal-code",
4657
+ jsonPath: ["address", "postalCode"],
4658
+ description: "The postal or ZIP code",
4659
+ valueName: "value",
4660
+ nullable: true,
4661
+ schema: z.string()
4662
+ },
4663
+ {
4664
+ name: "address-state",
4665
+ jsonPath: ["address", "state"],
4666
+ description: "The state or region",
4667
+ valueName: "value",
4668
+ nullable: true,
4669
+ schema: z.string()
4670
+ },
4671
+ {
4672
+ name: "consignee",
4673
+ jsonPath: ["consignee"],
4674
+ description: "The ID of the customer a zone is designated for, such as for reservations, provisioning or a 3PL",
4675
+ valueName: "uuid",
4676
+ nullable: true,
4677
+ schema: z.uuid()
4678
+ },
4679
+ {
4680
+ name: "customer-id",
4681
+ jsonPath: ["customerId"],
4682
+ description: "The ID of the customer to assign a site to, or null for your organization's own site",
4683
+ valueName: "uuid",
4684
+ nullable: true,
4685
+ schema: z.uuid()
4686
+ },
4687
+ {
4688
+ name: "description",
4689
+ jsonPath: ["description"],
4690
+ description: "A free-form description of a zone",
4691
+ valueName: "value",
4692
+ nullable: true,
4693
+ schema: z.string()
4694
+ },
4695
+ {
4696
+ name: "is-archived",
4697
+ jsonPath: ["isArchived"],
4698
+ description: "Whether the location is archived, and archiving a site archives its zones",
4699
+ negatable: true,
4700
+ nullable: true,
4701
+ schema: z.boolean()
4702
+ },
4703
+ {
4704
+ name: "is-inventory",
4705
+ jsonPath: ["isInventory"],
4706
+ description: "Whether assets at the location count as inventory for reporting",
4707
+ negatable: true,
4708
+ nullable: true,
4709
+ schema: z.boolean()
4710
+ },
4711
+ {
4712
+ name: "is-inventory-override",
4713
+ jsonPath: ["isInventoryOverride"],
4714
+ description: "Whether a zone sets its own isInventory rather than inheriting its parent site's, which a site refuses",
4715
+ negatable: true,
4716
+ nullable: true,
4717
+ schema: z.boolean()
4718
+ },
4719
+ {
4720
+ name: "is-transient",
4721
+ jsonPath: ["isTransient"],
4722
+ description: "Whether assets make only occasional or temporary stops at the location, which hides it from location lists by default",
4723
+ negatable: true,
4724
+ nullable: true,
4725
+ schema: z.boolean()
4726
+ },
4727
+ {
4728
+ name: "name",
4729
+ jsonPath: ["name"],
4730
+ description: "The location's display name",
4731
+ valueName: "value",
4732
+ nullable: true,
4733
+ schema: z.string()
4734
+ },
4735
+ {
4736
+ name: "type",
4737
+ jsonPath: ["type"],
4738
+ description: "SITE for a site, or ZONE for a zone within a site",
4739
+ valueName: "value",
4740
+ schema: toEnum([
4741
+ "SITE",
4742
+ "UNKNOWN",
4743
+ "ZONE"
4744
+ ])
4745
+ }
4746
+ ]
2416
4747
  }),
2417
4748
  {
2418
4749
  name: "zones",
@@ -2423,6 +4754,7 @@ const surfaceCommands = [
2423
4754
  subcommands: [defineOperation({
2424
4755
  name: "list",
2425
4756
  summary: "Get zones",
4757
+ example: "hardfin location zones list loc_4f9Xk2mQ7pLr8sTz",
2426
4758
  method: "GET",
2427
4759
  path: "/location/{locationKey}/zones",
2428
4760
  pathParameters: [{
@@ -2435,17 +4767,34 @@ const surfaceCommands = [
2435
4767
  queryName: "archived",
2436
4768
  description: "Whether to return unarchived zones, archived zones, or all of them",
2437
4769
  valueName: "value",
2438
- schema: z.enum([
4770
+ schema: toEnum([
2439
4771
  "all",
2440
4772
  "false",
2441
4773
  "true"
2442
4774
  ])
2443
4775
  }],
2444
- takesBody: false
4776
+ bodyFlags: []
2445
4777
  })]
2446
4778
  }
2447
4779
  ]
2448
4780
  },
4781
+ {
4782
+ name: "token",
4783
+ summary: "Token commands",
4784
+ arguments: [],
4785
+ flags: [],
4786
+ examples: [],
4787
+ subcommands: [defineOperation({
4788
+ name: "get",
4789
+ summary: "Report the credential this CLI is calling with",
4790
+ example: "hardfin token get",
4791
+ method: "GET",
4792
+ path: "/token",
4793
+ pathParameters: [],
4794
+ queryFlags: [],
4795
+ bodyFlags: []
4796
+ })]
4797
+ },
2449
4798
  {
2450
4799
  name: "url-link",
2451
4800
  summary: "URL link commands",
@@ -2456,6 +4805,7 @@ const surfaceCommands = [
2456
4805
  defineOperation({
2457
4806
  name: "get",
2458
4807
  summary: "Get URL link by key",
4808
+ example: "hardfin url-link get link_7hq2mx9pcr4stz8w",
2459
4809
  method: "GET",
2460
4810
  path: "/url-link/{linkKey}",
2461
4811
  pathParameters: [{
@@ -2464,11 +4814,12 @@ const surfaceCommands = [
2464
4814
  required: true
2465
4815
  }],
2466
4816
  queryFlags: [],
2467
- takesBody: false
4817
+ bodyFlags: []
2468
4818
  }),
2469
4819
  defineOperation({
2470
4820
  name: "update",
2471
4821
  summary: "Update URL link",
4822
+ example: "hardfin url-link update link_7hq2mx9pcr4stz8w",
2472
4823
  method: "PATCH",
2473
4824
  path: "/url-link/{linkKey}",
2474
4825
  pathParameters: [{
@@ -2477,11 +4828,26 @@ const surfaceCommands = [
2477
4828
  required: true
2478
4829
  }],
2479
4830
  queryFlags: [],
2480
- takesBody: true
4831
+ bodyFlags: [{
4832
+ name: "name",
4833
+ jsonPath: ["name"],
4834
+ description: "The link's display name, or null to show the address instead",
4835
+ valueName: "value",
4836
+ nullable: true,
4837
+ schema: z.string()
4838
+ }, {
4839
+ name: "url",
4840
+ jsonPath: ["url"],
4841
+ description: "The address the link points to, which cannot be empty or null",
4842
+ valueName: "value",
4843
+ nullable: true,
4844
+ schema: z.string()
4845
+ }]
2481
4846
  }),
2482
4847
  defineOperation({
2483
4848
  name: "delete",
2484
4849
  summary: "Delete URL link",
4850
+ example: "hardfin url-link delete link_7hq2mx9pcr4stz8w",
2485
4851
  method: "DELETE",
2486
4852
  path: "/url-link/{linkKey}",
2487
4853
  pathParameters: [{
@@ -2490,38 +4856,12 @@ const surfaceCommands = [
2490
4856
  required: true
2491
4857
  }],
2492
4858
  queryFlags: [],
2493
- takesBody: false
4859
+ bodyFlags: []
2494
4860
  })
2495
4861
  ]
2496
4862
  }
2497
4863
  ];
2498
4864
  //#endregion
2499
- //#region src/auth/jwt.ts
2500
- /**
2501
- * toClaims reads an access token's payload for display. Nothing here verifies the
2502
- * signature, because the API is what decides whether a token is good.
2503
- */
2504
- function toClaims(token) {
2505
- const payload = token.split(".")[1];
2506
- if (!payload) return {};
2507
- try {
2508
- const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
2509
- return {
2510
- expiresAt: typeof decoded["exp"] === "number" ? decoded["exp"] * 1e3 : void 0,
2511
- issuedAt: typeof decoded["iat"] === "number" ? decoded["iat"] * 1e3 : void 0,
2512
- scopes: toScopes(decoded),
2513
- subject: typeof decoded["sub"] === "string" ? decoded["sub"] : void 0
2514
- };
2515
- } catch {
2516
- return {};
2517
- }
2518
- }
2519
- /** toScopes reads scp, which is where Hardfin puts an access token's scopes. */
2520
- function toScopes(decoded) {
2521
- if (Array.isArray(decoded["scp"])) return decoded["scp"].map(String);
2522
- return typeof decoded["scope"] === "string" ? decoded["scope"].split(" ") : void 0;
2523
- }
2524
- //#endregion
2525
4865
  //#region src/system/host.ts
2526
4866
  const BYTES_PER_GB = 1024 ** 3;
2527
4867
  /** toDistribution reads the name a Linux distribution gives itself. */
@@ -2565,8 +4905,8 @@ function toFile(path) {
2565
4905
  //#endregion
2566
4906
  //#region src/command/status.ts
2567
4907
  /**
2568
- * Hardfin expires an unused refresh token after this long. It is the server's rule, not the
2569
- * CLI's, and the token endpoint reports no expiry, so what this produces is an estimate.
4908
+ * What the CLI falls back to for an older sign in, stored before the server began reporting
4909
+ * when a refresh token expires. Hardfin's own rule, and an estimate rather than a fact.
2570
4910
  */
2571
4911
  const REFRESH_SLIDING_DAYS = 90;
2572
4912
  const statusCommand = defineCommand({
@@ -2629,27 +4969,24 @@ function toEnvironmentReport() {
2629
4969
  apiVersion: API_VERSION,
2630
4970
  node: process.version,
2631
4971
  platform: process.platform,
2632
- interactive: Boolean(process.stdin.isTTY)
4972
+ interactive: process.stdin.isTTY
2633
4973
  };
2634
4974
  }
2635
4975
  /** toConfigurationReport names every setting, its source, and never a secret's value. */
2636
4976
  function toConfigurationReport(resolved) {
2637
- const entries = Object.entries(resolved.settings).map(([key, value]) => {
4977
+ const report = { configFile: CONFIG_FILE };
4978
+ for (const [key, value] of Object.entries(resolved.settings)) {
2638
4979
  const from = resolved.sources[key];
2639
- if (key === "apiKey") return [key, {
4980
+ report[key] = key === "apiKey" ? {
2640
4981
  set: value !== void 0,
2641
4982
  fingerprint: toFingerprint(value),
2642
4983
  from
2643
- }];
2644
- return [key, {
4984
+ } : {
2645
4985
  value: value ?? null,
2646
4986
  from
2647
- }];
2648
- });
2649
- return {
2650
- ...Object.fromEntries(entries),
2651
- configFile: CONFIG_FILE
2652
- };
4987
+ };
4988
+ }
4989
+ return report;
2653
4990
  }
2654
4991
  function toCredentialReport(apiKey, stored) {
2655
4992
  if (apiKey) return {
@@ -2670,8 +5007,8 @@ function toCredentialReport(apiKey, stored) {
2670
5007
  fingerprint: toFingerprint(stored.refreshToken),
2671
5008
  signedInAt: stored.signedInAt ?? null,
2672
5009
  renewedAt: stored.renewedAt ?? null,
2673
- refreshExpiresAt: toRefreshExpiry(stored.renewedAt),
2674
- refreshExpiryIsEstimated: true
5010
+ refreshExpiresAt: stored.expiresAt ?? toRefreshExpiry(stored.renewedAt),
5011
+ refreshExpiryIsEstimated: stored.expiresAt === void 0
2675
5012
  };
2676
5013
  }
2677
5014
  function toServerReport(metadata) {
@@ -2745,8 +5082,7 @@ function toFlattened(value) {
2745
5082
  if (value === null || typeof value !== "object" || Array.isArray(value)) return;
2746
5083
  const holder = value;
2747
5084
  if (holder.from === void 0) return;
2748
- const shown = holder.value ?? (holder.set ? holder.fingerprint ?? "set" : "not set");
2749
- return `${String(shown)} (${holder.from})`;
5085
+ return `${toText(holder.value ?? (holder.set ? holder.fingerprint ?? "set" : "not set"))} (${holder.from})`;
2750
5086
  }
2751
5087
  /** toLines lays the report out for a person, one indented line per value. */
2752
5088
  function toLines(report, depth = 0) {
@@ -2776,7 +5112,10 @@ const commands = [
2776
5112
  ...surfaceCommands,
2777
5113
  apiCommand,
2778
5114
  configCommand,
2779
- agentGuideCommand
5115
+ agentGuideCommand,
5116
+ completionCommand,
5117
+ mcpCommand,
5118
+ completeCommand
2780
5119
  ];
2781
5120
  //#endregion
2782
5121
  //#region src/command/validate.ts
@@ -2789,13 +5128,16 @@ function toRejectedFlag(command, flags) {
2789
5128
  }
2790
5129
  }
2791
5130
  //#endregion
2792
- //#region src/cli.ts
2793
- const program = new Command();
2794
- program.name("hardfin").description("Call the Hardfin API from a terminal or an agent").version(version$1, "-v, --version").option("--api-url <url>", "The API to call, whose host also holds the authorization server").option("--issuer-url <url>", "The authorization server, when it does not sit at the API's host").showHelpAfterError().enablePositionalOptions();
2795
- for (const command of commands) program.addCommand(toProgram(command));
2796
- await program.parseAsync(process.argv);
5131
+ //#region src/cli/program.ts
5132
+ /** toCli builds the parser from the registry, which is what every surface reads. */
5133
+ function toCli(entries = commands) {
5134
+ const program = new Command();
5135
+ program.name("hardfin").description("Call the Hardfin API from a terminal or an agent").version(version$1, "-v, --version").option("--api-url <url>", "The API to call, whose host also holds the authorization server").option("--issuer-url <url>", "The authorization server, when it does not sit at the API's host").showHelpAfterError().enablePositionalOptions();
5136
+ for (const command of entries) program.addCommand(toProgram(program, command), { hidden: command.hidden });
5137
+ return program;
5138
+ }
2797
5139
  /** toProgram wires one registry command into the parser. */
2798
- function toProgram(command) {
5140
+ function toProgram(root, command) {
2799
5141
  const program = new Command(command.name).summary(command.summary).description(command.description ?? command.summary);
2800
5142
  for (const argument of command.arguments) {
2801
5143
  const name = argument.variadic ? `${argument.name}...` : argument.name;
@@ -2805,21 +5147,22 @@ function toProgram(command) {
2805
5147
  const short = flag.short ? `-${flag.short}, ` : "";
2806
5148
  const value = flag.valueName ? ` <${flag.valueName}>` : "";
2807
5149
  const option = new Option(`${short}--${flag.name}${value}`, flag.description);
5150
+ if (flag.negatable) program.addOption(new Option(`--no-${flag.name}`, `${flag.description}, turned off`));
2808
5151
  if (flag.repeatable) option.argParser(collect);
2809
5152
  if (flag.defaultValue !== void 0) option.default(flag.defaultValue);
2810
5153
  program.addOption(option);
2811
5154
  }
2812
5155
  for (const example of command.examples) program.addHelpText("after", `\n${example.description}:\n $ ${example.command}`);
2813
- for (const subcommand of command.subcommands ?? []) program.addCommand(toProgram(subcommand));
5156
+ for (const subcommand of command.subcommands ?? []) program.addCommand(toProgram(root, subcommand), { hidden: subcommand.hidden });
2814
5157
  if (!command.run) return program;
2815
5158
  program.action(async (...parsed) => {
2816
5159
  const flags = parsed[parsed.length - 2] ?? {};
2817
5160
  const args = parsed.slice(0, parsed.length - 2).flatMap(toArgumentList);
2818
- process.exitCode = await toExitCode(command, args, flags);
5161
+ process.exitCode = await toExitCode(root, command, args, flags);
2819
5162
  });
2820
5163
  return program;
2821
5164
  }
2822
- async function toExitCode(command, args, flags) {
5165
+ async function toExitCode(root, command, args, flags) {
2823
5166
  const isJSON = isJSONOutput(flags);
2824
5167
  const rejected = toRejectedFlag(command, flags);
2825
5168
  if (rejected) {
@@ -2828,8 +5171,8 @@ async function toExitCode(command, args, flags) {
2828
5171
  }
2829
5172
  try {
2830
5173
  const resolved = toSettings({
2831
- apiUrl: program.opts()["apiUrl"],
2832
- issuerUrl: program.opts()["issuerUrl"]
5174
+ apiUrl: root.opts()["apiUrl"],
5175
+ issuerUrl: root.opts()["issuerUrl"]
2833
5176
  });
2834
5177
  return await command.run?.({
2835
5178
  args,
@@ -2845,10 +5188,17 @@ async function toExitCode(command, args, flags) {
2845
5188
  }
2846
5189
  function toArgumentList(value) {
2847
5190
  if (Array.isArray(value)) return value.map(String);
2848
- return value === void 0 ? [] : [String(value)];
5191
+ return value === void 0 ? [] : [toText(value)];
2849
5192
  }
2850
5193
  function collect(value, previous) {
2851
5194
  return [...previous ?? [], value];
2852
5195
  }
2853
5196
  //#endregion
5197
+ //#region src/cli.ts
5198
+ process.stdout.on("error", (error) => {
5199
+ if (error.code === "EPIPE") process.exit(0);
5200
+ throw error;
5201
+ });
5202
+ await toCli().parseAsync(process.argv);
5203
+ //#endregion
2854
5204
  export {};