@salifbiaye/create-boutique 0.5.0 → 0.6.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/cli/dist/commands.js +98 -7
- package/cli/dist/create.js +56 -10
- package/cli/dist/generate.js +43 -35
- package/cli/dist/guide.js +2 -2
- package/cli/dist/index.js +20 -2
- package/cli/dist/project.js +42 -3
- package/package.json +1 -1
- package/templates/backend/src/admin/i18n/json/en.json +12 -2
- package/templates/backend/src/admin/i18n/json/fr.json +12 -2
- package/templates/backend/src/admin/routes/boutique-campaigns/campaign-drawer.tsx +27 -17
- package/templates/backend/src/admin/routes/boutique-campaigns/page.tsx +49 -6
- package/templates/backend/src/api/admin/active-boutique/route.ts +25 -0
- package/templates/backend/src/api/admin/boutique-campaigns/[salesChannelId]/route.ts +7 -1
- package/templates/backend/src/api/admin/boutique-campaigns/route.ts +6 -0
- package/templates/backend/src/api/middlewares.ts +2 -0
- package/templates/backend/src/lib/active.ts +41 -0
- package/templates/backend/src/lib/project.ts +26 -1
- package/templates/backend/src/scripts/rewrite-file-urls.ts +56 -0
- package/templates/backend/src/scripts/seed.ts +8 -0
- package/templates/storefront/src/app/(main)/products/[handle]/page.tsx +4 -1
- package/templates/storefront/src/lib/boutique/config.ts +71 -23
- package/templates/storefront/src/lib/boutique/schema.ts +4 -1
- package/templates/storefront/src/lib/config.ts +37 -8
- package/templates/storefront/src/lib/data/cart.ts +4 -1
- package/templates/storefront/src/lib/data/cookies.ts +14 -2
- package/templates/storefront/src/modules/checkout/components/shipping-address/index.tsx +6 -1
- package/templates/storefront/src/proxy.ts +2 -1
- package/templates/storefront/src/styles/ub/motion.css +2 -1
- package/templates/storefront/src/styles/ub/tech.css +3 -3
package/cli/dist/commands.js
CHANGED
|
@@ -6,7 +6,7 @@ import { compose, composeOutput, isRunning } from "./docker.js";
|
|
|
6
6
|
import { readEnv, refreshEnv, writeCampaignFiles, writeCompose, writeReadme } from "./generate.js";
|
|
7
7
|
import { copyTemplate, templateVersion } from "./paths.js";
|
|
8
8
|
import { nextFreePort, reservedByOthers, reservePorts } from "./ports.js";
|
|
9
|
-
import { currencies, effective, readProject, validateProject, writeJsonSchema, writeProject, ProjectError, SLUG } from "./project.js";
|
|
9
|
+
import { boutiqueUrl, currencies, effective, normalizeUrl, readProject, validateProject, writeJsonSchema, writeProject, ProjectError, SLUG, storePorts } from "./project.js";
|
|
10
10
|
import { listThemes } from "./themes.js";
|
|
11
11
|
import { writeGuides } from "./guide.js";
|
|
12
12
|
const STATE = (dir) => path.join(dir, ".create-boutique-state.json");
|
|
@@ -59,7 +59,7 @@ export async function apply(dir, opts = { up: true }) {
|
|
|
59
59
|
const p = readProject(target); // lève une ProjectError détaillée si invalide
|
|
60
60
|
checkCsvCurrencies(target, p); // avant toute écriture
|
|
61
61
|
const others = reservedByOthers(target);
|
|
62
|
-
const clash = [p.apiPort, p.storagePort, ...p
|
|
62
|
+
const clash = [p.apiPort, p.storagePort, ...storePorts(p)].filter((port) => others.has(port));
|
|
63
63
|
if (clash.length)
|
|
64
64
|
throw new ProjectError(`port(s) déjà réservé(s) par un autre projet : ${clash.join(", ")}`);
|
|
65
65
|
writeCampaignFiles(target, p, p.defaults.language);
|
|
@@ -67,7 +67,7 @@ export async function apply(dir, opts = { up: true }) {
|
|
|
67
67
|
writeCompose(target, p);
|
|
68
68
|
refreshEnv(target, p);
|
|
69
69
|
writeReadme(target, p);
|
|
70
|
-
reservePorts(target, [p.apiPort, p.storagePort, ...p
|
|
70
|
+
reservePorts(target, [p.apiPort, p.storagePort, ...storePorts(p)]);
|
|
71
71
|
const previous = fs.existsSync(STATE(target)) ? JSON.parse(fs.readFileSync(STATE(target), "utf8")).hash : null;
|
|
72
72
|
const changed = previous !== hash(p);
|
|
73
73
|
console.log(pc.green("✔ project.json valide, docker-compose.yml et .env régénérés"));
|
|
@@ -84,18 +84,77 @@ export async function apply(dir, opts = { up: true }) {
|
|
|
84
84
|
fs.writeFileSync(STATE(target), JSON.stringify({ hash: hash(p), appliedAt: new Date().toISOString() }, null, 2) + "\n");
|
|
85
85
|
priceWarnings(target);
|
|
86
86
|
}
|
|
87
|
+
// ─────────────────────────── urls ───────────────────────────
|
|
88
|
+
/** Préfixe des images en base : <domaine>/medusa (le bucket). Accepte le domaine seul ou déjà suffixé. */
|
|
89
|
+
const filesPrefix = (v) => {
|
|
90
|
+
const t = v.trim().replace(/\/+$/, "");
|
|
91
|
+
return /\/medusa$/.test(t) ? t : `${t}/medusa`;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Change les domaines publics (admin/API, images, site en mode single), régénère .env / compose / Caddyfile.exemple,
|
|
95
|
+
* puis réécrit l'adresse des images déjà en base : ancienne adresse (S3_FILE_URL avant le changement,
|
|
96
|
+
* ou --from) → nouvelle. Relançable sans risque (ne touche que ce qui commence par l'ancienne adresse).
|
|
97
|
+
*/
|
|
98
|
+
export async function urls(dir, o, opts) {
|
|
99
|
+
const target = path.resolve(dir);
|
|
100
|
+
const p = readProject(target);
|
|
101
|
+
const before = readEnv(target).S3_FILE_URL;
|
|
102
|
+
if (o.api || o.files) {
|
|
103
|
+
const api = o.api ? normalizeUrl(o.api, "--api-url") : p.urls?.api;
|
|
104
|
+
const files = o.files ? normalizeUrl(o.files, "--files-url") : p.urls?.files;
|
|
105
|
+
if (!api || !files)
|
|
106
|
+
throw new ProjectError("première configuration : --api-url et --files-url ensemble (domaine de l'admin et domaine des images)");
|
|
107
|
+
p.urls = { api, files };
|
|
108
|
+
}
|
|
109
|
+
if (o.site) {
|
|
110
|
+
if (p.mode !== "single")
|
|
111
|
+
throw new ProjectError("--site-url concerne le mode single ; en mode multi, mettez url sur chaque boutique dans config/project.json");
|
|
112
|
+
p.site = { ...p.site, url: normalizeUrl(o.site, "--site-url") };
|
|
113
|
+
}
|
|
114
|
+
if (!o.api && !o.files && !o.site && !o.from)
|
|
115
|
+
throw new ProjectError("rien à changer : --api-url, --files-url, --site-url ou --from");
|
|
116
|
+
writeProject(target, validateProject(p));
|
|
117
|
+
await apply(target, opts);
|
|
118
|
+
const after = readEnv(target).S3_FILE_URL;
|
|
119
|
+
console.log(`Images : ${after}`);
|
|
120
|
+
// Images déjà importées : réécriture de l'ancienne adresse
|
|
121
|
+
const from = o.from ? filesPrefix(normalizeFrom(o.from)) : before;
|
|
122
|
+
if (!from || from === after)
|
|
123
|
+
return;
|
|
124
|
+
if (!opts.up) {
|
|
125
|
+
console.log(pc.yellow(`! --no-up : images non réécrites. Après le démarrage : create-boutique urls . --from ${from}`));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const backend = `http://localhost:${p.apiPort}`;
|
|
129
|
+
for (let i = 0; i < 60 && !(await fetch(`${backend}/health`).then((r) => r.ok).catch(() => false)); i++)
|
|
130
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
131
|
+
const r = composeOutput(target, ["exec", "-T", "-e", `FILES_FROM=${from}`, "-e", `FILES_TO=${after}`, "backend", "npx", "medusa", "exec", "./src/scripts/rewrite-file-urls.js"]);
|
|
132
|
+
const m = r.stdout.match(/<<<URLS(.*)URLS>>>/);
|
|
133
|
+
if (r.code !== 0 || !m)
|
|
134
|
+
throw new Error(`réécriture des images échouée :\n${r.stderr.slice(-800)}`);
|
|
135
|
+
const n = JSON.parse(m[1]);
|
|
136
|
+
console.log(pc.green(`✔ images réécrites : ${from} → ${after} (${n.products} produit(s), ${n.campaigns} campagne(s))`));
|
|
137
|
+
}
|
|
138
|
+
/** --from accepte aussi « localhost:9100 » : on complète le schéma. */
|
|
139
|
+
const normalizeFrom = (v) => (/^https?:\/\//.test(v.trim()) ? v.trim() : `http://${v.trim()}`);
|
|
87
140
|
// ─────────────────────────── add / remove / set ───────────────────────────
|
|
88
141
|
export async function add(dir, specs, opts) {
|
|
89
142
|
if (!specs.length)
|
|
90
143
|
throw new ProjectError("--boutique <slug>:<thème> obligatoire");
|
|
91
144
|
const target = path.resolve(dir);
|
|
92
145
|
const p = readProject(target);
|
|
93
|
-
const taken = new Set([...reservedByOthers(target), p.apiPort, p.storagePort, ...p
|
|
94
|
-
let from = Math.max(...p
|
|
146
|
+
const taken = new Set([...reservedByOthers(target), p.apiPort, p.storagePort, ...storePorts(p)]);
|
|
147
|
+
let from = Math.max(8000, ...storePorts(p)) + 1;
|
|
95
148
|
for (const spec of specs) {
|
|
96
149
|
const [slug, theme] = spec.split(":");
|
|
97
150
|
if (!slug || !theme || !SLUG.test(slug))
|
|
98
151
|
throw new ProjectError(`format attendu : --boutique <slug>:<thème> (reçu « ${spec} »)`);
|
|
152
|
+
if (p.mode === "single") {
|
|
153
|
+
// Mode single : pas de port, la boutique s'active dans admin → Campagnes
|
|
154
|
+
p.boutiques.push({ slug, theme });
|
|
155
|
+
console.log(`+ ${slug} (${theme}) : à mettre en ligne dans admin → Campagnes`);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
99
158
|
const port = await nextFreePort(from, taken);
|
|
100
159
|
taken.add(port);
|
|
101
160
|
from = port + 1;
|
|
@@ -119,6 +178,35 @@ export async function remove(dir, slugs, opts) {
|
|
|
119
178
|
console.log(`- ${slugs.join(", ")} retirée(s). Ses commandes et son sales channel restent dans l'admin.`);
|
|
120
179
|
await apply(target, opts);
|
|
121
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* multi → single : un seul site, sur le port de la première boutique ; les boutiques perdent leur port et leur domaine.
|
|
183
|
+
* single → multi : chaque boutique reçoit un port libre à partir de celui du site.
|
|
184
|
+
*/
|
|
185
|
+
async function switchMode(target, p, mode) {
|
|
186
|
+
if (mode === "single") {
|
|
187
|
+
const first = p.boutiques.find((b) => b.port);
|
|
188
|
+
p.site = { port: first?.port ?? 8001, ...(first?.url ? { url: first.url } : {}) };
|
|
189
|
+
for (const b of p.boutiques) {
|
|
190
|
+
delete b.port;
|
|
191
|
+
delete b.url;
|
|
192
|
+
}
|
|
193
|
+
console.log(`mode single : un seul site sur le port ${p.site.port}${p.site.url ? ` (${p.site.url})` : ""}, boutique en ligne choisie dans admin → Campagnes`);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
const taken = new Set([...reservedByOthers(target), p.apiPort, p.storagePort]);
|
|
197
|
+
let from = p.site?.port ?? 8001;
|
|
198
|
+
for (const b of p.boutiques) {
|
|
199
|
+
const port = await nextFreePort(from, taken);
|
|
200
|
+
taken.add(port);
|
|
201
|
+
from = port + 1;
|
|
202
|
+
b.port = port;
|
|
203
|
+
console.log(`${b.slug} → http://localhost:${port}`);
|
|
204
|
+
}
|
|
205
|
+
delete p.site;
|
|
206
|
+
console.log("mode multi : toutes les boutiques sont en ligne, chacune sur son port");
|
|
207
|
+
}
|
|
208
|
+
p.mode = mode;
|
|
209
|
+
}
|
|
122
210
|
export async function set(dir, o, opts) {
|
|
123
211
|
const target = path.resolve(dir);
|
|
124
212
|
const p = readProject(target);
|
|
@@ -140,6 +228,8 @@ export async function set(dir, o, opts) {
|
|
|
140
228
|
else {
|
|
141
229
|
if (o.theme)
|
|
142
230
|
throw new ProjectError("--theme s'applique à une boutique : ajoutez --boutique <slug>");
|
|
231
|
+
if (o.mode && o.mode !== p.mode)
|
|
232
|
+
await switchMode(target, p, o.mode);
|
|
143
233
|
if (o.name)
|
|
144
234
|
p.defaults.storeName = o.name;
|
|
145
235
|
if (o.currency)
|
|
@@ -248,9 +338,10 @@ export async function check(dir) {
|
|
|
248
338
|
const e = effective(p, b);
|
|
249
339
|
const steps = [];
|
|
250
340
|
try {
|
|
251
|
-
const url =
|
|
341
|
+
const url = boutiqueUrl(p, b);
|
|
252
342
|
const html = await fetch(url).then((r) => (r.ok ? r.text() : Promise.reject(new Error(`boutique HTTP ${r.status}`))));
|
|
253
|
-
|
|
343
|
+
// Mode single : le site n'affiche que la boutique active
|
|
344
|
+
if (p.mode !== "single" && !html.includes(`data-theme="${b.theme}"`))
|
|
254
345
|
throw new Error(`thème ${b.theme} absent de la page`);
|
|
255
346
|
steps.push("page");
|
|
256
347
|
const key = composeOutput(target, ["exec", "-T", "backend", "cat", `/app/keys/${b.slug}`]).stdout.trim();
|
package/cli/dist/create.js
CHANGED
|
@@ -7,7 +7,7 @@ import { writeCampaignFiles, writeCompose, writeEnv, writeReadme } from "./gener
|
|
|
7
7
|
import { writeGuides } from "./guide.js";
|
|
8
8
|
import { copyDir, copyTemplate, templateVersion } from "./paths.js";
|
|
9
9
|
import { nextFreePort, reservedByOthers, reservePorts } from "./ports.js";
|
|
10
|
-
import { validateProject, writeJsonSchema, writeProject, ProjectError } from "./project.js";
|
|
10
|
+
import { normalizeUrl, storePorts, validateProject, writeJsonSchema, writeProject, ProjectError } from "./project.js";
|
|
11
11
|
import { listThemes, themeIds } from "./themes.js";
|
|
12
12
|
/** Réponses du CLI : mêmes clés pour les options, le fichier --config et les questions. */
|
|
13
13
|
export const answersSchema = z.object({
|
|
@@ -18,6 +18,13 @@ export const answersSchema = z.object({
|
|
|
18
18
|
language: z.string().regex(/^[a-z]{2}$/).optional(),
|
|
19
19
|
themes: z.union([z.literal("all"), z.array(z.string())]).optional(),
|
|
20
20
|
boutiques: z.array(z.string().regex(/^[a-z0-9-]+:[a-z0-9-]+$/, "format slug:thème")).optional(),
|
|
21
|
+
/** multi : un site par boutique ; single : un seul site, la boutique active se choisit dans l'admin */
|
|
22
|
+
mode: z.enum(["multi", "single"]).optional(),
|
|
23
|
+
/** Production : domaines publics de l'admin/API et des images (le CLI ajoute /medusa pour les images) */
|
|
24
|
+
apiUrl: z.string().optional(),
|
|
25
|
+
filesUrl: z.string().optional(),
|
|
26
|
+
/** Mode single : domaine du site */
|
|
27
|
+
siteUrl: z.string().optional(),
|
|
21
28
|
stripePk: z.string().optional(),
|
|
22
29
|
stripeSk: z.string().optional(),
|
|
23
30
|
stripeWhsec: z.string().optional(),
|
|
@@ -88,6 +95,22 @@ async function ask(a, dirName, yes) {
|
|
|
88
95
|
extra.push(`${slug}:${theme}`);
|
|
89
96
|
}
|
|
90
97
|
}
|
|
98
|
+
const mode = a.mode ??
|
|
99
|
+
cancelled(await p.select({
|
|
100
|
+
message: "Sites",
|
|
101
|
+
options: [
|
|
102
|
+
{ value: "multi", label: "Un site par boutique", hint: "toutes en ligne, chacune sur son port / domaine" },
|
|
103
|
+
{ value: "single", label: "Un seul site", hint: "une boutique en ligne à la fois, choisie dans l'admin → Campagnes" },
|
|
104
|
+
],
|
|
105
|
+
initialValue: "multi",
|
|
106
|
+
}));
|
|
107
|
+
// Adresses publiques (production) : inscrites dans les images importées, donc à donner avant le premier démarrage
|
|
108
|
+
const apiUrl = a.apiUrl ??
|
|
109
|
+
(cancelled(await p.text({ message: "Domaine de l'admin / API en production (vide = test en local)", placeholder: "https://admin.marque.com", defaultValue: "" })).trim() ||
|
|
110
|
+
undefined);
|
|
111
|
+
const filesUrl = apiUrl
|
|
112
|
+
? (a.filesUrl ?? cancelled(await p.text({ message: "Domaine des images (juste le domaine, /medusa est ajouté)", placeholder: "https://images.marque.com", validate: (v) => (v?.trim() ? undefined : "obligatoire avec le domaine de l'admin") })).trim())
|
|
113
|
+
: a.filesUrl;
|
|
91
114
|
const stripePk = a.stripePk ?? (cancelled(await p.text({ message: "Clé Stripe publique (vide = paiement manuel)", placeholder: "pk_test_…", defaultValue: "" })) || undefined);
|
|
92
115
|
const stripeSk = stripePk ? (a.stripeSk ?? (cancelled(await p.password({ message: "Clé Stripe secrète" })) || undefined)) : a.stripeSk;
|
|
93
116
|
// E-mails clients (confirmation, expédition, mot de passe oublié…) via Resend ; vide = aucun e-mail envoyé.
|
|
@@ -112,7 +135,7 @@ async function ask(a, dirName, yes) {
|
|
|
112
135
|
};
|
|
113
136
|
const products = a.products ?? (cancelled(await p.text({ message: "Fichier produits (CSV, optionnel)", defaultValue: "", validate: exists("file") })).trim() || undefined);
|
|
114
137
|
const images = a.images ?? (cancelled(await p.text({ message: "Dossier images (optionnel)", defaultValue: "", validate: exists("dir") })).trim() || undefined);
|
|
115
|
-
return { ...a, name, email, currency, country, language, themes: chosen, boutiques: extra, stripePk, stripeSk, resendKey, emailFrom, products, images };
|
|
138
|
+
return { ...a, name, email, currency, country, language, mode, apiUrl, filesUrl, themes: chosen, boutiques: extra, stripePk, stripeSk, resendKey, emailFrom, products, images };
|
|
116
139
|
}
|
|
117
140
|
export async function create(dir, input, opts) {
|
|
118
141
|
const target = path.resolve(dir);
|
|
@@ -144,19 +167,39 @@ export async function create(dir, input, opts) {
|
|
|
144
167
|
taken.add(apiPort);
|
|
145
168
|
const storagePort = await nextFreePort(a.storagePort ?? 9100, taken);
|
|
146
169
|
taken.add(storagePort);
|
|
170
|
+
const mode = a.mode ?? "multi";
|
|
147
171
|
let nextStore = a.storePort ?? 8001;
|
|
148
172
|
const boutiques = [];
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
173
|
+
let site;
|
|
174
|
+
if (mode === "single") {
|
|
175
|
+
// Un seul site : un port pour tout le projet, les boutiques n'en ont pas
|
|
176
|
+
site = { port: await nextFreePort(nextStore, taken) };
|
|
177
|
+
taken.add(site.port);
|
|
178
|
+
for (const w of wanted)
|
|
179
|
+
boutiques.push({ slug: w.slug, theme: w.theme });
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
for (const w of wanted) {
|
|
183
|
+
const port = await nextFreePort(nextStore, taken);
|
|
184
|
+
taken.add(port);
|
|
185
|
+
nextStore = port + 1;
|
|
186
|
+
boutiques.push({ slug: w.slug, theme: w.theme, port });
|
|
187
|
+
}
|
|
154
188
|
}
|
|
189
|
+
// Adresses publiques : les deux ensemble (les images importées gardent l'adresse du stockage)
|
|
190
|
+
if (!!a.apiUrl !== !!a.filesUrl)
|
|
191
|
+
throw new ProjectError("--api-url et --files-url vont ensemble (domaine de l'admin et domaine des images)");
|
|
192
|
+
const urls = a.apiUrl && a.filesUrl ? { api: normalizeUrl(a.apiUrl, "--api-url"), files: normalizeUrl(a.filesUrl, "--files-url") } : undefined;
|
|
193
|
+
if (site && a.siteUrl)
|
|
194
|
+
Object.assign(site, { url: normalizeUrl(a.siteUrl, "--site-url") });
|
|
155
195
|
const proj = validateProject({
|
|
156
196
|
project,
|
|
157
197
|
templateVersion: templateVersion(),
|
|
158
198
|
apiPort,
|
|
159
199
|
storagePort,
|
|
200
|
+
mode,
|
|
201
|
+
...(site ? { site } : {}),
|
|
202
|
+
...(urls ? { urls } : {}),
|
|
160
203
|
defaults: {
|
|
161
204
|
storeName: a.name,
|
|
162
205
|
currency: a.currency,
|
|
@@ -171,8 +214,11 @@ export async function create(dir, input, opts) {
|
|
|
171
214
|
// Récapitulatif
|
|
172
215
|
const width = Math.max(...proj.boutiques.map((b) => b.slug.length)) + 2;
|
|
173
216
|
const lines = [
|
|
174
|
-
...proj.
|
|
175
|
-
|
|
217
|
+
...(proj.mode === "single"
|
|
218
|
+
? [`Site unique → http://localhost:${proj.site.port} (boutique en ligne choisie dans admin → Campagnes ; au départ : ${proj.boutiques[0].slug})`, ...proj.boutiques.map((b) => ` ${b.slug.padEnd(width)}${b.theme}`)]
|
|
219
|
+
: proj.boutiques.map((b) => `${b.slug.padEnd(width)}${b.theme.padEnd(16)}→ http://localhost:${b.port}`)),
|
|
220
|
+
`${"Backend / admin".padEnd(width + 16)}→ ${proj.urls ? `${proj.urls.api}/app` : `http://localhost:${proj.apiPort}/app`}`,
|
|
221
|
+
`Images : ${proj.urls ? `${proj.urls.files}/medusa` : `http://localhost:${proj.storagePort}/medusa (local : à changer avant la production avec create-boutique urls)`}`,
|
|
176
222
|
`Devise ${a.currency.toUpperCase()} · pays ${a.country.toUpperCase()} · langue ${a.language}`,
|
|
177
223
|
`Paiement : ${a.stripeSk ? "Stripe + manuel" : "manuel (Stripe : non configuré)"}`,
|
|
178
224
|
`E-mails : ${a.resendKey ? `Resend, depuis ${a.emailFrom ?? "(adresse manquante)"}` : "désactivés (pas de clé Resend)"}`,
|
|
@@ -209,7 +255,7 @@ export async function create(dir, input, opts) {
|
|
|
209
255
|
writeCompose(target, proj);
|
|
210
256
|
writeReadme(target, proj);
|
|
211
257
|
fs.writeFileSync(path.join(target, ".gitignore"), ".env\nbackups/\nnode_modules/\n");
|
|
212
|
-
reservePorts(target, [proj.apiPort, proj.storagePort, ...proj
|
|
258
|
+
reservePorts(target, [proj.apiPort, proj.storagePort, ...storePorts(proj)]);
|
|
213
259
|
for (const g of writeGuides(target, proj, a.email)) {
|
|
214
260
|
if (!g.pdf)
|
|
215
261
|
console.log(pc.yellow(`Chrome/Edge introuvable : ${path.basename(g.file)} à imprimer en PDF.`));
|
package/cli/dist/generate.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { currencies, effective } from "./project.js";
|
|
4
|
+
import { boutiqueUrl, currencies, effective, fronts } from "./project.js";
|
|
5
5
|
const secret = () => crypto.randomBytes(32).toString("base64url");
|
|
6
6
|
// ─────────────────────────── docker-compose.yml ───────────────────────────
|
|
7
|
-
/** Production (urls présent) : ports
|
|
7
|
+
/** Production (urls présent) : ports seulement sur 127.0.0.1 ; ton reverse proxy (Caddy, Nginx…) sur la machine les expose en https. */
|
|
8
8
|
const bind = (p, port) => (p.urls ? `127.0.0.1:${port}` : String(port));
|
|
9
9
|
const host = (url) => new URL(url).host;
|
|
10
10
|
export function composeYaml(p) {
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// Mode single : un seul service, BOUTIQUE=auto (il affiche la boutique active, choisie dans l'admin)
|
|
12
|
+
const services = fronts(p)
|
|
13
|
+
.map((f) => ` ${f.service}:
|
|
13
14
|
<<: *storefront
|
|
14
|
-
ports: ["${bind(p,
|
|
15
|
-
environment: { <<: *storefront-env, BOUTIQUE: ${
|
|
15
|
+
ports: ["${bind(p, f.port)}:8000"]
|
|
16
|
+
environment: { <<: *storefront-env, BOUTIQUE: ${f.boutique} }
|
|
16
17
|
`)
|
|
17
18
|
.join("\n");
|
|
18
19
|
return `# GÉNÉRÉ par create-boutique à partir de config/project.json — ne pas modifier à la main.
|
|
@@ -51,6 +52,7 @@ ${services}
|
|
|
51
52
|
volumes:
|
|
52
53
|
- ./config:/app/config:ro # boutiques, devises, pays → régions, sales channels, clés
|
|
53
54
|
- ./seed:/app/seed:ro # products.csv + images à importer
|
|
55
|
+
- ./storefront/themes:/app/themes:ro # textes par défaut des thèmes (pré-remplis dans admin → Campagnes)
|
|
54
56
|
- keys:/app/keys # y écrit la publishable key de chaque boutique
|
|
55
57
|
depends_on:
|
|
56
58
|
postgres: { condition: service_healthy }
|
|
@@ -94,53 +96,59 @@ ${services}
|
|
|
94
96
|
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9000/health >/dev/null || exit 1"]
|
|
95
97
|
interval: 5s
|
|
96
98
|
retries: 20
|
|
97
|
-
${p.urls ? caddyService : ""}
|
|
98
99
|
volumes:
|
|
99
100
|
pg_data:
|
|
100
101
|
redis_data:
|
|
101
102
|
storage_data:
|
|
102
|
-
keys
|
|
103
|
+
keys:
|
|
103
104
|
`;
|
|
104
105
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
restart: unless-stopped
|
|
110
|
-
ports: ["80:80", "443:443"]
|
|
111
|
-
volumes:
|
|
112
|
-
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
113
|
-
- caddy_data:/data
|
|
114
|
-
- caddy_config:/config
|
|
115
|
-
depends_on:
|
|
116
|
-
backend: { condition: service_healthy }
|
|
117
|
-
`;
|
|
118
|
-
/** Caddyfile (production) : chaque domaine vers son container ; certificats https obtenus et renouvelés par Caddy. */
|
|
106
|
+
/**
|
|
107
|
+
* Exemple de Caddyfile (production) : le CLI ne lance aucun reverse proxy, c'est toi qui le gères sur la machine.
|
|
108
|
+
* Ce fichier n'est utilisé par rien : il montre quel domaine envoyer vers quel port (127.0.0.1).
|
|
109
|
+
*/
|
|
119
110
|
export function caddyfile(p) {
|
|
120
|
-
const site = (url,
|
|
111
|
+
const site = (url, port) => `${host(url)} {
|
|
121
112
|
encode zstd gzip
|
|
122
|
-
reverse_proxy
|
|
113
|
+
reverse_proxy 127.0.0.1:${port}
|
|
123
114
|
}
|
|
124
115
|
`;
|
|
125
116
|
return [
|
|
126
|
-
"#
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
site(p.urls.
|
|
117
|
+
"# EXEMPLE généré par create-boutique (non utilisé) : à recopier dans ton propre Caddyfile, puis recharger Caddy.",
|
|
118
|
+
"# Régénéré à chaque apply / urls à partir de config/project.json.",
|
|
119
|
+
...fronts(p).filter((f) => f.url).map((f) => site(f.url, f.port)),
|
|
120
|
+
site(p.urls.api, p.apiPort),
|
|
121
|
+
// Images : lecture seule des fichiers du bucket (GET /medusa/…), tout le reste 404 (ni liste, ni écriture, ni console)
|
|
122
|
+
`${host(p.urls.files)} {
|
|
123
|
+
encode zstd gzip
|
|
124
|
+
@read {
|
|
125
|
+
method GET HEAD
|
|
126
|
+
path /medusa/*
|
|
127
|
+
}
|
|
128
|
+
handle @read {
|
|
129
|
+
reverse_proxy 127.0.0.1:${p.storagePort}
|
|
130
|
+
}
|
|
131
|
+
respond 404
|
|
132
|
+
}
|
|
133
|
+
`,
|
|
130
134
|
].join("\n");
|
|
131
135
|
}
|
|
132
|
-
/** Écrit docker-compose.yml (+ Caddyfile en production
|
|
136
|
+
/** Écrit docker-compose.yml (+ Caddyfile.exemple en production). */
|
|
133
137
|
export function writeCompose(dir, p) {
|
|
134
138
|
fs.writeFileSync(path.join(dir, "docker-compose.yml"), composeYaml(p));
|
|
135
|
-
|
|
139
|
+
// Ancien Caddyfile généré par une version précédente (service caddy retiré) : supprimé ; un Caddyfile à toi n'est jamais touché
|
|
140
|
+
const old = path.join(dir, "Caddyfile");
|
|
141
|
+
if (fs.existsSync(old) && fs.readFileSync(old, "utf8").startsWith("# GÉNÉRÉ par create-boutique"))
|
|
142
|
+
fs.rmSync(old);
|
|
143
|
+
const example = path.join(dir, "Caddyfile.exemple");
|
|
136
144
|
if (p.urls)
|
|
137
|
-
fs.writeFileSync(
|
|
138
|
-
else if (fs.existsSync(
|
|
139
|
-
fs.rmSync(
|
|
145
|
+
fs.writeFileSync(example, caddyfile(p));
|
|
146
|
+
else if (fs.existsSync(example))
|
|
147
|
+
fs.rmSync(example);
|
|
140
148
|
}
|
|
141
149
|
/** Variables qui dépendent de project.json (réécrites à chaque apply). Les secrets ne sont jamais régénérés. */
|
|
142
150
|
function derivedEnv(p) {
|
|
143
|
-
const origins = p.
|
|
151
|
+
const origins = fronts(p).map((f) => f.url ?? `http://localhost:${f.port}`);
|
|
144
152
|
const trim = (u) => u.replace(/\/+$/, "");
|
|
145
153
|
const admin = trim(p.urls?.api ?? `http://localhost:${p.apiPort}`);
|
|
146
154
|
const files = trim(p.urls?.files ?? `http://localhost:${p.storagePort}`);
|
|
@@ -309,7 +317,7 @@ export function writeReadme(dir, p) {
|
|
|
309
317
|
const rows = p.boutiques
|
|
310
318
|
.map((b) => {
|
|
311
319
|
const e = effective(p, b);
|
|
312
|
-
return `| ${b.slug} | ${b.theme} | ${e.currency.toUpperCase()} |
|
|
320
|
+
return `| ${b.slug} | ${b.theme} | ${e.currency.toUpperCase()} | ${boutiqueUrl(p, b)} |`;
|
|
313
321
|
})
|
|
314
322
|
.join("\n");
|
|
315
323
|
const text = `# ${p.defaults.storeName} — projet \`${p.project}\`
|
package/cli/dist/guide.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { PACKAGE_ROOT } from "./paths.js";
|
|
6
|
-
import { currencies, effective } from "./project.js";
|
|
6
|
+
import { boutiqueUrl, currencies, effective } from "./project.js";
|
|
7
7
|
const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
8
8
|
/** Chrome / Edge / Chromium installé (CHROME_PATH prioritaire). */
|
|
9
9
|
function findBrowser() {
|
|
@@ -52,7 +52,7 @@ export function writeGuides(dir, p, adminEmail, support = "") {
|
|
|
52
52
|
const stores = p.boutiques
|
|
53
53
|
.map((b) => {
|
|
54
54
|
const e = effective(p, b);
|
|
55
|
-
return `<tr><td>${escapeHtml(e.storeName)}</td><td><code>${escapeHtml(b.slug)}</code></td><td>${escapeHtml(b.theme)}</td><td>${e.currency.toUpperCase()}</td><td>${escapeHtml(
|
|
55
|
+
return `<tr><td>${escapeHtml(e.storeName)}</td><td><code>${escapeHtml(b.slug)}</code></td><td>${escapeHtml(b.theme)}</td><td>${e.currency.toUpperCase()}</td><td>${escapeHtml(boutiqueUrl(p, b))}</td></tr>`;
|
|
56
56
|
})
|
|
57
57
|
.join("");
|
|
58
58
|
const vars = {
|
package/cli/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
4
|
import pc from "picocolors";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import { add, admin, apply, backup, campaignExport, campaignImport, check, guide, remove, set, stripeWebhook, themes, update } from "./commands.js";
|
|
6
|
+
import { add, admin, apply, urls, backup, campaignExport, campaignImport, check, guide, remove, set, stripeWebhook, themes, update } from "./commands.js";
|
|
7
7
|
import { answersSchema, create } from "./create.js";
|
|
8
8
|
import { ProjectError } from "./project.js";
|
|
9
9
|
const HELP = `create-boutique — Usine à Boutiques
|
|
@@ -14,6 +14,9 @@ const HELP = `create-boutique — Usine à Boutiques
|
|
|
14
14
|
--currency usd (défaut usd) --country us (défaut us) --language en (défaut en)
|
|
15
15
|
--themes a,b,c | all une boutique par thème, slug = <dossier>-<thème>
|
|
16
16
|
--boutique <slug>:<thème> ajoute une boutique au nom choisi (répétable)
|
|
17
|
+
--api-url <domaine> --files-url <domaine> production : domaines de l'admin et des images (avant le 1er démarrage)
|
|
18
|
+
--site-url <domaine> mode single : domaine du site
|
|
19
|
+
--mode multi|single multi (défaut) : un site par boutique ; single : un seul site, boutique active choisie dans l'admin
|
|
17
20
|
--stripe-pk / --stripe-sk / --stripe-whsec clés Stripe (optionnelles)
|
|
18
21
|
--resend-key / --email-from e-mails (optionnels)
|
|
19
22
|
--products <csv> --images <dossier> catalogue (optionnel)
|
|
@@ -22,8 +25,11 @@ const HELP = `create-boutique — Usine à Boutiques
|
|
|
22
25
|
--yes aucune question, valeurs par défaut
|
|
23
26
|
|
|
24
27
|
create-boutique apply <dossier> [--no-up] applique config/project.json
|
|
28
|
+
create-boutique urls <dossier> [--api-url …] [--files-url …] [--site-url …] [--from <ancienne adresse des images>]
|
|
29
|
+
change les domaines publics ; réécrit l'adresse des images déjà en base
|
|
25
30
|
create-boutique add <dossier> --boutique <slug>:<thème>
|
|
26
31
|
create-boutique remove <dossier> --boutique <slug>
|
|
32
|
+
create-boutique set <dossier> [--mode multi|single] change de mode (un site par boutique ↔ un seul site)
|
|
27
33
|
create-boutique set <dossier> [--boutique <slug>] [--name …] [--theme …] [--currency …] [--country …] [--language …]
|
|
28
34
|
create-boutique themes liste les thèmes
|
|
29
35
|
create-boutique update <dossier> [--no-up] met à jour le code (config, .env, données conservés)
|
|
@@ -35,7 +41,7 @@ const HELP = `create-boutique — Usine à Boutiques
|
|
|
35
41
|
create-boutique campaign export <dossier> campagnes en base → config/campaigns/*.json
|
|
36
42
|
create-boutique campaign import <dossier> [--boutique <slug>] [--yes] JSON → base (écrase)
|
|
37
43
|
`;
|
|
38
|
-
const COMMANDS = new Set(["apply", "add", "remove", "set", "themes", "update", "check", "backup", "campaign", "admin", "guide", "stripe-webhook", "help"]);
|
|
44
|
+
const COMMANDS = new Set(["urls", "apply", "add", "remove", "set", "themes", "update", "check", "backup", "campaign", "admin", "guide", "stripe-webhook", "help"]);
|
|
39
45
|
async function main() {
|
|
40
46
|
const { values, positionals } = parseArgs({
|
|
41
47
|
allowPositionals: true,
|
|
@@ -48,6 +54,11 @@ async function main() {
|
|
|
48
54
|
themes: { type: "string" },
|
|
49
55
|
boutique: { type: "string", multiple: true },
|
|
50
56
|
theme: { type: "string" },
|
|
57
|
+
mode: { type: "string" },
|
|
58
|
+
"api-url": { type: "string" },
|
|
59
|
+
"files-url": { type: "string" },
|
|
60
|
+
"site-url": { type: "string" },
|
|
61
|
+
from: { type: "string" },
|
|
51
62
|
"stripe-pk": { type: "string" },
|
|
52
63
|
"stripe-sk": { type: "string" },
|
|
53
64
|
"stripe-whsec": { type: "string" },
|
|
@@ -88,6 +99,10 @@ async function main() {
|
|
|
88
99
|
language: lower(values.language),
|
|
89
100
|
themes: values.themes === "all" ? "all" : values.themes?.split(",").map((s) => s.trim()).filter(Boolean),
|
|
90
101
|
boutiques: values.boutique,
|
|
102
|
+
mode: lower(values.mode),
|
|
103
|
+
apiUrl: values["api-url"],
|
|
104
|
+
filesUrl: values["files-url"],
|
|
105
|
+
siteUrl: values["site-url"],
|
|
91
106
|
stripePk: values["stripe-pk"],
|
|
92
107
|
stripeSk: values["stripe-sk"],
|
|
93
108
|
stripeWhsec: values["stripe-whsec"],
|
|
@@ -104,6 +119,8 @@ async function main() {
|
|
|
104
119
|
switch (first) {
|
|
105
120
|
case "apply":
|
|
106
121
|
return apply(dirArg(), up);
|
|
122
|
+
case "urls":
|
|
123
|
+
return urls(dirArg(), { api: values["api-url"], files: values["files-url"], site: values["site-url"], from: values.from }, up);
|
|
107
124
|
case "add":
|
|
108
125
|
return add(dirArg(), values.boutique ?? [], up);
|
|
109
126
|
case "remove":
|
|
@@ -118,6 +135,7 @@ async function main() {
|
|
|
118
135
|
currency: lower(values.currency),
|
|
119
136
|
country: lower(values.country),
|
|
120
137
|
language: lower(values.language),
|
|
138
|
+
mode: lower(values.mode),
|
|
121
139
|
}, up);
|
|
122
140
|
case "themes":
|
|
123
141
|
return themes();
|
package/cli/dist/project.js
CHANGED
|
@@ -16,8 +16,14 @@ export const projectSchema = z.object({
|
|
|
16
16
|
templateVersion: z.string().optional(),
|
|
17
17
|
apiPort: z.number().int().min(1).max(65535).default(9000),
|
|
18
18
|
storagePort: z.number().int().min(1).max(65535).default(9100),
|
|
19
|
-
/** Production : adresses publiques (https) du backend/admin et des images. Présent =
|
|
19
|
+
/** Production : adresses publiques (https) du backend/admin et des images. Présent = ports sur 127.0.0.1 (ton reverse proxy les expose), Caddyfile.exemple fourni. */
|
|
20
20
|
urls: z.strictObject({ api: z.url(), files: z.url() }).optional(),
|
|
21
|
+
/**
|
|
22
|
+
* multi (défaut) : un site par boutique, toutes en ligne (1 container, 1 port, 1 domaine chacune).
|
|
23
|
+
* single : un seul site (site.port, site.url) qui affiche la boutique active, choisie dans admin → Campagnes.
|
|
24
|
+
*/
|
|
25
|
+
mode: z.enum(["multi", "single"]).default("multi"),
|
|
26
|
+
site: z.strictObject({ port: z.number().int().min(1).max(65535), url: z.url().optional() }).optional(),
|
|
21
27
|
defaults: z.looseObject({
|
|
22
28
|
storeName: z.string().min(1).max(60),
|
|
23
29
|
currency: code3,
|
|
@@ -31,7 +37,8 @@ export const projectSchema = z.object({
|
|
|
31
37
|
.array(z.strictObject({
|
|
32
38
|
slug: z.string().regex(SLUG, "slug : minuscules, chiffres et tirets"),
|
|
33
39
|
theme: z.string(),
|
|
34
|
-
|
|
40
|
+
/** Mode multi : port du site de la boutique (inutile en mode single) */
|
|
41
|
+
port: z.number().int().min(1).max(65535).optional(),
|
|
35
42
|
url: z.url().optional(),
|
|
36
43
|
storeName: z.string().min(1).max(60).optional(),
|
|
37
44
|
currency: code3.optional(),
|
|
@@ -59,7 +66,13 @@ export function validateProject(raw) {
|
|
|
59
66
|
for (const b of p.boutiques)
|
|
60
67
|
if (!themes.includes(b.theme))
|
|
61
68
|
errors.push(`${b.slug} : thème inconnu « ${b.theme} » (disponibles : ${themes.join(", ")})`);
|
|
62
|
-
|
|
69
|
+
if (p.mode === "single" && !p.site)
|
|
70
|
+
errors.push("mode single : bloc site { port } obligatoire");
|
|
71
|
+
if (p.mode === "multi")
|
|
72
|
+
for (const b of p.boutiques)
|
|
73
|
+
if (!b.port)
|
|
74
|
+
errors.push(`${b.slug} : port obligatoire (mode multi)`);
|
|
75
|
+
const ports = [p.apiPort, p.storagePort, ...storePorts(p)];
|
|
63
76
|
for (const port of new Set(ports))
|
|
64
77
|
if (ports.filter((x) => x === port).length > 1)
|
|
65
78
|
errors.push(`port utilisé deux fois : ${port}`);
|
|
@@ -119,3 +132,29 @@ export function writeJsonSchema(dir) {
|
|
|
119
132
|
const schema = z.toJSONSchema(projectSchema, { io: "input", unrepresentable: "any" });
|
|
120
133
|
fs.writeFileSync(path.join(dir, "config", "project.schema.json"), JSON.stringify(schema, null, 2) + "\n");
|
|
121
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Adresse publique saisie par l'utilisateur → origine seule (https://images.marque.com).
|
|
137
|
+
* Accepte « images.marque.com », une barre finale ou « /medusa » (ajouté ensuite par le CLI pour S3_FILE_URL).
|
|
138
|
+
*/
|
|
139
|
+
export function normalizeUrl(v, what) {
|
|
140
|
+
const raw = /^https?:\/\//.test(v.trim()) ? v.trim() : `https://${v.trim()}`;
|
|
141
|
+
let u;
|
|
142
|
+
try {
|
|
143
|
+
u = new URL(raw);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
throw new ProjectError(`${what} : adresse invalide « ${v} » (ex. https://images.marque.com)`);
|
|
147
|
+
}
|
|
148
|
+
const path = u.pathname.replace(/\/+$/, "").replace(/\/medusa$/, "");
|
|
149
|
+
if (path)
|
|
150
|
+
throw new ProjectError(`${what} : donnez seulement le domaine (ex. https://images.marque.com), sans chemin « ${u.pathname} »`);
|
|
151
|
+
return u.origin;
|
|
152
|
+
}
|
|
153
|
+
export function fronts(p) {
|
|
154
|
+
if (p.mode === "single")
|
|
155
|
+
return p.site ? [{ service: "storefront", boutique: "auto", port: p.site.port, url: p.site.url }] : [];
|
|
156
|
+
return p.boutiques.filter((b) => b.port).map((b) => ({ service: `storefront-${b.slug}`, boutique: b.slug, port: b.port, url: b.url }));
|
|
157
|
+
}
|
|
158
|
+
export const storePorts = (p) => fronts(p).map((f) => f.port);
|
|
159
|
+
/** Adresse publique d'une boutique (en mode single : celle du site unique). */
|
|
160
|
+
export const boutiqueUrl = (p, b) => p.mode === "single" ? (p.site?.url ?? `http://localhost:${p.site?.port}`) : (b.url ?? `http://localhost:${b.port}`);
|
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"saved": "Campaign saved",
|
|
17
17
|
"reset": "Reset to theme texts",
|
|
18
18
|
"resetDone": "Theme texts restored",
|
|
19
|
-
"emptyHint": "
|
|
19
|
+
"emptyHint": "Fields show the current texts (the theme ones by default): change what you want. {discount}, {endDate} and {freeShipping} are replaced automatically by the discount, end date and free-shipping threshold.",
|
|
20
20
|
"sections": {
|
|
21
21
|
"dates": "Dates",
|
|
22
22
|
"hero": "Hero",
|
|
@@ -66,6 +66,16 @@
|
|
|
66
66
|
"save": "Save image",
|
|
67
67
|
"saved": "Image saved"
|
|
68
68
|
},
|
|
69
|
-
"shareHint": "What shows when the store link is shared (WhatsApp, Facebook, LinkedIn…). Empty = store name, subtitle and main visual."
|
|
69
|
+
"shareHint": "What shows when the store link is shared (WhatsApp, Facebook, LinkedIn…). Empty = store name, subtitle and main visual.",
|
|
70
|
+
"online": {
|
|
71
|
+
"column": "Site",
|
|
72
|
+
"live": "Live",
|
|
73
|
+
"action": "Put live",
|
|
74
|
+
"hint": "Single site: the “Live” store is the one customers see. Orders and in-progress carts of the other stores are not lost.",
|
|
75
|
+
"confirmTitle": "Change the live store?",
|
|
76
|
+
"confirmText": "The site will switch from {{from}} to {{to}}. Customers paying right now can finish their order.",
|
|
77
|
+
"confirm": "Put live",
|
|
78
|
+
"done": "Store is live: the site has switched."
|
|
79
|
+
}
|
|
70
80
|
}
|
|
71
81
|
}
|