@haystackeditor/cli 0.15.14 → 0.15.16

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.
@@ -11,9 +11,11 @@
11
11
  * to create the registration row and obtain the HMAC secret.
12
12
  *
13
13
  * Remote queries (`list`, `deliveries`, `rotate-secret`, `replay`, etc.)
14
- * Hit the worker's /admin endpoints. Auth is CF Access on the worker
15
- * side; the CLI relies on the operator having a CF Access session
16
- * (cookie or service-token).
14
+ * Hit the worker's /admin endpoints with the user's Bearer token
15
+ * (introspected server-side; push access to the target repo required).
16
+ * Account resolution is repo-scoped so `haystack login` repoDefaults
17
+ * apply; id-based commands accept --repo when run outside the repo's
18
+ * checkout.
17
19
  *
18
20
  * Secret handling:
19
21
  * The worker returns the freshly-minted HMAC secret ONCE, on register or
@@ -24,7 +26,7 @@
24
26
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
25
27
  import { resolve } from 'node:path';
26
28
  import chalk from 'chalk';
27
- import { parseRemoteUrl } from '../utils/git.js';
29
+ import { parseRemoteUrl, UnsupportedRemoteUrlError } from '../utils/git.js';
28
30
  import { loadToken } from '../utils/auth.js';
29
31
  const DEFAULT_WORKER_URL = process.env.HAYSTACK_WEBHOOKS_URL ??
30
32
  'https://haystack-webhooks.akshaysg.workers.dev';
@@ -57,8 +59,40 @@ function currentRepo() {
57
59
  const r = parseRemoteUrl('origin');
58
60
  return `${r.owner}/${r.repo}`;
59
61
  }
