@tuanhv/op-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +15 -0
- package/README.md +108 -0
- package/dist/bin.js +8 -0
- package/dist/commands/doctor.js +135 -0
- package/dist/commands/meta.js +283 -0
- package/dist/commands/project.js +645 -0
- package/dist/commands/time.js +555 -0
- package/dist/commands/version.js +47 -0
- package/dist/commands/wp.js +1586 -0
- package/dist/context/metadata.js +528 -0
- package/dist/context/profile.js +218 -0
- package/dist/context/resolve.js +96 -0
- package/dist/core/define.js +155 -0
- package/dist/core/duration.js +151 -0
- package/dist/core/errors.js +102 -0
- package/dist/core/filters.js +133 -0
- package/dist/core/hal.js +114 -0
- package/dist/core/http.js +119 -0
- package/dist/core/paginate.js +55 -0
- package/dist/core/usage.js +58 -0
- package/dist/io/node-io.js +89 -0
- package/dist/output/table.js +52 -0
- package/dist/run.js +300 -0
- package/package.json +53 -0
package/NOTICE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
NOTICE
|
|
2
|
+
======
|
|
3
|
+
|
|
4
|
+
op-cli is an independent, community-developed command-line tool. It is not
|
|
5
|
+
affiliated with, endorsed by, sponsored by, or connected to OpenProject GmbH
|
|
6
|
+
in any way.
|
|
7
|
+
|
|
8
|
+
"OpenProject" is a trademark of OpenProject GmbH. This project makes no use
|
|
9
|
+
of OpenProject branding, logos, or other trademarks. References to
|
|
10
|
+
OpenProject in the documentation and command output describe
|
|
11
|
+
interoperability with the OpenProject REST API only; they are nominative
|
|
12
|
+
references to that product, not claims of association.
|
|
13
|
+
|
|
14
|
+
op-cli is released under the MIT license; see the "license" field of
|
|
15
|
+
package.json.
|
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# op-cli
|
|
2
|
+
|
|
3
|
+
> **Not affiliated with OpenProject GmbH.** op-cli is an independent,
|
|
4
|
+
> community-developed command-line tool. It is not affiliated with, endorsed
|
|
5
|
+
> by, or connected to OpenProject GmbH in any way, and it makes no use of
|
|
6
|
+
> OpenProject branding or logos. "OpenProject" is a trademark of OpenProject
|
|
7
|
+
> GmbH; this tool only speaks the REST API of the OpenProject application you
|
|
8
|
+
> already run. See the NOTICE file for the full statement.
|
|
9
|
+
>
|
|
10
|
+
> **Early release.** This is version 0.1.0 of a young tool. The command
|
|
11
|
+
> surface may still change between releases; pin your version if that
|
|
12
|
+
> matters to you.
|
|
13
|
+
|
|
14
|
+
A resolving CLI for OpenProject: every flag accepts a human name or an id,
|
|
15
|
+
and the tool turns names into ids itself. Errors carry stable `[CODE]`
|
|
16
|
+
markers so both humans and agents can react to them programmatically.
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Node.js 20 or newer.
|
|
21
|
+
- An OpenProject instance running v13 or later (API v3).
|
|
22
|
+
|
|
23
|
+
## From install to first useful command
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm install -g @tuanhv/op-cli
|
|
27
|
+
op-cli auth login # prompts for URL and API key; needs an interactive terminal
|
|
28
|
+
op-cli wp list --open # list open work packages of your instance
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Prefer environment variables over a stored profile? Export
|
|
32
|
+
`OPENPROJECT_URL` and `OPENPROJECT_API_KEY` instead of running
|
|
33
|
+
`op-cli auth login`; every command picks them up automatically.
|
|
34
|
+
|
|
35
|
+
Run any command with `--help` for its full reference. Useful starting
|
|
36
|
+
points beyond the quickstart:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
op-cli wp create "Fix login bug" --type Bug --priority High
|
|
40
|
+
op-cli time log 42 --hours 1h30m --activity Development
|
|
41
|
+
op-cli project member add web alice Manager # join members so assignment works
|
|
42
|
+
op-cli project list --search web
|
|
43
|
+
op-cli doctor # diagnose connectivity, credentials, versions
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Things worth knowing
|
|
47
|
+
|
|
48
|
+
- **Errors are a closed set.** Every failure prints a stable code on stderr,
|
|
49
|
+
such as `[CONFLICT]` or `[PROFILE_NOT_FOUND]`, with exit codes 0 through 7.
|
|
50
|
+
Run `op-cli doctor` when something does not work.
|
|
51
|
+
- **A project in context narrows listings.** `--project`, or a profile's
|
|
52
|
+
default project, scopes `wp list`, `wp count`, `time list`, and
|
|
53
|
+
`time report` to that project and its subprojects. Without one, those
|
|
54
|
+
four commands report instance-wide.
|
|
55
|
+
- **Deletion is guarded.** Deleting a project is irreversible: `wp delete`,
|
|
56
|
+
`time delete`, and `project delete` all require an explicit `--yes`.
|
|
57
|
+
`user delete` is not offered at all; there is no workaround by design.
|
|
58
|
+
- **JSON records follow the API, `--fields` narrows them.** `--json` emits
|
|
59
|
+
the flattened OpenProject record, so an instance's own custom fields show
|
|
60
|
+
up without a CLI release; pass `--fields id,subject,status` on any
|
|
61
|
+
record-returning command to keep only what you read. With `--all` a
|
|
62
|
+
listing streams NDJSON, one record per line, not an array.
|
|
63
|
+
- **Profiles beat the environment only when named.** An explicit
|
|
64
|
+
`--profile <name>` uses that profile's own instance URL and API key, even
|
|
65
|
+
when `OPENPROJECT_URL` / `OPENPROJECT_API_KEY` are exported; without the
|
|
66
|
+
flag the environment wins over the active profile. A command served by the
|
|
67
|
+
environment reports itself as the profile `env`.
|
|
68
|
+
- **`project copy` copies properties only** (description, visibility, and
|
|
69
|
+
parent). It does not copy work packages, members, or wiki pages.
|
|
70
|
+
- **No escape hatch by design.** If a command is missing there is no raw
|
|
71
|
+
HTTP passthrough; report the gap instead of working around the tool.
|
|
72
|
+
|
|
73
|
+
## Companion skill
|
|
74
|
+
|
|
75
|
+
`skills/op-cli/SKILL.md` packages this CLI's workflows as a coding-agent
|
|
76
|
+
skill. It ships in this repository, not in the npm package; to install
|
|
77
|
+
it, clone the repo and copy that directory into your agent's skills folder:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
cp -R skills/op-cli ~/.claude/skills/op-cli
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## The Python predecessor
|
|
84
|
+
|
|
85
|
+
op-cli is a TypeScript rewrite of an earlier Python-based OpenProject skill.
|
|
86
|
+
That archived implementation lives on the `feature/openproject-skill` branch
|
|
87
|
+
of [claude-plugin](https://github.com/hoangvantuan/claude-plugin/tree/feature/openproject-skill).
|
|
88
|
+
It is kept unmerged as historical documentation of API quirks and field
|
|
89
|
+
mappings; do not build on it.
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
npm run build # compile src/ to dist/
|
|
95
|
+
npm test # unit tests over fixtures from a real instance
|
|
96
|
+
npm run lint
|
|
97
|
+
scripts/smoke.sh # REQUIRED gate before publishing
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`scripts/smoke.sh` exercises a real create, update, time log, and delete
|
|
101
|
+
lifecycle against a scratch project on a live instance configured through
|
|
102
|
+
`OPENPROJECT_URL` and `OPENPROJECT_API_KEY`. It fails loudly at the first
|
|
103
|
+
broken step. There are no automated integration tests by design; this
|
|
104
|
+
script is the regression net for real write paths before every publish.
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT. See the NOTICE file for the trademark statement.
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createNodeIo } from "./io/node-io.js";
|
|
3
|
+
import { run } from "./run.js";
|
|
4
|
+
const result = await run(process.argv.slice(2), process.env, createNodeIo());
|
|
5
|
+
process.stdout.write(result.stdout);
|
|
6
|
+
process.stderr.write(result.stderr);
|
|
7
|
+
process.exitCode = result.exitCode;
|
|
8
|
+
//# sourceMappingURL=bin.js.map
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { OpCliError } from "../core/errors.js";
|
|
2
|
+
import { apiGet, authenticate } from "../core/http.js";
|
|
3
|
+
import { renderTable } from "../output/table.js";
|
|
4
|
+
// The CLI speaks OpenProject API v3; the documented minimum core release is
|
|
5
|
+
// v13 (PLAN.md row "Phiên bản OpenProject cũ"). An instance older than v13
|
|
6
|
+
// still works partially, so it only warns; a non-v3 API cannot work at all,
|
|
7
|
+
// so it fails with its own catalogue code.
|
|
8
|
+
const MINIMUM_CORE_MAJOR = 13;
|
|
9
|
+
function toRootInfo(root) {
|
|
10
|
+
const element = typeof root === "object" && root !== null
|
|
11
|
+
? root
|
|
12
|
+
: {};
|
|
13
|
+
return {
|
|
14
|
+
apiVersion: typeof element.apiVersion === "string" ? element.apiVersion : null,
|
|
15
|
+
coreVersion: typeof element.coreVersion === "string" ? element.coreVersion : null,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function majorOf(version) {
|
|
19
|
+
const match = /^(\d+)/.exec(version.trim());
|
|
20
|
+
const major = match?.[1];
|
|
21
|
+
return major === undefined ? undefined : Number.parseInt(major, 10);
|
|
22
|
+
}
|
|
23
|
+
function errorCodeOf(error) {
|
|
24
|
+
return error instanceof OpCliError ? error.code : "INTERNAL_ERROR";
|
|
25
|
+
}
|
|
26
|
+
function versionSummary(info) {
|
|
27
|
+
if (info.apiVersion === null && info.coreVersion === null) {
|
|
28
|
+
return "api unknown, core unknown (stock instances may omit this)";
|
|
29
|
+
}
|
|
30
|
+
return `api ${info.apiVersion ?? "unknown"}, core ${info.coreVersion ?? "unknown"}`;
|
|
31
|
+
}
|
|
32
|
+
export function registerDoctorCommand(parent, runtime) {
|
|
33
|
+
parent
|
|
34
|
+
.description("Diagnose connectivity, credentials, permissions, and versions")
|
|
35
|
+
.option("--json", "emit a JSON object")
|
|
36
|
+
.option("--profile <name>", "diagnose this profile instead of the active one")
|
|
37
|
+
.action(async (options) => {
|
|
38
|
+
runtime.setJsonMode(options.json === true);
|
|
39
|
+
const profile = await runtime.resolve({ profile: options.profile });
|
|
40
|
+
const checks = [];
|
|
41
|
+
let failureCode;
|
|
42
|
+
const recordFailure = (check, detail, code) => {
|
|
43
|
+
checks.push({ check, status: "fail", detail });
|
|
44
|
+
failureCode = failureCode ?? code;
|
|
45
|
+
};
|
|
46
|
+
const skip = (name) => {
|
|
47
|
+
checks.push({
|
|
48
|
+
check: name,
|
|
49
|
+
status: "skipped",
|
|
50
|
+
detail: "not attempted: an earlier check failed",
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
let info = { apiVersion: null, coreVersion: null };
|
|
54
|
+
try {
|
|
55
|
+
info = toRootInfo(await apiGet(profile.instanceUrl, profile.apiKey, "/api/v3/"));
|
|
56
|
+
checks.push({
|
|
57
|
+
check: "connectivity",
|
|
58
|
+
status: "pass",
|
|
59
|
+
detail: `reached ${profile.instanceUrl}`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
recordFailure("connectivity", `could not reach ${profile.instanceUrl} (${errorCodeOf(error)})`, errorCodeOf(error));
|
|
64
|
+
}
|
|
65
|
+
if (failureCode !== undefined) {
|
|
66
|
+
skip("credentials");
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
try {
|
|
70
|
+
const user = await authenticate(profile.instanceUrl, profile.apiKey);
|
|
71
|
+
checks.push({
|
|
72
|
+
check: "credentials",
|
|
73
|
+
status: "pass",
|
|
74
|
+
detail: `authenticated as ${user.login}`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
recordFailure("credentials", "could not verify credentials", errorCodeOf(error));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (failureCode !== undefined) {
|
|
82
|
+
skip("permissions");
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
try {
|
|
86
|
+
await apiGet(profile.instanceUrl, profile.apiKey, "/api/v3/projects?pageSize=1");
|
|
87
|
+
checks.push({
|
|
88
|
+
check: "permissions",
|
|
89
|
+
status: "pass",
|
|
90
|
+
detail: "can list projects",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
recordFailure("permissions", "cannot list projects", errorCodeOf(error));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (failureCode !== undefined) {
|
|
98
|
+
skip("versions");
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const apiMajor = info.apiVersion !== null ? majorOf(info.apiVersion) : undefined;
|
|
102
|
+
const coreMajor = info.coreVersion !== null ? majorOf(info.coreVersion) : undefined;
|
|
103
|
+
if (apiMajor !== undefined && apiMajor !== 3) {
|
|
104
|
+
recordFailure("versions", `API ${info.apiVersion} is not supported`, "UNSUPPORTED_VERSION");
|
|
105
|
+
}
|
|
106
|
+
else if (coreMajor !== undefined && coreMajor < MINIMUM_CORE_MAJOR) {
|
|
107
|
+
checks.push({
|
|
108
|
+
check: "versions",
|
|
109
|
+
status: "warn",
|
|
110
|
+
detail: `core ${info.coreVersion} is below the minimum supported v13`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
checks.push({
|
|
115
|
+
check: "versions",
|
|
116
|
+
status: "pass",
|
|
117
|
+
detail: versionSummary(info),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (options.json === true) {
|
|
122
|
+
runtime.write(`${JSON.stringify({ ok: failureCode === undefined, checks })}\n`);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
runtime.write(renderTable(["CHECK", "STATUS", "DETAIL"], checks.map((check) => [check.check, check.status, check.detail])));
|
|
126
|
+
for (const warning of checks.filter((check) => check.status === "warn")) {
|
|
127
|
+
runtime.write(`warning: ${warning.check}: ${warning.detail}\n`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (failureCode !== undefined) {
|
|
131
|
+
throw new OpCliError(failureCode);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=doctor.js.map
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { clearStoredMetadata, customFieldAllowedNames, loadProjectVocabulary, loadStoredMetadata, readStoredMetadata, refreshStoredMetadata, } from "../context/metadata.js";
|
|
2
|
+
import { parseOptionalId } from "../context/profile.js";
|
|
3
|
+
import { defineLookupCommand, } from "../core/define.js";
|
|
4
|
+
import { renderTable } from "../output/table.js";
|
|
5
|
+
function star(value) {
|
|
6
|
+
return value === true ? "*" : "";
|
|
7
|
+
}
|
|
8
|
+
const projectOption = {
|
|
9
|
+
flag: "--project <id>",
|
|
10
|
+
description: "override the profile default project",
|
|
11
|
+
};
|
|
12
|
+
const typeLookup = {
|
|
13
|
+
name: "types",
|
|
14
|
+
description: "List the work package types of the instance",
|
|
15
|
+
select: (metadata) => metadata.types,
|
|
16
|
+
columns: [
|
|
17
|
+
{ title: "ID", cell: (entry) => String(entry.id) },
|
|
18
|
+
{ title: "NAME", cell: (entry) => entry.name },
|
|
19
|
+
{ title: "MILESTONE", cell: (entry) => star(entry.is_milestone) },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
const statusLookup = {
|
|
23
|
+
name: "statuses",
|
|
24
|
+
description: "List the work package statuses of the instance",
|
|
25
|
+
select: (metadata) => metadata.statuses,
|
|
26
|
+
columns: [
|
|
27
|
+
{ title: "ID", cell: (entry) => String(entry.id) },
|
|
28
|
+
{ title: "NAME", cell: (entry) => entry.name },
|
|
29
|
+
{ title: "CLOSED", cell: (entry) => star(entry.is_closed) },
|
|
30
|
+
{ title: "DEFAULT", cell: (entry) => star(entry.is_default) },
|
|
31
|
+
],
|
|
32
|
+
filters: [
|
|
33
|
+
{
|
|
34
|
+
flag: "--open",
|
|
35
|
+
description: "only statuses that are not closed",
|
|
36
|
+
keep: (entry) => !entry.is_closed,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
flag: "--closed",
|
|
40
|
+
description: "only closed statuses",
|
|
41
|
+
keep: (entry) => entry.is_closed,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
const priorityLookup = {
|
|
46
|
+
name: "priorities",
|
|
47
|
+
description: "List the work package priorities of the instance",
|
|
48
|
+
select: (metadata) => metadata.priorities,
|
|
49
|
+
columns: [
|
|
50
|
+
{ title: "ID", cell: (entry) => String(entry.id) },
|
|
51
|
+
{ title: "NAME", cell: (entry) => entry.name },
|
|
52
|
+
{ title: "DEFAULT", cell: (entry) => star(entry.is_default) },
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
const memberLookup = {
|
|
56
|
+
name: "members",
|
|
57
|
+
description: "List the members of the active project",
|
|
58
|
+
select: (vocabulary) => vocabulary.members,
|
|
59
|
+
options: [projectOption],
|
|
60
|
+
columns: [
|
|
61
|
+
{ title: "ID", cell: (entry) => String(entry.user_id) },
|
|
62
|
+
{ title: "NAME", cell: (entry) => entry.name },
|
|
63
|
+
{ title: "TYPE", cell: (entry) => entry.type },
|
|
64
|
+
{
|
|
65
|
+
title: "ROLES",
|
|
66
|
+
cell: (entry) => entry.roles.map((role) => role.title).join(", "),
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
const versionLookup = {
|
|
71
|
+
name: "versions",
|
|
72
|
+
description: "List the versions of the active project",
|
|
73
|
+
select: (vocabulary) => vocabulary.versions,
|
|
74
|
+
options: [projectOption],
|
|
75
|
+
columns: [
|
|
76
|
+
{ title: "ID", cell: (entry) => String(entry.id) },
|
|
77
|
+
{ title: "NAME", cell: (entry) => entry.name },
|
|
78
|
+
{ title: "STATUS", cell: (entry) => entry.status },
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
const categoryLookup = {
|
|
82
|
+
name: "categories",
|
|
83
|
+
description: "List the categories of the active project",
|
|
84
|
+
select: (vocabulary) => vocabulary.categories,
|
|
85
|
+
options: [projectOption],
|
|
86
|
+
columns: [
|
|
87
|
+
{ title: "ID", cell: (entry) => String(entry.id) },
|
|
88
|
+
{ title: "NAME", cell: (entry) => entry.name },
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* One row per custom field, however many work package types carry it.
|
|
93
|
+
* `custom_fields` is stored keyed by type id, so flattening it repeats a
|
|
94
|
+
* shared field once per type; a custom field is defined once for the
|
|
95
|
+
* instance, so those copies differ only in which type exposes them and
|
|
96
|
+
* that difference belongs in the TYPES column, not in extra rows.
|
|
97
|
+
*
|
|
98
|
+
* The `wp create` resolver keeps reading the per-type map directly: there
|
|
99
|
+
* a shared name repeated across types is exactly what makes it ambiguous.
|
|
100
|
+
*/
|
|
101
|
+
function uniqueFields(data) {
|
|
102
|
+
const typeName = new Map(data.metadata.types.map((entry) => [String(entry.id), entry.name]));
|
|
103
|
+
const byKey = new Map();
|
|
104
|
+
for (const [typeId, fields] of Object.entries(data.vocabulary.custom_fields)) {
|
|
105
|
+
const owner = typeName.get(typeId) ?? typeId;
|
|
106
|
+
for (const field of fields) {
|
|
107
|
+
const existing = byKey.get(field.key);
|
|
108
|
+
if (existing === undefined) {
|
|
109
|
+
byKey.set(field.key, { field, types: [owner] });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!existing.types.includes(owner)) {
|
|
113
|
+
existing.types.push(owner);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return [...byKey.values()]
|
|
118
|
+
.map((entry) => ({ ...entry.field, types: entry.types }))
|
|
119
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
120
|
+
}
|
|
121
|
+
const fieldLookup = {
|
|
122
|
+
name: "fields",
|
|
123
|
+
description: "List the custom fields of the active project",
|
|
124
|
+
select: uniqueFields,
|
|
125
|
+
options: [projectOption],
|
|
126
|
+
columns: [
|
|
127
|
+
{ title: "ID", cell: (entry) => String(entry.id) },
|
|
128
|
+
{ title: "NAME", cell: (entry) => entry.name },
|
|
129
|
+
{ title: "KEY", cell: (entry) => entry.key },
|
|
130
|
+
{ title: "TYPES", cell: (entry) => entry.types.join(", ") },
|
|
131
|
+
{
|
|
132
|
+
title: "ALLOWED VALUES",
|
|
133
|
+
cell: (entry) => customFieldAllowedNames(entry).join(", "),
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
const activityLookup = {
|
|
138
|
+
name: "activities",
|
|
139
|
+
description: "List the time entry activities of the active project",
|
|
140
|
+
select: (vocabulary) => vocabulary.activities,
|
|
141
|
+
options: [projectOption],
|
|
142
|
+
columns: [
|
|
143
|
+
{ title: "ID", cell: (entry) => String(entry.id) },
|
|
144
|
+
{ title: "NAME", cell: (entry) => entry.name },
|
|
145
|
+
{ title: "DEFAULT", cell: (entry) => star(entry.is_default) },
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
function registerShow(parent, runtime) {
|
|
149
|
+
const command = parent
|
|
150
|
+
.command("show")
|
|
151
|
+
.description("Show the stored metadata of the active profile")
|
|
152
|
+
.option("--json", "emit a JSON object");
|
|
153
|
+
command.action(async (options) => {
|
|
154
|
+
runtime.setJsonMode(options.json === true);
|
|
155
|
+
const profile = await runtime.resolve();
|
|
156
|
+
const stored = await readStoredMetadata(runtime.env, profile);
|
|
157
|
+
if (stored === undefined) {
|
|
158
|
+
runtime.write(options.json === true
|
|
159
|
+
? "null\n"
|
|
160
|
+
: "No metadata stored yet.\nRun op-cli meta types to load it.\n");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (options.json === true) {
|
|
164
|
+
runtime.write(`${JSON.stringify(stored)}\n`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
runtime.write(renderTable(["KEY", "VALUE"], [
|
|
168
|
+
["profile", profile.name],
|
|
169
|
+
["instance", stored.instance.url],
|
|
170
|
+
["api_version", stored.instance.api_version ?? ""],
|
|
171
|
+
["core_version", stored.instance.core_version ?? ""],
|
|
172
|
+
["fetched_at", stored.instance.fetched_at],
|
|
173
|
+
["project", formatProjectRef(stored)],
|
|
174
|
+
["types", String(stored.types.length)],
|
|
175
|
+
["statuses", String(stored.statuses.length)],
|
|
176
|
+
["priorities", String(stored.priorities.length)],
|
|
177
|
+
]));
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
function formatProjectRef(stored) {
|
|
181
|
+
return stored.project === undefined
|
|
182
|
+
? ""
|
|
183
|
+
: `${String(stored.project.id)} (${stored.project.identifier})`;
|
|
184
|
+
}
|
|
185
|
+
function registerRefresh(parent, runtime) {
|
|
186
|
+
const command = parent
|
|
187
|
+
.command("refresh")
|
|
188
|
+
.description("Re-fetch the stored metadata and report what changed")
|
|
189
|
+
.option("--json", "emit a JSON object");
|
|
190
|
+
command.action(async (options) => {
|
|
191
|
+
runtime.setJsonMode(options.json === true);
|
|
192
|
+
const profile = await runtime.resolve();
|
|
193
|
+
const changes = await refreshStoredMetadata(runtime.env, profile);
|
|
194
|
+
if (options.json === true) {
|
|
195
|
+
runtime.write(`${JSON.stringify({
|
|
196
|
+
profile: profile.name,
|
|
197
|
+
instance: profile.instanceUrl,
|
|
198
|
+
changes,
|
|
199
|
+
})}\n`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
runtime.write(`Refreshing metadata for profile ${profile.name} at ${profile.instanceUrl}.\n`);
|
|
203
|
+
writeChangeLines(runtime.write, changes);
|
|
204
|
+
runtime.write(changes.length > 0 ? "Metadata updated.\n" : "No changes.\n");
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
function writeChangeLines(write, changes) {
|
|
208
|
+
let index = 0;
|
|
209
|
+
while (index < changes.length) {
|
|
210
|
+
const section = changes[index]?.section;
|
|
211
|
+
const details = [];
|
|
212
|
+
while (index < changes.length && changes[index]?.section === section) {
|
|
213
|
+
const detail = changes[index]?.detail;
|
|
214
|
+
if (detail !== undefined) {
|
|
215
|
+
details.push(detail);
|
|
216
|
+
}
|
|
217
|
+
index += 1;
|
|
218
|
+
}
|
|
219
|
+
write(`${String(section)}: ${details.join("; ")}\n`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function registerClear(parent, runtime) {
|
|
223
|
+
parent
|
|
224
|
+
.command("clear")
|
|
225
|
+
.description("Delete the stored metadata of the active profile")
|
|
226
|
+
.action(async () => {
|
|
227
|
+
const profile = await runtime.resolve();
|
|
228
|
+
await clearStoredMetadata(runtime.env, profile);
|
|
229
|
+
runtime.write(`Cleared stored metadata for profile ${profile.name}.\n`);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
function addLookup(parent, runtime, spec, load) {
|
|
233
|
+
parent.addCommand(defineLookupCommand(spec, {
|
|
234
|
+
load,
|
|
235
|
+
write: runtime.write,
|
|
236
|
+
setJsonMode: runtime.setJsonMode,
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
function instanceLoad(runtime) {
|
|
240
|
+
return async () => loadStoredMetadata(runtime.env, await runtime.resolve());
|
|
241
|
+
}
|
|
242
|
+
/** The active profile under this run's --project override, if any. */
|
|
243
|
+
async function projectProfile(runtime, options) {
|
|
244
|
+
const raw = options.project;
|
|
245
|
+
return runtime.resolve({
|
|
246
|
+
project: parseOptionalId(typeof raw === "string" ? raw : undefined),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
function projectLoad(runtime) {
|
|
250
|
+
return async (options) => loadProjectVocabulary(runtime.env, await projectProfile(runtime, options));
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The fields lookup needs the instance types too, to name them. Instance
|
|
254
|
+
* metadata is loaded first because the vocabulary is stored inside it, so
|
|
255
|
+
* an empty cache is filled by one fetch of each rather than two.
|
|
256
|
+
*/
|
|
257
|
+
function fieldLoad(runtime) {
|
|
258
|
+
return async (options) => {
|
|
259
|
+
const profile = await projectProfile(runtime, options);
|
|
260
|
+
const metadata = await loadStoredMetadata(runtime.env, profile);
|
|
261
|
+
return {
|
|
262
|
+
metadata,
|
|
263
|
+
vocabulary: await loadProjectVocabulary(runtime.env, profile),
|
|
264
|
+
};
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
export function registerMetaCommands(parent, runtime) {
|
|
268
|
+
// Eight read-only lookups, one declaration each.
|
|
269
|
+
const instance = instanceLoad(runtime);
|
|
270
|
+
const project = projectLoad(runtime);
|
|
271
|
+
addLookup(parent, runtime, typeLookup, instance);
|
|
272
|
+
addLookup(parent, runtime, statusLookup, instance);
|
|
273
|
+
addLookup(parent, runtime, priorityLookup, instance);
|
|
274
|
+
addLookup(parent, runtime, memberLookup, project);
|
|
275
|
+
addLookup(parent, runtime, versionLookup, project);
|
|
276
|
+
addLookup(parent, runtime, categoryLookup, project);
|
|
277
|
+
addLookup(parent, runtime, fieldLookup, fieldLoad(runtime));
|
|
278
|
+
addLookup(parent, runtime, activityLookup, project);
|
|
279
|
+
registerShow(parent, runtime);
|
|
280
|
+
registerRefresh(parent, runtime);
|
|
281
|
+
registerClear(parent, runtime);
|
|
282
|
+
}
|
|
283
|
+
//# sourceMappingURL=meta.js.map
|