@bussolabs/closeyourit-cli 0.19.0 → 0.21.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.
Files changed (82) hide show
  1. package/README.md +39 -5
  2. package/dist/base.d.ts +22 -0
  3. package/dist/base.js +46 -0
  4. package/dist/commands/alerts/notifications/read-all.js +3 -0
  5. package/dist/commands/alerts/preferences/show.js +2 -0
  6. package/dist/commands/ideas/convert.d.ts +2 -2
  7. package/dist/commands/ideas/convert.js +25 -8
  8. package/dist/commands/kb/book/add-page.d.ts +18 -0
  9. package/dist/commands/kb/book/add-page.js +55 -0
  10. package/dist/commands/kb/book/list.d.ts +12 -0
  11. package/dist/commands/kb/book/list.js +33 -0
  12. package/dist/commands/kb/book/show.d.ts +9 -0
  13. package/dist/commands/kb/book/show.js +22 -0
  14. package/dist/commands/kb/create.js +29 -6
  15. package/dist/commands/kb/publish.d.ts +15 -0
  16. package/dist/commands/kb/publish.js +106 -0
  17. package/dist/commands/kb/update.js +26 -5
  18. package/dist/commands/logout.js +3 -0
  19. package/dist/commands/org/show.js +2 -0
  20. package/dist/commands/personal/import.js +2 -2
  21. package/dist/commands/personal/run.js +1 -1
  22. package/dist/commands/run.js +1 -1
  23. package/dist/commands/secrets/import.js +2 -2
  24. package/dist/commands/seo/ignore.d.ts +12 -0
  25. package/dist/commands/seo/ignore.js +27 -0
  26. package/dist/commands/seo/list.d.ts +13 -0
  27. package/dist/commands/seo/list.js +52 -0
  28. package/dist/commands/seo/pages.d.ts +11 -0
  29. package/dist/commands/seo/pages.js +44 -0
  30. package/dist/commands/seo/promote.d.ts +9 -0
  31. package/dist/commands/seo/promote.js +22 -0
  32. package/dist/commands/seo/reopen.d.ts +9 -0
  33. package/dist/commands/seo/reopen.js +21 -0
  34. package/dist/commands/seo/rescan.d.ts +9 -0
  35. package/dist/commands/seo/rescan.js +21 -0
  36. package/dist/commands/seo/show.d.ts +9 -0
  37. package/dist/commands/seo/show.js +35 -0
  38. package/dist/commands/seo-sites/create.d.ts +14 -0
  39. package/dist/commands/seo-sites/create.js +43 -0
  40. package/dist/commands/seo-sites/delete.d.ts +9 -0
  41. package/dist/commands/seo-sites/delete.js +21 -0
  42. package/dist/commands/seo-sites/list.d.ts +10 -0
  43. package/dist/commands/seo-sites/list.js +38 -0
  44. package/dist/commands/seo-sites/update.d.ts +17 -0
  45. package/dist/commands/seo-sites/update.js +45 -0
  46. package/dist/commands/tickets/comment.d.ts +14 -3
  47. package/dist/commands/tickets/comment.js +29 -1
  48. package/dist/commands/tickets/create.d.ts +6 -6
  49. package/dist/commands/tickets/create.js +21 -2
  50. package/dist/commands/tickets/report-show.d.ts +20 -0
  51. package/dist/commands/tickets/report-show.js +38 -0
  52. package/dist/commands/tickets/report-versions.d.ts +19 -0
  53. package/dist/commands/tickets/report-versions.js +40 -0
  54. package/dist/commands/tickets/report.d.ts +24 -0
  55. package/dist/commands/tickets/report.js +69 -0
  56. package/dist/commands/tickets/status.d.ts +1 -1
  57. package/dist/commands/tickets/status.js +14 -4
  58. package/dist/commands/tickets/update.d.ts +25 -6
  59. package/dist/commands/tickets/update.js +144 -9
  60. package/dist/commands/whoami.js +3 -0
  61. package/dist/errors/error-codes.d.ts +12 -0
  62. package/dist/errors/error-codes.js +19 -0
  63. package/dist/errors/input-error.d.ts +16 -0
  64. package/dist/errors/input-error.js +42 -0
  65. package/dist/lib/alert-events.js +1 -0
  66. package/dist/lib/knowledge.d.ts +24 -0
  67. package/dist/lib/knowledge.js +68 -0
  68. package/dist/lib/limits.d.ts +59 -0
  69. package/dist/lib/limits.js +92 -0
  70. package/dist/lib/stdin.d.ts +1 -2
  71. package/dist/lib/stdin.js +1 -2
  72. package/dist/lib/subprocess.d.ts +1 -1
  73. package/dist/lib/subprocess.js +1 -1
  74. package/dist/lib/ticket-body.d.ts +22 -6
  75. package/dist/lib/ticket-body.js +71 -9
  76. package/dist/lib/ticket-lookup.d.ts +53 -0
  77. package/dist/lib/ticket-lookup.js +78 -0
  78. package/dist/lib/ticket-report.d.ts +7 -0
  79. package/dist/lib/ticket-report.js +14 -0
  80. package/oclif.manifest.json +3931 -2876
  81. package/opencli.json +860 -113
  82. package/package.json +4 -1
