@hardfin/cli 0.1.1-dev.56 → 0.1.1-dev.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -2
- package/dist/cli.js +4 -4
- package/dist/completion-tree.json +29 -0
- package/dist/{program-CI4W2agw.js → program-C2DP0mVy.js} +1037 -903
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
3
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="b26e6779-10c9-5732-b40c-04e75265338c")}catch(e){}}();
|
|
4
4
|
import { a as defineCommand, i as GLOBAL_FLAGS, r as ExitCode, t as toInvocation } from "./scrub-BZ0egWxC.js";
|
|
5
5
|
import { n as toAnswer$1, r as toCompletion, t as PATH_VALUES } from "./completion-tree-DLC6ARLR.js";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
@@ -1127,6 +1127,7 @@ async function request$1(options) {
|
|
|
1127
1127
|
const url = new URL(`${options.apiUrl}${toLeadingSlash(options.path)}`);
|
|
1128
1128
|
for (const [name, value] of options.query ?? []) url.searchParams.append(name, value);
|
|
1129
1129
|
const headers = {
|
|
1130
|
+
...options.headers,
|
|
1130
1131
|
[options.credential.header]: options.credential.value,
|
|
1131
1132
|
"X-API-Version": API_VERSION,
|
|
1132
1133
|
Accept: options.downloads ? "*/*" : "application/json"
|
|
@@ -1558,98 +1559,130 @@ function toJSONInput(source) {
|
|
|
1558
1559
|
return new InputFailure(`${name} does not hold JSON`);
|
|
1559
1560
|
}
|
|
1560
1561
|
}
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1562
|
+
//#endregion
|
|
1563
|
+
//#region src/command/operation.ts
|
|
1564
|
+
/**
|
|
1565
|
+
* toEnum takes a value in any case, as the API does, and answers the spelling the document
|
|
1566
|
+
* lists, which is what a response always uses.
|
|
1567
|
+
*/
|
|
1568
|
+
function toEnum(values) {
|
|
1569
|
+
return z.string().transform((value) => values.find((allowed) => allowed.toLowerCase() === value.toLowerCase()) ?? value).pipe(z.enum(values));
|
|
1570
|
+
}
|
|
1571
|
+
/** The word that clears a field the API takes null for, and the way to send that word as text. */
|
|
1572
|
+
const NULL_WORD = "null";
|
|
1573
|
+
/**
|
|
1574
|
+
* toNullOrText reads the word null before anything else. A field the API takes null for is
|
|
1575
|
+
* cleared by it, and "null" in quotes is the text. Any other value comes back as it was.
|
|
1576
|
+
*/
|
|
1577
|
+
function toNullOrText(value, isNullable) {
|
|
1578
|
+
if (value === "\"null\"") return NULL_WORD;
|
|
1579
|
+
return isNullable && value === "null" ? null : value;
|
|
1580
|
+
}
|
|
1581
|
+
const UNSET_FLAG = {
|
|
1582
|
+
name: "unset",
|
|
1583
|
+
description: "A field to clear, named as its flag is, repeatable",
|
|
1584
|
+
valueName: "field",
|
|
1585
|
+
repeatable: true,
|
|
1586
|
+
schema: z.array(z.string())
|
|
1587
|
+
};
|
|
1588
|
+
const INPUT_FLAG = {
|
|
1589
|
+
name: "input",
|
|
1590
|
+
description: "A file holding the JSON request body, or - for standard input. A flag given as well replaces its field",
|
|
1591
|
+
valueName: "path",
|
|
1592
|
+
schema: z.string()
|
|
1593
|
+
};
|
|
1594
|
+
const FILE_FLAG = {
|
|
1595
|
+
name: "file",
|
|
1596
|
+
description: "The file to upload",
|
|
1597
|
+
valueName: "path",
|
|
1598
|
+
schema: z.string()
|
|
1599
|
+
};
|
|
1600
|
+
const OUTPUT_FLAG = {
|
|
1601
|
+
name: "output",
|
|
1602
|
+
description: "Where to write the file, or - for standard output",
|
|
1603
|
+
valueName: "path",
|
|
1604
|
+
schema: z.string()
|
|
1605
|
+
};
|
|
1606
|
+
const JSON_FLAG$1 = {
|
|
1607
|
+
name: "json",
|
|
1608
|
+
description: "Print machine-readable output, which is the default when stdout is not a terminal",
|
|
1609
|
+
schema: z.boolean()
|
|
1610
|
+
};
|
|
1611
|
+
/** defineOperation turns one endpoint into the command that calls it. */
|
|
1612
|
+
function defineOperation(operation) {
|
|
1613
|
+
const flags = [
|
|
1614
|
+
...operation.queryFlags,
|
|
1615
|
+
...operation.bodyFlags,
|
|
1616
|
+
...operation.bodyFlags.some((flag) => flag.nullable) ? [UNSET_FLAG] : [],
|
|
1617
|
+
...operation.bodyFlags.length > 0 ? [INPUT_FLAG] : [],
|
|
1618
|
+
...operation.upload ? [...operation.upload.fields, FILE_FLAG] : [],
|
|
1619
|
+
...operation.downloads ? [OUTPUT_FLAG] : [],
|
|
1620
|
+
JSON_FLAG$1
|
|
1621
|
+
];
|
|
1622
|
+
return {
|
|
1623
|
+
name: operation.name,
|
|
1624
|
+
summary: operation.summary,
|
|
1625
|
+
description: operation.description ?? operation.summary,
|
|
1626
|
+
arguments: operation.pathParameters,
|
|
1627
|
+
flags,
|
|
1628
|
+
examples: operation.example ? [{
|
|
1629
|
+
description: operation.summary,
|
|
1630
|
+
command: operation.example
|
|
1631
|
+
}] : [],
|
|
1632
|
+
endpoint: {
|
|
1633
|
+
method: operation.method,
|
|
1634
|
+
path: operation.path
|
|
1613
1635
|
},
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
});
|
|
1621
|
-
async function runApi(input) {
|
|
1622
|
-
const path = input.args[0];
|
|
1623
|
-
if (!path) {
|
|
1624
|
-
writeFailure("invalid_arguments", "a path is required, such as /customer", input.isJSON);
|
|
1636
|
+
run: (input) => runOperation(operation, input)
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
async function runOperation(operation, input) {
|
|
1640
|
+
const path = toPath(operation, input.args);
|
|
1641
|
+
if (path === void 0) {
|
|
1642
|
+
writeFailure("invalid_arguments", `this command takes ${operation.pathParameters.length} argument(s), each the key of a record`, input.isJSON);
|
|
1625
1643
|
return ExitCode.USAGE;
|
|
1626
1644
|
}
|
|
1627
|
-
|
|
1628
|
-
|
|
1645
|
+
const given = toGivenBody$1(input.flags["input"]);
|
|
1646
|
+
if (given instanceof InputFailure) {
|
|
1647
|
+
writeFailure("invalid_input", given.message, input.isJSON);
|
|
1629
1648
|
return ExitCode.USAGE;
|
|
1630
1649
|
}
|
|
1631
|
-
const
|
|
1632
|
-
if (
|
|
1633
|
-
writeFailure("
|
|
1650
|
+
const missing = operation.bodyFlags.concat(operation.upload?.fields ?? []).filter((flag) => flag.required && input.flags[toOptionKey(flag.name)] === void 0 && !hasPath(given, flag.jsonPath));
|
|
1651
|
+
if (missing.length > 0) {
|
|
1652
|
+
writeFailure("missing_flag", `this command needs ${missing.map((flag) => `--${flag.name}`).join(", ")}`, input.isJSON);
|
|
1634
1653
|
return ExitCode.USAGE;
|
|
1635
1654
|
}
|
|
1636
|
-
|
|
1637
|
-
if (
|
|
1638
|
-
body
|
|
1639
|
-
|
|
1640
|
-
|
|
1655
|
+
const body = toBody(operation, input.flags, given);
|
|
1656
|
+
if (body instanceof Error) {
|
|
1657
|
+
writeFailure("invalid_flag", body.message, input.isJSON);
|
|
1658
|
+
return ExitCode.USAGE;
|
|
1659
|
+
}
|
|
1660
|
+
if (operation.bodyFlags.length > 0 && body === void 0) {
|
|
1661
|
+
writeFailure("empty_request", `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);
|
|
1662
|
+
return ExitCode.USAGE;
|
|
1663
|
+
}
|
|
1664
|
+
let form;
|
|
1665
|
+
if (operation.upload) {
|
|
1666
|
+
const built = await toForm$1(operation.upload, input.flags);
|
|
1667
|
+
if (built instanceof Error) {
|
|
1668
|
+
writeFailure("invalid_input", built.message, input.isJSON);
|
|
1641
1669
|
return ExitCode.USAGE;
|
|
1642
1670
|
}
|
|
1671
|
+
form = built;
|
|
1643
1672
|
}
|
|
1644
1673
|
try {
|
|
1645
|
-
|
|
1674
|
+
const envelope = await request$1({
|
|
1646
1675
|
apiUrl: input.resolved.settings.apiUrl,
|
|
1647
1676
|
credential: await toRequestCredential(input.resolved.settings, input.resolved.sources),
|
|
1648
|
-
method:
|
|
1677
|
+
method: operation.method,
|
|
1649
1678
|
path,
|
|
1650
|
-
query,
|
|
1651
|
-
body
|
|
1652
|
-
|
|
1679
|
+
query: toQuery$1(operation, input.flags),
|
|
1680
|
+
body,
|
|
1681
|
+
form,
|
|
1682
|
+
downloads: operation.downloads
|
|
1683
|
+
});
|
|
1684
|
+
if (operation.downloads) return toWritten(envelope.data, input);
|
|
1685
|
+
writeData(envelope.data);
|
|
1653
1686
|
return ExitCode.OK;
|
|
1654
1687
|
} catch (error) {
|
|
1655
1688
|
if (error instanceof NoCredential) {
|
|
@@ -1663,48 +1696,476 @@ async function runApi(input) {
|
|
|
1663
1696
|
throw error;
|
|
1664
1697
|
}
|
|
1665
1698
|
}
|
|
1666
|
-
/**
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1699
|
+
/**
|
|
1700
|
+
* toWritten puts a downloaded file where it was asked for. A terminal is never written to,
|
|
1701
|
+
* because a person would otherwise have their session filled with a file's bytes.
|
|
1702
|
+
*/
|
|
1703
|
+
function toWritten(download, input) {
|
|
1704
|
+
const asked = input.flags["output"];
|
|
1705
|
+
const offered = typeof asked === "string" ? void 0 : download.fileName;
|
|
1706
|
+
const path = typeof asked === "string" ? asked : offered === void 0 ? "-" : toLocalName(offered);
|
|
1707
|
+
if (path === void 0 || offered !== void 0 && existsSync(path)) {
|
|
1708
|
+
writeFailure("output_required", path === void 0 ? `this file is offered as ${JSON.stringify(offered)}, which is not a name to write here, so name where to write it with --output` : `${path} already exists, so name where to write this file with --output`, input.isJSON);
|
|
1709
|
+
return ExitCode.USAGE;
|
|
1710
|
+
}
|
|
1711
|
+
if (path === "-") {
|
|
1712
|
+
if (process.stdout.isTTY) {
|
|
1713
|
+
writeFailure("output_required", "this answer is a file, so name where to write it with --output, or send it on with a pipe", input.isJSON);
|
|
1714
|
+
return ExitCode.USAGE;
|
|
1715
|
+
}
|
|
1716
|
+
process.stdout.write(download.bytes);
|
|
1717
|
+
return ExitCode.OK;
|
|
1675
1718
|
}
|
|
1719
|
+
writeFileSync(path, download.bytes);
|
|
1720
|
+
writeData({
|
|
1721
|
+
written: path,
|
|
1722
|
+
bytes: download.bytes.length,
|
|
1723
|
+
contentType: download.contentType
|
|
1724
|
+
});
|
|
1725
|
+
return ExitCode.OK;
|
|
1676
1726
|
}
|
|
1677
|
-
/**
|
|
1678
|
-
function
|
|
1727
|
+
/** toLocalName reduces a name the API offered to one file in the working directory, or to nothing. */
|
|
1728
|
+
function toLocalName(offered) {
|
|
1729
|
+
const name = offered.split(/[\\/]/).pop() ?? "";
|
|
1730
|
+
return name === "" || name.startsWith(".") ? void 0 : name;
|
|
1731
|
+
}
|
|
1732
|
+
/** The arguments a URL reads as "this folder" and "the one above", and one that names nothing. */
|
|
1733
|
+
const NOT_A_KEY = /* @__PURE__ */ new Set([
|
|
1734
|
+
"",
|
|
1735
|
+
".",
|
|
1736
|
+
".."
|
|
1737
|
+
]);
|
|
1738
|
+
/** toPath fills the path template from the positional arguments, in order. */
|
|
1739
|
+
function toPath(operation, args) {
|
|
1740
|
+
if (args.length !== operation.pathParameters.length || args.some((argument) => NOT_A_KEY.has(argument))) return;
|
|
1741
|
+
let path = operation.path;
|
|
1742
|
+
for (const [index, parameter] of operation.pathParameters.entries()) path = path.replace(`{${parameter.name}}`, encodeURIComponent(args[index] ?? ""));
|
|
1743
|
+
return path;
|
|
1744
|
+
}
|
|
1745
|
+
/** toQuery carries only the flags this invocation actually set. */
|
|
1746
|
+
function toQuery$1(operation, flags) {
|
|
1679
1747
|
const query = new URLSearchParams();
|
|
1680
|
-
for (const
|
|
1681
|
-
const
|
|
1682
|
-
if (
|
|
1683
|
-
|
|
1748
|
+
for (const flag of operation.queryFlags) {
|
|
1749
|
+
const value = flags[toOptionKey(flag.name)];
|
|
1750
|
+
if (value === void 0) continue;
|
|
1751
|
+
const parsed = flag.schema.safeParse(value);
|
|
1752
|
+
const carried = parsed.success ? parsed.data : value;
|
|
1753
|
+
for (const entry of Array.isArray(carried) ? carried : [carried]) query.append(flag.queryName, toText(entry));
|
|
1684
1754
|
}
|
|
1685
1755
|
return query;
|
|
1686
1756
|
}
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
function isWSL() {
|
|
1691
|
-
return existsSync("/proc/sys/fs/binfmt_misc/WSLInterop");
|
|
1757
|
+
/** toOptionKey names the parsed flag, which the parser reports in camel case. */
|
|
1758
|
+
function toOptionKey(name) {
|
|
1759
|
+
return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
1692
1760
|
}
|
|
1693
|
-
/**
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
function toOpenable(url) {
|
|
1699
|
-
let parsed;
|
|
1761
|
+
/** toForm builds the file part and its fields, which an upload endpoint takes. */
|
|
1762
|
+
async function toForm$1(upload, flags) {
|
|
1763
|
+
const path = flags["file"];
|
|
1764
|
+
if (typeof path !== "string") return /* @__PURE__ */ new Error("this command needs --file, the file to upload");
|
|
1765
|
+
const form = new FormData();
|
|
1700
1766
|
try {
|
|
1701
|
-
|
|
1767
|
+
form.append(upload.filePart, await openAsBlob(path), basename(path));
|
|
1702
1768
|
} catch {
|
|
1703
|
-
return;
|
|
1769
|
+
return /* @__PURE__ */ new Error(`${path} cannot be read`);
|
|
1704
1770
|
}
|
|
1705
|
-
const
|
|
1706
|
-
|
|
1707
|
-
|
|
1771
|
+
for (const field of upload.fields) {
|
|
1772
|
+
const value = flags[toOptionKey(field.name)];
|
|
1773
|
+
if (value !== void 0) {
|
|
1774
|
+
const parsed = field.schema.safeParse(value);
|
|
1775
|
+
form.append(field.jsonPath[0] ?? field.name, toText(parsed.success ? parsed.data : value));
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
return form;
|
|
1779
|
+
}
|
|
1780
|
+
/** toGivenBody reads the --input body, which a generated command takes only as a JSON object. */
|
|
1781
|
+
function toGivenBody$1(source) {
|
|
1782
|
+
if (typeof source !== "string") return;
|
|
1783
|
+
const read = toJSONInput(source);
|
|
1784
|
+
if (read instanceof InputFailure) return read;
|
|
1785
|
+
if (typeof read !== "object" || read === null || Array.isArray(read)) return new InputFailure(`the body in ${source === "-" ? "standard input" : source} must be a JSON object, such as {"name": "Acme"}`);
|
|
1786
|
+
return read;
|
|
1787
|
+
}
|
|
1788
|
+
/** hasPath reports whether a body already holds a value at a field's path. */
|
|
1789
|
+
function hasPath(body, path) {
|
|
1790
|
+
let holder = body;
|
|
1791
|
+
for (const name of path) {
|
|
1792
|
+
if (typeof holder !== "object" || holder === null || !(name in holder)) return false;
|
|
1793
|
+
holder = holder[name];
|
|
1794
|
+
}
|
|
1795
|
+
return true;
|
|
1796
|
+
}
|
|
1797
|
+
/** toBody builds the request body from the --input body and the flags, a flag winning over its field. */
|
|
1798
|
+
function toBody(operation, flags, given) {
|
|
1799
|
+
const body = given ? structuredClone(given) : {};
|
|
1800
|
+
let hasField = given !== void 0;
|
|
1801
|
+
for (const flag of operation.bodyFlags) {
|
|
1802
|
+
const value = flags[toOptionKey(flag.name)];
|
|
1803
|
+
if (value === void 0) continue;
|
|
1804
|
+
if (flag.element) {
|
|
1805
|
+
const elements = toElements(flag, Array.isArray(value) ? value.map(toText) : [toText(value)]);
|
|
1806
|
+
if (elements instanceof Error) return elements;
|
|
1807
|
+
set(body, flag.jsonPath, elements);
|
|
1808
|
+
hasField = true;
|
|
1809
|
+
continue;
|
|
1810
|
+
}
|
|
1811
|
+
const given = toNullOrText(value, flag.nullable === true);
|
|
1812
|
+
if (given === null) {
|
|
1813
|
+
set(body, flag.jsonPath, null);
|
|
1814
|
+
hasField = true;
|
|
1815
|
+
continue;
|
|
1816
|
+
}
|
|
1817
|
+
const parsed = flag.schema.safeParse(given);
|
|
1818
|
+
set(body, flag.jsonPath, parsed.success ? parsed.data : given);
|
|
1819
|
+
hasField = true;
|
|
1820
|
+
}
|
|
1821
|
+
const cleared = toCleared(operation, flags, body);
|
|
1822
|
+
if (cleared instanceof Error) return cleared;
|
|
1823
|
+
return hasField || cleared ? body : void 0;
|
|
1824
|
+
}
|
|
1825
|
+
/** toCleared sends null for each field named by --unset, which is how a field is cleared. */
|
|
1826
|
+
function toCleared(operation, flags, body) {
|
|
1827
|
+
const named = flags["unset"];
|
|
1828
|
+
const names = Array.isArray(named) ? named.map(toText) : named === void 0 ? [] : [toText(named)];
|
|
1829
|
+
for (const name of names) {
|
|
1830
|
+
const flag = operation.bodyFlags.find((entry) => entry.name === name.replace(/^--/, ""));
|
|
1831
|
+
if (!flag) return /* @__PURE__ */ new Error(`--unset names no field called ${name}`);
|
|
1832
|
+
if (!flag.nullable) return /* @__PURE__ */ new Error(`--${flag.name} cannot be cleared, because the API does not accept null for it`);
|
|
1833
|
+
set(body, flag.jsonPath, null);
|
|
1834
|
+
}
|
|
1835
|
+
return names.length > 0;
|
|
1836
|
+
}
|
|
1837
|
+
/** toElements reads the key=value pairs a repeated flag carries for one array element. */
|
|
1838
|
+
function toElements(flag, values) {
|
|
1839
|
+
const elements = [];
|
|
1840
|
+
for (const value of values) {
|
|
1841
|
+
const element = {};
|
|
1842
|
+
for (const pair of value.split(",")) {
|
|
1843
|
+
const split = pair.indexOf("=");
|
|
1844
|
+
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`);
|
|
1845
|
+
const isJSON = pair[split - 1] === ":";
|
|
1846
|
+
const key = pair.slice(0, isJSON ? split - 1 : split).trim();
|
|
1847
|
+
if (flag.element && !flag.element.includes(key)) return /* @__PURE__ */ new Error(`--${flag.name} has no field ${key}. It takes ${flag.element.join(", ")}`);
|
|
1848
|
+
if (key in element) return /* @__PURE__ */ new Error(`--${flag.name} names ${key} twice in one element, so which one it takes is not clear`);
|
|
1849
|
+
const value = pair.slice(split + 1).trim();
|
|
1850
|
+
if (flag.elementUuids?.includes(key) && !z.uuid().safeParse(value).success) return /* @__PURE__ */ new Error(`--${flag.name} needs a UUID for ${key}, such as 3f2a9c1e-5b7d-4e8a-9c21-7d4e5f6a8b90, and ${value || "nothing"} is not one`);
|
|
1851
|
+
const types = flag.elementTypes?.[key];
|
|
1852
|
+
const typed = isJSON ? toJSONValue(value, types ?? ["string"]) : toTyped(value, types);
|
|
1853
|
+
if (typed === NOT_TAKEN) return /* @__PURE__ */ new Error(`--${flag.name} needs ${toNamedTypes(types ?? ["string"])} for ${key}, and ${value || "nothing"} is not one`);
|
|
1854
|
+
element[key] = typed;
|
|
1855
|
+
}
|
|
1856
|
+
elements.push(element);
|
|
1857
|
+
}
|
|
1858
|
+
return elements;
|
|
1859
|
+
}
|
|
1860
|
+
/** Marks text that is none of the types a field takes. */
|
|
1861
|
+
const NOT_TAKEN = Symbol("not taken");
|
|
1862
|
+
const NUMBER = /^-?\d+(\.\d+)?$/;
|
|
1863
|
+
/**
|
|
1864
|
+
* toTyped reads the text after key= as the type the document gives the field. A field that
|
|
1865
|
+
* takes text is sent as typed, whatever it looks like, because 36 may be a serial number. A
|
|
1866
|
+
* field that takes no text has only one reading, so order=2 is sent as the number.
|
|
1867
|
+
*/
|
|
1868
|
+
function toTyped(text, types) {
|
|
1869
|
+
const given = toNullOrText(text, types?.includes("null") === true);
|
|
1870
|
+
if (given === null || given !== text) return given;
|
|
1871
|
+
if (types === void 0 || types.includes("string")) return text;
|
|
1872
|
+
if ((types.includes("integer") || types.includes("number")) && NUMBER.test(text)) {
|
|
1873
|
+
const value = Number(text);
|
|
1874
|
+
if (types.includes("number") || Number.isInteger(value)) return value;
|
|
1875
|
+
}
|
|
1876
|
+
if (types.includes("boolean") && (text === "true" || text === "false")) return text === "true";
|
|
1877
|
+
return NOT_TAKEN;
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1880
|
+
* toJSONValue reads the text after key:= as JSON, and takes it when the field takes its type.
|
|
1881
|
+
* It is how a custom field is given the number 36 or true, where key= would send the text.
|
|
1882
|
+
*/
|
|
1883
|
+
function toJSONValue(text, types) {
|
|
1884
|
+
let value;
|
|
1885
|
+
try {
|
|
1886
|
+
value = JSON.parse(text);
|
|
1887
|
+
} catch {
|
|
1888
|
+
return NOT_TAKEN;
|
|
1889
|
+
}
|
|
1890
|
+
const type = value === null ? "null" : typeof value;
|
|
1891
|
+
return (types.includes(type) || type === "number" && types.includes("integer") && Number.isInteger(value)) && type !== "object" ? value : NOT_TAKEN;
|
|
1892
|
+
}
|
|
1893
|
+
/** toNamedTypes says in words what a field takes, for the message that refuses a value. */
|
|
1894
|
+
function toNamedTypes(types) {
|
|
1895
|
+
const named = {
|
|
1896
|
+
integer: "a whole number",
|
|
1897
|
+
number: "a number",
|
|
1898
|
+
boolean: "true or false",
|
|
1899
|
+
string: "text in quotes",
|
|
1900
|
+
null: "null"
|
|
1901
|
+
};
|
|
1902
|
+
return types.filter((type) => type in named).map((type) => named[type]).join(" or ");
|
|
1903
|
+
}
|
|
1904
|
+
/** set writes a value at its path, building the objects a nested field needs. */
|
|
1905
|
+
function set(body, path, value) {
|
|
1906
|
+
let holder = body;
|
|
1907
|
+
for (const name of path.slice(0, -1)) {
|
|
1908
|
+
holder[name] = holder[name] ?? {};
|
|
1909
|
+
holder = holder[name];
|
|
1910
|
+
}
|
|
1911
|
+
holder[path[path.length - 1] ?? ""] = value;
|
|
1912
|
+
}
|
|
1913
|
+
//#endregion
|
|
1914
|
+
//#region src/command/api.ts
|
|
1915
|
+
const METHODS = [
|
|
1916
|
+
"GET",
|
|
1917
|
+
"POST",
|
|
1918
|
+
"PATCH",
|
|
1919
|
+
"PUT",
|
|
1920
|
+
"DELETE"
|
|
1921
|
+
];
|
|
1922
|
+
/** The part name the API's upload endpoints take a file under. */
|
|
1923
|
+
const DEFAULT_FILE_PART = "data";
|
|
1924
|
+
/** The headers that carry the credential and the version, which the CLI sets and a caller does not. */
|
|
1925
|
+
const KEPT_HEADERS = /* @__PURE__ */ new Set([
|
|
1926
|
+
"authorization",
|
|
1927
|
+
"x-api-key",
|
|
1928
|
+
"x-api-version",
|
|
1929
|
+
"host",
|
|
1930
|
+
"content-length"
|
|
1931
|
+
]);
|
|
1932
|
+
const apiCommand = defineCommand({
|
|
1933
|
+
name: "api",
|
|
1934
|
+
summary: "Call a Hardfin API endpoint and print what it answers",
|
|
1935
|
+
description: "Reaches every endpoint the API publishes, for when no generated command fits. The path is everything after /v2, and what the endpoint answered with is printed. A file is uploaded with --file and downloaded with --output.",
|
|
1936
|
+
arguments: [{
|
|
1937
|
+
name: "path",
|
|
1938
|
+
description: "The endpoint path, such as /customer or /item/item_V1StGXR8Z5jdHi6B",
|
|
1939
|
+
required: true
|
|
1940
|
+
}],
|
|
1941
|
+
flags: [
|
|
1942
|
+
{
|
|
1943
|
+
name: "method",
|
|
1944
|
+
short: "X",
|
|
1945
|
+
description: "The HTTP method to use",
|
|
1946
|
+
valueName: "method",
|
|
1947
|
+
schema: z.enum(METHODS),
|
|
1948
|
+
defaultValue: "GET"
|
|
1949
|
+
},
|
|
1950
|
+
{
|
|
1951
|
+
name: "field",
|
|
1952
|
+
short: "f",
|
|
1953
|
+
description: "A query parameter as key=value, repeatable",
|
|
1954
|
+
valueName: "key=value",
|
|
1955
|
+
repeatable: true,
|
|
1956
|
+
schema: z.array(z.string()).default([])
|
|
1957
|
+
},
|
|
1958
|
+
{
|
|
1959
|
+
name: "input",
|
|
1960
|
+
description: "A file holding the JSON request body, or - for stdin",
|
|
1961
|
+
valueName: "file",
|
|
1962
|
+
schema: z.string().optional()
|
|
1963
|
+
},
|
|
1964
|
+
{
|
|
1965
|
+
name: "file",
|
|
1966
|
+
description: "A file to upload as multipart, in place of a JSON body",
|
|
1967
|
+
valueName: "path",
|
|
1968
|
+
schema: z.string().optional()
|
|
1969
|
+
},
|
|
1970
|
+
{
|
|
1971
|
+
name: "file-part",
|
|
1972
|
+
description: `The name of the part the file is sent as, which is ${DEFAULT_FILE_PART} when not given`,
|
|
1973
|
+
valueName: "name",
|
|
1974
|
+
schema: z.string().optional()
|
|
1975
|
+
},
|
|
1976
|
+
{
|
|
1977
|
+
name: "form",
|
|
1978
|
+
short: "F",
|
|
1979
|
+
description: "A field sent beside the file as key=value, repeatable",
|
|
1980
|
+
valueName: "key=value",
|
|
1981
|
+
repeatable: true,
|
|
1982
|
+
schema: z.array(z.string()).default([])
|
|
1983
|
+
},
|
|
1984
|
+
{
|
|
1985
|
+
name: "header",
|
|
1986
|
+
short: "H",
|
|
1987
|
+
description: "A request header as Name: value, repeatable",
|
|
1988
|
+
valueName: "name: value",
|
|
1989
|
+
repeatable: true,
|
|
1990
|
+
schema: z.array(z.string()).default([])
|
|
1991
|
+
},
|
|
1992
|
+
{
|
|
1993
|
+
name: "output",
|
|
1994
|
+
description: "Where to write a file the endpoint answers with, or - for standard output",
|
|
1995
|
+
valueName: "path",
|
|
1996
|
+
schema: z.string().optional()
|
|
1997
|
+
},
|
|
1998
|
+
{
|
|
1999
|
+
name: "json",
|
|
2000
|
+
description: "Print machine-readable output, which is the default when stdout is not a terminal",
|
|
2001
|
+
schema: z.boolean().optional()
|
|
2002
|
+
}
|
|
2003
|
+
],
|
|
2004
|
+
examples: [
|
|
2005
|
+
{
|
|
2006
|
+
description: "List customers",
|
|
2007
|
+
command: "hardfin api /customer"
|
|
2008
|
+
},
|
|
2009
|
+
{
|
|
2010
|
+
description: "Take the second page",
|
|
2011
|
+
command: "hardfin api /customer -f page=2 -f limit=50"
|
|
2012
|
+
},
|
|
2013
|
+
{
|
|
2014
|
+
description: "Change an item",
|
|
2015
|
+
command: "hardfin api -X PATCH /item/item_V1StGXR8Z5jdHi6B --input body.json"
|
|
2016
|
+
},
|
|
2017
|
+
{
|
|
2018
|
+
description: "Download a file",
|
|
2019
|
+
command: "hardfin api /file/file_7hq2mx9pkr4stz8w --output photo.jpg"
|
|
2020
|
+
},
|
|
2021
|
+
{
|
|
2022
|
+
description: "Upload a file, with the fields the endpoint takes beside it",
|
|
2023
|
+
command: "hardfin api -X POST /file --file photo.jpg -F fileType=ASSET_FILE -F forEntity=0192f7d4-3b6a-7c1e-9f2d-5a8b4c6e7d70"
|
|
2024
|
+
},
|
|
2025
|
+
{
|
|
2026
|
+
description: "Send a header",
|
|
2027
|
+
command: "hardfin api /file/file_7hq2mx9pkr4stz8w -H \"If-None-Match: \\\"abc\\\"\" --output photo.jpg"
|
|
2028
|
+
}
|
|
2029
|
+
],
|
|
2030
|
+
run: runApi
|
|
2031
|
+
});
|
|
2032
|
+
async function runApi(input) {
|
|
2033
|
+
const path = input.args[0];
|
|
2034
|
+
if (!path) {
|
|
2035
|
+
writeFailure("invalid_arguments", "a path is required, such as /customer", input.isJSON);
|
|
2036
|
+
return ExitCode.USAGE;
|
|
2037
|
+
}
|
|
2038
|
+
if (!isInsideApi(input.resolved.settings.apiUrl, path)) {
|
|
2039
|
+
writeFailure("invalid_arguments", "a path stays inside the API, so it cannot climb out of it with ..", input.isJSON);
|
|
2040
|
+
return ExitCode.USAGE;
|
|
2041
|
+
}
|
|
2042
|
+
const query = toQuery(input.flags["field"]);
|
|
2043
|
+
if (query === void 0) {
|
|
2044
|
+
writeFailure("invalid_flag", "each --field is key=value, such as -f limit=50", input.isJSON);
|
|
2045
|
+
return ExitCode.USAGE;
|
|
2046
|
+
}
|
|
2047
|
+
const headers = toHeaders(input.flags["header"]);
|
|
2048
|
+
if (headers instanceof Error) {
|
|
2049
|
+
writeFailure("invalid_flag", headers.message, input.isJSON);
|
|
2050
|
+
return ExitCode.USAGE;
|
|
2051
|
+
}
|
|
2052
|
+
const method = toText(input.flags["method"] ?? "GET").toUpperCase();
|
|
2053
|
+
const isUpload = typeof input.flags["file"] === "string";
|
|
2054
|
+
if (isUpload && (typeof input.flags["input"] === "string" || method === "GET")) {
|
|
2055
|
+
writeFailure("invalid_flag", method === "GET" ? "--file uploads a file, so give the method with -X, such as -X POST" : "--file and --input both give the body, so give one of them", input.isJSON);
|
|
2056
|
+
return ExitCode.USAGE;
|
|
2057
|
+
}
|
|
2058
|
+
let body;
|
|
2059
|
+
if (typeof input.flags["input"] === "string") {
|
|
2060
|
+
body = toJSONInput(input.flags["input"]);
|
|
2061
|
+
if (body instanceof InputFailure) {
|
|
2062
|
+
writeFailure("invalid_input", body.message, input.isJSON);
|
|
2063
|
+
return ExitCode.USAGE;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
const form = isUpload ? await toForm(input.flags) : void 0;
|
|
2067
|
+
if (form instanceof Error) {
|
|
2068
|
+
writeFailure("invalid_input", form.message, input.isJSON);
|
|
2069
|
+
return ExitCode.USAGE;
|
|
2070
|
+
}
|
|
2071
|
+
const isDownload = typeof input.flags["output"] === "string";
|
|
2072
|
+
try {
|
|
2073
|
+
const envelope = await request$1({
|
|
2074
|
+
apiUrl: input.resolved.settings.apiUrl,
|
|
2075
|
+
credential: await toRequestCredential(input.resolved.settings, input.resolved.sources),
|
|
2076
|
+
method,
|
|
2077
|
+
path,
|
|
2078
|
+
query,
|
|
2079
|
+
body,
|
|
2080
|
+
form,
|
|
2081
|
+
headers,
|
|
2082
|
+
downloads: isDownload
|
|
2083
|
+
});
|
|
2084
|
+
if (isDownload) return toWritten(envelope.data, input);
|
|
2085
|
+
writeData(envelope.data);
|
|
2086
|
+
return ExitCode.OK;
|
|
2087
|
+
} catch (error) {
|
|
2088
|
+
if (error instanceof NoCredential) {
|
|
2089
|
+
writeFailure("not_signed_in", error.message, input.isJSON);
|
|
2090
|
+
return ExitCode.NOT_AUTHENTICATED;
|
|
2091
|
+
}
|
|
2092
|
+
if (error instanceof RequestFailure) {
|
|
2093
|
+
writeFailure(toRequestCode(error), error.message, input.isJSON, error.errors, error.requestId, error.status);
|
|
2094
|
+
return error.status === 401 ? ExitCode.NOT_AUTHENTICATED : ExitCode.ERROR;
|
|
2095
|
+
}
|
|
2096
|
+
throw error;
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
/** isInsideApi reports whether a path, once parsed, still lands under the API's own path. */
|
|
2100
|
+
function isInsideApi(apiUrl, path) {
|
|
2101
|
+
try {
|
|
2102
|
+
const base = new URL(apiUrl);
|
|
2103
|
+
const target = new URL(`${apiUrl}${path.startsWith("/") ? "" : "/"}${path}`);
|
|
2104
|
+
const root = base.pathname.replace(/\/+$/, "");
|
|
2105
|
+
return target.origin === base.origin && (target.pathname === root || target.pathname.startsWith(`${root}/`));
|
|
2106
|
+
} catch {
|
|
2107
|
+
return false;
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
/** toHeaders reads the repeated --header flags, and refuses one the CLI sets itself. */
|
|
2111
|
+
function toHeaders(given) {
|
|
2112
|
+
const headers = {};
|
|
2113
|
+
for (const header of Array.isArray(given) ? given : []) {
|
|
2114
|
+
const split = header.indexOf(":");
|
|
2115
|
+
const name = header.slice(0, Math.max(split, 0)).trim();
|
|
2116
|
+
if (split < 1 || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) return /* @__PURE__ */ new Error(`--header is Name: value, such as -H "If-None-Match: abc", and ${JSON.stringify(header)} is not`);
|
|
2117
|
+
if (KEPT_HEADERS.has(name.toLowerCase())) return /* @__PURE__ */ new Error(`--header cannot set ${name}, because the CLI sets it from your sign in and settings`);
|
|
2118
|
+
headers[name] = header.slice(split + 1).trim();
|
|
2119
|
+
}
|
|
2120
|
+
return headers;
|
|
2121
|
+
}
|
|
2122
|
+
/** toForm builds the multipart body an upload sends: the file, and the fields given beside it. */
|
|
2123
|
+
async function toForm(flags) {
|
|
2124
|
+
const path = toText(flags["file"]);
|
|
2125
|
+
const form = new FormData();
|
|
2126
|
+
try {
|
|
2127
|
+
form.append(toText(flags["filePart"] ?? DEFAULT_FILE_PART), await openAsBlob(path), basename(path));
|
|
2128
|
+
} catch {
|
|
2129
|
+
return /* @__PURE__ */ new Error(`${path} cannot be read`);
|
|
2130
|
+
}
|
|
2131
|
+
for (const field of Array.isArray(flags["form"]) ? flags["form"] : []) {
|
|
2132
|
+
const split = field.indexOf("=");
|
|
2133
|
+
if (split < 1) return /* @__PURE__ */ new Error(`each --form is key=value, such as -F fileType=ASSET_FILE, and ${JSON.stringify(field)} is not`);
|
|
2134
|
+
form.append(field.slice(0, split), field.slice(split + 1));
|
|
2135
|
+
}
|
|
2136
|
+
return form;
|
|
2137
|
+
}
|
|
2138
|
+
/** toQuery folds the repeated --field flags into query parameters. */
|
|
2139
|
+
function toQuery(fields) {
|
|
2140
|
+
const query = new URLSearchParams();
|
|
2141
|
+
for (const field of Array.isArray(fields) ? fields : []) {
|
|
2142
|
+
const split = field.indexOf("=");
|
|
2143
|
+
if (split < 1) return;
|
|
2144
|
+
query.append(field.slice(0, split), field.slice(split + 1));
|
|
2145
|
+
}
|
|
2146
|
+
return query;
|
|
2147
|
+
}
|
|
2148
|
+
//#endregion
|
|
2149
|
+
//#region src/auth/browser.ts
|
|
2150
|
+
/** isWSL reports whether this Linux is running under Windows. */
|
|
2151
|
+
function isWSL() {
|
|
2152
|
+
return existsSync("/proc/sys/fs/binfmt_misc/WSLInterop");
|
|
2153
|
+
}
|
|
2154
|
+
/**
|
|
2155
|
+
* toOpenable answers the address a browser may be sent to, and nothing for anything else. The
|
|
2156
|
+
* address comes from the authorization server, which a file in the working directory can
|
|
2157
|
+
* name, so it is parsed and written out again before any program is handed it.
|
|
2158
|
+
*/
|
|
2159
|
+
function toOpenable(url) {
|
|
2160
|
+
let parsed;
|
|
2161
|
+
try {
|
|
2162
|
+
parsed = new URL(url);
|
|
2163
|
+
} catch {
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
const isLocal = parsed.protocol === "http:" && LOOPBACK_HOSTS.has(parsed.hostname);
|
|
2167
|
+
return parsed.protocol === "https:" || isLocal ? parsed.href : void 0;
|
|
2168
|
+
}
|
|
1708
2169
|
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
1709
2170
|
"127.0.0.1",
|
|
1710
2171
|
"[::1]",
|
|
@@ -1933,7 +2394,7 @@ const YES_FLAG = {
|
|
|
1933
2394
|
description: "Make the change without asking",
|
|
1934
2395
|
schema: z.boolean()
|
|
1935
2396
|
};
|
|
1936
|
-
const JSON_FLAG
|
|
2397
|
+
const JSON_FLAG = {
|
|
1937
2398
|
name: "json",
|
|
1938
2399
|
description: "Print machine-readable output, which is the default when stdout is not a terminal",
|
|
1939
2400
|
schema: z.boolean()
|
|
@@ -1943,7 +2404,7 @@ const installCommand = defineCommand({
|
|
|
1943
2404
|
summary: "Set up completion in your shell",
|
|
1944
2405
|
description: "Writes the completion script under your data directory and loads it from your shell's startup file, between two marked lines. Fish loads it from its completions directory, so no startup file changes. Run it again after upgrading, so the script matches the CLI.",
|
|
1945
2406
|
arguments: [SHELL_ARGUMENT],
|
|
1946
|
-
flags: [YES_FLAG, JSON_FLAG
|
|
2407
|
+
flags: [YES_FLAG, JSON_FLAG],
|
|
1947
2408
|
examples: [{
|
|
1948
2409
|
description: "Install for the shell you are using",
|
|
1949
2410
|
command: "hardfin completion install"
|
|
@@ -1958,7 +2419,7 @@ const uninstallCommand = defineCommand({
|
|
|
1958
2419
|
summary: "Remove completion from your shell",
|
|
1959
2420
|
description: "Removes the script install wrote, and the marked lines it added to your shell's startup file.",
|
|
1960
2421
|
arguments: [SHELL_ARGUMENT],
|
|
1961
|
-
flags: [YES_FLAG, JSON_FLAG
|
|
2422
|
+
flags: [YES_FLAG, JSON_FLAG],
|
|
1962
2423
|
examples: [{
|
|
1963
2424
|
description: "Remove completion from zsh",
|
|
1964
2425
|
command: "hardfin completion uninstall zsh"
|
|
@@ -2592,837 +3053,503 @@ async function toTokensFromDevice(tokenUrl, clientId, device, sleep = toSleep) {
|
|
|
2592
3053
|
try {
|
|
2593
3054
|
return await toTokens(tokenUrl, {
|
|
2594
3055
|
grant_type: DEVICE_GRANT,
|
|
2595
|
-
client_id: clientId,
|
|
2596
|
-
device_code: device.deviceCode
|
|
2597
|
-
});
|
|
2598
|
-
} catch (error) {
|
|
2599
|
-
if (!(error instanceof GrantFailure)) throw error;
|
|
2600
|
-
if (error.code === "slow_down") {
|
|
2601
|
-
intervalMs += SLOW_DOWN_SECONDS * 1e3;
|
|
2602
|
-
continue;
|
|
2603
|
-
}
|
|
2604
|
-
if (error.code !== "authorization_pending") throw error;
|
|
2605
|
-
}
|
|
2606
|
-
}
|
|
2607
|
-
}
|
|
2608
|
-
function toSleep(ms) {
|
|
2609
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2610
|
-
}
|
|
2611
|
-
//#endregion
|
|
2612
|
-
//#region src/auth/loopback.ts
|
|
2613
|
-
/** The path the browser is sent back to, which the client metadata document registers. */
|
|
2614
|
-
const CALLBACK_PATH = "/callback";
|
|
2615
|
-
const HOST = "127.0.0.1";
|
|
2616
|
-
/**
|
|
2617
|
-
* toListener opens a loopback server for one authorization response. The port is whatever
|
|
2618
|
-
* the machine hands out, which is why the server matches a loopback redirect by everything
|
|
2619
|
-
* except its port.
|
|
2620
|
-
*/
|
|
2621
|
-
async function toListener(timeoutMs, state) {
|
|
2622
|
-
let settle = () => {};
|
|
2623
|
-
let fail = () => {};
|
|
2624
|
-
const callback = new Promise((resolve, reject) => {
|
|
2625
|
-
settle = resolve;
|
|
2626
|
-
fail = reject;
|
|
2627
|
-
});
|
|
2628
|
-
const server = createServer((request, response) => {
|
|
2629
|
-
const answered = toCallback(request);
|
|
2630
|
-
if (answered === void 0) {
|
|
2631
|
-
response.writeHead(404).end();
|
|
2632
|
-
return;
|
|
2633
|
-
}
|
|
2634
|
-
if (!isOurs(answered, state)) {
|
|
2635
|
-
writePage(response, 400, "Not this sign in", "This page did not come from the sign in your terminal started.");
|
|
2636
|
-
return;
|
|
2637
|
-
}
|
|
2638
|
-
writeOutcome(response, answered);
|
|
2639
|
-
settle(answered);
|
|
2640
|
-
});
|
|
2641
|
-
await new Promise((resolve, reject) => {
|
|
2642
|
-
server.once("error", reject);
|
|
2643
|
-
server.listen(0, HOST, resolve);
|
|
2644
|
-
});
|
|
2645
|
-
const timer = setTimeout(() => {
|
|
2646
|
-
fail(new TimedOut("the browser did not come back in time"));
|
|
2647
|
-
close(server, timer);
|
|
2648
|
-
}, timeoutMs);
|
|
2649
|
-
return {
|
|
2650
|
-
redirectUri: `http://${HOST}:${server.address().port}${CALLBACK_PATH}`,
|
|
2651
|
-
callback: callback.finally(() => {
|
|
2652
|
-
close(server, timer);
|
|
2653
|
-
}),
|
|
2654
|
-
close: () => {
|
|
2655
|
-
close(server, timer);
|
|
2656
|
-
}
|
|
2657
|
-
};
|
|
2658
|
-
}
|
|
2659
|
-
function toCallback(request) {
|
|
2660
|
-
const url = new URL(request.url ?? "/", `http://${HOST}`);
|
|
2661
|
-
if (url.pathname !== CALLBACK_PATH) return;
|
|
2662
|
-
return {
|
|
2663
|
-
source: "listener",
|
|
2664
|
-
code: url.searchParams.get("code") ?? void 0,
|
|
2665
|
-
state: url.searchParams.get("state") ?? void 0,
|
|
2666
|
-
issuer: url.searchParams.get("iss") ?? void 0,
|
|
2667
|
-
error: url.searchParams.get("error") ?? void 0,
|
|
2668
|
-
errorDescription: url.searchParams.get("error_description") ?? void 0
|
|
2669
|
-
};
|
|
2670
|
-
}
|
|
2671
|
-
/**
|
|
2672
|
-
* isOurs answers whether a response belongs to this sign in. A refusal with no state at all
|
|
2673
|
-
* is still taken, because not every authorization server sends the state back with one.
|
|
2674
|
-
*/
|
|
2675
|
-
function isOurs(callback, state) {
|
|
2676
|
-
if (callback.state !== void 0) return callback.state === state;
|
|
2677
|
-
return callback.code === void 0 && callback.error !== void 0;
|
|
2678
|
-
}
|
|
2679
|
-
function writeOutcome(response, callback) {
|
|
2680
|
-
if (!callback.error) {
|
|
2681
|
-
writePage(response, 200, "Signed in", "You can close this tab and return to your terminal.");
|
|
2682
|
-
return;
|
|
2683
|
-
}
|
|
2684
|
-
writePage(response, 200, "Sign in refused", `${callback.error}${callback.errorDescription ? `: ${callback.errorDescription}` : ""}`);
|
|
2685
|
-
}
|
|
2686
|
-
/** toEscaped makes text safe to put in a page, because the words of a refusal come from the URL. */
|
|
2687
|
-
function toEscaped(text) {
|
|
2688
|
-
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
2689
|
-
}
|
|
2690
|
-
function writePage(response, status, heading, words) {
|
|
2691
|
-
const title = toEscaped(heading);
|
|
2692
|
-
const detail = toEscaped(words);
|
|
2693
|
-
response.writeHead(status, {
|
|
2694
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
2695
|
-
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
|
|
2696
|
-
"X-Content-Type-Options": "nosniff"
|
|
2697
|
-
}).end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title></head><body style="font-family: system-ui, sans-serif; padding: 3rem; max-width: 34rem"><h1 style="font-size: 1.25rem">${title}</h1><p>${detail}</p></body></html>`);
|
|
2698
|
-
}
|
|
2699
|
-
function close(server, timer) {
|
|
2700
|
-
clearTimeout(timer);
|
|
2701
|
-
server.close();
|
|
2702
|
-
server.closeAllConnections();
|
|
2703
|
-
}
|
|
2704
|
-
//#endregion
|
|
2705
|
-
//#region src/auth/pkce.ts
|
|
2706
|
-
/** toPkce makes a fresh verifier and the challenge derived from it. */
|
|
2707
|
-
function toPkce() {
|
|
2708
|
-
const verifier = toUrlSafe(randomBytes(32));
|
|
2709
|
-
return {
|
|
2710
|
-
verifier,
|
|
2711
|
-
challenge: toUrlSafe(createHash("sha256").update(verifier).digest()),
|
|
2712
|
-
method: "S256"
|
|
2713
|
-
};
|
|
2714
|
-
}
|
|
2715
|
-
/** toState makes the value that ties a callback to the request that started it. */
|
|
2716
|
-
function toState() {
|
|
2717
|
-
return toUrlSafe(randomBytes(16));
|
|
2718
|
-
}
|
|
2719
|
-
function toUrlSafe(bytes) {
|
|
2720
|
-
return bytes.toString("base64url");
|
|
2721
|
-
}
|
|
2722
|
-
//#endregion
|
|
2723
|
-
//#region src/auth/clipboard.ts
|
|
2724
|
-
/** toClipboardCommand names how this host takes text onto the clipboard. */
|
|
2725
|
-
function toClipboardCommand() {
|
|
2726
|
-
if (process.platform === "darwin") return ["pbcopy", []];
|
|
2727
|
-
if (process.platform === "win32") return ["clip", []];
|
|
2728
|
-
if (existsSync("/proc/sys/fs/binfmt_misc/WSLInterop")) return ["clip.exe", []];
|
|
2729
|
-
if (process.env["WAYLAND_DISPLAY"]) return ["wl-copy", []];
|
|
2730
|
-
return ["xclip", ["-selection", "clipboard"]];
|
|
2731
|
-
}
|
|
2732
|
-
/** copyToClipboard puts text on the clipboard, and reports whether anything took it. */
|
|
2733
|
-
function copyToClipboard(text, copy = spawnSync) {
|
|
2734
|
-
const command = toClipboardCommand();
|
|
2735
|
-
if (!command) return false;
|
|
2736
|
-
const [name, args] = command;
|
|
2737
|
-
const result = copy(name, args, { input: text });
|
|
2738
|
-
return result.error === void 0 && result.status === 0;
|
|
2739
|
-
}
|
|
2740
|
-
//#endregion
|
|
2741
|
-
//#region src/auth/prompt.ts
|
|
2742
|
-
const CTRL_C = "";
|
|
2743
|
-
const CTRL_BACKSLASH = "";
|
|
2744
|
-
const BACKSPACE = "";
|
|
2745
|
-
/**
|
|
2746
|
-
* toPastedCallback reads what a person pasted. A browser that could not reach the listener
|
|
2747
|
-
* leaves the whole redirect in the address bar, and a page that shows the code leaves
|
|
2748
|
-
* `code#state`, so both are accepted alongside a bare code.
|
|
2749
|
-
*/
|
|
2750
|
-
function toPastedCallback(pasted) {
|
|
2751
|
-
const value = pasted.trim();
|
|
2752
|
-
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
2753
|
-
const url = new URL(value);
|
|
2754
|
-
return {
|
|
2755
|
-
source: "pasted",
|
|
2756
|
-
code: url.searchParams.get("code") ?? void 0,
|
|
2757
|
-
state: url.searchParams.get("state") ?? void 0,
|
|
2758
|
-
issuer: url.searchParams.get("iss") ?? void 0,
|
|
2759
|
-
error: url.searchParams.get("error") ?? void 0,
|
|
2760
|
-
errorDescription: url.searchParams.get("error_description") ?? void 0
|
|
2761
|
-
};
|
|
2762
|
-
}
|
|
2763
|
-
const [code, state] = value.split("#");
|
|
2764
|
-
return {
|
|
2765
|
-
source: "pasted",
|
|
2766
|
-
code: code || void 0,
|
|
2767
|
-
state: state || void 0
|
|
2768
|
-
};
|
|
2769
|
-
}
|
|
2770
|
-
/**
|
|
2771
|
-
* toPrompt watches the keyboard while the browser is away. Pressing c copies the URL, and
|
|
2772
|
-
* pasting a code finishes the sign in on a machine whose browser cannot reach this listener.
|
|
2773
|
-
*/
|
|
2774
|
-
function toPrompt(url, input = process.stdin, copy = copyToClipboard) {
|
|
2775
|
-
let settle = () => {};
|
|
2776
|
-
let fail = () => {};
|
|
2777
|
-
const pasted = new Promise((resolve, reject) => {
|
|
2778
|
-
settle = resolve;
|
|
2779
|
-
fail = reject;
|
|
2780
|
-
});
|
|
2781
|
-
pasted.catch(() => {});
|
|
2782
|
-
if (!input.isTTY) return {
|
|
2783
|
-
pasted,
|
|
2784
|
-
close: () => {}
|
|
2785
|
-
};
|
|
2786
|
-
let typed = "";
|
|
2787
|
-
const onData = (chunk) => {
|
|
2788
|
-
const isKeystroke = chunk.length === 1;
|
|
2789
|
-
for (const character of chunk) {
|
|
2790
|
-
if (character === CTRL_C || character === CTRL_BACKSLASH) {
|
|
2791
|
-
fail(new Interrupted());
|
|
2792
|
-
return;
|
|
2793
|
-
}
|
|
2794
|
-
if (character === "\r" || character === "\n") {
|
|
2795
|
-
if (typed.trim() === "") continue;
|
|
2796
|
-
process.stderr.write("\n");
|
|
2797
|
-
settle(toPastedCallback(typed));
|
|
2798
|
-
return;
|
|
2799
|
-
}
|
|
2800
|
-
if (character === BACKSPACE) {
|
|
2801
|
-
typed = typed.slice(0, -1);
|
|
2802
|
-
process.stderr.write("\b \b");
|
|
2803
|
-
continue;
|
|
2804
|
-
}
|
|
2805
|
-
if (isKeystroke && (character === "c" || character === "C") && typed === "") {
|
|
2806
|
-
process.stderr.write(copy(url) ? "Copied the URL to your clipboard\n" : "Nothing on this host takes a clipboard\n");
|
|
3056
|
+
client_id: clientId,
|
|
3057
|
+
device_code: device.deviceCode
|
|
3058
|
+
});
|
|
3059
|
+
} catch (error) {
|
|
3060
|
+
if (!(error instanceof GrantFailure)) throw error;
|
|
3061
|
+
if (error.code === "slow_down") {
|
|
3062
|
+
intervalMs += SLOW_DOWN_SECONDS * 1e3;
|
|
2807
3063
|
continue;
|
|
2808
3064
|
}
|
|
2809
|
-
if (
|
|
2810
|
-
typed += character;
|
|
2811
|
-
process.stderr.write(character);
|
|
3065
|
+
if (error.code !== "authorization_pending") throw error;
|
|
2812
3066
|
}
|
|
2813
|
-
}
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
input.on("data", onData);
|
|
2818
|
-
const close = () => {
|
|
2819
|
-
input.off("data", onData);
|
|
2820
|
-
input.setRawMode(false);
|
|
2821
|
-
input.pause();
|
|
2822
|
-
};
|
|
2823
|
-
return {
|
|
2824
|
-
pasted: pasted.finally(close),
|
|
2825
|
-
close
|
|
2826
|
-
};
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
function toSleep(ms) {
|
|
3070
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2827
3071
|
}
|
|
2828
3072
|
//#endregion
|
|
2829
|
-
//#region src/
|
|
2830
|
-
/** The
|
|
2831
|
-
const
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
schema: z.string()
|
|
2851
|
-
},
|
|
2852
|
-
{
|
|
2853
|
-
name: "no-browser",
|
|
2854
|
-
description: "Print the URL instead of opening it",
|
|
2855
|
-
schema: z.boolean()
|
|
2856
|
-
},
|
|
2857
|
-
{
|
|
2858
|
-
name: "device",
|
|
2859
|
-
description: "Approve on another machine, by typing a code, with no listener on this one",
|
|
2860
|
-
schema: z.boolean()
|
|
2861
|
-
},
|
|
2862
|
-
{
|
|
2863
|
-
name: "json",
|
|
2864
|
-
description: "Print machine-readable output, which is the default when stdout is not a terminal",
|
|
2865
|
-
schema: z.boolean()
|
|
2866
|
-
}
|
|
2867
|
-
],
|
|
2868
|
-
examples: [
|
|
2869
|
-
{
|
|
2870
|
-
description: "Sign in",
|
|
2871
|
-
command: "hardfin login"
|
|
2872
|
-
},
|
|
2873
|
-
{
|
|
2874
|
-
description: "Sign in so that nothing can be changed",
|
|
2875
|
-
command: "hardfin login --read-only"
|
|
2876
|
-
},
|
|
2877
|
-
{
|
|
2878
|
-
description: "Sign in over SSH, opening the URL yourself",
|
|
2879
|
-
command: "hardfin login --no-browser"
|
|
2880
|
-
},
|
|
2881
|
-
{
|
|
2882
|
-
description: "Approve from your phone or another machine",
|
|
2883
|
-
command: "hardfin login --device"
|
|
2884
|
-
}
|
|
2885
|
-
],
|
|
2886
|
-
run: runLogin
|
|
2887
|
-
});
|
|
2888
|
-
async function runLogin(input) {
|
|
2889
|
-
const settings = input.resolved.settings;
|
|
2890
|
-
const isReadOnly = input.flags["readOnly"] === true;
|
|
2891
|
-
if (isReadOnly && input.flags["scope"] !== void 0) {
|
|
2892
|
-
writeFailure("invalid_flag", "--read-only and --scope both name the scopes to ask for, so give one of them", input.isJSON);
|
|
2893
|
-
return ExitCode.USAGE;
|
|
2894
|
-
}
|
|
2895
|
-
try {
|
|
2896
|
-
const clientId = settings.clientId;
|
|
2897
|
-
const metadata = await toMetadata(settings.issuerUrl);
|
|
2898
|
-
const scope = isReadOnly ? READ_ONLY_SCOPES : toText(input.flags["scope"] ?? DEFAULT_SCOPES);
|
|
2899
|
-
if (input.flags["device"] === true) return await runDeviceLogin(input, metadata, scope);
|
|
2900
|
-
const state = toState();
|
|
2901
|
-
const listener = await toListener(WAIT_MS, state);
|
|
2902
|
-
const pkce = toPkce();
|
|
2903
|
-
const url = toAuthorizationUrl(metadata.authorization_endpoint, {
|
|
2904
|
-
clientId,
|
|
2905
|
-
redirectUri: listener.redirectUri,
|
|
2906
|
-
scope,
|
|
2907
|
-
state,
|
|
2908
|
-
challenge: pkce.challenge
|
|
2909
|
-
});
|
|
2910
|
-
const opened = input.flags["noBrowser"] !== true && !process.env["HARDFIN_NO_BROWSER"] && openBrowser(url);
|
|
2911
|
-
if (input.isJSON) writeEvent({
|
|
2912
|
-
event: "sign_in_url",
|
|
2913
|
-
url,
|
|
2914
|
-
browserOpened: opened,
|
|
2915
|
-
waitSeconds: WAIT_MS / 1e3
|
|
2916
|
-
});
|
|
2917
|
-
else 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`);
|
|
2918
|
-
const prompt = toPrompt(url);
|
|
2919
|
-
if (process.stdin.isTTY) process.stderr.write("Press c to copy the URL, or paste the code or redirect URL here: ");
|
|
2920
|
-
let callback;
|
|
2921
|
-
try {
|
|
2922
|
-
callback = await Promise.race([listener.callback, prompt.pasted]);
|
|
2923
|
-
} finally {
|
|
2924
|
-
listener.close();
|
|
2925
|
-
prompt.close();
|
|
2926
|
-
}
|
|
2927
|
-
if (!input.isJSON) process.stderr.write(callback.error ? "\n" : "\nApproved, finishing the sign in\n");
|
|
2928
|
-
if (callback.error) {
|
|
2929
|
-
writeFailure("sign_in_failed", `sign in was refused: ${callback.error}${callback.errorDescription ? `, ${callback.errorDescription}` : ""}`, input.isJSON);
|
|
2930
|
-
return ExitCode.ERROR;
|
|
2931
|
-
}
|
|
2932
|
-
if (callback.source === "listener" ? callback.state !== state : callback.state !== void 0 && callback.state !== state) {
|
|
2933
|
-
writeFailure("sign_in_failed", "the browser came back with a state this sign in did not send", input.isJSON);
|
|
2934
|
-
return ExitCode.ERROR;
|
|
2935
|
-
}
|
|
2936
|
-
if (callback.issuer !== void 0 && callback.issuer.replace(/\/+$/, "") !== metadata.issuer.replace(/\/+$/, "")) {
|
|
2937
|
-
writeFailure("sign_in_failed", `the browser came back from ${callback.issuer}, which is not ${metadata.issuer}`, input.isJSON);
|
|
2938
|
-
return ExitCode.ERROR;
|
|
3073
|
+
//#region src/auth/loopback.ts
|
|
3074
|
+
/** The path the browser is sent back to, which the client metadata document registers. */
|
|
3075
|
+
const CALLBACK_PATH = "/callback";
|
|
3076
|
+
const HOST = "127.0.0.1";
|
|
3077
|
+
/**
|
|
3078
|
+
* toListener opens a loopback server for one authorization response. The port is whatever
|
|
3079
|
+
* the machine hands out, which is why the server matches a loopback redirect by everything
|
|
3080
|
+
* except its port.
|
|
3081
|
+
*/
|
|
3082
|
+
async function toListener(timeoutMs, state) {
|
|
3083
|
+
let settle = () => {};
|
|
3084
|
+
let fail = () => {};
|
|
3085
|
+
const callback = new Promise((resolve, reject) => {
|
|
3086
|
+
settle = resolve;
|
|
3087
|
+
fail = reject;
|
|
3088
|
+
});
|
|
3089
|
+
const server = createServer((request, response) => {
|
|
3090
|
+
const answered = toCallback(request);
|
|
3091
|
+
if (answered === void 0) {
|
|
3092
|
+
response.writeHead(404).end();
|
|
3093
|
+
return;
|
|
2939
3094
|
}
|
|
2940
|
-
if (!
|
|
2941
|
-
|
|
2942
|
-
return
|
|
3095
|
+
if (!isOurs(answered, state)) {
|
|
3096
|
+
writePage(response, 400, "Not this sign in", "This page did not come from the sign in your terminal started.");
|
|
3097
|
+
return;
|
|
2943
3098
|
}
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
3099
|
+
writeOutcome(response, answered);
|
|
3100
|
+
settle(answered);
|
|
3101
|
+
});
|
|
3102
|
+
await new Promise((resolve, reject) => {
|
|
3103
|
+
server.once("error", reject);
|
|
3104
|
+
server.listen(0, HOST, resolve);
|
|
3105
|
+
});
|
|
3106
|
+
const timer = setTimeout(() => {
|
|
3107
|
+
fail(new TimedOut("the browser did not come back in time"));
|
|
3108
|
+
close(server, timer);
|
|
3109
|
+
}, timeoutMs);
|
|
3110
|
+
return {
|
|
3111
|
+
redirectUri: `http://${HOST}:${server.address().port}${CALLBACK_PATH}`,
|
|
3112
|
+
callback: callback.finally(() => {
|
|
3113
|
+
close(server, timer);
|
|
3114
|
+
}),
|
|
3115
|
+
close: () => {
|
|
3116
|
+
close(server, timer);
|
|
2949
3117
|
}
|
|
2950
|
-
|
|
2951
|
-
}
|
|
3118
|
+
};
|
|
2952
3119
|
}
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
"",
|
|
2976
|
-
|
|
2977
|
-
].join("\n"));
|
|
2978
|
-
const prompt = toPrompt(url);
|
|
2979
|
-
try {
|
|
2980
|
-
return await toSignedIn(input, metadata, await toDeviceTokens(toTokensFromDevice(metadata.token_endpoint, settings.clientId, device), prompt.pasted));
|
|
2981
|
-
} finally {
|
|
2982
|
-
prompt.close();
|
|
3120
|
+
function toCallback(request) {
|
|
3121
|
+
const url = new URL(request.url ?? "/", `http://${HOST}`);
|
|
3122
|
+
if (url.pathname !== CALLBACK_PATH) return;
|
|
3123
|
+
return {
|
|
3124
|
+
source: "listener",
|
|
3125
|
+
code: url.searchParams.get("code") ?? void 0,
|
|
3126
|
+
state: url.searchParams.get("state") ?? void 0,
|
|
3127
|
+
issuer: url.searchParams.get("iss") ?? void 0,
|
|
3128
|
+
error: url.searchParams.get("error") ?? void 0,
|
|
3129
|
+
errorDescription: url.searchParams.get("error_description") ?? void 0
|
|
3130
|
+
};
|
|
3131
|
+
}
|
|
3132
|
+
/**
|
|
3133
|
+
* isOurs answers whether a response belongs to this sign in. A refusal with no state at all
|
|
3134
|
+
* is still taken, because not every authorization server sends the state back with one.
|
|
3135
|
+
*/
|
|
3136
|
+
function isOurs(callback, state) {
|
|
3137
|
+
if (callback.state !== void 0) return callback.state === state;
|
|
3138
|
+
return callback.code === void 0 && callback.error !== void 0;
|
|
3139
|
+
}
|
|
3140
|
+
function writeOutcome(response, callback) {
|
|
3141
|
+
if (!callback.error) {
|
|
3142
|
+
writePage(response, 200, "Signed in", "You can close this tab and return to your terminal.");
|
|
3143
|
+
return;
|
|
2983
3144
|
}
|
|
3145
|
+
writePage(response, 200, "Sign in refused", `${callback.error}${callback.errorDescription ? `: ${callback.errorDescription}` : ""}`);
|
|
3146
|
+
}
|
|
3147
|
+
/** toEscaped makes text safe to put in a page, because the words of a refusal come from the URL. */
|
|
3148
|
+
function toEscaped(text) {
|
|
3149
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
3150
|
+
}
|
|
3151
|
+
function writePage(response, status, heading, words) {
|
|
3152
|
+
const title = toEscaped(heading);
|
|
3153
|
+
const detail = toEscaped(words);
|
|
3154
|
+
response.writeHead(status, {
|
|
3155
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
3156
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
|
|
3157
|
+
"X-Content-Type-Options": "nosniff"
|
|
3158
|
+
}).end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title></head><body style="font-family: system-ui, sans-serif; padding: 3rem; max-width: 34rem"><h1 style="font-size: 1.25rem">${title}</h1><p>${detail}</p></body></html>`);
|
|
2984
3159
|
}
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
*/
|
|
2990
|
-
async function toDeviceTokens(approved, typed) {
|
|
2991
|
-
return await Promise.race([approved, typed.then(() => {
|
|
2992
|
-
throw new SignInStopped("Sign in stopped. The code goes on the page in your browser, not here. Run hardfin login to try again.");
|
|
2993
|
-
})]);
|
|
3160
|
+
function close(server, timer) {
|
|
3161
|
+
clearTimeout(timer);
|
|
3162
|
+
server.close();
|
|
3163
|
+
server.closeAllConnections();
|
|
2994
3164
|
}
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
writeData({
|
|
3006
|
-
signedIn: true,
|
|
3007
|
-
issuer: metadata.issuer,
|
|
3008
|
-
scope: tokens.scope ?? null,
|
|
3009
|
-
scopes: scopes ?? null,
|
|
3010
|
-
canWrite: toCanWrite(scopes),
|
|
3011
|
-
storedIn: backend
|
|
3012
|
-
});
|
|
3013
|
-
return ExitCode.OK;
|
|
3014
|
-
}
|
|
3015
|
-
const stored = backend === "keyring" ? "your OS keyring" : toCredentialPath();
|
|
3016
|
-
writeData([
|
|
3017
|
-
`Signed in to ${metadata.issuer}`,
|
|
3018
|
-
`Scope ${scopes?.join(" ") ?? tokens.scope ?? "as granted"}`,
|
|
3019
|
-
...toCanWrite(scopes) === false ? ["Access read-only, so a command that changes a record will be refused"] : [],
|
|
3020
|
-
`Stored in ${stored}`,
|
|
3021
|
-
"",
|
|
3022
|
-
"Run hardfin status to see what this CLI is using",
|
|
3023
|
-
...isInstalled() ? [] : ["Run hardfin completion install to complete commands with Tab"]
|
|
3024
|
-
].join("\n"));
|
|
3025
|
-
return ExitCode.OK;
|
|
3165
|
+
//#endregion
|
|
3166
|
+
//#region src/auth/pkce.ts
|
|
3167
|
+
/** toPkce makes a fresh verifier and the challenge derived from it. */
|
|
3168
|
+
function toPkce() {
|
|
3169
|
+
const verifier = toUrlSafe(randomBytes(32));
|
|
3170
|
+
return {
|
|
3171
|
+
verifier,
|
|
3172
|
+
challenge: toUrlSafe(createHash("sha256").update(verifier).digest()),
|
|
3173
|
+
method: "S256"
|
|
3174
|
+
};
|
|
3026
3175
|
}
|
|
3027
|
-
/**
|
|
3028
|
-
function
|
|
3029
|
-
|
|
3176
|
+
/** toState makes the value that ties a callback to the request that started it. */
|
|
3177
|
+
function toState() {
|
|
3178
|
+
return toUrlSafe(randomBytes(16));
|
|
3030
3179
|
}
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
const url = new URL(endpoint);
|
|
3034
|
-
url.searchParams.set("response_type", "code");
|
|
3035
|
-
url.searchParams.set("client_id", request.clientId);
|
|
3036
|
-
url.searchParams.set("redirect_uri", request.redirectUri);
|
|
3037
|
-
url.searchParams.set("scope", request.scope);
|
|
3038
|
-
url.searchParams.set("state", request.state);
|
|
3039
|
-
url.searchParams.set("code_challenge", request.challenge);
|
|
3040
|
-
url.searchParams.set("code_challenge_method", "S256");
|
|
3041
|
-
return url.toString();
|
|
3180
|
+
function toUrlSafe(bytes) {
|
|
3181
|
+
return bytes.toString("base64url");
|
|
3042
3182
|
}
|
|
3043
3183
|
//#endregion
|
|
3044
|
-
//#region src/
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
}
|
|
3059
|
-
|
|
3060
|
-
});
|
|
3061
|
-
async function runLogout(input) {
|
|
3062
|
-
const settings = input.resolved.settings;
|
|
3063
|
-
const stored = toCredential(settings.issuerUrl);
|
|
3064
|
-
if (!stored) {
|
|
3065
|
-
writeData({
|
|
3066
|
-
signedOut: false,
|
|
3067
|
-
issuer: settings.issuerUrl,
|
|
3068
|
-
reason: "no sign in was stored"
|
|
3069
|
-
});
|
|
3070
|
-
return ExitCode.OK;
|
|
3071
|
-
}
|
|
3072
|
-
let revoked = false;
|
|
3073
|
-
try {
|
|
3074
|
-
const metadata = await toMetadata(settings.issuerUrl);
|
|
3075
|
-
if (metadata.revocation_endpoint) {
|
|
3076
|
-
await revoke(metadata.revocation_endpoint, settings.clientId, stored.refreshToken);
|
|
3077
|
-
revoked = true;
|
|
3078
|
-
}
|
|
3079
|
-
} catch {
|
|
3080
|
-
revoked = false;
|
|
3081
|
-
}
|
|
3082
|
-
await withLock(() => {
|
|
3083
|
-
forget(settings.issuerUrl);
|
|
3084
|
-
});
|
|
3085
|
-
forgetHeldTokens();
|
|
3086
|
-
writeData({
|
|
3087
|
-
signedOut: true,
|
|
3088
|
-
issuer: settings.issuerUrl,
|
|
3089
|
-
revoked
|
|
3090
|
-
});
|
|
3091
|
-
return ExitCode.OK;
|
|
3184
|
+
//#region src/auth/clipboard.ts
|
|
3185
|
+
/** toClipboardCommand names how this host takes text onto the clipboard. */
|
|
3186
|
+
function toClipboardCommand() {
|
|
3187
|
+
if (process.platform === "darwin") return ["pbcopy", []];
|
|
3188
|
+
if (process.platform === "win32") return ["clip", []];
|
|
3189
|
+
if (existsSync("/proc/sys/fs/binfmt_misc/WSLInterop")) return ["clip.exe", []];
|
|
3190
|
+
if (process.env["WAYLAND_DISPLAY"]) return ["wl-copy", []];
|
|
3191
|
+
return ["xclip", ["-selection", "clipboard"]];
|
|
3192
|
+
}
|
|
3193
|
+
/** copyToClipboard puts text on the clipboard, and reports whether anything took it. */
|
|
3194
|
+
function copyToClipboard(text, copy = spawnSync) {
|
|
3195
|
+
const command = toClipboardCommand();
|
|
3196
|
+
if (!command) return false;
|
|
3197
|
+
const [name, args] = command;
|
|
3198
|
+
const result = copy(name, args, { input: text });
|
|
3199
|
+
return result.error === void 0 && result.status === 0;
|
|
3092
3200
|
}
|
|
3093
3201
|
//#endregion
|
|
3094
|
-
//#region src/
|
|
3202
|
+
//#region src/auth/prompt.ts
|
|
3203
|
+
const CTRL_C = "";
|
|
3204
|
+
const CTRL_BACKSLASH = "";
|
|
3205
|
+
const BACKSPACE = "";
|
|
3095
3206
|
/**
|
|
3096
|
-
*
|
|
3097
|
-
*
|
|
3207
|
+
* toPastedCallback reads what a person pasted. A browser that could not reach the listener
|
|
3208
|
+
* leaves the whole redirect in the address bar, and a page that shows the code leaves
|
|
3209
|
+
* `code#state`, so both are accepted alongside a bare code.
|
|
3098
3210
|
*/
|
|
3099
|
-
function
|
|
3100
|
-
|
|
3211
|
+
function toPastedCallback(pasted) {
|
|
3212
|
+
const value = pasted.trim();
|
|
3213
|
+
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
3214
|
+
const url = new URL(value);
|
|
3215
|
+
return {
|
|
3216
|
+
source: "pasted",
|
|
3217
|
+
code: url.searchParams.get("code") ?? void 0,
|
|
3218
|
+
state: url.searchParams.get("state") ?? void 0,
|
|
3219
|
+
issuer: url.searchParams.get("iss") ?? void 0,
|
|
3220
|
+
error: url.searchParams.get("error") ?? void 0,
|
|
3221
|
+
errorDescription: url.searchParams.get("error_description") ?? void 0
|
|
3222
|
+
};
|
|
3223
|
+
}
|
|
3224
|
+
const [code, state] = value.split("#");
|
|
3225
|
+
return {
|
|
3226
|
+
source: "pasted",
|
|
3227
|
+
code: code || void 0,
|
|
3228
|
+
state: state || void 0
|
|
3229
|
+
};
|
|
3101
3230
|
}
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
};
|
|
3109
|
-
const
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
const
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
}
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3231
|
+
/**
|
|
3232
|
+
* toPrompt watches the keyboard while the browser is away. Pressing c copies the URL, and
|
|
3233
|
+
* pasting a code finishes the sign in on a machine whose browser cannot reach this listener.
|
|
3234
|
+
*/
|
|
3235
|
+
function toPrompt(url, input = process.stdin, copy = copyToClipboard) {
|
|
3236
|
+
let settle = () => {};
|
|
3237
|
+
let fail = () => {};
|
|
3238
|
+
const pasted = new Promise((resolve, reject) => {
|
|
3239
|
+
settle = resolve;
|
|
3240
|
+
fail = reject;
|
|
3241
|
+
});
|
|
3242
|
+
pasted.catch(() => {});
|
|
3243
|
+
if (!input.isTTY) return {
|
|
3244
|
+
pasted,
|
|
3245
|
+
close: () => {}
|
|
3246
|
+
};
|
|
3247
|
+
let typed = "";
|
|
3248
|
+
const onData = (chunk) => {
|
|
3249
|
+
const isKeystroke = chunk.length === 1;
|
|
3250
|
+
for (const character of chunk) {
|
|
3251
|
+
if (character === CTRL_C || character === CTRL_BACKSLASH) {
|
|
3252
|
+
fail(new Interrupted());
|
|
3253
|
+
return;
|
|
3254
|
+
}
|
|
3255
|
+
if (character === "\r" || character === "\n") {
|
|
3256
|
+
if (typed.trim() === "") continue;
|
|
3257
|
+
process.stderr.write("\n");
|
|
3258
|
+
settle(toPastedCallback(typed));
|
|
3259
|
+
return;
|
|
3260
|
+
}
|
|
3261
|
+
if (character === BACKSPACE) {
|
|
3262
|
+
typed = typed.slice(0, -1);
|
|
3263
|
+
process.stderr.write("\b \b");
|
|
3264
|
+
continue;
|
|
3265
|
+
}
|
|
3266
|
+
if (isKeystroke && (character === "c" || character === "C") && typed === "") {
|
|
3267
|
+
process.stderr.write(copy(url) ? "Copied the URL to your clipboard\n" : "Nothing on this host takes a clipboard\n");
|
|
3268
|
+
continue;
|
|
3269
|
+
}
|
|
3270
|
+
if (character < " ") continue;
|
|
3271
|
+
typed += character;
|
|
3272
|
+
process.stderr.write(character);
|
|
3273
|
+
}
|
|
3274
|
+
};
|
|
3275
|
+
input.setRawMode(true);
|
|
3276
|
+
input.setEncoding("utf8");
|
|
3277
|
+
input.resume();
|
|
3278
|
+
input.on("data", onData);
|
|
3279
|
+
const close = () => {
|
|
3280
|
+
input.off("data", onData);
|
|
3281
|
+
input.setRawMode(false);
|
|
3282
|
+
input.pause();
|
|
3283
|
+
};
|
|
3143
3284
|
return {
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
description: operation.description ?? operation.summary,
|
|
3147
|
-
arguments: operation.pathParameters,
|
|
3148
|
-
flags,
|
|
3149
|
-
examples: operation.example ? [{
|
|
3150
|
-
description: operation.summary,
|
|
3151
|
-
command: operation.example
|
|
3152
|
-
}] : [],
|
|
3153
|
-
endpoint: {
|
|
3154
|
-
method: operation.method,
|
|
3155
|
-
path: operation.path
|
|
3156
|
-
},
|
|
3157
|
-
run: (input) => runOperation(operation, input)
|
|
3285
|
+
pasted: pasted.finally(close),
|
|
3286
|
+
close
|
|
3158
3287
|
};
|
|
3159
3288
|
}
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3289
|
+
//#endregion
|
|
3290
|
+
//#region src/command/login.ts
|
|
3291
|
+
/** The scopes a sign-in asks for, with offline access so a refresh token comes back. */
|
|
3292
|
+
const DEFAULT_SCOPES = "hardfin:read hardfin:write offline_access";
|
|
3293
|
+
/** The scopes a sign in asks for when it must not be able to change anything. */
|
|
3294
|
+
const READ_ONLY_SCOPES = "hardfin:read offline_access";
|
|
3295
|
+
const WAIT_MS = 3e5;
|
|
3296
|
+
const loginCommand = defineCommand({
|
|
3297
|
+
name: "login",
|
|
3298
|
+
summary: "Sign in to Hardfin through a browser",
|
|
3299
|
+
description: "Opens the browser to approve this CLI, then keeps the refresh token in the OS keyring, or in a file with owner-only permissions where there is no keyring. Access tokens are never written anywhere.",
|
|
3300
|
+
arguments: [],
|
|
3301
|
+
flags: [
|
|
3302
|
+
{
|
|
3303
|
+
name: "read-only",
|
|
3304
|
+
description: "Ask for read access alone, so nothing run with this sign in can change a record",
|
|
3305
|
+
schema: z.boolean()
|
|
3306
|
+
},
|
|
3307
|
+
{
|
|
3308
|
+
name: "scope",
|
|
3309
|
+
description: `The scopes to ask for, separated by spaces, which are ${DEFAULT_SCOPES} when not given`,
|
|
3310
|
+
valueName: "scopes",
|
|
3311
|
+
schema: z.string()
|
|
3312
|
+
},
|
|
3313
|
+
{
|
|
3314
|
+
name: "no-browser",
|
|
3315
|
+
description: "Print the URL instead of opening it",
|
|
3316
|
+
schema: z.boolean()
|
|
3317
|
+
},
|
|
3318
|
+
{
|
|
3319
|
+
name: "device",
|
|
3320
|
+
description: "Approve on another machine, by typing a code, with no listener on this one",
|
|
3321
|
+
schema: z.boolean()
|
|
3322
|
+
},
|
|
3323
|
+
{
|
|
3324
|
+
name: "json",
|
|
3325
|
+
description: "Print machine-readable output, which is the default when stdout is not a terminal",
|
|
3326
|
+
schema: z.boolean()
|
|
3191
3327
|
}
|
|
3192
|
-
|
|
3328
|
+
],
|
|
3329
|
+
examples: [
|
|
3330
|
+
{
|
|
3331
|
+
description: "Sign in",
|
|
3332
|
+
command: "hardfin login"
|
|
3333
|
+
},
|
|
3334
|
+
{
|
|
3335
|
+
description: "Sign in so that nothing can be changed",
|
|
3336
|
+
command: "hardfin login --read-only"
|
|
3337
|
+
},
|
|
3338
|
+
{
|
|
3339
|
+
description: "Sign in over SSH, opening the URL yourself",
|
|
3340
|
+
command: "hardfin login --no-browser"
|
|
3341
|
+
},
|
|
3342
|
+
{
|
|
3343
|
+
description: "Approve from your phone or another machine",
|
|
3344
|
+
command: "hardfin login --device"
|
|
3345
|
+
}
|
|
3346
|
+
],
|
|
3347
|
+
run: runLogin
|
|
3348
|
+
});
|
|
3349
|
+
async function runLogin(input) {
|
|
3350
|
+
const settings = input.resolved.settings;
|
|
3351
|
+
const isReadOnly = input.flags["readOnly"] === true;
|
|
3352
|
+
if (isReadOnly && input.flags["scope"] !== void 0) {
|
|
3353
|
+
writeFailure("invalid_flag", "--read-only and --scope both name the scopes to ask for, so give one of them", input.isJSON);
|
|
3354
|
+
return ExitCode.USAGE;
|
|
3193
3355
|
}
|
|
3194
3356
|
try {
|
|
3195
|
-
const
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3357
|
+
const clientId = settings.clientId;
|
|
3358
|
+
const metadata = await toMetadata(settings.issuerUrl);
|
|
3359
|
+
const scope = isReadOnly ? READ_ONLY_SCOPES : toText(input.flags["scope"] ?? DEFAULT_SCOPES);
|
|
3360
|
+
if (input.flags["device"] === true) return await runDeviceLogin(input, metadata, scope);
|
|
3361
|
+
const state = toState();
|
|
3362
|
+
const listener = await toListener(WAIT_MS, state);
|
|
3363
|
+
const pkce = toPkce();
|
|
3364
|
+
const url = toAuthorizationUrl(metadata.authorization_endpoint, {
|
|
3365
|
+
clientId,
|
|
3366
|
+
redirectUri: listener.redirectUri,
|
|
3367
|
+
scope,
|
|
3368
|
+
state,
|
|
3369
|
+
challenge: pkce.challenge
|
|
3204
3370
|
});
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3371
|
+
const opened = input.flags["noBrowser"] !== true && !process.env["HARDFIN_NO_BROWSER"] && openBrowser(url);
|
|
3372
|
+
if (input.isJSON) writeEvent({
|
|
3373
|
+
event: "sign_in_url",
|
|
3374
|
+
url,
|
|
3375
|
+
browserOpened: opened,
|
|
3376
|
+
waitSeconds: WAIT_MS / 1e3
|
|
3377
|
+
});
|
|
3378
|
+
else 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`);
|
|
3379
|
+
const prompt = toPrompt(url);
|
|
3380
|
+
if (process.stdin.isTTY) process.stderr.write("Press c to copy the URL, or paste the code or redirect URL here: ");
|
|
3381
|
+
let callback;
|
|
3382
|
+
try {
|
|
3383
|
+
callback = await Promise.race([listener.callback, prompt.pasted]);
|
|
3384
|
+
} finally {
|
|
3385
|
+
listener.close();
|
|
3386
|
+
prompt.close();
|
|
3212
3387
|
}
|
|
3213
|
-
if (error
|
|
3214
|
-
|
|
3215
|
-
|
|
3388
|
+
if (!input.isJSON) process.stderr.write(callback.error ? "\n" : "\nApproved, finishing the sign in\n");
|
|
3389
|
+
if (callback.error) {
|
|
3390
|
+
writeFailure("sign_in_failed", `sign in was refused: ${callback.error}${callback.errorDescription ? `, ${callback.errorDescription}` : ""}`, input.isJSON);
|
|
3391
|
+
return ExitCode.ERROR;
|
|
3216
3392
|
}
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
/**
|
|
3221
|
-
* toWritten puts a downloaded file where it was asked for. A terminal is never written to,
|
|
3222
|
-
* because a person would otherwise have their session filled with a file's bytes.
|
|
3223
|
-
*/
|
|
3224
|
-
function toWritten(download, input) {
|
|
3225
|
-
const asked = input.flags["output"];
|
|
3226
|
-
const offered = typeof asked === "string" ? void 0 : download.fileName;
|
|
3227
|
-
const path = typeof asked === "string" ? asked : offered === void 0 ? "-" : toLocalName(offered);
|
|
3228
|
-
if (path === void 0 || offered !== void 0 && existsSync(path)) {
|
|
3229
|
-
writeFailure("output_required", path === void 0 ? `this file is offered as ${JSON.stringify(offered)}, which is not a name to write here, so name where to write it with --output` : `${path} already exists, so name where to write this file with --output`, input.isJSON);
|
|
3230
|
-
return ExitCode.USAGE;
|
|
3231
|
-
}
|
|
3232
|
-
if (path === "-") {
|
|
3233
|
-
if (process.stdout.isTTY) {
|
|
3234
|
-
writeFailure("output_required", "this answer is a file, so name where to write it with --output, or send it on with a pipe", input.isJSON);
|
|
3235
|
-
return ExitCode.USAGE;
|
|
3393
|
+
if (callback.source === "listener" ? callback.state !== state : callback.state !== void 0 && callback.state !== state) {
|
|
3394
|
+
writeFailure("sign_in_failed", "the browser came back with a state this sign in did not send", input.isJSON);
|
|
3395
|
+
return ExitCode.ERROR;
|
|
3236
3396
|
}
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
contentType: download.contentType
|
|
3245
|
-
});
|
|
3246
|
-
return ExitCode.OK;
|
|
3247
|
-
}
|
|
3248
|
-
/** toLocalName reduces a name the API offered to one file in the working directory, or to nothing. */
|
|
3249
|
-
function toLocalName(offered) {
|
|
3250
|
-
const name = offered.split(/[\\/]/).pop() ?? "";
|
|
3251
|
-
return name === "" || name.startsWith(".") ? void 0 : name;
|
|
3252
|
-
}
|
|
3253
|
-
/** The arguments a URL reads as "this folder" and "the one above", and one that names nothing. */
|
|
3254
|
-
const NOT_A_KEY = /* @__PURE__ */ new Set([
|
|
3255
|
-
"",
|
|
3256
|
-
".",
|
|
3257
|
-
".."
|
|
3258
|
-
]);
|
|
3259
|
-
/** toPath fills the path template from the positional arguments, in order. */
|
|
3260
|
-
function toPath(operation, args) {
|
|
3261
|
-
if (args.length !== operation.pathParameters.length || args.some((argument) => NOT_A_KEY.has(argument))) return;
|
|
3262
|
-
let path = operation.path;
|
|
3263
|
-
for (const [index, parameter] of operation.pathParameters.entries()) path = path.replace(`{${parameter.name}}`, encodeURIComponent(args[index] ?? ""));
|
|
3264
|
-
return path;
|
|
3265
|
-
}
|
|
3266
|
-
/** toQuery carries only the flags this invocation actually set. */
|
|
3267
|
-
function toQuery(operation, flags) {
|
|
3268
|
-
const query = new URLSearchParams();
|
|
3269
|
-
for (const flag of operation.queryFlags) {
|
|
3270
|
-
const value = flags[toOptionKey(flag.name)];
|
|
3271
|
-
if (value === void 0) continue;
|
|
3272
|
-
const parsed = flag.schema.safeParse(value);
|
|
3273
|
-
const carried = parsed.success ? parsed.data : value;
|
|
3274
|
-
for (const entry of Array.isArray(carried) ? carried : [carried]) query.append(flag.queryName, toText(entry));
|
|
3275
|
-
}
|
|
3276
|
-
return query;
|
|
3277
|
-
}
|
|
3278
|
-
/** toOptionKey names the parsed flag, which the parser reports in camel case. */
|
|
3279
|
-
function toOptionKey(name) {
|
|
3280
|
-
return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
3281
|
-
}
|
|
3282
|
-
/** toForm builds the file part and its fields, which an upload endpoint takes. */
|
|
3283
|
-
async function toForm(upload, flags) {
|
|
3284
|
-
const path = flags["file"];
|
|
3285
|
-
if (typeof path !== "string") return /* @__PURE__ */ new Error("this command needs --file, the file to upload");
|
|
3286
|
-
const form = new FormData();
|
|
3287
|
-
try {
|
|
3288
|
-
form.append(upload.filePart, await openAsBlob(path), basename(path));
|
|
3289
|
-
} catch {
|
|
3290
|
-
return /* @__PURE__ */ new Error(`${path} cannot be read`);
|
|
3291
|
-
}
|
|
3292
|
-
for (const field of upload.fields) {
|
|
3293
|
-
const value = flags[toOptionKey(field.name)];
|
|
3294
|
-
if (value !== void 0) {
|
|
3295
|
-
const parsed = field.schema.safeParse(value);
|
|
3296
|
-
form.append(field.jsonPath[0] ?? field.name, toText(parsed.success ? parsed.data : value));
|
|
3397
|
+
if (callback.issuer !== void 0 && callback.issuer.replace(/\/+$/, "") !== metadata.issuer.replace(/\/+$/, "")) {
|
|
3398
|
+
writeFailure("sign_in_failed", `the browser came back from ${callback.issuer}, which is not ${metadata.issuer}`, input.isJSON);
|
|
3399
|
+
return ExitCode.ERROR;
|
|
3400
|
+
}
|
|
3401
|
+
if (!callback.code) {
|
|
3402
|
+
writeFailure("sign_in_failed", "the browser came back without an authorization code", input.isJSON);
|
|
3403
|
+
return ExitCode.ERROR;
|
|
3297
3404
|
}
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
if (typeof source !== "string") return;
|
|
3304
|
-
const read = toJSONInput(source);
|
|
3305
|
-
if (read instanceof InputFailure) return read;
|
|
3306
|
-
if (typeof read !== "object" || read === null || Array.isArray(read)) return new InputFailure(`the body in ${source === "-" ? "standard input" : source} must be a JSON object, such as {"name": "Acme"}`);
|
|
3307
|
-
return read;
|
|
3308
|
-
}
|
|
3309
|
-
/** hasPath reports whether a body already holds a value at a field's path. */
|
|
3310
|
-
function hasPath(body, path) {
|
|
3311
|
-
let holder = body;
|
|
3312
|
-
for (const name of path) {
|
|
3313
|
-
if (typeof holder !== "object" || holder === null || !(name in holder)) return false;
|
|
3314
|
-
holder = holder[name];
|
|
3315
|
-
}
|
|
3316
|
-
return true;
|
|
3317
|
-
}
|
|
3318
|
-
/** toBody builds the request body from the --input body and the flags, a flag winning over its field. */
|
|
3319
|
-
function toBody(operation, flags, given) {
|
|
3320
|
-
const body = given ? structuredClone(given) : {};
|
|
3321
|
-
let hasField = given !== void 0;
|
|
3322
|
-
for (const flag of operation.bodyFlags) {
|
|
3323
|
-
const value = flags[toOptionKey(flag.name)];
|
|
3324
|
-
if (value === void 0) continue;
|
|
3325
|
-
if (flag.element) {
|
|
3326
|
-
const elements = toElements(flag, Array.isArray(value) ? value.map(toText) : [toText(value)]);
|
|
3327
|
-
if (elements instanceof Error) return elements;
|
|
3328
|
-
set(body, flag.jsonPath, elements);
|
|
3329
|
-
hasField = true;
|
|
3330
|
-
continue;
|
|
3405
|
+
return await toSignedIn(input, metadata, await toTokensFromCode(metadata.token_endpoint, clientId, callback.code, listener.redirectUri, pkce));
|
|
3406
|
+
} catch (error) {
|
|
3407
|
+
if (error instanceof DiscoveryFailure || error instanceof GrantFailure || error instanceof SignInStopped) {
|
|
3408
|
+
writeFailure("sign_in_failed", error.message, input.isJSON);
|
|
3409
|
+
return ExitCode.ERROR;
|
|
3331
3410
|
}
|
|
3332
|
-
|
|
3333
|
-
set(body, flag.jsonPath, parsed.success ? parsed.data : value);
|
|
3334
|
-
hasField = true;
|
|
3411
|
+
throw error;
|
|
3335
3412
|
}
|
|
3336
|
-
const cleared = toCleared(operation, flags, body);
|
|
3337
|
-
if (cleared instanceof Error) return cleared;
|
|
3338
|
-
return hasField || cleared ? body : void 0;
|
|
3339
3413
|
}
|
|
3340
|
-
/**
|
|
3341
|
-
function
|
|
3342
|
-
const
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
if (!flag) return /* @__PURE__ */ new Error(`--unset names no field called ${name}`);
|
|
3347
|
-
if (!flag.nullable) return /* @__PURE__ */ new Error(`--${flag.name} cannot be cleared, because the API does not accept null for it`);
|
|
3348
|
-
set(body, flag.jsonPath, null);
|
|
3414
|
+
/** runDeviceLogin waits while the person approves on a machine that has a browser. */
|
|
3415
|
+
async function runDeviceLogin(input, metadata, scope) {
|
|
3416
|
+
const settings = input.resolved.settings;
|
|
3417
|
+
if (!metadata.device_authorization_endpoint) {
|
|
3418
|
+
writeFailure("sign_in_failed", `${metadata.issuer} does not offer the device grant, so sign in without --device`, input.isJSON);
|
|
3419
|
+
return ExitCode.ERROR;
|
|
3349
3420
|
}
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3421
|
+
const device = await toDeviceAuthorization(metadata.device_authorization_endpoint, settings.clientId, scope);
|
|
3422
|
+
const url = device.verificationUriComplete ?? device.verificationUri;
|
|
3423
|
+
const opened = input.flags["noBrowser"] !== true && !process.env["HARDFIN_NO_BROWSER"] && openBrowser(url);
|
|
3424
|
+
if (input.isJSON) writeEvent({
|
|
3425
|
+
event: "device_code",
|
|
3426
|
+
userCode: device.userCode,
|
|
3427
|
+
verificationUri: device.verificationUri,
|
|
3428
|
+
url,
|
|
3429
|
+
browserOpened: opened,
|
|
3430
|
+
waitSeconds: Math.max(0, Math.round((device.expiresAt - Date.now()) / 1e3))
|
|
3431
|
+
});
|
|
3432
|
+
else process.stderr.write([
|
|
3433
|
+
"",
|
|
3434
|
+
` Code ${toPrintable(device.userCode)}`,
|
|
3435
|
+
` At ${toPrintable(device.verificationUri)}`,
|
|
3436
|
+
"",
|
|
3437
|
+
opened ? "Opened your browser there. Waiting for approval\n" : "Open that page on any machine, and enter the code. Waiting for approval\n"
|
|
3438
|
+
].join("\n"));
|
|
3439
|
+
const prompt = toPrompt(url);
|
|
3440
|
+
try {
|
|
3441
|
+
return await toSignedIn(input, metadata, await toDeviceTokens(toTokensFromDevice(metadata.token_endpoint, settings.clientId, device), prompt.pasted));
|
|
3442
|
+
} finally {
|
|
3443
|
+
prompt.close();
|
|
3372
3444
|
}
|
|
3373
|
-
return elements;
|
|
3374
3445
|
}
|
|
3375
|
-
/** Marks text that is none of the types a field takes. */
|
|
3376
|
-
const NOT_TAKEN = Symbol("not taken");
|
|
3377
|
-
const NUMBER = /^-?\d+(\.\d+)?$/;
|
|
3378
3446
|
/**
|
|
3379
|
-
*
|
|
3380
|
-
*
|
|
3381
|
-
*
|
|
3447
|
+
* toDeviceTokens waits for the approval, or for the keyboard. Ctrl-C arrives as an Interrupted
|
|
3448
|
+
* and stops quietly. Text followed by Enter means the person typed the code here, where it
|
|
3449
|
+
* does nothing, so they are told where it goes instead.
|
|
3382
3450
|
*/
|
|
3383
|
-
function
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
const value = Number(text);
|
|
3388
|
-
if (types.includes("number") || Number.isInteger(value)) return value;
|
|
3389
|
-
}
|
|
3390
|
-
if (types.includes("boolean") && (text === "true" || text === "false")) return text === "true";
|
|
3391
|
-
return NOT_TAKEN;
|
|
3451
|
+
async function toDeviceTokens(approved, typed) {
|
|
3452
|
+
return await Promise.race([approved, typed.then(() => {
|
|
3453
|
+
throw new SignInStopped("Sign in stopped. The code goes on the page in your browser, not here. Run hardfin login to try again.");
|
|
3454
|
+
})]);
|
|
3392
3455
|
}
|
|
3393
|
-
/**
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
try {
|
|
3400
|
-
value = JSON.parse(text);
|
|
3401
|
-
} catch {
|
|
3402
|
-
return NOT_TAKEN;
|
|
3456
|
+
/** toSignedIn stores what a sign in issued, whichever flow issued it. */
|
|
3457
|
+
async function toSignedIn(input, metadata, tokens) {
|
|
3458
|
+
const refreshToken = tokens.refreshToken;
|
|
3459
|
+
if (!refreshToken) {
|
|
3460
|
+
writeFailure("sign_in_failed", "the authorization server issued no refresh token, so this sign in cannot be kept", input.isJSON);
|
|
3461
|
+
return ExitCode.ERROR;
|
|
3403
3462
|
}
|
|
3404
|
-
const
|
|
3405
|
-
|
|
3463
|
+
const scopes = toGrantedScopes(tokens);
|
|
3464
|
+
const backend = await withLock(() => keep$1(input.resolved.settings.issuerUrl, refreshToken, tokens.refreshExpiresAt, scopes));
|
|
3465
|
+
if (input.isJSON) {
|
|
3466
|
+
writeData({
|
|
3467
|
+
signedIn: true,
|
|
3468
|
+
issuer: metadata.issuer,
|
|
3469
|
+
scope: tokens.scope ?? null,
|
|
3470
|
+
scopes: scopes ?? null,
|
|
3471
|
+
canWrite: toCanWrite(scopes),
|
|
3472
|
+
storedIn: backend
|
|
3473
|
+
});
|
|
3474
|
+
return ExitCode.OK;
|
|
3475
|
+
}
|
|
3476
|
+
const stored = backend === "keyring" ? "your OS keyring" : toCredentialPath();
|
|
3477
|
+
writeData([
|
|
3478
|
+
`Signed in to ${metadata.issuer}`,
|
|
3479
|
+
`Scope ${scopes?.join(" ") ?? tokens.scope ?? "as granted"}`,
|
|
3480
|
+
...toCanWrite(scopes) === false ? ["Access read-only, so a command that changes a record will be refused"] : [],
|
|
3481
|
+
`Stored in ${stored}`,
|
|
3482
|
+
"",
|
|
3483
|
+
"Run hardfin status to see what this CLI is using",
|
|
3484
|
+
...isInstalled() ? [] : ["Run hardfin completion install to complete commands with Tab"]
|
|
3485
|
+
].join("\n"));
|
|
3486
|
+
return ExitCode.OK;
|
|
3406
3487
|
}
|
|
3407
|
-
/**
|
|
3408
|
-
function
|
|
3409
|
-
|
|
3410
|
-
integer: "a whole number",
|
|
3411
|
-
number: "a number",
|
|
3412
|
-
boolean: "true or false",
|
|
3413
|
-
string: "text in quotes",
|
|
3414
|
-
null: "null"
|
|
3415
|
-
};
|
|
3416
|
-
return types.filter((type) => type in named).map((type) => named[type]).join(" or ");
|
|
3488
|
+
/** writeEvent tells a program what a sign in is waiting on, as one line of JSON on stderr. */
|
|
3489
|
+
function writeEvent(event) {
|
|
3490
|
+
process.stderr.write(`${JSON.stringify(event)}\n`);
|
|
3417
3491
|
}
|
|
3418
|
-
/**
|
|
3419
|
-
function
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3492
|
+
/** toAuthorizationUrl builds the URL the person approves this CLI at. */
|
|
3493
|
+
function toAuthorizationUrl(endpoint, request) {
|
|
3494
|
+
const url = new URL(endpoint);
|
|
3495
|
+
url.searchParams.set("response_type", "code");
|
|
3496
|
+
url.searchParams.set("client_id", request.clientId);
|
|
3497
|
+
url.searchParams.set("redirect_uri", request.redirectUri);
|
|
3498
|
+
url.searchParams.set("scope", request.scope);
|
|
3499
|
+
url.searchParams.set("state", request.state);
|
|
3500
|
+
url.searchParams.set("code_challenge", request.challenge);
|
|
3501
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
3502
|
+
return url.toString();
|
|
3503
|
+
}
|
|
3504
|
+
//#endregion
|
|
3505
|
+
//#region src/command/logout.ts
|
|
3506
|
+
const logoutCommand = defineCommand({
|
|
3507
|
+
name: "logout",
|
|
3508
|
+
summary: "Sign out, and tell Hardfin to forget this machine",
|
|
3509
|
+
description: "Removes the stored refresh token and asks the authorization server to revoke it, so a copy taken from this machine stops working.",
|
|
3510
|
+
arguments: [],
|
|
3511
|
+
flags: [{
|
|
3512
|
+
name: "json",
|
|
3513
|
+
description: "Print machine-readable output, which is the default when stdout is not a terminal",
|
|
3514
|
+
schema: z.boolean()
|
|
3515
|
+
}],
|
|
3516
|
+
examples: [{
|
|
3517
|
+
description: "Sign out",
|
|
3518
|
+
command: "hardfin logout"
|
|
3519
|
+
}],
|
|
3520
|
+
run: runLogout
|
|
3521
|
+
});
|
|
3522
|
+
async function runLogout(input) {
|
|
3523
|
+
const settings = input.resolved.settings;
|
|
3524
|
+
const stored = toCredential(settings.issuerUrl);
|
|
3525
|
+
if (!stored) {
|
|
3526
|
+
writeData({
|
|
3527
|
+
signedOut: false,
|
|
3528
|
+
issuer: settings.issuerUrl,
|
|
3529
|
+
reason: "no sign in was stored"
|
|
3530
|
+
});
|
|
3531
|
+
return ExitCode.OK;
|
|
3424
3532
|
}
|
|
3425
|
-
|
|
3533
|
+
let revoked = false;
|
|
3534
|
+
try {
|
|
3535
|
+
const metadata = await toMetadata(settings.issuerUrl);
|
|
3536
|
+
if (metadata.revocation_endpoint) {
|
|
3537
|
+
await revoke(metadata.revocation_endpoint, settings.clientId, stored.refreshToken);
|
|
3538
|
+
revoked = true;
|
|
3539
|
+
}
|
|
3540
|
+
} catch {
|
|
3541
|
+
revoked = false;
|
|
3542
|
+
}
|
|
3543
|
+
await withLock(() => {
|
|
3544
|
+
forget(settings.issuerUrl);
|
|
3545
|
+
});
|
|
3546
|
+
forgetHeldTokens();
|
|
3547
|
+
writeData({
|
|
3548
|
+
signedOut: true,
|
|
3549
|
+
issuer: settings.issuerUrl,
|
|
3550
|
+
revoked
|
|
3551
|
+
});
|
|
3552
|
+
return ExitCode.OK;
|
|
3426
3553
|
}
|
|
3427
3554
|
//#endregion
|
|
3428
3555
|
//#region src/mcp/confine.ts
|
|
@@ -4686,6 +4813,7 @@ const commands = [
|
|
|
4686
4813
|
jsonPath: ["depreciationModel"],
|
|
4687
4814
|
description: "The method one unit is depreciated by, or null to clear it",
|
|
4688
4815
|
valueName: "value",
|
|
4816
|
+
nullable: true,
|
|
4689
4817
|
schema: toEnum([
|
|
4690
4818
|
"DOUBLE_DECLINING",
|
|
4691
4819
|
"STRAIGHT_LINE",
|
|
@@ -5051,6 +5179,7 @@ const commands = [
|
|
|
5051
5179
|
jsonPath: ["depreciationModel"],
|
|
5052
5180
|
description: "The method one unit is depreciated by, or null to clear it",
|
|
5053
5181
|
valueName: "value",
|
|
5182
|
+
nullable: true,
|
|
5054
5183
|
schema: toEnum([
|
|
5055
5184
|
"DOUBLE_DECLINING",
|
|
5056
5185
|
"STRAIGHT_LINE",
|
|
@@ -6427,6 +6556,7 @@ const commands = [
|
|
|
6427
6556
|
jsonPath: ["depreciationModel"],
|
|
6428
6557
|
description: "The method one unit is depreciated by, or null to clear it",
|
|
6429
6558
|
valueName: "value",
|
|
6559
|
+
nullable: true,
|
|
6430
6560
|
schema: toEnum([
|
|
6431
6561
|
"DOUBLE_DECLINING",
|
|
6432
6562
|
"STRAIGHT_LINE",
|
|
@@ -6860,6 +6990,7 @@ const commands = [
|
|
|
6860
6990
|
jsonPath: ["type"],
|
|
6861
6991
|
description: "SITE for a site, or ZONE for a zone within a site",
|
|
6862
6992
|
valueName: "value",
|
|
6993
|
+
nullable: true,
|
|
6863
6994
|
schema: toEnum([
|
|
6864
6995
|
"SITE",
|
|
6865
6996
|
"UNKNOWN",
|
|
@@ -7020,6 +7151,7 @@ const commands = [
|
|
|
7020
7151
|
jsonPath: ["type"],
|
|
7021
7152
|
description: "SITE for a site, or ZONE for a zone within a site",
|
|
7022
7153
|
valueName: "value",
|
|
7154
|
+
nullable: true,
|
|
7023
7155
|
schema: toEnum([
|
|
7024
7156
|
"SITE",
|
|
7025
7157
|
"UNKNOWN",
|
|
@@ -7160,7 +7292,9 @@ function toRejectedFlag(command, flags) {
|
|
|
7160
7292
|
for (const flag of command.flags) {
|
|
7161
7293
|
const value = flags[toOptionKey(flag.name)];
|
|
7162
7294
|
if (value === void 0) continue;
|
|
7163
|
-
|
|
7295
|
+
const given = toNullOrText(value, flag.nullable === true);
|
|
7296
|
+
if (given === null) continue;
|
|
7297
|
+
if (!flag.schema.safeParse(given).success) return `--${flag.name} does not accept ${JSON.stringify(value)}`;
|
|
7164
7298
|
}
|
|
7165
7299
|
}
|
|
7166
7300
|
//#endregion
|
|
@@ -7247,5 +7381,5 @@ async function toReported(error, isJSON, resolved, level = "error") {
|
|
|
7247
7381
|
//#endregion
|
|
7248
7382
|
export { toCli, toParseExitCode, toReported, wasReportAttempted, writeCrash };
|
|
7249
7383
|
|
|
7250
|
-
//# sourceMappingURL=program-
|
|
7251
|
-
//# debugId=
|
|
7384
|
+
//# sourceMappingURL=program-C2DP0mVy.js.map
|
|
7385
|
+
//# debugId=b26e6779-10c9-5732-b40c-04e75265338c
|