@by-association-only/harness-app 0.0.0-stage → 0.1.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.
@@ -0,0 +1,1382 @@
1
+ import { i as switchPath, t as branchCookie } from "./branch-XrPxkmYQ.mjs";
2
+ import { existsSync, globSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import * as Schema from "effect/Schema";
5
+ import * as Console from "effect/Console";
6
+ import * as Effect from "effect/Effect";
7
+ import { Argument, Command, Flag } from "effect/cli";
8
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
9
+ import * as Stream from "effect/Stream";
10
+ import { branchStage, isBranchName, stageHost } from "@by-association-only/harness-unisian/stages";
11
+ import * as Config from "effect/Config";
12
+ import * as FileSystem from "effect/FileSystem";
13
+ import * as Path from "effect/Path";
14
+ import { TomlDate, parse, stringify } from "smol-toml";
15
+ import * as Predicate from "effect/Predicate";
16
+ import * as DateTime from "effect/DateTime";
17
+ import * as Option from "effect/Option";
18
+ import { DoctorFlags, VerifyFailed, argument, attempt, checkedArgument, command, flowStep, openedPage, optionalFlag, toggle, writeEvidence } from "@by-association-only/harness/verify";
19
+ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
20
+ import * as Clock from "effect/Clock";
21
+ import * as Redacted from "effect/Redacted";
22
+ import { Kind, parse as parse$1 } from "graphql";
23
+ //#region src/blocks.ts
24
+ const agents$1 = (slot, id, lines) => ({
25
+ file: "AGENTS.md",
26
+ id,
27
+ slot,
28
+ text: lines.join("\n")
29
+ });
30
+ const lore = (page, slot, id, lines) => ({
31
+ file: `docs/lore/${page}.md`,
32
+ id,
33
+ slot,
34
+ text: lines.join("\n")
35
+ });
36
+ const baseBlocks = [
37
+ agents$1("hard-to-reverse", "hard-to-reverse", ["- A second Shopify app config, such as `shopify.app.<name>.toml`, which is per-store infrastructure."]),
38
+ agents$1("commands", "commands", [
39
+ "| Command | What it does |",
40
+ "| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |",
41
+ "| `pnpm exec shopify-graphql-codegen` | Regenerates the Admin operation and Events subscription types and the metaobject and metafield Schemas in `src/graphql/`; `--update-schema` refreshes the Admin schema ([lore](docs/lore/codegen-checks-every-template.md)) |",
42
+ "| `pnpm exec harness app dev` | Runs `shopify app dev` on the `local-<user>` stage, holding the dev store's slot, and gives the slot back when it fails or stops; `harness unisian dev` runs the Alchemy half ([lore](docs/lore/branch-router.md)) |",
43
+ "| `pnpm exec harness app stage` | Deploys the branch as `w-<branch>` behind the router host in `shopify.app.toml`, then prints the `/__branch/<stage>` path to open in the Admin |"
44
+ ]),
45
+ agents$1("architecture", "architecture", [
46
+ "- `src/features/shell/` is the Admin frame: the nav, the surfaces list, the error page and the browser error report. The surfaces list is typed from the route tree's `fullPaths`, so tsc fails when a route and the list disagree. `src/server/` also holds the webhook and Events routers and the `ShopSessions` Durable Object, which declares `@unisian/shopify-auth`'s class with the app's config; the Worker entry exports it and `alchemy.run.ts` binds it, since Cloudflare needs both.",
47
+ "- `src/routes/app.tsx` is the shell every Admin page sits in. Its head loads App Bridge first, then the Polaris extensions, then Polaris 2.0 RC; the script URL and the `@shopify/polaris-types` pin change together ([lore](docs/lore/polaris-2-rc.md)). `appNav` in `src/features/shell/nav.ts` is the one nav list: the shell renders it in `ui-nav-menu` and preloads it when the browser goes idle, so a page joins the nav with one entry ([lore](docs/lore/one-nav-list.md)).",
48
+ "- An `/app` page that fails gets `AppErrorPage`, the router's default, and an `/app` path no route serves gets `NotFoundPage`. A failure a merchant should read carries a `merchantMessage` field, which the error page shows. Errors thrown in the browser are reported to the server, so every error page has a reference, and a production build shows none of the failure's text (`test/production-bundle.test.ts`) ([lore](docs/lore/shell-errors.md)).",
49
+ "- A form is `useAppForm(useCommandForm({ contract, command, defaultValues }))`, with `useAppForm` and the Polaris fields from `@unisian/shopify-app-ui/react`. A file field opens Shopify's `pick:shopify/File` intent, so merchant uploads land in Shopify Files ([lore](docs/lore/forms-on-the-contract.md), [files](docs/lore/files-through-intents.md)).",
50
+ "- A feature worker is a Worker every app shares. unisian-next is the only repo that writes one, and an app binds it by name."
51
+ ]),
52
+ agents$1("project-law", "project-law", [
53
+ "- Admin API calls go through `ShopifyAdmin` from `@unisian/shopify-auth`, and every other request through `HttpClient`. Never write an `/admin/api/` URL; lint enforces this (`app/admin-api-url`) ([lore](docs/lore/one-request-path.md)).",
54
+ "- The caller's identity is the shop (`CurrentShop`), provided by the entry point that proved it. ID-token auth runs on the admin app only: admin pages go under `/app`, and server functions are the admin app's calls only. One request middleware (`adminAuth`) authenticates both by path, and the global function middleware reads the shop it found. The app proxy, webhooks, Events and public URLs are server routes that prove themselves (HMAC, a signed query) or need nothing ([lore](docs/lore/who-is-calling.md)).",
55
+ "- A webhook or Events delivery verifies, decodes Shopify's payload, then calls a capability or service inline. A bad HMAC gets 401 and any other failure 500, so Shopify's retry is the queue. Work that can't finish inside Shopify's five seconds starts a Workflow. A handler re-reads current state, tolerates duplicate, late and out-of-order deliveries, and reaches an outside effect, such as an email, only through `once(key, effect)` ([lore](docs/lore/webhooks-are-ingress.md)).",
56
+ "- The app subscribes the webhook topics its own code handles: `app/uninstalled` and `shop/redact` always, `shop/update` and `locales/*` with code that reads the shop's app info, and `customers/data_request` and `customers/redact` only when the app keeps customer data, with handlers that answer them ([lore](docs/lore/webhooks-are-ingress.md)).",
57
+ "- A save splits by what the merchant has to see before \"Saved\" ([lore](docs/lore/saves-and-events.md)):",
58
+ " - The command does the checks, the write, dependent writes and their rollback, and anything only the request knows (who acted, the value before).",
59
+ " - Work derived from Shopify's state reacts to Events at `/events`, whoever made the change, and has a periodic sweep. `shopify-graphql-codegen` types each `[[events.subscription]]` from its `query`, and `eventDispatcher` takes one handler per handle, so a missing or extra handle fails tsc.",
60
+ " - Slow work the merchant waits on starts a Workflow from the command.",
61
+ " - Topics Events lacks (the shop, locales, `app/uninstalled`, compliance) stay on webhooks.",
62
+ "- Every metaobject type is app-owned: `$app:<type>`, declared in `shopify.app.toml`. `access.admin` is `merchant_read_write` unless a merchant's edit would do damage no handler can repair afterwards, such as a points balance or a code that must stay unique; then it's `merchant_read`, and the app's own UI makes the edits ([lore](docs/lore/saves-and-events.md)).",
63
+ "- Every `#graphql` template is one named operation with no interpolation. `pnpm check` runs `shopify-graphql-codegen --check`, which fails a template the registry doesn't match and a hand edit to a generated file ([lore](docs/lore/codegen-checks-every-template.md)).",
64
+ "- An app declares a write scope alone, never its read scope beside it: Shopify grants read with write."
65
+ ]),
66
+ agents$1("deploys", "deploys", [
67
+ "- On every push to `{{defaultBranch}}`, once the `live` Worker deploys, CI releases the live app with `shopify app deploy --config live --allow-updates`, so merging is the go-ahead for the release too. A failed Worker deploy stops the release ([lore](docs/lore/one-live-stage.md)).",
68
+ "- Releasing the dev app, and any release with `--allow-deletes`, asks {{maintainer}} at the time: deleting app configuration or an extension deletes its data on stores. {{maintainer}} merges a PR whose release needs `--allow-deletes`.",
69
+ "- Take the dev store's slot without asking when your branch changes app config (scopes, webhooks, Events, the app proxy, extensions). `pnpm exec harness app slot take` refuses while another checkout holds it: find that checkout's agent with `herdr agent list`, ask whether it can give the slot back, and wait if it's still proving. Give the slot back with `pnpm exec harness app slot give` as soon as your proof is in ([lore](docs/lore/branch-router.md))."
70
+ ]),
71
+ lore("who-is-calling", "entry-points", "who-is-calling", [
72
+ "## In a Shopify app",
73
+ "",
74
+ "The identity service is `CurrentShop` (`@unisian/shopify-auth`), and it holds only which shop (`{ shop }`), because every entry point can prove that (Dan Gamble, 2026-09-28). On the storefront, the customer comes from the Shopify-signed app proxy's `logged_in_customer_id`, never from the body.",
75
+ "",
76
+ "| Entry point | Proves | Provides |",
77
+ "| ------------------------------ | -------------------------------------- | --------------------------------------------------------------------------------- |",
78
+ "| Admin page load (under `/app`) | staff session token (`id_token` query) | nothing directly; its loaders' server functions reuse the check, once per request |",
79
+ "| Admin server function | staff session token | `CurrentShop`, `ShopifyAdmin` |",
80
+ "| App proxy route | Shopify-signed proxy query | `CurrentShop`, `ShopifyAdmin`, `CurrentCustomer` (not built yet) |",
81
+ "| Webhook or Events delivery | HMAC | `CurrentShop`, `ShopifyAdmin.layerForShop` |",
82
+ "| Queue or Workflow step | its own enqueued payload | `CurrentShop`, `ShopifyAdmin.layerForShop` |",
83
+ "",
84
+ "A capability that requires `CurrentCustomer` can't be exposed as an admin server function, because nothing there provides it, so `tsc` fails.",
85
+ "",
86
+ "The offline session and the request's ID token stay with the API clients. The admin entry point passes them to `ShopifyAdmin.layer`, which re-exchanges the ID token after a 401; everywhere else `ShopifyAdmin.layerForShop` loads the stored session by the shop's domain. So the access token never reaches a capability, and a capability that only needs the shop runs from a page and a webhook alike. abask confirms the split: its session reads either use only the shop (about 20 of them) or build an Admin or Storefront API client. A Storefront API client follows `ShopifyAdmin`'s pattern and holds the session itself.",
87
+ "",
88
+ "## ID-token auth on the admin app only",
89
+ "",
90
+ "Only the admin app proves itself with an ID token, so only the admin app runs ID-token auth (Dan Gamble, 2026-09-28). One request middleware, `adminAuth` (`src/server/middleware.ts`), authenticates a request whose path is `/app`, under `/app/`, or under Start's server-function base, and puts the shop in context. `adminCalls`, the global function middleware, reads the shop from there. A loader's server-function call during SSR reads the page's own result, so an `/app` load verifies once. Server functions are the admin app's calls only; storefront and proxy work are server routes. Everything else proves itself its own way or not at all: the app proxy by its signed query, webhooks and Events by HMAC, `/auth/bounce` and `/` by needing nothing.",
91
+ "",
92
+ "An earlier request middleware authenticated every request that wasn't the bounce or a webhook. So a path nothing served, such as `/favicon.ico` or the Admin's `/extensions` socket, got the 400 or the bounce and logged a token failure. `adminAuth` checks the path first, so those paths keep the router's 404. Its context types `shopify` as possibly undefined, and `adminCalls` narrows it: a server function a loader calls outside `/app` fails there rather than in `serve`. `test/request-handler.test.ts` builds the app and checks both halves.",
93
+ "",
94
+ "## Who may frame the app",
95
+ "",
96
+ "The `frame-ancestors` header, which `adminAuth` sets on every HTML response, names a shop only when the ID token proved it. Every other HTML response, the `/auth/bounce` page and error pages included, lets only Shopify's Admin hosts frame it, whatever `shop` the query names. Shopify's `addDocumentResponseHeaders` and Unisian's `addShopifyHeaders` take the shop from the sanitised query parameter instead, and send no header without one (Dan Gamble, 2026-10-03; [iframe protection](https://shopify.dev/docs/apps/build/security/set-up-iframe-protection))."
97
+ ]),
98
+ lore("forms-on-the-contract", "form-kit", "forms-on-the-contract", [
99
+ "## The Polaris form kit",
100
+ "",
101
+ "Unisian's kit was TanStack Form with TanStack Query, and client apps build on it: `abask-pstack-bootstrap` adds its own fields through the kit's `extendForm`. The kit keeps that shape. `@unisian/shopify-app-ui` holds `useAppForm`, `extendForm`, the `SaveBar` on App Bridge's save bar, `ErrorBanner`, `HexColour`, `Picker`, and a field for every Polaris input: TextField, TextArea, EmailField, UrlField, PasswordField, SearchField, MoneyField, NumberField, DateField, DatePicker, ColorField, Checkbox, Switch, Select, ChoiceList, ColorPicker, and FilePicker, which opens the `pick:shopify/File` intent in place of `s-drop-zone` ([files through the intent](./files-through-intents.md)). BooleanSelect holds yes, no or unset, and EnumSwitch maps checked and unchecked to two values a schema names, such as `VISIBLE` and `HIDDEN`. `s-number` only displays a number, so it isn't a field. A form is `useAppForm(useCommandForm({ contract, command, defaultValues }))`, and an app adds its own fields through `extendForm` in `src/`.",
102
+ "",
103
+ "`Picker`, and the ColorPicker and FilePicker built on it, take a `marker` beside the label and `actions` in place of Reset and Clear, so a field that overrides a shared default can offer Reset to default. Every field passes the Polaris element's own props through, and each event the kit handles takes an `(event, field)` override. Select and ChoiceList set `selected` on each `s-option` and `s-choice`: setting `value` on `s-select` or `values` on `s-choice-list` didn't stick on the Polaris RC, and after a reload a saved value showed the first option or nothing ticked (2026-10-02, in the Admin).",
104
+ "",
105
+ "- Data a form can show after it renders, such as a picked file's thumbnail, the loader starts with `void queryClient.query(...).catch(() => null)` and doesn't await, and the field reads it with `useSuspenseQuery` inside a `Suspense` boundary. The server sends the spinner, then streams the image into the boundary in the same response. Awaiting it would hold back a form with many files; reading it with `useQuery` instead rendered the image on the client where the server had sent the spinner, a hydration error. Under `shopify app dev` the Cloudflare quick tunnel holds the whole response until its last chunk, so the page shows only once the slowest file arrives.",
106
+ "- Discard on the save bar resets to the form's `defaultValues` without a server call.",
107
+ "- On a `CapabilityFailureError`, `showOnForm` puts each issue whose path names a field on that field, and shows the `merchantMessage`, path-less issues and issues for fields the form doesn't have in `ErrorBanner`.",
108
+ "- A capability that runs a mutation turns Shopify's `userErrors` into `fieldIssues`, mapping Shopify's field paths onto the form's."
109
+ ]),
110
+ lore("one-live-stage", "releases", "one-live-stage", [
111
+ "## Releasing the Shopify app",
112
+ "",
113
+ "- Merging is the go-ahead, for the Worker and the app. After the `live` Worker deploys, the same job releases the live app: `shopify app deploy --config live --allow-updates`, with the commit as `--source-control-url` (Dan Gamble, 2026-09-30). The Worker goes first so Shopify is never pointed at code that can't serve a new webhook topic or scope yet, and a failed Worker deploy stops the release. `shopify.app.toml` is never on the list of paths that never deploy, because `alchemy.run.ts` reads the API version from it.",
114
+ "- CI never passes `--allow-deletes`. Shopify warns that deleting app configuration or extensions \"also deletes related data on stores\", so a merge that removes one fails the release and a person runs it by hand, after asking.",
115
+ "- The Shopify CLI is pinned as a dev dependency (`@shopify/cli`), so CI and local dev run the same version. It authenticates with an App Automation Token made for the live app in the Dev Dashboard (`SHOPIFY_APP_AUTOMATION_TOKEN` in the `live` environment). That token expires after at most 6 months, and the Dashboard's rotate flow keeps old and new valid until the old one is revoked. CI also holds `SHOPIFY_API_SECRET`.",
116
+ "- The live app is its own Shopify app, in `shopify.app.live.toml`, created with `shopify app config link --file-name live`. Local `shopify app dev` keeps the dev app in `shopify.app.toml`, so the two never compete for one app's URL. The two apps have no reason to differ in scopes, webhooks, metaobjects or anything else they declare, so `test/app-configs.test.ts` parses both files whole and fails on any difference outside `client_id`, `name`, `application_url`, `auth.redirect_urls` and `build.automatically_update_urls_on_dev`. It also fails if the live app shares the dev app's client ID or lets a dev session repoint its URLs.",
117
+ "- The live app's config is the source of its identity and URL. On the `live` stage, `alchemy.run.ts` reads `client_id` and `application_url` from `shopify.app.live.toml`: `client_id` becomes `SHOPIFY_API_KEY`, and `application_url`'s hostname becomes the Worker's custom domain. Every other stage reads both from `shopify.app.toml`, and a stage named like a branch gets the host the branch router sends it ([branch router](./branch-router.md)). The hostname's zone sits in the same Cloudflare account as the Workers, and CI's Cloudflare token needs Zone Read on it, since Alchemy looks the zone up by name. A fixed URL means the first deploy is the only one: before it, the Worker's `workers.dev` name would have held a random instance ID.",
118
+ "",
119
+ "Rejected, from Unisian's `ops.md` (Dan Gamble, 2026-10-14, [harness#13](https://github.com/baoagency/harness/issues/13)):",
120
+ "",
121
+ "- A manual production deploy. Merging is the go-ahead.",
122
+ "- A production stage only. Every app has `live`, `main`, `pr-<n>` and `w-<branch>`.",
123
+ "- No unit-test gate. A change is ready when `pnpm check` and `pnpm test` pass."
124
+ ])
125
+ ];
126
+ const featureWorkerBlocks = [
127
+ agents$1("architecture", "feature-workers-architecture", ["- The app binds each feature worker with `bindService` in `alchemy.run.ts` and lists it in `boundServices` (`src/features/shell/services.ts`), whose `binding` is typed from the app's env, so an entry with no binding fails tsc. A merchant request under the worker's route prefix passes the app's own ID-token auth, then the app's proxy forwards it with the shop's access token and the `X-Unisian-*` headers from `@unisian/service-contract`. A feature worker never gets the refresh token or the API secret ([lore](docs/lore/cloudflare-bindings.md))."]),
128
+ agents$1("project-law", "feature-workers-project-law", ["- Every scope a `boundServices` entry names is in both app configs, where a write scope counts as its read scope; `test/service-scopes.test.ts` fails on a missing one.", "- Only a feature worker whose entry sets `targetShops: true` gets the app's other shops and may act on one, and only in an app installed on its own client's stores ([lore](docs/lore/cloudflare-bindings.md))."]),
129
+ lore("cloudflare-bindings", "service-bindings", "feature-workers", [
130
+ "## Feature workers",
131
+ "",
132
+ "A feature worker is a Worker BAO shares across apps: unisian-next writes it in `services/<name>/` and deploys it once, and an app binds it by name. Feature workers are always central and shared, so unisian-next is the only repo that writes one (Dan Gamble, 2026-10-14, [harness#13](https://github.com/baoagency/harness/issues/13)). An app handed over to its client binds a deployment unisian-next makes once in the client's Cloudflare account, since a binding can't cross accounts ([one deployment per client](./one-deployment-per-client.md)).",
133
+ "",
134
+ "Unisian's feature workers took the app's offline token and its API secret in headers. Here a feature worker gets neither (Dan Gamble, 2026-10-03). A request carries the app's current access token, which lives an hour. For anything the worker keeps, or that outlives the request, such as a Workflow's tokens, it mints a delegate with `createDelegateToken` (`@unisian/shopify-auth`), carrying only the scopes it needs. The app's `ShopSessions` stays the only holder of the refresh token.",
135
+ "",
136
+ "### Binding one",
137
+ "",
138
+ "- The app binds a worker with `bindService` (`alchemy.run.ts`) and lists it in `boundServices` (`src/features/shell/services.ts`), whose `binding` is typed from the app's env: a manifest entry with no binding fails tsc. Every scope an entry names must appear in both app configs, and `test/service-scopes.test.ts` fails otherwise. A write scope counts as its read scope, since Shopify grants read with write.",
139
+ "- A merchant request under a worker's route prefix goes through the app's own ID-token auth, then the proxy forwards it with the shop's access token and the `X-Unisian-*` headers. The proxy refreshes a token with under 5 minutes left before it forwards it, and drops anything the browser sent under that prefix, and the browser's `authorization` and `cookie`. The header contract only ever grows, so every header added after the first five is optional: a worker deploys before the app at each stage, and an older app sends none of them.",
140
+ "- A request under a worker's asset prefix skips auth, since a page's `<script src>` and stylesheet loads carry no ID token. The proxy forwards a `GET` or `HEAD` without the browser's credentials or any `X-Unisian-*` header, and answers any other method with 405.",
141
+ "- `X-Unisian-Nav` carries the app's `appNav` and then every bound worker's entry, as JSON. A worker's page renders it as plain links inside its own `ui-nav-menu`, so the Admin's nav stays the same on every page ([one nav list](./one-nav-list.md)).",
142
+ "- A worker that works across the merchant's stores sets `targetShops: true` in its entry. Only such a worker gets `X-Unisian-Shops`, the shops with the app installed, and only such a worker may ask for a target shop with `X-Unisian-Target-Shop`. The proxy checks that the app holds a session for the target and that its scopes cover the worker's, then sends the target and its access token as `X-Unisian-Target-Shop` and `X-Unisian-Target-Access-Token`. A target's session holds the scopes Shopify returned at the app's last token exchange or refresh, which can lack one the worker needs. A token with write scopes on another store is the reason for the opt-in. The proxy answers 403 with a merchant message, and never calls the worker, when the target has no session, when its scopes fall short of the worker's, or when the worker hasn't opted in (Dan Gamble, 2026-10-05). Any store the app holds a session for may be a target, so this rests on a client app being installed only on its own client's stores: an app installed on unrelated merchants' stores must not bind a worker with `targetShops`.",
143
+ "- The shops list leaves out a store whose app was uninstalled: `app/uninstalled` marks its row, and the store's next Admin load clears the mark and fetches the app info again.",
144
+ "- A worker that runs a long job answers with `X-Unisian-Token-Needed: 1`. The shop's `ShopSessions` then sets an alarm that renews the offline token and pushes a fresh delegate to the worker's `POST /__unisian/token` until every worker answers `needed: false`, and a request that had a target does the same for the target's `ShopSessions`. The first push goes out at once; later ones run every 50 minutes on `live` and every 2 minutes on other stages, since a delegate can't outlive its parent, which lives an hour. A worker already waiting leaves the alarm where it is, so a page that polls can't force a renewal per request. `needed` comes from the Workflow instance's real status, never from a flag the job sets when it ends: a job that crashes before clearing its flag keeps the app pushing tokens.",
145
+ "",
146
+ "### Which stage an app stage binds",
147
+ "",
148
+ "The live app binds the workers at `live`; `main`, `pr-<n>` and `w-<branch>` bind them at `main` (Dan Gamble, 2026-10-04). Alchemy's beta binds every service under `alchemy dev` through the local dev registry, so a local dev stage can't reach a worker deployed at `main`.",
149
+ "",
150
+ "### Rejected",
151
+ "",
152
+ "- The app's offline token, API secret, app URL and scopes in headers, as Unisian did. A worker that holds the refresh token or the secret can act as the app forever.",
153
+ "- A token only on merchant requests. A Workflow outlives the request, so long jobs need pushes.",
154
+ "- A delegate minted per proxied request. It cost about 250 ms a request, and a request that keeps nothing gains little from a narrower token.",
155
+ "- The worker pulling tokens from the app, which needs a binding from each worker back to every app that uses it.",
156
+ "- Every app stage binding `live`. A preview's requests would reach the live workers.",
157
+ "- A worker stage per app stage. Every `pr-<n>` and `w-<branch>` would deploy a copy of every worker it doesn't change."
158
+ ])
159
+ ];
160
+ const extensionBlocks = [agents$1("architecture", "extensions-architecture", ["- An extension lives in `apps/<app>/extensions/<name>/` with its `shopify.extension.toml`, and ships in its app's release. A Shopify Function is Rust: its folder holds a `Cargo.toml`, and `pnpm check` fails one without. Logic two Functions share is a crate published to crates.io, never a copy or a git dependency."]), agents$1("project-law", "extensions-project-law", ["- A Function runs inside checkout, and Shopify says to \"choose Rust to stay within the Shopify Functions instruction count requirements\"; the limit fails at the buyer, not in CI. `pnpm check` runs `cargo fmt --check` and `cargo clippy --all-targets --locked -- -D warnings` on every crate (`harness app rust check`).", "- Each Function's `tests/` folder holds one test file, which calls `testFunctionFixtures({ instructionBudget, testsDir: import.meta.dirname })` from `@by-association-only/harness-app/function-tests`, with the budget a named constant. It builds the Function, runs every fixture in `tests/fixtures/`, and fails one whose output differs from the fixture's or that runs more instructions than the budget. Shopify stops a Function at 11 million instructions for a cart of up to 200 lines, so the budget sits below that, and one fixture is the largest input the client will realistically send."])];
161
+ //#endregion
162
+ //#region src/unisian-docs.ts
163
+ const agents = (slot, id, lines) => ({
164
+ file: "AGENTS.md",
165
+ id,
166
+ slot,
167
+ text: lines.join("\n")
168
+ });
169
+ const unisianDocBlocks = [
170
+ agents("what-this-repo-is", "manifesto", ["- Why BAO builds apps the way this file says: [the manifesto](docs/manifesto.md), seven tenets in Dan Gamble's words. It comes from `@by-association-only/harness-app`, so `harness rulebook --check` fails on a local edit, and a change to it goes to Dan Gamble."]),
171
+ agents("architecture", "one-deployment-per-client", ["- A deployment serves one client's stores, on every stage, and code may trust that every shop the app holds a session for belongs to the client. A second client gets an app and a deployment of its own. An app handed over to its client deploys to the client's own Cloudflare account ([lore](docs/lore/one-deployment-per-client.md))."]),
172
+ agents("project-law", "where-data-lives", ["- Where data lives turns on one question: does a Shopify surface (the theme, a Function, the Admin, the merchant, another app) read it? Yes puts it in Shopify, as a metafield, a metaobject or a file; no puts it in D1. Data a surface reads that also needs fast app queries sits in D1, with a metaobject copy the command writes in the same operation: a named exception, never a default ([lore](docs/lore/saves-and-events.md))."]),
173
+ agents("project-law", "extension-choice", [
174
+ "- Before an app gains an extension:",
175
+ " - A Shopify Function goes in only where Shopify's own features can't express the behaviour without a clumsy merchant workflow. The app then builds the screen that configures it, so weigh that screen into the choice.",
176
+ " - Behaviour, such as hiding, blocking or changing what the buyer gets, is a Function. A checkout UI extension goes in only for content or input that Shopify's checkout doesn't offer, such as a gift message, a free-shipping threshold, or the words that explain what a Function changed, and beside a Function only when the Function's own message can't explain what happened.",
177
+ " - Checkout's components are fewer than the Admin's Polaris set. A design they can't draw well doesn't belong in checkout.",
178
+ " - One app holds every Function. A second app needs a reason: a Function at its instruction budget that has to take a new behaviour, a domain unrelated to the app's, or a team or merchant that needs the code kept apart. A different scope or a different feature isn't one."
179
+ ]),
180
+ agents("project-law", "theme-content", [
181
+ `- App content reaches the storefront through a theme app extension when the client's theme takes app blocks: the starter theme, or any Online Store 2.0 theme whose sections take them. Otherwise the theme team edits the theme by hand. What this repo is says which kind this client's theme is ([harness#13](https://github.com/baoagency/harness/issues/13#issuecomment-6167943534)).`,
182
+ " - An extension ships the theme's read path in the same release as the data it reads, since merging releases the live app at once.",
183
+ " - With hand edits, an app change that breaks the theme's read path waits until the theme change can go live with it."
184
+ ])
185
+ ];
186
+ const unisianDocFeatureWorkerBlocks = [agents("architecture", "feature-workers-handed-over", ["- A service binding reaches only a Worker on the app's own Cloudflare account, so an app handed over to its client can't bind the feature workers BAO deploys. unisian-next deploys each feature worker the app binds once, into the client's account, and the app binds that deployment. The app never copies a feature worker's code ([lore](docs/lore/one-deployment-per-client.md))."])];
187
+ const unisianDocExtensionBlocks = [agents("project-law", "function-judgement", [
188
+ "- One Function per behaviour. Two behaviours share a Function only when the merchant sees them as one idea, and merging them asks the merchant first.",
189
+ "- Anything the merchant might change lives in an `$app:` metaobject the Function reads through its input query, never in the Function's code. The input query asks only for the fields the Function uses, since every field costs instructions.",
190
+ "- Fixtures cover each shape of input the Function handles."
191
+ ]), agents("project-law", "checkout-ui", ["- One checkout UI extension per concern, and no branching on its target inside it.", "- A target draws differently by cart state, so prove each extension on the dev store's checkout with carts of different shapes, such as a high-value cart and one shipping abroad."])];
192
+ //#endregion
193
+ //#region src/codegen/check.ts
194
+ const codegenPackage = "@unisian/shopify-graphql-codegen";
195
+ const codegenBin = "shopify-graphql-codegen";
196
+ var CodegenCheckFailed = class extends Schema.TaggedError()("CodegenCheckFailed", { reason: Schema.String }) {
197
+ get message() {
198
+ return this.reason;
199
+ }
200
+ };
201
+ const Manifest = Schema.fromJsonString(Schema.Struct({
202
+ dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
203
+ devDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String))
204
+ }));
205
+ const decodeManifest = Schema.decodeUnknownSync(Manifest);
206
+ const listsCodegen = (repo, file) => {
207
+ const { dependencies = {}, devDependencies = {} } = decodeManifest(readFileSync(path.join(repo, file), "utf-8"));
208
+ return Object.hasOwn(dependencies, codegenPackage) || Object.hasOwn(devDependencies, codegenPackage);
209
+ };
210
+ const appsWithCodegen = (repo) => globSync(["package.json", "apps/*/package.json"], { cwd: repo }).filter((file) => listsCodegen(repo, file)).map((file) => path.dirname(file)).toSorted();
211
+ const checkApp = Effect.fn("checkApp")(function* checkApp(repo, app) {
212
+ if ((yield* (yield* ChildProcessSpawner.ChildProcessSpawner).exitCode(ChildProcess.make("pnpm", [
213
+ "exec",
214
+ codegenBin,
215
+ "--check"
216
+ ], {
217
+ cwd: path.join(repo, app),
218
+ extendEnv: true,
219
+ stderr: "inherit",
220
+ stdin: "ignore",
221
+ stdout: "inherit"
222
+ })).pipe(Effect.mapError((error) => new CodegenCheckFailed({ reason: `Can't run ${codegenBin} in ${app} (${error.message}).` })))) !== 0) return yield* new CodegenCheckFailed({ reason: `${codegenBin} --check failed in ${app}: the generated types don't match its #graphql templates and shopify.app.toml. Run ${codegenBin} there and commit what it writes.` });
223
+ });
224
+ const codegenCheckCommand = Command.make("codegen-check", {}, () => Effect.gen(function* codegenCheck() {
225
+ const repo = process.cwd();
226
+ const apps = appsWithCodegen(repo);
227
+ if (apps.length === 0) {
228
+ yield* Console.log(`No app depends on ${codegenPackage}, so there's nothing to check.`);
229
+ return;
230
+ }
231
+ for (const app of apps) yield* checkApp(repo, app);
232
+ yield* Console.log(`${codegenBin} --check passed in ${apps.join(", ")}.`);
233
+ })).pipe(Command.withDescription(`Run \`${codegenBin} --check\` in each app (the root, or apps/<name>/) whose package.json lists ${codegenPackage}, so every #graphql template's types stay current. A repo without it passes.`));
234
+ //#endregion
235
+ //#region src/failed.ts
236
+ var CommandFailed = class extends Schema.TaggedError()("CommandFailed", { reason: Schema.String }) {
237
+ get message() {
238
+ return this.reason;
239
+ }
240
+ };
241
+ //#endregion
242
+ //#region src/run.ts
243
+ const cannotRun = (command) => (error) => new CommandFailed({ reason: `Can't run ${command} (${error.message}). Run this through pnpm, which puts the repo's own ${command} on PATH.` });
244
+ const output = Effect.fn("output")(function* output(command, args, { cwd, env = {} } = {}) {
245
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
246
+ const [stdout, stderr, status] = yield* Effect.scoped(Effect.gen(function* collect() {
247
+ const handle = yield* spawner.spawn(ChildProcess.make(command, args, {
248
+ cwd,
249
+ env: { ...env },
250
+ extendEnv: true
251
+ }));
252
+ return yield* Effect.all([
253
+ Stream.mkString(Stream.decodeText(handle.stdout)),
254
+ Stream.mkString(Stream.decodeText(handle.stderr)),
255
+ handle.exitCode
256
+ ], { concurrency: "unbounded" });
257
+ })).pipe(Effect.mapError(cannotRun(command)));
258
+ if (status !== 0) return yield* new CommandFailed({ reason: `${[command, ...args].join(" ")} exited ${status}: ${stderr.trim()}` });
259
+ return stdout.trim();
260
+ });
261
+ const runInherited = Effect.fn("runInherited")(function* runInherited(command, args, { cwd, env = {} } = {}) {
262
+ return yield* (yield* ChildProcessSpawner.ChildProcessSpawner).exitCode(ChildProcess.make(command, args, {
263
+ cwd,
264
+ detached: false,
265
+ env: { ...env },
266
+ extendEnv: true,
267
+ stderr: "inherit",
268
+ stdin: "inherit",
269
+ stdout: "inherit"
270
+ })).pipe(Effect.mapError(cannotRun(command)));
271
+ });
272
+ //#endregion
273
+ //#region src/checkout.ts
274
+ const git = (checkout, args) => output("git", [
275
+ "-C",
276
+ checkout,
277
+ ...args
278
+ ]);
279
+ const checkoutAt = Effect.fn("checkoutAt")(function* checkoutAt(directory) {
280
+ const [root = "", commonDirectory = "", branch = ""] = (yield* git(directory, [
281
+ "rev-parse",
282
+ "--path-format=absolute",
283
+ "--show-toplevel",
284
+ "--git-common-dir",
285
+ "--abbrev-ref",
286
+ "HEAD"
287
+ ]).pipe(Effect.mapError(() => new CommandFailed({ reason: `${directory} isn't inside a git checkout.` })))).split("\n");
288
+ return {
289
+ branch,
290
+ commonDirectory,
291
+ root
292
+ };
293
+ });
294
+ const currentCheckout = Effect.suspend(() => checkoutAt(globalThis.process.cwd()));
295
+ //#endregion
296
+ //#region src/env/check.ts
297
+ const envIgnored = Effect.fn("envIgnored")(function* envIgnored(root) {
298
+ const status = yield* runInherited("git", [
299
+ "-C",
300
+ root,
301
+ "check-ignore",
302
+ "--quiet",
303
+ ".env"
304
+ ]);
305
+ if (status === 1) return yield* new CommandFailed({ reason: `git doesn't ignore ${root}/.env, so the app's secret could be committed. Add .env to .gitignore, then run harness app pull-env.` });
306
+ if (status !== 0) return yield* new CommandFailed({ reason: `git check-ignore .env exited ${status}.` });
307
+ });
308
+ const envCheckCommand = Command.make("env-check", {}, Effect.fn("envCheck")(function* envCheck() {
309
+ const tracked = yield* git(process.cwd(), [
310
+ "ls-files",
311
+ "--full-name",
312
+ "--",
313
+ ":(top,glob)**/.env"
314
+ ]);
315
+ if (tracked !== "") return yield* new CommandFailed({ reason: `git tracks ${tracked.split("\n").join(", ")}, which holds the app's secret. Run git rm --cached on it, and add .env to .gitignore.` });
316
+ yield* Console.log("git tracks no .env.");
317
+ })).pipe(Command.withDescription("Fail when git tracks a .env anywhere in the repo, since it holds the app's secret. pnpm check runs it"));
318
+ //#endregion
319
+ //#region src/app-config.ts
320
+ const appConfigFile = "shopify.app.toml";
321
+ const AppConfig = Schema.Struct({
322
+ application_url: Schema.String,
323
+ build: Schema.optional(Schema.Struct({ dev_store_url: Schema.optional(Schema.String) }))
324
+ });
325
+ const decodeAppConfig = Effect.fn("decodeAppConfig")(function* decodeAppConfig(source, file) {
326
+ const toml = yield* Effect.try({
327
+ catch: (error) => new CommandFailed({ reason: `${file} isn't TOML: ${error instanceof Error ? error.message : String(error)}` }),
328
+ try: () => parse(source)
329
+ });
330
+ const config = yield* Schema.decodeUnknownEffect(AppConfig)(toml).pipe(Effect.mapError((error) => new CommandFailed({ reason: `${file} needs an application_url: ${error.message}` })));
331
+ const routerHost = yield* Effect.try({
332
+ catch: () => new CommandFailed({ reason: `${file}'s application_url, ${config.application_url}, isn't a URL.` }),
333
+ try: () => new URL(config.application_url).hostname
334
+ });
335
+ return {
336
+ devStore: config.build?.dev_store_url,
337
+ routerHost
338
+ };
339
+ });
340
+ const readAppConfig$1 = Effect.fn("readAppConfig")(function* readAppConfig(checkout, file = appConfigFile) {
341
+ const fs = yield* FileSystem.FileSystem;
342
+ const location = (yield* Path.Path).join(checkout, file);
343
+ const source = yield* fs.readFileString(location).pipe(Effect.mapError(() => new CommandFailed({ reason: `${location} can't be read. Run this in a Shopify app.` })));
344
+ return yield* decodeAppConfig(source, location);
345
+ });
346
+ //#endregion
347
+ //#region src/slot/config.ts
348
+ const slotConfigName = "slot";
349
+ const slotConfigFile = `shopify.app.${slotConfigName}.toml`;
350
+ const noWebDirectory = ".shopify/slot-no-web";
351
+ const isTable = (value) => Predicate.isObject(value) && !Array.isArray(value) && !(value instanceof TomlDate);
352
+ const onStage = (url, hosts) => {
353
+ if (!Predicate.isString(url)) return url;
354
+ if (url.startsWith("/")) return `https://${hosts.toHost}${url}`;
355
+ return url.startsWith(`https://${hosts.routerHost}/`) ? url.replace(`//${hosts.routerHost}/`, `//${hosts.toHost}/`) : url;
356
+ };
357
+ const withUrl = (table, key, hosts) => {
358
+ const url = onStage(table[key], hosts);
359
+ return url === void 0 ? table : {
360
+ ...table,
361
+ [key]: url
362
+ };
363
+ };
364
+ const withSubscriptionUris = (section, key, hosts) => {
365
+ const subscriptions = section[key];
366
+ return Array.isArray(subscriptions) ? {
367
+ ...section,
368
+ [key]: subscriptions.map((subscription) => isTable(subscription) ? withUrl(subscription, "uri", hosts) : subscription)
369
+ } : section;
370
+ };
371
+ const slotConfig = (source, hosts, webDirectories) => {
372
+ const config = parse(source);
373
+ const { app_proxy: appProxy, build, events, webhooks } = config;
374
+ if (isTable(webhooks)) config.webhooks = withSubscriptionUris(webhooks, "subscriptions", hosts);
375
+ if (isTable(events)) config.events = withSubscriptionUris(events, "subscription", hosts);
376
+ if (isTable(appProxy)) config.app_proxy = withUrl(appProxy, "url", hosts);
377
+ config.build = isTable(build) ? {
378
+ ...build,
379
+ automatically_update_urls_on_dev: false
380
+ } : { automatically_update_urls_on_dev: false };
381
+ if (webDirectories !== void 0) config.web_directories = [...webDirectories];
382
+ return stringify(config);
383
+ };
384
+ const writeSlotConfig = Effect.fn("writeSlotConfig")(function* writeSlotConfig({ checkout, routerHost, source, stage, webDirectories }) {
385
+ const fs = yield* FileSystem.FileSystem;
386
+ const path = yield* Path.Path;
387
+ const toHost = stageHost(routerHost, stage);
388
+ if (toHost === void 0) return yield* new CommandFailed({ reason: `${stage} isn't a stage name that fits a host under ${routerHost}.` });
389
+ const text = yield* Effect.try({
390
+ catch: (error) => new CommandFailed({ reason: `The app config for ${stage} isn't TOML: ${error instanceof Error ? error.message : String(error)}` }),
391
+ try: () => slotConfig(source, {
392
+ routerHost,
393
+ toHost
394
+ }, webDirectories)
395
+ });
396
+ yield* fs.writeFileString(path.join(checkout, slotConfigFile), text).pipe(Effect.mapError((error) => new CommandFailed({ reason: error.message })));
397
+ });
398
+ //#endregion
399
+ //#region src/slot/holder.ts
400
+ const SlotHolder = Schema.Struct({
401
+ checkout: Schema.String,
402
+ pid: Schema.NullOr(Schema.Finite),
403
+ since: Schema.String,
404
+ stage: Schema.String
405
+ });
406
+ const HolderFile = Schema.fromJsonString(SlotHolder);
407
+ const lockFileIn = (commonDirectory) => Path.Path.use((path) => Effect.succeed(path.join(commonDirectory, "harness-app-store-slot.json")));
408
+ const unusable = (file) => (error) => new CommandFailed({ reason: `Can't use the store slot's lock in ${file}: ${error.message}` });
409
+ const readHolder = Effect.fn("readHolder")(function* readHolder(lockFile) {
410
+ const fs = yield* FileSystem.FileSystem;
411
+ if (!(yield* fs.exists(lockFile).pipe(Effect.mapError(unusable(lockFile))))) return;
412
+ return yield* fs.readFileString(lockFile).pipe(Effect.flatMap(Schema.decodeUnknownEffect(HolderFile)), Effect.mapError(unusable(lockFile)));
413
+ });
414
+ const isAlive = (pid) => Effect.sync(() => {
415
+ try {
416
+ globalThis.process.kill(pid, 0);
417
+ return true;
418
+ } catch {
419
+ return false;
420
+ }
421
+ });
422
+ const isStale = Effect.fn("isStale")(function* isStale(holder) {
423
+ return !(yield* (yield* FileSystem.FileSystem).exists(holder.checkout).pipe(Effect.orElseSucceed(() => false))) || holder.pid !== null && !(yield* isAlive(holder.pid));
424
+ });
425
+ const holds = Effect.fn("holds")(function* holds(holder, checkout) {
426
+ return holder !== void 0 && holder.checkout === checkout && !(yield* isStale(holder));
427
+ });
428
+ const heldElsewhere = Effect.fn("heldElsewhere")(function* heldElsewhere(holder, checkout) {
429
+ return holder !== void 0 && holder.checkout !== checkout && !(yield* isStale(holder));
430
+ });
431
+ const writeHolder = Effect.fn("writeHolder")(function* writeHolder(lockFile, holder) {
432
+ const fs = yield* FileSystem.FileSystem;
433
+ yield* Schema.encodeEffect(HolderFile)(holder).pipe(Effect.flatMap((text) => fs.writeFileString(lockFile, `${text}\n`)), Effect.mapError(unusable(lockFile)));
434
+ });
435
+ const giveSlot = Effect.fn("giveSlot")(function* giveSlot(lockFile) {
436
+ yield* (yield* FileSystem.FileSystem).remove(lockFile, { force: true }).pipe(Effect.mapError(unusable(lockFile)));
437
+ });
438
+ const takeSlot = Effect.fn("takeSlot")(function* takeSlot(lockFile, taking) {
439
+ const current = yield* readHolder(lockFile);
440
+ if (current !== void 0 && (yield* heldElsewhere(current, taking.checkout))) return yield* new CommandFailed({ reason: `${current.stage} holds the store's slot (${current.checkout}). Wait for harness app slot give there.` });
441
+ yield* writeHolder(lockFile, {
442
+ ...taking,
443
+ since: DateTime.formatIso(yield* DateTime.now)
444
+ });
445
+ return current;
446
+ });
447
+ const restoreSlot = (lockFile, before) => before === void 0 ? giveSlot(lockFile) : writeHolder(lockFile, before);
448
+ //#endregion
449
+ //#region src/slot/push.ts
450
+ const ready = "Ready, watching for changes in your app";
451
+ const pushTimeout = "5 minutes";
452
+ const pushPort = 3461;
453
+ const pushPreview = Effect.fn("pushPreview")(function* pushPreview(checkout, routerHost, store) {
454
+ const fs = yield* FileSystem.FileSystem;
455
+ const path = yield* Path.Path;
456
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
457
+ yield* fs.makeDirectory(path.join(checkout, noWebDirectory), { recursive: true }).pipe(Effect.mapError((error) => new CommandFailed({ reason: error.message })));
458
+ const command = ChildProcess.make("shopify", [
459
+ "app",
460
+ "dev",
461
+ "--config",
462
+ slotConfigName,
463
+ "--no-update",
464
+ "--tunnel-url",
465
+ `https://${routerHost}:${pushPort}`,
466
+ ...store === void 0 ? [] : ["--store", store]
467
+ ], {
468
+ cwd: checkout,
469
+ env: { SHOPIFY_CLI_DISABLE_GRAPHIQL: "1" },
470
+ extendEnv: true,
471
+ stderr: "inherit",
472
+ stdin: "ignore"
473
+ });
474
+ if (!(yield* Effect.scoped(spawner.spawn(command).pipe(Effect.flatMap((handle) => Stream.decodeText(handle.stdout).pipe(Stream.tap((text) => Effect.sync(() => globalThis.process.stdout.write(text))), Stream.scan(() => "", (seen, text) => `${seen}${text}`), Stream.takeUntil((seen) => seen.includes(ready)), Stream.runLast)))).pipe(Effect.map(Option.exists((seen) => seen.includes(ready))), Effect.mapError((error) => new CommandFailed({ reason: `Can't run shopify (${error.message}). Run this through pnpm, which puts the repo's own shopify on PATH.` })), Effect.timeoutOrElse({
475
+ duration: pushTimeout,
476
+ orElse: () => Effect.fail(new CommandFailed({ reason: `shopify app dev didn't report "${ready}" within ${pushTimeout}.` }))
477
+ })))) return yield* new CommandFailed({ reason: `shopify app dev stopped before it reported "${ready}".` });
478
+ yield* Console.log(`Pushed ${slotConfigName}'s app config to ${store ?? "the dev store"}.`);
479
+ });
480
+ //#endregion
481
+ //#region src/slot/slot.ts
482
+ const defaultStage = "main";
483
+ const HarnessSettings = Schema.fromJsonString(Schema.Struct({ harness: Schema.optional(Schema.Struct({ defaultBranch: Schema.optional(Schema.String) })) }));
484
+ const slotContext = Effect.fn("slotContext")(function* slotContext(directory) {
485
+ const checkout = yield* checkoutAt(directory);
486
+ const { devStore, routerHost } = yield* readAppConfig$1(checkout.root);
487
+ return {
488
+ ...checkout,
489
+ devStore,
490
+ lockFile: yield* lockFileIn(checkout.commonDirectory),
491
+ routerHost
492
+ };
493
+ });
494
+ const defaultBranchOf = Effect.fn("defaultBranchOf")(function* defaultBranchOf(checkout) {
495
+ const fs = yield* FileSystem.FileSystem;
496
+ const manifest = (yield* Path.Path).join(checkout, "package.json");
497
+ const branch = (yield* fs.readFileString(manifest).pipe(Effect.flatMap(Schema.decodeUnknownEffect(HarnessSettings)), Effect.mapError((error) => new CommandFailed({ reason: `${manifest}: ${error.message}` })))).harness?.defaultBranch;
498
+ if (branch === void 0) return yield* new CommandFailed({ reason: `${manifest} has no harness.defaultBranch. harness init writes it.` });
499
+ return branch;
500
+ });
501
+ const defaultBranchAppConfig = Effect.fn("defaultBranchAppConfig")(function* defaultBranchAppConfig(checkout) {
502
+ const branch = yield* defaultBranchOf(checkout);
503
+ yield* git(checkout, [
504
+ "fetch",
505
+ "--quiet",
506
+ "origin",
507
+ branch
508
+ ]);
509
+ return {
510
+ branch,
511
+ source: yield* git(checkout, ["show", `origin/${branch}:${appConfigFile}`])
512
+ };
513
+ });
514
+ const pushConfig = Effect.fn("pushConfig")(function* pushConfig(context, source, stage, store) {
515
+ yield* writeSlotConfig({
516
+ checkout: context.root,
517
+ routerHost: context.routerHost,
518
+ source,
519
+ stage,
520
+ webDirectories: [noWebDirectory]
521
+ });
522
+ yield* pushPreview(context.root, context.routerHost, store ?? context.devStore);
523
+ });
524
+ const pushDefaultBranch = Effect.fn("pushDefaultBranch")(function* pushDefaultBranch(context, store) {
525
+ const { branch, source } = yield* defaultBranchAppConfig(context.root);
526
+ yield* pushConfig(context, source, defaultStage, store);
527
+ return branch;
528
+ });
529
+ const stageOf = (context, stage) => Effect.try({
530
+ catch: (error) => new CommandFailed({ reason: error instanceof Error ? error.message : String(error) }),
531
+ try: () => stage ?? branchStage(context.routerHost, context.branch)
532
+ }).pipe(Effect.filterOrFail((name) => isBranchName(context.routerHost, name), (name) => new CommandFailed({ reason: `${name} isn't a stage name that fits a host under ${context.routerHost}.` })));
533
+ const slotStatus = Effect.fn("slotStatus")(function* slotStatus(context) {
534
+ const holder = yield* readHolder(context.lockFile);
535
+ if (holder === void 0) return yield* Console.log("The store's slot is free.");
536
+ const stale = (yield* isStale(holder)) ? ", but its checkout or process is gone" : "";
537
+ yield* Console.log(`${holder.stage} holds the store's slot since ${holder.since} (${holder.checkout})${stale}.`);
538
+ });
539
+ const takeAndPush = Effect.fn("takeAndPush")(function* takeAndPush(context, stage, store) {
540
+ const fs = yield* FileSystem.FileSystem;
541
+ const path = yield* Path.Path;
542
+ const source = yield* fs.readFileString(path.join(context.root, appConfigFile)).pipe(Effect.mapError((error) => new CommandFailed({ reason: error.message })));
543
+ const before = yield* takeSlot(context.lockFile, {
544
+ checkout: context.root,
545
+ pid: null,
546
+ stage
547
+ });
548
+ yield* pushConfig(context, source, stage, store).pipe(Effect.tapError(() => restoreSlot(context.lockFile, before)));
549
+ yield* Console.log(`\nThe store runs ${stage}'s app config now. Give it back with harness app slot give.`);
550
+ });
551
+ const giveBack = Effect.fn("giveBack")(function* giveBack(context, store) {
552
+ const holder = yield* readHolder(context.lockFile);
553
+ if (yield* heldElsewhere(holder, context.root)) return yield* new CommandFailed({ reason: `${holder?.stage} holds the slot from ${holder?.checkout}, not this checkout.` });
554
+ const branch = yield* pushDefaultBranch(context, store);
555
+ yield* giveSlot(context.lockFile);
556
+ yield* Console.log(`\nThe store runs ${branch}'s app config again, and the slot is free.`);
557
+ });
558
+ //#endregion
559
+ //#region src/dev/command.ts
560
+ const tunnelPort = 3458;
561
+ const localStage = (user) => `local-${user.toLowerCase().replaceAll(/[^a-z0-9]+/gu, "-")}`.replace(/-+$/u, "");
562
+ const giveBackAfterDev = Effect.fn("giveBackAfterDev")(function* giveBackAfterDev(context) {
563
+ yield* Console.log("\nGiving the store back to the default branch's app config.");
564
+ const ranOn = yield* readAppConfig$1(context.root, slotConfigFile);
565
+ yield* pushDefaultBranch(context, ranOn.devStore);
566
+ yield* giveSlot(context.lockFile);
567
+ });
568
+ const devCommand = Command.make("dev", { shopifyArgs: Argument.String("shopify-args").pipe(Argument.withDescription("Passed on to shopify app dev, after --"), Argument.variadic()) }, Effect.fn("dev")(function* dev({ shopifyArgs }) {
569
+ const fs = yield* FileSystem.FileSystem;
570
+ const path = yield* Path.Path;
571
+ const context = yield* slotContext(globalThis.process.cwd());
572
+ const user = yield* Config.String("USER").pipe(Config.withDefault("dev"), Effect.mapError(() => new CommandFailed({ reason: "USER can't be read" })));
573
+ const stage = localStage(user);
574
+ const tunnelHost = stageHost(context.routerHost, stage);
575
+ if (tunnelHost === void 0 || !isBranchName(context.routerHost, stage)) return yield* new CommandFailed({ reason: `${stage} isn't a stage name that fits a host under ${context.routerHost}.` });
576
+ const source = yield* fs.readFileString(path.join(context.root, appConfigFile)).pipe(Effect.mapError((error) => new CommandFailed({ reason: error.message })));
577
+ const status = yield* Effect.acquireUseRelease(takeSlot(context.lockFile, {
578
+ checkout: context.root,
579
+ pid: globalThis.process.pid,
580
+ stage
581
+ }).pipe(Effect.mapError((error) => new CommandFailed({ reason: `${error.reason} harness unisian stage proves code without it.` }))), () => Effect.gen(function* runShopifyDev() {
582
+ yield* Console.log(`harness app dev needs your named tunnel routing ${tunnelHost} to http://localhost:${tunnelPort}. Open the app with /__branch/${stage}.\n`);
583
+ yield* writeSlotConfig({
584
+ checkout: context.root,
585
+ routerHost: context.routerHost,
586
+ source,
587
+ stage
588
+ });
589
+ return yield* runInherited("shopify", [
590
+ "app",
591
+ "dev",
592
+ "--config",
593
+ slotConfigName,
594
+ "--no-update",
595
+ "--tunnel-url",
596
+ `https://${tunnelHost}:${tunnelPort}`,
597
+ ...shopifyArgs
598
+ ], { cwd: context.root });
599
+ }), () => giveBackAfterDev(context));
600
+ if (status !== 0) return yield* new CommandFailed({ reason: `shopify app dev exited ${status}.` });
601
+ })).pipe(Command.withDescription("Run shopify app dev on this checkout's local-<user> stage, holding the store's slot, and give the store back to the default branch's app config when it stops. The app's shopify.web.toml runs the Alchemy half, harness unisian dev"));
602
+ //#endregion
603
+ //#region src/slot/command.ts
604
+ const store = Flag.String("store").pipe(Flag.withDescription("The store's domain, such as example.myshopify.com. Defaults to dev_store_url in shopify.app.toml"), Flag.optional);
605
+ const here = Effect.suspend(() => slotContext(globalThis.process.cwd()));
606
+ const statusCommand = Command.make("status", {}, () => here.pipe(Effect.flatMap(slotStatus))).pipe(Command.withDescription("Say which checkout holds the store's slot, and since when"));
607
+ const takeCommand = Command.make("take", {
608
+ stage: Flag.String("stage").pipe(Flag.withDescription("The stage whose host gets the store's traffic. Defaults to w-<branch>"), Flag.optional),
609
+ store
610
+ }, Effect.fn("take")(function* take({ stage, store }) {
611
+ const context = yield* here;
612
+ const name = yield* stageOf(context, Option.getOrUndefined(stage));
613
+ yield* takeAndPush(context, name, Option.getOrUndefined(store));
614
+ })).pipe(Command.withDescription("Push this checkout's shopify.app.toml to the store as its one dev preview, with webhooks, Events and the app proxy sent to the stage's host, and hold the slot until harness app slot give"));
615
+ const giveCommand = Command.make("give", { store }, Effect.fn("give")(function* give({ store }) {
616
+ yield* giveBack(yield* here, Option.getOrUndefined(store));
617
+ })).pipe(Command.withDescription("Push the default branch's shopify.app.toml back to the store, sent to the main stage, and free the slot"));
618
+ const slotCommand = Command.make("slot").pipe(Command.withDescription("The store holds one dev preview per app. A branch that changes app config takes the slot to prove it, and gives it back"), Command.withSubcommands([
619
+ statusCommand,
620
+ takeCommand,
621
+ giveCommand
622
+ ]));
623
+ //#endregion
624
+ //#region src/stage/command.ts
625
+ const stageCommand = Command.make("stage", { config: Argument.String("config").pipe(Argument.withDescription("The Alchemy entrypoint"), Argument.withDefault("alchemy.run.ts")) }, Effect.fn("stage")(function* stage({ config }) {
626
+ const { branch, root } = yield* currentCheckout;
627
+ const { routerHost } = yield* readAppConfig$1(root);
628
+ const status = yield* runInherited("harness", [
629
+ "unisian",
630
+ "stage",
631
+ "--host",
632
+ routerHost,
633
+ config
634
+ ]);
635
+ if (status !== 0) return yield* new CommandFailed({ reason: `harness unisian stage --host ${routerHost} ${config} exited ${status}.` });
636
+ yield* Console.log(`In the Admin, open the app's ${switchPath}${branchStage(routerHost, branch)}.`);
637
+ })).pipe(Command.withDescription("Deploy this checkout to its branch's stage with harness unisian stage, under the router host in shopify.app.toml, and say where the Admin opens it"));
638
+ //#endregion
639
+ //#region src/worktree/commands.ts
640
+ const placeholderAppConfig = {
641
+ SHOPIFY_API_KEY: "dev-stage-destroy",
642
+ SHOPIFY_API_SECRET: "dev-stage-destroy"
643
+ };
644
+ const pullEnvCommand = Command.make("pull-env", {}, Effect.fn("pullEnv")(function* pullEnv() {
645
+ const { root } = yield* currentCheckout;
646
+ yield* envIgnored(root);
647
+ if ((yield* runInherited("shopify", [
648
+ "app",
649
+ "env",
650
+ "pull",
651
+ "--path",
652
+ root
653
+ ], {
654
+ cwd: root,
655
+ env: { CI: void 0 }
656
+ })) !== 0) return yield* new CommandFailed({ reason: `.env wasn't pulled; run shopify app env pull in ${root}.` });
657
+ })).pipe(Command.withDescription("Pull the dev app's .env with shopify app env pull, once git ignores .env. harness worktree runs it in each new worktree"));
658
+ const destroyCommand = Command.make("destroy", {}, Effect.fn("destroy")(function* destroy() {
659
+ const { commonDirectory, root } = yield* currentCheckout;
660
+ const holder = yield* readHolder(yield* lockFileIn(commonDirectory));
661
+ if (yield* holds(holder, root)) return yield* new CommandFailed({ reason: `${root} holds the store's slot; run harness app slot give there first.` });
662
+ if ((yield* runInherited("harness", ["unisian", "destroy"], {
663
+ cwd: root,
664
+ env: {
665
+ ...placeholderAppConfig,
666
+ CI: void 0
667
+ }
668
+ })) !== 0) return yield* new CommandFailed({ reason: "harness unisian destroy failed." });
669
+ })).pipe(Command.withDescription("Refuse while this checkout holds the store's slot, then destroy its stages with harness unisian destroy under a placeholder app config, since alchemy.run.ts reads SHOPIFY_API_KEY and SHOPIFY_API_SECRET. harness worktree --done runs it"));
670
+ //#endregion
671
+ //#region src/extensions/files.ts
672
+ const filesNamed = Effect.fn("filesNamed")(function* filesNamed(name) {
673
+ return (yield* (yield* ChildProcessSpawner.ChildProcessSpawner).lines(ChildProcess.make("git", [
674
+ "ls-files",
675
+ "--cached",
676
+ "--others",
677
+ "--exclude-standard",
678
+ `*${name}`
679
+ ])).pipe(Effect.mapError((error) => new CommandFailed({ reason: `Can't list this checkout's files: ${error.message}` })))).filter((file) => path.basename(file) === name && existsSync(file)).toSorted();
680
+ });
681
+ //#endregion
682
+ //#region src/extensions/functions.ts
683
+ const ExtensionConfig = Schema.Struct({ extensions: Schema.optional(Schema.Array(Schema.Struct({ type: Schema.optional(Schema.String) }))) });
684
+ const isFunction = (config) => Effect.try({
685
+ catch: (error) => new CommandFailed({ reason: `Can't read ${config}: ${error instanceof Error ? error.message : String(error)}` }),
686
+ try: () => (Schema.decodeUnknownSync(ExtensionConfig)(parse(readFileSync(config, "utf-8"))).extensions ?? []).some(({ type }) => type === "function")
687
+ });
688
+ const functionsWithoutCargo = Effect.gen(function* functionsWithoutCargo() {
689
+ const configs = yield* filesNamed("shopify.extension.toml");
690
+ const manifests = new Set((yield* filesNamed("Cargo.toml")).map((manifest) => path.dirname(manifest)));
691
+ return (yield* Effect.filter(configs, isFunction)).map((config) => path.dirname(config)).filter((folder) => !manifests.has(folder));
692
+ });
693
+ const functionsCommand = Command.make("functions", {}, () => Effect.gen(function* functions() {
694
+ const missing = yield* functionsWithoutCargo;
695
+ if (missing.length > 0) return yield* new CommandFailed({ reason: [
696
+ "Shopify Functions are Rust, and these Function folders have no Cargo.toml:",
697
+ ...missing.map((folder) => ` ${folder}`),
698
+ "Write each in Rust: shopify app generate extension --flavor rust."
699
+ ].join("\n") });
700
+ yield* Console.log("harness app functions: every Shopify Function is a Rust crate.");
701
+ })).pipe(Command.withDescription("Fail when a Shopify Function (a shopify.extension.toml with type = \"function\") has no Cargo.toml beside it"));
702
+ //#endregion
703
+ //#region src/extensions/rust.ts
704
+ const cargo = Effect.fn("cargo")(function* cargo(args) {
705
+ return yield* (yield* ChildProcessSpawner.ChildProcessSpawner).exitCode(ChildProcess.make("cargo", args, {
706
+ stderr: "inherit",
707
+ stdin: "inherit",
708
+ stdout: "inherit"
709
+ })).pipe(Effect.mapError((error) => new CommandFailed({ reason: `Can't run cargo (${error.message}). This repo has a Cargo.toml, so it needs Rust: https://rustup.rs.` })));
710
+ });
711
+ const eachCrate = Effect.fn("eachCrate")(function* eachCrate(runs) {
712
+ const manifests = yield* filesNamed("Cargo.toml");
713
+ if (manifests.length === 0) {
714
+ yield* Console.log("harness app rust: no Cargo.toml in this repo, so nothing to run.");
715
+ return;
716
+ }
717
+ for (const [subcommand, ...rest] of runs) for (const manifest of manifests) {
718
+ yield* Console.log(`cargo ${subcommand} ${manifest}`);
719
+ if ((yield* cargo([
720
+ subcommand,
721
+ "--manifest-path",
722
+ manifest,
723
+ ...rest
724
+ ])) !== 0) return yield* new CommandFailed({ reason: `cargo ${subcommand} failed on ${manifest}.` });
725
+ }
726
+ });
727
+ const checkCommand = Command.make("check", {}, () => eachCrate([["fmt", "--check"], [
728
+ "clippy",
729
+ "--all-targets",
730
+ "--locked",
731
+ "--",
732
+ "-D",
733
+ "warnings"
734
+ ]])).pipe(Command.withDescription("Run cargo fmt --check, then cargo clippy -D warnings, on every Cargo.toml git lists"));
735
+ const testCommand = Command.make("test", {}, () => eachCrate([["test", "--locked"]])).pipe(Command.withDescription("Run cargo test on every Cargo.toml git lists"));
736
+ const rustCommand = Command.make("rust").pipe(Command.withDescription("Rust checks for the repo's crates and Shopify Functions"), Command.withSubcommands([checkCommand, testCommand]));
737
+ //#endregion
738
+ //#region src/extensions/switch.ts
739
+ const extensionFiles = "apps/*/extensions/*/shopify.extension.toml";
740
+ //#endregion
741
+ //#region src/verify/admin.ts
742
+ const ClientCredentialsToken = Schema.Struct({
743
+ access_token: Schema.String,
744
+ expires_in: Schema.Int,
745
+ scope: Schema.String
746
+ });
747
+ const AdminDocumentBody = Schema.Struct({
748
+ data: Schema.optional(Schema.NullOr(Schema.Json)),
749
+ errors: Schema.optional(Schema.Array(Schema.Json))
750
+ });
751
+ const clientCredentialsToken = Effect.fn("clientCredentialsToken")(function* clientCredentialsToken(app) {
752
+ const client = yield* HttpClient.HttpClient;
753
+ const request = HttpClientRequest.post(`https://${app.shop}/admin/oauth/access_token`).pipe(HttpClientRequest.acceptJson, HttpClientRequest.bodyUrlParams({
754
+ client_id: app.apiKey,
755
+ client_secret: Redacted.value(app.apiSecret),
756
+ grant_type: "client_credentials"
757
+ }));
758
+ const response = yield* client.execute(request).pipe(Effect.mapError((error) => new VerifyFailed({ check: `The client credentials request to ${app.shop} failed: ${error.message}` })));
759
+ if (response.status < 200 || response.status >= 300) return yield* new VerifyFailed({ check: `Shopify refused the client credentials grant for ${app.shop} (HTTP ${response.status}). The app and the store must be in the same Dev Dashboard organization, and the app installed on the store` });
760
+ return yield* HttpClientResponse.schemaBodyJson(ClientCredentialsToken)(response).pipe(Effect.mapError(() => new VerifyFailed({ check: `${app.shop} answered the client credentials grant without an access_token` })));
761
+ });
762
+ const openAdmin = Effect.fn("openAdmin")(function* openAdmin(app) {
763
+ const client = yield* HttpClient.HttpClient;
764
+ const token = yield* clientCredentialsToken(app);
765
+ const accessToken = Redacted.make(token.access_token);
766
+ const now = yield* Clock.currentTimeMillis;
767
+ const send = (document, variables = {}) => Effect.gen(function* sendAdminDocument() {
768
+ const request = HttpClientRequest.post(`https://${app.shop}/admin/api/${app.apiVersion}/graphql.json`).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeader("X-Shopify-Access-Token", Redacted.value(accessToken)), HttpClientRequest.bodyJsonUnsafe({
769
+ query: document,
770
+ variables
771
+ }));
772
+ const failed = (reason) => new VerifyFailed({ check: `The Admin API request to ${app.shop} failed: ${reason}` });
773
+ const response = yield* client.execute(request).pipe(Effect.mapError((error) => failed(error.message)));
774
+ const body = yield* HttpClientResponse.schemaBodyJson(AdminDocumentBody)(response).pipe(Effect.mapError(() => failed(`HTTP ${response.status} with a body that isn't GraphQL`)));
775
+ return {
776
+ data: body.data ?? null,
777
+ errors: body.errors ?? [],
778
+ status: response.status
779
+ };
780
+ });
781
+ return {
782
+ accessToken,
783
+ app,
784
+ expiresAt: now + token.expires_in * 1e3,
785
+ send
786
+ };
787
+ });
788
+ const secretsOf = (admin) => [admin.accessToken, admin.app.apiSecret];
789
+ const readData = (admin, document, data, variables) => admin.send(document, variables).pipe(Effect.flatMap((result) => result.status === 200 && result.errors.length === 0 && result.data !== null ? Schema.decodeUnknownEffect(data)(result.data).pipe(Effect.mapError((issue) => new VerifyFailed({
790
+ check: "The Admin API answered in an unexpected shape",
791
+ detail: String(issue)
792
+ }))) : Effect.fail(new VerifyFailed({
793
+ check: `The Admin API answered ${result.status} with errors`,
794
+ detail: [...result.errors]
795
+ }))));
796
+ //#endregion
797
+ //#region src/verify/app-config.ts
798
+ const targetDescriptions = {
799
+ app: "The app config: reads shopify.app.<app>.toml and .env.<app>. Leave it out for shopify.app.toml and .env",
800
+ shop: "A second store's domain, such as other-store.myshopify.com. Leave it out for the dev store, [build] dev_store_url in the app's config"
801
+ };
802
+ const ShopDomain = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9-]*\.myshopify\.com$/u));
803
+ const AppToml = Schema.Struct({
804
+ app_proxy: Schema.optional(Schema.Struct({
805
+ prefix: Schema.String,
806
+ subpath: Schema.String
807
+ })),
808
+ application_url: Schema.String,
809
+ build: Schema.optional(Schema.Struct({ dev_store_url: Schema.optional(Schema.String) })),
810
+ client_id: Schema.String,
811
+ webhooks: Schema.Struct({ api_version: Schema.String })
812
+ });
813
+ const appConfigName = /^shopify\.app(?:\.[\w-]+)?\.toml$/u;
814
+ const envEntries = (text) => new Map(text.split("\n").flatMap((line) => {
815
+ const match = /^(?<key>[A-Z0-9_]+)=(?<value>.*)$/u.exec(line.trim());
816
+ return match?.groups?.key === void 0 ? [] : [[match.groups.key, (match.groups.value ?? "").replaceAll(/^["']|["']$/gu, "")]];
817
+ }));
818
+ const readText = (file, hint) => FileSystem.FileSystem.use((fs) => fs.readFileString(file)).pipe(Effect.mapError(() => new VerifyFailed({ check: `Can't read ${file}. ${hint}` })));
819
+ const configFileOf = (app) => app === void 0 ? "shopify.app.toml" : `shopify.app.${app}.toml`;
820
+ const readAppToml = (config) => readText(config, "Run this from the app's folder, or pass --app <config> for shopify.app.<config>.toml.").pipe(Effect.flatMap((text) => Effect.try({
821
+ catch: () => new VerifyFailed({ check: `${config} isn't valid TOML` }),
822
+ try: () => parse(text)
823
+ })), Effect.flatMap((toml) => Schema.decodeUnknownEffect(AppToml)(toml).pipe(Effect.mapError(() => new VerifyFailed({ check: `${config} needs client_id, application_url and [webhooks] api_version` })))));
824
+ const appUrlsOfEveryConfig = FileSystem.FileSystem.use((fs) => fs.readDirectory(".")).pipe(Effect.mapError(() => new VerifyFailed({ check: "Can't list the app's configs" })), Effect.flatMap((entries) => Effect.forEach(entries.filter((entry) => appConfigName.test(entry)).toSorted((a, b) => a.localeCompare(b)), (config) => readAppToml(config).pipe(Effect.map((toml) => toml.application_url)))));
825
+ const readSecrets = (envFile, clientId) => Effect.gen(function* readEnvFile() {
826
+ const env = envEntries(yield* readText(envFile, `Write it with \`shopify app env pull${envFile === ".env" ? "" : ` --config ${envFile.slice(5)}`}\`.`));
827
+ const apiKey = env.get("SHOPIFY_API_KEY");
828
+ const apiSecret = env.get("SHOPIFY_API_SECRET");
829
+ if (apiKey === void 0 || apiSecret === void 0 || apiSecret === "") return yield* new VerifyFailed({ check: `${envFile} needs SHOPIFY_API_KEY and SHOPIFY_API_SECRET` });
830
+ if (apiKey !== clientId) return yield* new VerifyFailed({ check: `${envFile} holds another app's key (${apiKey}), not this config's client_id (${clientId}). Pull it again for this config.` });
831
+ return {
832
+ apiKey,
833
+ apiSecret: Redacted.make(apiSecret)
834
+ };
835
+ });
836
+ const toShop = (shop, from) => Schema.decodeEffect(ShopDomain)(shop).pipe(Effect.mapError(() => new VerifyFailed({ check: `${shop} (${from}) isn't a store domain like my-store.myshopify.com` })));
837
+ const readAppConfig = Effect.fn("readAppConfig")(function* readAppConfig(target) {
838
+ const config = configFileOf(target.app);
839
+ const envFile = target.app === void 0 ? ".env" : `.env.${target.app}`;
840
+ const toml = yield* readAppToml(config);
841
+ const secrets = yield* readSecrets(envFile, toml.client_id);
842
+ const devStore = toml.build?.dev_store_url ?? null;
843
+ const chosen = target.shop ?? devStore;
844
+ if (chosen === null) return yield* new VerifyFailed({ check: `${config} names no dev store in [build] dev_store_url. Add it, or pass --shop` });
845
+ const shop = yield* toShop(chosen, target.shop === void 0 ? `${config} [build] dev_store_url` : "--shop");
846
+ return {
847
+ ...secrets,
848
+ apiVersion: toml.webhooks.api_version,
849
+ appUrl: toml.application_url,
850
+ config,
851
+ devStore,
852
+ envFile,
853
+ proxyPrefix: toml.app_proxy === void 0 ? null : `/${toml.app_proxy.prefix}/${toml.app_proxy.subpath}`,
854
+ shop
855
+ };
856
+ });
857
+ //#endregion
858
+ //#region src/verify/identity.ts
859
+ const identityDocument = `query HarnessVerifyIdentity {
860
+ shop { name myshopifyDomain }
861
+ currentAppInstallation { app { apiKey handle title } accessScopes { handle } }
862
+ }`;
863
+ const Identity = Schema.Struct({
864
+ currentAppInstallation: Schema.Struct({
865
+ accessScopes: Schema.Array(Schema.Struct({ handle: Schema.String })),
866
+ app: Schema.Struct({
867
+ apiKey: Schema.String,
868
+ handle: Schema.String,
869
+ title: Schema.String
870
+ })
871
+ }),
872
+ shop: Schema.Struct({
873
+ myshopifyDomain: Schema.String,
874
+ name: Schema.String
875
+ })
876
+ });
877
+ const appIdentity = Effect.fn("appIdentity")(function* appIdentity(admin) {
878
+ const identity = yield* readData(admin, identityDocument, Identity);
879
+ const { app, accessScopes } = identity.currentAppInstallation;
880
+ if (identity.shop.myshopifyDomain !== admin.app.shop) return yield* new VerifyFailed({ check: `The token answered for ${identity.shop.myshopifyDomain}, not ${admin.app.shop}` });
881
+ if (app.apiKey !== admin.app.apiKey) return yield* new VerifyFailed({ check: `The installed app is ${app.handle} (${app.apiKey}), not the one ${admin.app.config} names` });
882
+ return {
883
+ app: {
884
+ apiKey: app.apiKey,
885
+ handle: app.handle,
886
+ title: app.title
887
+ },
888
+ scopes: accessScopes.map((scope) => scope.handle),
889
+ shop: {
890
+ domain: identity.shop.myshopifyDomain,
891
+ name: identity.shop.name
892
+ }
893
+ };
894
+ });
895
+ //#endregion
896
+ //#region src/verify/store-fields.ts
897
+ const storeFields = {
898
+ app: optionalFlag("app", targetDescriptions.app),
899
+ shop: optionalFlag("shop", targetDescriptions.shop)
900
+ };
901
+ //#endregion
902
+ //#region src/verify/admin-url.ts
903
+ const adminPageUrl = Effect.fn("adminPageUrl")(function* adminPageUrl(target, path) {
904
+ const config = yield* readAppConfig(target);
905
+ const { app } = yield* appIdentity(yield* openAdmin(config));
906
+ const appPath = path ?? "/app";
907
+ const store = config.shop.replace(/\.myshopify\.com$/u, "");
908
+ return {
909
+ handle: app.handle,
910
+ shop: config.shop,
911
+ url: `https://admin.shopify.com/store/${store}/apps/${app.handle}${appPath.startsWith("/") ? appPath : `/${appPath}`}`
912
+ };
913
+ });
914
+ const adminUrlCommand = command("admin-url", "Print the Shopify Admin URL that opens a page of the app, embedded, the way a merchant reaches it. Reads the app's handle from Shopify rather than guessing it", {
915
+ ...storeFields,
916
+ path: optionalFlag("path", "The app path to open, such as /app/settings. Defaults to /app")
917
+ }, ({ path, ...target }) => adminPageUrl(target, path).pipe(Effect.provide(FetchHttpClient.layer)));
918
+ //#endregion
919
+ //#region src/verify/branch-step.ts
920
+ const shopifyPartition = "https://shopify.com";
921
+ const adminPage = "https://admin.shopify.com/";
922
+ const BranchInput = Schema.Struct({
923
+ app: Schema.optional(Schema.String),
924
+ headed: Schema.optional(Schema.Boolean),
925
+ name: Schema.String
926
+ });
927
+ const branchStep = flowStep(BranchInput, ({ app, headed = false, name }) => Effect.gen(function* routeToBranch() {
928
+ const config = configFileOf(app);
929
+ const appUrl = new URL((yield* readAppToml(config)).application_url);
930
+ if (!isBranchName(appUrl.hostname, name)) return yield* new VerifyFailed({ check: `${name} isn't a stage the branch router at ${appUrl.hostname} can route to, such as main, pr-31 or w-my-branch` });
931
+ const now = yield* Clock.currentTimeMillis;
932
+ yield* Effect.scoped(openedPage(headed, adminPage).pipe(Effect.flatMap((page) => attempt(`Can't set the ${branchCookie} cookie`, () => page.context().addCookies([{
933
+ expires: Math.floor(now / 1e3) + 604800,
934
+ httpOnly: true,
935
+ name: branchCookie,
936
+ partitionKey: shopifyPartition,
937
+ sameSite: "None",
938
+ secure: true,
939
+ url: appUrl.origin,
940
+ value: name
941
+ }])))));
942
+ return {
943
+ app: appUrl.origin,
944
+ branch: name,
945
+ config
946
+ };
947
+ }));
948
+ //#endregion
949
+ //#region src/verify/graphql.ts
950
+ const Variables = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Json));
951
+ const decodeVariables = (variables) => variables === void 0 ? Effect.succeed({}) : Schema.decodeUnknownEffect(Variables)(variables).pipe(Effect.mapError(() => new VerifyFailed({ check: "--variables isn't a JSON object, e.g. '{\"id\":\"gid://shopify/Shop/1\"}'" })));
952
+ const documentFrom = (query, file) => {
953
+ if (query !== void 0) return Effect.succeed(query);
954
+ if (file === void 0) return Effect.fail(new VerifyFailed({ check: "Pass the document with --query or --file" }));
955
+ return FileSystem.FileSystem.use((fs) => fs.readFileString(file)).pipe(Effect.mapError(() => new VerifyFailed({ check: `Can't read ${file}` })));
956
+ };
957
+ const operationsOf = (document) => Effect.try({
958
+ catch: (error) => new VerifyFailed({ check: `Not a GraphQL document: ${error instanceof Error ? error.message : String(error)}` }),
959
+ try: () => parse$1(document).definitions.flatMap((definition) => definition.kind === Kind.OPERATION_DEFINITION ? [definition.operation] : [])
960
+ });
961
+ const graphqlCommand = command("graphql", "Send one Admin GraphQL document as the app, with its own token from the client credentials grant. Refuses a mutation, before calling Shopify, unless --write is given, and --write is only for a write the feature file allows", {
962
+ ...storeFields,
963
+ file: optionalFlag("file", "Read the document from this file"),
964
+ out: optionalFlag("out", "Also write the result to <artifacts>/<out>, e.g. admin-graphql/shop.json"),
965
+ query: optionalFlag("query", "The document, e.g. '{ shop { name } }'"),
966
+ variables: optionalFlag("variables", "The variables as a JSON object"),
967
+ write: toggle("write", "Allow a mutation")
968
+ }, ({ file, out, query, variables, write, ...target }) => Effect.gen(function* runGraphql() {
969
+ const document = yield* documentFrom(query, file);
970
+ const operations = yield* operationsOf(document);
971
+ const values = yield* decodeVariables(variables);
972
+ if (operations.includes("mutation") && !write) return yield* new VerifyFailed({ check: "That document is a mutation. Run it again with --write only if the feature file allows this write" });
973
+ const config = yield* readAppConfig(target);
974
+ const admin = yield* openAdmin(config);
975
+ const result = yield* admin.send(document, values);
976
+ const report = {
977
+ apiVersion: config.apiVersion,
978
+ data: result.data,
979
+ errors: [...result.errors],
980
+ shop: config.shop,
981
+ status: result.status
982
+ };
983
+ const evidence = out === void 0 ? null : yield* writeEvidence(out, report, secretsOf(admin));
984
+ if (result.status !== 200 || result.errors.length > 0 || result.data === null) return yield* new VerifyFailed({
985
+ check: `The Admin API answered ${result.status}${evidence === null ? "" : ` (evidence in ${evidence})`}`,
986
+ detail: report.errors
987
+ });
988
+ return {
989
+ ...report,
990
+ evidence
991
+ };
992
+ }).pipe(Effect.provide(FetchHttpClient.layer)));
993
+ //#endregion
994
+ //#region src/verify/safe-write.ts
995
+ const namespace = "$app";
996
+ const key = "harness_verify";
997
+ const readProbe = `query HarnessVerifySafeWriteRead {
998
+ shop { id metafield(namespace: "${namespace}", key: "${key}") { value } }
999
+ }`;
1000
+ const setProbe = `mutation HarnessVerifySafeWriteSet($metafields: [MetafieldsSetInput!]!) {
1001
+ metafieldsSet(metafields: $metafields) { userErrors { field message } }
1002
+ }`;
1003
+ const deleteProbe = `mutation HarnessVerifySafeWriteDelete($metafields: [MetafieldIdentifierInput!]!) {
1004
+ metafieldsDelete(metafields: $metafields) { userErrors { field message } }
1005
+ }`;
1006
+ const ShopProbe = Schema.Struct({ shop: Schema.Struct({
1007
+ id: Schema.String,
1008
+ metafield: Schema.NullOr(Schema.Struct({ value: Schema.String }))
1009
+ }) });
1010
+ const UserErrors = Schema.Array(Schema.Struct({ message: Schema.String }));
1011
+ const read = (admin) => readData(admin, readProbe, ShopProbe).pipe(Effect.map(({ shop }) => ({
1012
+ ownerId: shop.id,
1013
+ value: shop.metafield?.value ?? null
1014
+ })));
1015
+ const set = (admin, ownerId, value) => readData(admin, setProbe, Schema.Struct({ metafieldsSet: Schema.Struct({ userErrors: UserErrors }) }), { metafields: [{
1016
+ key,
1017
+ namespace,
1018
+ ownerId,
1019
+ type: "single_line_text_field",
1020
+ value
1021
+ }] }).pipe(Effect.map(({ metafieldsSet }) => metafieldsSet.userErrors));
1022
+ const remove = (admin, ownerId) => readData(admin, deleteProbe, Schema.Struct({ metafieldsDelete: Schema.Struct({ userErrors: UserErrors }) }), { metafields: [{
1023
+ key,
1024
+ namespace,
1025
+ ownerId
1026
+ }] }).pipe(Effect.map(({ metafieldsDelete }) => metafieldsDelete.userErrors));
1027
+ const expectNoUserErrors = (step) => (errors) => errors.length === 0 ? Effect.void : Effect.fail(new VerifyFailed({
1028
+ check: `safe-write's ${step} failed`,
1029
+ detail: errors.map((error) => error.message)
1030
+ }));
1031
+ const safeWriteRoundTrip = Effect.fn("safeWriteRoundTrip")(function* safeWriteRoundTrip(admin) {
1032
+ const before = yield* read(admin);
1033
+ const now = yield* Clock.currentTimeMillis;
1034
+ const probe = `harness verify ${DateTime.formatIso(DateTime.makeUnsafe(now))}`;
1035
+ yield* set(admin, before.ownerId, probe).pipe(Effect.flatMap(expectNoUserErrors("write")));
1036
+ const readBack = (yield* read(admin)).value;
1037
+ yield* (before.value === null ? remove(admin, before.ownerId) : set(admin, before.ownerId, before.value)).pipe(Effect.flatMap(expectNoUserErrors("restore")));
1038
+ const restoredTo = (yield* read(admin)).value;
1039
+ if (readBack !== probe || restoredTo !== before.value) return yield* new VerifyFailed({
1040
+ check: "safe-write didn't round-trip",
1041
+ detail: {
1042
+ previous: before.value,
1043
+ probe,
1044
+ readBack,
1045
+ restoredTo
1046
+ }
1047
+ });
1048
+ return {
1049
+ key,
1050
+ namespace,
1051
+ previous: before.value,
1052
+ probe,
1053
+ readBack,
1054
+ restoredTo,
1055
+ shop: admin.app.shop
1056
+ };
1057
+ });
1058
+ const safeWriteCommand = command("safe-write", `Make the one write verification may always make: set the app-owned ${namespace}.${key} metafield on the shop, read it back, then restore what was there`, storeFields, (target) => Effect.gen(function* runSafeWrite() {
1059
+ const admin = yield* openAdmin(yield* readAppConfig(target));
1060
+ const report = yield* safeWriteRoundTrip(admin);
1061
+ const evidence = yield* writeEvidence("admin-graphql/safe-write.json", report, secretsOf(admin));
1062
+ return {
1063
+ ...report,
1064
+ evidence
1065
+ };
1066
+ }).pipe(Effect.provide(FetchHttpClient.layer)));
1067
+ //#endregion
1068
+ //#region src/verify/delivery.ts
1069
+ const pathFor = {
1070
+ event: "/events",
1071
+ webhook: "/webhooks"
1072
+ };
1073
+ const clockSkewSeconds = 5;
1074
+ const Kind$1 = Schema.Literals(["webhook", "event"]);
1075
+ const sendWrite = (admin, step, document, variables) => admin.send(document, variables).pipe(Effect.flatMap((result) => result.status === 200 && result.errors.length === 0 && result.data !== null ? Effect.succeed(result.data) : Effect.fail(new VerifyFailed({
1076
+ check: `${step} answered ${result.status}`,
1077
+ detail: [...result.errors]
1078
+ }))));
1079
+ const makeChange = (admin, write, restore, variables) => Effect.gen(function* changeTheStore() {
1080
+ if (write === void 0) return {
1081
+ restored: null,
1082
+ safeWrite: yield* safeWriteRoundTrip(admin),
1083
+ written: null
1084
+ };
1085
+ const written = yield* sendWrite(admin, "--write", write, variables);
1086
+ return {
1087
+ restored: restore === void 0 ? null : yield* sendWrite(admin, "--restore", restore, variables),
1088
+ safeWrite: null,
1089
+ written
1090
+ };
1091
+ });
1092
+ const decodeReport = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Json));
1093
+ const runWorkersLogs = (args) => Effect.scoped(Effect.gen(function* spawnWorkersLogs() {
1094
+ const handle = yield* (yield* ChildProcessSpawner.ChildProcessSpawner).spawn(ChildProcess.make(process.execPath, [
1095
+ ...process.execArgv,
1096
+ process.argv[1] ?? "",
1097
+ "verify",
1098
+ "workers-logs",
1099
+ ...args
1100
+ ], {
1101
+ stderr: "inherit",
1102
+ stdin: "ignore",
1103
+ stdout: "pipe"
1104
+ }));
1105
+ const [stdout, exitCode] = yield* Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], { concurrency: 2 });
1106
+ return {
1107
+ exitCode,
1108
+ stdout
1109
+ };
1110
+ })).pipe(Effect.mapError((error) => new VerifyFailed({ check: `Can't run harness verify workers-logs: ${error.message}` })));
1111
+ const deliveryCommand = command("delivery", "Make a real change on the store, then run harness-unisian's harness verify workers-logs for the webhook or Events delivery Shopify sent for it, from the moment of the change. Passes when the app answered 200. The change is the safe write unless --write gives a mutation the feature file allows", {
1112
+ ...storeFields,
1113
+ handle: optionalFlag("handle", "The Events subscription handle to wait for"),
1114
+ kind: checkedArgument("kind", "webhook (/webhooks) or event (/events)", Kind$1),
1115
+ out: optionalFlag("out", "Where the change's evidence goes under the artifacts folder. Defaults to deliveries/<kind>.json"),
1116
+ profile: optionalFlag("profile", "The Alchemy profile whose Cloudflare login reads Workers Logs. Defaults to workers-logs' own default"),
1117
+ restore: optionalFlag("restore", "A mutation that undoes --write, sent right after it"),
1118
+ timeout: optionalFlag("timeout", "Seconds to wait for the delivery. Defaults to workers-logs' own default"),
1119
+ topic: optionalFlag("topic", "The webhook topic to wait for, e.g. shop/update"),
1120
+ variables: optionalFlag("variables", "The variables for --write and --restore, as a JSON object"),
1121
+ write: optionalFlag("write", "A mutation that makes the change, instead of the safe write")
1122
+ }, ({ handle, kind, out, profile, restore, timeout, topic, variables, write, ...target }) => Effect.gen(function* runDelivery() {
1123
+ const config = yield* readAppConfig(target);
1124
+ const values = yield* decodeVariables(variables);
1125
+ const admin = yield* openAdmin(config);
1126
+ const startedAt = yield* Clock.currentTimeMillis;
1127
+ const change = yield* makeChange(admin, write, restore, values);
1128
+ const appHost = new URL(config.appUrl).host;
1129
+ const otherHosts = (yield* appUrlsOfEveryConfig).map((url) => new URL(url).host).filter((host) => host !== appHost);
1130
+ const evidence = yield* writeEvidence(out ?? `deliveries/${kind}.json`, {
1131
+ change,
1132
+ kind,
1133
+ shop: config.shop
1134
+ }, secretsOf(admin));
1135
+ const since = Math.ceil(((yield* Clock.currentTimeMillis) - startedAt) / 1e3) + clockSkewSeconds;
1136
+ const args = [
1137
+ "--host",
1138
+ appHost,
1139
+ ...otherHosts.flatMap((host) => ["--other-host", host]),
1140
+ "--path",
1141
+ pathFor[kind],
1142
+ "--responded",
1143
+ `${kind} responded`,
1144
+ "--where",
1145
+ `shop=${config.shop}`,
1146
+ ...topic === void 0 ? [] : ["--where-if-logged", `topic=${topic}`],
1147
+ ...handle === void 0 ? [] : ["--where-if-logged", `handle=${handle}`],
1148
+ "--since",
1149
+ String(since),
1150
+ ...profile === void 0 ? [] : ["--profile", profile],
1151
+ ...timeout === void 0 ? [] : ["--timeout", timeout]
1152
+ ];
1153
+ const { exitCode, stdout } = yield* runWorkersLogs(args);
1154
+ if (exitCode !== 0) return yield* new VerifyFailed({ check: `harness verify workers-logs found no 200 for the ${kind} delivery; its reason is above (the change's evidence is in ${evidence})` });
1155
+ return {
1156
+ change,
1157
+ delivery: yield* decodeReport(stdout).pipe(Effect.mapError(() => new VerifyFailed({ check: "harness verify workers-logs printed something that isn't JSON" }))),
1158
+ evidence,
1159
+ shop: config.shop
1160
+ };
1161
+ }).pipe(Effect.provide(FetchHttpClient.layer)));
1162
+ //#endregion
1163
+ //#region src/verify/doctor.ts
1164
+ const doctorFlags = targetDescriptions;
1165
+ const doctor = Effect.gen(function* runAppDoctor() {
1166
+ const { app, shop } = yield* DoctorFlags;
1167
+ const config = yield* readAppConfig({
1168
+ app,
1169
+ shop
1170
+ });
1171
+ const admin = yield* openAdmin(config);
1172
+ const identity = yield* appIdentity(admin);
1173
+ const report = {
1174
+ ...identity,
1175
+ apiVersion: config.apiVersion,
1176
+ config: config.config,
1177
+ devStore: config.devStore,
1178
+ scopes: [...identity.scopes],
1179
+ tokenExpiresAt: DateTime.formatIso(DateTime.makeUnsafe(admin.expiresAt))
1180
+ };
1181
+ const evidence = yield* writeEvidence("admin-graphql/doctor.json", report, secretsOf(admin));
1182
+ return {
1183
+ ...report,
1184
+ evidence
1185
+ };
1186
+ }).pipe(Effect.provide(FetchHttpClient.layer));
1187
+ //#endregion
1188
+ //#region src/verify/proxy-signature.ts
1189
+ const hex = (bytes) => [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
1190
+ const byCodePoint = (a, b) => {
1191
+ if (a === b) return 0;
1192
+ return a < b ? -1 : 1;
1193
+ };
1194
+ const proxySignature = Effect.fn("proxySignature")(function* proxySignature(secret, params) {
1195
+ const message = [...params].map(([name, values]) => `${name}=${values.join(",")}`).toSorted(byCodePoint).join("");
1196
+ const encoder = new TextEncoder();
1197
+ const key = yield* Effect.promise(() => crypto.subtle.importKey("raw", encoder.encode(Redacted.value(secret)), {
1198
+ hash: "SHA-256",
1199
+ name: "HMAC"
1200
+ }, false, ["sign"]));
1201
+ const signature = yield* Effect.promise(() => crypto.subtle.sign("HMAC", key, encoder.encode(message)));
1202
+ return hex(signature);
1203
+ });
1204
+ //#endregion
1205
+ //#region src/verify/proxy.ts
1206
+ const chromeUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
1207
+ const bodyLimit = 2e4;
1208
+ const proxyCommand = command("proxy", "Send a request to the app signed the way Shopify signs app proxy requests, as the storefront would forward it. The signature is the merchant's auth path, not a bypass", {
1209
+ ...storeFields,
1210
+ base: optionalFlag("base", "The origin that serves the app, e.g. a stage's. Defaults to the config's application_url"),
1211
+ body: optionalFlag("body", "The request body, for POST"),
1212
+ customer: optionalFlag("customer", "logged_in_customer_id: a customer ID on this shop, resolved this run. Leave it out for a logged-out visitor"),
1213
+ method: optionalFlag("method", "GET or POST. Defaults to GET", Schema.Literals(["GET", "POST"])),
1214
+ out: optionalFlag("out", "Also write the response to <artifacts>/<out>, e.g. proxy/registry.json"),
1215
+ path: argument("path", "The app path Shopify forwards to, e.g. /proxy/registry?page=2"),
1216
+ prefix: optionalFlag("prefix", "path_prefix, e.g. /apps/wishlist. Defaults to the config's [app_proxy] prefix and subpath")
1217
+ }, ({ base, body, customer, method = "GET", out, path, prefix, ...target }) => Effect.gen(function* runProxy() {
1218
+ const config = yield* readAppConfig(target);
1219
+ const pathPrefix = prefix ?? config.proxyPrefix;
1220
+ if (pathPrefix === null) return yield* new VerifyFailed({ check: `${config.config} has no [app_proxy]; pass --prefix` });
1221
+ const url = new URL(path, base ?? config.appUrl);
1222
+ const now = yield* Clock.currentTimeMillis;
1223
+ const params = new Map([
1224
+ ...[...new Set(url.searchParams.keys())].map((name) => [name, url.searchParams.getAll(name)]),
1225
+ ["logged_in_customer_id", [customer ?? ""]],
1226
+ ["path_prefix", [pathPrefix]],
1227
+ ["shop", [config.shop]],
1228
+ ["timestamp", [String(Math.floor(now / 1e3))]]
1229
+ ]);
1230
+ const signature = yield* proxySignature(config.apiSecret, params);
1231
+ const signed = new URL(url.pathname, url);
1232
+ for (const [name, values] of params) for (const value of values) signed.searchParams.append(name, value);
1233
+ const unsigned = signed.toString();
1234
+ signed.searchParams.set("signature", signature);
1235
+ const request = HttpClientRequest.make(method === "POST" ? "POST" : "GET")(signed.toString()).pipe(HttpClientRequest.setHeader("User-Agent", chromeUserAgent), body === void 0 ? (same) => same : HttpClientRequest.bodyText(body));
1236
+ const response = yield* (yield* HttpClient.HttpClient).pipe(HttpClient.followRedirects(0)).execute(request).pipe(Effect.mapError((error) => new VerifyFailed({ check: `Request to ${unsigned} failed: ${error.message}` })));
1237
+ const report = {
1238
+ body: (yield* response.text.pipe(Effect.orElseSucceed(() => ""))).slice(0, bodyLimit),
1239
+ location: response.headers.location ?? null,
1240
+ method,
1241
+ shop: config.shop,
1242
+ status: response.status,
1243
+ url: unsigned
1244
+ };
1245
+ const evidence = out === void 0 ? null : yield* writeEvidence(out, report, [config.apiSecret]);
1246
+ return {
1247
+ ...report,
1248
+ evidence
1249
+ };
1250
+ }).pipe(Effect.provide(FetchHttpClient.layer)));
1251
+ //#endregion
1252
+ //#region src/verify/skill-blocks.ts
1253
+ const skill = (slot, lines) => ({
1254
+ file: "skills/verify/SKILL.md",
1255
+ id: slot,
1256
+ slot,
1257
+ text: lines.join("\n")
1258
+ });
1259
+ const verifySkillBlocks = [
1260
+ skill("verify-what", [
1261
+ "## A merchant, in Shopify Admin",
1262
+ "",
1263
+ "{{name}} is an embedded Shopify app. A merchant only uses it inside Shopify Admin: Polaris in the app's iframe, App Bridge navigation in the Admin around it. Proof is what the merchant sees there, or a request signed the way Shopify signs app proxy traffic. Fetching a route outside Admin, minting a token, `vite preview` and `pnpm test` don't prove the app: they never run App Bridge or talk to Shopify as the installed app."
1264
+ ]),
1265
+ skill("verify-setup", [
1266
+ "- The store is the app's dev store, `[build] dev_store_url` in `shopify.app.toml`, the one place it's named, and `pnpm verify doctor` prints it. A feature that needs a second store, such as one that copies between stores, names it in its feature file's frontmatter, and every app command takes `--shop <domain>` for it.",
1267
+ "- `.env` holds the app's key and secret. `pnpm worktree` pulls it into a new worktree (`harness app pull-env`), and `shopify app env pull` refreshes it. A second config, `shopify.app.<config>.toml`, reads `.env.<config>` with `--app <config>`.",
1268
+ "- The browser's login is a Shopify login: run `pnpm verify open https://admin.shopify.com/ --headed` and have a person log in once.",
1269
+ "- The dev app's URL is the branch router, so prove a branch on its own stage. Deploy it with `pnpm exec harness app stage`, then start a flow with `{ \"step\": \"branch\", \"name\": \"w-<branch>\" }`, which sets the router's `__Host-branch` cookie in this checkout's browser, under Shopify's partition, for seven days. Without it the browser keeps its last choice, or gets `main`. A branch that changes `shopify.app.toml` or an extension takes the store's slot first."
1270
+ ]),
1271
+ skill("verify-doctor", [
1272
+ "```sh",
1273
+ "pnpm verify doctor",
1274
+ "```",
1275
+ "",
1276
+ "The app's check gets the app's own Admin token for the store through the client credentials grant, and passes when Shopify answers as this app installed on this store. It prints `devStore`, needs no dev server and no login, and writes `admin-graphql/doctor.json`. `--shop <domain>` checks a second store instead. A failure names what to fix: a missing `.env`, an `.env` for another app, a config with no dev store, or a store the grant can't reach, since the app and the store must share a Dev Dashboard organization and the app must be installed there."
1277
+ ]),
1278
+ skill("verify-drive", [
1279
+ "### The app's iframe",
1280
+ "",
1281
+ "The app lives in `iframe[name=app-iframe]` on `https://admin.shopify.com/`. Pass `--frame 'iframe[name=app-iframe]'` to every command that acts inside it, or to `flow` once for every step; leave it out to act on the Admin around the iframe, such as the app's items in the left nav or a page action Admin draws in its title bar. `open` takes the URL `pnpm verify admin-url --path /app/<route>` prints, and `--heading 'h1, s-page[heading]'` reads a Polaris page's heading.",
1282
+ "",
1283
+ "```sh",
1284
+ "pnpm verify admin-url --path /app/settings",
1285
+ "pnpm verify open <admin url> --frame 'iframe[name=app-iframe]' --heading 'h1, s-page[heading]' --out settings/page",
1286
+ "pnpm verify flow skills/verify/flows/<name>.json --frame 'iframe[name=app-iframe]'",
1287
+ "pnpm verify graphql --query '{ shop { name } }' --out admin-graphql/shop.json",
1288
+ "pnpm verify safe-write",
1289
+ "pnpm verify proxy /proxy/<path> --base <origin> --out <feature>/<name>.json",
1290
+ "pnpm verify delivery webhook --topic shop/update",
1291
+ "```",
1292
+ "",
1293
+ "- `graphql` refuses a mutation unless you pass `--write`, and `--write` is only for a write the feature file allows. `safe-write` is the one write that's always allowed.",
1294
+ "- A server function's path ends in its contract's name on a stage, such as `/_serverFn/saveSettings`, so `click --fail /_serverFn/saveSettings` fails its next call and `requests --match _serverFn` lists the calls. Under `shopify app dev` the path is base64 of the source file and export instead (TanStack Start names IDs in builds only), so neither matches there.",
1295
+ "- Page-load time: `requests` gives each entry a `startMs` and an `endMs` from the start of the iframe document's navigation. The page has loaded at the largest `endMs` among the document and the scripts, stylesheets and fonts it asks for; leave out the idle preload of other routes, which starts after the page's own requests end. An App Bridge navigation keeps the document, so it adds no `navigation` entry. The first load after a deploy runs slow: drop it, then compare stages on the median of three `open` and `requests` pairs.",
1296
+ "- The recipes: [embedded Admin](embedded-admin.md) for driving the iframe, [building Admin features](building-admin-features.md) for a new Admin page, [Admin GraphQL](admin-graphql.md) for the app's own Admin access, and [when the app grows](when-the-app-grows.md) for proxy routes, webhooks, Events, extensions and a second app config."
1297
+ ])
1298
+ ];
1299
+ //#endregion
1300
+ //#region src/verify/surfaces.ts
1301
+ const decodeSurfaceModule = Schema.decodeUnknownEffect(Schema.Struct({ surfaces: Schema.Array(Schema.String) }));
1302
+ const serviceSurfaceList = (service) => `services/${service}/src/features/shell/surfaces.ts`;
1303
+ const listed = (fs, directory) => fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
1304
+ const existing = (fs, files) => Effect.filter(files, (file) => fs.exists(file).pipe(Effect.orElseSucceed(() => false)));
1305
+ const extensionFolders = Effect.gen(function* findExtensionFolders() {
1306
+ const fs = yield* FileSystem.FileSystem;
1307
+ const apps = (yield* listed(fs, "apps")).map((app) => `apps/${app}/extensions`);
1308
+ const folders = [];
1309
+ for (const directory of ["extensions", ...apps.toSorted((a, b) => a.localeCompare(b))]) for (const name of (yield* listed(fs, directory)).toSorted((a, b) => a.localeCompare(b))) folders.push(`${directory}/${name}`);
1310
+ return folders;
1311
+ });
1312
+ const surfacesIn = (file) => Effect.gen(function* importSurfaceList() {
1313
+ const path = yield* Path.Path;
1314
+ const url = yield* path.toFileUrl(path.resolve(file));
1315
+ const module = yield* Effect.tryPromise({
1316
+ catch: () => ({ message: `Can't import ${file}` }),
1317
+ try: () => import(url.href)
1318
+ });
1319
+ return (yield* decodeSurfaceModule(module).pipe(Effect.mapError(() => ({ message: `${file} doesn't export a surfaces list of strings` })))).surfaces;
1320
+ });
1321
+ const appSurfaces = Effect.gen(function* listAppSurfaces() {
1322
+ const fs = yield* FileSystem.FileSystem;
1323
+ const path = yield* Path.Path;
1324
+ const extensions = yield* existing(fs, (yield* extensionFolders).map((folder) => `${folder}/shopify.extension.toml`));
1325
+ const services = yield* existing(fs, (yield* listed(fs, "services")).toSorted((a, b) => a.localeCompare(b)).map(serviceSurfaceList));
1326
+ const serviceSurfaces = yield* Effect.forEach(services, surfacesIn);
1327
+ return [...extensions.map((file) => `extension:${path.basename(path.dirname(file))}`), ...serviceSurfaces.flat()];
1328
+ });
1329
+ //#endregion
1330
+ //#region src/harness.ts
1331
+ var harness_default = {
1332
+ blocks: [
1333
+ ...baseBlocks,
1334
+ ...verifySkillBlocks,
1335
+ ...unisianDocBlocks
1336
+ ],
1337
+ checkSteps: [
1338
+ "harness app env-check",
1339
+ "harness app codegen-check",
1340
+ "harness app functions",
1341
+ "harness app rust check"
1342
+ ],
1343
+ testSteps: ["harness app rust test"],
1344
+ commands: [
1345
+ envCheckCommand,
1346
+ codegenCheckCommand,
1347
+ functionsCommand,
1348
+ rustCommand,
1349
+ slotCommand,
1350
+ devCommand,
1351
+ stageCommand,
1352
+ pullEnvCommand,
1353
+ destroyCommand
1354
+ ],
1355
+ pieces: [{
1356
+ blocks: [...featureWorkerBlocks, ...unisianDocFeatureWorkerBlocks],
1357
+ when: { dependency: "@unisian/service-contract" }
1358
+ }, {
1359
+ blocks: [...extensionBlocks, ...unisianDocExtensionBlocks],
1360
+ when: { file: extensionFiles }
1361
+ }],
1362
+ verify: {
1363
+ commands: [
1364
+ adminUrlCommand,
1365
+ graphqlCommand,
1366
+ safeWriteCommand,
1367
+ proxyCommand,
1368
+ deliveryCommand
1369
+ ],
1370
+ doctor,
1371
+ doctorFlags,
1372
+ settings: { refusedFramingHint: "The app's iframe loads the application_url Shopify has for this app. The dev app's URL is the branch router, which serves the stage this browser's branch cookie names, else main" },
1373
+ steps: { branch: branchStep },
1374
+ surfaces: appSurfaces
1375
+ },
1376
+ worktree: {
1377
+ postCreate: ["pnpm exec harness app pull-env"],
1378
+ preRemove: ["pnpm exec harness app destroy"]
1379
+ }
1380
+ };
1381
+ //#endregion
1382
+ export { harness_default as default };