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/README.md +122 -74
- package/lib/index.js +540 -244
- package/lib/preflight.js +358 -0
- package/lib/provision.js +187 -5
- package/package.json +1 -1
- package/templates/js/functions/package.json +6 -6
- package/templates/js/functions/src/admin-api.js +1 -1
- package/templates/js/functions/src/auth.js +45 -13
- package/templates/js/functions/src/firebase.js +6 -4
- package/templates/js/functions/src/index.js +14 -0
- package/templates/shared/extensions/theme-block/blocks/app-block.liquid +1 -1
- package/templates/shared/extensions/theme-block/shopify.extension.toml +1 -1
- package/templates/shared/firebase.json +1 -1
- package/templates/shared/firestore.rules +10 -2
- package/templates/shopify.app.toml +1 -1
- package/templates/ts/functions/package.json +10 -10
- package/templates/ts/functions/src/admin-api.ts +5 -2
- package/templates/ts/functions/src/auth.ts +69 -15
- package/templates/ts/functions/src/firebase.ts +8 -4
- package/templates/ts/functions/tsconfig.json +2 -1
- package/templates/web/css/app.css +78 -1138
- package/templates/web/index.html +79 -68
- package/templates/web/js/app.js +60 -33
- package/templates/web/js/pages/home.js +33 -22
- package/templates/web/js/pages/polaris-demo.js +25 -83
- package/templates/web/js/pages/products.js +100 -114
- package/templates/web/js/pages/settings.js +68 -166
- package/templates/web/polaris.html +1221 -950
- package/templates/web/products.html +35 -40
- package/templates/web/settings.html +69 -17
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.
|
|
212
|
-
|
|
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.
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
"{{APP_URL}}": config.appUrl || "",
|
|
287
|
-
};
|
|
475
|
+
renderAppToml(outputDir, config);
|
|
476
|
+
renderWebPages(outputDir, config);
|
|
477
|
+
writeFunctionsEnv(outputDir, config);
|
|
288
478
|
|
|
289
|
-
//
|
|
290
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
|
825
|
-
|
|
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
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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(`
|
|
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
|
|