@@ -2,40 +2,175 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const core_1 = require("@oclif/core");
4
4
  const base_1 = require("../../base");
5
+ const limits_1 = require("../../lib/limits");
5
6
  const output_1 = require("../../lib/output");
6
7
  const ticket_body_1 = require("../../lib/ticket-body");
8
+ const ticket_lookup_1 = require("../../lib/ticket-lookup");
9
+ /** Campi che la GET del ticket restituisce già con la chiave e il valore attesi dalla PUT. */
10
+ const PRESERVED_KEYS = ['title', 'kind', 'description', 'technical_analysis', 'weight', 'due_at', 'parent_id'];
7
11
  class TicketsUpdate extends base_1.BaseCommand {
8
- // PUT /cli/v1/projects/:p/tickets/:id maps to Ticketing::UpdateTicket, a full replace:
9
- // fields you omit are cleared server-side. Pass the complete desired state, not a partial patch.
10
- static description = 'Update a ticket (full replace fields you omit are cleared server-side)';
12
+ // PUT /cli/v1/projects/:p/tickets/:id maps to Ticketing::UpdateTicket, a full replace built for the
13
+ // web form, which always posts every field: whatever the body omits is cleared server-side, and
14
+ // status/priority are mandatory, so `--title` alone did not even go through ("Priority must exist")
15
+ // while a body carrying them silently emptied description, technical analysis, weight, due date,
16
+ // assignee, milestone and platforms (CYCL-30).
17
+ //
18
+ // The command now reads the ticket first and re-sends its current state under the flags you passed:
19
+ // what you do not name stays as it was. Emptying is a separate, explicit request (`--clear <field>`),
20
+ // and `--replace` keeps the raw full replace for callers that really do send the whole desired state.
21
+ //
22
+ // Scenarios and conditions stay out of the merge: the backend replaces them when the body carries
23
+ // new ones and leaves them untouched otherwise, so re-sending them would only risk duplicates.
24
+ //
25
+ // Known limit — the read and the write are not one atomic operation, and this endpoint offers no
26
+ // optimistic locking (no lock_version, no If-Match/ETag: the GET exposes nothing to send back),
27
+ // so an edit landing between them is overwritten by the state this command read. It cannot be
28
+ // fixed from the client alone, and it is strictly narrower than what it replaces: the full replace
29
+ // wiped those fields on EVERY call, concurrency or not, while here the exposure is the round trip.
30
+ // Closing it for good needs a server-side merge (or a version token to send back) — the fix the
31
+ // ticket calls the "server remedy".
32
+ static description = 'Update a ticket, changing only the fields you pass (the others keep their current value; use --clear to empty one, --replace for the raw full replace)';
11
33
  static examples = [
12
34
  '<%= config.bin %> tickets update <ticket-id> --project acme-api --title "New title"',
35
+ '<%= config.bin %> tickets update <ticket-id> -p acme-api --clear assignee --clear due-at',
13
36
  '<%= config.bin %> tickets update <ticket-id> -p acme-api --title "Login bug" --scenarios \'[{"step_given":"logged out","step_when":"I submit","step_then":"it 500s","step_expected":"dashboard loads"}]\' --condition "login works"',
37
+ '<%= config.bin %> tickets update <ticket-id> -p acme-api --status in_review --priority High',
38
+ '<%= config.bin %> tickets update <ticket-id> -p acme-api --replace --title "Only this" --status open --priority medium',
14
39
  ];
15
40
  static args = {
16
41
  id: core_1.Args.string({ description: 'Ticket id', required: true }),
17
42
  };
18
43
  static flags = {
19
44
  ...base_1.projectFlag,
20
- title: core_1.Flags.string({ description: 'Ticket title' }),
45
+ title: core_1.Flags.string({ description: `Ticket title (max ${limits_1.LENGTH_LIMITS.title} characters)` }),
21
46
  kind: core_1.Flags.string({ description: 'Ticket kind (bug, story, task, epic)', options: ['bug', 'story', 'task', 'epic'] }),
22
47
  ...ticket_body_1.ticketBodyFlags,
48
+ ...ticket_body_1.clearFlag,
49
+ replace: core_1.Flags.boolean({
50
+ description: 'Send only the flags you passed (full replace — every field you omit is cleared server-side)',
51
+ default: false,
52
+ }),
23
53
  };
24
54
  async run() {
25
55
  const { args, flags } = await this.parse(TicketsUpdate);
26
56
  const projectId = await this.resolveProjectId(flags.project);
27
- const body = {};
57
+ const path = `/cli/v1/projects/${encodeURIComponent(projectId)}/tickets/${encodeURIComponent(args.id)}`;
58
+ const requested = {};
28
59
  if (flags.title !== undefined)
29
- body.title = flags.title;
60
+ requested.title = flags.title;
30
61
  if (flags.kind !== undefined)
31
- body.kind = flags.kind;
32
- (0, ticket_body_1.applyTicketBody)(body, flags);
33
- const res = await this.api.put(`/cli/v1/projects/${projectId}/tickets/${args.id}`, body);
62
+ requested.kind = flags.kind;
63
+ (0, ticket_body_1.applyTicketBody)(requested, flags);
64
+ // Cambiare stato o priorità non costringe più a cercarne prima l'id: il code o l'etichetta
65
+ // bastano, e un id passa così com'è (CYCL-34).
66
+ if (flags.status !== undefined) {
67
+ requested.status_id = await this.resolveTicketLookupId(ticket_lookup_1.TICKET_STATUS, flags.status);
68
+ }
69
+ if (flags.priority !== undefined) {
70
+ requested.priority_id = await this.resolveTicketLookupId(ticket_lookup_1.TICKET_PRIORITY, flags.priority);
71
+ }
72
+ this.refuseClearConflicts(requested, flags.clear);
73
+ (0, ticket_body_1.applyTicketClears)(requested, flags.clear);
74
+ let body = requested;
75
+ if (!flags.replace) {
76
+ const ticket = (await this.api.get(path)).data ?? {};
77
+ // I campi troppo lunghi si scoprono qui, tutti insieme, invece che uno per rifiuto (CYCL-32).
78
+ // Il valore attuale fa da salvaguardia: un testo scritto prima che il tetto esistesse resta
79
+ // correggibile finché non si allunga, com'è dal server. Con `--replace` lo stato attuale non
80
+ // si legge, quindi la salvaguardia non è calcolabile e la misura resta al server.
81
+ const tooLong = (0, limits_1.tooLongMessage)((0, ticket_body_1.ticketLengthChecks)(flags, ticket));
82
+ if (tooLong)
83
+ this.error(tooLong, { exit: 2 });
84
+ body = { ...(await this.currentState(projectId, ticket, requested)), ...requested };
85
+ }
86
+ const res = await this.api.put(path, body);
34
87
  if (!this.jsonEnabled()) {
35
88
  this.log('Ticket updated:');
36
89
  this.log((0, output_1.renderRecord)(res.data ?? {}));
37
90
  }
38
91
  return res;
39
92
  }
93
+ /**
94
+ * Lo stato attuale del ticket, nella forma che la PUT si aspetta, per i soli campi che chi ha
95
+ * scritto il comando non ha già deciso. I campi vuoti restano fuori dal corpo: ometterli o
96
+ * mandarli a null è la stessa cosa, e il corpo resta leggibile.
97
+ */
98
+ async currentState(projectId, ticket, requested) {
99
+ const current = {};
100
+ for (const key of PRESERVED_KEYS) {
101
+ const value = ticket[key];
102
+ if (key in requested || value === null || value === undefined || value === '')
103
+ continue;
104
+ current[key] = value;
105
+ }
106
+ // Status e priorità sono obbligatorie per il backend: senza di loro l'update non passa. La GET
107
+ // espone l'id dello status, mentre della priorità dà solo l'etichetta, da rileggere nella lookup.
108
+ if (!('status_id' in requested)) {
109
+ const statusId = ticket.status_ref?.id;
110
+ if (typeof statusId !== 'string' || statusId === '') {
111
+ this.error('Cannot read the current status of this ticket: pass --status <code|label|id> or use --replace.', {
112
+ exit: 2,
113
+ });
114
+ }
115
+ current.status_id = statusId;
116
+ }
117
+ if (!('priority_id' in requested)) {
118
+ current.priority_id = await this.lookupId(ticket_lookup_1.TICKET_PRIORITY.path, ticket.priority, 'priority', '--priority');
119
+ }
120
+ if (!('assignee_id' in requested) && ticket.assignee) {
121
+ current.assignee_id = await this.lookupId('/cli/v1/members', ticket.assignee, 'assignee', '--assignee-id', 'account_id');
122
+ }
123
+ if (!('milestone_id' in requested) && ticket.milestone) {
124
+ const milestones = `/cli/v1/projects/${encodeURIComponent(projectId)}/milestones`;
125
+ current.milestone_id = await this.lookupId(milestones, ticket.milestone, 'milestone', '--milestone-id');
126
+ }
127
+ const platforms = Array.isArray(ticket.platforms) ? ticket.platforms : [];
128
+ if (!('platform_ids' in requested) && platforms.length > 0) {
129
+ const rows = await this.lookupRows('/cli/v1/platforms');
130
+ current.platform_ids = platforms.map((platform) => this.matchId(rows, platform, 'platform', '--platform-id'));
131
+ }
132
+ return current;
133
+ }
134
+ /** Rifiuta `--clear x` insieme a `--x`: impostare e svuotare lo stesso campo è una richiesta ambigua. */
135
+ refuseClearConflicts(requested, fields) {
136
+ for (const field of fields ?? []) {
137
+ if (ticket_body_1.CLEARABLE_FIELDS[field] in requested) {
138
+ this.error(`--clear ${field} conflicts with the value passed for the same field: pick one.`, { exit: 2 });
139
+ }
140
+ }
141
+ }
142
+ /** Id della voce di lookup che corrisponde all'etichetta mostrata dalla GET del ticket. */
143
+ async lookupId(path, label, field, flag, idKey = 'id') {
144
+ const rows = await this.lookupRows(path);
145
+ return this.matchId(rows, label, field, flag, idKey);
146
+ }
147
+ /**
148
+ * Una lookup non trovata o ambigua ferma il comando: proseguire manderebbe una PUT senza quel
149
+ * campo, cioè lo cancellerebbe in silenzio — il difetto che questo comando esiste per evitare.
150
+ */
151
+ matchId(rows, label, field, flag, idKey = 'id') {
152
+ const wanted = String(label ?? '').toLowerCase();
153
+ const matches = rows.filter((row) => [row.label, row.code, row.name].some((value) => typeof value === 'string' && value.toLowerCase() === wanted));
154
+ if (matches.length !== 1 || typeof matches[0][idKey] !== 'string') {
155
+ const reason = matches.length > 1 ? `${matches.length} entries match it` : 'no entry matches it';
156
+ this.error(`Cannot keep the current ${field} "${String(label ?? '')}": ${reason}. Pass ${flag} <id> or use --replace.`, { exit: 2 });
157
+ }
158
+ return matches[0][idKey];
159
+ }
160
+ /** Ogni pagina di un endpoint di lookup: la voce cercata può stare oltre la prima. */
161
+ async lookupRows(path) {
162
+ const rows = [];
163
+ const separator = path.includes('?') ? '&' : '?';
164
+ let page = 1;
165
+ let totalPages = 1;
166
+ do {
167
+ // eslint-disable-next-line no-await-in-loop
168
+ const res = await this.api.get(`${path}${separator}page=${page}&per=100`);
169
+ rows.push(...(Array.isArray(res.data) ? res.data : []));
170
+ totalPages = Number(res.meta?.total_pages ?? 1);
171
+ page += 1;
172
+ } while (page <= totalPages);
173
+ return rows;
174
+ }
40
175
  }
