@vitrine-kit/vitrine 0.4.0 → 0.4.1

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/dist/index.js CHANGED
@@ -2,10 +2,13 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { readFileSync as readFileSync3 } from "fs";
5
- import { resolve as resolve6 } from "path";
5
+ import { resolve as resolve7 } from "path";
6
6
  import { Command } from "commander";
7
7
  import * as p from "@clack/prompts";
8
8
 
9
+ // src/commands.ts
10
+ import { join as join11, resolve as resolve5 } from "path";
11
+
9
12
  // src/project.ts
10
13
  import { existsSync as existsSync2 } from "fs";
11
14
  import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
@@ -34,9 +37,12 @@ function readText(p2) {
34
37
  function readJson(p2) {
35
38
  return JSON.parse(readText(p2));
36
39
  }
40
+ function normalizeEol(s) {
41
+ return s.includes("\r") ? s.replace(/\r\n/g, "\n") : s;
42
+ }
37
43
  function writeText(p2, content) {
38
44
  mkdirSync(dirname(p2), { recursive: true });
39
- writeFileSync(p2, content, "utf8");
45
+ writeFileSync(p2, normalizeEol(content), "utf8");
40
46
  }
41
47
  function toPosix(p2) {
42
48
  return p2.split("\\").join("/");
@@ -50,6 +56,16 @@ function safeJoin(root, ...segs) {
50
56
  }
51
57
  return target;
52
58
  }
59
+ function assertFeatureName(name) {
60
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
61
+ throw new Error(`[vitrine] invalid feature name "${name}"`);
62
+ }
63
+ }
64
+ function assertSafeRel(p2, what) {
65
+ if (isAbsolute(p2) || p2.split(/[\\/]/).some((seg) => seg === "..")) {
66
+ throw new Error(`[vitrine] ${what} "${p2}" must be a relative path without ".."`);
67
+ }
68
+ }
53
69
  function parseEnvKeys(text2) {
54
70
  return new Set(
55
71
  text2.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#") && l.includes("=")).map((l) => l.split("=")[0]?.trim() ?? "").filter(Boolean)
@@ -137,8 +153,9 @@ function bundledKitRoot() {
137
153
  }
138
154
 
139
155
  // src/cache.ts
140
- import { cpSync, existsSync as existsSync4, rmSync } from "fs";
156
+ import { cpSync, existsSync as existsSync4, mkdirSync as mkdirSync2, renameSync, rmSync } from "fs";
141
157
  import { join as join4, resolve as resolve3 } from "path";
158
+ import { registryIndexSchema } from "@vitrine-kit/contracts";
142
159
  function vitrineHome() {
143
160
  if (process.env.VITRINE_HOME) return resolve3(process.env.VITRINE_HOME);
144
161
  const home = process.env.USERPROFILE ?? process.env.HOME;
@@ -198,11 +215,22 @@ function populateCache(fromDir, opts = {}) {
198
215
  }
199
216
  const paths = cachePaths(home);
200
217
  const oldIndex = readIndex(paths.registry);
201
- rmSync(paths.registry, { recursive: true, force: true });
202
- cpSync(srcRegistry, paths.registry, { recursive: true });
203
- if (existsSync4(srcTemplates)) {
204
- rmSync(paths.templates, { recursive: true, force: true });
205
- cpSync(srcTemplates, paths.templates, { recursive: true });
218
+ const staging = join4(home, `.staging-${process.pid}`);
219
+ try {
220
+ rmSync(staging, { recursive: true, force: true });
221
+ mkdirSync2(staging, { recursive: true });
222
+ cpSync(srcRegistry, join4(staging, "registry"), { recursive: true });
223
+ registryIndexSchema.parse(readJson(join4(staging, "registry", "_index.json")));
224
+ const hasTemplates = existsSync4(srcTemplates);
225
+ if (hasTemplates) cpSync(srcTemplates, join4(staging, "templates"), { recursive: true });
226
+ rmSync(paths.registry, { recursive: true, force: true });
227
+ renameSync(join4(staging, "registry"), paths.registry);
228
+ if (hasTemplates) {
229
+ rmSync(paths.templates, { recursive: true, force: true });
230
+ renameSync(join4(staging, "templates"), paths.templates);
231
+ }
232
+ } finally {
233
+ rmSync(staging, { recursive: true, force: true });
206
234
  }
207
235
  const newIndex = readIndex(paths.registry);
208
236
  const kitVersion = newIndex?.kitVersion ?? "0.0.0";
@@ -247,7 +275,10 @@ function resolveRegistryRoot(explicit) {
247
275
  function createRegistrySource(explicitRoot) {
248
276
  const root = resolveRegistryRoot(explicitRoot);
249
277
  const cache = /* @__PURE__ */ new Map();
250
- const featureDir = (name) => join5(root, name);
278
+ const featureDir = (name) => {
279
+ assertFeatureName(name);
280
+ return join5(root, name);
281
+ };
251
282
  const hasFeature = (name) => existsSync5(join5(featureDir(name), "feature.json"));
252
283
  const loadManifest = (name) => {
253
284
  const cached = cache.get(name);
@@ -255,6 +286,10 @@ function createRegistrySource(explicitRoot) {
255
286
  const file = join5(featureDir(name), "feature.json");
256
287
  if (!existsSync5(file)) throw new Error(`[vitrine] feature "${name}" not found in the registry`);
257
288
  const manifest = featureManifestSchema.parse(JSON.parse(readText(file)));
289
+ for (const map of manifest.files) {
290
+ assertSafeRel(map.from, `feature "${name}": files.from`);
291
+ assertSafeRel(map.to, `feature "${name}": files.to`);
292
+ }
258
293
  cache.set(name, manifest);
259
294
  return manifest;
260
295
  };
@@ -269,7 +304,7 @@ function createRegistrySource(explicitRoot) {
269
304
  import { join as join7 } from "path";
270
305
 
271
306
  // src/transaction.ts
272
- import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
307
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
273
308
  import { dirname as dirname5 } from "path";
274
309
  var FsTransaction = class {
275
310
  backups = [];
@@ -280,8 +315,8 @@ var FsTransaction = class {
280
315
  }
281
316
  write(path, content) {
282
317
  this.snapshot(path);
283
- mkdirSync2(dirname5(path), { recursive: true });
284
- writeFileSync2(path, content, "utf8");
318
+ mkdirSync3(dirname5(path), { recursive: true });
319
+ writeFileSync2(path, normalizeEol(content), "utf8");
285
320
  }
286
321
  remove(path) {
287
322
  this.snapshot(path);
@@ -467,7 +502,7 @@ function renderNeutralTheme() {
467
502
  // src/feature-files.ts
468
503
  import { join as join6 } from "path";
469
504
  function* eachFeatureFile(featDir, map) {
470
- const src = join6(featDir, map.from);
505
+ const src = safeJoin(featDir, map.from);
471
506
  if (!exists(src)) return;
472
507
  const rels = isDir(src) ? walkRelFiles(src) : [""];
473
508
  for (const rel of rels) {
@@ -481,27 +516,33 @@ function* eachFeatureFile(featDir, map) {
481
516
  function resolveOrder(names, registry) {
482
517
  const order = [];
483
518
  const seen = /* @__PURE__ */ new Set();
519
+ const visiting = /* @__PURE__ */ new Set();
484
520
  const visit = (name) => {
485
521
  if (seen.has(name)) return;
522
+ if (visiting.has(name)) {
523
+ throw new Error(`[vitrine] circular registryDependencies involving "${name}"`);
524
+ }
486
525
  if (!registry.hasFeature(name)) {
487
526
  throw new Error(`[vitrine] feature "${name}" not found in the registry`);
488
527
  }
489
- seen.add(name);
528
+ visiting.add(name);
490
529
  const manifest = registry.loadManifest(name);
491
530
  for (const dep of manifest.registryDependencies ?? []) visit(dep);
531
+ visiting.delete(name);
532
+ seen.add(name);
492
533
  order.push(name);
493
534
  };
494
535
  for (const name of names) visit(name);
495
536
  return order;
496
537
  }
497
- function validate(name, manifest, project) {
538
+ function validate(name, manifest, project, present) {
498
539
  if (!manifest.tier.includes(project.lock.tier)) {
499
540
  throw new Error(
500
541
  `[vitrine] feature "${name}" does not support tier "${project.lock.tier}" (only ${manifest.tier.join(", ")})`
501
542
  );
502
543
  }
503
544
  for (const conflict of manifest.conflicts ?? []) {
504
- if (project.lock.features[conflict]) {
545
+ if (present.has(conflict)) {
505
546
  throw new Error(`[vitrine] conflict: "${name}" is incompatible with the installed "${conflict}"`);
506
547
  }
507
548
  }
@@ -510,9 +551,6 @@ function copyFeatureFiles(project, name, manifest, registry, tx) {
510
551
  const featDir = registry.featureDir(name);
511
552
  const originalsBase = join7(projectPaths(project.root).originals, `${name}@${manifest.kitVersion}`);
512
553
  for (const map of manifest.files) {
513
- if (!exists(join7(featDir, map.from))) {
514
- throw new Error(`[vitrine] feature "${name}": no source "${map.from}"`);
515
- }
516
554
  for (const file of eachFeatureFile(featDir, map)) {
517
555
  const content = readText(file.srcAbs);
518
556
  tx.write(safeJoin(project.root, file.repoRel), content);
@@ -574,11 +612,21 @@ function installFeatures(project, names, registry) {
574
612
  if (toInstall.length === 0) {
575
613
  return { installed: [], skipped: order };
576
614
  }
615
+ const present = new Set(installedNames);
616
+ for (const name of toInstall) {
617
+ const manifest = registry.loadManifest(name);
618
+ validate(name, manifest, project, present);
619
+ present.add(name);
620
+ for (const map of manifest.files) {
621
+ if (!exists(join7(registry.featureDir(name), map.from))) {
622
+ throw new Error(`[vitrine] feature "${name}": no source "${map.from}"`);
623
+ }
624
+ }
625
+ }
577
626
  const tx = new FsTransaction();
578
627
  try {
579
628
  for (const name of toInstall) {
580
629
  const manifest = registry.loadManifest(name);
581
- validate(name, manifest, project);
582
630
  copyFeatureFiles(project, name, manifest, registry, tx);
583
631
  project.lock.features[name] = { version: manifest.kitVersion };
584
632
  }
@@ -623,6 +671,20 @@ function removeFeature(project, name, registry) {
623
671
  }
624
672
  delete project.lock.features[name];
625
673
  regenerateDerived(project, registry, tx);
674
+ const keptKeys = new Set(
675
+ Object.keys(project.lock.features).flatMap(
676
+ (other) => (registry.loadManifest(other).env ?? []).map((e) => e.key)
677
+ )
678
+ );
679
+ const stale = (manifest.env ?? []).map((e) => e.key).filter((k) => !keptKeys.has(k));
680
+ const envPath = projectPaths(project.root).env;
681
+ if (stale.length > 0 && exists(envPath)) {
682
+ const lines = readText(envPath).split("\n").filter((l) => {
683
+ const key = l.includes("=") ? l.split("=")[0]?.trim() : void 0;
684
+ return !(key && stale.includes(key));
685
+ });
686
+ tx.write(envPath, lines.join("\n"));
687
+ }
626
688
  tx.commit();
627
689
  } catch (error) {
628
690
  tx.rollback();
@@ -637,6 +699,7 @@ import { existsSync as existsSync7, readdirSync as readdirSync2 } from "fs";
637
699
  import { delimiter, join as join8 } from "path";
638
700
  import { TOKEN_CSS_VARS as TOKEN_CSS_VARS2 } from "@vitrine-kit/contracts";
639
701
  var DESIGN_HEADING = "## INSTRUCTION: apply the design from /design";
702
+ var MAX_INSTRUCTION_CHARS = 4e3;
640
703
  function findClaudeBin(explicit) {
641
704
  const pinned = explicit ?? process.env.VITRINE_CLAUDE_BIN;
642
705
  if (pinned) {
@@ -667,7 +730,11 @@ function extractDesignInstruction(claudeMd) {
667
730
  }
668
731
  function buildDesignPrompt(project) {
669
732
  const claudeMd = existsSync7(join8(project.root, "CLAUDE.md")) ? readText(join8(project.root, "CLAUDE.md")) : "";
670
- const instruction = extractDesignInstruction(claudeMd) ?? DESIGN_HEADING;
733
+ let instruction = extractDesignInstruction(claudeMd) ?? DESIGN_HEADING;
734
+ if (instruction.length > MAX_INSTRUCTION_CHARS) {
735
+ instruction = `${instruction.slice(0, MAX_INSTRUCTION_CHARS)}
736
+ [\u2026truncated by vitrine: the design instruction exceeds ${MAX_INSTRUCTION_CHARS} chars]`;
737
+ }
671
738
  const tokens = TOKEN_CSS_VARS2.map((v) => ` ${v}`).join("\n");
672
739
  return [
673
740
  instruction,
@@ -687,6 +754,19 @@ var defaultRunner = ({ bin, args, cwd }) => {
687
754
  if (res.error) throw res.error;
688
755
  return res.status ?? 0;
689
756
  };
757
+ function preflightClaude(bin) {
758
+ const res = spawnSync(bin, ["--version"], {
759
+ stdio: "ignore",
760
+ // .cmd/.bat shims cannot be spawned directly on modern Node — safe here since
761
+ // the args carry no user input.
762
+ shell: process.platform === "win32" && /\.(cmd|bat)$/i.test(bin)
763
+ });
764
+ if (res.error || res.status !== 0) {
765
+ throw new Error(
766
+ `[vitrine] "${bin}" did not answer --version \u2014 Claude Code missing or broken? Reinstall it (npm i -g @anthropic-ai/claude-code) or pass another --bin.`
767
+ );
768
+ }
769
+ }
690
770
  function planDesignApply(project, opts = {}) {
691
771
  const bin = findClaudeBin(opts.bin);
692
772
  const prompt = buildDesignPrompt(project);
@@ -704,10 +784,12 @@ function designApply(project, opts = {}, runner = defaultRunner) {
704
784
  console.log(`[vitrine] dry-run: ${cmd.bin} -p <prompt ${cmd.prompt.length} chars> --permission-mode acceptEdits`);
705
785
  return 0;
706
786
  }
787
+ if (runner === defaultRunner) preflightClaude(cmd.bin);
707
788
  return runner(cmd);
708
789
  }
709
790
 
710
791
  // src/doctor.ts
792
+ import { readdirSync as readdirSync3 } from "fs";
711
793
  import { join as join9 } from "path";
712
794
  function runDoctor(project, registry) {
713
795
  const paths = projectPaths(project.root);
@@ -719,12 +801,13 @@ function runDoctor(project, registry) {
719
801
  const configText = exists(paths.config) ? readText(paths.config) : "";
720
802
  const slotsText = exists(paths.slots) ? readText(paths.slots) : "";
721
803
  const paymentsText = exists(paths.payments) ? readText(paths.payments) : "";
804
+ const claudeText = exists(paths.claude) ? readText(paths.claude) : "";
722
805
  for (const core of ["@vitrine-kit/contracts", "@vitrine-kit/core"]) {
723
806
  if (!deps[core]) {
724
807
  add({ severity: "error", scope: "packages", message: `missing dependency ${core}`, fix: "add it to package.json" });
725
808
  }
726
809
  }
727
- if (exists(paths.claude) && !readText(paths.claude).includes("INSTRUCTION: apply the design")) {
810
+ if (exists(paths.claude) && !claudeText.includes("INSTRUCTION: apply the design")) {
728
811
  add({
729
812
  severity: "warn",
730
813
  scope: "design",
@@ -732,6 +815,50 @@ function runDoctor(project, registry) {
732
815
  fix: "update CLAUDE.md (kit update brings a fresh instruction)"
733
816
  });
734
817
  }
818
+ const markerPairs = [
819
+ { file: "site.config.ts", text: configText, start: "// vitrine:features:start", end: "// vitrine:features:end" },
820
+ { file: "site.config.ts", text: configText, start: "// vitrine:integrations:start", end: "// vitrine:integrations:end" },
821
+ ...exists(paths.claude) ? [{ file: "CLAUDE.md", text: claudeText, start: "<!-- vitrine:features:start -->", end: "<!-- vitrine:features:end -->" }] : []
822
+ ];
823
+ for (const m of markerPairs) {
824
+ const si = m.text.indexOf(m.start);
825
+ const ei = m.text.indexOf(m.end);
826
+ if (si === -1 || ei === -1 || ei < si) {
827
+ add({
828
+ severity: "error",
829
+ scope: "markers",
830
+ message: `${m.file}: managed markers "${m.start}" / "${m.end}" are missing or unpaired`,
831
+ fix: "restore the marker lines \u2014 add/update/remove cannot regenerate without them"
832
+ });
833
+ }
834
+ }
835
+ if (exists(join9(project.root, ".env"))) {
836
+ const gi = exists(join9(project.root, ".gitignore")) ? readText(join9(project.root, ".gitignore")) : "";
837
+ const covered = gi.split("\n").some((l) => /^\.env(\*)?$/.test(l.trim()));
838
+ if (!covered) {
839
+ add({
840
+ severity: "warn",
841
+ scope: "env",
842
+ message: '.env exists but .gitignore has no ".env" entry',
843
+ fix: 'add ".env" to .gitignore \u2014 secrets must not be committed'
844
+ });
845
+ }
846
+ }
847
+ if (isDir(paths.originals)) {
848
+ for (const entry of readdirSync3(paths.originals)) {
849
+ const at = entry.lastIndexOf("@");
850
+ const name = at > 0 ? entry.slice(0, at) : entry;
851
+ const version = at > 0 ? entry.slice(at + 1) : "";
852
+ if (project.lock.features[name]?.version !== version) {
853
+ add({
854
+ severity: "warn",
855
+ scope: "originals",
856
+ message: `orphaned snapshot "${entry}" (no installed feature@version matches)`,
857
+ fix: `delete .vitrine/originals/${entry}`
858
+ });
859
+ }
860
+ }
861
+ }
735
862
  for (const [name, pin] of Object.entries(project.lock.features)) {
736
863
  const scope = `feature:${name}`;
737
864
  if (!registry.hasFeature(name)) {
@@ -948,11 +1075,11 @@ function planUpdate(project, name, registry) {
948
1075
  const files = [];
949
1076
  for (const map of manifest.files) {
950
1077
  for (const file of eachFeatureFile(featDir, map)) {
951
- const theirs = readText(file.srcAbs);
952
- const oursPath = join10(project.root, file.repoRel);
953
- const basePath = join10(originalsBase, file.repoRel);
954
- const ours = exists(oursPath) ? readText(oursPath) : null;
955
- const base = exists(basePath) ? readText(basePath) : null;
1078
+ const theirs = normalizeEol(readText(file.srcAbs));
1079
+ const oursPath = safeJoin(project.root, file.repoRel);
1080
+ const basePath = safeJoin(originalsBase, file.repoRel);
1081
+ const ours = exists(oursPath) ? normalizeEol(readText(oursPath)) : null;
1082
+ const base = exists(basePath) ? normalizeEol(readText(basePath)) : null;
956
1083
  if (ours === null) {
957
1084
  files.push({ to: file.toRel, status: "new", merged: theirs, conflicts: 0 });
958
1085
  } else if (theirs === base || theirs === ours) {
@@ -995,6 +1122,7 @@ function applyUpdate(project, plan, registry) {
995
1122
  tx.commit();
996
1123
  } catch (error) {
997
1124
  tx.rollback();
1125
+ project.lock.features[plan.feature] = { version: plan.fromVersion };
998
1126
  throw error;
999
1127
  }
1000
1128
  if (plan.fromVersion !== plan.toVersion) {
@@ -1011,34 +1139,41 @@ function renderPlan(plan) {
1011
1139
  }
1012
1140
 
1013
1141
  // src/commands.ts
1014
- function requireProject() {
1142
+ function requireProject(explicitRoot) {
1143
+ if (explicitRoot) {
1144
+ const root2 = resolve5(explicitRoot);
1145
+ if (!exists(join11(root2, "vitrine.json"))) {
1146
+ throw new Error(`[vitrine] no vitrine.json in "${root2}" (--project must point at a client repo root)`);
1147
+ }
1148
+ return loadProject(root2);
1149
+ }
1015
1150
  const root = findProjectRoot();
1016
1151
  if (!root) {
1017
1152
  throw new Error("[vitrine] vitrine.json not found \u2014 not a Vitrine client repository");
1018
1153
  }
1019
1154
  return loadProject(root);
1020
1155
  }
1021
- function addFeatures(names, registryRoot) {
1022
- return installFeatures(requireProject(), names, createRegistrySource(registryRoot));
1156
+ function addFeatures(names, registryRoot, projectRoot) {
1157
+ return installFeatures(requireProject(projectRoot), names, createRegistrySource(registryRoot));
1023
1158
  }
1024
- function removeFeatureCmd(name, registryRoot) {
1025
- removeFeature(requireProject(), name, createRegistrySource(registryRoot));
1159
+ function removeFeatureCmd(name, registryRoot, projectRoot) {
1160
+ removeFeature(requireProject(projectRoot), name, createRegistrySource(registryRoot));
1026
1161
  }
1027
- function listFeatures(registryRoot) {
1028
- const project = requireProject();
1162
+ function listFeatures(registryRoot, projectRoot) {
1163
+ const project = requireProject(projectRoot);
1029
1164
  const registry = createRegistrySource(registryRoot);
1030
1165
  const installed = Object.keys(project.lock.features);
1031
1166
  const available = registry.listFeatures().filter((name) => !installed.includes(name));
1032
1167
  return { installed, available };
1033
1168
  }
1034
1169
  function designApplyCmd(opts = {}) {
1035
- return designApply(requireProject(), { bin: opts.bin, dryRun: opts.dryRun });
1170
+ return designApply(requireProject(opts.projectRoot), { bin: opts.bin, dryRun: opts.dryRun });
1036
1171
  }
1037
- function doctorCmd(registryRoot) {
1038
- return runDoctor(requireProject(), createRegistrySource(registryRoot));
1172
+ function doctorCmd(registryRoot, projectRoot) {
1173
+ return runDoctor(requireProject(projectRoot), createRegistrySource(registryRoot));
1039
1174
  }
1040
1175
  function updateFeaturesCmd(names, registryRoot, opts = {}) {
1041
- const project = requireProject();
1176
+ const project = requireProject(opts.projectRoot);
1042
1177
  const registry = createRegistrySource(registryRoot);
1043
1178
  const targets = names.length > 0 ? names : Object.keys(project.lock.features);
1044
1179
  return targets.map((name) => {
@@ -1048,16 +1183,16 @@ function updateFeaturesCmd(names, registryRoot, opts = {}) {
1048
1183
  return { plan, applied };
1049
1184
  });
1050
1185
  }
1051
- function diffFeatureCmd(name, registryRoot) {
1052
- return planUpdate(requireProject(), name, createRegistrySource(registryRoot));
1186
+ function diffFeatureCmd(name, registryRoot, projectRoot) {
1187
+ return planUpdate(requireProject(projectRoot), name, createRegistrySource(registryRoot));
1053
1188
  }
1054
1189
 
1055
1190
  // src/init.ts
1056
- import { readdirSync as readdirSync3 } from "fs";
1057
- import { join as join12 } from "path";
1191
+ import { readdirSync as readdirSync4 } from "fs";
1192
+ import { join as join13 } from "path";
1058
1193
 
1059
1194
  // src/kit-versions.generated.ts
1060
- var KIT_VERSION = "0.4.0";
1195
+ var KIT_VERSION = "0.4.1";
1061
1196
  var CONTRACTS_VERSION = "1.0.0";
1062
1197
  var CONTRACTS_RANGE = "^1.0.0";
1063
1198
  var CORE_RANGE = "^0.2.0";
@@ -1071,17 +1206,17 @@ var PAYLOAD_RANGE = "^3.0.0";
1071
1206
 
1072
1207
  // src/templates.ts
1073
1208
  import { existsSync as existsSync8 } from "fs";
1074
- import { dirname as dirname6, join as join11 } from "path";
1209
+ import { dirname as dirname6, join as join12 } from "path";
1075
1210
  function templatesRoot(registryRoot) {
1076
- return join11(dirname6(registryRoot), "templates");
1211
+ return join12(dirname6(registryRoot), "templates");
1077
1212
  }
1078
1213
  function hasTemplate(root, name) {
1079
- return existsSync8(join11(root, name, "files"));
1214
+ return existsSync8(join12(root, name, "files"));
1080
1215
  }
1081
1216
  function copyTemplate(root, name, destRoot) {
1082
- const filesDir = join11(root, name, "files");
1217
+ const filesDir = join12(root, name, "files");
1083
1218
  const rels = walkRelFiles(filesDir);
1084
- for (const rel of rels) writeText(join11(destRoot, rel), readText(join11(filesDir, rel)));
1219
+ for (const rel of rels) writeText(join12(destRoot, rel), readText(join12(filesDir, rel)));
1085
1220
  return rels;
1086
1221
  }
1087
1222
 
@@ -1184,6 +1319,9 @@ function clientEnvExample(backend) {
1184
1319
  "# DB. Empty in dev \u2014 built-in SQLite (.vitrine/vendure.sqlite).",
1185
1320
  "DATABASE_URL=",
1186
1321
  "",
1322
+ "# Postgres password for docker compose (db service; compose default: vitrine).",
1323
+ "POSTGRES_PASSWORD=",
1324
+ "",
1187
1325
  "# Vendure Shop API (storefront \u2192 server).",
1188
1326
  "VENDURE_SHOP_API_URL=http://localhost:3001/shop-api",
1189
1327
  "",
@@ -1206,6 +1344,9 @@ function clientEnvExample(backend) {
1206
1344
  "# DB. For local dev you can leave it empty \u2014 a SQLite fallback is used (.vitrine/dev.sqlite).",
1207
1345
  "DATABASE_URL=",
1208
1346
  "",
1347
+ "# Postgres password for docker compose (db service; compose default: vitrine).",
1348
+ "POSTGRES_PASSWORD=",
1349
+ "",
1209
1350
  "# Payload secret (required; generate a random one for prod).",
1210
1351
  "PAYLOAD_SECRET=",
1211
1352
  "",
@@ -1313,10 +1454,10 @@ function scaffoldBase(opts) {
1313
1454
  const backendTemplate = `backend-${backend}`;
1314
1455
  if (hasTemplate(tRoot, backendTemplate)) copyTemplate(tRoot, backendTemplate, root);
1315
1456
  if (!baseCopied) {
1316
- writeText(join12(root, ".gitignore"), "node_modules/\n.next/\ndist/\n.env\n.env.local\n.vitrine/\n");
1457
+ writeText(join13(root, ".gitignore"), "node_modules/\n.next/\ndist/\n.env\n.env.local\n.vitrine/\n");
1317
1458
  }
1318
1459
  writeText(
1319
- join12(root, "vitrine.json"),
1460
+ join13(root, "vitrine.json"),
1320
1461
  `${JSON.stringify(
1321
1462
  { kitVersion: KIT_VERSION, contracts: CONTRACTS_VERSION, backend, tier, features: {} },
1322
1463
  null,
@@ -1325,7 +1466,7 @@ function scaffoldBase(opts) {
1325
1466
  `
1326
1467
  );
1327
1468
  writeText(
1328
- join12(root, "site.config.ts"),
1469
+ join13(root, "site.config.ts"),
1329
1470
  `import type { SiteConfig } from '@vitrine-kit/contracts';
1330
1471
 
1331
1472
  export const siteConfig: SiteConfig = {
@@ -1346,7 +1487,7 @@ export default siteConfig;
1346
1487
  `
1347
1488
  );
1348
1489
  writeText(
1349
- join12(root, "CLAUDE.md"),
1490
+ join13(root, "CLAUDE.md"),
1350
1491
  `# ${name}
1351
1492
 
1352
1493
  A Vitrine project. Backend: \`${backend}\`, tier: \`${tier}\`.
@@ -1412,16 +1553,16 @@ The step is idempotent: re-running converges and doesn't accumulate cruft.
1412
1553
  - **The user makes commits** \u2014 don't run \`git commit\`/\`git push\` without an explicit request.
1413
1554
  `
1414
1555
  );
1415
- writeText(join12(root, "README.md"), clientReadme(name, backend, tier));
1416
- writeText(join12(root, "lib", "slots.ts"), renderSlotsFile([]));
1417
- writeText(join12(root, "lib", "blueprint.ts"), renderBlueprintFile([]));
1418
- writeText(join12(root, "theme", "client.css"), renderNeutralTheme());
1419
- writeText(join12(root, ".env.example"), clientEnvExample(backend));
1420
- writeText(join12(root, "package.json"), `${JSON.stringify(clientPackageJson(name, backend), null, 2)}
1556
+ writeText(join13(root, "README.md"), clientReadme(name, backend, tier));
1557
+ writeText(join13(root, "lib", "slots.ts"), renderSlotsFile([]));
1558
+ writeText(join13(root, "lib", "blueprint.ts"), renderBlueprintFile([]));
1559
+ writeText(join13(root, "theme", "client.css"), renderNeutralTheme());
1560
+ writeText(join13(root, ".env.example"), clientEnvExample(backend));
1561
+ writeText(join13(root, "package.json"), `${JSON.stringify(clientPackageJson(name, backend), null, 2)}
1421
1562
  `);
1422
1563
  }
1423
1564
  function initProject(opts) {
1424
- if (exists(opts.root) && readdirSync3(opts.root).length > 0) {
1565
+ if (exists(opts.root) && readdirSync4(opts.root).length > 0) {
1425
1566
  throw new Error(`[vitrine] directory "${opts.root}" is not empty`);
1426
1567
  }
1427
1568
  scaffoldBase(opts);
@@ -1431,22 +1572,21 @@ function initProject(opts) {
1431
1572
 
1432
1573
  // src/kit-update.ts
1433
1574
  import { spawnSync as spawnSync2 } from "child_process";
1434
- import { existsSync as existsSync9, mkdtempSync, readdirSync as readdirSync4, statSync as statSync2 } from "fs";
1575
+ import { existsSync as existsSync9, mkdtempSync, readdirSync as readdirSync5, rmSync as rmSync4, statSync as statSync2 } from "fs";
1435
1576
  import { tmpdir } from "os";
1436
- import { join as join13, resolve as resolve5 } from "path";
1577
+ import { join as join14, resolve as resolve6 } from "path";
1437
1578
  var REPO = "vitrine-kit/vitrine";
1438
1579
  function hasBin(bin) {
1439
1580
  const probe = process.platform === "win32" ? "where" : "which";
1440
1581
  return spawnSync2(probe, [bin], { stdio: "ignore" }).status === 0;
1441
1582
  }
1442
- function acquireFromGh(version) {
1583
+ function acquireFromGh(tmp, version) {
1443
1584
  if (!hasBin("gh")) {
1444
1585
  throw new Error("[vitrine] network update needs gh (GitHub CLI), or pass --from <dir>");
1445
1586
  }
1446
1587
  if (!hasBin("tar")) {
1447
1588
  throw new Error("[vitrine] unpacking needs tar, or pass --from <dir>");
1448
1589
  }
1449
- const tmp = mkdtempSync(join13(tmpdir(), "vitrine-kit-"));
1450
1590
  const dl = spawnSync2(
1451
1591
  "gh",
1452
1592
  ["release", "download", ...version ? [version] : [], "--repo", REPO, "--archive=tar.gz", "--dir", tmp, "--clobber"],
@@ -1455,22 +1595,41 @@ function acquireFromGh(version) {
1455
1595
  if (dl.status !== 0) {
1456
1596
  throw new Error("[vitrine] gh release download failed (check access/auth: gh auth status, and gh auth login if needed)");
1457
1597
  }
1458
- const tarball = readdirSync4(tmp).find((f) => f.endsWith(".tar.gz"));
1598
+ const tarball = readdirSync5(tmp).find((f) => f.endsWith(".tar.gz"));
1459
1599
  if (!tarball) throw new Error("[vitrine] release tarball not found after download");
1460
- if (spawnSync2("tar", ["-xzf", join13(tmp, tarball), "-C", tmp], { stdio: "inherit" }).status !== 0) {
1600
+ if (spawnSync2("tar", ["-xzf", join14(tmp, tarball), "-C", tmp], { stdio: "inherit" }).status !== 0) {
1461
1601
  throw new Error("[vitrine] tarball extraction failed");
1462
1602
  }
1463
- const root = readdirSync4(tmp).map((f) => join13(tmp, f)).find((p2) => statSync2(p2).isDirectory() && existsSync9(join13(p2, "registry", "_index.json")));
1603
+ const root = readdirSync5(tmp).map((f) => join14(tmp, f)).find((p2) => statSync2(p2).isDirectory() && existsSync9(join14(p2, "registry", "_index.json")));
1464
1604
  if (!root) throw new Error("[vitrine] the unpacked tarball has no registry/_index.json");
1465
1605
  return root;
1466
1606
  }
1607
+ function warnOnVersionMismatch(root, requested) {
1608
+ if (!requested) return;
1609
+ const tagSemver = requested.match(/^@vitrine-kit\/vitrine@(.+)$/)?.[1] ?? requested.match(/^v?(\d+\.\d+\.\d+\S*)$/)?.[1];
1610
+ const pkgFile = join14(root, "packages", "cli", "package.json");
1611
+ if (!tagSemver || !exists(pkgFile)) return;
1612
+ const version = readJson(pkgFile).version;
1613
+ if (version && version !== tagSemver) {
1614
+ console.warn(`[vitrine] requested "${requested}" but the downloaded kit is @vitrine-kit/vitrine@${version}`);
1615
+ }
1616
+ }
1467
1617
  function kitUpdate(opts = {}) {
1468
- const source = opts.from ? resolve5(opts.from) : acquireFromGh(opts.version);
1469
- return populateCache(source, { home: opts.home, channel: opts.channel ?? "stable" });
1618
+ if (opts.from) {
1619
+ return populateCache(resolve6(opts.from), { home: opts.home, channel: opts.channel ?? "stable" });
1620
+ }
1621
+ const tmp = mkdtempSync(join14(tmpdir(), "vitrine-kit-"));
1622
+ try {
1623
+ const source = acquireFromGh(tmp, opts.version);
1624
+ warnOnVersionMismatch(source, opts.version);
1625
+ return populateCache(source, { home: opts.home, channel: opts.channel ?? "stable" });
1626
+ } finally {
1627
+ rmSync4(tmp, { recursive: true, force: true });
1628
+ }
1470
1629
  }
1471
1630
  function kitStatus(home = vitrineHome()) {
1472
1631
  const meta = readKitMeta(home);
1473
- const idxFile = join13(cachePaths(home).registry, "_index.json");
1632
+ const idxFile = join14(cachePaths(home).registry, "_index.json");
1474
1633
  const idx = exists(idxFile) ? readJson(idxFile) : null;
1475
1634
  return {
1476
1635
  cached: meta !== null,
@@ -1496,16 +1655,16 @@ function selfUpdate(opts = {}) {
1496
1655
  var pkg = JSON.parse(readFileSync3(new URL("../package.json", import.meta.url), "utf8"));
1497
1656
  var program = new Command();
1498
1657
  program.name("vitrine").description("Vitrine CLI").version(pkg.version);
1499
- program.command("add").description("Add feature(s) to the current repository").argument("<features...>", "feature names").option("--registry <path>", "path to the registry").action((features, opts) => {
1500
- const res = addFeatures(features, opts.registry);
1658
+ program.command("add").description("Add feature(s) to the current repository").argument("<features...>", "feature names").option("--registry <path>", "path to the registry").option("--project <path>", "client repo root (default: walk up from cwd)").action((features, opts) => {
1659
+ const res = addFeatures(features, opts.registry, opts.project);
1501
1660
  console.log(res.installed.length ? `Installed: ${res.installed.join(", ")}` : "Already installed \u2014 no changes.");
1502
1661
  });
1503
- program.command("remove").description("Remove a feature (if removable)").argument("<feature>", "feature name").option("--registry <path>", "path to the registry").action((feature, opts) => {
1504
- removeFeatureCmd(feature, opts.registry);
1662
+ program.command("remove").description("Remove a feature (if removable)").argument("<feature>", "feature name").option("--registry <path>", "path to the registry").option("--project <path>", "client repo root (default: walk up from cwd)").action((feature, opts) => {
1663
+ removeFeatureCmd(feature, opts.registry, opts.project);
1505
1664
  console.log(`Removed: ${feature}`);
1506
1665
  });
1507
- program.command("update").description("Update an installed feature (3-way merge); all if no arguments").argument("[features...]", "feature names").option("--registry <path>", "path to the registry").option("--dry-run", "show the plan without writing").action((features, opts) => {
1508
- const outcomes = updateFeaturesCmd(features, opts.registry, { dryRun: opts.dryRun });
1666
+ program.command("update").description("Update an installed feature (3-way merge); all if no arguments").argument("[features...]", "feature names").option("--registry <path>", "path to the registry").option("--dry-run", "show the plan without writing").option("--project <path>", "client repo root (default: walk up from cwd)").action((features, opts) => {
1667
+ const outcomes = updateFeaturesCmd(features, opts.registry, { dryRun: opts.dryRun, projectRoot: opts.project });
1509
1668
  let conflicts = 0;
1510
1669
  for (const { plan, applied } of outcomes) {
1511
1670
  console.log(renderPlan(plan));
@@ -1517,17 +1676,17 @@ program.command("update").description("Update an installed feature (3-way merge)
1517
1676
  \u26A0 conflicts: ${conflicts}. Resolve the git markers (<<<<<<< / >>>>>>>) by hand.`);
1518
1677
  }
1519
1678
  });
1520
- program.command("diff").description("Preview update changes (without writing)").argument("<feature>", "feature name").option("--registry <path>", "path to the registry").action((feature, opts) => {
1521
- console.log(renderPlan(diffFeatureCmd(feature, opts.registry)));
1679
+ program.command("diff").description("Preview update changes (without writing)").argument("<feature>", "feature name").option("--registry <path>", "path to the registry").option("--project <path>", "client repo root (default: walk up from cwd)").action((feature, opts) => {
1680
+ console.log(renderPlan(diffFeatureCmd(feature, opts.registry, opts.project)));
1522
1681
  });
1523
- program.command("list").description("Installed and available features").option("--registry <path>", "path to the registry").action((opts) => {
1524
- const { installed, available } = listFeatures(opts.registry);
1682
+ program.command("list").description("Installed and available features").option("--registry <path>", "path to the registry").option("--project <path>", "client repo root (default: walk up from cwd)").action((opts) => {
1683
+ const { installed, available } = listFeatures(opts.registry, opts.project);
1525
1684
  console.log("Installed:", installed.join(", ") || "\u2014");
1526
1685
  console.log("Available:", available.join(", ") || "\u2014");
1527
1686
  });
1528
1687
  var design = program.command("design").description("AI design step (wrapper over Claude Code)");
1529
- design.command("apply").description("Apply the design from /design to the tokens (via Claude Code)").option("--bin <path>", "path to Claude Code (otherwise VITRINE_CLAUDE_BIN / PATH)").option("--dry-run", "show the command without running").action((opts) => {
1530
- const code = designApplyCmd({ bin: opts.bin, dryRun: opts.dryRun });
1688
+ design.command("apply").description("Apply the design from /design to the tokens (via Claude Code)").option("--bin <path>", "path to Claude Code (otherwise VITRINE_CLAUDE_BIN / PATH)").option("--dry-run", "show the command without running").option("--project <path>", "client repo root (default: walk up from cwd)").action((opts) => {
1689
+ const code = designApplyCmd({ bin: opts.bin, dryRun: opts.dryRun, projectRoot: opts.project });
1531
1690
  if (code !== 0) process.exit(code);
1532
1691
  });
1533
1692
  var kit = program.command("kit").description("Local tooling (the ~/.vitrine registry cache)");
@@ -1549,8 +1708,8 @@ program.command("self-update").description("Update the CLI itself (@vitrine-kit/
1549
1708
  const code = selfUpdate({ dryRun: opts.dryRun });
1550
1709
  if (code !== 0) process.exit(code);
1551
1710
  });
1552
- program.command("doctor").description("Check consistency: vitrine.json \u2194 files \u2194 packages \u2194 env").option("--registry <path>", "path to the registry").action((opts) => {
1553
- const report = doctorCmd(opts.registry);
1711
+ program.command("doctor").description("Check consistency: vitrine.json \u2194 files \u2194 packages \u2194 env").option("--registry <path>", "path to the registry").option("--project <path>", "client repo root (default: walk up from cwd)").action((opts) => {
1712
+ const report = doctorCmd(opts.registry, opts.project);
1554
1713
  if (report.issues.length === 0) {
1555
1714
  console.log("\u2713 No issues found.");
1556
1715
  return;
@@ -1614,7 +1773,7 @@ program.command("init").description("Create a new client repository").argument("
1614
1773
  const finalTier = tier ?? "catalog";
1615
1774
  const backend = opts.backend ?? defaultBackend(finalTier);
1616
1775
  const finalFeatures = features ?? suggestFeatures(finalTier, registry, backend);
1617
- const root = resolve6(String(opts.dir ?? process.cwd()), name);
1776
+ const root = resolve7(String(opts.dir ?? process.cwd()), name);
1618
1777
  const res = initProject({ root, name, backend, tier: finalTier, features: finalFeatures, registry });
1619
1778
  console.log(`Created project "${name}" \u2192 ${root}`);
1620
1779
  console.log(`Installed: ${res.installed.join(", ") || "\u2014"}`);
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "$schema": "../schemas/registry-index.schema.json",
3
- "kitVersion": "0.4.0",
3
+ "kitVersion": "0.4.1",
4
4
  "contracts": "1.0.0",
5
5
  "features": {
6
6
  "catalog": {
7
7
  "title": "Product catalog",
8
- "kitVersion": "0.4.0",
8
+ "kitVersion": "0.4.1",
9
9
  "tier": [
10
10
  "catalog",
11
11
  "simple-store",
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "product-page": {
16
16
  "title": "Product page",
17
- "kitVersion": "0.4.0",
17
+ "kitVersion": "0.4.1",
18
18
  "tier": [
19
19
  "catalog",
20
20
  "simple-store",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "seo": {
25
25
  "title": "SEO — metadata and JSON-LD",
26
- "kitVersion": "0.4.0",
26
+ "kitVersion": "0.4.1",
27
27
  "tier": [
28
28
  "catalog",
29
29
  "simple-store",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "cart": {
34
34
  "title": "Cart",
35
- "kitVersion": "0.4.0",
35
+ "kitVersion": "0.4.1",
36
36
  "tier": [
37
37
  "simple-store",
38
38
  "full-store"
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "checkout": {
42
42
  "title": "Checkout",
43
- "kitVersion": "0.4.0",
43
+ "kitVersion": "0.4.1",
44
44
  "tier": [
45
45
  "simple-store",
46
46
  "full-store"
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "checkout-stripe": {
50
50
  "title": "Payment — Stripe",
51
- "kitVersion": "0.4.0",
51
+ "kitVersion": "0.4.1",
52
52
  "tier": [
53
53
  "simple-store",
54
54
  "full-store"
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "checkout-paddle": {
58
58
  "title": "Payment — Paddle",
59
- "kitVersion": "0.4.0",
59
+ "kitVersion": "0.4.1",
60
60
  "tier": [
61
61
  "simple-store",
62
62
  "full-store"
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "checkout-yookassa": {
66
66
  "title": "Payment — YooKassa",
67
- "kitVersion": "0.4.0",
67
+ "kitVersion": "0.4.1",
68
68
  "tier": [
69
69
  "simple-store",
70
70
  "full-store"
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../schemas/feature.schema.json",
3
3
  "name": "cart",
4
4
  "title": "Cart",
5
- "kitVersion": "0.4.0",
5
+ "kitVersion": "0.4.1",
6
6
  "requiresContracts": ">=1.0.0 <2.0.0",
7
7
  "tier": [
8
8
  "simple-store",
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../schemas/feature.schema.json",
3
3
  "name": "catalog",
4
4
  "title": "Product catalog",
5
- "kitVersion": "0.4.0",
5
+ "kitVersion": "0.4.1",
6
6
  "requiresContracts": ">=1.0.0 <2.0.0",
7
7
  "tier": [
8
8
  "catalog",
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../schemas/feature.schema.json",
3
3
  "name": "checkout",
4
4
  "title": "Checkout",
5
- "kitVersion": "0.4.0",
5
+ "kitVersion": "0.4.1",
6
6
  "requiresContracts": ">=1.0.0 <2.0.0",
7
7
  "tier": [
8
8
  "simple-store",
@@ -6,26 +6,41 @@ import { useState } from 'react';
6
6
 
7
7
  export function CheckoutButton() {
8
8
  const [pending, setPending] = useState(false);
9
+ const [error, setError] = useState<string | null>(null);
9
10
 
10
11
  async function checkout(): Promise<void> {
11
12
  setPending(true);
13
+ setError(null);
12
14
  try {
13
15
  const res = await fetch('/api/checkout', { method: 'POST' });
14
- const data = (await res.json()) as { url?: string; error?: string };
15
- if (data.url) location.assign(data.url);
16
+ const data = (await res.json().catch(() => ({}))) as { url?: string; error?: string };
17
+ if (!res.ok || !data.url) {
18
+ setError(data.error ?? 'Checkout is unavailable — try again later.');
19
+ return;
20
+ }
21
+ location.assign(data.url);
22
+ } catch {
23
+ setError('Checkout is unavailable — try again later.');
16
24
  } finally {
17
25
  setPending(false);
18
26
  }
19
27
  }
20
28
 
21
29
  return (
22
- <button
23
- type="button"
24
- onClick={checkout}
25
- disabled={pending}
26
- className="vt-checkout-button rounded-md bg-primary px-gutter py-unit text-primary-fg transition hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 ring-ring"
27
- >
28
- {pending ? 'Redirecting to payment…' : 'Checkout'}
29
- </button>
30
+ <>
31
+ <button
32
+ type="button"
33
+ onClick={checkout}
34
+ disabled={pending}
35
+ className="vt-checkout-button rounded-md bg-primary px-gutter py-unit text-primary-fg transition hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 ring-ring"
36
+ >
37
+ {pending ? 'Redirecting to payment…' : 'Checkout'}
38
+ </button>
39
+ {error ? (
40
+ <p role="alert" className="vt-checkout-error text-danger">
41
+ {error}
42
+ </p>
43
+ ) : null}
44
+ </>
30
45
  );
31
46
  }
@@ -8,12 +8,15 @@ in `@vitrine-kit/core`.
8
8
  - **Provider:** `lib/checkout-paddle/provider.ts` → `paddleProvider`
9
9
  (`PaymentProvider`): `createCheckout` creates a Paddle transaction with non-catalog
10
10
  line items (prices from the cart, `customData.cartId`); `verifyWebhook` verifies the
11
- `Paddle-Signature` via `@paddle/paddle-node-sdk`.
11
+ `Paddle-Signature` via `@paddle/paddle-node-sdk`. The notification carries no email, so
12
+ the provider does a best-effort `customers.get(customerId)` lookup for the order's
13
+ `email` — a lookup failure does not fail fulfillment.
12
14
  - **Registration:** `registerCheckoutPaddleProvider()` (from `lib/payments.ts`), sets
13
15
  `integrations.payments: "paddle"`.
14
16
  - **API (Next glue):** `POST /api/webhooks/paddle` → `handlePaymentWebhook` →
15
17
  `fulfillOrderFromEvent` (on `transaction.completed`/`transaction.paid`).
16
- - **env:** `PADDLE_API_KEY`, `PADDLE_WEBHOOK_SECRET` (required);
18
+ - **env:** `PADDLE_API_KEY`, `PADDLE_WEBHOOK_SECRET` (required — read at call time, a
19
+ missing key throws `[vitrine] <KEY> is not set …`; webhook route → 400);
17
20
  `PADDLE_ENVIRONMENT` (`sandbox`|`production`, default `sandbox`),
18
21
  `PADDLE_CHECKOUT_URL` (override the hosted checkout, otherwise a default payment link
19
22
  in the Paddle dashboard is required) — optional.
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../schemas/feature.schema.json",
3
3
  "name": "checkout-paddle",
4
4
  "title": "Payment — Paddle",
5
- "kitVersion": "0.4.0",
5
+ "kitVersion": "0.4.1",
6
6
  "requiresContracts": ">=1.0.0 <2.0.0",
7
7
  "tier": [
8
8
  "simple-store",
@@ -9,8 +9,15 @@
9
9
  import { Environment, Paddle, type EventEntity } from '@paddle/paddle-node-sdk';
10
10
  import type { CreateCheckoutArgs, NormalizedPaymentEvent, PaymentProvider, PaymentWebhookRequest } from '@vitrine-kit/core';
11
11
 
12
+ /** Required env, read at call time — `next build` must not need secrets. */
13
+ function requiredEnv(key: string): string {
14
+ const value = process.env[key];
15
+ if (!value) throw new Error(`[vitrine] ${key} is not set — add it to .env (see .env.example)`);
16
+ return value;
17
+ }
18
+
12
19
  function client(): Paddle {
13
- return new Paddle(process.env.PADDLE_API_KEY ?? '', {
20
+ return new Paddle(requiredEnv('PADDLE_API_KEY'), {
14
21
  environment:
15
22
  process.env.PADDLE_ENVIRONMENT === 'production' ? Environment.production : Environment.sandbox,
16
23
  });
@@ -40,16 +47,30 @@ export const paddleProvider: PaymentProvider = {
40
47
 
41
48
  async verifyWebhook(req: PaymentWebhookRequest): Promise<NormalizedPaymentEvent> {
42
49
  const paddle = client();
43
- const secret = process.env.PADDLE_WEBHOOK_SECRET ?? '';
50
+ const secret = requiredEnv('PADDLE_WEBHOOK_SECRET');
44
51
  const signature = req.headers['paddle-signature'] ?? '';
45
52
  // Throws on an invalid signature — handlePaymentWebhook returns a 400.
46
53
  const event = (await paddle.webhooks.unmarshal(req.rawBody, secret, signature)) as EventEntity | null;
47
54
  if (!event) return { kind: 'unknown', raw: req.rawBody };
48
55
 
49
56
  if (event.eventType === 'transaction.completed' || event.eventType === 'transaction.paid') {
50
- const data = event.data as { id?: string; customData?: { cartId?: string } | null };
57
+ const data = event.data as {
58
+ id?: string;
59
+ customData?: { cartId?: string } | null;
60
+ customerId?: string | null;
61
+ };
51
62
  const cartId = (data.customData ?? undefined)?.cartId;
52
- return { kind: 'checkout_completed', cartId, providerRef: data.id, raw: event };
63
+ // The notification carries no email best-effort lookup on the Customer entity.
64
+ // A lookup failure must not fail fulfillment: email is optional on the event.
65
+ let email: string | undefined;
66
+ if (data.customerId) {
67
+ try {
68
+ email = (await paddle.customers.get(data.customerId)).email ?? undefined;
69
+ } catch {
70
+ // keep email undefined — the order is still created
71
+ }
72
+ }
73
+ return { kind: 'checkout_completed', cartId, providerRef: data.id, email, raw: event };
53
74
  }
54
75
  if (event.eventType === 'transaction.payment_failed') {
55
76
  return { kind: 'payment_failed', raw: event };
@@ -11,7 +11,9 @@ logic (webhook dispatcher, order from cart) lives in `@vitrine-kit/core`.
11
11
  sets `integrations.payments: "stripe"` in `site.config`.
12
12
  - **API (Next glue):** `POST /api/webhooks/stripe` → `handlePaymentWebhook` →
13
13
  `fulfillOrderFromEvent` (the `checkout` feature's shared code).
14
- - **env:** `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` (required).
14
+ - **env:** `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` (required). Read at call time
15
+ (`next build` needs no secrets); a missing key throws `[vitrine] <KEY> is not set …` —
16
+ in the webhook route this surfaces as a 400, in `POST /api/checkout` as a 500.
15
17
 
16
18
  Flow: cart → `Checkout` → redirect to Stripe → webhook
17
19
  `checkout.session.completed` → order in the admin, cart `converted`.
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../schemas/feature.schema.json",
3
3
  "name": "checkout-stripe",
4
4
  "title": "Payment — Stripe",
5
- "kitVersion": "0.4.0",
5
+ "kitVersion": "0.4.1",
6
6
  "requiresContracts": ">=1.0.0 <2.0.0",
7
7
  "tier": [
8
8
  "simple-store",
@@ -10,6 +10,13 @@ import type {
10
10
  PaymentWebhookRequest,
11
11
  } from '@vitrine-kit/core';
12
12
 
13
+ /** Required env, read at call time — `next build` must not need secrets. */
14
+ function requiredEnv(key: string): string {
15
+ const value = process.env[key];
16
+ if (!value) throw new Error(`[vitrine] ${key} is not set — add it to .env (see .env.example)`);
17
+ return value;
18
+ }
19
+
13
20
  interface StripeLineItem {
14
21
  quantity: number;
15
22
  price_data: { currency: string; unit_amount: number; product_data: { name: string } };
@@ -32,7 +39,7 @@ export const stripeProvider: PaymentProvider = {
32
39
  async createCheckout(args: CreateCheckoutArgs): Promise<{ redirectUrl: string }> {
33
40
  const { cart, baseUrl, successPath = '/order/success', cancelPath = '/cart' } = args;
34
41
  const { default: Stripe } = await import('stripe');
35
- const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '');
42
+ const stripe = new Stripe(requiredEnv('STRIPE_SECRET_KEY'));
36
43
  const session = await stripe.checkout.sessions.create({
37
44
  mode: 'payment',
38
45
  line_items: cartToStripeLineItems(cart),
@@ -45,8 +52,8 @@ export const stripeProvider: PaymentProvider = {
45
52
 
46
53
  async verifyWebhook(req: PaymentWebhookRequest): Promise<NormalizedPaymentEvent> {
47
54
  const { default: Stripe } = await import('stripe');
48
- const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '');
49
- const secret = process.env.STRIPE_WEBHOOK_SECRET ?? '';
55
+ const stripe = new Stripe(requiredEnv('STRIPE_SECRET_KEY'));
56
+ const secret = requiredEnv('STRIPE_WEBHOOK_SECRET');
50
57
  const signature = req.headers['stripe-signature'] ?? '';
51
58
  // Throws on an invalid signature — handlePaymentWebhook surfaces it as a 400.
52
59
  const event = stripe.webhooks.constructEvent(req.rawBody, signature, secret);
@@ -11,10 +11,13 @@ A YooKassa (yookassa.ru) provider — Russian acquiring: cards, SBP, wallets. Fo
11
11
  sets `integrations.payments: "yookassa"`.
12
12
  - **API (Next glue):** `POST /api/webhooks/yookassa` → `handlePaymentWebhook` →
13
13
  `fulfillOrderFromEvent`.
14
- - **env:** `YOOKASSA_SHOP_ID`, `YOOKASSA_SECRET_KEY` (required).
14
+ - **env:** `YOOKASSA_SHOP_ID`, `YOOKASSA_SECRET_KEY` (required — read at call time, a
15
+ missing key throws `[vitrine] <KEY> is not set …`; webhook route → 400).
15
16
 
16
17
  **Security:** YooKassa notifications are **not signed** — the provider re-verifies the
17
- payment via `GET /v3/payments/{id}` and trusts only `status: "succeeded"`.
18
+ payment via `GET /v3/payments/{id}` and trusts only `status: "succeeded"`. A 404 on that
19
+ check (forged/foreign payment id) is acked as `kind: "unknown"`; other API failures throw,
20
+ so the non-200 response makes YooKassa redeliver the notification.
18
21
 
19
22
  **Money:** YooKassa expects a decimal string (`"1990.00"`); `Money` is in minor
20
23
  units, so we divide by 100 (RUB and most currencies have 2 decimal places).
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../schemas/feature.schema.json",
3
3
  "name": "checkout-yookassa",
4
4
  "title": "Payment — YooKassa",
5
- "kitVersion": "0.4.0",
5
+ "kitVersion": "0.4.1",
6
6
  "requiresContracts": ">=1.0.0 <2.0.0",
7
7
  "tier": [
8
8
  "simple-store",
@@ -18,9 +18,16 @@ function minorToDecimalString(amount: number, currency: string): string {
18
18
  return ZERO_DECIMAL.has(currency.toUpperCase()) ? String(amount) : (amount / 100).toFixed(2);
19
19
  }
20
20
 
21
+ /** Required env, read at call time — `next build` must not need secrets. */
22
+ function requiredEnv(key: string): string {
23
+ const value = process.env[key];
24
+ if (!value) throw new Error(`[vitrine] ${key} is not set — add it to .env (see .env.example)`);
25
+ return value;
26
+ }
27
+
21
28
  function authHeader(): string {
22
- const shopId = process.env.YOOKASSA_SHOP_ID ?? '';
23
- const secret = process.env.YOOKASSA_SECRET_KEY ?? '';
29
+ const shopId = requiredEnv('YOOKASSA_SHOP_ID');
30
+ const secret = requiredEnv('YOOKASSA_SECRET_KEY');
24
31
  return `Basic ${Buffer.from(`${shopId}:${secret}`).toString('base64')}`;
25
32
  }
26
33
 
@@ -57,9 +64,12 @@ export const yookassaProvider: PaymentProvider = {
57
64
  const paymentId = body.object?.id;
58
65
  if (!paymentId) return { kind: 'unknown', raw: body };
59
66
 
60
- // The notification is unsigned — re-check the payment via the API.
67
+ // The notification is unsigned — re-check the payment via the API. 404 = no such
68
+ // payment for this shop (forged/foreign notification) → ack as 'unknown'; any other
69
+ // failure is transient → throw (a non-200 response makes YooKassa redeliver later).
61
70
  const res = await fetch(`${API}/${paymentId}`, { headers: { Authorization: authHeader() } });
62
- if (!res.ok) throw new Error(`[vitrine] YooKassa verify: ${res.status}`);
71
+ if (res.status === 404) return { kind: 'unknown', raw: body };
72
+ if (!res.ok) throw new Error(`[vitrine] YooKassa verify: ${res.status} (transient — expecting a redelivery)`);
63
73
  const payment = (await res.json()) as {
64
74
  id?: string;
65
75
  status?: string;
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../schemas/feature.schema.json",
3
3
  "name": "product-page",
4
4
  "title": "Product page",
5
- "kitVersion": "0.4.0",
5
+ "kitVersion": "0.4.1",
6
6
  "requiresContracts": ">=1.0.0 <2.0.0",
7
7
  "tier": [
8
8
  "catalog",
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../schemas/feature.schema.json",
3
3
  "name": "seo",
4
4
  "title": "SEO — metadata and JSON-LD",
5
- "kitVersion": "0.4.0",
5
+ "kitVersion": "0.4.1",
6
6
  "requiresContracts": ">=1.0.0 <2.0.0",
7
7
  "tier": [
8
8
  "catalog",
@@ -0,0 +1,12 @@
1
+ # Normalize line endings to LF — keeps `vitrine update` 3-way merges clean on Windows
2
+ # (.vitrine/originals and the registry are LF; a CRLF working tree would look all-changed).
3
+ * text=auto eol=lf
4
+
5
+ # Binary assets — no normalization.
6
+ *.png binary
7
+ *.jpg binary
8
+ *.jpeg binary
9
+ *.gif binary
10
+ *.ico binary
11
+ *.woff binary
12
+ *.woff2 binary
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@vitrine-kit/vitrine",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Vitrine CLI — feature install primitive, init (wizard), add, update, doctor, design apply.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
7
10
  "bin": {
8
11
  "vitrine": "./dist/index.js"
9
12
  },
@@ -22,7 +25,7 @@
22
25
  "dependencies": {
23
26
  "@clack/prompts": "^0.8.2",
24
27
  "commander": "^12.1.0",
25
- "@vitrine-kit/contracts": "1.2.0"
28
+ "@vitrine-kit/contracts": "1.2.1"
26
29
  },
27
30
  "scripts": {
28
31
  "build": "node scripts/generate-kit-versions.mjs && tsup src/index.ts --format esm --clean && node scripts/copy-kit.mjs",