browser-debugger-cli 0.14.0 → 0.16.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 (167) hide show
  1. package/.claude/skills/bdg/SKILL.md +3 -2
  2. package/dist/cdp/methodTarget.d.ts +92 -0
  3. package/dist/cdp/methodTarget.js +159 -0
  4. package/dist/cdp/protocol.d.ts +16 -1
  5. package/dist/cdp/protocol.js +21 -0
  6. package/dist/cdp/schema.d.ts +55 -1
  7. package/dist/cdp/schema.js +134 -25
  8. package/dist/cdp/types.d.ts +3 -1
  9. package/dist/commands/cdp.d.ts +38 -1
  10. package/dist/commands/cdp.js +201 -133
  11. package/dist/commands/cleanup.js +21 -4
  12. package/dist/commands/dom/eval.d.ts +2 -1
  13. package/dist/commands/dom/eval.js +6 -21
  14. package/dist/commands/dom/formInteraction.js +8 -4
  15. package/dist/commands/dom/helpers/evalResult.d.ts +36 -0
  16. package/dist/commands/dom/helpers/evalResult.js +59 -0
  17. package/dist/commands/dom/helpers/index.d.ts +4 -4
  18. package/dist/commands/dom/helpers/index.js +3 -3
  19. package/dist/commands/dom/helpers/query.d.ts +2 -2
  20. package/dist/commands/dom/helpers/query.js +2 -2
  21. package/dist/commands/dom/helpers/screenshot.d.ts +21 -26
  22. package/dist/commands/dom/helpers/screenshot.js +50 -668
  23. package/dist/commands/dom/screenshot.js +56 -36
  24. package/dist/commands/helpJson.d.ts +1 -1
  25. package/dist/commands/helpJson.js +3 -3
  26. package/dist/commands/helpTopic.js +10 -4
  27. package/dist/commands/network/har.js +18 -14
  28. package/dist/commands/optionBehaviors.js +24 -9
  29. package/dist/commands/shared/CommandRunner.d.ts +5 -0
  30. package/dist/commands/shared/CommandRunner.js +18 -3
  31. package/dist/commands/shared/interrupt.d.ts +40 -0
  32. package/dist/commands/shared/interrupt.js +73 -0
  33. package/dist/commands/shared/optionTypes.d.ts +3 -0
  34. package/dist/commands/shared/outputFile.d.ts +2 -1
  35. package/dist/commands/shared/outputFile.js +7 -4
  36. package/dist/commands/shared/startHelpers.d.ts +26 -3
  37. package/dist/commands/shared/startHelpers.js +145 -23
  38. package/dist/commands/status.js +3 -1
  39. package/dist/commands/stop.js +2 -1
  40. package/dist/commands/types.d.ts +5 -0
  41. package/dist/connection/cdp.js +1 -16
  42. package/dist/connection/chromeIdentity.d.ts +24 -5
  43. package/dist/connection/chromeIdentity.js +53 -22
  44. package/dist/connection/launcher/flagsBuilder.d.ts +46 -0
  45. package/dist/connection/launcher/flagsBuilder.js +107 -23
  46. package/dist/connection/launcher.d.ts +35 -2
  47. package/dist/connection/launcher.js +99 -12
  48. package/dist/connection/typed-cdp.d.ts +3 -2
  49. package/dist/constants.d.ts +3 -5
  50. package/dist/constants.js +3 -5
  51. package/dist/daemon/SessionController.d.ts +10 -5
  52. package/dist/daemon/SessionController.js +15 -8
  53. package/dist/daemon/ipcServer.js +1 -1
  54. package/dist/daemon/launcher.d.ts +22 -3
  55. package/dist/daemon/launcher.js +45 -8
  56. package/dist/daemon/session/Session.d.ts +5 -1
  57. package/dist/daemon/session/Session.js +9 -8
  58. package/dist/daemon/session/TelemetryStore.d.ts +5 -0
  59. package/dist/daemon/session/TelemetryStore.js +4 -0
  60. package/dist/daemon/session/captureGate.d.ts +59 -0
  61. package/dist/daemon/session/captureGate.js +96 -0
  62. package/dist/daemon/session/chromeConnection.d.ts +16 -1
  63. package/dist/daemon/session/chromeConnection.js +34 -4
  64. package/dist/daemon/session/collectors.d.ts +15 -0
  65. package/dist/daemon/session/collectors.js +39 -2
  66. package/dist/daemon/session/commandRegistry.d.ts +14 -1
  67. package/dist/daemon/session/commandRegistry.js +48 -13
  68. package/dist/daemon/session/downloads.d.ts +32 -0
  69. package/dist/daemon/session/downloads.js +96 -0
  70. package/dist/daemon/session/interactions.d.ts +3 -2
  71. package/dist/daemon/session/interactions.js +7 -2
  72. package/dist/daemon/session/plugins.js +6 -0
  73. package/dist/daemon.js +18520 -17014
  74. package/dist/errors/CommandError.d.ts +2 -0
  75. package/dist/errors/issues.d.ts +1 -1
  76. package/dist/errors/messages.d.ts +81 -0
  77. package/dist/errors/messages.js +198 -6
  78. package/dist/index.js +1446 -1078
  79. package/dist/ipc/client.d.ts +20 -2
  80. package/dist/ipc/client.js +32 -6
  81. package/dist/ipc/protocol/commands.d.ts +36 -2
  82. package/dist/ipc/protocol/commands.js +1 -0
  83. package/dist/ipc/protocol/domTypes.d.ts +24 -1
  84. package/dist/ipc/session/queries.d.ts +3 -0
  85. package/dist/ipc/session/types.d.ts +5 -0
  86. package/dist/ipc/transport/IPCError.d.ts +9 -0
  87. package/dist/ipc/transport/IPCError.js +12 -0
  88. package/dist/ipc/transport/errors.d.ts +2 -1
  89. package/dist/ipc/transport/errors.js +4 -1
  90. package/dist/ipc/transport/index.d.ts +10 -2
  91. package/dist/ipc/transport/index.js +29 -4
  92. package/dist/runtime/dom/actionEffects.d.ts +48 -9
  93. package/dist/runtime/dom/actionEffects.js +269 -34
  94. package/dist/runtime/dom/actionEffectsScripts.d.ts +45 -0
  95. package/dist/runtime/dom/actionEffectsScripts.js +101 -2
  96. package/dist/runtime/dom/captureArea.d.ts +35 -0
  97. package/dist/runtime/dom/captureArea.js +203 -0
  98. package/dist/runtime/dom/elementInfo.d.ts +13 -4
  99. package/dist/runtime/dom/elementInfo.js +12 -3
  100. package/dist/runtime/dom/evalHelpers.d.ts +24 -4
  101. package/dist/runtime/dom/evalHelpers.js +40 -12
  102. package/dist/runtime/dom/formDiscovery.d.ts +1 -1
  103. package/dist/runtime/dom/frames.d.ts +2 -1
  104. package/dist/runtime/dom/frames.js +3 -1
  105. package/dist/runtime/page/bdgWorld.d.ts +9 -0
  106. package/dist/runtime/page/bdgWorld.js +11 -0
  107. package/dist/runtime/page/captureEmulation.d.ts +119 -0
  108. package/dist/runtime/page/captureEmulation.js +189 -0
  109. package/dist/runtime/page/captureScroll.d.ts +24 -0
  110. package/dist/runtime/page/captureScroll.js +124 -0
  111. package/dist/runtime/page/emulation.js +6 -5
  112. package/dist/runtime/page/screenshot.d.ts +41 -0
  113. package/dist/runtime/page/screenshot.js +394 -0
  114. package/dist/runtime/page/userAgent.d.ts +86 -2
  115. package/dist/runtime/page/userAgent.js +154 -33
  116. package/dist/session/paths.d.ts +52 -3
  117. package/dist/session/paths.js +179 -7
  118. package/dist/session/portClaims.d.ts +0 -8
  119. package/dist/session/portClaims.js +1 -22
  120. package/dist/session/sessionList.d.ts +5 -1
  121. package/dist/session/sessionList.js +5 -1
  122. package/dist/telemetry/downloads.d.ts +127 -0
  123. package/dist/telemetry/downloads.js +265 -0
  124. package/dist/telemetry/har/builder.d.ts +12 -1
  125. package/dist/telemetry/har/builder.js +32 -9
  126. package/dist/telemetry/har/sanitize.d.ts +28 -0
  127. package/dist/telemetry/har/sanitize.js +184 -0
  128. package/dist/telemetry/har/sanitizeBody.d.ts +78 -0
  129. package/dist/telemetry/har/sanitizeBody.js +541 -0
  130. package/dist/telemetry/har/types.d.ts +2 -0
  131. package/dist/telemetry/network.d.ts +4 -4
  132. package/dist/telemetry/network.js +38 -4
  133. package/dist/telemetry/networkRetention.d.ts +35 -14
  134. package/dist/telemetry/networkRetention.js +62 -26
  135. package/dist/types.d.ts +9 -14
  136. package/dist/ui/OutputBuilder.d.ts +3 -2
  137. package/dist/ui/OutputBuilder.js +4 -3
  138. package/dist/ui/formatters/cdp.d.ts +32 -9
  139. package/dist/ui/formatters/cdp.js +77 -6
  140. package/dist/ui/formatters/details.js +7 -15
  141. package/dist/ui/formatters/preview.d.ts +2 -0
  142. package/dist/ui/formatters/preview.js +7 -1
  143. package/dist/ui/formatters/sessions.d.ts +3 -2
  144. package/dist/ui/formatters/sessions.js +10 -3
  145. package/dist/ui/formatters/status.js +6 -1
  146. package/dist/ui/formatting.d.ts +7 -0
  147. package/dist/ui/formatting.js +13 -0
  148. package/dist/ui/logging/logger.d.ts +1 -1
  149. package/dist/ui/messages/chrome.d.ts +27 -6
  150. package/dist/ui/messages/chrome.js +78 -12
  151. package/dist/ui/messages/commands.d.ts +71 -3
  152. package/dist/ui/messages/commands.js +98 -3
  153. package/dist/ui/messages/networkMessages.d.ts +50 -5
  154. package/dist/ui/messages/networkMessages.js +50 -6
  155. package/dist/ui/messages/session.d.ts +8 -0
  156. package/dist/ui/messages/session.js +10 -0
  157. package/dist/utils/async.d.ts +3 -2
  158. package/dist/utils/async.js +16 -3
  159. package/dist/utils/atomicFile.d.ts +2 -1
  160. package/dist/utils/atomicFile.js +5 -2
  161. package/dist/utils/directories.d.ts +41 -0
  162. package/dist/utils/directories.js +48 -0
  163. package/dist/utils/http.d.ts +11 -4
  164. package/dist/utils/http.js +5 -3
  165. package/package.json +18 -4
  166. /package/dist/{commands/dom → runtime/page}/screenshotResize.d.ts +0 -0
  167. /package/dist/{commands/dom → runtime/page}/screenshotResize.js +0 -0
