@tuanhv/op-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ import { isFlatLink } from "../core/hal.js";
2
+ export function renderTable(header, rows) {
3
+ const widths = header.map((title, column) => Math.max(title.length, ...rows.map((row) => row[column]?.length ?? 0)));
4
+ const line = (cells) => cells
5
+ .map((cell, column) => {
6
+ const width = widths[column];
7
+ return column === cells.length - 1 || width === undefined
8
+ ? cell
9
+ : cell.padEnd(width);
10
+ })
11
+ .join(" ")
12
+ .trimEnd();
13
+ return [line(header), ...rows.map(line)].join("\n") + "\n";
14
+ }
15
+ /**
16
+ * One table cell from a flattened record: links show their name (or id),
17
+ * scalars pass through, everything else serialises as JSON.
18
+ */
19
+ export function formatCell(value) {
20
+ if (value === null || value === undefined) {
21
+ return "";
22
+ }
23
+ if (typeof value === "string") {
24
+ return value;
25
+ }
26
+ if (isFlatLink(value)) {
27
+ return value.name ?? (value.id === null ? "" : String(value.id));
28
+ }
29
+ return JSON.stringify(value);
30
+ }
31
+ export function renderStatusTable(row) {
32
+ return renderTable(["PROFILE", "INSTANCE", "PROJECT", "USER"], [
33
+ [
34
+ row.profile,
35
+ row.instance,
36
+ formatProject(row.project),
37
+ row.user,
38
+ ],
39
+ ]);
40
+ }
41
+ function formatProject(project) {
42
+ return project === undefined || project === null ? "" : String(project);
43
+ }
44
+ export function renderProfilesTable(rows) {
45
+ return renderTable(["PROFILE", "INSTANCE", "PROJECT", "ACTIVE"], rows.map((row) => [
46
+ row.name,
47
+ row.instance,
48
+ formatProject(row.project),
49
+ row.active ? "*" : "",
50
+ ]));
51
+ }
52
+ //# sourceMappingURL=table.js.map
package/dist/run.js ADDED
@@ -0,0 +1,300 @@
1
+ import { listProfiles, removeProfile, requireStoredName, resolveProfile, parseOptionalId, saveProfile, setActiveProfile, LOGIN_PROFILE, } from "./context/profile.js";
2
+ import { OpCliError, renderJsonError, renderTextError } from "./core/errors.js";
3
+ import { usageErrorFrom } from "./core/usage.js";
4
+ import { authenticate, bindRequestTimeout } from "./core/http.js";
5
+ import { renderProfilesTable, renderStatusTable } from "./output/table.js";
6
+ import { registerMetaCommands } from "./commands/meta.js";
7
+ import { registerDoctorCommand } from "./commands/doctor.js";
8
+ import { registerWpCommands } from "./commands/wp.js";
9
+ import { registerProjectCommands } from "./commands/project.js";
10
+ import { registerTimeCommands } from "./commands/time.js";
11
+ import { buildVersionOutput } from "./commands/version.js";
12
+ import { Command, CommanderError } from "commander";
13
+ export async function run(argv, env, io) {
14
+ let stdout = "";
15
+ let stderr = "";
16
+ // Spec story 51: one environment variable makes JSON the output for
17
+ // the whole session, errors included, instead of a flag per command.
18
+ let jsonOutput = env.OP_CLI_OUTPUT === "json";
19
+ // Keyed by the thrown error because Commander hands the failing command
20
+ // to its own exit callback and to nobody else.
21
+ const failedIn = new WeakMap();
22
+ const program = new Command()
23
+ .name("op-cli")
24
+ .description("A resolving command-line client for OpenProject")
25
+ .exitOverride()
26
+ .configureOutput({
27
+ writeOut: (text) => {
28
+ stdout += text;
29
+ },
30
+ writeErr: (text) => {
31
+ stderr += text;
32
+ },
33
+ });
34
+ if (env.OP_CLI_OUTPUT === "json") {
35
+ program.hook("preAction", (_thisCommand, actionCommand) => {
36
+ const options = actionCommand.opts();
37
+ const declaresJson = actionCommand.options.some((option) => option.name() === "json");
38
+ // The bulk stdin path is skipped: it already reports one NDJSON
39
+ // line per item, and its explicit --json refusal must survive.
40
+ if (declaresJson && options.json !== true && options.stdin !== true) {
41
+ options.json = true;
42
+ }
43
+ });
44
+ }
45
+ const auth = program
46
+ .command("auth")
47
+ .description("Authenticate with an OpenProject instance");
48
+ auth
49
+ .command("login")
50
+ .description("Verify and store OpenProject credentials")
51
+ .option("--profile <name>", 'profile to create or update, defaults to "default"')
52
+ .option("--project <id>", "default project stored on the profile")
53
+ .action(async (options) => {
54
+ if (!io.prompt || io.stdinIsTTY !== true) {
55
+ throw new OpCliError("USAGE_ERROR", "auth login requires an interactive terminal.", "set OPENPROJECT_URL and OPENPROJECT_API_KEY in the environment for non-interactive use.");
56
+ }
57
+ const enteredUrl = await io.prompt("Instance URL: ", false);
58
+ const apiKey = await io.prompt("API key: ", true);
59
+ let parsedUrl;
60
+ try {
61
+ parsedUrl = new URL(enteredUrl);
62
+ }
63
+ catch {
64
+ throw new OpCliError("USAGE_ERROR");
65
+ }
66
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
67
+ throw new OpCliError("USAGE_ERROR");
68
+ }
69
+ const instanceUrl = parsedUrl.toString().replace(/\/+$/, "");
70
+ const profileName = options.profile ?? LOGIN_PROFILE;
71
+ const project = parseOptionalId(options.project);
72
+ const user = await authenticate(instanceUrl, apiKey);
73
+ await saveProfile(env, instanceUrl, apiKey, profileName, project);
74
+ stdout +=
75
+ `Authenticated ${user.name} at ${instanceUrl} ` +
76
+ `using profile ${profileName}.\n`;
77
+ });
78
+ auth
79
+ .command("status")
80
+ .description("Show the active profile and authenticated user")
81
+ .option("--json", "emit a JSON object")
82
+ .option("--profile <name>", "use this profile for this command only")
83
+ .option("--project <id>", "override the profile default project")
84
+ .action(async (options) => {
85
+ jsonOutput = options.json === true;
86
+ const profile = await resolveProfile(env, {
87
+ profile: options.profile,
88
+ project: parseOptionalId(options.project),
89
+ });
90
+ const user = await authenticate(profile.instanceUrl, profile.apiKey);
91
+ if (options.json) {
92
+ stdout += `${JSON.stringify({
93
+ profile: profile.name,
94
+ instance: profile.instanceUrl,
95
+ project: profile.project ?? null,
96
+ user,
97
+ })}\n`;
98
+ return;
99
+ }
100
+ stdout += renderStatusTable({
101
+ profile: profile.name,
102
+ instance: profile.instanceUrl,
103
+ project: profile.project ?? null,
104
+ user: user.name,
105
+ });
106
+ });
107
+ auth
108
+ .command("list")
109
+ .description("List stored profiles and mark the active one")
110
+ .option("--json", "emit a JSON object")
111
+ .action(async (options) => {
112
+ jsonOutput = options.json === true;
113
+ const list = await listProfiles(env);
114
+ if (options.json) {
115
+ stdout += `${JSON.stringify({
116
+ active: list.activeName ?? null,
117
+ profiles: list.profiles.map((profile) => ({
118
+ name: profile.name,
119
+ instance: profile.instanceUrl,
120
+ project: profile.project ?? null,
121
+ })),
122
+ })}\n`;
123
+ return;
124
+ }
125
+ stdout += renderProfilesTable(list.profiles.map((profile) => ({
126
+ name: profile.name,
127
+ instance: profile.instanceUrl,
128
+ project: profile.project,
129
+ active: profile.name === list.activeName,
130
+ })));
131
+ });
132
+ auth
133
+ .command("use")
134
+ .description("Set the active profile")
135
+ .argument("<profile>")
136
+ .option("--project <id>", "change the profile's default project")
137
+ .action(async (profile, options) => {
138
+ await setActiveProfile(env, profile, {
139
+ project: parseOptionalId(options.project),
140
+ });
141
+ stdout += `Switched to profile ${profile}.\n`;
142
+ });
143
+ auth
144
+ .command("logout")
145
+ .description("Remove the stored credentials of a profile")
146
+ .option("--profile <name>", "profile to log out of, defaults to the active one")
147
+ .action(async (options) => {
148
+ const target = options.profile ?? (await requireStoredName(env));
149
+ await removeProfile(env, target);
150
+ stdout += `Logged out of profile ${target}.\n`;
151
+ });
152
+ const meta = program
153
+ .command("meta")
154
+ .description("Inspect the stored metadata of the instance");
155
+ registerMetaCommands(meta, {
156
+ env,
157
+ resolve: (overrides) => resolveProfile(env, overrides),
158
+ write: (text) => {
159
+ stdout += text;
160
+ },
161
+ setJsonMode: (on) => {
162
+ jsonOutput = on;
163
+ },
164
+ });
165
+ const wp = program
166
+ .command("wp")
167
+ .description("Inspect and manage work packages");
168
+ registerWpCommands(wp, {
169
+ env,
170
+ resolve: (overrides) => resolveProfile(env, overrides),
171
+ write: (text) => {
172
+ stdout += text;
173
+ },
174
+ writeErr: (text) => {
175
+ stderr += text;
176
+ },
177
+ setJsonMode: (on) => {
178
+ jsonOutput = on;
179
+ },
180
+ ...(io.readStdin === undefined ? {} : { readStdin: io.readStdin }),
181
+ });
182
+ const project = program
183
+ .command("project")
184
+ .description("Inspect and manage projects");
185
+ registerProjectCommands(project, {
186
+ resolve: (overrides) => resolveProfile(env, overrides),
187
+ write: (text) => {
188
+ stdout += text;
189
+ },
190
+ writeErr: (text) => {
191
+ stderr += text;
192
+ },
193
+ setJsonMode: (on) => {
194
+ jsonOutput = on;
195
+ },
196
+ });
197
+ const time = program
198
+ .command("time")
199
+ .description("Track and inspect time entries");
200
+ registerTimeCommands(time, {
201
+ env,
202
+ resolve: (overrides) => resolveProfile(env, overrides),
203
+ write: (text) => {
204
+ stdout += text;
205
+ },
206
+ writeErr: (text) => {
207
+ stderr += text;
208
+ },
209
+ setJsonMode: (on) => {
210
+ jsonOutput = on;
211
+ },
212
+ });
213
+ const doctor = program
214
+ .command("doctor")
215
+ .description("Diagnose connectivity, credentials, permissions, and versions");
216
+ registerDoctorCommand(doctor, {
217
+ resolve: (overrides) => resolveProfile(env, overrides),
218
+ write: (text) => {
219
+ stdout += text;
220
+ },
221
+ setJsonMode: (on) => {
222
+ jsonOutput = on;
223
+ },
224
+ });
225
+ // Registered lazily and only at the root so a subcommand can reuse
226
+ // the "--version <value>" spelling as its own option (wp list).
227
+ if (argv[0] === "--version") {
228
+ program.version(await buildVersionOutput(env), "--version", "print version information");
229
+ }
230
+ // Commands built declaratively start as standalone Command objects, so
231
+ // they miss the root's exitOverride and output capture; without this
232
+ // walk their --help would print to the real stdout and process.exit,
233
+ // and the seam would never return.
234
+ //
235
+ // The walk also records which command refused the parse: Commander
236
+ // throws from that command's own exitOverride, and nothing downstream
237
+ // could name it afterwards.
238
+ const captureHelp = (command, path) => {
239
+ for (const child of command.commands) {
240
+ const childPath = `${path} ${child.name()}`;
241
+ child
242
+ .exitOverride((error) => {
243
+ failedIn.set(error, childPath);
244
+ throw error;
245
+ })
246
+ .configureOutput({
247
+ writeOut: (text) => {
248
+ stdout += text;
249
+ },
250
+ writeErr: (text) => {
251
+ stderr += text;
252
+ },
253
+ });
254
+ captureHelp(child, childPath);
255
+ }
256
+ };
257
+ captureHelp(program, program.name());
258
+ try {
259
+ // Environment misuse is refused before any command runs: a request
260
+ // budget that silently fell back would hide the user's mistake.
261
+ bindRequestTimeout(env);
262
+ await program.parseAsync([...argv], { from: "user" });
263
+ return { stdout, stderr, exitCode: 0 };
264
+ }
265
+ catch (error) {
266
+ if (error instanceof CommanderError
267
+ && (error.code === "commander.helpDisplayed" || error.code === "commander.version")) {
268
+ return { stdout, stderr, exitCode: 0 };
269
+ }
270
+ if (error instanceof CommanderError) {
271
+ const usageError = usageErrorFrom(error, failedIn.get(error) ?? program.name());
272
+ return {
273
+ stdout,
274
+ // Text mode replaces Commander's prose with the catalogue line,
275
+ // as before; JSON mode must stay a single parseable object.
276
+ stderr: jsonOutput
277
+ ? renderJsonError(usageError)
278
+ : renderTextError(usageError),
279
+ exitCode: usageError.exitCode,
280
+ };
281
+ }
282
+ if (error instanceof OpCliError) {
283
+ return {
284
+ stdout,
285
+ stderr: stderr + (jsonOutput ? renderJsonError(error) : renderTextError(error)),
286
+ exitCode: error.exitCode,
287
+ };
288
+ }
289
+ const internalError = new OpCliError("INTERNAL_ERROR");
290
+ return {
291
+ stdout,
292
+ stderr: stderr +
293
+ (jsonOutput
294
+ ? renderJsonError(internalError)
295
+ : renderTextError(internalError)),
296
+ exitCode: internalError.exitCode,
297
+ };
298
+ }
299
+ }
300
+ //# sourceMappingURL=run.js.map
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@tuanhv/op-cli",
3
+ "version": "0.1.0",
4
+ "description": "A resolving command-line client for OpenProject",
5
+ "license": "MIT",
6
+ "author": "hoangvantuan",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/hoangvantuan/openproject-cli.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/hoangvantuan/openproject-cli/issues"
13
+ },
14
+ "homepage": "https://github.com/hoangvantuan/openproject-cli#readme",
15
+ "keywords": [
16
+ "openproject",
17
+ "cli",
18
+ "command-line",
19
+ "project-management",
20
+ "work-packages",
21
+ "time-tracking"
22
+ ],
23
+ "type": "module",
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "bin": {
28
+ "op-cli": "dist/bin.js"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "!dist/**/*.map",
33
+ "NOTICE"
34
+ ],
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.json && chmod +x dist/bin.js",
37
+ "lint": "eslint src test",
38
+ "pretest": "npm run build",
39
+ "test": "vitest run",
40
+ "prepublishOnly": "npm run lint && npm test"
41
+ },
42
+ "dependencies": {
43
+ "commander": "^14.0.1"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^22.18.0",
47
+ "eslint": "^9.34.0",
48
+ "typescript": "^5.9.2",
49
+ "typescript-eslint": "^8.41.0",
50
+ "undici": "^7.15.0",
51
+ "vitest": "^3.2.4"
52
+ }
53
+ }