@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 +243 -84
- package/kit/registry/_index.json +9 -9
- package/kit/registry/cart/feature.json +1 -1
- package/kit/registry/catalog/feature.json +1 -1
- package/kit/registry/checkout/feature.json +1 -1
- package/kit/registry/checkout/files/components/checkout/CheckoutButton.tsx +25 -10
- package/kit/registry/checkout-paddle/docs/checkout-paddle.md +5 -2
- package/kit/registry/checkout-paddle/feature.json +1 -1
- package/kit/registry/checkout-paddle/files/lib/checkout-paddle/provider.ts +25 -4
- package/kit/registry/checkout-stripe/docs/checkout-stripe.md +3 -1
- package/kit/registry/checkout-stripe/feature.json +1 -1
- package/kit/registry/checkout-stripe/files/lib/checkout-stripe/provider.ts +10 -3
- package/kit/registry/checkout-yookassa/docs/checkout-yookassa.md +5 -2
- package/kit/registry/checkout-yookassa/feature.json +1 -1
- package/kit/registry/checkout-yookassa/files/lib/checkout-yookassa/provider.ts +14 -4
- package/kit/registry/product-page/feature.json +1 -1
- package/kit/registry/seo/feature.json +1 -1
- package/kit/templates/base/files/.gitattributes +12 -0
- package/package.json +5 -2
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
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
cpSync(
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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) && !
|
|
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 =
|
|
953
|
-
const basePath =
|
|
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
|
|
1057
|
-
import { join as
|
|
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.
|
|
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
|
|
1209
|
+
import { dirname as dirname6, join as join12 } from "path";
|
|
1075
1210
|
function templatesRoot(registryRoot) {
|
|
1076
|
-
return
|
|
1211
|
+
return join12(dirname6(registryRoot), "templates");
|
|
1077
1212
|
}
|
|
1078
1213
|
function hasTemplate(root, name) {
|
|
1079
|
-
return existsSync8(
|
|
1214
|
+
return existsSync8(join12(root, name, "files"));
|
|
1080
1215
|
}
|
|
1081
1216
|
function copyTemplate(root, name, destRoot) {
|
|
1082
|
-
const filesDir =
|
|
1217
|
+
const filesDir = join12(root, name, "files");
|
|
1083
1218
|
const rels = walkRelFiles(filesDir);
|
|
1084
|
-
for (const rel of rels) writeText(
|
|
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(
|
|
1457
|
+
writeText(join13(root, ".gitignore"), "node_modules/\n.next/\ndist/\n.env\n.env.local\n.vitrine/\n");
|
|
1317
1458
|
}
|
|
1318
1459
|
writeText(
|
|
1319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1416
|
-
writeText(
|
|
1417
|
-
writeText(
|
|
1418
|
-
writeText(
|
|
1419
|
-
writeText(
|
|
1420
|
-
writeText(
|
|
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) &&
|
|
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
|
|
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
|
|
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 =
|
|
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",
|
|
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 =
|
|
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
|
-
|
|
1469
|
-
|
|
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 =
|
|
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 =
|
|
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"}`);
|
package/kit/registry/_index.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "../schemas/registry-index.schema.json",
|
|
3
|
-
"kitVersion": "0.4.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
67
|
+
"kitVersion": "0.4.1",
|
|
68
68
|
"tier": [
|
|
69
69
|
"simple-store",
|
|
70
70
|
"full-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 (
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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.
|
|
@@ -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(
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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`.
|
|
@@ -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(
|
|
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(
|
|
49
|
-
const 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).
|
|
@@ -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 =
|
|
23
|
-
const secret =
|
|
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 (
|
|
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;
|
|
@@ -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.
|
|
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.
|
|
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",
|