@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.
- package/LICENSE +21 -0
- package/NOTICE +2 -2
- package/README.md +29 -8
- 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 +189 -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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hoangvantuan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
CHANGED
|
@@ -11,5 +11,5 @@ OpenProject in the documentation and command output describe
|
|
|
11
11
|
interoperability with the OpenProject REST API only; they are nominative
|
|
12
12
|
references to that product, not claims of association.
|
|
13
13
|
|
|
14
|
-
op-cli is released under the MIT license; see the
|
|
15
|
-
package.json.
|
|
14
|
+
op-cli is released under the MIT license; see the LICENSE file. The
|
|
15
|
+
"license" field of package.json carries the same grant.
|
package/README.md
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
> GmbH; this tool only speaks the REST API of the OpenProject application you
|
|
8
8
|
> already run. See the NOTICE file for the full statement.
|
|
9
9
|
>
|
|
10
|
-
> **Early release.** This is
|
|
11
|
-
> surface may still change between releases; pin your version
|
|
12
|
-
> matters to you.
|
|
10
|
+
> **Early release.** This is an early 0.x release of a young tool. The
|
|
11
|
+
> command surface may still change between releases; pin your version
|
|
12
|
+
> if that matters to you.
|
|
13
13
|
|
|
14
14
|
A resolving CLI for OpenProject: every flag accepts a human name or an id,
|
|
15
15
|
and the tool turns names into ids itself. Errors carry stable `[CODE]`
|
|
@@ -22,6 +22,8 @@ markers so both humans and agents can react to them programmatically.
|
|
|
22
22
|
|
|
23
23
|
## From install to first useful command
|
|
24
24
|
|
|
25
|
+
The npm package is [@tuanhv/op-cli](https://www.npmjs.com/package/@tuanhv/op-cli):
|
|
26
|
+
|
|
25
27
|
```sh
|
|
26
28
|
npm install -g @tuanhv/op-cli
|
|
27
29
|
op-cli auth login # prompts for URL and API key; needs an interactive terminal
|
|
@@ -37,10 +39,14 @@ points beyond the quickstart:
|
|
|
37
39
|
|
|
38
40
|
```sh
|
|
39
41
|
op-cli wp create "Fix login bug" --type Bug --priority High
|
|
42
|
+
op-cli wp list --search login # server-side subject search
|
|
40
43
|
op-cli time log 42 --hours 1h30m --activity Development
|
|
44
|
+
op-cli time report --from 2026-08-01 --to 2026-08-31 # closed period
|
|
41
45
|
op-cli project member add web alice Manager # join members so assignment works
|
|
42
46
|
op-cli project list --search web
|
|
47
|
+
op-cli wp move 42 archive # move a work package to another project
|
|
43
48
|
op-cli doctor # diagnose connectivity, credentials, versions
|
|
49
|
+
op-cli update # self-update; prints the right command for Volta/Homebrew
|
|
44
50
|
```
|
|
45
51
|
|
|
46
52
|
## Things worth knowing
|
|
@@ -52,6 +58,13 @@ op-cli doctor # diagnose connectivity, credentials, versions
|
|
|
52
58
|
default project, scopes `wp list`, `wp count`, `time list`, and
|
|
53
59
|
`time report` to that project and its subprojects. Without one, those
|
|
54
60
|
four commands report instance-wide.
|
|
61
|
+
- **Finding things needs no id.** `wp list --search <text>` matches
|
|
62
|
+
subjects server-side, `--created-after` / `--updated-after` bound by
|
|
63
|
+
date, and `time list` / `time report` take `--from` and an inclusive
|
|
64
|
+
`--to`. Every name flag also accepts the human name or the id.
|
|
65
|
+
- **Tables respect the terminal.** Read from a terminal, a table is
|
|
66
|
+
bounded to its width and long cells are cut with an ellipsis; `--json`
|
|
67
|
+
output is never truncated.
|
|
55
68
|
- **Deletion is guarded.** Deleting a project is irreversible: `wp delete`,
|
|
56
69
|
`time delete`, and `project delete` all require an explicit `--yes`.
|
|
57
70
|
`user delete` is not offered at all; there is no workaround by design.
|
|
@@ -65,8 +78,10 @@ op-cli doctor # diagnose connectivity, credentials, versions
|
|
|
65
78
|
when `OPENPROJECT_URL` / `OPENPROJECT_API_KEY` are exported; without the
|
|
66
79
|
flag the environment wins over the active profile. A command served by the
|
|
67
80
|
environment reports itself as the profile `env`.
|
|
68
|
-
-
|
|
69
|
-
|
|
81
|
+
- **Update notices are interactive-only.** Commands run from a terminal
|
|
82
|
+
check npm for a newer release at most once a day and print one stderr
|
|
83
|
+
line; scripted use (stderr not a TTY) never checks, `--version` stays
|
|
84
|
+
offline, and `OP_CLI_NO_UPDATE_CHECK=1` disables the check entirely.
|
|
70
85
|
- **No escape hatch by design.** If a command is missing there is no raw
|
|
71
86
|
HTTP passthrough; report the gap instead of working around the tool.
|
|
72
87
|
|
|
@@ -94,7 +109,8 @@ mappings; do not build on it.
|
|
|
94
109
|
npm run build # compile src/ to dist/
|
|
95
110
|
npm test # unit tests over fixtures from a real instance
|
|
96
111
|
npm run lint
|
|
97
|
-
scripts/smoke.sh
|
|
112
|
+
scripts/smoke.sh # REQUIRED gate before publishing
|
|
113
|
+
npm publish --access public # scoped package; public is not the default
|
|
98
114
|
```
|
|
99
115
|
|
|
100
116
|
`scripts/smoke.sh` exercises a real create, update, time log, and delete
|
|
@@ -103,6 +119,11 @@ lifecycle against a scratch project on a live instance configured through
|
|
|
103
119
|
broken step. There are no automated integration tests by design; this
|
|
104
120
|
script is the regression net for real write paths before every publish.
|
|
105
121
|
|
|
106
|
-
|
|
122
|
+
Releases after the first can ship from CI: once the package exists and its
|
|
123
|
+
npm Trusted Publishers entry points at `.github/workflows/release.yml`,
|
|
124
|
+
pushing a `v*` tag has GitHub Actions publish with a short-lived OIDC token
|
|
125
|
+
and provenance; no npm token is stored anywhere.
|
|
107
126
|
|
|
108
|
-
|
|
127
|
+
## License
|
|
128
|
+
MIT; see the LICENSE file. See the NOTICE file for the trademark
|
|
129
|
+
statement.
|
package/dist/bin.js
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createNodeIo } from "./io/node-io.js";
|
|
3
3
|
import { run } from "./run.js";
|
|
4
|
+
/** 128 + 13: the exit status a shell reports for a process killed by SIGPIPE. */
|
|
5
|
+
const EXIT_SIGPIPE = 141;
|
|
6
|
+
/**
|
|
7
|
+
* Node ignores SIGPIPE, so writing after a pipe consumer is gone (`op-cli
|
|
8
|
+
* wp list | head`) surfaces as an EPIPE stream error. Without a listener
|
|
9
|
+
* that error escalates to an uncaught exception with a stack trace; exit
|
|
10
|
+
* quietly instead, and let every other write error keep crashing as before.
|
|
11
|
+
*/
|
|
12
|
+
function exitOnBrokenPipe(stream) {
|
|
13
|
+
stream.once("error", (error) => {
|
|
14
|
+
if (error.code === "EPIPE") {
|
|
15
|
+
process.exit(EXIT_SIGPIPE);
|
|
16
|
+
}
|
|
17
|
+
throw error;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
exitOnBrokenPipe(process.stdout);
|
|
21
|
+
exitOnBrokenPipe(process.stderr);
|
|
4
22
|
const result = await run(process.argv.slice(2), process.env, createNodeIo());
|
|
5
23
|
process.stdout.write(result.stdout);
|
|
6
24
|
process.stderr.write(result.stderr);
|
package/dist/commands/doctor.js
CHANGED
|
@@ -25,9 +25,12 @@ function errorCodeOf(error) {
|
|
|
25
25
|
}
|
|
26
26
|
function versionSummary(info) {
|
|
27
27
|
if (info.apiVersion === null && info.coreVersion === null) {
|
|
28
|
-
return "
|
|
28
|
+
return "reported neither version; the root document is optional, so core 13+ may still be running";
|
|
29
29
|
}
|
|
30
|
-
|
|
30
|
+
if (info.apiVersion === null) {
|
|
31
|
+
return `core ${info.coreVersion ?? "unknown"}; api version not reported, core 13+ is what the check reads`;
|
|
32
|
+
}
|
|
33
|
+
return `api ${info.apiVersion}, core ${info.coreVersion ?? "unknown"}`;
|
|
31
34
|
}
|
|
32
35
|
export function registerDoctorCommand(parent, runtime) {
|
|
33
36
|
parent
|
package/dist/commands/meta.js
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { clearStoredMetadata, customFieldAllowedNames, loadProjectVocabulary, loadStoredMetadata, readStoredMetadata, refreshStoredMetadata, } from "../context/metadata.js";
|
|
2
|
-
import {
|
|
2
|
+
import { parseProjectOverride } from "../context/profile.js";
|
|
3
3
|
import { defineLookupCommand, } from "../core/define.js";
|
|
4
4
|
import { renderTable } from "../output/table.js";
|
|
5
5
|
function star(value) {
|
|
6
6
|
return value === true ? "*" : "";
|
|
7
7
|
}
|
|
8
8
|
const projectOption = {
|
|
9
|
-
flag: "--project <id>",
|
|
9
|
+
flag: "--project <name-or-id>",
|
|
10
10
|
description: "override the profile default project",
|
|
11
11
|
};
|
|
12
|
+
const profileOption = {
|
|
13
|
+
flag: "--profile <name>",
|
|
14
|
+
description: "use this profile for this command only",
|
|
15
|
+
};
|
|
12
16
|
const typeLookup = {
|
|
13
17
|
name: "types",
|
|
14
18
|
description: "List the work package types of the instance",
|
|
15
19
|
select: (metadata) => metadata.types,
|
|
20
|
+
options: [profileOption],
|
|
16
21
|
columns: [
|
|
17
22
|
{ title: "ID", cell: (entry) => String(entry.id) },
|
|
18
23
|
{ title: "NAME", cell: (entry) => entry.name },
|
|
@@ -23,6 +28,7 @@ const statusLookup = {
|
|
|
23
28
|
name: "statuses",
|
|
24
29
|
description: "List the work package statuses of the instance",
|
|
25
30
|
select: (metadata) => metadata.statuses,
|
|
31
|
+
options: [profileOption],
|
|
26
32
|
columns: [
|
|
27
33
|
{ title: "ID", cell: (entry) => String(entry.id) },
|
|
28
34
|
{ title: "NAME", cell: (entry) => entry.name },
|
|
@@ -46,6 +52,7 @@ const priorityLookup = {
|
|
|
46
52
|
name: "priorities",
|
|
47
53
|
description: "List the work package priorities of the instance",
|
|
48
54
|
select: (metadata) => metadata.priorities,
|
|
55
|
+
options: [profileOption],
|
|
49
56
|
columns: [
|
|
50
57
|
{ title: "ID", cell: (entry) => String(entry.id) },
|
|
51
58
|
{ title: "NAME", cell: (entry) => entry.name },
|
|
@@ -56,7 +63,7 @@ const memberLookup = {
|
|
|
56
63
|
name: "members",
|
|
57
64
|
description: "List the members of the active project",
|
|
58
65
|
select: (vocabulary) => vocabulary.members,
|
|
59
|
-
options: [projectOption],
|
|
66
|
+
options: [profileOption, projectOption],
|
|
60
67
|
columns: [
|
|
61
68
|
{ title: "ID", cell: (entry) => String(entry.user_id) },
|
|
62
69
|
{ title: "NAME", cell: (entry) => entry.name },
|
|
@@ -71,7 +78,7 @@ const versionLookup = {
|
|
|
71
78
|
name: "versions",
|
|
72
79
|
description: "List the versions of the active project",
|
|
73
80
|
select: (vocabulary) => vocabulary.versions,
|
|
74
|
-
options: [projectOption],
|
|
81
|
+
options: [profileOption, projectOption],
|
|
75
82
|
columns: [
|
|
76
83
|
{ title: "ID", cell: (entry) => String(entry.id) },
|
|
77
84
|
{ title: "NAME", cell: (entry) => entry.name },
|
|
@@ -82,7 +89,7 @@ const categoryLookup = {
|
|
|
82
89
|
name: "categories",
|
|
83
90
|
description: "List the categories of the active project",
|
|
84
91
|
select: (vocabulary) => vocabulary.categories,
|
|
85
|
-
options: [projectOption],
|
|
92
|
+
options: [profileOption, projectOption],
|
|
86
93
|
columns: [
|
|
87
94
|
{ title: "ID", cell: (entry) => String(entry.id) },
|
|
88
95
|
{ title: "NAME", cell: (entry) => entry.name },
|
|
@@ -122,7 +129,7 @@ const fieldLookup = {
|
|
|
122
129
|
name: "fields",
|
|
123
130
|
description: "List the custom fields of the active project",
|
|
124
131
|
select: uniqueFields,
|
|
125
|
-
options: [projectOption],
|
|
132
|
+
options: [profileOption, projectOption],
|
|
126
133
|
columns: [
|
|
127
134
|
{ title: "ID", cell: (entry) => String(entry.id) },
|
|
128
135
|
{ title: "NAME", cell: (entry) => entry.name },
|
|
@@ -138,7 +145,7 @@ const activityLookup = {
|
|
|
138
145
|
name: "activities",
|
|
139
146
|
description: "List the time entry activities of the active project",
|
|
140
147
|
select: (vocabulary) => vocabulary.activities,
|
|
141
|
-
options: [projectOption],
|
|
148
|
+
options: [profileOption, projectOption],
|
|
142
149
|
columns: [
|
|
143
150
|
{ title: "ID", cell: (entry) => String(entry.id) },
|
|
144
151
|
{ title: "NAME", cell: (entry) => entry.name },
|
|
@@ -149,10 +156,11 @@ function registerShow(parent, runtime) {
|
|
|
149
156
|
const command = parent
|
|
150
157
|
.command("show")
|
|
151
158
|
.description("Show the stored metadata of the active profile")
|
|
159
|
+
.option("--profile <name>", "use this profile for this command only")
|
|
152
160
|
.option("--json", "emit a JSON object");
|
|
153
161
|
command.action(async (options) => {
|
|
154
162
|
runtime.setJsonMode(options.json === true);
|
|
155
|
-
const profile = await runtime.resolve();
|
|
163
|
+
const profile = await runtime.resolve({ profile: options.profile });
|
|
156
164
|
const stored = await readStoredMetadata(runtime.env, profile);
|
|
157
165
|
if (stored === undefined) {
|
|
158
166
|
runtime.write(options.json === true
|
|
@@ -186,10 +194,11 @@ function registerRefresh(parent, runtime) {
|
|
|
186
194
|
const command = parent
|
|
187
195
|
.command("refresh")
|
|
188
196
|
.description("Re-fetch the stored metadata and report what changed")
|
|
197
|
+
.option("--profile <name>", "use this profile for this command only")
|
|
189
198
|
.option("--json", "emit a JSON object");
|
|
190
199
|
command.action(async (options) => {
|
|
191
200
|
runtime.setJsonMode(options.json === true);
|
|
192
|
-
const profile = await runtime.resolve();
|
|
201
|
+
const profile = await runtime.resolve({ profile: options.profile });
|
|
193
202
|
const changes = await refreshStoredMetadata(runtime.env, profile);
|
|
194
203
|
if (options.json === true) {
|
|
195
204
|
runtime.write(`${JSON.stringify({
|
|
@@ -223,8 +232,9 @@ function registerClear(parent, runtime) {
|
|
|
223
232
|
parent
|
|
224
233
|
.command("clear")
|
|
225
234
|
.description("Delete the stored metadata of the active profile")
|
|
226
|
-
.
|
|
227
|
-
|
|
235
|
+
.option("--profile <name>", "use this profile for this command only")
|
|
236
|
+
.action(async (options) => {
|
|
237
|
+
const profile = await runtime.resolve({ profile: options.profile });
|
|
228
238
|
await clearStoredMetadata(runtime.env, profile);
|
|
229
239
|
runtime.write(`Cleared stored metadata for profile ${profile.name}.\n`);
|
|
230
240
|
});
|
|
@@ -236,14 +246,19 @@ function addLookup(parent, runtime, spec, load) {
|
|
|
236
246
|
setJsonMode: runtime.setJsonMode,
|
|
237
247
|
}));
|
|
238
248
|
}
|
|
249
|
+
/** The `--profile` override a lookup was handed, if any. */
|
|
250
|
+
function profileOverride(options) {
|
|
251
|
+
return typeof options.profile === "string" ? options.profile : undefined;
|
|
252
|
+
}
|
|
239
253
|
function instanceLoad(runtime) {
|
|
240
|
-
return async () => loadStoredMetadata(runtime.env, await runtime.resolve());
|
|
254
|
+
return async (options) => loadStoredMetadata(runtime.env, await runtime.resolve({ profile: profileOverride(options) }));
|
|
241
255
|
}
|
|
242
|
-
/** The active profile under this run's --project
|
|
256
|
+
/** The active profile under this run's --profile/--project overrides. */
|
|
243
257
|
async function projectProfile(runtime, options) {
|
|
244
258
|
const raw = options.project;
|
|
245
259
|
return runtime.resolve({
|
|
246
|
-
|
|
260
|
+
profile: profileOverride(options),
|
|
261
|
+
project: parseProjectOverride(typeof raw === "string" ? raw : undefined),
|
|
247
262
|
});
|
|
248
263
|
}
|
|
249
264
|
function projectLoad(runtime) {
|
package/dist/commands/project.js
CHANGED
|
@@ -6,7 +6,8 @@ import { flattenHalRecord, toFormattable } from "../core/hal.js";
|
|
|
6
6
|
import { apiDelete, apiGet, apiPatchRaw, apiPost, apiPostRaw, authenticate, } from "../core/http.js";
|
|
7
7
|
import { DEFAULT_PAGE_SIZE, allPageSize, halElements, parsePageSize, withPageSize, } from "../core/paginate.js";
|
|
8
8
|
import { isIdForm, rankByCloseness, resolveName, } from "../context/resolve.js";
|
|
9
|
-
import {
|
|
9
|
+
import { parseProjectOverride, } from "../context/profile.js";
|
|
10
|
+
import { PROJECTS_COLLECTION, resolveProjectRef } from "../context/projectref.js";
|
|
10
11
|
export const LIST_COLUMNS = [
|
|
11
12
|
{ title: "ID", field: "id" },
|
|
12
13
|
{ title: "IDENTIFIER", field: "identifier" },
|
|
@@ -33,7 +34,7 @@ async function connect(runtime, options) {
|
|
|
33
34
|
runtime.setJsonMode(options.json === true);
|
|
34
35
|
const profile = await runtime.resolve({
|
|
35
36
|
profile: options.profile,
|
|
36
|
-
project:
|
|
37
|
+
project: parseProjectOverride(options.project),
|
|
37
38
|
});
|
|
38
39
|
return {
|
|
39
40
|
getPage: (path) => apiGet(profile.instanceUrl, profile.apiKey, path),
|
|
@@ -42,75 +43,9 @@ async function connect(runtime, options) {
|
|
|
42
43
|
function collectValue(value, previous) {
|
|
43
44
|
return [...previous, value];
|
|
44
45
|
}
|
|
45
|
-
function hitOf(element) {
|
|
46
|
-
const candidate = element;
|
|
47
|
-
return typeof candidate.id === "number"
|
|
48
|
-
&& typeof candidate.identifier === "string"
|
|
49
|
-
&& typeof candidate.name === "string"
|
|
50
|
-
? { id: candidate.id, identifier: candidate.identifier, name: candidate.name }
|
|
51
|
-
: undefined;
|
|
52
|
-
}
|
|
53
|
-
const PROJECTS_COLLECTION = "/api/v3/projects";
|
|
54
46
|
const PRINCIPALS_COLLECTION = "/api/v3/principals";
|
|
55
47
|
const ROLES_COLLECTION = "/api/v3/roles";
|
|
56
48
|
const MEMBERSHIPS_COLLECTION = "/api/v3/memberships";
|
|
57
|
-
/**
|
|
58
|
-
* Every visible project, page by page in server order. Resolution walks
|
|
59
|
-
* instead of filtering server-side because the exact-match filter set
|
|
60
|
-
* differs between instance versions; the collection endpoint does not.
|
|
61
|
-
*/
|
|
62
|
-
async function walkProjects(getPage) {
|
|
63
|
-
const hits = [];
|
|
64
|
-
for await (const element of halElements(getPage, withPageSize(PROJECTS_COLLECTION, DEFAULT_PAGE_SIZE))) {
|
|
65
|
-
const hit = hitOf(element);
|
|
66
|
-
if (hit !== undefined) {
|
|
67
|
-
hits.push(hit);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return hits;
|
|
71
|
-
}
|
|
72
|
-
function missingProjectError(raw, known) {
|
|
73
|
-
return new OpCliError("NOT_FOUND", `project "${raw}" not found.`, known.length > 0
|
|
74
|
-
? `known projects, closest first: ${rankByCloseness(raw, known).slice(0, 6).join(", ")}.`
|
|
75
|
-
: "check the spelling or list projects with op-cli project list.");
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Resolve one reference that may be an id, an identifier, or a name.
|
|
79
|
-
* All-digits means an id and is fetched directly. Anything else matches
|
|
80
|
-
* identifier and name exactly across every visible project; one distinct
|
|
81
|
-
* match wins, several distinct projects fail loudly instead of guessing.
|
|
82
|
-
*/
|
|
83
|
-
async function resolveProjectRef(getPage, raw) {
|
|
84
|
-
if (isIdForm(raw)) {
|
|
85
|
-
try {
|
|
86
|
-
const hit = hitOf(await getPage(`${PROJECTS_COLLECTION}/${raw}`));
|
|
87
|
-
if (hit !== undefined) {
|
|
88
|
-
return hit;
|
|
89
|
-
}
|
|
90
|
-
throw new OpCliError("NOT_FOUND", `project "${raw}" not found.`, "check the id; run op-cli project list to see what is visible.");
|
|
91
|
-
}
|
|
92
|
-
catch (error) {
|
|
93
|
-
if (error instanceof OpCliError && error.code === "USAGE_ERROR") {
|
|
94
|
-
throw error;
|
|
95
|
-
}
|
|
96
|
-
if (error instanceof OpCliError && error.code === "NOT_FOUND") {
|
|
97
|
-
throw new OpCliError("NOT_FOUND", `project "${raw}" not found.`, "check the id; run op-cli project list to see what is visible.");
|
|
98
|
-
}
|
|
99
|
-
throw error;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
const all = await walkProjects(getPage);
|
|
103
|
-
const matches = all.filter((hit) => hit.identifier === raw || hit.name === raw);
|
|
104
|
-
const distinct = [...new Map(matches.map((hit) => [hit.id, hit])).values()];
|
|
105
|
-
if (distinct.length === 1) {
|
|
106
|
-
return distinct[0];
|
|
107
|
-
}
|
|
108
|
-
if (distinct.length > 1) {
|
|
109
|
-
const listed = distinct.map((hit) => `${String(hit.id)} (${hit.name})`).join(", ");
|
|
110
|
-
throw new OpCliError("USAGE_ERROR", `project "${raw}" is ambiguous. Candidates: ${listed}.`, "repeat the value as the explicit id of one candidate.");
|
|
111
|
-
}
|
|
112
|
-
throw missingProjectError(raw, all.flatMap((hit) => [hit.identifier, hit.name]));
|
|
113
|
-
}
|
|
114
49
|
/**
|
|
115
50
|
* One live lookup over an uncached collection. Stored metadata has a
|
|
116
51
|
* refresh to re-read the disk store; here refresh simply walks again.
|
|
@@ -276,7 +211,7 @@ export function registerProjectCommands(project, runtime) {
|
|
|
276
211
|
.option("--limit <n>", "maximum number of results to show")
|
|
277
212
|
.option("--all", "fetch every page instead of one limited page")
|
|
278
213
|
.option("--profile <name>", "use this profile for this command only")
|
|
279
|
-
.option("--project <id>", "override the profile default project")
|
|
214
|
+
.option("--project <name-or-id>", "override the profile default project")
|
|
280
215
|
.action(async (options) => {
|
|
281
216
|
runtime.setJsonMode(options.json === true);
|
|
282
217
|
// Flag misuse is refused before any traffic.
|
|
@@ -352,7 +287,7 @@ export function registerProjectCommands(project, runtime) {
|
|
|
352
287
|
.option("--json", "emit a flat JSON record")
|
|
353
288
|
.option("--fields <list>", "comma-separated fields to show")
|
|
354
289
|
.option("--profile <name>", "use this profile for this command only")
|
|
355
|
-
.option("--project <id>", "override the profile default project")
|
|
290
|
+
.option("--project <name-or-id>", "override the profile default project")
|
|
356
291
|
.action(async (reference, options) => {
|
|
357
292
|
const { getPage } = await connect(runtime, options);
|
|
358
293
|
const hit = await resolveProjectRef(getPage, reference);
|
|
@@ -372,13 +307,13 @@ export function registerProjectCommands(project, runtime) {
|
|
|
372
307
|
.option("--json", "emit a flat JSON record")
|
|
373
308
|
.option("--fields <list>", "comma-separated fields to show")
|
|
374
309
|
.option("--profile <name>", "use this profile for this command only")
|
|
375
|
-
.option("--project <id>", "override the profile default project")
|
|
310
|
+
.option("--project <name-or-id>", "override the profile default project")
|
|
376
311
|
.action(async (name, options) => {
|
|
377
312
|
const identifier = requireIdentifier(options, "create");
|
|
378
313
|
const { getPage } = await connect(runtime, options);
|
|
379
314
|
const profile = await runtime.resolve({
|
|
380
315
|
profile: options.profile,
|
|
381
|
-
project:
|
|
316
|
+
project: parseProjectOverride(options.project),
|
|
382
317
|
});
|
|
383
318
|
const payload = { name, identifier };
|
|
384
319
|
if (options.description !== undefined) {
|
|
@@ -408,7 +343,7 @@ export function registerProjectCommands(project, runtime) {
|
|
|
408
343
|
.option("--json", "emit a flat JSON record")
|
|
409
344
|
.option("--fields <list>", "comma-separated fields to show")
|
|
410
345
|
.option("--profile <name>", "use this profile for this command only")
|
|
411
|
-
.option("--project <id>", "override the profile default project")
|
|
346
|
+
.option("--project <name-or-id>", "override the profile default project")
|
|
412
347
|
.action(async (reference, options) => {
|
|
413
348
|
const isPublic = optionalBooleanFlag(options.public !== undefined, options.public, "public");
|
|
414
349
|
const touchesSomething = options.name !== undefined
|
|
@@ -420,7 +355,7 @@ export function registerProjectCommands(project, runtime) {
|
|
|
420
355
|
const { getPage } = await connect(runtime, options);
|
|
421
356
|
const profile = await runtime.resolve({
|
|
422
357
|
profile: options.profile,
|
|
423
|
-
project:
|
|
358
|
+
project: parseProjectOverride(options.project),
|
|
424
359
|
});
|
|
425
360
|
const hit = await resolveProjectRef(getPage, reference);
|
|
426
361
|
const payload = {};
|
|
@@ -447,13 +382,13 @@ export function registerProjectCommands(project, runtime) {
|
|
|
447
382
|
.option("--json", "emit a flat JSON record")
|
|
448
383
|
.option("--fields <list>", "comma-separated fields to show")
|
|
449
384
|
.option("--profile <name>", "use this profile for this command only")
|
|
450
|
-
.option("--project <id>", "override the profile default project")
|
|
385
|
+
.option("--project <name-or-id>", "override the profile default project")
|
|
451
386
|
.action(async (reference, name, options) => {
|
|
452
387
|
const identifier = requireIdentifier(options, "copy");
|
|
453
388
|
const { getPage } = await connect(runtime, options);
|
|
454
389
|
const profile = await runtime.resolve({
|
|
455
390
|
profile: options.profile,
|
|
456
|
-
project:
|
|
391
|
+
project: parseProjectOverride(options.project),
|
|
457
392
|
});
|
|
458
393
|
const hit = await resolveProjectRef(getPage, reference);
|
|
459
394
|
const source = (await getPage(`${PROJECTS_COLLECTION}/${String(hit.id)}`));
|
|
@@ -478,12 +413,12 @@ export function registerProjectCommands(project, runtime) {
|
|
|
478
413
|
.description("Mark one project as favourited for the current user")
|
|
479
414
|
.argument("<reference>")
|
|
480
415
|
.option("--profile <name>", "use this profile for this command only")
|
|
481
|
-
.option("--project <id>", "override the profile default project")
|
|
416
|
+
.option("--project <name-or-id>", "override the profile default project")
|
|
482
417
|
.action(async (reference, options) => {
|
|
483
418
|
const { getPage } = await connect(runtime, options);
|
|
484
419
|
const profile = await runtime.resolve({
|
|
485
420
|
profile: options.profile,
|
|
486
|
-
project:
|
|
421
|
+
project: parseProjectOverride(options.project),
|
|
487
422
|
});
|
|
488
423
|
const hit = await resolveProjectRef(getPage, reference);
|
|
489
424
|
await apiPost(profile.instanceUrl, profile.apiKey, `${PROJECTS_COLLECTION}/${String(hit.id)}/favorite`, {});
|
|
@@ -494,12 +429,12 @@ export function registerProjectCommands(project, runtime) {
|
|
|
494
429
|
.description("Remove one project from the current user's favourites")
|
|
495
430
|
.argument("<reference>")
|
|
496
431
|
.option("--profile <name>", "use this profile for this command only")
|
|
497
|
-
.option("--project <id>", "override the profile default project")
|
|
432
|
+
.option("--project <name-or-id>", "override the profile default project")
|
|
498
433
|
.action(async (reference, options) => {
|
|
499
434
|
const { getPage } = await connect(runtime, options);
|
|
500
435
|
const profile = await runtime.resolve({
|
|
501
436
|
profile: options.profile,
|
|
502
|
-
project:
|
|
437
|
+
project: parseProjectOverride(options.project),
|
|
503
438
|
});
|
|
504
439
|
const hit = await resolveProjectRef(getPage, reference);
|
|
505
440
|
await apiDelete(profile.instanceUrl, profile.apiKey, `${PROJECTS_COLLECTION}/${String(hit.id)}/favorite`);
|
|
@@ -519,7 +454,7 @@ export function registerProjectCommands(project, runtime) {
|
|
|
519
454
|
.option("--json", "emit a flat JSON array")
|
|
520
455
|
.option("--fields <list>", "comma-separated columns to show")
|
|
521
456
|
.option("--profile <name>", "use this profile for this command only")
|
|
522
|
-
.option("--project <id>", "override the profile default project")
|
|
457
|
+
.option("--project <name-or-id>", "override the profile default project")
|
|
523
458
|
.action(async (reference, options) => {
|
|
524
459
|
const { getPage } = await connect(runtime, options);
|
|
525
460
|
const hit = await resolveProjectRef(getPage, reference);
|
|
@@ -547,13 +482,13 @@ export function registerProjectCommands(project, runtime) {
|
|
|
547
482
|
.option("--json", "emit a flat JSON record")
|
|
548
483
|
.option("--fields <list>", "comma-separated fields to show")
|
|
549
484
|
.option("--profile <name>", "use this profile for this command only")
|
|
550
|
-
.option("--project <id>", "override the profile default project")
|
|
485
|
+
.option("--project <name-or-id>", "override the profile default project")
|
|
551
486
|
.action(async (reference, principal, roles, options) => {
|
|
552
487
|
runtime.setJsonMode(options.json === true);
|
|
553
488
|
const { getPage } = await connect(runtime, options);
|
|
554
489
|
const profile = await runtime.resolve({
|
|
555
490
|
profile: options.profile,
|
|
556
|
-
project:
|
|
491
|
+
project: parseProjectOverride(options.project),
|
|
557
492
|
});
|
|
558
493
|
const hit = await resolveProjectRef(getPage, reference);
|
|
559
494
|
const principalId = await resolvePrincipalId(profile, principal);
|
|
@@ -576,12 +511,12 @@ export function registerProjectCommands(project, runtime) {
|
|
|
576
511
|
.argument("<reference>", "project, by id, identifier, or name")
|
|
577
512
|
.argument("<principal>", "user or group, by name, id, or me")
|
|
578
513
|
.option("--profile <name>", "use this profile for this command only")
|
|
579
|
-
.option("--project <id>", "override the profile default project")
|
|
514
|
+
.option("--project <name-or-id>", "override the profile default project")
|
|
580
515
|
.action(async (reference, principal, options) => {
|
|
581
516
|
const { getPage } = await connect(runtime, options);
|
|
582
517
|
const profile = await runtime.resolve({
|
|
583
518
|
profile: options.profile,
|
|
584
|
-
project:
|
|
519
|
+
project: parseProjectOverride(options.project),
|
|
585
520
|
});
|
|
586
521
|
const hit = await resolveProjectRef(getPage, reference);
|
|
587
522
|
const principalId = await resolvePrincipalId(profile, principal);
|
|
@@ -624,7 +559,7 @@ export function registerProjectCommands(project, runtime) {
|
|
|
624
559
|
.argument("<reference>", "project, by id, identifier, or name")
|
|
625
560
|
.option("--yes", "confirm the deletion")
|
|
626
561
|
.option("--profile <name>", "use this profile for this command only")
|
|
627
|
-
.option("--project <id>", "override the profile default project")
|
|
562
|
+
.option("--project <name-or-id>", "override the profile default project")
|
|
628
563
|
.action(async (reference, options) => {
|
|
629
564
|
// The guard fires before any resolution or traffic: without --yes
|
|
630
565
|
// nothing is read, nothing is sent, with or without a terminal.
|
|
@@ -635,7 +570,7 @@ export function registerProjectCommands(project, runtime) {
|
|
|
635
570
|
const { getPage } = await connect(runtime, options);
|
|
636
571
|
const profile = await runtime.resolve({
|
|
637
572
|
profile: options.profile,
|
|
638
|
-
project:
|
|
573
|
+
project: parseProjectOverride(options.project),
|
|
639
574
|
});
|
|
640
575
|
const hit = await resolveProjectRef(getPage, reference);
|
|
641
576
|
await apiDelete(profile.instanceUrl, profile.apiKey, `${PROJECTS_COLLECTION}/${String(hit.id)}`);
|