@tuanhv/op-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ export const ERROR_CATALOGUE = {
2
+ USAGE_ERROR: {
3
+ exitCode: 1,
4
+ message: "Invalid command usage.",
5
+ hint: "run op-cli --help.",
6
+ },
7
+ PROFILE_NOT_FOUND: {
8
+ exitCode: 1,
9
+ message: "No active profile.",
10
+ hint: "run op-cli auth login.",
11
+ },
12
+ NOT_FOUND: {
13
+ exitCode: 4,
14
+ message: "Not found.",
15
+ hint: "check the id; run op-cli meta refresh if names changed recently.",
16
+ },
17
+ API_ERROR: {
18
+ exitCode: 2,
19
+ message: "OpenProject request failed.",
20
+ hint: "try again later.",
21
+ },
22
+ INTERNAL_ERROR: {
23
+ exitCode: 2,
24
+ message: "Unexpected failure.",
25
+ hint: "retry or report the error.",
26
+ },
27
+ AUTH_FAILED: {
28
+ exitCode: 3,
29
+ message: "Authentication failed.",
30
+ hint: "run op-cli auth login.",
31
+ },
32
+ UNSUPPORTED_VERSION: {
33
+ exitCode: 7,
34
+ message: "The instance version is not supported.",
35
+ hint: "OpenProject v13 with API v3 or newer is required.",
36
+ },
37
+ CONFLICT: {
38
+ exitCode: 5,
39
+ message: "The work package was modified while the update ran.",
40
+ hint: "read the current values, merge them with your change, and repeat.",
41
+ },
42
+ NETWORK_ERROR: {
43
+ exitCode: 6,
44
+ message: "Could not reach the instance.",
45
+ hint: "check the instance URL and try again.",
46
+ },
47
+ };
48
+ export class OpCliError extends Error {
49
+ code;
50
+ exitCode;
51
+ hint;
52
+ /** Extra machine-readable facts merged into JSON error output. */
53
+ details;
54
+ // The code stays inside the closed catalogue; only the wording is
55
+ // contextual, so scripts matching on codes keep their promise.
56
+ constructor(code, message, hint, details) {
57
+ const definition = ERROR_CATALOGUE[code];
58
+ super(message ?? definition.message);
59
+ this.name = "OpCliError";
60
+ this.code = code;
61
+ this.exitCode = definition.exitCode;
62
+ this.hint = hint ?? definition.hint;
63
+ this.details = details;
64
+ }
65
+ }
66
+ /** OpenProject's own explanation of a refusal, when the body carries one. */
67
+ function explanationOf(body) {
68
+ const message = typeof body === "object" && body !== null
69
+ ? body.message
70
+ : undefined;
71
+ return typeof message === "string" ? message : undefined;
72
+ }
73
+ /**
74
+ * The catalogue entry for a write the instance refused. A refusal is not
75
+ * an outage: the same request will be refused the same way until the
76
+ * values change, so it must not borrow the "try again later" hint that
77
+ * belongs to an unreachable or overloaded instance. The one exception is
78
+ * 429, where waiting is exactly the repair.
79
+ */
80
+ export function writeRefusal(subject, status, body) {
81
+ const explanation = explanationOf(body);
82
+ const message = `OpenProject rejected the ${subject}`
83
+ + (explanation === undefined ? "." : `: ${explanation}`);
84
+ if (status === 429) {
85
+ return new OpCliError("API_ERROR", message);
86
+ }
87
+ return new OpCliError("API_ERROR", message, "fix the rejected values and repeat the command.");
88
+ }
89
+ export function renderTextError(error) {
90
+ return `[${error.code}] ${error.message} Hint: ${error.hint}\n`;
91
+ }
92
+ export function renderJsonError(error) {
93
+ return `${JSON.stringify({
94
+ error: {
95
+ code: error.code,
96
+ message: error.message,
97
+ hint: error.hint,
98
+ ...(error.details ?? {}),
99
+ },
100
+ })}\n`;
101
+ }
102
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1,133 @@
1
+ import { OpCliError } from "./errors.js";
2
+ const DATE_FORMS = "today, yesterday, a number of days such as 7d, or an explicit YYYY-MM-DD date";
3
+ export function isoDate(date) {
4
+ const month = String(date.getMonth() + 1).padStart(2, "0");
5
+ const day = String(date.getDate()).padStart(2, "0");
6
+ return `${String(date.getFullYear())}-${month}-${day}`;
7
+ }
8
+ function isCalendarDate(raw) {
9
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
10
+ return false;
11
+ }
12
+ const year = Number(raw.slice(0, 4));
13
+ const month = Number(raw.slice(5, 7));
14
+ const day = Number(raw.slice(8, 10));
15
+ const probe = new Date(year, month - 1, day);
16
+ return (probe.getFullYear() === year
17
+ && probe.getMonth() === month - 1
18
+ && probe.getDate() === day);
19
+ }
20
+ function daysAgo(now, days) {
21
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate() - days);
22
+ }
23
+ /**
24
+ * Turn the raw `--updated-after` input into the local date the window
25
+ * opens at: today, yesterday, N days ago, or an explicit date. The
26
+ * upper bound is deliberately left open (#24): the API reads a closed
27
+ * one as the start of that day, so every change made during the
28
+ * closing day would drop out. Callers emit `<>d [date, ""]`.
29
+ */
30
+ export function sinceDate(raw, now) {
31
+ const token = raw.trim().toLowerCase();
32
+ const today = isoDate(now);
33
+ if (token === "today") {
34
+ return today;
35
+ }
36
+ if (token === "yesterday") {
37
+ return isoDate(daysAgo(now, 1));
38
+ }
39
+ const relative = /^(\d+)d$/.exec(token);
40
+ if (relative !== null) {
41
+ const days = Number(relative[1]);
42
+ if (days >= 1) {
43
+ return isoDate(daysAgo(now, days));
44
+ }
45
+ }
46
+ if (/^\d{4}-\d{2}-\d{2}$/.test(token)) {
47
+ if (!isCalendarDate(token)) {
48
+ throw new OpCliError("USAGE_ERROR", `"${raw}" is not a real date.`, `--updated-after accepts ${DATE_FORMS}.`);
49
+ }
50
+ if (token > today) {
51
+ throw new OpCliError("USAGE_ERROR", `"${raw}" is in the future.`, "--updated-after needs a date up to today.");
52
+ }
53
+ return token;
54
+ }
55
+ throw new OpCliError("USAGE_ERROR", `cannot read "${raw}" as an updated-after value.`, `--updated-after accepts ${DATE_FORMS}.`);
56
+ }
57
+ function unique(values) {
58
+ return [...new Set(values)];
59
+ }
60
+ /**
61
+ * Build the exact filter JSON the work-packages endpoint expects. Flag
62
+ * combinations that cannot reach the API as one query (--open with
63
+ * --closed, either with explicit statuses) fail here so both commands
64
+ * refuse them before any network traffic.
65
+ */
66
+ export function buildWpFilters(flags, now) {
67
+ if (flags.open === true && flags.closed === true) {
68
+ throw new OpCliError("USAGE_ERROR", "--open and --closed cannot be combined.", "pass only one of the two shorthands.");
69
+ }
70
+ const statuses = unique(flags.statuses ?? []);
71
+ if ((flags.open === true || flags.closed === true) && statuses.length > 0) {
72
+ throw new OpCliError("USAGE_ERROR", "--status cannot be combined with --open or --closed.", "list explicit statuses or use one shorthand, not both.");
73
+ }
74
+ const filters = [];
75
+ // The project leads: it is the scope every other clause narrows, and it
76
+ // is the same clause the project-scoped collection applies internally,
77
+ // subprojects included.
78
+ if (flags.project !== undefined) {
79
+ filters.push({ name: "project", operator: "=", values: [flags.project] });
80
+ }
81
+ if (flags.open === true) {
82
+ filters.push({ name: "status", operator: "o", values: [] });
83
+ }
84
+ else if (flags.closed === true) {
85
+ filters.push({ name: "status", operator: "c", values: [] });
86
+ }
87
+ else if (statuses.length > 0) {
88
+ filters.push({ name: "status", operator: "=", values: statuses });
89
+ }
90
+ if ((flags.types ?? []).length > 0) {
91
+ filters.push({ name: "type", operator: "=", values: unique(flags.types) });
92
+ }
93
+ if ((flags.assignees ?? []).length > 0) {
94
+ filters.push({ name: "assigned_to", operator: "=", values: unique(flags.assignees) });
95
+ }
96
+ if ((flags.authors ?? []).length > 0) {
97
+ filters.push({ name: "author", operator: "=", values: unique(flags.authors) });
98
+ }
99
+ if ((flags.versions ?? []).length > 0) {
100
+ filters.push({ name: "version", operator: "=", values: unique(flags.versions) });
101
+ }
102
+ if ((flags.categories ?? []).length > 0) {
103
+ filters.push({ name: "category", operator: "=", values: unique(flags.categories) });
104
+ }
105
+ if ((flags.priorities ?? []).length > 0) {
106
+ filters.push({ name: "priority", operator: "=", values: unique(flags.priorities) });
107
+ }
108
+ if ((flags.parents ?? []).length > 0) {
109
+ filters.push({ name: "parent", operator: "=", values: unique(flags.parents) });
110
+ }
111
+ if (flags.updatedAfter !== undefined) {
112
+ const since = sinceDate(flags.updatedAfter, now);
113
+ filters.push({
114
+ name: "updated_at",
115
+ operator: "<>d",
116
+ // The empty upper bound is the fix for #24: a closed one drops
117
+ // everything changed during its own day.
118
+ values: [since, ""],
119
+ });
120
+ }
121
+ return filters;
122
+ }
123
+ /**
124
+ * Serialise clauses into the URL-encoded `filters` query parameter
125
+ * value, in wire shape: one object per clause keyed by filter name.
126
+ */
127
+ export function filtersQuery(filters) {
128
+ const wire = filters.map((filter) => ({
129
+ [filter.name]: { operator: filter.operator, values: [...filter.values] },
130
+ }));
131
+ return encodeURIComponent(JSON.stringify(wire));
132
+ }
133
+ //# sourceMappingURL=filters.js.map
@@ -0,0 +1,114 @@
1
+ /** Trailing numeric segment of an href ("/api/v3/types/2" -> 2). */
2
+ export function halRefId(href) {
3
+ if (href === undefined || href === null) {
4
+ return null;
5
+ }
6
+ const match = /(\d+)\/?$/.exec(href);
7
+ return match === null ? null : Number(match[1]);
8
+ }
9
+ export function flattenHalLink(link) {
10
+ const body = link;
11
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
12
+ return { id: null, name: null };
13
+ }
14
+ return {
15
+ id: halRefId(typeof body.href === "string" ? body.href : null),
16
+ name: typeof body.title === "string" ? body.title : null,
17
+ };
18
+ }
19
+ /**
20
+ * Whether a link belongs in the flat record. HAL mixes two kinds under
21
+ * `_links`: attributes that point at a resource, and operations the
22
+ * caller may perform. An operation declares its `method`, and its href
23
+ * often ends in the record's own id, so keeping it would spell an
24
+ * unrelated action as if it were a resource. A link that names nothing
25
+ * at all (`{"id":null,"name":null}` from a real href, such as a
26
+ * sub-collection endpoint) carries no fact either. What stays is every
27
+ * link that names a resource, plus the unset attributes whose href is
28
+ * null: "no version" is data.
29
+ */
30
+ function namesAResource(link, flat) {
31
+ const body = link;
32
+ if (typeof body === "object" && body !== null && body.method !== undefined) {
33
+ return false;
34
+ }
35
+ if (flat.id !== null || flat.name !== null) {
36
+ return true;
37
+ }
38
+ return typeof body?.href !== "string";
39
+ }
40
+ function isFlatLink(value) {
41
+ const record = value;
42
+ if (typeof record !== "object" || record === null || Array.isArray(record)) {
43
+ return false;
44
+ }
45
+ return (Object.keys(record).every((key) => key === "id" || key === "name")
46
+ && ("id" in record || "name" in record));
47
+ }
48
+ /**
49
+ * Flatten one HAL resource in place: `_type` is dropped, `_embedded` is
50
+ * stripped (embedded resources are never merged into the record), every
51
+ * `_links` entry except `self` and the operations shrinks to
52
+ * `{ id, name }`, and scalars pass through untouched. The result is the
53
+ * bare record; nothing wraps it. See ADR-0003 for what the shape
54
+ * promises.
55
+ */
56
+ export function flattenHalRecord(record) {
57
+ const resource = record;
58
+ if (typeof resource !== "object" || resource === null || Array.isArray(resource)) {
59
+ throw new TypeError("expected a HAL resource object");
60
+ }
61
+ const flat = {};
62
+ for (const [key, value] of Object.entries(resource)) {
63
+ if (key === "_type" || key === "_embedded" || key === "_links") {
64
+ continue;
65
+ }
66
+ flat[key] = Array.isArray(value)
67
+ ? value.map(flattenHalRecord)
68
+ : typeof value === "object" && value !== null
69
+ ? flattenHalRecord(value)
70
+ : value;
71
+ }
72
+ const links = resource._links;
73
+ if (typeof links === "object" && links !== null) {
74
+ for (const [key, link] of Object.entries(links)) {
75
+ if (key === "self") {
76
+ continue;
77
+ }
78
+ if (Array.isArray(link)) {
79
+ flat[key] = link.map(flattenHalLink);
80
+ continue;
81
+ }
82
+ const flatLink = flattenHalLink(link);
83
+ if (namesAResource(link, flatLink)) {
84
+ flat[key] = flatLink;
85
+ }
86
+ }
87
+ }
88
+ return flat;
89
+ }
90
+ /**
91
+ * The write form of a Formattable field. API v3 models `description` and
92
+ * `comment` as `{ raw, format }`: a bare string is accepted, silently
93
+ * ignored, and reported as success, so every write goes through here.
94
+ */
95
+ export function toFormattable(value) {
96
+ return { raw: value };
97
+ }
98
+ /**
99
+ * The markdown behind a Formattable field as it arrives: the `raw` of the
100
+ * object the API returns, the string itself when an instance answers with
101
+ * one, null when the field carries nothing.
102
+ */
103
+ export function formattableRaw(value) {
104
+ if (typeof value === "string") {
105
+ return value;
106
+ }
107
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
108
+ return null;
109
+ }
110
+ const raw = value.raw;
111
+ return typeof raw === "string" ? raw : null;
112
+ }
113
+ export { isFlatLink };
114
+ //# sourceMappingURL=hal.js.map
@@ -0,0 +1,119 @@
1
+ import { OpCliError } from "./errors.js";
2
+ export async function authenticate(instanceUrl, apiKey) {
3
+ const user = (await apiGet(instanceUrl, apiKey, "/api/v3/users/me"));
4
+ return {
5
+ id: user.id,
6
+ name: user.name,
7
+ login: user.login,
8
+ };
9
+ }
10
+ // Reads may burn exactly one extra attempt on a transient 429/5xx; writes
11
+ // never retry (ADR-0002): a replayed create or update can duplicate data.
12
+ const READ_RETRY_DELAY_MS = 200;
13
+ // The per-attempt budget is a policy of the run environment, not a
14
+ // constant: OP_CLI_TIMEOUT_MS names a larger one for slow reads, and an
15
+ // unusable value is refused like a bad --limit instead of silently
16
+ // falling back.
17
+ const DEFAULT_TIMEOUT_MS = 10_000;
18
+ function parseTimeoutMs(raw) {
19
+ if (raw === undefined) {
20
+ return DEFAULT_TIMEOUT_MS;
21
+ }
22
+ if (!/^\d+$/.test(raw) || Number(raw) < 1) {
23
+ throw new OpCliError("USAGE_ERROR", `OP_CLI_TIMEOUT_MS "${raw}" is not a positive integer.`, "pass a whole number of milliseconds, 1 or more.");
24
+ }
25
+ return Number(raw);
26
+ }
27
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
28
+ /**
29
+ * Derive the per-request timeout from the run environment. Called once
30
+ * per run so every request of one session shares one budget.
31
+ */
32
+ export function bindRequestTimeout(env) {
33
+ timeoutMs = parseTimeoutMs(env.OP_CLI_TIMEOUT_MS);
34
+ }
35
+ async function attempt(instanceUrl, apiKey, path, method, body) {
36
+ // AbortSignal.timeout aborts with a TimeoutError reason. A budget
37
+ // problem (too impatient) and a reachability problem (wrong URL)
38
+ // share the NETWORK_ERROR code and exit 6 by contract; only the
39
+ // wording and machine-readable details tell them apart.
40
+ const signal = AbortSignal.timeout(timeoutMs);
41
+ try {
42
+ return await fetch(`${instanceUrl}${path}`, {
43
+ headers: {
44
+ accept: "application/hal+json",
45
+ authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString("base64")}`,
46
+ // The API rejects non-JSON bodies with 415; without this header
47
+ // fetch would label the body text/plain.
48
+ ...(method === "POST" || method === "PATCH"
49
+ ? { "content-type": "application/json" }
50
+ : {}),
51
+ },
52
+ signal,
53
+ ...(method === "POST" || method === "PATCH"
54
+ ? { method, body: JSON.stringify(body) }
55
+ : { method }),
56
+ });
57
+ }
58
+ catch {
59
+ if (signal.reason instanceof Error && signal.reason.name === "TimeoutError") {
60
+ throw new OpCliError("NETWORK_ERROR", `The request exceeded the ${timeoutMs} ms timeout.`, "raise OP_CLI_TIMEOUT_MS and try again.", { timedOut: true, timeoutMs });
61
+ }
62
+ throw new OpCliError("NETWORK_ERROR");
63
+ }
64
+ }
65
+ async function request(instanceUrl, apiKey, path, method, body) {
66
+ let response = await attempt(instanceUrl, apiKey, path, method, body);
67
+ if (method === "GET" && (response.status === 429 || response.status >= 500)) {
68
+ // Promise.withResolvers needs ES2024 lib; the executor form stays.
69
+ await new Promise((resolve) => setTimeout(resolve, READ_RETRY_DELAY_MS));
70
+ response = await attempt(instanceUrl, apiKey, path, method, body);
71
+ }
72
+ if (response.status === 401 || response.status === 403) {
73
+ throw new OpCliError("AUTH_FAILED");
74
+ }
75
+ return response;
76
+ }
77
+ async function send(instanceUrl, apiKey, path, method, body) {
78
+ const response = await request(instanceUrl, apiKey, path, method, body);
79
+ if (response.status === 204) {
80
+ return undefined;
81
+ }
82
+ if (response.status === 404) {
83
+ throw new OpCliError("NOT_FOUND");
84
+ }
85
+ if (!response.ok) {
86
+ throw new OpCliError("API_ERROR");
87
+ }
88
+ return await response.json();
89
+ }
90
+ async function bodyOf(response) {
91
+ const text = await response.text();
92
+ if (text === "") {
93
+ return undefined;
94
+ }
95
+ try {
96
+ return JSON.parse(text);
97
+ }
98
+ catch {
99
+ return text;
100
+ }
101
+ }
102
+ export async function apiPostRaw(instanceUrl, apiKey, path, body) {
103
+ const response = await request(instanceUrl, apiKey, path, "POST", body);
104
+ return { status: response.status, body: await bodyOf(response) };
105
+ }
106
+ export async function apiPatchRaw(instanceUrl, apiKey, path, body) {
107
+ const response = await request(instanceUrl, apiKey, path, "PATCH", body);
108
+ return { status: response.status, body: await bodyOf(response) };
109
+ }
110
+ export async function apiDelete(instanceUrl, apiKey, path) {
111
+ return send(instanceUrl, apiKey, path, "DELETE");
112
+ }
113
+ export async function apiGet(instanceUrl, apiKey, path) {
114
+ return send(instanceUrl, apiKey, path, "GET");
115
+ }
116
+ export async function apiPost(instanceUrl, apiKey, path, body) {
117
+ return send(instanceUrl, apiKey, path, "POST", body);
118
+ }
119
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1,55 @@
1
+ import { OpCliError } from "./errors.js";
2
+ export const DEFAULT_PAGE_SIZE = 100;
3
+ /** Same contract as the flag of `wp list`: a whole number of 1 or more. */
4
+ export function parsePageSize(raw) {
5
+ if (raw === undefined) {
6
+ return DEFAULT_PAGE_SIZE;
7
+ }
8
+ if (!/^\d+$/.test(raw) || Number(raw) < 1) {
9
+ throw new OpCliError("USAGE_ERROR", `--limit "${raw}" is not a positive integer.`, "pass a whole number of 1 or more.");
10
+ }
11
+ return Number(raw);
12
+ }
13
+ /** Append pageSize to an endpoint path that may or may not carry a query. */
14
+ export function withPageSize(path, size) {
15
+ return `${path}${path.includes("?") ? "&" : "?"}pageSize=${String(size)}`;
16
+ }
17
+ /**
18
+ * The page size an --all walk requests: the largest page the instance
19
+ * allows, taken from the maximumAPIV3PageSize the API root advertises,
20
+ * with the collection default as the fallback. One root document read
21
+ * replaces a walk of dozens of --limit-sized pages.
22
+ */
23
+ export async function allPageSize(getPage) {
24
+ const root = (await getPage("/api/v3/"));
25
+ const advertised = root?.maximumAPIV3PageSize;
26
+ return typeof advertised === "number" && advertised >= 1
27
+ ? advertised
28
+ : DEFAULT_PAGE_SIZE;
29
+ }
30
+ /**
31
+ * Reduce a nextByOffset href (path or absolute URL) to the path the API
32
+ * client should request next, or undefined when the collection ended.
33
+ */
34
+ export function halNextPath(href) {
35
+ if (href === undefined || href === "") {
36
+ return undefined;
37
+ }
38
+ const url = new URL(href, "https://op-cli.invalid");
39
+ return `${url.pathname}${url.search}`;
40
+ }
41
+ /**
42
+ * Walk a HAL collection page by page, yielding every element in server
43
+ * order. The only pagination contract is `_links.nextByOffset`.
44
+ */
45
+ export async function* halElements(getPage, startPath) {
46
+ let path = startPath;
47
+ while (path !== undefined) {
48
+ const collection = (await getPage(path));
49
+ for (const element of collection._embedded?.elements ?? []) {
50
+ yield element;
51
+ }
52
+ path = halNextPath(collection._links?.nextByOffset?.href);
53
+ }
54
+ }
55
+ //# sourceMappingURL=paginate.js.map
@@ -0,0 +1,58 @@
1
+ import { OpCliError } from "./errors.js";
2
+ /**
3
+ * The single-quoted token Commander puts in its own prose: the offending
4
+ * flag, option declaration, argument name, or command.
5
+ */
6
+ function quoted(detail) {
7
+ return /'([^']*)'/.exec(detail)?.[1];
8
+ }
9
+ /** Commander's suggestion tail, "(Did you mean --field?)", if it made one. */
10
+ function suggestion(message) {
11
+ const match = /\(Did you mean (.+)\?\)/.exec(message);
12
+ return match === null ? "" : ` Did you mean ${match[1]}?`;
13
+ }
14
+ /** Commander's first prose line without its "error: " prefix. */
15
+ function detailOf(message) {
16
+ return (message.split("\n")[0] ?? "").replace(/^error:\s*/, "");
17
+ }
18
+ /**
19
+ * Commander's prose, ended as a sentence and requoted: the catalogue
20
+ * spells names in double quotes, and a message that mixes both reads as
21
+ * two voices.
22
+ */
23
+ function sentence(detail) {
24
+ const requoted = detail.replace(/'([^']*)'/g, '"$1"');
25
+ return requoted.endsWith(".") ? requoted : `${requoted}.`;
26
+ }
27
+ /**
28
+ * The catalogue line for a parse failure. Commander knows which flag,
29
+ * argument, or command was refused and by whom; the code stays the
30
+ * stable `USAGE_ERROR` while the wording carries those facts instead of
31
+ * discarding them.
32
+ */
33
+ export function usageErrorFrom(error, commandPath) {
34
+ const detail = detailOf(error.message);
35
+ const named = quoted(detail);
36
+ const hint = `run ${commandPath} --help to see what it accepts.`;
37
+ const message = (() => {
38
+ if (named === undefined) {
39
+ return sentence(detail === "" ? "Invalid command usage" : detail);
40
+ }
41
+ switch (error.code) {
42
+ case "commander.unknownOption":
43
+ return `unknown option "${named}".${suggestion(error.message)}`;
44
+ case "commander.unknownCommand":
45
+ return `unknown command "${named}".${suggestion(error.message)}`;
46
+ case "commander.missingMandatoryOptionValue":
47
+ return `required option "${named}" is missing.`;
48
+ case "commander.optionMissingArgument":
49
+ return `option "${named}" needs a value.`;
50
+ case "commander.missingArgument":
51
+ return `missing required argument "${named}".`;
52
+ default:
53
+ return sentence(detail);
54
+ }
55
+ })();
56
+ return new OpCliError("USAGE_ERROR", message, hint);
57
+ }
58
+ //# sourceMappingURL=usage.js.map
@@ -0,0 +1,89 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { StringDecoder } from "node:string_decoder";
3
+ import { OpCliError } from "../core/errors.js";
4
+ const ENV_HINT = "set OPENPROJECT_URL and OPENPROJECT_API_KEY in the environment for non-interactive use.";
5
+ /**
6
+ * The binary's RunIo over real streams. Both prompt styles are bounded:
7
+ * a readline question races the interface's close event, so EOF settles
8
+ * the wait instead of hanging the awaited run() forever, and a secret is
9
+ * read through a raw-mode byte loop that never forwards keystrokes to
10
+ * the output stream.
11
+ */
12
+ export function createNodeIo(input = process.stdin, output = process.stdout) {
13
+ const stdinIsTTY = input.isTTY === true;
14
+ // Executor form on purpose: Promise.withResolvers needs ES2024, and the
15
+ // published engines field still supports Node 20.
16
+ const askLine = (message) => new Promise((resolve, reject) => {
17
+ const line = createInterface({ input, output });
18
+ let settled = false;
19
+ const eofError = new OpCliError("USAGE_ERROR", "stdin closed while waiting for input.", ENV_HINT);
20
+ const settle = (finish) => {
21
+ if (settled) {
22
+ return;
23
+ }
24
+ settled = true;
25
+ line.removeListener("close", onClose);
26
+ finish();
27
+ // Closing after every question keeps the process free to exit;
28
+ // an open interface would hold the event loop open.
29
+ line.close();
30
+ };
31
+ const onClose = () => settle(() => reject(eofError));
32
+ line.once("close", onClose);
33
+ void line.question(message).then((answer) => settle(() => resolve(answer)), (error) => settle(() => reject(error)));
34
+ });
35
+ const readSecret = async (message) => {
36
+ output.write(message);
37
+ const wasRaw = input.isRaw === true;
38
+ if (typeof input.setRawMode === "function" && !wasRaw) {
39
+ input.setRawMode(true);
40
+ }
41
+ const decoder = new StringDecoder("utf8");
42
+ let answer = "";
43
+ let terminated = false;
44
+ try {
45
+ for await (const chunk of input) {
46
+ const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
47
+ for (const char of text) {
48
+ if (char === "\r" || char === "\n") {
49
+ terminated = true;
50
+ break;
51
+ }
52
+ if (char === "\b" || char === "\u007f") {
53
+ answer = answer.slice(0, -1);
54
+ continue;
55
+ }
56
+ if (char === "\u0003") {
57
+ throw new OpCliError("USAGE_ERROR", "credential entry cancelled.", ENV_HINT);
58
+ }
59
+ answer += char;
60
+ }
61
+ if (terminated) {
62
+ break;
63
+ }
64
+ }
65
+ if (!terminated) {
66
+ throw new OpCliError("USAGE_ERROR", "stdin closed while waiting for input.", ENV_HINT);
67
+ }
68
+ output.write("\n");
69
+ return answer;
70
+ }
71
+ finally {
72
+ if (typeof input.setRawMode === "function") {
73
+ input.setRawMode(wasRaw);
74
+ }
75
+ }
76
+ };
77
+ return {
78
+ stdinIsTTY,
79
+ prompt: (message, secret) => (secret ? readSecret(message) : askLine(message)),
80
+ readStdin: async () => {
81
+ const chunks = [];
82
+ for await (const chunk of input) {
83
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
84
+ }
85
+ return Buffer.concat(chunks).toString("utf8");
86
+ },
87
+ };
88
+ }
89
+ //# sourceMappingURL=node-io.js.map