@haystackeditor/cli 0.24.0 → 0.25.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 (49) hide show
  1. package/dist/assets/capture/capture.cb4204fcc997d8e8.js +2 -0
  2. package/dist/assets/capture/release.json +4 -0
  3. package/dist/assets/telemetry/runtime.cjs +829 -1254
  4. package/dist/capture/adapters/client-routes.js +383 -0
  5. package/dist/capture/adapters/django.js +127 -0
  6. package/dist/capture/adapters/files.js +77 -0
  7. package/dist/capture/adapters/index.js +64 -0
  8. package/dist/capture/adapters/jsx-edit.js +81 -0
  9. package/dist/capture/adapters/next.js +327 -0
  10. package/dist/capture/adapters/nuxt.js +192 -0
  11. package/dist/capture/adapters/rails.js +171 -0
  12. package/dist/capture/adapters/react-router.js +432 -0
  13. package/dist/capture/adapters/sveltekit.js +102 -0
  14. package/dist/capture/adapters/types.js +4 -0
  15. package/dist/capture/adapters/vite.js +121 -0
  16. package/dist/capture/app-config.js +106 -0
  17. package/dist/capture/consent.js +127 -0
  18. package/dist/capture/csp.js +331 -0
  19. package/dist/capture/html.js +74 -0
  20. package/dist/capture/js-ast.js +400 -0
  21. package/dist/capture/manifest.js +95 -0
  22. package/dist/capture/project.js +177 -0
  23. package/dist/capture/route-pattern.js +119 -0
  24. package/dist/capture/script-release.js +47 -0
  25. package/dist/capture/tag.js +74 -0
  26. package/dist/capture-step.js +53 -0
  27. package/dist/commands/capture-brief.js +92 -0
  28. package/dist/commands/capture-contract.js +46 -0
  29. package/dist/commands/capture-manifest.js +78 -0
  30. package/dist/commands/init-capture.js +409 -0
  31. package/dist/commands/init-telemetry.js +1011 -0
  32. package/dist/commands/init.js +75 -9
  33. package/dist/commands/server-telemetry-contract.d.ts +66 -0
  34. package/dist/commands/server-telemetry-contract.js +127 -0
  35. package/dist/commands/telemetry-token.js +238 -0
  36. package/dist/commands/telemetry.d.ts +161 -8
  37. package/dist/commands/telemetry.js +940 -158
  38. package/dist/commands/verify-onboarding.js +5 -1
  39. package/dist/commands/verify.js +54 -7
  40. package/dist/index.js +83 -6
  41. package/dist/schema.js +2 -2
  42. package/dist/telemetry/next-loader.cjs +66 -9
  43. package/dist/telemetry/next.d.ts +11 -3
  44. package/dist/telemetry/next.js +95 -15
  45. package/dist/telemetry/typed-source.d.ts +47 -0
  46. package/dist/telemetry/typed-source.js +379 -0
  47. package/package.json +4 -2
  48. package/schemas/init.v1.json +63 -4
  49. package/schemas/pre-verify.v1.json +60 -3
@@ -10,6 +10,9 @@
10
10
  * .git/info/exclude), when Claude Code is in use here;
11
11
  * 2. a short marked block in AGENTS.md (and in CLAUDE.md when the repository
12
12
  * keeps one) telling coding agents to run `haystack verify` after a change.
13
+ * 3. telemetry (init-telemetry.ts, CAPTURE-V1 rule 8): the app's server instrumentation and the CLI dependency it needs,
14
+ * the settings proposal and the browser tag, once the user's answers (which app, its origins, consent) are given;
15
+ * until then only the telemetry report stops, saying what to ask.
13
16
  * It then starts onboarding the app at the base `haystack verify` would use, so
14
17
  * the app is usually ready by the first verify. With --notes, the coding agent's
15
18
  * notes on how the app runs, signs people in and is used (onboarding-notes.ts)
@@ -22,7 +25,7 @@
22
25
  * installed, 6 set up but onboarding stopped before finishing (run init again).
23
26
  */
24
27
  import { execFileSync } from 'node:child_process';
25
- import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
28
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
26
29
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
27
30
  import { createInterface } from 'node:readline/promises';
28
31
  import chalk from 'chalk';
@@ -42,14 +45,17 @@ export const AGENT_NOTE = [
42
45
  NOTE_BEGIN,
43
46
  '## Haystack',
44
47
  '',
45
- 'After you change this app\'s code, run `haystack verify` before you call the work done. It runs the app with and',
46
- 'without your change, explores it, and reports where the change shows up and what broke.',
48
+ 'After you change this app\'s code, or when the user asks you to check a change, run `haystack verify` before you',
49
+ 'call the work done. It runs the app with and without the change, explores it, and reports where the change shows up',
50
+ 'and what broke. It changes nothing, so run it without asking.',
47
51
  '',
48
- '- It often takes 10 to 15 minutes. If your command times out, run it again: it picks up the same crawl.',
52
+ '- It often takes 10 to 15 minutes: give it your longest timeout or run it in the background, and do not pipe it',
53
+ ' through `tail` or `head`, which hide its progress. If your command times out, run it again: it picks up the same crawl.',
49
54
  '- Fix each bug it reports that your change caused, then run `haystack verify` again.',
50
55
  '- If it says onboarding needs an answer, ask the user that question and file their choice with',
51
56
  ' `haystack verify answer <question-id> <choice>`.',
52
- '- `haystack verify --json` prints one JSON document; `haystack verify --help` explains the output and exit codes.',
57
+ '- Read its short report. `--json` prints the full result, often hundreds of kilobytes, for scripts;',
58
+ ' `haystack verify --help` explains the output and exit codes.',
53
59
  '- If `haystack` is not installed, run each command as `npx -y @haystackeditor/cli@latest <command>`.',
54
60
  NOTE_END,
55
61
  ].join('\n');
