@tuanhv/op-cli 0.1.1 → 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 +20 -7
- package/dist/bin.js +18 -0
- package/dist/commands/doctor.js +5 -2
- package/dist/commands/meta.js +29 -14
- package/dist/commands/project.js +22 -87
- package/dist/commands/time.js +50 -27
- package/dist/commands/update.js +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/dist/output/table.js
CHANGED
|
@@ -1,14 +1,119 @@
|
|
|
1
1
|
import { isFlatLink } from "../core/hal.js";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/** Gap between two columns, in display cells. */
|
|
3
|
+
const COLUMN_GAP = 2;
|
|
4
|
+
/**
|
|
5
|
+
* Display width of one string: East Asian wide characters count two
|
|
6
|
+
* cells, combining marks count none, everything else one. An
|
|
7
|
+
* approximation by design: it fixes Vietnamese NFD and CJK tables
|
|
8
|
+
* without weighing the CLI down with a full Unicode width table.
|
|
9
|
+
*/
|
|
10
|
+
export function displayWidth(text) {
|
|
11
|
+
let width = 0;
|
|
12
|
+
for (const character of text) {
|
|
13
|
+
const code = character.codePointAt(0) ?? 0;
|
|
14
|
+
if (isZeroWidth(code)) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
width += isWide(code) ? 2 : 1;
|
|
18
|
+
}
|
|
19
|
+
return width;
|
|
20
|
+
}
|
|
21
|
+
function isZeroWidth(code) {
|
|
22
|
+
return ((code >= 0x0300 && code <= 0x036f)
|
|
23
|
+
|| (code >= 0x200b && code <= 0x200f)
|
|
24
|
+
|| (code >= 0xfe00 && code <= 0xfe0f)
|
|
25
|
+
|| code === 0xfeff);
|
|
26
|
+
}
|
|
27
|
+
function isWide(code) {
|
|
28
|
+
return ((code >= 0x1100 && code <= 0x115f)
|
|
29
|
+
|| (code >= 0x2e80 && code <= 0x303e)
|
|
30
|
+
|| (code >= 0x3041 && code <= 0x33ff)
|
|
31
|
+
|| (code >= 0x3400 && code <= 0x4dbf)
|
|
32
|
+
|| (code >= 0x4e00 && code <= 0x9fff)
|
|
33
|
+
|| (code >= 0xa000 && code <= 0xa4cf)
|
|
34
|
+
|| (code >= 0xa960 && code <= 0xa97f)
|
|
35
|
+
|| (code >= 0xac00 && code <= 0xd7a3)
|
|
36
|
+
|| (code >= 0xf900 && code <= 0xfaff)
|
|
37
|
+
|| (code >= 0xfe10 && code <= 0xfe19)
|
|
38
|
+
|| (code >= 0xfe30 && code <= 0xfe6f)
|
|
39
|
+
|| (code >= 0xff00 && code <= 0xff60)
|
|
40
|
+
|| (code >= 0xffe0 && code <= 0xffe6)
|
|
41
|
+
|| (code >= 0x1f300 && code <= 0x1f64f)
|
|
42
|
+
|| (code >= 0x1f900 && code <= 0x1f9ff));
|
|
43
|
+
}
|
|
44
|
+
/** The longest prefix of `text` that fits `target` cells, "…" if cut. */
|
|
45
|
+
function truncateToWidth(text, target) {
|
|
46
|
+
if (displayWidth(text) <= target) {
|
|
47
|
+
return text;
|
|
48
|
+
}
|
|
49
|
+
let kept = "";
|
|
50
|
+
let width = 0;
|
|
51
|
+
for (const character of text) {
|
|
52
|
+
const cell = displayWidth(character);
|
|
53
|
+
if (width + cell > target - 1) {
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
kept += character;
|
|
57
|
+
width += cell;
|
|
58
|
+
}
|
|
59
|
+
return kept + "…";
|
|
60
|
+
}
|
|
61
|
+
function padToWidth(text, target) {
|
|
62
|
+
return text + " ".repeat(Math.max(0, target - displayWidth(text)));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Terminal width for table bounding, when stdout is a TTY. Piped and
|
|
66
|
+
* captured output (tests, scripts) reports undefined and stays unbounded.
|
|
67
|
+
*/
|
|
68
|
+
export function terminalWidth() {
|
|
69
|
+
const columns = process.stdout?.columns;
|
|
70
|
+
return typeof columns === "number" && columns > 0 ? columns : undefined;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Column budgets that fit `maxWidth`: the widest column yields a cell
|
|
74
|
+
* at a time, so one long subject narrows instead of every column
|
|
75
|
+
* starving. Headers are truncated like data; a budget never drops
|
|
76
|
+
* below one cell.
|
|
77
|
+
*/
|
|
78
|
+
function shrinkColumns(widths, maxWidth) {
|
|
79
|
+
const budgets = [...widths];
|
|
80
|
+
const total = () => budgets.reduce((sum, width) => sum + width, 0);
|
|
81
|
+
while (total() > maxWidth) {
|
|
82
|
+
let widest = 0;
|
|
83
|
+
for (let column = 1; column < budgets.length; column += 1) {
|
|
84
|
+
if (budgets[column] > budgets[widest]) {
|
|
85
|
+
widest = column;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (budgets[widest] <= 1) {
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
budgets[widest] = budgets[widest] - 1;
|
|
92
|
+
}
|
|
93
|
+
return budgets;
|
|
94
|
+
}
|
|
95
|
+
export function renderTable(header, rows, maxWidth) {
|
|
96
|
+
const limit = maxWidth ?? terminalWidth();
|
|
97
|
+
const naturalWidths = header.map((title, column) => Math.max(displayWidth(title), ...rows.map((row) => displayWidth(row[column] ?? ""))));
|
|
98
|
+
const unbounded = limit === undefined
|
|
99
|
+
|| naturalWidths.reduce((sum, width) => sum + width, 0)
|
|
100
|
+
+ COLUMN_GAP * Math.max(0, header.length - 1)
|
|
101
|
+
<= limit;
|
|
102
|
+
const widths = unbounded
|
|
103
|
+
? naturalWidths
|
|
104
|
+
: shrinkColumns(naturalWidths, limit - COLUMN_GAP * Math.max(0, header.length - 1));
|
|
4
105
|
const line = (cells) => cells
|
|
5
106
|
.map((cell, column) => {
|
|
6
107
|
const width = widths[column];
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
108
|
+
if (width === undefined) {
|
|
109
|
+
return cell;
|
|
110
|
+
}
|
|
111
|
+
const bounded = unbounded ? cell : truncateToWidth(cell, width);
|
|
112
|
+
return column === cells.length - 1
|
|
113
|
+
? bounded
|
|
114
|
+
: padToWidth(bounded, width);
|
|
10
115
|
})
|
|
11
|
-
.join("
|
|
116
|
+
.join(" ".repeat(COLUMN_GAP))
|
|
12
117
|
.trimEnd();
|
|
13
118
|
return [line(header), ...rows.map(line)].join("\n") + "\n";
|
|
14
119
|
}
|
package/dist/run.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { listProfiles, removeProfile, requireStoredName, resolveProfile,
|
|
1
|
+
import { listProfiles, removeProfile, requireStoredName, resolveProfile, parseProjectOverride, saveProfile, setActiveProfile, LOGIN_PROFILE, } from "./context/profile.js";
|
|
2
|
+
import { resolveProjectOverride } from "./context/projectref.js";
|
|
2
3
|
import { OpCliError, renderJsonError, renderTextError } from "./core/errors.js";
|
|
3
4
|
import { usageErrorFrom } from "./core/usage.js";
|
|
4
5
|
import { authenticate, bindRequestTimeout } from "./core/http.js";
|
|
@@ -9,6 +10,7 @@ import { registerWpCommands } from "./commands/wp.js";
|
|
|
9
10
|
import { registerProjectCommands } from "./commands/project.js";
|
|
10
11
|
import { registerTimeCommands } from "./commands/time.js";
|
|
11
12
|
import { buildVersionOutput } from "./commands/version.js";
|
|
13
|
+
import { registerUpdateCommand, startVersionNotice } from "./commands/update.js";
|
|
12
14
|
import { Command, CommanderError } from "commander";
|
|
13
15
|
export async function run(argv, env, io) {
|
|
14
16
|
let stdout = "";
|
|
@@ -16,6 +18,9 @@ export async function run(argv, env, io) {
|
|
|
16
18
|
// Spec story 51: one environment variable makes JSON the output for
|
|
17
19
|
// the whole session, errors included, instead of a flag per command.
|
|
18
20
|
let jsonOutput = env.OP_CLI_OUTPUT === "json";
|
|
21
|
+
// The update command forwards the installer's own exit code; every
|
|
22
|
+
// other success path stays 0.
|
|
23
|
+
let forcedExitCode;
|
|
19
24
|
// Keyed by the thrown error because Commander hands the failing command
|
|
20
25
|
// to its own exit callback and to nobody else.
|
|
21
26
|
const failedIn = new WeakMap();
|
|
@@ -49,11 +54,13 @@ export async function run(argv, env, io) {
|
|
|
49
54
|
.command("login")
|
|
50
55
|
.description("Verify and store OpenProject credentials")
|
|
51
56
|
.option("--profile <name>", 'profile to create or update, defaults to "default"')
|
|
52
|
-
.option("--project <id>", "default project stored on the profile")
|
|
57
|
+
.option("--project <name-or-id>", "default project stored on the profile")
|
|
53
58
|
.action(async (options) => {
|
|
54
59
|
if (!io.prompt || io.stdinIsTTY !== true) {
|
|
55
60
|
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
61
|
}
|
|
62
|
+
// Flag misuse is refused before the interactive prompt starts.
|
|
63
|
+
const projectRef = parseProjectOverride(options.project);
|
|
57
64
|
const enteredUrl = await io.prompt("Instance URL: ", false);
|
|
58
65
|
const apiKey = await io.prompt("API key: ", true);
|
|
59
66
|
let parsedUrl;
|
|
@@ -68,8 +75,11 @@ export async function run(argv, env, io) {
|
|
|
68
75
|
}
|
|
69
76
|
const instanceUrl = parsedUrl.toString().replace(/\/+$/, "");
|
|
70
77
|
const profileName = options.profile ?? LOGIN_PROFILE;
|
|
71
|
-
const project = parseOptionalId(options.project);
|
|
72
78
|
const user = await authenticate(instanceUrl, apiKey);
|
|
79
|
+
let project = projectRef;
|
|
80
|
+
if (typeof project === "string") {
|
|
81
|
+
project = await resolveProjectOverride(instanceUrl, apiKey, project);
|
|
82
|
+
}
|
|
73
83
|
await saveProfile(env, instanceUrl, apiKey, profileName, project);
|
|
74
84
|
stdout +=
|
|
75
85
|
`Authenticated ${user.name} at ${instanceUrl} ` +
|
|
@@ -80,12 +90,12 @@ export async function run(argv, env, io) {
|
|
|
80
90
|
.description("Show the active profile and authenticated user")
|
|
81
91
|
.option("--json", "emit a JSON object")
|
|
82
92
|
.option("--profile <name>", "use this profile for this command only")
|
|
83
|
-
.option("--project <id>", "override the profile default project")
|
|
93
|
+
.option("--project <name-or-id>", "override the profile default project")
|
|
84
94
|
.action(async (options) => {
|
|
85
95
|
jsonOutput = options.json === true;
|
|
86
96
|
const profile = await resolveProfile(env, {
|
|
87
97
|
profile: options.profile,
|
|
88
|
-
project:
|
|
98
|
+
project: parseProjectOverride(options.project),
|
|
89
99
|
});
|
|
90
100
|
const user = await authenticate(profile.instanceUrl, profile.apiKey);
|
|
91
101
|
if (options.json) {
|
|
@@ -133,11 +143,17 @@ export async function run(argv, env, io) {
|
|
|
133
143
|
.command("use")
|
|
134
144
|
.description("Set the active profile")
|
|
135
145
|
.argument("<profile>")
|
|
136
|
-
.option("--project <id>", "change the profile's default project")
|
|
146
|
+
.option("--project <name-or-id>", "change the profile's default project")
|
|
137
147
|
.action(async (profile, options) => {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
148
|
+
// A named project resolves against the target profile's own
|
|
149
|
+
// instance so what gets stored is a numeric id, like every other
|
|
150
|
+
// surface; ids and an absent flag never touch the network.
|
|
151
|
+
let project = parseProjectOverride(options.project);
|
|
152
|
+
if (typeof project === "string") {
|
|
153
|
+
const target = await resolveProfile(env, { profile });
|
|
154
|
+
project = await resolveProjectOverride(target.instanceUrl, target.apiKey, project);
|
|
155
|
+
}
|
|
156
|
+
await setActiveProfile(env, profile, { project });
|
|
141
157
|
stdout += `Switched to profile ${profile}.\n`;
|
|
142
158
|
});
|
|
143
159
|
auth
|
|
@@ -222,11 +238,31 @@ export async function run(argv, env, io) {
|
|
|
222
238
|
jsonOutput = on;
|
|
223
239
|
},
|
|
224
240
|
});
|
|
241
|
+
const update = program
|
|
242
|
+
.command("update")
|
|
243
|
+
.description("Update the CLI to the latest published release");
|
|
244
|
+
registerUpdateCommand(update, {
|
|
245
|
+
env,
|
|
246
|
+
write: (text) => {
|
|
247
|
+
stdout += text;
|
|
248
|
+
},
|
|
249
|
+
writeErr: (text) => {
|
|
250
|
+
stderr += text;
|
|
251
|
+
},
|
|
252
|
+
setExitCode: (code) => {
|
|
253
|
+
forcedExitCode = code;
|
|
254
|
+
},
|
|
255
|
+
...(io.runExternal === undefined ? {} : { runExternal: io.runExternal }),
|
|
256
|
+
...(io.captureExternal === undefined ? {} : { captureExternal: io.captureExternal }),
|
|
257
|
+
});
|
|
225
258
|
// Registered lazily and only at the root so a subcommand can reuse
|
|
226
|
-
// the "--version <value>" spelling as its own option (wp list)
|
|
259
|
+
// the "--version <value>" spelling as its own option (wp list); a
|
|
260
|
+
// root-level version flag hijacks that filter. Root help still
|
|
261
|
+
// advertises the invocation.
|
|
227
262
|
if (argv[0] === "--version") {
|
|
228
263
|
program.version(await buildVersionOutput(env), "--version", "print version information");
|
|
229
264
|
}
|
|
265
|
+
program.addHelpText("after", "\nRun op-cli --version for version information.");
|
|
230
266
|
// Commands built declaratively start as standalone Command objects, so
|
|
231
267
|
// they miss the root's exitOverride and output capture; without this
|
|
232
268
|
// walk their --help would print to the real stdout and process.exit,
|
|
@@ -259,8 +295,17 @@ export async function run(argv, env, io) {
|
|
|
259
295
|
// Environment misuse is refused before any command runs: a request
|
|
260
296
|
// budget that silently fell back would hide the user's mistake.
|
|
261
297
|
bindRequestTimeout(env);
|
|
298
|
+
// Started before the parse so the registry lookup overlaps the
|
|
299
|
+
// command itself; a slow registry then costs nothing extra.
|
|
300
|
+
const versionNotice = startVersionNotice(argv, env, io);
|
|
262
301
|
await program.parseAsync([...argv], { from: "user" });
|
|
263
|
-
|
|
302
|
+
const noticeLine = await versionNotice;
|
|
303
|
+
// The notice never mixes with a stderr that already spoke (a
|
|
304
|
+
// truncation warning, a JSON error object): it stays quiet then.
|
|
305
|
+
if (noticeLine !== undefined && stderr === "") {
|
|
306
|
+
stderr += noticeLine;
|
|
307
|
+
}
|
|
308
|
+
return { stdout, stderr, exitCode: forcedExitCode ?? 0 };
|
|
264
309
|
}
|
|
265
310
|
catch (error) {
|
|
266
311
|
if (error instanceof CommanderError
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuanhv/op-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A resolving command-line client for OpenProject",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "hoangvantuan",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
"dist",
|
|
32
32
|
"!dist/**/*.map",
|
|
33
|
+
"LICENSE",
|
|
33
34
|
"NOTICE"
|
|
34
35
|
],
|
|
35
36
|
"scripts": {
|