toolcraft-openapi 0.0.23 → 0.0.25

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 (63) hide show
  1. package/README.md +2 -3
  2. package/dist/auth/bearer-token-auth.js +12 -3
  3. package/dist/auth/types.d.ts +1 -1
  4. package/dist/bin/generate.d.ts +5 -4
  5. package/dist/bin/generate.js +57 -23
  6. package/dist/generate.js +6 -2
  7. package/dist/http.js +29 -17
  8. package/dist/interpreter.js +12 -3
  9. package/dist/mock/fetch.js +22 -5
  10. package/dist/network-error.js +5 -3
  11. package/dist/redaction.d.ts +3 -0
  12. package/dist/redaction.js +38 -0
  13. package/node_modules/@poe-code/design-system/dist/acp/components.js +3 -1
  14. package/node_modules/@poe-code/design-system/dist/components/browser.d.ts +1 -1
  15. package/node_modules/@poe-code/design-system/dist/components/browser.js +6 -1
  16. package/node_modules/@poe-code/design-system/dist/components/color.js +9 -8
  17. package/node_modules/@poe-code/design-system/dist/components/command-errors.js +3 -2
  18. package/node_modules/@poe-code/design-system/dist/components/detail-card.d.ts +22 -0
  19. package/node_modules/@poe-code/design-system/dist/components/detail-card.js +69 -0
  20. package/node_modules/@poe-code/design-system/dist/components/help-formatter.js +88 -11
  21. package/node_modules/@poe-code/design-system/dist/components/index.d.ts +1 -1
  22. package/node_modules/@poe-code/design-system/dist/components/index.js +1 -1
  23. package/node_modules/@poe-code/design-system/dist/components/table.d.ts +2 -0
  24. package/node_modules/@poe-code/design-system/dist/components/table.js +82 -5
  25. package/node_modules/@poe-code/design-system/dist/components/template.d.ts +4 -0
  26. package/node_modules/@poe-code/design-system/dist/components/template.js +198 -32
  27. package/node_modules/@poe-code/design-system/dist/components/text.js +29 -5
  28. package/node_modules/@poe-code/design-system/dist/dashboard/ansi.d.ts +2 -2
  29. package/node_modules/@poe-code/design-system/dist/dashboard/ansi.js +77 -32
  30. package/node_modules/@poe-code/design-system/dist/dashboard/buffer.js +28 -5
  31. package/node_modules/@poe-code/design-system/dist/dashboard/components/output-pane.js +45 -28
  32. package/node_modules/@poe-code/design-system/dist/dashboard/terminal-width.d.ts +4 -0
  33. package/node_modules/@poe-code/design-system/dist/dashboard/terminal-width.js +71 -0
  34. package/node_modules/@poe-code/design-system/dist/dashboard/types.d.ts +1 -0
  35. package/node_modules/@poe-code/design-system/dist/explorer/events.d.ts +6 -0
  36. package/node_modules/@poe-code/design-system/dist/explorer/reducer.js +32 -10
  37. package/node_modules/@poe-code/design-system/dist/explorer/render/detail.js +3 -0
  38. package/node_modules/@poe-code/design-system/dist/explorer/runtime.js +57 -6
  39. package/node_modules/@poe-code/design-system/dist/explorer/state.d.ts +1 -0
  40. package/node_modules/@poe-code/design-system/dist/explorer/state.js +12 -15
  41. package/node_modules/@poe-code/design-system/dist/index.d.ts +3 -1
  42. package/node_modules/@poe-code/design-system/dist/index.js +2 -1
  43. package/node_modules/@poe-code/design-system/dist/prompts/primitives/intro.js +2 -1
  44. package/node_modules/@poe-code/design-system/dist/prompts/primitives/log.js +8 -5
  45. package/node_modules/@poe-code/design-system/dist/prompts/primitives/note.js +1 -1
  46. package/node_modules/@poe-code/design-system/dist/static/menu.js +8 -2
  47. package/node_modules/@poe-code/design-system/dist/static/spinner.js +10 -4
  48. package/node_modules/@poe-code/design-system/dist/terminal-markdown/parser/frontmatter.js +9 -2
  49. package/node_modules/@poe-code/design-system/dist/terminal-markdown/renderer.js +19 -2
  50. package/node_modules/@poe-code/design-system/package.json +2 -1
  51. package/node_modules/auth-store/dist/create-secret-store.js +4 -1
  52. package/node_modules/auth-store/dist/encrypted-file-store.d.ts +8 -0
  53. package/node_modules/auth-store/dist/encrypted-file-store.js +104 -8
  54. package/node_modules/auth-store/dist/index.d.ts +1 -1
  55. package/node_modules/auth-store/dist/keychain-store.d.ts +4 -1
  56. package/node_modules/auth-store/dist/keychain-store.js +18 -16
  57. package/node_modules/auth-store/dist/provider-store.d.ts +5 -1
  58. package/node_modules/auth-store/dist/provider-store.js +55 -7
  59. package/node_modules/auth-store/dist/types.d.ts +3 -1
  60. package/node_modules/auth-store/package.json +2 -1
  61. package/package.json +3 -3
  62. package/dist/lock.d.ts +0 -14
  63. package/dist/lock.js +0 -152
