@nateui/cli 0.0.0-stage → 0.0.3
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 +24 -2
- package/dist/chassis/nextjs/GENERATED.md +44 -0
- package/dist/chassis/nextjs/next.config.ts +7 -0
- package/dist/chassis/nextjs/package.json +29 -0
- package/dist/chassis/nextjs/pnpm-workspace.yaml +6 -0
- package/dist/chassis/nextjs/postcss.config.mjs +7 -0
- package/dist/chassis/nextjs/src/app/layout.tsx +32 -0
- package/dist/chassis/nextjs/src/app/page.tsx +12 -0
- package/dist/chassis/nextjs/src/styles/base.css +535 -0
- package/dist/chassis/nextjs/src/styles/index.css +54 -0
- package/dist/chassis/nextjs/tsconfig.json +34 -0
- package/dist/chassis/vite/GENERATED.md +46 -0
- package/dist/chassis/vite/index.html +18 -0
- package/dist/chassis/vite/package.json +31 -0
- package/dist/chassis/vite/src/App.tsx +12 -0
- package/dist/chassis/vite/src/main.tsx +10 -0
- package/dist/chassis/vite/src/styles/base.css +535 -0
- package/dist/chassis/vite/src/styles/index.css +54 -0
- package/dist/chassis/vite/tsconfig.app.json +28 -0
- package/dist/chassis/vite/tsconfig.json +7 -0
- package/dist/chassis/vite/tsconfig.node.json +23 -0
- package/dist/chassis/vite/vite.config.ts +13 -0
- package/dist/nateui.js +2631 -0
- package/package.json +31 -4
package/dist/nateui.js
ADDED
|
@@ -0,0 +1,2631 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/args.ts
|
|
4
|
+
function parseFlags(argv, valueFlags) {
|
|
5
|
+
const flags = /* @__PURE__ */ new Set();
|
|
6
|
+
const values = {};
|
|
7
|
+
const positional = [];
|
|
8
|
+
const valueFlagSet = new Set(valueFlags);
|
|
9
|
+
for (let i = 0; i < argv.length; i++) {
|
|
10
|
+
const arg = argv[i];
|
|
11
|
+
if (!arg.startsWith("--")) {
|
|
12
|
+
positional.push(arg);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const body = arg.slice(2);
|
|
16
|
+
const eq = body.indexOf("=");
|
|
17
|
+
if (eq !== -1) {
|
|
18
|
+
values[body.slice(0, eq)] = body.slice(eq + 1);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (valueFlagSet.has(body)) {
|
|
22
|
+
values[body] = argv[++i];
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
flags.add(body);
|
|
26
|
+
}
|
|
27
|
+
return { flags, values, positional };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/closure.ts
|
|
31
|
+
async function closure(ids, catalog) {
|
|
32
|
+
const out = /* @__PURE__ */ new Map();
|
|
33
|
+
const queue = [...ids];
|
|
34
|
+
const scheduled = /* @__PURE__ */ new Set();
|
|
35
|
+
let cursor = 0;
|
|
36
|
+
while (cursor < queue.length) {
|
|
37
|
+
const batch = [];
|
|
38
|
+
while (cursor < queue.length && batch.length < 4) {
|
|
39
|
+
const id = queue[cursor++];
|
|
40
|
+
if (scheduled.has(id)) continue;
|
|
41
|
+
scheduled.add(id);
|
|
42
|
+
batch.push(id);
|
|
43
|
+
}
|
|
44
|
+
const results = batch.map(async (id) => {
|
|
45
|
+
try {
|
|
46
|
+
return { status: "fulfilled", value: await catalog.item(id) };
|
|
47
|
+
} catch (reason) {
|
|
48
|
+
return { status: "rejected", reason };
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
for (const [index, pending] of results.entries()) {
|
|
52
|
+
const result = await pending;
|
|
53
|
+
if (result.status === "rejected") throw result.reason;
|
|
54
|
+
out.set(batch[index], result.value);
|
|
55
|
+
queue.push(...result.value.registryDependencies);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/components-json.ts
|
|
62
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
63
|
+
import { readFile as readFile2, writeFile } from "node:fs/promises";
|
|
64
|
+
import { join as join2 } from "node:path";
|
|
65
|
+
|
|
66
|
+
// src/project-detect.ts
|
|
67
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
68
|
+
import { readFile } from "node:fs/promises";
|
|
69
|
+
import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
70
|
+
function parseJsonc(raw) {
|
|
71
|
+
let out = "";
|
|
72
|
+
let inString = false;
|
|
73
|
+
for (let i = 0; i < raw.length; i++) {
|
|
74
|
+
const ch = raw[i];
|
|
75
|
+
if (inString) {
|
|
76
|
+
out += ch;
|
|
77
|
+
if (ch === "\\") out += raw[++i] ?? "";
|
|
78
|
+
else if (ch === '"') inString = false;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (ch === '"') {
|
|
82
|
+
inString = true;
|
|
83
|
+
out += ch;
|
|
84
|
+
} else if (ch === "/" && raw[i + 1] === "/") {
|
|
85
|
+
while (i < raw.length && raw[i] !== "\n") i++;
|
|
86
|
+
out += "\n";
|
|
87
|
+
} else if (ch === "/" && raw[i + 1] === "*") {
|
|
88
|
+
i += 2;
|
|
89
|
+
while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
|
|
90
|
+
i++;
|
|
91
|
+
} else {
|
|
92
|
+
out += ch;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
|
|
96
|
+
}
|
|
97
|
+
async function readJson(path) {
|
|
98
|
+
if (!existsSync(path)) return void 0;
|
|
99
|
+
try {
|
|
100
|
+
return parseJsonc(await readFile(path, "utf-8"));
|
|
101
|
+
} catch {
|
|
102
|
+
return void 0;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function loadCompilerOptions(file, seen) {
|
|
106
|
+
if (seen.has(file)) return {};
|
|
107
|
+
const chain = new Set(seen).add(file);
|
|
108
|
+
const config = await readJson(file);
|
|
109
|
+
if (!config) return {};
|
|
110
|
+
let merged = {};
|
|
111
|
+
const parents = Array.isArray(config.extends) ? config.extends : config.extends ? [config.extends] : [];
|
|
112
|
+
for (const parent of parents) {
|
|
113
|
+
if (typeof parent !== "string" || !parent.startsWith(".")) continue;
|
|
114
|
+
const parentFile = resolve(dirname(file), parent.endsWith(".json") ? parent : `${parent}.json`);
|
|
115
|
+
const inherited = await loadCompilerOptions(parentFile, chain);
|
|
116
|
+
merged = { ...merged, ...inherited.paths ? { paths: inherited.paths } : {}, ...inherited.baseUrl ? { baseUrl: inherited.baseUrl } : {} };
|
|
117
|
+
}
|
|
118
|
+
const options = config.compilerOptions ?? {};
|
|
119
|
+
if (typeof options.baseUrl === "string") merged.baseUrl = resolve(dirname(file), options.baseUrl);
|
|
120
|
+
if (options.paths && typeof options.paths === "object") merged.paths = { value: options.paths, file };
|
|
121
|
+
return merged;
|
|
122
|
+
}
|
|
123
|
+
async function loadCompilerPaths(file) {
|
|
124
|
+
const { paths, baseUrl } = await loadCompilerOptions(file, /* @__PURE__ */ new Set());
|
|
125
|
+
if (!paths) return {};
|
|
126
|
+
const baseDir = baseUrl ?? dirname(paths.file);
|
|
127
|
+
const entries = [];
|
|
128
|
+
for (const [key, targets] of Object.entries(paths.value)) {
|
|
129
|
+
const first = Array.isArray(targets) ? targets[0] : targets;
|
|
130
|
+
if (typeof first === "string") entries.push({ key, target: first, baseDir, configFile: paths.file });
|
|
131
|
+
}
|
|
132
|
+
return { paths: entries };
|
|
133
|
+
}
|
|
134
|
+
async function collectPaths(projectDir) {
|
|
135
|
+
const files = [];
|
|
136
|
+
for (const name of ["tsconfig.json", "jsconfig.json"]) {
|
|
137
|
+
const file = join(projectDir, name);
|
|
138
|
+
if (!existsSync(file)) continue;
|
|
139
|
+
files.push(file);
|
|
140
|
+
const config = await readJson(file);
|
|
141
|
+
for (const ref of config?.references ?? []) {
|
|
142
|
+
if (typeof ref?.path !== "string") continue;
|
|
143
|
+
const refPath = resolve(projectDir, ref.path);
|
|
144
|
+
files.push(refPath.endsWith(".json") ? refPath : join(refPath, "tsconfig.json"));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const name of ["tsconfig.app.json", "tsconfig.web.json"]) {
|
|
148
|
+
const file = join(projectDir, name);
|
|
149
|
+
if (existsSync(file) && !files.includes(file)) files.push(file);
|
|
150
|
+
}
|
|
151
|
+
const entries = [];
|
|
152
|
+
for (const file of files) {
|
|
153
|
+
const { paths } = await loadCompilerPaths(file);
|
|
154
|
+
if (paths) entries.push(...paths);
|
|
155
|
+
}
|
|
156
|
+
return entries;
|
|
157
|
+
}
|
|
158
|
+
function insideProject(projectDir, dir) {
|
|
159
|
+
const rel = relative(projectDir, dir);
|
|
160
|
+
if (rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
161
|
+
return rel === "" ? "." : rel.split(sep).join("/");
|
|
162
|
+
}
|
|
163
|
+
function layoutFor(prefix, installPath, aliasSource) {
|
|
164
|
+
return { aliasPrefix: prefix, aliasBase: installPath === "." ? "./*" : `./${installPath}/*`, installPath, aliasSource };
|
|
165
|
+
}
|
|
166
|
+
var PREFERRED_PREFIXES = ["@", "~", "#", "$"];
|
|
167
|
+
var SOURCE_ROOTS = [".", "src", "app", "resources/js"];
|
|
168
|
+
var SUBFOLDER_NAMES = /* @__PURE__ */ new Set(["components", "hooks", "utils", "lib", "ui", "styles", "assets", "types", "constants", "configs"]);
|
|
169
|
+
async function aliasFromTsconfig(projectDir) {
|
|
170
|
+
const candidates = [];
|
|
171
|
+
for (const entry of await collectPaths(projectDir)) {
|
|
172
|
+
if (!entry.key.endsWith("/*") || !entry.target.endsWith("/*")) continue;
|
|
173
|
+
const prefix = entry.key.slice(0, -2);
|
|
174
|
+
if (!prefix || prefix.includes("*")) continue;
|
|
175
|
+
const installPath = insideProject(projectDir, resolve(entry.baseDir, entry.target.slice(0, -2)));
|
|
176
|
+
if (!installPath || SUBFOLDER_NAMES.has(posix.basename(installPath))) continue;
|
|
177
|
+
candidates.push({ prefix, installPath, source: relative(projectDir, entry.configFile).split(sep).join("/") });
|
|
178
|
+
}
|
|
179
|
+
if (candidates.length === 0) return null;
|
|
180
|
+
const rank = (c) => {
|
|
181
|
+
const p = PREFERRED_PREFIXES.indexOf(c.prefix);
|
|
182
|
+
const r = SOURCE_ROOTS.includes(c.installPath) ? 0 : 1;
|
|
183
|
+
return r * 10 + (p === -1 ? PREFERRED_PREFIXES.length : p);
|
|
184
|
+
};
|
|
185
|
+
candidates.sort((a, b) => rank(a) - rank(b));
|
|
186
|
+
const best = candidates[0];
|
|
187
|
+
return layoutFor(best.prefix, best.installPath, best.source);
|
|
188
|
+
}
|
|
189
|
+
function importTarget(value) {
|
|
190
|
+
if (typeof value === "string") return value;
|
|
191
|
+
if (value && typeof value === "object") {
|
|
192
|
+
for (const condition of ["types", "import", "default", "node"]) {
|
|
193
|
+
const resolved = importTarget(value[condition]);
|
|
194
|
+
if (resolved) return resolved;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
function aliasFromPackageImports(projectDir, pkg) {
|
|
200
|
+
const imports = pkg?.imports;
|
|
201
|
+
if (!imports || typeof imports !== "object") return null;
|
|
202
|
+
for (const [key, value] of Object.entries(imports)) {
|
|
203
|
+
if (!key.startsWith("#") || !key.endsWith("/*")) continue;
|
|
204
|
+
const target = importTarget(value);
|
|
205
|
+
if (!target || !target.startsWith("./") || !target.includes("*")) continue;
|
|
206
|
+
const dir = posix.dirname(target.slice(0, target.indexOf("*")) + "x");
|
|
207
|
+
const installPath = insideProject(projectDir, resolve(projectDir, dir));
|
|
208
|
+
if (installPath) return layoutFor(key.slice(0, -2), installPath, "package.json imports");
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
function hasConfig(projectDir, names) {
|
|
213
|
+
let entries;
|
|
214
|
+
try {
|
|
215
|
+
entries = readdirSync(projectDir);
|
|
216
|
+
} catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
return entries.some((entry) => names.some((name) => entry === name || entry.startsWith(`${name}.`)));
|
|
220
|
+
}
|
|
221
|
+
function detectFrameworkLabel(projectDir, pkg) {
|
|
222
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
223
|
+
const has = (name) => name in deps;
|
|
224
|
+
const hasPrefix = (prefix) => Object.keys(deps).some((dep) => dep.startsWith(prefix));
|
|
225
|
+
if (hasConfig(projectDir, ["next.config"]) || has("next")) return "Next.js";
|
|
226
|
+
if (hasConfig(projectDir, ["react-router.config"]) || has("@react-router/dev")) return "React Router";
|
|
227
|
+
if (hasPrefix("@tanstack/react-start")) return "TanStack Start";
|
|
228
|
+
if (hasPrefix("@remix-run/")) return "Remix";
|
|
229
|
+
if (hasConfig(projectDir, ["astro.config"]) || has("astro")) return "Astro";
|
|
230
|
+
if (hasConfig(projectDir, ["gatsby-config"]) || has("gatsby")) return "Gatsby";
|
|
231
|
+
if (existsSync(join(projectDir, "artisan")) || has("laravel-vite-plugin")) return "Laravel";
|
|
232
|
+
if (has("waku")) return "Waku";
|
|
233
|
+
if (hasPrefix("@rsbuild/")) return "Rsbuild";
|
|
234
|
+
if (hasPrefix("@rspack/")) return "Rspack";
|
|
235
|
+
if (hasConfig(projectDir, ["vite.config"]) || has("vite")) return "Vite";
|
|
236
|
+
if (has("parcel")) return "Parcel";
|
|
237
|
+
if (has("react-scripts")) return "Create React App";
|
|
238
|
+
return "React";
|
|
239
|
+
}
|
|
240
|
+
function conventionalFolder(projectDir, framework) {
|
|
241
|
+
if (framework === "React Router" || framework === "Remix") {
|
|
242
|
+
if (existsSync(join(projectDir, "app"))) return "app";
|
|
243
|
+
}
|
|
244
|
+
if (framework === "Laravel" && existsSync(join(projectDir, "resources", "js"))) return "resources/js";
|
|
245
|
+
return existsSync(join(projectDir, "src")) ? "src" : ".";
|
|
246
|
+
}
|
|
247
|
+
var UnsupportedProjectError = class extends Error {
|
|
248
|
+
};
|
|
249
|
+
async function detectProject(projectDir) {
|
|
250
|
+
const pkg = await readJson(join(projectDir, "package.json"));
|
|
251
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
252
|
+
if (("react-native" in deps || "expo" in deps) && !("react-dom" in deps)) {
|
|
253
|
+
throw new UnsupportedProjectError(
|
|
254
|
+
"this looks like a React Native project \u2014 NateUI components are for React on the web (react-dom)"
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
const framework = detectFrameworkLabel(projectDir, pkg);
|
|
258
|
+
const layout = await aliasFromTsconfig(projectDir) ?? aliasFromPackageImports(projectDir, pkg) ?? layoutFor("@", conventionalFolder(projectDir, framework), null);
|
|
259
|
+
return { framework, ...layout };
|
|
260
|
+
}
|
|
261
|
+
function rewriteAliasImports(content, prefix) {
|
|
262
|
+
if (prefix === "@") return content;
|
|
263
|
+
return content.replace(
|
|
264
|
+
/(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*|\b(?:vi|jest)\.mock\s*\(\s*)(['"])@\//g,
|
|
265
|
+
(_match, lead, quote) => `${lead}${quote}${prefix}/`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
function isScriptFile(path) {
|
|
269
|
+
return /\.(?:[cm]?[jt]sx?)$/.test(path);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/ui.ts
|
|
273
|
+
var NateuiError = class extends Error {
|
|
274
|
+
};
|
|
275
|
+
function fail(message) {
|
|
276
|
+
throw new NateuiError(message);
|
|
277
|
+
}
|
|
278
|
+
function printError(message) {
|
|
279
|
+
console.error(`nateui: ${message}`);
|
|
280
|
+
}
|
|
281
|
+
function warn(message) {
|
|
282
|
+
console.log(`warn: ${message}`);
|
|
283
|
+
}
|
|
284
|
+
function info(message) {
|
|
285
|
+
console.log(message);
|
|
286
|
+
}
|
|
287
|
+
async function withSpinner(label, work) {
|
|
288
|
+
if (!process.stdout.isTTY) return work();
|
|
289
|
+
const fancy = process.platform !== "win32" || !!process.env.WT_SESSION || !!process.env.TERM_PROGRAM || !!process.env.TERM;
|
|
290
|
+
const frames = fancy ? ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"] : ["-", "\\", "|", "/"];
|
|
291
|
+
let frame = 0;
|
|
292
|
+
const draw = () => process.stdout.write(`\r${frames[frame++ % frames.length]} ${label}`);
|
|
293
|
+
draw();
|
|
294
|
+
const interval = setInterval(draw, 90);
|
|
295
|
+
try {
|
|
296
|
+
return await work();
|
|
297
|
+
} finally {
|
|
298
|
+
clearInterval(interval);
|
|
299
|
+
process.stdout.write(`\r${" ".repeat(label.length + 2)}\r`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// src/components-json.ts
|
|
304
|
+
function componentsJsonPath(projectDir) {
|
|
305
|
+
return join2(projectDir, "components.json");
|
|
306
|
+
}
|
|
307
|
+
function componentsJsonExists(projectDir) {
|
|
308
|
+
return existsSync2(componentsJsonPath(projectDir));
|
|
309
|
+
}
|
|
310
|
+
async function readComponentsJson(projectDir) {
|
|
311
|
+
const raw = await readFile2(componentsJsonPath(projectDir), "utf-8");
|
|
312
|
+
return JSON.parse(raw);
|
|
313
|
+
}
|
|
314
|
+
async function writeComponentsJson(projectDir, data) {
|
|
315
|
+
await writeFile(componentsJsonPath(projectDir), JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
316
|
+
}
|
|
317
|
+
function newComponentsJson(framework, layout = { aliasBase: "./src/*", installPath: "src" }) {
|
|
318
|
+
return {
|
|
319
|
+
schemaVersion: 1,
|
|
320
|
+
framework,
|
|
321
|
+
alias: { scheme: layout.aliasPrefix ?? "@", base: layout.aliasBase },
|
|
322
|
+
installPath: layout.installPath,
|
|
323
|
+
installed: { components: [], pages: [] }
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
async function loadOrDetectComponentsJson(projectDir) {
|
|
327
|
+
if (componentsJsonExists(projectDir)) return readComponentsJson(projectDir);
|
|
328
|
+
let project;
|
|
329
|
+
try {
|
|
330
|
+
project = await detectProject(projectDir);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
if (err instanceof UnsupportedProjectError) fail(err.message);
|
|
333
|
+
throw err;
|
|
334
|
+
}
|
|
335
|
+
const folder = project.installPath === "." ? "the project root" : `${project.installPath}/`;
|
|
336
|
+
if (project.aliasSource) {
|
|
337
|
+
info(`creating components.json (${project.framework}, "${project.aliasPrefix}/" -> ${folder}, from ${project.aliasSource})`);
|
|
338
|
+
} else {
|
|
339
|
+
info(`creating components.json (${project.framework}, installing into ${folder})`);
|
|
340
|
+
warn(
|
|
341
|
+
`no import shortcut found \u2014 NateUI files import from "@/". Add "paths": { "@/*": ["${project.aliasBase}"] } to your tsconfig` + (project.framework === "Vite" ? " (tsconfig.app.json) and a matching resolve.alias in vite.config" : "")
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
return newComponentsJson(project.framework, project);
|
|
345
|
+
}
|
|
346
|
+
function addInstalledComponents(data, ids) {
|
|
347
|
+
const set = new Set(data.installed.components);
|
|
348
|
+
for (const id of ids) set.add(id);
|
|
349
|
+
data.installed.components = [...set].sort();
|
|
350
|
+
}
|
|
351
|
+
function addInstalledPage(data, id) {
|
|
352
|
+
const set = new Set(data.installed.pages);
|
|
353
|
+
set.add(id);
|
|
354
|
+
data.installed.pages = [...set].sort();
|
|
355
|
+
}
|
|
356
|
+
function removeInstalledPage(data, id) {
|
|
357
|
+
data.installed.pages = data.installed.pages.filter((p) => p !== id);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// src/install-items.ts
|
|
361
|
+
import { readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
|
|
362
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
363
|
+
import { join as join3 } from "node:path";
|
|
364
|
+
|
|
365
|
+
// src/write-plan.ts
|
|
366
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
367
|
+
import { mkdir, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
|
|
368
|
+
import { dirname as dirname2 } from "node:path";
|
|
369
|
+
function normalizeText(content) {
|
|
370
|
+
return content.replace(/\r\n/g, "\n");
|
|
371
|
+
}
|
|
372
|
+
async function writeAction(file, overwrite) {
|
|
373
|
+
if (!existsSync3(file.targetPath)) return "written";
|
|
374
|
+
const existing = await readFile3(file.targetPath);
|
|
375
|
+
const text = normalizeText(existing.toString("utf-8"));
|
|
376
|
+
const identical = typeof file.content === "string" ? text === normalizeText(file.content) : existing.equals(file.content);
|
|
377
|
+
if (identical) return "skipped-identical";
|
|
378
|
+
if (overwrite || file.starterOverlay && text === normalizeText(file.starterOverlay.content)) return "written";
|
|
379
|
+
return "skipped-differs";
|
|
380
|
+
}
|
|
381
|
+
async function applyWritePlan(files, overwrite) {
|
|
382
|
+
const results = [];
|
|
383
|
+
for (const file of files) {
|
|
384
|
+
const action = await writeAction(file, overwrite);
|
|
385
|
+
if (action === "written") {
|
|
386
|
+
await mkdir(dirname2(file.targetPath), { recursive: true });
|
|
387
|
+
await writeFile2(file.targetPath, file.content, "utf-8");
|
|
388
|
+
} else if (action === "skipped-differs") {
|
|
389
|
+
const needed = file.starterOverlay ? `needed by ${file.starterOverlay.pageId}; ` : "";
|
|
390
|
+
warn(`exists, differs, skipped: ${file.targetPath} (${needed}rerun with --overwrite to replace)`);
|
|
391
|
+
}
|
|
392
|
+
results.push({ targetPath: file.targetPath, action });
|
|
393
|
+
}
|
|
394
|
+
return results;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/install-items.ts
|
|
398
|
+
function planFiles(items, projectDir, installPath, aliasPrefix = "@") {
|
|
399
|
+
const plan = [];
|
|
400
|
+
for (const item of items) {
|
|
401
|
+
for (const file of item.files) {
|
|
402
|
+
if (file.content === null || file.content === void 0) {
|
|
403
|
+
throw new Error(`'${item.id}' requires a license \u2014 run: nateui login`);
|
|
404
|
+
}
|
|
405
|
+
const content = isScriptFile(file.path) ? rewriteAliasImports(file.content, aliasPrefix) : file.content;
|
|
406
|
+
plan.push({ targetPath: join3(projectDir, installPath, file.path), content });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return plan;
|
|
410
|
+
}
|
|
411
|
+
async function writeItemFiles(items, projectDir, installPath, overwrite, aliasPrefix = "@") {
|
|
412
|
+
const plan = planFiles(items, projectDir, installPath, aliasPrefix);
|
|
413
|
+
return applyWritePlan(plan, overwrite);
|
|
414
|
+
}
|
|
415
|
+
async function mergeNpmDependencies(items, projectDir) {
|
|
416
|
+
const pkgPath = join3(projectDir, "package.json");
|
|
417
|
+
if (!existsSync4(pkgPath)) return { changed: false, added: [] };
|
|
418
|
+
const pkg = JSON.parse(await readFile4(pkgPath, "utf-8"));
|
|
419
|
+
pkg.dependencies ??= {};
|
|
420
|
+
const added = [];
|
|
421
|
+
let changed = false;
|
|
422
|
+
for (const item of items) {
|
|
423
|
+
for (const [name, pin] of Object.entries(item.dependencies)) {
|
|
424
|
+
if (pkg.dependencies[name] !== void 0) {
|
|
425
|
+
if (pkg.dependencies[name] !== pin) {
|
|
426
|
+
warn(`'${name}' already at ${pkg.dependencies[name]}, registry wants ${pin} \u2014 not changed, resolve manually`);
|
|
427
|
+
}
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
pkg.dependencies[name] = pin;
|
|
431
|
+
added.push(name);
|
|
432
|
+
changed = true;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (changed) await writeFile3(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
|
|
436
|
+
return { changed, added };
|
|
437
|
+
}
|
|
438
|
+
function npmDependencyLines(items) {
|
|
439
|
+
const seen = /* @__PURE__ */ new Map();
|
|
440
|
+
for (const item of items) {
|
|
441
|
+
for (const [name, pin] of Object.entries(item.dependencies)) {
|
|
442
|
+
if (!seen.has(name)) seen.set(name, pin);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return [...seen.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, pin]) => ` ${name} (>=${pin.replace(/^[\^~]/, "")})`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// src/resolve-name.ts
|
|
449
|
+
function itemName(id) {
|
|
450
|
+
return id.slice(id.indexOf("/") + 1);
|
|
451
|
+
}
|
|
452
|
+
function normalizedName(name) {
|
|
453
|
+
return name.replace(/[\s_-]/g, "").toLowerCase();
|
|
454
|
+
}
|
|
455
|
+
function resolveItemId(input, index) {
|
|
456
|
+
const nonHidden = index.items.filter((i) => !i.hidden);
|
|
457
|
+
if (input.includes("/")) {
|
|
458
|
+
const [kind, name] = input.split("/", 2);
|
|
459
|
+
const match = nonHidden.find(
|
|
460
|
+
(i) => i.kind.toLowerCase() === kind.toLowerCase() && normalizedName(itemName(i.id)) === normalizedName(name)
|
|
461
|
+
);
|
|
462
|
+
if (!match) fail(`unknown component '${input}' \u2014 run: nateui list`);
|
|
463
|
+
return match.id;
|
|
464
|
+
}
|
|
465
|
+
const matches = nonHidden.filter((i) => normalizedName(itemName(i.id)) === normalizedName(input));
|
|
466
|
+
if (matches.length === 0) fail(`unknown component '${input}' \u2014 run: nateui list`);
|
|
467
|
+
if (matches.length > 1) {
|
|
468
|
+
const ids = matches.map((m) => m.id).join(", ");
|
|
469
|
+
fail(`'${input}' matches more than one item: ${ids} \u2014 run with the layer prefix, e.g. nateui add ${matches[0].id}`);
|
|
470
|
+
}
|
|
471
|
+
return matches[0].id;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// src/transport.ts
|
|
475
|
+
import { existsSync as existsSync7 } from "node:fs";
|
|
476
|
+
import { mkdir as mkdir3, writeFile as writeFile5 } from "node:fs/promises";
|
|
477
|
+
import { join as join6 } from "node:path";
|
|
478
|
+
|
|
479
|
+
// src/catalog.ts
|
|
480
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
481
|
+
import { readFile as readFile6, readdir } from "node:fs/promises";
|
|
482
|
+
import { join as join5 } from "node:path";
|
|
483
|
+
|
|
484
|
+
// src/login-flow.ts
|
|
485
|
+
import { chmod, mkdir as mkdir2, writeFile as writeFile4 } from "node:fs/promises";
|
|
486
|
+
import { createServer } from "node:http";
|
|
487
|
+
import { hostname } from "node:os";
|
|
488
|
+
import { createInterface } from "node:readline/promises";
|
|
489
|
+
|
|
490
|
+
// src/open-browser.ts
|
|
491
|
+
import { spawn } from "node:child_process";
|
|
492
|
+
function openBrowser(url) {
|
|
493
|
+
const [command, args, verbatim] = process.platform === "win32" ? ["cmd", ["/c", "start", '""', url.replace(/&/g, "^&")], true] : process.platform === "darwin" ? ["open", [url], false] : ["xdg-open", [url], false];
|
|
494
|
+
return new Promise((resolve5) => {
|
|
495
|
+
let settled = false;
|
|
496
|
+
const settle = (opened) => {
|
|
497
|
+
if (settled) return;
|
|
498
|
+
settled = true;
|
|
499
|
+
resolve5(opened);
|
|
500
|
+
};
|
|
501
|
+
try {
|
|
502
|
+
const child = spawn(command, args, {
|
|
503
|
+
stdio: "ignore",
|
|
504
|
+
detached: true,
|
|
505
|
+
windowsVerbatimArguments: verbatim
|
|
506
|
+
});
|
|
507
|
+
child.on("error", () => settle(false));
|
|
508
|
+
child.on("exit", (code) => settle(code === 0));
|
|
509
|
+
child.unref();
|
|
510
|
+
setTimeout(() => settle(true), 3e3).unref();
|
|
511
|
+
} catch {
|
|
512
|
+
settle(false);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/paths.ts
|
|
518
|
+
import { dirname as dirname3, join as join4 } from "node:path";
|
|
519
|
+
import { fileURLToPath } from "node:url";
|
|
520
|
+
import { homedir } from "node:os";
|
|
521
|
+
var PACKAGE_ROOT = dirname3(dirname3(fileURLToPath(import.meta.url)));
|
|
522
|
+
var CHASSIS_DIR = join4(PACKAGE_ROOT, "dist", "chassis");
|
|
523
|
+
var NATEUI_HOME = join4(homedir(), ".nateui");
|
|
524
|
+
var CACHE_DIR = join4(NATEUI_HOME, "cache");
|
|
525
|
+
var CREDENTIALS_PATH = join4(NATEUI_HOME, "credentials");
|
|
526
|
+
|
|
527
|
+
// src/pkce.ts
|
|
528
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
529
|
+
function createPkcePair() {
|
|
530
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
531
|
+
return { verifier, challenge: s256(verifier) };
|
|
532
|
+
}
|
|
533
|
+
function s256(verifier) {
|
|
534
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
535
|
+
}
|
|
536
|
+
function createState() {
|
|
537
|
+
return randomBytes(24).toString("base64url");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/token.ts
|
|
541
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
542
|
+
import { readFile as readFile5 } from "node:fs/promises";
|
|
543
|
+
async function resolveToken(flag) {
|
|
544
|
+
if (flag) return flag;
|
|
545
|
+
if (process.env.NATEUI_TOKEN) return process.env.NATEUI_TOKEN;
|
|
546
|
+
if (existsSync5(CREDENTIALS_PATH)) {
|
|
547
|
+
try {
|
|
548
|
+
const stored = JSON.parse(await readFile5(CREDENTIALS_PATH, "utf-8"));
|
|
549
|
+
if (stored.token) return stored.token;
|
|
550
|
+
} catch {
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return void 0;
|
|
554
|
+
}
|
|
555
|
+
function apiBase() {
|
|
556
|
+
return process.env.NATEUI_API ?? "https://nateui.com";
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// src/login-flow.ts
|
|
560
|
+
var MAX_PASTE_ATTEMPTS = 3;
|
|
561
|
+
function machineLabel() {
|
|
562
|
+
let raw = "";
|
|
563
|
+
try {
|
|
564
|
+
raw = hostname();
|
|
565
|
+
} catch {
|
|
566
|
+
}
|
|
567
|
+
const cleaned = raw.replace(/[^A-Za-z0-9 ._-]/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64).trim();
|
|
568
|
+
return cleaned || "machine";
|
|
569
|
+
}
|
|
570
|
+
function connectUrl(params) {
|
|
571
|
+
return `${apiBase()}/cli/connect?${new URLSearchParams(params).toString()}`;
|
|
572
|
+
}
|
|
573
|
+
function isRemoteSession() {
|
|
574
|
+
return !!process.env.SSH_CONNECTION || !!process.env.SSH_TTY;
|
|
575
|
+
}
|
|
576
|
+
async function exchangeCode(code, verifier) {
|
|
577
|
+
let res;
|
|
578
|
+
try {
|
|
579
|
+
res = await fetch(`${apiBase()}/api/cli/auth/token`, {
|
|
580
|
+
method: "POST",
|
|
581
|
+
headers: { "Content-Type": "application/json" },
|
|
582
|
+
body: JSON.stringify({ code, verifier })
|
|
583
|
+
});
|
|
584
|
+
} catch {
|
|
585
|
+
fail(`could not reach ${apiBase()} \u2014 check your connection and try again`);
|
|
586
|
+
}
|
|
587
|
+
if (res.status === 400) return null;
|
|
588
|
+
if (!res.ok) fail(`sign-in failed (${res.status})`);
|
|
589
|
+
return await res.json();
|
|
590
|
+
}
|
|
591
|
+
function escapeHtml(value) {
|
|
592
|
+
return value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
593
|
+
}
|
|
594
|
+
function resultPage(title, message, showDashboard) {
|
|
595
|
+
const dashboard = escapeHtml(`${apiBase()}/dashboard`);
|
|
596
|
+
return `<!doctype html>
|
|
597
|
+
<html lang="en">
|
|
598
|
+
<head>
|
|
599
|
+
<meta charset="utf-8">
|
|
600
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
601
|
+
<meta name="robots" content="noindex">
|
|
602
|
+
<title>NateUI CLI</title>
|
|
603
|
+
<style>
|
|
604
|
+
:root{color-scheme:light dark;--bg:#fff;--fg:#0a0a0a;--muted:#525252;--btn:#0a0a0a;--btn-fg:#fff}
|
|
605
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0a0a0a;--fg:#fafafa;--muted:#a3a3a3;--btn:#fafafa;--btn-fg:#0a0a0a}}
|
|
606
|
+
*{box-sizing:border-box}
|
|
607
|
+
body{margin:0;min-height:100dvh;display:flex;align-items:center;justify-content:center;padding:24px 16px;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
608
|
+
main{width:100%;max-width:28rem}
|
|
609
|
+
h1{margin:0 0 8px;font-size:28px;line-height:36px;font-weight:600;letter-spacing:-.01em}
|
|
610
|
+
p{margin:0 0 24px;color:var(--muted)}
|
|
611
|
+
a{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:0 20px;border-radius:999px;background:var(--btn);color:var(--btn-fg);font-size:14px;font-weight:500;text-decoration:none}
|
|
612
|
+
a:focus-visible{outline:2px solid var(--fg);outline-offset:2px}
|
|
613
|
+
</style>
|
|
614
|
+
</head>
|
|
615
|
+
<body>
|
|
616
|
+
<main>
|
|
617
|
+
<h1>${escapeHtml(title)}</h1>
|
|
618
|
+
<p>${escapeHtml(message)}</p>
|
|
619
|
+
${showDashboard ? `<a href="${dashboard}">Go to dashboard</a>` : ""}
|
|
620
|
+
</main>
|
|
621
|
+
</body>
|
|
622
|
+
</html>`;
|
|
623
|
+
}
|
|
624
|
+
function sendPage(res, status, html) {
|
|
625
|
+
return new Promise((resolve5) => {
|
|
626
|
+
res.writeHead(status, {
|
|
627
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
628
|
+
"Cache-Control": "no-store",
|
|
629
|
+
Connection: "close"
|
|
630
|
+
});
|
|
631
|
+
res.end(html, () => resolve5());
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
async function loopbackLogin(pkce, state, label, open) {
|
|
635
|
+
let settle;
|
|
636
|
+
const result = new Promise((resolve5, reject) => {
|
|
637
|
+
settle = { resolve: resolve5, reject };
|
|
638
|
+
});
|
|
639
|
+
result.catch(() => {
|
|
640
|
+
});
|
|
641
|
+
let handled = false;
|
|
642
|
+
const server = createServer(async (req, res) => {
|
|
643
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
644
|
+
if (req.method !== "GET" || url.pathname !== "/callback") {
|
|
645
|
+
await sendPage(res, 404, resultPage("Not found", "Return to your terminal.", false));
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (url.searchParams.get("state") !== state || handled) {
|
|
649
|
+
await sendPage(res, 400, resultPage("Sign-in not recognised", "Run the command again in your terminal.", false));
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
handled = true;
|
|
653
|
+
if (url.searchParams.get("error")) {
|
|
654
|
+
await sendPage(res, 200, resultPage("Cancelled", "The CLI was not connected. You can close this tab.", false));
|
|
655
|
+
settle.reject(new NateuiError("sign-in cancelled in the browser"));
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const code = url.searchParams.get("code") ?? "";
|
|
659
|
+
try {
|
|
660
|
+
const token = await exchangeCode(code, pkce.verifier);
|
|
661
|
+
if (!token) {
|
|
662
|
+
await sendPage(res, 200, resultPage("Sign-in failed", "Return to your terminal and run the command again.", false));
|
|
663
|
+
settle.reject(new NateuiError("sign-in failed \u2014 the code was invalid or expired; run the command again"));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
await sendPage(
|
|
667
|
+
res,
|
|
668
|
+
200,
|
|
669
|
+
resultPage("Connected", "You can close this tab and return to your terminal, or", true)
|
|
670
|
+
);
|
|
671
|
+
settle.resolve(token);
|
|
672
|
+
} catch (error) {
|
|
673
|
+
await sendPage(res, 200, resultPage("Sign-in failed", "Return to your terminal for details.", false));
|
|
674
|
+
settle.reject(error instanceof Error ? error : new NateuiError("sign-in failed"));
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
await new Promise((resolve5, reject) => {
|
|
678
|
+
server.once("error", reject);
|
|
679
|
+
server.listen(0, "127.0.0.1", () => resolve5());
|
|
680
|
+
});
|
|
681
|
+
const close = () => {
|
|
682
|
+
server.close();
|
|
683
|
+
server.closeAllConnections?.();
|
|
684
|
+
};
|
|
685
|
+
try {
|
|
686
|
+
const { port } = server.address();
|
|
687
|
+
const url = connectUrl({ state, challenge: pkce.challenge, label, port: String(port) });
|
|
688
|
+
info("Opening your browser to sign in\u2026");
|
|
689
|
+
info(`If it doesn't open, visit: ${url}`);
|
|
690
|
+
const opened = await open(url);
|
|
691
|
+
if (!opened && !handled) return null;
|
|
692
|
+
return await withSpinner("waiting for the browser", () => result);
|
|
693
|
+
} finally {
|
|
694
|
+
close();
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
async function pasteLogin(pkce, state, label) {
|
|
698
|
+
const url = connectUrl({ state, challenge: pkce.challenge, label, mode: "paste" });
|
|
699
|
+
info("Open this address in a browser to sign in:");
|
|
700
|
+
info(url);
|
|
701
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
702
|
+
try {
|
|
703
|
+
for (let attempt = 1; attempt <= MAX_PASTE_ATTEMPTS; attempt++) {
|
|
704
|
+
let pasted;
|
|
705
|
+
try {
|
|
706
|
+
pasted = (await rl.question("Paste code here if prompted: ")).trim();
|
|
707
|
+
} catch {
|
|
708
|
+
fail("sign-in cancelled \u2014 no code entered");
|
|
709
|
+
}
|
|
710
|
+
const [code, pastedState] = pasted.split("#");
|
|
711
|
+
if (!code) {
|
|
712
|
+
info("no code entered");
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
if (pastedState && pastedState !== state) fail("that code belongs to a different sign-in \u2014 run the command again");
|
|
716
|
+
const token = await withSpinner("checking your code", () => exchangeCode(code, pkce.verifier));
|
|
717
|
+
if (token) return token;
|
|
718
|
+
info("that code didn't work \u2014 copy it again from the browser page");
|
|
719
|
+
}
|
|
720
|
+
fail("too many attempts \u2014 run nateui login again");
|
|
721
|
+
} finally {
|
|
722
|
+
rl.close();
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
async function runLoginFlow(open = openBrowser) {
|
|
726
|
+
void fetch(`${apiBase()}/api/cli/validate`).then((r) => r.arrayBuffer()).catch(() => {
|
|
727
|
+
});
|
|
728
|
+
const pkce = createPkcePair();
|
|
729
|
+
const state = createState();
|
|
730
|
+
const label = machineLabel();
|
|
731
|
+
let result = isRemoteSession() ? null : await loopbackLogin(pkce, state, label, open);
|
|
732
|
+
if (!result) {
|
|
733
|
+
if (!isRemoteSession()) info("Couldn't open a browser on this machine.");
|
|
734
|
+
result = await pasteLogin(pkce, state, label);
|
|
735
|
+
}
|
|
736
|
+
await mkdir2(NATEUI_HOME, { recursive: true });
|
|
737
|
+
await writeFile4(CREDENTIALS_PATH, JSON.stringify({ token: result.token }) + "\n", "utf-8");
|
|
738
|
+
await chmod(CREDENTIALS_PATH, 384).catch(() => {
|
|
739
|
+
});
|
|
740
|
+
info(`Signed in as ${result.email}`);
|
|
741
|
+
if (result.needsPick) info(`pick your template at ${apiBase()}/dashboard`);
|
|
742
|
+
return result.token;
|
|
743
|
+
}
|
|
744
|
+
var pendingLogin;
|
|
745
|
+
async function resolveOrLogin(flag) {
|
|
746
|
+
const stored = await resolveToken(flag);
|
|
747
|
+
if (stored) return stored;
|
|
748
|
+
if (!process.stdin.isTTY) return void 0;
|
|
749
|
+
pendingLogin ??= runLoginFlow();
|
|
750
|
+
return pendingLogin;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// src/catalog.ts
|
|
754
|
+
function localCatalog(dir) {
|
|
755
|
+
const registryDir = join5(dir, "registry");
|
|
756
|
+
let cachedIndex;
|
|
757
|
+
return {
|
|
758
|
+
async index() {
|
|
759
|
+
if (cachedIndex) return cachedIndex;
|
|
760
|
+
const path = join5(registryDir, "index.json");
|
|
761
|
+
if (!existsSync6(path)) {
|
|
762
|
+
fail(`no registry source \u2014 pass --local <dir> (network transport lands in M4)`);
|
|
763
|
+
}
|
|
764
|
+
cachedIndex = JSON.parse(await readFile6(path, "utf-8"));
|
|
765
|
+
return cachedIndex;
|
|
766
|
+
},
|
|
767
|
+
async item(id) {
|
|
768
|
+
const path = join5(registryDir, "components", `${id}.json`);
|
|
769
|
+
if (!existsSync6(path)) fail(`unknown item '${id}'`);
|
|
770
|
+
return JSON.parse(await readFile6(path, "utf-8"));
|
|
771
|
+
},
|
|
772
|
+
async pageRegistry(slug) {
|
|
773
|
+
fail(`page registry for '${slug}' isn't available via --local alone \u2014 resolve the template tarball instead`);
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
function networkCatalog(base, initialToken) {
|
|
778
|
+
let token = initialToken;
|
|
779
|
+
let cachedIndex;
|
|
780
|
+
let pendingIndex;
|
|
781
|
+
async function ensureIndex() {
|
|
782
|
+
if (cachedIndex) return cachedIndex;
|
|
783
|
+
pendingIndex ??= (async () => {
|
|
784
|
+
const res = await fetch(`${base}/api/cli/registry/index`);
|
|
785
|
+
if (!res.ok) fail(`couldn't reach the registry (${res.status}) \u2014 check --local/--tarball or NATEUI_API`);
|
|
786
|
+
cachedIndex = await res.json();
|
|
787
|
+
return cachedIndex;
|
|
788
|
+
})().catch((error) => {
|
|
789
|
+
pendingIndex = void 0;
|
|
790
|
+
throw error;
|
|
791
|
+
});
|
|
792
|
+
return pendingIndex;
|
|
793
|
+
}
|
|
794
|
+
const authHeaders = () => token ? { Authorization: `Bearer ${token}` } : void 0;
|
|
795
|
+
return {
|
|
796
|
+
index: ensureIndex,
|
|
797
|
+
async item(id) {
|
|
798
|
+
const index = await ensureIndex();
|
|
799
|
+
const isPro = index.items.find((i) => i.id === id)?.tier === "pro";
|
|
800
|
+
if (isPro && !token) {
|
|
801
|
+
token = await resolveOrLogin();
|
|
802
|
+
}
|
|
803
|
+
const res = await fetch(`${base}/api/cli/registry/components/${id}`, { headers: authHeaders() });
|
|
804
|
+
if (res.status === 404) fail(`unknown item '${id}'`);
|
|
805
|
+
if (!res.ok) fail(`couldn't fetch '${id}' (${res.status})`);
|
|
806
|
+
return await res.json();
|
|
807
|
+
},
|
|
808
|
+
async pageRegistry(slug) {
|
|
809
|
+
if (!token) token = await resolveOrLogin();
|
|
810
|
+
const res = await fetch(`${base}/api/cli/registry/pages/${slug}`, { headers: authHeaders() });
|
|
811
|
+
if (res.status === 404) fail(`unknown template '${slug}'`);
|
|
812
|
+
if (res.status === 401 || res.status === 403) {
|
|
813
|
+
const body = await res.json().catch(() => ({}));
|
|
814
|
+
fail(body.error ?? `not entitled to '${slug}' (${res.status})`);
|
|
815
|
+
}
|
|
816
|
+
if (!res.ok) fail(`couldn't fetch pages for '${slug}' (${res.status})`);
|
|
817
|
+
return await res.json();
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
async function findLocalTarball(localDir, slug) {
|
|
822
|
+
const parent = join5(localDir, "..");
|
|
823
|
+
if (!existsSync6(parent)) return null;
|
|
824
|
+
for (const entry of await readdir(parent)) {
|
|
825
|
+
if (entry.startsWith(`${slug}-`) && entry.endsWith(".tar.gz")) {
|
|
826
|
+
return join5(parent, entry);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return null;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// src/transport.ts
|
|
833
|
+
async function resolveCatalog(localDir, tokenFlag) {
|
|
834
|
+
if (localDir) return localCatalog(localDir);
|
|
835
|
+
const token = await resolveToken(tokenFlag);
|
|
836
|
+
return networkCatalog(apiBase(), token);
|
|
837
|
+
}
|
|
838
|
+
async function fetchTarballFromNetwork(slug, tokenFlag) {
|
|
839
|
+
const token = await resolveOrLogin(tokenFlag);
|
|
840
|
+
const headers = { "Content-Type": "application/json" };
|
|
841
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
842
|
+
const res = await withSpinner(
|
|
843
|
+
"requesting template",
|
|
844
|
+
() => fetch(`${apiBase()}/api/cli/scaffold`, {
|
|
845
|
+
method: "POST",
|
|
846
|
+
headers,
|
|
847
|
+
body: JSON.stringify({ template: slug })
|
|
848
|
+
})
|
|
849
|
+
);
|
|
850
|
+
if (!res.ok) {
|
|
851
|
+
const body = await res.json().catch(() => ({}));
|
|
852
|
+
fail(body.error ?? `scaffold request failed (${res.status})`);
|
|
853
|
+
}
|
|
854
|
+
const scaffold = await res.json();
|
|
855
|
+
const buffer = await withSpinner("downloading template", async () => {
|
|
856
|
+
const download = await fetch(scaffold.url);
|
|
857
|
+
if (!download.ok) fail(`tarball download failed (${download.status})`);
|
|
858
|
+
return Buffer.from(await download.arrayBuffer());
|
|
859
|
+
});
|
|
860
|
+
await mkdir3(CACHE_DIR, { recursive: true });
|
|
861
|
+
const cachePath = join6(CACHE_DIR, `${scaffold.template}@${scaffold.version}.tar.gz`);
|
|
862
|
+
await writeFile5(cachePath, buffer);
|
|
863
|
+
return cachePath;
|
|
864
|
+
}
|
|
865
|
+
async function resolveTarball(slug, opts) {
|
|
866
|
+
if (opts.tarball) {
|
|
867
|
+
if (!existsSync7(opts.tarball)) fail(`--tarball path does not exist: ${opts.tarball}`);
|
|
868
|
+
return opts.tarball;
|
|
869
|
+
}
|
|
870
|
+
if (opts.local) {
|
|
871
|
+
const found = await findLocalTarball(opts.local, slug);
|
|
872
|
+
if (found) return found;
|
|
873
|
+
fail(`no source for template '${slug}' \u2014 pass --tarball <path> or point --local at a dir with the tarball next to it`);
|
|
874
|
+
}
|
|
875
|
+
return fetchTarballFromNetwork(slug, opts.token);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// src/package-manager.ts
|
|
879
|
+
import { existsSync as existsSync8 } from "node:fs";
|
|
880
|
+
import { join as join7 } from "node:path";
|
|
881
|
+
var LOCKFILES = [
|
|
882
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
883
|
+
["yarn.lock", "yarn"],
|
|
884
|
+
["bun.lockb", "bun"],
|
|
885
|
+
["bun.lock", "bun"],
|
|
886
|
+
["package-lock.json", "npm"]
|
|
887
|
+
];
|
|
888
|
+
function detectPackageManager(projectDir) {
|
|
889
|
+
for (const [lockfile, manager] of LOCKFILES) {
|
|
890
|
+
if (existsSync8(join7(projectDir, lockfile))) return manager;
|
|
891
|
+
}
|
|
892
|
+
return "npm";
|
|
893
|
+
}
|
|
894
|
+
function installCommand(projectDir) {
|
|
895
|
+
return `${detectPackageManager(projectDir)} install`;
|
|
896
|
+
}
|
|
897
|
+
function devCommand(projectDir) {
|
|
898
|
+
const manager = detectPackageManager(projectDir);
|
|
899
|
+
return manager === "npm" ? "npm run dev" : `${manager} dev`;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// src/commands/add.ts
|
|
903
|
+
async function runAdd(argv) {
|
|
904
|
+
const { flags, values, positional } = parseFlags(argv, ["local", "token"]);
|
|
905
|
+
if (positional.length === 0) fail(`at least one component name required (e.g. nateui add button)`);
|
|
906
|
+
const projectDir = process.cwd();
|
|
907
|
+
const cj = await loadOrDetectComponentsJson(projectDir);
|
|
908
|
+
const overwrite = flags.has("overwrite");
|
|
909
|
+
const catalog = await resolveCatalog(values.local, values.token);
|
|
910
|
+
const index = await catalog.index();
|
|
911
|
+
let anyFailed = false;
|
|
912
|
+
const resolvedIds = [];
|
|
913
|
+
for (const name of positional) {
|
|
914
|
+
try {
|
|
915
|
+
const id = resolveItemId(name, index);
|
|
916
|
+
info(`add ${name} -> ${id}`);
|
|
917
|
+
resolvedIds.push(id);
|
|
918
|
+
} catch (err) {
|
|
919
|
+
anyFailed = true;
|
|
920
|
+
printError(err instanceof Error ? err.message : String(err));
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (resolvedIds.length === 0) process.exit(1);
|
|
924
|
+
const items = await closure(resolvedIds, catalog);
|
|
925
|
+
const results = await writeItemFiles(items.values(), projectDir, cj.installPath, overwrite, cj.alias.scheme);
|
|
926
|
+
const { changed: npmChanged } = await mergeNpmDependencies(items.values(), projectDir);
|
|
927
|
+
addInstalledComponents(cj, [...items.keys()]);
|
|
928
|
+
await writeComponentsJson(projectDir, cj);
|
|
929
|
+
for (const r of results) {
|
|
930
|
+
if (r.action === "written") info(` wrote ${r.targetPath}`);
|
|
931
|
+
}
|
|
932
|
+
if (npmChanged) info(`run: ${installCommand(projectDir)}`);
|
|
933
|
+
if (anyFailed) process.exit(1);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// src/commands/add-page.ts
|
|
937
|
+
import { existsSync as existsSync9, statSync } from "node:fs";
|
|
938
|
+
import { mkdtemp, readFile as readFile9, readdir as readdir2, rm, writeFile as writeFile7 } from "node:fs/promises";
|
|
939
|
+
import { isBuiltin } from "node:module";
|
|
940
|
+
import { tmpdir } from "node:os";
|
|
941
|
+
import { dirname as dirname4, isAbsolute as isAbsolute2, join as join9, relative as relative2, resolve as resolve2, sep as sep2 } from "node:path";
|
|
942
|
+
|
|
943
|
+
// src/splice.ts
|
|
944
|
+
import { readFile as readFile7, writeFile as writeFile6 } from "node:fs/promises";
|
|
945
|
+
var NAV_MARKER = "// nateui:nav:insert";
|
|
946
|
+
var NAV_SECONDARY_MARKER = "// nateui:nav:insert:secondary";
|
|
947
|
+
var ROUTES_MARKER = "// nateui:routes:insert";
|
|
948
|
+
var ICONS_IMPORTS_MARKER = "// nateui:icons:imports:insert";
|
|
949
|
+
var ICONS_MAP_MARKER = "// nateui:icons:map:insert";
|
|
950
|
+
function blockBegin(template, page) {
|
|
951
|
+
return `// nateui:page:${template}/${page}:begin`;
|
|
952
|
+
}
|
|
953
|
+
function blockEnd(template, page) {
|
|
954
|
+
return `// nateui:page:${template}/${page}:end`;
|
|
955
|
+
}
|
|
956
|
+
function findLine(lines, marker) {
|
|
957
|
+
return lines.findIndex((l) => l.trim() === marker);
|
|
958
|
+
}
|
|
959
|
+
function indentOf(line) {
|
|
960
|
+
const m = line.match(/^(\s*)/);
|
|
961
|
+
return m ? m[1] : "";
|
|
962
|
+
}
|
|
963
|
+
function insertAboveMarker(lines, marker, blockLines) {
|
|
964
|
+
const idx = findLine(lines, marker);
|
|
965
|
+
if (idx === -1) return lines;
|
|
966
|
+
return [...lines.slice(0, idx), ...blockLines, ...lines.slice(idx)];
|
|
967
|
+
}
|
|
968
|
+
function removeBlock(lines, begin, end) {
|
|
969
|
+
let out = lines;
|
|
970
|
+
while (true) {
|
|
971
|
+
const beginIdx = findLine(out, begin);
|
|
972
|
+
if (beginIdx === -1) return out;
|
|
973
|
+
const endIdx = out.findIndex((l, i) => i > beginIdx && l.trim() === end);
|
|
974
|
+
if (endIdx === -1) return out;
|
|
975
|
+
out = [...out.slice(0, beginIdx), ...out.slice(endIdx + 1)];
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
async function checkMarkers(configs) {
|
|
979
|
+
const [nav, routes, icon] = await Promise.all([
|
|
980
|
+
readFile7(configs.navPath, "utf-8"),
|
|
981
|
+
readFile7(configs.routesPath, "utf-8"),
|
|
982
|
+
readFile7(configs.iconPath, "utf-8")
|
|
983
|
+
]);
|
|
984
|
+
const navLines = nav.split("\n");
|
|
985
|
+
const routesLines = routes.split("\n");
|
|
986
|
+
const iconLines = icon.split("\n");
|
|
987
|
+
return [
|
|
988
|
+
{ file: configs.navPath, marker: NAV_MARKER, present: findLine(navLines, NAV_MARKER) !== -1 },
|
|
989
|
+
{
|
|
990
|
+
file: configs.navPath,
|
|
991
|
+
marker: NAV_SECONDARY_MARKER,
|
|
992
|
+
present: findLine(navLines, NAV_SECONDARY_MARKER) !== -1
|
|
993
|
+
},
|
|
994
|
+
{ file: configs.routesPath, marker: ROUTES_MARKER, present: findLine(routesLines, ROUTES_MARKER) !== -1 },
|
|
995
|
+
{
|
|
996
|
+
file: configs.iconPath,
|
|
997
|
+
marker: ICONS_IMPORTS_MARKER,
|
|
998
|
+
present: findLine(iconLines, ICONS_IMPORTS_MARKER) !== -1
|
|
999
|
+
},
|
|
1000
|
+
{ file: configs.iconPath, marker: ICONS_MAP_MARKER, present: findLine(iconLines, ICONS_MAP_MARKER) !== -1 }
|
|
1001
|
+
];
|
|
1002
|
+
}
|
|
1003
|
+
function assertMarkersPresent(checks) {
|
|
1004
|
+
const missing = checks.find((c) => !c.present);
|
|
1005
|
+
if (missing) {
|
|
1006
|
+
fail(`${missing.file} is missing the '${missing.marker}' marker \u2014 add-page needs it to know where to insert`);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
function renderLiteralValue(value) {
|
|
1010
|
+
if (typeof value === "string") return `'${value.replace(/'/g, "\\'")}'`;
|
|
1011
|
+
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
|
1012
|
+
if (Array.isArray(value)) return `[${value.map(renderLiteralValue).join(", ")}]`;
|
|
1013
|
+
if (value && typeof value === "object") {
|
|
1014
|
+
const entries = Object.entries(value).map(([k, v]) => `${k}: ${renderLiteralValue(v)}`).join(", ");
|
|
1015
|
+
return `{ ${entries} }`;
|
|
1016
|
+
}
|
|
1017
|
+
return JSON.stringify(value);
|
|
1018
|
+
}
|
|
1019
|
+
function renderNavItem(item, indent) {
|
|
1020
|
+
const { slot: _slot, ...rest } = item;
|
|
1021
|
+
const lines = [`${indent}{`];
|
|
1022
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
1023
|
+
lines.push(`${indent} ${key}: ${renderLiteralValue(value)},`);
|
|
1024
|
+
}
|
|
1025
|
+
lines.push(`${indent}},`);
|
|
1026
|
+
return lines;
|
|
1027
|
+
}
|
|
1028
|
+
function renderRouteEntry(path, value, indent) {
|
|
1029
|
+
const lines = [`${indent}'${path}': {`];
|
|
1030
|
+
for (const [key, val] of Object.entries(value)) {
|
|
1031
|
+
lines.push(`${indent} ${key}: ${renderLiteralValue(val)},`);
|
|
1032
|
+
}
|
|
1033
|
+
lines.push(`${indent}},`);
|
|
1034
|
+
return lines;
|
|
1035
|
+
}
|
|
1036
|
+
var PROPERTY_KEY = /^\s*(?:([$_\p{ID_Start}][$_\p{ID_Continue}\u200c\u200d]*)|'([^']+)'|"([^"]+)")\s*:/u;
|
|
1037
|
+
var STRING_VALUE = /^\s*(key|icon):\s*(?:'([^']+)'|"([^"]+)")\s*,?/;
|
|
1038
|
+
var NAMED_IMPORT = /\bimport\s+(?!type\b)(?:[$\w]+\s*,\s*)?\{([^}]+)\}\s*from\s*(['"])([^'"]+)\2/g;
|
|
1039
|
+
function propertyKey(line) {
|
|
1040
|
+
const match = line.match(PROPERTY_KEY);
|
|
1041
|
+
return match && (match[1] ?? match[2] ?? match[3]) || void 0;
|
|
1042
|
+
}
|
|
1043
|
+
function namedImports(raw) {
|
|
1044
|
+
const imports = /* @__PURE__ */ new Map();
|
|
1045
|
+
const source = raw.replace(
|
|
1046
|
+
/(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g,
|
|
1047
|
+
(match, comment) => comment ? " " : match
|
|
1048
|
+
);
|
|
1049
|
+
for (const [, bindings, , from] of source.matchAll(NAMED_IMPORT)) {
|
|
1050
|
+
for (const binding of bindings.split(",")) {
|
|
1051
|
+
const names = binding.trim().match(/^([$\w]+)(?:\s+as\s+([$\w]+))?$/);
|
|
1052
|
+
if (names) imports.set(`${from}:${names[1]}`, names[2] ?? names[1]);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
return imports;
|
|
1056
|
+
}
|
|
1057
|
+
function findOwner(lines, matches) {
|
|
1058
|
+
let owner;
|
|
1059
|
+
for (const line of lines) {
|
|
1060
|
+
const match = line.match(/^\s*\/\/ nateui:page:(.+):begin\s*$/);
|
|
1061
|
+
if (match) owner = match[1];
|
|
1062
|
+
else if (/^\s*\/\/ nateui:page:.+:end\s*$/.test(line)) owner = void 0;
|
|
1063
|
+
else if (owner && matches(line)) return owner;
|
|
1064
|
+
}
|
|
1065
|
+
return void 0;
|
|
1066
|
+
}
|
|
1067
|
+
function keepOwnedLine(lines, marker, owner, line) {
|
|
1068
|
+
const end = `// nateui:page:${owner}:end`;
|
|
1069
|
+
const markerIndex = findLine(lines, marker);
|
|
1070
|
+
const existingEnd = lines.slice(0, markerIndex).map((entry) => entry.trim()).lastIndexOf(end);
|
|
1071
|
+
if (existingEnd !== -1 && (marker !== ICONS_MAP_MARKER || existingEnd > findLine(lines, ICONS_IMPORTS_MARKER))) {
|
|
1072
|
+
return [...lines.slice(0, existingEnd), line, ...lines.slice(existingEnd)];
|
|
1073
|
+
}
|
|
1074
|
+
const indent = indentOf(lines[markerIndex]);
|
|
1075
|
+
return insertAboveMarker(lines, marker, [`${indent}// nateui:page:${owner}:begin`, line, indent + end]);
|
|
1076
|
+
}
|
|
1077
|
+
async function spliceAdd(target, template, page, registration) {
|
|
1078
|
+
const begin = blockBegin(template, page);
|
|
1079
|
+
const end = blockEnd(template, page);
|
|
1080
|
+
{
|
|
1081
|
+
const raw = await readFile7(target.navPath, "utf-8");
|
|
1082
|
+
let lines = raw.split("\n");
|
|
1083
|
+
const navKeys = new Set(lines.flatMap((line) => {
|
|
1084
|
+
const match = line.match(STRING_VALUE);
|
|
1085
|
+
return match?.[1] === "key" ? [match[2] ?? match[3]] : [];
|
|
1086
|
+
}));
|
|
1087
|
+
const nav = registration.nav.filter((item) => !navKeys.has(item.key));
|
|
1088
|
+
const primary = nav.filter((n) => n.slot !== "secondary");
|
|
1089
|
+
const secondary = nav.filter((n) => n.slot === "secondary");
|
|
1090
|
+
if (primary.length) {
|
|
1091
|
+
const idx = findLine(lines, NAV_MARKER);
|
|
1092
|
+
const indent = indentOf(lines[idx]);
|
|
1093
|
+
const block = [`${indent}${begin}`, ...primary.flatMap((n) => renderNavItem(n, indent)), `${indent}${end}`];
|
|
1094
|
+
lines = insertAboveMarker(lines, NAV_MARKER, block);
|
|
1095
|
+
}
|
|
1096
|
+
if (secondary.length) {
|
|
1097
|
+
const idx = findLine(lines, NAV_SECONDARY_MARKER);
|
|
1098
|
+
const indent = indentOf(lines[idx]);
|
|
1099
|
+
const block = [`${indent}${begin}`, ...secondary.flatMap((n) => renderNavItem(n, indent)), `${indent}${end}`];
|
|
1100
|
+
lines = insertAboveMarker(lines, NAV_SECONDARY_MARKER, block);
|
|
1101
|
+
}
|
|
1102
|
+
await writeFile6(target.navPath, lines.join("\n"), "utf-8");
|
|
1103
|
+
}
|
|
1104
|
+
{
|
|
1105
|
+
const raw = await readFile7(target.routesPath, "utf-8");
|
|
1106
|
+
const lines = raw.split("\n");
|
|
1107
|
+
const routeKeys = new Set(lines.map(propertyKey));
|
|
1108
|
+
const entries = Object.entries(registration.routes).filter(([path]) => !routeKeys.has(path));
|
|
1109
|
+
if (entries.length) {
|
|
1110
|
+
const idx = findLine(lines, ROUTES_MARKER);
|
|
1111
|
+
const indent = indentOf(lines[idx]);
|
|
1112
|
+
const block = [
|
|
1113
|
+
`${indent}${begin}`,
|
|
1114
|
+
...entries.flatMap(([path, value]) => renderRouteEntry(path, value, indent)),
|
|
1115
|
+
`${indent}${end}`
|
|
1116
|
+
];
|
|
1117
|
+
await writeFile6(target.routesPath, insertAboveMarker(lines, ROUTES_MARKER, block).join("\n"), "utf-8");
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
{
|
|
1121
|
+
const raw = await readFile7(target.iconPath, "utf-8");
|
|
1122
|
+
let lines = raw.split("\n");
|
|
1123
|
+
const existing = new Set(lines.map(propertyKey));
|
|
1124
|
+
const imports = namedImports(raw);
|
|
1125
|
+
const localNames = new Set(imports.values());
|
|
1126
|
+
const importIndent = indentOf(lines[findLine(lines, ICONS_IMPORTS_MARKER)]);
|
|
1127
|
+
const mapIndent = indentOf(lines[findLine(lines, ICONS_MAP_MARKER)]);
|
|
1128
|
+
const classConstant = raw.match(/^\s*const\s+(COMMON_CLASS_NAME|CLASS_NAME)\s*=/m)?.[1];
|
|
1129
|
+
const className = classConstant ? ` className={${classConstant}}` : "";
|
|
1130
|
+
const importLines = [];
|
|
1131
|
+
const mapLines = [];
|
|
1132
|
+
for (const [key, icon] of Object.entries(registration.icons)) {
|
|
1133
|
+
if (existing.has(key)) continue;
|
|
1134
|
+
const binding = `${icon.from}:${icon.import}`;
|
|
1135
|
+
let local = imports.get(binding);
|
|
1136
|
+
if (!local) {
|
|
1137
|
+
local = icon.import;
|
|
1138
|
+
for (let suffix = 2; localNames.has(local); suffix++) local = icon.import + suffix;
|
|
1139
|
+
const specifier = local === icon.import ? local : `${icon.import} as ${local}`;
|
|
1140
|
+
importLines.push(`${importIndent}import { ${specifier} } from '${icon.from}'`);
|
|
1141
|
+
imports.set(binding, local);
|
|
1142
|
+
localNames.add(local);
|
|
1143
|
+
}
|
|
1144
|
+
const mapKey = /^[$_\p{ID_Start}][$_\p{ID_Continue}\u200c\u200d]*$/u.test(key) ? key : JSON.stringify(key);
|
|
1145
|
+
mapLines.push(`${mapIndent}${mapKey}: <${local}${className} />,`);
|
|
1146
|
+
}
|
|
1147
|
+
if (importLines.length) {
|
|
1148
|
+
lines = insertAboveMarker(lines, ICONS_IMPORTS_MARKER, [importIndent + begin, ...importLines, importIndent + end]);
|
|
1149
|
+
}
|
|
1150
|
+
if (mapLines.length) {
|
|
1151
|
+
lines = insertAboveMarker(lines, ICONS_MAP_MARKER, [mapIndent + begin, ...mapLines, mapIndent + end]);
|
|
1152
|
+
}
|
|
1153
|
+
await writeFile6(target.iconPath, lines.join("\n"), "utf-8");
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
async function spliceRemove(target, template, page) {
|
|
1157
|
+
const begin = blockBegin(template, page);
|
|
1158
|
+
const end = blockEnd(template, page);
|
|
1159
|
+
const nav = removeBlock((await readFile7(target.navPath, "utf-8")).split("\n"), begin, end);
|
|
1160
|
+
const routes = removeBlock((await readFile7(target.routesPath, "utf-8")).split("\n"), begin, end);
|
|
1161
|
+
const original = (await readFile7(target.iconPath, "utf-8")).split("\n");
|
|
1162
|
+
let icons = removeBlock(original, begin, end);
|
|
1163
|
+
const retained = new Set(icons);
|
|
1164
|
+
const removed = original.filter((line) => !retained.has(line));
|
|
1165
|
+
const remainingKeys = new Set(icons.map(propertyKey));
|
|
1166
|
+
for (const line of removed) {
|
|
1167
|
+
const key = propertyKey(line);
|
|
1168
|
+
if (!key || remainingKeys.has(key)) continue;
|
|
1169
|
+
const owner = findOwner(nav, (entry) => {
|
|
1170
|
+
const value = entry.match(STRING_VALUE);
|
|
1171
|
+
return value?.[1] === "icon" && (value[2] ?? value[3]) === key;
|
|
1172
|
+
});
|
|
1173
|
+
if (owner) icons = keepOwnedLine(icons, ICONS_MAP_MARKER, owner, line);
|
|
1174
|
+
}
|
|
1175
|
+
const remainingImports = namedImports(icons.join("\n"));
|
|
1176
|
+
for (const line of removed) {
|
|
1177
|
+
const binding = line.match(/^\s*import \{ ([$\w]+)(?: as ([$\w]+))? \} from '([^']+)'/);
|
|
1178
|
+
if (!binding) continue;
|
|
1179
|
+
const local = binding[2] ?? binding[1];
|
|
1180
|
+
if (remainingImports.get(`${binding[3]}:${binding[1]}`) === local) continue;
|
|
1181
|
+
const owner = findOwner(icons, (entry) => entry.includes(`<${local} `));
|
|
1182
|
+
if (owner) icons = keepOwnedLine(icons, ICONS_IMPORTS_MARKER, owner, line);
|
|
1183
|
+
}
|
|
1184
|
+
await writeFile6(target.navPath, nav.join("\n"), "utf-8");
|
|
1185
|
+
await writeFile6(target.routesPath, routes.join("\n"), "utf-8");
|
|
1186
|
+
await writeFile6(target.iconPath, icons.join("\n"), "utf-8");
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// src/tarball.ts
|
|
1190
|
+
import { execFile } from "node:child_process";
|
|
1191
|
+
import { promisify } from "node:util";
|
|
1192
|
+
import { mkdir as mkdir4, readFile as readFile8 } from "node:fs/promises";
|
|
1193
|
+
import { join as join8 } from "node:path";
|
|
1194
|
+
var execFileAsync = promisify(execFile);
|
|
1195
|
+
function toTarPath(p) {
|
|
1196
|
+
return p.replace(/\\/g, "/");
|
|
1197
|
+
}
|
|
1198
|
+
async function extractTarball(tarballPath, destDir) {
|
|
1199
|
+
await mkdir4(destDir, { recursive: true });
|
|
1200
|
+
const src = toTarPath(tarballPath);
|
|
1201
|
+
const dest = toTarPath(destDir);
|
|
1202
|
+
try {
|
|
1203
|
+
await execFileAsync("tar", ["-xzf", src, "-C", dest]);
|
|
1204
|
+
return;
|
|
1205
|
+
} catch {
|
|
1206
|
+
}
|
|
1207
|
+
try {
|
|
1208
|
+
await execFileAsync("tar", ["--force-local", "-xzf", src, "-C", dest]);
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
const detail = (err instanceof Error ? err.message : String(err)).split("\n")[0];
|
|
1211
|
+
fail(`couldn't unpack the template \u2014 ${detail}`);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
async function readTarballManifest(extractedDir) {
|
|
1215
|
+
const path = join8(extractedDir, "tarball.json");
|
|
1216
|
+
return JSON.parse(await readFile8(path, "utf-8"));
|
|
1217
|
+
}
|
|
1218
|
+
function variantRoot(extractedDir, manifest) {
|
|
1219
|
+
const variant = manifest.variants.find((v) => v.framework === "nextjs" && v.language === "typescript");
|
|
1220
|
+
if (!variant) fail(`tarball has no nextjs/typescript variant`);
|
|
1221
|
+
return join8(extractedDir, variant.path);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// src/commands/add-page.ts
|
|
1225
|
+
var KNOWN_SLUGS = ["crm", "project-management", "sales-inventory", "ai-chat"];
|
|
1226
|
+
function configPaths(projectDir) {
|
|
1227
|
+
return {
|
|
1228
|
+
navPath: join9(projectDir, "src", "configs", "navigation.config.ts"),
|
|
1229
|
+
routesPath: join9(projectDir, "src", "configs", "routes.config.ts"),
|
|
1230
|
+
iconPath: join9(projectDir, "src", "configs", "navigation-icon.config.tsx")
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
async function collectFilesUnder(dir) {
|
|
1234
|
+
const out = [];
|
|
1235
|
+
if (!existsSync9(dir)) return out;
|
|
1236
|
+
const entries = await readdir2(dir, { withFileTypes: true });
|
|
1237
|
+
for (const entry of entries) {
|
|
1238
|
+
const full = join9(dir, entry.name);
|
|
1239
|
+
if (entry.isDirectory()) out.push(...await collectFilesUnder(full));
|
|
1240
|
+
else if (entry.isFile()) out.push(full);
|
|
1241
|
+
}
|
|
1242
|
+
return out;
|
|
1243
|
+
}
|
|
1244
|
+
var COMPONENT_ALIAS = /^@\/components\/(ui|composites|patterns|layouts|blocks)\/([^/]+)/;
|
|
1245
|
+
var SCRIPT_FILE = /\.[cm]?[jt]sx?$/;
|
|
1246
|
+
var COMMENT = String.raw`\/\/[^\n]*|\/\*[\s\S]*?\*\/`;
|
|
1247
|
+
var SPACE = String.raw`(?:\s|${COMMENT})*`;
|
|
1248
|
+
var IDENTIFIER = String.raw`[$_\p{ID_Start}][$_\p{ID_Continue}\u200c\u200d]*`;
|
|
1249
|
+
var NAMED = String.raw`\{[^}]*\}`;
|
|
1250
|
+
var NAMESPACE = String.raw`\*${SPACE}as\b${SPACE}${IDENTIFIER}`;
|
|
1251
|
+
var BINDINGS = String.raw`(?:type\b${SPACE})?(?:${IDENTIFIER}(?:${SPACE},${SPACE}(?:${NAMED}|${NAMESPACE}))?|${NAMED}|${NAMESPACE})`;
|
|
1252
|
+
var IMPORT_SPEC = new RegExp(
|
|
1253
|
+
String.raw`${COMMENT}|(?<![\p{ID_Continue}$.])(?:import\b${SPACE}(?:\(${SPACE}|(${BINDINGS})${SPACE}from\b${SPACE})?|export\b${SPACE}(?:type\b${SPACE})?(${NAMED}|${NAMESPACE}|\*)${SPACE}from\b${SPACE})(["'])([^"'\r\n]+)\3|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'`,
|
|
1254
|
+
"gu"
|
|
1255
|
+
);
|
|
1256
|
+
var EXPORTED_NAME = /^\s*export\s+(?:(?:declare|async|abstract)\s+)*(?:const|let|var|function|class|interface|type|enum|namespace)\s+([\w$]+)|^\s*export\s+(default)\b|^\s*export\s+(?:type\s+)?\{([^}]+)\}/gm;
|
|
1257
|
+
function namedBindings(clause) {
|
|
1258
|
+
const body = clause.match(/\{([^}]*)\}/)?.[1];
|
|
1259
|
+
return body?.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, "").split(",").map((entry) => entry.trim().replace(/^type\s+/, "")) ?? [];
|
|
1260
|
+
}
|
|
1261
|
+
function importedNames(imports, reexports) {
|
|
1262
|
+
if (imports) imports = imports.replace(new RegExp(COMMENT, "g"), " ");
|
|
1263
|
+
const names = namedBindings(imports ?? reexports ?? "").map((binding) => binding.split(/\s+as\s+/)[0]);
|
|
1264
|
+
if (imports && /^\s*(?:type\s+)?[$\w]+\s*(?:,|$)/.test(imports)) names.push("default");
|
|
1265
|
+
return names.filter(Boolean);
|
|
1266
|
+
}
|
|
1267
|
+
function exportedNames(source) {
|
|
1268
|
+
const names = /* @__PURE__ */ new Set();
|
|
1269
|
+
for (const [statement, declared, defaultName, list] of source.matchAll(EXPORTED_NAME)) {
|
|
1270
|
+
if (declared || defaultName) names.add(declared ?? defaultName);
|
|
1271
|
+
if (list) {
|
|
1272
|
+
for (const binding of namedBindings(statement)) names.add(binding.split(/\s+as\s+/).at(-1));
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
return names;
|
|
1276
|
+
}
|
|
1277
|
+
function resolveModule(variant, base) {
|
|
1278
|
+
const rel = relative2(variant, base);
|
|
1279
|
+
if (rel === ".." || rel.startsWith(".." + sep2) || isAbsolute2(rel)) return void 0;
|
|
1280
|
+
const candidates = [base, ...[".ts", ".tsx", ".js", ".jsx"].map((ext) => base + ext)];
|
|
1281
|
+
return candidates.find((file) => existsSync9(file));
|
|
1282
|
+
}
|
|
1283
|
+
async function checkOverlayConsumers(projectDir, writes, removedExports, pageId) {
|
|
1284
|
+
if (!removedExports.size) return;
|
|
1285
|
+
const sourceRoot = join9(projectDir, "src");
|
|
1286
|
+
for (const file of await collectFilesUnder(sourceRoot)) {
|
|
1287
|
+
if (writes.has(file) || !SCRIPT_FILE.test(file)) continue;
|
|
1288
|
+
const source = await readFile9(file, "utf-8");
|
|
1289
|
+
for (const [, imports, reexports, , spec] of source.matchAll(IMPORT_SPEC)) {
|
|
1290
|
+
if (!spec) continue;
|
|
1291
|
+
const base = spec.startsWith("@/") ? resolve2(sourceRoot, spec.slice(2)) : spec.startsWith("./") || spec.startsWith("../") ? resolve2(dirname4(file), spec) : void 0;
|
|
1292
|
+
let resolved = base && resolveModule(projectDir, base);
|
|
1293
|
+
if (resolved && statSync(resolved).isDirectory()) resolved = resolveModule(projectDir, join9(resolved, "index"));
|
|
1294
|
+
if (!resolved) continue;
|
|
1295
|
+
const removed = removedExports.get(resolved);
|
|
1296
|
+
const missing = removed && importedNames(imports, reexports).find((name) => removed.has(name));
|
|
1297
|
+
if (missing) {
|
|
1298
|
+
fail(`can't add '${pageId}': replacing ${relative2(projectDir, resolved)} removes '${missing}', used by ${relative2(projectDir, file)}`);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
function packageName(spec) {
|
|
1304
|
+
if (spec.startsWith("node:") || isBuiltin(spec)) return void 0;
|
|
1305
|
+
return spec.match(/^(?:@[^/\s]+\/)?[\w.-]+(?=\/|$)/)?.[0];
|
|
1306
|
+
}
|
|
1307
|
+
function mergePagePins(project, reference, packages, componentPins) {
|
|
1308
|
+
let changed = false;
|
|
1309
|
+
const has = (name) => project.dependencies?.[name] !== void 0 || project.devDependencies?.[name] !== void 0;
|
|
1310
|
+
for (const name of /* @__PURE__ */ new Set([...packages, ...componentPins.keys()])) {
|
|
1311
|
+
if (!has(name)) {
|
|
1312
|
+
const pin = reference.dependencies?.[name] ?? reference.devDependencies?.[name] ?? componentPins.get(name);
|
|
1313
|
+
if (pin) {
|
|
1314
|
+
(project.dependencies ??= {})[name] = pin;
|
|
1315
|
+
changed = true;
|
|
1316
|
+
} else {
|
|
1317
|
+
warn(`no template package pin for '${name}'`);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
if (name.startsWith("@types/")) continue;
|
|
1321
|
+
const types = "@types/" + (name.startsWith("@") ? name.slice(1).replace("/", "__") : name);
|
|
1322
|
+
const typePin = reference.devDependencies?.[types] ?? reference.dependencies?.[types];
|
|
1323
|
+
if (typePin && !has(types)) {
|
|
1324
|
+
(project.devDependencies ??= {})[types] = typePin;
|
|
1325
|
+
changed = true;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
return changed;
|
|
1329
|
+
}
|
|
1330
|
+
async function runAddPage(argv) {
|
|
1331
|
+
const { flags, values } = parseFlags(argv, ["tarball", "local", "token"]);
|
|
1332
|
+
const template = KNOWN_SLUGS.find((s) => flags.has(s));
|
|
1333
|
+
if (!template) fail(`template flag required, one of: ${KNOWN_SLUGS.map((s) => `--${s}`).join(", ")}`);
|
|
1334
|
+
const pageFlag = [...flags].find((f) => f !== template && f !== "overwrite");
|
|
1335
|
+
if (!pageFlag) fail(`page flag required (e.g. --deals)`);
|
|
1336
|
+
const pageId = `${template}/${pageFlag}`;
|
|
1337
|
+
const projectDir = process.cwd();
|
|
1338
|
+
const overwrite = flags.has("overwrite");
|
|
1339
|
+
const cj = await loadOrDetectComponentsJson(projectDir);
|
|
1340
|
+
if (cj.installed.pages.includes(pageId) && !overwrite) {
|
|
1341
|
+
info(`page '${pageId}' already installed \u2014 nothing to do`);
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
const tarballPath = await resolveTarball(template, { tarball: values.tarball, local: values.local, token: values.token });
|
|
1345
|
+
const scratch = await mkdtemp(join9(tmpdir(), "nateui-page-"));
|
|
1346
|
+
try {
|
|
1347
|
+
let addComponent2 = function(id) {
|
|
1348
|
+
if (!componentItems.has(id)) components.add(id);
|
|
1349
|
+
}, useTemplateComponent2 = function(id) {
|
|
1350
|
+
const subpath = /^(ui|composites|patterns|layouts|blocks)\//.test(id) ? "components/" + id : id;
|
|
1351
|
+
const base = resolve2(variant, "src", subpath);
|
|
1352
|
+
if (!resolveModule(variant, base)) return false;
|
|
1353
|
+
queue.push({ base, direct: false });
|
|
1354
|
+
return true;
|
|
1355
|
+
}, walkImports2 = function(content, file) {
|
|
1356
|
+
for (const [, imports, reexports, , spec] of content.matchAll(IMPORT_SPEC)) {
|
|
1357
|
+
if (!spec) continue;
|
|
1358
|
+
if (spec.startsWith("@/")) {
|
|
1359
|
+
const component = spec.match(COMPONENT_ALIAS);
|
|
1360
|
+
const id = component && `${component[1]}/${component[2].replace(SCRIPT_FILE, "")}`;
|
|
1361
|
+
if (id && catalogIds.has(id)) addComponent2(id);
|
|
1362
|
+
else queue.push({ base: resolve2(variant, "src", spec.slice(2)), direct: false, names: importedNames(imports, reexports) });
|
|
1363
|
+
} else if (spec.startsWith("./") || spec.startsWith("../")) {
|
|
1364
|
+
queue.push({ base: resolve2(dirname4(file), spec), direct: false, names: importedNames(imports, reexports) });
|
|
1365
|
+
} else {
|
|
1366
|
+
const name = packageName(spec);
|
|
1367
|
+
if (name) packages.add(name);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
};
|
|
1371
|
+
var addComponent = addComponent2, useTemplateComponent = useTemplateComponent2, walkImports = walkImports2;
|
|
1372
|
+
await extractTarball(tarballPath, scratch);
|
|
1373
|
+
const manifest = await readTarballManifest(scratch);
|
|
1374
|
+
const variant = variantRoot(scratch, manifest);
|
|
1375
|
+
const registry = JSON.parse(await readFile9(join9(variant, "registry.json"), "utf-8"));
|
|
1376
|
+
const page = registry.pages.find((p) => p.id === pageId);
|
|
1377
|
+
if (!page) fail(`unknown page '${pageFlag}' for template '${template}' \u2014 run: nateui list --template ${template}`);
|
|
1378
|
+
const paths = configPaths(projectDir);
|
|
1379
|
+
for (const p of Object.values(paths)) {
|
|
1380
|
+
if (!existsSync9(p)) fail(`${p} not found \u2014 is this a NateUI-scaffolded project?`);
|
|
1381
|
+
}
|
|
1382
|
+
assertMarkersPresent(await checkMarkers(paths));
|
|
1383
|
+
const catalog = await resolveCatalog(values.local, values.token);
|
|
1384
|
+
const catalogIds = new Set((await catalog.index()).items.map((item) => item.id));
|
|
1385
|
+
const components = /* @__PURE__ */ new Set();
|
|
1386
|
+
const packages = new Set(page.npmDeps.filter((name) => !name.startsWith("node:") && !isBuiltin(name)));
|
|
1387
|
+
const overlays = registry.starter?.overlays ?? {};
|
|
1388
|
+
const configTargets = new Set(Object.values(paths));
|
|
1389
|
+
const plan = /* @__PURE__ */ new Map();
|
|
1390
|
+
const writes = /* @__PURE__ */ new Set();
|
|
1391
|
+
const removedExports = /* @__PURE__ */ new Map();
|
|
1392
|
+
const queue = [page.routeDir, ...page.sharedFiles].map((path) => ({ base: resolve2(variant, path), direct: true }));
|
|
1393
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1394
|
+
const componentItems = /* @__PURE__ */ new Map();
|
|
1395
|
+
const componentPins = /* @__PURE__ */ new Map();
|
|
1396
|
+
for (const id of page.componentDeps) {
|
|
1397
|
+
if (catalogIds.has(id) || !useTemplateComponent2(id)) addComponent2(id);
|
|
1398
|
+
}
|
|
1399
|
+
let cursor = 0;
|
|
1400
|
+
while (cursor < queue.length || components.size) {
|
|
1401
|
+
if (cursor === queue.length) {
|
|
1402
|
+
const items = await closure([...components], {
|
|
1403
|
+
...catalog,
|
|
1404
|
+
async item(id) {
|
|
1405
|
+
const cached = componentItems.get(id);
|
|
1406
|
+
if (cached) return cached;
|
|
1407
|
+
const item = await catalog.item(id);
|
|
1408
|
+
const registryDependencies = item.registryDependencies.filter(
|
|
1409
|
+
(dependency) => catalogIds.has(dependency) || !useTemplateComponent2(dependency)
|
|
1410
|
+
);
|
|
1411
|
+
return { ...item, registryDependencies };
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
components.clear();
|
|
1415
|
+
for (const [id, item] of items) {
|
|
1416
|
+
if (componentItems.has(id)) continue;
|
|
1417
|
+
componentItems.set(id, item);
|
|
1418
|
+
for (const [name, pin] of Object.entries(item.dependencies)) {
|
|
1419
|
+
if (!componentPins.has(name)) componentPins.set(name, pin);
|
|
1420
|
+
}
|
|
1421
|
+
const files = planFiles([item], projectDir, cj.installPath, cj.alias.scheme);
|
|
1422
|
+
for (const [index, file2] of files.entries()) {
|
|
1423
|
+
if (plan.has(file2.targetPath) || configTargets.has(file2.targetPath)) continue;
|
|
1424
|
+
plan.set(file2.targetPath, file2);
|
|
1425
|
+
if (await writeAction(file2, overwrite) !== "written") continue;
|
|
1426
|
+
writes.add(file2.targetPath);
|
|
1427
|
+
const { path, content: content2 } = item.files[index];
|
|
1428
|
+
if (SCRIPT_FILE.test(path)) walkImports2(content2, join9(variant, "src", path));
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
const { base, direct, names } = queue[cursor++];
|
|
1434
|
+
const file = resolveModule(variant, base);
|
|
1435
|
+
if (!file || visited.has(file)) continue;
|
|
1436
|
+
if (statSync(file).isDirectory()) {
|
|
1437
|
+
visited.add(file);
|
|
1438
|
+
for (const entry of await collectFilesUnder(file)) queue.push({ base: entry, direct, names });
|
|
1439
|
+
continue;
|
|
1440
|
+
}
|
|
1441
|
+
const rel = relative2(variant, file);
|
|
1442
|
+
const targetPath = join9(projectDir, rel);
|
|
1443
|
+
if (configTargets.has(targetPath) || plan.has(targetPath)) continue;
|
|
1444
|
+
const overlayPath = overlays[rel.split(sep2).join("/")];
|
|
1445
|
+
const exists2 = existsSync9(targetPath);
|
|
1446
|
+
if (!direct && !overlayPath && exists2) continue;
|
|
1447
|
+
const script = SCRIPT_FILE.test(file);
|
|
1448
|
+
const content = script || overlayPath ? await readFile9(file, "utf-8") : await readFile9(file);
|
|
1449
|
+
const planned = { targetPath, content };
|
|
1450
|
+
if (overlayPath) {
|
|
1451
|
+
const overlay = resolveModule(variant, resolve2(variant, overlayPath));
|
|
1452
|
+
if (overlay) planned.starterOverlay = { content: await readFile9(overlay, "utf-8"), pageId };
|
|
1453
|
+
}
|
|
1454
|
+
if (!direct && exists2 && planned.starterOverlay && typeof content === "string") {
|
|
1455
|
+
const starterExports = exportedNames(planned.starterOverlay.content);
|
|
1456
|
+
const fullExports = exportedNames(content);
|
|
1457
|
+
const wanted = names?.length ? names : [...fullExports];
|
|
1458
|
+
if (!wanted.some((name) => fullExports.has(name) && !starterExports.has(name))) continue;
|
|
1459
|
+
}
|
|
1460
|
+
visited.add(file);
|
|
1461
|
+
plan.set(targetPath, planned);
|
|
1462
|
+
if (await writeAction(planned, overwrite) !== "written") continue;
|
|
1463
|
+
writes.add(targetPath);
|
|
1464
|
+
if (typeof content !== "string" || !script) continue;
|
|
1465
|
+
if (exists2 && planned.starterOverlay) {
|
|
1466
|
+
const fullExports = exportedNames(content);
|
|
1467
|
+
const removed = [...exportedNames(await readFile9(targetPath, "utf-8"))].filter((name) => !fullExports.has(name));
|
|
1468
|
+
if (removed.length) removedExports.set(targetPath, new Set(removed));
|
|
1469
|
+
}
|
|
1470
|
+
walkImports2(content, file);
|
|
1471
|
+
}
|
|
1472
|
+
await checkOverlayConsumers(projectDir, writes, removedExports, pageId);
|
|
1473
|
+
const refPkg = JSON.parse(await readFile9(join9(variant, "package.json"), "utf-8"));
|
|
1474
|
+
const projectPkgPath = join9(projectDir, "package.json");
|
|
1475
|
+
const projectPkg = existsSync9(projectPkgPath) ? JSON.parse(await readFile9(projectPkgPath, "utf-8")) : void 0;
|
|
1476
|
+
const npmChanged = projectPkg && mergePagePins(projectPkg, refPkg, packages, componentPins);
|
|
1477
|
+
await applyWritePlan([...plan.values()], overwrite);
|
|
1478
|
+
if (npmChanged) await writeFile7(projectPkgPath, JSON.stringify(projectPkg, null, 2) + "\n", "utf-8");
|
|
1479
|
+
await spliceAdd(paths, template, pageFlag, page.registration);
|
|
1480
|
+
addInstalledPage(cj, pageId);
|
|
1481
|
+
await writeComponentsJson(projectDir, cj);
|
|
1482
|
+
info(`added page ${pageId}`);
|
|
1483
|
+
if (npmChanged) info(`run: ${installCommand(projectDir)}`);
|
|
1484
|
+
} finally {
|
|
1485
|
+
await rm(scratch, { recursive: true, force: true });
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// src/commands/describe.ts
|
|
1490
|
+
async function runDescribe(argv) {
|
|
1491
|
+
const { values, positional } = parseFlags(argv, ["local", "token"]);
|
|
1492
|
+
const name = positional[0];
|
|
1493
|
+
if (!name) fail(`component name required (e.g. nateui describe button)`);
|
|
1494
|
+
const catalog = await resolveCatalog(values.local, values.token);
|
|
1495
|
+
const index = await catalog.index();
|
|
1496
|
+
const id = resolveItemId(name, index);
|
|
1497
|
+
const item = await catalog.item(id);
|
|
1498
|
+
const projectDir = process.cwd();
|
|
1499
|
+
const installed = componentsJsonExists(projectDir) && (await readComponentsJson(projectDir)).installed.components.includes(id);
|
|
1500
|
+
info(`${id}${installed ? " (installed)" : ""}`);
|
|
1501
|
+
info(`tier: ${item.tier}`);
|
|
1502
|
+
info(`files:`);
|
|
1503
|
+
for (const f of item.files) info(` ${f.path}`);
|
|
1504
|
+
if (item.registryDependencies.length) info(`registryDependencies: ${item.registryDependencies.join(", ")}`);
|
|
1505
|
+
const deps = Object.entries(item.dependencies);
|
|
1506
|
+
if (deps.length) {
|
|
1507
|
+
info(`dependencies:`);
|
|
1508
|
+
for (const [dep, pin] of deps) info(` ${dep}@${pin}`);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// src/commands/init.ts
|
|
1513
|
+
import { existsSync as existsSync11 } from "node:fs";
|
|
1514
|
+
import { readdir as readdir3 } from "node:fs/promises";
|
|
1515
|
+
|
|
1516
|
+
// src/chassis-write.ts
|
|
1517
|
+
import { existsSync as existsSync10 } from "node:fs";
|
|
1518
|
+
import { cp, mkdir as mkdir5 } from "node:fs/promises";
|
|
1519
|
+
import { join as join10 } from "node:path";
|
|
1520
|
+
async function writeChassis(framework, targetDir) {
|
|
1521
|
+
const src = join10(CHASSIS_DIR, framework);
|
|
1522
|
+
if (!existsSync10(src)) {
|
|
1523
|
+
fail(
|
|
1524
|
+
`bundled chassis for '${framework}' not found at ${src} \u2014 this package build is broken (run \`pnpm factory --chassis\` then \`pnpm build\` in nateui-cli/)`
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
await mkdir5(targetDir, { recursive: true });
|
|
1528
|
+
await cp(src, targetDir, { recursive: true });
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// src/commands/init.ts
|
|
1532
|
+
var LAYERS = ["ui", "composites", "layouts", "patterns", "blocks"];
|
|
1533
|
+
function freeItemIds(index) {
|
|
1534
|
+
return index.items.filter((i) => i.kind === "ui" && !i.hidden).map((i) => i.id);
|
|
1535
|
+
}
|
|
1536
|
+
function layerItemIds(index, layers) {
|
|
1537
|
+
const set = new Set(layers);
|
|
1538
|
+
return index.items.filter((i) => set.has(i.kind) && !i.hidden).map((i) => i.id);
|
|
1539
|
+
}
|
|
1540
|
+
async function runInit(argv) {
|
|
1541
|
+
const { flags, values, positional } = parseFlags(argv, ["local", "layers", "token"]);
|
|
1542
|
+
const dir = positional[0];
|
|
1543
|
+
if (!dir) fail(`target directory required (e.g. nateui init --nextjs my-app)`);
|
|
1544
|
+
if (existsSync11(dir) && (await readdir3(dir)).length > 0) {
|
|
1545
|
+
fail(`target '${dir}' is not empty`);
|
|
1546
|
+
}
|
|
1547
|
+
const framework = flags.has("nextjs") ? "nextjs" : flags.has("vite") ? "vite" : void 0;
|
|
1548
|
+
if (!framework) fail(`framework flag required: --nextjs or --vite`);
|
|
1549
|
+
const catalog = await resolveCatalog(values.local, values.token);
|
|
1550
|
+
const index = await catalog.index();
|
|
1551
|
+
let seedIds;
|
|
1552
|
+
if (flags.has("pro")) {
|
|
1553
|
+
let layers;
|
|
1554
|
+
if (values.layers) {
|
|
1555
|
+
layers = values.layers.split(",").map((l) => l.trim());
|
|
1556
|
+
const unknown = layers.find((l) => !LAYERS.includes(l));
|
|
1557
|
+
if (unknown) fail(`unknown layer '${unknown}' \u2014 one of ${LAYERS.join(", ")}`);
|
|
1558
|
+
} else {
|
|
1559
|
+
layers = [...LAYERS];
|
|
1560
|
+
}
|
|
1561
|
+
seedIds = layerItemIds(index, layers);
|
|
1562
|
+
} else {
|
|
1563
|
+
seedIds = freeItemIds(index);
|
|
1564
|
+
}
|
|
1565
|
+
const items = await closure(seedIds, catalog);
|
|
1566
|
+
info("");
|
|
1567
|
+
for (const line of npmDependencyLines(items.values())) info(line);
|
|
1568
|
+
info("");
|
|
1569
|
+
await writeChassis(framework, dir);
|
|
1570
|
+
const results = await writeItemFiles(items.values(), dir, "src", true);
|
|
1571
|
+
await mergeNpmDependencies(items.values(), dir);
|
|
1572
|
+
const cj = newComponentsJson(framework);
|
|
1573
|
+
addInstalledComponents(cj, [...items.keys()]);
|
|
1574
|
+
await writeComponentsJson(dir, cj);
|
|
1575
|
+
info(`scaffolded ${results.length} files -> ${dir}`);
|
|
1576
|
+
info(`cd ${dir} && ${installCommand(dir)} && ${devCommand(dir)}`);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// src/commands/init-template.ts
|
|
1580
|
+
import { existsSync as existsSync13 } from "node:fs";
|
|
1581
|
+
import { mkdir as mkdir6, mkdtemp as mkdtemp2, readdir as readdir5, rename, rm as rm3 } from "node:fs/promises";
|
|
1582
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
1583
|
+
import { join as join12 } from "node:path";
|
|
1584
|
+
|
|
1585
|
+
// src/starter-derive.ts
|
|
1586
|
+
import { existsSync as existsSync12, statSync as statSync2 } from "node:fs";
|
|
1587
|
+
import { cp as cp2, readFile as readFile10, readdir as readdir4, rm as rm2, rmdir, writeFile as writeFile8 } from "node:fs/promises";
|
|
1588
|
+
import { dirname as dirname5, join as join11, relative as relative3, resolve as resolve3 } from "node:path";
|
|
1589
|
+
var KEEP_WHOLESALE = /* @__PURE__ */ new Set(["ui", "svg"]);
|
|
1590
|
+
var KEEP_ALWAYS = ["src/utils/axios/"];
|
|
1591
|
+
var TOOLCHAIN_DEPS = /* @__PURE__ */ new Set([
|
|
1592
|
+
"next",
|
|
1593
|
+
"react",
|
|
1594
|
+
"react-dom",
|
|
1595
|
+
"typescript",
|
|
1596
|
+
"tailwindcss",
|
|
1597
|
+
"@tailwindcss/postcss",
|
|
1598
|
+
"@tailwindcss/typography",
|
|
1599
|
+
"eslint",
|
|
1600
|
+
"eslint-config-next",
|
|
1601
|
+
"@types/node",
|
|
1602
|
+
"@types/react",
|
|
1603
|
+
"@types/react-dom",
|
|
1604
|
+
"axios"
|
|
1605
|
+
]);
|
|
1606
|
+
var PEER_DEPS = /* @__PURE__ */ new Map([
|
|
1607
|
+
["@tiptap/pm", ["@tiptap/react", "@tiptap/starter-kit"]]
|
|
1608
|
+
]);
|
|
1609
|
+
var DEMO_ONLY_DEPS = /* @__PURE__ */ new Set(["@opennextjs/cloudflare", "wrangler"]);
|
|
1610
|
+
var DEMO_ONLY_SCRIPTS = /* @__PURE__ */ new Set(["cf:build", "cf:dry-run", "preview"]);
|
|
1611
|
+
var IMPORT_RE = /\b(?:from|import)\s*\(?\s*(['"])([^'"]+)\1/g;
|
|
1612
|
+
var ASSET_DIR_RE = /\/img\/([A-Za-z0-9_-]+)\//g;
|
|
1613
|
+
var STARTER_PAGES = {
|
|
1614
|
+
"ai-chat": /* @__PURE__ */ new Set(["chat"])
|
|
1615
|
+
};
|
|
1616
|
+
var STARTER_API = {
|
|
1617
|
+
"ai-chat": /* @__PURE__ */ new Set(["settings"])
|
|
1618
|
+
};
|
|
1619
|
+
function posix2(p) {
|
|
1620
|
+
return p.replace(/\\/g, "/");
|
|
1621
|
+
}
|
|
1622
|
+
async function collectFiles(dir, filter) {
|
|
1623
|
+
const out = [];
|
|
1624
|
+
const entries = await readdir4(dir, { withFileTypes: true });
|
|
1625
|
+
for (const entry of entries) {
|
|
1626
|
+
const full = join11(dir, entry.name);
|
|
1627
|
+
if (entry.isDirectory()) out.push(...await collectFiles(full, filter));
|
|
1628
|
+
else if (!filter || filter(entry.name)) out.push(full);
|
|
1629
|
+
}
|
|
1630
|
+
return out;
|
|
1631
|
+
}
|
|
1632
|
+
async function pruneEmptyDirs(dir) {
|
|
1633
|
+
const entries = await readdir4(dir, { withFileTypes: true });
|
|
1634
|
+
let remaining = entries.length;
|
|
1635
|
+
for (const entry of entries) {
|
|
1636
|
+
if (!entry.isDirectory()) continue;
|
|
1637
|
+
if (await pruneEmptyDirs(join11(dir, entry.name))) remaining--;
|
|
1638
|
+
}
|
|
1639
|
+
if (remaining === 0) {
|
|
1640
|
+
await rmdir(dir);
|
|
1641
|
+
return true;
|
|
1642
|
+
}
|
|
1643
|
+
return false;
|
|
1644
|
+
}
|
|
1645
|
+
function isPrunable(relPath) {
|
|
1646
|
+
if (relPath.startsWith("src/app/")) return false;
|
|
1647
|
+
if (KEEP_ALWAYS.some((prefix) => relPath.startsWith(prefix))) return false;
|
|
1648
|
+
const m = relPath.match(/^src\/components\/([^/]+)\//);
|
|
1649
|
+
if (m) return !KEEP_WHOLESALE.has(m[1]);
|
|
1650
|
+
return true;
|
|
1651
|
+
}
|
|
1652
|
+
function importSpecs(text) {
|
|
1653
|
+
const specs = [];
|
|
1654
|
+
IMPORT_RE.lastIndex = 0;
|
|
1655
|
+
let m;
|
|
1656
|
+
while (m = IMPORT_RE.exec(text)) specs.push(m[2]);
|
|
1657
|
+
return specs;
|
|
1658
|
+
}
|
|
1659
|
+
function resolveSpec(spec, fromFile, rootDir) {
|
|
1660
|
+
let base;
|
|
1661
|
+
if (spec.startsWith("@/")) {
|
|
1662
|
+
base = resolve3(rootDir, "src", spec.slice(2));
|
|
1663
|
+
} else if (spec.startsWith("./") || spec.startsWith("../")) {
|
|
1664
|
+
base = resolve3(dirname5(fromFile), spec);
|
|
1665
|
+
} else {
|
|
1666
|
+
return null;
|
|
1667
|
+
}
|
|
1668
|
+
const candidates = [
|
|
1669
|
+
base,
|
|
1670
|
+
`${base}.ts`,
|
|
1671
|
+
`${base}.tsx`,
|
|
1672
|
+
`${base}.js`,
|
|
1673
|
+
`${base}.jsx`,
|
|
1674
|
+
`${base}.mjs`,
|
|
1675
|
+
`${base}.css`,
|
|
1676
|
+
`${base}.json`,
|
|
1677
|
+
join11(base, "index.ts"),
|
|
1678
|
+
join11(base, "index.tsx")
|
|
1679
|
+
];
|
|
1680
|
+
return candidates.find((c) => existsSync12(c) && statSync2(c).isFile()) ?? null;
|
|
1681
|
+
}
|
|
1682
|
+
function packageOf(spec) {
|
|
1683
|
+
if (spec.startsWith("node:")) return null;
|
|
1684
|
+
const parts = spec.split("/");
|
|
1685
|
+
if (spec.startsWith("@")) return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : null;
|
|
1686
|
+
return parts[0] || null;
|
|
1687
|
+
}
|
|
1688
|
+
async function pruneUnreachable(rootDir) {
|
|
1689
|
+
const srcDir = join11(rootDir, "src");
|
|
1690
|
+
const files = await collectFiles(srcDir, (name) => /\.(ts|tsx)$/.test(name));
|
|
1691
|
+
const rel = (f) => posix2(relative3(rootDir, f));
|
|
1692
|
+
const roots = files.filter((f) => !isPrunable(rel(f)));
|
|
1693
|
+
const reached = new Set(roots.map((f) => resolve3(f)));
|
|
1694
|
+
const queue = [...roots];
|
|
1695
|
+
while (queue.length > 0) {
|
|
1696
|
+
const file = queue.pop();
|
|
1697
|
+
const text = await readFile10(file, "utf-8");
|
|
1698
|
+
for (const spec of importSpecs(text)) {
|
|
1699
|
+
const target = resolveSpec(spec, file, rootDir);
|
|
1700
|
+
if (!target) continue;
|
|
1701
|
+
const key = resolve3(target);
|
|
1702
|
+
if (reached.has(key)) continue;
|
|
1703
|
+
reached.add(key);
|
|
1704
|
+
if (/\.(ts|tsx)$/.test(key)) queue.push(key);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
for (const file of files) {
|
|
1708
|
+
if (reached.has(resolve3(file))) continue;
|
|
1709
|
+
await rm2(file, { force: true });
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
async function assertNoDanglingImports(rootDir) {
|
|
1713
|
+
const files = await collectFiles(join11(rootDir, "src"), (name) => /\.(ts|tsx)$/.test(name));
|
|
1714
|
+
const danglers = [];
|
|
1715
|
+
for (const file of files) {
|
|
1716
|
+
const text = await readFile10(file, "utf-8");
|
|
1717
|
+
for (const spec of importSpecs(text)) {
|
|
1718
|
+
if (!spec.startsWith("@/") && !spec.startsWith(".")) continue;
|
|
1719
|
+
if (resolveSpec(spec, file, rootDir)) continue;
|
|
1720
|
+
danglers.push(`${posix2(relative3(rootDir, file))} -> ${spec}`);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
if (danglers.length > 0) {
|
|
1724
|
+
throw new Error(`pruning left ${danglers.length} dangling import(s):
|
|
1725
|
+
${danglers.join("\n ")}`);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
async function pruneAssets(rootDir) {
|
|
1729
|
+
const imgDir = join11(rootDir, "public", "img");
|
|
1730
|
+
if (!existsSync12(imgDir)) return;
|
|
1731
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
1732
|
+
for (const file of await collectFiles(join11(rootDir, "src"), (name) => /\.(ts|tsx|css|json)$/.test(name))) {
|
|
1733
|
+
const text = await readFile10(file, "utf-8");
|
|
1734
|
+
for (const m of text.matchAll(ASSET_DIR_RE)) referenced.add(m[1]);
|
|
1735
|
+
}
|
|
1736
|
+
for (const entry of await readdir4(imgDir, { withFileTypes: true })) {
|
|
1737
|
+
if (!entry.isDirectory()) continue;
|
|
1738
|
+
if (referenced.has(entry.name)) continue;
|
|
1739
|
+
await rm2(join11(imgDir, entry.name), { recursive: true, force: true });
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
async function importedPackages(rootDir) {
|
|
1743
|
+
const packages = /* @__PURE__ */ new Set();
|
|
1744
|
+
for (const file of await collectFiles(join11(rootDir, "src"), (name) => /\.(ts|tsx)$/.test(name))) {
|
|
1745
|
+
const text = await readFile10(file, "utf-8");
|
|
1746
|
+
for (const spec of importSpecs(text)) {
|
|
1747
|
+
if (spec.startsWith("@/") || spec.startsWith(".")) continue;
|
|
1748
|
+
const pkg = packageOf(spec);
|
|
1749
|
+
if (pkg) packages.add(pkg);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
return packages;
|
|
1753
|
+
}
|
|
1754
|
+
async function deriveManifest(rootDir, slug) {
|
|
1755
|
+
const manifestPath = join11(rootDir, "package.json");
|
|
1756
|
+
const manifest = JSON.parse(await readFile10(manifestPath, "utf-8"));
|
|
1757
|
+
manifest.name = `nateui-${slug}-starter`;
|
|
1758
|
+
const used = await importedPackages(rootDir);
|
|
1759
|
+
const declared = /* @__PURE__ */ new Set([
|
|
1760
|
+
...Object.keys(manifest.dependencies ?? {}),
|
|
1761
|
+
...Object.keys(manifest.devDependencies ?? {})
|
|
1762
|
+
]);
|
|
1763
|
+
const keep = (name) => {
|
|
1764
|
+
if (DEMO_ONLY_DEPS.has(name)) return false;
|
|
1765
|
+
if (TOOLCHAIN_DEPS.has(name)) return true;
|
|
1766
|
+
const dependents = PEER_DEPS.get(name);
|
|
1767
|
+
if (dependents) return dependents.some((dep) => used.has(dep));
|
|
1768
|
+
if (name.startsWith("@types/")) {
|
|
1769
|
+
const typed = name.slice("@types/".length);
|
|
1770
|
+
return declared.has(typed) ? used.has(typed) : true;
|
|
1771
|
+
}
|
|
1772
|
+
return used.has(name);
|
|
1773
|
+
};
|
|
1774
|
+
const filterDeps = (deps) => {
|
|
1775
|
+
if (!deps) return void 0;
|
|
1776
|
+
const out = {};
|
|
1777
|
+
for (const [name, range] of Object.entries(deps)) {
|
|
1778
|
+
if (keep(name)) out[name] = range;
|
|
1779
|
+
}
|
|
1780
|
+
return out;
|
|
1781
|
+
};
|
|
1782
|
+
manifest.dependencies = filterDeps(manifest.dependencies);
|
|
1783
|
+
manifest.devDependencies = filterDeps(manifest.devDependencies);
|
|
1784
|
+
if (manifest.scripts) {
|
|
1785
|
+
for (const name of Object.keys(manifest.scripts)) {
|
|
1786
|
+
if (DEMO_ONLY_SCRIPTS.has(name)) delete manifest.scripts[name];
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
await writeFile8(manifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
1790
|
+
`);
|
|
1791
|
+
}
|
|
1792
|
+
async function subtractPages(starterDir, slug) {
|
|
1793
|
+
const pagesDir = join11(starterDir, "src", "app", "(protected-pages)");
|
|
1794
|
+
if (!existsSync12(pagesDir)) {
|
|
1795
|
+
throw new Error("starter derivation: src/app/(protected-pages) not found in the tarball's demo tree");
|
|
1796
|
+
}
|
|
1797
|
+
const keep = STARTER_PAGES[slug] ?? /* @__PURE__ */ new Set();
|
|
1798
|
+
const removed = [];
|
|
1799
|
+
for (const entry of await readdir4(pagesDir, { withFileTypes: true })) {
|
|
1800
|
+
if (!entry.isDirectory()) continue;
|
|
1801
|
+
if (keep.has(entry.name)) continue;
|
|
1802
|
+
removed.push(entry.name);
|
|
1803
|
+
await rm2(join11(pagesDir, entry.name), { recursive: true, force: true });
|
|
1804
|
+
}
|
|
1805
|
+
return removed;
|
|
1806
|
+
}
|
|
1807
|
+
async function subtractApi(starterDir, removedPages, slug) {
|
|
1808
|
+
const apiDir = join11(starterDir, "src", "app", "api");
|
|
1809
|
+
if (!existsSync12(apiDir)) return;
|
|
1810
|
+
const removedSet = new Set(removedPages);
|
|
1811
|
+
const explicit = STARTER_API[slug] ?? /* @__PURE__ */ new Set();
|
|
1812
|
+
for (const entry of await readdir4(apiDir, { withFileTypes: true })) {
|
|
1813
|
+
if (!entry.isDirectory()) continue;
|
|
1814
|
+
if (!removedSet.has(entry.name) && !explicit.has(entry.name)) continue;
|
|
1815
|
+
await rm2(join11(apiDir, entry.name), { recursive: true, force: true });
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
async function applyOverlay(starterDir, overlayDir) {
|
|
1819
|
+
await cp2(overlayDir, starterDir, { recursive: true, force: true });
|
|
1820
|
+
}
|
|
1821
|
+
async function deriveStarterInPlace(variantDir, slug) {
|
|
1822
|
+
const overlayDir = join11(variantDir, "starter");
|
|
1823
|
+
if (!existsSync12(overlayDir)) {
|
|
1824
|
+
throw new Error(`starter derivation: no starter/ overlay found in the tarball for '${slug}'`);
|
|
1825
|
+
}
|
|
1826
|
+
const removedPages = await subtractPages(variantDir, slug);
|
|
1827
|
+
await subtractApi(variantDir, removedPages, slug);
|
|
1828
|
+
await applyOverlay(variantDir, overlayDir);
|
|
1829
|
+
await pruneUnreachable(variantDir);
|
|
1830
|
+
await assertNoDanglingImports(variantDir);
|
|
1831
|
+
await pruneAssets(variantDir);
|
|
1832
|
+
await deriveManifest(variantDir, slug);
|
|
1833
|
+
await pruneEmptyDirs(join11(variantDir, "src"));
|
|
1834
|
+
await rm2(overlayDir, { recursive: true, force: true });
|
|
1835
|
+
await rm2(join11(variantDir, "registry.json"), { force: true });
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// src/commands/init-template.ts
|
|
1839
|
+
var KNOWN_SLUGS2 = ["crm", "project-management", "sales-inventory", "ai-chat"];
|
|
1840
|
+
async function runInitTemplate(argv) {
|
|
1841
|
+
const { flags, values, positional } = parseFlags(argv, ["tarball", "local", "token"]);
|
|
1842
|
+
const slug = KNOWN_SLUGS2.find((s) => flags.has(s));
|
|
1843
|
+
if (!slug) fail(`template flag required, one of: ${KNOWN_SLUGS2.map((s) => `--${s}`).join(", ")}`);
|
|
1844
|
+
const dir = positional[0];
|
|
1845
|
+
if (!dir) fail(`target directory required (e.g. nateui init-template --crm my-app)`);
|
|
1846
|
+
if (existsSync13(dir) && (await readdir5(dir)).length > 0) {
|
|
1847
|
+
fail(`target '${dir}' is not empty`);
|
|
1848
|
+
}
|
|
1849
|
+
const tarballPath = await resolveTarball(slug, { tarball: values.tarball, local: values.local, token: values.token });
|
|
1850
|
+
const scratch = await mkdtemp2(join12(tmpdir2(), "nateui-"));
|
|
1851
|
+
try {
|
|
1852
|
+
const variant = await withSpinner("setting up your project", async () => {
|
|
1853
|
+
await extractTarball(tarballPath, scratch);
|
|
1854
|
+
const manifest = await readTarballManifest(scratch);
|
|
1855
|
+
const root = variantRoot(scratch, manifest);
|
|
1856
|
+
await deriveStarterInPlace(root, slug);
|
|
1857
|
+
return root;
|
|
1858
|
+
});
|
|
1859
|
+
await mkdir6(join12(dir, ".."), { recursive: true }).catch(() => {
|
|
1860
|
+
});
|
|
1861
|
+
await rename(variant, dir);
|
|
1862
|
+
const cj = newComponentsJson("nextjs");
|
|
1863
|
+
await writeComponentsJson(dir, cj);
|
|
1864
|
+
info(`scaffolded ${slug} -> ${dir}`);
|
|
1865
|
+
info(`cd ${dir} && ${installCommand(dir)} && ${devCommand(dir)}`);
|
|
1866
|
+
} finally {
|
|
1867
|
+
await rm3(scratch, { recursive: true, force: true });
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// src/commands/list.ts
|
|
1872
|
+
import { mkdtemp as mkdtemp3, readFile as readFile11, rm as rm4 } from "node:fs/promises";
|
|
1873
|
+
import { tmpdir as tmpdir3 } from "node:os";
|
|
1874
|
+
import { join as join13 } from "node:path";
|
|
1875
|
+
async function runList(argv) {
|
|
1876
|
+
const { flags, values } = parseFlags(argv, ["local", "tarball", "template", "token"]);
|
|
1877
|
+
if (values.template) {
|
|
1878
|
+
if (!values.tarball && !values.local) {
|
|
1879
|
+
const catalog2 = await resolveCatalog(void 0);
|
|
1880
|
+
const registry = await catalog2.pageRegistry(values.template);
|
|
1881
|
+
for (const page of registry.pages) info(`${page.id} ${page.title}`);
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
const tarballPath = await resolveTarball(values.template, { tarball: values.tarball, local: values.local, token: values.token });
|
|
1885
|
+
const scratch = await mkdtemp3(join13(tmpdir3(), "nateui-list-"));
|
|
1886
|
+
try {
|
|
1887
|
+
await extractTarball(tarballPath, scratch);
|
|
1888
|
+
const manifest = await readTarballManifest(scratch);
|
|
1889
|
+
const variant = variantRoot(scratch, manifest);
|
|
1890
|
+
const registry = JSON.parse(await readFile11(join13(variant, "registry.json"), "utf-8"));
|
|
1891
|
+
for (const page of registry.pages) info(`${page.id} ${page.title}`);
|
|
1892
|
+
} finally {
|
|
1893
|
+
await rm4(scratch, { recursive: true, force: true });
|
|
1894
|
+
}
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
if (flags.has("installed")) {
|
|
1898
|
+
const projectDir = process.cwd();
|
|
1899
|
+
if (!componentsJsonExists(projectDir)) {
|
|
1900
|
+
fail(`no components.json found \u2014 run nateui init or nateui add first`);
|
|
1901
|
+
}
|
|
1902
|
+
const cj = await readComponentsJson(projectDir);
|
|
1903
|
+
for (const id of cj.installed.components) info(id);
|
|
1904
|
+
for (const id of cj.installed.pages) info(id);
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
const catalog = await resolveCatalog(values.local, values.token);
|
|
1908
|
+
const index = await catalog.index();
|
|
1909
|
+
for (const item of index.items) {
|
|
1910
|
+
if (item.hidden) continue;
|
|
1911
|
+
info(`${item.id} ${item.kind} ${item.tier}`);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
// src/commands/login.ts
|
|
1916
|
+
async function runLogin() {
|
|
1917
|
+
if (!process.stdin.isTTY) fail(`login requires an interactive terminal`);
|
|
1918
|
+
await runLoginFlow();
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
// src/commands/remove-page.ts
|
|
1922
|
+
import { existsSync as existsSync14 } from "node:fs";
|
|
1923
|
+
import { rm as rm5 } from "node:fs/promises";
|
|
1924
|
+
import { join as join14 } from "node:path";
|
|
1925
|
+
async function runRemovePage(argv) {
|
|
1926
|
+
const { flags } = parseFlags(argv, []);
|
|
1927
|
+
const pageFlag = [...flags][0];
|
|
1928
|
+
if (!pageFlag) fail(`page flag required (e.g. nateui remove-page --deals)`);
|
|
1929
|
+
const projectDir = process.cwd();
|
|
1930
|
+
if (!componentsJsonExists(projectDir)) fail(`no components.json found in ${projectDir}`);
|
|
1931
|
+
const cj = await readComponentsJson(projectDir);
|
|
1932
|
+
const installed = cj.installed.pages.find((p) => p.endsWith(`/${pageFlag}`));
|
|
1933
|
+
if (!installed) fail(`no installed page '${pageFlag}' (see nateui list --installed)`);
|
|
1934
|
+
const [template] = installed.split("/");
|
|
1935
|
+
const routeDir = join14(projectDir, "src", "app", "(protected-pages)", pageFlag);
|
|
1936
|
+
if (existsSync14(routeDir)) await rm5(routeDir, { recursive: true, force: true });
|
|
1937
|
+
const configs = {
|
|
1938
|
+
navPath: join14(projectDir, "src", "configs", "navigation.config.ts"),
|
|
1939
|
+
routesPath: join14(projectDir, "src", "configs", "routes.config.ts"),
|
|
1940
|
+
iconPath: join14(projectDir, "src", "configs", "navigation-icon.config.tsx")
|
|
1941
|
+
};
|
|
1942
|
+
await spliceRemove(configs, template, pageFlag);
|
|
1943
|
+
removeInstalledPage(cj, installed);
|
|
1944
|
+
await writeComponentsJson(projectDir, cj);
|
|
1945
|
+
info(`removed page ${installed}`);
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// src/skill/installer.ts
|
|
1949
|
+
import { createHash as createHash2, randomUUID } from "node:crypto";
|
|
1950
|
+
import { access, lstat, mkdir as mkdir7, mkdtemp as mkdtemp4, readFile as readFile12, readdir as readdir6, realpath, rename as rename2, rm as rm6, stat, writeFile as writeFile9 } from "node:fs/promises";
|
|
1951
|
+
import { homedir as homedir2 } from "node:os";
|
|
1952
|
+
import { basename, dirname as dirname6, isAbsolute as isAbsolute3, join as join15, relative as relative4, resolve as resolve4 } from "node:path";
|
|
1953
|
+
var SKILL_CLIENTS = ["codex", "claude", "cursor", "opencode", "vscode"];
|
|
1954
|
+
var CURRENT_CLI_VERSION = "0.0.3";
|
|
1955
|
+
var DEFAULT_DESIGN_ASSET = "assets/default-design.md";
|
|
1956
|
+
var MAX_BUNDLE_BYTES = 2e6;
|
|
1957
|
+
var LOCAL_MANIFEST = ".nateui-skill-manifest.json";
|
|
1958
|
+
var RELEASE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
|
1959
|
+
var SkillInstallError = class extends NateuiError {
|
|
1960
|
+
code;
|
|
1961
|
+
constructor(code, message) {
|
|
1962
|
+
super(`${code}: ${message}`);
|
|
1963
|
+
this.code = code;
|
|
1964
|
+
this.name = "SkillInstallError";
|
|
1965
|
+
}
|
|
1966
|
+
};
|
|
1967
|
+
var SkillSetupError = class extends SkillInstallError {
|
|
1968
|
+
result;
|
|
1969
|
+
constructor(error, result) {
|
|
1970
|
+
const code = error instanceof SkillInstallError ? error.code : "SETUP_FAILED";
|
|
1971
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1972
|
+
super(code, error instanceof SkillInstallError ? message.slice(code.length + 2) : message);
|
|
1973
|
+
this.result = { ...result, error: { code, message } };
|
|
1974
|
+
}
|
|
1975
|
+
};
|
|
1976
|
+
function fail2(code, message) {
|
|
1977
|
+
throw new SkillInstallError(code, message);
|
|
1978
|
+
}
|
|
1979
|
+
function assertObject(value, label, code = "INVALID_BUNDLE") {
|
|
1980
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) fail2(code, `${label} must be an object`);
|
|
1981
|
+
}
|
|
1982
|
+
function requiredString(value, label) {
|
|
1983
|
+
if (typeof value !== "string") fail2("INVALID_BUNDLE", `${label} must be a string`);
|
|
1984
|
+
return value;
|
|
1985
|
+
}
|
|
1986
|
+
function nonnegativeInteger(value, label) {
|
|
1987
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > MAX_BUNDLE_BYTES) fail2("INVALID_BUNDLE", `${label} must be a valid byte count`);
|
|
1988
|
+
return value;
|
|
1989
|
+
}
|
|
1990
|
+
function safeSkillPath(value) {
|
|
1991
|
+
const parts = typeof value === "string" ? value.split("/") : [];
|
|
1992
|
+
const valid = typeof value === "string" && value !== "" && !value.includes("\\") && !value.startsWith("/") && parts.every((part) => part !== "" && part !== "." && part !== ".." && /^[A-Za-z0-9._-]+$/.test(part) && !part.endsWith(".") && !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)) && (value === "SKILL.md" || value === DEFAULT_DESIGN_ASSET || parts[0] === "references" && parts.length > 1);
|
|
1993
|
+
if (!valid) {
|
|
1994
|
+
fail2("INVALID_BUNDLE", `unsafe public skill path: ${String(value)}`);
|
|
1995
|
+
}
|
|
1996
|
+
return value;
|
|
1997
|
+
}
|
|
1998
|
+
function pathsOverlap(left, right) {
|
|
1999
|
+
const a = left.toLowerCase();
|
|
2000
|
+
const b = right.toLowerCase();
|
|
2001
|
+
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
2002
|
+
}
|
|
2003
|
+
function sha256(value) {
|
|
2004
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
2005
|
+
}
|
|
2006
|
+
function parseSemver(value, label) {
|
|
2007
|
+
if (typeof value !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value)) {
|
|
2008
|
+
fail2("INVALID_BUNDLE", `${label} must be a semantic version`);
|
|
2009
|
+
}
|
|
2010
|
+
const match = value.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
2011
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
2012
|
+
}
|
|
2013
|
+
function semverGreater(a, b) {
|
|
2014
|
+
for (let index = 0; index < 3; index += 1) {
|
|
2015
|
+
if (a[index] !== b[index]) return a[index] > b[index];
|
|
2016
|
+
}
|
|
2017
|
+
return false;
|
|
2018
|
+
}
|
|
2019
|
+
function validateReleaseId(value) {
|
|
2020
|
+
if (typeof value !== "string" || !RELEASE_ID_PATTERN.test(value)) fail2("INVALID_BUNDLE", "release id is invalid");
|
|
2021
|
+
return value;
|
|
2022
|
+
}
|
|
2023
|
+
function rejectPrivateText(path, content) {
|
|
2024
|
+
if (/(?:^|[\s"'(=])(?:[A-Za-z]:[\\/]|(?:\.\.\/[\\/]*)?(?:wiki|private|\.nateui)[\\/]|platform[\\/]src|factory[\\/]dist|code[\\/]agent)/i.test(content)) {
|
|
2025
|
+
fail2("INVALID_BUNDLE", `public skill file ${path} contains an internal path`);
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
function validateSkillBundle(input) {
|
|
2029
|
+
assertObject(input, "skill bundle");
|
|
2030
|
+
if (input.schemaVersion !== 1) fail2("INVALID_BUNDLE", "unsupported skill bundle schema");
|
|
2031
|
+
const releaseId = validateReleaseId(input.releaseId);
|
|
2032
|
+
assertObject(input.manifest, "skill manifest");
|
|
2033
|
+
const rawManifest = input.manifest;
|
|
2034
|
+
if (rawManifest.schemaVersion !== 1 || rawManifest.releaseId !== releaseId) fail2("INVALID_BUNDLE", "skill manifest identity is inconsistent");
|
|
2035
|
+
const skillVersion = requiredString(rawManifest.skillVersion, "skillVersion");
|
|
2036
|
+
const minimumCliVersion = requiredString(rawManifest.minimumCliVersion, "minimumCliVersion");
|
|
2037
|
+
const parsedSkillVersion = parseSemver(skillVersion, "skillVersion");
|
|
2038
|
+
const minimum = parseSemver(minimumCliVersion, "minimumCliVersion");
|
|
2039
|
+
if (semverGreater(minimum, parseSemver(CURRENT_CLI_VERSION, "current CLI version"))) {
|
|
2040
|
+
fail2("CLI_TOO_OLD", `skill requires CLI ${minimumCliVersion} or newer`);
|
|
2041
|
+
}
|
|
2042
|
+
const declaredDeliveryBytes = nonnegativeInteger(rawManifest.deliveryBytes, "skill delivery size");
|
|
2043
|
+
if (!Array.isArray(rawManifest.files) || rawManifest.files.length === 0) fail2("INVALID_BUNDLE", "skill manifest has no files");
|
|
2044
|
+
assertObject(input.files, "skill files");
|
|
2045
|
+
const fileMap = input.files;
|
|
2046
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2047
|
+
const files = [];
|
|
2048
|
+
const deliveredFiles = {};
|
|
2049
|
+
let deliveryBytes = 0;
|
|
2050
|
+
for (const rawFile of rawManifest.files) {
|
|
2051
|
+
assertObject(rawFile, "skill manifest file");
|
|
2052
|
+
const path = safeSkillPath(rawFile.path);
|
|
2053
|
+
const folded = path.toLowerCase();
|
|
2054
|
+
if (seen.has(folded)) fail2("INVALID_BUNDLE", `duplicate skill path: ${path}`);
|
|
2055
|
+
if ([...seen].some((existing) => pathsOverlap(existing, folded))) fail2("INVALID_BUNDLE", `overlapping skill paths: ${path}`);
|
|
2056
|
+
seen.add(folded);
|
|
2057
|
+
const fileDigest = requiredString(rawFile.sha256, `digest for ${path}`);
|
|
2058
|
+
if (!/^[a-f0-9]{64}$/.test(fileDigest)) fail2("INVALID_BUNDLE", `invalid digest for ${path}`);
|
|
2059
|
+
const fileBytes = nonnegativeInteger(rawFile.bytes, `byte count for ${path}`);
|
|
2060
|
+
if (typeof fileMap[path] !== "string") fail2("INVALID_BUNDLE", `manifest file is missing from delivery: ${path}`);
|
|
2061
|
+
const content = fileMap[path];
|
|
2062
|
+
if (typeof content !== "string") fail2("INVALID_BUNDLE", `manifest file is missing from delivery: ${path}`);
|
|
2063
|
+
const bytes = Buffer.from(content, "utf8");
|
|
2064
|
+
if (bytes.byteLength !== fileBytes || sha256(bytes) !== fileDigest) fail2("INVALID_BUNDLE", `digest mismatch for ${path}`);
|
|
2065
|
+
rejectPrivateText(path, content);
|
|
2066
|
+
deliveryBytes += bytes.byteLength;
|
|
2067
|
+
files.push({ path, sha256: fileDigest, bytes: fileBytes });
|
|
2068
|
+
deliveredFiles[path] = content;
|
|
2069
|
+
}
|
|
2070
|
+
const deliveredPaths = Object.keys(fileMap);
|
|
2071
|
+
if (deliveredPaths.some((path) => !seen.has(path.toLowerCase())) || deliveredPaths.length !== files.length) {
|
|
2072
|
+
fail2("INVALID_BUNDLE", "delivery contains files outside its manifest");
|
|
2073
|
+
}
|
|
2074
|
+
if (deliveryBytes !== declaredDeliveryBytes) fail2("INVALID_BUNDLE", "deliveryBytes does not match delivered content");
|
|
2075
|
+
if (!seen.has("skill.md")) fail2("INVALID_BUNDLE", "delivery must contain SKILL.md");
|
|
2076
|
+
if (!semverGreater([0, 2, 0], parsedSkillVersion)) {
|
|
2077
|
+
if (!seen.has(DEFAULT_DESIGN_ASSET)) fail2("INVALID_BUNDLE", `skill ${skillVersion} must include ${DEFAULT_DESIGN_ASSET}`);
|
|
2078
|
+
if (semverGreater([0, 0, 2], minimum)) fail2("INVALID_BUNDLE", "theme-aware skill requires minimumCliVersion 0.0.2 or newer");
|
|
2079
|
+
}
|
|
2080
|
+
return {
|
|
2081
|
+
schemaVersion: 1,
|
|
2082
|
+
releaseId,
|
|
2083
|
+
manifest: {
|
|
2084
|
+
schemaVersion: 1,
|
|
2085
|
+
releaseId,
|
|
2086
|
+
skillVersion,
|
|
2087
|
+
minimumCliVersion,
|
|
2088
|
+
deliveryBytes,
|
|
2089
|
+
files
|
|
2090
|
+
},
|
|
2091
|
+
files: deliveredFiles
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
var DESTINATIONS = {
|
|
2095
|
+
codex: { project: [".agents", "skills", "nateui"], global: [".agents", "skills", "nateui"] },
|
|
2096
|
+
claude: { project: [".claude", "skills", "nateui"], global: [".claude", "skills", "nateui"] },
|
|
2097
|
+
cursor: { project: [".cursor", "skills", "nateui"], global: [".cursor", "skills", "nateui"] },
|
|
2098
|
+
opencode: { project: [".opencode", "skills", "nateui"], global: [".config", "opencode", "skills", "nateui"] },
|
|
2099
|
+
vscode: { project: [".github", "skills", "nateui"], global: [".copilot", "skills", "nateui"] }
|
|
2100
|
+
};
|
|
2101
|
+
var CLIENT_ROOTS = SKILL_CLIENTS.map((client) => ({
|
|
2102
|
+
client,
|
|
2103
|
+
path: DESTINATIONS[client].project.slice(0, -1)
|
|
2104
|
+
}));
|
|
2105
|
+
async function directoryExists(path) {
|
|
2106
|
+
try {
|
|
2107
|
+
return (await stat(path)).isDirectory();
|
|
2108
|
+
} catch {
|
|
2109
|
+
return false;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
function assertClient(value) {
|
|
2113
|
+
if (value === void 0) return void 0;
|
|
2114
|
+
if (!SKILL_CLIENTS.includes(value)) fail2("CLIENT_INVALID", `unsupported client '${value}'`);
|
|
2115
|
+
return value;
|
|
2116
|
+
}
|
|
2117
|
+
async function resolveSkillDestination(options) {
|
|
2118
|
+
if (options.path && (options.client || options.global)) fail2("DESTINATION_CONFLICT", "--path cannot be combined with --client or --global");
|
|
2119
|
+
if (options.global && !options.client) fail2("CLIENT_REQUIRED", "--global requires --client");
|
|
2120
|
+
if (options.path) return { destination: resolve4(options.path) };
|
|
2121
|
+
const cwd = resolve4(options.cwd ?? process.cwd());
|
|
2122
|
+
const home = resolve4(options.home ?? homedir2());
|
|
2123
|
+
let client = assertClient(options.client);
|
|
2124
|
+
if (!client) {
|
|
2125
|
+
const detected = (await Promise.all(CLIENT_ROOTS.map(async ({ client: candidate, path }) => await directoryExists(join15(cwd, ...path)) ? candidate : void 0))).filter((candidate) => candidate !== void 0);
|
|
2126
|
+
if (detected.length > 1) fail2("CLIENT_REQUIRED", `multiple client skill roots detected (${detected.join(", ")}); pass --client`);
|
|
2127
|
+
client = detected[0] ?? "codex";
|
|
2128
|
+
}
|
|
2129
|
+
return {
|
|
2130
|
+
client,
|
|
2131
|
+
destination: join15(options.global ? home : cwd, ...DESTINATIONS[client][options.global ? "global" : "project"])
|
|
2132
|
+
};
|
|
2133
|
+
}
|
|
2134
|
+
async function exists(path) {
|
|
2135
|
+
try {
|
|
2136
|
+
await access(path);
|
|
2137
|
+
return true;
|
|
2138
|
+
} catch {
|
|
2139
|
+
return false;
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
function samePath(left, right) {
|
|
2143
|
+
return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
2144
|
+
}
|
|
2145
|
+
function isMissing(error) {
|
|
2146
|
+
return error.code === "ENOENT";
|
|
2147
|
+
}
|
|
2148
|
+
function assertDirectChild(path, parent, label) {
|
|
2149
|
+
const relativePath = relative4(resolve4(parent), resolve4(path));
|
|
2150
|
+
if (!relativePath || relativePath.startsWith("..") || isAbsolute3(relativePath) || relativePath.includes("\\") || relativePath.includes("/")) {
|
|
2151
|
+
fail2("INSTALLATION_STATE_INVALID", `${label} is outside its safe parent: ${path}`);
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
async function ensureSafeExistingPath(path, label = "skill destination") {
|
|
2155
|
+
let current = resolve4(path);
|
|
2156
|
+
while (true) {
|
|
2157
|
+
let entry;
|
|
2158
|
+
try {
|
|
2159
|
+
entry = await lstat(current);
|
|
2160
|
+
} catch (error) {
|
|
2161
|
+
if (!isMissing(error)) fail2("INSTALLATION_STATE_INVALID", `could not inspect the ${label}: ${current}`);
|
|
2162
|
+
const parent = dirname6(current);
|
|
2163
|
+
if (parent === current) return;
|
|
2164
|
+
current = parent;
|
|
2165
|
+
continue;
|
|
2166
|
+
}
|
|
2167
|
+
if (entry.isSymbolicLink()) fail2("INSTALLATION_STATE_INVALID", `symlinks and junctions are not supported in the ${label}: ${current}`);
|
|
2168
|
+
let resolvedPath;
|
|
2169
|
+
try {
|
|
2170
|
+
resolvedPath = await realpath(current);
|
|
2171
|
+
} catch {
|
|
2172
|
+
fail2("INSTALLATION_STATE_INVALID", `could not resolve the ${label}: ${current}`);
|
|
2173
|
+
}
|
|
2174
|
+
if (!samePath(resolve4(resolvedPath), current)) {
|
|
2175
|
+
fail2("INSTALLATION_STATE_INVALID", `redirecting filesystem entry is not supported in the ${label}: ${current}`);
|
|
2176
|
+
}
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
async function ensureNoSymlink(path) {
|
|
2181
|
+
await ensureSafeExistingPath(path);
|
|
2182
|
+
}
|
|
2183
|
+
async function assertDirectoryAncestors(path) {
|
|
2184
|
+
let current = dirname6(resolve4(path));
|
|
2185
|
+
while (true) {
|
|
2186
|
+
try {
|
|
2187
|
+
if (!(await lstat(current)).isDirectory()) fail2("DESTINATION_INVALID", `destination parent is not a directory: ${current}`);
|
|
2188
|
+
await ensureSafeExistingPath(current);
|
|
2189
|
+
return;
|
|
2190
|
+
} catch (error) {
|
|
2191
|
+
if (!isMissing(error)) throw error;
|
|
2192
|
+
const parent = dirname6(current);
|
|
2193
|
+
if (parent === current) throw error;
|
|
2194
|
+
current = parent;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
async function listFiles(root, prefix = "", directories = []) {
|
|
2199
|
+
await ensureSafeExistingPath(root);
|
|
2200
|
+
const entries = await readdir6(root, { withFileTypes: true });
|
|
2201
|
+
const files = [];
|
|
2202
|
+
for (const entry of entries) {
|
|
2203
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
2204
|
+
const absolutePath = join15(root, entry.name);
|
|
2205
|
+
if (entry.isSymbolicLink()) fail2("INSTALLATION_STATE_INVALID", `symlinks are not supported in the skill destination: ${relativePath}`);
|
|
2206
|
+
if (entry.isDirectory()) {
|
|
2207
|
+
directories.push(relativePath);
|
|
2208
|
+
files.push(...await listFiles(absolutePath, relativePath, directories));
|
|
2209
|
+
} else if (entry.isFile()) {
|
|
2210
|
+
await ensureSafeExistingPath(absolutePath);
|
|
2211
|
+
files.push(relativePath);
|
|
2212
|
+
} else fail2("INSTALLATION_STATE_INVALID", `unsupported filesystem entry in skill destination: ${relativePath}`);
|
|
2213
|
+
}
|
|
2214
|
+
return files.sort();
|
|
2215
|
+
}
|
|
2216
|
+
function localManifestString(value, label) {
|
|
2217
|
+
if (typeof value !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value)) {
|
|
2218
|
+
fail2("INSTALLATION_STATE_INVALID", `${label} is invalid`);
|
|
2219
|
+
}
|
|
2220
|
+
return value;
|
|
2221
|
+
}
|
|
2222
|
+
function parseLocalManifest(value) {
|
|
2223
|
+
assertObject(value, "local skill manifest", "INSTALLATION_STATE_INVALID");
|
|
2224
|
+
if (value.schemaVersion !== 1) fail2("INSTALLATION_STATE_INVALID", "local skill manifest is invalid");
|
|
2225
|
+
const releaseId = value.releaseId;
|
|
2226
|
+
if (typeof releaseId !== "string" || !RELEASE_ID_PATTERN.test(releaseId)) fail2("INSTALLATION_STATE_INVALID", "local skill manifest release id is invalid");
|
|
2227
|
+
const skillVersion = localManifestString(value.skillVersion, "local skill manifest skill version");
|
|
2228
|
+
if (!Array.isArray(value.files) || value.files.length === 0) fail2("INSTALLATION_STATE_INVALID", "local skill manifest has no files");
|
|
2229
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2230
|
+
const files = [];
|
|
2231
|
+
for (const rawFile of value.files) {
|
|
2232
|
+
assertObject(rawFile, "local skill manifest file", "INSTALLATION_STATE_INVALID");
|
|
2233
|
+
const path = safeSkillPath(rawFile.path);
|
|
2234
|
+
const folded = path.toLowerCase();
|
|
2235
|
+
if (seen.has(folded)) fail2("INSTALLATION_STATE_INVALID", `duplicate local skill path: ${path}`);
|
|
2236
|
+
if ([...seen].some((existing) => pathsOverlap(existing, folded))) fail2("INSTALLATION_STATE_INVALID", `overlapping local skill paths: ${path}`);
|
|
2237
|
+
seen.add(folded);
|
|
2238
|
+
const fileDigest = rawFile.sha256;
|
|
2239
|
+
if (typeof fileDigest !== "string" || !/^[a-f0-9]{64}$/.test(fileDigest)) fail2("INSTALLATION_STATE_INVALID", `invalid local digest for ${path}`);
|
|
2240
|
+
const bytes = rawFile.bytes;
|
|
2241
|
+
if (typeof bytes !== "number" || !Number.isInteger(bytes) || bytes < 0 || bytes > MAX_BUNDLE_BYTES) fail2("INSTALLATION_STATE_INVALID", `invalid local byte count for ${path}`);
|
|
2242
|
+
files.push({ path, sha256: fileDigest, bytes });
|
|
2243
|
+
}
|
|
2244
|
+
if (!seen.has("skill.md")) fail2("INSTALLATION_STATE_INVALID", "local skill manifest must own SKILL.md");
|
|
2245
|
+
return { schemaVersion: 1, releaseId, skillVersion, files };
|
|
2246
|
+
}
|
|
2247
|
+
async function readLocalManifest(destination) {
|
|
2248
|
+
const path = join15(destination, LOCAL_MANIFEST);
|
|
2249
|
+
if (!await exists(path)) return null;
|
|
2250
|
+
try {
|
|
2251
|
+
return parseLocalManifest(JSON.parse(await readFile12(path, "utf8")));
|
|
2252
|
+
} catch (error) {
|
|
2253
|
+
if (error instanceof SkillInstallError) throw error;
|
|
2254
|
+
fail2("INSTALLATION_STATE_INVALID", "local skill manifest is invalid");
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
async function validateOwnedFiles(destination, local) {
|
|
2258
|
+
for (const file of local.files) {
|
|
2259
|
+
const path = safeSkillPath(file.path);
|
|
2260
|
+
const absolute = join15(destination, path);
|
|
2261
|
+
if (!await exists(absolute)) fail2("INSTALLATION_STATE_INVALID", `owned skill file is missing: ${path}`);
|
|
2262
|
+
const bytes = await readFile12(absolute);
|
|
2263
|
+
if (bytes.byteLength !== file.bytes || sha256(bytes) !== file.sha256) fail2("INSTALLATION_STATE_INVALID", `owned skill file was modified: ${path}`);
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
async function ownedFilesMatch(destination, local) {
|
|
2267
|
+
for (const file of local.files) {
|
|
2268
|
+
const absolute = join15(destination, file.path);
|
|
2269
|
+
try {
|
|
2270
|
+
const bytes = await readFile12(absolute);
|
|
2271
|
+
if (bytes.byteLength !== file.bytes || sha256(bytes) !== file.sha256) return false;
|
|
2272
|
+
} catch (error) {
|
|
2273
|
+
if (isMissing(error)) return false;
|
|
2274
|
+
throw error;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
return true;
|
|
2278
|
+
}
|
|
2279
|
+
function localManifestFor(bundle) {
|
|
2280
|
+
return {
|
|
2281
|
+
schemaVersion: 1,
|
|
2282
|
+
releaseId: bundle.releaseId,
|
|
2283
|
+
skillVersion: bundle.manifest.skillVersion,
|
|
2284
|
+
files: bundle.manifest.files
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
async function interruptedBackup(destination) {
|
|
2288
|
+
await ensureSafeExistingPath(destination);
|
|
2289
|
+
if (await exists(destination)) return void 0;
|
|
2290
|
+
const parent = dirname6(destination);
|
|
2291
|
+
await ensureSafeExistingPath(parent);
|
|
2292
|
+
if (!await directoryExists(parent)) return void 0;
|
|
2293
|
+
const prefix = `.nateui-skill-backup-${basename(destination)}-`;
|
|
2294
|
+
const candidates = (await readdir6(parent, { withFileTypes: true })).filter((entry) => entry.name.startsWith(prefix) && entry.isDirectory()).map((entry) => join15(parent, entry.name));
|
|
2295
|
+
if (candidates.length === 0) return void 0;
|
|
2296
|
+
if (candidates.length > 1) fail2("INSTALLATION_STATE_INVALID", "multiple interrupted skill backups require manual cleanup");
|
|
2297
|
+
assertDirectChild(candidates[0], parent, "interrupted skill backup");
|
|
2298
|
+
await ensureNoSymlink(candidates[0]);
|
|
2299
|
+
if (!(await lstat(candidates[0])).isDirectory()) fail2("INSTALLATION_STATE_INVALID", "interrupted skill backup is not a directory");
|
|
2300
|
+
return candidates[0];
|
|
2301
|
+
}
|
|
2302
|
+
async function installSkillBundle(bundle, options) {
|
|
2303
|
+
const destination = resolve4(options.destination);
|
|
2304
|
+
await ensureSafeExistingPath(destination);
|
|
2305
|
+
const pendingBackup = await interruptedBackup(destination);
|
|
2306
|
+
const recovered = Boolean(pendingBackup && !options.dryRun);
|
|
2307
|
+
if (recovered) await rename2(pendingBackup, destination);
|
|
2308
|
+
const sourceDestination = options.dryRun && pendingBackup ? pendingBackup : destination;
|
|
2309
|
+
const destinationExists = await exists(sourceDestination);
|
|
2310
|
+
if (destinationExists) await ensureNoSymlink(sourceDestination);
|
|
2311
|
+
if (destinationExists && !(await lstat(sourceDestination)).isDirectory()) fail2("DESTINATION_INVALID", `skill destination is not a directory: ${destination}`);
|
|
2312
|
+
await assertDirectoryAncestors(destination);
|
|
2313
|
+
const local = destinationExists ? await readLocalManifest(sourceDestination) : null;
|
|
2314
|
+
const ownedMatches = local ? await ownedFilesMatch(sourceDestination, local) : true;
|
|
2315
|
+
if (local && !ownedMatches && !options.force) {
|
|
2316
|
+
await validateOwnedFiles(sourceDestination, local);
|
|
2317
|
+
}
|
|
2318
|
+
const existingDirectories = [];
|
|
2319
|
+
const existingFiles = destinationExists ? await listFiles(sourceDestination, "", existingDirectories) : [];
|
|
2320
|
+
const owned = new Set((local?.files ?? []).map((file) => file.path));
|
|
2321
|
+
owned.add(LOCAL_MANIFEST);
|
|
2322
|
+
const incomingPaths = new Set(bundle.manifest.files.map((file) => file.path));
|
|
2323
|
+
for (const path of incomingPaths) {
|
|
2324
|
+
const collision = existingFiles.find((existing) => pathsOverlap(existing, path) && !owned.has(existing)) ?? existingDirectories.find((existing) => existing.toLowerCase() === path.toLowerCase() || existing.toLowerCase().startsWith(`${path.toLowerCase()}/`));
|
|
2325
|
+
if (collision) fail2("DESTINATION_COLLISION", `destination contains an unowned file at ${collision}`);
|
|
2326
|
+
}
|
|
2327
|
+
const identical = local?.releaseId === bundle.releaseId && ownedMatches && local.files.length === bundle.manifest.files.length && local.files.every((file) => incomingPaths.has(file.path) && bundle.manifest.files.find((candidate) => candidate.path === file.path)?.sha256 === file.sha256);
|
|
2328
|
+
if (identical) {
|
|
2329
|
+
return { releaseId: bundle.releaseId, destination, dryRun: Boolean(options.dryRun), ...recovered ? { recovered: true } : {}, ...options.dryRun && pendingBackup ? { recoveryRequired: true } : {}, files: bundle.manifest.files.map((file) => ({ path: file.path, action: "unchanged", bytes: file.bytes })) };
|
|
2330
|
+
}
|
|
2331
|
+
if (local && !options.force) fail2("FORCE_REQUIRED", `skill destination already contains a different NateUI release; pass --force to replace it`);
|
|
2332
|
+
const changes = bundle.manifest.files.map((file) => ({
|
|
2333
|
+
path: file.path,
|
|
2334
|
+
action: local?.files.some((old) => old.path === file.path && old.sha256 === file.sha256 && ownedMatches) ? "unchanged" : local?.files.some((old) => old.path === file.path) ? "replace" : "create",
|
|
2335
|
+
bytes: file.bytes
|
|
2336
|
+
}));
|
|
2337
|
+
if (local) {
|
|
2338
|
+
for (const old of local.files) if (!incomingPaths.has(old.path)) changes.push({ path: old.path, action: "remove", bytes: old.bytes });
|
|
2339
|
+
}
|
|
2340
|
+
changes.sort((a, b) => a.path.localeCompare(b.path));
|
|
2341
|
+
if (options.dryRun) return { releaseId: bundle.releaseId, destination, dryRun: true, ...pendingBackup ? { recoveryRequired: true } : {}, files: changes };
|
|
2342
|
+
const parent = dirname6(destination);
|
|
2343
|
+
await ensureSafeExistingPath(parent);
|
|
2344
|
+
await mkdir7(parent, { recursive: true });
|
|
2345
|
+
const stage = await mkdtemp4(join15(parent, ".nateui-skill-stage-"));
|
|
2346
|
+
assertDirectChild(stage, parent, "staging directory");
|
|
2347
|
+
await ensureNoSymlink(stage);
|
|
2348
|
+
let backup;
|
|
2349
|
+
try {
|
|
2350
|
+
for (const path of existingFiles) {
|
|
2351
|
+
if (owned.has(path)) continue;
|
|
2352
|
+
const source = join15(destination, path);
|
|
2353
|
+
const target = join15(stage, path);
|
|
2354
|
+
await mkdir7(dirname6(target), { recursive: true });
|
|
2355
|
+
await writeFile9(target, await readFile12(source));
|
|
2356
|
+
}
|
|
2357
|
+
for (const file of bundle.manifest.files) {
|
|
2358
|
+
const target = join15(stage, file.path);
|
|
2359
|
+
await mkdir7(dirname6(target), { recursive: true });
|
|
2360
|
+
await writeFile9(target, bundle.files[file.path], "utf8");
|
|
2361
|
+
}
|
|
2362
|
+
await writeFile9(join15(stage, LOCAL_MANIFEST), JSON.stringify(localManifestFor(bundle), null, 2) + "\n", "utf8");
|
|
2363
|
+
if (destinationExists) {
|
|
2364
|
+
backup = join15(parent, `.nateui-skill-backup-${basename(destination)}-${process.pid}-${randomUUID()}`);
|
|
2365
|
+
assertDirectChild(backup, parent, "skill backup");
|
|
2366
|
+
await rename2(destination, backup);
|
|
2367
|
+
if (process.env.NATEUI_SKILL_FAIL_SWAP === "after-backup") fail2("SWAP_INTERRUPTED", "test failure injected after backup; rerun to recover");
|
|
2368
|
+
}
|
|
2369
|
+
await rename2(stage, destination);
|
|
2370
|
+
if (backup) {
|
|
2371
|
+
await ensureNoSymlink(backup);
|
|
2372
|
+
await rm6(backup, { recursive: true, force: true });
|
|
2373
|
+
}
|
|
2374
|
+
return { releaseId: bundle.releaseId, destination, dryRun: false, ...recovered ? { recovered: true } : {}, files: changes };
|
|
2375
|
+
} catch (error) {
|
|
2376
|
+
if (backup && !(error instanceof SkillInstallError && error.code === "SWAP_INTERRUPTED")) {
|
|
2377
|
+
try {
|
|
2378
|
+
if (await exists(destination)) {
|
|
2379
|
+
assertDirectChild(destination, parent, "skill destination");
|
|
2380
|
+
await ensureNoSymlink(destination);
|
|
2381
|
+
await rm6(destination, { recursive: true, force: true });
|
|
2382
|
+
}
|
|
2383
|
+
await ensureNoSymlink(backup);
|
|
2384
|
+
await rename2(backup, destination);
|
|
2385
|
+
backup = void 0;
|
|
2386
|
+
} catch {
|
|
2387
|
+
fail2("SWAP_RECOVERY_FAILED", `could not restore the previous skill directory; backup remains at ${backup}`);
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
throw error;
|
|
2391
|
+
} finally {
|
|
2392
|
+
if (await exists(stage)) {
|
|
2393
|
+
await ensureNoSymlink(stage);
|
|
2394
|
+
await rm6(stage, { recursive: true, force: true });
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
async function inspectProjectDesign(applicationRoot) {
|
|
2399
|
+
const root = resolve4(applicationRoot);
|
|
2400
|
+
const path = join15(root, "DESIGN.md");
|
|
2401
|
+
await ensureSafeExistingPath(root, "application root");
|
|
2402
|
+
if (!(await lstat(root)).isDirectory()) fail2("DESIGN_PATH_INVALID", `application root is not a directory: ${root}`);
|
|
2403
|
+
const caseCollision = (await readdir6(root)).find((entry) => entry.toLowerCase() === "design.md" && entry !== "DESIGN.md");
|
|
2404
|
+
if (caseCollision) fail2("DESIGN_PATH_INVALID", `project design has a case-colliding name: ${caseCollision}`);
|
|
2405
|
+
await ensureSafeExistingPath(path, "project DESIGN.md");
|
|
2406
|
+
try {
|
|
2407
|
+
if (!(await lstat(path)).isFile()) fail2("DESIGN_PATH_INVALID", `project DESIGN.md must be a regular file: ${path}`);
|
|
2408
|
+
return { path, action: "preserve" };
|
|
2409
|
+
} catch (error) {
|
|
2410
|
+
if (isMissing(error)) return { path, action: "create" };
|
|
2411
|
+
throw error;
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
async function createProjectDesign(applicationRoot, content) {
|
|
2415
|
+
const current = await inspectProjectDesign(applicationRoot);
|
|
2416
|
+
if (current.action === "preserve") return current;
|
|
2417
|
+
try {
|
|
2418
|
+
await writeFile9(current.path, content, { encoding: "utf8", flag: "wx" });
|
|
2419
|
+
return current;
|
|
2420
|
+
} catch (error) {
|
|
2421
|
+
if (error.code !== "EEXIST") throw error;
|
|
2422
|
+
const concurrent = await inspectProjectDesign(applicationRoot);
|
|
2423
|
+
if (concurrent.action !== "preserve") throw error;
|
|
2424
|
+
return concurrent;
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
async function installSkillSetup(bundle, options) {
|
|
2428
|
+
const destination = resolve4(options.destination);
|
|
2429
|
+
let result = {
|
|
2430
|
+
releaseId: bundle.releaseId,
|
|
2431
|
+
destination,
|
|
2432
|
+
dryRun: Boolean(options.dryRun),
|
|
2433
|
+
files: [],
|
|
2434
|
+
status: "failed",
|
|
2435
|
+
skill: { path: destination, action: "skip" },
|
|
2436
|
+
design: options.applicationRoot ? { path: join15(resolve4(options.applicationRoot), "DESIGN.md"), action: "error" } : { action: "skip", reason: options.designSkipReason ?? "custom_skill_path" }
|
|
2437
|
+
};
|
|
2438
|
+
try {
|
|
2439
|
+
if (options.applicationRoot) {
|
|
2440
|
+
if (typeof bundle.files[DEFAULT_DESIGN_ASSET] !== "string") {
|
|
2441
|
+
fail2("DESIGN_STARTER_MISSING", "this skill release has no Default starter; project setup requires a newer release");
|
|
2442
|
+
}
|
|
2443
|
+
const design = await inspectProjectDesign(options.applicationRoot);
|
|
2444
|
+
const relation = relative4(destination, design.path);
|
|
2445
|
+
if (!relation || !relation.startsWith(`..\\`) && !relation.startsWith("../") && relation !== ".." && !isAbsolute3(relation)) {
|
|
2446
|
+
fail2("DESIGN_PATH_INVALID", "project DESIGN.md must be outside the updater-owned skill destination");
|
|
2447
|
+
}
|
|
2448
|
+
result.design = design;
|
|
2449
|
+
}
|
|
2450
|
+
const plan = await installSkillBundle(bundle, { ...options, dryRun: true });
|
|
2451
|
+
const changes = plan.files.filter((file) => file.action !== "unchanged");
|
|
2452
|
+
const previousInstallation = plan.recoveryRequired || await exists(join15(destination, LOCAL_MANIFEST));
|
|
2453
|
+
const action = changes.length === 0 ? "preserve" : previousInstallation ? "update" : "create";
|
|
2454
|
+
result = { ...result, ...plan, skill: { path: destination, action } };
|
|
2455
|
+
if (options.dryRun) return { ...result, status: "planned" };
|
|
2456
|
+
} catch (error) {
|
|
2457
|
+
throw new SkillSetupError(error, result);
|
|
2458
|
+
}
|
|
2459
|
+
try {
|
|
2460
|
+
const installed = await installSkillBundle(bundle, options);
|
|
2461
|
+
result = { ...result, ...installed, dryRun: false, status: "partial" };
|
|
2462
|
+
delete result.recoveryRequired;
|
|
2463
|
+
} catch (error) {
|
|
2464
|
+
throw new SkillSetupError(error, {
|
|
2465
|
+
...result,
|
|
2466
|
+
dryRun: false,
|
|
2467
|
+
status: error instanceof SkillInstallError && ["SWAP_INTERRUPTED", "SWAP_RECOVERY_FAILED"].includes(error.code) ? "partial" : "failed",
|
|
2468
|
+
skill: { path: destination, action: "error" },
|
|
2469
|
+
design: options.applicationRoot ? { path: result.design.path, action: "skip", reason: "skill_install_failed" } : result.design
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
if (options.applicationRoot) {
|
|
2473
|
+
try {
|
|
2474
|
+
result.design = await createProjectDesign(options.applicationRoot, bundle.files[DEFAULT_DESIGN_ASSET]);
|
|
2475
|
+
} catch (error) {
|
|
2476
|
+
throw new SkillSetupError(error, { ...result, design: { path: result.design.path, action: "error" } });
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
return { ...result, status: "complete" };
|
|
2480
|
+
}
|
|
2481
|
+
async function fetchSkillBundle() {
|
|
2482
|
+
const base = apiBase().replace(/\/$/, "");
|
|
2483
|
+
let latest;
|
|
2484
|
+
try {
|
|
2485
|
+
latest = await fetch(`${base}/api/agent/skill/latest`, { headers: { Accept: "application/json" } });
|
|
2486
|
+
} catch {
|
|
2487
|
+
fail2("NETWORK_ERROR", "could not reach the NateUI skill service");
|
|
2488
|
+
}
|
|
2489
|
+
if (!latest.ok) fail2("NETWORK_ERROR", `skill metadata request failed (${latest.status})`);
|
|
2490
|
+
let metadata;
|
|
2491
|
+
try {
|
|
2492
|
+
metadata = await latest.json();
|
|
2493
|
+
} catch {
|
|
2494
|
+
fail2("INVALID_BUNDLE", "skill metadata was not valid JSON");
|
|
2495
|
+
}
|
|
2496
|
+
assertObject(metadata, "skill metadata");
|
|
2497
|
+
const releaseId = validateReleaseId(metadata.releaseId);
|
|
2498
|
+
let response;
|
|
2499
|
+
try {
|
|
2500
|
+
response = await fetch(`${base}/api/agent/skill/releases/${releaseId}`, { headers: { Accept: "application/json" } });
|
|
2501
|
+
} catch {
|
|
2502
|
+
fail2("NETWORK_ERROR", "could not download the NateUI skill release");
|
|
2503
|
+
}
|
|
2504
|
+
if (!response.ok) fail2("NETWORK_ERROR", `skill release request failed (${response.status})`);
|
|
2505
|
+
try {
|
|
2506
|
+
return validateSkillBundle(await response.json());
|
|
2507
|
+
} catch (error) {
|
|
2508
|
+
if (error instanceof SkillInstallError) throw error;
|
|
2509
|
+
fail2("INVALID_BUNDLE", "skill release was not valid JSON");
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
// src/commands/skill.ts
|
|
2514
|
+
var VALUE_FLAGS = ["client", "path"];
|
|
2515
|
+
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["global", "force", "dry-run", "json"]);
|
|
2516
|
+
async function runSkill(argv) {
|
|
2517
|
+
const jsonRequested = argv.includes("--json");
|
|
2518
|
+
const dryRunRequested = argv.includes("--dry-run");
|
|
2519
|
+
let flags = /* @__PURE__ */ new Set();
|
|
2520
|
+
let selected;
|
|
2521
|
+
let phase = "options";
|
|
2522
|
+
let result;
|
|
2523
|
+
try {
|
|
2524
|
+
for (const [index, arg] of argv.entries()) {
|
|
2525
|
+
if (VALUE_FLAGS.some((flag) => arg === `--${flag}`)) {
|
|
2526
|
+
const value = argv[index + 1];
|
|
2527
|
+
if (!value || value.startsWith("--")) fail(`${arg} requires a value`);
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
const parsed = parseFlags(argv, VALUE_FLAGS);
|
|
2531
|
+
flags = parsed.flags;
|
|
2532
|
+
const { values, positional } = parsed;
|
|
2533
|
+
const unknownFlags = [...flags].filter((flag) => !BOOLEAN_FLAGS.has(flag));
|
|
2534
|
+
const unknownValues = Object.keys(values).filter((flag) => !VALUE_FLAGS.includes(flag));
|
|
2535
|
+
if (unknownFlags.length > 0 || unknownValues.length > 0) fail(`unknown skill option: --${unknownFlags[0] ?? unknownValues[0]}`);
|
|
2536
|
+
if (positional.length > 0) fail(`skill does not accept positional arguments: ${positional[0]}`);
|
|
2537
|
+
for (const flag of VALUE_FLAGS) {
|
|
2538
|
+
if (values[flag] === "") fail(`--${flag} requires a value`);
|
|
2539
|
+
}
|
|
2540
|
+
if (values.client !== void 0 && !SKILL_CLIENTS.includes(values.client)) {
|
|
2541
|
+
fail(`unsupported client '${values.client}'`);
|
|
2542
|
+
}
|
|
2543
|
+
if (!values.path && flags.has("global") && !values.client) fail("--global requires --client");
|
|
2544
|
+
phase = "destination";
|
|
2545
|
+
selected = await resolveSkillDestination({
|
|
2546
|
+
client: values.client,
|
|
2547
|
+
global: flags.has("global"),
|
|
2548
|
+
path: values.path
|
|
2549
|
+
});
|
|
2550
|
+
phase = "bundle";
|
|
2551
|
+
const bundle = await fetchSkillBundle();
|
|
2552
|
+
result = await installSkillSetup(bundle, {
|
|
2553
|
+
destination: selected.destination,
|
|
2554
|
+
...flags.has("global") ? { designSkipReason: "global_install" } : values.path ? { designSkipReason: "custom_skill_path" } : { applicationRoot: process.cwd() },
|
|
2555
|
+
force: flags.has("force"),
|
|
2556
|
+
dryRun: flags.has("dry-run")
|
|
2557
|
+
});
|
|
2558
|
+
} catch (error) {
|
|
2559
|
+
if (jsonRequested) {
|
|
2560
|
+
const failure = error instanceof SkillSetupError ? error.result : {
|
|
2561
|
+
status: "failed",
|
|
2562
|
+
destination: selected?.destination ?? null,
|
|
2563
|
+
dryRun: dryRunRequested,
|
|
2564
|
+
skill: { path: selected?.destination ?? null, action: "skip" },
|
|
2565
|
+
design: { action: "skip", reason: phase === "options" ? "invalid_options" : phase === "destination" ? "destination_not_selected" : "bundle_unavailable" },
|
|
2566
|
+
error: { code: error instanceof SkillInstallError ? error.code : phase === "options" ? "INVALID_OPTIONS" : "SETUP_FAILED", message: error instanceof Error ? error.message : String(error) }
|
|
2567
|
+
};
|
|
2568
|
+
process.stdout.write(`${JSON.stringify({ ...failure, client: selected?.client ?? null }, null, 2)}
|
|
2569
|
+
`);
|
|
2570
|
+
} else if (error instanceof SkillSetupError && error.result.status === "partial") {
|
|
2571
|
+
console.error(`nateui: partial setup at ${error.result.destination}; project DESIGN.md was not completed`);
|
|
2572
|
+
if (error.result.design.action === "error") console.error(`nateui: skill installed; inspect ${error.result.design.path} and rerun after resolving the write failure`);
|
|
2573
|
+
}
|
|
2574
|
+
throw error;
|
|
2575
|
+
}
|
|
2576
|
+
if (jsonRequested) {
|
|
2577
|
+
process.stdout.write(`${JSON.stringify({ ...result, client: selected.client }, null, 2)}
|
|
2578
|
+
`);
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2581
|
+
const changed = result.files.filter((file) => file.action !== "unchanged").length;
|
|
2582
|
+
const verb = result.dryRun ? "would update" : changed === 0 ? "already current" : "updated";
|
|
2583
|
+
console.error(`nateui: ${verb} ${result.destination} (${result.releaseId}; ${changed} file change${changed === 1 ? "" : "s"})`);
|
|
2584
|
+
if (result.recovered) console.error("nateui: recovered an interrupted previous skill install");
|
|
2585
|
+
if (result.recoveryRequired) console.error("nateui: would recover an interrupted previous skill install");
|
|
2586
|
+
if (result.design.action === "skip") {
|
|
2587
|
+
const starterHint = result.files.some((file) => file.path === DEFAULT_DESIGN_ASSET) ? "; copy assets/default-design.md to the application root deliberately if needed" : "; this release has no Default starter";
|
|
2588
|
+
console.error(`nateui: DESIGN.md skipped (${result.design.reason === "global_install" ? "global installation" : "custom skill destination"})${starterHint}`);
|
|
2589
|
+
} else {
|
|
2590
|
+
const designVerb = result.design.action === "preserve" ? "preserved existing" : result.dryRun ? "would create" : "created";
|
|
2591
|
+
console.error(`nateui: ${designVerb} ${result.design.path}`);
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
// src/main.ts
|
|
2596
|
+
var VERBS = {
|
|
2597
|
+
init: runInit,
|
|
2598
|
+
"init-template": runInitTemplate,
|
|
2599
|
+
add: runAdd,
|
|
2600
|
+
"add-page": runAddPage,
|
|
2601
|
+
"remove-page": runRemovePage,
|
|
2602
|
+
list: runList,
|
|
2603
|
+
describe: runDescribe,
|
|
2604
|
+
login: runLogin,
|
|
2605
|
+
skill: runSkill
|
|
2606
|
+
};
|
|
2607
|
+
async function main() {
|
|
2608
|
+
const [verb, ...rest] = process.argv.slice(2);
|
|
2609
|
+
const handler = VERBS[verb];
|
|
2610
|
+
if (!handler) {
|
|
2611
|
+
printError(`unknown command '${verb ?? ""}' \u2014 one of: ${Object.keys(VERBS).join(", ")}`);
|
|
2612
|
+
process.exit(1);
|
|
2613
|
+
}
|
|
2614
|
+
await handler(rest);
|
|
2615
|
+
}
|
|
2616
|
+
function closeFetchPool() {
|
|
2617
|
+
try {
|
|
2618
|
+
const dispatcher = globalThis[/* @__PURE__ */ Symbol.for("undici.globalDispatcher.1")];
|
|
2619
|
+
void dispatcher?.close?.();
|
|
2620
|
+
} catch {
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
main().catch((err) => {
|
|
2624
|
+
if (err instanceof NateuiError) {
|
|
2625
|
+
printError(err.message);
|
|
2626
|
+
} else {
|
|
2627
|
+
printError(err instanceof Error ? err.message : String(err));
|
|
2628
|
+
}
|
|
2629
|
+
process.exitCode = 1;
|
|
2630
|
+
closeFetchPool();
|
|
2631
|
+
});
|