@tryvitris/website-harness 0.1.0-beta.8 → 1.0.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 (56) hide show
  1. package/README.md +5 -1
  2. package/agent/refs/admin.md +11 -0
  3. package/agent/refs/launch.md +13 -11
  4. package/bin/api/agent/evaluate.js +1 -1
  5. package/bin/api/agent/grade.js +1 -1
  6. package/bin/api/cli/api.js +9 -9
  7. package/bin/api/cli.js +11 -11
  8. package/bin/api/engine.js +2 -2
  9. package/bin/api/hosting.js +3 -3
  10. package/bin/chunks/{chunk-QQOMS2DL.js → chunk-445FZZEC.js} +125 -19
  11. package/bin/chunks/{chunk-PYCRUMCT.js → chunk-54QPYUM2.js} +1 -1
  12. package/bin/chunks/{chunk-R2WRQDVE.js → chunk-65LRZIBP.js} +1 -1
  13. package/bin/chunks/{chunk-FGSQE3DU.js → chunk-72Y4YMTV.js} +1 -1
  14. package/bin/chunks/{chunk-XY73ZLHB.js → chunk-GQPUILFR.js} +33 -5
  15. package/bin/chunks/{chunk-A2XMAPVW.js → chunk-JBSGJR7X.js} +1 -1
  16. package/bin/chunks/{chunk-WNE543KF.js → chunk-KIWEFRNG.js} +5 -5
  17. package/bin/chunks/{chunk-DYKLM4I3.js → chunk-M72KKZIB.js} +24 -6
  18. package/bin/chunks/{chunk-FEFLSJM5.js → chunk-RE4GOHSU.js} +3 -3
  19. package/bin/chunks/{chunk-WMJVLMU2.js → chunk-UR4NWYVW.js} +2 -2
  20. package/bin/chunks/{chunk-3OKR5AZJ.js → chunk-VKU5TY5C.js} +1 -1
  21. package/bin/chunks/{design-context-store-W3VTZ4HA.js → design-context-store-ETVS4N6W.js} +1 -1
  22. package/bin/chunks/{packets-LBMDIFPO.js → packets-XFXKHSEY.js} +1 -1
  23. package/bin/chunks/{preparation-AV4PDQY6.js → preparation-3OR7LVPQ.js} +1 -1
  24. package/bin/cloudflare/backend.js +143 -61
  25. package/bin/types/src/admin/connection.d.ts +3 -0
  26. package/bin/types/src/agent/refine-document-checks.d.ts +5 -0
  27. package/bin/types/src/cli/admin.d.ts +21 -0
  28. package/bin/types/src/cli/site-create.d.ts +2 -0
  29. package/bin/vitris-art.js +1 -1
  30. package/bin/vitris-create-site.js +5 -3
  31. package/bin/vitris-cursor-hook.js +1 -1
  32. package/bin/vitris-eval.js +3 -3
  33. package/bin/vitris-go.js +3 -3
  34. package/bin/vitris-harvest.js +2 -2
  35. package/bin/vitris-knowledge.js +2 -2
  36. package/bin/vitris-launch-preflight.js +1 -1
  37. package/bin/vitris-motion-view.js +1 -1
  38. package/bin/vitris-refresh.js +2 -2
  39. package/bin/vitris-stock.js +1 -1
  40. package/bin/vitris-themes.js +1 -1
  41. package/bin/vitris-upgrade.js +2 -2
  42. package/bin/vitris-verify-browser.js +1 -1
  43. package/bin/vitris.js +11 -11
  44. package/docs/ADMIN.md +85 -0
  45. package/docs/CLI.md +11 -5
  46. package/docs/CLOUDFLARE.md +24 -2
  47. package/docs/DISTRIBUTION.md +5 -3
  48. package/docs/PRESERVE.md +1 -1
  49. package/docs/SECURITY.md +2 -2
  50. package/package.json +28 -23
  51. package/templates/site/AGENTS.md +3 -1
  52. package/templates/site/README.md +5 -3
  53. package/templates/site/deployment/README.md +24 -0
  54. package/templates/site/{.github/workflows/deploy.yml → deployment/github-actions.yml.example} +5 -6
  55. package/templates/site/gitignore +3 -0
  56. package/templates/site/package.json +2 -1