41
176
  exports.default = TicketsUpdate;
@@ -5,6 +5,9 @@ class Whoami extends base_1.BaseCommand {
5
5
  static description = 'Show the authenticated account, organization, token prefix and permissions';
6
6
  static examples = ['<%= config.bin %> whoami', '<%= config.bin %> whoami --json'];
7
7
  async run() {
8
+ // Nothing to read from the parser, but oclif validates the command line only here: without this
9
+ // call a mistyped flag is silently ignored and the command answers as if it were right (CYCL-33).
10
+ await this.parse(Whoami);
8
11
  const res = await this.api.get('/cli/v1/whoami');
9
12
  // Rebuild from an allow-list. If a future backend accidentally includes a token secret or other
10
13
  // authentication internals, `--json` must not proxy them to stdout.
@@ -25,10 +25,22 @@ export declare const ErrorCodes: {
25
25
  readonly Member: {
26
26
  readonly notFound: "C404-MBR-001";
27
27
  };
28
+ readonly Knowledge: {
29
+ readonly publishUnavailable: "C404-KNW-001";
30
+ };
31
+ readonly TicketStatus: {
32
+ readonly notFound: "C404-STA-001";
33
+ };
34
+ readonly TicketPriority: {
35
+ readonly notFound: "C404-PRI-001";
36
+ };
28
37
  readonly Device: {
29
38
  readonly expired: "C408-DEV-001";
30
39
  readonly failed: "C400-DEV-002";
31
40
  };
41
+ readonly Input: {
42
+ readonly invalid: "C400-INP-001";
43
+ };
32
44
  readonly System: {
33
45
  readonly unexpected: "C500-SYS-001";
34
46
  };
@@ -30,10 +30,29 @@ exports.ErrorCodes = {
30
30
  Member: {
31
31
  notFound: 'C404-MBR-001',
32
32
  },
33
+ // Il backend risponde 404 senza codice applicativo sulla route della pubblicazione atomica:
34
+ // non è il progetto a mancare, è il server a essere più vecchio del comando (CYCL-8).
35
+ Knowledge: {
36
+ publishUnavailable: 'C404-KNW-001',
37
+ },
38
+ // Stato e priorità di un ticket, indicati per code o etichetta e non trovati nelle lookup
39
+ // dell'organizzazione (o corrispondenti a più di una voce) — CYCL-34.
40
+ TicketStatus: {
41
+ notFound: 'C404-STA-001',
42
+ },
43
+ TicketPriority: {
44
+ notFound: 'C404-PRI-001',
45
+ },
33
46
  Device: {
34
47
  expired: 'C408-DEV-001',
35
48
  failed: 'C400-DEV-002',
36
49
  },
50
+ // The command line itself was rejected (missing/unknown flag or arg, value outside the allowed
51
+ // options, a `this.error(...)` guard): the message says what to fix, and it never reached the
52
+ // server — the opposite of System.unexpected, which stays for real failures (CYCL-33).
53
+ Input: {
54
+ invalid: 'C400-INP-001',
55
+ },
37
56
  System: {
38
57
  unexpected: 'C500-SYS-001',
39
58
  },
@@ -0,0 +1,16 @@
1
+ /**
2
+ * True for the failures oclif has already worded for whoever typed the command: a parse/validation
3
+ * error (missing required flag or arg, nonexistent flag, value outside the allowed options) and the
4
+ * `this.error(...)` guards inside a command (`--confirm` missing, nothing to update, file unreadable).
5
+ *
6
+ * Deliberately NOT input errors, although both extend `CLIError`:
7
+ * - `ExitError`, thrown by `this.exit(code)` — `cyi run` uses it to propagate the child exit code;
8
+ * - `ModuleLoadError`, which is a real internal failure and keeps the generic system code.
9
+ */
10
+ export declare function isInputError(error: unknown): error is Error;
11
+ /**
12
+ * Flatten oclif's multi-line, ANSI-decorated message into the single line a JSON consumer reads:
13
+ * `The following error occurred:\n Missing required flag project\nSee more help with --help`
14
+ * becomes `Missing required flag project` (CYCL-33).
15
+ */
16
+ export declare function inputErrorMessage(error: Error): string;
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isInputError = isInputError;
4
+ exports.inputErrorMessage = inputErrorMessage;
5
+ const core_1 = require("@oclif/core");
6
+ /** oclif dims parts of a parse error with ANSI when the terminal has colors; machine output must not. */
7
+ const ANSI = /\x1B\[[\d;]*m/g;
8
+ /** Every parse error ends with this hint, and multi-reason ones open with a header line. */
9
+ const HELP_HINT = 'See more help with --help';
10
+ const HEADER = /^The following errors? occurred:$/;
11
+ /**
12
+ * True for the failures oclif has already worded for whoever typed the command: a parse/validation
13
+ * error (missing required flag or arg, nonexistent flag, value outside the allowed options) and the
14
+ * `this.error(...)` guards inside a command (`--confirm` missing, nothing to update, file unreadable).
15
+ *
16
+ * Deliberately NOT input errors, although both extend `CLIError`:
17
+ * - `ExitError`, thrown by `this.exit(code)` — `cyi run` uses it to propagate the child exit code;
18
+ * - `ModuleLoadError`, which is a real internal failure and keeps the generic system code.
19
+ */
20
+ function isInputError(error) {
21
+ if (!(error instanceof core_1.Errors.CLIError))
22
+ return false;
23
+ return !(error instanceof core_1.Errors.ExitError) && !(error instanceof core_1.Errors.ModuleLoadError);
24
+ }
25
+ /**
26
+ * Flatten oclif's multi-line, ANSI-decorated message into the single line a JSON consumer reads:
27
+ * `The following error occurred:\n Missing required flag project\nSee more help with --help`
28
+ * becomes `Missing required flag project` (CYCL-33).
29
+ */
30
+ function inputErrorMessage(error) {
31
+ const plain = error.message.replace(ANSI, '');
32
+ const lines = plain
33
+ .split('\n')
34
+ .map((line) => line.trim())
35
+ .filter((line) => line !== '' && line !== HELP_HINT && !HEADER.test(line));
36
+ // Only noise (a bare hint, or an empty message): keep whatever the error carried rather than nothing.
37
+ if (lines.length === 0)
38
+ return plain.trim();
39
+ // A line ending in `:` introduces the next one ("Missing 1 required arg:" + the arg itself);
40
+ // anything else is a reason of its own ("Missing required flag environment" + "…flag project").
41
+ return lines.reduce((acc, line) => (acc.endsWith(':') ? `${acc} ${line}` : `${acc}; ${line}`));
42
+ }
@@ -53,4 +53,5 @@ exports.ALERT_EVENT_TYPES = [
53
53
  'server_replication_up',
54
54
  'server_container_restart_loop',
55
55
  'server_container_stable',
56
+ 'seo_issue_new',
56
57
  ];
@@ -48,6 +48,30 @@ export declare function renderRelated(rows: RelatedRow[], heading?: string, inde
48
48
  * order as the table above it. Results without related pages are simply left out.
49
49
  */
50
50
  export declare function renderRelatedGroups(pages: Array<Record<string, unknown>>, grouped: Record<string, RelatedRow[]>): string;
51
+ /**
52
+ * Build the query string for `kb book list`. `project` goes out as the raw reference: the books
53
+ * endpoint resolves a key or a UUID by itself (and 404s outside the scope), exactly like the pages
54
+ * endpoint — so no extra lookup call here.
55
+ */
56
+ export declare function buildBooksQuery(opts: {
57
+ page: number;
58
+ per?: number;
59
+ project?: string;
60
+ q?: string;
61
+ }): string;
62
+ /**
63
+ * Render the `kb book list` table. ID first: it is the argument of `kb book show` and of
64
+ * `kb book add-page`, so a table without it would force `--json` just to act on what you are
65
+ * already looking at.
66
+ */
67
+ export declare function renderBooksTable(books: Array<Record<string, unknown>>): string;
68
+ /**
69
+ * Render a book with its summary: the metadata header, then the table of contents — the response
70
+ * both `kb book show` and `kb book add-page` get back, so the reader sees the resulting order in
71
+ * the same shape either way. `#` is the 0-based position, the very number `--position` takes.
72
+ * Page bodies are not here on purpose: the TOC is for orientation, `kb show <page>` for reading.
73
+ */
74
+ export declare function renderBook(book: Record<string, unknown>): string;
51
75
  /**
52
76
  * Render the shared list/search table: TITLE, KIND, PROJECT, AUTHOR, UPDATED.
53
77
  *
@@ -6,6 +6,9 @@ exports.truncate = truncate;
6
6
  exports.buildPagesQuery = buildPagesQuery;
7
7
  exports.renderRelated = renderRelated;
8
8
  exports.renderRelatedGroups = renderRelatedGroups;
9
+ exports.buildBooksQuery = buildBooksQuery;
10
+ exports.renderBooksTable = renderBooksTable;
11
+ exports.renderBook = renderBook;
9
12
  exports.renderPagesTable = renderPagesTable;
10
13
  const output_1 = require("./output");
11
14
  /** Knowledge page kinds accepted by the backend (Knowledge::Page). */
@@ -82,6 +85,71 @@ function renderRelatedGroups(pages, grouped) {
82
85
  .map(({ rows, title }) => renderRelated(rows, ` ${(0, output_1.sanitize)(title)}`, ' '));
83
86
  return blocks.length === 0 ? '' : ['\n🔗 Related:', ...blocks].join('\n');
84
87
  }
88
+ /**
89
+ * Build the query string for `kb book list`. `project` goes out as the raw reference: the books
90
+ * endpoint resolves a key or a UUID by itself (and 404s outside the scope), exactly like the pages
91
+ * endpoint — so no extra lookup call here.
92
+ */
93
+ function buildBooksQuery(opts) {
94
+ const query = new URLSearchParams();
95
+ query.set('page', String(opts.page));
96
+ if (opts.per !== undefined)
97
+ query.set('per', String(opts.per));
98
+ if (opts.project)
99
+ query.set('project', opts.project);
100
+ if (opts.q)
101
+ query.set('q', opts.q);
102
+ return query.toString();
103
+ }
104
+ /** A list of names (project keys, group names) as one cell, or undefined when there is nothing. */
105
+ function joinList(value) {
106
+ if (!Array.isArray(value) || value.length === 0)
107
+ return undefined;
108
+ return value.map((entry) => String(entry)).join(', ');
109
+ }
110
+ /**
111
+ * Render the `kb book list` table. ID first: it is the argument of `kb book show` and of
112
+ * `kb book add-page`, so a table without it would force `--json` just to act on what you are
113
+ * already looking at.
114
+ */
115
+ function renderBooksTable(books) {
116
+ return (0, output_1.renderTable)(['ID', 'TITLE', 'PAGES', 'PROJECTS', 'AUTHOR', 'UPDATED'], books.map((book) => [
117
+ String(book.id ?? ''),
118
+ truncate(String(book.title ?? '')),
119
+ String(book.pages_count ?? ''),
120
+ joinList(book.projects) ?? '',
121
+ String(book.author ?? ''),
122
+ String(book.updated_at ?? ''),
123
+ ]));
124
+ }
125
+ /**
126
+ * Render a book with its summary: the metadata header, then the table of contents — the response
127
+ * both `kb book show` and `kb book add-page` get back, so the reader sees the resulting order in
128
+ * the same shape either way. `#` is the 0-based position, the very number `--position` takes.
129
+ * Page bodies are not here on purpose: the TOC is for orientation, `kb show <page>` for reading.
130
+ */
131
+ function renderBook(book) {
132
+ const pages = Array.isArray(book.pages) ? book.pages : [];
133
+ const header = (0, output_1.renderRecord)({
134
+ id: book.id,
135
+ title: book.title,
136
+ description: book.description,
137
+ projects: joinList(book.projects),
138
+ groups: joinList(book.groups),
139
+ author: book.author,
140
+ pages_count: book.pages_count,
141
+ created_at: book.created_at,
142
+ updated_at: book.updated_at,
143
+ });
144
+ const toc = (0, output_1.renderTable)(['#', 'TITLE', 'KIND', 'PROJECT', 'ID'], pages.map((page) => [
145
+ String(page.position ?? ''),
146
+ truncate(String(page.title ?? '')),
147
+ String(page.kind ?? ''),
148
+ String(page.project ?? ''),
149
+ String(page.id ?? ''),
150
+ ]));
151
+ return [header, (0, output_1.section)('Contents', '📖'), toc].join('\n');
152
+ }
85
153
  /**
86
154
  * Render the shared list/search table: TITLE, KIND, PROJECT, AUTHOR, UPDATED.
87
155
  *
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Tetti di lunghezza dei campi di testo, come li applica il server (CYCL-32).
3
+ *
4
+ * Prima erano scoperti solo sbagliando: l'aiuto non li nominava e il rifiuto arrivava dopo aver
5
+ * scritto tutto. Qui vivono i numeri — copia dichiarata di `Knowledge::Constants`,
6
+ * `Ticketing::Constants` e `App::Constants::COMMENT_MAX_CHARS` — che le descrizioni dei flag
7
+ * mostrano e che i comandi verificano prima di spedire, così i campi troppo lunghi si scoprono
8
+ * tutti insieme e senza chiamare il server.
9
+ */
10
+ export declare const LENGTH_LIMITS: {
11
+ /** Titolo di un ticket e di una pagina di conoscenza. */
12
+ readonly title: 255;
13
+ readonly description: 4000;
14
+ readonly technicalAnalysis: 1500;
15
+ readonly comment: 240;
16
+ readonly report: 20000;
17
+ readonly pageBody: 4000;
18
+ readonly techSpec: 1500;
19
+ readonly reviewNote: 240;
20
+ };
21
+ /** Un campo da misurare: il flag come lo scrive chi usa il comando, il valore e il suo tetto. */
22
+ export interface LengthCheck {
23
+ flag: string;
24
+ value: unknown;
25
+ max: number;
26
+ /** Valore attualmente sul server, quando il comando lo ha letto (vedi `tooLongMessage`). */
27
+ previous?: unknown;
28
+ /** Campo che il server corregge in ortografia italiana prima di misurarlo (vedi `exceeds`). */
29
+ orthography?: boolean;
30
+ }
31
+ /**
32
+ * Caratteri di un testo contati come li conta il server (Ruby `String#length`, cioè per code point).
33
+ * La `.length` di JS conta unità UTF-16: un'emoji o qualunque carattere fuori dal BMP ne vale due, e
34
+ * la lunghezza mostrata divergerebbe da quella su cui il server misura il suo limite.
35
+ */
36
+ export declare function charCount(value: unknown): number;
37
+ /**
38
+ * Lunghezza sulla quale il server decide: il testo passa dalle sue normalizzazioni prima di essere
39
+ * misurato — CRLF ridotti a un solo a capo (`LengthBudget.normalize_newlines`) e spazi ai bordi via
40
+ * (`strip`). Senza questo un file con 100 righe scritto su Windows "peserebbe" 100 caratteri in più
41
+ * qui che là, e il comando rifiuterebbe un testo che il server accetta.
42
+ *
43
+ * `trim()` non è `String#strip`: toglie anche gli spazi Unicode che Ruby lascia (lo spazio unificatore
44
+ * in testa a una riga, per dire). Va nella direzione innocua — misura al più quanto il server, mai di
45
+ * più — quindi al massimo lascia partire una chiamata che il server rifiuterà, mai il contrario.
46
+ */
47
+ export declare function serverLength(value: unknown): number;
48
+ /**
49
+ * Il messaggio che elenca TUTTI i campi oltre il tetto, in un colpo solo — il punto della
50
+ * lavorazione: sistemarne uno e scoprire il successivo al tentativo dopo è esattamente ciò che
51
+ * questa funzione toglie di mezzo. `undefined` quando è tutto in regola.
52
+ *
53
+ * `previous` porta la clausola di salvaguardia del server (`LengthBudget`): un testo già oltre il
54
+ * tetto resta salvabile finché non si allunga, così chi ha in archivio una pagina o un ticket
55
+ * scritti prima del limite può ancora correggerli e accorciarli per gradi. I comandi che leggono lo
56
+ * stato attuale prima di scrivere lo passano; chi non lo conosce (una creazione, o `--replace`) lo
57
+ * lascia fuori e il tetto vale pieno.
58
+ */
59
+ export declare function tooLongMessage(checks: LengthCheck[]): string | undefined;
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LENGTH_LIMITS = void 0;
4
+ exports.charCount = charCount;
5
+ exports.serverLength = serverLength;
6
+ exports.tooLongMessage = tooLongMessage;
7
+ /**
8
+ * Tetti di lunghezza dei campi di testo, come li applica il server (CYCL-32).
9
+ *
10
+ * Prima erano scoperti solo sbagliando: l'aiuto non li nominava e il rifiuto arrivava dopo aver
11
+ * scritto tutto. Qui vivono i numeri — copia dichiarata di `Knowledge::Constants`,
12
+ * `Ticketing::Constants` e `App::Constants::COMMENT_MAX_CHARS` — che le descrizioni dei flag
13
+ * mostrano e che i comandi verificano prima di spedire, così i campi troppo lunghi si scoprono
14
+ * tutti insieme e senza chiamare il server.
15
+ */
16
+ exports.LENGTH_LIMITS = {
17
+ /** Titolo di un ticket e di una pagina di conoscenza. */
18
+ title: 255,
19
+ description: 4_000,
20
+ technicalAnalysis: 1_500,
21
+ comment: 240,
22
+ report: 20_000,
23
+ pageBody: 4_000,
24
+ techSpec: 1_500,
25
+ reviewNote: 240,
26
+ };
27
+ /**
28
+ * Caratteri di un testo contati come li conta il server (Ruby `String#length`, cioè per code point).
29
+ * La `.length` di JS conta unità UTF-16: un'emoji o qualunque carattere fuori dal BMP ne vale due, e
30
+ * la lunghezza mostrata divergerebbe da quella su cui il server misura il suo limite.
31
+ */
32
+ function charCount(value) {
33
+ return [...String(value ?? '')].length;
34
+ }
35
+ /**
36
+ * Lunghezza sulla quale il server decide: il testo passa dalle sue normalizzazioni prima di essere
37
+ * misurato — CRLF ridotti a un solo a capo (`LengthBudget.normalize_newlines`) e spazi ai bordi via
38
+ * (`strip`). Senza questo un file con 100 righe scritto su Windows "peserebbe" 100 caratteri in più
39
+ * qui che là, e il comando rifiuterebbe un testo che il server accetta.
40
+ *
41
+ * `trim()` non è `String#strip`: toglie anche gli spazi Unicode che Ruby lascia (lo spazio unificatore
42
+ * in testa a una riga, per dire). Va nella direzione innocua — misura al più quanto il server, mai di
43
+ * più — quindi al massimo lascia partire una chiamata che il server rifiuterà, mai il contrario.
44
+ */
45
+ function serverLength(value) {
46
+ return charCount(String(value ?? '').replaceAll('\r\n', '\n').trim());
47
+ }
48
+ /**
49
+ * Quanto la correzione ortografica italiana del server può ancora accorciare il testo (CYCL-32).
50
+ *
51
+ * `Text::ItalianOrthography.correct` gira sui campi dei ticket e sul resoconto prima della misura, e
52
+ * sostituisce forme note con la loro versione accentata: «gia» → «già» non cambia la lunghezza,
53
+ * «perche'» → «perché» ne toglie una, perché assorbe l'apostrofo. Il dizionario non vive qui e non
54
+ * deve: il taglio massimo possibile è comunque UN carattere per apostrofo presente nel testo.
55
+ *
56
+ * Serve a non rifiutare in locale ciò che il server accetterebbe — un testo appena sopra il tetto e
57
+ * pieno di apostrofi da correggere ci rientra da solo. Sotto quel margine la misura resta al server.
58
+ */
59
+ function orthographyMargin(value) {
60
+ return (value.match(/['’]/g) ?? []).length;
61
+ }
62
+ /**
63
+ * Il messaggio che elenca TUTTI i campi oltre il tetto, in un colpo solo — il punto della
64
+ * lavorazione: sistemarne uno e scoprire il successivo al tentativo dopo è esattamente ciò che
65
+ * questa funzione toglie di mezzo. `undefined` quando è tutto in regola.
66
+ *
67
+ * `previous` porta la clausola di salvaguardia del server (`LengthBudget`): un testo già oltre il
68
+ * tetto resta salvabile finché non si allunga, così chi ha in archivio una pagina o un ticket
69
+ * scritti prima del limite può ancora correggerli e accorciarli per gradi. I comandi che leggono lo
70
+ * stato attuale prima di scrivere lo passano; chi non lo conosce (una creazione, o `--replace`) lo
71
+ * lascia fuori e il tetto vale pieno.
72
+ */
73
+ function tooLongMessage(checks) {
74
+ const over = checks.filter((check) => exceeds(check));
75
+ if (over.length === 0)
76
+ return undefined;
77
+ const list = over.map((check) => `${check.flag} is ${serverLength(check.value)} characters (max ${check.max})`);
78
+ return `Too long for the server: ${list.join('; ')}. Shorten ${over.length === 1 ? 'it' : 'them'} and run the command again.`;
79
+ }
80
+ /**
81
+ * Vero solo quando il rifiuto è CERTO: si misura la lunghezza più corta che il testo può avere una
82
+ * volta arrivato al server (correzione ortografica inclusa, dove il server la applica) e si tiene
83
+ * conto della salvaguardia sul valore attuale. Nel dubbio si tace e decide il server.
84
+ */
85
+ function exceeds(check) {
86
+ const length = serverLength(check.value);
87
+ const shortest = check.orthography ? length - orthographyMargin(String(check.value ?? '')) : length;
88
+ if (shortest <= check.max)
89
+ return false;
90
+ const previous = serverLength(check.previous);
91
+ return !(previous > check.max && length <= previous);
92
+ }
@@ -1,3 +1,2 @@
1
- /** Read a whole stream (default: process.stdin) into a UTF-8 string. Used by `secrets import` to accept
2
- * piped input, e.g. `doppler secrets download --format json | cyi secrets import --format json`. */
1
+ /** Read a whole stream (default: process.stdin) into a UTF-8 string for `secrets import`. */
3
2
  export declare function readStream(stream?: AsyncIterable<Buffer | string>): Promise<string>;
package/dist/lib/stdin.js CHANGED
@@ -1,8 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.readStream = readStream;
4
- /** Read a whole stream (default: process.stdin) into a UTF-8 string. Used by `secrets import` to accept
5
- * piped input, e.g. `doppler secrets download --format json | cyi secrets import --format json`. */
4
+ /** Read a whole stream (default: process.stdin) into a UTF-8 string for `secrets import`. */
6
5
  async function readStream(stream = process.stdin) {
7
6
  const chunks = [];
8
7
  for await (const chunk of stream)
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Run a child process inheriting stdio, resolving with its exit code. Unlike `openUrl` (browser.ts)
3
3
  * this is NOT detached/unref'd: `cyi run` must stay attached and propagate the child's exit code
4
- * (like `doppler run`). A terminating signal maps to a non-zero code.
4
+ * and propagate the child's exit code. A terminating signal maps to a non-zero code.
5
5
  */
6
6
  export declare function runCommand(command: string, args: string[], env: NodeJS.ProcessEnv): Promise<number>;
@@ -5,7 +5,7 @@ const node_child_process_1 = require("node:child_process");
5
5
  /**
6
6
  * Run a child process inheriting stdio, resolving with its exit code. Unlike `openUrl` (browser.ts)
7
7
  * this is NOT detached/unref'd: `cyi run` must stay attached and propagate the child's exit code
8
- * (like `doppler run`). A terminating signal maps to a non-zero code.
8
+ * and propagate the child's exit code. A terminating signal maps to a non-zero code.
9
9
  */
10
10
  function runCommand(command, args, env) {
11
11
  return new Promise((resolve, reject) => {