znode-plugin 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/lib/znode-package.mjs +212 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -292,7 +292,8 @@ Run them with `npm run <name>` inside a project (the scripts call `znode … .`)
|
|
|
292
292
|
| | `npx znode install <zip> [pluginsDir]` | install a released zip into a store's plugin folder |
|
|
293
293
|
| | `npx znode create <theme\|plugin\|widget\|content> <name>` | a minimal starter of another kind |
|
|
294
294
|
| | `npx znode eject` | list the store components a package can take over (each has a slot that hands the component over) |
|
|
295
|
-
| | `npx znode
|
|
295
|
+
| | `npx znode add-slot <Component> <area.name> [--dry-run] [--file <store file>]` | add a slot around a store component that has none, in the **linked store's source** (not the theme, never `.next`): a `Runtime<Component>` wrapper in `packages/extensions/src/slots/RuntimeAddedSlots.tsx`, the component's export wrapped with `withRuntimeWrapper`, the slot added to the store's slot list and docs, the module registry regenerated. Refuses server components (`async`, server-only imports): a slot is filled in the browser. Nothing is committed: review the diff in the store, commit it there, rebuild the webstore, then `npx znode eject <Component>`. `add slot` (with a space) works too |
|
|
296
|
+
| | `npx znode eject <Name> [--force]` | `<Name>` is the component or its slot (`ProductBasicInfo` or `product.basicInfo`). Copy the store's component into `src/components/<Name>.tsx` (imports turned into store aliases, runtime wrapper removed) and register it in `src/browser.tsx` under its slot; prints any npm package the copy needs. The copy is yours: store fixes no longer reach it |
|
|
296
297
|
|
|
297
298
|
The plugin folder is taken from: the command's argument, else the `RUNTIME_PLUGINS_DIR` environment variable, else
|
|
298
299
|
`znode.local.json`.
|
package/lib/znode-package.mjs
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
// znode link-store <storeRepoDir> [dir] the store's real types: writes tsconfig.store.json (see README)
|
|
18
18
|
// znode eject [<Name>] [--force] in a package folder: list the store components that can be ejected, or
|
|
19
19
|
// copy one into src/components/ and register it in its slot
|
|
20
|
+
// (<Name> is the component or its slot name)
|
|
21
|
+
// znode add-slot <Component> <area.name> [--dry-run] [--file <f>] a slot around a store component, written into
|
|
22
|
+
// the linked store's source (base developers; review and commit there)
|
|
20
23
|
//
|
|
21
24
|
// A package is a folder with znode-package.json (README.md shows a whole project, as create-znode-plugin makes it):
|
|
22
25
|
// {
|
|
@@ -697,8 +700,38 @@ const EJECT_SLOTS = {
|
|
|
697
700
|
RuntimeShippingOptions: "checkout.shippingOptions",
|
|
698
701
|
};
|
|
699
702
|
|
|
703
|
+
// the slot each store wrapper renders: every `export function Runtime<X>` in packages/extensions/src/slots with the first
|
|
704
|
+
// <RuntimeSlot name="…"> in its body (so a slot added with `znode add-slot` is ejectable with no SDK release); the
|
|
705
|
+
// hand-kept EJECT_SLOTS above fill in for a store that predates this
|
|
706
|
+
function storeWrapperSlots(root) {
|
|
707
|
+
const slots = { ...EJECT_SLOTS };
|
|
708
|
+
const dir = path.join(root, "packages/extensions/src/slots");
|
|
709
|
+
if (!fs.existsSync(dir)) return slots;
|
|
710
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
711
|
+
if (!/\.tsx$/.test(entry)) continue;
|
|
712
|
+
const text = fs.readFileSync(path.join(dir, entry), "utf8");
|
|
713
|
+
const starts = [...text.matchAll(/export\s+function\s+(Runtime\w+)/g)];
|
|
714
|
+
starts.forEach((m, i) => {
|
|
715
|
+
const body = text.slice(m.index, i + 1 < starts.length ? starts[i + 1].index : undefined);
|
|
716
|
+
// the first slot that is handed the component as children (<RuntimeSlot name="…">…{children}…</RuntimeSlot>); a
|
|
717
|
+
// layout slot (self-closing, the base rendered hidden beside it) cannot take an ejected copy
|
|
718
|
+
for (const open of body.matchAll(/<RuntimeSlot\s+name=["']([\w.]+)["']/g)) {
|
|
719
|
+
const rest = body.slice(open.index + open[0].length);
|
|
720
|
+
const close = rest.indexOf("</RuntimeSlot>");
|
|
721
|
+
const next = rest.indexOf("<RuntimeSlot");
|
|
722
|
+
if (close >= 0 && (next < 0 || close < next) && /\{\s*children\s*\}/.test(rest.slice(0, close))) {
|
|
723
|
+
if (!slots[m[1]]) slots[m[1]] = open[1];
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
return slots;
|
|
730
|
+
}
|
|
731
|
+
|
|
700
732
|
// every withRuntimeWrapper(<Wrapper>, <Base>, "<Name>") in the store's base components, with the slot it fills
|
|
701
733
|
function ejectableComponents(root) {
|
|
734
|
+
const EJECT_SLOTS = storeWrapperSlots(root);
|
|
702
735
|
const found = [];
|
|
703
736
|
const walk = (dir) => {
|
|
704
737
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -748,8 +781,13 @@ function eject(name, force) {
|
|
|
748
781
|
console.log("\nznode eject <Name> copies one into src/components/ and registers it in src/browser.tsx");
|
|
749
782
|
return;
|
|
750
783
|
}
|
|
751
|
-
const c = components.find((item) => item.name.toLowerCase() === name.toLowerCase());
|
|
752
|
-
if (!c)
|
|
784
|
+
const c = components.find((item) => item.name.toLowerCase() === name.toLowerCase() || item.slot.toLowerCase() === name.toLowerCase());
|
|
785
|
+
if (!c)
|
|
786
|
+
fail(
|
|
787
|
+
`${name} cannot be ejected: no store component with that name or slot hands itself over to a slot.\n` +
|
|
788
|
+
` znode eject lists the ones that can (component name or slot name both work)\n` +
|
|
789
|
+
` znode add-slot ${/^[A-Z]/.test(name) ? name : "<Component>"} <area.name> adds a slot for a store component (edits the linked store's source)`
|
|
790
|
+
);
|
|
753
791
|
|
|
754
792
|
const out = path.join(project, "src", "components", `${c.name}.tsx`);
|
|
755
793
|
if (fs.existsSync(out) && !force) fail(`${path.relative(project, out)} exists: delete it or run znode eject ${c.name} --force`);
|
|
@@ -839,6 +877,170 @@ function eject(name, force) {
|
|
|
839
877
|
console.log(`\nNext: npm run build (it names any store import a package cannot use), then npm run dev and open a page that shows ${c.name}.`);
|
|
840
878
|
}
|
|
841
879
|
|
|
880
|
+
// ---- add-slot: a runtime slot around a store component, written into the linked store's source ------------------
|
|
881
|
+
// `znode add-slot <Component> <area.name>` does in the store what a base developer did by hand for product.basicInfo:
|
|
882
|
+
// a Runtime<Component> wrapper (packages/extensions/src/slots/RuntimeAddedSlots.tsx) that renders <RuntimeSlot name>
|
|
883
|
+
// with the component's props and the component itself as children, and the component's export wrapped with
|
|
884
|
+
// withRuntimeWrapper, so every place that renders it gets the slot. It edits source only (never .next, never git):
|
|
885
|
+
// review the diff, commit it through the store's own flow, rebuild the webstore; then `znode eject <Component>` works.
|
|
886
|
+
const ADDED_SLOTS_FILE = "packages/extensions/src/slots/RuntimeAddedSlots.tsx";
|
|
887
|
+
const SLOT_NAME = /^[a-z][A-Za-z0-9]*(\.[a-z][A-Za-z0-9]*)+$/;
|
|
888
|
+
|
|
889
|
+
function storeSourceFiles(root) {
|
|
890
|
+
const files = [];
|
|
891
|
+
const walk = (dir) => {
|
|
892
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
893
|
+
const file = path.join(dir, entry.name);
|
|
894
|
+
if (entry.isDirectory()) {
|
|
895
|
+
if (entry.name !== "node_modules" && !entry.name.startsWith("__")) walk(file);
|
|
896
|
+
} else if (/\.tsx$/.test(entry.name) && !/\.(test|spec|stories)\.tsx$/.test(entry.name)) files.push(file);
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
for (const dir of ["packages/base-components/src", "packages/page-builder/src"]) if (fs.existsSync(path.join(root, dir))) walk(path.join(root, dir));
|
|
900
|
+
return files;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function addSlot(name, slot, { dryRun, file: chosen }) {
|
|
904
|
+
const usage = "usage: znode add-slot <Component> <area.name> [--dry-run] [--file <store file>] e.g. znode add-slot ProductBasicInfo product.basicInfo";
|
|
905
|
+
if (!name || !slot) fail(usage);
|
|
906
|
+
if (!/^[A-Z]\w*$/.test(name)) fail(`"${name}" is not a component name (PascalCase, e.g. ProductDetails)\n${usage}`);
|
|
907
|
+
if (!SLOT_NAME.test(slot)) fail(`"${slot}" is not a slot name: area.name in camelCase, e.g. product.details`);
|
|
908
|
+
const store = linkedStore(path.resolve("."));
|
|
909
|
+
if (!store) fail("not linked to a store: run it in a package linked with npm run link-store -- <store repo> (add-slot edits that store's source)");
|
|
910
|
+
const root = store.root;
|
|
911
|
+
const rel = (file) => path.relative(root, file).split(path.sep).join("/");
|
|
912
|
+
|
|
913
|
+
// the slot must be new
|
|
914
|
+
const slotsDir = path.join(root, "packages/extensions/src/slots");
|
|
915
|
+
if (!fs.existsSync(slotsDir)) fail(`${rel(slotsDir)} not found: this store has no runtime slots (packages/extensions)`);
|
|
916
|
+
const wrapperSlots = storeWrapperSlots(root);
|
|
917
|
+
const taken = Object.entries(wrapperSlots).find(([, value]) => value === slot);
|
|
918
|
+
if (taken) fail(`slot "${slot}" already exists (${taken[0]}); znode eject lists what it hands over`);
|
|
919
|
+
const wrapper = `Runtime${name}`;
|
|
920
|
+
if (wrapperSlots[wrapper] || fs.readdirSync(slotsDir).some((f) => new RegExp(`export\\s+function\\s+${wrapper}\\b`).test(fs.readFileSync(path.join(slotsDir, f), "utf8"))))
|
|
921
|
+
fail(`${wrapper} already exists in ${rel(slotsDir)}: ${name} already has a slot (znode eject lists it)`);
|
|
922
|
+
|
|
923
|
+
// the file that defines the component
|
|
924
|
+
const DEFINES = new RegExp(`^(export\\s+(?:default\\s+)?)?(async\\s+)?function\\s+${name}\\s*[(<]|^(export\\s+)?const\\s+${name}\\s*(?::[^=]+)?=\\s*(async\\b)?`, "m");
|
|
925
|
+
let candidates = chosen ? [path.resolve(root, chosen)] : storeSourceFiles(root).filter((f) => DEFINES.test(fs.readFileSync(f, "utf8")));
|
|
926
|
+
if (chosen && !fs.existsSync(candidates[0])) fail(`${chosen} not found in ${root}`);
|
|
927
|
+
if (candidates.length > 1) {
|
|
928
|
+
const named = candidates.filter((f) => path.basename(f, ".tsx") === name);
|
|
929
|
+
if (named.length === 1) candidates = named;
|
|
930
|
+
else fail(`${name} is defined in more than one store file; pick one with --file:\n ${candidates.map(rel).join("\n ")}`);
|
|
931
|
+
}
|
|
932
|
+
if (!candidates.length) fail(`no ${name} component found in packages/base-components/src or packages/page-builder/src (only .tsx components can get a slot)`);
|
|
933
|
+
const target = candidates[0];
|
|
934
|
+
let source = fs.readFileSync(target, "utf8");
|
|
935
|
+
const def = source.match(DEFINES);
|
|
936
|
+
if (!def) fail(`${rel(target)} does not define ${name} as a function or const component`);
|
|
937
|
+
|
|
938
|
+
// only a client-renderable component: a slot is filled in the browser
|
|
939
|
+
if (new RegExp(`withRuntimeWrapper\\([^)]*["']${name}["']`).test(source)) fail(`${name} is already wrapped (${rel(target)}): it has a slot, znode eject lists it`);
|
|
940
|
+
if (def[2] || def[4])
|
|
941
|
+
fail(
|
|
942
|
+
`${name} is an async server component (${rel(target)}): a runtime slot is filled in the browser, so it cannot wrap a server component.\n` +
|
|
943
|
+
` Add the slot to a client component it renders instead (e.g. its ProductBasicInfo), or split ${name} into a server part (data) and a client part (UI) and add the slot to the client part.`
|
|
944
|
+
);
|
|
945
|
+
const serverImport = source.match(/^\s*import\b[^;]*["'](server-only|next\/headers|fs|node:fs)["']/m);
|
|
946
|
+
if (serverImport) fail(`${name} imports ${serverImport[1]} (${rel(target)}): it runs on the server only, so a browser slot cannot wrap it`);
|
|
947
|
+
|
|
948
|
+
// wrap the export: <Name>Base is the component, <Name> is the wrapped one every caller already imports
|
|
949
|
+
const baseName = `${name}Base`;
|
|
950
|
+
if (new RegExp(`\\b${baseName}\\b`).test(source)) fail(`${rel(target)} already has a ${baseName}: wrap it by hand`);
|
|
951
|
+
const wrapped = `withRuntimeWrapper(${wrapper}, ${baseName}, "${name}")`;
|
|
952
|
+
const comment = `// runtime packages render around it through the slot "${slot}" (znode add-slot); with none, it renders as it is`;
|
|
953
|
+
if (/^export\s+default\s+/.test(def[0])) {
|
|
954
|
+
source = source.replace(def[0], def[0].replace(/^export\s+default\s+/, "").replace(name, baseName));
|
|
955
|
+
source = source.trimEnd() + `\n\n${comment}\nconst ${name} = ${wrapped};\n\nexport default ${name};\n`;
|
|
956
|
+
} else if (def[1] || def[3]) {
|
|
957
|
+
source = source.replace(def[0], def[0].replace(/^export\s+/, "").replace(name, baseName));
|
|
958
|
+
source = source.trimEnd() + `\n\n${comment}\nexport const ${name} = ${wrapped};\n`;
|
|
959
|
+
} else {
|
|
960
|
+
// defined, exported later (export default Name; / export { Name }): the wrapped const goes right before that export
|
|
961
|
+
source = source.replace(def[0], def[0].replace(name, baseName));
|
|
962
|
+
const exportAt = source.search(new RegExp(`^export\\s+(default\\s+${name}\\b|\\{[^}]*\\b${name}\\b)`, "m"));
|
|
963
|
+
const line = `${comment}\nconst ${name} = ${wrapped};\n\n`;
|
|
964
|
+
source = exportAt >= 0 ? source.slice(0, exportAt) + line + source.slice(exportAt) : source.trimEnd() + `\n\n${line}`;
|
|
965
|
+
}
|
|
966
|
+
// statics set on the component belong to the base (they run before the wrapped const exists)
|
|
967
|
+
source = source.replace(new RegExp(`\\b${name}\\.(displayName|defaultProps|propTypes)\\b`, "g"), `${baseName}.$1`);
|
|
968
|
+
const imports = [`import { withRuntimeWrapper } from "@znode/extensions/slots/with-runtime-wrapper";`, `import { ${wrapper} } from "@znode/extensions/slots/RuntimeAddedSlots";`].filter(
|
|
969
|
+
(line) => !(line.includes("withRuntimeWrapper }") && /import\s*\{[^}]*\bwithRuntimeWrapper\b/.test(source))
|
|
970
|
+
);
|
|
971
|
+
const directive = source.match(/^\s*["']use (client|server)["'];?\s*\n/);
|
|
972
|
+
const at = directive ? directive[0].length : 0;
|
|
973
|
+
source = source.slice(0, at) + (directive ? "\n" : "") + imports.join("\n") + "\n" + (directive ? "" : "\n") + source.slice(at).replace(/^\n+/, directive ? "" : "");
|
|
974
|
+
|
|
975
|
+
// the wrapper
|
|
976
|
+
const addedFile = path.join(root, ADDED_SLOTS_FILE);
|
|
977
|
+
const addedBefore = fs.existsSync(addedFile) ? fs.readFileSync(addedFile, "utf8") : null;
|
|
978
|
+
let added =
|
|
979
|
+
addedBefore ??
|
|
980
|
+
`"use client";\n\n// Slots added with \`znode add-slot <Component> <area.name>\` (znode-plugin): each Runtime<Component> renders the slot\n// around the store component (withRuntimeWrapper where the component is defined), with the component's props and the\n// component itself as children. With no runtime package for the slot, the component renders as it is.\n\nimport { ReactNode } from "react";\nimport { RuntimeSlot } from "../client/RuntimeSlot";\n`;
|
|
981
|
+
added =
|
|
982
|
+
added.trimEnd() +
|
|
983
|
+
`\n\n/** ${slot}: the store's ${name} (children), with every prop it gets. Added with \`znode add-slot ${name} ${slot}\` (${new Date().toISOString().slice(0, 10)}). */\n` +
|
|
984
|
+
`export function ${wrapper}({ children, ...props }: Readonly<Record<string, unknown> & { children?: ReactNode }>) {\n` +
|
|
985
|
+
` return (\n <RuntimeSlot name="${slot}" props={props}>\n {children}\n </RuntimeSlot>\n );\n}\n`;
|
|
986
|
+
|
|
987
|
+
const changes = [
|
|
988
|
+
{ file: target, before: fs.readFileSync(target, "utf8"), after: source },
|
|
989
|
+
{ file: addedFile, before: addedBefore, after: added },
|
|
990
|
+
];
|
|
991
|
+
const warnings = [];
|
|
992
|
+
|
|
993
|
+
// the slot list in the SDK types of the store (packages/extensions/src/types.ts), when it has one
|
|
994
|
+
const typesFile = path.join(root, "packages/extensions/src/types.ts");
|
|
995
|
+
if (fs.existsSync(typesFile)) {
|
|
996
|
+
const before = fs.readFileSync(typesFile, "utf8");
|
|
997
|
+
const lines = before.split("\n");
|
|
998
|
+
const start = lines.findIndex((line) => /^\s*\/\/\s*Slots\s*\(/.test(line));
|
|
999
|
+
if (start >= 0) {
|
|
1000
|
+
let end = start + 1;
|
|
1001
|
+
while (end < lines.length && /^\s*\/\/\s{3,}/.test(lines[end])) end++;
|
|
1002
|
+
lines.splice(end, 0, `// ${slot.padEnd(20)} the store's ${name} itself (children) props: every prop ${name} gets (znode add-slot)`);
|
|
1003
|
+
changes.push({ file: typesFile, before, after: lines.join("\n") });
|
|
1004
|
+
} else warnings.push(`${rel(typesFile)}: no "// Slots (" comment found; add "${slot}" to the slot list yourself`);
|
|
1005
|
+
}
|
|
1006
|
+
// the slot table in docs/RUNTIME_EXTENSIONS_AGENTS.md, when it has one
|
|
1007
|
+
const docsFile = path.join(root, "docs/RUNTIME_EXTENSIONS_AGENTS.md");
|
|
1008
|
+
if (fs.existsSync(docsFile)) {
|
|
1009
|
+
const before = fs.readFileSync(docsFile, "utf8");
|
|
1010
|
+
const lines = before.split("\n");
|
|
1011
|
+
let row = lines.findIndex((line) => line.startsWith("| `product.card`"));
|
|
1012
|
+
if (row >= 0) {
|
|
1013
|
+
while (row + 1 < lines.length && lines[row + 1].startsWith("|")) row++;
|
|
1014
|
+
lines.splice(row + 1, 0, `| \`${slot}\` | \`${wrapper}\` (\`${ADDED_SLOTS_FILE.replace("packages/extensions/src/", "")}\`) | \`${rel(target).replace(/^packages\/[\w-]+\/src\//, "").replace(/\.tsx$/, "")}\` (its children: the component itself) | every ${name} prop; keep \`children\`, wrap it, or render a copy instead (\`znode eject ${name}\`) |`);
|
|
1015
|
+
changes.push({ file: docsFile, before, after: lines.join("\n") });
|
|
1016
|
+
} else warnings.push(`${rel(docsFile)}: slot table not found; add a "${slot}" row yourself`);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
console.log(`${dryRun ? "would add" : "adding"} slot "${slot}" around ${name} (${rel(target)}) in ${root}\n`);
|
|
1020
|
+
for (const { file, before, after } of changes) {
|
|
1021
|
+
const old = new Set((before || "").split("\n"));
|
|
1022
|
+
const added = after.split("\n").filter((line) => !old.has(line));
|
|
1023
|
+
console.log(`${before === null ? "new " : "edit"} ${rel(file)}`);
|
|
1024
|
+
for (const line of added) console.log(` + ${line}`);
|
|
1025
|
+
}
|
|
1026
|
+
if (dryRun) {
|
|
1027
|
+
console.log("\n--dry-run: nothing written. Run it again without --dry-run to write these changes.");
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
for (const { file, after } of changes) fs.writeFileSync(file, after);
|
|
1031
|
+
|
|
1032
|
+
// the store's module registry, when the store generates one (the ejected copy's imports are checked against it)
|
|
1033
|
+
const registry = path.join(root, "scripts/gen-store-module-registry.mjs");
|
|
1034
|
+
if (fs.existsSync(registry)) spawnSync(process.execPath, [registry], { cwd: root, stdio: "inherit" });
|
|
1035
|
+
for (const warning of warnings) console.log(`warning ${warning}`);
|
|
1036
|
+
console.log(
|
|
1037
|
+
`\nDone (the store's source only: nothing committed, .next untouched). Next:\n` +
|
|
1038
|
+
` 1. review the diff in ${root} (git diff) and commit it through the store's own flow (hooks, review: it edits base components)\n` +
|
|
1039
|
+
` 2. rebuild / restart the webstore so it renders the slot\n` +
|
|
1040
|
+
` 3. here: npx znode eject ${name} (or fill "${slot}" in src/browser.tsx with a component of your own)`
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
842
1044
|
const args = process.argv.slice(2);
|
|
843
1045
|
// the store's plugin folder: the argument, else RUNTIME_PLUGINS_DIR, else the project's git-ignored znode.local.json
|
|
844
1046
|
// ({ "pluginsDir": "…" }, written by create-znode-plugin --plugins-dir) in the folder the command runs in
|
|
@@ -858,6 +1060,14 @@ if (command === "eject") {
|
|
|
858
1060
|
eject(args.find((arg, index) => index > 0 && !arg.startsWith("--")), args.includes("--force"));
|
|
859
1061
|
process.exit(0);
|
|
860
1062
|
}
|
|
1063
|
+
if (command === "add-slot" || (command === "add" && args[1] === "slot")) {
|
|
1064
|
+
const rest = args.slice(command === "add" ? 2 : 1).filter((arg) => arg !== "--");
|
|
1065
|
+
const fileAt = rest.indexOf("--file");
|
|
1066
|
+
const file = fileAt >= 0 ? rest[fileAt + 1] : undefined;
|
|
1067
|
+
const positional = rest.filter((arg, index) => !arg.startsWith("--") && !(fileAt >= 0 && index === fileAt + 1));
|
|
1068
|
+
addSlot(positional[0], positional[1], { dryRun: rest.includes("--dry-run"), file });
|
|
1069
|
+
process.exit(0);
|
|
1070
|
+
}
|
|
861
1071
|
if (command === "link-store") {
|
|
862
1072
|
if (!target) fail("usage: znode link-store <storeRepoDir> [projectDir]");
|
|
863
1073
|
linkStore(target, args[2]);
|
package/package.json
CHANGED