package/README.md CHANGED
@@ -6,8 +6,10 @@ The package is [`@tryvitris/website-harness`](https://www.npmjs.com/package/@try
6
6
 
7
7
  ## Create a website
8
8
 
9
+ An optional separate business admin is available through `vitris admin setup|status|verify`; new-site `--admin` prepares follow-up instructions. See the [admin integration guide](docs/ADMIN.md) for pinned packages, local plan/apply, backend grants and the separate customer-account Cloudflare rollout.
10
+
9
11
  ```bash
10
- npx --package @tryvitris/website-harness@0.1.0-beta.8 vitris site create \
12
+ npx --package @tryvitris/website-harness@1.0.0 vitris site create \
11
13
  --dir acme --slug acme --name "Acme Shop" \
12
14
  --from https://old-site.example --template "Still & Field" \
13
15
  --content-file ./acme-notes.md
@@ -50,6 +52,8 @@ For example, ask your agent to “improve the mobile layout,” “review the vi
50
52
 
51
53
  ## Develop the harness
52
54
 
55
+ Customer sites build and verify locally, then upload the exact finished bundle directly to Cloudflare Pages. Use `npm run deploy:preview` for a hosted preview and `npm run deploy` for explicitly requested production publication. GitHub stores source; hosted deployment automation is opt-in. See [Cloudflare setup](docs/CLOUDFLARE.md) for new sites and migration of existing sites.
56
+
53
57
  ```bash
54
58
  npm ci
55
59
  npx playwright install chromium
@@ -0,0 +1,11 @@
1
+ # Optional business admin
2
+
3
+ Only prepare an admin when requested or when the operator opts in with site creation `--admin`. Continue the website's prepare → CURRENT → work → complete workflow and verified local-preview stop. Admin rollout does not bypass website consent or release gates.
4
+
5
+ Read the installed harness's `docs/ADMIN.md` and the published admin's packaged `agent/WORKFLOW.md`, `agent/refs/harness.md`, and `agent/refs/cloudflare.md`. Use the pinned **0.1.0-alpha.1** release. Run `vitris admin setup --site WEBSITE --dir SIBLING_ADMIN --domain admin.customer-domain.com --website HTTPS_SITE_ORIGIN --tenant EXISTING_TENANT_ID --backend-origin HTTPS_BACKEND --plan`. Review `artifacts/admin/setup-plan.json`, then repeat with `--apply` and a dedicated environment `VITRIS_ADMIN_API_TOKEN`. Reuse the published installer; never reproduce it or silently overwrite an installation.
6
+
7
+ Setup is local scaffolding and public connection metadata only. Preserve forms, origins, tenants, existing accounts/records, authored source and workflow progress. Never infer compatibility from scaffolding: an older deployed backend needs its own reviewed API upgrade. `vitris admin status` is local metadata; `vitris admin verify --site WEBSITE --dir ADMIN` checks deployed identity and authoritative reads with the dedicated credential, without printing records. Failures must remain visible, with no demo fallback.
8
+
9
+ Default scopes: leads:read and analytics:read. Add --lead-write to both setup commands only for explicit status-write authorization. Prepare a complete hashed grant map with `vitris admin grant`; preserve unrelated grants. Store only SHA-256 in backend ADMIN_CONNECTIONS, and upload the raw token to the admin as HARNESS_API_TOKEN through secret stdin. Never commit configuration or credentials.
10
+
11
+ Prepare explicit, separate customer-account provisioning, D1 migrations, owner bootstrap, Cloudflare Email Service onboarding, authentication, DNS/TLS and deployment steps. Never deploy/migrate a shared backend as a side effect. Sites without collection need an explicitly provisioned backend and separately reviewed form/analytics activation; they have no historical data to import. Production demo login is forbidden. Test submissions and notification captures remain isolated and read-only; preview traffic is excluded from analytics. Clearly label synthetic fixtures and stub services. Pages, Media, page builders and arbitrary HTML editing remain outside scope.
@@ -1,13 +1,15 @@
1
- # Launch (optional)
2
-
3
- Only when the operator asks after `preview_ready`.
4
-
5
- 1. `gh repo create ORG/SLUG --source . --private --push` (or push to existing remote).
6
- 2. Set the real `launch.json.publicOrigin` and Cloudflare Pages build command to `npm ci && npx playwright install chromium && npm run build && npm run preflight`, output `dist`, production `main`. Set `VITRIS_SITE_ENVIRONMENT=production` on the production branch. Preview branches use `preview` and remain diagnostic. A direct `wrangler pages deploy` must follow a successful current preflight on the exact `dist` bytes.
7
- 3. Register tenant in harness `/ops`: owner email (`launch.json` → `ownerEmail`), `pages_project`, `github_owner`, `github_repo`.
8
- 4. Fill `launch.json` → `github`, `publicOrigin` when known.
9
- 5. Owner admin: `{backendOrigin}/app/login` with `ownerEmail`.
10
-
11
- Do not merge secrets into git. Use `.env` for Cloudflare tokens.
1
+ # Launch (optional)
2
+
3
+ For a separately requested business admin at `admin.customer-domain.com`, read `admin.md` and the installed harness's `docs/ADMIN.md`. Use `vitris admin setup --plan` and reviewed `--apply`; verify the actual deployed backend with `vitris admin verify`. Local scaffolding does not enable collection/authentication or deploy infrastructure. Keep the sibling admin's Cloudflare provisioning and release separate from website publication.
4
+
5
+ Only when the operator asks after `preview_ready`. Build and verify locally, then upload the exact staged bundle directly to Cloudflare Pages. GitHub stores source and history; hosted deployment automation is opt-in.
6
+
7
+ 1. Commit authored source and the lockfile to the customer's private repository when requested. Keep `dist/`, `agent/release-bundles/`, `agent/release-candidate.json`, `agent/releases/`, captures and credentials out of Git.
8
+ 2. Authenticate with the operator's own `npx wrangler login` and confirm the account. For a new site create a Pages Direct Upload project with `npx wrangler pages project create PROJECT`. Set `launch.json.pagesProject` and the real production `launch.json.publicOrigin`. Install Chromium locally for preflight. Run `npm run deploy:preview` and accept the hosted preview; run `npm run deploy` only for explicit production publication. Ordinary deployment builds and verifies locally, stages the exact checked files, uploads them, records their identity and checks the hosted URL. Preservation sites support temporary previews only.
9
+ 3. For existing Git-connected Pages projects, disable automatic production and preview branch deployments before local uploads. Review and disable existing automatic GitHub deployment workflows as well. Preserve project names, domains, launch configuration, authored files and workflow progress. Installing or upgrading the harness does not migrate package scripts or remote settings. See the installed harness's `docs/CLOUDFLARE.md`.
10
+ 4. Backend tenant registration, contact forms, administration sign-in, custom domains and DNS are separate configured features. If publishing through the authenticated backend Worker, retain the exact verified bundle signing and attestation contract and review that path before migration. Direct Pages uploads do not provision tenants or forms.
11
+ 5. Hosted automation is opt-in. `deployment/github-actions.yml.example` is inactive until an operator chooses and installs it under `.github/workflows/` with protected secrets. Cloudflare Git builds are another optional alternative. Enable one publication path per site; preserve preflight and bundle integrity checks in every ordinary path.
12
+
13
+ Do not merge secrets into git. Use `.env` for Cloudflare tokens.
12
14
 
13
15
  For an explicitly requested quick publish, use `vitris deploy --quick --environment production` (`npm run deploy:quick` in newly scaffolded sites). This rebuilds and deploys without browser, graph or launch review gates. Compilation, Cloudflare credentials, staged-file integrity and an HTTP smoke check still run. Receipts record build-only verification. Preservation sites use `--environment preview` and also skip screenshot comparisons. Ordinary deployment retains full preflight.
@@ -11,7 +11,7 @@ import {
11
11
  parentDiversitySourceSubject,
12
12
  reviewSubject,
13
13
  validateParentDiversityReview
14
- } from "../../chunks/chunk-XY73ZLHB.js";
14
+ } from "../../chunks/chunk-GQPUILFR.js";
15
15
  import "../../chunks/chunk-KVMZSQ3D.js";
