create-shopify-firebase-app 2.0.5 → 2.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/lib/index.js CHANGED
@@ -22,10 +22,15 @@ import { fileURLToPath } from "node:url";
22
22
  import { execSync, spawn } from "node:child_process";
23
23
  import prompts from "prompts";
24
24
  import { provisionFirebase } from "./provision.js";
25
+ import { preflight } from "./preflight.js";
25
26
 
26
27
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
28
  const TEMPLATES_DIR = path.join(__dirname, "..", "templates");
28
29
 
30
+ // Shopify rejects any app name containing "Shopify", and the app name is
31
+ // derived from the project name — so the default must derive to a valid one.
32
+ const DEFAULT_PROJECT_NAME = "my-store-app";
33
+
29
34
  // ─── ANSI helpers (no chalk dependency) ──────────────────────────────────
30
35
  const c = {
31
36
  reset: "\x1b[0m",
@@ -111,6 +116,76 @@ function parseTomlField(tomlPath, field) {
111
116
  }
112
117
  }
113
118
 
119
+ // ─── Shopify credential helpers ──────────────────────────────────────────
120
+
121
+ function matchValue(text, re) {
122
+ const m = String(text || "").match(re);
123
+ return m ? m[1].replace(/^["']|["']$/g, "") : null;
124
+ }
125
+
126
+ /**
127
+ * Read Client ID + Client Secret from the linked Shopify app.
128
+ * `shopify app env show` runs non-interactively and prints both, so the user
129
+ * never has to paste the secret by hand.
130
+ */
131
+ function readShopifyEnv(outputDir) {
132
+ let output = "";
133
+ try {
134
+ output = execSync(`shopify app env show --path "${outputDir}"`, {
135
+ encoding: "utf8",
136
+ cwd: outputDir,
137
+ timeout: 120000,
138
+ stdio: ["ignore", "pipe", "pipe"],
139
+ });
140
+ } catch (e) {
141
+ // The CLI can exit non-zero while still having printed the values
142
+ output = `${e.stdout || ""}\n${e.stderr || ""}`;
143
+ }
144
+ return {
145
+ apiKey: matchValue(output, /SHOPIFY_API_KEY=(\S+)/),
146
+ apiSecret: matchValue(output, /SHOPIFY_API_SECRET=(\S+)/),
147
+ };
148
+ }
149
+
150
+ /** Never echo a full secret — show the prefix and the length only. */
151
+ function maskSecret(secret) {
152
+ const s = String(secret || "");
153
+ const shown = s.slice(0, 8);
154
+ return `${shown}${"*".repeat(Math.max(0, s.length - shown.length))} (${s.length} chars)`;
155
+ }
156
+
157
+ // ─── App name helpers ────────────────────────────────────────────────────
158
+ // Shopify rejects app names containing "Shopify" (case-insensitive) with:
159
+ // App name cannot contain "Shopify"
160
+
161
+ /** Strip the reserved word and tidy whitespace. Never returns an empty name. */
162
+ function sanitizeAppName(name) {
163
+ const cleaned = String(name || "")
164
+ .replace(/shopify/gi, "")
165
+ .replace(/\s+/g, " ")
166
+ .replace(/^[\s\-_.]+|[\s\-_.]+$/g, "")
167
+ .trim();
168
+ return cleaned || "My Store App";
169
+ }
170
+
171
+ /** Turn "my-store-app" into a valid Shopify app name: "My Store App". */
172
+ function deriveAppName(projectName) {
173
+ const titled = String(projectName || "")
174
+ .replace(/[-_.]+/g, " ")
175
+ .replace(/\b\w/g, (ch) => ch.toUpperCase());
176
+ return sanitizeAppName(titled);
177
+ }
178
+
179
+ /** Inline validation for the app name prompt. */
180
+ function validateAppName(v) {
181
+ const name = String(v || "").trim();
182
+ if (!name) return "Required";
183
+ if (/shopify/i.test(name)) {
184
+ return 'Shopify rejects app names containing "Shopify" — try "My Store App"';
185
+ }
186
+ return true;
187
+ }
188
+
114
189
  function listFirebaseProjects() {
115
190
  try {
116
191
  const output = execSync("firebase projects:list --json", {
@@ -130,6 +205,51 @@ function listFirebaseProjects() {
130
205
  return [];
131
206
  }
132
207
 
208
+ /**
209
+ * Signed-in Google accounts, newest-first as firebase-tools reports them.
210
+ * Only emails are read — the raw payload also carries OAuth tokens.
211
+ */
212
+ function listFirebaseAccounts() {
213
+ try {
214
+ const output = execSync("firebase login:list --json", {
215
+ encoding: "utf8",
216
+ stdio: ["ignore", "pipe", "ignore"],
217
+ });
218
+ const data = JSON.parse(output);
219
+ if (data.status === "success" && Array.isArray(data.result)) {
220
+ return data.result
221
+ .map((a) => a?.user?.email)
222
+ .filter((email) => typeof email === "string" && email.length > 0);
223
+ }
224
+ } catch {}
225
+ return [];
226
+ }
227
+
228
+ async function useFirebaseAccount(email) {
229
+ try {
230
+ execSync(`firebase login:use ${email}`, {
231
+ encoding: "utf8",
232
+ stdio: ["ignore", "pipe", "pipe"],
233
+ });
234
+ return true;
235
+ } catch (e) {
236
+ // Picking the account that is already active exits non-zero and reports it
237
+ // on stdout, not stderr — that is a no-op, not a failure
238
+ const out = `${e?.stdout || ""}${e?.stderr || ""}`;
239
+ return /already using account/i.test(out);
240
+ }
241
+ }
242
+
243
+ /** Attach Firebase resources to a project that already exists in Google Cloud. */
244
+ async function addFirebaseToProject(projectId) {
245
+ try {
246
+ await exec(`firebase projects:addfirebase ${projectId}`);
247
+ return true;
248
+ } catch {
249
+ return false;
250
+ }
251
+ }
252
+
133
253
  async function createFirebaseProject(projectId, displayName) {
134
254
  try {
135
255
  await exec(`firebase projects:create "${projectId}" --display-name "${displayName}"`);
@@ -165,9 +285,22 @@ function parseArgs(argv) {
165
285
  }
166
286
 
167
287
  // ─── File helpers ────────────────────────────────────────────────────────
288
+
289
+ // Build artifacts and local state that must never leak from templates/ into a
290
+ // scaffolded project — a stray node_modules/ here would be copied verbatim.
291
+ const COPY_EXCLUDE = new Set([
292
+ "node_modules",
293
+ "lib",
294
+ "package-lock.json",
295
+ ".env",
296
+ ".firebase",
297
+ ".DS_Store",
298
+ ]);
299
+
168
300
  function copyDirSync(src, dest) {
169
301
  fs.mkdirSync(dest, { recursive: true });
170
302
  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
303
+ if (COPY_EXCLUDE.has(entry.name)) continue;
171
304
  const srcPath = path.join(src, entry.name);
172
305
  const destPath = path.join(dest, entry.name);
173
306
  if (entry.isDirectory()) copyDirSync(srcPath, destPath);
@@ -193,6 +326,88 @@ function substituteVars(filePath, vars) {
193
326
  fs.writeFileSync(filePath, content);
194
327
  }
195
328
 
329
+ // ─── Template rendering ──────────────────────────────────────────────────
330
+
331
+ /**
332
+ * Render shopify.app.toml from the pristine template.
333
+ *
334
+ * Always rendered from templates/ — never re-substituted in place. The
335
+ * placeholders are consumed by the first write, so a second pass over the
336
+ * generated file would silently leave the earlier (empty) values behind.
337
+ */
338
+ function renderAppToml(outputDir, config) {
339
+ const tomlPath = path.join(outputDir, "shopify.app.toml");
340
+
341
+ // Keep the identity fields the Shopify CLI writes when an app is linked
342
+ const preserved = [];
343
+ for (const field of ["handle", "organization_id"]) {
344
+ const val = parseTomlField(tomlPath, field);
345
+ if (val) preserved.push(`${field} = "${val}"`);
346
+ }
347
+
348
+ const vars = {
349
+ "{{APP_NAME}}": config.appName,
350
+ "{{API_KEY}}": config.apiKey || "",
351
+ "{{SCOPES}}": config.scopes,
352
+ "{{APP_URL}}": config.appUrl || "",
353
+ };
354
+
355
+ let content = fs.readFileSync(
356
+ path.join(TEMPLATES_DIR, "shopify.app.toml"),
357
+ "utf8",
358
+ );
359
+ for (const [key, val] of Object.entries(vars)) {
360
+ content = content.replaceAll(key, val);
361
+ }
362
+ if (preserved.length > 0) {
363
+ content = content.replace(/^client_id = .*$/m, (line) =>
364
+ [line, ...preserved].join("\n"),
365
+ );
366
+ }
367
+
368
+ fs.writeFileSync(tomlPath, content);
369
+ }
370
+
371
+ // The embedded pages App Bridge configures itself from
372
+ const WEB_PAGES = ["index.html", "products.html", "settings.html", "polaris.html"];
373
+
374
+ /**
375
+ * Re-render the HTML pages from the pristine templates.
376
+ * App Bridge reads the client ID from <meta name="shopify-api-key">, so these
377
+ * must be rewritten once the real credentials are known — rendering them from
378
+ * the template (rather than substituting in place) means the placeholders can
379
+ * never be consumed early.
380
+ */
381
+ function renderWebPages(outputDir, config) {
382
+ const vars = {
383
+ "{{APP_NAME}}": config.appName,
384
+ "{{API_KEY}}": config.apiKey || "",
385
+ "{{APP_URL}}": config.appUrl || "",
386
+ };
387
+ for (const page of WEB_PAGES) {
388
+ const src = path.join(TEMPLATES_DIR, "web", page);
389
+ if (!fs.existsSync(src)) continue;
390
+ let content = fs.readFileSync(src, "utf8");
391
+ for (const [key, val] of Object.entries(vars)) {
392
+ content = content.replaceAll(key, val);
393
+ }
394
+ fs.writeFileSync(path.join(outputDir, "web", page), content);
395
+ }
396
+ }
397
+
398
+ /** Write functions/.env — regenerated whenever credentials change. */
399
+ function writeFunctionsEnv(outputDir, config) {
400
+ const envContent = [
401
+ `SHOPIFY_API_KEY=${config.apiKey || ""}`,
402
+ `SHOPIFY_API_SECRET=${config.apiSecret || ""}`,
403
+ `SCOPES=${config.scopes}`,
404
+ `APP_URL=${config.appUrl || ""}`,
405
+ "",
406
+ ].join("\n");
407
+ fs.mkdirSync(path.join(outputDir, "functions"), { recursive: true });
408
+ fs.writeFileSync(path.join(outputDir, "functions", ".env"), envContent);
409
+ }
410
+
196
411
  // ─── Scaffold ────────────────────────────────────────────────────────────
197
412
  function scaffold(outputDir, config) {
198
413
  // 1. Copy shared files (firebase.json, firestore, gitignore, extensions)
@@ -208,11 +423,8 @@ function scaffold(outputDir, config) {
208
423
  path.join(outputDir, "functions"),
209
424
  );
210
425
 
211
- // 4. Copy shopify.app.toml
212
- fs.copyFileSync(
213
- path.join(TEMPLATES_DIR, "shopify.app.toml"),
214
- path.join(outputDir, "shopify.app.toml"),
215
- );
426
+ // 4. Render shopify.app.toml (re-rendered later once credentials are known)
427
+ renderAppToml(outputDir, config);
216
428
 
217
429
  // 5. Rename dotfiles (npm strips leading dots on publish)
218
430
  const renames = [
@@ -225,37 +437,12 @@ function scaffold(outputDir, config) {
225
437
  if (fs.existsSync(src)) fs.renameSync(src, dest);
226
438
  }
227
439
 
228
- // 6. Variable substitution
229
- const vars = {
230
- "{{APP_NAME}}": config.appName,
231
- "{{API_KEY}}": config.apiKey || "",
232
- "{{API_SECRET}}": config.apiSecret || "",
233
- "{{SCOPES}}": config.scopes,
234
- "{{PROJECT_ID}}": config.projectId || "",
235
- "{{APP_URL}}": config.appUrl || "",
236
- };
237
-
238
- const templateFiles = [
239
- "shopify.app.toml",
240
- "web/index.html",
241
- "web/products.html",
242
- "web/settings.html",
243
- "web/polaris.html",
244
- ];
245
-
246
- for (const relPath of templateFiles) {
247
- substituteVars(path.join(outputDir, relPath), vars);
248
- }
440
+ // 6. Render the frontend pages (re-rendered later once credentials are known —
441
+ // App Bridge reads the client ID from the shopify-api-key meta tag)
442
+ renderWebPages(outputDir, config);
249
443
 
250
444
  // 7. Generate functions/.env
251
- const envContent = [
252
- `SHOPIFY_API_KEY=${config.apiKey || ""}`,
253
- `SHOPIFY_API_SECRET=${config.apiSecret || ""}`,
254
- `SCOPES=${config.scopes}`,
255
- `APP_URL=${config.appUrl || ""}`,
256
- "",
257
- ].join("\n");
258
- fs.writeFileSync(path.join(outputDir, "functions", ".env"), envContent);
445
+ writeFunctionsEnv(outputDir, config);
259
446
 
260
447
  // 8. Generate .firebaserc
261
448
  if (config.projectId) {
@@ -279,31 +466,139 @@ function scaffold(outputDir, config) {
279
466
  }
280
467
 
281
468
  // ─── Update credentials after Shopify app creation ───────────────────────
469
+ // The wizard scaffolds before the Firebase project and the Shopify app exist,
470
+ // so shopify.app.toml is first written with empty values — and its
471
+ // placeholders are gone by then. Re-render from the pristine template rather
472
+ // than substituting placeholders that no longer exist. This write is
473
+ // authoritative: it is the final word on client_id and every URL.
282
474
  function updateCredentials(outputDir, config) {
283
- const vars = {
284
- "{{API_KEY}}": config.apiKey || "",
285
- "{{API_SECRET}}": config.apiSecret || "",
286
- "{{APP_URL}}": config.appUrl || "",
287
- };
475
+ renderAppToml(outputDir, config);
476
+ renderWebPages(outputDir, config);
477
+ writeFunctionsEnv(outputDir, config);
288
478
 
289
- // Update shopify.app.toml
290
- substituteVars(path.join(outputDir, "shopify.app.toml"), vars);
479
+ // Verify — an empty client_id or a relative URL means the generated app
480
+ // config is non-functional in the Partner Dashboard
481
+ const tomlPath = path.join(outputDir, "shopify.app.toml");
482
+ const clientId = parseTomlField(tomlPath, "client_id");
483
+ const appUrl = parseTomlField(tomlPath, "application_url");
484
+ const absolute = !!appUrl && appUrl.startsWith("https://");
291
485
 
292
- // Update functions/.env
293
- const envContent = [
294
- `SHOPIFY_API_KEY=${config.apiKey || ""}`,
295
- `SHOPIFY_API_SECRET=${config.apiSecret || ""}`,
296
- `SCOPES=${config.scopes}`,
297
- `APP_URL=${config.appUrl || ""}`,
298
- "",
299
- ].join("\n");
300
- fs.writeFileSync(path.join(outputDir, "functions", ".env"), envContent);
486
+ if (clientId && absolute) {
487
+ ok(`App config written — ${c.dim}${appUrl}${c.reset}`);
488
+ } else {
489
+ if (!clientId) warn("shopify.app.toml has no client_id — set it manually");
490
+ if (!absolute) warn("shopify.app.toml has no absolute application_url — set it manually");
491
+ }
301
492
  }
302
493
 
303
494
  // ═══════════════════════════════════════════════════════════════════════════
304
495
  // ─── MAIN FLOW ───────────────────────────────────────────────────────────
305
496
  // ═══════════════════════════════════════════════════════════════════════════
306
497
 
498
+ // ─── Shopify steps ───────────────────────────────────────────────────────
499
+
500
+ /** Sign in to Shopify. Uses the CLI's device-code flow — no token pasting. */
501
+ async function shopifyLogin() {
502
+ info("Signing in to Shopify...");
503
+ info(`${c.dim}A browser window opens — approve the code shown below.${c.reset}`);
504
+ console.log();
505
+ try {
506
+ await execInteractive("shopify auth login");
507
+ ok("Signed in to Shopify");
508
+ return true;
509
+ } catch {
510
+ warn("Shopify sign-in did not complete");
511
+ return false;
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Create (or link) the Shopify app and read its credentials back.
517
+ * Runs before Firebase so the Client ID and Secret exist by the time any
518
+ * config file is written — the ordering is what makes this hands-free.
519
+ */
520
+ async function linkShopifyApp(outputDir, config) {
521
+ try {
522
+ await execInteractive(`shopify app config link --path "${outputDir}"`, outputDir);
523
+ } catch {
524
+ // The CLI can exit non-zero even after the app was created successfully
525
+ warn("Shopify CLI exited with a warning");
526
+ }
527
+
528
+ // The CLI occasionally writes the TOML to the parent directory instead
529
+ for (const dir of [outputDir, path.dirname(outputDir)]) {
530
+ if (config.apiKey) break;
531
+ try {
532
+ for (const f of fs.readdirSync(dir).filter((n) => n.endsWith(".toml"))) {
533
+ const filePath = path.join(dir, f);
534
+ const clientId = parseTomlField(filePath, "client_id");
535
+ if (clientId && clientId !== "{{API_KEY}}" && clientId.length > 5) {
536
+ config.apiKey = clientId;
537
+ if (dir !== outputDir) {
538
+ try {
539
+ if (!fs.existsSync(path.join(outputDir, f))) {
540
+ fs.renameSync(filePath, path.join(outputDir, f));
541
+ } else {
542
+ fs.unlinkSync(filePath);
543
+ }
544
+ } catch {}
545
+ }
546
+ break;
547
+ }
548
+ }
549
+ } catch {}
550
+ }
551
+
552
+ // `shopify app env show` is non-interactive and returns BOTH values, so the
553
+ // Client Secret never has to be pasted by hand.
554
+ const env = readShopifyEnv(outputDir);
555
+ if (!config.apiKey && env.apiKey) config.apiKey = env.apiKey;
556
+ if (env.apiSecret) config.apiSecret = env.apiSecret;
557
+
558
+ if (config.apiKey) ok(`Client ID: ${c.cyan}${config.apiKey}${c.reset}`);
559
+ if (config.apiSecret) ok(`Client Secret: ${c.dim}${maskSecret(config.apiSecret)}${c.reset}`);
560
+ }
561
+
562
+ // ─── Deploy steps ────────────────────────────────────────────────────────
563
+
564
+ /** `--force` — a fresh project otherwise stalls on the artifact-policy prompt. */
565
+ async function deployFirebase(outputDir, projectId) {
566
+ info("Deploying hosting, functions and Firestore rules...");
567
+ info(`${c.dim}First deploy builds four containers — this takes a few minutes.${c.reset}`);
568
+ try {
569
+ await execInteractive(
570
+ `firebase deploy --project=${projectId} --force`,
571
+ outputDir,
572
+ );
573
+ ok("Deployed to Firebase");
574
+ return true;
575
+ } catch {
576
+ warn("Firebase deploy failed");
577
+ info(`Retry with: ${c.cyan}cd ${path.basename(outputDir)} && firebase deploy --force${c.reset}`);
578
+ return false;
579
+ }
580
+ }
581
+
582
+ /** Push the live URLs to Shopify so the app is installable. */
583
+ async function deployShopifyApp(outputDir) {
584
+ info("Registering the live URLs with Shopify...");
585
+ try {
586
+ await execInteractive(
587
+ `shopify app deploy --path "${outputDir}" --allow-updates ` +
588
+ `--message "Deployed to Firebase"`,
589
+ outputDir,
590
+ );
591
+ ok("Shopify app updated");
592
+ return true;
593
+ } catch {
594
+ warn("Could not update the Shopify app");
595
+ info("If that said you are not a member of the organization, the app was");
596
+ info(`created under a different Shopify account — run ${c.cyan}shopify auth login${c.reset}`);
597
+ info(`Retry with: ${c.cyan}cd ${path.basename(outputDir)} && shopify app deploy${c.reset}`);
598
+ return false;
599
+ }
600
+ }
601
+
307
602
  export async function run(argv) {
308
603
  const args = parseArgs(argv);
309
604
 
@@ -338,6 +633,26 @@ export async function run(argv) {
338
633
  console.log(` ${c.dim}Build Shopify apps for free — serverless, zero-framework${c.reset}`);
339
634
 
340
635
  // ═══════════════════════════════════════════════════════════════════
636
+ // Step 0 — check the toolchain before anything slow or stateful runs,
637
+ // so nothing fails six minutes in because a CLI was missing.
638
+ // (preflight prints its own section header)
639
+ const health = await preflight({ autoInstall: !!args.yes });
640
+ if (!health.ok) {
641
+ console.log();
642
+ fail("Cannot continue until the problems above are resolved.");
643
+ console.log();
644
+ return;
645
+ }
646
+
647
+ // ═══════════════════════════════════════════════════════════════════
648
+ // Step 1 — Shopify sign-in comes first: every later step needs it.
649
+ if (!args["skip-shopify"]) {
650
+ section("Sign In to Shopify");
651
+ await shopifyLogin();
652
+ }
653
+
654
+ // ═══════════════════════════════════════════════════════════════════
655
+ // Step 2 — which kind of app
341
656
  section("Choose Your Template");
342
657
 
343
658
  const { appTemplate } = await prompts({
@@ -358,21 +673,9 @@ export async function run(argv) {
358
673
  ],
359
674
  }, { onCancel });
360
675
 
361
- // ── Extension-only: delegate to Shopify CLI ───────────────────────
676
+ // ── Extension-only: hand straight over to the Shopify CLI ─────────
362
677
  if (appTemplate === "extension") {
363
678
  console.log();
364
- if (!hasCommand("shopify")) {
365
- info("Installing Shopify CLI...");
366
- try {
367
- await exec("npm install -g @shopify/cli");
368
- ok("Shopify CLI installed");
369
- } catch {
370
- fail("Could not install Shopify CLI");
371
- info("Install manually: npm i -g @shopify/cli");
372
- info("Then run: shopify app init");
373
- return;
374
- }
375
- }
376
679
  info("Launching Shopify CLI...");
377
680
  console.log();
378
681
  try {
@@ -404,7 +707,7 @@ export async function run(argv) {
404
707
  type: "text",
405
708
  name: "projectName",
406
709
  message: "Project directory name",
407
- initial: "my-shopify-app",
710
+ initial: DEFAULT_PROJECT_NAME,
408
711
  validate: (v) => {
409
712
  if (!v.trim()) return "Required";
410
713
  if (/[^a-zA-Z0-9._-]/.test(v)) return "Use only letters, numbers, dots, hyphens, underscores";
@@ -415,11 +718,13 @@ export async function run(argv) {
415
718
  }
416
719
 
417
720
  // ── App name ──────────────────────────────────────────────────────
721
+ // Derived from the project name, minus the reserved word "Shopify"
418
722
  const { appName } = await prompts({
419
723
  type: "text",
420
724
  name: "appName",
421
725
  message: "App name (shown in Shopify admin)",
422
- initial: projectName.replace(/[-_.]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
726
+ initial: deriveAppName(projectName),
727
+ validate: validateAppName,
423
728
  }, { onCancel });
424
729
 
425
730
  // ── API scopes ────────────────────────────────────────────────────
@@ -489,6 +794,46 @@ export async function run(argv) {
489
794
  info(`${c.dim}Frontend: 4 pages — Dashboard, Products, Settings, Components${c.reset}`);
490
795
 
491
796
  // ═══════════════════════════════════════════════════════════════════
797
+ // Step 4 — register the Shopify app FIRST. Creating it here is what mints
798
+ // the Client ID and Secret, so every file written later already has them.
799
+ section("Create Your Shopify App");
800
+
801
+ if (!args["skip-shopify"]) {
802
+ info("The Shopify CLI will ask which organization to use,");
803
+ info(`and let you ${c.bold}create a new app${c.reset} or pick an existing one.`);
804
+ console.log();
805
+ await linkShopifyApp(outputDir, config);
806
+ }
807
+
808
+ // ── Fallbacks — only ask for whatever could not be read ───────────
809
+ if (!config.apiKey) {
810
+ console.log();
811
+ info(`Find it at: ${c.cyan}https://partners.shopify.com${c.reset} → Apps → Client credentials`);
812
+ const res = await prompts({
813
+ type: "text",
814
+ name: "apiKey",
815
+ message: `Client ID ${c.dim}(API Key)${c.reset}`,
816
+ validate: (v) => (v.trim() ? true : "Required"),
817
+ }, { onCancel });
818
+ config.apiKey = res.apiKey;
819
+ }
820
+
821
+ if (!config.apiSecret) {
822
+ console.log();
823
+ info("The Client Secret could not be read automatically.");
824
+ info(`Find it at: ${c.cyan}https://partners.shopify.com${c.reset} → your app → Client credentials`);
825
+ console.log();
826
+ const { apiSecret } = await prompts({
827
+ type: "password",
828
+ name: "apiSecret",
829
+ message: "Client Secret (API Secret)",
830
+ validate: (v) => (v.trim() ? true : "Required"),
831
+ }, { onCancel });
832
+ config.apiSecret = apiSecret;
833
+ }
834
+
835
+ // ═══════════════════════════════════════════════════════════════════
836
+ // Step 5 — Firebase, now that the credentials already exist
492
837
  section("Firebase Setup");
493
838
 
494
839
  // ── Ensure Firebase CLI ───────────────────────────────────────────
@@ -505,34 +850,63 @@ export async function run(argv) {
505
850
 
506
851
  if (hasCommand("firebase")) {
507
852
  // ── Firebase login ──────────────────────────────────────────────
853
+ // Ask the account store directly. Inferring auth from an empty project
854
+ // list is wrong: a signed-in account with no projects yet looks identical
855
+ // to being signed out.
508
856
  info("Checking Firebase authentication...");
509
- const projects = listFirebaseProjects();
510
- if (projects.length > 0) {
511
- ok("Firebase authenticated");
512
- } else {
857
+ let accounts = listFirebaseAccounts();
858
+
859
+ if (accounts.length === 0) {
513
860
  info("Opening browser for Firebase login...");
514
861
  try {
515
862
  await execInteractive("firebase login");
516
- ok("Logged into Firebase");
517
863
  } catch {
518
864
  warn("Firebase login failed — run 'firebase login' manually later");
519
865
  }
866
+ accounts = listFirebaseAccounts();
867
+ }
868
+
869
+ if (accounts.length === 0) {
870
+ warn("Not signed in to Firebase");
871
+ } else if (accounts.length === 1) {
872
+ ok(`Firebase account: ${c.cyan}${accounts[0]}${c.reset}`);
873
+ } else {
874
+ // Several accounts are signed in — mirror `firebase login:use`
875
+ const { account } = await prompts({
876
+ type: "select",
877
+ name: "account",
878
+ message: "Which Google account should own this project?",
879
+ choices: accounts.map((email) => ({ title: email, value: email })),
880
+ }, { onCancel });
881
+
882
+ if (account && (await useFirebaseAccount(account))) {
883
+ ok(`Firebase account: ${c.cyan}${account}${c.reset}`);
884
+ } else if (account) {
885
+ warn(`Could not switch account — continuing as ${accounts[0]}`);
886
+ }
520
887
  }
521
888
 
522
889
  // ── Project selection ───────────────────────────────────────────
890
+ // Same shape as `firebase init`: create, pick an existing one, adopt a
891
+ // plain Google Cloud project, or type an ID.
523
892
  const freshProjects = listFirebaseProjects();
524
893
  const fbChoices = [
525
894
  { title: `${c.cyan}[create a new project]${c.reset}`, value: "__create__" },
526
895
  ];
527
896
 
528
- if (freshProjects.length > 0) {
529
- for (const p of freshProjects) {
530
- fbChoices.push({
531
- title: `${p.displayName} ${c.dim}(${p.projectId})${c.reset}`,
532
- value: p.projectId,
533
- });
534
- }
897
+ for (const p of freshProjects.sort((a, b) =>
898
+ a.displayName.localeCompare(b.displayName),
899
+ )) {
900
+ fbChoices.push({
901
+ title: `${p.displayName} ${c.dim}(${p.projectId})${c.reset}`,
902
+ value: p.projectId,
903
+ });
535
904
  }
905
+
906
+ fbChoices.push({
907
+ title: `${c.dim}[add Firebase to an existing Google Cloud project]${c.reset}`,
908
+ value: "__addfirebase__",
909
+ });
536
910
  fbChoices.push({ title: `${c.dim}[enter project ID manually]${c.reset}`, value: "__manual__" });
537
911
 
538
912
  const { firebaseChoice } = await prompts({
@@ -571,6 +945,22 @@ export async function run(argv) {
571
945
  }, { onCancel });
572
946
  config.projectId = manualId;
573
947
  }
948
+ } else if (firebaseChoice === "__addfirebase__") {
949
+ const { gcpId } = await prompts({
950
+ type: "text",
951
+ name: "gcpId",
952
+ message: "Google Cloud project ID",
953
+ validate: (v) => (v.trim() ? true : "Required"),
954
+ }, { onCancel });
955
+
956
+ info(`Adding Firebase to ${c.cyan}${gcpId}${c.reset}...`);
957
+ if (await addFirebaseToProject(gcpId)) {
958
+ ok(`Firebase enabled on ${c.cyan}${gcpId}${c.reset}`);
959
+ } else {
960
+ warn("Could not add Firebase automatically — continuing with this ID");
961
+ info(`Manual: firebase projects:addfirebase ${gcpId}`);
962
+ }
963
+ config.projectId = gcpId;
574
964
  } else if (firebaseChoice === "__manual__") {
575
965
  const { manualId } = await prompts({
576
966
  type: "text",
@@ -612,163 +1002,6 @@ export async function run(argv) {
612
1002
  fs.writeFileSync(path.join(outputDir, ".firebaserc"), firebaserc + "\n");
613
1003
  }
614
1004
 
615
- // ═══════════════════════════════════════════════════════════════════
616
- section("Shopify App Setup");
617
-
618
- // ── Ensure Shopify CLI ────────────────────────────────────────────
619
- if (!hasCommand("shopify")) {
620
- info("Shopify CLI not found — installing...");
621
- try {
622
- await exec("npm install -g @shopify/cli");
623
- ok("Shopify CLI installed");
624
- } catch {
625
- warn("Could not install Shopify CLI");
626
- }
627
- }
628
-
629
- if (hasCommand("shopify")) {
630
- const { shopifyAction } = await prompts({
631
- type: "select",
632
- name: "shopifyAction",
633
- message: "Shopify app setup",
634
- choices: [
635
- { title: "Create a new app + link it (recommended)", value: "create" },
636
- { title: "Link an existing app", value: "link" },
637
- { title: "Skip — I'll configure manually later", value: "skip" },
638
- ],
639
- }, { onCancel });
640
-
641
- if (shopifyAction !== "skip") {
642
- // ── Login ──────────────────────────────────────────────────
643
- console.log();
644
- info("Logging into Shopify...");
645
- info("A browser window will open — sign in to your Partner account.");
646
- console.log();
647
- try {
648
- await execInteractive("shopify auth login");
649
- ok("Logged into Shopify");
650
- } catch {
651
- warn("Shopify login failed — continuing with manual setup");
652
- }
653
-
654
- // ── Create / Link app via Shopify CLI ──────────────────────
655
- // Must `cd` into project dir — Shopify CLI uses process.cwd(),
656
- // not spawn's cwd option, for TOML generation.
657
- console.log();
658
- if (shopifyAction === "create") {
659
- info(`Creating a new Shopify app: ${c.cyan}${appName}${c.reset}`);
660
- info(`Select ${c.bold}"Create a new app"${c.reset} when prompted by the CLI.`);
661
- } else {
662
- info("Select your existing app from the list.");
663
- }
664
- console.log();
665
-
666
- // Use `cd` to ensure Shopify CLI writes files in the project directory
667
- const cdCmd = process.platform === "win32"
668
- ? `cd /d "${outputDir}" && shopify app config link`
669
- : `cd "${outputDir}" && shopify app config link`;
670
-
671
- try {
672
- await execInteractive(cdCmd);
673
- } catch {
674
- // Shopify CLI may exit non-zero even after creating/linking the app
675
- // (e.g. "directory doesn't have a package.json" warning)
676
- warn("Shopify CLI exited with a warning");
677
- }
678
-
679
- // ── Parse client_id from any TOML (runs regardless of exit code) ──
680
- // Check project dir first, then parent dir (Shopify CLI fallback location)
681
- const dirsToCheck = [outputDir, path.dirname(outputDir)];
682
- for (const dir of dirsToCheck) {
683
- if (config.apiKey) break;
684
- try {
685
- const tomlFiles = fs.readdirSync(dir).filter((f) => f.endsWith(".toml"));
686
- for (const f of tomlFiles) {
687
- const filePath = path.join(dir, f);
688
- const clientId = parseTomlField(filePath, "client_id");
689
- if (clientId && clientId !== "{{API_KEY}}" && clientId.length > 5) {
690
- config.apiKey = clientId;
691
- ok(`Client ID: ${c.cyan}${config.apiKey}${c.reset}`);
692
-
693
- // If TOML was in parent dir (Shopify CLI bug), move it to project dir
694
- if (dir !== outputDir) {
695
- const destPath = path.join(outputDir, f);
696
- try {
697
- // Don't overwrite our template — just read the client_id
698
- if (!fs.existsSync(destPath)) {
699
- fs.renameSync(filePath, destPath);
700
- } else {
701
- // Clean up the stray TOML from parent dir
702
- fs.unlinkSync(filePath);
703
- }
704
- info(`${c.dim}Picked up credentials from Shopify CLI${c.reset}`);
705
- } catch {}
706
- }
707
- break;
708
- }
709
- }
710
- } catch {}
711
- }
712
-
713
- if (!config.apiKey) {
714
- console.log();
715
- info("Could not read Client ID from Shopify CLI output.");
716
- const res = await prompts({
717
- type: "text",
718
- name: "apiKey",
719
- message: "Paste your Client ID (from Partner Dashboard → Apps)",
720
- validate: (v) => (v.trim() ? true : "Required"),
721
- }, { onCancel });
722
- config.apiKey = res.apiKey;
723
- }
724
- }
725
-
726
- // ── Get API Secret (never in TOML) ──────────────────────────────
727
- if (!config.apiKey) {
728
- console.log();
729
- info(`Find credentials at: ${c.cyan}https://partners.shopify.com${c.reset} → Apps → Client credentials`);
730
- const res = await prompts({
731
- type: "text",
732
- name: "apiKey",
733
- message: `Client ID ${c.dim}(API Key)${c.reset}`,
734
- validate: (v) => (v.trim() ? true : "Required"),
735
- }, { onCancel });
736
- config.apiKey = res.apiKey;
737
- }
738
-
739
- console.log();
740
- info("The API Secret is not stored in config files for security.");
741
- info(`Find it at: ${c.cyan}https://partners.shopify.com${c.reset} → your app → Client credentials`);
742
- console.log();
743
- const { apiSecret } = await prompts({
744
- type: "password",
745
- name: "apiSecret",
746
- message: "Client Secret (API Secret)",
747
- validate: (v) => (v.trim() ? true : "Required"),
748
- }, { onCancel });
749
- config.apiSecret = apiSecret;
750
-
751
- } else {
752
- // No Shopify CLI — full manual entry
753
- info(`Enter your Shopify app credentials (from ${c.cyan}partners.shopify.com${c.reset})`);
754
- const creds = await prompts([
755
- {
756
- type: "text",
757
- name: "apiKey",
758
- message: `Client ID ${c.dim}(API Key)${c.reset}`,
759
- validate: (v) => (v.trim() ? true : "Required"),
760
- },
761
- {
762
- type: "password",
763
- name: "apiSecret",
764
- message: "Client Secret (API Secret)",
765
- validate: (v) => (v.trim() ? true : "Required"),
766
- },
767
- ], { onCancel });
768
- config.apiKey = creds.apiKey;
769
- config.apiSecret = creds.apiSecret;
770
- }
771
-
772
1005
  // ── Write final credentials to files ──────────────────────────────
773
1006
  updateCredentials(outputDir, config);
774
1007
 
@@ -796,6 +1029,23 @@ export async function run(argv) {
796
1029
  }
797
1030
  }
798
1031
 
1032
+ // ═══════════════════════════════════════════════════════════════════
1033
+ // Steps 6 and 7 — put it live, then tell Shopify where "live" is.
1034
+ // Doing both here is the difference between a scaffold and a working app.
1035
+ if (config.projectId && !args["no-deploy"]) {
1036
+ section("Going Live");
1037
+
1038
+ const deployed = await deployFirebase(outputDir, config.projectId);
1039
+ config.deployed = deployed;
1040
+
1041
+ if (deployed && config.apiKey) {
1042
+ console.log();
1043
+ await deployShopifyApp(outputDir);
1044
+ } else if (!deployed) {
1045
+ info(`${c.dim}Skipping the Shopify update until the deploy succeeds.${c.reset}`);
1046
+ }
1047
+ }
1048
+
799
1049
  // ═══════════════════════════════════════════════════════════════════
800
1050
  section("Finishing Up");
801
1051
 
@@ -820,9 +1070,11 @@ export async function run(argv) {
820
1070
 
821
1071
  // ─── CI / non-interactive mode ──────────────────────────────────────────
822
1072
  async function runCI(args) {
1073
+ const projectName = args.projectName || DEFAULT_PROJECT_NAME;
823
1074
  const config = {
824
- projectName: args.projectName || "my-shopify-app",
825
- appName: args["app-name"] || args.projectName || "My Shopify App",
1075
+ projectName,
1076
+ // Shopify rejects app names containing "Shopify" — sanitise before use
1077
+ appName: sanitizeAppName(args["app-name"] || deriveAppName(projectName)),
826
1078
  language: args.language === "javascript" ? "javascript" : "typescript",
827
1079
  apiKey: args["api-key"],
828
1080
  apiSecret: args["api-secret"],
@@ -862,6 +1114,27 @@ async function runCI(args) {
862
1114
  }
863
1115
  }
864
1116
 
1117
+ // ── Firebase project — provisioning never prompts in CI, so a missing
1118
+ // project has to be created here or 'firebase use' fails ────────────
1119
+ if (hasCommand("firebase")) {
1120
+ const existing = listFirebaseProjects();
1121
+ const projectExists = existing.some((p) => p.projectId === config.projectId);
1122
+
1123
+ if (!projectExists && args["create-project"]) {
1124
+ info(`Creating Firebase project: ${c.cyan}${config.projectId}${c.reset}...`);
1125
+ const created = await createFirebaseProject(config.projectId, config.appName);
1126
+ if (created) {
1127
+ ok(`Project created: ${c.cyan}${config.projectId}${c.reset}`);
1128
+ } else {
1129
+ fail(`Could not create Firebase project "${config.projectId}"`);
1130
+ info(`Manual: firebase projects:create "${config.projectId}"`);
1131
+ }
1132
+ } else if (!projectExists) {
1133
+ warn(`Firebase project "${config.projectId}" not found in your account`);
1134
+ info("Pass --create-project to create it automatically");
1135
+ }
1136
+ }
1137
+
865
1138
  if (hasCommand("firebase") && !args["skip-provision"]) {
866
1139
  info("Setting up Firebase...");
867
1140
  await provisionFirebase(config, {
@@ -936,7 +1209,7 @@ async function distributeFlow() {
936
1209
 
937
1210
  console.log(` ${c.bold}Before submitting to the App Store:${c.reset}`);
938
1211
  console.log();
939
- console.log(` ${c.cyan}1.${c.reset} Deploy your app: ${c.cyan}firebase deploy${c.reset}`);
1212
+ console.log(` ${c.cyan}1.${c.reset} Deploy your app: ${c.cyan}firebase deploy --force${c.reset}`);
940
1213
  console.log(` ${c.cyan}2.${c.reset} Test on a development store`);
941
1214
  console.log(` ${c.cyan}3.${c.reset} Add your privacy policy URL`);
942
1215
  console.log(` ${c.cyan}4.${c.reset} Add your app listing details (description, screenshots)`);
@@ -968,14 +1241,27 @@ function printSuccess(config) {
968
1241
  console.log(` ${c.green}✔${c.reset} App Bridge — navigation, toasts, modals, resource picker`);
969
1242
  console.log(` ${c.green}✔${c.reset} 4 Cloud Functions — auth, api, webhooks, proxy`);
970
1243
  console.log();
971
- console.log(` ${c.bold}Deploy:${c.reset}`);
972
- console.log();
973
- console.log(` ${c.cyan}cd ${config.projectName}${c.reset}`);
974
- console.log(` ${c.cyan}firebase deploy${c.reset}`);
975
- console.log();
976
- console.log(` ${c.bold}Install on your dev store:${c.reset}`);
1244
+ if (config.deployed) {
1245
+ console.log(` ${c.green}${c.bold}Your app is live.${c.reset} Install it on a dev store:`);
1246
+ console.log();
1247
+ console.log(` ${c.cyan}${config.appUrl}/auth?shop=YOUR-STORE.myshopify.com${c.reset}`);
1248
+ console.log();
1249
+ console.log(` ${c.dim}Replace YOUR-STORE with your development store's domain.${c.reset}`);
1250
+ } else {
1251
+ console.log(` ${c.bold}Deploy when ready:${c.reset}`);
1252
+ console.log();
1253
+ console.log(` ${c.cyan}cd ${config.projectName}${c.reset}`);
1254
+ // --force is required on a fresh project: a bare deploy exits 1 and leaves
1255
+ // Hosting unreleased when there is nothing to compare against
1256
+ console.log(` ${c.cyan}firebase deploy --force${c.reset}`);
1257
+ console.log(` ${c.cyan}shopify app deploy${c.reset}`);
1258
+ console.log();
1259
+ console.log(` ${c.bold}Then install on your dev store:${c.reset}`);
1260
+ console.log();
1261
+ console.log(` ${c.cyan}${config.appUrl}/auth?shop=YOUR-STORE.myshopify.com${c.reset}`);
1262
+ }
977
1263
  console.log();
978
- console.log(` ${c.cyan}${config.appUrl}/auth?shop=YOUR-STORE.myshopify.com${c.reset}`);
1264
+ console.log(` ${c.bold}Make changes:${c.reset} edit ${c.cyan}web/${c.reset} or ${c.cyan}functions/src/${c.reset}, then ${c.cyan}firebase deploy --force${c.reset}`);
979
1265
  console.log();
980
1266
  console.log(` ${c.bold}Go live:${c.reset}`);
981
1267
  console.log();
@@ -1006,6 +1292,9 @@ function printHelp() {
1006
1292
  --help, -h Show this help
1007
1293
  --version, -v Show version
1008
1294
  --distribute Open distribution dashboard for your app
1295
+ --yes Auto-install any missing CLI tools
1296
+ --skip-shopify Don't create/link a Shopify app
1297
+ --no-deploy Scaffold only — skip the two deploy steps
1009
1298
 
1010
1299
  ${c.bold}CI / non-interactive:${c.reset}
1011
1300
 
@@ -1015,6 +1304,7 @@ function printHelp() {
1015
1304
  --scopes=SCOPES API scopes (default: read_products)
1016
1305
  --language=LANG typescript or javascript (default: typescript)
1017
1306
  --app-name=NAME App name shown in Shopify admin
1307
+ --create-project Create the Firebase project if it doesn't exist
1018
1308
  --skip-provision Skip Firebase service provisioning
1019
1309
  --firestore-region=LOC Firestore region (e.g. us-central1)
1020
1310
 
@@ -1031,6 +1321,12 @@ function printHelp() {
1031
1321
  --api-key=abc123 --api-secret=secret \\
1032
1322
  --project-id=my-firebase-project
1033
1323
 
1324
+ ${c.dim}# CI — create the Firebase project too${c.reset}
1325
+ npx create-shopify-firebase-app my-app \\
1326
+ --api-key=abc123 --api-secret=secret \\
1327
+ --project-id=my-new-project --create-project \\
1328
+ --firestore-region=us-central1
1329
+
1034
1330
  ${c.dim}# Go live — open distribution page${c.reset}
1035
1331
  npx create-shopify-firebase-app --distribute
1036
1332