@getvitops/create 0.8.0-alpha.0 → 0.8.2

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.
@@ -194,6 +194,59 @@
194
194
  so.
195
195
  </span>
196
196
  </label>
197
+ <label class="custom-check">
198
+ <input type="checkbox" data-sop="hosting.content-in-prod" />
199
+ <span class="custom-check__box"
200
+ ><span class="custom-check__indicator custom-check__indicator--check"></span
201
+ ></span>
202
+ <span>
203
+ <strong>Know where content is edited (EmDash template).</strong> Editors work on the
204
+ <em>production</em> site — drafts, "Publish changes" and preview links are the
205
+ staging step. The dev site holds a disposable copy of production's database; it is
206
+ refreshed from prod and never promoted back. Code, pages and
207
+ <code>site.json</code> still go dev → main.
208
+ </span>
209
+ </label>
210
+ <label class="custom-check">
211
+ <input type="checkbox" data-sop="hosting.refresh-dev" />
212
+ <span class="custom-check__box"
213
+ ><span class="custom-check__indicator custom-check__indicator--check"></span
214
+ ></span>
215
+ <span>
216
+ EmDash template: run <strong>Refresh dev from prod</strong> (Actions tab) once the
217
+ prod site has content, and again before rehearsing a schema change. It runs weekly
218
+ on its own. To copy media as well, set <code>R2_ACCESS_KEY_ID</code> and
219
+ <code>R2_SECRET_ACCESS_KEY</code> (an R2 API token's S3 credentials) as repo
220
+ secrets; without them only the database is refreshed.
221
+ </span>
222
+ </label>
223
+ <label class="custom-check">
224
+ <input type="checkbox" data-sop="hosting.magic-link" />
225
+ <span class="custom-check__box"
226
+ ><span class="custom-check__indicator custom-check__indicator--check"></span
227
+ ></span>
228
+ <span>
229
+ EmDash template: onboard the sender domain for sign-in email —
230
+ <code>npx vitops email setup send.&lt;your-domain&gt;</code>, then put that address
231
+ in <code>cloudflareEmail({ from })</code> in <code>astro.config.mjs</code> (it ships
232
+ a placeholder). Editors sign in with a magic link ("Sign in with email" on the login
233
+ page) because a passkey only works at the origin it was registered on, so a
234
+ refreshed dev site has no other way in. Check <strong>Settings → Email</strong> and
235
+ send the test email on both prod and dev.
236
+ </span>
237
+ </label>
238
+ <label class="custom-check">
239
+ <input type="checkbox" data-sop="hosting.preview-secret" />
240
+ <span class="custom-check__box"
241
+ ><span class="custom-check__indicator custom-check__indicator--check"></span
242
+ ></span>
243
+ <span>
244
+ EmDash template: set a different <code>EMDASH_PREVIEW_SECRET</code> on the dev
245
+ worker (<code>wrangler secret put EMDASH_PREVIEW_SECRET</code>). The secret lives in
246
+ the database, so a refresh copies prod's — and prod preview links would then open
247
+ drafts on dev.
248
+ </span>
249
+ </label>
197
250
  </div>
198
251
  </details>
199
252
 
@@ -1,4 +1,5 @@
1
1
  import react from '@astrojs/react';
2
+ import { cloudflareEmail } from '@emdash-cms/cloudflare/plugins';
2
3
  import vitops from '@getvitops/astro';
3
4
  import { resolveOrganizationName } from '@getvitops/astro/routes';
4
5
  import { vitopsEmdash, vitopsHosting } from '@getvitops/emdash';
@@ -35,8 +36,9 @@ export default defineConfig({
35
36
  emdash({
36
37
  database,
37
38
  storage,
38
- // Vitops design-system blocks (image compare, copy snippet, banner,
39
- // disclosure, carousel) in the editor slash menu. Default 'integration'
39
+ // Vitops design-system blocks (link/button, image compare, copy snippet,
40
+ // banner, disclosure, accordion, hero, carousel, gallery, cards, marquee) in the
41
+ // editor slash menu. Narrow them with `vitopsEmdash({ blocks: { exclude } })`. Default 'integration'
40
42
  // script delivery: Layout.astro renders <Head /> from @getvitops/astro,
41
43
  // which emits the web-component runtime tags.
42
44
  //
@@ -46,7 +48,24 @@ export default defineConfig({
46
48
  // a VITOPS_GITHUB_TOKEN secret are set (see SETUP.html) — see
47
49
  // AGENTS.md's "EmDash plugin as the design-system editing surface".
48
50
  // Until then the live half still works; only the sync half is skipped.
49
- plugins: [vitopsEmdash({ designSystemEditor: { config } })],
51
+ //
52
+ // cloudflareEmail sends magic-link sign-in, invites and account recovery
53
+ // through Cloudflare Email Sending (the EMAIL binding in wrangler.jsonc).
54
+ // Magic link is how an editor gets into a site whose database was copied
55
+ // from another origin: a passkey only works at the origin it was
56
+ // registered on, so a dev database refreshed from prod
57
+ // (scripts/refresh-dev.mjs) would otherwise lock everyone out of dev. The
58
+ // cloned users already exist, so the same email signs in on both origins.
59
+ // `from` must be on a domain onboarded with Cloudflare Email Sending —
60
+ // `npx vitops email setup send.<your-domain>` — and is usually the
61
+ // subdomain, not the apex (see AGENTS.md's "Sender-domain onboarding").
62
+ // Without it, magic link answers "Email is not configured".
63
+ plugins: [
64
+ vitopsEmdash({ designSystemEditor: { config } }),
65
+ cloudflareEmail({
66
+ from: { email: 'cms@send.my-emdash-site.example.com', name: organizationName },
67
+ }),
68
+ ],
50
69
  // 1.0's runtime migration mode defaults to 'auto' (apply pending core
51
70
  // migrations on first request) — fine for local dev, but a deploy
52
71
  // should have already migrated before traffic arrives. 'check' 503s a
@@ -20,22 +20,23 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@astrojs/cloudflare": "^14.0.0",
23
- "@astrojs/react": "^6.0.0",
24
- "@emdash-cms/cloudflare": "^1.0.1",
23
+ "@astrojs/react": "^7.1.2",
24
+ "@emdash-cms/cloudflare": "^1.4.0",
25
25
  "@getvitops/astro": "latest",
26
26
  "@getvitops/core": "latest",
27
27
  "@getvitops/emdash": "latest",
28
28
  "@tailwindcss/vite": "^4.3.3",
29
- "astro": "^7.1.2",
30
- "emdash": "^1.0.1",
31
- "react": "^19.2.4",
32
- "react-dom": "^19.2.4",
29
+ "astro": "^7.4.4",
30
+ "emdash": "^1.4.0",
31
+ "react": "^19.3.1",
32
+ "react-dom": "^19.3.1",
33
33
  "tailwindcss": "^4.3.3"
34
34
  },
35
35
  "devDependencies": {
36
- "@astrojs/check": "^0.9.7",
36
+ "@astrojs/check": "^0.9.12",
37
37
  "@cloudflare/workers-types": "^4.20260305.1",
38
38
  "@getvitops/cli": "latest",
39
+ "@iconify-json/fa7-solid": "^1.2.5",
39
40
  "typescript": "^6.0.3",
40
41
  "vite": "latest",
41
42
  "vite-plus": "latest",
@@ -44,11 +45,11 @@
44
45
  "devEngines": {
45
46
  "runtime": {
46
47
  "name": "node",
47
- "version": "24.18.0",
48
+ "version": "24",
48
49
  "onFail": "download"
49
50
  }
50
51
  },
51
- "packageManager": "pnpm@11.17.0",
52
+ "packageManager": "pnpm@12.16.1",
52
53
  "emdash": {
53
54
  "seed": "seed/seed.json"
54
55
  }
@@ -0,0 +1,183 @@
1
+ ---
2
+ lockfileVersion: '9.0'
3
+
4
+ importers:
5
+
6
+ .:
7
+ configDependencies: {}
8
+ packageManagerDependencies:
9
+ '@pnpm/exe':
10
+ specifier: 12.16.1
11
+ version: 12.16.1
12
+ pnpm:
13
+ specifier: 12.16.1
14
+ version: 12.16.1
15
+
16
+ packages:
17
+
18
+ '@pnpm/exe.android-arm64@12.16.1':
19
+ resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==}
20
+ cpu: [arm64]
21
+ os: [android]
22
+
23
+ '@pnpm/exe.android-x64@12.16.1':
24
+ resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==}
25
+ cpu: [x64]
26
+ os: [android]
27
+
28
+ '@pnpm/exe.darwin-arm64@12.16.1':
29
+ resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==}
30
+ cpu: [arm64]
31
+ os: [darwin]
32
+
33
+ '@pnpm/exe.darwin-x64@12.16.1':
34
+ resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==}
35
+ cpu: [x64]
36
+ os: [darwin]
37
+
38
+ '@pnpm/exe.freebsd-x64@12.16.1':
39
+ resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==}
40
+ cpu: [x64]
41
+ os: [freebsd]
42
+
43
+ '@pnpm/exe.linux-arm64-musl@12.16.1':
44
+ resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==}
45
+ cpu: [arm64]
46
+ os: [linux]
47
+ libc: [musl]
48
+
49
+ '@pnpm/exe.linux-arm64@12.16.1':
50
+ resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==}
51
+ cpu: [arm64]
52
+ os: [linux]
53
+ libc: [glibc]
54
+
55
+ '@pnpm/exe.linux-ppc64@12.16.1':
56
+ resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==}
57
+ cpu: [ppc64]
58
+ os: [linux]
59
+ libc: [glibc]
60
+
61
+ '@pnpm/exe.linux-riscv64@12.16.1':
62
+ resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==}
63
+ cpu: [riscv64]
64
+ os: [linux]
65
+ libc: [glibc]
66
+
67
+ '@pnpm/exe.linux-s390x@12.16.1':
68
+ resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==}
69
+ cpu: [s390x]
70
+ os: [linux]
71
+ libc: [glibc]
72
+
73
+ '@pnpm/exe.linux-x64-musl@12.16.1':
74
+ resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==}
75
+ cpu: [x64]
76
+ os: [linux]
77
+ libc: [musl]
78
+
79
+ '@pnpm/exe.linux-x64@12.16.1':
80
+ resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==}
81
+ cpu: [x64]
82
+ os: [linux]
83
+ libc: [glibc]
84
+
85
+ '@pnpm/exe.win32-arm64@12.16.1':
86
+ resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==}
87
+ cpu: [arm64]
88
+ os: [win32]
89
+
90
+ '@pnpm/exe.win32-x64@12.16.1':
91
+ resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==}
92
+ cpu: [x64]
93
+ os: [win32]
94
+
95
+ '@pnpm/exe@12.16.1':
96
+ resolution: {integrity: sha512-Opsh2R7IuFY3ekd0C282Tmzgr5hiSlKMOv05/3dJXUrrp5NbmjC1Cm5PCFsa8xsiXY7z4oSkI4Qof1LjTkgAJg==}
97
+ engines: {node: '>=18'}
98
+ hasBin: true
99
+
100
+ pnpm@12.16.1:
101
+ resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==}
102
+ engines: {node: '>=18'}
103
+ hasBin: true
104
+
105
+ snapshots:
106
+
107
+ '@pnpm/exe.android-arm64@12.16.1':
108
+ optional: true
109
+
110
+ '@pnpm/exe.android-x64@12.16.1':
111
+ optional: true
112
+
113
+ '@pnpm/exe.darwin-arm64@12.16.1':
114
+ optional: true
115
+
116
+ '@pnpm/exe.darwin-x64@12.16.1':
117
+ optional: true
118
+
119
+ '@pnpm/exe.freebsd-x64@12.16.1':
120
+ optional: true
121
+
122
+ '@pnpm/exe.linux-arm64-musl@12.16.1':
123
+ optional: true
124
+
125
+ '@pnpm/exe.linux-arm64@12.16.1':
126
+ optional: true
127
+
128
+ '@pnpm/exe.linux-ppc64@12.16.1':
129
+ optional: true
130
+
131
+ '@pnpm/exe.linux-riscv64@12.16.1':
132
+ optional: true
133
+
134
+ '@pnpm/exe.linux-s390x@12.16.1':
135
+ optional: true
136
+
137
+ '@pnpm/exe.linux-x64-musl@12.16.1':
138
+ optional: true
139
+
140
+ '@pnpm/exe.linux-x64@12.16.1':
141
+ optional: true
142
+
143
+ '@pnpm/exe.win32-arm64@12.16.1':
144
+ optional: true
145
+
146
+ '@pnpm/exe.win32-x64@12.16.1':
147
+ optional: true
148
+
149
+ '@pnpm/exe@12.16.1':
150
+ optionalDependencies:
151
+ '@pnpm/exe.android-arm64': 12.16.1
152
+ '@pnpm/exe.android-x64': 12.16.1
153
+ '@pnpm/exe.darwin-arm64': 12.16.1
154
+ '@pnpm/exe.darwin-x64': 12.16.1
155
+ '@pnpm/exe.freebsd-x64': 12.16.1
156
+ '@pnpm/exe.linux-arm64': 12.16.1
157
+ '@pnpm/exe.linux-arm64-musl': 12.16.1
158
+ '@pnpm/exe.linux-ppc64': 12.16.1
159
+ '@pnpm/exe.linux-riscv64': 12.16.1
160
+ '@pnpm/exe.linux-s390x': 12.16.1
161
+ '@pnpm/exe.linux-x64': 12.16.1
162
+ '@pnpm/exe.linux-x64-musl': 12.16.1
163
+ '@pnpm/exe.win32-arm64': 12.16.1
164
+ '@pnpm/exe.win32-x64': 12.16.1
165
+
166
+ pnpm@12.16.1:
167
+ optionalDependencies:
168
+ '@pnpm/exe.android-arm64': 12.16.1
169
+ '@pnpm/exe.android-x64': 12.16.1
170
+ '@pnpm/exe.darwin-arm64': 12.16.1
171
+ '@pnpm/exe.darwin-x64': 12.16.1
172
+ '@pnpm/exe.freebsd-x64': 12.16.1
173
+ '@pnpm/exe.linux-arm64': 12.16.1
174
+ '@pnpm/exe.linux-arm64-musl': 12.16.1
175
+ '@pnpm/exe.linux-ppc64': 12.16.1
176
+ '@pnpm/exe.linux-riscv64': 12.16.1
177
+ '@pnpm/exe.linux-s390x': 12.16.1
178
+ '@pnpm/exe.linux-x64': 12.16.1
179
+ '@pnpm/exe.linux-x64-musl': 12.16.1
180
+ '@pnpm/exe.win32-arm64': 12.16.1
181
+ '@pnpm/exe.win32-x64': 12.16.1
182
+
183
+ ---
@@ -8,7 +8,7 @@ allowBuilds:
8
8
 