16
16
  import "../../chunks/chunk-WOORR67N.js";
17
17
  import "../../chunks/chunk-PRLL7B63.js";
@@ -9,7 +9,7 @@ import {
9
9
  gradePages,
10
10
  gradePreview,
11
11
  gradeResearch
12
- } from "../../chunks/chunk-XY73ZLHB.js";
12
+ } from "../../chunks/chunk-GQPUILFR.js";
13
13
  import "../../chunks/chunk-KVMZSQ3D.js";
14
14
  import "../../chunks/chunk-WOORR67N.js";
15
15
  import "../../chunks/chunk-PRLL7B63.js";
@@ -11,7 +11,7 @@ import {
11
11
  runDoctor,
12
12
  shareSite,
13
13
  synthesizeSite
14
- } from "../../chunks/chunk-WNE543KF.js";
14
+ } from "../../chunks/chunk-KIWEFRNG.js";
15
15
  import {
16
16
  applyOperations,
17
17
  buildSiteDraft,
@@ -19,35 +19,35 @@ import {
19
19
  inspectSite,
20
20
  prepareChange,
21
21
  verifyChange
22
- } from "../../chunks/chunk-FGSQE3DU.js";
22
+ } from "../../chunks/chunk-72Y4YMTV.js";
23
23
  import {
24
24
  deployVerifiedArtifact,
25
25
  prepareQuickRelease,
26
26
  prepareVerifiedRelease,
27
27
  verifySite
28
- } from "../../chunks/chunk-WMJVLMU2.js";
28
+ } from "../../chunks/chunk-UR4NWYVW.js";
29
29
  import {
30
30
  upgradeSite
31
- } from "../../chunks/chunk-A2XMAPVW.js";
31
+ } from "../../chunks/chunk-JBSGJR7X.js";
32
32
  import {
33
33
  createSite
34
- } from "../../chunks/chunk-DYKLM4I3.js";
34
+ } from "../../chunks/chunk-M72KKZIB.js";
35
35
  import "../../chunks/chunk-2NW7KSPA.js";
36
36
  import {
37
37
  buildAdapterConfig,
38
38
  runPreserve,
39
39
  runSite,
40
40
  statusSite
41
- } from "../../chunks/chunk-FEFLSJM5.js";
42
- import "../../chunks/chunk-R2WRQDVE.js";
41
+ } from "../../chunks/chunk-RE4GOHSU.js";
42
+ import "../../chunks/chunk-65LRZIBP.js";
43
43
  import "../../chunks/chunk-34H54KEY.js";
44
44
  import {
45
45
  EXIT_CODES
46
46
  } from "../../chunks/chunk-JCRGE6AL.js";
47
47
  import "../../chunks/chunk-XL2DRAYI.js";
48
- import "../../chunks/chunk-PYCRUMCT.js";
48
+ import "../../chunks/chunk-54QPYUM2.js";
49
49
  import "../../chunks/chunk-XGTHGWPW.js";
50
- import "../../chunks/chunk-XY73ZLHB.js";
50
+ import "../../chunks/chunk-GQPUILFR.js";
51
51
  import "../../chunks/chunk-KVMZSQ3D.js";
52
52
  import "../../chunks/chunk-WOORR67N.js";
53
53
  import "../../chunks/chunk-PRLL7B63.js";
package/bin/api/cli.js CHANGED
@@ -1,21 +1,21 @@
1
1
  import {
2
2
  runCli
3
- } from "../chunks/chunk-QQOMS2DL.js";
4
- import "../chunks/chunk-WNE543KF.js";
5
- import "../chunks/chunk-FGSQE3DU.js";
6
- import "../chunks/chunk-WMJVLMU2.js";
7
- import "../chunks/chunk-A2XMAPVW.js";
8
- import "../chunks/chunk-DYKLM4I3.js";
3
+ } from "../chunks/chunk-445FZZEC.js";
4
+ import "../chunks/chunk-KIWEFRNG.js";
5
+ import "../chunks/chunk-72Y4YMTV.js";
6
+ import "../chunks/chunk-UR4NWYVW.js";
7
+ import "../chunks/chunk-JBSGJR7X.js";
8
+ import "../chunks/chunk-M72KKZIB.js";
9
9
  import "../chunks/chunk-2NW7KSPA.js";
10
- import "../chunks/chunk-3OKR5AZJ.js";
11
- import "../chunks/chunk-FEFLSJM5.js";
12
- import "../chunks/chunk-R2WRQDVE.js";
10
+ import "../chunks/chunk-VKU5TY5C.js";
11
+ import "../chunks/chunk-RE4GOHSU.js";
12
+ import "../chunks/chunk-65LRZIBP.js";
13
13
  import "../chunks/chunk-34H54KEY.js";
14
14
  import "../chunks/chunk-JCRGE6AL.js";
15
15
  import "../chunks/chunk-XL2DRAYI.js";
16
- import "../chunks/chunk-PYCRUMCT.js";
16
+ import "../chunks/chunk-54QPYUM2.js";
17
17
  import "../chunks/chunk-XGTHGWPW.js";
18
- import "../chunks/chunk-XY73ZLHB.js";
18
+ import "../chunks/chunk-GQPUILFR.js";
19
19
  import "../chunks/chunk-KVMZSQ3D.js";
