@ollie-shop/cli 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +3 -3
- package/CHANGELOG.md +6 -0
- package/dist/index.js +324 -12
- package/package.json +1 -1
- package/src/commands/help.tsx +6 -0
- package/src/commands/setup-cmd.ts +142 -0
- package/src/index.tsx +2 -0
- package/src/utils/config.ts +76 -0
- package/src/utils/esbuild.ts +216 -19
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @ollie-shop/cli@1.
|
|
2
|
+
> @ollie-shop/cli@1.5.0 build /home/runner/work/ollie-shop/ollie-shop/packages/cli
|
|
3
3
|
> tsup
|
|
4
4
|
|
|
5
5
|
[34mCLI[39m Building entry: src/index.tsx
|
|
@@ -9,5 +9,5 @@
|
|
|
9
9
|
[34mCLI[39m Target: node22
|
|
10
10
|
[34mCLI[39m Cleaning output folder
|
|
11
11
|
[34mESM[39m Build start
|
|
12
|
-
[32mESM[39m [1mdist/index.js [22m[
|
|
13
|
-
[32mESM[39m ⚡️ Build success in
|
|
12
|
+
[32mESM[39m [1mdist/index.js [22m[32m105.76 KB[39m
|
|
13
|
+
[32mESM[39m ⚡️ Build success in 353ms
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @ollie-shop/cli
|
|
2
2
|
|
|
3
|
+
## 1.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 54cca83: Make `ollie.json` the source of truth for Studio components via a `components` map (`{ id: { path, slot } }`), replacing per-folder `meta.json` while staying backward-compatible: existing `meta.json` projects keep working, and folders not referenced anywhere are still discovered as unlinked. Add a `setup` command that migrates legacy `meta.json` / `meta.{stage}.json` files into the config map and removes the old metas (`--dry-run` supported). The dev server exposes `/stages` and `/stage` endpoints for runtime stage switching, and the manifest now carries `otherStage` (component owned by another stage's config) and `unlinked` (no component id) flags. All additive — older Studio clients ignore the new endpoints and fields.
|
|
8
|
+
|
|
3
9
|
## 1.4.1
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/dist/index.js
CHANGED
|
@@ -70,6 +70,10 @@ function HelpCommand() {
|
|
|
70
70
|
/* @__PURE__ */ jsx(Box, { width: 24, children: /* @__PURE__ */ jsx(Text, { color: "green", children: "init" }) }),
|
|
71
71
|
/* @__PURE__ */ jsx(Text, { children: "Write store/version IDs to ollie.json" })
|
|
72
72
|
] }),
|
|
73
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
74
|
+
/* @__PURE__ */ jsx(Box, { width: 24, children: /* @__PURE__ */ jsx(Text, { color: "green", children: "setup" }) }),
|
|
75
|
+
/* @__PURE__ */ jsx(Text, { children: "Migrate legacy meta.json into the config components map" })
|
|
76
|
+
] }),
|
|
73
77
|
/* @__PURE__ */ jsxs(Box, { children: [
|
|
74
78
|
/* @__PURE__ */ jsx(Box, { width: 24, children: /* @__PURE__ */ jsx(Text, { color: "green", children: "help" }) }),
|
|
75
79
|
/* @__PURE__ */ jsx(Text, { children: "Show this help message" })
|
|
@@ -386,9 +390,14 @@ import { useCallback, useEffect as useEffect2, useRef, useState as useState2 } f
|
|
|
386
390
|
import fs2 from "fs/promises";
|
|
387
391
|
import path2 from "path";
|
|
388
392
|
import { z } from "zod";
|
|
393
|
+
var ComponentEntrySchema = z.object({
|
|
394
|
+
path: z.string(),
|
|
395
|
+
slot: z.string().optional()
|
|
396
|
+
});
|
|
389
397
|
var OllieConfigSchema = z.object({
|
|
390
398
|
storeId: z.string().uuid(),
|
|
391
|
-
versionId: z.string().uuid().optional()
|
|
399
|
+
versionId: z.string().uuid().optional(),
|
|
400
|
+
components: z.record(z.string(), ComponentEntrySchema).optional()
|
|
392
401
|
}).passthrough();
|
|
393
402
|
var CONFIG_FILE = "ollie.json";
|
|
394
403
|
function getConfigFileName(stage) {
|
|
@@ -443,6 +452,51 @@ async function saveConfig(config, options = {}) {
|
|
|
443
452
|
};
|
|
444
453
|
await fs2.writeFile(configPath, JSON.stringify(merged, null, 2));
|
|
445
454
|
}
|
|
455
|
+
async function listStages(cwd = process.cwd()) {
|
|
456
|
+
let files;
|
|
457
|
+
try {
|
|
458
|
+
files = await fs2.readdir(cwd);
|
|
459
|
+
} catch {
|
|
460
|
+
return [];
|
|
461
|
+
}
|
|
462
|
+
const seen = /* @__PURE__ */ new Set();
|
|
463
|
+
const stages = [];
|
|
464
|
+
for (const file of files.sort()) {
|
|
465
|
+
const match = file.match(/^ollie(?:\.([^.]+))?\.json$/);
|
|
466
|
+
if (!match) continue;
|
|
467
|
+
const stage = match[1] ?? "prod";
|
|
468
|
+
if (seen.has(stage)) continue;
|
|
469
|
+
seen.add(stage);
|
|
470
|
+
let versionId;
|
|
471
|
+
try {
|
|
472
|
+
const config = await loadConfig({ cwd, stage: match[1] });
|
|
473
|
+
versionId = config?.versionId;
|
|
474
|
+
} catch {
|
|
475
|
+
}
|
|
476
|
+
stages.push({ stage, versionId });
|
|
477
|
+
}
|
|
478
|
+
return stages;
|
|
479
|
+
}
|
|
480
|
+
async function upsertComponentEntry(id, values, options = {}) {
|
|
481
|
+
const { cwd = process.cwd(), stage } = options;
|
|
482
|
+
const configPath = path2.join(cwd, getConfigFileName(stage));
|
|
483
|
+
const existing = await loadConfigFile(configPath) ?? {};
|
|
484
|
+
const components = {
|
|
485
|
+
...existing.components ?? {}
|
|
486
|
+
};
|
|
487
|
+
const prev = components[id];
|
|
488
|
+
const slot = values.slot ?? prev?.slot;
|
|
489
|
+
const entry = {
|
|
490
|
+
path: prev?.path ?? values.defaultPath,
|
|
491
|
+
...slot !== void 0 ? { slot } : {}
|
|
492
|
+
};
|
|
493
|
+
components[id] = entry;
|
|
494
|
+
await fs2.writeFile(
|
|
495
|
+
configPath,
|
|
496
|
+
JSON.stringify({ ...existing, components }, null, 2)
|
|
497
|
+
);
|
|
498
|
+
return entry;
|
|
499
|
+
}
|
|
446
500
|
function resolveStage(cliStage) {
|
|
447
501
|
return cliStage || process.env.OLLIE_STAGE || void 0;
|
|
448
502
|
}
|
|
@@ -579,19 +633,84 @@ async function readComponentMeta(componentDir, stage) {
|
|
|
579
633
|
return null;
|
|
580
634
|
}
|
|
581
635
|
}
|
|
636
|
+
async function componentsByDir(cwd, stageLabel) {
|
|
637
|
+
const byDir = /* @__PURE__ */ new Map();
|
|
638
|
+
let config = null;
|
|
639
|
+
try {
|
|
640
|
+
config = await loadConfig({ cwd, stage: stageLabel });
|
|
641
|
+
} catch {
|
|
642
|
+
return byDir;
|
|
643
|
+
}
|
|
644
|
+
for (const [id, entry] of Object.entries(config?.components ?? {})) {
|
|
645
|
+
const dir = path4.resolve(cwd, entry.path);
|
|
646
|
+
if (!byDir.has(dir)) byDir.set(dir, { id, slot: entry.slot });
|
|
647
|
+
}
|
|
648
|
+
return byDir;
|
|
649
|
+
}
|
|
582
650
|
async function discoverComponents(options = {}) {
|
|
583
651
|
const { cwd = process.cwd(), stage } = options;
|
|
652
|
+
const activeStage = stage ?? "prod";
|
|
653
|
+
const buildDir = path4.join(cwd, "node_modules/.ollie", "build");
|
|
654
|
+
const components = [];
|
|
655
|
+
const mappedDirs = /* @__PURE__ */ new Set();
|
|
656
|
+
const activeByDir = await componentsByDir(cwd, activeStage);
|
|
657
|
+
const otherStageByDir = /* @__PURE__ */ new Map();
|
|
658
|
+
try {
|
|
659
|
+
for (const { stage: label } of await listStages(cwd)) {
|
|
660
|
+
if (label === activeStage) continue;
|
|
661
|
+
for (const [dir, entry] of await componentsByDir(cwd, label)) {
|
|
662
|
+
if (!activeByDir.has(dir) && !otherStageByDir.has(dir)) {
|
|
663
|
+
otherStageByDir.set(dir, entry);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
} catch {
|
|
668
|
+
}
|
|
669
|
+
for (const [componentDir, entry] of activeByDir) {
|
|
670
|
+
const entryPoint = path4.join(componentDir, "index.tsx");
|
|
671
|
+
try {
|
|
672
|
+
await fs4.access(entryPoint);
|
|
673
|
+
} catch {
|
|
674
|
+
console.warn(
|
|
675
|
+
`${entry.id}: ${componentDir}/index.tsx not found - skipping`
|
|
676
|
+
);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
const name = path4.basename(componentDir);
|
|
680
|
+
mappedDirs.add(componentDir);
|
|
681
|
+
components.push({
|
|
682
|
+
id: entry.id,
|
|
683
|
+
name,
|
|
684
|
+
slot: entry.slot,
|
|
685
|
+
entryPoint,
|
|
686
|
+
outfile: path4.join(buildDir, name, "index.js")
|
|
687
|
+
});
|
|
688
|
+
}
|
|
584
689
|
const componentsDir = path4.join(cwd, "components");
|
|
585
690
|
try {
|
|
586
691
|
await fs4.access(componentsDir);
|
|
587
692
|
} catch {
|
|
588
|
-
return
|
|
693
|
+
return components;
|
|
589
694
|
}
|
|
590
695
|
const entries = await glob("*/index.tsx", { cwd: componentsDir });
|
|
591
|
-
const components = [];
|
|
592
696
|
for (const entry of entries) {
|
|
593
697
|
const name = path4.dirname(entry);
|
|
594
|
-
const componentDir = path4.
|
|
698
|
+
const componentDir = path4.resolve(componentsDir, name);
|
|
699
|
+
if (mappedDirs.has(componentDir)) continue;
|
|
700
|
+
const entryPoint = path4.join(componentsDir, entry);
|
|
701
|
+
const outfile = path4.join(buildDir, name, "index.js");
|
|
702
|
+
const other = otherStageByDir.get(componentDir);
|
|
703
|
+
if (other) {
|
|
704
|
+
components.push({
|
|
705
|
+
id: other.id,
|
|
706
|
+
name,
|
|
707
|
+
slot: other.slot,
|
|
708
|
+
otherStage: true,
|
|
709
|
+
entryPoint,
|
|
710
|
+
outfile
|
|
711
|
+
});
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
595
714
|
const meta = await readComponentMeta(componentDir, stage);
|
|
596
715
|
const id = meta?.id ?? `studio-${name}`;
|
|
597
716
|
const isUnlinked = !meta?.id;
|
|
@@ -604,8 +723,9 @@ async function discoverComponents(options = {}) {
|
|
|
604
723
|
id,
|
|
605
724
|
name,
|
|
606
725
|
slot: meta?.slot,
|
|
607
|
-
|
|
608
|
-
|
|
726
|
+
...isUnlinked ? { unlinked: true } : {},
|
|
727
|
+
entryPoint,
|
|
728
|
+
outfile
|
|
609
729
|
});
|
|
610
730
|
}
|
|
611
731
|
return components;
|
|
@@ -685,24 +805,29 @@ async function startDevServer(options = {}) {
|
|
|
685
805
|
const servedir = path4.join(cwd, "node_modules/.ollie", "build");
|
|
686
806
|
const componentsDir = path4.join(cwd, "components");
|
|
687
807
|
const internalPort = port + 1;
|
|
808
|
+
let activeStage = stage ?? "prod";
|
|
688
809
|
let ctx = null;
|
|
689
810
|
let entryNames = /* @__PURE__ */ new Set();
|
|
690
811
|
async function buildAndServe(components) {
|
|
691
812
|
entryNames = new Set(components.map((c) => c.name));
|
|
692
|
-
ctx = await createBuildContext(components, {
|
|
813
|
+
ctx = await createBuildContext(components, {
|
|
814
|
+
cwd,
|
|
815
|
+
stage: activeStage,
|
|
816
|
+
onBuildEnd
|
|
817
|
+
});
|
|
693
818
|
await ctx.rebuild();
|
|
694
819
|
await ctx.watch();
|
|
695
820
|
await ctx.serve({ port: internalPort, host, servedir, onRequest });
|
|
696
821
|
}
|
|
697
822
|
async function recreate() {
|
|
698
|
-
const components = await discoverComponents({ cwd, stage });
|
|
823
|
+
const components = await discoverComponents({ cwd, stage: activeStage });
|
|
699
824
|
const oldCtx = ctx;
|
|
700
825
|
ctx = null;
|
|
701
826
|
if (oldCtx) await oldCtx.dispose();
|
|
702
827
|
await buildAndServe(components);
|
|
703
828
|
notifyComponentsChanged(components);
|
|
704
829
|
}
|
|
705
|
-
await buildAndServe(await discoverComponents({ cwd, stage }));
|
|
830
|
+
await buildAndServe(await discoverComponents({ cwd, stage: activeStage }));
|
|
706
831
|
async function currentComponentNames() {
|
|
707
832
|
try {
|
|
708
833
|
const entries = await glob("*/index.tsx", { cwd: componentsDir });
|
|
@@ -859,6 +984,55 @@ data: ${payload}
|
|
|
859
984
|
}
|
|
860
985
|
return;
|
|
861
986
|
}
|
|
987
|
+
if (url.pathname === "/stages" && req.method === "GET") {
|
|
988
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
989
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
990
|
+
res.setHeader("Content-Type", "application/json");
|
|
991
|
+
const stages = await listStages(cwd);
|
|
992
|
+
res.end(JSON.stringify({ active: activeStage, stages }));
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
if (url.pathname === "/stage" && req.method === "POST") {
|
|
996
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
997
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
998
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
999
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
1000
|
+
let body = "";
|
|
1001
|
+
req.on("data", (chunk) => {
|
|
1002
|
+
body += chunk.toString();
|
|
1003
|
+
});
|
|
1004
|
+
req.on("end", async () => {
|
|
1005
|
+
try {
|
|
1006
|
+
const { stage: next } = JSON.parse(body);
|
|
1007
|
+
if (typeof next !== "string" || !next) {
|
|
1008
|
+
res.statusCode = 400;
|
|
1009
|
+
res.setHeader("Content-Type", "application/json");
|
|
1010
|
+
res.end(JSON.stringify({ error: "Missing 'stage' in body" }));
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
activeStage = next;
|
|
1014
|
+
await recreate();
|
|
1015
|
+
let versionId;
|
|
1016
|
+
try {
|
|
1017
|
+
versionId = (await loadConfig({ cwd, stage: activeStage }))?.versionId;
|
|
1018
|
+
} catch {
|
|
1019
|
+
}
|
|
1020
|
+
res.setHeader("Content-Type", "application/json");
|
|
1021
|
+
res.end(
|
|
1022
|
+
JSON.stringify({ success: true, stage: activeStage, versionId })
|
|
1023
|
+
);
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
res.statusCode = 400;
|
|
1026
|
+
res.setHeader("Content-Type", "application/json");
|
|
1027
|
+
res.end(
|
|
1028
|
+
JSON.stringify({
|
|
1029
|
+
error: err instanceof Error ? err.message : "Invalid JSON body"
|
|
1030
|
+
})
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
862
1036
|
if (url.pathname === "/meta" && req.method === "POST") {
|
|
863
1037
|
const componentName = url.searchParams.get("component");
|
|
864
1038
|
if (!componentName) {
|
|
@@ -880,6 +1054,32 @@ data: ${payload}
|
|
|
880
1054
|
req.on("end", async () => {
|
|
881
1055
|
try {
|
|
882
1056
|
const updates = JSON.parse(body);
|
|
1057
|
+
let cfg = null;
|
|
1058
|
+
try {
|
|
1059
|
+
cfg = await loadConfig({ cwd, stage: activeStage });
|
|
1060
|
+
} catch {
|
|
1061
|
+
}
|
|
1062
|
+
if (cfg?.components !== void 0) {
|
|
1063
|
+
const id = typeof updates.id === "string" ? updates.id : void 0;
|
|
1064
|
+
if (!id) {
|
|
1065
|
+
res.statusCode = 400;
|
|
1066
|
+
res.setHeader("Content-Type", "application/json");
|
|
1067
|
+
res.end(JSON.stringify({ error: "Missing 'id' in body" }));
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
const slot = typeof updates.slot === "string" ? updates.slot : void 0;
|
|
1071
|
+
const entry = await upsertComponentEntry(
|
|
1072
|
+
id,
|
|
1073
|
+
{ defaultPath: `components/${componentName}`, slot },
|
|
1074
|
+
{ cwd, stage: activeStage }
|
|
1075
|
+
);
|
|
1076
|
+
await ctx?.rebuild();
|
|
1077
|
+
res.setHeader("Content-Type", "application/json");
|
|
1078
|
+
res.end(
|
|
1079
|
+
JSON.stringify({ success: true, component: { id, ...entry } })
|
|
1080
|
+
);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
883
1083
|
const metaPath = path4.join(
|
|
884
1084
|
cwd,
|
|
885
1085
|
"components",
|
|
@@ -986,7 +1186,9 @@ async function writeManifest(components, buildResult, cwd = process.cwd()) {
|
|
|
986
1186
|
id: c.id,
|
|
987
1187
|
name: c.name,
|
|
988
1188
|
js: `/${c.name}/index.js`,
|
|
989
|
-
slot: c.slot
|
|
1189
|
+
slot: c.slot,
|
|
1190
|
+
...c.otherStage ? { otherStage: true } : {},
|
|
1191
|
+
...c.unlinked ? { unlinked: true } : {}
|
|
990
1192
|
};
|
|
991
1193
|
const cssPath = path4.join(outdir, c.name, "index.css");
|
|
992
1194
|
try {
|
|
@@ -1340,7 +1542,7 @@ function App({ command, args }) {
|
|
|
1340
1542
|
}
|
|
1341
1543
|
}
|
|
1342
1544
|
function VersionCommand() {
|
|
1343
|
-
const version = "1.
|
|
1545
|
+
const version = "1.5.0" ? "1.5.0" : "unknown";
|
|
1344
1546
|
return /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs4(Text4, { children: [
|
|
1345
1547
|
"ollieshop v",
|
|
1346
1548
|
version
|
|
@@ -2493,6 +2695,115 @@ async function schemaCommand(parsed2) {
|
|
|
2493
2695
|
`);
|
|
2494
2696
|
}
|
|
2495
2697
|
|
|
2698
|
+
// src/commands/setup-cmd.ts
|
|
2699
|
+
import fs5 from "fs/promises";
|
|
2700
|
+
import path5 from "path";
|
|
2701
|
+
import { glob as glob2 } from "glob";
|
|
2702
|
+
var META_FILE_RE = /^meta(?:\.([^.]+))?\.json$/;
|
|
2703
|
+
function configFileForStage(stage) {
|
|
2704
|
+
return !stage || stage === "prod" ? "ollie.json" : `ollie.${stage}.json`;
|
|
2705
|
+
}
|
|
2706
|
+
async function setupCommand(parsed2) {
|
|
2707
|
+
const format = detectOutputFormat(parsed2.global.output);
|
|
2708
|
+
const cwd = process.cwd();
|
|
2709
|
+
const dryRun = parsed2.global.dryRun;
|
|
2710
|
+
try {
|
|
2711
|
+
const componentsDir = path5.join(cwd, "components");
|
|
2712
|
+
let folders = [];
|
|
2713
|
+
try {
|
|
2714
|
+
await fs5.access(componentsDir);
|
|
2715
|
+
const entries = await glob2("*/index.tsx", { cwd: componentsDir });
|
|
2716
|
+
folders = entries.map((entry) => path5.dirname(entry));
|
|
2717
|
+
} catch {
|
|
2718
|
+
}
|
|
2719
|
+
const migrated = [];
|
|
2720
|
+
const skipped = [];
|
|
2721
|
+
for (const name of folders) {
|
|
2722
|
+
const folderDir = path5.join(componentsDir, name);
|
|
2723
|
+
const metaFiles = await glob2("meta*.json", { cwd: folderDir });
|
|
2724
|
+
for (const metaFile of metaFiles) {
|
|
2725
|
+
const match = metaFile.match(META_FILE_RE);
|
|
2726
|
+
if (!match) continue;
|
|
2727
|
+
const stage = match[1];
|
|
2728
|
+
let meta;
|
|
2729
|
+
try {
|
|
2730
|
+
meta = JSON.parse(
|
|
2731
|
+
await fs5.readFile(path5.join(folderDir, metaFile), "utf-8")
|
|
2732
|
+
);
|
|
2733
|
+
} catch {
|
|
2734
|
+
skipped.push({
|
|
2735
|
+
component: name,
|
|
2736
|
+
metaFile,
|
|
2737
|
+
reason: "unreadable meta"
|
|
2738
|
+
});
|
|
2739
|
+
continue;
|
|
2740
|
+
}
|
|
2741
|
+
const id = typeof meta.id === "string" ? meta.id : void 0;
|
|
2742
|
+
if (!id) {
|
|
2743
|
+
skipped.push({
|
|
2744
|
+
component: name,
|
|
2745
|
+
metaFile,
|
|
2746
|
+
reason: "no id (unlinked) - left as-is"
|
|
2747
|
+
});
|
|
2748
|
+
continue;
|
|
2749
|
+
}
|
|
2750
|
+
const slot = typeof meta.slot === "string" ? meta.slot : void 0;
|
|
2751
|
+
if (!dryRun) {
|
|
2752
|
+
await upsertComponentEntry(
|
|
2753
|
+
id,
|
|
2754
|
+
{ defaultPath: `components/${name}`, slot },
|
|
2755
|
+
{ cwd, stage }
|
|
2756
|
+
);
|
|
2757
|
+
await fs5.rm(path5.join(folderDir, metaFile));
|
|
2758
|
+
}
|
|
2759
|
+
migrated.push({
|
|
2760
|
+
component: name,
|
|
2761
|
+
stage: stage ?? "prod",
|
|
2762
|
+
configFile: configFileForStage(stage),
|
|
2763
|
+
metaFile,
|
|
2764
|
+
id,
|
|
2765
|
+
slot
|
|
2766
|
+
});
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
if (format === "json") {
|
|
2770
|
+
outputResult(
|
|
2771
|
+
{ data: { dryRun, migrated, skipped } },
|
|
2772
|
+
format,
|
|
2773
|
+
parsed2.global.fields
|
|
2774
|
+
);
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
const prefix = dryRun ? "\x1B[33m[dry-run]\x1B[0m " : "";
|
|
2778
|
+
if (migrated.length === 0) {
|
|
2779
|
+
console.log(`${prefix}No legacy meta files to migrate.`);
|
|
2780
|
+
} else {
|
|
2781
|
+
console.log(
|
|
2782
|
+
`${prefix}\x1B[1mMigrated ${migrated.length} meta file(s) to config:\x1B[0m`
|
|
2783
|
+
);
|
|
2784
|
+
for (const entry of migrated) {
|
|
2785
|
+
const slotLabel = entry.slot ? `, slot ${entry.slot}` : "";
|
|
2786
|
+
const removed = dryRun ? "" : ` [removed ${entry.metaFile}]`;
|
|
2787
|
+
console.log(
|
|
2788
|
+
` ${entry.configFile.padEnd(16)} ${entry.component} (id ${entry.id}${slotLabel})${removed}`
|
|
2789
|
+
);
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
if (skipped.length > 0) {
|
|
2793
|
+
console.log("\n\x1B[2mSkipped:\x1B[0m");
|
|
2794
|
+
for (const entry of skipped) {
|
|
2795
|
+
console.log(` ${entry.component}/${entry.metaFile}: ${entry.reason}`);
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
} catch (err) {
|
|
2799
|
+
outputResult(
|
|
2800
|
+
{ error: { message: err instanceof Error ? err.message : String(err) } },
|
|
2801
|
+
format
|
|
2802
|
+
);
|
|
2803
|
+
process.exit(1);
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2496
2807
|
// src/commands/status-cmd.ts
|
|
2497
2808
|
async function statusCommand(parsed2) {
|
|
2498
2809
|
const format = detectOutputFormat(parsed2.global.output);
|
|
@@ -2876,7 +3187,8 @@ var AGENT_COMMANDS = {
|
|
|
2876
3187
|
schema: schemaCommand,
|
|
2877
3188
|
init: initCommand,
|
|
2878
3189
|
deploy: deployCommand,
|
|
2879
|
-
status: statusCommand
|
|
3190
|
+
status: statusCommand,
|
|
3191
|
+
setup: setupCommand
|
|
2880
3192
|
};
|
|
2881
3193
|
var parsed = parseArgs(process.argv);
|
|
2882
3194
|
if (parsed.command in AGENT_COMMANDS) {
|
package/package.json
CHANGED
package/src/commands/help.tsx
CHANGED
|
@@ -91,6 +91,12 @@ export function HelpCommand() {
|
|
|
91
91
|
</Box>
|
|
92
92
|
<Text>Write store/version IDs to ollie.json</Text>
|
|
93
93
|
</Box>
|
|
94
|
+
<Box>
|
|
95
|
+
<Box width={24}>
|
|
96
|
+
<Text color="green">setup</Text>
|
|
97
|
+
</Box>
|
|
98
|
+
<Text>Migrate legacy meta.json into the config components map</Text>
|
|
99
|
+
</Box>
|
|
94
100
|
<Box>
|
|
95
101
|
<Box width={24}>
|
|
96
102
|
<Text color="green">help</Text>
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { glob } from "glob";
|
|
4
|
+
import { upsertComponentEntry } from "../utils/config.js";
|
|
5
|
+
import { detectOutputFormat, outputResult } from "../utils/output.js";
|
|
6
|
+
import type { ParsedArgs } from "../utils/parse-args.js";
|
|
7
|
+
|
|
8
|
+
const META_FILE_RE = /^meta(?:\.([^.]+))?\.json$/;
|
|
9
|
+
|
|
10
|
+
function configFileForStage(stage?: string): string {
|
|
11
|
+
return !stage || stage === "prod" ? "ollie.json" : `ollie.${stage}.json`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface MigratedMeta {
|
|
15
|
+
component: string;
|
|
16
|
+
stage: string;
|
|
17
|
+
configFile: string;
|
|
18
|
+
metaFile: string;
|
|
19
|
+
id: string;
|
|
20
|
+
slot?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Migrates legacy per-folder meta.json / meta.{stage}.json into the config
|
|
25
|
+
* `components` map (meta.json -> ollie.json, meta.{stage}.json -> ollie.{stage}.json)
|
|
26
|
+
* and removes the migrated meta files. Local-only metas (no id) are left in place.
|
|
27
|
+
*/
|
|
28
|
+
export async function setupCommand(parsed: ParsedArgs): Promise<void> {
|
|
29
|
+
const format = detectOutputFormat(parsed.global.output);
|
|
30
|
+
const cwd = process.cwd();
|
|
31
|
+
const dryRun = parsed.global.dryRun;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const componentsDir = path.join(cwd, "components");
|
|
35
|
+
let folders: string[] = [];
|
|
36
|
+
try {
|
|
37
|
+
await fs.access(componentsDir);
|
|
38
|
+
const entries = await glob("*/index.tsx", { cwd: componentsDir });
|
|
39
|
+
folders = entries.map((entry) => path.dirname(entry));
|
|
40
|
+
} catch {
|
|
41
|
+
// No components directory
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const migrated: MigratedMeta[] = [];
|
|
45
|
+
const skipped: Array<{
|
|
46
|
+
component: string;
|
|
47
|
+
metaFile: string;
|
|
48
|
+
reason: string;
|
|
49
|
+
}> = [];
|
|
50
|
+
|
|
51
|
+
for (const name of folders) {
|
|
52
|
+
const folderDir = path.join(componentsDir, name);
|
|
53
|
+
const metaFiles = await glob("meta*.json", { cwd: folderDir });
|
|
54
|
+
|
|
55
|
+
for (const metaFile of metaFiles) {
|
|
56
|
+
const match = metaFile.match(META_FILE_RE);
|
|
57
|
+
if (!match) continue;
|
|
58
|
+
const stage = match[1];
|
|
59
|
+
|
|
60
|
+
let meta: { id?: unknown; slot?: unknown };
|
|
61
|
+
try {
|
|
62
|
+
meta = JSON.parse(
|
|
63
|
+
await fs.readFile(path.join(folderDir, metaFile), "utf-8"),
|
|
64
|
+
);
|
|
65
|
+
} catch {
|
|
66
|
+
skipped.push({
|
|
67
|
+
component: name,
|
|
68
|
+
metaFile,
|
|
69
|
+
reason: "unreadable meta",
|
|
70
|
+
});
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const id = typeof meta.id === "string" ? meta.id : undefined;
|
|
75
|
+
if (!id) {
|
|
76
|
+
skipped.push({
|
|
77
|
+
component: name,
|
|
78
|
+
metaFile,
|
|
79
|
+
reason: "no id (unlinked) - left as-is",
|
|
80
|
+
});
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const slot = typeof meta.slot === "string" ? meta.slot : undefined;
|
|
84
|
+
|
|
85
|
+
if (!dryRun) {
|
|
86
|
+
await upsertComponentEntry(
|
|
87
|
+
id,
|
|
88
|
+
{ defaultPath: `components/${name}`, slot },
|
|
89
|
+
{ cwd, stage },
|
|
90
|
+
);
|
|
91
|
+
await fs.rm(path.join(folderDir, metaFile));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
migrated.push({
|
|
95
|
+
component: name,
|
|
96
|
+
stage: stage ?? "prod",
|
|
97
|
+
configFile: configFileForStage(stage),
|
|
98
|
+
metaFile,
|
|
99
|
+
id,
|
|
100
|
+
slot,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (format === "json") {
|
|
106
|
+
outputResult(
|
|
107
|
+
{ data: { dryRun, migrated, skipped } },
|
|
108
|
+
format,
|
|
109
|
+
parsed.global.fields,
|
|
110
|
+
);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const prefix = dryRun ? "\x1b[33m[dry-run]\x1b[0m " : "";
|
|
115
|
+
if (migrated.length === 0) {
|
|
116
|
+
console.log(`${prefix}No legacy meta files to migrate.`);
|
|
117
|
+
} else {
|
|
118
|
+
console.log(
|
|
119
|
+
`${prefix}\x1b[1mMigrated ${migrated.length} meta file(s) to config:\x1b[0m`,
|
|
120
|
+
);
|
|
121
|
+
for (const entry of migrated) {
|
|
122
|
+
const slotLabel = entry.slot ? `, slot ${entry.slot}` : "";
|
|
123
|
+
const removed = dryRun ? "" : ` [removed ${entry.metaFile}]`;
|
|
124
|
+
console.log(
|
|
125
|
+
` ${entry.configFile.padEnd(16)} ${entry.component} (id ${entry.id}${slotLabel})${removed}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (skipped.length > 0) {
|
|
130
|
+
console.log("\n\x1b[2mSkipped:\x1b[0m");
|
|
131
|
+
for (const entry of skipped) {
|
|
132
|
+
console.log(` ${entry.component}/${entry.metaFile}: ${entry.reason}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
outputResult(
|
|
137
|
+
{ error: { message: err instanceof Error ? err.message : String(err) } },
|
|
138
|
+
format,
|
|
139
|
+
);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { deployCommand } from "./commands/deploy-cmd.js";
|
|
|
6
6
|
import { functionCommand } from "./commands/function-cmd.js";
|
|
7
7
|
import { initCommand } from "./commands/init-cmd.js";
|
|
8
8
|
import { schemaCommand } from "./commands/schema-cmd.js";
|
|
9
|
+
import { setupCommand } from "./commands/setup-cmd.js";
|
|
9
10
|
import { statusCommand } from "./commands/status-cmd.js";
|
|
10
11
|
import { storeCommand } from "./commands/store-cmd.js";
|
|
11
12
|
import { versionCommand } from "./commands/version-cmd.js";
|
|
@@ -27,6 +28,7 @@ const AGENT_COMMANDS: Record<
|
|
|
27
28
|
init: initCommand,
|
|
28
29
|
deploy: deployCommand,
|
|
29
30
|
status: statusCommand,
|
|
31
|
+
setup: setupCommand,
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
const parsed = parseArgs(process.argv);
|
package/src/utils/config.ts
CHANGED
|
@@ -2,10 +2,18 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
|
|
5
|
+
const ComponentEntrySchema = z.object({
|
|
6
|
+
path: z.string(),
|
|
7
|
+
slot: z.string().optional(),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export type ComponentEntry = z.infer<typeof ComponentEntrySchema>;
|
|
11
|
+
|
|
5
12
|
const OllieConfigSchema = z
|
|
6
13
|
.object({
|
|
7
14
|
storeId: z.string().uuid(),
|
|
8
15
|
versionId: z.string().uuid().optional(),
|
|
16
|
+
components: z.record(z.string(), ComponentEntrySchema).optional(),
|
|
9
17
|
})
|
|
10
18
|
.passthrough(); // Allow any other fields without validation
|
|
11
19
|
|
|
@@ -114,6 +122,74 @@ export async function saveConfig(
|
|
|
114
122
|
await fs.writeFile(configPath, JSON.stringify(merged, null, 2));
|
|
115
123
|
}
|
|
116
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Lists the stages available in a project by scanning its ollie config files.
|
|
127
|
+
* ollie.json is the "prod" stage; ollie.{stage}.json is the "{stage}" stage.
|
|
128
|
+
*/
|
|
129
|
+
export async function listStages(
|
|
130
|
+
cwd: string = process.cwd(),
|
|
131
|
+
): Promise<Array<{ stage: string; versionId?: string }>> {
|
|
132
|
+
let files: string[];
|
|
133
|
+
try {
|
|
134
|
+
files = await fs.readdir(cwd);
|
|
135
|
+
} catch {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const seen = new Set<string>();
|
|
140
|
+
const stages: Array<{ stage: string; versionId?: string }> = [];
|
|
141
|
+
for (const file of files.sort()) {
|
|
142
|
+
const match = file.match(/^ollie(?:\.([^.]+))?\.json$/);
|
|
143
|
+
if (!match) continue;
|
|
144
|
+
const stage = match[1] ?? "prod";
|
|
145
|
+
if (seen.has(stage)) continue;
|
|
146
|
+
seen.add(stage);
|
|
147
|
+
|
|
148
|
+
let versionId: string | undefined;
|
|
149
|
+
try {
|
|
150
|
+
const config = await loadConfig({ cwd, stage: match[1] });
|
|
151
|
+
versionId = config?.versionId;
|
|
152
|
+
} catch {
|
|
153
|
+
// List the stage even if its config is invalid/incomplete
|
|
154
|
+
}
|
|
155
|
+
stages.push({ stage, versionId });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return stages;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Upserts a component entry into the stage's config file (ollie.json or
|
|
163
|
+
* ollie.{stage}.json). Preserves the existing path/slot when not provided.
|
|
164
|
+
*/
|
|
165
|
+
export async function upsertComponentEntry(
|
|
166
|
+
id: string,
|
|
167
|
+
values: { defaultPath: string; slot?: string },
|
|
168
|
+
options: SaveConfigOptions = {},
|
|
169
|
+
): Promise<ComponentEntry> {
|
|
170
|
+
const { cwd = process.cwd(), stage } = options;
|
|
171
|
+
const configPath = path.join(cwd, getConfigFileName(stage));
|
|
172
|
+
|
|
173
|
+
const existing = (await loadConfigFile(configPath)) ?? {};
|
|
174
|
+
const components = {
|
|
175
|
+
...((existing.components as Record<string, ComponentEntry>) ?? {}),
|
|
176
|
+
};
|
|
177
|
+
const prev = components[id];
|
|
178
|
+
const slot = values.slot ?? prev?.slot;
|
|
179
|
+
const entry: ComponentEntry = {
|
|
180
|
+
path: prev?.path ?? values.defaultPath,
|
|
181
|
+
...(slot !== undefined ? { slot } : {}),
|
|
182
|
+
};
|
|
183
|
+
components[id] = entry;
|
|
184
|
+
|
|
185
|
+
await fs.writeFile(
|
|
186
|
+
configPath,
|
|
187
|
+
JSON.stringify({ ...existing, components }, null, 2),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
return entry;
|
|
191
|
+
}
|
|
192
|
+
|
|
117
193
|
/**
|
|
118
194
|
* Returns the resolved stage from CLI args or environment.
|
|
119
195
|
* Priority: CLI arg > OLLIE_STAGE env > undefined (defaults to prod behavior)
|
package/src/utils/esbuild.ts
CHANGED
|
@@ -5,14 +5,24 @@ import path from "node:path";
|
|
|
5
5
|
import * as esbuild from "esbuild";
|
|
6
6
|
import { glob } from "glob";
|
|
7
7
|
import { createComponentBundle } from "./bundle.js";
|
|
8
|
+
import {
|
|
9
|
+
type OllieConfig,
|
|
10
|
+
listStages,
|
|
11
|
+
loadConfig,
|
|
12
|
+
upsertComponentEntry,
|
|
13
|
+
} from "./config.js";
|
|
8
14
|
|
|
9
15
|
export interface ComponentInfo {
|
|
10
|
-
/** Component ID from
|
|
16
|
+
/** Component ID from the ollie config (UUID) or generated temporary ID (studio-*) */
|
|
11
17
|
id: string;
|
|
12
18
|
/** Folder name */
|
|
13
19
|
name: string;
|
|
14
|
-
/** Target slot
|
|
20
|
+
/** Target slot (from the ollie config) */
|
|
15
21
|
slot?: string;
|
|
22
|
+
/** True when the folder belongs to another stage's config, not the active one */
|
|
23
|
+
otherStage?: boolean;
|
|
24
|
+
/** True when the folder isn't linked to a component id (synthetic studio-* id) */
|
|
25
|
+
unlinked?: boolean;
|
|
16
26
|
entryPoint: string;
|
|
17
27
|
outfile: string;
|
|
18
28
|
}
|
|
@@ -33,7 +43,7 @@ interface ComponentMeta {
|
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
/**
|
|
36
|
-
* Reads the meta.json file for a component.
|
|
46
|
+
* Reads the meta.json file for a component (legacy, per-folder source).
|
|
37
47
|
* Supports stage-specific meta files: meta.{stage}.json
|
|
38
48
|
*/
|
|
39
49
|
async function readComponentMeta(
|
|
@@ -66,35 +76,118 @@ export interface DiscoverComponentsOptions {
|
|
|
66
76
|
stage?: string;
|
|
67
77
|
}
|
|
68
78
|
|
|
79
|
+
/** Maps resolved component dirs -> {id, slot} for a stage's config map. */
|
|
80
|
+
async function componentsByDir(
|
|
81
|
+
cwd: string,
|
|
82
|
+
stageLabel: string,
|
|
83
|
+
): Promise<Map<string, { id: string; slot?: string }>> {
|
|
84
|
+
const byDir = new Map<string, { id: string; slot?: string }>();
|
|
85
|
+
let config: OllieConfig | null = null;
|
|
86
|
+
try {
|
|
87
|
+
config = await loadConfig({ cwd, stage: stageLabel });
|
|
88
|
+
} catch {
|
|
89
|
+
return byDir;
|
|
90
|
+
}
|
|
91
|
+
for (const [id, entry] of Object.entries(config?.components ?? {})) {
|
|
92
|
+
const dir = path.resolve(cwd, entry.path);
|
|
93
|
+
if (!byDir.has(dir)) byDir.set(dir, { id, slot: entry.slot });
|
|
94
|
+
}
|
|
95
|
+
return byDir;
|
|
96
|
+
}
|
|
97
|
+
|
|
69
98
|
/**
|
|
70
|
-
* Discovers components
|
|
71
|
-
*
|
|
99
|
+
* Discovers components, config-first:
|
|
100
|
+
* 1. The active stage's `components` map — the source of truth; entries may
|
|
101
|
+
* point inside or outside components/.
|
|
102
|
+
* 2. Folders under components/ that belong to ANOTHER stage's config are
|
|
103
|
+
* surfaced with their real id and flagged `otherStage` (hidden by default
|
|
104
|
+
* in Studio, shown via "show all").
|
|
105
|
+
* 3. Remaining folders fall back to legacy per-folder meta.json; folders with
|
|
106
|
+
* neither get a temporary studio-* id (unlinked).
|
|
72
107
|
*/
|
|
73
108
|
export async function discoverComponents(
|
|
74
109
|
options: DiscoverComponentsOptions = {},
|
|
75
110
|
): Promise<ComponentInfo[]> {
|
|
76
111
|
const { cwd = process.cwd(), stage } = options;
|
|
77
|
-
const
|
|
112
|
+
const activeStage = stage ?? "prod";
|
|
113
|
+
const buildDir = path.join(cwd, "node_modules/.ollie", "build");
|
|
114
|
+
const components: ComponentInfo[] = [];
|
|
115
|
+
const mappedDirs = new Set<string>();
|
|
116
|
+
|
|
117
|
+
const activeByDir = await componentsByDir(cwd, activeStage);
|
|
78
118
|
|
|
119
|
+
// Component dirs claimed by other stages (so we don't mistake them for new)
|
|
120
|
+
const otherStageByDir = new Map<string, { id: string; slot?: string }>();
|
|
121
|
+
try {
|
|
122
|
+
for (const { stage: label } of await listStages(cwd)) {
|
|
123
|
+
if (label === activeStage) continue;
|
|
124
|
+
for (const [dir, entry] of await componentsByDir(cwd, label)) {
|
|
125
|
+
if (!activeByDir.has(dir) && !otherStageByDir.has(dir)) {
|
|
126
|
+
otherStageByDir.set(dir, entry);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// No other stages
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 1. Active stage's config map (source of truth)
|
|
135
|
+
for (const [componentDir, entry] of activeByDir) {
|
|
136
|
+
const entryPoint = path.join(componentDir, "index.tsx");
|
|
137
|
+
try {
|
|
138
|
+
await fs.access(entryPoint);
|
|
139
|
+
} catch {
|
|
140
|
+
console.warn(
|
|
141
|
+
`${entry.id}: ${componentDir}/index.tsx not found - skipping`,
|
|
142
|
+
);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const name = path.basename(componentDir);
|
|
146
|
+
mappedDirs.add(componentDir);
|
|
147
|
+
components.push({
|
|
148
|
+
id: entry.id,
|
|
149
|
+
name,
|
|
150
|
+
slot: entry.slot,
|
|
151
|
+
entryPoint,
|
|
152
|
+
outfile: path.join(buildDir, name, "index.js"),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 2. Folders under components/ not in the active map
|
|
157
|
+
const componentsDir = path.join(cwd, "components");
|
|
79
158
|
try {
|
|
80
159
|
await fs.access(componentsDir);
|
|
81
160
|
} catch {
|
|
82
|
-
return
|
|
161
|
+
return components;
|
|
83
162
|
}
|
|
84
163
|
|
|
85
164
|
const entries = await glob("*/index.tsx", { cwd: componentsDir });
|
|
86
|
-
const components: ComponentInfo[] = [];
|
|
87
|
-
|
|
88
165
|
for (const entry of entries) {
|
|
89
166
|
const name = path.dirname(entry);
|
|
90
|
-
const componentDir = path.
|
|
91
|
-
|
|
167
|
+
const componentDir = path.resolve(componentsDir, name);
|
|
168
|
+
if (mappedDirs.has(componentDir)) continue;
|
|
169
|
+
|
|
170
|
+
const entryPoint = path.join(componentsDir, entry);
|
|
171
|
+
const outfile = path.join(buildDir, name, "index.js");
|
|
172
|
+
|
|
173
|
+
// Belongs to another stage's config: surface with its real id, flagged
|
|
174
|
+
const other = otherStageByDir.get(componentDir);
|
|
175
|
+
if (other) {
|
|
176
|
+
components.push({
|
|
177
|
+
id: other.id,
|
|
178
|
+
name,
|
|
179
|
+
slot: other.slot,
|
|
180
|
+
otherStage: true,
|
|
181
|
+
entryPoint,
|
|
182
|
+
outfile,
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
92
186
|
|
|
93
|
-
//
|
|
187
|
+
// Legacy per-folder meta.json fallback
|
|
188
|
+
const meta = await readComponentMeta(componentDir, stage);
|
|
94
189
|
const id = meta?.id ?? `studio-${name}`;
|
|
95
190
|
const isUnlinked = !meta?.id;
|
|
96
|
-
|
|
97
|
-
// Unlinked components without a slot can still be built, just not placed in checkout
|
|
98
191
|
if (isUnlinked && !meta?.slot) {
|
|
99
192
|
console.warn(
|
|
100
193
|
`${name}: no slot defined - component will be built but not placed in checkout`,
|
|
@@ -105,8 +198,9 @@ export async function discoverComponents(
|
|
|
105
198
|
id,
|
|
106
199
|
name,
|
|
107
200
|
slot: meta?.slot,
|
|
108
|
-
|
|
109
|
-
|
|
201
|
+
...(isUnlinked ? { unlinked: true } : {}),
|
|
202
|
+
entryPoint,
|
|
203
|
+
outfile,
|
|
110
204
|
});
|
|
111
205
|
}
|
|
112
206
|
|
|
@@ -250,19 +344,26 @@ export async function startDevServer(
|
|
|
250
344
|
const componentsDir = path.join(cwd, "components");
|
|
251
345
|
const internalPort = port + 1;
|
|
252
346
|
|
|
347
|
+
// The active stage is mutable: the admin can switch it at runtime via POST /stage.
|
|
348
|
+
let activeStage = stage ?? "prod";
|
|
349
|
+
|
|
253
350
|
let ctx: esbuild.BuildContext | null = null;
|
|
254
351
|
let entryNames = new Set<string>();
|
|
255
352
|
|
|
256
353
|
async function buildAndServe(components: ComponentInfo[]): Promise<void> {
|
|
257
354
|
entryNames = new Set(components.map((c) => c.name));
|
|
258
|
-
ctx = await createBuildContext(components, {
|
|
355
|
+
ctx = await createBuildContext(components, {
|
|
356
|
+
cwd,
|
|
357
|
+
stage: activeStage,
|
|
358
|
+
onBuildEnd,
|
|
359
|
+
});
|
|
259
360
|
await ctx.rebuild();
|
|
260
361
|
await ctx.watch();
|
|
261
362
|
await ctx.serve({ port: internalPort, host, servedir, onRequest });
|
|
262
363
|
}
|
|
263
364
|
|
|
264
365
|
async function recreate(): Promise<void> {
|
|
265
|
-
const components = await discoverComponents({ cwd, stage });
|
|
366
|
+
const components = await discoverComponents({ cwd, stage: activeStage });
|
|
266
367
|
const oldCtx = ctx;
|
|
267
368
|
ctx = null;
|
|
268
369
|
if (oldCtx) await oldCtx.dispose();
|
|
@@ -270,7 +371,7 @@ export async function startDevServer(
|
|
|
270
371
|
notifyComponentsChanged(components);
|
|
271
372
|
}
|
|
272
373
|
|
|
273
|
-
await buildAndServe(await discoverComponents({ cwd, stage }));
|
|
374
|
+
await buildAndServe(await discoverComponents({ cwd, stage: activeStage }));
|
|
274
375
|
|
|
275
376
|
async function currentComponentNames(): Promise<Set<string>> {
|
|
276
377
|
try {
|
|
@@ -459,6 +560,62 @@ export async function startDevServer(
|
|
|
459
560
|
return;
|
|
460
561
|
}
|
|
461
562
|
|
|
563
|
+
// List available stages (from local ollie*.json) and the active one
|
|
564
|
+
if (url.pathname === "/stages" && req.method === "GET") {
|
|
565
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
566
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
567
|
+
res.setHeader("Content-Type", "application/json");
|
|
568
|
+
const stages = await listStages(cwd);
|
|
569
|
+
res.end(JSON.stringify({ active: activeStage, stages }));
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Switch the active stage at runtime: re-discover + rebuild for it
|
|
574
|
+
if (url.pathname === "/stage" && req.method === "POST") {
|
|
575
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
576
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
577
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
578
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
579
|
+
|
|
580
|
+
let body = "";
|
|
581
|
+
req.on("data", (chunk) => {
|
|
582
|
+
body += chunk.toString();
|
|
583
|
+
});
|
|
584
|
+
req.on("end", async () => {
|
|
585
|
+
try {
|
|
586
|
+
const { stage: next } = JSON.parse(body) as { stage?: unknown };
|
|
587
|
+
if (typeof next !== "string" || !next) {
|
|
588
|
+
res.statusCode = 400;
|
|
589
|
+
res.setHeader("Content-Type", "application/json");
|
|
590
|
+
res.end(JSON.stringify({ error: "Missing 'stage' in body" }));
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
activeStage = next;
|
|
594
|
+
await recreate();
|
|
595
|
+
let versionId: string | undefined;
|
|
596
|
+
try {
|
|
597
|
+
versionId = (await loadConfig({ cwd, stage: activeStage }))
|
|
598
|
+
?.versionId;
|
|
599
|
+
} catch {
|
|
600
|
+
// Stage config may be missing/invalid
|
|
601
|
+
}
|
|
602
|
+
res.setHeader("Content-Type", "application/json");
|
|
603
|
+
res.end(
|
|
604
|
+
JSON.stringify({ success: true, stage: activeStage, versionId }),
|
|
605
|
+
);
|
|
606
|
+
} catch (err) {
|
|
607
|
+
res.statusCode = 400;
|
|
608
|
+
res.setHeader("Content-Type", "application/json");
|
|
609
|
+
res.end(
|
|
610
|
+
JSON.stringify({
|
|
611
|
+
error: err instanceof Error ? err.message : "Invalid JSON body",
|
|
612
|
+
}),
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
462
619
|
// Handle POST /meta?component=ComponentName to update meta.json
|
|
463
620
|
if (url.pathname === "/meta" && req.method === "POST") {
|
|
464
621
|
const componentName = url.searchParams.get("component");
|
|
@@ -487,6 +644,40 @@ export async function startDevServer(
|
|
|
487
644
|
req.on("end", async () => {
|
|
488
645
|
try {
|
|
489
646
|
const updates = JSON.parse(body) as Record<string, unknown>;
|
|
647
|
+
|
|
648
|
+
// A project opts into the config model by having a `components` map.
|
|
649
|
+
// Otherwise we keep writing the legacy per-folder meta.json.
|
|
650
|
+
let cfg: OllieConfig | null = null;
|
|
651
|
+
try {
|
|
652
|
+
cfg = await loadConfig({ cwd, stage: activeStage });
|
|
653
|
+
} catch {
|
|
654
|
+
// Treat as legacy if the config can't be loaded
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (cfg?.components !== undefined) {
|
|
658
|
+
const id = typeof updates.id === "string" ? updates.id : undefined;
|
|
659
|
+
if (!id) {
|
|
660
|
+
res.statusCode = 400;
|
|
661
|
+
res.setHeader("Content-Type", "application/json");
|
|
662
|
+
res.end(JSON.stringify({ error: "Missing 'id' in body" }));
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const slot =
|
|
666
|
+
typeof updates.slot === "string" ? updates.slot : undefined;
|
|
667
|
+
const entry = await upsertComponentEntry(
|
|
668
|
+
id,
|
|
669
|
+
{ defaultPath: `components/${componentName}`, slot },
|
|
670
|
+
{ cwd, stage: activeStage },
|
|
671
|
+
);
|
|
672
|
+
// Rebuild so the manifest reflects the new mapping/slot
|
|
673
|
+
await ctx?.rebuild();
|
|
674
|
+
res.setHeader("Content-Type", "application/json");
|
|
675
|
+
res.end(
|
|
676
|
+
JSON.stringify({ success: true, component: { id, ...entry } }),
|
|
677
|
+
);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
490
681
|
const metaPath = path.join(
|
|
491
682
|
cwd,
|
|
492
683
|
"components",
|
|
@@ -641,6 +832,10 @@ export interface ManifestEntry {
|
|
|
641
832
|
css?: string;
|
|
642
833
|
/** Target slot for components */
|
|
643
834
|
slot?: string;
|
|
835
|
+
/** True when the component belongs to another stage's config */
|
|
836
|
+
otherStage?: boolean;
|
|
837
|
+
/** True when the component isn't linked to a component id (studio-*) */
|
|
838
|
+
unlinked?: boolean;
|
|
644
839
|
}
|
|
645
840
|
|
|
646
841
|
/**
|
|
@@ -690,6 +885,8 @@ export async function writeManifest(
|
|
|
690
885
|
name: c.name,
|
|
691
886
|
js: `/${c.name}/index.js`,
|
|
692
887
|
slot: c.slot,
|
|
888
|
+
...(c.otherStage ? { otherStage: true } : {}),
|
|
889
|
+
...(c.unlinked ? { unlinked: true } : {}),
|
|
693
890
|
};
|
|
694
891
|
|
|
695
892
|
// Check if CSS exists
|