@@ -297,6 +303,20 @@ function applyChange(gitRoot, planned) {
297
303
  assertNoSymlinkOnPath(gitRoot, planned.target);
298
304
  else if (isLink(planned.target))
299
305
  throw new Error(`Refusing to write ${planned.target}: it is a symlink.`);
306
+ if (planned.action === 'delete') {
307
+ unlinkSync(planned.target);
308
+ return;
309
+ }
310
+ if (planned.run) {
311
+ const before = existsSync(planned.target) ? readFileSync(planned.target, 'utf8') : '';
312
+ // Its output goes to stderr: in --json mode stdout carries only the one document.
313
+ execFileSync(planned.run.argv[0], planned.run.argv.slice(1), { cwd: planned.run.cwd, stdio: ['ignore', 2, 2] });
314
+ const failed = planned.run.check?.() ?? null;
315
+ if (failed !== null)
316
+ throw new Error(`\`${planned.run.argv.join(' ')}\` ran, but ${failed}.`);
317
+ planned.diff = lineDiff(before, existsSync(planned.target) ? readFileSync(planned.target, 'utf8') : '');
318
+ return;
319
+ }
300
320
  mkdirSync(dirname(planned.target), { recursive: true });
301
321
  writeFileSync(planned.target, planned.content);
302
322
  }
