create-astroid 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,6 +38,9 @@ pnpm create astroid [directory] [options]
38
38
  --host <domain> Primary domain, e.g. example.com
39
39
  --commerce <provider> square | stripe | fourthwall — also adds the queue
40
40
  consumer, webhook receiver, and cron safety net
41
+ --square-locations <n> single | multi — with --commerce square; multi is one
42
+ Square Location per merchant, resolved from the request
43
+ host rather than SQUARE_LOCATION_ID
41
44
  --map Self-hosted PMTiles/MapLibre location map
42
45
  --pwa Installable PWA: a scoped service worker that never
43
46
  caches /api/* or the editor, plus a manifest
package/index.mjs CHANGED
@@ -57,6 +57,11 @@ const ARCHETYPES = Object.keys(ARCHETYPE_SECTIONS);
57
57
  // consumer, the webhook receiver, and the cron safety net.
58
58
  const COMMERCE_PROVIDERS = ["square", "stripe", "fourthwall"];
59
59
 
60
+ // Square's location model (`commerce.square.locations`). `multi` is the
61
+ // multi-merchant shape: the location comes from the request, not the
62
+ // environment, and the checkout route is scaffolded around resolving it.
63
+ const SQUARE_LOCATIONS = ["single", "multi"];
64
+
60
65
  // --- args ------------------------------------------------------------------
61
66
  function parseArgs(argv) {
62
67
  const flags = {};
@@ -189,8 +194,17 @@ function astroidConfigSource(config) {
189
194
  ` colors: { brand: ${JSON.stringify(config.theme.colors.brand)} },`,
190
195
  " },",
191
196
  ` sections: ${JSON.stringify(config.sections)},`,
197
+ // `square` must be emitted too: `astroid doctor` derives the required
198
+ // secrets from THIS file, so a multi-location project whose config lost the
199
+ // option would be told it is missing a SQUARE_LOCATION_ID it must not have.
192
200
  ...(config.commerce
193
- ? [` commerce: { provider: ${JSON.stringify(config.commerce.provider)} },`]
201
+ ? [
202
+ ` commerce: { provider: ${JSON.stringify(config.commerce.provider)}${
203
+ config.commerce.square?.locations
204
+ ? `, square: { locations: ${JSON.stringify(config.commerce.square.locations)} }`
205
+ : ""
206
+ } },`,
207
+ ]
194
208
  : []),
195
209
  // Must be emitted, for the same reason the portal is: `astroid generate`
196
210
  // rebuilds the middleware and CSP from THIS file, so a config that dropped
@@ -244,6 +258,9 @@ Options:
244
258
  --host <domain> Primary domain, e.g. example.com
245
259
  --commerce <provider> ${COMMERCE_PROVIDERS.join(" | ")}
246
260
  Also adds the queue consumer, webhook receiver, and cron
261
+ --square-locations <n> ${SQUARE_LOCATIONS.join(" | ")} (default: single; needs --commerce square)
262
+ multi: one Square Location per merchant, resolved from
263
+ the request host — no SQUARE_LOCATION_ID
247
264
  --map Add the self-hosted PMTiles/MapLibre location map
248
265
  --pwa Add an installable PWA: a scoped service worker that
249
266
  never caches /api/* or the editor, plus a manifest
@@ -317,6 +334,22 @@ async function main() {
317
334
  );
318
335
  process.exit(1);
319
336
  }
337
+ // Refused, not ignored, without Square: the checkout route is scaffolded ONCE,
338
+ // so a multi-merchant store that silently got the single-location route would
339
+ // ring every sale against one ambient SQUARE_LOCATION_ID — and look fine doing it.
340
+ const squareLocationsRaw = flags["square-locations"];
341
+ const squareLocations =
342
+ typeof squareLocationsRaw === "string" ? squareLocationsRaw.toLowerCase() : undefined;
343
+ if (squareLocationsRaw !== undefined && !SQUARE_LOCATIONS.includes(squareLocations)) {
344
+ process.stderr.write(
345
+ `create-astroid: --square-locations expects ${SQUARE_LOCATIONS.join(" | ")}\n`,
346
+ );
347
+ process.exit(1);
348
+ }
349
+ if (squareLocations && commerce !== "square") {
350
+ process.stderr.write("create-astroid: --square-locations needs --commerce square\n");
351
+ process.exit(1);
352
+ }
320
353
 
321
354
  if (existsSync(dir) && readdirSync(dir).length > 0) {
322
355
  process.stderr.write(`create-astroid: target directory is not empty: ${dir}\n`);
@@ -330,7 +363,14 @@ async function main() {
330
363
  ...(host ? { hosts: [host] } : {}),
331
364
  theme: { name, colors: { brand: color } },
332
365
  sections: ARCHETYPE_SECTIONS[archetype],
333
- ...(commerce ? { commerce: { provider: commerce } } : {}),
366
+ ...(commerce
367
+ ? {
368
+ commerce: {
369
+ provider: commerce,
370
+ ...(squareLocations ? { square: { locations: squareLocations } } : {}),
371
+ },
372
+ }
373
+ : {}),
334
374
  // Unprefixed `user`/`session` (customers — email + password), so the studio's
335
375
  // `louise_`-prefixed tables and the portal's never collide (mirrors the
336
376
  // reference storefront). `signUp: true` because a shop lets customers register.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-astroid",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Scaffold a new Astroid site — an editable, multi-editor Astro app on Cloudflare Workers — in one command.",
5
5
  "keywords": [
6
6
  "astro",
@@ -31,10 +31,10 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@better-auth/passkey": "^1.7.2",
34
- "@louise-toolkit/astro": "^0.1.1",
34
+ "@louise-toolkit/astro": "^0.2.1",
35
35
  "better-auth": "^1.7.2",
36
- "louise-toolkit": "^0.28.0",
37
- "astroidjs": "0.10.0"
36
+ "louise-toolkit": "^0.30.1",
37
+ "astroidjs": "0.12.0"
38
38
  },
39
39
  "engines": {
40
40
  "node": ">=26.0.0"
@@ -45,9 +45,9 @@ const siteKey = turnstileSiteKey(env);
45
45
  </label>
46
46
  {
47
47
  siteKey && (
48
- /* Turnstile injects a hidden `cf-turnstile-response` input here once
49
- it solves. The submit handler reads it out of the form. */
50
- <div class="cf-turnstile" data-sitekey={siteKey} data-size="flexible" />
48
+ /* Rendered explicitly by the script below, which reads the key
49
+ from here — a processed script can't take `define:vars`. */
50
+ <div id="captcha" data-sitekey={siteKey} />
51
51
  )
