@tuanhv/op-cli 0.1.0 → 0.2.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.
@@ -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 { parseOptionalId, } from "../context/profile.js";
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: parseOptionalId(options.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: parseOptionalId(options.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: parseOptionalId(options.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: parseOptionalId(options.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: parseOptionalId(options.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: parseOptionalId(options.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: parseOptionalId(options.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: parseOptionalId(options.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}.\n`);
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
- ? parseOptionalId(options.project)
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")
@@ -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
  }
@@ -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
- if (env.OP_CLI_CONFIG_DIR) {
7
- return env.OP_CLI_CONFIG_DIR;
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 join(home, ".config", "op-cli");
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: overrides.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
@@ -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.
@@ -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
- * Turn the raw `--updated-after` input into the local date the window
25
- * opens at: today, yesterday, N days ago, or an explicit date. The
26
- * upper bound is deliberately left open (#24): the API reads a closed
27
- * one as the start of that day, so every change made during the
28
- * closing day would drop out. Callers emit `<>d [date, ""]`.
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
- export function sinceDate(raw, now) {
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 today;
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.`, `--updated-after accepts ${DATE_FORMS}.`);
49
- }
50
- if (token > today) {
51
- throw new OpCliError("USAGE_ERROR", `"${raw}" is in the future.`, "--updated-after needs a date up to today.");
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 an updated-after value.`, `--updated-after accepts ${DATE_FORMS}.`);
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({
@@ -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