@saastemly/voidcommerce 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.
- package/README.md +271 -0
- package/bin/vc +2 -0
- package/dist/catalog.d.ts +69 -0
- package/dist/catalog.js +34 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +544 -0
- package/dist/deploy/cloudflare.d.ts +25 -0
- package/dist/deploy/index.d.ts +16 -0
- package/dist/deploy/jsonc.d.ts +8 -0
- package/dist/deploy/preflight.d.ts +29 -0
- package/dist/deploy/wrangler.d.ts +37 -0
- package/dist/dist.d.ts +22 -0
- package/dist/generate/auth.d.ts +2 -0
- package/dist/generate/ci.d.ts +24 -0
- package/dist/generate/env.d.ts +13 -0
- package/dist/generate/frontend.d.ts +47 -0
- package/dist/generate/index.d.ts +28 -0
- package/dist/generate/requirements.d.ts +13 -0
- package/dist/generate/strict.d.ts +72 -0
- package/dist/generate/support.d.ts +23 -0
- package/dist/help.d.ts +31 -0
- package/dist/import.d.ts +2 -0
- package/dist/index-s7sq41qs.js +590 -0
- package/dist/index-ssv3a6wc.js +172 -0
- package/dist/index-wzy1xtr1.js +3155 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +190 -0
- package/dist/init.d.ts +1 -0
- package/dist/manifest.d.ts +131 -0
- package/dist/manifest.js +41 -0
- package/dist/project.d.ts +20 -0
- package/dist/regenerate.d.ts +9 -0
- package/dist/scripts.d.ts +12 -0
- package/dist/void.d.ts +30 -0
- package/dist/wizard.d.ts +7 -0
- package/package.json +50 -0
- package/src/catalog.ts +673 -0
- package/src/cli.ts +78 -0
- package/src/deploy/cloudflare.ts +166 -0
- package/src/deploy/index.ts +101 -0
- package/src/deploy/jsonc.ts +148 -0
- package/src/deploy/preflight.ts +137 -0
- package/src/deploy/wrangler.ts +111 -0
- package/src/dist.ts +157 -0
- package/src/generate/auth.ts +386 -0
- package/src/generate/ci.ts +208 -0
- package/src/generate/env.ts +164 -0
- package/src/generate/frontend.ts +275 -0
- package/src/generate/index.ts +390 -0
- package/src/generate/requirements.ts +48 -0
- package/src/generate/strict.ts +692 -0
- package/src/generate/support.ts +252 -0
- package/src/help.ts +172 -0
- package/src/import.ts +237 -0
- package/src/index.ts +37 -0
- package/src/init.ts +187 -0
- package/src/manifest.ts +303 -0
- package/src/project.ts +63 -0
- package/src/regenerate.ts +51 -0
- package/src/scripts.ts +53 -0
- package/src/void.ts +115 -0
- package/src/wizard.ts +234 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { DIST_BRANCH, DIST_DIR } from "../dist";
|
|
2
|
+
import type { Manifest } from "../manifest";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The workflow that turns a push into a deploy.
|
|
6
|
+
*
|
|
7
|
+
* ── Why a branch and not a build ─────────────────────────────────────────
|
|
8
|
+
*
|
|
9
|
+
* Cloudflare's Workers Builds watches a repository and builds what it finds.
|
|
10
|
+
* A strict repository has no app in it — `.vc/app` is generated and
|
|
11
|
+
* gitignored — and Cloudflare documents nothing about a build command that
|
|
12
|
+
* writes the source it then builds. The install step's ordering against a
|
|
13
|
+
* generated `package.json` is undocumented, which is not a thing to guess at
|
|
14
|
+
* in the path that puts a shop on the internet.
|
|
15
|
+
*
|
|
16
|
+
* So the generator runs in GitHub Actions, where it is ordinary, and pushes
|
|
17
|
+
* the result to its own branch. `main` stays the manifest and the data;
|
|
18
|
+
* `void-dist` is a plain Void app with a committed `package.json`, lockfile
|
|
19
|
+
* and `wrangler.jsonc` that anything can build from a cold checkout.
|
|
20
|
+
* Cloudflare is pointed at that branch and needs to know nothing about
|
|
21
|
+
* voidcommerce — which also means the deploy path is one a person can run by
|
|
22
|
+
* hand when CI is not the answer.
|
|
23
|
+
*/
|
|
24
|
+
export function renderDistWorkflow(manifest: Manifest): string {
|
|
25
|
+
const worker = manifest.shop.domain.split(".")[0];
|
|
26
|
+
return `name: Build the deployable app
|
|
27
|
+
|
|
28
|
+
# Generated by \`vc init\` from voidcommerce.json.
|
|
29
|
+
#
|
|
30
|
+
# Every push to main regenerates the Void app from the manifest and force-pushes
|
|
31
|
+
# it to \`${DIST_BRANCH}\`. Cloudflare's Workers Builds watches THAT branch and
|
|
32
|
+
# deploys it, so a push here is a deploy — and no Cloudflare credential is
|
|
33
|
+
# stored in GitHub.
|
|
34
|
+
#
|
|
35
|
+
# The Cloudflare side is configured once, in the dashboard, for the Worker
|
|
36
|
+
# named \`${worker}\` (Settings → Builds). DEPLOY.md has the settings and the
|
|
37
|
+
# one non-obvious part: the build's API token needs D1:Edit, which the token
|
|
38
|
+
# Cloudflare generates for you does not have.
|
|
39
|
+
on:
|
|
40
|
+
push:
|
|
41
|
+
branches: [main, master]
|
|
42
|
+
workflow_dispatch:
|
|
43
|
+
|
|
44
|
+
# A later push wins: an older tree must never overwrite a newer one.
|
|
45
|
+
concurrency:
|
|
46
|
+
group: ${DIST_BRANCH}-\${{ github.repository }}
|
|
47
|
+
cancel-in-progress: true
|
|
48
|
+
|
|
49
|
+
# contents: write is what lets GITHUB_TOKEN force-push the branch. Nothing else.
|
|
50
|
+
permissions:
|
|
51
|
+
contents: write
|
|
52
|
+
|
|
53
|
+
jobs:
|
|
54
|
+
build:
|
|
55
|
+
runs-on: ubuntu-latest
|
|
56
|
+
steps:
|
|
57
|
+
- uses: actions/checkout@v6
|
|
58
|
+
- uses: oven-sh/setup-bun@v2
|
|
59
|
+
|
|
60
|
+
- run: bun install --frozen-lockfile
|
|
61
|
+
|
|
62
|
+
# Regenerates the app from voidcommerce.json, runs void's own prepare and
|
|
63
|
+
# migration generation, then resolves the symlinks into a tree that stands
|
|
64
|
+
# on its own.
|
|
65
|
+
- run: bunx vc dist
|
|
66
|
+
|
|
67
|
+
# A migration generated here and not committed would be applied under a
|
|
68
|
+
# new name on the next run, against tables that already exist. The
|
|
69
|
+
# migrations belong to the repository, so this fails rather than
|
|
70
|
+
# inventing one.
|
|
71
|
+
- name: Refuse to ship a migration that is not committed
|
|
72
|
+
run: |
|
|
73
|
+
set -euo pipefail
|
|
74
|
+
if [ -n "$(git status --porcelain migrations/)" ]; then
|
|
75
|
+
echo "::error::vc generate wrote a migration that is not committed."
|
|
76
|
+
git status --porcelain migrations/
|
|
77
|
+
echo "Run 'vc generate' locally, review it, and commit it."
|
|
78
|
+
exit 1
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
- name: Publish to ${DIST_BRANCH}
|
|
82
|
+
env:
|
|
83
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
84
|
+
run: |
|
|
85
|
+
set -euo pipefail
|
|
86
|
+
cd ${DIST_DIR}
|
|
87
|
+
# A fresh single-commit history each run: the branch is build output,
|
|
88
|
+
# so it is replaced rather than accumulating commits.
|
|
89
|
+
git init -q -b ${DIST_BRANCH}
|
|
90
|
+
git config user.name "github-actions[bot]"
|
|
91
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
92
|
+
git add -A
|
|
93
|
+
git commit -q -m "\${{ github.sha }} — ${manifest.shop.domain}"
|
|
94
|
+
git push -f "https://x-access-token:\${GITHUB_TOKEN}@github.com/\${{ github.repository }}.git" ${DIST_BRANCH}
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What a person still has to do once, and why each thing cannot be done for them. */
|
|
99
|
+
export function renderDeployReadme(manifest: Manifest, zone: string, hosts: string[]): string {
|
|
100
|
+
const worker = manifest.shop.domain.split(".")[0];
|
|
101
|
+
return `# Going live
|
|
102
|
+
|
|
103
|
+
\`${manifest.shop.domain}\` on Cloudflare Workers. Generated by \`vc init\`;
|
|
104
|
+
regenerate with \`vc generate\`.
|
|
105
|
+
|
|
106
|
+
## The loop, once it is set up
|
|
107
|
+
|
|
108
|
+
Push to \`main\`. GitHub Actions regenerates the app from \`voidcommerce.json\`
|
|
109
|
+
and force-pushes it to \`${DIST_BRANCH}\`; Cloudflare's Workers Builds builds that
|
|
110
|
+
branch and deploys it. Nothing else runs.
|
|
111
|
+
|
|
112
|
+
Your secrets survive every deploy — \`wrangler deploy\` never deletes a secret.
|
|
113
|
+
Plaintext \`vars\` are replaced from \`.env.production\` on each deploy, which is
|
|
114
|
+
why no credential is allowed in that file.
|
|
115
|
+
|
|
116
|
+
## Once, before the first push
|
|
117
|
+
|
|
118
|
+
Each of these either creates something in your Cloudflare account or holds a
|
|
119
|
+
credential, so none of them can live in a repository.
|
|
120
|
+
|
|
121
|
+
### 1. The zone
|
|
122
|
+
|
|
123
|
+
\`${zone}\` must be on Cloudflare. A Worker custom domain is a record Cloudflare
|
|
124
|
+
creates in its own zone, so a domain hosted anywhere else cannot have one. The
|
|
125
|
+
worker answers on ${hosts.map((h) => `\`${h}\``).join(" and ")}.
|
|
126
|
+
|
|
127
|
+
### 2. The database, the queue, and the account id
|
|
128
|
+
|
|
129
|
+
\`\`\`sh
|
|
130
|
+
vc deploy --cloudflare --provision
|
|
131
|
+
\`\`\`
|
|
132
|
+
|
|
133
|
+
Idempotent: it creates nothing that already exists. It pins your account id,
|
|
134
|
+
creates the D1 database and the queue, and records both in \`wrangler.jsonc\`
|
|
135
|
+
and \`voidcommerce.json\` so every later generate carries the real ids.
|
|
136
|
+
**Commit that change.**
|
|
137
|
+
|
|
138
|
+
### 3. The secrets
|
|
139
|
+
|
|
140
|
+
\`\`\`sh
|
|
141
|
+
vc preflight --cloudflare
|
|
142
|
+
\`\`\`
|
|
143
|
+
|
|
144
|
+
lists every required key, says what breaks without it, and prints the
|
|
145
|
+
\`wrangler secret put\` line. On a Worker that has never been deployed, setting
|
|
146
|
+
the first secret is what creates it.
|
|
147
|
+
|
|
148
|
+
### 4. An API token the build can use
|
|
149
|
+
|
|
150
|
+
This is the one non-obvious step. Cloudflare generates an API token for
|
|
151
|
+
Workers Builds automatically, and **that token has no D1 permission** — its
|
|
152
|
+
scopes are Workers Scripts, KV, R2, Workers Routes and account/user reads. The
|
|
153
|
+
deploy command below applies database migrations, so it needs more.
|
|
154
|
+
|
|
155
|
+
Create a token at **My Profile → API Tokens** with:
|
|
156
|
+
|
|
157
|
+
| scope | permission |
|
|
158
|
+
|---|---|
|
|
159
|
+
| Account | Workers Scripts: Edit |
|
|
160
|
+
| Account | D1: Edit |
|
|
161
|
+
| Account | Workers KV Storage: Edit, Workers R2 Storage: Edit |
|
|
162
|
+
| Account | Queues: Edit |
|
|
163
|
+
| Account | Account Settings: Read |
|
|
164
|
+
| Zone | Workers Routes: Edit (${zone}) |
|
|
165
|
+
| User | User Details: Read, Memberships: Read |
|
|
166
|
+
|
|
167
|
+
Then select it in the build settings below. Use the same token for every
|
|
168
|
+
deploy of this Worker; permissions are whatever that token has.
|
|
169
|
+
|
|
170
|
+
### 5. Connect the repository
|
|
171
|
+
|
|
172
|
+
In the Cloudflare dashboard, on the Worker named \`${worker}\`, under
|
|
173
|
+
**Settings → Builds → Connect**. The Worker's name must equal the \`name\` in
|
|
174
|
+
the \`wrangler.jsonc\` at the root directory, or the build fails.
|
|
175
|
+
|
|
176
|
+
| setting | value |
|
|
177
|
+
|---|---|
|
|
178
|
+
| branch | \`${DIST_BRANCH}\` |
|
|
179
|
+
| root directory | \`/\` |
|
|
180
|
+
| build command | \`bun install && bunx void prepare && bunx vp build\` |
|
|
181
|
+
| deploy command | \`bunx wrangler d1 migrations apply DB --remote && bunx wrangler deploy -c dist/ssr/wrangler.json\` |
|
|
182
|
+
| API token | the one from step 4 |
|
|
183
|
+
|
|
184
|
+
The migration command names the **binding** (\`DB\`), not the database, so it
|
|
185
|
+
still points at the right database if the name ever differs.
|
|
186
|
+
|
|
187
|
+
Cloudflare's build image has Bun; pin its version with a \`BUN_VERSION\` build
|
|
188
|
+
variable if you ever need to. The free plan allows 3,000 build minutes a month
|
|
189
|
+
and one build at a time.
|
|
190
|
+
|
|
191
|
+
### 6. The catalogue
|
|
192
|
+
|
|
193
|
+
\`vc import\` pushes \`data/\` into the running shop. It is an upsert, so it is
|
|
194
|
+
safe on every deploy and costs one pass when nothing changed.
|
|
195
|
+
|
|
196
|
+
## Without Cloudflare's build
|
|
197
|
+
|
|
198
|
+
The same thing by hand, from a checkout of either branch:
|
|
199
|
+
|
|
200
|
+
\`\`\`sh
|
|
201
|
+
vc deploy --cloudflare
|
|
202
|
+
\`\`\`
|
|
203
|
+
|
|
204
|
+
which preflights, builds, strips the baked development values out of the
|
|
205
|
+
worker's vars, applies the remote migrations, and deploys exactly what it
|
|
206
|
+
verified.
|
|
207
|
+
`;
|
|
208
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { type EnvKey } from "../catalog";
|
|
2
|
+
import { type Manifest, envKeysOf, has, hasFrontend, isApex, zone } from "../manifest";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `env.ts`, `.env.example` and `.env.production` — from the manifest.
|
|
6
|
+
*
|
|
7
|
+
* Every key is REQUIRED, with no default. A default is compiled into the
|
|
8
|
+
* worker's vars and shadows the real secret; Void's own deploy gate calls a
|
|
9
|
+
* required key absent from the vars "the only state a remote secret can
|
|
10
|
+
* discharge". Locally, `unset` is the one documented value that reads as
|
|
11
|
+
* absent, and preflight refuses it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Keys every shop has, whatever else was chosen. */
|
|
15
|
+
const BASE: EnvKey[] = [
|
|
16
|
+
{
|
|
17
|
+
key: "SHOP_DOMAIN",
|
|
18
|
+
breaks: "everything derived from it is wrong at once — no public origin, the storefront rejected as untrusted, no DNS records",
|
|
19
|
+
plaintext: true,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
key: "COMMERCE_CRON_SECRET",
|
|
23
|
+
breaks: "the scheduler cannot authenticate, so every background job stops",
|
|
24
|
+
where: "openssl rand -base64 32",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
key: "COMMERCE_WEBHOOK_SECRET",
|
|
28
|
+
breaks: "payment webhooks cannot be verified, so no order ever becomes paid",
|
|
29
|
+
where: "the payment provider's dashboard",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
key: "SHIPPING_DOMESTIC_MINOR",
|
|
33
|
+
breaks: "delivery is quoted at nothing, and every order loses the carrier cost",
|
|
34
|
+
plaintext: true,
|
|
35
|
+
dev: "4900",
|
|
36
|
+
where: "your carrier agreement, in minor units ex VAT",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
key: "SHIPPING_FREE_FROM_MINOR",
|
|
40
|
+
breaks: "the free-delivery threshold is undefined",
|
|
41
|
+
plaintext: true,
|
|
42
|
+
dev: "100000",
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export function allEnvKeys(manifest: Manifest): EnvKey[] {
|
|
47
|
+
const keys = [...BASE, ...envKeysOf(manifest)];
|
|
48
|
+
// Only a shop that is not its zone's apex needs to name the zone: records
|
|
49
|
+
// go there, and the worker's custom domain needs that zone on Cloudflare.
|
|
50
|
+
if (!isApex(manifest)) {
|
|
51
|
+
keys.splice(1, 0, {
|
|
52
|
+
key: "SHOP_ZONE",
|
|
53
|
+
breaks: "DNS records are written to the wrong zone, or to none",
|
|
54
|
+
plaintext: true,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// Only a layout with a storefront has somewhere to point the records.
|
|
58
|
+
if (hasFrontend(manifest.layout) && manifest.shop.pagesHost) {
|
|
59
|
+
keys.splice(1, 0, {
|
|
60
|
+
key: "GITHUB_PAGES_HOST",
|
|
61
|
+
breaks: "the www record has nothing to point at",
|
|
62
|
+
plaintext: true,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return keys;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const NUMERIC = new Set(["SHIPPING_DOMESTIC_MINOR", "SHIPPING_FREE_FROM_MINOR"]);
|
|
69
|
+
|
|
70
|
+
export function renderEnvTs(manifest: Manifest): string {
|
|
71
|
+
const keys = allEnvKeys(manifest);
|
|
72
|
+
const lines = keys.map((key) => {
|
|
73
|
+
const helper = NUMERIC.has(key.key) ? "number()" : "string()";
|
|
74
|
+
const doc = [` /** ${key.breaks}${key.where ? ` — from: ${key.where}` : ""} */`];
|
|
75
|
+
return `${doc.join("\n")}\n ${key.key}: ${helper},`;
|
|
76
|
+
});
|
|
77
|
+
return `import { defineEnv, number, string } from "void/env";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Every env key the app reads. All of them are REQUIRED.
|
|
81
|
+
*
|
|
82
|
+
* Generated by \`vc init\` from voidcommerce.json — edit the manifest and
|
|
83
|
+
* regenerate rather than editing this by hand.
|
|
84
|
+
*
|
|
85
|
+
* Nothing is optional: an integration this shop supports is one the
|
|
86
|
+
* deployment sets up. No key has a default, because a default is compiled
|
|
87
|
+
* into the worker's vars and shadows the real secret. Locally, the literal
|
|
88
|
+
* value \`unset\` is the one documented way to say "I do not have this yet",
|
|
89
|
+
* and \`vc preflight\` refuses it.
|
|
90
|
+
*/
|
|
91
|
+
export default defineEnv({
|
|
92
|
+
${lines.join("\n")}
|
|
93
|
+
});
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function renderEnvExample(manifest: Manifest): string {
|
|
98
|
+
const keys = allEnvKeys(manifest);
|
|
99
|
+
return [
|
|
100
|
+
"# Every key is required. Copy to .env for local development.",
|
|
101
|
+
"# `unset` is the one value that reads as absent — for a credential you do not have yet.",
|
|
102
|
+
"",
|
|
103
|
+
...keys.map((key) => `${key.key}=${key.dev ?? (key.plaintext ? "" : "unset")}`),
|
|
104
|
+
"",
|
|
105
|
+
].join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function renderEnvLocal(manifest: Manifest): string {
|
|
109
|
+
const keys = allEnvKeys(manifest);
|
|
110
|
+
const local: Record<string, string> = {
|
|
111
|
+
SHOP_DOMAIN: `${manifest.shop.domain.split(".")[0]}.test`,
|
|
112
|
+
GITHUB_PAGES_HOST: manifest.shop.pagesHost ?? "",
|
|
113
|
+
SHOP_ZONE: zone(manifest),
|
|
114
|
+
COMMERCE_CRON_SECRET: "dev-cron-secret-not-for-production",
|
|
115
|
+
COMMERCE_WEBHOOK_SECRET: "whsec_dev_secret",
|
|
116
|
+
SYSTEM_API_KEY: "dev-system-key-0123456789abcdefghijklmnopqrstuvwxyz",
|
|
117
|
+
EMAIL_FROM: `"${manifest.shop.name} <noreply@${manifest.shop.domain}>"`,
|
|
118
|
+
};
|
|
119
|
+
return [
|
|
120
|
+
"# Local development. Gitignored; .env.example documents every key.",
|
|
121
|
+
"",
|
|
122
|
+
...keys.map((key) => `${key.key}=${local[key.key] ?? key.dev ?? "unset"}`),
|
|
123
|
+
// One file for both halves in a strict repo, where the storefront and
|
|
124
|
+
// the worker share a root. The worker takes 5173 because it is started
|
|
125
|
+
// first; the storefront lands on the next free port and calls back here.
|
|
126
|
+
...(hasFrontend(manifest.layout)
|
|
127
|
+
? ["", "# The storefront, reaching the worker. Start the worker first.", "VITE_API_ORIGIN=http://localhost:5173"]
|
|
128
|
+
: []),
|
|
129
|
+
"",
|
|
130
|
+
].join("\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function renderEnvProduction(manifest: Manifest): string {
|
|
134
|
+
const keys = allEnvKeys(manifest).filter((key) => key.plaintext);
|
|
135
|
+
const values: Record<string, string> = {
|
|
136
|
+
SHOP_DOMAIN: manifest.shop.domain,
|
|
137
|
+
GITHUB_PAGES_HOST: manifest.shop.pagesHost ?? "",
|
|
138
|
+
SHOP_ZONE: zone(manifest),
|
|
139
|
+
EMAIL_FROM: `"${manifest.shop.name} <noreply@${manifest.shop.domain}>"`,
|
|
140
|
+
ADYEN_ENVIRONMENT: "live",
|
|
141
|
+
BC_ENVIRONMENT: "production",
|
|
142
|
+
};
|
|
143
|
+
return [
|
|
144
|
+
"# Production values that are NOT secrets.",
|
|
145
|
+
"#",
|
|
146
|
+
"# On `void deploy --backend cloudflare` every .env* file is baked into the",
|
|
147
|
+
"# worker's vars as PLAINTEXT, so this holds only values safe to commit. Every",
|
|
148
|
+
"# credential is `wrangler secret put <NAME>` instead.",
|
|
149
|
+
"",
|
|
150
|
+
...keys.map((key) => `${key.key}=${values[key.key] ?? key.dev ?? ""}`),
|
|
151
|
+
"",
|
|
152
|
+
].join("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** So the generator can say what it decided. */
|
|
156
|
+
export function envSummary(manifest: Manifest): { secrets: string[]; plaintext: string[] } {
|
|
157
|
+
const keys = allEnvKeys(manifest);
|
|
158
|
+
return {
|
|
159
|
+
secrets: keys.filter((key) => !key.plaintext).map((key) => key.key),
|
|
160
|
+
plaintext: keys.filter((key) => key.plaintext).map((key) => key.key),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export { has };
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import type { Manifest } from "../manifest";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The storefront: a static Void site that talks to the worker over HTTP.
|
|
5
|
+
*
|
|
6
|
+
* Two layouts have one, and the only difference is where it sits.
|
|
7
|
+
*
|
|
8
|
+
* monorepo `frontend/`, its own workspace beside `api/`
|
|
9
|
+
* strict the REPOSITORY ROOT, sharing the one package.json
|
|
10
|
+
*
|
|
11
|
+
* Strict's placement is the interesting one. What a person edits in a strict
|
|
12
|
+
* repository is the storefront, so the storefront is what the repository
|
|
13
|
+
* looks like: its Vite config, its pages, its package.json. The backend is
|
|
14
|
+
* generated underneath it at `.vc/app` and is nobody's to edit. So the repo
|
|
15
|
+
* is, in technicality, a frontend app that builds a backend app — and in
|
|
16
|
+
* practice a monorepo without the ceremony of being one.
|
|
17
|
+
*
|
|
18
|
+
* Void prerenders it (`output: "static"`), and a workflow publishes the built
|
|
19
|
+
* tree to a branch that GitHub Pages — or Cloudflare Pages — serves as-is.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Where the frontend's build output lands, relative to its own directory. */
|
|
23
|
+
export const FRONTEND_OUT = "dist/client";
|
|
24
|
+
export const FRONTEND_BRANCH = "frontend-static";
|
|
25
|
+
|
|
26
|
+
export function renderFrontendApiTs(manifest: Manifest): string {
|
|
27
|
+
return `/// <reference types="vite/client" />
|
|
28
|
+
import { createAuthClient } from "better-auth/react";
|
|
29
|
+
import { commerceClient } from "@saastemly/better-commerce/client";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The one client the storefront talks through.
|
|
33
|
+
*
|
|
34
|
+
* The API is another origin — api.${manifest.shop.domain} — so its address is
|
|
35
|
+
* baked at build time: .env.production for the build, .env locally. There is
|
|
36
|
+
* no fallback. A storefront that silently points nowhere is worse than one
|
|
37
|
+
* that refuses to build.
|
|
38
|
+
*/
|
|
39
|
+
const origin = import.meta.env.VITE_API_ORIGIN as string | undefined;
|
|
40
|
+
if (!origin) throw new Error("VITE_API_ORIGIN is not set — .env locally, .env.production for the build.");
|
|
41
|
+
|
|
42
|
+
export const API_ORIGIN: string = origin;
|
|
43
|
+
export const api = createAuthClient({ baseURL: API_ORIGIN, plugins: [commerceClient()] });
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function renderFrontendEnvProduction(manifest: Manifest): string {
|
|
48
|
+
return `# Baked into the storefront build. Generated by \`vc init\` from voidcommerce.json.
|
|
49
|
+
VITE_API_ORIGIN=https://api.${manifest.shop.domain}
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The Vite config for a static Void site. Deliberately small: this is yours. */
|
|
54
|
+
export function renderFrontendViteConfig(): string {
|
|
55
|
+
return `import { defineConfig } from "vite";
|
|
56
|
+
import { voidPlugin } from "void";
|
|
57
|
+
import { voidReact } from "@void/react/plugin";
|
|
58
|
+
|
|
59
|
+
/** The storefront. The API is a different origin, so nothing is proxied here. */
|
|
60
|
+
export default defineConfig({
|
|
61
|
+
plugins: [voidPlugin(), voidReact()],
|
|
62
|
+
});
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `target: node` because Void 0.10.13's Cloudflare prerender calls miniflare
|
|
68
|
+
* with the single-worker shorthand that the miniflare its own dependencies
|
|
69
|
+
* pin rejects. The node target prerenders directly, and needs
|
|
70
|
+
* `@hono/node-server`. It also disables Cloudflare bindings, which a static
|
|
71
|
+
* export could not use anyway.
|
|
72
|
+
*/
|
|
73
|
+
export function renderFrontendVoidJson(): string {
|
|
74
|
+
return `{
|
|
75
|
+
"$schema": "./node_modules/void/schema.json",
|
|
76
|
+
"target": "node",
|
|
77
|
+
"output": "static"
|
|
78
|
+
}
|
|
79
|
+
`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function renderFrontendTsconfig(): string {
|
|
83
|
+
return `{
|
|
84
|
+
"extends": "./.void/tsconfig.json",
|
|
85
|
+
"compilerOptions": {
|
|
86
|
+
"target": "ES2022",
|
|
87
|
+
"module": "ESNext",
|
|
88
|
+
"moduleResolution": "bundler",
|
|
89
|
+
"strict": true,
|
|
90
|
+
"esModuleInterop": true,
|
|
91
|
+
"skipLibCheck": true,
|
|
92
|
+
"noEmit": true,
|
|
93
|
+
"types": ["vite/client"],
|
|
94
|
+
"jsx": "react-jsx",
|
|
95
|
+
"paths": { "@/*": ["./*"] }
|
|
96
|
+
},
|
|
97
|
+
"include": ["pages", "lib", "src"]
|
|
98
|
+
}
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A storefront that actually shows the catalogue, so the first build proves the wiring. */
|
|
103
|
+
export function renderStorefrontPage(manifest: Manifest): string {
|
|
104
|
+
const { name, currency } = manifest.shop;
|
|
105
|
+
return `import { useEffect, useState } from "react";
|
|
106
|
+
import { api } from "@/lib/api";
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The shop front.
|
|
110
|
+
*
|
|
111
|
+
* Yours to replace — this is a starting point, not a framework. What it is
|
|
112
|
+
* here to prove is the wiring: the catalogue comes from the worker over HTTP,
|
|
113
|
+
* with no server code on this side, which is what makes the whole storefront
|
|
114
|
+
* a static export.
|
|
115
|
+
*
|
|
116
|
+
* Prices arrive in MINOR units (øre, cents) because that is the only way to
|
|
117
|
+
* add money without losing it to floating point.
|
|
118
|
+
*/
|
|
119
|
+
interface Product {
|
|
120
|
+
id: string;
|
|
121
|
+
title: string;
|
|
122
|
+
handle?: string;
|
|
123
|
+
description?: string;
|
|
124
|
+
priceMinor?: number;
|
|
125
|
+
currency?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const money = (minor: number, currency: string) =>
|
|
129
|
+
new Intl.NumberFormat(undefined, { style: "currency", currency: currency.toUpperCase() }).format(minor / 100);
|
|
130
|
+
|
|
131
|
+
export default function StorefrontPage() {
|
|
132
|
+
const [products, setProducts] = useState<Product[] | null>(null);
|
|
133
|
+
const [error, setError] = useState<string | null>(null);
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
// The catalogue is public: no session, no key. A visitor sees it.
|
|
137
|
+
api.$fetch("/commerce/products", { method: "GET" })
|
|
138
|
+
.then((result) => {
|
|
139
|
+
const data = (result as { data?: { products?: Product[] } }).data;
|
|
140
|
+
setProducts(data?.products ?? []);
|
|
141
|
+
})
|
|
142
|
+
.catch((cause: unknown) => setError(cause instanceof Error ? cause.message : String(cause)));
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<main style={{ maxWidth: "60rem", margin: "0 auto", padding: "2rem 1rem", fontFamily: "system-ui, sans-serif" }}>
|
|
147
|
+
<h1>${name}</h1>
|
|
148
|
+
{error && <p role="alert">The catalogue could not be loaded: {error}</p>}
|
|
149
|
+
{!products && !error && <p>Loading the catalogue…</p>}
|
|
150
|
+
{products?.length === 0 && <p>No products yet. Run <code>vc import</code> to push <code>data/</code> into the shop.</p>}
|
|
151
|
+
<ul style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(14rem, 1fr))", gap: "1.5rem", listStyle: "none", padding: 0 }}>
|
|
152
|
+
{products?.map((product) => (
|
|
153
|
+
<li key={product.id}>
|
|
154
|
+
<h2 style={{ fontSize: "1rem", margin: "0 0 .25rem" }}>{product.title}</h2>
|
|
155
|
+
{product.description && <p style={{ margin: "0 0 .5rem", opacity: 0.8 }}>{product.description}</p>}
|
|
156
|
+
{typeof product.priceMinor === "number" && <p style={{ margin: 0 }}>{money(product.priceMinor, product.currency ?? "${currency}")}</p>}
|
|
157
|
+
</li>
|
|
158
|
+
))}
|
|
159
|
+
</ul>
|
|
160
|
+
</main>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Publishes the storefront to a branch on every push.
|
|
168
|
+
*
|
|
169
|
+
* The branch holds build output only, so it is force-pushed as a single
|
|
170
|
+
* orphan commit rather than accumulating history. GitHub Pages serves it from
|
|
171
|
+
* `/`, and the CNAME file in it sets the domain — so a green run is a live
|
|
172
|
+
* deploy with no Pages environment to configure. Cloudflare Pages can be
|
|
173
|
+
* pointed at the same branch instead.
|
|
174
|
+
*/
|
|
175
|
+
export function renderFrontendWorkflow(manifest: Manifest, dir: string): string {
|
|
176
|
+
const at = dir ? `\n working-directory: ${dir}` : "";
|
|
177
|
+
const out = dir ? `${dir}/${FRONTEND_OUT}` : FRONTEND_OUT;
|
|
178
|
+
const paths = dir
|
|
179
|
+
? ` - "${dir}/**"\n - "package.json"\n - "bun.lock"`
|
|
180
|
+
: ` - "pages/**"\n - "lib/**"\n - "src/**"\n - "public/**"\n - "vite.config.ts"\n - "void.json"\n - "package.json"\n - "bun.lock"`;
|
|
181
|
+
return `name: Publish the storefront
|
|
182
|
+
|
|
183
|
+
# Generated by \`vc init\` from voidcommerce.json.
|
|
184
|
+
#
|
|
185
|
+
# Builds the storefront and force-pushes the prerendered tree to
|
|
186
|
+
# \`${FRONTEND_BRANCH}\`. Point GitHub Pages (or Cloudflare Pages) at that branch
|
|
187
|
+
# and a push here is a live storefront. The worker deploys separately, from
|
|
188
|
+
# \`void-dist\`.
|
|
189
|
+
on:
|
|
190
|
+
push:
|
|
191
|
+
branches: [main, master]
|
|
192
|
+
paths:
|
|
193
|
+
${paths}
|
|
194
|
+
- ".github/workflows/frontend-static.yml"
|
|
195
|
+
workflow_dispatch:
|
|
196
|
+
|
|
197
|
+
# A later push wins: an older build must never overwrite a newer one.
|
|
198
|
+
concurrency:
|
|
199
|
+
group: ${FRONTEND_BRANCH}-\${{ github.repository }}
|
|
200
|
+
cancel-in-progress: true
|
|
201
|
+
|
|
202
|
+
# contents: write is what lets GITHUB_TOKEN force-push the branch.
|
|
203
|
+
permissions:
|
|
204
|
+
contents: write
|
|
205
|
+
|
|
206
|
+
env:
|
|
207
|
+
# Served from the root at the custom domain. Set a BASE_PATH repository
|
|
208
|
+
# variable to "/<repo>/" only while the site is still at <owner>.github.io/<repo>/.
|
|
209
|
+
BASE_PATH: \${{ vars.BASE_PATH || '/' }}
|
|
210
|
+
# The publish step force-pushes, which would otherwise wipe the CNAME file
|
|
211
|
+
# GitHub keeps in the branch.
|
|
212
|
+
CNAME: ${manifest.shop.domain}
|
|
213
|
+
|
|
214
|
+
jobs:
|
|
215
|
+
build:
|
|
216
|
+
runs-on: ubuntu-latest
|
|
217
|
+
steps:
|
|
218
|
+
- uses: actions/checkout@v6
|
|
219
|
+
- uses: oven-sh/setup-bun@v2
|
|
220
|
+
|
|
221
|
+
- run: bun install --frozen-lockfile
|
|
222
|
+
|
|
223
|
+
# Generates .void/* codegen without booting Vite.
|
|
224
|
+
- run: bunx void prepare${at}
|
|
225
|
+
|
|
226
|
+
# void.json sets output: "static", so this prerenders every page to HTML
|
|
227
|
+
# under ${FRONTEND_OUT} alongside the client bundle.
|
|
228
|
+
- run: bunx vp build${at}
|
|
229
|
+
|
|
230
|
+
- name: Rewrite absolute URLs onto the Pages base path${at}
|
|
231
|
+
run: |
|
|
232
|
+
set -euo pipefail
|
|
233
|
+
BASE="\${BASE_PATH%/}/"
|
|
234
|
+
if [ "$BASE" = "/" ]; then
|
|
235
|
+
echo "BASE_PATH is /, nothing to rewrite."
|
|
236
|
+
exit 0
|
|
237
|
+
fi
|
|
238
|
+
# Void emits root-absolute URLs (/assets/..., /_void/...) and has no
|
|
239
|
+
# Vite \`base\` support, so they are rewritten here for the subpath a
|
|
240
|
+
# project site is served from.
|
|
241
|
+
find ${FRONTEND_OUT} -type f \\( -name '*.html' -o -name '*.js' -o -name '*.css' \\) -print0 \\
|
|
242
|
+
| xargs -0 -r sed -i \\
|
|
243
|
+
-e "s|\\"/assets/|\\"\${BASE}assets/|g" \\
|
|
244
|
+
-e "s|'/assets/|'\${BASE}assets/|g" \\
|
|
245
|
+
-e "s|\\"/_void/|\\"\${BASE}_void/|g" \\
|
|
246
|
+
-e "s|'/_void/|'\${BASE}_void/|g"
|
|
247
|
+
echo "Rewrote absolute URLs onto \${BASE}"
|
|
248
|
+
|
|
249
|
+
- name: Prepare the published tree${at}
|
|
250
|
+
run: |
|
|
251
|
+
set -euo pipefail
|
|
252
|
+
# Without .nojekyll, Pages runs Jekyll and drops _void/ (and every
|
|
253
|
+
# other underscore-prefixed path) from the served site.
|
|
254
|
+
touch ${FRONTEND_OUT}/.nojekyll
|
|
255
|
+
# Unknown paths fall back to the prerendered entry page so the client
|
|
256
|
+
# router can take over instead of showing GitHub's 404.
|
|
257
|
+
[ -f ${FRONTEND_OUT}/index.html ] && cp ${FRONTEND_OUT}/index.html ${FRONTEND_OUT}/404.html
|
|
258
|
+
echo "\${CNAME}" > ${FRONTEND_OUT}/CNAME
|
|
259
|
+
ls -la ${FRONTEND_OUT}
|
|
260
|
+
|
|
261
|
+
- name: Publish to ${FRONTEND_BRANCH}
|
|
262
|
+
env:
|
|
263
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
264
|
+
run: |
|
|
265
|
+
set -euo pipefail
|
|
266
|
+
cd ${out}
|
|
267
|
+
# A fresh single-commit history each run: the branch is build output.
|
|
268
|
+
git init -q -b ${FRONTEND_BRANCH}
|
|
269
|
+
git config user.name "github-actions[bot]"
|
|
270
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
271
|
+
git add -A
|
|
272
|
+
git commit -q -m "Storefront \${GITHUB_SHA}"
|
|
273
|
+
git push -f "https://x-access-token:\${GITHUB_TOKEN}@github.com/\${{ github.repository }}.git" ${FRONTEND_BRANCH}
|
|
274
|
+
`;
|
|
275
|
+
}
|