@haystackeditor/cli 0.24.1 → 0.25.1

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 (51) 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 +134 -0
  6. package/dist/capture/adapters/files.js +77 -0
  7. package/dist/capture/adapters/index.js +74 -0
  8. package/dist/capture/adapters/jsx-edit.js +81 -0
  9. package/dist/capture/adapters/next-build.js +113 -0
  10. package/dist/capture/adapters/next.js +494 -0
  11. package/dist/capture/adapters/nuxt.js +199 -0
  12. package/dist/capture/adapters/rails.js +178 -0
  13. package/dist/capture/adapters/react-router.js +439 -0
  14. package/dist/capture/adapters/sveltekit.js +109 -0
  15. package/dist/capture/adapters/types.js +4 -0
  16. package/dist/capture/adapters/vite.js +135 -0
  17. package/dist/capture/app-config.js +107 -0
  18. package/dist/capture/consent.js +127 -0
  19. package/dist/capture/csp.js +332 -0
  20. package/dist/capture/html.js +74 -0
  21. package/dist/capture/js-ast.js +400 -0
  22. package/dist/capture/manifest.js +95 -0
  23. package/dist/capture/project.js +177 -0
  24. package/dist/capture/route-pattern.js +119 -0
  25. package/dist/capture/script-release.js +47 -0
  26. package/dist/capture/tag.js +74 -0
  27. package/dist/capture/url-rewrites.js +232 -0
  28. package/dist/capture-step.js +56 -0
  29. package/dist/commands/capture-brief.js +92 -0
  30. package/dist/commands/capture-contract.js +46 -0
  31. package/dist/commands/capture-manifest.js +86 -0
  32. package/dist/commands/init-capture.js +426 -0
  33. package/dist/commands/init-telemetry.js +1028 -0
  34. package/dist/commands/init.js +78 -5
  35. package/dist/commands/server-telemetry-contract.d.ts +66 -0
  36. package/dist/commands/server-telemetry-contract.js +127 -0
  37. package/dist/commands/telemetry-token.js +238 -0
  38. package/dist/commands/telemetry.d.ts +161 -8
  39. package/dist/commands/telemetry.js +940 -158
  40. package/dist/commands/verify-onboarding.js +21 -1
  41. package/dist/commands/verify.js +56 -9
  42. package/dist/index.js +85 -6
  43. package/dist/schema.js +2 -2
  44. package/dist/telemetry/next-loader.cjs +66 -9
  45. package/dist/telemetry/next.d.ts +11 -3
  46. package/dist/telemetry/next.js +95 -15
  47. package/dist/telemetry/typed-source.d.ts +47 -0
  48. package/dist/telemetry/typed-source.js +379 -0
  49. package/package.json +4 -2
  50. package/schemas/init.v1.json +63 -4
  51. 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';
@@ -300,6 +303,20 @@ function applyChange(gitRoot, planned) {
300
303
  assertNoSymlinkOnPath(gitRoot, planned.target);
301
304
  else if (isLink(planned.target))
302
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
+ }
303
320
  mkdirSync(dirname(planned.target), { recursive: true });
304
321
  writeFileSync(planned.target, planned.content);
305
322
  }
@@ -340,12 +357,28 @@ export async function initCommand(options) {
340
357
  // Read before anything else, so notes that cannot be used stop init with nothing done.
341
358
  const notes = options.notes === undefined ? null : readNotes(options.notes);
342
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, urlRewrites: options.urlRewrites };
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);
343
367
  let notesWritten = null;
