orizu 0.5.28 → 0.5.29

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 (73) hide show
  1. package/README.md +33 -0
  2. package/dist/cli-project-slug.d.ts +17 -0
  3. package/dist/cli-project-slug.d.ts.map +1 -0
  4. package/dist/cli-project-slug.js +24 -0
  5. package/dist/credentials.d.ts +4 -0
  6. package/dist/credentials.d.ts.map +1 -1
  7. package/dist/credentials.js +15 -4
  8. package/dist/feedback-cli.d.ts +14 -0
  9. package/dist/feedback-cli.d.ts.map +1 -0
  10. package/dist/feedback-cli.js +601 -0
  11. package/dist/feedback-contract.d.ts +77 -0
  12. package/dist/feedback-contract.d.ts.map +1 -0
  13. package/dist/feedback-contract.js +414 -0
  14. package/dist/feedback-scrub.d.ts +16 -0
  15. package/dist/feedback-scrub.d.ts.map +1 -0
  16. package/dist/feedback-scrub.js +324 -0
  17. package/dist/headless-login.d.ts +2 -0
  18. package/dist/headless-login.d.ts.map +1 -1
  19. package/dist/headless-login.js +14 -11
  20. package/dist/help.d.ts.map +1 -1
  21. package/dist/help.js +35 -1
  22. package/dist/hosted-boot.d.ts +5 -0
  23. package/dist/hosted-boot.d.ts.map +1 -1
  24. package/dist/hosted-boot.js +12 -3
  25. package/dist/http.d.ts +5 -3
  26. package/dist/http.d.ts.map +1 -1
  27. package/dist/http.js +41 -24
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +60 -24
  31. package/dist/instruction-set-sync/index.d.ts +1 -0
  32. package/dist/instruction-set-sync/index.d.ts.map +1 -1
  33. package/dist/instruction-set-sync/index.js +15 -6
  34. package/dist/last-error-caps.d.ts +6 -0
  35. package/dist/last-error-caps.d.ts.map +1 -0
  36. package/dist/last-error-caps.js +5 -0
  37. package/dist/last-error-record.d.ts +34 -0
  38. package/dist/last-error-record.d.ts.map +1 -0
  39. package/dist/last-error-record.js +289 -0
  40. package/dist/runner-env.d.ts +3 -0
  41. package/dist/runner-env.d.ts.map +1 -0
  42. package/dist/runner-env.js +29 -0
  43. package/dist/self-update.d.ts +42 -0
  44. package/dist/self-update.d.ts.map +1 -0
  45. package/dist/self-update.js +506 -0
  46. package/package.json +1 -1
  47. package/src/cli-project-slug.ts +38 -0
  48. package/src/credentials.ts +21 -4
  49. package/src/feedback-cli.ts +763 -0
  50. package/src/feedback-contract.ts +473 -0
  51. package/src/feedback-scrub.ts +466 -0
  52. package/src/headless-login.ts +25 -11
  53. package/src/help.ts +35 -1
  54. package/src/hosted-boot.ts +21 -3
  55. package/src/http.ts +46 -23
  56. package/src/index.ts +61 -23
  57. package/src/instruction-set-sync/index.ts +25 -6
  58. package/src/last-error-caps.js +4 -0
  59. package/src/last-error-caps.ts +5 -0
  60. package/src/last-error-record.ts +382 -0
  61. package/src/runner-env.ts +31 -0
  62. package/src/secret-redaction.js +4 -0
  63. package/src/self-update.ts +548 -0
  64. package/vendor/skills/orizu/SKILL.md +4 -0
  65. package/vendor/skills/orizu/flows/expand.md +2 -0
  66. package/vendor/skills/orizu/flows/first-win.md +2 -0
  67. package/vendor/skills/orizu/flows/onboard.md +2 -0
  68. package/vendor/skills/orizu/flows/promote.md +2 -0
  69. package/vendor/skills/orizu/flows/recurse.md +2 -0
  70. package/vendor/skills/orizu/flows/triage.md +2 -0
  71. package/vendor/skills/orizu/references/authority-map.md +1 -0
  72. package/vendor/skills/orizu/references/cli-reference.md +62 -0
  73. package/vendor/skills/orizu/references/feedback.md +101 -0