20
20
  import "../chunks/chunk-WOORR67N.js";
21
21
  import "../chunks/chunk-PRLL7B63.js";
package/bin/api/engine.js CHANGED
@@ -7,11 +7,11 @@ import {
7
7
  prepareChange,
8
8
  publicEditManifest,
9
9
  verifyChange
10
- } from "../chunks/chunk-FGSQE3DU.js";
10
+ } from "../chunks/chunk-72Y4YMTV.js";
11
11
  import {
12
12
  changedSourceFiles,
13
13
  sourceRevision
14
- } from "../chunks/chunk-XY73ZLHB.js";
14
+ } from "../chunks/chunk-GQPUILFR.js";
15
15
  import "../chunks/chunk-KVMZSQ3D.js";
16
16
  import "../chunks/chunk-WOORR67N.js";
17
17
  import "../chunks/chunk-PRLL7B63.js";
@@ -2,12 +2,12 @@ import {
2
2
  deployVerifiedArtifact,
3
3
  prepareQuickRelease,
4
4
  prepareVerifiedRelease
5
- } from "../chunks/chunk-WMJVLMU2.js";
5
+ } from "../chunks/chunk-UR4NWYVW.js";
6
6
  import "../chunks/chunk-2NW7KSPA.js";
7
- import "../chunks/chunk-R2WRQDVE.js";
7
+ import "../chunks/chunk-65LRZIBP.js";
8
8
  import "../chunks/chunk-JCRGE6AL.js";
9
9
  import "../chunks/chunk-XL2DRAYI.js";
10
- import "../chunks/chunk-XY73ZLHB.js";
10
+ import "../chunks/chunk-GQPUILFR.js";
11
11
  import "../chunks/chunk-KVMZSQ3D.js";
12
12
  import "../chunks/chunk-WOORR67N.js";
13
13
  import "../chunks/chunk-PRLL7B63.js";
@@ -11,30 +11,30 @@ import {
11
11
  runDoctor,
12
12
  shareSite,
13
13
  synthesizeSite
14
- } from "./chunk-WNE543KF.js";
14
+ } from "./chunk-KIWEFRNG.js";
15
15
  import {
16
16
  verifySite
17
- } from "./chunk-WMJVLMU2.js";
17
+ } from "./chunk-UR4NWYVW.js";
18
18
  import {
19
19
  upgradeSite
20
- } from "./chunk-A2XMAPVW.js";
20
+ } from "./chunk-JBSGJR7X.js";
21
21
  import {
22
22
  createSite
23
- } from "./chunk-DYKLM4I3.js";
23
+ } from "./chunk-M72KKZIB.js";
24
24
  import {
25
25
  explainStage
26
- } from "./chunk-3OKR5AZJ.js";
26
+ } from "./chunk-VKU5TY5C.js";
27
27
  import {
28
28
  runSite,
29
29
  statusSite,
30
30
  workflowArgvFromFlags
31
- } from "./chunk-FEFLSJM5.js";
31
+ } from "./chunk-RE4GOHSU.js";
32
32
  import {
33
33
  EXIT_CODES
34
34
  } from "./chunk-JCRGE6AL.js";