60
- async function adminFetch(path, init = {}) {
61
- const token = await loadToken();
62
+ /** currentRepo() for token-account resolution. Outside a git repo there is
63
+ * simply no repo context — expected for id-based commands run from anywhere,
64
+ * so that case stays quiet. A remote that EXISTS but isn't recognized is a
65
+ * different story: repo-scoped account defaults silently won't apply, so say
66
+ * so (classified by error type per PR007). */
67
+ export function tryCurrentRepo() {
68
+ try {
69
+ return currentRepo();
70
+ }
71
+ catch (err) {
72
+ if (err instanceof UnsupportedRemoteUrlError) {
73
+ process.stderr.write(chalk.dim(` [origin remote not recognized (${err.message}); using the active account, not repo defaults]\n`));
74
+ }
75
+ return null;
76
+ }
77
+ }
78
+ /** Token-resolution scope for adminFetch: explicit repo wins, else the cwd's
79
+ * origin remote, else no scope (active account). Exported for tests. */
80
+ export function resolveTokenScope(repoFullName) {
81
+ const target = repoFullName ?? tryCurrentRepo();
82
+ if (!target || !target.includes('/'))
83
+ return {};
84
+ const [owner, repo] = target.split('/');
85
+ if (!owner || !repo)
86
+ return {};
87
+ return { owner, repo };
88
+ }
89
+ async function adminFetch(path, init = {}, repoFullName) {
90
+ // Resolve the token FOR THE TARGET REPO so `haystack login`'s repoDefaults
91
+ // apply, matching every other repo-scoped command (triage, submit). A bare
92
+ // loadToken() used the active account regardless of repo — with multiple
93
+ // saved accounts, register/list could 403 with "no push access" even
94
+ // though the repo's default account had admin (found 2026-07-06).
95
+ const token = await loadToken(resolveTokenScope(repoFullName));
62
96
  const headers = {
63
97
  'Content-Type': 'application/json',
64
98
  ...(init.headers ?? {}),
@@ -99,7 +133,7 @@ export async function registerWebhook(opts) {
99
133
  const res = await adminFetch('/admin/registrations', {
100
134
  method: 'POST',
101
135
  body: { repo, url: opts.url, events },
102
- });
136
+ }, repo);
103
137
  const data = (await readJsonOrFail(res));
104
138
  // Update .haystack.json. Append; do not overwrite an existing entry — the
105
139
  // operator may want multiple webhooks pointing at different endpoints.
@@ -132,7 +166,7 @@ export async function registerWebhook(opts) {
132
166
  // ─── list ────────────────────────────────────────────────────────────────────
133
167
  export async function listWebhooks(opts) {
134
168
  const repo = opts.repo ?? currentRepo();
135
- const res = await adminFetch(`/admin/registrations?repo=${encodeURIComponent(repo)}`);
169
+ const res = await adminFetch(`/admin/registrations?repo=${encodeURIComponent(repo)}`, {}, repo);
136
170
  const data = (await readJsonOrFail(res));
137
171
  if (opts.json) {
138
172
  console.log(JSON.stringify(data, null, 2));
@@ -154,10 +188,10 @@ export async function listWebhooks(opts) {
154
188
  }
155
189
  }
156
190
  // ─── rotate-secret ───────────────────────────────────────────────────────────
157
- export async function rotateWebhookSecret(id) {
191
+ export async function rotateWebhookSecret(id, repo) {
158
192
  const res = await adminFetch(`/admin/registrations/${encodeURIComponent(id)}/rotate-secret`, {
159
193
  method: 'POST',
160
- });
194
+ }, repo);
161
195
  const data = (await readJsonOrFail(res));
162
196
  console.log(chalk.green('✓ Secret rotated'));
163
197
  console.log('');
@@ -167,11 +201,11 @@ export async function rotateWebhookSecret(id) {
167
201
  console.log(chalk.red.bold('The previous secret is now invalid. Update your receiver before the next event fires.'));
168
202
  }
169
203
  // ─── enable / disable ────────────────────────────────────────────────────────
170
- export async function setWebhookEnabled(id, enabled) {
204
+ export async function setWebhookEnabled(id, enabled, repo) {
171
205
  const action = enabled ? 'enable' : 'disable';
172
206
  const res = await adminFetch(`/admin/registrations/${encodeURIComponent(id)}/${action}`, {
173
207
  method: 'POST',
174
- });
208
+ }, repo);
175
209
  await readJsonOrFail(res);
176
210
  console.log(chalk.green(`✓ Webhook ${id} ${action}d`));
177
211
  }
@@ -179,7 +213,7 @@ export async function setWebhookEnabled(id, enabled) {
179
213
  export async function listDeliveries(opts) {
180
214
  const repo = opts.repo ?? currentRepo();
181
215
  const limit = opts.limit ?? 20;
182
- const res = await adminFetch(`/admin/deliveries?repo=${encodeURIComponent(repo)}&limit=${limit}`);
216
+ const res = await adminFetch(`/admin/deliveries?repo=${encodeURIComponent(repo)}&limit=${limit}`, {}, repo);
183
217
  const data = (await readJsonOrFail(res));
184
218
  if (opts.json) {
185
219
  console.log(JSON.stringify(data, null, 2));
@@ -201,10 +235,10 @@ export async function listDeliveries(opts) {
201
235
  console.log(` ${chalk.dim('error:')} ${d.last_error}`);
202
236
  }
203
237
  }
204
- export async function replayDelivery(id) {
238
+ export async function replayDelivery(id, repo) {
205
239
  const res = await adminFetch(`/admin/deliveries/${encodeURIComponent(id)}/replay`, {
206
240
  method: 'POST',
207
- });
241
+ }, repo);
208
242
  await readJsonOrFail(res);
209
243
  console.log(chalk.green(`✓ Replay queued for ${id}`));
210
244
  }
package/dist/index.js CHANGED
@@ -41,6 +41,7 @@ import { setupCommand } from './commands/setup.js';
41
41
  import { schemaCommand, listSchemas } from './commands/schema-cmd.js';
42
42
  import { registerWebhook, listWebhooks, rotateWebhookSecret, setWebhookEnabled, listDeliveries, replayDelivery, } from './commands/webhooks.js';
43
43
  import { editRulesCommand, validateRulesCommand } from './commands/rules.js';
44
+ import { verificationInitCommand, verificationValidateCommand, verificationPreflightCommand, verificationRunCommand, verificationCollectCommand, verificationCheckSafetyCommand, } from './commands/verification.js';
44
45
  import { headlessLoginCommand, headlessLogoutCommand, listTokensCommand, revokeTokenCommand, } from './commands/tokens.js';
45
46
  // `mcp` is imported lazily inside its command action (below), NOT at the top level.
46
47
  // It is the only module that pulls in `@modelcontextprotocol/sdk`; keeping it out of
@@ -731,9 +732,10 @@ webhooks
731
732
  webhooks
732
733
  .command('rotate-secret <id>')
733
734
  .description('Rotate the HMAC secret for a registration (prints the new secret once)')
734
- .action(async (id) => {
735
+ .option('--repo <owner/name>', 'Repo the registration belongs to (for account resolution outside its checkout)')
736
+ .action(async (id, opts) => {
735
737
  try {
736
- await rotateWebhookSecret(id);
738
+ await rotateWebhookSecret(id, opts.repo);
737
739
  }
738
740
  catch (err) {
739
741
  console.error(chalk.red('rotate-secret failed:'), err instanceof Error ? err.message : err);
@@ -743,9 +745,10 @@ webhooks
743
745
  webhooks
744
746
  .command('disable <id>')
745
747
  .description('Disable a registration (stops delivery; existing in-flight retries continue)')
746
- .action(async (id) => {
748
+ .option('--repo <owner/name>', 'Repo the registration belongs to (for account resolution outside its checkout)')
749
+ .action(async (id, opts) => {
747
750
  try {
748
- await setWebhookEnabled(id, false);
751
+ await setWebhookEnabled(id, false, opts.repo);
749
752
  }
750
753
  catch (err) {
751
754
  console.error(chalk.red('disable failed:'), err instanceof Error ? err.message : err);
@@ -755,9 +758,10 @@ webhooks
755
758
  webhooks
756
759
  .command('enable <id>')
757
760
  .description('Re-enable a previously disabled registration')
758
- .action(async (id) => {
761
+ .option('--repo <owner/name>', 'Repo the registration belongs to (for account resolution outside its checkout)')
762
+ .action(async (id, opts) => {
759
763
  try {
760
- await setWebhookEnabled(id, true);
764
+ await setWebhookEnabled(id, true, opts.repo);
761
765
  }
762
766
  catch (err) {
763
767
  console.error(chalk.red('enable failed:'), err instanceof Error ? err.message : err);
@@ -782,9 +786,10 @@ webhooks
782
786
  webhooks
783
787
  .command('replay <id>')
784
788
  .description('Replay a previous delivery (idempotent via X-Haystack-Delivery)')
785
- .action(async (id) => {
789
+ .option('--repo <owner/name>', 'Repo the delivery belongs to (for account resolution outside its checkout)')
790
+ .action(async (id, opts) => {
786
791
  try {
787
- await replayDelivery(id);
792
+ await replayDelivery(id, opts.repo);
788
793
  }
789
794
  catch (err) {
790
795
  console.error(chalk.red('replay failed:'), err instanceof Error ? err.message : err);
@@ -817,6 +822,135 @@ rules
817
822
  process.exit(1);
818
823
  }
819
824
  });
825
+ // ─── verification ────────────────────────────────────────────────────────────
826
+ //
827
+ // Repo-local verification contract (.haystack/verification.yml): how to boot
828
+ // the app, prove it's ready, log in as test personas, run scenarios, and
829
+ // collect evidence into a standard artifact manifest + review packet.
830
+ // See docs/VERIFICATION-CONTRACT.md.
831
+ const verification = program
832
+ .command('verification')
833
+ .description('Repo verification contract — validate, run scenarios, collect evidence');
834
+ verification
835
+ .command('init')
836
+ .description('Scaffold .haystack/verification.yml, preflight/smoke scripts, and the agent skill')
837
+ .option('-f, --force', 'Overwrite existing files')
838
+ .option('--skill', 'Only write the install-verification agent skill (hand the integration to a coding agent)')
839
+ .addHelpText('after', `
840
+ Creates:
841
+ .haystack/verification.yml the verification contract
842
+ scripts/haystack-preflight app-is-ready check
843
+ scripts/haystack-smoke boot + capture-evidence smoke scenario
844
+ .haystack/AGENTS.md how agents should use the contract
845
+ .haystack/skills/install-verification/SKILL.md skill for completing the integration
846
+
847
+ Detection fills in framework, package manager, dev command, port, and any
848
+ auth-bypass env var found in the repo's own env examples. Review the output —
849
+ detection is a starting point, not truth.
850
+ `)
851
+ .action(async (opts) => {
852
+ try {
853
+ await verificationInitCommand(opts);
854
+ }
855
+ catch (err) {
856
+ console.error(chalk.red('init failed:'), err instanceof Error ? err.message : err);
857
+ process.exit(1);
858
+ }
859
+ });
860
+ verification
861
+ .command('validate')
862
+ .description('Check whether the repo satisfies the verification contract')
863
+ .option('--json', 'Machine-readable report')
864
+ .addHelpText('after', `
865
+ Readiness verdicts:
866
+ ready all checks pass (exit 0)
867
+ partially_ready usable, with gaps (exit 0)
868
+ not_ready contract missing/invalid or commands broken (exit 1)
869
+ unsafe contract permits real side effects, or login helpers
870
+ lack a production-disabled proof (exit 2)
871
+ `)
872
+ .action(async (opts) => {
873
+ try {
874
+ await verificationValidateCommand(opts);
875
+ }
876
+ catch (err) {
877
+ console.error(chalk.red('validate failed:'), err instanceof Error ? err.message : err);
878
+ process.exit(1);
879
+ }
880
+ });
881
+ verification
882
+ .command('preflight')
883
+ .description('Run the contract preflight — is the app up and answering?')
884
+ .action(async () => {
885
+ try {
886
+ await verificationPreflightCommand();
887
+ }
888
+ catch (err) {
889
+ console.error(chalk.red('preflight failed:'), err instanceof Error ? err.message : err);
890
+ process.exit(1);
891
+ }
892
+ });
893
+ verification
894
+ .command('run <scenario>')
895
+ .description('Run a contract scenario and write the artifact manifest + review packet')
896
+ .option('--json', 'Print the manifest as JSON')
897
+ .option('--setup', 'Run environment.setup first (clean-clone mode)')
898
+ .option('--skip-preflight', 'Skip the preflight check')
899
+ .option('--timeout <seconds>', 'Override the scenario timeout', (v) => parseInt(v, 10))
900
+ .addHelpText('after', `
901
+ Runs preflight, then the scenario command, capturing stdout/stderr to the
902
+ artifact directory. Ends with a status:
903
+ verified scenario passed (exit 0)
904
+ failed scenario failed (exit 1)
905
+ environment_not_ready setup/preflight failed before the scenario ran (exit 1)
906
+ unsafe_to_verify safety policy permits real side effects (exit 2)
907
+
908
+ Evidence lands in the contract's artifact_dir:
909
+ manifest-<scenario>.json machine-readable evidence manifest
910
+ review-packet-<scenario>.md human-readable review packet
911
+ logs/ captured command output
912
+
913
+ Examples:
914
+ haystack verification run smoke
915
+ haystack verification run billing_upgrade --json
916
+ haystack verification run smoke --setup # clean-clone mode (CI)
917
+ `)
918
+ .action(async (scenario, opts) => {
919
+ try {
920
+ await verificationRunCommand(scenario, opts);
921
+ }
922
+ catch (err) {
923
+ console.error(chalk.red('run failed:'), err instanceof Error ? err.message : err);
924
+ process.exit(1);
925
+ }
926
+ });
927
+ verification
928
+ .command('collect-artifacts')
929
+ .description('Rebuild an evidence manifest from artifacts already on disk (status: inconclusive)')
930
+ .option('--scenario <name>', 'Scenario name to record in the manifest')
931
+ .option('--json', 'Print the manifest as JSON')
932
+ .action(async (opts) => {
933
+ try {
934
+ await verificationCollectCommand(opts);
935
+ }
936
+ catch (err) {
937
+ console.error(chalk.red('collect-artifacts failed:'), err instanceof Error ? err.message : err);
938
+ process.exit(1);
939
+ }
940
+ });
941
+ verification
942
+ .command('check-safety')
943
+ .description('Verify the verification setup is safe (policy, prod-disabled proof, dev-route gating, secret scan)')
944
+ .option('--json', 'Machine-readable report')
945
+ .action(async (opts) => {
946
+ try {
947
+ await verificationCheckSafetyCommand(opts);
948
+ }
949
+ catch (err) {
950
+ console.error(chalk.red('check-safety failed:'), err instanceof Error ? err.message : err);
951
+ process.exit(1);
952
+ }
953
+ });
820
954
  // ─── mcp ─────────────────────────────────────────────────────────────────────
821
955
  program
822
956
  .command('mcp')
@@ -0,0 +1,240 @@
1
+ import { z } from 'zod';
2
+ export declare const CONTRACT_RELATIVE_PATH = ".haystack/verification.yml";
3
+ export declare const DEFAULT_ARTIFACT_DIR = ".haystack/artifacts";
4
+ declare const scenarioSchema: z.ZodObject<{
5
+ command: z.ZodString;
6
+ description: z.ZodOptional<z.ZodString>;
7
+ /** Free-form labels of what the scenario captures (screenshots, server_logs, ...). Informational. */
8
+ artifacts: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
9
+ /** Wall-clock cap for the scenario command. Default applied by the runner. */
10
+ timeout_seconds: z.ZodOptional<z.ZodNumber>;
11
+ /**
12
+ * Whether `run` gates this scenario on environment.preflight. Set false for
13
+ * self-contained scenarios that boot the app themselves (like the scaffolded
14
+ * smoke script) — preflight against a cold environment would always fail.
15
+ */
16
+ preflight: z.ZodDefault<z.ZodBoolean>;
17
+ }, "strip", z.ZodTypeAny, {
18
+ command: string;
19
+ preflight: boolean;
20
+ description?: string | undefined;
21
+ artifacts?: string[] | undefined;
22
+ timeout_seconds?: number | undefined;
23
+ }, {
24
+ command: string;
25
+ description?: string | undefined;
26
+ artifacts?: string[] | undefined;
27
+ timeout_seconds?: number | undefined;
28
+ preflight?: boolean | undefined;
29
+ }>;
30
+ declare const safetySchema: z.ZodObject<{
31
+ /** Command proving dev-only helpers are disabled in production builds. */
32
+ production_disabled_check: z.ZodOptional<z.ZodString>;
33
+ external_services: z.ZodOptional<z.ZodEnum<["sandbox_only", "mocked", "none"]>>;
34
+ allow_real_email: z.ZodDefault<z.ZodBoolean>;
35
+ allow_real_payments: z.ZodDefault<z.ZodBoolean>;
36
+ allow_customer_data: z.ZodDefault<z.ZodBoolean>;
37
+ }, "strip", z.ZodTypeAny, {
38
+ allow_real_email: boolean;
39
+ allow_real_payments: boolean;
40
+ allow_customer_data: boolean;
41
+ production_disabled_check?: string | undefined;
42
+ external_services?: "none" | "sandbox_only" | "mocked" | undefined;
43
+ }, {
44
+ production_disabled_check?: string | undefined;
45
+ external_services?: "none" | "sandbox_only" | "mocked" | undefined;
46
+ allow_real_email?: boolean | undefined;
47
+ allow_real_payments?: boolean | undefined;
48
+ allow_customer_data?: boolean | undefined;
49
+ }>;
50
+ export declare const verificationContractSchema: z.ZodObject<{
51
+ version: z.ZodEffects<z.ZodUnion<[z.ZodLiteral<1>, z.ZodLiteral<"1">]>, 1, 1 | "1">;
52
+ app: z.ZodObject<{
53
+ name: z.ZodString;
54
+ framework: z.ZodOptional<z.ZodString>;
55
+ }, "strip", z.ZodTypeAny, {
56
+ name: string;
57
+ framework?: string | undefined;
58
+ }, {
59
+ name: string;
60
+ framework?: string | undefined;
61
+ }>;
62
+ environment: z.ZodObject<{
63
+ setup: z.ZodOptional<z.ZodString>;
64
+ start: z.ZodOptional<z.ZodString>;
65
+ preflight: z.ZodString;
66
+ reset: z.ZodOptional<z.ZodString>;
67
+ artifact_dir: z.ZodDefault<z.ZodString>;
68
+ log_paths: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
69
+ }, "strip", z.ZodTypeAny, {
70
+ preflight: string;
71
+ artifact_dir: string;
72
+ log_paths: string[];
73
+ start?: string | undefined;
74
+ setup?: string | undefined;
75
+ reset?: string | undefined;
76
+ }, {
77
+ preflight: string;
78
+ start?: string | undefined;
79
+ setup?: string | undefined;
80
+ reset?: string | undefined;
81
+ artifact_dir?: string | undefined;
82
+ log_paths?: string[] | undefined;
83
+ }>;
84
+ personas: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
85
+ login: z.ZodString;
86
+ description: z.ZodOptional<z.ZodString>;
87
+ }, "strip", z.ZodTypeAny, {
88
+ login: string;
89
+ description?: string | undefined;
90
+ }, {
91
+ login: string;
92
+ description?: string | undefined;
93
+ }>>>;
94
+ scenarios: z.ZodEffects<z.ZodRecord<z.ZodString, z.ZodObject<{
95
+ command: z.ZodString;
96
+ description: z.ZodOptional<z.ZodString>;
97
+ /** Free-form labels of what the scenario captures (screenshots, server_logs, ...). Informational. */
98
+ artifacts: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
99
+ /** Wall-clock cap for the scenario command. Default applied by the runner. */
100
+ timeout_seconds: z.ZodOptional<z.ZodNumber>;
101
+ /**
102
+ * Whether `run` gates this scenario on environment.preflight. Set false for
103
+ * self-contained scenarios that boot the app themselves (like the scaffolded
104
+ * smoke script) — preflight against a cold environment would always fail.
105
+ */
106
+ preflight: z.ZodDefault<z.ZodBoolean>;
107
+ }, "strip", z.ZodTypeAny, {
108
+ command: string;
109
+ preflight: boolean;
110
+ description?: string | undefined;
111
+ artifacts?: string[] | undefined;
112
+ timeout_seconds?: number | undefined;
113
+ }, {
114
+ command: string;
115
+ description?: string | undefined;
116
+ artifacts?: string[] | undefined;
117
+ timeout_seconds?: number | undefined;
118
+ preflight?: boolean | undefined;
119
+ }>>, Record<string, {
120
+ command: string;
121
+ preflight: boolean;
122
+ description?: string | undefined;
123
+ artifacts?: string[] | undefined;
124
+ timeout_seconds?: number | undefined;
125
+ }>, Record<string, {
126
+ command: string;
127
+ description?: string | undefined;
128
+ artifacts?: string[] | undefined;
129
+ timeout_seconds?: number | undefined;
130
+ preflight?: boolean | undefined;
131
+ }>>;
132
+ safety: z.ZodObject<{
133
+ /** Command proving dev-only helpers are disabled in production builds. */
134
+ production_disabled_check: z.ZodOptional<z.ZodString>;
135
+ external_services: z.ZodOptional<z.ZodEnum<["sandbox_only", "mocked", "none"]>>;
136
+ allow_real_email: z.ZodDefault<z.ZodBoolean>;
137
+ allow_real_payments: z.ZodDefault<z.ZodBoolean>;
138
+ allow_customer_data: z.ZodDefault<z.ZodBoolean>;
139
+ }, "strip", z.ZodTypeAny, {
140
+ allow_real_email: boolean;
141
+ allow_real_payments: boolean;
142
+ allow_customer_data: boolean;
143
+ production_disabled_check?: string | undefined;
144
+ external_services?: "none" | "sandbox_only" | "mocked" | undefined;
145
+ }, {
146
+ production_disabled_check?: string | undefined;
147
+ external_services?: "none" | "sandbox_only" | "mocked" | undefined;
148
+ allow_real_email?: boolean | undefined;
149
+ allow_real_payments?: boolean | undefined;
150
+ allow_customer_data?: boolean | undefined;
151
+ }>;
152
+ }, "strip", z.ZodTypeAny, {
153
+ version: 1;
154
+ app: {
155
+ name: string;
156
+ framework?: string | undefined;
157
+ };
158
+ environment: {
159
+ preflight: string;
160
+ artifact_dir: string;
161
+ log_paths: string[];
162
+ start?: string | undefined;
163
+ setup?: string | undefined;
164
+ reset?: string | undefined;
165
+ };
166
+ personas: Record<string, {
167
+ login: string;
168
+ description?: string | undefined;
169
+ }>;
170
+ scenarios: Record<string, {
171
+ command: string;
172
+ preflight: boolean;
173
+ description?: string | undefined;
174
+ artifacts?: string[] | undefined;
175
+ timeout_seconds?: number | undefined;
176
+ }>;
177
+ safety: {
178
+ allow_real_email: boolean;
179
+ allow_real_payments: boolean;
180
+ allow_customer_data: boolean;
181
+ production_disabled_check?: string | undefined;
182
+ external_services?: "none" | "sandbox_only" | "mocked" | undefined;
183
+ };
184
+ }, {
185
+ version: 1 | "1";
186
+ app: {
187
+ name: string;
188
+ framework?: string | undefined;
189
+ };
190
+ environment: {
191
+ preflight: string;
192
+ start?: string | undefined;
193
+ setup?: string | undefined;
194
+ reset?: string | undefined;
195
+ artifact_dir?: string | undefined;
196
+ log_paths?: string[] | undefined;
197
+ };
198
+ scenarios: Record<string, {
199
+ command: string;
200
+ description?: string | undefined;
201
+ artifacts?: string[] | undefined;
202
+ timeout_seconds?: number | undefined;
203
+ preflight?: boolean | undefined;
204
+ }>;
205
+ safety: {
206
+ production_disabled_check?: string | undefined;
207
+ external_services?: "none" | "sandbox_only" | "mocked" | undefined;
208
+ allow_real_email?: boolean | undefined;
209
+ allow_real_payments?: boolean | undefined;
210
+ allow_customer_data?: boolean | undefined;
211
+ };
212
+ personas?: Record<string, {
213
+ login: string;
214
+ description?: string | undefined;
215
+ }> | undefined;
216
+ }>;
217
+ export type VerificationContract = z.infer<typeof verificationContractSchema>;
218
+ export type VerificationScenario = z.infer<typeof scenarioSchema>;
219
+ export type VerificationSafety = z.infer<typeof safetySchema>;
220
+ export type ContractLoadResult = {
221
+ status: 'ok';
222
+ contract: VerificationContract;
223
+ path: string;
224
+ } | {
225
+ status: 'missing';
226
+ path: string;
227
+ } | {
228
+ status: 'invalid';
229
+ path: string;
230
+ errors: string[];
231
+ };
232
+ /**
233
+ * Load and validate the contract from `<rootDir>/.haystack/verification.yml`.
234
+ * Never throws — parse/schema failures come back as `status: 'invalid'` so
235
+ * callers can map them onto readiness / manifest statuses.
236
+ */
237
+ export declare function loadContract(rootDir?: string): Promise<ContractLoadResult>;
238
+ /** Resolve the artifact directory to an absolute path, creating it if needed. */
239
+ export declare function ensureArtifactDir(contract: VerificationContract, rootDir?: string): Promise<string>;
240
+ export {};
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Haystack Verification Contract — `.haystack/verification.yml`
3
+ *
4
+ * The contract is the repo-local answer to five questions:
5
+ * 1. How do I set up and start the app?
6
+ * 2. How do I know the app is ready? (preflight)
7
+ * 3. How do I log in as useful test users? (personas)
8
+ * 4. Which verification flows can I run? (scenarios)
9
+ * 5. Where do logs, screenshots, and artifacts go?
10
+ *
11
+ * This is deliberately separate from `.haystack.json`: that file configures
12
+ * Haystack's own sandbox verification (services/flows/fixtures the sandbox
13
+ * drives). The contract instead describes commands the REPO owns — anything
14
+ * (Haystack, CI, a coding agent, a human) can run them and get the same
15
+ * evidence. See docs/VERIFICATION-CONTRACT.md.
16
+ */
17
+ import * as fs from 'node:fs/promises';
18
+ import * as path from 'node:path';
19
+ import { parse as parseYaml } from 'yaml';
20
+ import { z } from 'zod';
21
+ export const CONTRACT_RELATIVE_PATH = '.haystack/verification.yml';
22
+ export const DEFAULT_ARTIFACT_DIR = '.haystack/artifacts';
23
+ const commandString = z.string().min(1, 'command must be a non-empty string');
24
+ const personaSchema = z.object({
25
+ login: commandString.describe('Command that logs the given persona in (or prints credentials)'),
26
+ description: z.string().optional(),
27
+ });
28
+ const scenarioSchema = z.object({
29
+ command: commandString,
30
+ description: z.string().optional(),
31
+ /** Free-form labels of what the scenario captures (screenshots, server_logs, ...). Informational. */
32
+ artifacts: z.array(z.string()).optional(),
33
+ /** Wall-clock cap for the scenario command. Default applied by the runner. */
34
+ timeout_seconds: z.number().int().positive().max(7200).optional(),
35
+ /**
36
+ * Whether `run` gates this scenario on environment.preflight. Set false for
37
+ * self-contained scenarios that boot the app themselves (like the scaffolded
38
+ * smoke script) — preflight against a cold environment would always fail.
39
+ */
40
+ preflight: z.boolean().default(true),
41
+ });
42
+ const safetySchema = z.object({
43
+ /** Command proving dev-only helpers are disabled in production builds. */
44
+ production_disabled_check: commandString.optional(),
45
+ external_services: z.enum(['sandbox_only', 'mocked', 'none']).optional(),
46
+ allow_real_email: z.boolean().default(false),
47
+ allow_real_payments: z.boolean().default(false),
48
+ allow_customer_data: z.boolean().default(false),
49
+ });
50
+ export const verificationContractSchema = z.object({
51
+ // Accept 1 or "1" — YAML authors will write both.
52
+ version: z
53
+ .union([z.literal(1), z.literal('1')])
54
+ .transform(() => 1),
55
+ app: z.object({
56
+ name: commandString,
57
+ framework: z.string().optional(),
58
+ }),
59
+ environment: z.object({
60
+ setup: commandString.optional(),
61
+ start: commandString.optional(),
62
+ preflight: commandString,
63
+ reset: commandString.optional(),
64
+ artifact_dir: z.string().default(DEFAULT_ARTIFACT_DIR),
65
+ log_paths: z.array(z.string()).default([]),
66
+ }),
67
+ personas: z.record(personaSchema).default({}),
68
+ scenarios: z
69
+ .record(scenarioSchema)
70
+ .refine((s) => Object.keys(s).length > 0, {
71
+ message: 'at least one scenario is required',
72
+ }),
73
+ safety: safetySchema,
74
+ });
75
+ /**
76
+ * Load and validate the contract from `<rootDir>/.haystack/verification.yml`.
77
+ * Never throws — parse/schema failures come back as `status: 'invalid'` so
78
+ * callers can map them onto readiness / manifest statuses.
79
+ */
80
+ export async function loadContract(rootDir = process.cwd()) {
81
+ const contractPath = path.join(rootDir, CONTRACT_RELATIVE_PATH);
82
+ let raw;
83
+ try {
84
+ raw = await fs.readFile(contractPath, 'utf-8');
85
+ }
86
+ catch {
87
+ return { status: 'missing', path: contractPath };
88
+ }
89
+ let parsed;
90
+ try {
91
+ parsed = parseYaml(raw);
92
+ }
93
+ catch (err) {
94
+ return {
95
+ status: 'invalid',
96
+ path: contractPath,
97
+ errors: [`YAML parse error: ${err instanceof Error ? err.message : String(err)}`],
98
+ };
99
+ }
100
+ const result = verificationContractSchema.safeParse(parsed);
101
+ if (!result.success) {
102
+ return {
103
+ status: 'invalid',
104
+ path: contractPath,
105
+ errors: result.error.issues.map((issue) => `${issue.path.length ? issue.path.join('.') : '(root)'}: ${issue.message}`),
106
+ };
107
+ }
108
+ return { status: 'ok', contract: result.data, path: contractPath };
109
+ }
110
+ /** Resolve the artifact directory to an absolute path, creating it if needed. */
111
+ export async function ensureArtifactDir(contract, rootDir = process.cwd()) {
112
+ const dir = path.resolve(rootDir, contract.environment.artifact_dir);
113
+ await fs.mkdir(path.join(dir, 'logs'), { recursive: true });
114
+ return dir;
115
+ }