@@ -0,0 +1,601 @@
1
+ import { closeSync, constants, fstatSync, openSync, readFileSync, realpathSync, } from 'fs';
2
+ import { homedir, release } from 'os';
3
+ import { basename, extname, isAbsolute, relative, resolve, sep, } from 'path';
4
+ import { parseCliProjectSlug } from './cli-project-slug.js';
5
+ import { FEEDBACK_ATTACHMENT_EXTENSIONS, FEEDBACK_CAPS, FROM_FILE_KEYS, hasValidProductFeedbackAttachmentNameSyntax, hasValidProductFeedbackSlugSyntax, isLastErrorRecordShape, PRODUCT_FEEDBACK_ROUTE, PRODUCT_FEEDBACK_UNAUTHENTICATED_MESSAGE, validateProductFeedbackRequest, } from './feedback-contract.js';
6
+ import { getSecretsSeenThisProcess, getConfigDir, getServerCredentials, resolveEnvBearerToken, } from './credentials.js';
7
+ import { scrubFeedbackText, scrubFeedbackValue } from './feedback-scrub.js';
8
+ import { LAST_ERROR_ATTACH_MAX_AGE_MS, readLastErrorRecord, } from './last-error-record.js';
9
+ import { captureAuthenticatedRequestContext } from './http.js';
10
+ import { parseJsonResponse, sanitizeHumanInlineText, sanitizeTerminalText, } from './json-response.js';
11
+ import { existingWorkspaceTeamSlug, getWorkspaceRoot, workspaceExists, } from './workspace.js';
12
+ const VALUE_FLAGS = [
13
+ '--category',
14
+ '--severity',
15
+ '--summary',
16
+ '--tried',
17
+ '--expected',
18
+ '--actual',
19
+ '--impact',
20
+ '--repro',
21
+ '--attach',
22
+ '--from-file',
23
+ '--project',
24
+ ];
25
+ const REPEATABLE_FLAGS = new Set(['--attach']);
26
+ const FATAL_TEXT_DECODER = new TextDecoder('utf-8', { fatal: true });
27
+ const FROM_FILE_KEY_SET = new Set(FROM_FILE_KEYS);
28
+ const FROM_FILE_MAX_BYTES = 65_536;
29
+ function clientRefusal(code, reason) {
30
+ return `${code}: ${reason}`;
31
+ }
32
+ function getCliVersion() {
33
+ const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
34
+ if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) {
35
+ throw new Error('Unable to read orizu CLI version.');
36
+ }
37
+ return packageJson.version;
38
+ }
39
+ function safeCliVersion() {
40
+ try {
41
+ return getCliVersion();
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ function expandHomePath(path, homeDirectory) {
48
+ return path === '~'
49
+ ? homeDirectory
50
+ : path.startsWith('~/')
51
+ ? resolve(homeDirectory, path.slice(2))
52
+ : path;
53
+ }
54
+ function parseFeedbackArgs(args) {
55
+ const values = {};
56
+ const attachments = [];
57
+ const seen = new Set();
58
+ let fromFile = null;
59
+ let project = null;
60
+ let suppressLastError = false;
61
+ for (let index = 0; index < args.length; index += 1) {
62
+ const argument = args[index];
63
+ if (argument === '--json')
64
+ continue;
65
+ if (argument === '--no-last-error') {
66
+ if (seen.has(argument)) {
67
+ return clientRefusal('invalid_flag_repeated', '--no-last-error may be given only once');
68
+ }
69
+ seen.add(argument);
70
+ suppressLastError = true;
71
+ continue;
72
+ }
73
+ let flag = argument;
74
+ let value = null;
75
+ const equalsIndex = argument.indexOf('=');
76
+ if (equalsIndex > 0) {
77
+ flag = argument.slice(0, equalsIndex);
78
+ value = argument.slice(equalsIndex + 1);
79
+ }
80
+ if (!VALUE_FLAGS.includes(flag)) {
81
+ return clientRefusal('invalid_flag_value', `unknown argument ${argument}`);
82
+ }
83
+ if (!REPEATABLE_FLAGS.has(flag) && seen.has(flag)) {
84
+ return clientRefusal('invalid_flag_repeated', `${flag} may be given only once`);
85
+ }
86
+ if (value === null) {
87
+ const candidate = args[index + 1];
88
+ if (!candidate)
89
+ return clientRefusal('invalid_flag_value', `${flag} requires a value`);
90
+ if (candidate.startsWith('-')) {
91
+ return clientRefusal('invalid_flag_value', `${flag} needs a value (use ${flag}=<text> for values that start with a dash)`);
92
+ }
93
+ value = candidate;
94
+ index += 1;
95
+ }
96
+ if (value.length === 0)
97
+ return clientRefusal('invalid_flag_value', `${flag} requires a value`);
98
+ seen.add(flag);
99
+ if (flag === '--attach')
100
+ attachments.push(value);
101
+ else if (flag === '--from-file')
102
+ fromFile = value;
103
+ else if (flag === '--project')
104
+ project = value;
105
+ else
106
+ values[flag.slice(2)] = value;
107
+ }
108
+ if (attachments.length > FEEDBACK_CAPS.maxAttachments) {
109
+ return clientRefusal('invalid_attachment_count', `at most ${FEEDBACK_CAPS.maxAttachments} user attachments are allowed`);
110
+ }
111
+ return { values, attachments, fromFile, project, suppressLastError };
112
+ }
113
+ function readFromFile(path, homeDirectory) {
114
+ const expandedPath = expandHomePath(path, homeDirectory);
115
+ let descriptor;
116
+ try {
117
+ descriptor = openSync(expandedPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
118
+ }
119
+ catch (error) {
120
+ return error instanceof Error && 'code' in error && error.code === 'ELOOP'
121
+ ? clientRefusal('invalid_from_file', '--from-file must be a regular file, not a link')
122
+ : clientRefusal('invalid_from_file_unreadable', '--from-file could not be opened');
123
+ }
124
+ let contents;
125
+ try {
126
+ const entry = fstatSync(descriptor);
127
+ if (!entry.isFile()) {
128
+ return clientRefusal('invalid_from_file', '--from-file must be a regular file');
129
+ }
130
+ if (entry.size > FROM_FILE_MAX_BYTES) {
131
+ return clientRefusal('invalid_from_file', `--from-file exceeds ${FROM_FILE_MAX_BYTES} bytes`);
132
+ }
133
+ contents = readFileSync(descriptor, 'utf8');
134
+ }
135
+ catch {
136
+ return clientRefusal('invalid_from_file_unreadable', '--from-file could not be read');
137
+ }
138
+ finally {
139
+ closeSync(descriptor);
140
+ }
141
+ let parsed;
142
+ try {
143
+ parsed = JSON.parse(contents);
144
+ }
145
+ catch {
146
+ return clientRefusal('invalid_from_file_json', '--from-file contains invalid JSON');
147
+ }
148
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
149
+ return clientRefusal('invalid_from_file_json', '--from-file must contain one JSON object');
150
+ }
151
+ const entries = Object.entries(parsed);
152
+ const unknownEntry = entries.find(([key]) => !FROM_FILE_KEY_SET.has(key));
153
+ if (unknownEntry) {
154
+ return clientRefusal('invalid_from_file_json', `--from-file contains unknown field ${unknownEntry[0]}`);
155
+ }
156
+ const invalidEntry = entries.find(([, value]) => value !== null && typeof value !== 'string');
157
+ if (invalidEntry) {
158
+ return clientRefusal('invalid_from_file_json', `${invalidEntry[0]} must be a string or null`);
159
+ }
160
+ return Object.fromEntries(entries);
161
+ }
162
+ function canonicalConfigRoot(configDirectory) {
163
+ try {
164
+ return realpathSync(configDirectory);
165
+ }
166
+ catch {
167
+ return resolve(configDirectory);
168
+ }
169
+ }
170
+ function attachmentIsInsideConfig(path, canonicalConfigDirectory) {
171
+ const pathFromConfig = relative(canonicalConfigDirectory, path);
172
+ return pathFromConfig === ''
173
+ || (pathFromConfig !== '..'
174
+ && !pathFromConfig.startsWith(`..${sep}`)
175
+ && !isAbsolute(pathFromConfig));
176
+ }
177
+ function readAttachment(inputPath, attachmentIndex, options) {
178
+ const resolvedPath = resolve(expandHomePath(inputPath, options.homeDirectory));
179
+ const name = basename(resolvedPath);
180
+ if (name.toLowerCase() === 'last-error.json') {
181
+ return clientRefusal('invalid_attachment_name', 'last-error.json is reserved for the automatic last-error record');
182
+ }
183
+ if (!FEEDBACK_ATTACHMENT_EXTENSIONS.includes(extname(name).toLowerCase())) {
184
+ return clientRefusal('invalid_attachment_extension', `attachment ${name} is not a supported text file`);
185
+ }
186
+ let descriptor;
187
+ try {
188
+ descriptor = openSync(resolvedPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
189
+ }
190
+ catch (error) {
191
+ return error instanceof Error && 'code' in error && error.code === 'ELOOP'
192
+ ? clientRefusal('invalid_attachment_path', `${name} must be a regular file, not a link or directory`)
193
+ : clientRefusal('invalid_attachment_unreadable', `cannot open attachment ${name}`);
194
+ }
195
+ let bytes;
196
+ try {
197
+ const entry = fstatSync(descriptor);
198
+ if (entry.isFIFO()) {
199
+ return clientRefusal('invalid_attachment_unreadable', `${name} must not be a pipe`);
200
+ }
201
+ if (!entry.isFile()) {
202
+ return clientRefusal('invalid_attachment_path', `${name} must be a regular file, not a link or directory`);
203
+ }
204
+ if (entry.size > FEEDBACK_CAPS.attachmentBytes) {
205
+ return clientRefusal('too_large_attachment', `${name} exceeds ${FEEDBACK_CAPS.attachmentBytes} bytes`);
206
+ }
207
+ const canonicalPath = realpathSync(resolvedPath);
208
+ if (attachmentIsInsideConfig(canonicalPath, options.canonicalConfigDirectory)) {
209
+ return clientRefusal('invalid_attachment_path', `${name} is inside the Orizu config directory and cannot be attached`);
210
+ }
211
+ bytes = readFileSync(descriptor);
212
+ }
213
+ catch {
214
+ return clientRefusal('invalid_attachment_unreadable', `cannot read attachment ${name}`);
215
+ }
216
+ finally {
217
+ closeSync(descriptor);
218
+ }
219
+ let contents;
220
+ try {
221
+ contents = FATAL_TEXT_DECODER.decode(bytes);
222
+ }
223
+ catch {
224
+ return clientRefusal('invalid_attachment_content', `${name} is not a UTF-8 text file`);
225
+ }
226
+ if (contents.includes('\0')) {
227
+ return clientRefusal('invalid_attachment_content', `${name} is not a UTF-8 text file`);
228
+ }
229
+ const scrubOptions = {
230
+ reporterEmail: null,
231
+ homeDir: options.homeDirectory,
232
+ secrets: options.secrets,
233
+ };
234
+ const scrubbed = scrubFeedbackText(contents, scrubOptions);
235
+ const scrubbedName = scrubFeedbackText(name, scrubOptions);
236
+ const attachmentName = scrubbedName === name
237
+ && hasValidProductFeedbackAttachmentNameSyntax(name)
238
+ ? name
239
+ : `attachment-${attachmentIndex}${extname(name)}`;
240
+ if (attachmentName !== name)
241
+ options.onRename(scrubbedName, attachmentName);
242
+ return { name: attachmentName, contentBase64: btoa(unescape(encodeURIComponent(scrubbed))) };
243
+ }
244
+ function firstUnusedAttachmentName(extension, attachments) {
245
+ const usedNames = new Set(attachments.map(attachment => attachment.name.toLowerCase()));
246
+ for (let number = 1;; number += 1) {
247
+ const candidate = `attachment-${number}${extension}`;
248
+ if (!usedNames.has(candidate.toLowerCase()))
249
+ return candidate;
250
+ }
251
+ }
252
+ function activeCredentialSecrets(baseUrl) {
253
+ const credentials = getServerCredentials(baseUrl);
254
+ if (!credentials)
255
+ return [];
256
+ return 'accessToken' in credentials
257
+ ? [credentials.accessToken, credentials.refreshToken]
258
+ : [credentials.apiKey];
259
+ }
260
+ function activeCredentialSecretsBestEffort(baseUrl) {
261
+ try {
262
+ return activeCredentialSecrets(baseUrl);
263
+ }
264
+ catch {
265
+ return [];
266
+ }
267
+ }
268
+ function resolveScrubSecrets(baseUrl, envBearer) {
269
+ return [...new Set([
270
+ ...(envBearer ? [envBearer] : []),
271
+ ...getSecretsSeenThisProcess(),
272
+ ...activeCredentialSecretsBestEffort(baseUrl),
273
+ ])];
274
+ }
275
+ function currentEnvironmentBearerBestEffort() {
276
+ try {
277
+ return resolveEnvBearerToken();
278
+ }
279
+ catch {
280
+ return null;
281
+ }
282
+ }
283
+ function currentScrubSecrets(baseUrl, initialSecrets) {
284
+ return [...new Set([
285
+ ...initialSecrets,
286
+ ...getSecretsSeenThisProcess(),
287
+ ...activeCredentialSecretsBestEffort(baseUrl),
288
+ ])];
289
+ }
290
+ function parsedProjectContext(value) {
291
+ const parsed = parseCliProjectSlug(value);
292
+ return parsed
293
+ && hasValidProductFeedbackSlugSyntax(parsed.teamSlug)
294
+ && hasValidProductFeedbackSlugSyntax(parsed.projectSlug)
295
+ ? parsed
296
+ : null;
297
+ }
298
+ function resolveProjectContext(explicitProject, cwd) {
299
+ const invalidProject = clientRefusal('invalid_flag_value', '--project must be <team-slug>/<project-slug>');
300
+ if (explicitProject)
301
+ return parsedProjectContext(explicitProject) ?? invalidProject;
302
+ if (process.env.ORIZU_PROJECT) {
303
+ return parsedProjectContext(process.env.ORIZU_PROJECT) ?? invalidProject;
304
+ }
305
+ const workspaceTeam = existingWorkspaceTeamSlug(getWorkspaceRoot(cwd));
306
+ return { teamSlug: workspaceTeam, projectSlug: null };
307
+ }
308
+ function lastErrorForContext(configDirectory, serverBaseUrl, teamSlug, now, printErr, scrubNotice) {
309
+ const record = readLastErrorRecord(configDirectory);
310
+ if (!record || !isLastErrorRecordShape(record))
311
+ return null;
312
+ if (teamSlug === null) {
313
+ printErr('last error not attached: no team context');
314
+ return null;
315
+ }
316
+ if (record.serverBaseUrl !== null && record.serverBaseUrl !== serverBaseUrl) {
317
+ printErr('last error not attached: recorded for a different server or team');
318
+ return null;
319
+ }
320
+ if (record.teamSlug !== null && record.teamSlug !== teamSlug) {
321
+ printErr('last error not attached: recorded for a different team');
322
+ return null;
323
+ }
324
+ const recordedAt = Date.parse(record.recordedAt);
325
+ const age = now - recordedAt;
326
+ const descriptor = lastErrorDescriptor(record);
327
+ if (age >= 0 && age <= LAST_ERROR_ATTACH_MAX_AGE_MS)
328
+ return record;
329
+ printErr(scrubNotice(`Ignored stale ${descriptor}.`));
330
+ return null;
331
+ }
332
+ function lastErrorDescriptor(record) {
333
+ return `last-error record for ${record.command ?? 'unknown'} at ${record.recordedAt}`;
334
+ }
335
+ function scrubLastErrorRecord(record, scrubOptions) {
336
+ if (record === null)
337
+ return null;
338
+ const scrubbed = scrubFeedbackValue(record, scrubOptions);
339
+ scrubbed.serverBaseUrl = record.serverBaseUrl;
340
+ scrubbed.teamSlug = record.teamSlug;
341
+ return scrubbed;
342
+ }
343
+ function runtimeDescription() {
344
+ const bunRuntime = globalThis.Bun;
345
+ return bunRuntime
346
+ ? `bun ${bunRuntime.version}`
347
+ : `node ${process.versions.node}`;
348
+ }
349
+ function isProductFeedbackSuccess(value) {
350
+ return value !== null
351
+ && typeof value === 'object'
352
+ && typeof value.id === 'string'
353
+ && value.id.trim().length > 0
354
+ && typeof value.message === 'string'
355
+ && value.message.trim().length > 0;
356
+ }
357
+ const INVALID_TOKEN_FILE_MESSAGE = 'invalid_token_file: ORIZU_TOKEN_FILE is set but could not be read; fix the sandbox or email feedback@orizu.ai';
358
+ const DEFAULT_FEEDBACK_TIMEOUT_MS = 30_000;
359
+ const FEEDBACK_TIMEOUT_MESSAGE = `feedback_timeout: no response within ${DEFAULT_FEEDBACK_TIMEOUT_MS / 1_000} s. Email feedback@orizu.ai`;
360
+ const FEEDBACK_STORAGE_UNCERTAINTY_MESSAGE = 'The report may or may not have been stored; retrying can file it twice. Email feedback@orizu.ai';
361
+ function printFeedbackTimeout(printErr) {
362
+ printErr(FEEDBACK_TIMEOUT_MESSAGE);
363
+ printErr(FEEDBACK_STORAGE_UNCERTAINTY_MESSAGE);
364
+ }
365
+ export function resolveFeedbackTimeoutMs(baseUrl) {
366
+ const hostname = new URL(baseUrl).hostname.toLowerCase();
367
+ const isLoopback = hostname === 'localhost'
368
+ || hostname === '[::1]'
369
+ || /^127(?:\.\d{1,3}){3}$/.test(hostname);
370
+ if (!isLoopback)
371
+ return DEFAULT_FEEDBACK_TIMEOUT_MS;
372
+ const configured = Number(process.env.ORIZU_FEEDBACK_TIMEOUT_MS);
373
+ return Number.isSafeInteger(configured) && configured > 0
374
+ ? Math.min(configured, DEFAULT_FEEDBACK_TIMEOUT_MS)
375
+ : DEFAULT_FEEDBACK_TIMEOUT_MS;
376
+ }
377
+ function endpointUnavailableMessage(status) {
378
+ return `Feedback endpoint unavailable (status ${status}). Email feedback@orizu.ai`;
379
+ }
380
+ function ambiguousEndpointFailureMessage(status) {
381
+ return `Feedback endpoint unavailable (status ${status}). The report may or may not have been stored; retrying can file it twice. Email feedback@orizu.ai`;
382
+ }
383
+ function isInvalidTokenFileError(error) {
384
+ return error instanceof Error && error.message.includes('ORIZU_TOKEN_FILE');
385
+ }
386
+ function scrubbedErrorMessage(error, secrets, homeDirectory) {
387
+ return scrubFeedbackText(error instanceof Error ? error.message : String(error), {
388
+ reporterEmail: null,
389
+ homeDir: homeDirectory,
390
+ secrets,
391
+ });
392
+ }
393
+ function scrubbedInlineErrorMessage(error, secrets, homeDirectory) {
394
+ return sanitizeHumanInlineText(sanitizeTerminalText, scrubbedErrorMessage(error, secrets, homeDirectory));
395
+ }
396
+ export async function feedbackCommand(args, io) {
397
+ const configDirectory = io.configDir ?? getConfigDir();
398
+ const canonicalConfigDirectory = canonicalConfigRoot(configDirectory);
399
+ const cwd = io.cwd ?? process.cwd();
400
+ const homeDirectory = io.homeDir ?? homedir();
401
+ const now = io.now ?? Date.now;
402
+ let requestContext;
403
+ try {
404
+ requestContext = captureAuthenticatedRequestContext({ pinEnvBearerValue: true });
405
+ }
406
+ catch (error) {
407
+ if (isInvalidTokenFileError(error)) {
408
+ io.printErr(INVALID_TOKEN_FILE_MESSAGE);
409
+ }
410
+ else if (error instanceof Error && error.message.startsWith('Not logged in for ')) {
411
+ io.printErr(clientRefusal('unauthenticated', PRODUCT_FEEDBACK_UNAUTHENTICATED_MESSAGE));
412
+ }
413
+ else {
414
+ io.printErr(`network_error: ${scrubbedErrorMessage(error, getSecretsSeenThisProcess(), homeDirectory)}. Email feedback@orizu.ai`);
415
+ }
416
+ return 1;
417
+ }
418
+ const baseUrl = requestContext.baseUrl;
419
+ const isHosted = requestContext.source !== 'stored';
420
+ let secrets = resolveScrubSecrets(baseUrl, null);
421
+ const scrubNotice = (message) => scrubFeedbackText(message, {
422
+ reporterEmail: null,
423
+ homeDir: homeDirectory,
424
+ secrets,
425
+ });
426
+ try {
427
+ const parsed = parseFeedbackArgs(args);
428
+ if (typeof parsed === 'string') {
429
+ io.printErr(scrubNotice(parsed));
430
+ return 1;
431
+ }
432
+ const fromFile = parsed.fromFile
433
+ ? readFromFile(parsed.fromFile, homeDirectory)
434
+ : {};
435
+ if (typeof fromFile === 'string') {
436
+ io.printErr(scrubNotice(fromFile));
437
+ return 1;
438
+ }
439
+ const projectContext = resolveProjectContext(parsed.project, cwd);
440
+ if (typeof projectContext === 'string') {
441
+ io.printErr(scrubNotice(projectContext));
442
+ return 1;
443
+ }
444
+ if (parsed.attachments.length > 0 && projectContext.teamSlug === null) {
445
+ io.printErr(clientRefusal('invalid_attachments', 'attachments need a project: pass --project <team/project> or set ORIZU_PROJECT'));
446
+ return 1;
447
+ }
448
+ const attachments = [];
449
+ for (const [index, path] of parsed.attachments.entries()) {
450
+ const attachment = readAttachment(path, index + 1, {
451
+ canonicalConfigDirectory,
452
+ homeDirectory,
453
+ secrets,
454
+ onRename: (originalName, attachmentName) => {
455
+ io.printErr(`attachment renamed: ${originalName} -> ${attachmentName}`);
456
+ },
457
+ });
458
+ if (typeof attachment === 'string') {
459
+ io.printErr(scrubNotice(attachment));
460
+ return 1;
461
+ }
462
+ let acceptedAttachment = attachment;
463
+ if (attachments.some(existing => existing.name.toLowerCase() === acceptedAttachment.name.toLowerCase())) {
464
+ const renamedAttachment = firstUnusedAttachmentName(extname(acceptedAttachment.name), attachments);
465
+ io.printErr(`attachment renamed: ${acceptedAttachment.name} -> ${renamedAttachment}`);
466
+ acceptedAttachment = { ...acceptedAttachment, name: renamedAttachment };
467
+ }
468
+ attachments.push(acceptedAttachment);
469
+ }
470
+ secrets = resolveScrubSecrets(baseUrl, currentEnvironmentBearerBestEffort());
471
+ const values = { ...fromFile, ...parsed.values };
472
+ const lastError = parsed.suppressLastError
473
+ ? null
474
+ : lastErrorForContext(configDirectory, baseUrl, projectContext.teamSlug, now(), io.printErr, scrubNotice);
475
+ const scrubOptions = { reporterEmail: null, homeDir: homeDirectory, secrets };
476
+ const scrubbedFields = scrubFeedbackValue({
477
+ category: values.category,
478
+ severity: values.severity,
479
+ summary: values.summary,
480
+ actual: values.actual,
481
+ tried: values.tried ?? null,
482
+ expected: values.expected ?? null,
483
+ impact: values.impact ?? null,
484
+ repro: values.repro ?? null,
485
+ }, scrubOptions);
486
+ const requestCandidate = {
487
+ ...scrubbedFields,
488
+ lastError: scrubLastErrorRecord(lastError, scrubOptions),
489
+ environment: {
490
+ cliVersion: safeCliVersion(),
491
+ os: `${process.platform} ${release()}`,
492
+ runtime: runtimeDescription(),
493
+ serverBaseUrl: baseUrl,
494
+ hosted: isHosted,
495
+ teamSlug: projectContext.teamSlug,
496
+ projectSlug: projectContext.projectSlug,
497
+ workspaceRootFound: workspaceExists(cwd),
498
+ },
499
+ };
500
+ const validation = validateProductFeedbackRequest({ ...requestCandidate, attachments });
501
+ if (!validation.ok) {
502
+ io.printErr(scrubNotice(validation.refusal.message));
503
+ return 1;
504
+ }
505
+ if (lastError !== null) {
506
+ io.printErr(scrubNotice(`Attached ${lastErrorDescriptor(lastError)}.`));
507
+ }
508
+ const fetcher = io.fetcher ?? requestContext.fetch;
509
+ const feedbackSignal = AbortSignal.timeout(resolveFeedbackTimeoutMs(baseUrl));
510
+ let response;
511
+ try {
512
+ response = await fetcher(PRODUCT_FEEDBACK_ROUTE, {
513
+ method: 'POST',
514
+ headers: { 'Content-Type': 'application/json' },
515
+ body: JSON.stringify(validation.request),
516
+ signal: feedbackSignal,
517
+ });
518
+ }
519
+ catch (error) {
520
+ if (feedbackSignal.aborted) {
521
+ printFeedbackTimeout(io.printErr);
522
+ return 1;
523
+ }
524
+ throw error;
525
+ }
526
+ const responseSecrets = currentScrubSecrets(baseUrl, secrets);
527
+ let responseBody;
528
+ try {
529
+ responseBody = await parseJsonResponse(response, 'Product feedback');
530
+ }
531
+ catch {
532
+ if (feedbackSignal.aborted) {
533
+ printFeedbackTimeout(io.printErr);
534
+ return 1;
535
+ }
536
+ if (response.status !== 429) {
537
+ io.printErr(response.status >= 500 && response.status <= 599
538
+ ? ambiguousEndpointFailureMessage(response.status)
539
+ : endpointUnavailableMessage(response.status));
540
+ return 1;
541
+ }
542
+ responseBody = null;
543
+ }
544
+ if (response.status === 429) {
545
+ const responseError = responseBody !== null
546
+ && typeof responseBody === 'object'
547
+ && typeof responseBody.error === 'string'
548
+ ? responseBody.error
549
+ : 'rate limited';
550
+ const detail = responseError.replace(/^rate_limited:\s*/i, '').trim() || 'rate limited';
551
+ const retryAfter = response.headers.get('Retry-After');
552
+ const retryInstruction = retryAfter === null
553
+ ? ''
554
+ : /^\d+$/.test(retryAfter)
555
+ ? `; retry after ${retryAfter} s`
556
+ : `; retry at ${retryAfter}`;
557
+ io.printErr(scrubbedInlineErrorMessage(`rate_limited: ${detail}${retryInstruction}`, responseSecrets, homeDirectory));
558
+ return 1;
559
+ }
560
+ if (!response.ok) {
561
+ const serverError = responseBody !== null
562
+ && typeof responseBody === 'object'
563
+ && typeof responseBody.error === 'string'
564
+ ? responseBody.error
565
+ : `Product feedback failed with status ${response.status}.`;
566
+ if (response.status >= 500 && response.status <= 599) {
567
+ if (response.status === 500 && /^storage_failed(?:\b|:)/.test(serverError)) {
568
+ io.printErr(scrubbedInlineErrorMessage(serverError, responseSecrets, homeDirectory));
569
+ io.printErr('Nothing was stored; try again in a minute or email feedback@orizu.ai');
570
+ }
571
+ else {
572
+ io.printErr(ambiguousEndpointFailureMessage(response.status));
573
+ }
574
+ return 1;
575
+ }
576
+ io.printErr(scrubbedInlineErrorMessage(serverError, responseSecrets, homeDirectory));
577
+ return 1;
578
+ }
579
+ if (!isProductFeedbackSuccess(responseBody)) {
580
+ io.printErr(endpointUnavailableMessage(response.status));
581
+ return 1;
582
+ }
583
+ const success = scrubFeedbackValue(responseBody, {
584
+ ...scrubOptions,
585
+ secrets: responseSecrets,
586
+ reporterEmail: 'feedback@orizu.ai',
587
+ });
588
+ if (io.json)
589
+ io.print(JSON.stringify(success));
590
+ else {
591
+ io.print(sanitizeHumanInlineText(sanitizeTerminalText, `Reported (id ${success.id}). ${success.message}`));
592
+ }
593
+ return 0;
594
+ }
595
+ catch (error) {
596
+ io.printErr(isInvalidTokenFileError(error)
597
+ ? INVALID_TOKEN_FILE_MESSAGE
598
+ : `network_error: ${scrubbedErrorMessage(error, currentScrubSecrets(baseUrl, secrets), homeDirectory)}. Email feedback@orizu.ai`);
599
+ return 1;
600
+ }
601
+ }
@@ -0,0 +1,77 @@
1
+ export declare const FEEDBACK_CATEGORIES: readonly ["bug", "docs", "missing", "guidance", "friction", "other"];
2
+ export type FeedbackCategory = typeof FEEDBACK_CATEGORIES[number];
3
+ export declare const FEEDBACK_SEVERITIES: readonly ["blocking", "major", "minor"];
4
+ export type FeedbackSeverity = typeof FEEDBACK_SEVERITIES[number];
5
+ export declare const FEEDBACK_ATTACHMENT_EXTENSIONS: readonly [".log", ".txt", ".md", ".json"];
6
+ export declare const FEEDBACK_CAPS: {
7
+ readonly summaryBytes: 200;
8
+ readonly actualBytes: 1200;
9
+ readonly triedBytes: 800;
10
+ readonly expectedBytes: 800;
11
+ readonly impactBytes: 800;
12
+ readonly reproBytes: 1200;
13
+ readonly narrativeTotalBytes: 4000;
14
+ readonly environmentBytes: 4096;
15
+ readonly attachmentBytes: 262144;
16
+ readonly attachmentNameBytes: 128;
17
+ readonly maxAttachments: 5;
18
+ readonly bodyBytes: 2097152;
19
+ };
20
+ export declare const PRODUCT_FEEDBACK_FLAGS: readonly ["--category", "--severity", "--summary", "--tried", "--expected", "--actual", "--impact", "--repro", "--attach", "--from-file", "--project", "--no-last-error", "--json"];
21
+ export declare const FROM_FILE_KEYS: string[];
22
+ export interface ProductFeedbackEnvironment {
23
+ cliVersion: string | null;
24
+ os: string;
25
+ runtime: string;
26
+ serverBaseUrl: string;
27
+ hosted: boolean;
28
+ teamSlug: string | null;
29
+ projectSlug: string | null;
30
+ workspaceRootFound: boolean;
31
+ }
32
+ export interface ProductFeedbackAttachment {
33
+ name: string;
34
+ contentBase64: string;
35
+ }
36
+ export interface ProductFeedbackRequest {
37
+ category: FeedbackCategory;
38
+ severity: FeedbackSeverity;
39
+ summary: string;
40
+ actual: string;
41
+ tried: string | null;
42
+ expected: string | null;
43
+ impact: string | null;
44
+ repro: string | null;
45
+ environment: ProductFeedbackEnvironment;
46
+ lastError: unknown | null;
47
+ attachments: ProductFeedbackAttachment[];
48
+ }
49
+ export declare const FEEDBACK_REFUSAL_CODES: readonly ["invalid_json", "invalid_category", "invalid_severity", "invalid_summary", "invalid_actual", "invalid_tried", "invalid_expected", "invalid_impact", "invalid_repro", "invalid_environment", "invalid_last_error", "invalid_attachment_name", "invalid_attachment_count", "invalid_attachment_content", "invalid_attachments", "too_large_summary", "too_large_actual", "too_large_tried", "too_large_expected", "too_large_impact", "too_large_repro", "too_large_narrative", "too_large_environment", "too_large_attachment", "too_large_body"];
50
+ export type FeedbackRefusalCode = typeof FEEDBACK_REFUSAL_CODES[number];
51
+ export interface FeedbackRefusal {
52
+ code: FeedbackRefusalCode;
53
+ message: string;
54
+ }
55
+ export type ValidateProductFeedbackResult = {
56
+ ok: true;
57
+ request: ProductFeedbackRequest;
58
+ } | {
59
+ ok: false;
60
+ refusal: FeedbackRefusal;
61
+ };
62
+ export declare const FEEDBACK_CLIENT_REFUSAL_CODES: readonly ["invalid_flag_repeated", "invalid_flag_value", "invalid_attachment_path", "invalid_attachment_extension", "invalid_attachment_unreadable", "invalid_from_file", "invalid_from_file_unreadable", "invalid_from_file_json", "invalid_token_file", "unauthenticated", "network_error", "feedback_timeout"];
63
+ export declare const FEEDBACK_SERVER_REFUSAL_PREFIXES: readonly ["unauthorized", "forbidden", "rate_limited", "storage_failed"];
64
+ export declare const PRODUCT_FEEDBACK_ROUTE = "/api/cli/feedback";
65
+ export declare const PRODUCT_FEEDBACK_MESSAGE = "Thanks, this has been reported. To follow up, email feedback@orizu.ai";
66
+ export declare const PRODUCT_FEEDBACK_UNAUTHENTICATED_MESSAGE = "Sign in first (orizu login) or email feedback@orizu.ai";
67
+ export interface ProductFeedbackSuccess {
68
+ id: string;
69
+ message: string;
70
+ }
71
+ export declare function hasValidProductFeedbackAttachmentNameSyntax(value: unknown): value is string;
72
+ export declare function hasValidProductFeedbackSlugSyntax(value: unknown): value is string;
73
+ export declare function isLastErrorRecordShape(value: unknown): boolean;
74
+ export declare function decodedBase64Bytes(value: string): number | null;
75
+ export declare function feedbackRefusalStatus(code: FeedbackRefusalCode): 400 | 413;
76
+ export declare function validateProductFeedbackRequest(input: unknown): ValidateProductFeedbackResult;
77
+ //# sourceMappingURL=feedback-contract.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"feedback-contract.d.ts","sourceRoot":"","sources":["../src/feedback-contract.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,mBAAmB,sEAOtB,CAAA;AAEV,MAAM,MAAM,gBAAgB,GAAG,OAAO,mBAAmB,CAAC,MAAM,CAAC,CAAA;AAEjE,eAAO,MAAM,mBAAmB,yCAA0C,CAAA;AAE1E,MAAM,MAAM,gBAAgB,GAAG,OAAO,mBAAmB,CAAC,MAAM,CAAC,CAAA;AAEjE,eAAO,MAAM,8BAA8B,2CAA4C,CAAA;AAEvF,eAAO,MAAM,aAAa;;;;;;;;;;;;;CAahB,CAAA;AAEV,eAAO,MAAM,sBAAsB,qLAczB,CAAA;AAGV,eAAO,MAAM,cAAc,UAEE,CAAA;AAE7B,MAAM,WAAW,0BAA0B;IACzC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,EAAE,MAAM,CAAA;IACf,aAAa,EAAE,MAAM,CAAA;IACrB,MAAM,EAAE,OAAO,CAAA;IACf,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,kBAAkB,EAAE,OAAO,CAAA;CAC5B;AAED,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,MAAM,CAAA;IACZ,aAAa,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,gBAAgB,CAAA;IAC1B,QAAQ,EAAE,gBAAgB,CAAA;IAC1B,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,WAAW,EAAE,0BAA0B,CAAA;IACvC,SAAS,EAAE,OAAO,GAAG,IAAI,CAAA;IACzB,WAAW,EAAE,yBAAyB,EAAE,CAAA;CACzC;AAED,eAAO,MAAM,sBAAsB,4hBA0BzB,CAAA;AAEV,MAAM,MAAM,mBAAmB,GAAG,OAAO,sBAAsB,CAAC,MAAM,CAAC,CAAA;AAEvE,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,mBAAmB,CAAA;IACzB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,MAAM,6BAA6B,GACrC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,sBAAsB,CAAA;CAAE,GAC7C;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,eAAe,CAAA;CAAE,CAAA;AAE3C,eAAO,MAAM,6BAA6B,mTAahC,CAAA;AAEV,eAAO,MAAM,gCAAgC,0EAKnC,CAAA;AAEV,eAAO,MAAM,sBAAsB,sBAAsB,CAAA;AACzD,eAAO,MAAM,wBAAwB,0EAA0E,CAAA;AAC/G,eAAO,MAAM,wCAAwC,2DAA2D,CAAA;AAEhH,MAAM,WAAW,sBAAsB;IACrC,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,EAAE,MAAM,CAAA;CAChB;AAqCD,wBAAgB,2CAA2C,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAE3F;AA0CD,wBAAgB,iCAAiC,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAEjF;AAoDD,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAyB9D;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAgB/D;AAaD,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,mBAAmB,GAAG,GAAG,GAAG,GAAG,CAE1E;AAED,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,OAAO,GAAG,6BAA6B,CAuH5F"}