368
+ let telemetry = null;
369
+ // Before the apply (a preview, or no login), the report is the preview: what telemetry will do, `received` unchecked (null).
370
+ const report = () => telemetry ?? telemetryModule.previewTelemetry(telemetryPlan, repository, notes);
371
+ const showPreview = () => {
372
+ const preview = telemetryModule.previewTelemetry(telemetryPlan, repository, notes);
373
+ for (const line of telemetryModule.formatTelemetry(preview.telemetry, preview.steps))
374
+ say(line);
375
+ say('');
376
+ };
344
377
  const finish = (status, onboarding, exitCode) => {
345
378
  if (options.json) {
346
379
  const changes = plan.changes.map(({ path, action, reason, diff }) => ({ path, action, reason, diff }));
347
380
  process.stdout.write(`${JSON.stringify(withSchema('init', { status, repository, changes, notices: plan.notices, notes: notesWritten,
348
- onboarding }), null, 2)}\n`);
381
+ onboarding, telemetry: report().telemetry, telemetrySteps: report().steps }), null, 2)}\n`);
349
382
  }
350
383
  process.exitCode = exitCode;
351
384
  };
@@ -354,7 +387,7 @@ export async function initCommand(options) {
354
387
  say(chalk.dim('Nothing in this checkout needs changing.'));
355
388
  for (const planned of plan.changes) {
356
389
  say('');
357
- say(`${chalk.bold(planned.action === 'create' ? 'Create' : 'Update')} ${planned.path}: ${planned.reason}`);
390
+ say(`${chalk.bold(planned.action === 'create' ? 'Create' : planned.action === 'delete' ? 'Delete' : 'Update')} ${planned.path}: ${planned.reason}`);
358
391
  for (const line of planned.diff)
359
392
  say(` ${colored(line)}`);
360
393
  }
@@ -372,6 +405,7 @@ export async function initCommand(options) {
372
405
  catch (error) {
373
406
  if (!(error instanceof NotLoggedInError))
374
407
  throw error;
408
+ showPreview();
375
409
  say(chalk.yellow('Not logged in, so nothing was changed. Run `haystack login`, then `haystack init` again.'));
376
410
  finish('login-required', null, EXIT_CODES['login-required']);
377
411
  return;
@@ -379,15 +413,50 @@ export async function initCommand(options) {
379
413
  if (plan.changes.length > 0 && !options.yes) {
380
414
  const interactive = !options.json && process.stdin.isTTY === true && process.stdout.isTTY === true;
381
415
  if (!interactive || !await confirmed()) {
416
+ showPreview();
382
417
  say('Nothing was changed. Run `haystack init --yes` to make these changes.');
383
418
  finish('planned', null, EXIT_CODES.planned);
384
419
  return;
385
420
  }
386
421
  }
422
+ // Browser capture's registration (rule 8b), which the preview announced: checked with Haystack now, and when the files
423
+ // need a new key the telemetry changes are planned again with it (or without capture's, when it is refused).
424
+ const approved = options.yes === true || plan.changes.length > 0;
425
+ const capture = telemetryPlan.captureRegistration
426
+ ? await telemetryModule.prepareCapture(telemetryPlan.captureRegistration, token, say, approved) : null;
427
+ if (capture?.replan) {
428
+ telemetryPlan = await telemetryModule.planTelemetry(gitRoot, repository, { ...telemetryOptions, registration: capture.replan });
429
+ plan.changes.splice(telemetryFrom, plan.changes.length - telemetryFrom, ...telemetryPlan.changes);
430
+ }
431
+ // All or nothing: a change that fails (an install that does not end as planned) restores every file the changes
432
+ // touched, so no edit is left referencing what was never installed.
433
+ // Bytes, not text: a lockfile can be binary (bun.lockb).
434
+ const before = new Map();
387
435
  for (const planned of plan.changes) {
388
- applyChange(gitRoot, planned);
389
- say(chalk.green(`✓ ${planned.action === 'create' ? 'Created' : 'Updated'} ${planned.path}`));
436
+ for (const path of [planned.target, ...(planned.run?.touches ?? [])]) {
437
+ if (!before.has(path))
438
+ before.set(path, existsSync(path) ? readFileSync(path) : null);
439
+ }
390
440
  }
441
+ try {
442
+ for (const planned of plan.changes) {
443
+ applyChange(gitRoot, planned);
444
+ say(chalk.green(`✓ ${planned.action === 'create' ? 'Created' : planned.action === 'delete' ? 'Deleted' : 'Updated'} ${planned.path}`));
445
+ }
446
+ }
447
+ catch (error) {
448
+ for (const [path, content] of before) {
449
+ if (content !== null)
450
+ writeFileSync(path, content);
451
+ else if (existsSync(path))
452
+ unlinkSync(path);
453
+ }
454
+ await capture?.rollback(say);
455
+ throw new Error(`${error instanceof Error ? error.message : String(error)} Every file init changed was restored; nothing was changed.`);
456
+ }
457
+ const captureFollowUp = await capture?.finish(say);
458
+ if (captureFollowUp)
459
+ telemetryPlan.capture = telemetryModule.withCaptureStep(telemetryPlan.capture, captureFollowUp);
391
460
  let onboarding;
392
461
  try {
393
462
  // The notes first: onboarding plans from the facts it starts with, and new notes are new facts (a new onboarding).
@@ -411,6 +480,10 @@ export async function initCommand(options) {
411
480
  reportOnboardingState(onboarding);
412
481
  say(formatOnboarding(onboarding));
413
482
  say('');
483
+ telemetry = await telemetryModule.telemetryReport(telemetryPlan, { repository, token, onboarding, notes });
484
+ for (const line of telemetryModule.formatTelemetry(telemetry.telemetry, telemetry.steps))
485
+ say(line);
486
+ say('');
414
487
  if (onboarding.state === 'ready')
415
488
  say(chalk.green('Ready. After your next change, run `haystack verify`.'));
416
489
  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
+ }