@@ -3,8 +3,9 @@
3
3
  */
4
4
  import { extname } from 'path';
5
5
  import { DomElementResolver } from './DomElementResolver.js';
6
- import { capturePageScreenshot, captureElementScreenshot, resolveSelector, selectMatch, } from './helpers/index.js';
6
+ import { captureScreenshot, resolveSelector, screenshotInterrupted, selectMatch, } from './helpers/index.js';
7
7
  import { runCommand } from '../shared/CommandRunner.js';
8
+ import { abortOnInterrupt, unlessInterrupted } from '../shared/interrupt.js';
8
9
  import { assertFilePath, outputPathError } from '../shared/outputFile.js';
9
10
  import { positiveIntRule } from '../shared/validation.js';
10
11
  import { CommandError } from '../../errors/index.js';
@@ -47,34 +48,43 @@ export function resolveImageFormat(outputPath, requested) {
47
48
  }
48
49
  return requested ?? fromExtension ?? 'png';
49
50
  }
50
- function buildPageScreenshotOptions(options) {
51
- return filterDefined({
52
- format: options.format,
53
- quality: options.quality,
54
- fullPage: options.fullPage,
55
- noResize: options.resize === false,
56
- scroll: options.scroll,
57
- });
58
- }
59
- function buildElementScreenshotOptions(options) {
60
- return filterDefined({
61
- format: options.format,
62
- quality: options.quality,
63
- noResize: options.resize === false,
64
- padding: options.padding,
65
- });
51
+ /**
52
+ * What to capture, from the command's options: the element `backendNodeId`
53
+ * names (with its `--padding`), else the page (`--full-page`, `--scroll`).
54
+ *
55
+ * @param options - Command options
56
+ * @param backendNodeId - Element to capture, if any
57
+ * @returns Daemon request
58
+ */
59
+ function buildScreenshotRequest(options, backendNodeId) {
60
+ const shared = {
61
+ format: options.format ?? 'png',
62
+ ...filterDefined({ quality: options.quality }),
63
+ ...(options.resize === false && { noResize: true }),
64
+ };
65
+ if (backendNodeId !== undefined) {
66
+ return { ...shared, backendNodeId, ...filterDefined({ padding: options.padding }) };
67
+ }
68
+ return { ...shared, ...filterDefined({ fullPage: options.fullPage, scroll: options.scroll }) };
66
69
  }
67
70
  function hasElementTarget(options) {
68
71
  return options.selector !== undefined || options.index !== undefined;
69
72
  }
73
+ /**
74
+ * The element to capture: the selector's match (`--index` picks one), or the
75
+ * cached query result at `--index`.
76
+ *
77
+ * @param options - Command options
78
+ * @returns Its backend node id
79
+ * @throws CommandError (81) when neither is given
80
+ */
70
81
  async function resolveElementNodeId(options) {
71
82
  if (options.selector !== undefined && options.index !== undefined) {
72
- return { backendNodeId: await selectMatch(options.selector, options.index) };
83
+ return selectMatch(options.selector, options.index);
73
84
  }
74
85
  if (options.index !== undefined) {
75
- const resolver = DomElementResolver.getInstance();
76
- const node = await resolver.getNodeIdForIndex(options.index);
77
- return { backendNodeId: node.nodeId };
86
+ const node = await DomElementResolver.getInstance().getNodeIdForIndex(options.index);
87
+ return node.nodeId;
78
88
  }
79
89
  if (options.selector !== undefined) {
80
90
  return resolveSelector(options.selector);
@@ -120,32 +130,42 @@ function ensureDirectory(dirPath, fs) {
120
130
  function formatFrameFilename(frameNumber, format) {
121
131
  return `${String(frameNumber).padStart(3, '0')}.${format}`;
122
132
  }
133
+ /**
134
+ * Capture the page. Ctrl-C (or SIGTERM) cancels the capture and exits 130
135
+ * (143), with the error envelope under `--json`.
136
+ *
137
+ * @param outputPath - File to write
138
+ * @param options - Command options
139
+ */
123
140
  async function handlePageScreenshot(outputPath, options) {
141
+ const interrupt = abortOnInterrupt();
124
142
  await runCommand(async () => {
125
- const screenshotOptions = buildPageScreenshotOptions(options);
126
- const result = await capturePageScreenshot(outputPath, screenshotOptions);
143
+ const request = buildScreenshotRequest(options);
144
+ const result = await captureScreenshot(outputPath, request, interrupt);
127
145
  return { success: true, data: result };
128
146
  }, options, formatDomScreenshot);
129
147
  }
148
+ /**
149
+ * Capture one element; interrupted like {@link handlePageScreenshot}, also
150
+ * while the element is looked up.
151
+ *
152
+ * @param outputPath - File to write
153
+ * @param options - Command options
154
+ */
130
155
  async function handleElementScreenshot(outputPath, options) {
156
+ const interrupt = abortOnInterrupt();
131
157
  await runCommand(async () => {
132
- const nodeRef = await resolveElementNodeId(options);
133
- const screenshotOptions = buildElementScreenshotOptions(options);
134
- const result = await captureElementScreenshot(outputPath, nodeRef, screenshotOptions);
135
- const elementResult = addElementInfo(result, options);
158
+ const lookup = resolveElementNodeId(options);
159
+ const backendNodeId = await unlessInterrupted(lookup, interrupt, screenshotInterrupted);
160
+ const request = buildScreenshotRequest(options, backendNodeId);
161
+ const shot = await captureScreenshot(outputPath, request, interrupt);
162
+ const elementResult = addElementInfo(shot, options);
136
163
  return { success: true, data: elementResult };
137
164
  }, options, formatDomScreenshot);
138
165
  }
139
166
  async function captureSequenceFrame(outputPath, options) {
140
- if (hasElementTarget(options)) {
141
- const nodeRef = await resolveElementNodeId(options);
142
- const elementOptions = buildElementScreenshotOptions(options);
143
- await captureElementScreenshot(outputPath, nodeRef, elementOptions);
144
- }
145
- else {
146
- const pageOptions = buildPageScreenshotOptions(options);
147
- await capturePageScreenshot(outputPath, pageOptions);
148
- }
167
+ const backendNodeId = hasElementTarget(options) ? await resolveElementNodeId(options) : undefined;
168
+ await captureScreenshot(outputPath, buildScreenshotRequest(options, backendNodeId));
149
169
  }
150
170
  async function handleSequenceCapture(outputDir, options) {
151
171
  const fs = await import('fs');
@@ -93,7 +93,7 @@ export interface CompactCommand {
93
93
  name: string;
94
94
  /** Command aliases (only when it has some) */
95
95
  aliases?: readonly string[];
96
- /** First line of the description */
96
+ /** Summary if set, else the first line of the description */
97
97
  description: string;
98
98
  /** Arguments as in usage, e.g. "<selector> [index]" (only when it takes some) */
99
99
  arguments?: string;
@@ -114,8 +114,8 @@ function argumentTerm(argument) {
114
114
  return argument.required ? `<${name}>` : `[${name}]`;
115
115
  }
116
116
  /**
117
- * Recursively converts a Commander Command to its compact summary: first
118
- * description line, arguments, and visible options with their descriptions.
117
+ * Recursively converts a Commander Command to its compact summary: summary
118
+ * (or first description line), arguments, and visible options with their descriptions.
119
119
  * Empty fields are left out.
120
120
  *
121
121
  * @param command - Commander command instance
@@ -128,7 +128,7 @@ function convertCompactCommand(command) {
128
128
  return {
129
129
  name: command.name(),
130
130
  ...(aliases.length > 0 && { aliases }),
131
- description: command.description().split('\n')[0] ?? '',
131
+ description: command.summary() || (command.description().split('\n')[0] ?? ''),
132
132
  ...(args && { arguments: args }),
133
133
  ...(options.length > 0 && {
134
134
  options: Object.fromEntries(options.map((option) => [option.flags, option.description])),
@@ -3,11 +3,14 @@
3
3
  */
4
4
  import { commandPath } from './helpJson.js';
5
5
  import { CommandError } from '../errors/index.js';
6
- import { missingSubcommandMessage, unknownHelpTopicError, usageHelpSuggestion, } from '../errors/messages.js';
6
+ import { didYouMeanSuggestion, missingSubcommandMessage, unknownHelpTopicError, usageHelpSuggestion, } from '../errors/messages.js';
7
7
  import { EXIT_CODES } from '../utils/exitCodes.js';
8
8
  import { findSimilar } from '../utils/suggestions.js';
9
- /** Commander's typo hint on its own line, e.g. "(Did you mean query?)" */
10
- const COMMANDER_HINT = /\n?\(Did you mean (.+)\?\)\s*$/;
9
+ /**
10
+ * Commander's typo hint on its own line: "(Did you mean query?)" or, for
11
+ * several equally close candidates, "(Did you mean one of form, frames?)"
12
+ */
13
+ const COMMANDER_HINT = /\n?\(Did you mean (?:one of )?(.+)\?\)\s*$/;
11
14
  /**
12
15
  * The command path of `bdg help <path...>`: the words before the first option.
13
16
  *
@@ -54,7 +57,10 @@ export function splitCommanderHint(text) {
54
57
  const hint = COMMANDER_HINT.exec(message);
55
58
  if (!hint)
56
59
  return { message };
57
- return { message: message.slice(0, hint.index).trim(), suggestion: `Did you mean: ${hint[1]}?` };
60
+ return {
61
+ message: message.slice(0, hint.index).trim(),
62
+ suggestion: didYouMeanSuggestion((hint[1] ?? '').split(', ')),
63
+ };
58
64
  }
59
65
  /** The option named in Commander's "unknown option '--x'" message, without an `=value` */
60
66
  const UNKNOWN_OPTION = /unknown option '([^'=]+)/;
@@ -15,6 +15,7 @@ import { getSessionFilePath } from '../../session/paths.js';
15
15
  import { applyFilters, parseFilterString } from '../../telemetry/filterDsl.js';
16
16
  import { buildHAR } from '../../telemetry/har/builder.js';
17
17
  import { createLogger } from '../../ui/logging/index.js';
18
+ import { harExportedMessage } from '../../ui/messages/networkMessages.js';
18
19
  import { getErrorMessage } from '../../utils/errors.js';
19
20
  import { VERSION } from '../../utils/version.js';
20
21
  import { getNetworkRequests, validateFilterOption } from './shared.js';
@@ -67,15 +68,20 @@ async function getChromeVersion() {
67
68
  function formatHARExport(data) {
68
69
  if ('log' in data)
69
70
  return JSON.stringify(data, null, 2);
70
- const filterNote = data.filtered ? ' (filtered)' : '';
71
- return `✓ Exported ${data.entries} requests${filterNote} to ${data.file}`;
71
+ return harExportedMessage(data);
72
72
  }
73
+ /** HAR files may hold credentials (--include-sensitive): readable by their owner only */
74
+ const HAR_FILE_MODE = 0o600;
73
75
  /** Output path meaning "write the HAR to stdout" */
74
76
  const STDOUT_PATH = '-';
75
77
  /**
76
78
  * Option for filter DSL.
77
79
  */
78
80
  const filterDslOption = new Option('--filter <dsl>', 'Filter requests using DevTools DSL (e.g., "status-code:>=400")');
81
+ /**
82
+ * Option to keep credentials in the export.
83
+ */
84
+ const includeSensitiveOption = new Option('--include-sensitive', 'Keep credentials (auth/cookie/API key headers, cookie values, URL tokens, password and token body fields); redacted by default');
79
85
  /**
80
86
  * Register HAR export command.
81
87
  *
@@ -88,6 +94,7 @@ export function registerHarCommand(networkCmd) {
88
94
  .description('Export network data as HAR 1.2 format')
89
95
  .addOption(jsonOption())
90
96
  .addOption(filterDslOption)
97
+ .addOption(includeSensitiveOption)
91
98
  .action(async (outputFile, options) => {
92
99
  await runCommand(async () => {
93
100
  let requests = await getNetworkRequests();
@@ -103,21 +110,18 @@ export function registerHarCommand(networkCmd) {
103
110
  if (outputPath !== STDOUT_PATH)
104
111
  assertFilePath(outputPath, '.har');
105
112
  const chromeVersion = await getChromeVersion();
106
- const har = buildHAR(requests, {
107
- version: VERSION,
108
- ...(chromeVersion && { chromeVersion }),
109
- });
113
+ const includeSensitive = options.includeSensitive === true;
114
+ const har = buildHAR(requests, { version: VERSION, ...(chromeVersion && { chromeVersion }) }, { includeSensitive });
110
115
  if (outputPath === STDOUT_PATH)
111
116
  return { success: true, data: har };
112
- const file = await writeOutputFile(outputPath, JSON.stringify(har, null, 2), '.har');
113
- return {
114
- success: true,
115
- data: {
116
- file,
117
- entries: har.log.entries.length,
118
- filtered,
119
- },
117
+ const file = await writeOutputFile(outputPath, JSON.stringify(har, null, 2), '.har', HAR_FILE_MODE);
118
+ const data = {
119
+ file,
120
+ entries: har.log.entries.length,
121
+ filtered,
122
+ sanitized: !includeSensitive,
120
123
  };
124
+ return { success: true, data };
121
125
  }, options, formatHARExport);
122
126
  });
123
127
  }
@@ -6,13 +6,13 @@
6
6
  *
7
7
  * @see docs/principles/SELF_DOCUMENTING_SYSTEMS.md
8
8
  */
9
- import { MAX_EDGE_PX, PIXELS_PER_TOKEN, TALL_PAGE_THRESHOLD, } from './dom/screenshotResize.js';
9
+ import { MAX_EDGE_PX, PIXELS_PER_TOKEN, TALL_PAGE_THRESHOLD, } from '../runtime/page/screenshotResize.js';
10
10
  /** What DOM actions report about the network requests they triggered */
11
11
  const TRIGGERED_REQUESTS_BEHAVIOR = 'Requests (and WebSocket connections) that start after the action begins are returned as triggeredRequests (method, url, status, durationMs; pending when still running at return, loading when the response arrived but its body is still streaming; with resourceType; human output lists documents, XHR/fetch and WebSockets first (up to 10) and counts static assets on one line; absent when network telemetry is off). Attribution is by time: requests a page timer or poller starts meanwhile are listed too, whether or not the action caused them';
12
12
  /** What every DOM action reports about the page besides its requests */
13
- const ACTION_EFFECTS_BEHAVIOR = 'The result also says what changed on the page: a navigation (Page: navigated to <url> (status), or URL changed to <url> (same document); JSON navigation { url, sameDocument, status }), and messages that appeared or changed in alert/status/aria-live elements or flash/error/toast-like classes (New text: "…" (element); JSON messages [{ text, element }], at most 3, with "(+N more)" and moreMessages for the rest; after a navigation every message on the new page counts; texts of only digits and time units, such as clocks and counters, are left out, but other text that changes on its own, such as a rotating banner, can show up). Both are absent when nothing changed. Cost: one page script sent before the action without waiting for it and one read after it, a few ms; when the page does not answer (a pending navigation) bdg waits at most 200 ms for the snapshot and 250 ms per read, and the navigation is still reported from CDP events';
13
+ const ACTION_EFFECTS_BEHAVIOR = 'The result also says what changed on the page: a navigation (Page: navigated to <url> (status), or URL changed to <url> (same document); JSON navigation { url, sameDocument, status }), and messages that appeared or changed in alert/status/aria-live elements or flash/error/toast-like classes (New text: "…" (element); JSON messages [{ text, element }], at most 3, with "(+N more)" and moreMessages for the rest; after a navigation every message on the new page counts; texts of only digits and time units, such as clocks and counters, are left out, but other text that changes on its own, such as a rotating banner, can show up). Both are absent when nothing changed. Downloads that began meanwhile are listed (Download: <name> → <path> (<state>, <size>); JSON downloads [{ url, suggestedFilename, path, state: inProgress|completed|canceled, bytes }]); a Chrome bdg launched saves them into <session dir>/downloads (never ~/Downloads), named as suggested with (1), (2)… when taken, and one still running when the action returns is inProgress and appears at its path once complete; downloads of tabs the page opens (target=_blank, window.open) count too; when that directory cannot be created they are refused (canceled, with reason) instead of going to ~/Downloads, and if Chrome refuses the download behavior bdg status warns that downloads are not redirected; ones that begin after the action returned are listed by bdg status and bdg peek (Downloads: N (last: …)); files stay after stop and cleanup; with --chrome-ws-url (the exception: not a browser bdg owns) downloads go to the download folder of that Chrome (usually ~/Downloads), only downloads of the session page are tracked (not those of tabs or popups it opens), and bdg sets behavior default with events, which replaces one another CDP client set and which Chrome drops when the session connection closes. Cost: one page script sent before the action without waiting for it and one read after it, a few ms; when the page does not answer (a pending navigation) bdg waits at most 200 ms for the snapshot and 250 ms per read, and the navigation is still reported from CDP events';
14
14
  /** What click and pressKey report when the page was still changing as they returned */
15
- const STILL_CHANGING_BEHAVIOR = 'When the page was still changing as the action returned, the status line says (page still changing), a note below it says what was pending and suggests bdg dom wait <selector>, and JSON has settled: false with pending { requests (document, fetch/XHR and script requests still running), navigation (a new page still loading), loading (a loading indicator that appeared, e.g. "div#loading"), domChanging (DOM changes kept coming in bursts over a second look 250 ms later; a single render, ticking text and style animations do not count), busy (the page did not answer within 250 ms: a long script) }; absent when the page looked settled (exit code stays 0). A result a timer renders later, with no DOM change, request or loading indicator before it, is not detected. Cost: nothing extra, except 250 ms plus one read when the DOM looked busy. Not checked with --no-wait';
15
+ const STILL_CHANGING_BEHAVIOR = 'When the page was still changing as the action returned, the status line says (page still changing), a note below it says what was pending and suggests bdg dom wait <selector>, and JSON has settled: false with pending { requests (document, fetch/XHR and script requests still running), navigation (a new page still loading), loading (a loading indicator that appeared, e.g. "div#loading"), domChanging (DOM changes came in bursts and, at a second look 250 ms later, again, with no quiet gap over 150 ms; time the page could not run its tasks, a long task or timers a starved renderer runs late, is not quiet, and a single burst more than 150 ms old with at most 75 ms of quiet time since also gets the second look (only that 250 ms; domChanging still needs a fresh change at it); a single render, ticking text and style animations do not count, and changes more than about 150 ms apart while the page could run look settled), busy (the page did not answer within 250 ms and ran a long task since the action began, or did not answer a check within 250 ms more: a long script; a page that answered late without a long task, a starved renderer, gets its answer waited for instead; a renderer descheduled in the middle of a task can still record a long task and is then busy; the check runs at most once per action, adding at most 250 ms, so an endless script is reported busy about 500 ms after the action) }; absent when the page looked settled (exit code stays 0). A result a timer renders later, with no DOM change, request or loading indicator before it, is not detected. Cost: nothing extra, except 250 ms plus one read when the DOM looked busy. Not checked with --no-wait';
16
16
  /** What hover and pressKey report about elements they showed */
17
17
  const SHOWN_BEHAVIOR = 'Elements the action showed are listed (Shown: <element> "<text>"; JSON shown [{ text, element }], at most 3, outermost first): elements with visible text added inside the target\'s form, search box, dialog or combobox (else its grandparent, or its parent when that is the body), and popups and messages added anywhere (tooltip, menu, listbox, dialog, alert, status roles, popover, aria-live, message-like classes); widgets elsewhere on the page and re-rendered elements whose text was there before do not count';
18
18
  /** What `--no-wait` does to a DOM action's triggered requests */
@@ -101,9 +101,9 @@ const OPTION_BEHAVIORS = {
101
101
  automaticBehavior: 'The value is matched as: a 0-based index (bdg dom frames order: document order of the <iframe> elements, nested ones depth-first, main page not counted), else an exact name/id attribute, else a case-insensitive part of the name, id or URL. Several matches fail with 81 listing them; none fails with 83 listing all frames. Frames are looked up on every call (a reloaded iframe is found again). An index that names another frame than in the last bdg dom frames listing (iframes added, removed or moved, or the page navigated) fails with 87 STALE_CACHE: re-run bdg dom frames or pick the frame by name.',
102
102
  },
103
103
  'eval:--full': {
104
- default: 'Human output prints the first 20000 characters of the value followed by "… N more chars (use --full)"; in JSON a string result is cut to 20000 characters with truncatedFrom (the original length). Objects and arrays in JSON are not cut',
105
- whenEnabled: 'Prints the whole value, byte for byte',
106
- tokenImpact: 'dom eval document.documentElement.outerHTML on Wikipedia is about 3.6 MB with --full; select what you need in the expression instead',
104
+ default: 'Human output prints the first 20000 characters of the value followed by "… N more chars (use --full)". In JSON a string result is cut to 20000 characters with truncatedFrom (the original length); an array result lists its first 100 elements with count (all of them) and omitted; an object or array whose JSON is still over 20000 characters becomes the first 20000 characters of its JSON text (a string; arrays keep count) with truncatedFrom (the length of the JSON text of the copy, which holds at most 1000 entries per list or object): a string result with truncatedFrom while type is object is such a JSON-text start',
105
+ whenEnabled: 'Prints the whole value, byte for byte; objects and arrays are copied with every entry (without --full at most 1000 per list or object)',
106
+ tokenImpact: 'dom eval document.documentElement.outerHTML on Wikipedia is about 3.6 MB with --full; on a page with 20000 elements, [...document.querySelectorAll("*")].map(e => e.outerHTML) --json is 3 MB with --full and 23 KB without; select what you need in the expression instead',
107
107
  },
108
108
  'console:--full': {
109
109
  default: 'Message texts are cut: human output (summary, --list, --follow) at 200 characters followed by "… N more chars (use --full)"; JSON text at 10000 characters with truncatedFrom (the original length)',
@@ -330,6 +330,11 @@ const OPTION_BEHAVIORS = {
330
330
  whenEnabled: 'Streams requests as they finish',
331
331
  automaticBehavior: FOLLOW_BEHAVIOR,
332
332
  },
333
+ 'har:--include-sensitive': {
334
+ default: 'The HAR is sanitized: values become "[redacted]" for Authorization, Proxy-Authorization, Authentication, Cookie and Set-Cookie headers, X-*key/token/secret/auth headers and headers with an api-key/apikey/token/secret/jwt/subscription-key/session(-id) segment (www-authenticate is kept); every cookie value; query and fragment parameters named like credentials (plus code, sig, key) in the request URL, queryString, redirectURL and Location/Referer headers ("%5Bredacted%5D" in URLs); and password/token/secret/key/session/signature/bearer/cookie/csrf/refresh/auth/sid/pin/ssn/card-number fields of JSON (primitives at any depth under such a name), form-urlencoded (also sniffed when the Content-Type says otherwise, user[password] names too) and multipart request and response bodies (an access_token/refresh_token/id_token login response too) and of WebSocket messages, also in truncated JSON, JSON encoded in string values (3 levels), socket.io and SockJS packets, server-sent events, NDJSON, base64 bodies with no, a generic, JSON, form or event-stream type and binary WebSocket messages that are UTF-8 text; any whole JWT (eyJ…) in a JSON string, form or URL value, multipart part or text body (only the JWT is replaced). camelCase names count as words (userPin). JSON is edited in place, not re-serialized: everything but the replaced values (64-bit numbers, formatting, duplicate keys, a BOM or )]}\' prefix) stays byte for byte. Header, cookie and parameter names, cookie attributes, headersSize, bodySize and content.size stay; log.comment and JSON sanitized: true say so',
335
+ whenEnabled: 'Writes every captured value (JSON sanitized: false); human output warns that the file holds credentials',
336
+ automaticBehavior: 'Matching is by name, so it over-redacts: harmless values under credential-looking names (tokenCount: 5, sessionLength, refreshInterval, cookieConsent) are replaced too, and credentials under other names are kept. Only JSON syntax is understood: single-quoted strings, unquoted keys, JSONP, STOMP passcode: header lines and bare values with spaces ({"token":abc def}, a non-JWT token after Bearer in text) are not (fully) redacted. A body the sanitizer fails on is replaced whole by [redacted]. Unlike Chrome DevTools, which drops these headers and empties cookies, names are kept so the HAR still shows a request was authenticated. Kept as captured: other binary (base64) bodies, binary WebSocket messages that are not UTF-8, and text that is not JSON or a form (apart from JWTs); a WebSocket message cut at 100 KB has _truncatedFrom. HAR files are written readable by their owner only (0600). network headers and network getCookies always show real values',
337
+ },
333
338
  'peek:--verbose': {
334
339
  default: 'Compact output (truncated URLs, no resource types)',
335
340
  whenEnabled: 'Verbose output with full URLs and resource types',
@@ -360,8 +365,8 @@ const OPTION_BEHAVIORS = {
360
365
  whenEnabled: 'Alias for --force, kept for compatibility',
361
366
  },
362
367
  'cleanup:--purge': {
363
- default: "A named session's directory (Chrome profile, ~60 MB; logs; port.txt) is kept for its next start",
364
- whenEnabled: 'After cleaning up, deletes the directory of the session named by --session (exit 81 without --session); a running session is refused unless --force is given, and the directory is kept (exit 90) if the daemon still answers, cleanup reported a problem, or its Chrome has not exited',
368
+ default: "A named session's directory (Chrome profile, ~60 MB; logs; port.txt; downloads/) is kept for its next start",
369
+ whenEnabled: 'After cleaning up, deletes the directory of the session named by --session, downloaded files included (exit 81 without --session); a running session is refused unless --force is given, and the directory is kept (exit 90) if the daemon still answers, cleanup reported a problem, or its Chrome has not exited',
365
370
  },
366
371
  'bdg:--viewport': {
367
372
  default: 'A launched Chrome opens a 1920x1080 window (the viewport is smaller by the scrollbar, and in a visible window by the browser UI); an attached Chrome keeps its window',
@@ -381,12 +386,22 @@ const OPTION_BEHAVIORS = {
381
386
  'bdg:--chrome-ws-url': {
382
387
  default: 'bdg launches its own Chrome (closed on stop)',
383
388
  whenEnabled: 'Attaches to a running Chrome instead; it keeps running after stop. --port, -u and --[no-]headless cannot be combined with it (exit 81)',
384
- automaticBehavior: 'A port (9222), host:port or http://host:port is turned into the browser WebSocket URL via /json/version; a browser URL uses the first open tab. Refused with exit 90 when another running bdg session launched that Chrome or drives that tab (sessions of this BDG_SESSION_DIR, and of others that claimed a port)',
389
+ automaticBehavior: 'A port (9222), host:port or http://host:port is turned into the browser WebSocket URL via /json/version; a browser URL uses the first open tab. Refused with exit 90 when another running bdg session launched that Chrome or drives that tab (sessions of this BDG_SESSION_DIR, and of others that claimed a port). Downloads are not redirected: they go to the download folder of that Chrome (usually ~/Downloads), and only downloads of the session page are reported (not those of tabs or popups it opens)',
385
390
  },
386
391
  'stop:--kill-chrome': {
387
392
  default: 'Chrome launched by bdg is always closed on stop; an attached Chrome (--chrome-ws-url) is left running',
388
393
  whenEnabled: 'No additional effect; kept for compatibility',
389
394
  },
395
+ 'cdp:--send-anyway': {
396
+ default: 'A Domain.method 1 edit from a bundled method or domain (2 for names over 5 letters, case ignored) is taken for a typo: exit 81 with Did you mean',
397
+ whenEnabled: 'Sends such a name to Chrome as typed (for a method newer than the bundled protocol, e.g. next to a bundled sibling like getWindowBounds/setWindowBounds), with the not-in-bundled-protocol warning',
398
+ automaticBehavior: 'Blocked methods (Page.captureScreenshot, Page.close, Browser.close), type names and names that are not Domain.method are still refused; a well-formed name far from every bundled one is sent without the flag',
399
+ },
400
+ 'cdp:--describe': {
401
+ default: 'Without --describe, a Domain.method is called (one missing from the bundled protocol is sent as typed, with a warning: only bundled methods are matched case-insensitively)',
402
+ whenEnabled: 'Describes a domain, a method (parameters with ? for optional, returns, example) or a protocol type (Domain.Type: enum values or object properties)',
403
+ automaticBehavior: 'Parameters referring to an enum type list its values inline (JSON enum, ref, refType); a redirected method (DOM.highlightNode) also shows the method implementing it and its parameters, which Chrome checks (JSON redirect, resolved: true); a redirect to a method the protocol lacks (Page.deleteCookie → Network.deleteCookie) is shown as unresolved (resolved: false). Example values work as typed: width 1280, height 800, x/y 100, deviceScaleFactor/scale 1, timeout 5000, url https://example.com, other numbers 1 (never 0, which often means off)',
404
+ },
390
405
  'status:--verbose': {
391
406
  default: 'Basic session status (daemon running, session active, URL)',
392
407
  whenEnabled: 'Includes Chrome diagnostics and CDP connection details',
@@ -44,6 +44,11 @@ export interface CommandResult<T = unknown> {
44
44
  errorContext?: Record<string, unknown>;
45
45
  /** Optional hint message to display on stderr (for successful commands with guidance) */
46
46
  hint?: string;
47
+ /**
48
+ * Warning about how the command ran (success or failure): top-level
49
+ * `warning` in the JSON envelope, `Warning: …` on stderr otherwise
50
+ */
51
+ warning?: string;
47
52
  }
48
53
  /**
49
54
  * Handler function type.
@@ -80,6 +80,15 @@ function sessionEndedError() {
80
80
  const err = sessionEndedDuringCommandError();
81
81
  return new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.RESOURCE_NOT_FOUND);
82
82
  }
83
+ /**
84
+ * Print a command's warning on stderr (text output; `--json` has it in the envelope).
85
+ *
86
+ * @param warning - Warning, if any
87
+ */
88
+ function printWarning(warning) {
89
+ if (warning)
90
+ console.error(escapeControlChars(`Warning: ${warning}`));
91
+ }
83
92
  /**
84
93
  * Run a command with consistent error handling, output formatting, and exit codes.
85
94
  * Eliminates boilerplate try-catch and JSON output logic from command handlers.
@@ -114,6 +123,7 @@ export async function runCommand(handler, options, formatter) {
114
123
  if (options.json) {
115
124
  console.log(stringifyEnvelope(OutputBuilder.buildJsonError(result.error ?? 'Unknown error', {
116
125
  ...result.errorContext,
126
+ ...(result.warning && { warning: result.warning }),
117
127
  exitCode,
118
128
  })));
119
129
  }
@@ -126,14 +136,17 @@ export async function runCommand(handler, options, formatter) {
126
136
  }
127
137
  }
128
138
  }
139
+ printWarning(result.warning);
129
140
  }
130
141
  process.exit(exitCode);
131
142
  }
143
+ if (!options.json)
144
+ printWarning(result.warning);
132
145
  if (result.hint && !options.quiet) {
133
146
  console.error(escapeControlChars(result.hint));
134
147
  }
135
148
  if (options.json) {
136
- console.log(stringifyEnvelope(buildSuccessResponse(result.data)));
149
+ console.log(stringifyEnvelope(buildSuccessResponse(result.data, result.warning)));
137
150
  }
138
151
  else if (formatter) {
139
152
  const formattedOutput = formatter(result.data);
@@ -158,10 +171,12 @@ export async function runCommand(handler, options, formatter) {
158
171
  })));
159
172
  }
160
173
  else {
174
+ const { warning, ...metadata } = error.metadata;
161
175
  console.error(genericError(error.message));
162
- for (const value of Object.values(error.metadata)) {
163
- console.error(escapeControlChars(String(value)));
176
+ for (const value of Object.values(metadata)) {
177
+ console.error(escapeControlChars(typeof value === 'string' ? value : JSON.stringify(value)));
164
178
  }
179
+ printWarning(warning);
165
180
  }
166
181
  process.exit(error.exitCode);
167
182
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Ctrl-C and SIGTERM for a command that cancels its daemon request instead
3
+ * of dying mid-request, so it can still report (the `--json` envelope) and
4
+ * exit with the shell's code.
5
+ */
6
+ /** Signals that interrupt a command */
7
+ export type InterruptSignal = 'SIGINT' | 'SIGTERM';
8
+ /**
9
+ * The exit code of an interrupted command, as shells expect.
10
+ *
11
+ * @param signal - The signal
12
+ * @returns 130 for SIGINT, 143 for SIGTERM
13
+ */
14
+ export declare function interruptExitCode(signal: InterruptSignal): number;
15
+ /**
16
+ * The signal that aborted an interrupt, from its reason.
17
+ *
18
+ * @param interrupt - Aborted interrupt (reason: the signal name)
19
+ * @returns The signal, SIGINT unless it was SIGTERM
20
+ */
21
+ export declare function interruptSignal(interrupt: AbortSignal): InterruptSignal;
22
+ /**
23
+ * Abort on Ctrl-C or SIGTERM (reason: the signal name). A second signal
24
+ * exits at once.
25
+ *
26
+ * @returns Aborted on the first signal
27
+ */
28
+ export declare function abortOnInterrupt(): AbortSignal;
29
+ /**
30
+ * Wait for work unless interrupted first: the first signal ends the wait at
31
+ * once with the interrupted error (the work is left to the exiting process).
32
+ *
33
+ * @param work - Work to wait for
34
+ * @param interrupt - Aborted on Ctrl-C or SIGTERM (reason: the signal)
35
+ * @param interruptedError - The error for an interrupt by a signal
36
+ * @returns The work's value
37
+ * @throws The interrupted error once interrupted, else the work's error
38
+ */
39
+ export declare function unlessInterrupted<T>(work: Promise<T>, interrupt: AbortSignal, interruptedError: (signal: InterruptSignal) => Error): Promise<T>;
40
+ //# sourceMappingURL=interrupt.d.ts.map
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Ctrl-C and SIGTERM for a command that cancels its daemon request instead
3
+ * of dying mid-request, so it can still report (the `--json` envelope) and
4
+ * exit with the shell's code.
5
+ */
6
+ import { EXIT_CODES } from '../../utils/exitCodes.js';
7
+ /**
8
+ * The exit code of an interrupted command, as shells expect.
9
+ *
10
+ * @param signal - The signal
11
+ * @returns 130 for SIGINT, 143 for SIGTERM
12
+ */
13
+ export function interruptExitCode(signal) {
14
+ return signal === 'SIGINT' ? EXIT_CODES.INTERRUPTED : EXIT_CODES.TERMINATED;
15
+ }
16
+ /**
17
+ * The signal that aborted an interrupt, from its reason.
18
+ *
19
+ * @param interrupt - Aborted interrupt (reason: the signal name)
20
+ * @returns The signal, SIGINT unless it was SIGTERM
21
+ */
22
+ export function interruptSignal(interrupt) {
23
+ return interrupt.reason === 'SIGTERM' ? 'SIGTERM' : 'SIGINT';
24
+ }
25
+ /**
26
+ * Abort on Ctrl-C or SIGTERM (reason: the signal name). A second signal
27
+ * exits at once.
28
+ *
29
+ * @returns Aborted on the first signal
30
+ */
31
+ export function abortOnInterrupt() {
32
+ const interrupt = new AbortController();
33
+ for (const signal of ['SIGINT', 'SIGTERM']) {
34
+ process.on(signal, () => {
35
+ if (interrupt.signal.aborted)
36
+ process.exit(interruptExitCode(signal));
37
+ interrupt.abort(signal);
38
+ });
39
+ }
40
+ return interrupt.signal;
41
+ }
42
+ /**
43
+ * Wait for work unless interrupted first: the first signal ends the wait at
44
+ * once with the interrupted error (the work is left to the exiting process).
45
+ *
46
+ * @param work - Work to wait for
47
+ * @param interrupt - Aborted on Ctrl-C or SIGTERM (reason: the signal)
48
+ * @param interruptedError - The error for an interrupt by a signal
49
+ * @returns The work's value
50
+ * @throws The interrupted error once interrupted, else the work's error
51
+ */
52
+ export async function unlessInterrupted(work, interrupt, interruptedError) {
53
+ const fail = () => interruptedError(interruptSignal(interrupt));
54
+ if (interrupt.aborted)
55
+ throw fail();
56
+ let onAbort = () => undefined;
57
+ const interrupted = new Promise((_, reject) => {
58
+ onAbort = () => reject(fail());
59
+ interrupt.addEventListener('abort', onAbort, { once: true });
60
+ });
61
+ try {
62
+ return await Promise.race([work, interrupted]);
63
+ }
64
+ catch (error) {
65
+ if (interrupt.aborted)
66
+ throw fail();
67
+ throw error;
68
+ }
69
+ finally {
70
+ interrupt.removeEventListener('abort', onAbort);
71
+ }
72
+ }
73
+ //# sourceMappingURL=interrupt.js.map
@@ -129,6 +129,8 @@ export interface CdpMethodOptions {
129
129
  describe?: boolean;
130
130
  /** Search for methods */
131
131
  search?: string;
132
+ /** Send a method that looks like a typo of a bundled one as typed */
133
+ sendAnyway?: boolean;
132
134
  }
133
135
  /** Options for stop command */
134
136
  export type StopCommandOptions = BaseOptions & ChromeOptions;
@@ -300,6 +302,7 @@ export type NetworkCookiesCommandOptions = BaseOptions & {
300
302
  /** Options for network HAR command */
301
303
  export type NetworkHarCommandOptions = BaseOptions & {
302
304
  outputFile?: string;
305
+ includeSensitive?: boolean;
303
306
  };
304
307
  /** Options for network headers command */
305
308
  export type NetworkHeadersCommandOptions = BaseOptions & {
@@ -26,8 +26,9 @@ export declare function assertFilePath(filePath: string, extension?: string): vo
26
26
  * @param filePath - Path the user gave
27
27
  * @param data - File contents
28
28
  * @param extension - Extension of the file kind, for examples in errors
29
+ * @param mode - File permissions of a text file (default: 0666 less the umask)
29
30
  * @returns Absolute path written
30
31
  * @throws CommandError naming the path when it cannot be written
31
32
  */
32
- export declare function writeOutputFile(filePath: string, data: string | Buffer, extension?: string): Promise<string>;
33
+ export declare function writeOutputFile(filePath: string, data: string | Buffer, extension?: string, mode?: number): Promise<string>;
33
34
  //# sourceMappingURL=outputFile.d.ts.map
@@ -62,18 +62,21 @@ export function assertFilePath(filePath, extension) {
62
62
  * @param filePath - Path the user gave
63
63
  * @param data - File contents
64
64
  * @param extension - Extension of the file kind, for examples in errors
65
+ * @param mode - File permissions of a text file (default: 0666 less the umask)
65
66
  * @returns Absolute path written
66
67
  * @throws CommandError naming the path when it cannot be written
67
68
  */
68
- export async function writeOutputFile(filePath, data, extension) {
69
+ export async function writeOutputFile(filePath, data, extension, mode) {
69
70
  assertFilePath(filePath, extension);
70
71
  const absolutePath = path.resolve(filePath);
71
72
  try {
72
73
  makeDirectory(path.dirname(absolutePath));
73
- if (typeof data === 'string')
74
- await AtomicFileWriter.writeAsync(absolutePath, data);
75
- else
74
+ if (typeof data === 'string') {
75
+ await AtomicFileWriter.writeAsync(absolutePath, data, mode === undefined ? {} : { mode });
76
+ }
77
+ else {
76
78
  await AtomicFileWriter.writeBufferAsync(absolutePath, data);
79
+ }
77
80
  }
78
81
  catch (error) {
79
82
  throw outputPathError(filePath, error, extension);