package/README.md CHANGED
@@ -19,12 +19,11 @@ const auth = bearerTokenAuth({
19
19
  ## Generator CLI
20
20
 
21
21
  `toolcraft-openapi-generate` reads an OpenAPI document from disk or a URL, writes generated
22
- command files, and stores the current spec hash in `openapi.lock`.
22
+ command files.
23
23
 
24
24
  - `--input <path-or-url>` — OpenAPI document to read. Defaults to `openapi.json`.
25
25
  - `--output <dir>` — directory for generated files. Defaults to `src/generated`.
26
- - `--lock <path>` lock file path. Defaults to `openapi.lock`.
27
- - `--check` — exits non-zero when generated files or `openapi.lock` would change.
26
+ - `--check`exits non-zero when generated files would change.
28
27
 
29
28
  ### CI drift check
30
29
 
@@ -24,22 +24,26 @@ export function bearerTokenAuth(options) {
24
24
  account: KEYCHAIN_ACCOUNT,
25
25
  },
26
26
  });
27
+ let lastResolvedToken = null;
27
28
  async function resolveToken() {
28
29
  const envToken = normalizeToken(process.env[options.envVar]);
29
30
  if (envToken) {
30
- return {
31
+ lastResolvedToken = {
31
32
  token: envToken,
32
33
  tokenSource: `env (${options.envVar})`,
33
34
  };
35
+ return lastResolvedToken;
34
36
  }
35
37
  const storedToken = normalizeToken(await store.get());
36
38
  if (!storedToken) {
39
+ lastResolvedToken = null;
37
40
  return null;
38
41
  }
39
- return {
42
+ lastResolvedToken = {
40
43
  token: storedToken,
41
44
  tokenSource: backend,
42
45
  };
46
+ return lastResolvedToken;
43
47
  }
44
48
  const loginCommand = defineCommand({
45
49
  name: "login",
@@ -122,7 +126,12 @@ export function bearerTokenAuth(options) {
122
126
  }
123
127
  throw new UserError(`Run '${commandPrefix} login' first.`);
124
128
  },
125
- async invalidate() {
129
+ async invalidate(token) {
130
+ if (token !== undefined &&
131
+ lastResolvedToken?.token === token &&
132
+ lastResolvedToken.tokenSource.startsWith("env (")) {
133
+ return;
134
+ }
126
135
  await store.delete();
127
136
  },
128
137
  commands: [defineGroup({
@@ -1,7 +1,7 @@
1
1
  import type { CommandNode } from "toolcraft";
2
2
  export interface TokenSource {
3
3
  getToken(): Promise<string>;
4
- invalidate?(): Promise<void>;
4
+ invalidate?(token?: string): Promise<void>;
5
5
  }
6
6
  export interface CommandContributor {
7
7
  commands: CommandNode<any>[];
@@ -1,5 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  interface GenerateCliFileSystem {
3
+ lstat(targetPath: string): Promise<{
4
+ isDirectory(): boolean;
5
+ isSymbolicLink(): boolean;
6
+ }>;
3
7
  mkdir(directoryPath: string, options?: {
4
8
  recursive?: boolean;
5
9
  }): Promise<unknown>;
@@ -8,9 +12,7 @@ interface GenerateCliFileSystem {
8
12
  rm(targetPath: string, options?: {
9
13
  force?: boolean;
10
14
  }): Promise<void>;
11
- stat(targetPath: string): Promise<{
12
- isDirectory(): boolean;
13
- }>;
15
+ realpath(targetPath: string): Promise<string>;
14
16
  writeFile(filePath: string, contents: string, encoding: BufferEncoding): Promise<void>;
15
17
  }
16
18
  interface GenerateCliWriter {
@@ -26,7 +28,6 @@ interface GenerateCliServices {
26
28
  interface GenerateCliOptions {
27
29
  check: boolean;
28
30
  input: string;
29
- lockPath: string;
30
31
  outputDir: string;
31
32
  }
32
33
  interface SyncGeneratedClientResult {
@@ -6,12 +6,10 @@ import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { UserError } from "toolcraft";
8
8
  import { generate } from "../generate.js";
9
- import { readOpenApiLock, writeOpenApiLock } from "../lock.js";
10
9
  import { parseOpenApiDocument, readOpenApiSourceText } from "../spec-source.js";
11
10
  const DEFAULT_OPTIONS = {
12
11
  check: false,
13
12
  input: "openapi.json",
14
- lockPath: "openapi.lock",
15
13
  outputDir: "src/generated"
16
14
  };
17
15
  const HELP_TEXT = `Usage: toolcraft-openapi-generate [options]
@@ -19,8 +17,7 @@ const HELP_TEXT = `Usage: toolcraft-openapi-generate [options]
19
17
  Options:
20
18
  --input <path-or-url> OpenAPI document to read (default: openapi.json)
21
19
  --output <dir> Directory for generated command files (default: src/generated)
22
- --lock <path> Lock file path (default: openapi.lock)
23
- --check Exit non-zero if generated output or lock file would change
20
+ --check Exit non-zero if generated output would change
24
21
  -h, --help Show this help text
25
22
  `;
26
23
  export async function runGenerateCli(argv = process.argv, services = {
@@ -73,8 +70,6 @@ export async function syncGeneratedClient(options, services) {
73
70
  const document = parseOpenApiDocument(sourceText, options.input);
74
71
  const generatedFiles = generate(document, { specSha });
75
72
  const outputDir = path.resolve(services.cwd, options.outputDir);
76
- const lockPath = path.resolve(services.cwd, options.lockPath);
77
- const currentLock = await readOpenApiLock(services.fs, lockPath);
78
73
  const currentFiles = await readGeneratedFiles(services.fs, outputDir);
79
74
  const desiredFiles = new Map([
80
75
  ...generatedFiles.map((file) => [path.resolve(outputDir, file.path), file.contents]),
@@ -82,11 +77,16 @@ export async function syncGeneratedClient(options, services) {
82
77
  ]);
83
78
  const updatedFiles = collectUpdatedFiles(currentFiles, desiredFiles);
84
79
  const deletedFiles = collectDeletedFiles(currentFiles, desiredFiles);
85
- const drifted = currentLock?.specSha !== specSha || updatedFiles.length > 0 || deletedFiles.length > 0;
80
+ const drifted = updatedFiles.length > 0 || deletedFiles.length > 0;
86
81
  if (!options.check && drifted) {
87
- await writeGeneratedFiles(services.fs, updatedFiles);
88
- await deleteGeneratedFiles(services.fs, deletedFiles);
89
- await writeOpenApiLock(services.fs, lockPath, { specSha });
82
+ try {
83
+ await writeGeneratedFiles(services.fs, outputDir, updatedFiles);
84
+ await deleteGeneratedFiles(services.fs, outputDir, deletedFiles);
85
+ }
86
+ catch (error) {
87
+ await restoreGeneratedFiles(services.fs, outputDir, currentFiles, updatedFiles, deletedFiles);
88
+ throw error;
89
+ }
90
90
  }
91
91
  return {
92
92
  drifted,
@@ -106,7 +106,7 @@ function parseGenerateCliArgs(argv) {
106
106
  options.check = true;
107
107
  continue;
108
108
  }
109
- if (argument === "--input" || argument === "--output" || argument === "--lock") {
109
+ if (argument === "--input" || argument === "--output") {
110
110
  const value = argv[index + 1];
111
111
  if (value === undefined) {
112
112
  throw new UserError(`Missing value for ${JSON.stringify(argument)}.`);
@@ -123,10 +123,6 @@ function parseGenerateCliArgs(argv) {
123
123
  assignOptionValue(options, "--output", argument.slice("--output=".length));
124
124
  continue;
125
125
  }
126
- if (argument.startsWith("--lock=")) {
127
- assignOptionValue(options, "--lock", argument.slice("--lock=".length));
128
- continue;
129
- }
130
126
  throw new UserError(`Unknown argument ${JSON.stringify(argument)}.`);
131
127
  }
132
128
  return options;
@@ -139,11 +135,7 @@ function assignOptionValue(options, argument, value) {
139
135
  options.input = value;
140
136
  return;
141
137
  }
142
- if (argument === "--output") {
143
- options.outputDir = value;
144
- return;
145
- }
146
- options.lockPath = value;
138
+ options.outputDir = value;
147
139
  }
148
140
  function createSpecSha(sourceText) {
149
141
  return `sha256:${createHash("sha256").update(sourceText).digest("hex")}`;
@@ -178,10 +170,17 @@ function tryParseUrl(input) {
178
170
  async function readGeneratedFiles(fs, directoryPath) {
179
171
  const files = new Map();
180
172
  try {
173
+ const directoryStats = await fs.lstat(directoryPath);
174
+ if (directoryStats.isSymbolicLink()) {
175
+ throw new Error("Generated output must remain inside the output directory.");
176
+ }
181
177
  const entries = await fs.readdir(directoryPath);
182
178
  for (const entry of entries) {
183
179
  const entryPath = path.resolve(directoryPath, entry);
184
- const stats = await fs.stat(entryPath);
180
+ const stats = await fs.lstat(entryPath);
181
+ if (stats.isSymbolicLink()) {
182
+ throw new Error("Generated output must remain inside the output directory.");
183
+ }
185
184
  if (stats.isDirectory()) {
186
185
  for (const [nestedPath, nestedContents] of await readGeneratedFiles(fs, entryPath)) {
187
186
  files.set(nestedPath, nestedContents);
@@ -218,17 +217,52 @@ function collectDeletedFiles(currentFiles, desiredFiles) {
218
217
  }
219
218
  return deletedFiles;
220
219
  }
221
- async function writeGeneratedFiles(fs, filesToWrite) {
220
+ async function writeGeneratedFiles(fs, outputDir, filesToWrite) {
222
221
  for (const file of filesToWrite) {
223
222
  await fs.mkdir(path.dirname(file.path), { recursive: true });
223
+ await assertSafeOutputPath(fs, outputDir, file.path);
224
224
  await fs.writeFile(file.path, file.contents, "utf8");
225
225
  }
226
226
  }
227
- async function deleteGeneratedFiles(fs, filePaths) {
227
+ async function assertSafeOutputPath(fs, outputDir, filePath) {
228
+ const canonicalOutputDir = await fs.realpath(outputDir);
229
+ const canonicalFileParent = await fs.realpath(path.dirname(filePath));
230
+ const relativeParentPath = path.relative(canonicalOutputDir, canonicalFileParent);
231
+ if (relativeParentPath === ".." ||
232
+ relativeParentPath.startsWith(`..${path.sep}`) ||
233
+ path.isAbsolute(relativeParentPath) ||
234
+ canonicalOutputDir !== path.resolve(outputDir)) {
235
+ throw new Error("Generated output must remain inside the output directory.");
236
+ }
237
+ }
238
+ async function deleteGeneratedFiles(fs, outputDir, filePaths) {
228
239
  for (const filePath of filePaths) {
240
+ await assertSafeOutputPath(fs, outputDir, filePath);
229
241
  await fs.rm(filePath, { force: true });
230
242
  }
231
243
  }
244
+ async function restoreGeneratedFiles(fs, outputDir, currentFiles, updatedFiles, deletedFiles) {
245
+ for (const file of updatedFiles) {
246
+ const previousContents = currentFiles.get(file.path);
247
+ if (previousContents === undefined) {
248
+ await assertSafeOutputPath(fs, outputDir, file.path);
249
+ await fs.rm(file.path, { force: true });
250
+ continue;
251
+ }
252
+ await fs.mkdir(path.dirname(file.path), { recursive: true });
253
+ await assertSafeOutputPath(fs, outputDir, file.path);
254
+ await fs.writeFile(file.path, previousContents, "utf8");
255
+ }
256
+ for (const filePath of deletedFiles) {
257
+ const previousContents = currentFiles.get(filePath);
258
+ if (previousContents === undefined) {
259
+ continue;
260
+ }
261
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
262
+ await assertSafeOutputPath(fs, outputDir, filePath);
263
+ await fs.writeFile(filePath, previousContents, "utf8");
264
+ }
265
+ }
232
266
  function isNotFoundError(error) {
233
267
  return (typeof error === "object" &&
234
268
  error !== null &&
package/dist/generate.js CHANGED
@@ -751,7 +751,7 @@ function isEnumPrimitiveValue(value) {
751
751
  return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
752
752
  }
753
753
  function isOpenApiScalarType(type) {
754
- return typeof type === "string" && type in SCHEMA_TYPE_TO_KIND;
754
+ return typeof type === "string" && Object.prototype.hasOwnProperty.call(SCHEMA_TYPE_TO_KIND, type);
755
755
  }
756
756
  function expectRequestBody(document, requestBody, operationId, context, refChain = []) {
757
757
  if (!isReferenceObject(requestBody)) {
@@ -931,7 +931,8 @@ function getOperationAuthMode(document, operation, operationId) {
931
931
  const definedSchemes = document.components?.securitySchemes;
932
932
  for (const requirement of security ?? []) {
933
933
  for (const schemeName of Object.keys(requirement)) {
934
- if (definedSchemes !== undefined && schemeName in definedSchemes) {
934
+ if (definedSchemes !== undefined &&
935
+ Object.prototype.hasOwnProperty.call(definedSchemes, schemeName)) {
935
936
  continue;
936
937
  }
937
938
  throw new UserError(`Operation ${JSON.stringify(operationId)} references undefined security scheme ${JSON.stringify(schemeName)} in ${securityScope} security. Expected components.securitySchemes to define it.`);
@@ -1001,6 +1002,9 @@ function renderConstArray(values) {
1001
1002
  return `${JSON.stringify(values)} as const`;
1002
1003
  }
1003
1004
  function renderObjectKey(name) {
1005
+ if (name === "__proto__") {
1006
+ return `[${JSON.stringify(name)}]`;
1007
+ }
1004
1008
  if (name === normalizeParamName(name) && isIdentifierName(name)) {
1005
1009
  return name;
1006
1010
  }
package/dist/http.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { text as designText } from "@poe-code/design-system";
2
2
  import { UserError } from "toolcraft";
3
3
  import { classifyNetworkError } from "./network-error.js";
4
+ import { redactHeaders, redactHeaderValue, redactSensitiveQueryValues } from "./redaction.js";
4
5
  const TRANSCRIPT_BODY_BYTE_LIMIT = 4 * 1024;
5
6
  export class HttpError extends Error {
6
7
  status;
@@ -29,7 +30,7 @@ export async function requestJson(options) {
29
30
  const headers = createHeaders(token, hasBody);
30
31
  const writeStdout = options.writeStdout ?? process.stdout.write.bind(process.stdout);
31
32
  const writeStderr = options.writeStderr ?? process.stderr.write.bind(process.stderr);
32
- const requestLine = `${method} ${url}`;
33
+ const requestLine = `${method} ${redactSensitiveQueryValues(url)}`;
33
34
  if (options.dryRun) {
34
35
  writeStdout(formatDryRunOutput(requestLine, headers, options.body));
35
36
  return undefined;
@@ -52,7 +53,7 @@ export async function requestJson(options) {
52
53
  const text = await response.text();
53
54
  const contentType = response.headers.get("content-type");
54
55
  const request = createHttpErrorRequest(method, url, headers, options.body);
55
- const responseHeaders = serializeHeaders(response.headers);
56
+ const responseHeaders = redactHeaders(serializeHeaders(response.headers));
56
57
  if (response.ok) {
57
58
  if (text.length === 0) {
58
59
  if (options.verbose) {
@@ -75,14 +76,32 @@ export async function requestJson(options) {
75
76
  message: `Expected a JSON response body but received content-type ${JSON.stringify(contentType ?? "<missing>")}.`
76
77
  });
77
78
  }
78
- const body = JSON.parse(text);
79
+ let body;
80
+ try {
81
+ body = JSON.parse(text);
82
+ }
83
+ catch {
84
+ if (options.verbose) {
85
+ writeStderr(formatTranscriptLines(formatVerboseResponseTranscript(response, responseHeaders, text)));
86
+ }
87
+ throw new HttpError({
88
+ request,
89
+ response: {
90
+ status: response.status,
91
+ statusText: response.statusText,
92
+ headers: responseHeaders,
93
+ body: text
94
+ },
95
+ message: "Expected a valid JSON response body but received malformed JSON."
96
+ });
97
+ }
79
98
  if (options.verbose) {
80
99
  writeStderr(formatTranscriptLines(formatVerboseResponseTranscript(response, responseHeaders, body)));
81
100
  }
82
101
  return body;
83
102
  }
84
- if (response.status === 401) {
85
- await options.tokenSource.invalidate?.();
103
+ if (response.status === 401 && options.auth === "required") {
104
+ await options.tokenSource.invalidate?.(token).catch(() => undefined);
86
105
  }
87
106
  const body = parseResponseBody(text, contentType);
88
107
  if (options.verbose) {
@@ -113,7 +132,9 @@ function buildRequestUrl(options) {
113
132
  }
114
133
  function substitutePathParams(path, pathParams) {
115
134
  const resolvedPath = path.replace(/\{([^}]+)\}/g, (_match, key) => {
116
- const value = pathParams?.[key];
135
+ const value = pathParams !== undefined && Object.prototype.hasOwnProperty.call(pathParams, key)
136
+ ? pathParams[key]
137
+ : undefined;
117
138
  if (value === undefined) {
118
139
  throw new UserError(`Missing path parameter "${key}".`);
119
140
  }
@@ -142,7 +163,7 @@ function createHeaders(token, hasBody) {
142
163
  function createHttpErrorRequest(method, url, headers, body) {
143
164
  return {
144
165
  method,
145
- url,
166
+ url: redactSensitiveQueryValues(url),
146
167
  headers: redactHeaders(headers),
147
168
  ...(body === undefined ? {} : { body })
148
169
  };
@@ -150,15 +171,6 @@ function createHttpErrorRequest(method, url, headers, body) {
150
171
  function serializeHeaders(headers) {
151
172
  return Object.fromEntries(headers.entries());
152
173
  }
153
- function redactHeaders(headers) {
154
- return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, redactHeaderValue(key, value)]));
155
- }
156
- function redactHeaderValue(key, value) {
157
- if (key.toLowerCase() === "authorization" && value.startsWith("Bearer ")) {
158
- return "Bearer ****";
159
- }
160
- return value;
161
- }
162
174
  function formatDryRunOutput(requestLine, headers, body) {
163
175
  const lines = [
164
176
  requestLine,
@@ -175,7 +187,7 @@ function formatDryRunOutput(requestLine, headers, body) {
175
187
  }
176
188
  function formatVerboseRequestTranscript(method, url, headers, body) {
177
189
  const lines = [
178
- `→ ${method} ${url}`,
190
+ `→ ${method} ${redactSensitiveQueryValues(url)}`,
179
191
  ...Object.entries(headers).map(([key, value]) => {
180
192
  const headerValue = redactHeaderValue(key, value);
181
193
  return ` ${key}: ${headerValue}`;
@@ -8,7 +8,7 @@ const QUERY_ARRAY_SERIALIZATION_SEPARATORS = {
8
8
  const VALUE_REFERENCE_OPERATIONS = {
9
9
  param: {
10
10
  render: (reference) => renderParamAccess(reference.paramName),
11
- evaluate: (reference, context) => context.params[reference.paramName]
11
+ evaluate: (reference, context) => readOwnParam(context.params, reference.paramName)
12
12
  },
13
13
  resolved: {
14
14
  render: (reference) => reference.resolvedName,
@@ -168,7 +168,7 @@ const REQUEST_SECTION_OPERATIONS = {
168
168
  if (!optional) {
169
169
  return [
170
170
  ` ${section.key}: {`,
171
- ...sectionFields.map((field) => ` ${JSON.stringify(field.wireName)}: ${renderValueExpression(field.value)},`),
171
+ ...sectionFields.map((field) => ` ${renderWireName(field.wireName)}: ${renderValueExpression(field.value)},`),
172
172
  " },"
173
173
  ];
174
174
  }
@@ -177,7 +177,7 @@ const REQUEST_SECTION_OPERATIONS = {
177
177
  " ? {}",
178
178
  " : {",
179
179
  ` ${section.key}: {`,
180
- ...sectionFields.map((field) => ` ${JSON.stringify(field.wireName)}: ${renderValueExpression(field.value)},`),
180
+ ...sectionFields.map((field) => ` ${renderWireName(field.wireName)}: ${renderValueExpression(field.value)},`),
181
181
  " },",
182
182
  " }),"
183
183
  ];
@@ -285,5 +285,14 @@ function renderOmitWhenUndefinedExpression(reference) {
285
285
  return `${renderValueReference(reference)} === undefined`;
286
286
  }
287
287
  function renderParamAccess(name) {
288
+ if (Object.prototype.hasOwnProperty.call(Object.prototype, name)) {
289
+ return `(Object.prototype.hasOwnProperty.call(params, ${JSON.stringify(name)}) ? params[${JSON.stringify(name)}] : undefined)`;
290
+ }
288
291
  return isIdentifierName(name) ? `params.${name}` : `params[${JSON.stringify(name)}]`;
289
292
  }
293
+ function renderWireName(name) {
294
+ return name === "__proto__" ? `[${JSON.stringify(name)}]` : JSON.stringify(name);
295
+ }
296
+ function readOwnParam(params, name) {
297
+ return Object.prototype.hasOwnProperty.call(params, name) ? params[name] : undefined;
298
+ }
@@ -302,17 +302,26 @@ function validateAgainstSchema(value, schema, document, pointer) {
302
302
  return errors;
303
303
  }
304
304
  if (types.includes("object") && typeof value === "object" && value !== null && !Array.isArray(value)) {
305
+ const objectValue = value;
305
306
  for (const required of resolved.required ?? []) {
306
- if (!(required in value)) {
307
+ if (!Object.prototype.hasOwnProperty.call(objectValue, required)) {
307
308
  errors.push(`${pointer}/${required}: required`);
308
309
  }
309
310
  }
310
311
  if (resolved.properties !== undefined) {
311
- for (const [key, propValue] of Object.entries(value)) {
312
+ for (const [key, propValue] of Object.entries(objectValue)) {
312
313
  const propSchema = resolved.properties[key];
313
314
  if (propSchema !== undefined) {
314
315
  errors.push(...validateAgainstSchema(propValue, propSchema, document, `${pointer}/${key}`));
315
316
  }
317
+ else if (resolved.additionalProperties === false) {
318
+ errors.push(`${pointer}/${key}: additional property not allowed`);
319
+ }
320
+ }
321
+ }
322
+ else if (resolved.additionalProperties === false && Object.keys(objectValue).length > 0) {
323
+ for (const key of Object.keys(objectValue)) {
324
+ errors.push(`${pointer}/${key}: additional property not allowed`);
316
325
  }
317
326
  }
318
327
  }
@@ -393,20 +402,28 @@ function appendHeaders(target, source) {
393
402
  }
394
403
  if (source instanceof Headers) {
395
404
  source.forEach((value, key) => {
396
- target[key.toLowerCase()] = value;
405
+ setHeader(target, key, value);
397
406
  });
398
407
  return;
399
408
  }
400
409
  if (Array.isArray(source)) {
401
410
  for (const [key, value] of source) {
402
- target[key.toLowerCase()] = value;
411
+ setHeader(target, key, value);
403
412
  }
404
413
  return;
405
414
  }
406
415
  for (const [key, value] of Object.entries(source)) {
407
- target[key.toLowerCase()] = String(value);
416
+ setHeader(target, key, String(value));
408
417
  }
409
418
  }
419
+ function setHeader(target, key, value) {
420
+ Object.defineProperty(target, key.toLowerCase(), {
421
+ enumerable: true,
422
+ configurable: true,
423
+ writable: true,
424
+ value
425
+ });
426
+ }
410
427
  async function readRequestBody(input, init) {
411
428
  if (init?.body !== undefined && init.body !== null) {
412
429
  return typeof init.body === "string" ? init.body : await new Response(init.body).text();
@@ -1,13 +1,15 @@
1
1
  import { UserError } from "toolcraft";
2
+ import { redactSensitiveQueryValues } from "./redaction.js";
2
3
  export function classifyNetworkError(error, url) {
3
4
  const networkError = findNetworkError(error);
4
5
  const urlParts = new URL(url);
5
6
  const host = getHost(networkError, urlParts);
7
+ const redactedUrl = redactSensitiveQueryValues(url);
6
8
  switch (networkError?.code) {
7
9
  case "ECONNREFUSED":
8
10
  return new UserError(`Connection refused: ${host}:${getPort(networkError, urlParts)}. Is the server running?`, { cause: error });
9
11
  case "ETIMEDOUT":
10
- return new UserError(`Request timed out after ${getTimeoutMs(networkError)}ms: ${url}.`, {
12
+ return new UserError(`Request timed out after ${getTimeoutMs(networkError)}ms: ${redactedUrl}.`, {
11
13
  cause: error
12
14
  });
13
15
  case "ENOTFOUND":
@@ -24,10 +26,10 @@ export function classifyNetworkError(error, url) {
24
26
  });
25
27
  }
26
28
  if (findAbortError(error) !== null) {
27
- return new UserError(`Request aborted: ${url}.`, { cause: error });
29
+ return new UserError(`Request aborted: ${redactedUrl}.`, { cause: error });
28
30
  }
29
31
  if (error instanceof TypeError && error.message === "fetch failed" && !hasCause(error)) {
30
- return new UserError(`Network request failed: ${url}.`, { cause: error });
32
+ return new UserError(`Network request failed: ${redactedUrl}.`, { cause: error });
31
33
  }
32
34
  return null;
33
35
  }
@@ -0,0 +1,3 @@
1
+ export declare function redactHeaders(headers: Record<string, string>): Record<string, string>;
2
+ export declare function redactHeaderValue(key: string, value: string): string;
3
+ export declare function redactSensitiveQueryValues(url: string): string;
@@ -0,0 +1,38 @@
1
+ const SENSITIVE_QUERY_KEYS = new Set([
2
+ "apikey",
3
+ "accesstoken",
4
+ "authtoken",
5
+ "clientsecret",
6
+ "key",
7
+ "password",
8
+ "secret",
9
+ "sig",
10
+ "signature",
11
+ "token"
12
+ ]);
13
+ const SENSITIVE_HEADER_NAMES = new Set(["cookie", "proxy-authorization", "set-cookie"]);
14
+ export function redactHeaders(headers) {
15
+ return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, redactHeaderValue(key, value)]));
16
+ }
17
+ export function redactHeaderValue(key, value) {
18
+ const normalizedKey = key.toLowerCase();
19
+ if (normalizedKey === "authorization") {
20
+ return value.startsWith("Bearer ") ? "Bearer ****" : "****";
21
+ }
22
+ if (SENSITIVE_HEADER_NAMES.has(normalizedKey)) {
23
+ return "****";
24
+ }
25
+ return value;
26
+ }
27
+ export function redactSensitiveQueryValues(url) {
28
+ const redactedUrl = new URL(url);
29
+ for (const key of redactedUrl.searchParams.keys()) {
30
+ if (SENSITIVE_QUERY_KEYS.has(normalizeQueryKey(key))) {
31
+ redactedUrl.searchParams.set(key, "****");
32
+ }
33
+ }
34
+ return redactedUrl.toString();
35
+ }
36
+ function normalizeQueryKey(key) {
37
+ return key.toLowerCase().replaceAll("_", "").replaceAll("-", "");
38
+ }
@@ -18,7 +18,9 @@ const KIND_COLORS = {
18
18
  other: (text) => color.dim(text)
19
19
  };
20
20
  function colorForKind(kind) {
21
- return KIND_COLORS[kind] ?? ((text) => color.dim(text));
21
+ return Object.prototype.hasOwnProperty.call(KIND_COLORS, kind)
22
+ ? KIND_COLORS[kind]
23
+ : (text) => color.dim(text);
22
24
  }
23
25
  function writeLine(line) {
24
26
  getAcpWriter()(line);
@@ -1,6 +1,6 @@
1
1
  interface BrowserProcess {
2
2
  once(event: "error", listener: (error: Error) => void): this;
3
- once(event: "spawn", listener: () => void): this;
3
+ once(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
4
4
  unref(): void;
5
5
  }
6
6
  type SpawnBrowserProcess = (command: string, args: string[], options: {
@@ -18,7 +18,12 @@ function launchBrowser(command, args, spawnProcess) {
18
18
  return new Promise((resolve, reject) => {
19
19
  const child = spawnProcess(command, args, { detached: true, stdio: "ignore" });
20
20
  child.once("error", reject);
21
- child.once("spawn", () => {
21
+ child.once("close", (code, signal) => {
22
+ if (code !== 0) {
23
+ const reason = code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`;
24
+ reject(new Error(`Browser launcher exited with ${reason}`));
25
+ return;
26
+ }
22
27
  child.unref();
23
28
  resolve();
24
29
  });
@@ -48,6 +48,10 @@ function hexChannel(value, offset) {
48
48
  }
49
49
  function normalizeHex(value) {
50
50
  const normalized = value.startsWith("#") ? value.slice(1) : value;
51
+ if ((normalized.length !== 3 && normalized.length !== 6) ||
52
+ Array.from(normalized).some((char) => !"0123456789abcdefABCDEF".includes(char))) {
53
+ throw new Error(`Invalid hexadecimal color: ${value}`);
54
+ }
51
55
  if (normalized.length === 3) {
52
56
  const red = normalized[0];
53
57
  const green = normalized[1];
@@ -58,14 +62,11 @@ function normalizeHex(value) {
58
62
  Number.parseInt(`${blue}${blue}`, 16)
59
63
  ];
60
64
  }
61
- if (normalized.length === 6) {
62
- return [
63
- hexChannel(normalized, 0),
64
- hexChannel(normalized, 2),
65
- hexChannel(normalized, 4)
66
- ];
67
- }
68
- return [0, 0, 0];
65
+ return [
66
+ hexChannel(normalized, 0),
67
+ hexChannel(normalized, 2),
68
+ hexChannel(normalized, 4)
69
+ ];
69
70
  }
70
71
  function rgbStyle(red, green, blue) {
71
72
  return {
@@ -1,8 +1,9 @@
1
1
  import { typography } from "../tokens/typography.js";
2
2
  import { text } from "./text.js";
3
3
  export function formatCommandNotFound(input) {
4
- const unknown = input.unknownCommand.length > 0
5
- ? input.unknownCommand
4
+ const unknownInput = input.unknownCommand.replaceAll("\r\n", " ").replaceAll("\n", " ").replaceAll("\r", " ");
5
+ const unknown = unknownInput.length > 0
6
+ ? unknownInput
6
7
  : "<command>";
7
8
  return {
8
9
  label: `${typography.bold("Unknown command:")} ${text.command(unknown)}`,