@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,218 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { OpCliError } from "../core/errors.js";
|
|
4
|
+
export const LOGIN_PROFILE = "default";
|
|
5
|
+
function writableConfigDirectory(env) {
|
|
6
|
+
if (env.OP_CLI_CONFIG_DIR) {
|
|
7
|
+
return env.OP_CLI_CONFIG_DIR;
|
|
8
|
+
}
|
|
9
|
+
const home = env.HOME;
|
|
10
|
+
if (!home) {
|
|
11
|
+
throw new OpCliError("USAGE_ERROR");
|
|
12
|
+
}
|
|
13
|
+
return join(home, ".config", "op-cli");
|
|
14
|
+
}
|
|
15
|
+
async function readStored(env) {
|
|
16
|
+
const directory = env.OP_CLI_CONFIG_DIR ??
|
|
17
|
+
(env.HOME !== undefined ? join(env.HOME, ".config", "op-cli") : undefined);
|
|
18
|
+
if (directory === undefined) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const config = JSON.parse(await readFile(join(directory, "config.json"), "utf8"));
|
|
23
|
+
let credentials = {};
|
|
24
|
+
try {
|
|
25
|
+
credentials = JSON.parse(await readFile(join(directory, "credentials.json"), "utf8"));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Credentials are optional when only the profile identity is needed,
|
|
29
|
+
// e.g. --version reading cached metadata without an API key.
|
|
30
|
+
}
|
|
31
|
+
// `profiles` is required by the type but comes straight from JSON, so a
|
|
32
|
+
// config file that parses without it would otherwise reach every caller
|
|
33
|
+
// as a missing map. Normalising here keeps the guards downstream simple.
|
|
34
|
+
return {
|
|
35
|
+
config: { ...config, profiles: config.profiles ?? {} },
|
|
36
|
+
credentials,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function writeStored(env, parts) {
|
|
44
|
+
const directory = writableConfigDirectory(env);
|
|
45
|
+
await mkdir(directory, { recursive: true });
|
|
46
|
+
if (parts.config) {
|
|
47
|
+
await writeFile(join(directory, "config.json"), `${JSON.stringify(parts.config, null, 2)}\n`);
|
|
48
|
+
}
|
|
49
|
+
if (!parts.credentials) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const credentialsPath = join(directory, "credentials.json");
|
|
53
|
+
await writeFile(credentialsPath, `${JSON.stringify(parts.credentials, null, 2)}\n`, {
|
|
54
|
+
mode: 0o600,
|
|
55
|
+
});
|
|
56
|
+
await chmod(credentialsPath, 0o600);
|
|
57
|
+
}
|
|
58
|
+
export async function saveProfile(env, instanceUrl, apiKey, name = LOGIN_PROFILE, project) {
|
|
59
|
+
const state = await readStored(env);
|
|
60
|
+
const profiles = {
|
|
61
|
+
...state?.config.profiles,
|
|
62
|
+
[name]: project === undefined
|
|
63
|
+
? { url: instanceUrl }
|
|
64
|
+
: { url: instanceUrl, project },
|
|
65
|
+
};
|
|
66
|
+
const credentials = {
|
|
67
|
+
...state?.credentials,
|
|
68
|
+
[name]: { api_key: apiKey },
|
|
69
|
+
};
|
|
70
|
+
const config = { profiles };
|
|
71
|
+
// Only the first login ever claims default+active; logging into another
|
|
72
|
+
// profile merges alongside instead of stealing the selection.
|
|
73
|
+
config.default_profile =
|
|
74
|
+
state?.config.default_profile ?? name;
|
|
75
|
+
config.active_profile = state?.config.active_profile ?? name;
|
|
76
|
+
await writeStored(env, { config, credentials });
|
|
77
|
+
}
|
|
78
|
+
function storedSelectedName(state) {
|
|
79
|
+
const { config } = state;
|
|
80
|
+
if (config.active_profile && config.profiles[config.active_profile]) {
|
|
81
|
+
return config.active_profile;
|
|
82
|
+
}
|
|
83
|
+
if (config.default_profile && config.profiles[config.default_profile]) {
|
|
84
|
+
return config.default_profile;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
export async function listProfiles(env) {
|
|
89
|
+
const state = await readStored(env);
|
|
90
|
+
if (!state) {
|
|
91
|
+
throw new OpCliError("PROFILE_NOT_FOUND");
|
|
92
|
+
}
|
|
93
|
+
const profiles = Object.entries(state.config.profiles ?? {}).map(([name, entry]) => ({
|
|
94
|
+
name,
|
|
95
|
+
instanceUrl: entry.url.replace(/\/+$/, ""),
|
|
96
|
+
project: entry.project,
|
|
97
|
+
}));
|
|
98
|
+
return { activeName: storedSelectedName(state), profiles };
|
|
99
|
+
}
|
|
100
|
+
export async function setActiveProfile(env, name, options = {}) {
|
|
101
|
+
const state = await readStored(env);
|
|
102
|
+
if (!state || !state.config.profiles[name]) {
|
|
103
|
+
throw new OpCliError("PROFILE_NOT_FOUND");
|
|
104
|
+
}
|
|
105
|
+
const profiles = {
|
|
106
|
+
...state.config.profiles,
|
|
107
|
+
};
|
|
108
|
+
const entry = state.config.profiles[name];
|
|
109
|
+
if (options.project !== undefined && entry !== undefined) {
|
|
110
|
+
profiles[name] = { ...entry, project: options.project };
|
|
111
|
+
}
|
|
112
|
+
const config = { active_profile: name, profiles };
|
|
113
|
+
if (state.config.default_profile !== undefined) {
|
|
114
|
+
config.default_profile = state.config.default_profile;
|
|
115
|
+
}
|
|
116
|
+
await writeStored(env, { config });
|
|
117
|
+
}
|
|
118
|
+
export async function requireStoredName(env) {
|
|
119
|
+
const state = await readStored(env);
|
|
120
|
+
const name = state ? storedSelectedName(state) : undefined;
|
|
121
|
+
if (!name) {
|
|
122
|
+
throw new OpCliError("PROFILE_NOT_FOUND");
|
|
123
|
+
}
|
|
124
|
+
return name;
|
|
125
|
+
}
|
|
126
|
+
export async function removeProfile(env, name) {
|
|
127
|
+
const state = await readStored(env);
|
|
128
|
+
if (!state || !(state.config.profiles[name] || state.credentials[name])) {
|
|
129
|
+
throw new OpCliError("PROFILE_NOT_FOUND");
|
|
130
|
+
}
|
|
131
|
+
const credentials = { ...state.credentials };
|
|
132
|
+
delete credentials[name];
|
|
133
|
+
await writeStored(env, { credentials });
|
|
134
|
+
}
|
|
135
|
+
export const ENV_PROFILE_NAME = "env";
|
|
136
|
+
function firstDefined(...values) {
|
|
137
|
+
for (const value of values) {
|
|
138
|
+
if (value) {
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
export function parseOptionalId(raw) {
|
|
145
|
+
if (raw === undefined || raw === "") {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
const value = Number(raw);
|
|
149
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
150
|
+
throw new OpCliError("USAGE_ERROR");
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
export async function resolveProfile(env, overrides = {}) {
|
|
155
|
+
const state = await readStored(env);
|
|
156
|
+
const explicitName = firstDefined(overrides.profile, env.OP_CLI_PROFILE);
|
|
157
|
+
// A named profile has to exist, whether or not a config file was ever
|
|
158
|
+
// written: environment variables alone form an implicit *unnamed* profile,
|
|
159
|
+
// so no name can select it. Falling through here would run the named
|
|
160
|
+
// command against the environment instance and report it as healthy.
|
|
161
|
+
if (explicitName !== undefined && !state?.config.profiles[explicitName]) {
|
|
162
|
+
throw new OpCliError("PROFILE_NOT_FOUND");
|
|
163
|
+
}
|
|
164
|
+
const storedName = state ? storedSelectedName(state) : undefined;
|
|
165
|
+
const name = explicitName ?? storedName;
|
|
166
|
+
const entry = name !== undefined ? state?.config.profiles[name] : undefined;
|
|
167
|
+
const secret = name !== undefined ? state?.credentials[name]?.api_key : undefined;
|
|
168
|
+
// An explicit --profile flag sits at the top of the documented order: the
|
|
169
|
+
// named entry's own URL and key beat the environment for this command.
|
|
170
|
+
// OP_CLI_PROFILE stays in the environment tier: environment first, then the
|
|
171
|
+
// selected entry's stored values (#20).
|
|
172
|
+
const flagCarriesValues = overrides.profile !== undefined;
|
|
173
|
+
const instanceUrl = (flagCarriesValues
|
|
174
|
+
? firstDefined(entry?.url, env.OPENPROJECT_URL)
|
|
175
|
+
: firstDefined(env.OPENPROJECT_URL, entry?.url))?.replace(/\/+$/, "");
|
|
176
|
+
const apiKey = flagCarriesValues
|
|
177
|
+
? firstDefined(secret, env.OPENPROJECT_API_KEY)
|
|
178
|
+
: firstDefined(env.OPENPROJECT_API_KEY, secret);
|
|
179
|
+
if (!instanceUrl) {
|
|
180
|
+
throw new OpCliError("PROFILE_NOT_FOUND");
|
|
181
|
+
}
|
|
182
|
+
if (!apiKey) {
|
|
183
|
+
throw new OpCliError("AUTH_FAILED");
|
|
184
|
+
}
|
|
185
|
+
// Claim the profile name only when its own values were actually used; a run
|
|
186
|
+
// served by the environment renders as the environment profile so it can
|
|
187
|
+
// never contradict what auth list shows for that name.
|
|
188
|
+
const claimsProfile = entry !== undefined &&
|
|
189
|
+
name !== undefined &&
|
|
190
|
+
instanceUrl === entry.url.replace(/\/+$/, "") &&
|
|
191
|
+
apiKey === secret;
|
|
192
|
+
return {
|
|
193
|
+
name: claimsProfile ? name : ENV_PROFILE_NAME,
|
|
194
|
+
instanceUrl,
|
|
195
|
+
apiKey,
|
|
196
|
+
project: overrides.project ??
|
|
197
|
+
parseOptionalId(env.OP_CLI_PROJECT) ??
|
|
198
|
+
entry?.project,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
// Resolves the active profile identity without requiring credentials, for
|
|
202
|
+
// read-only commands such as --version that must run without an API key.
|
|
203
|
+
export async function peekActiveProfile(env) {
|
|
204
|
+
const state = await readStored(env);
|
|
205
|
+
const explicitName = env.OP_CLI_PROFILE;
|
|
206
|
+
const storedName = state ? storedSelectedName(state) : undefined;
|
|
207
|
+
const name = explicitName ?? storedName;
|
|
208
|
+
const entry = name !== undefined ? state?.config.profiles[name] : undefined;
|
|
209
|
+
const instanceUrl = firstDefined(env.OPENPROJECT_URL, entry?.url)?.replace(/\/+$/, "");
|
|
210
|
+
if (!instanceUrl) {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
name: entry !== undefined && name !== undefined ? name : ENV_PROFILE_NAME,
|
|
215
|
+
instanceUrl,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
//# sourceMappingURL=profile.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { OpCliError } from "../core/errors.js";
|
|
2
|
+
export function isIdForm(raw) {
|
|
3
|
+
return /^\d+$/.test(raw);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* The explicit `customFieldN` spelling of a field key. It is accepted
|
|
7
|
+
* wherever a human field name is, to disambiguate a name defined by more
|
|
8
|
+
* than one work package type.
|
|
9
|
+
*/
|
|
10
|
+
export function explicitCustomFieldKey(raw) {
|
|
11
|
+
const normalized = raw.toLowerCase();
|
|
12
|
+
return /^customfield\d+$/.test(normalized)
|
|
13
|
+
? normalized.replace(/^customfield/, "customField")
|
|
14
|
+
: undefined;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Exact-name matching over one vocabulary snapshot. Entries carrying the
|
|
18
|
+
* same value collapse together (the same user may sit in several
|
|
19
|
+
* memberships); distinct values under one name are ambiguous.
|
|
20
|
+
*/
|
|
21
|
+
export function matchByName(raw, entries) {
|
|
22
|
+
const hits = entries.filter((entry) => entry.name === raw);
|
|
23
|
+
if (hits.length === 0) {
|
|
24
|
+
return { kind: "missing" };
|
|
25
|
+
}
|
|
26
|
+
const distinct = new Map();
|
|
27
|
+
for (const entry of hits) {
|
|
28
|
+
const key = typeof entry.value === "string" ? entry.value : String(entry.value);
|
|
29
|
+
if (!distinct.has(key)) {
|
|
30
|
+
distinct.set(key, entry);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const candidates = [...distinct.values()];
|
|
34
|
+
return candidates.length === 1
|
|
35
|
+
? { kind: "unique", entry: candidates[0] }
|
|
36
|
+
: { kind: "ambiguous", candidates };
|
|
37
|
+
}
|
|
38
|
+
function editDistance(a, b) {
|
|
39
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
40
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
41
|
+
const current = [i];
|
|
42
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
43
|
+
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
44
|
+
}
|
|
45
|
+
previous = current;
|
|
46
|
+
}
|
|
47
|
+
return previous[b.length];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Valid values ordered by closeness to what the caller typed (case
|
|
51
|
+
* ignored), ties alphabetical, so a failed resolution teaches instead of
|
|
52
|
+
* just failing.
|
|
53
|
+
*/
|
|
54
|
+
export function rankByCloseness(raw, names) {
|
|
55
|
+
const needle = raw.toLowerCase();
|
|
56
|
+
return [...new Set(names)].sort((a, b) => editDistance(needle, a.toLowerCase()) - editDistance(needle, b.toLowerCase())
|
|
57
|
+
|| a.localeCompare(b));
|
|
58
|
+
}
|
|
59
|
+
function missingError(raw, source, names) {
|
|
60
|
+
const hint = "run op-cli meta refresh if the instance changed recently.";
|
|
61
|
+
// An empty vocabulary has nothing to rank, and "closest first: ." reads
|
|
62
|
+
// as a rendering fault rather than as the fact that there is no
|
|
63
|
+
// candidate at all.
|
|
64
|
+
if (names.length === 0) {
|
|
65
|
+
return new OpCliError("USAGE_ERROR", `${source.label} "${raw}" not found; no ${source.label} is available to match.`, hint);
|
|
66
|
+
}
|
|
67
|
+
return new OpCliError("USAGE_ERROR", `${source.label} "${raw}" not found. Valid values, closest first: `
|
|
68
|
+
+ `${rankByCloseness(raw, names).join(", ")}.`, hint);
|
|
69
|
+
}
|
|
70
|
+
function ambiguityError(raw, source, candidates) {
|
|
71
|
+
const listed = candidates
|
|
72
|
+
.map((candidate) => `${String(candidate.value)} (${candidate.owner})`)
|
|
73
|
+
.join(", ");
|
|
74
|
+
return new OpCliError("USAGE_ERROR", `${source.label} "${raw}" is ambiguous. Candidates: ${listed}.`, "repeat the value in the explicit id or key form of one candidate.");
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolve a non-id value against metadata: exact match wins, a miss
|
|
78
|
+
* triggers exactly one refresh and one retry, and every remaining failure
|
|
79
|
+
* is a loud exit-1 misuse that never guesses on the caller's behalf.
|
|
80
|
+
*/
|
|
81
|
+
export async function resolveName(raw, source) {
|
|
82
|
+
let match = matchByName(raw, await source.load());
|
|
83
|
+
if (match.kind === "missing") {
|
|
84
|
+
await source.refresh();
|
|
85
|
+
match = matchByName(raw, await source.load());
|
|
86
|
+
}
|
|
87
|
+
if (match.kind === "unique") {
|
|
88
|
+
return match.entry.value;
|
|
89
|
+
}
|
|
90
|
+
if (match.kind === "ambiguous") {
|
|
91
|
+
throw ambiguityError(raw, source, match.candidates);
|
|
92
|
+
}
|
|
93
|
+
const all = await source.load();
|
|
94
|
+
throw missingError(raw, source, all.map((entry) => entry.name));
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=resolve.js.map
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { OpCliError } from "./errors.js";
|
|
3
|
+
import { formatCell, renderTable } from "../output/table.js";
|
|
4
|
+
import { allPageSize, halElements, parsePageSize, withPageSize } from "./paginate.js";
|
|
5
|
+
import { isIdForm, rankByCloseness } from "../context/resolve.js";
|
|
6
|
+
// Commander camel-cases option names ("--open-only" -> options.openOnly),
|
|
7
|
+
// so the declared flag must go through the same transformation before the
|
|
8
|
+
// parsed options object is consulted.
|
|
9
|
+
function optionKey(flag) {
|
|
10
|
+
return flag.replace(/^--/, "").replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
11
|
+
}
|
|
12
|
+
export function defineLookupCommand(spec, runtime) {
|
|
13
|
+
const command = new Command(spec.name).description(spec.description);
|
|
14
|
+
const filters = spec.filters ?? [];
|
|
15
|
+
for (const filter of filters) {
|
|
16
|
+
command.option(filter.flag, filter.description);
|
|
17
|
+
}
|
|
18
|
+
for (const option of spec.options ?? []) {
|
|
19
|
+
command.option(option.flag, option.description);
|
|
20
|
+
}
|
|
21
|
+
command.option("--json", "emit a JSON array");
|
|
22
|
+
command.action(async (options) => {
|
|
23
|
+
runtime.setJsonMode(options.json === true);
|
|
24
|
+
const active = filters.filter((filter) => options[optionKey(filter.flag)] === true);
|
|
25
|
+
if (active.length > 1) {
|
|
26
|
+
throw new OpCliError("USAGE_ERROR");
|
|
27
|
+
}
|
|
28
|
+
let rows = spec.select(await runtime.load(options));
|
|
29
|
+
if (active.length === 1) {
|
|
30
|
+
rows = rows.filter(active[0]?.keep ?? (() => true));
|
|
31
|
+
}
|
|
32
|
+
if (options.json === true) {
|
|
33
|
+
runtime.write(`${JSON.stringify(rows)}\n`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
runtime.write(renderTable(spec.columns.map((column) => column.title), rows.map((row) => spec.columns.map((column) => column.cell(row)))));
|
|
37
|
+
});
|
|
38
|
+
return command;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Help text of --json wherever --all exists. With --all the output is one
|
|
42
|
+
* record per line, not an array (#8), and a caller who reads only --help
|
|
43
|
+
* would otherwise write JSON.parse over the whole body.
|
|
44
|
+
*/
|
|
45
|
+
export const PAGED_JSON_HELP = "emit a flat JSON array, or NDJSON (one record per line) with --all";
|
|
46
|
+
/**
|
|
47
|
+
* The column view behind one invocation: --fields validated against the
|
|
48
|
+
* declared columns (before any traffic) plus the row picker it implies.
|
|
49
|
+
* Without --fields the table shows every declared column while JSON keeps
|
|
50
|
+
* the whole flat record, matching `wp list`.
|
|
51
|
+
*/
|
|
52
|
+
export function rowViewOf(columns, options) {
|
|
53
|
+
const fields = options.fields;
|
|
54
|
+
if (typeof fields !== "string") {
|
|
55
|
+
return { view: columns, pick: (row) => row };
|
|
56
|
+
}
|
|
57
|
+
const requested = [
|
|
58
|
+
...new Set(fields.split(",").map((name) => name.trim()).filter((name) => name !== "")),
|
|
59
|
+
];
|
|
60
|
+
const declared = columns.map((column) => column.field);
|
|
61
|
+
const missing = requested.find((name) => !declared.includes(name));
|
|
62
|
+
if (missing !== undefined) {
|
|
63
|
+
throw new OpCliError("USAGE_ERROR", `field "${missing}" is not a column. Valid fields, closest first: `
|
|
64
|
+
+ `${rankByCloseness(missing, declared).join(", ")}.`, "run the command without --fields to list every available column.");
|
|
65
|
+
}
|
|
66
|
+
const view = requested.map((name) => columns.find((column) => column.field === name));
|
|
67
|
+
return {
|
|
68
|
+
view,
|
|
69
|
+
pick: (row) => Object.fromEntries(view.map((column) => [column.field, row[column.field]])),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function renderRowsTable(view, rows) {
|
|
73
|
+
return renderTable(view.map((column) => column.title), rows.map((row) => view.map((column) => formatCell(row[column.field]))));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Render rows as a table or one flat JSON array. Shared by every
|
|
77
|
+
* non-streaming listing so field selection exists exactly once.
|
|
78
|
+
*/
|
|
79
|
+
export function emitRows(io, columns, rows, options) {
|
|
80
|
+
const { view, pick } = rowViewOf(columns, options);
|
|
81
|
+
if (options.json === true) {
|
|
82
|
+
io.write(`${JSON.stringify(rows.map(pick))}\n`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
io.write(renderRowsTable(view, rows));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Declaration of one paginated listing over a per-work-package HAL
|
|
89
|
+
* collection: pagination, the truncation notice, both output shapes and
|
|
90
|
+
* field selection all come from here; a declaration only names the
|
|
91
|
+
* endpoint, its rows, and its columns.
|
|
92
|
+
*/
|
|
93
|
+
export function defineCollectionCommand(spec, runtime) {
|
|
94
|
+
const command = new Command(spec.name)
|
|
95
|
+
.description(spec.description)
|
|
96
|
+
.argument("<id>", "work package id")
|
|
97
|
+
.option("--json", PAGED_JSON_HELP)
|
|
98
|
+
.option("--fields <list>", "comma-separated columns to show")
|
|
99
|
+
.option("--limit <n>", "maximum number of results to show")
|
|
100
|
+
.option("--all", "fetch every page instead of one limited page")
|
|
101
|
+
.option("--profile <name>", "use this profile for this command only")
|
|
102
|
+
.option("--project <id>", "override the profile default project");
|
|
103
|
+
command.action(async (reference, options) => {
|
|
104
|
+
runtime.setJsonMode(options.json === true);
|
|
105
|
+
// Flag misuse is refused before any traffic.
|
|
106
|
+
const { view, pick } = rowViewOf(spec.columns, options);
|
|
107
|
+
if (!isIdForm(reference)) {
|
|
108
|
+
throw new OpCliError("USAGE_ERROR", `work package "${reference}" is not an id.`, "work packages are addressed by their numeric id.");
|
|
109
|
+
}
|
|
110
|
+
const getPage = await runtime.connect(options);
|
|
111
|
+
const resolve = spec.resolve?.(getPage) ?? ((row) => Promise.resolve(row));
|
|
112
|
+
const limit = parsePageSize(typeof options.limit === "string" ? options.limit : undefined);
|
|
113
|
+
const startPath = withPageSize(spec.path(reference), options.all === true ? await allPageSize(getPage) : limit);
|
|
114
|
+
if (options.all === true) {
|
|
115
|
+
if (options.json === true) {
|
|
116
|
+
for await (const element of halElements(getPage, startPath)) {
|
|
117
|
+
const row = spec.row(element);
|
|
118
|
+
if (row !== undefined) {
|
|
119
|
+
runtime.write(`${JSON.stringify(pick(await resolve(row)))}\n`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const rows = [];
|
|
125
|
+
for await (const element of halElements(getPage, startPath)) {
|
|
126
|
+
const row = spec.row(element);
|
|
127
|
+
if (row !== undefined) {
|
|
128
|
+
rows.push(await resolve(row));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
runtime.write(renderRowsTable(view, rows));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const page = (await getPage(startPath));
|
|
135
|
+
const elements = page._embedded?.elements ?? [];
|
|
136
|
+
const rows = [];
|
|
137
|
+
for (const element of elements) {
|
|
138
|
+
const row = spec.row(element);
|
|
139
|
+
if (row !== undefined) {
|
|
140
|
+
rows.push(await resolve(row));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
emitRows(runtime, spec.columns, rows, options);
|
|
144
|
+
// The notice compares against what the API delivered on the page, not
|
|
145
|
+
// the post-filter count: a comments listing drops other activity kinds
|
|
146
|
+
// even when the page was complete.
|
|
147
|
+
const total = typeof page.total === "number" ? page.total : elements.length;
|
|
148
|
+
if (total > elements.length) {
|
|
149
|
+
runtime.writeErr(`Showing ${rows.length} of ${total} records. `
|
|
150
|
+
+ "Pass --all to fetch every result.\n");
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
return command;
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=define.js.map
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { OpCliError } from "./errors.js";
|
|
2
|
+
// The accepted value grammar of --hours, taught verbatim in every refusal.
|
|
3
|
+
const ACCEPTED_FORMS = "a decimal number of hours such as 1.5, a compound form such as 1h30m, "
|
|
4
|
+
+ "or an ISO 8601 duration such as PT1H30M";
|
|
5
|
+
// Every fixed-length ISO 8601 component, in the order the grammar spells
|
|
6
|
+
// them: weeks and days before the T, hours, minutes, and seconds after it.
|
|
7
|
+
// Years and months are absent on purpose: they name calendar spans of no
|
|
8
|
+
// fixed number of hours, so no value can be read out of them.
|
|
9
|
+
const ISO_DURATION = /^p(?:(\d+(?:\.\d+)?)w)?(?:(\d+(?:\.\d+)?)d)?(t(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m)?(?:(\d+(?:\.\d+)?)s)?)?$/i;
|
|
10
|
+
/**
|
|
11
|
+
* Parse any fixed-length ISO 8601 duration into milliseconds, or return
|
|
12
|
+
* undefined when the string is not that grammar. This is the read path:
|
|
13
|
+
* OpenProject spells a duration of 24 hours or more with a day component
|
|
14
|
+
* ("P3DT1H") and a week component beyond that, so everything the instance
|
|
15
|
+
* can emit has to be readable here.
|
|
16
|
+
*/
|
|
17
|
+
function isoDurationToMs(raw) {
|
|
18
|
+
const match = ISO_DURATION.exec(raw.trim());
|
|
19
|
+
if (match === null) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
// Each matched component, paired with the seconds one unit of it is worth.
|
|
23
|
+
const dateComponents = [[match[1], 604_800], [match[2], 86_400]];
|
|
24
|
+
const timeComponents = [[match[4], 3_600], [match[5], 60], [match[6], 1]];
|
|
25
|
+
const components = [...dateComponents, ...timeComponents];
|
|
26
|
+
const missing = ([component]) => component === undefined;
|
|
27
|
+
// "P" and "PT" name no amount of time at all, and a "T" carrying nothing
|
|
28
|
+
// after it is half a form; refuse them like any other malformed input
|
|
29
|
+
// rather than reading a gap as zero. match[3] is the whole T section when
|
|
30
|
+
// one was written, so its presence beside no H, M, or S is that bare "T".
|
|
31
|
+
if (components.every(missing)) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
if (match[3] !== undefined && timeComponents.every(missing)) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const seconds = components.reduce((carry, [component, perUnit]) => carry + Number(component ?? 0) * perUnit, 0);
|
|
38
|
+
return Math.round(seconds * 1000);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Parse one ISO 8601 time-only duration (PT…) into milliseconds, or return
|
|
42
|
+
* undefined when the string is not exactly that grammar. Weeks and days
|
|
43
|
+
* are refused on purpose: this is the write path, and the hours field of
|
|
44
|
+
* a single time entry never carries a calendar component.
|
|
45
|
+
*
|
|
46
|
+
* The narrowing rides on the shape of ISO_DURATION, where the week and day
|
|
47
|
+
* components can only ever precede the "T": a value that starts "PT" has
|
|
48
|
+
* none of them. Keep that ordering if the shared grammar is ever rewritten.
|
|
49
|
+
*/
|
|
50
|
+
function isoTimeToMs(raw) {
|
|
51
|
+
const token = raw.trim();
|
|
52
|
+
return /^pt/i.test(token) ? isoDurationToMs(token) : undefined;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Convert an ISO 8601 duration the instance reported ("PT1H30M", "PT0S",
|
|
56
|
+
* "P3DT1H") to decimal hours. The inverse of [hoursToIso] within
|
|
57
|
+
* millisecond precision for every value [hoursToIso] can render.
|
|
58
|
+
*
|
|
59
|
+
* The value read here was never typed by the caller, so a duration this
|
|
60
|
+
* client cannot read is the instance's answer failing, not misuse.
|
|
61
|
+
*/
|
|
62
|
+
export function isoToHours(iso) {
|
|
63
|
+
const ms = isoDurationToMs(iso);
|
|
64
|
+
if (ms === undefined) {
|
|
65
|
+
throw new OpCliError("API_ERROR", `the instance reported the duration "${iso}", which is not an ISO 8601 `
|
|
66
|
+
+ "duration such as PT1H30M, PT0S, or P3DT1H.", "durations in weeks, days, hours, minutes, or seconds can be read; "
|
|
67
|
+
+ "report the value if the instance keeps sending it.");
|
|
68
|
+
}
|
|
69
|
+
return ms / 3_600_000;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Convert decimal hours into the canonical ISO 8601 duration OpenProject
|
|
73
|
+
* expects on the wire: the shortest exact H/M/S spelling of the value at
|
|
74
|
+
* millisecond precision, so binary-fraction noise from decimal input can
|
|
75
|
+
* never leak into a request.
|
|
76
|
+
*/
|
|
77
|
+
export function hoursToIso(hours) {
|
|
78
|
+
if (!Number.isFinite(hours) || hours < 0) {
|
|
79
|
+
throw new OpCliError("USAGE_ERROR", `cannot render ${String(hours)} hours as a duration.`, "hours must be a finite number of 0 or more.");
|
|
80
|
+
}
|
|
81
|
+
let rest = Math.round(hours * 3_600_000);
|
|
82
|
+
const h = Math.floor(rest / 3_600_000);
|
|
83
|
+
rest -= h * 3_600_000;
|
|
84
|
+
const m = Math.floor(rest / 60_000);
|
|
85
|
+
rest -= m * 60_000;
|
|
86
|
+
const s = rest / 1000;
|
|
87
|
+
const parts = [];
|
|
88
|
+
if (h > 0) {
|
|
89
|
+
parts.push(`${String(h)}H`);
|
|
90
|
+
}
|
|
91
|
+
if (m > 0) {
|
|
92
|
+
parts.push(`${String(m)}M`);
|
|
93
|
+
}
|
|
94
|
+
if (s > 0) {
|
|
95
|
+
// Up to three decimals, no trailing zeros: 0.5S stays 0.5S, 27S stays 27S.
|
|
96
|
+
parts.push(`${s.toFixed(3).replace(/\.?0+$/, "")}S`);
|
|
97
|
+
}
|
|
98
|
+
return parts.length === 0 ? "PT0S" : `PT${parts.join("")}`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Read one raw `--hours` value: a decimal number of hours ("1.5"), a
|
|
102
|
+
* compound form ("1h30m"), or an ISO 8601 duration ("PT1H30M"). Every
|
|
103
|
+
* accepted spelling of the same amount produces the identical result.
|
|
104
|
+
*/
|
|
105
|
+
export function parseDuration(raw) {
|
|
106
|
+
const token = raw.trim();
|
|
107
|
+
if (/^\d+(?:\.\d+)?$/.test(token)) {
|
|
108
|
+
return parsedFromHours(Number(token), raw);
|
|
109
|
+
}
|
|
110
|
+
const compound = token.toLowerCase().replace(/\s+/g, "");
|
|
111
|
+
// Segments are consumed in whatever units the caller used, but the
|
|
112
|
+
// h → m → s order must hold and no unit may repeat: "45m" is legal,
|
|
113
|
+
// "30m1h" and "1h30h" are refused instead of guessed at. Any
|
|
114
|
+
// unconsumed tail refuses the whole input; NaN marks the refusal.
|
|
115
|
+
const segment = /^(\d+(?:\.\d+)?)([hms])/;
|
|
116
|
+
const UNIT_MS = {
|
|
117
|
+
h: 3_600_000,
|
|
118
|
+
m: 60_000,
|
|
119
|
+
s: 1_000,
|
|
120
|
+
};
|
|
121
|
+
let rest = compound;
|
|
122
|
+
let ms = 0;
|
|
123
|
+
let lastIndex = -1;
|
|
124
|
+
while (rest !== "") {
|
|
125
|
+
const match = segment.exec(rest);
|
|
126
|
+
const unitIndex = match === null ? -1 : ["h", "m", "s"].indexOf(match[2]);
|
|
127
|
+
if (match === null || unitIndex < 0 || unitIndex <= lastIndex) {
|
|
128
|
+
ms = Number.NaN;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
lastIndex = unitIndex;
|
|
132
|
+
ms += Number(match[1]) * UNIT_MS[match[2]];
|
|
133
|
+
rest = rest.slice(match[0].length);
|
|
134
|
+
}
|
|
135
|
+
if (!Number.isNaN(ms)) {
|
|
136
|
+
return parsedFromHours(ms / 3_600_000, raw);
|
|
137
|
+
}
|
|
138
|
+
const direct = isoTimeToMs(token);
|
|
139
|
+
if (direct !== undefined) {
|
|
140
|
+
return parsedFromHours(direct / 3_600_000, raw);
|
|
141
|
+
}
|
|
142
|
+
throw new OpCliError("USAGE_ERROR", `cannot read "${raw}" as a duration.`, `--hours accepts ${ACCEPTED_FORMS}.`);
|
|
143
|
+
}
|
|
144
|
+
/** Shared tail of parseDuration: positive amounts only. */
|
|
145
|
+
function parsedFromHours(hours, raw) {
|
|
146
|
+
if (!(hours > 0)) {
|
|
147
|
+
throw new OpCliError("USAGE_ERROR", `"${raw}" does not name a positive amount of time.`, `--hours accepts ${ACCEPTED_FORMS}, and logging needs more than zero.`);
|
|
148
|
+
}
|
|
149
|
+
return { hours, iso: hoursToIso(hours) };
|
|
150
|
+
}
|
|
151
|
+
//# sourceMappingURL=duration.js.map
|