@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.
- package/dist/assets/skills/install-verification.md +67 -0
- package/dist/assets/skills/submit.md +14 -1
- package/dist/commands/triage.js +98 -17
- package/dist/commands/verification.d.ts +21 -0
- package/dist/commands/verification.js +291 -0
- package/dist/commands/webhooks.d.ts +15 -3
- package/dist/commands/webhooks.js +49 -15
- package/dist/index.js +142 -8
- package/dist/verification/contract.d.ts +240 -0
- package/dist/verification/contract.js +115 -0
- package/dist/verification/init.d.ts +10 -0
- package/dist/verification/init.js +243 -0
- package/dist/verification/manifest.d.ts +49 -0
- package/dist/verification/manifest.js +186 -0
- package/dist/verification/runner.d.ts +20 -0
- package/dist/verification/runner.js +102 -0
- package/dist/verification/safety.d.ts +10 -0
- package/dist/verification/safety.js +252 -0
- package/dist/verification/validate.d.ts +12 -0
- package/dist/verification/validate.js +219 -0
- package/package.json +7 -3
|
@@ -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
|
|
15
|
-
* side;
|
|
16
|
-
*
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
+
}
|