@waron97/prbot 3.3.0 → 3.5.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.
@@ -4,6 +4,7 @@ import search from '@inquirer/search';
4
4
  import inquirer from 'inquirer';
5
5
  import { resolveAddonsPath } from '../lib/addons.js';
6
6
  import { fuzzyMatch } from '../lib/fuzzy.js';
7
+ import { log } from '../lib/logger.js';
7
8
 
8
9
  function buildRefString(tridents, jiras, prNumber) {
9
10
  const refs = [];
@@ -116,7 +117,7 @@ async function changelog(prNumber, options) {
116
117
  const duplicate = findDuplicateLine(content, tridents, jiras);
117
118
  let appendMode = false;
118
119
  if (duplicate) {
119
- console.log(`\nExisting entry (line ${duplicate.lineNumber + 1}):\n ${duplicate.line}`);
120
+ log(`\nExisting entry (line ${duplicate.lineNumber + 1}):\n ${duplicate.line}`);
120
121
  const { confirm } = await inquirer.prompt([
121
122
  {
122
123
  type: 'confirm',
@@ -132,7 +133,7 @@ async function changelog(prNumber, options) {
132
133
  const lines = content.split('\n');
133
134
  lines[duplicate.lineNumber] = appendPrToLine(lines[duplicate.lineNumber], prNumber);
134
135
  await fs.writeFile(changelogPath, lines.join('\n'));
135
- console.log('Updated existing line');
136
+ log('Updated existing line');
136
137
  return;
137
138
  }
138
139
 
@@ -176,7 +177,7 @@ async function changelog(prNumber, options) {
176
177
  lines.splice(endLine + 1, 0, newEntry);
177
178
 
178
179
  await fs.writeFile(changelogPath, lines.join('\n'));
179
- console.log('Changelog entry added');
180
+ log('Changelog entry added');
180
181
  }
181
182
 
182
183
  function findLineByPrNumber(content, prNumber) {
@@ -3,6 +3,7 @@ import inquirer from 'inquirer';
3
3
  import searchList from 'inquirer-search-list';
4
4
  import { resolveAddonsPath } from '../lib/addons.js';
5
5
  import { execGit } from '../lib/git.js';
6
+ import { log } from '../lib/logger.js';
6
7
 
7
8
  inquirer.registerPrompt('search-list', searchList);
8
9
 
@@ -50,7 +51,7 @@ function validateSameModule(files) {
50
51
  const allSameModule = modules.every((module) => `[${module}]` === currentModule);
51
52
 
52
53
  if (!allSameModule) {
53
- console.log(chalk.red('Selected files are not of the same module'));
54
+ log(chalk.red('Selected files are not of the same module'));
54
55
  return null;
55
56
  }
56
57
 
@@ -59,8 +60,8 @@ function validateSameModule(files) {
59
60
 
60
61
  async function getFilesToCommit(stagedChanges, unstagedChanges) {
61
62
  if (stagedChanges.trim()) {
62
- console.log(chalk.green('Staged changes:'));
63
- console.log(stagedChanges);
63
+ log(chalk.green('Staged changes:'));
64
+ log(stagedChanges);
64
65
 
65
66
  return {
66
67
  filesToCheck: stagedChanges.trim().split('\n'),
@@ -71,9 +72,9 @@ async function getFilesToCommit(stagedChanges, unstagedChanges) {
71
72
  const unstagedFiles = unstagedChanges.trim().split('\n');
72
73
 
73
74
  while (true) {
74
- console.log(chalk.yellow('No staged changes found.'));
75
- console.log(chalk.yellow('Unstaged changes:'));
76
- console.log(unstagedChanges);
75
+ log(chalk.yellow('No staged changes found.'));
76
+ log(chalk.yellow('Unstaged changes:'));
77
+ log(unstagedChanges);
77
78
 
78
79
  const answers = await inquirer.prompt([
79
80
  {
@@ -104,7 +105,7 @@ async function commit() {
104
105
  const stagedChanges = await execGit(['diff', '--cached', '--name-only'], ADDONS_PATH);
105
106
 
106
107
  if (!unstagedChanges.trim() && !stagedChanges.trim()) {
107
- console.log(chalk.red('No changes found to commit.'));
108
+ log(chalk.red('No changes found to commit.'));
108
109
  return;
109
110
  }
110
111
 
@@ -115,7 +116,7 @@ async function commit() {
115
116
  } = await getFilesToCommit(stagedChanges, unstagedChanges);
116
117
 
117
118
  if (filesToCheck.length === 0) {
118
- console.log(chalk.red('No files selected.'));
119
+ log(chalk.red('No files selected.'));
119
120
  return;
120
121
  }
121
122
 
@@ -167,13 +168,13 @@ async function commit() {
167
168
  }
168
169
 
169
170
  await execGit(['commit', '-m', commitMessage], ADDONS_PATH);
170
- console.log(chalk.green('Commit created successfully!'));
171
+ log(chalk.green('Commit created successfully!'));
171
172
 
172
173
  const remainingUnstaged = await execGit(['diff', '--name-only'], ADDONS_PATH);
173
174
  const remainingStaged = await execGit(['diff', '--cached', '--name-only'], ADDONS_PATH);
174
175
 
175
176
  if (!remainingUnstaged.trim() && !remainingStaged.trim()) {
176
- console.log(chalk.green('No more changes to commit.'));
177
+ log(chalk.green('No more changes to commit.'));
177
178
  break;
178
179
  }
179
180
 
@@ -1,3 +1,4 @@
1
+ import { log } from '../lib/logger.js';
1
2
  import { exportEmailTemplates } from './exportEmailTemplates.js';
2
3
  import { exportImperex } from './exportImperex.js';
3
4
  import { exportLrp } from './exportLrp.js';
@@ -5,7 +6,7 @@ import { exportPb } from './exportPb.js';
5
6
  import { exportWorkflow } from './exportWorkflow.js';
6
7
 
7
8
  function exportRip() {
8
- console.log('Not implemented yet.');
9
+ log('Not implemented yet.');
9
10
  }
10
11
 
11
12
  export { exportPb, exportRip, exportImperex, exportEmailTemplates, exportWorkflow, exportLrp };
@@ -36,13 +36,30 @@ async function initiateExport(guid, token) {
36
36
  if (!response.ok) throw new Error(await response.text());
37
37
  }
38
38
 
39
+ // No job/request id is returned by initiateExport, so the poll still
40
+ // correlates by GUID + a corrected date window over the last few results —
41
+ // a heuristic, not a stable identifier (see REL-ADP-PB-001; a real fix
42
+ // depends on the ImportExport API exposing a request id, EXT-002). What we
43
+ // can and do own locally is not hanging forever: an overall deadline.
44
+ const PB_EXPORT_POLL_INTERVAL_MS = 3_000;
45
+ const PB_EXPORT_POLL_TIMEOUT_MS = 120_000;
46
+
39
47
  async function pollExportResult(guid, requestTime, token) {
40
48
  const url = `${process.env.IMPORTEXPORT_URL}/export/info/processKey=ExportElement&subProcess=true&status=FAILED,COMPLETED&referenceId=process_builder`;
41
49
  // Server createDate is offset -1hr from system time; subtract 1hr+5s buffer
42
50
  const cutoff = requestTime - 3_605_000;
51
+ const deadline = requestTime + PB_EXPORT_POLL_TIMEOUT_MS;
43
52
 
44
53
  while (true) {
45
- await new Promise((r) => setTimeout(r, 3000));
54
+ if (Date.now() > deadline) {
55
+ throw new Error(
56
+ `REMOTE_TIMEOUT: Process Builder export for guid ${guid} did not complete within ${
57
+ PB_EXPORT_POLL_TIMEOUT_MS / 1000
58
+ }s`
59
+ );
60
+ }
61
+
62
+ await new Promise((r) => setTimeout(r, PB_EXPORT_POLL_INTERVAL_MS));
46
63
 
47
64
  const response = await fetch(url, {
48
65
  method: 'POST',
@@ -153,4 +170,4 @@ async function exportPb(opts) {
153
170
  }
154
171
  }
155
172
 
156
- export { exportPb };
173
+ export { exportPb, pollExportResult };
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
2
  import inquirer from 'inquirer';
3
3
  import { CONFIG_DIR, CONFIG_FILE } from '../config.js';
4
+ import { log } from '../lib/logger.js';
4
5
 
5
6
  async function init() {
6
7
  if (!existsSync(CONFIG_DIR)) {
@@ -142,7 +143,7 @@ async function init() {
142
143
  .map(([k, v]) => `${k}=${v}`)
143
144
  .join('\n') + '\n'
144
145
  );
145
- console.log(`Config written to ${CONFIG_FILE}`);
146
+ log(`Config written to ${CONFIG_FILE}`);
146
147
  }
147
148
 
148
149
  export { init };
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import { select } from '@inquirer/prompts';
4
4
  import { parse } from 'yaml';
5
5
  import { CONFIG_DIR } from '../config.js';
6
- import { setSilent } from '../lib/logger.js';
6
+ import { log, setSilent } from '../lib/logger.js';
7
7
  import { exportEmailTemplates } from './exportEmailTemplates.js';
8
8
  import { exportImperex } from './exportImperex.js';
9
9
  import { exportLrp } from './exportLrp.js';
@@ -52,39 +52,50 @@ function isNothingToCommit(err) {
52
52
  }
53
53
 
54
54
  async function runRoutine(routine) {
55
- console.log(`Running Routine: ${routine.name}`);
55
+ log(`Running Routine: ${routine.name}`);
56
+
57
+ const failures = [];
56
58
 
57
59
  for (const step of routine.steps) {
58
60
  const label = `[${step.name}]`;
59
61
  const fn = COMMAND_MAP[step.command];
60
62
  if (!fn) {
61
- console.log(`${label} Unknown command: ${step.command}`);
63
+ log(`${label} Unknown command: ${step.command}`);
64
+ failures.push(`${step.name}: unknown command "${step.command}"`);
62
65
  continue;
63
66
  }
64
67
 
65
- console.log(`${label} Job started`);
68
+ log(`${label} Job started`);
66
69
  setSilent(true);
67
70
 
68
71
  try {
69
72
  await fn(stepOpts(step));
70
- console.log(`${label} Done (committed)`);
73
+ log(`${label} Done (committed)`);
71
74
  } catch (err) {
72
75
  if (isNothingToCommit(err)) {
73
- console.log(`${label} Done (nothing to commit)`);
76
+ log(`${label} Done (nothing to commit)`);
74
77
  } else {
75
- console.log(`${label} Failed: ${err.message}`);
78
+ log(`${label} Failed: ${err.message}`);
79
+ failures.push(`${step.name}: ${err.message}`);
76
80
  }
77
81
  } finally {
78
82
  setSilent(false);
79
83
  }
80
84
  }
85
+
86
+ if (failures.length) {
87
+ throw new Error(
88
+ `Routine "${routine.name}" completed with ${failures.length} failed step(s):\n` +
89
+ failures.map((f) => ` - ${f}`).join('\n')
90
+ );
91
+ }
81
92
  }
82
93
 
83
94
  async function routine() {
84
95
  const routines = loadRoutines();
85
96
 
86
97
  if (!routines.length) {
87
- console.log(
98
+ log(
88
99
  'No routines defined. Create ~/.config/prbot/routines.yaml or add routines: to agrippa.yaml.'
89
100
  );
90
101
  return;
package/src/index.js CHANGED
@@ -18,54 +18,102 @@ import { main as prMain } from './commands/pr.js';
18
18
  import { routine } from './commands/routine.js';
19
19
  import { verbot } from './commands/ver.js';
20
20
  import { CONFIG_FILE } from './config.js';
21
- import { setSilent } from './lib/logger.js';
21
+ import { error, log, setSilent } from './lib/logger.js';
22
22
  import { checkForUpdate, currentVersion } from './lib/updateCheck.js';
23
23
 
24
+ // Commands that never talk to RIP/DevOps/Trident/Keycloak. They still read
25
+ // non-secret config (e.g. ADDONS_PATH) from the same file, but must not end
26
+ // up holding credentials in their process env — a coding agent invoking one
27
+ // of these should not even technically be able to leak or reuse a secret it
28
+ // never needed.
29
+ const LOCAL_COMMANDS = new Set(['init', 'ver', 'commit', 'changelog']);
30
+ const SECRET_ENV_KEYS = [
31
+ 'KC_USER',
32
+ 'KC_PASSWORD',
33
+ 'KC_ID',
34
+ 'KC_SECRET',
35
+ 'DEVOPS_TOKEN',
36
+ 'TRIDENT_TOKEN',
37
+ ];
38
+ const OFFLINE = process.env.PRBOT_OFFLINE === '1' || process.argv.includes('--offline');
39
+
24
40
  configDotenv({ path: CONFIG_FILE, quiet: true });
25
41
 
42
+ const invokedCommand = process.argv[2];
43
+ if (LOCAL_COMMANDS.has(invokedCommand)) {
44
+ for (const key of SECRET_ENV_KEYS) delete process.env[key];
45
+ }
46
+
26
47
  let _updateAvailable = null;
27
- checkForUpdate().then((v) => {
28
- _updateAvailable = v;
29
- });
48
+ if (!OFFLINE) {
49
+ checkForUpdate().then((v) => {
50
+ _updateAvailable = v;
51
+ });
52
+ }
30
53
 
31
54
  process.on('exit', () => {
32
55
  if (_updateAvailable) {
33
- console.log(
34
- `\nUpdate available: ${currentVersion} → ${_updateAvailable}\nRun: prbot update`
35
- );
56
+ log(`\nUpdate available: ${currentVersion} → ${_updateAvailable}\nRun: prbot update`);
36
57
  }
37
58
  });
38
59
 
60
+ // Single point of convergence for every command's failure. Exit code 0 must
61
+ // mean the requested operation actually succeeded; nothing downstream of
62
+ // this should ever swallow an error into a zero exit.
63
+ function fail(err) {
64
+ error(`Error: ${err?.message ?? err}`);
65
+ process.exitCode = 1;
66
+ }
67
+
68
+ process.on('unhandledRejection', (err) => {
69
+ fail(err instanceof Error ? err : new Error(String(err)));
70
+ });
71
+ process.on('uncaughtException', (err) => {
72
+ fail(err);
73
+ });
74
+
75
+ /**
76
+ * Adds `--quiet`/`--silent` to a command and returns a helper that reads
77
+ * both, warning once if the deprecated `--silent` alias is used. `--silent`
78
+ * used to also swallow errors (so a failed export could report success);
79
+ * it no longer does — both flags only suppress informational output.
80
+ */
81
+ function withQuiet(cmd) {
82
+ return cmd
83
+ .option('-q, --quiet', 'Suppress informational output (errors still fail the command)')
84
+ .option('-s, --silent', 'Deprecated alias of --quiet; no longer swallows errors');
85
+ }
86
+
87
+ function resolveQuiet(opts) {
88
+ if (opts.silent) {
89
+ error('Warning: --silent is deprecated and no longer swallows errors; use --quiet.');
90
+ }
91
+ return Boolean(opts.quiet || opts.silent);
92
+ }
93
+
39
94
  program
40
95
  .command('pr <module>')
41
96
  .option('-b, --bump <level>')
42
- .action((module, opts) => {
43
- prMain(module)
44
- .then(() => {
45
- if (opts.bump) {
46
- return verbot(module, opts.bump);
47
- }
48
- })
49
- .catch((err) => {
50
- throw err;
51
- });
97
+ .action(async (module, opts) => {
98
+ await prMain(module);
99
+ if (opts.bump) await verbot(module, opts.bump);
52
100
  });
53
101
 
54
102
  program
55
103
  .command('ver <module>')
56
104
  .option('-b, --bump <level>')
57
- .action((module, opts) => {
105
+ .action(async (module, opts) => {
58
106
  if (!opts.bump) {
59
107
  throw new Error('No bump level specified');
60
108
  }
61
- verbot(module, opts.bump);
109
+ await verbot(module, opts.bump);
62
110
  });
63
111
 
64
112
  program
65
113
  .command('init')
66
114
  .description('Create config file')
67
- .action(() => {
68
- init();
115
+ .action(async () => {
116
+ await init();
69
117
  });
70
118
 
71
119
  const collect = (val, prev) => [...(prev ?? []), val];
@@ -75,10 +123,8 @@ program
75
123
  .option('-t, --trident <code>', 'Trident issue codes (repeatable)', collect)
76
124
  .option('-j, --jira <code>', 'JIRA issue codes (repeatable)', collect)
77
125
  .option('-m, --message <text>', 'Changelog entry message')
78
- .action((prNumber, opts) => {
79
- changelog(prNumber, opts).catch((err) => {
80
- throw err;
81
- });
126
+ .action(async (prNumber, opts) => {
127
+ await changelog(prNumber, opts);
82
128
  });
83
129
 
84
130
  program
@@ -89,107 +135,86 @@ program
89
135
  .option('-b, --branch <name>', 'Branch name (default: autopr_<taskId>)')
90
136
  .option('-n, --name <text>', 'PR title (default: task name from Odoo)')
91
137
  .option('--amend', 'Amend existing PR on current branch with new trident/jira refs')
92
- .action((opts) => {
93
- autopr(opts).catch((err) => {
94
- throw err;
95
- });
138
+ .action(async (opts) => {
139
+ await autopr(opts);
96
140
  });
97
141
 
98
- program.command('commit').action((opts) => {
99
- commit(opts).catch((err) => {
100
- throw err;
101
- });
142
+ program.command('commit').action(async (opts) => {
143
+ await commit(opts);
102
144
  });
103
145
 
104
146
  const exportCmd = program.command('export');
105
147
 
106
- exportCmd
107
- .command('workflow')
108
- .option('--no-commit')
109
- .option('-b, --bump <level>', 'Version bump level (patch, minor, major)')
110
- .option('-m, --module <id>', 'Module/workflow ID to export (skips interactive selection)')
111
- .option('-s, --silent', 'Suppress all output and swallow errors')
112
- .option(
113
- '--auto-premigrate',
114
- 'Auto-generate pre-migrate script when XML ID renames are detected (no prompt)'
115
- )
116
- .action((opts) => {
117
- if (opts.silent) setSilent(true);
118
- exportWorkflow(opts).catch((err) => {
119
- if (!opts.silent) throw err;
120
- });
121
- });
148
+ withQuiet(
149
+ exportCmd
150
+ .command('workflow')
151
+ .option('--no-commit')
152
+ .option('-b, --bump <level>', 'Version bump level (patch, minor, major)')
153
+ .option('-m, --module <id>', 'Module/workflow ID to export (skips interactive selection)')
154
+ .option(
155
+ '--auto-premigrate',
156
+ 'Auto-generate pre-migrate script when XML ID renames are detected (no prompt)'
157
+ )
158
+ ).action(async (opts) => {
159
+ if (resolveQuiet(opts)) setSilent(true);
160
+ await exportWorkflow(opts);
161
+ });
122
162
 
123
163
  exportCmd.command('rip').action(() => exportRip());
124
164
 
125
- exportCmd
126
- .command('pb')
127
- .option('--no-commit')
128
- .option('-s, --silent', 'Suppress all output and swallow errors')
129
- .action((opts) => {
130
- if (opts.silent) setSilent(true);
131
- exportPb(opts).catch((err) => {
132
- if (!opts.silent) throw err;
133
- });
134
- });
165
+ withQuiet(exportCmd.command('pb').option('--no-commit')).action(async (opts) => {
166
+ if (resolveQuiet(opts)) setSilent(true);
167
+ await exportPb(opts);
168
+ });
135
169
 
136
- exportCmd
137
- .command('imperex')
138
- .option('--no-commit')
139
- .option('-s, --silent', 'Suppress all output and swallow errors')
140
- .action((opts) => {
141
- if (opts.silent) setSilent(true);
142
- exportImperex(opts).catch((err) => {
143
- if (!opts.silent) throw err;
144
- });
145
- });
170
+ withQuiet(exportCmd.command('imperex').option('--no-commit')).action(async (opts) => {
171
+ if (resolveQuiet(opts)) setSilent(true);
172
+ await exportImperex(opts);
173
+ });
146
174
 
147
- exportCmd
148
- .command('lrp')
149
- .option('--no-commit')
150
- .option('-s, --silent', 'Suppress all output and swallow errors')
151
- .action((opts) => {
152
- if (opts.silent) setSilent(true);
153
- exportLrp(opts).catch((err) => {
154
- if (!opts.silent) throw err;
155
- });
156
- });
175
+ withQuiet(exportCmd.command('lrp').option('--no-commit')).action(async (opts) => {
176
+ if (resolveQuiet(opts)) setSilent(true);
177
+ await exportLrp(opts);
178
+ });
157
179
 
158
- exportCmd
159
- .command('email-templates')
160
- .option('--no-commit')
161
- .option('-b, --bump <level>', 'Version bump level (patch, minor, major)')
162
- .option('-e, --exclude <value...>', 'exclude templates matching id, name, or template_code')
163
- .option('-m, --module <name>', 'module directory name (skip prompt)')
164
- .option('-w, --workflow <value>', 'workflow name or id (skip prompt)')
165
- .option('-s, --silent', 'Suppress all output and swallow errors')
166
- .option(
167
- '--auto-premigrate',
168
- 'Auto-generate pre-migrate script when XML ID renames are detected (no prompt)'
169
- )
170
- .action((opts) => {
171
- if (opts.silent) setSilent(true);
172
- exportEmailTemplates(opts).catch((err) => {
173
- if (!opts.silent) throw err;
174
- });
175
- });
180
+ withQuiet(
181
+ exportCmd
182
+ .command('email-templates')
183
+ .option('--no-commit')
184
+ .option('-b, --bump <level>', 'Version bump level (patch, minor, major)')
185
+ .option('-e, --exclude <value...>', 'exclude templates matching id, name, or template_code')
186
+ .option('-m, --module <name>', 'module directory name (skip prompt)')
187
+ .option('-w, --workflow <value>', 'workflow name or id (skip prompt)')
188
+ .option(
189
+ '--auto-premigrate',
190
+ 'Auto-generate pre-migrate script when XML ID renames are detected (no prompt)'
191
+ )
192
+ ).action(async (opts) => {
193
+ if (resolveQuiet(opts)) setSilent(true);
194
+ await exportEmailTemplates(opts);
195
+ });
176
196
 
177
- program.command('routine').action(() => {
178
- routine().catch((err) => {
179
- throw err;
180
- });
197
+ program.command('routine').action(async () => {
198
+ await routine();
181
199
  });
182
200
 
183
- program.command('update').action(() => {
184
- console.log('Updating prbot...');
185
- execFile('npm', ['i', '-g', '@waron97/prbot'], (error, stdout, stderr) => {
186
- if (error) {
187
- console.error(stderr || error.message);
188
- process.exit(1);
189
- }
190
- console.log(stdout);
191
- console.log('Done.');
201
+ program
202
+ .command('update [version]')
203
+ .description('Update the global prbot install (defaults to latest if no version given)')
204
+ .action(async (version) => {
205
+ const target = version ? `@waron97/prbot@${version}` : '@waron97/prbot';
206
+ log(version ? `Updating prbot to ${version}...` : 'Updating prbot to latest...');
207
+ await new Promise((resolve, reject) => {
208
+ execFile('npm', ['i', '-g', target], (err, stdout, stderr) => {
209
+ if (err) {
210
+ reject(new Error(stderr || err.message));
211
+ return;
212
+ }
213
+ log(stdout);
214
+ log('Done.');
215
+ resolve();
216
+ });
217
+ });
192
218
  });
193
- });
194
219
 
195
- program.parse();
220
+ program.parseAsync().catch(fail);
package/src/lib/auth.js CHANGED
@@ -18,7 +18,25 @@ async function getToken() {
18
18
  body: payload.toString(),
19
19
  });
20
20
 
21
- const json = await response.json();
21
+ if (!response.ok) {
22
+ // Never log the response body: on a password-grant endpoint it can
23
+ // echo back the submitted credentials.
24
+ throw new Error(
25
+ `AUTH_FAILED: Keycloak token request failed with status ${response.status}`
26
+ );
27
+ }
28
+
29
+ let json;
30
+ try {
31
+ json = await response.json();
32
+ } catch {
33
+ throw new Error('AUTH_FAILED: Keycloak response was not valid JSON');
34
+ }
35
+
36
+ if (!json || typeof json.access_token !== 'string' || !json.access_token) {
37
+ throw new Error('AUTH_FAILED: Keycloak response did not contain an access_token');
38
+ }
39
+
22
40
  return json.access_token;
23
41
  }
24
42
 
package/src/lib/logger.js CHANGED
@@ -8,8 +8,19 @@ function isSilent() {
8
8
  return silent;
9
9
  }
10
10
 
11
+ // Informational output — suppressed by --quiet/--silent.
11
12
  function log(...args) {
12
13
  if (!silent) console.log(...args);
13
14
  }
14
15
 
15
- export { setSilent, isSilent, log };
16
+ // Warnings and errors are never suppressed: --quiet reduces noise, it must
17
+ // never hide something the user needs to see to trust the exit code.
18
+ function warn(...args) {
19
+ console.warn(...args);
20
+ }
21
+
22
+ function error(...args) {
23
+ console.error(...args);
24
+ }
25
+
26
+ export { setSilent, isSilent, log, warn, error };