@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.
- package/NOTICE +15 -0
- package/README.md +108 -0
- package/dist/bin.js +8 -0
- package/dist/commands/doctor.js +135 -0
- package/dist/commands/meta.js +283 -0
- package/dist/commands/project.js +645 -0
- package/dist/commands/time.js +555 -0
- package/dist/commands/version.js +47 -0
- package/dist/commands/wp.js +1586 -0
- package/dist/context/metadata.js +528 -0
- package/dist/context/profile.js +218 -0
- package/dist/context/resolve.js +96 -0
- package/dist/core/define.js +155 -0
- package/dist/core/duration.js +151 -0
- package/dist/core/errors.js +102 -0
- package/dist/core/filters.js +133 -0
- package/dist/core/hal.js +114 -0
- package/dist/core/http.js +119 -0
- package/dist/core/paginate.js +55 -0
- package/dist/core/usage.js +58 -0
- package/dist/io/node-io.js +89 -0
- package/dist/output/table.js +52 -0
- package/dist/run.js +300 -0
- package/package.json +53 -0
|
@@ -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
|
package/dist/core/hal.js
ADDED
|
@@ -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
|