@tuanhv/op-cli 0.1.1 → 0.2.1
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/LICENSE +21 -0
- package/NOTICE +2 -2
- package/README.md +20 -7
- package/dist/bin.js +18 -0
- package/dist/commands/doctor.js +5 -2
- package/dist/commands/meta.js +29 -14
- package/dist/commands/project.js +22 -87
- package/dist/commands/time.js +50 -27
- package/dist/commands/update.js +199 -0
- package/dist/commands/version.js +1 -1
- package/dist/commands/wp.js +155 -26
- package/dist/context/metadata.js +1 -1
- package/dist/context/profile.js +37 -9
- package/dist/context/projectref.js +84 -0
- package/dist/core/define.js +1 -1
- package/dist/core/filters.js +43 -13
- package/dist/io/node-io.js +23 -1
- package/dist/output/table.js +111 -6
- package/dist/run.js +56 -11
- package/package.json +2 -1
package/dist/commands/wp.js
CHANGED
|
@@ -5,7 +5,8 @@ import { flattenHalRecord, isFlatLink } from "../core/hal.js";
|
|
|
5
5
|
import { apiDelete, apiGet, apiPost, apiPatchRaw, apiPostRaw, authenticate, } from "../core/http.js";
|
|
6
6
|
import { allPageSize, DEFAULT_PAGE_SIZE, halElements, parsePageSize, withPageSize, } from "../core/paginate.js";
|
|
7
7
|
import { explicitCustomFieldKey, isIdForm, matchByName, rankByCloseness, resolveName, } from "../context/resolve.js";
|
|
8
|
-
import {
|
|
8
|
+
import { parseProjectOverride, } from "../context/profile.js";
|
|
9
|
+
import { resolveProjectRef } from "../context/projectref.js";
|
|
9
10
|
import { loadProjectVocabulary, loadStoredMetadata, refreshStoredMetadata, } from "../context/metadata.js";
|
|
10
11
|
import { formatCell, renderTable } from "../output/table.js";
|
|
11
12
|
// Canonical row order for a single-record table; every other key of the
|
|
@@ -93,7 +94,7 @@ async function openProfile(runtime, options) {
|
|
|
93
94
|
runtime.setJsonMode(options.json === true);
|
|
94
95
|
return runtime.resolve({
|
|
95
96
|
profile: options.profile,
|
|
96
|
-
project:
|
|
97
|
+
project: parseProjectOverride(options.project),
|
|
97
98
|
});
|
|
98
99
|
}
|
|
99
100
|
function collectValue(value, previous) {
|
|
@@ -114,10 +115,12 @@ function addFilterFlags(command) {
|
|
|
114
115
|
.option("--priority <name>", "priority name or id; repeat to OR values", collectValue, [])
|
|
115
116
|
.option("--parent <reference>", "parent work package id or exact subject", collectValue, [])
|
|
116
117
|
.option("--updated-after <value>", "today, yesterday, days back such as 7d, or YYYY-MM-DD")
|
|
118
|
+
.option("--created-after <value>", "only work packages created since; same date forms as --updated-after")
|
|
119
|
+
.option("--search <text>", "substring matched against work package subjects")
|
|
117
120
|
.option("--open", "shorthand for every open status")
|
|
118
121
|
.option("--closed", "shorthand for every closed status")
|
|
119
122
|
.option("--profile <name>", "use this profile for this command only")
|
|
120
|
-
.option("--project <id>", "override the profile default project");
|
|
123
|
+
.option("--project <name-or-id>", "override the profile default project");
|
|
121
124
|
}
|
|
122
125
|
function rawListFlags(options) {
|
|
123
126
|
return {
|
|
@@ -130,6 +133,8 @@ function rawListFlags(options) {
|
|
|
130
133
|
priorities: options.priority,
|
|
131
134
|
parents: options.parent,
|
|
132
135
|
updatedAfter: options.updatedAfter,
|
|
136
|
+
createdAfter: options.createdAfter,
|
|
137
|
+
search: options.search,
|
|
133
138
|
open: options.open,
|
|
134
139
|
closed: options.closed,
|
|
135
140
|
};
|
|
@@ -155,7 +160,7 @@ function projectSource(runtime, profile, flag, label, select, memo) {
|
|
|
155
160
|
label,
|
|
156
161
|
load: async () => {
|
|
157
162
|
if (profile.project === undefined) {
|
|
158
|
-
throw new OpCliError("USAGE_ERROR", `--${flag} needs a project to look names up in.`, "pass --project <id> or set a default project on the profile.");
|
|
163
|
+
throw new OpCliError("USAGE_ERROR", `--${flag} needs a project to look names up in.`, "pass --project <name-or-id> or set a default project on the profile.");
|
|
159
164
|
}
|
|
160
165
|
return select(await (memo === undefined
|
|
161
166
|
? loadProjectVocabulary(runtime.env, profile)
|
|
@@ -380,10 +385,10 @@ async function runBulkCreate(runtime, subjectArg, options) {
|
|
|
380
385
|
}
|
|
381
386
|
const profile = await runtime.resolve({
|
|
382
387
|
profile: options.profile,
|
|
383
|
-
project:
|
|
388
|
+
project: parseProjectOverride(options.project),
|
|
384
389
|
});
|
|
385
390
|
if (profile.project === undefined) {
|
|
386
|
-
throw new OpCliError("USAGE_ERROR", "wp create needs a project to create the work package in.", "pass --project <id> or set a default project on the profile.");
|
|
391
|
+
throw new OpCliError("USAGE_ERROR", "wp create needs a project to create the work package in.", "pass --project <name-or-id> or set a default project on the profile.");
|
|
387
392
|
}
|
|
388
393
|
const memo = newResolutionMemo(runtime, profile);
|
|
389
394
|
const dryRun = options.dryRun === true;
|
|
@@ -474,7 +479,7 @@ function customFieldRows(metadata, vocabulary) {
|
|
|
474
479
|
}
|
|
475
480
|
async function resolveFieldDefinition(runtime, profile, rawName, memo) {
|
|
476
481
|
if (profile.project === undefined) {
|
|
477
|
-
throw new OpCliError("USAGE_ERROR", "--field needs a project to look field names up in.", "pass --project <id> or set a default project on the profile.");
|
|
482
|
+
throw new OpCliError("USAGE_ERROR", "--field needs a project to look field names up in.", "pass --project <name-or-id> or set a default project on the profile.");
|
|
478
483
|
}
|
|
479
484
|
const metadata = memo === undefined
|
|
480
485
|
? loadStoredMetadata(runtime.env, profile)
|
|
@@ -918,6 +923,12 @@ async function resolveListFlags(runtime, profile, options) {
|
|
|
918
923
|
if (options.updatedAfter !== undefined && options.updatedAfter !== "") {
|
|
919
924
|
flags.updatedAfter = options.updatedAfter;
|
|
920
925
|
}
|
|
926
|
+
if (options.createdAfter !== undefined && options.createdAfter !== "") {
|
|
927
|
+
flags.createdAfter = options.createdAfter;
|
|
928
|
+
}
|
|
929
|
+
if (options.search !== undefined && options.search !== "") {
|
|
930
|
+
flags.search = options.search;
|
|
931
|
+
}
|
|
921
932
|
return { ...flags, open: options.open, closed: options.closed };
|
|
922
933
|
}
|
|
923
934
|
function workPackagesPath(filters) {
|
|
@@ -1041,6 +1052,7 @@ export const HISTORY_COLUMNS = [
|
|
|
1041
1052
|
{ title: "KIND", field: "kind" },
|
|
1042
1053
|
{ title: "AUTHOR", field: "user" },
|
|
1043
1054
|
{ title: "COMMENT", field: "comment" },
|
|
1055
|
+
{ title: "DETAILS", field: "details" },
|
|
1044
1056
|
{ title: "CREATED", field: "createdAt" },
|
|
1045
1057
|
];
|
|
1046
1058
|
export const RELATION_COLUMNS = [
|
|
@@ -1095,6 +1107,11 @@ function linkIdOf(link) {
|
|
|
1095
1107
|
* survives flattening because flattenHalRecord drops _type from records.
|
|
1096
1108
|
* The formattable `comment` collapses to its raw text so the one name
|
|
1097
1109
|
* means one thing wherever an activity is rendered.
|
|
1110
|
+
*
|
|
1111
|
+
* `details` carries the audit half the comment never could: the changes
|
|
1112
|
+
* behind a system activity, one instance-spelled sentence per attribute
|
|
1113
|
+
* ("Status changed from New to In progress"). A comment activity changed
|
|
1114
|
+
* nothing, so its details render empty rather than absent.
|
|
1098
1115
|
*/
|
|
1099
1116
|
export function activityRow(element) {
|
|
1100
1117
|
const resource = element;
|
|
@@ -1108,9 +1125,28 @@ export function activityRow(element) {
|
|
|
1108
1125
|
kind,
|
|
1109
1126
|
user: record.user,
|
|
1110
1127
|
comment: typeof comment?.raw === "string" ? comment.raw : "",
|
|
1128
|
+
details: activityDetails(record.details),
|
|
1111
1129
|
createdAt: record.createdAt,
|
|
1112
1130
|
};
|
|
1113
1131
|
}
|
|
1132
|
+
/**
|
|
1133
|
+
* The changes of one activity as a single line: each detail arrives as the
|
|
1134
|
+
* instance already spells it, so joining the raw texts keeps the summary
|
|
1135
|
+
* honest in every locale instead of re-deriving it from values the API
|
|
1136
|
+
* does not hand back. Details without raw text contribute nothing.
|
|
1137
|
+
*/
|
|
1138
|
+
function activityDetails(details) {
|
|
1139
|
+
if (!Array.isArray(details)) {
|
|
1140
|
+
return "";
|
|
1141
|
+
}
|
|
1142
|
+
return details
|
|
1143
|
+
.map((detail) => {
|
|
1144
|
+
const raw = detail?.raw;
|
|
1145
|
+
return typeof raw === "string" ? raw : "";
|
|
1146
|
+
})
|
|
1147
|
+
.filter((text) => text !== "")
|
|
1148
|
+
.join("; ");
|
|
1149
|
+
}
|
|
1114
1150
|
/**
|
|
1115
1151
|
* The key set of an activityRow output, so `wp comment` can refuse a bad
|
|
1116
1152
|
* --fields before it writes. Taken FROM the row builder rather than
|
|
@@ -1225,7 +1261,7 @@ export function registerWpCommands(wp, runtime) {
|
|
|
1225
1261
|
buildWpFilters(rawListFlags(options), new Date());
|
|
1226
1262
|
const profile = await runtime.resolve({
|
|
1227
1263
|
profile: options.profile,
|
|
1228
|
-
project:
|
|
1264
|
+
project: parseProjectOverride(options.project),
|
|
1229
1265
|
});
|
|
1230
1266
|
const now = new Date();
|
|
1231
1267
|
const limit = parsePageSize(options.limit);
|
|
@@ -1268,7 +1304,7 @@ export function registerWpCommands(wp, runtime) {
|
|
|
1268
1304
|
buildWpFilters(rawListFlags(options), new Date());
|
|
1269
1305
|
const profile = await runtime.resolve({
|
|
1270
1306
|
profile: options.profile,
|
|
1271
|
-
project:
|
|
1307
|
+
project: parseProjectOverride(options.project),
|
|
1272
1308
|
});
|
|
1273
1309
|
const filters = buildWpFilters(await resolveListFlags(runtime, profile, options), new Date());
|
|
1274
1310
|
const body = (await apiGet(profile.instanceUrl, profile.apiKey, `${workPackagesPath(filters)}&pageSize=1`));
|
|
@@ -1283,13 +1319,13 @@ export function registerWpCommands(wp, runtime) {
|
|
|
1283
1319
|
.option("--json", "emit a flat JSON record")
|
|
1284
1320
|
.option("--fields <list>", "comma-separated columns to show")
|
|
1285
1321
|
.option("--profile <name>", "use this profile for this command only")
|
|
1286
|
-
.option("--project <id>", "override the profile default project")
|
|
1322
|
+
.option("--project <name-or-id>", "override the profile default project")
|
|
1287
1323
|
.action(async (reference, options) => {
|
|
1288
1324
|
runtime.setJsonMode(options.json === true);
|
|
1289
1325
|
requireWpId(reference);
|
|
1290
1326
|
const profile = await runtime.resolve({
|
|
1291
1327
|
profile: options.profile,
|
|
1292
|
-
project:
|
|
1328
|
+
project: parseProjectOverride(options.project),
|
|
1293
1329
|
});
|
|
1294
1330
|
const record = flattenHalRecord(await apiGet(profile.instanceUrl, profile.apiKey, `/api/v3/work_packages/${reference}`));
|
|
1295
1331
|
renderRecord(runtime, options, record);
|
|
@@ -1315,7 +1351,7 @@ export function registerWpCommands(wp, runtime) {
|
|
|
1315
1351
|
.option("--json", "emit a flat JSON record")
|
|
1316
1352
|
.option("--fields <list>", "comma-separated columns to show")
|
|
1317
1353
|
.option("--profile <name>", "use this profile for this command only")
|
|
1318
|
-
.option("--project <id>", "override the profile default project")
|
|
1354
|
+
.option("--project <name-or-id>", "override the profile default project")
|
|
1319
1355
|
.action(async (subject, options) => {
|
|
1320
1356
|
if (options.stdin === true) {
|
|
1321
1357
|
await runBulkCreate(runtime, subject, options);
|
|
@@ -1333,10 +1369,10 @@ export function registerWpCommands(wp, runtime) {
|
|
|
1333
1369
|
}
|
|
1334
1370
|
const profile = await runtime.resolve({
|
|
1335
1371
|
profile: options.profile,
|
|
1336
|
-
project:
|
|
1372
|
+
project: parseProjectOverride(options.project),
|
|
1337
1373
|
});
|
|
1338
1374
|
if (profile.project === undefined) {
|
|
1339
|
-
throw new OpCliError("USAGE_ERROR", "wp create needs a project to create the work package in.", "pass --project <id> or set a default project on the profile.");
|
|
1375
|
+
throw new OpCliError("USAGE_ERROR", "wp create needs a project to create the work package in.", "pass --project <name-or-id> or set a default project on the profile.");
|
|
1340
1376
|
}
|
|
1341
1377
|
const { payload, refs } = await prepareCreatePayload(runtime, profile, subject, options);
|
|
1342
1378
|
const body = await submitCreate(runtime, profile, payload, refs);
|
|
@@ -1360,7 +1396,7 @@ export function registerWpCommands(wp, runtime) {
|
|
|
1360
1396
|
.option("--json", "emit a flat JSON record")
|
|
1361
1397
|
.option("--fields <list>", "comma-separated columns to show")
|
|
1362
1398
|
.option("--profile <name>", "use this profile for this command only")
|
|
1363
|
-
.option("--project <id>", "override the profile default project")
|
|
1399
|
+
.option("--project <name-or-id>", "override the profile default project")
|
|
1364
1400
|
.action(async (reference, options) => {
|
|
1365
1401
|
runtime.setJsonMode(options.json === true);
|
|
1366
1402
|
requireWpId(reference);
|
|
@@ -1379,7 +1415,7 @@ export function registerWpCommands(wp, runtime) {
|
|
|
1379
1415
|
}
|
|
1380
1416
|
const profile = await runtime.resolve({
|
|
1381
1417
|
profile: options.profile,
|
|
1382
|
-
project:
|
|
1418
|
+
project: parseProjectOverride(options.project),
|
|
1383
1419
|
});
|
|
1384
1420
|
const path = `/api/v3/work_packages/${reference}`;
|
|
1385
1421
|
// The optimistic-locking read: everything below compares against
|
|
@@ -1419,27 +1455,120 @@ export function registerWpCommands(wp, runtime) {
|
|
|
1419
1455
|
const updated = flattenHalRecord(response.body);
|
|
1420
1456
|
renderRecord(runtime, options, updated);
|
|
1421
1457
|
});
|
|
1458
|
+
// -------------------------------------------------------------------------
|
|
1459
|
+
// wp move
|
|
1460
|
+
wp.command("move")
|
|
1461
|
+
.description("Move one work package to another project")
|
|
1462
|
+
.argument("<id>")
|
|
1463
|
+
.argument("<project>", "target project, by id, identifier, or name")
|
|
1464
|
+
.option("--json", "emit a flat JSON record")
|
|
1465
|
+
.option("--fields <list>", "comma-separated columns to show")
|
|
1466
|
+
.option("--profile <name>", "use this profile for this command only")
|
|
1467
|
+
.option("--project <name-or-id>", "override the profile default project")
|
|
1468
|
+
.action(async (reference, projectRef, options) => {
|
|
1469
|
+
runtime.setJsonMode(options.json === true);
|
|
1470
|
+
requireWpId(reference);
|
|
1471
|
+
const profile = await openProfile(runtime, options);
|
|
1472
|
+
const target = await resolveProjectRef((query) => apiGet(profile.instanceUrl, profile.apiKey, query), projectRef);
|
|
1473
|
+
const path = `/api/v3/work_packages/${reference}`;
|
|
1474
|
+
// The optimistic-locking read: the payload below carries its
|
|
1475
|
+
// lockVersion, and the refusal path names the project it came from.
|
|
1476
|
+
const before = await apiGet(profile.instanceUrl, profile.apiKey, path);
|
|
1477
|
+
const flatBefore = flattenHalRecord(before);
|
|
1478
|
+
// A bad --fields name is refused while the work package is still
|
|
1479
|
+
// untouched.
|
|
1480
|
+
selectedFields(options.fields, flatBefore);
|
|
1481
|
+
const currentProjectId = isFlatLink(flatBefore.project)
|
|
1482
|
+
? flatBefore.project.id
|
|
1483
|
+
: null;
|
|
1484
|
+
if (currentProjectId === target.id) {
|
|
1485
|
+
throw new OpCliError("USAGE_ERROR", `work package ${reference} is already in project ${target.name} (${String(target.id)}).`, "pass a different project to move it to.");
|
|
1486
|
+
}
|
|
1487
|
+
const payload = {
|
|
1488
|
+
lockVersion: lockVersionOf(before),
|
|
1489
|
+
_links: { project: { href: `/api/v3/projects/${String(target.id)}` } },
|
|
1490
|
+
};
|
|
1491
|
+
let response = await patchWp(profile, reference, payload);
|
|
1492
|
+
if (response.status === 409) {
|
|
1493
|
+
// Same race rule as wp update, but project-centric: the generic
|
|
1494
|
+
// checker skips `project` on purpose, so the comparison here is
|
|
1495
|
+
// explicit. A concurrent edit elsewhere is absorbed by one retry
|
|
1496
|
+
// with the fresh lockVersion; a concurrent move is never
|
|
1497
|
+
// overwritten, and a move that already landed exactly where this
|
|
1498
|
+
// one aimed is reported as done.
|
|
1499
|
+
const after = await apiGet(profile.instanceUrl, profile.apiKey, path);
|
|
1500
|
+
const flatAfter = flattenHalRecord(after);
|
|
1501
|
+
const afterProjectId = isFlatLink(flatAfter.project)
|
|
1502
|
+
? flatAfter.project.id
|
|
1503
|
+
: null;
|
|
1504
|
+
if (afterProjectId === target.id) {
|
|
1505
|
+
renderRecord(runtime, options, flatAfter);
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
if (afterProjectId !== currentProjectId) {
|
|
1509
|
+
throw new OpCliError("CONFLICT", `the work package was moved while this move ran: it is now in project ${String(afterProjectId)}.`, "read the current project, decide again, and repeat the move.", { moved_to: afterProjectId });
|
|
1510
|
+
}
|
|
1511
|
+
payload.lockVersion = lockVersionOf(after);
|
|
1512
|
+
response = await patchWp(profile, reference, payload);
|
|
1513
|
+
}
|
|
1514
|
+
if (response.status >= 400) {
|
|
1515
|
+
// A move the instance refuses is usually a type the target
|
|
1516
|
+
// project does not offer; writeRejection quotes the instance's
|
|
1517
|
+
// own explanation.
|
|
1518
|
+
throw writeRejection(response.status, response.body, "move");
|
|
1519
|
+
}
|
|
1520
|
+
const moved = flattenHalRecord(response.body);
|
|
1521
|
+
renderRecord(runtime, options, moved);
|
|
1522
|
+
});
|
|
1422
1523
|
// ---------------------------------------------------------------------------
|
|
1423
1524
|
// wp delete
|
|
1525
|
+
/**
|
|
1526
|
+
* How many work packages the server would cascade-delete beside this
|
|
1527
|
+
* one: every descendant, children and deeper, matched by the ancestor
|
|
1528
|
+
* filter. Undefined when the instance will not say; a read that fails
|
|
1529
|
+
* must not block a confirmed deletion, and the DELETE below stays the
|
|
1530
|
+
* authority on what actually happened.
|
|
1531
|
+
*/
|
|
1532
|
+
const countDescendants = async (profile, id) => {
|
|
1533
|
+
try {
|
|
1534
|
+
const page = (await apiGet(profile.instanceUrl, profile.apiKey, `${workPackagesPath([{ name: "ancestor", operator: "=", values: [id] }])}&pageSize=1`));
|
|
1535
|
+
return typeof page.total === "number" ? page.total : undefined;
|
|
1536
|
+
}
|
|
1537
|
+
catch {
|
|
1538
|
+
return undefined;
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1424
1541
|
wp.command("delete")
|
|
1425
1542
|
.description("Delete one work package after explicit confirmation")
|
|
1426
1543
|
.argument("<id>")
|
|
1427
1544
|
.option("--yes", "confirm the deletion")
|
|
1428
1545
|
.option("--profile <name>", "use this profile for this command only")
|
|
1429
|
-
.option("--project <id>", "override the profile default project")
|
|
1546
|
+
.option("--project <name-or-id>", "override the profile default project")
|
|
1430
1547
|
.action(async (reference, options) => {
|
|
1431
|
-
// The guard fires before any resolution or traffic: without --yes
|
|
1432
|
-
// nothing is read, nothing is sent, with or without a terminal.
|
|
1433
|
-
if (options.yes !== true) {
|
|
1434
|
-
throw new OpCliError("USAGE_ERROR", `wp delete refuses to remove work package ${reference} without confirmation.`, "repeat the command with --yes to confirm the deletion.");
|
|
1435
|
-
}
|
|
1436
1548
|
requireWpId(reference);
|
|
1437
1549
|
const profile = await runtime.resolve({
|
|
1438
1550
|
profile: options.profile,
|
|
1439
|
-
project:
|
|
1551
|
+
project: parseProjectOverride(options.project),
|
|
1440
1552
|
});
|
|
1553
|
+
// Without --yes the only traffic is the descendant read: the
|
|
1554
|
+
// refusal names what the cascade would destroy, but no write ever
|
|
1555
|
+
// leaves without the flag, terminal or not.
|
|
1556
|
+
const descendants = await countDescendants(profile, reference);
|
|
1557
|
+
if (options.yes !== true) {
|
|
1558
|
+
throw new OpCliError("USAGE_ERROR", `wp delete refuses to remove work package ${reference} without confirmation.`
|
|
1559
|
+
+ (descendants !== undefined && descendants > 0
|
|
1560
|
+
? ` Work package ${reference} has ${descendants} `
|
|
1561
|
+
+ `${descendants === 1 ? "descendant" : "descendants"};`
|
|
1562
|
+
+ " they are deleted with it."
|
|
1563
|
+
: ""), "repeat the command with --yes to confirm the deletion.");
|
|
1564
|
+
}
|
|
1441
1565
|
await apiDelete(profile.instanceUrl, profile.apiKey, `/api/v3/work_packages/${reference}`);
|
|
1442
|
-
runtime.write(`Deleted work package ${reference}
|
|
1566
|
+
runtime.write(`Deleted work package ${reference}`
|
|
1567
|
+
+ (descendants !== undefined && descendants > 0
|
|
1568
|
+
? ` and its ${descendants} `
|
|
1569
|
+
+ `${descendants === 1 ? "descendant" : "descendants"}`
|
|
1570
|
+
: "")
|
|
1571
|
+
+ ".\n");
|
|
1443
1572
|
});
|
|
1444
1573
|
// ---------------------------------------------------------------------------
|
|
1445
1574
|
// Discussion, history, relations, schema
|
|
@@ -1448,7 +1577,7 @@ export function registerWpCommands(wp, runtime) {
|
|
|
1448
1577
|
const profile = await runtime.resolve({
|
|
1449
1578
|
profile: typeof options.profile === "string" ? options.profile : undefined,
|
|
1450
1579
|
project: typeof options.project === "string"
|
|
1451
|
-
?
|
|
1580
|
+
? parseProjectOverride(options.project)
|
|
1452
1581
|
: undefined,
|
|
1453
1582
|
});
|
|
1454
1583
|
return (path) => apiGet(profile.instanceUrl, profile.apiKey, path);
|
|
@@ -1484,7 +1613,7 @@ export function registerWpCommands(wp, runtime) {
|
|
|
1484
1613
|
json: "--json",
|
|
1485
1614
|
fields: "--fields <list>",
|
|
1486
1615
|
profile: "--profile <name>",
|
|
1487
|
-
project: "--project <id>",
|
|
1616
|
+
project: "--project <name-or-id>",
|
|
1488
1617
|
};
|
|
1489
1618
|
wp.command("comment")
|
|
1490
1619
|
.description("Add a comment to one work package")
|
package/dist/context/metadata.js
CHANGED
|
@@ -376,7 +376,7 @@ export async function fetchProjectVocabulary(profile, projectId) {
|
|
|
376
376
|
}
|
|
377
377
|
export async function loadProjectVocabulary(env, profile) {
|
|
378
378
|
if (profile.project === undefined) {
|
|
379
|
-
throw new OpCliError("USAGE_ERROR");
|
|
379
|
+
throw new OpCliError("USAGE_ERROR", "the lookup needs a project to read its vocabulary from.", "pass --project <id> or set a default project on the profile.");
|
|
380
380
|
}
|
|
381
381
|
return loadProjectVocabularyById(env, profile, profile.project);
|
|
382
382
|
}
|
package/dist/context/profile.js
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { OpCliError } from "../core/errors.js";
|
|
4
|
+
import { isIdForm } from "./resolve.js";
|
|
5
|
+
import { resolveProjectOverride } from "./projectref.js";
|
|
4
6
|
export const LOGIN_PROFILE = "default";
|
|
7
|
+
/**
|
|
8
|
+
* The directory profiles live in, when one can be located at all:
|
|
9
|
+
* OP_CLI_CONFIG_DIR wins, then ~/.config/op-cli. Optional features
|
|
10
|
+
* (the update notice cache) use this to silence themselves when no
|
|
11
|
+
* directory exists; writes go through writableConfigDirectory instead.
|
|
12
|
+
*/
|
|
13
|
+
export function configDirectory(env) {
|
|
14
|
+
return env.OP_CLI_CONFIG_DIR
|
|
15
|
+
?? (env.HOME !== undefined ? join(env.HOME, ".config", "op-cli") : undefined);
|
|
16
|
+
}
|
|
5
17
|
function writableConfigDirectory(env) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
9
|
-
const home = env.HOME;
|
|
10
|
-
if (!home) {
|
|
18
|
+
const directory = configDirectory(env);
|
|
19
|
+
if (directory === undefined) {
|
|
11
20
|
throw new OpCliError("USAGE_ERROR");
|
|
12
21
|
}
|
|
13
|
-
return
|
|
22
|
+
return directory;
|
|
14
23
|
}
|
|
15
24
|
async function readStored(env) {
|
|
16
25
|
const directory = env.OP_CLI_CONFIG_DIR ??
|
|
@@ -151,6 +160,20 @@ export function parseOptionalId(raw) {
|
|
|
151
160
|
}
|
|
152
161
|
return value;
|
|
153
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* The parsed `--project` value: digit forms validate as ids through
|
|
165
|
+
* parseOptionalId above, anything else stays a string for the instance
|
|
166
|
+
* to resolve by identifier or name (#34).
|
|
167
|
+
*/
|
|
168
|
+
export function parseProjectOverride(raw) {
|
|
169
|
+
if (raw === undefined || raw === "") {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
if (typeof raw === "number") {
|
|
173
|
+
return raw;
|
|
174
|
+
}
|
|
175
|
+
return isIdForm(raw) ? parseOptionalId(raw) : raw;
|
|
176
|
+
}
|
|
154
177
|
export async function resolveProfile(env, overrides = {}) {
|
|
155
178
|
const state = await readStored(env);
|
|
156
179
|
const explicitName = firstDefined(overrides.profile, env.OP_CLI_PROFILE);
|
|
@@ -189,13 +212,18 @@ export async function resolveProfile(env, overrides = {}) {
|
|
|
189
212
|
name !== undefined &&
|
|
190
213
|
instanceUrl === entry.url.replace(/\/+$/, "") &&
|
|
191
214
|
apiKey === secret;
|
|
215
|
+
// A project named on the flag or in OP_CLI_PROJECT resolves against
|
|
216
|
+
// the instance before the profile is handed on, so every consumer
|
|
217
|
+
// sees a numeric id; numeric overrides keep the no-traffic fast path.
|
|
218
|
+
const override = firstDefined(parseProjectOverride(overrides.project), parseProjectOverride(env.OP_CLI_PROJECT));
|
|
219
|
+
const project = typeof override === "string"
|
|
220
|
+
? await resolveProjectOverride(instanceUrl, apiKey, override)
|
|
221
|
+
: (override ?? entry?.project);
|
|
192
222
|
return {
|
|
193
223
|
name: claimsProfile ? name : ENV_PROFILE_NAME,
|
|
194
224
|
instanceUrl,
|
|
195
225
|
apiKey,
|
|
196
|
-
project
|
|
197
|
-
parseOptionalId(env.OP_CLI_PROJECT) ??
|
|
198
|
-
entry?.project,
|
|
226
|
+
project,
|
|
199
227
|
};
|
|
200
228
|
}
|
|
201
229
|
// Resolves the active profile identity without requiring credentials, for
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { apiGet } from "../core/http.js";
|
|
2
|
+
import { OpCliError } from "../core/errors.js";
|
|
3
|
+
import { DEFAULT_PAGE_SIZE, halElements, withPageSize, } from "../core/paginate.js";
|
|
4
|
+
import { isIdForm, rankByCloseness } from "./resolve.js";
|
|
5
|
+
export function hitOf(element) {
|
|
6
|
+
const candidate = element;
|
|
7
|
+
return typeof candidate.id === "number"
|
|
8
|
+
&& typeof candidate.identifier === "string"
|
|
9
|
+
&& typeof candidate.name === "string"
|
|
10
|
+
? { id: candidate.id, identifier: candidate.identifier, name: candidate.name }
|
|
11
|
+
: undefined;
|
|
12
|
+
}
|
|
13
|
+
export const PROJECTS_COLLECTION = "/api/v3/projects";
|
|
14
|
+
/**
|
|
15
|
+
* Every visible project, page by page in server order. Resolution walks
|
|
16
|
+
* instead of filtering server-side because the exact-match filter set
|
|
17
|
+
* differs between instance versions; the collection endpoint does not.
|
|
18
|
+
*/
|
|
19
|
+
export async function walkProjects(getPage) {
|
|
20
|
+
const hits = [];
|
|
21
|
+
for await (const element of halElements(getPage, withPageSize(PROJECTS_COLLECTION, DEFAULT_PAGE_SIZE))) {
|
|
22
|
+
const hit = hitOf(element);
|
|
23
|
+
if (hit !== undefined) {
|
|
24
|
+
hits.push(hit);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return hits;
|
|
28
|
+
}
|
|
29
|
+
function missingProjectError(raw, known) {
|
|
30
|
+
return new OpCliError("NOT_FOUND", `project "${raw}" not found.`, known.length > 0
|
|
31
|
+
? `known projects, closest first: ${rankByCloseness(raw, known).slice(0, 6).join(", ")}.`
|
|
32
|
+
: "check the spelling or list projects with op-cli project list.");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve one reference that may be an id, an identifier, or a name.
|
|
36
|
+
* All-digits means an id and is fetched directly. Anything else matches
|
|
37
|
+
* identifier and name exactly across every visible project; one distinct
|
|
38
|
+
* match wins, several distinct projects fail loudly instead of guessing.
|
|
39
|
+
*/
|
|
40
|
+
export async function resolveProjectRef(getPage, raw) {
|
|
41
|
+
if (isIdForm(raw)) {
|
|
42
|
+
try {
|
|
43
|
+
const hit = hitOf(await getPage(`${PROJECTS_COLLECTION}/${raw}`));
|
|
44
|
+
if (hit !== undefined) {
|
|
45
|
+
return hit;
|
|
46
|
+
}
|
|
47
|
+
throw new OpCliError("NOT_FOUND", `project "${raw}" not found.`, "check the id; run op-cli project list to see what is visible.");
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error instanceof OpCliError && error.code === "USAGE_ERROR") {
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
if (error instanceof OpCliError && error.code === "NOT_FOUND") {
|
|
54
|
+
throw new OpCliError("NOT_FOUND", `project "${raw}" not found.`, "check the id; run op-cli project list to see what is visible.");
|
|
55
|
+
}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const all = await walkProjects(getPage);
|
|
60
|
+
const matches = all.filter((hit) => hit.identifier === raw || hit.name === raw);
|
|
61
|
+
const distinct = [...new Map(matches.map((hit) => [hit.id, hit])).values()];
|
|
62
|
+
if (distinct.length === 1) {
|
|
63
|
+
return distinct[0];
|
|
64
|
+
}
|
|
65
|
+
if (distinct.length > 1) {
|
|
66
|
+
const listed = distinct.map((hit) => `${String(hit.id)} (${hit.name})`).join(", ");
|
|
67
|
+
throw new OpCliError("USAGE_ERROR", `project "${raw}" is ambiguous. Candidates: ${listed}.`, "repeat the value as the explicit id of one candidate.");
|
|
68
|
+
}
|
|
69
|
+
throw missingProjectError(raw, all.flatMap((hit) => [hit.identifier, hit.name]));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The project half of a resolved profile: a numeric id passes through
|
|
73
|
+
* untouched, a name or identifier resolves against the instance (#34).
|
|
74
|
+
* Callers hand this the parsed `--project` value or OP_CLI_PROJECT, so
|
|
75
|
+
* the fast path costs no traffic and only a name pays for the walk.
|
|
76
|
+
*/
|
|
77
|
+
export async function resolveProjectOverride(instanceUrl, apiKey, override) {
|
|
78
|
+
if (typeof override === "number") {
|
|
79
|
+
return override;
|
|
80
|
+
}
|
|
81
|
+
const hit = await resolveProjectRef((path) => apiGet(instanceUrl, apiKey, path), override);
|
|
82
|
+
return hit.id;
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=projectref.js.map
|
package/dist/core/define.js
CHANGED
|
@@ -99,7 +99,7 @@ export function defineCollectionCommand(spec, runtime) {
|
|
|
99
99
|
.option("--limit <n>", "maximum number of results to show")
|
|
100
100
|
.option("--all", "fetch every page instead of one limited page")
|
|
101
101
|
.option("--profile <name>", "use this profile for this command only")
|
|
102
|
-
.option("--project <id>", "override the profile default project");
|
|
102
|
+
.option("--project <name-or-id>", "override the profile default project");
|
|
103
103
|
command.action(async (reference, options) => {
|
|
104
104
|
runtime.setJsonMode(options.json === true);
|
|
105
105
|
// Flag misuse is refused before any traffic.
|
package/dist/core/filters.js
CHANGED
|
@@ -21,17 +21,14 @@ function daysAgo(now, days) {
|
|
|
21
21
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - days);
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* one as the start of that day, so every change made during the
|
|
28
|
-
* closing day would drop out. Callers emit `<>d [date, ""]`.
|
|
24
|
+
* The shared grammar of every window date flag: today, yesterday, N
|
|
25
|
+
* days back, or an explicit date, all in local time. `flag` names the
|
|
26
|
+
* flag in the error messages.
|
|
29
27
|
*/
|
|
30
|
-
|
|
28
|
+
function parseWindowDate(raw, now, flag) {
|
|
31
29
|
const token = raw.trim().toLowerCase();
|
|
32
|
-
const today = isoDate(now);
|
|
33
30
|
if (token === "today") {
|
|
34
|
-
return
|
|
31
|
+
return isoDate(now);
|
|
35
32
|
}
|
|
36
33
|
if (token === "yesterday") {
|
|
37
34
|
return isoDate(daysAgo(now, 1));
|
|
@@ -45,14 +42,30 @@ export function sinceDate(raw, now) {
|
|
|
45
42
|
}
|
|
46
43
|
if (/^\d{4}-\d{2}-\d{2}$/.test(token)) {
|
|
47
44
|
if (!isCalendarDate(token)) {
|
|
48
|
-
throw new OpCliError("USAGE_ERROR", `"${raw}" is not a real date.`,
|
|
49
|
-
}
|
|
50
|
-
if (token > today) {
|
|
51
|
-
throw new OpCliError("USAGE_ERROR", `"${raw}" is in the future.`, "--updated-after needs a date up to today.");
|
|
45
|
+
throw new OpCliError("USAGE_ERROR", `"${raw}" is not a real date.`, `${flag} accepts ${DATE_FORMS}.`);
|
|
52
46
|
}
|
|
53
47
|
return token;
|
|
54
48
|
}
|
|
55
|
-
throw new OpCliError("USAGE_ERROR", `cannot read "${raw}" as
|
|
49
|
+
throw new OpCliError("USAGE_ERROR", `cannot read "${raw}" as a ${flag.slice(2)} value.`, `${flag} accepts ${DATE_FORMS}.`);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The date a window opens at. The future check keeps the opening
|
|
53
|
+
* anchored in the past (#24 grammar).
|
|
54
|
+
*/
|
|
55
|
+
export function sinceDate(raw, now, flag = "--updated-after") {
|
|
56
|
+
const token = parseWindowDate(raw, now, flag);
|
|
57
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(token) && token > isoDate(now)) {
|
|
58
|
+
throw new OpCliError("USAGE_ERROR", `"${raw}" is in the future.`, `${flag} needs a date up to today.`);
|
|
59
|
+
}
|
|
60
|
+
return token;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The `--to` input of `time list` / `time report`. Same grammar, but a
|
|
64
|
+
* future end is let through: a period such as `--from 2026-08-01 --to
|
|
65
|
+
* 2026-08-31` stays valid while only part of it has happened.
|
|
66
|
+
*/
|
|
67
|
+
export function untilDate(raw, now, flag = "--to") {
|
|
68
|
+
return parseWindowDate(raw, now, flag);
|
|
56
69
|
}
|
|
57
70
|
function unique(values) {
|
|
58
71
|
return [...new Set(values)];
|
|
@@ -108,6 +121,23 @@ export function buildWpFilters(flags, now) {
|
|
|
108
121
|
if ((flags.parents ?? []).length > 0) {
|
|
109
122
|
filters.push({ name: "parent", operator: "=", values: unique(flags.parents) });
|
|
110
123
|
}
|
|
124
|
+
if (flags.search !== undefined) {
|
|
125
|
+
if (flags.search.trim() === "") {
|
|
126
|
+
throw new OpCliError("USAGE_ERROR", "--search needs text to match against subjects.", "pass a substring, e.g. --search login.");
|
|
127
|
+
}
|
|
128
|
+
// The only substring clause: `~` is the API's contains operator,
|
|
129
|
+
// matched server-side so case and diacritics follow the instance.
|
|
130
|
+
filters.push({ name: "subject", operator: "~", values: [flags.search.trim()] });
|
|
131
|
+
}
|
|
132
|
+
if (flags.createdAfter !== undefined) {
|
|
133
|
+
const since = sinceDate(flags.createdAfter, now, "--created-after");
|
|
134
|
+
filters.push({
|
|
135
|
+
name: "created_at",
|
|
136
|
+
operator: "<>d",
|
|
137
|
+
// Open upper bound, same reason as updated_at (#24).
|
|
138
|
+
values: [since, ""],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
111
141
|
if (flags.updatedAfter !== undefined) {
|
|
112
142
|
const since = sinceDate(flags.updatedAfter, now);
|
|
113
143
|
filters.push({
|
package/dist/io/node-io.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
1
3
|
import { createInterface } from "node:readline/promises";
|
|
2
4
|
import { StringDecoder } from "node:string_decoder";
|
|
3
5
|
import { OpCliError } from "../core/errors.js";
|
|
@@ -9,8 +11,9 @@ const ENV_HINT = "set OPENPROJECT_URL and OPENPROJECT_API_KEY in the environment
|
|
|
9
11
|
* read through a raw-mode byte loop that never forwards keystrokes to
|
|
10
12
|
* the output stream.
|
|
11
13
|
*/
|
|
12
|
-
export function createNodeIo(input = process.stdin, output = process.stdout) {
|
|
14
|
+
export function createNodeIo(input = process.stdin, output = process.stdout, errorOutput = process.stderr) {
|
|
13
15
|
const stdinIsTTY = input.isTTY === true;
|
|
16
|
+
const stderrIsTTY = errorOutput.isTTY === true;
|
|
14
17
|
// Executor form on purpose: Promise.withResolvers needs ES2024, and the
|
|
15
18
|
// published engines field still supports Node 20.
|
|
16
19
|
const askLine = (message) => new Promise((resolve, reject) => {
|
|
@@ -76,6 +79,7 @@ export function createNodeIo(input = process.stdin, output = process.stdout) {
|
|
|
76
79
|
};
|
|
77
80
|
return {
|
|
78
81
|
stdinIsTTY,
|
|
82
|
+
stderrIsTTY,
|
|
79
83
|
prompt: (message, secret) => (secret ? readSecret(message) : askLine(message)),
|
|
80
84
|
readStdin: async () => {
|
|
81
85
|
const chunks = [];
|
|
@@ -84,6 +88,24 @@ export function createNodeIo(input = process.stdin, output = process.stdout) {
|
|
|
84
88
|
}
|
|
85
89
|
return Buffer.concat(chunks).toString("utf8");
|
|
86
90
|
},
|
|
91
|
+
// 127 is the shell's "command not found": a spawn that never started
|
|
92
|
+
// and a child killed by a signal both collapse onto ordinary exit
|
|
93
|
+
// codes, so callers cannot mistake them for a hanging success.
|
|
94
|
+
runExternal: (file, args) => new Promise((resolve) => {
|
|
95
|
+
const child = spawn(file, [...args], { stdio: "inherit" });
|
|
96
|
+
child.once("error", () => resolve(127));
|
|
97
|
+
child.once("close", (code) => resolve(code ?? 1));
|
|
98
|
+
}),
|
|
99
|
+
captureExternal: (file, args) => new Promise((resolve) => {
|
|
100
|
+
const child = spawn(file, [...args], { stdio: ["ignore", "pipe", "ignore"] });
|
|
101
|
+
let captured = "";
|
|
102
|
+
child.stdout.setEncoding("utf8");
|
|
103
|
+
child.stdout.on("data", (chunk) => {
|
|
104
|
+
captured += chunk;
|
|
105
|
+
});
|
|
106
|
+
child.once("error", () => resolve(undefined));
|
|
107
|
+
child.once("close", (code) => resolve(code === 0 ? captured : undefined));
|
|
108
|
+
}),
|
|
87
109
|
};
|
|
88
110
|
}
|
|
89
111
|
//# sourceMappingURL=node-io.js.map
|