@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.
@@ -1,14 +1,119 @@
1
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)));
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
- return column === cells.length - 1 || width === undefined
8
- ? cell
9
- : cell.padEnd(width);
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, parseOptionalId, saveProfile, setActiveProfile, LOGIN_PROFILE, } from "./context/profile.js";
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: parseOptionalId(options.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
- await setActiveProfile(env, profile, {
139
- project: parseOptionalId(options.project),
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
- return { stdout, stderr, exitCode: 0 };
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.1.1",
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": {