52
52
  }
53
53
  <button type="submit" class="btn btn-primary">Email me a sign-in link</button>
@@ -57,43 +57,45 @@ const siteKey = turnstileSiteKey(env);
57
57
  </div>
58
58
  </main>
59
59
 
60
- {
61
- /* Turnstile's widget script. `is:inline` because it's a remote src Astro must
62
- not process; the CSP already allows challenges.cloudflare.com for script,
63
- frame, and connect (astroidSecurity). Only loaded when there's a widget. */
64
- siteKey && (
65
- <script
66
- is:inline
67
- src="https://challenges.cloudflare.com/turnstile/v0/api.js"
68
- async
69
- defer
70
- />
71
- )
72
- }
73
-
74
- {/* Processed (NOT is:inline) so Astro's security.csp hashes it into script-src.
75
- Keep it dependency-free and static — an is:inline or define:vars script here
76
- would be unhashable and CSP-blocked. */}
60
+ {/* Processed (NOT is:inline) so Astro's security.csp hashes it into script-src,
61
+ and bundles the import. The widget's own script is loaded by
62
+ `renderTurnstile` from challenges.cloudflare.com, which the CSP already
63
+ allows for script, frame and connect (astroidSecurity). */}
77
64
  <script>
78
- const form = document.getElementById("magic");
65
+ import { renderTurnstile, type TurnstileWidget } from "louise-toolkit/forms/turnstile";
66
+
67
+ const form = document.getElementById("magic") as HTMLFormElement | null;
79
68
  const msg = document.getElementById("msg");
69
+ const captcha = document.getElementById("captcha");
70
+
71
+ // Absent when no real site key is set — then the server's captcha plugin is
72
+ // off too, and the request goes without a token.
73
+ let widget: TurnstileWidget | null = null;
74
+ const ready = captcha?.dataset.sitekey
75
+ ? renderTurnstile(captcha, { siteKey: captcha.dataset.sitekey, size: "flexible" })
76
+ .then((w) => {
77
+ widget = w;
78
+ })
79
+ .catch(() => {
80
+ // The server will refuse a request without a token, so say so now
81
+ // rather than after the owner has typed their email.
82
+ if (msg) msg.textContent = "The sign-in check didn't load — reload the page to try again.";
83
+ })
84
+ : Promise.resolve();
85
+
80
86
  form?.addEventListener("submit", async (e) => {
81
87
  e.preventDefault();
82
88
  const input = document.getElementById("email") as HTMLInputElement | null;
83
89
  const email = input?.value.trim();
84
90
  if (!email || !msg) return;
91
+ await ready;
92
+ if (captcha && !widget) return; // the load failure is already on screen
85
93
  msg.textContent = "Sending…";
86
- // Turnstile writes its token into a hidden input it injects into the form.
87
- // Better Auth's captcha plugin reads it from the `x-captcha-response`
88
- // HEADER (not the body), and rejects the request outright when it's
89
- // missing — so this header is what makes a captcha-enabled sign-in work.
90
- // Absent when no widget rendered, in which case the plugin isn't active
91
- // either and the header is simply not sent.
92
- const token = (form as HTMLFormElement).querySelector<HTMLInputElement>(
93
- 'input[name="cf-turnstile-response"]',
94
- )?.value;
94
+ // Better Auth's captcha plugin reads the token from the `x-captcha-response`
95
+ // HEADER (not the body), and rejects the request outright when it's missing.
96
+ const token = widget?.token();
95
97
  try {
96
- await fetch("/api/auth/sign-in/magic-link", {
98
+ const res = await fetch("/api/auth/sign-in/magic-link", {
97
99
  method: "POST",
98
100
  headers: {
99
101
  "content-type": "application/json",
@@ -101,9 +103,17 @@ const siteKey = turnstileSiteKey(env);
101
103
  },
102
104
  body: JSON.stringify({ email, callbackURL: "/?louise" }),
103
105
  });
104
- msg.textContent = "If that email is an editor, a sign-in link is on its way.";
106
+ // Enumeration-safe either way: a non-editor gets the same 200 as an
107
+ // editor, so a failure here is the captcha, a rate limit, or an outage.
108
+ msg.textContent = res.ok
109
+ ? "If that email is an editor, a sign-in link is on its way."
110
+ : "That didn't go through — please try again.";
105
111
  } catch {
106
112
  msg.textContent = "Something went wrong — please try again.";
113
+ } finally {
114
+ // A token is single-use, and this one is spent whatever happened. Without
115
+ // a fresh one, "send it again" posts the spent token and is refused.
116
+ widget?.reset();
107
117
  }
108
118
  });
109
119
  </script>