blastproof 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  <h1>
2
2
  <picture>
3
- <source media="(prefers-color-scheme: dark)" srcset="./.github/logo-dark.svg">
4
- <img src="./.github/logo-light.svg" alt="" width="30" height="30">
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/hamc/blastproof/main/.github/logo-dark.svg">
4
+ <img src="https://raw.githubusercontent.com/hamc/blastproof/main/.github/logo-light.svg" alt="" width="30" height="30">
5
5
  </picture>
6
6
  blastproof
7
7
  </h1>
@@ -18,10 +18,29 @@ git diff → impact mapping → test generation → agentic execution → report
18
18
 
19
19
  100% local. MIT. Bring your own LLM key.
20
20
 
21
+ <img src="https://raw.githubusercontent.com/hamc/blastproof/main/.github/dogfood.gif" width="100%"
22
+ alt="A terminal running blastproof against this repo's demo app. It passes four steps, then fails
23
+ step 5: the page says the SAVE20 promo gave 20% off, but the discount shown is -$6.00 where
24
+ 20% of $120.00 would be -$24.00. Score: 0.">
25
+
21
26
  ▶ **[Watch the introduction](https://www.youtube.com/shorts/miqN5FzMF_k)** — what it does, in a minute.
22
27
 
23
28
  **Documentation:** [Configuration](./docs/configuration.md) · [Testing behind a login](./docs/auth.md) · [Running in CI](./docs/ci.md) · [Contributing](./CONTRIBUTING.md) · [Architecture](./AGENTS.md)
24
29
 
30
+ ## Set it up with your coding agent
31
+
32
+ Most of what the setup asks — which stack, which port, which journeys matter — is a question your coding agent can already answer by looking at your project. So let it answer them:
33
+
34
+ ```bash
35
+ npx skills add hamc/blastproof
36
+ ```
37
+
38
+ Then tell your agent: **"set up e2e tests"**. The skill installs for whichever agents you have — Claude Code, Cursor, Codex and others — and walks the whole path: check whether your markup is reachable at all, pick a provider, scaffold, generate drafts from your *running* app, curate them, run them, and write down the accessibility constraints that keep the suite alive as the project grows.
39
+
40
+ Two things it deliberately will not do, so their absence does not read as a defect: it does not configure [authentication](./docs/auth.md), and it does not wire up [CI](./docs/ci.md). Both are worth doing after you have seen a green run on your own machine.
41
+
42
+ The skill lives in [`skills/blastproof/`](./skills/blastproof/) and is worth reading even if you never install it. [`references/authoring.md`](./skills/blastproof/references/authoring.md) sets out what separates a test that detects something from one that reports Score 100 and detects nothing — including two drafts this tool generated that passed unedited and were worth nothing.
43
+
25
44
  ## Quick start
26
45
 
27
46
  ```bash
@@ -89,7 +108,7 @@ It is **not** a guarantee of zero duplicate writes. An agent that reaches the sa
89
108
 
90
109
  ## How it works
91
110
 
92
- <img src="./.github/coverage-flow.svg" width="100%"
111
+ <img src="https://raw.githubusercontent.com/hamc/blastproof/main/.github/coverage-flow.svg" width="100%"
93
112
  alt="How a diff becomes a merge decision. In CI, unattended: changed files are matched against the routes: and ignore: globs; matched files contribute affected routes, files matching neither are reported as unclassified and fail the run only under --fail-on-unmapped. Tests declaring an affected route are executed and produce a weighted score, which --min-score gates on. An affected route no test declares is reported as a coverage gap and never fails the run. Separately and manually, outside CI: blastproof plan loads such a route in Chromium, makes one model call, and produces a YAML draft you review, edit and run before committing it.">
94
113
 
95
114
  **The boundary in the middle is the point.** Everything above it runs unattended on every pull request and ends in an exit code. Everything below it is something you choose to run, on your machine, and review before it lands.
@@ -145,6 +164,8 @@ The agent is **forbidden from inventing values** — one it types must come from
145
164
 
146
165
  The rule used to live only in the prompt, and a prompt instructs rather than enforces. Run against a real model, `fill the note field` did not fail — the agent made a value up, filled it, and the step **passed**, producing "This is a test note." on two runs and "This is a new note" on a third. A test going green over a value nobody wrote, differing between runs, is worse than a failure.
147
166
 
167
+ A placeholder counts as a source **only when the step names that variable**. `fill the password field with {{env.TEST_PASSWORD}}` works; `fill the password field` does not become valid because the agent supplies `{{env.SOMETHING}}` itself. An agent cannot know the name of a variable nobody showed it, so one it produces is a guess — and a guessed variable would put a live credential into a field your test never pointed one at, in output that cannot redact a secret it was never told about.
168
+
148
169
  Two limits worth knowing. A value the page shows in one format and the field wants in another — `1234` in the step, `1,234.00` in the box — is refused, and the fix is to write the value the way it is typed. And a very short value (`3`) appears somewhere in almost any page, so it will pass; this closes fabricated content, not every fabricated character.
149
170
 
150
171
  `run` also warns about it first — the same rule caught earlier, from the test file, on every path, before launching a browser or asking for a key:
@@ -219,6 +240,10 @@ blastproof run --min-score 80 # one failing P2 is tolerated
219
240
 
220
241
  `--min-score` **replaces** the all-must-pass rule rather than adding to it. Only executed tests count: filtered and unrouted tests are neither numerator nor denominator, and a run that executed nothing scores 100, so a docs-only PR is never blocked. JUnit carries the score as a `<property name="score">`, and unrouted tests appear as `<skipped/>` so the coverage gap shows up in CI rather than vanishing.
221
242
 
243
+ **The verdict behind that number is pinned, and that is not the same as reproducible.** The model call that decides whether a step passed runs at temperature 0, so the same page and the same expectation are not re-sampled into a different answer — a gate that flips on identical input teaches people to re-run until green, which is worse than no gate. What pinning does not survive: provider-side batching, floating point, and a gateway routing two calls to different providers or quantizations. Expect far less variance, not a guarantee of the same output twice.
244
+
245
+ The calls that *choose* an action, and the one that drafts a test, are deliberately left free — that latitude is what re-resolves a control after a redesign instead of failing on it.
246
+
222
247
  Wiring this into a pipeline, with the gating patterns worth knowing: [Running in CI](./docs/ci.md).
223
248
 
224
249
  ## Without a browser or a key
@@ -244,17 +269,13 @@ If your application legitimately spans hosts (an identity provider, a hosted pay
244
269
 
245
270
  **Your secrets stay out of prompts.** `{{env.*}}` placeholders survive intact and are substituted at the moment of typing. Every value your tests or auth recipe reference is redacted from everything else crossing into a prompt — page snapshots included — in literal and percent-encoded form. Redaction matches known values, so treat it as a strong default rather than a guarantee against a hostile app.
246
271
 
272
+ **A redacted value cannot be asserted on.** The masking is thorough by design, and page snapshots are not exempt — so a step that verifies text which happens to equal an `{{env.*}}` value can never pass, because the judge is shown `***` where the page shows the thing. The failure is the most misleading shape available: the test is right, the application is right, and the report blames the application. Put a value in `{{env.*}}` because it is a secret or because it varies by environment, but do not then write a step that asserts on it ([#87](https://github.com/hamc/blastproof/issues/87)).
273
+
247
274
  The system prompt also tells the model that page content is data, never instruction. That raises the cost of casual injection and is **not** a boundary — the origin constraint is. Do not point blastproof at an application you would not run locally.
248
275
 
249
276
  ## blastproof tests itself
250
277
 
251
- The **Dogfood** badge is blastproof running against the demo app in this repo — real Chromium, real model, scored and gated, with public logs. It catches real regressions rather than diffing strings: change the demo discount from 20% to 5% while the page still claims *"20% off"* and it reports
252
-
253
- ```
254
- FAIL P0 Promo code SAVE20 applies a 20% discount in the cart
255
- reason: the discount is currently -$6.00, but a 20% discount on
256
- $120.00 should be -$24.00
257
- ```
278
+ The **Dogfood** badge is blastproof running against the demo app in this repo — real Chromium, real model, scored and gated, with public logs. It catches real regressions rather than diffing strings: change the demo discount from 20% to 5% while the page still claims *"20% off"* and it fails the step — that is the run in the GIF at the top of this page, verbatim.
258
279
 
259
280
  No selector was updated to catch that. The agent read the value, did the arithmetic, and disagreed with the page.
260
281
 
package/dist/cli.js CHANGED
@@ -458,7 +458,28 @@ function requireValue(action) {
458
458
  }
459
459
  return action.value;
460
460
  }
461
+ var INTERCEPTS_POINTER = /(<[^>\n]{1,400}>)[^\n]{0,400}?intercepts pointer events/;
462
+ var MAX_BLOCKER_LENGTH = 80;
463
+ function truncateBlocker(tag) {
464
+ return tag.length <= MAX_BLOCKER_LENGTH ? tag : `${tag.slice(0, MAX_BLOCKER_LENGTH)}\u2026>`;
465
+ }
466
+ function obstructionFor(error, action) {
467
+ const message = error instanceof Error ? error.message : String(error);
468
+ const match = INTERCEPTS_POINTER.exec(message);
469
+ if (!match) return void 0;
470
+ const where = action.target ? ` on ${describeTarget(action.target)}` : "";
471
+ return new ActionError(
472
+ `blocked: the ${action.action}${where} was NOT performed. The target was found and is visible, enabled and stable \u2014 nothing about it is wrong. ${truncateBlocker(match[1])} is on top of it and received the pointer event instead. Something is covering the page: find it in the snapshot \u2014 a dialog, a cookie banner, an onboarding overlay \u2014 and dismiss it first, using its own close or accept control, or by pressing Escape with no target. Then act on this target again. Choosing a different name for the same target cannot help.`
473
+ );
474
+ }
461
475
  async function performAction(page, action, ctx) {
476
+ try {
477
+ return await performResolvedAction(page, action, ctx);
478
+ } catch (error) {
479
+ throw obstructionFor(error, action) ?? error;
480
+ }
481
+ }
482
+ async function performResolvedAction(page, action, ctx) {
462
483
  switch (action.action) {
463
484
  case "navigate": {
464
485
  const value = resolve(requireValue(action), ctx);
@@ -509,7 +530,10 @@ async function performAction(page, action, ctx) {
509
530
  var COMMIT_ACTIONS = /* @__PURE__ */ new Set(["click", "press"]);
510
531
  var COMMIT_KEYS = /* @__PURE__ */ new Set(["Enter", "NumpadEnter", " ", "Space", "Spacebar"]);
511
532
  var SOURCED_VALUE_ACTIONS = /* @__PURE__ */ new Set(["fill", "select"]);
512
- var ENV_PLACEHOLDER2 = /^\s*\{\{env\.[A-Za-z_][A-Za-z0-9_]*\}\}\s*$/;
533
+ function referencesOnlyNamedVars(value, step) {
534
+ const named = new Set(referencedEnvVars(step));
535
+ return referencedEnvVars(value).every((name) => named.has(name));
536
+ }
513
537
  function normalise(text) {
514
538
  return text.replace(/\s+/g, " ").trim().toLowerCase();
515
539
  }
@@ -545,7 +569,16 @@ var StepRecovery = class {
545
569
  * step — which is also the whole of the "does not cross steps" rule.
546
570
  */
547
571
  readable;
572
+ /**
573
+ * The step exactly as written, for deciding which `{{env.*}}` variables it
574
+ * names (design env-placeholder-must-be-named, D3). Kept alongside the
575
+ * normalised copy rather than derived from it: `readable` is lowercased, and
576
+ * environment variable names are case-sensitive, so `TOKEN` and `token` must
577
+ * stay distinguishable in the one place that decides which secret gets typed.
578
+ */
579
+ step;
548
580
  constructor(step) {
581
+ this.step = step;
549
582
  this.readable = [normalise(step)];
550
583
  }
551
584
  /**
@@ -614,7 +647,11 @@ var StepRecovery = class {
614
647
  if (!SOURCED_VALUE_ACTIONS.has(action.action)) return void 0;
615
648
  const value = action.value;
616
649
  if (!value) return void 0;
617
- if (ENV_PLACEHOLDER2.test(value)) return void 0;
650
+ if (!referencesOnlyNamedVars(value, this.step)) {
651
+ const named = referencedEnvVars(this.step);
652
+ return `refused: the value "${value}" was NOT typed, because this step does not reference that {{env.*}} variable. ${named.length > 0 ? `This step references ${named.map((n) => `{{env.${n}}}`).join(", ")}.` : "This step references no environment variable."} A placeholder is a source only when the step names it \u2014 otherwise the test never asked for that secret to be typed here. Use a value this step supplies, or fail the step and say it supplies none.`;
653
+ }
654
+ if (referencedEnvVars(value).length > 0) return void 0;
618
655
  const needle = normalise(value);
619
656
  if (needle === "") return void 0;
620
657
  if (this.readable.some((source) => source.includes(needle))) return void 0;
@@ -1305,6 +1342,7 @@ Rules:
1305
1342
  - Return "done" when the current step's outcome holds \u2014 including when it already held before you acted, or was achieved by your previous action. "Already true" is done, never failure. Do not return "done" for work belonging to later steps.
1306
1343
  - Return "fail" only when the step's outcome cannot be reached: the element is still absent after retries, the page cannot support the step, or an error blocks progress. Never return "fail" because the work appears to have been done already.
1307
1344
  - If your previous action errored, re-read the fresh snapshot and choose an alternative element or approach. Do not repeat the exact same failing action.
1345
+ - An action reported as "blocked" is the exception to that rule: it means another element is on top of your target, not that you picked the wrong target. Re-targeting cannot fix it. Whatever is covering the page is in the snapshot \u2014 a dialog, a cookie banner, an onboarding overlay \u2014 so dismiss that first, with its own close or accept control, or by pressing Escape with no target, and then act on your original target again. Overlays can be stacked: clearing one may reveal another, and that is progress, not failure.
1308
1346
  - Never invent a value. A value you type must come from the step, from the page, or from an {{env.*}} placeholder. This one is enforced, not merely asked: a fill or select whose value is in none of those is refused and not performed. If a step needs a value it does not give you, that is a failing step, not a gap for you to fill in.
1309
1347
  - A record of the actions you already performed in this step may be shown to you. It is the ground truth about what happened, even when the page no longer shows it: a form that submitted successfully and came back empty looks exactly like one you never submitted. Do not redo work that record says you already did.
1310
1348
  - \`***\` in a snapshot is a redacted secret \u2014 a password, token or key deliberately withheld from you. Seeing it is expected and is not a problem. A field showing \`***\` after you filled it from an {{env.VAR}} placeholder means the fill worked; treat that as success and move on. Never retry a fill because its value is redacted, and never report failure because a value was withheld.
@@ -1398,19 +1436,49 @@ function plannerUserPrompt(input) {
1398
1436
 
1399
1437
  // src/llm/schemas.ts
1400
1438
  import { z as z2 } from "zod";
1439
+ function absentAsNull(schema) {
1440
+ return schema.nullable().transform((value) => value ?? void 0);
1441
+ }
1442
+ var ACTION_NAMES = ["navigate", "click", "fill", "press", "select", "assert", "done", "fail"];
1401
1443
  var agentActionSchema = z2.object({
1402
- action: z2.enum(["navigate", "click", "fill", "press", "select", "assert", "done", "fail"]).describe("The next browser action to perform for the current step."),
1403
- target: z2.object({
1404
- role: z2.string().optional().describe('ARIA role of the target element, e.g. "button", "link", "textbox".'),
1405
- name: z2.string().optional().describe("Accessible name of the target element, exactly as shown in the snapshot."),
1406
- text: z2.string().optional().describe("Visible text of the target element, used as a fallback when no role matches.")
1407
- }).optional().describe("Element to act on, resolved from the accessibility snapshot. Omit for navigate/done/fail."),
1408
- value: z2.string().optional().describe(
1409
- 'Action payload: URL/path for navigate, text for fill, key for press (e.g. "Enter"), option label for select.'
1444
+ action: z2.enum(ACTION_NAMES).describe("The next browser action to perform for the current step."),
1445
+ target: absentAsNull(
1446
+ z2.object({
1447
+ role: absentAsNull(
1448
+ z2.string().describe('ARIA role of the target element, e.g. "button", "link", "textbox".')
1449
+ ),
1450
+ name: absentAsNull(
1451
+ z2.string().describe("Accessible name of the target element, exactly as shown in the snapshot.")
1452
+ ),
1453
+ text: absentAsNull(
1454
+ z2.string().describe("Visible text of the target element, used as a fallback when no role matches.")
1455
+ )
1456
+ })
1457
+ ).describe("Element to act on, resolved from the accessibility snapshot. Null for navigate/done/fail."),
1458
+ value: absentAsNull(
1459
+ z2.string().describe(
1460
+ 'Action payload: URL/path for navigate, text for fill, key for press (e.g. "Enter"), option label for select.'
1461
+ )
1410
1462
  ),
1411
1463
  reasoning: z2.string().describe("One sentence explaining why this action moves the step forward."),
1412
- expectation: z2.string().optional().describe("For assert: the condition the current page snapshot must satisfy.")
1464
+ expectation: absentAsNull(
1465
+ z2.string().describe("For assert: the condition the current page snapshot must satisfy.")
1466
+ )
1467
+ });
1468
+ var parsedAgentActionSchema = z2.object({
1469
+ action: z2.enum(ACTION_NAMES),
1470
+ target: z2.object({
1471
+ role: z2.string().optional(),
1472
+ name: z2.string().optional(),
1473
+ text: z2.string().optional()
1474
+ }).optional(),
1475
+ value: z2.string().optional(),
1476
+ reasoning: z2.string(),
1477
+ expectation: z2.string().optional()
1413
1478
  });
1479
+ function parseAgentAction(value) {
1480
+ return parsedAgentActionSchema.safeParse(value);
1481
+ }
1414
1482
  var assertJudgmentSchema = z2.object({
1415
1483
  pass: z2.boolean().describe("Whether the snapshot satisfies the expectation."),
1416
1484
  reason: z2.string().describe("One sentence explaining the judgment.")
@@ -1431,9 +1499,24 @@ var MalformedModelOutputError = class extends Error {
1431
1499
  this.name = "MalformedModelOutputError";
1432
1500
  }
1433
1501
  };
1502
+ var MAX_PROVIDER_DETAIL = 300;
1503
+ function withProviderDetail(error) {
1504
+ if (!(error instanceof Error)) return error;
1505
+ const body = error.responseBody;
1506
+ if (typeof body !== "string" || body.length === 0) return error;
1507
+ const detail = body.replace(/\s+/g, " ").trim().slice(0, MAX_PROVIDER_DETAIL);
1508
+ if (error.message.includes(detail)) return error;
1509
+ error.message = `${error.message} \u2014 provider said: ${detail}`;
1510
+ return error;
1511
+ }
1434
1512
  async function countedGenerate(generate, budget, options) {
1435
1513
  budget.check();
1436
- const result = await generate(options);
1514
+ let result;
1515
+ try {
1516
+ result = await generate(options);
1517
+ } catch (error) {
1518
+ throw withProviderDetail(error);
1519
+ }
1437
1520
  budget.record(result.usage);
1438
1521
  return result;
1439
1522
  }
@@ -1446,7 +1529,7 @@ function createBrain(model, generate = generateObject, budget) {
1446
1529
  system: agentSystemPrompt(),
1447
1530
  prompt: agentUserPrompt(input)
1448
1531
  });
1449
- const parsed = agentActionSchema.safeParse(result.object);
1532
+ const parsed = parseAgentAction(result.object);
1450
1533
  if (!parsed.success) {
1451
1534
  throw new MalformedModelOutputError(
1452
1535
  `Model returned an invalid action: ${parsed.error.issues[0]?.message ?? "unknown"}`
@@ -1459,7 +1542,8 @@ function createBrain(model, generate = generateObject, budget) {
1459
1542
  model,
1460
1543
  schema: assertJudgmentSchema,
1461
1544
  system: assertSystemPrompt(),
1462
- prompt: assertUserPrompt(step, expectation, snapshot, stepHistory)
1545
+ prompt: assertUserPrompt(step, expectation, snapshot, stepHistory),
1546
+ temperature: 0
1463
1547
  });
1464
1548
  const parsed = assertJudgmentSchema.safeParse(result.object);
1465
1549
  if (!parsed.success) {
@@ -1544,7 +1628,7 @@ function createModel(llm, env = process.env) {
1544
1628
  }
1545
1629
 
1546
1630
  // src/planner.ts
1547
- import { writeFile as writeFile3 } from "fs/promises";
1631
+ import { mkdir as mkdir4, writeFile as writeFile3 } from "fs/promises";
1548
1632
  import path6 from "path";
1549
1633
  import { stringify } from "yaml";
1550
1634
 
@@ -1671,16 +1755,21 @@ async function generateForRoute(page, options) {
1671
1755
  return { ...generated, routes: [route] };
1672
1756
  }
1673
1757
  async function writeDraft(cwd, draft, meta) {
1674
- const file = path6.join(cwd, TESTS_RELATIVE_DIR, `${routeToSlug(meta.route)}.yaml`);
1758
+ const dir = path6.join(cwd, TESTS_RELATIVE_DIR);
1759
+ const file = path6.join(dir, `${routeToSlug(meta.route)}.yaml`);
1675
1760
  try {
1761
+ await mkdir4(dir, { recursive: true });
1676
1762
  await writeFile3(file, renderTestYaml(draft, meta), { flag: "wx" });
1677
1763
  } catch (error) {
1678
- if (error.code === "EEXIST") {
1764
+ const err = error;
1765
+ if (err.code === "EEXIST" && err.syscall !== "mkdir") {
1679
1766
  throw new PlannerError(
1680
1767
  `Refusing to overwrite ${path6.relative(cwd, file)} (route ${meta.route}). Delete or rename it to regenerate.`
1681
1768
  );
1682
1769
  }
1683
- throw error;
1770
+ throw new PlannerError(
1771
+ `Cannot write draft to ${file}: ${fsReason(error)}. Check that ${path6.dirname(file)} is a directory you can write to, not a file.`
1772
+ );
1684
1773
  }
1685
1774
  return file;
1686
1775
  }
@@ -1841,7 +1930,7 @@ function formatSpendLine(spend) {
1841
1930
  import path9 from "path";
1842
1931
 
1843
1932
  // src/report/html.ts
1844
- import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
1933
+ import { mkdir as mkdir5, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
1845
1934
  import path7 from "path";
1846
1935
  var ESCAPES = {
1847
1936
  "&": "&amp;",
@@ -2006,7 +2095,7 @@ ${detail}
2006
2095
  }
2007
2096
  async function writeHtml(file, html) {
2008
2097
  try {
2009
- await mkdir4(path7.dirname(file), { recursive: true });
2098
+ await mkdir5(path7.dirname(file), { recursive: true });
2010
2099
  await writeFile4(file, html, "utf8");
2011
2100
  return file;
2012
2101
  } catch (error) {
@@ -2017,7 +2106,7 @@ async function writeHtml(file, html) {
2017
2106
  }
2018
2107
 
2019
2108
  // src/report/junit.ts
2020
- import { mkdir as mkdir5, writeFile as writeFile5 } from "fs/promises";
2109
+ import { mkdir as mkdir6, writeFile as writeFile5 } from "fs/promises";
2021
2110
  import path8 from "path";
2022
2111
  var ESCAPES2 = {
2023
2112
  "&": "&amp;",
@@ -2085,7 +2174,7 @@ ${reason}` : reason;
2085
2174
  }
2086
2175
  async function writeJUnit(file, xml) {
2087
2176
  try {
2088
- await mkdir5(path8.dirname(file), { recursive: true });
2177
+ await mkdir6(path8.dirname(file), { recursive: true });
2089
2178
  await writeFile5(file, xml, "utf8");
2090
2179
  return file;
2091
2180
  } catch (error) {
@@ -2990,7 +3079,7 @@ function parsePositiveNumber(flag) {
2990
3079
  };
2991
3080
  }
2992
3081
  var program = new Command();
2993
- program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.14.0");
3082
+ program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.16.0");
2994
3083
  program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
2995
3084
  try {
2996
3085
  const result = await initProject(process.cwd());