35
35
  import {
36
36
  siteInputsFromFlags
37
- } from "./chunk-XY73ZLHB.js";
37
+ } from "./chunk-GQPUILFR.js";
38
38
  import {
39
39
  buildVerified,
40
40
  findHarnessRoot
@@ -50,9 +50,9 @@ import {
50
50
  } from "./chunk-T6PE4KN4.js";
51
51
 
52
52
  // src/cli/index.ts
53
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
54
- import { resolve as resolve2 } from "node:path";
55
- import { spawnSync } from "node:child_process";
53
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
54
+ import { resolve as resolve3 } from "node:path";
55
+ import { spawnSync as spawnSync2 } from "node:child_process";
56
56
 
57
57
  // src/cli/events.ts
58
58
  var EventStream = class {
@@ -270,6 +270,106 @@ async function formsCommand(command, opts) {
270
270
  }
271
271
  }
272
272
 
273
+ // src/cli/admin.ts
274
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
275
+ import { resolve as resolve2 } from "node:path";
276
+ import { createRequire } from "node:module";
277
+ import { spawnSync } from "node:child_process";
278
+ import { parseManifest } from "@tryvitris/website-admin-contracts";
279
+
280
+ // src/admin/connection.ts
281
+ async function readConnection(manifest, token, resource, test = false, transport = fetch) {
282
+ if (!manifest.backend || !/^[a-zA-Z0-9_-]{32,200}$/.test(token)) throw Error("A dedicated VITRIS_ADMIN_API_TOKEN and backend connection are required");
283
+ const url = new URL(`/api/admin/v1/${encodeURIComponent(manifest.backend.tenantId)}/${resource}`, manifest.backend.origin);
284
+ if (test) url.searchParams.set("mode", "test");
285
+ const response = await transport(url, { redirect: "manual", signal: AbortSignal.timeout(12e3), headers: { authorization: `Bearer ${token}`, "x-vitris-installation": manifest.installationId, "x-vitris-site-origin": manifest.site.origin } });
286
+ if (!response.ok) throw Error(`Admin v1 ${resource} rejected (${response.status}); connection is not verified`);
287
+ const reader = response.body?.getReader();
288
+ if (!reader) throw Error("Empty backend response");
289
+ const chunks = [];
290
+ let size = 0;
291
+ for (; ; ) {
292
+ const { done, value } = await reader.read();
293
+ if (done) break;
294
+ size += value.length;
295
+ if (size > 4e6) {
296
+ await reader.cancel();
297
+ throw Error("Backend response too large");
298
+ }
299
+ chunks.push(value);
300
+ }
301
+ const bytes = new Uint8Array(size);
302
+ let offset = 0;
303
+ for (const chunk of chunks) {
304
+ bytes.set(chunk, offset);
305
+ offset += chunk.length;
306
+ }
307
+ const data = JSON.parse(new TextDecoder().decode(bytes));
308
+ if (data?.schemaVersion !== 1 || data.installationId !== manifest.installationId || data.tenantId !== manifest.backend.tenantId || data.siteOrigin !== manifest.site.origin) throw Error("Backend contract or installation identity mismatch");
309
+ if (resource === "status") {
310
+ if (!Array.isArray(data.scopes)) throw Error("Invalid backend scopes");
311
+ } else {
312
+ if (data.source !== "connected" || !Array.isArray(data.rows) || data.rows.length > 200) throw Error("Invalid backend records");
313
+ if (resource === "leads" && data.rows.some((r) => ["id", "name", "email", "message", "created_at", "status"].some((k) => typeof r?.[k] !== "string"))) throw Error("Invalid lead response");
314
+ if (resource === "analytics" && data.rows.some((r) => typeof r?.day !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(r.day) || ["views", "visitors", "enquiries"].some((k) => typeof r[k] !== "number" || !Number.isFinite(r[k]) || r[k] < 0))) throw Error("Invalid analytics response");
315
+ }
316
+ return data;
317
+ }
318
+
319
+ // src/cli/admin.ts
320
+ function mergeAdminGrant(existing, addition) {
321
+ const object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
322
+ if (!object(existing) || !object(addition) || Object.keys(addition).length !== 1) throw Error("Expected complete grant map and one installation grant");
323
+ for (const [id, raw] of Object.entries({ ...existing, ...addition })) {
324
+ const g = raw;
325
+ if (!/^[a-z][a-z0-9-]{0,50}$/.test(id) || !object(g) || typeof g.tenantId !== "string" || !/^[a-zA-Z0-9_-]{1,100}$/.test(g.tenantId) || typeof g.tokenHash !== "string" || !/^[a-f0-9]{64}$/.test(g.tokenHash) || typeof g.siteOrigin !== "string" || new URL(g.siteOrigin).origin !== g.siteOrigin || !g.siteOrigin.startsWith("https://") || !Array.isArray(g.scopes) || g.scopes.some((s) => !["leads:read", "analytics:read", "leads:write"].includes(s)) || Object.keys(g).some((k) => !["tenantId", "siteOrigin", "tokenHash", "scopes"].includes(k))) throw Error("Invalid hashed grant; raw credentials are forbidden");
326
+ }
327
+ for (const id of Object.keys(addition)) if (Object.hasOwn(existing, id) && JSON.stringify(existing[id]) !== JSON.stringify(addition[id])) throw Error("Grant already exists with different authority; rotate explicitly");
328
+ return { ...existing, ...addition };
329
+ }
330
+ async function adminCommand(command, options) {
331
+ try {
332
+ const site = resolve2(options.site || ".");
333
+ if (command === "grant") {
334
+ if (!options.dir || !options.grants || !options.out || options.dryRun) throw Error("admin grant requires --dir, --grants COMPLETE_MAP_FILE and --out NEW_IGNORED_FILE");
335
+ const addition = JSON.parse(readFileSync2(resolve2(options.dir, ".vitris/backend-grant.json"), "utf8"));
336
+ const manifest2 = parseManifest(JSON.parse(readFileSync2(resolve2(options.dir, "admin.config.json"), "utf8")));
337
+ const grant = addition[manifest2.installationId];
338
+ if (!grant || grant.tenantId !== manifest2.backend?.tenantId || grant.siteOrigin !== manifest2.site.origin) throw Error("Grant and installation identity differ");
339
+ const merged = mergeAdminGrant(JSON.parse(readFileSync2(resolve2(options.grants), "utf8")), addition);
340
+ writeFileSync2(resolve2(options.out), JSON.stringify(merged, null, 2) + "\n", { flag: "wx", mode: 384 });
341
+ return { ok: true, exitCode: 0, summary: "Merged hashed grants into the new local file. No backend secret changed; review and install explicitly through secret stdin." };
342
+ }
343
+ if (command === "setup") {
344
+ if (!options.dir || !options.domain || !options.tenant || !!options.plan === !!options.apply) throw Error("admin setup requires --dir, --domain, --tenant and exactly one of --plan or --apply");
345
+ if (options.dryRun) throw Error("Use --plan for local setup planning; --dry-run does not write a setup plan");
346
+ const args = ["setup", "--site", site, "--dir", resolve2(options.dir), "--domain", options.domain, "--tenant", options.tenant, options.apply ? "--apply" : "--plan"];
347
+ if (options.website) args.push("--website", options.website);
348
+ if (options.backendOrigin) args.push("--backend-origin", options.backendOrigin);
349
+ if (options.leadWrite) args.push("--lead-write");
350
+ const entry = createRequire(import.meta.url).resolve("@tryvitris/website-admin-harness/cli");
351
+ const output = spawnSync(process.execPath, [entry, ...args], { encoding: "utf8", windowsHide: true, maxBuffer: 1024 * 1024 });
352
+ if (output.status !== 0) throw Error(output.error?.message || output.stderr.trim() || "Admin setup failed");
353
+ return { ok: true, exitCode: 0, summary: output.stdout.trim() };
354
+ }
355
+ if (!["status", "verify"].includes(command)) throw Error("Use vitris admin setup|status|verify|grant");
356
+ const launch = JSON.parse(readFileSync2(resolve2(site, "launch.json"), "utf8"));
357
+ if (command === "status") return { ok: true, exitCode: 0, summary: launch.admin ? "Local admin metadata exists. Backend, authentication and deployment are unverified; run admin verify with --dir." : "No admin connection. Read docs/ADMIN.md; scaffolding alone does not enable collection.", data: { connection: launch.admin || null, verified: false } };
358
+ if (!options.dir) throw Error("admin verify requires --dir ADMIN_DIRECTORY");
359
+ const manifest = parseManifest(JSON.parse(readFileSync2(resolve2(options.dir, "admin.config.json"), "utf8")));
360
+ if (!launch.admin || manifest.installationId !== launch.admin.installationId || manifest.site.id !== launch.admin.siteId || manifest.adminOrigin !== launch.admin.url || manifest.site.origin !== launch.admin.siteOrigin || manifest.backend?.tenantId !== launch.admin.tenantId) throw Error("Website and admin installation identities differ");
361
+ const token = process.env.VITRIS_ADMIN_API_TOKEN || "";
362
+ const status = await readConnection(manifest, token, "status");
363
+ if (!["leads:read", "analytics:read"].every((scope) => status.scopes.includes(scope))) throw Error("Default read scopes are missing");
364
+ const leads = await readConnection(manifest, token, "leads");
365
+ const tests = await readConnection(manifest, token, "leads", true);
366
+ const analytics = await readConnection(manifest, token, "analytics");
367
+ return { ok: true, exitCode: 0, summary: "Admin v1 data connection verified. Authentication, collection and deployment require separate acceptance checks.", data: { verified: true, productionLeads: leads.rows.length, testSubmissions: tests.rows.length, analyticsDays: analytics.rows.length, scopes: status.scopes } };
368
+ } catch (error) {
369
+ return { ok: false, exitCode: EXIT_CODES.PRECONDITION_FAILED, error: error instanceof Error ? error.message : String(error) };
370
+ }
371
+ }
372
+
273
373
  // src/cli/index.ts
274
374
  function parseArgs(args) {
275
375
  const command = [];
@@ -309,9 +409,9 @@ function parseArgs(args) {
309
409
  }
310
410
  function resolvePrecedence(flags, siteRoot) {
311
411
  const merged = {};
312
- if (siteRoot && existsSync2(resolve2(siteRoot, "launch.json"))) {
412
+ if (siteRoot && existsSync2(resolve3(siteRoot, "launch.json"))) {
313
413
  try {
314
- const launch = JSON.parse(readFileSync2(resolve2(siteRoot, "launch.json"), "utf8"));
414
+ const launch = JSON.parse(readFileSync3(resolve3(siteRoot, "launch.json"), "utf8"));
315
415
  if (typeof launch.ownerEmail === "string") merged.owner = launch.ownerEmail;
316
416
  if (typeof launch.slug === "string") merged.slug = launch.slug;
317
417
  if (typeof launch.name === "string") merged.name = launch.name;
@@ -320,9 +420,9 @@ function resolvePrecedence(flags, siteRoot) {
320
420
  }
321
421
  if (flags.config && typeof flags.config === "string") {
322
422
  try {
323
- const configPath = resolve2(flags.config);
423
+ const configPath = resolve3(flags.config);
324
424
  if (existsSync2(configPath)) {
325
- const cfg = JSON.parse(readFileSync2(configPath, "utf8"));
425
+ const cfg = JSON.parse(readFileSync3(configPath, "utf8"));
326
426
  for (const [k, v] of Object.entries(cfg)) {
327
427
  if (typeof v === "string" || typeof v === "boolean") merged[k] = v;
328
428
  }
@@ -354,7 +454,7 @@ async function runCli(argv) {
354
454
  }
355
455
  const stream = new EventStream(outputMode);
356
456
  if (flags.version || flags.v) {
357
- stream.log(`@tryvitris/website-harness v${JSON.parse(readFileSync2(resolve2(findHarnessRoot(), "package.json"), "utf8")).version}`);
457
+ stream.log(`@tryvitris/website-harness v${JSON.parse(readFileSync3(resolve3(findHarnessRoot(), "package.json"), "utf8")).version}`);
358
458
  return EXIT_CODES.SUCCESS;
359
459
  }
360
460
  if (flags.help || flags.h || command.length === 0) {
@@ -363,6 +463,7 @@ async function runCli(argv) {
363
463
  Usage: vitris <command> [options]
364
464
 
365
465
  Commands:
466
+ admin setup|status|verify Separate business admin; explicit cloud rollout
366
467
  forms inspect|setup|status Inventory, provision, and check contact forms
367
468
  doctor Check environment, dependencies, and site health
368
469
  site create --dir DIR --slug SLUG Scaffold a new customer site repo (--from URL or --blank)
@@ -391,6 +492,7 @@ Options:
391
492
  --template-release RELEASE Optional template revision
392
493
  --content TEXT Additional business content or instructions
393
494
  --content-file FILE UTF-8 content file (instead of --content)
495
+ --admin New-site opt-in: prepare sibling admin next steps
394
496
  --backend-origin URL Backend origin for a new site
395
497
  --site DIR Site directory path (default: current directory)
396
498
  --json Output single structured JSON result at completion
@@ -413,7 +515,10 @@ Options:
413
515
  const secondary = command[1];
414
516
  let result;
415
517
  try {
416
- if (primary === "forms") {
518
+ if (primary === "admin") {
519
+ const value = (key) => typeof opts[key] === "string" ? opts[key] : void 0;
520
+ result = await adminCommand(secondary || "status", { site: siteRoot, dir: value("dir"), domain: value("domain"), website: value("website"), tenant: value("tenant"), backendOrigin: value("backend-origin"), plan: !!opts.plan, apply: !!opts.apply, leadWrite: !!opts["lead-write"], grants: value("grants"), out: value("out"), dryRun });
521
+ } else if (primary === "forms") {
417
522
  const list = (name) => typeof opts[name] === "string" ? opts[name].split(",").map((s) => s.trim()).filter(Boolean) : void 0;
418
523
  result = await formsCommand(secondary || "status", { site: siteRoot, tenant: typeof opts.tenant === "string" ? opts.tenant : void 0, backendOrigin: typeof opts["backend-origin"] === "string" ? opts["backend-origin"] : void 0, origins: list("origins"), recipients: list("recipients"), mode: typeof opts.mode === "string" ? opts.mode : void 0, apply: !!opts.apply, testKeys: !!opts["test-keys"], cloudflare: !!opts.cloudflare, worker: typeof opts.worker === "string" ? opts.worker : void 0, config: typeof opts["forms-config"] === "string" ? opts["forms-config"] : void 0, dryRun });
419
524
  } else if (primary === "doctor") {
@@ -425,6 +530,7 @@ Options:
425
530
  } else if (primary === "site" && secondary === "create") {
426
531
  result = await createSite({
427
532
  ...siteInputsFromFlags(opts),
533
+ admin: !!opts.admin,
428
534
  dir: typeof opts.dir === "string" ? opts.dir : "",
429
535
  slug: typeof opts.slug === "string" ? opts.slug : "",
430
536
  name: typeof opts.name === "string" ? opts.name : void 0,
@@ -559,14 +665,14 @@ Options:
559
665
  if (sub === "doctor") {
560
666
  result = await runDoctor({ provider: "cloudflare", operation: "pages" });
561
667
  } else if (sub === "whoami") {
562
- const probe = spawnSync(process.platform === "win32" ? "npx.cmd" : "npx", ["wrangler", "whoami"], { encoding: "utf8" });
668
+ const probe = spawnSync2(process.platform === "win32" ? "npx.cmd" : "npx", ["wrangler", "whoami"], { encoding: "utf8" });
563
669
  result = {
564
670
  ok: probe.status === 0,
565
671
  exitCode: probe.status === 0 ? EXIT_CODES.SUCCESS : EXIT_CODES.CONFIG_OR_CLI_ERROR,
566
672
  summary: probe.stdout || probe.stderr
567
673
  };
568
674
  } else if (sub === "login") {
569
- const probe = spawnSync(process.platform === "win32" ? "npx.cmd" : "npx", ["wrangler", "login"], { stdio: "inherit" });
675
+ const probe = spawnSync2(process.platform === "win32" ? "npx.cmd" : "npx", ["wrangler", "login"], { stdio: "inherit" });
570
676
  result = {
571
677
  ok: probe.status === 0,
572
678
  exitCode: probe.status === 0 ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR,
@@ -580,7 +686,7 @@ Options:
580
686
  };
581
687
  }
582
688
  } else if (primary === "knowledge" && secondary === "explain") {
583
- const explained = explainStage(resolve2(typeof opts.site === "string" ? opts.site : "."), typeof opts.stage === "string" ? opts.stage : "direction");
689
+ const explained = explainStage(resolve3(typeof opts.site === "string" ? opts.site : "."), typeof opts.stage === "string" ? opts.stage : "direction");
584
690
  result = { ok: true, exitCode: EXIT_CODES.SUCCESS, summary: explained.text, data: explained };
585
691
  } else if (primary === "share") {
586
692
  result = await shareSite({
@@ -13,7 +13,7 @@ import {
13
13
  formatExclusion,
14
14
  isErrorPage,
15
15
  isPlaywrightLaunchFailure
16
- } from "./chunk-XY73ZLHB.js";
16
+ } from "./chunk-GQPUILFR.js";
17
17
  import {
18
18
  json,
19
19
  save
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-XL2DRAYI.js";
4
4
  import {
5
5
  sourceRevision
6
- } from "./chunk-XY73ZLHB.js";
6
+ } from "./chunk-GQPUILFR.js";
7
7
  import {
8
8
  previewServer
9
9
  } from "./chunk-WEFDVXFX.js";
@@ -7,7 +7,7 @@ import {
7
7
  sourceRevision,
8
8
  verifyRefine,
9
9
  withMutationLock
10
- } from "./chunk-XY73ZLHB.js";
10
+ } from "./chunk-GQPUILFR.js";
11
11
  import {
12
12
  assertRequestId,
13
13
  parseChangePlan,
@@ -5172,7 +5172,7 @@ ${message}`;
5172
5172
  }
5173
5173
 
5174
5174
  // src/agent/refine.ts
5175
- import { parseFragment as parseFragment3 } from "parse5";
5175
+ import { parseFragment as parseFragment4 } from "parse5";
5176
5176
 
5177
5177
  // src/editing/identity.ts
5178
5178
  function attribute(attrs, name) {
@@ -5182,6 +5182,33 @@ function elementMatchesIdentity(attrs, identity) {
5182
5182
  return attribute(attrs, "data-vitris-field") === identity || attribute(attrs, "id") === identity;
5183
5183
  }
5184
5184
 
5185
+ // src/agent/refine-document-checks.ts
5186
+ import { parseFragment as parseFragment3 } from "parse5";
5187
+ function hasRefinementPlaceholder(html) {
5188
+ const token = /\[TODO\b|\[INSERT\b|\[TBD\b|\bundefined\b|\bNaN\b/i;
5189
+ const contains = (node) => {
5190
+ if (["script", "style", "#comment"].includes(node.nodeName)) return false;
5191
+ if (node.nodeName === "#text" && token.test(node.value || "")) return true;
5192
+ if (node.attrs?.some((attr2) => !/^on/i.test(attr2.name) && attr2.name !== "style" && token.test(attr2.value))) return true;
5193
+ return (node.childNodes || []).some(contains) || !!(node.content && contains(node.content));
5194
+ };
5195
+ return contains(parseFragment3(html));
5196
+ }
5197
+ async function settleRefinementImages(page, timeoutMs = 5e3) {
5198
+ await page.evaluate(`(async () => {
5199
+ let timer;
5200
+ try {
5201
+ await Promise.race([
5202
+ Promise.all([...document.images].filter(image => image.getClientRects().length).map(image => {
5203
+ image.loading = 'eager';
5204
+ return image.decode().catch(() => {});
5205
+ })),
5206
+ new Promise(resolve => { timer = setTimeout(resolve, ${Math.max(0, timeoutMs)}); })
5207
+ ]);
5208
+ } finally { clearTimeout(timer); }
5209
+ })()`);
5210
+ }
5211
+
5185
5212
  // src/agent/refine.ts
5186
5213
  var CHANGE_SCOPES = ["business", "copy", "pages", "direction", "media"];
5187
5214
  var COPY_REQUEST_NODES = ["copy", "pages"];
@@ -6350,7 +6377,7 @@ function sourceTargetText(root, target) {
6350
6377
  const matches = [];
6351
6378
  const content = (node) => node.nodeName === "#text" ? node.value || "" : (node.childNodes || []).map(content).join("");
6352
6379
  for (const file of candidates.filter((file2) => existsSync23(file2))) {
6353
- const tree = parseFragment3(readFileSync18(file, "utf8"));
6380
+ const tree = parseFragment4(readFileSync18(file, "utf8"));
6354
6381
  const walk = (node) => {
6355
6382
  if (elementMatchesIdentity(node.attrs, id)) matches.push(content(node).trim());
6356
6383
  for (const child of node.childNodes || []) walk(child);
@@ -6518,7 +6545,7 @@ function verifyFulfillment(root, request, baseline) {
6518
6545
  const htmlFiles = files(distDir).filter((f) => f.endsWith(".html"));
6519
6546
  for (const file of htmlFiles) {
6520
6547
  const content = readFileSync18(file, "utf8");
6521
- if (/\[TODO\b|\[INSERT\b|\[TBD\b|\bundefined\b|\bNaN\b/i.test(content)) {
6548
+ if (hasRefinementPlaceholder(content)) {
6522
6549
  errors.push(`Unresolved placeholder content found in ${file.slice(distDir.length).replace(/\\/g, "/")}`);
6523
6550
  }
6524
6551
  if (/<h[1-6]>\s*<\/h[1-6]>/i.test(content)) {
@@ -6543,7 +6570,7 @@ function verifyFulfillment(root, request, baseline) {
6543
6570
  errors.push(`Criterion ${criterion.id} route ${route} was not built`);
6544
6571
  continue;
6545
6572
  }
6546
- const tree = parseFragment3(readFileSync18(output, "utf8"));
6573
+ const tree = parseFragment4(readFileSync18(output, "utf8"));
6547
6574
  const matches = [];
6548
6575
  const walk = (node) => {
6549
6576
  if (elementMatchesIdentity(node.attrs, id)) matches.push(node);
@@ -6634,7 +6661,7 @@ function verifyFulfillment(root, request, baseline) {
6634
6661
  if (intentKeywords.length > 0) {
6635
6662
  const isRemoval = /\b(remove|delete|drop|hide|eliminate)\b/i.test(request.instruction);
6636
6663
  const visibleText = (node) => ["script", "style", "svg", "noscript"].includes(node.tagName) ? "" : node.nodeName === "#text" ? node.value : (node.childNodes || []).map(visibleText).join(" ");
6637
- const currentAllText = htmlFiles.map((f) => visibleText(parseFragment3(readFileSync18(f, "utf8"))).toLowerCase()).join(" ");
6664
+ const currentAllText = htmlFiles.map((f) => visibleText(parseFragment4(readFileSync18(f, "utf8"))).toLowerCase()).join(" ");
6638
6665
  const copyPlanPath = join22(root, "onboarding/copy-plan.json");
6639
6666
  const copyText = existsSync23(copyPlanPath) ? readFileSync18(copyPlanPath, "utf8").toLowerCase() : "";
6640
6667
  const fullText = currentAllText + " " + copyText;
@@ -6818,6 +6845,7 @@ async function verifyRefine(opts) {
6818
6845
  const url = `http://127.0.0.1:${address.port}${route === "/" ? "" : route}`;
6819
6846
  try {
6820
6847
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15e3 });
6848
+ await settleRefinementImages(page);
6821
6849
  const observed = await page.evaluate(`(() => {
6822
6850
  const errors = [];
6823
6851
  if (document.documentElement.scrollWidth > innerWidth + 2) errors.push('Live browser horizontal overflow detected at ${width}px');
@@ -6,7 +6,7 @@ import {
6
6
  planKitUpgrade,
7
7
  recoverKitUpgrade,
8
8
  rollbackKitUpgrade
9
- } from "./chunk-XY73ZLHB.js";
9
+ } from "./chunk-GQPUILFR.js";
10
10
  import {
11
11
  findHarnessRoot
12
12
  } from "./chunk-H3BS2EBR.js";
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  prepareChange,
3
3
  verifyChange
4
- } from "./chunk-FGSQE3DU.js";
4
+ } from "./chunk-72Y4YMTV.js";
5
5
  import {
6
6
  deployVerifiedArtifact,
7
7
  prepareQuickRelease,
8
8
  prepareVerifiedRelease
9
- } from "./chunk-WMJVLMU2.js";
9
+ } from "./chunk-UR4NWYVW.js";
10
10
  import {
11
11
  creativeRunStatus,
12
12
  currentRunId,
@@ -14,10 +14,10 @@ import {
14
14
  runCreativeProfile,
15
15
  startDesignReview,
16
16
  submitFeedback
17
- } from "./chunk-FEFLSJM5.js";
17
+ } from "./chunk-RE4GOHSU.js";
18
18
  import {
19
19
  verifyPreservedSite
20
- } from "./chunk-R2WRQDVE.js";
20
+ } from "./chunk-65LRZIBP.js";
21
21
  import {
22
22
  EXIT_CODES
23
23
  } from "./chunk-JCRGE6AL.js";
@@ -35,7 +35,7 @@ import {
35
35
  prepareRefine,
36
36
  runWorkflow,
37
37
  verifyRefine
38
- } from "./chunk-XY73ZLHB.js";
38
+ } from "./chunk-GQPUILFR.js";
39
39
  import {
40
40
  parseGraphYaml
41
41
  } from "./chunk-BBV7PMFC.js";