@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,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