@@ -337,12 +357,20 @@ export async function initCommand(options) {
337
357
  // Read before anything else, so notes that cannot be used stop init with nothing done.
338
358
  const notes = options.notes === undefined ? null : readNotes(options.notes);
339
359
  const plan = planInit(gitRoot);
360
+ // Telemetry is part of onboarding (CAPTURE-V1 rule 8): planned on every run; a missing answer stops only it.
361
+ const telemetryModule = await import('./init-telemetry.js');
362
+ const telemetryOptions = { app: options.app, origins: options.origin ?? [], consent: options.consent };
363
+ let telemetryPlan = await telemetryModule.planTelemetry(gitRoot, repository, telemetryOptions);
364
+ const telemetryFrom = plan.changes.length;
365
+ plan.changes.push(...telemetryPlan.changes);
366
+ plan.notices.push(...telemetryPlan.notices);
340
367
  let notesWritten = null;
368
+ let telemetry = null;
341
369
  const finish = (status, onboarding, exitCode) => {
342
370
  if (options.json) {
343
371
  const changes = plan.changes.map(({ path, action, reason, diff }) => ({ path, action, reason, diff }));
344
372
  process.stdout.write(`${JSON.stringify(withSchema('init', { status, repository, changes, notices: plan.notices, notes: notesWritten,
345
- onboarding }), null, 2)}\n`);
373
+ onboarding, telemetry: telemetry?.telemetry ?? null, telemetrySteps: telemetry?.steps ?? [] }), null, 2)}\n`);
346
374
  }
347
375
  process.exitCode = exitCode;
348
376
  };
@@ -351,7 +379,7 @@ export async function initCommand(options) {
351
379
  say(chalk.dim('Nothing in this checkout needs changing.'));
352
380
  for (const planned of plan.changes) {
353
381
  say('');
354
- say(`${chalk.bold(planned.action === 'create' ? 'Create' : 'Update')} ${planned.path}: ${planned.reason}`);
382
+ say(`${chalk.bold(planned.action === 'create' ? 'Create' : planned.action === 'delete' ? 'Delete' : 'Update')} ${planned.path}: ${planned.reason}`);
355
383
  for (const line of planned.diff)
356
384
  say(` ${colored(line)}`);
357
385
  }
@@ -381,10 +409,44 @@ export async function initCommand(options) {
381
409
  return;
382
410
  }
383
411
  }
412
+ // Browser capture's registration (rule 8b), which the preview announced: checked with Haystack now, and when the files
413
+ // need a new key the telemetry changes are planned again with it (or without capture's, when it is refused).
414
+ const approved = options.yes === true || plan.changes.length > 0;
415
+ const capture = telemetryPlan.captureRegistration
416
+ ? await telemetryModule.prepareCapture(telemetryPlan.captureRegistration, token, say, approved) : null;
417
+ if (capture?.replan) {
418
+ telemetryPlan = await telemetryModule.planTelemetry(gitRoot, repository, { ...telemetryOptions, registration: capture.replan });
419
+ plan.changes.splice(telemetryFrom, plan.changes.length - telemetryFrom, ...telemetryPlan.changes);
420
+ }
421
+ // All or nothing: a change that fails (an install that does not end as planned) restores every file the changes
422
+ // touched, so no edit is left referencing what was never installed.
423
+ // Bytes, not text: a lockfile can be binary (bun.lockb).
424
+ const before = new Map();
384
425
  for (const planned of plan.changes) {
385
- applyChange(gitRoot, planned);
386
- say(chalk.green(`✓ ${planned.action === 'create' ? 'Created' : 'Updated'} ${planned.path}`));
426
+ for (const path of [planned.target, ...(planned.run?.touches ?? [])]) {
427
+ if (!before.has(path))
428
+ before.set(path, existsSync(path) ? readFileSync(path) : null);
429
+ }
430
+ }
431
+ try {
432
+ for (const planned of plan.changes) {
433
+ applyChange(gitRoot, planned);
434
+ say(chalk.green(`✓ ${planned.action === 'create' ? 'Created' : planned.action === 'delete' ? 'Deleted' : 'Updated'} ${planned.path}`));
435
+ }
436
+ }
437
+ catch (error) {
438
+ for (const [path, content] of before) {
439
+ if (content !== null)
440
+ writeFileSync(path, content);
441
+ else if (existsSync(path))
442
+ unlinkSync(path);
443
+ }
444
+ await capture?.rollback(say);
445
+ throw new Error(`${error instanceof Error ? error.message : String(error)} Every file init changed was restored; nothing was changed.`);
387
446
  }
447
+ const captureFollowUp = await capture?.finish(say);
448
+ if (captureFollowUp)
449
+ telemetryPlan.capture = telemetryModule.withCaptureStep(telemetryPlan.capture, captureFollowUp);
388
450
  let onboarding;
389
451
  try {
390
452
  // The notes first: onboarding plans from the facts it starts with, and new notes are new facts (a new onboarding).
@@ -408,6 +470,10 @@ export async function initCommand(options) {
408
470
  reportOnboardingState(onboarding);
409
471
  say(formatOnboarding(onboarding));
410
472
  say('');
473
+ telemetry = await telemetryModule.telemetryReport(telemetryPlan, { repository, token, onboarding, notes });
474
+ for (const line of telemetryModule.formatTelemetry(telemetry.telemetry, telemetry.steps))
475
+ say(line);
476
+ say('');
411
477
  if (onboarding.state === 'ready')
412
478
  say(chalk.green('Ready. After your next change, run `haystack verify`.'));
413
479
  else if (onboarding.state === 'onboarding') {
@@ -0,0 +1,66 @@
1
+ /** FIXED CONTRACT — mirror of CAPTURE-V1 Part B (rule 9, server telemetry sampler 5) and rule 13(b)
2
+ * (infra/lambda/haystack-design-verifier-shared/capture_contracts.ts) for the published CLI, whose build cannot import
3
+ * repository files outside src. The source file wins. The customer runtime (src/assets/telemetry/runtime.cjs) receives
4
+ * these constants and the category vocabulary from serverRuntimeContract() when the runtime is authenticated, so the
5
+ * runtime holds no copy of its own. */
6
+ export declare const SERVER_SAMPLER_VERSION = 5;
7
+ export declare const SERVER_SAMPLE_AFTER_CALLS = 4096;
8
+ export declare const SERVER_SAMPLE_RATE = 64;
9
+ export declare const SERVER_OBSERVATIONS_PER_SECOND = 20000;
10
+ export declare const SERVER_FIELDS_PER_SITE = 48;
11
+ export declare const SERVER_BUILD_OVERHEAD_PERCENT = 15;
12
+ export declare const SERVER_SEND_TIMEOUT_MS = 10000;
13
+ export declare const SERVER_EXIT_FLUSH_MS = 2000;
14
+ export declare const SERVER_RUNTIME_MAX_BYTES: number;
15
+ export declare const SETTINGS_MAX_DOMAIN = 32;
16
+ export declare const SETTINGS_MAX_LITERAL_CHARS = 32;
17
+ export declare const SIZE_BUCKETS: readonly ["0", "1", "2-5", "6-20", "21-100", "101+"];
18
+ export declare const MAGNITUDE_BUCKETS: readonly ["0", "<1", "1-9", "10-99", "100-999", "1000+"];
19
+ export declare const SERVER_VALUE_CATEGORIES: readonly ["undefined", "null", "boolean", "bigint", "symbol", "function", "object", "error", "opaque", "overflow", "string:0", "string:1", "string:2-5", "string:6-20", "string:21-100", "string:101+", "array:0", "array:1", "array:2-5", "array:6-20", "array:21-100", "array:101+", "map:0", "map:1", "map:2-5", "map:6-20", "map:21-100", "map:101+", "set:0", "set:1", "set:2-5", "set:6-20", "set:21-100", "set:101+", "number:0", "number:nonfinite", "number:+<1", "number:+1-9", "number:+10-99", "number:+100-999", "number:+1000+", "number:-<1", "number:-1-9", "number:-10-99", "number:-100-999", "number:-1000+"];
20
+ export declare const SERVER_BRANCH_CATEGORIES: readonly ["true", "false", "overflow"];
21
+ export declare const SERVER_FIELD_STATES: readonly ["present", "missing", "null", "undefined", "opaque"];
22
+ export declare const SERVER_SETTING_CATEGORY_PREFIX: "setting:";
23
+ export declare const SERVER_SETTING_OTHER: "other";
24
+ export declare const SERVER_FIELD_NAME_PATTERN: RegExp;
25
+ export declare const SETTINGS_FILE_PATH: ".haystack/telemetry-settings.json";
26
+ export declare const SETTINGS_PROPOSAL_PATH: ".haystack/telemetry-settings.proposed.json";
27
+ export type SettingLiteral = string | number | boolean;
28
+ export interface TelemetrySettingsEntryV1 {
29
+ sourcePath: string;
30
+ qualifiedName: string;
31
+ label: string;
32
+ domain: SettingLiteral[];
33
+ }
34
+ export interface TelemetrySettingsFileV1 {
35
+ version: 1;
36
+ settings: TelemetrySettingsEntryV1[];
37
+ }
38
+ export declare const SETTINGS_REFUSED_WORDS: readonly ["id", "email", "name", "token", "password", "key", "secret", "address", "phone"];
39
+ export declare const SETTINGS_REFUSED_LITERAL_PATTERNS: readonly ["@", "[0-9]{6,}", "[0-9A-Fa-f]{16,}", "[A-Za-z0-9+/=_]{24,}"];
40
+ export interface TelemetrySettingsProposal {
41
+ /** Where the proposal was written (SETTINGS_PROPOSAL_PATH): never read as approval. */
42
+ path: string;
43
+ /** The allowlist `haystack telemetry settings --approve` promotes it into (SETTINGS_FILE_PATH). */
44
+ approvedPath: string;
45
+ file: TelemetrySettingsFileV1;
46
+ added: TelemetrySettingsEntryV1[];
47
+ refused: Array<{
48
+ sourcePath: string;
49
+ qualifiedName: string;
50
+ label: string;
51
+ why: string;
52
+ }>;
53
+ typedSource: boolean;
54
+ }
55
+ /** What runtime.cjs reads as CONTRACT: the constants it enforces and every category string it may count, prebuilt so a
56
+ * classified call concatenates nothing. */
57
+ export declare function serverRuntimeContract(): Record<string, unknown>;
58
+ /** Rule 9(c): an approvable literal is a boolean, a safe integer, or a string of 1 to SETTINGS_MAX_LITERAL_CHARS
59
+ * printable characters (no control characters, no lone surrogates), so its category `setting:<JSON>` has exactly one
60
+ * spelling that the runtime, the ingestion and the readers all produce. Null when it is approvable. */
61
+ export declare function settingLiteralProblem(literal: unknown): string | null;
62
+ /** The category of an approved literal: exactly `setting:` and its JSON. */
63
+ export declare function settingCategory(literal: SettingLiteral): string;
64
+ /** Rule 9(c): why a setting must not be recorded as its literals, or null. `names` are the parameter or binding and the
65
+ * type it is declared with; classification against the contract's fixed word list, never fuzzy matching of data. */
66
+ export declare function settingRefusal(names: string[], domain: SettingLiteral[]): string | null;
@@ -0,0 +1,127 @@
1
+ /** FIXED CONTRACT — mirror of CAPTURE-V1 Part B (rule 9, server telemetry sampler 5) and rule 13(b)
2
+ * (infra/lambda/haystack-design-verifier-shared/capture_contracts.ts) for the published CLI, whose build cannot import
3
+ * repository files outside src. The source file wins. The customer runtime (src/assets/telemetry/runtime.cjs) receives
4
+ * these constants and the category vocabulary from serverRuntimeContract() when the runtime is authenticated, so the
5
+ * runtime holds no copy of its own. */
6
+ export const SERVER_SAMPLER_VERSION = 5;
7
+ export const SERVER_SAMPLE_AFTER_CALLS = 4_096;
8
+ export const SERVER_SAMPLE_RATE = 64;
9
+ export const SERVER_OBSERVATIONS_PER_SECOND = 20_000;
10
+ export const SERVER_FIELDS_PER_SITE = 48;
11
+ export const SERVER_BUILD_OVERHEAD_PERCENT = 15;
12
+ export const SERVER_SEND_TIMEOUT_MS = 10_000;
13
+ export const SERVER_EXIT_FLUSH_MS = 2_000;
14
+ export const SERVER_RUNTIME_MAX_BYTES = 32 * 1024 * 1024;
15
+ export const SETTINGS_MAX_DOMAIN = 32;
16
+ export const SETTINGS_MAX_LITERAL_CHARS = 32;
17
+ export const SIZE_BUCKETS = ['0', '1', '2-5', '6-20', '21-100', '101+'];
18
+ export const MAGNITUDE_BUCKETS = ['0', '<1', '1-9', '10-99', '100-999', '1000+'];
19
+ export const SERVER_VALUE_CATEGORIES = [
20
+ 'undefined', 'null', 'boolean', 'bigint', 'symbol', 'function', 'object', 'error', 'opaque', 'overflow',
21
+ 'string:0', 'string:1', 'string:2-5', 'string:6-20', 'string:21-100', 'string:101+',
22
+ 'array:0', 'array:1', 'array:2-5', 'array:6-20', 'array:21-100', 'array:101+',
23
+ 'map:0', 'map:1', 'map:2-5', 'map:6-20', 'map:21-100', 'map:101+',
24
+ 'set:0', 'set:1', 'set:2-5', 'set:6-20', 'set:21-100', 'set:101+',
25
+ 'number:0', 'number:nonfinite',
26
+ 'number:+<1', 'number:+1-9', 'number:+10-99', 'number:+100-999', 'number:+1000+',
27
+ 'number:-<1', 'number:-1-9', 'number:-10-99', 'number:-100-999', 'number:-1000+',
28
+ ];
29
+ export const SERVER_BRANCH_CATEGORIES = ['true', 'false', 'overflow'];
30
+ export const SERVER_FIELD_STATES = ['present', 'missing', 'null', 'undefined', 'opaque'];
31
+ export const SERVER_SETTING_CATEGORY_PREFIX = 'setting:';
32
+ export const SERVER_SETTING_OTHER = 'other';
33
+ export const SERVER_FIELD_NAME_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
34
+ export const SETTINGS_FILE_PATH = '.haystack/telemetry-settings.json';
35
+ export const SETTINGS_PROPOSAL_PATH = '.haystack/telemetry-settings.proposed.json';
36
+ export const SETTINGS_REFUSED_WORDS = ['id', 'email', 'name', 'token', 'password', 'key', 'secret', 'address', 'phone'];
37
+ export const SETTINGS_REFUSED_LITERAL_PATTERNS = ['@', '[0-9]{6,}', '[0-9A-Fa-f]{16,}', '[A-Za-z0-9+/=_]{24,}'];
38
+ /** What runtime.cjs reads as CONTRACT: the constants it enforces and every category string it may count, prebuilt so a
39
+ * classified call concatenates nothing. */
40
+ export function serverRuntimeContract() {
41
+ const sized = (prefix) => SIZE_BUCKETS.map(bucket => `${prefix}:${bucket}`);
42
+ const signed = (sign) => MAGNITUDE_BUCKETS.slice(1).map(bucket => `number:${sign}${bucket}`);
43
+ const categories = {
44
+ undefined: 'undefined', null: 'null', boolean: 'boolean', bigint: 'bigint', symbol: 'symbol', function: 'function',
45
+ object: 'object', error: 'error', opaque: 'opaque', overflow: 'overflow', other: SERVER_SETTING_OTHER,
46
+ true: 'true', false: 'false',
47
+ string: sized('string'), array: sized('array'), map: sized('map'), set: sized('set'),
48
+ numberZero: 'number:0', numberNonfinite: 'number:nonfinite', numberPositive: signed('+'), numberNegative: signed('-'),
49
+ };
50
+ // Every string the runtime can emit is in the vocabulary (a broken mirror fails the build that writes the runtime,
51
+ // never a customer's process).
52
+ const vocabulary = new Set([...SERVER_VALUE_CATEGORIES, ...SERVER_BRANCH_CATEGORIES, SERVER_SETTING_OTHER]);
53
+ for (const value of Object.values(categories).flat()) {
54
+ if (!vocabulary.has(value))
55
+ throw new Error(`Telemetry runtime category ${value} is not in the contract's vocabulary`);
56
+ }
57
+ return {
58
+ SERVER_SAMPLER_VERSION, SERVER_SAMPLE_AFTER_CALLS, SERVER_SAMPLE_RATE, SERVER_OBSERVATIONS_PER_SECOND,
59
+ SERVER_FIELDS_PER_SITE, SERVER_SEND_TIMEOUT_MS, SERVER_EXIT_FLUSH_MS, SERVER_RUNTIME_MAX_BYTES,
60
+ SETTINGS_MAX_DOMAIN, SETTINGS_MAX_LITERAL_CHARS, SERVER_FIELD_STATES, SERVER_SETTING_CATEGORY_PREFIX,
61
+ SERVER_FIELD_NAME_PATTERN: SERVER_FIELD_NAME_PATTERN.source,
62
+ categories,
63
+ };
64
+ }
65
+ /** Rule 9(c): an approvable literal is a boolean, a safe integer, or a string of 1 to SETTINGS_MAX_LITERAL_CHARS
66
+ * printable characters (no control characters, no lone surrogates), so its category `setting:<JSON>` has exactly one
67
+ * spelling that the runtime, the ingestion and the readers all produce. Null when it is approvable. */
68
+ export function settingLiteralProblem(literal) {
69
+ if (typeof literal === 'boolean')
70
+ return null;
71
+ if (typeof literal === 'number')
72
+ return Number.isSafeInteger(literal) ? null : 'a number literal is not a safe integer';
73
+ if (typeof literal !== 'string')
74
+ return 'a literal is not a boolean, an integer or a string';
75
+ if (literal.length < 1 || literal.length > SETTINGS_MAX_LITERAL_CHARS) {
76
+ return `a literal is not 1 to ${SETTINGS_MAX_LITERAL_CHARS} characters`;
77
+ }
78
+ for (let index = 0; index < literal.length; index++) {
79
+ const code = literal.charCodeAt(index);
80
+ if (code < 0x20 || (code >= 0xd800 && code <= 0xdfff))
81
+ return 'a literal has a control character or a lone surrogate';
82
+ }
83
+ return null;
84
+ }
85
+ /** The category of an approved literal: exactly `setting:` and its JSON. */
86
+ export function settingCategory(literal) {
87
+ return `${SERVER_SETTING_CATEGORY_PREFIX}${JSON.stringify(literal)}`;
88
+ }
89
+ function words(text) {
90
+ return text
91
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
92
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
93
+ .split(/[^A-Za-z]+/)
94
+ .filter(Boolean)
95
+ .map(word => word.toLowerCase());
96
+ }
97
+ const REFUSED_WORDS = new Set(SETTINGS_REFUSED_WORDS);
98
+ const REFUSED_LITERALS = SETTINGS_REFUSED_LITERAL_PATTERNS.map(pattern => new RegExp(pattern));
99
+ /** Rule 9(c): why a setting must not be recorded as its literals, or null. `names` are the parameter or binding and the
100
+ * type it is declared with; classification against the contract's fixed word list, never fuzzy matching of data. */
101
+ export function settingRefusal(names, domain) {
102
+ for (const name of names) {
103
+ const hit = words(name).find(word => REFUSED_WORDS.has(word));
104
+ if (hit)
105
+ return `the name ${name} looks like identity or a secret (${hit})`;
106
+ }
107
+ if (domain.length === 0)
108
+ return 'no literal domain';
109
+ const strings = domain.filter(literal => typeof literal !== 'boolean');
110
+ if (strings.length > SETTINGS_MAX_DOMAIN)
111
+ return `more than ${SETTINGS_MAX_DOMAIN} literals`;
112
+ for (const literal of domain) {
113
+ const problem = settingLiteralProblem(literal);
114
+ if (problem)
115
+ return problem;
116
+ if (typeof literal === 'boolean')
117
+ continue;
118
+ const text = String(literal);
119
+ const hit = words(text).find(word => REFUSED_WORDS.has(word));
120
+ if (hit)
121
+ return `the literal ${JSON.stringify(literal)} looks like identity or a secret (${hit})`;
122
+ if (REFUSED_LITERALS.some(pattern => pattern.test(text))) {
123
+ return `the literal ${JSON.stringify(literal)} looks like identity or a secret`;
124
+ }
125
+ }
126
+ return null;
127
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * `haystack telemetry token --repo <owner/name> [--rotate] [--out <file>]`: a person mints the server-telemetry ingest
3
+ * token for one repository's production (CAPTURE-V1 rule 8c; telemetry_token_contracts.ts).
4
+ *
5
+ * The token is a production credential, so only a person issues it: the command runs only in an interactive terminal
6
+ * and asks first, the route needs admin or maintain permission on the repository, and the auth proxy refuses an hsk_live
7
+ * (automation) login. init and coding agents never run it; init tells the agent to ask its user to.
8
+ *
9
+ * Custody: this command generates the token (32 random bytes), writes it to a new file only the person can read (0600, in
10
+ * a 0700 directory) BEFORE Haystack records anything, and sends only its SHA-256. The token is never printed, logged,
11
+ * piped or sent: Haystack stores the hash, the ingestion endpoint authenticates by it, and the plaintext exists only in
12
+ * that file until the person moves it into production's environment. A rerun mints nothing while a token is active and
13
+ * says whether the file here holds it; --rotate replaces it, and the old token stops authenticating in the same write.
14
+ * A run holds `<file>.lock` from its first read to its last write, so concurrent runs cannot leave the file holding a
15
+ * revoked token. When the request's outcome is unknown (no answer, a server error), the token stays in its 0600 pending
16
+ * file and the next run keeps it if it became active, else removes it; only a refusal removes it at once.
17
+ */
18
+ import { createHash, randomBytes } from 'node:crypto';
19
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { basename, dirname, join, resolve } from 'node:path';
22
+ import { createInterface } from 'node:readline/promises';
23
+ import chalk from 'chalk';
24
+ import { resolveAuthContext } from '../utils/auth.js';
25
+ import { classifyHttpError, HaystackApiError } from '../utils/haystack-api.js';
26
+ import { gatewayFetch } from './case-batch.js';
27
+ import { TELEMETRY_ENVIRONMENT, TELEMETRY_SERVER_STATUS_PATH, TELEMETRY_TOKEN_PATH, TELEMETRY_TOKEN_SHA256, TELEMETRY_TOKEN_VERSION, } from './capture-contract.js';
28
+ /** GitHub's owner and repository name alphabets. */
29
+ const REPOSITORY = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9_.-]{1,100})$/u;
30
+ function sha256(text) {
31
+ return createHash('sha256').update(text).digest('hex');
32
+ }
33
+ function parseAnswer(value, repository) {
34
+ const answer = value;
35
+ if (!answer || answer.version !== TELEMETRY_TOKEN_VERSION || (answer.status !== 'minted' && answer.status !== 'active')
36
+ || typeof answer.repository !== 'string' || answer.repository.toLowerCase() !== repository.toLowerCase()
37
+ || answer.environment !== TELEMETRY_ENVIRONMENT || typeof answer.endpoint !== 'string' || typeof answer.createdAt !== 'string'
38
+ || typeof answer.tokenSha256 !== 'string' || !TELEMETRY_TOKEN_SHA256.test(answer.tokenSha256)) {
39
+ throw new Error(`Haystack answered the token request with something that is not a ${TELEMETRY_TOKEN_VERSION} answer for ${repository}.`);
40
+ }
41
+ return answer;
42
+ }
43
+ async function confirmed(question) {
44
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
45
+ try {
46
+ return /^y(es)?$/i.test((await prompt.question(question)).trim());
47
+ }
48
+ finally {
49
+ prompt.close();
50
+ }
51
+ }
52
+ /** GET the repository's server-telemetry status: its active token's hash, the endpoint, the newest flush. */
53
+ export async function readTelemetryServerStatus(repository, token) {
54
+ const path = `${TELEMETRY_SERVER_STATUS_PATH}?repository=${encodeURIComponent(repository)}`;
55
+ const response = await gatewayFetch(path, token);
56
+ if (!response.ok)
57
+ throw await classifyHttpError(response, `Haystack API ${TELEMETRY_SERVER_STATUS_PATH}`);
58
+ const value = await response.json();
59
+ const flush = value?.newestFlush;
60
+ const active = value?.token;
61
+ if (!value || value.version !== TELEMETRY_TOKEN_VERSION || typeof value.repository !== 'string' || typeof value.endpoint !== 'string'
62
+ || value.repository.toLowerCase() !== repository.toLowerCase() || value.environment !== TELEMETRY_ENVIRONMENT
63
+ || !(active === null || (typeof active?.tokenSha256 === 'string' && TELEMETRY_TOKEN_SHA256.test(active.tokenSha256) && typeof active.createdAt === 'string'))
64
+ || !(flush === null || (typeof flush?.storedAt === 'string' && typeof flush.buildId === 'string'))) {
65
+ throw new Error(`Haystack API ${TELEMETRY_SERVER_STATUS_PATH} answered something that is not a ${TELEMETRY_TOKEN_VERSION} status for ${repository}.`);
66
+ }
67
+ return value;
68
+ }
69
+ function activeElsewhere(repository, active, file) {
70
+ console.log(chalk.yellow(`${repository} already has an active telemetry token (minted ${active.createdAt}, SHA-256 `
71
+ + `${active.tokenSha256.slice(0, 12)}…), and ${file} does not hold it.`));
72
+ console.log('Nothing was minted. If production already has that token, nothing more is needed. If it is lost, run this command again '
73
+ + 'with --rotate: the new token replaces it, and the old one stops working at once.');
74
+ process.exitCode = 3;
75
+ }
76
+ function environmentLines(endpoint, file) {
77
+ return [
78
+ 'Set these in production\'s server environment (never a public or client-side variable), then restart it:',
79
+ ' HAYSTACK_TELEMETRY=1',
80
+ ` HAYSTACK_TELEMETRY_ENDPOINT=${endpoint}`,
81
+ ` HAYSTACK_TELEMETRY_TOKEN=<the contents of ${file}>`,
82
+ 'HAYSTACK_TELEMETRY=0 turns server telemetry off again at the next restart.',
83
+ ];
84
+ }
85
+ /** True when process `pid` is running (EPERM: it runs as someone else). */
86
+ function running(pid) {
87
+ try {
88
+ process.kill(pid, 0);
89
+ return true;
90
+ }
91
+ catch (error) {
92
+ return error.code === 'EPERM';
93
+ }
94
+ }
95
+ /** Exclusive use of `file` for this run (`<file>.lock`, created exclusively, naming this process), so two runs can never
96
+ * interleave a mint with a rename and leave the file holding a token the other run revoked. A lock whose process is gone
97
+ * is taken over. Returns the release. */
98
+ function lockTokenFile(file) {
99
+ const lock = `${file}.lock`;
100
+ for (let attempt = 0; attempt < 2; attempt += 1) {
101
+ try {
102
+ writeFileSync(lock, String(process.pid), { flag: 'wx', mode: 0o600 });
103
+ return () => unlinkSync(lock);
104
+ }
105
+ catch (error) {
106
+ if (error.code !== 'EEXIST')
107
+ throw error;
108
+ const holder = Number(readFileSync(lock, 'utf8'));
109
+ if (Number.isSafeInteger(holder) && holder > 0 && running(holder)) {
110
+ throw new Error(`Another \`haystack telemetry token\` (process ${holder}) is writing ${file}; wait for it to finish.`);
111
+ }
112
+ unlinkSync(lock);
113
+ }
114
+ }
115
+ throw new Error(`Could not take ${lock}.`);
116
+ }
117
+ /** Tokens a run left beside `file` because its answer was lost (`<file>.<random>.pending`). */
118
+ function pendingTokens(file) {
119
+ const prefix = `${basename(file)}.`;
120
+ return readdirSync(dirname(file)).filter(name => name.startsWith(prefix) && name.endsWith('.pending')).map(name => join(dirname(file), name));
121
+ }
122
+ /** A failure after which Haystack provably recorded nothing: it answered and refused (4xx), before or by the write. */
123
+ function provenNotRecorded(error) {
124
+ return error instanceof HaystackApiError && error.status >= 400 && error.status < 500;
125
+ }
126
+ export async function telemetryTokenCommand(options) {
127
+ const match = options.repo === undefined ? null : REPOSITORY.exec(options.repo);
128
+ if (!match)
129
+ throw new Error('Name the repository: `haystack telemetry token --repo <owner/name>`.');
130
+ const repository = options.repo;
131
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
132
+ throw new Error('Run `haystack telemetry token` yourself, in your own terminal: it creates a production credential, so a coding agent '
133
+ + 'or a script never runs it.');
134
+ }
135
+ const [, owner, name] = match;
136
+ const file = resolve(options.out ?? join(homedir(), '.haystack', 'telemetry-ingest-tokens', `${owner}__${name}-${TELEMETRY_ENVIRONMENT}.token`));
137
+ const auth = await resolveAuthContext({ preferredLogin: options.account, owner, repo: name });
138
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
139
+ const release = lockTokenFile(file);
140
+ try {
141
+ await mintUnderLock(repository, file, auth.token, options);
142
+ }
143
+ finally {
144
+ release();
145
+ }
146
+ }
147
+ async function mintUnderLock(repository, file, authToken, options) {
148
+ // An earlier run's token whose answer was lost is settled first: the active token (that run's mint went through, so
149
+ // this file becomes it) or not (it never authenticated, or no longer does: removed).
150
+ const pending = pendingTokens(file);
151
+ if (pending.length > 0) {
152
+ const status = await readTelemetryServerStatus(repository, authToken);
153
+ for (const path of pending) {
154
+ if (status.token !== null && sha256(readFileSync(path, 'utf8')) === status.token.tokenSha256) {
155
+ renameSync(path, file);
156
+ chmodSync(file, 0o600);
157
+ console.log(chalk.green(`An earlier run's token for ${status.repository} did become active (minted ${status.token.createdAt}); ${file} now holds it.`));
158
+ }
159
+ else {
160
+ unlinkSync(path);
161
+ }
162
+ }
163
+ }
164
+ if (!options.rotate) {
165
+ // While a token is active nothing is minted: the file either holds it (nothing to do) or does not (--rotate replaces
166
+ // it). With none active, a stale default file (this command's own) is replaced; a file named with --out never is.
167
+ const status = await readTelemetryServerStatus(repository, authToken);
168
+ const held = existsSync(file) ? sha256(readFileSync(file, 'utf8')) : null;
169
+ if (status.token !== null && status.token.tokenSha256 === held) {
170
+ console.log(chalk.green(`${status.repository} already has an active telemetry token (minted ${status.token.createdAt}); ${file} holds it.`));
171
+ for (const line of environmentLines(status.endpoint, file))
172
+ console.log(line);
173
+ console.log(chalk.dim('Nothing was minted. `--rotate` replaces the token.'));
174
+ return;
175
+ }
176
+ if (status.token !== null) {
177
+ activeElsewhere(status.repository, status.token, file);
178
+ return;
179
+ }
180
+ if (options.out !== undefined && held !== null) {
181
+ throw new Error(`${file} exists and holds no active token of ${repository}. Name a new file with --out, or remove that one.`);
182
+ }
183
+ }
184
+ console.log(`${options.rotate ? 'Replace' : 'Mint'} the server-telemetry ingest token for ${chalk.bold(repository)} (${TELEMETRY_ENVIRONMENT}).`);
185
+ console.log(chalk.dim(`The token is written to ${file} (readable only by you) and never printed; Haystack keeps only its SHA-256.`));
186
+ if (options.rotate)
187
+ console.log(chalk.yellow('The active token stops working as soon as this one is minted.'));
188
+ if (!await confirmed('Continue? [y/N] ')) {
189
+ console.log('Nothing was minted.');
190
+ process.exitCode = 2;
191
+ return;
192
+ }
193
+ const token = randomBytes(32).toString('hex');
194
+ const tokenSha256 = sha256(token);
195
+ // The file first: Haystack never records a hash whose token was not saved.
196
+ const pendingPath = `${file}.${randomBytes(6).toString('hex')}.pending`;
197
+ writeFileSync(pendingPath, token, { flag: 'wx', mode: 0o600 });
198
+ chmodSync(pendingPath, 0o600);
199
+ let answer;
200
+ try {
201
+ const request = { repository, tokenSha256, rotate: options.rotate === true };
202
+ const response = await gatewayFetch(`${TELEMETRY_TOKEN_PATH}?repository=${encodeURIComponent(repository)}`, authToken, {
203
+ method: 'POST', body: JSON.stringify(request),
204
+ });
205
+ if (!response.ok)
206
+ throw await classifyHttpError(response, `Haystack API ${TELEMETRY_TOKEN_PATH}`);
207
+ answer = parseAnswer(await response.json(), repository);
208
+ }
209
+ catch (error) {
210
+ // Only a refusal proves nothing was recorded. Anything else (no answer, a server error, an unreadable answer) may
211
+ // follow a write that went through, and this file is then the only copy of the active token: it is kept, and the
212
+ // next run settles it.
213
+ if (provenNotRecorded(error)) {
214
+ unlinkSync(pendingPath);
215
+ throw error;
216
+ }
217
+ throw new Error(`${error instanceof Error ? error.message : String(error)} The request may have reached Haystack, so the token is kept in `
218
+ + `${pendingPath} (mode 0600). Run this command again: it finds out whether that token became active and keeps or removes it.`);
219
+ }
220
+ if (answer.status === 'active' && answer.tokenSha256 !== tokenSha256) {
221
+ // Another mint won since the status read: nothing was recorded for this token.
222
+ unlinkSync(pendingPath);
223
+ activeElsewhere(answer.repository, answer, file);
224
+ return;
225
+ }
226
+ // Minted (or the answer to a retry of this very mint): this file is now the active token's.
227
+ renameSync(pendingPath, file);
228
+ chmodSync(file, 0o600);
229
+ console.log(chalk.green(`Minted the telemetry ingest token for ${answer.repository} (${TELEMETRY_ENVIRONMENT}).`));
230
+ console.log(` token file: ${file} (mode 0600; the token is not printed)`);
231
+ console.log(` SHA-256: ${tokenSha256} (all Haystack keeps)`);
232
+ if (answer.status === 'minted' && answer.replaced)
233
+ console.log(chalk.yellow(` replaced: ${answer.replaced.slice(0, 12)}… no longer authenticates`));
234
+ for (const line of environmentLines(answer.endpoint, file))
235
+ console.log(line);
236
+ console.log(chalk.dim('Keep the file private until production has the token. Running this command again names the active token; '
237
+ + '--rotate replaces it.'));
238
+ }