9
9
  # Supply-chain hardening: cooldown on brand-new releases (toolchain exempt)
10
10
  # and no non-registry transitive sources.
11
- minimumReleaseAge: 1440
11
+ minimumReleaseAge: 4320
12
12
  minimumReleaseAgeExclude:
13
13
  - '@getvitops/*'
14
14
  - emdash
@@ -0,0 +1,10 @@
1
+ # Cloudflare Workers static assets apply this file to every asset response.
2
+ #
3
+ # A baseline that cannot break a page. No Content-Security-Policy on purpose: <Head /> emits
4
+ # inline scripts (the consent stub, the colour-scheme bootstrap), so a strict policy needs
5
+ # per-site nonces or hashes — add one deliberately once you know which third-party tags you load.
6
+ /*
7
+ X-Content-Type-Options: nosniff
8
+ Referrer-Policy: strict-origin-when-cross-origin
9
+ Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
10
+ X-Frame-Options: SAMEORIGIN
@@ -66,6 +66,12 @@ cfg.r2_buckets = (cfg.r2_buckets ?? []).map((r) =>
66
66
  r.binding === 'MEDIA' ? { ...r, bucket_name: `${r.bucket_name}-dev` } : r,
67
67
  );
68
68
 
69
+ // No cron on dev. Dev's database is a copy of prod (scripts/refresh-dev.mjs),
70
+ // so it carries prod's scheduled publishes and plugin jobs; leaving the trigger
71
+ // on would run them a second time against the copy. The cost: scheduled
72
+ // publishing can't be exercised on dev.
73
+ delete cfg.triggers;
74
+
69
75
  // Flat config — no nested environments.
70
76
  delete cfg.env;
71
77
 
@@ -0,0 +1,182 @@
1
+ // Refreshes the DEV D1 database (and, optionally, the dev media bucket) from
2
+ // PRODUCTION. One direction only: prod is the source of truth for content, dev
3
+ // is a disposable copy used to rehearse code and schema changes against real
4
+ // data (EmDash's own "rehearse changes on a preview environment" pattern).
5
+ //
6
+ // node scripts/refresh-dev.mjs # wipe dev D1, import prod's export
7
+ // node scripts/refresh-dev.mjs --dry # run every check, change nothing
8
+ //
9
+ // Names come from wrangler.jsonc, the same way scripts/build-dev-wrangler.mjs
10
+ // derives them, so the two can't disagree about which resources are "dev":
11
+ //
12
+ // D1 <database_name> → <database_name>-dev (id from $DEV_D1_ID)
13
+ // R2 <bucket_name> → <bucket_name>-dev
14
+ //
15
+ // SAFETY, in the order it is enforced:
16
+ // 1. $DEV_D1_ID must be set, and `wrangler d1 info <name>-dev` must report
17
+ // that same id. A name that resolves to a different database than the id
18
+ // we were told about means one of the two is stale.
19
+ // 2. The dev id must differ from prod's. This script DROPS every table in
20
+ // the target, so pointing it at prod would erase production — the exact
21
+ // thing the dev/prod split exists to prevent.
22
+ // Both checks run before the first write, and `--dry` stops after them.
23
+ //
24
+ // Not copied: anything outside D1/R2. Sessions live in the Astro session store
25
+ // (KV), not D1, so nothing is carried over there. The preview-link secret IS in
26
+ // D1 — set EMDASH_PREVIEW_SECRET on the dev worker so prod preview links don't
27
+ // verify on dev (see SETUP.html).
28
+ //
29
+ // Media is copied only when R2 S3 credentials are present, because wrangler has
30
+ // no bucket-to-bucket sync and rclone needs the S3 API:
31
+ // R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, CLOUDFLARE_ACCOUNT_ID
32
+ // Without them the database is refreshed and media is left alone, with a note —
33
+ // dev then shows broken images for anything uploaded since the last copy.
34
+ //
35
+ // KNOWN LIMIT: `wrangler d1 export` does not support virtual tables, so an FTS5
36
+ // search table breaks the export. EmDash rebuilds its search index on the first
37
+ // search call, so if an export ever fails on one, drop it from prod's schema
38
+ // dump rather than working around it here.
39
+
40
+ import { execFileSync } from 'node:child_process';
41
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
42
+ import { tmpdir } from 'node:os';
43
+ import { join } from 'node:path';
44
+ import { pathToFileURL } from 'node:url';
45
+
46
+ /**
47
+ * Pull the D1 database and R2 bucket names out of a wrangler.jsonc source.
48
+ * Regex rather than a JSONC parser: the template carries no parser, the file
49
+ * is ours, and these two keys are the only thing read. `//` comment lines are
50
+ * stripped first so a commented-out example block can't be picked up.
51
+ */
52
+ export function parseBindingNames(jsonc) {
53
+ const code = jsonc
54
+ .split('\n')
55
+ .filter((line) => !line.trim().startsWith('//'))
56
+ .join('\n');
57
+ const database = /"database_name"\s*:\s*"([^"]+)"/.exec(code)?.[1];
58
+ const bucket = /"bucket_name"\s*:\s*"([^"]+)"/.exec(code)?.[1];
59
+ return { database, bucket };
60
+ }
61
+
62
+ /**
63
+ * Throws unless it is safe to wipe `devId`'s database. Pure, so the one check
64
+ * that stands between this script and production is asserted in a test.
65
+ */
66
+ export function assertSafeTarget({ devName, devId, devReportedId, prodId }) {
67
+ if (!devId) throw new Error('DEV_D1_ID is not set.');
68
+ if (!devName.endsWith('-dev')) throw new Error(`Refusing: "${devName}" is not a -dev database.`);
69
+ if (devReportedId !== devId) {
70
+ throw new Error(
71
+ `DEV_D1_ID (${devId}) does not match "${devName}" (${devReportedId}). One of them is stale.`,
72
+ );
73
+ }
74
+ if (devId === prodId) throw new Error('Refusing: the dev database id is the production id.');
75
+ }
76
+
77
+ /**
78
+ * SQL that empties a database: views, then virtual tables, then ordinary
79
+ * tables. `rows` is `{ name, type, sql }` from sqlite_master. A virtual table
80
+ * owns shadow tables named `<virtual>_*`, which SQLite refuses to drop on their
81
+ * own and removes with the parent, so those are skipped.
82
+ */
83
+ export function dropStatements(rows) {
84
+ const virtual = rows.filter((r) => /^CREATE VIRTUAL TABLE/i.test(r.sql ?? ''));
85
+ const isShadow = (name) => virtual.some((v) => name.startsWith(`${v.name}_`));
86
+ const tables = rows.filter(
87
+ (r) => r.type === 'table' && !virtual.includes(r) && !isShadow(r.name),
88
+ );
89
+ const q = (name) => `"${name.replaceAll('"', '""')}"`;
90
+ return [
91
+ 'PRAGMA defer_foreign_keys = true;',
92
+ ...rows.filter((r) => r.type === 'view').map((r) => `DROP VIEW IF EXISTS ${q(r.name)};`),
93
+ ...virtual.map((r) => `DROP TABLE IF EXISTS ${q(r.name)};`),
94
+ ...tables.map((r) => `DROP TABLE IF EXISTS ${q(r.name)};`),
95
+ ];
96
+ }
97
+
98
+ const wrangler = (args, opts = {}) =>
99
+ execFileSync('pnpm', ['exec', 'wrangler', ...args], { encoding: 'utf8', ...opts });
100
+
101
+ const d1Id = (name) => JSON.parse(wrangler(['d1', 'info', name, '--json'])).uuid;
102
+
103
+ function main() {
104
+ const dry = process.argv.includes('--dry');
105
+ const wranglerConfig = 'wrangler.jsonc';
106
+ if (!existsSync(wranglerConfig))
107
+ throw new Error(`Run from the project root (no ${wranglerConfig}).`);
108
+
109
+ const { database, bucket } = parseBindingNames(readFileSync(wranglerConfig, 'utf8'));
110
+ if (!database) throw new Error(`No D1 database_name found in ${wranglerConfig}.`);
111
+ const devDatabase = `${database}-dev`;
112
+
113
+ assertSafeTarget({
114
+ devName: devDatabase,
115
+ devId: process.env.DEV_D1_ID,
116
+ devReportedId: d1Id(devDatabase),
117
+ prodId: d1Id(database),
118
+ });
119
+ console.log(`OK to refresh ${devDatabase} from ${database}.`);
120
+
121
+ const dir = mkdtempSync(join(tmpdir(), 'refresh-dev-'));
122
+ const dump = join(dir, 'prod.sql');
123
+ const wipe = join(dir, 'wipe.sql');
124
+
125
+ const master = JSON.parse(
126
+ wrangler([
127
+ 'd1',
128
+ 'execute',
129
+ devDatabase,
130
+ '--remote',
131
+ '--json',
132
+ '--command',
133
+ "SELECT name, type, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_cf\\_%' ESCAPE '\\' AND type IN ('table','view')",
134
+ ]),
135
+ )[0].results;
136
+ writeFileSync(wipe, `${dropStatements(master).join('\n')}\n`);
137
+
138
+ if (dry) {
139
+ console.log(`--dry: would drop ${master.length} objects from ${devDatabase} and import prod.`);
140
+ console.log(
141
+ bucket ? `--dry: would copy ${bucket} → ${bucket}-dev.` : '--dry: no R2 bucket found.',
142
+ );
143
+ return;
144
+ }
145
+
146
+ wrangler(['d1', 'export', database, '--remote', '--output', dump], { stdio: 'inherit' });
147
+ wrangler(['d1', 'execute', devDatabase, '--remote', '--file', wipe], { stdio: 'inherit' });
148
+ wrangler(['d1', 'execute', devDatabase, '--remote', '--file', dump], { stdio: 'inherit' });
149
+ console.log(`Imported ${database} into ${devDatabase}.`);
150
+
151
+ const { R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, CLOUDFLARE_ACCOUNT_ID } = process.env;
152
+ if (!bucket) return;
153
+ if (!R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !CLOUDFLARE_ACCOUNT_ID) {
154
+ console.log(
155
+ `Skipped media: R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY not set, ${bucket}-dev left as is.`,
156
+ );
157
+ return;
158
+ }
159
+ // Credentials ride rclone's env-var config, not the command line, so the
160
+ // secret never appears in a process listing.
161
+ const env = {
162
+ ...process.env,
163
+ RCLONE_CONFIG_R2_TYPE: 's3',
164
+ RCLONE_CONFIG_R2_PROVIDER: 'Cloudflare',
165
+ RCLONE_CONFIG_R2_ENDPOINT: `https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
166
+ RCLONE_CONFIG_R2_ACCESS_KEY_ID: R2_ACCESS_KEY_ID,
167
+ RCLONE_CONFIG_R2_SECRET_ACCESS_KEY: R2_SECRET_ACCESS_KEY,
168
+ };
169
+ // `sync`, not `copy`: media deleted from prod should disappear from dev too.
170
+ execFileSync('rclone', ['sync', `R2:${bucket}`, `R2:${bucket}-dev`], { env, stdio: 'inherit' });
171
+ console.log(`Synced ${bucket} → ${bucket}-dev.`);
172
+ }
173
+
174
+ // Importable for tests without running; executed when invoked as a script.
175
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
176
+ try {
177
+ main();
178
+ } catch (err) {
179
+ console.error(`ERROR: ${err.message}`);
180
+ process.exit(1);
181
+ }
182
+ }