@gntik-ai/cli 0.0.0-stage → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -2
- package/dist/index.js +1712 -0
- package/package.json +33 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,1712 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
//#region package.json
|
|
10
|
+
var version = "0.5.0";
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/errors.ts
|
|
13
|
+
/** Machine-readable error codes and the exit code each one maps to. */
|
|
14
|
+
const EXIT_CODES = {
|
|
15
|
+
OK: 0,
|
|
16
|
+
ERROR: 1,
|
|
17
|
+
REGISTRY: 1,
|
|
18
|
+
INSTALL: 1,
|
|
19
|
+
CODEMODS: 1,
|
|
20
|
+
USAGE: 2,
|
|
21
|
+
NOT_FOUND: 3,
|
|
22
|
+
CONFLICT: 4
|
|
23
|
+
};
|
|
24
|
+
var CliError = class extends Error {
|
|
25
|
+
code;
|
|
26
|
+
constructor(code, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.code = code;
|
|
29
|
+
}
|
|
30
|
+
get exitCode() {
|
|
31
|
+
return EXIT_CODES[this.code];
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/args.ts
|
|
36
|
+
const COMMANDS = [
|
|
37
|
+
"init",
|
|
38
|
+
"add",
|
|
39
|
+
"eject",
|
|
40
|
+
"list",
|
|
41
|
+
"search",
|
|
42
|
+
"docs",
|
|
43
|
+
"upgrade"
|
|
44
|
+
];
|
|
45
|
+
const OPTIONS = {
|
|
46
|
+
json: { type: "boolean" },
|
|
47
|
+
cwd: { type: "string" },
|
|
48
|
+
registry: {
|
|
49
|
+
type: "string",
|
|
50
|
+
short: "r"
|
|
51
|
+
},
|
|
52
|
+
"dry-run": { type: "boolean" },
|
|
53
|
+
yes: {
|
|
54
|
+
type: "boolean",
|
|
55
|
+
short: "y"
|
|
56
|
+
},
|
|
57
|
+
overwrite: { type: "boolean" },
|
|
58
|
+
"no-install": { type: "boolean" },
|
|
59
|
+
"no-deps": { type: "boolean" },
|
|
60
|
+
kind: {
|
|
61
|
+
type: "string",
|
|
62
|
+
short: "k"
|
|
63
|
+
},
|
|
64
|
+
group: {
|
|
65
|
+
type: "string",
|
|
66
|
+
short: "g"
|
|
67
|
+
},
|
|
68
|
+
brand: { type: "string" },
|
|
69
|
+
limit: { type: "string" },
|
|
70
|
+
from: { type: "string" },
|
|
71
|
+
to: { type: "string" },
|
|
72
|
+
codemod: {
|
|
73
|
+
type: "string",
|
|
74
|
+
multiple: true
|
|
75
|
+
},
|
|
76
|
+
"skip-codemod": {
|
|
77
|
+
type: "string",
|
|
78
|
+
multiple: true
|
|
79
|
+
},
|
|
80
|
+
list: { type: "boolean" },
|
|
81
|
+
apply: { type: "boolean" },
|
|
82
|
+
help: {
|
|
83
|
+
type: "boolean",
|
|
84
|
+
short: "h"
|
|
85
|
+
},
|
|
86
|
+
version: {
|
|
87
|
+
type: "boolean",
|
|
88
|
+
short: "v"
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
/** Flags each command accepts on top of the global ones (--json --cwd --registry --help --yes). */
|
|
92
|
+
const COMMAND_FLAGS = {
|
|
93
|
+
init: [
|
|
94
|
+
"dry-run",
|
|
95
|
+
"overwrite",
|
|
96
|
+
"no-install",
|
|
97
|
+
"brand"
|
|
98
|
+
],
|
|
99
|
+
add: [
|
|
100
|
+
"dry-run",
|
|
101
|
+
"overwrite",
|
|
102
|
+
"no-install"
|
|
103
|
+
],
|
|
104
|
+
eject: [
|
|
105
|
+
"dry-run",
|
|
106
|
+
"overwrite",
|
|
107
|
+
"no-install",
|
|
108
|
+
"no-deps"
|
|
109
|
+
],
|
|
110
|
+
list: ["kind", "group"],
|
|
111
|
+
search: ["kind", "limit"],
|
|
112
|
+
docs: [],
|
|
113
|
+
upgrade: [
|
|
114
|
+
"dry-run",
|
|
115
|
+
"from",
|
|
116
|
+
"to",
|
|
117
|
+
"codemod",
|
|
118
|
+
"skip-codemod",
|
|
119
|
+
"list",
|
|
120
|
+
"apply"
|
|
121
|
+
]
|
|
122
|
+
};
|
|
123
|
+
const GLOBAL_FLAGS = [
|
|
124
|
+
"json",
|
|
125
|
+
"cwd",
|
|
126
|
+
"registry",
|
|
127
|
+
"help",
|
|
128
|
+
"version",
|
|
129
|
+
"yes"
|
|
130
|
+
];
|
|
131
|
+
function parseCliArgs(argv) {
|
|
132
|
+
let parsed;
|
|
133
|
+
try {
|
|
134
|
+
parsed = parseArgs({
|
|
135
|
+
args: argv,
|
|
136
|
+
options: OPTIONS,
|
|
137
|
+
allowPositionals: true,
|
|
138
|
+
strict: true
|
|
139
|
+
});
|
|
140
|
+
} catch (e) {
|
|
141
|
+
throw new CliError("USAGE", e.message);
|
|
142
|
+
}
|
|
143
|
+
const { values, positionals } = parsed;
|
|
144
|
+
const [first, ...rest] = positionals;
|
|
145
|
+
let command;
|
|
146
|
+
if (first !== void 0) {
|
|
147
|
+
if (!COMMANDS.includes(first)) throw new CliError("USAGE", `Unknown command "${first}". Run gntik-ui --help.`);
|
|
148
|
+
command = first;
|
|
149
|
+
const allowed = /* @__PURE__ */ new Set([...GLOBAL_FLAGS, ...COMMAND_FLAGS[command]]);
|
|
150
|
+
for (const key of Object.keys(values)) if (!allowed.has(key)) throw new CliError("USAGE", `Option --${key} is not valid for "${command}".`);
|
|
151
|
+
}
|
|
152
|
+
let limit;
|
|
153
|
+
if (values.limit !== void 0) {
|
|
154
|
+
limit = Number(values.limit);
|
|
155
|
+
if (!Number.isInteger(limit) || limit < 1) throw new CliError("USAGE", "--limit must be a positive integer");
|
|
156
|
+
}
|
|
157
|
+
if (values.apply && values["dry-run"]) throw new CliError("USAGE", "--apply and --dry-run cannot be combined");
|
|
158
|
+
return {
|
|
159
|
+
command,
|
|
160
|
+
positionals: command ? rest : positionals,
|
|
161
|
+
flags: {
|
|
162
|
+
json: values.json ?? false,
|
|
163
|
+
cwd: values.cwd,
|
|
164
|
+
registry: values.registry,
|
|
165
|
+
dryRun: values["dry-run"] ?? false,
|
|
166
|
+
yes: values.yes ?? false,
|
|
167
|
+
overwrite: values.overwrite ?? false,
|
|
168
|
+
install: !values["no-install"],
|
|
169
|
+
deps: !values["no-deps"],
|
|
170
|
+
kind: values.kind,
|
|
171
|
+
group: values.group,
|
|
172
|
+
brand: values.brand,
|
|
173
|
+
limit,
|
|
174
|
+
from: values.from,
|
|
175
|
+
to: values.to,
|
|
176
|
+
codemods: (values.codemod ?? []).flatMap(splitList),
|
|
177
|
+
skipCodemods: (values["skip-codemod"] ?? []).flatMap(splitList),
|
|
178
|
+
list: values.list ?? false,
|
|
179
|
+
apply: values.apply ?? false,
|
|
180
|
+
help: values.help ?? false,
|
|
181
|
+
version: values.version ?? false
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** `--codemod a,b --codemod c` → ['a', 'b', 'c']. */
|
|
186
|
+
function splitList(value) {
|
|
187
|
+
return value.split(",").map((v) => v.trim()).filter(Boolean);
|
|
188
|
+
}
|
|
189
|
+
//#endregion
|
|
190
|
+
//#region src/project.ts
|
|
191
|
+
const CONFIG_FILE = "gntik-ui.json";
|
|
192
|
+
const VITE_CONFIGS = [
|
|
193
|
+
"ts",
|
|
194
|
+
"mts",
|
|
195
|
+
"js",
|
|
196
|
+
"mjs",
|
|
197
|
+
"cjs",
|
|
198
|
+
"cts"
|
|
199
|
+
].map((e) => `vite.config.${e}`);
|
|
200
|
+
const NEXT_CONFIGS = [
|
|
201
|
+
"ts",
|
|
202
|
+
"mjs",
|
|
203
|
+
"js",
|
|
204
|
+
"cjs"
|
|
205
|
+
].map((e) => `next.config.${e}`);
|
|
206
|
+
const CSS_CANDIDATES = [
|
|
207
|
+
"src/index.css",
|
|
208
|
+
"src/styles.css",
|
|
209
|
+
"src/globals.css",
|
|
210
|
+
"src/app.css",
|
|
211
|
+
"src/main.css",
|
|
212
|
+
"src/styles/globals.css",
|
|
213
|
+
"src/app/globals.css",
|
|
214
|
+
"app/globals.css",
|
|
215
|
+
"styles/globals.css",
|
|
216
|
+
"styles.css"
|
|
217
|
+
];
|
|
218
|
+
const LOCKFILES = [
|
|
219
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
220
|
+
["bun.lock", "bun"],
|
|
221
|
+
["bun.lockb", "bun"],
|
|
222
|
+
["yarn.lock", "yarn"],
|
|
223
|
+
["package-lock.json", "npm"]
|
|
224
|
+
];
|
|
225
|
+
function readJson(file) {
|
|
226
|
+
if (!existsSync(file)) return null;
|
|
227
|
+
try {
|
|
228
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
229
|
+
} catch {
|
|
230
|
+
throw new CliError("ERROR", `${file} is not valid JSON`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function detectPackageManager(cwd, pkg) {
|
|
234
|
+
let dir = cwd;
|
|
235
|
+
for (;;) {
|
|
236
|
+
for (const [file, pm] of LOCKFILES) if (existsSync(path.join(dir, file))) return pm;
|
|
237
|
+
const parent = path.dirname(dir);
|
|
238
|
+
if (parent === dir) break;
|
|
239
|
+
dir = parent;
|
|
240
|
+
}
|
|
241
|
+
const declared = pkg?.packageManager?.split("@")[0];
|
|
242
|
+
if (declared === "pnpm" || declared === "yarn" || declared === "bun" || declared === "npm") return declared;
|
|
243
|
+
return "npm";
|
|
244
|
+
}
|
|
245
|
+
function detectProject(cwd) {
|
|
246
|
+
const has = (f) => existsSync(path.join(cwd, f));
|
|
247
|
+
const packageJson = readJson(path.join(cwd, "package.json"));
|
|
248
|
+
const deps = {
|
|
249
|
+
...packageJson?.dependencies,
|
|
250
|
+
...packageJson?.devDependencies
|
|
251
|
+
};
|
|
252
|
+
let framework = "generic";
|
|
253
|
+
if (NEXT_CONFIGS.some(has) || "next" in deps) framework = "next";
|
|
254
|
+
else if (VITE_CONFIGS.some(has) || "vite" in deps) framework = "vite";
|
|
255
|
+
const src = has("src");
|
|
256
|
+
const css = CSS_CANDIDATES.find(has) ?? null;
|
|
257
|
+
let cssDefault = "src/index.css";
|
|
258
|
+
let entry = [
|
|
259
|
+
"src/main.tsx",
|
|
260
|
+
"src/main.jsx",
|
|
261
|
+
"src/main.ts",
|
|
262
|
+
"src/main.js",
|
|
263
|
+
"src/index.tsx"
|
|
264
|
+
].find(has) ?? "src/main.tsx";
|
|
265
|
+
if (framework === "next") {
|
|
266
|
+
const appDir = has("src/app") || src && !has("app") ? "src/app" : "app";
|
|
267
|
+
cssDefault = `${appDir}/globals.css`;
|
|
268
|
+
entry = [
|
|
269
|
+
"tsx",
|
|
270
|
+
"jsx",
|
|
271
|
+
"js"
|
|
272
|
+
].map((e) => `${appDir}/layout.${e}`).find(has) ?? `${appDir}/layout.tsx`;
|
|
273
|
+
} else if (!src) cssDefault = "index.css";
|
|
274
|
+
return {
|
|
275
|
+
cwd,
|
|
276
|
+
framework,
|
|
277
|
+
packageManager: detectPackageManager(cwd, packageJson),
|
|
278
|
+
css,
|
|
279
|
+
cssDefault,
|
|
280
|
+
src,
|
|
281
|
+
entry,
|
|
282
|
+
packageJson
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function defaultConfig(info, brand = "gntik") {
|
|
286
|
+
const root = info.src ? "src/" : "";
|
|
287
|
+
return {
|
|
288
|
+
version: 1,
|
|
289
|
+
framework: info.framework,
|
|
290
|
+
packageManager: info.packageManager,
|
|
291
|
+
css: info.css ?? info.cssDefault,
|
|
292
|
+
brand,
|
|
293
|
+
aliases: {
|
|
294
|
+
components: `${root}components`,
|
|
295
|
+
blocks: `${root}components/blocks`,
|
|
296
|
+
templates: `${root}components/templates`
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
function loadConfig(cwd) {
|
|
301
|
+
const cfg = readJson(path.join(cwd, CONFIG_FILE));
|
|
302
|
+
if (!cfg) return null;
|
|
303
|
+
if (!cfg.aliases?.components) throw new CliError("ERROR", `${CONFIG_FILE} is missing aliases.components`);
|
|
304
|
+
return cfg;
|
|
305
|
+
}
|
|
306
|
+
/** Maps a registry target into the project via the config aliases (project-relative, posix). */
|
|
307
|
+
function mapTarget(target, kind, config) {
|
|
308
|
+
const { aliases } = config;
|
|
309
|
+
const join = (dir, rest) => path.posix.join(dir || ".", rest);
|
|
310
|
+
if (target.startsWith("components/")) return join(aliases.components, target.slice(11));
|
|
311
|
+
if (target.startsWith("blocks/")) return join(aliases.blocks, target.slice(7));
|
|
312
|
+
if (target.startsWith("templates/")) return join(aliases.templates, target.slice(10));
|
|
313
|
+
return join(kind === "block" ? aliases.blocks : kind === "template" ? aliases.templates : aliases.components, target);
|
|
314
|
+
}
|
|
315
|
+
function installedPackages(pkg) {
|
|
316
|
+
return /* @__PURE__ */ new Set([...Object.keys(pkg?.dependencies ?? {}), ...Object.keys(pkg?.devDependencies ?? {})]);
|
|
317
|
+
}
|
|
318
|
+
function installCommand(pm, packages, dev = false) {
|
|
319
|
+
return [
|
|
320
|
+
pm,
|
|
321
|
+
pm === "npm" ? "install" : "add",
|
|
322
|
+
...dev ? ["-D"] : [],
|
|
323
|
+
...packages
|
|
324
|
+
];
|
|
325
|
+
}
|
|
326
|
+
//#endregion
|
|
327
|
+
//#region src/copy.ts
|
|
328
|
+
const posix = path.posix;
|
|
329
|
+
const CODE_EXT = /\.(tsx?|jsx?|mts|cts|mjs|cjs)$/;
|
|
330
|
+
/** Packages a React project always has; never auto-installed. */
|
|
331
|
+
const IMPLICIT = /* @__PURE__ */ new Set(["react", "react-dom"]);
|
|
332
|
+
const stripExt = (p) => p.replace(CODE_EXT, "");
|
|
333
|
+
/** Bare specifier → package name ('@scope/pkg/sub' → '@scope/pkg'). */
|
|
334
|
+
function packageName(spec) {
|
|
335
|
+
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("node:") || spec.startsWith("#")) return null;
|
|
336
|
+
const parts = spec.split("/");
|
|
337
|
+
return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0] ?? null;
|
|
338
|
+
}
|
|
339
|
+
/** Every module specifier in a source file, with its position. */
|
|
340
|
+
const SPEC_RE = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)(['"])([^'"\n]+)\2/g;
|
|
341
|
+
function rewriteSpecifiers(code, fn) {
|
|
342
|
+
return code.replace(SPEC_RE, (m, lead, q, spec) => {
|
|
343
|
+
const next = fn(spec);
|
|
344
|
+
return next === spec ? m : `${lead}${q}${next}${q}`;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
function buildIndex(registry) {
|
|
348
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
349
|
+
const pkgByDir = /* @__PURE__ */ new Map();
|
|
350
|
+
for (const item of registry.items) for (const f of [...item.files.map((x) => x.path), ...item.docs ? [item.docs] : []]) {
|
|
351
|
+
const key = stripExt(f);
|
|
352
|
+
if (!byKey.has(key)) byKey.set(key, {
|
|
353
|
+
item,
|
|
354
|
+
path: f,
|
|
355
|
+
viaDir: false
|
|
356
|
+
});
|
|
357
|
+
if (posix.basename(key) === "index") {
|
|
358
|
+
const dir = posix.dirname(key);
|
|
359
|
+
if (!byKey.has(dir)) byKey.set(dir, {
|
|
360
|
+
item,
|
|
361
|
+
path: f,
|
|
362
|
+
viaDir: true
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const m = /^packages\/([^/]+)\//.exec(f);
|
|
366
|
+
if (m?.[1] && !pkgByDir.has(m[1])) pkgByDir.set(m[1], item.package);
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
byKey,
|
|
370
|
+
pkgByDir
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
/** Target for an item's doc file: next to the item's file that shares its source folder. */
|
|
374
|
+
function docsTarget(item, config) {
|
|
375
|
+
if (!item.docs) return null;
|
|
376
|
+
const dir = posix.dirname(item.docs);
|
|
377
|
+
const sibling = item.files.find((f) => posix.dirname(f.path) === dir) ?? item.files[0];
|
|
378
|
+
if (!sibling) return null;
|
|
379
|
+
return posix.join(posix.dirname(mapTarget(sibling.target, item.kind, config)), posix.basename(item.docs));
|
|
380
|
+
}
|
|
381
|
+
function relSpec(fromFile, toFile, keepExt, asDir) {
|
|
382
|
+
let to = asDir ? posix.dirname(toFile) : stripExt(toFile);
|
|
383
|
+
if (keepExt && !asDir) to += keepExt;
|
|
384
|
+
let rel = posix.relative(posix.dirname(fromFile), to);
|
|
385
|
+
if (!rel.startsWith(".")) rel = `./${rel}`;
|
|
386
|
+
return rel;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Reads the items' files from the registry, maps them to project targets and rewrites imports:
|
|
390
|
+
* relative imports between copied files are re-pointed at the new locations; relative imports
|
|
391
|
+
* into kit code that is not copied become package imports (e.g. '../../components/Button' →
|
|
392
|
+
* '@gntik-ai/ui'). Nothing is written here.
|
|
393
|
+
*/
|
|
394
|
+
async function planCopy(registry, items, config, cwd, opts = {}) {
|
|
395
|
+
const { byKey, pkgByDir } = buildIndex(registry);
|
|
396
|
+
const copyMap = /* @__PURE__ */ new Map();
|
|
397
|
+
const sources = [];
|
|
398
|
+
for (const item of items) {
|
|
399
|
+
for (const f of item.files) {
|
|
400
|
+
copyMap.set(f.path, mapTarget(f.target, item.kind, config));
|
|
401
|
+
sources.push({
|
|
402
|
+
item,
|
|
403
|
+
source: f.path
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
const dt = opts.includeDocs ? docsTarget(item, config) : null;
|
|
407
|
+
if (dt && item.docs) {
|
|
408
|
+
copyMap.set(item.docs, dt);
|
|
409
|
+
sources.push({
|
|
410
|
+
item,
|
|
411
|
+
source: item.docs
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const rewrites = [];
|
|
416
|
+
const warnings = [];
|
|
417
|
+
const packages = /* @__PURE__ */ new Set();
|
|
418
|
+
const files = [];
|
|
419
|
+
for (const { item, source } of sources) {
|
|
420
|
+
const target = copyMap.get(source);
|
|
421
|
+
let content = await registry.read(source);
|
|
422
|
+
if (CODE_EXT.test(source)) content = rewriteSpecifiers(content, (spec) => {
|
|
423
|
+
const bare = packageName(spec);
|
|
424
|
+
if (bare) {
|
|
425
|
+
if (!IMPLICIT.has(bare)) packages.add(bare);
|
|
426
|
+
return spec;
|
|
427
|
+
}
|
|
428
|
+
if (!spec.startsWith(".")) return spec;
|
|
429
|
+
const extMatch = CODE_EXT.exec(spec);
|
|
430
|
+
const resolved = posix.normalize(posix.join(posix.dirname(source), stripExt(spec)));
|
|
431
|
+
const hit = byKey.get(resolved);
|
|
432
|
+
let next = spec;
|
|
433
|
+
const copiedTarget = hit ? copyMap.get(hit.path) : void 0;
|
|
434
|
+
if (hit && copiedTarget) next = relSpec(target, copiedTarget, extMatch?.[0] ?? null, hit.viaDir);
|
|
435
|
+
else if (hit) next = hit.item.package;
|
|
436
|
+
else {
|
|
437
|
+
const dir = /^packages\/([^/]+)\//.exec(resolved)?.[1];
|
|
438
|
+
if (dir) {
|
|
439
|
+
next = pkgByDir.get(dir) ?? `@gntik-ai/${dir}`;
|
|
440
|
+
if (source !== item.docs) warnings.push(`${target}: '${spec}' is kit-internal code outside the registry; mapped to '${next}' — check that it is exported there.`);
|
|
441
|
+
} else warnings.push(`${target}: could not resolve '${spec}'; left unchanged.`);
|
|
442
|
+
}
|
|
443
|
+
if (next !== spec) {
|
|
444
|
+
rewrites.push({
|
|
445
|
+
file: target,
|
|
446
|
+
from: spec,
|
|
447
|
+
to: next
|
|
448
|
+
});
|
|
449
|
+
const pkg = packageName(next);
|
|
450
|
+
if (pkg) packages.add(pkg);
|
|
451
|
+
}
|
|
452
|
+
return next;
|
|
453
|
+
});
|
|
454
|
+
const abs = path.join(cwd, target);
|
|
455
|
+
let status = "create";
|
|
456
|
+
if (existsSync(abs)) status = await readFile(abs, "utf8") === content ? "identical" : "conflict";
|
|
457
|
+
files.push({
|
|
458
|
+
id: item.id,
|
|
459
|
+
source,
|
|
460
|
+
target,
|
|
461
|
+
status,
|
|
462
|
+
content
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
files,
|
|
467
|
+
rewrites,
|
|
468
|
+
warnings,
|
|
469
|
+
packages: [...packages].sort()
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
/** Writes the plan unless it has conflicts (then nothing is written) or this is a dry run. */
|
|
473
|
+
async function applyCopy(plan, cwd, opts) {
|
|
474
|
+
if (opts.overwrite) {
|
|
475
|
+
for (const f of plan.files) if (f.status === "conflict") f.status = "overwrite";
|
|
476
|
+
}
|
|
477
|
+
const conflicts = plan.files.filter((f) => f.status === "conflict").map((f) => f.target);
|
|
478
|
+
const written = [];
|
|
479
|
+
if (conflicts.length || opts.dryRun) return {
|
|
480
|
+
conflicts,
|
|
481
|
+
written
|
|
482
|
+
};
|
|
483
|
+
for (const f of plan.files) {
|
|
484
|
+
if (f.status === "identical") continue;
|
|
485
|
+
const abs = path.join(cwd, f.target);
|
|
486
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
487
|
+
await writeFile(abs, f.content);
|
|
488
|
+
written.push(f.target);
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
conflicts,
|
|
492
|
+
written
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
const fileSummary = (f) => ({
|
|
496
|
+
id: f.id,
|
|
497
|
+
source: f.source,
|
|
498
|
+
target: f.target,
|
|
499
|
+
status: f.status
|
|
500
|
+
});
|
|
501
|
+
const REGISTRY_FILE = "kit-registry.json";
|
|
502
|
+
const ITEM_KINDS = [
|
|
503
|
+
"component",
|
|
504
|
+
"layout",
|
|
505
|
+
"block",
|
|
506
|
+
"template"
|
|
507
|
+
];
|
|
508
|
+
/** Copy-in kinds: their source is copied into the project. Components and layouts install from npm. */
|
|
509
|
+
const isCopyIn = (kind) => kind === "block" || kind === "template";
|
|
510
|
+
var Registry = class {
|
|
511
|
+
data;
|
|
512
|
+
source;
|
|
513
|
+
byId = /* @__PURE__ */ new Map();
|
|
514
|
+
constructor(data, source) {
|
|
515
|
+
this.data = data;
|
|
516
|
+
this.source = source;
|
|
517
|
+
for (const item of data.items) this.byId.set(item.id, item);
|
|
518
|
+
}
|
|
519
|
+
get items() {
|
|
520
|
+
return this.data.items;
|
|
521
|
+
}
|
|
522
|
+
get(id) {
|
|
523
|
+
return this.byId.get(id);
|
|
524
|
+
}
|
|
525
|
+
/** Reads a repo-relative file from the registry source. */
|
|
526
|
+
async read(file) {
|
|
527
|
+
if (this.source.type === "local") try {
|
|
528
|
+
return await readFile(path.join(this.source.base, file), "utf8");
|
|
529
|
+
} catch {
|
|
530
|
+
throw new CliError("REGISTRY", `Cannot read ${file} from ${this.source.base}`);
|
|
531
|
+
}
|
|
532
|
+
return fetchText(new URL(file, this.source.base).href);
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Requested ids plus their registryDependencies, recursively; dependencies come before
|
|
536
|
+
* the items that need them. Unknown ids throw NOT_FOUND.
|
|
537
|
+
*/
|
|
538
|
+
closure(ids) {
|
|
539
|
+
const out = [];
|
|
540
|
+
const seen = /* @__PURE__ */ new Set();
|
|
541
|
+
const visit = (id, from) => {
|
|
542
|
+
if (seen.has(id)) return;
|
|
543
|
+
seen.add(id);
|
|
544
|
+
const item = this.get(id);
|
|
545
|
+
if (!item) throw new CliError("NOT_FOUND", from ? `Unknown registry dependency "${id}" (needed by ${from})` : `Unknown item "${id}"`);
|
|
546
|
+
for (const dep of item.registryDependencies) visit(dep, id);
|
|
547
|
+
out.push(item);
|
|
548
|
+
};
|
|
549
|
+
for (const id of ids) visit(id);
|
|
550
|
+
return out;
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
async function fetchText(url) {
|
|
554
|
+
let res;
|
|
555
|
+
try {
|
|
556
|
+
res = await fetch(url);
|
|
557
|
+
} catch (e) {
|
|
558
|
+
throw new CliError("REGISTRY", `Cannot fetch ${url}: ${e.message}`);
|
|
559
|
+
}
|
|
560
|
+
if (!res.ok) throw new CliError("REGISTRY", `Cannot fetch ${url}: HTTP ${res.status}`);
|
|
561
|
+
return res.text();
|
|
562
|
+
}
|
|
563
|
+
const isUrl = (s) => /^https?:\/\//i.test(s);
|
|
564
|
+
/** Resolves `--registry` (a checkout dir, a kit-registry.json path or a base URL) to a source. */
|
|
565
|
+
async function resolveSource(spec, cwd) {
|
|
566
|
+
if (isUrl(spec)) {
|
|
567
|
+
if (spec.endsWith(".json")) {
|
|
568
|
+
const u = new URL(spec);
|
|
569
|
+
return {
|
|
570
|
+
source: {
|
|
571
|
+
type: "url",
|
|
572
|
+
base: new URL("./", u).href
|
|
573
|
+
},
|
|
574
|
+
file: u.href
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
const base = spec.endsWith("/") ? spec : `${spec}/`;
|
|
578
|
+
return {
|
|
579
|
+
source: {
|
|
580
|
+
type: "url",
|
|
581
|
+
base
|
|
582
|
+
},
|
|
583
|
+
file: new URL(REGISTRY_FILE, base).href
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
const abs = path.resolve(cwd, spec);
|
|
587
|
+
const st = await stat(abs).catch(() => null);
|
|
588
|
+
if (!st) throw new CliError("REGISTRY", `Registry path not found: ${abs}`);
|
|
589
|
+
if (st.isFile()) return {
|
|
590
|
+
source: {
|
|
591
|
+
type: "local",
|
|
592
|
+
base: path.dirname(abs)
|
|
593
|
+
},
|
|
594
|
+
file: abs
|
|
595
|
+
};
|
|
596
|
+
return {
|
|
597
|
+
source: {
|
|
598
|
+
type: "local",
|
|
599
|
+
base: abs
|
|
600
|
+
},
|
|
601
|
+
file: path.join(abs, REGISTRY_FILE)
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
function validateRegistry(data, where) {
|
|
605
|
+
const fail = (msg) => {
|
|
606
|
+
throw new CliError("REGISTRY", `Invalid registry ${where}: ${msg}`);
|
|
607
|
+
};
|
|
608
|
+
if (!data || typeof data !== "object") fail("not an object");
|
|
609
|
+
const d = data;
|
|
610
|
+
if (d.version !== 1) fail(`unsupported version ${String(d.version)}`);
|
|
611
|
+
if (!Array.isArray(d.items)) fail("\"items\" must be an array");
|
|
612
|
+
for (const [i, item] of d.items.entries()) {
|
|
613
|
+
if (!item || typeof item.id !== "string") fail(`items[${i}].id missing`);
|
|
614
|
+
if (!ITEM_KINDS.includes(item.kind)) fail(`items[${i}] (${item.id}) has unknown kind "${String(item.kind)}"`);
|
|
615
|
+
if (!Array.isArray(item.files)) fail(`items[${i}] (${item.id}) has no files array`);
|
|
616
|
+
item.dependencies ??= [];
|
|
617
|
+
item.registryDependencies ??= [];
|
|
618
|
+
item.group ??= "";
|
|
619
|
+
item.status ??= "stable";
|
|
620
|
+
item.description ??= "";
|
|
621
|
+
}
|
|
622
|
+
return d;
|
|
623
|
+
}
|
|
624
|
+
async function loadRegistry(spec, cwd) {
|
|
625
|
+
const { source, file } = await resolveSource(spec, cwd);
|
|
626
|
+
let text;
|
|
627
|
+
if (source.type === "local") text = await readFile(file, "utf8").catch(() => {
|
|
628
|
+
throw new CliError("REGISTRY", `No ${REGISTRY_FILE} at ${file}`);
|
|
629
|
+
});
|
|
630
|
+
else text = await fetchText(file);
|
|
631
|
+
let json;
|
|
632
|
+
try {
|
|
633
|
+
json = JSON.parse(text);
|
|
634
|
+
} catch {
|
|
635
|
+
throw new CliError("REGISTRY", `${file} is not valid JSON`);
|
|
636
|
+
}
|
|
637
|
+
return new Registry(validateRegistry(json, file), source);
|
|
638
|
+
}
|
|
639
|
+
/** Short, stable item summary used by list/search JSON. */
|
|
640
|
+
function summary(item) {
|
|
641
|
+
return {
|
|
642
|
+
id: item.id,
|
|
643
|
+
kind: item.kind,
|
|
644
|
+
name: item.name,
|
|
645
|
+
package: item.package,
|
|
646
|
+
group: item.group,
|
|
647
|
+
status: item.status,
|
|
648
|
+
description: item.description
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
//#endregion
|
|
652
|
+
//#region src/context.ts
|
|
653
|
+
const defaultExec = (cmd, args, { cwd, quiet }) => {
|
|
654
|
+
const r = spawnSync(cmd, args, {
|
|
655
|
+
cwd,
|
|
656
|
+
stdio: quiet ? "pipe" : "inherit",
|
|
657
|
+
shell: process.platform === "win32"
|
|
658
|
+
});
|
|
659
|
+
if (r.error) return 127;
|
|
660
|
+
return r.status ?? 1;
|
|
661
|
+
};
|
|
662
|
+
const ok = (data, text) => ({
|
|
663
|
+
code: 0,
|
|
664
|
+
data,
|
|
665
|
+
text
|
|
666
|
+
});
|
|
667
|
+
function registrySpec(ctx, configured) {
|
|
668
|
+
return ctx.flags.registry ?? ctx.env.GNTIK_UI_REGISTRY ?? configured ?? "https://raw.githubusercontent.com/gntik-ai/gntik-ui/main/";
|
|
669
|
+
}
|
|
670
|
+
async function openRegistry(ctx, configured) {
|
|
671
|
+
return loadRegistry(registrySpec(ctx, configured), ctx.invocationCwd);
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Installs the packages the project does not declare yet. Runs only when installing is allowed
|
|
675
|
+
* (no --dry-run, no --no-install); otherwise reports the commands.
|
|
676
|
+
*/
|
|
677
|
+
function runInstall(ctx, pm, pkg, wanted, wantedDev = []) {
|
|
678
|
+
const have = installedPackages(pkg);
|
|
679
|
+
const packages = [...new Set(wanted)].filter((p) => !have.has(p)).sort();
|
|
680
|
+
const dev = [...new Set(wantedDev)].filter((p) => !have.has(p) && !packages.includes(p)).sort();
|
|
681
|
+
const cmds = [];
|
|
682
|
+
if (packages.length) cmds.push(installCommand(pm, packages));
|
|
683
|
+
if (dev.length) cmds.push(installCommand(pm, dev, true));
|
|
684
|
+
const report = {
|
|
685
|
+
packages,
|
|
686
|
+
dev,
|
|
687
|
+
commands: cmds.map((c) => c.join(" ")),
|
|
688
|
+
ran: false,
|
|
689
|
+
exitCode: null
|
|
690
|
+
};
|
|
691
|
+
if (!cmds.length || ctx.flags.dryRun || !ctx.flags.install) return report;
|
|
692
|
+
report.ran = true;
|
|
693
|
+
for (const [cmd, ...args] of cmds) {
|
|
694
|
+
const code = ctx.exec(cmd, args, {
|
|
695
|
+
cwd: ctx.cwd,
|
|
696
|
+
quiet: ctx.flags.json
|
|
697
|
+
});
|
|
698
|
+
report.exitCode = code;
|
|
699
|
+
if (code !== 0) break;
|
|
700
|
+
}
|
|
701
|
+
return report;
|
|
702
|
+
}
|
|
703
|
+
function installLines(report, ctx) {
|
|
704
|
+
if (!report.commands.length) return ["Packages: already installed."];
|
|
705
|
+
const verb = report.ran ? "Ran" : ctx.flags.dryRun ? "Would run" : "Run";
|
|
706
|
+
const lines = report.commands.map((c) => `${verb}: ${c}`);
|
|
707
|
+
if (report.ran && report.exitCode !== 0) lines.push(`Install failed (exit ${report.exitCode}).`);
|
|
708
|
+
return lines;
|
|
709
|
+
}
|
|
710
|
+
/** Turns a failed install into a non-zero result that still carries the data. */
|
|
711
|
+
function withInstallStatus(result, report) {
|
|
712
|
+
if (report.ran && report.exitCode !== 0) return {
|
|
713
|
+
...result,
|
|
714
|
+
code: 1,
|
|
715
|
+
error: new CliError("INSTALL", `Install command failed with exit code ${report.exitCode}`)
|
|
716
|
+
};
|
|
717
|
+
return result;
|
|
718
|
+
}
|
|
719
|
+
//#endregion
|
|
720
|
+
//#region src/commands/add.ts
|
|
721
|
+
function projectContext(ctx) {
|
|
722
|
+
const info = detectProject(ctx.cwd);
|
|
723
|
+
const loaded = loadConfig(ctx.cwd);
|
|
724
|
+
return {
|
|
725
|
+
info,
|
|
726
|
+
config: loaded ?? defaultConfig(info),
|
|
727
|
+
configured: loaded !== null
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
/** Public export names of a component: from its index file, else its name. */
|
|
731
|
+
function exportNames(item, indexSource) {
|
|
732
|
+
const names = /* @__PURE__ */ new Set();
|
|
733
|
+
if (indexSource) for (const m of indexSource.matchAll(/export\s*\{([^}]*)\}/g)) for (const part of (m[1] ?? "").split(",")) {
|
|
734
|
+
const p = part.trim();
|
|
735
|
+
if (!p || p.startsWith("type ")) continue;
|
|
736
|
+
const name = (p.split(/\s+as\s+/)[1] ?? p).trim();
|
|
737
|
+
if (/^[A-Z]/.test(name)) names.add(name);
|
|
738
|
+
}
|
|
739
|
+
if (!names.size) names.add(item.name.replace(/[^A-Za-z0-9_$]/g, ""));
|
|
740
|
+
return [...names];
|
|
741
|
+
}
|
|
742
|
+
function importLine(names, from) {
|
|
743
|
+
return `import { ${names.join(", ")} } from '${from}';`;
|
|
744
|
+
}
|
|
745
|
+
async function packageImport(ctx, item, read) {
|
|
746
|
+
const index = item.files.find((f) => /\/index\.[jt]sx?$/.test(f.path));
|
|
747
|
+
return importLine(exportNames(item, index ? await read(index.path).catch(() => void 0) : void 0), item.package);
|
|
748
|
+
}
|
|
749
|
+
function copySection(plan, conflicts, ctx) {
|
|
750
|
+
const label = {
|
|
751
|
+
create: ctx.flags.dryRun ? "would create" : "created",
|
|
752
|
+
identical: "unchanged",
|
|
753
|
+
conflict: "CONFLICT",
|
|
754
|
+
overwrite: ctx.flags.dryRun ? "would overwrite" : "overwritten"
|
|
755
|
+
};
|
|
756
|
+
const lines = plan.files.map((f) => ` ${label[f.status].padEnd(15)} ${f.target}`);
|
|
757
|
+
if (plan.rewrites.length) lines.push("Rewritten imports:", ...plan.rewrites.map((r) => ` ${r.file}: '${r.from}' → '${r.to}'`));
|
|
758
|
+
if (plan.warnings.length) lines.push("Warnings:", ...plan.warnings.map((w) => ` ${w}`));
|
|
759
|
+
if (conflicts.length) lines.push(`${conflicts.length} file(s) already exist and differ — nothing was written. Re-run with --overwrite to replace them.`);
|
|
760
|
+
return lines;
|
|
761
|
+
}
|
|
762
|
+
async function add(ctx, positionals) {
|
|
763
|
+
if (!positionals.length) throw new CliError("USAGE", "Usage: gntik-ui add <id...>");
|
|
764
|
+
const project = projectContext(ctx);
|
|
765
|
+
const { config, info } = project;
|
|
766
|
+
const registry = await openRegistry(ctx, config.registry);
|
|
767
|
+
const closure = registry.closure(positionals);
|
|
768
|
+
const requested = new Set(positionals);
|
|
769
|
+
const toCopy = closure.filter((i) => isCopyIn(i.kind));
|
|
770
|
+
const packageItems = closure.filter((i) => !isCopyIn(i.kind) && (requested.has(i.id) || toCopy.length > 0));
|
|
771
|
+
const viaPackage = packageItems.filter((i) => requested.has(i.id));
|
|
772
|
+
const plan = await planCopy(registry, toCopy, config, ctx.cwd);
|
|
773
|
+
const { conflicts, written } = await applyCopy(plan, ctx.cwd, {
|
|
774
|
+
overwrite: ctx.flags.overwrite,
|
|
775
|
+
dryRun: ctx.flags.dryRun
|
|
776
|
+
});
|
|
777
|
+
const imports = [];
|
|
778
|
+
for (const item of viaPackage) imports.push(await packageImport(ctx, item, (p) => registry.read(p)));
|
|
779
|
+
const wanted = [
|
|
780
|
+
...packageItems.map((i) => i.package),
|
|
781
|
+
...toCopy.flatMap((i) => i.dependencies),
|
|
782
|
+
...plan.packages
|
|
783
|
+
];
|
|
784
|
+
const install = conflicts.length ? {
|
|
785
|
+
packages: [],
|
|
786
|
+
dev: [],
|
|
787
|
+
commands: [],
|
|
788
|
+
ran: false,
|
|
789
|
+
exitCode: null
|
|
790
|
+
} : runInstall(ctx, config.packageManager, info.packageJson, wanted);
|
|
791
|
+
const data = {
|
|
792
|
+
dryRun: ctx.flags.dryRun,
|
|
793
|
+
configured: project.configured,
|
|
794
|
+
items: closure.map((i) => ({
|
|
795
|
+
id: i.id,
|
|
796
|
+
kind: i.kind,
|
|
797
|
+
package: i.package,
|
|
798
|
+
mode: isCopyIn(i.kind) ? "copy" : "package",
|
|
799
|
+
requested: requested.has(i.id)
|
|
800
|
+
})),
|
|
801
|
+
files: plan.files.map(fileSummary),
|
|
802
|
+
written,
|
|
803
|
+
conflicts,
|
|
804
|
+
rewrites: plan.rewrites,
|
|
805
|
+
warnings: plan.warnings,
|
|
806
|
+
imports,
|
|
807
|
+
install
|
|
808
|
+
};
|
|
809
|
+
const text = [];
|
|
810
|
+
if (!project.configured) text.push(`No ${CONFIG_FILE} found — using defaults (run gntik-ui init).`);
|
|
811
|
+
if (plan.files.length) text.push("Files:", ...copySection(plan, conflicts, ctx));
|
|
812
|
+
if (imports.length) text.push("Import:", ...imports.map((l) => ` ${l}`));
|
|
813
|
+
text.push(...installLines(install, ctx));
|
|
814
|
+
if (conflicts.length) return {
|
|
815
|
+
code: 4,
|
|
816
|
+
data,
|
|
817
|
+
text,
|
|
818
|
+
error: new CliError("CONFLICT", `${conflicts.length} file(s) already exist: ${conflicts.join(", ")}`)
|
|
819
|
+
};
|
|
820
|
+
return withInstallStatus({
|
|
821
|
+
code: 0,
|
|
822
|
+
data,
|
|
823
|
+
text
|
|
824
|
+
}, install);
|
|
825
|
+
}
|
|
826
|
+
//#endregion
|
|
827
|
+
//#region src/commands/eject.ts
|
|
828
|
+
const SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
829
|
+
"node_modules",
|
|
830
|
+
".git",
|
|
831
|
+
"dist",
|
|
832
|
+
"build",
|
|
833
|
+
".next",
|
|
834
|
+
"out",
|
|
835
|
+
"coverage",
|
|
836
|
+
".turbo",
|
|
837
|
+
".vercel"
|
|
838
|
+
]);
|
|
839
|
+
const CODE = /\.(tsx?|jsx?|mts|mjs)$/;
|
|
840
|
+
async function walk(dir, out = []) {
|
|
841
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
842
|
+
for (const e of entries) if (e.isDirectory()) {
|
|
843
|
+
if (!SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) await walk(path.join(dir, e.name), out);
|
|
844
|
+
} else if (CODE.test(e.name)) out.push(path.join(dir, e.name));
|
|
845
|
+
return out;
|
|
846
|
+
}
|
|
847
|
+
/** Finds `import { … } from '<package>'` lines that use the ejected component's names. */
|
|
848
|
+
async function findUsages(cwd, pkg, names, localEntry, skip) {
|
|
849
|
+
const usages = [];
|
|
850
|
+
const re = new RegExp(String.raw`import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]${pkg.replace(/[/.]/g, "\\$&")}['"]`, "g");
|
|
851
|
+
for (const abs of await walk(cwd)) {
|
|
852
|
+
const rel = path.relative(cwd, abs).split(path.sep).join("/");
|
|
853
|
+
if (skip.has(rel)) continue;
|
|
854
|
+
const text = await readFile(abs, "utf8");
|
|
855
|
+
for (const m of text.matchAll(re)) {
|
|
856
|
+
const hit = (m[1] ?? "").split(",").map((s) => s.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim() ?? "").filter((n) => names.includes(n));
|
|
857
|
+
if (!hit.length) continue;
|
|
858
|
+
let spec = path.posix.relative(path.posix.dirname(rel), localEntry);
|
|
859
|
+
if (!spec.startsWith(".")) spec = `./${spec}`;
|
|
860
|
+
const line = text.slice(0, m.index).split("\n").length;
|
|
861
|
+
usages.push({
|
|
862
|
+
file: rel,
|
|
863
|
+
line,
|
|
864
|
+
names: hit,
|
|
865
|
+
replacement: `import { ${hit.join(", ")} } from '${spec}';`
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return usages;
|
|
870
|
+
}
|
|
871
|
+
async function eject(ctx, positionals) {
|
|
872
|
+
const [id] = positionals;
|
|
873
|
+
if (!id || positionals.length > 1) throw new CliError("USAGE", "Usage: gntik-ui eject <id>");
|
|
874
|
+
const project = projectContext(ctx);
|
|
875
|
+
const { config, info } = project;
|
|
876
|
+
const registry = await openRegistry(ctx, config.registry);
|
|
877
|
+
const item = registry.get(id);
|
|
878
|
+
if (!item) throw new CliError("NOT_FOUND", `Unknown item "${id}"`);
|
|
879
|
+
const items = ctx.flags.deps ? registry.closure([id]) : [item];
|
|
880
|
+
const plan = await planCopy(registry, items, config, ctx.cwd, { includeDocs: true });
|
|
881
|
+
const { conflicts, written } = await applyCopy(plan, ctx.cwd, {
|
|
882
|
+
overwrite: ctx.flags.overwrite,
|
|
883
|
+
dryRun: ctx.flags.dryRun
|
|
884
|
+
});
|
|
885
|
+
const indexFile = item.files.find((f) => /\/index\.[jt]sx?$/.test(f.path)) ?? item.files[0];
|
|
886
|
+
const localEntry = indexFile ? (() => {
|
|
887
|
+
const t = mapTarget(indexFile.target, item.kind, config);
|
|
888
|
+
return /\/index\.[jt]sx?$/.test(t) ? path.posix.dirname(t) : t.replace(/\.[jt]sx?$/, "");
|
|
889
|
+
})() : "";
|
|
890
|
+
const indexContent = plan.files.find((f) => f.source === indexFile?.path)?.content;
|
|
891
|
+
const names = exportNames(item, indexContent);
|
|
892
|
+
const usages = await findUsages(ctx.cwd, item.package, names, localEntry, new Set(plan.files.map((f) => f.target)));
|
|
893
|
+
const install = conflicts.length ? {
|
|
894
|
+
packages: [],
|
|
895
|
+
dev: [],
|
|
896
|
+
commands: [],
|
|
897
|
+
ran: false,
|
|
898
|
+
exitCode: null
|
|
899
|
+
} : runInstall(ctx, config.packageManager, info.packageJson, [...items.flatMap((i) => i.dependencies), ...plan.packages]);
|
|
900
|
+
const nextSteps = [
|
|
901
|
+
`Import ${names.join(", ")} from the local copy (${localEntry}) instead of '${item.package}'.`,
|
|
902
|
+
...usages.length ? [`Update the ${usages.length} import(s) listed under usages.`] : [],
|
|
903
|
+
"The copy no longer receives kit updates; diff against the registry when upgrading."
|
|
904
|
+
];
|
|
905
|
+
const data = {
|
|
906
|
+
dryRun: ctx.flags.dryRun,
|
|
907
|
+
configured: project.configured,
|
|
908
|
+
id,
|
|
909
|
+
items: items.map((i) => i.id),
|
|
910
|
+
files: plan.files.map(fileSummary),
|
|
911
|
+
written,
|
|
912
|
+
conflicts,
|
|
913
|
+
rewrites: plan.rewrites,
|
|
914
|
+
warnings: plan.warnings,
|
|
915
|
+
localImport: localEntry,
|
|
916
|
+
exports: names,
|
|
917
|
+
usages,
|
|
918
|
+
nextSteps,
|
|
919
|
+
install
|
|
920
|
+
};
|
|
921
|
+
const text = [];
|
|
922
|
+
if (!project.configured) text.push(`No ${CONFIG_FILE} found — using defaults (run gntik-ui init).`);
|
|
923
|
+
text.push(`Eject ${item.name} (${items.map((i) => i.id).join(", ")}):`, ...copySection(plan, conflicts, ctx));
|
|
924
|
+
if (conflicts.length) {
|
|
925
|
+
if (ctx.flags.deps) text.push("Tip: --no-deps ejects only this item and keeps its dependencies on the package.");
|
|
926
|
+
return {
|
|
927
|
+
code: 4,
|
|
928
|
+
data,
|
|
929
|
+
text,
|
|
930
|
+
error: new CliError("CONFLICT", `${conflicts.length} file(s) already exist: ${conflicts.join(", ")}`)
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
text.push("Next:", ...nextSteps.map((s) => ` - ${s}`));
|
|
934
|
+
for (const u of usages) text.push(` ${u.file}:${u.line} ${u.replacement}`);
|
|
935
|
+
text.push(...installLines(install, ctx));
|
|
936
|
+
return withInstallStatus({
|
|
937
|
+
code: 0,
|
|
938
|
+
data,
|
|
939
|
+
text
|
|
940
|
+
}, install);
|
|
941
|
+
}
|
|
942
|
+
//#endregion
|
|
943
|
+
//#region src/commands/init.ts
|
|
944
|
+
const BRANDS = [
|
|
945
|
+
"gntik",
|
|
946
|
+
"musematic",
|
|
947
|
+
"falcone"
|
|
948
|
+
];
|
|
949
|
+
const CSS_IMPORTS = [
|
|
950
|
+
"@import \"tailwindcss\";",
|
|
951
|
+
"@import \"@fontsource/geist\";",
|
|
952
|
+
"@import \"@fontsource/geist-mono\";",
|
|
953
|
+
"@import \"@gntik-ai/ui/styles.css\";"
|
|
954
|
+
];
|
|
955
|
+
const IMPORT_TARGETS = [
|
|
956
|
+
"tailwindcss",
|
|
957
|
+
"@fontsource/geist",
|
|
958
|
+
"@fontsource/geist-mono",
|
|
959
|
+
"@gntik-ai/ui/styles.css"
|
|
960
|
+
];
|
|
961
|
+
/**
|
|
962
|
+
* Puts Tailwind, the Geist fonts and the kit styles at the top of the CSS entry, in that order,
|
|
963
|
+
* keeping everything else. Existing copies of those imports are moved, not duplicated.
|
|
964
|
+
*/
|
|
965
|
+
function patchCss(text) {
|
|
966
|
+
const lines = text === null ? [] : text.split(/\r?\n/);
|
|
967
|
+
const isOurs = (l) => {
|
|
968
|
+
const m = /^\s*@import\s+(?:url\()?\s*["']([^"']+)["']/.exec(l);
|
|
969
|
+
return !!m && IMPORT_TARGETS.includes(m[1] ?? "");
|
|
970
|
+
};
|
|
971
|
+
const head = lines.slice(0, CSS_IMPORTS.length).map((l) => l.trim());
|
|
972
|
+
if (CSS_IMPORTS.every((l, i) => head[i] === l) && lines.slice(CSS_IMPORTS.length).every((l) => !isOurs(l))) return text ?? "";
|
|
973
|
+
const rest = lines.filter((l) => !isOurs(l));
|
|
974
|
+
while (rest.length && rest[0]?.trim() === "") rest.shift();
|
|
975
|
+
return [
|
|
976
|
+
...CSS_IMPORTS,
|
|
977
|
+
"",
|
|
978
|
+
...rest
|
|
979
|
+
].join("\n").replace(/\n*$/, "\n");
|
|
980
|
+
}
|
|
981
|
+
const presetName = (brand) => `${brand}Preset`;
|
|
982
|
+
function themeSnippet(framework, entry, css, brand) {
|
|
983
|
+
const preset = presetName(brand);
|
|
984
|
+
const cssImport = (from) => {
|
|
985
|
+
let rel = path.posix.relative(path.posix.dirname(from), css);
|
|
986
|
+
if (!rel.startsWith(".")) rel = `./${rel}`;
|
|
987
|
+
return rel;
|
|
988
|
+
};
|
|
989
|
+
if (framework === "next") return [{
|
|
990
|
+
file: `${path.posix.dirname(entry)}/providers.tsx`,
|
|
991
|
+
code: `'use client';
|
|
992
|
+
import type { ReactNode } from 'react';
|
|
993
|
+
import { ThemeProvider, ${preset}, themeScript } from '@gntik-ai/ui';
|
|
994
|
+
|
|
995
|
+
export function Providers({ children }: { children: ReactNode }) {
|
|
996
|
+
return (
|
|
997
|
+
<>
|
|
998
|
+
{/* Applies the stored theme before first paint (no flash). */}
|
|
999
|
+
<script dangerouslySetInnerHTML={{ __html: themeScript() }} />
|
|
1000
|
+
<ThemeProvider brand={${preset}}>{children}</ThemeProvider>
|
|
1001
|
+
</>
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
`
|
|
1005
|
+
}, {
|
|
1006
|
+
file: entry,
|
|
1007
|
+
code: `import type { ReactNode } from 'react';
|
|
1008
|
+
import { Providers } from './providers';
|
|
1009
|
+
import '${cssImport(entry)}';
|
|
1010
|
+
|
|
1011
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
1012
|
+
return (
|
|
1013
|
+
// The theme script sets the class before React hydrates.
|
|
1014
|
+
<html lang="en" className="dark" suppressHydrationWarning>
|
|
1015
|
+
<body className="bg-background text-foreground antialiased">
|
|
1016
|
+
<Providers>{children}</Providers>
|
|
1017
|
+
</body>
|
|
1018
|
+
</html>
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
`
|
|
1022
|
+
}];
|
|
1023
|
+
const files = [{
|
|
1024
|
+
file: entry,
|
|
1025
|
+
code: `import { StrictMode } from 'react';
|
|
1026
|
+
import { createRoot } from 'react-dom/client';
|
|
1027
|
+
import { ThemeProvider, ${preset} } from '@gntik-ai/ui';
|
|
1028
|
+
import App from './App';
|
|
1029
|
+
import '${cssImport(entry)}';
|
|
1030
|
+
|
|
1031
|
+
createRoot(document.getElementById('root')!).render(
|
|
1032
|
+
<StrictMode>
|
|
1033
|
+
<ThemeProvider brand={${preset}}>
|
|
1034
|
+
<App />
|
|
1035
|
+
</ThemeProvider>
|
|
1036
|
+
</StrictMode>,
|
|
1037
|
+
);
|
|
1038
|
+
`
|
|
1039
|
+
}];
|
|
1040
|
+
if (framework === "vite") files.push({
|
|
1041
|
+
file: "vite.config.ts",
|
|
1042
|
+
code: `import { defineConfig } from 'vite';
|
|
1043
|
+
import react from '@vitejs/plugin-react';
|
|
1044
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
1045
|
+
import { themeScript } from '@gntik-ai/ui';
|
|
1046
|
+
|
|
1047
|
+
export default defineConfig({
|
|
1048
|
+
plugins: [
|
|
1049
|
+
react(),
|
|
1050
|
+
tailwindcss(),
|
|
1051
|
+
// Inlines the theme script in <head> so the stored theme applies before first paint.
|
|
1052
|
+
{ name: 'gntik-theme', transformIndexHtml: () => [{ tag: 'script', children: themeScript(), injectTo: 'head-prepend' }] },
|
|
1053
|
+
],
|
|
1054
|
+
});
|
|
1055
|
+
`
|
|
1056
|
+
});
|
|
1057
|
+
else files.push({
|
|
1058
|
+
file: "index.html",
|
|
1059
|
+
code: `<!-- In <head>: inline the output of themeScript() from @gntik-ai/ui to avoid a theme flash. -->
|
|
1060
|
+
<script>/* themeScript() output */<\/script>
|
|
1061
|
+
`
|
|
1062
|
+
});
|
|
1063
|
+
return files;
|
|
1064
|
+
}
|
|
1065
|
+
async function readOrNull(file) {
|
|
1066
|
+
return existsSync(file) ? readFile(file, "utf8") : null;
|
|
1067
|
+
}
|
|
1068
|
+
function change(file, before, after) {
|
|
1069
|
+
return {
|
|
1070
|
+
file,
|
|
1071
|
+
action: before === null ? "create" : before === after ? "unchanged" : "patch",
|
|
1072
|
+
content: after
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
async function init(ctx) {
|
|
1076
|
+
const brand = ctx.flags.brand ?? "gntik";
|
|
1077
|
+
if (!BRANDS.includes(brand)) throw new CliError("USAGE", `--brand must be one of ${BRANDS.join(", ")}`);
|
|
1078
|
+
if (!existsSync(ctx.cwd)) throw new CliError("USAGE", `Directory not found: ${ctx.cwd}`);
|
|
1079
|
+
const info = detectProject(ctx.cwd);
|
|
1080
|
+
const existing = loadConfig(ctx.cwd);
|
|
1081
|
+
const config = existing && !ctx.flags.overwrite ? existing : {
|
|
1082
|
+
...defaultConfig(info, brand),
|
|
1083
|
+
...existing?.registry ? { registry: existing.registry } : {}
|
|
1084
|
+
};
|
|
1085
|
+
const cssFile = config.css;
|
|
1086
|
+
const cssPath = path.join(ctx.cwd, cssFile);
|
|
1087
|
+
const cfgPath = path.join(ctx.cwd, CONFIG_FILE);
|
|
1088
|
+
const cssBefore = await readOrNull(cssPath);
|
|
1089
|
+
const cfgBefore = await readOrNull(cfgPath);
|
|
1090
|
+
const cfgText = `${JSON.stringify(config, null, 2)}\n`;
|
|
1091
|
+
const changes = [change(cssFile, cssBefore, patchCss(cssBefore)), change(CONFIG_FILE, cfgBefore, cfgBefore !== null && !ctx.flags.overwrite ? cfgBefore : cfgText)];
|
|
1092
|
+
if (!ctx.flags.dryRun) for (const c of changes) {
|
|
1093
|
+
if (c.action === "unchanged") continue;
|
|
1094
|
+
const abs = path.join(ctx.cwd, c.file);
|
|
1095
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
1096
|
+
await writeFile(abs, c.content);
|
|
1097
|
+
}
|
|
1098
|
+
const warnings = [];
|
|
1099
|
+
if ((await readOrNull(path.join(ctx.cwd, ".npmrc")))?.split(/\r?\n/).some((line) => line.trim().startsWith(`@gntik-ai:registry`))) warnings.push("New @gntik-ai versions are on npmjs.org; remove the @gntik-ai:registry line from .npmrc.");
|
|
1100
|
+
if (cssBefore && /@tailwind\s+(base|components|utilities)/.test(cssBefore)) warnings.push(`${cssFile} has Tailwind v3 directives (@tailwind …); the kit needs Tailwind v4 — remove them.`);
|
|
1101
|
+
if (!info.packageJson) warnings.push("No package.json found in this directory.");
|
|
1102
|
+
const have = installedPackages(info.packageJson);
|
|
1103
|
+
const dev = [];
|
|
1104
|
+
if (!have.has("tailwindcss")) {
|
|
1105
|
+
dev.push("tailwindcss", info.framework === "next" ? "@tailwindcss/postcss" : "@tailwindcss/vite");
|
|
1106
|
+
if (info.framework === "next" && !existsSync(path.join(ctx.cwd, "postcss.config.mjs"))) warnings.push("Add postcss.config.mjs: export default { plugins: { \"@tailwindcss/postcss\": {} } };");
|
|
1107
|
+
}
|
|
1108
|
+
const install = runInstall(ctx, config.packageManager, info.packageJson, [
|
|
1109
|
+
"@gntik-ai/ui",
|
|
1110
|
+
"@gntik-ai/tokens",
|
|
1111
|
+
"@fontsource/geist",
|
|
1112
|
+
"@fontsource/geist-mono"
|
|
1113
|
+
], dev);
|
|
1114
|
+
const snippet = themeSnippet(info.framework, info.entry, cssFile, config.brand);
|
|
1115
|
+
const label = (a) => (ctx.flags.dryRun ? {
|
|
1116
|
+
create: "would create",
|
|
1117
|
+
patch: "would patch",
|
|
1118
|
+
unchanged: "unchanged"
|
|
1119
|
+
} : {
|
|
1120
|
+
create: "created",
|
|
1121
|
+
patch: "patched",
|
|
1122
|
+
unchanged: "unchanged"
|
|
1123
|
+
})[a];
|
|
1124
|
+
const text = [
|
|
1125
|
+
`Project: ${info.framework} · ${config.packageManager} · CSS ${cssFile}${info.css ? "" : " (new)"}`,
|
|
1126
|
+
...changes.map((c) => ` ${label(c.action).padEnd(13)} ${c.file}`),
|
|
1127
|
+
...warnings.map((w) => `Warning: ${w}`),
|
|
1128
|
+
"",
|
|
1129
|
+
"Wire the theme (dark by default; light and high_contrast via useTheme().setMode):",
|
|
1130
|
+
...snippet.flatMap((s) => [
|
|
1131
|
+
"",
|
|
1132
|
+
`// ${s.file}`,
|
|
1133
|
+
...s.code.trimEnd().split("\n")
|
|
1134
|
+
]),
|
|
1135
|
+
"",
|
|
1136
|
+
...installLines(install, ctx)
|
|
1137
|
+
];
|
|
1138
|
+
return withInstallStatus({
|
|
1139
|
+
code: 0,
|
|
1140
|
+
data: {
|
|
1141
|
+
dryRun: ctx.flags.dryRun,
|
|
1142
|
+
project: {
|
|
1143
|
+
framework: info.framework,
|
|
1144
|
+
packageManager: config.packageManager,
|
|
1145
|
+
css: cssFile,
|
|
1146
|
+
cssExisted: info.css !== null,
|
|
1147
|
+
entry: info.entry,
|
|
1148
|
+
src: info.src
|
|
1149
|
+
},
|
|
1150
|
+
config,
|
|
1151
|
+
changes: changes.map(({ file, action }) => ({
|
|
1152
|
+
file,
|
|
1153
|
+
action
|
|
1154
|
+
})),
|
|
1155
|
+
snippet,
|
|
1156
|
+
warnings,
|
|
1157
|
+
install
|
|
1158
|
+
},
|
|
1159
|
+
text
|
|
1160
|
+
}, install);
|
|
1161
|
+
}
|
|
1162
|
+
//#endregion
|
|
1163
|
+
//#region src/commands/query.ts
|
|
1164
|
+
async function registryFor(ctx) {
|
|
1165
|
+
let configured;
|
|
1166
|
+
try {
|
|
1167
|
+
configured = loadConfig(ctx.cwd)?.registry;
|
|
1168
|
+
} catch {
|
|
1169
|
+
configured = void 0;
|
|
1170
|
+
}
|
|
1171
|
+
return openRegistry(ctx, configured);
|
|
1172
|
+
}
|
|
1173
|
+
function checkKind(kind) {
|
|
1174
|
+
if (kind && !ITEM_KINDS.includes(kind)) throw new CliError("USAGE", `--kind must be one of ${ITEM_KINDS.join(", ")}`);
|
|
1175
|
+
}
|
|
1176
|
+
/** Fixed-width table for humans. */
|
|
1177
|
+
function table(rows) {
|
|
1178
|
+
const widths = rows[0]?.map((_, i) => Math.max(...rows.map((r) => (r[i] ?? "").length))) ?? [];
|
|
1179
|
+
return rows.map((r) => r.map((c, i) => i === r.length - 1 ? c : c.padEnd(widths[i] ?? 0)).join(" ").trimEnd());
|
|
1180
|
+
}
|
|
1181
|
+
const truncate = (s, n) => s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
1182
|
+
async function list(ctx) {
|
|
1183
|
+
const { kind, group } = ctx.flags;
|
|
1184
|
+
checkKind(kind);
|
|
1185
|
+
const registry = await registryFor(ctx);
|
|
1186
|
+
const items = registry.items.filter((i) => (!kind || i.kind === kind) && (!group || i.group.toLowerCase() === group.toLowerCase()));
|
|
1187
|
+
const rows = [[
|
|
1188
|
+
"ID",
|
|
1189
|
+
"KIND",
|
|
1190
|
+
"GROUP",
|
|
1191
|
+
"STATUS",
|
|
1192
|
+
"PACKAGE"
|
|
1193
|
+
], ...items.map((i) => [
|
|
1194
|
+
i.id,
|
|
1195
|
+
i.kind,
|
|
1196
|
+
i.group,
|
|
1197
|
+
i.status,
|
|
1198
|
+
i.package
|
|
1199
|
+
])];
|
|
1200
|
+
return ok({
|
|
1201
|
+
registry: registry.source.base,
|
|
1202
|
+
count: items.length,
|
|
1203
|
+
items: items.map(summary)
|
|
1204
|
+
}, items.length ? [
|
|
1205
|
+
...table(rows),
|
|
1206
|
+
"",
|
|
1207
|
+
`${items.length} item(s)`
|
|
1208
|
+
] : ["No items match."]);
|
|
1209
|
+
}
|
|
1210
|
+
/** Subsequence match: every query char appears in order. Returns a 0..1 closeness. */
|
|
1211
|
+
function subsequence(q, s) {
|
|
1212
|
+
let i = 0;
|
|
1213
|
+
let gaps = 0;
|
|
1214
|
+
let last = -1;
|
|
1215
|
+
for (let j = 0; j < s.length && i < q.length; j++) if (s[j] === q[i]) {
|
|
1216
|
+
if (last >= 0) gaps += j - last - 1;
|
|
1217
|
+
last = j;
|
|
1218
|
+
i++;
|
|
1219
|
+
}
|
|
1220
|
+
return i === q.length ? 1 / (1 + gaps) : 0;
|
|
1221
|
+
}
|
|
1222
|
+
/** Scores one item against one query term; 0 means no match. */
|
|
1223
|
+
function termScore(term, item) {
|
|
1224
|
+
const id = item.id.toLowerCase();
|
|
1225
|
+
const name = item.name.toLowerCase();
|
|
1226
|
+
const group = item.group.toLowerCase();
|
|
1227
|
+
const desc = item.description.toLowerCase();
|
|
1228
|
+
if (id === term || name === term) return 100;
|
|
1229
|
+
if (id.startsWith(term) || name.startsWith(term)) return 80;
|
|
1230
|
+
if (id.includes(term) || name.includes(term)) return 60;
|
|
1231
|
+
if (group === term || group.includes(term)) return 40;
|
|
1232
|
+
if (new RegExp(`\\b${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(desc)) return 30;
|
|
1233
|
+
const fuzzy = Math.max(subsequence(term, id), subsequence(term, name));
|
|
1234
|
+
if (fuzzy > 0 && term.length >= 2) return Math.round(10 + 20 * fuzzy);
|
|
1235
|
+
if (desc.includes(term)) return 10;
|
|
1236
|
+
return 0;
|
|
1237
|
+
}
|
|
1238
|
+
function searchItems(items, query) {
|
|
1239
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
1240
|
+
return items.map((item) => {
|
|
1241
|
+
const scores = terms.map((t) => termScore(t, item));
|
|
1242
|
+
return {
|
|
1243
|
+
item,
|
|
1244
|
+
score: scores.every((s) => s > 0) ? scores.reduce((a, b) => a + b, 0) : 0
|
|
1245
|
+
};
|
|
1246
|
+
}).filter((r) => r.score > 0).sort((a, b) => b.score - a.score || a.item.id.localeCompare(b.item.id));
|
|
1247
|
+
}
|
|
1248
|
+
async function search(ctx, positionals) {
|
|
1249
|
+
const query = positionals.join(" ").trim();
|
|
1250
|
+
if (!query) throw new CliError("USAGE", "Usage: gntik-ui search <query>");
|
|
1251
|
+
checkKind(ctx.flags.kind);
|
|
1252
|
+
let results = searchItems((await registryFor(ctx)).items.filter((i) => !ctx.flags.kind || i.kind === ctx.flags.kind), query);
|
|
1253
|
+
if (ctx.flags.limit) results = results.slice(0, ctx.flags.limit);
|
|
1254
|
+
const rows = [[
|
|
1255
|
+
"ID",
|
|
1256
|
+
"KIND",
|
|
1257
|
+
"SCORE",
|
|
1258
|
+
"DESCRIPTION"
|
|
1259
|
+
], ...results.map((r) => [
|
|
1260
|
+
r.item.id,
|
|
1261
|
+
r.item.kind,
|
|
1262
|
+
String(r.score),
|
|
1263
|
+
truncate(r.item.description, 60)
|
|
1264
|
+
])];
|
|
1265
|
+
return ok({
|
|
1266
|
+
query,
|
|
1267
|
+
count: results.length,
|
|
1268
|
+
items: results.map((r) => ({
|
|
1269
|
+
...summary(r.item),
|
|
1270
|
+
score: r.score
|
|
1271
|
+
}))
|
|
1272
|
+
}, results.length ? table(rows) : [`No items match "${query}".`]);
|
|
1273
|
+
}
|
|
1274
|
+
const STR = String.raw`(?:'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)"|\x60((?:[^\x60\\]|\\.)*)\x60)`;
|
|
1275
|
+
const unescape = (s) => s.replace(/\\(.)/g, "$1");
|
|
1276
|
+
/** Reads primitive, pattern and the keyboard table from a *.doc.ts file without executing it. */
|
|
1277
|
+
function parseDocFile(text) {
|
|
1278
|
+
const field = (name) => {
|
|
1279
|
+
const m = new RegExp(`\\b${name}\\s*:\\s*${STR}`).exec(text);
|
|
1280
|
+
return m ? unescape(m[1] ?? m[2] ?? m[3] ?? "") : null;
|
|
1281
|
+
};
|
|
1282
|
+
const keyboard = [];
|
|
1283
|
+
const start = /\bkeyboard\s*:\s*\[/.exec(text);
|
|
1284
|
+
if (start) {
|
|
1285
|
+
let depth = 1;
|
|
1286
|
+
let i = start.index + start[0].length;
|
|
1287
|
+
for (; i < text.length && depth > 0; i++) {
|
|
1288
|
+
const c = text[i];
|
|
1289
|
+
if (c === "[") depth++;
|
|
1290
|
+
else if (c === "]") depth--;
|
|
1291
|
+
else if (c === "'" || c === "\"" || c === "`") {
|
|
1292
|
+
for (i++; i < text.length && text[i] !== c; i++) if (text[i] === "\\") i++;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
const body = text.slice(start.index + start[0].length, i - 1);
|
|
1296
|
+
const row = new RegExp(String.raw`\[\s*${STR}\s*,\s*${STR}\s*,?\s*\]`, "g");
|
|
1297
|
+
for (const m of body.matchAll(row)) keyboard.push({
|
|
1298
|
+
key: unescape(m[1] ?? m[2] ?? m[3] ?? ""),
|
|
1299
|
+
behaviour: unescape(m[4] ?? m[5] ?? m[6] ?? "")
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
return {
|
|
1303
|
+
primitive: field("primitive"),
|
|
1304
|
+
pattern: field("pattern"),
|
|
1305
|
+
keyboard
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
async function docs(ctx, positionals) {
|
|
1309
|
+
const [id] = positionals;
|
|
1310
|
+
if (!id || positionals.length > 1) throw new CliError("USAGE", "Usage: gntik-ui docs <id>");
|
|
1311
|
+
const registry = await registryFor(ctx);
|
|
1312
|
+
const item = registry.get(id);
|
|
1313
|
+
if (!item) throw new CliError("NOT_FOUND", `Unknown item "${id}". Try: gntik-ui search ${id}`);
|
|
1314
|
+
let info = {
|
|
1315
|
+
primitive: null,
|
|
1316
|
+
pattern: null,
|
|
1317
|
+
keyboard: []
|
|
1318
|
+
};
|
|
1319
|
+
if (item.docs) info = parseDocFile(await registry.read(item.docs));
|
|
1320
|
+
const text = [
|
|
1321
|
+
`${item.name} (${item.id}) — ${item.kind} · ${item.group} · ${item.status}`,
|
|
1322
|
+
"",
|
|
1323
|
+
item.description,
|
|
1324
|
+
"",
|
|
1325
|
+
`Package: ${item.package}`,
|
|
1326
|
+
...info.primitive ? [`Primitive: ${info.primitive}`] : [],
|
|
1327
|
+
...info.pattern ? [`ARIA pattern: ${info.pattern}`] : [],
|
|
1328
|
+
`Dependencies: ${item.dependencies.join(", ") || "—"}`,
|
|
1329
|
+
`Registry deps: ${item.registryDependencies.join(", ") || "—"}`,
|
|
1330
|
+
"Files:",
|
|
1331
|
+
...item.files.map((f) => ` ${f.path} → ${f.target}`)
|
|
1332
|
+
];
|
|
1333
|
+
if (info.keyboard.length) text.push("", "Keyboard:", ...table([["KEY", "BEHAVIOUR"], ...info.keyboard.map((k) => [k.key, k.behaviour])]).map((l) => ` ${l}`));
|
|
1334
|
+
return ok({
|
|
1335
|
+
item: {
|
|
1336
|
+
...summary(item),
|
|
1337
|
+
files: item.files,
|
|
1338
|
+
dependencies: item.dependencies,
|
|
1339
|
+
registryDependencies: item.registryDependencies,
|
|
1340
|
+
docs: item.docs ?? null
|
|
1341
|
+
},
|
|
1342
|
+
primitive: info.primitive,
|
|
1343
|
+
pattern: info.pattern,
|
|
1344
|
+
keyboard: info.keyboard
|
|
1345
|
+
}, text);
|
|
1346
|
+
}
|
|
1347
|
+
//#endregion
|
|
1348
|
+
//#region src/commands/upgrade.ts
|
|
1349
|
+
const CODEMODS_PACKAGE = "@gntik-ai/codemods";
|
|
1350
|
+
const VERSION_RE = /^v?\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?$/;
|
|
1351
|
+
/**
|
|
1352
|
+
* Finds @gntik-ai/codemods in the project's node_modules (walking up from cwd, like Node — but not
|
|
1353
|
+
* NODE_PATH or global folders, so only a package the project installed counts).
|
|
1354
|
+
*/
|
|
1355
|
+
function findCodemods(cwd) {
|
|
1356
|
+
let dir = path.resolve(cwd);
|
|
1357
|
+
for (;;) {
|
|
1358
|
+
const pkgDir = path.join(dir, "node_modules", ...CODEMODS_PACKAGE.split("/"));
|
|
1359
|
+
if (existsSync(path.join(pkgDir, "package.json"))) try {
|
|
1360
|
+
return createRequire(path.join(pkgDir, "package.json")).resolve(CODEMODS_PACKAGE);
|
|
1361
|
+
} catch {
|
|
1362
|
+
return null;
|
|
1363
|
+
}
|
|
1364
|
+
const parent = path.dirname(dir);
|
|
1365
|
+
if (parent === dir) return null;
|
|
1366
|
+
dir = parent;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
/** Imports @gntik-ai/codemods from the project; null when it is not installed. */
|
|
1370
|
+
async function loadCodemods(cwd) {
|
|
1371
|
+
const resolved = findCodemods(cwd);
|
|
1372
|
+
if (!resolved) return null;
|
|
1373
|
+
const mod = await import(pathToFileURL(resolved).href);
|
|
1374
|
+
if (!Array.isArray(mod.transforms) || typeof mod.selectTransforms !== "function" || typeof mod.runCodemods !== "function") throw new CliError("ERROR", `${resolved} does not look like ${CODEMODS_PACKAGE} (missing transforms/selectTransforms/runCodemods)`);
|
|
1375
|
+
return {
|
|
1376
|
+
module: mod,
|
|
1377
|
+
path: resolved
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
/** Declared @gntik-ai/* versions in the project's package.json (ranges stripped; workspace:/file: specs skipped). */
|
|
1381
|
+
function declaredVersions(pkg) {
|
|
1382
|
+
const out = {};
|
|
1383
|
+
for (const [name, spec] of Object.entries({
|
|
1384
|
+
...pkg?.devDependencies,
|
|
1385
|
+
...pkg?.dependencies
|
|
1386
|
+
})) {
|
|
1387
|
+
if (!name.startsWith(`@gntik-ai/`) || name === "@gntik-ai/codemods") continue;
|
|
1388
|
+
const m = /\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?/.exec(spec);
|
|
1389
|
+
if (m && !/^(workspace|file|link|git|github|https?):/.test(spec)) out[name] = m[0];
|
|
1390
|
+
}
|
|
1391
|
+
return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)));
|
|
1392
|
+
}
|
|
1393
|
+
const metaOut = (t) => ({
|
|
1394
|
+
id: t.id,
|
|
1395
|
+
package: t.package,
|
|
1396
|
+
fromVersion: t.fromVersion,
|
|
1397
|
+
toVersion: t.toVersion,
|
|
1398
|
+
reportOnly: t.reportOnly,
|
|
1399
|
+
description: t.description
|
|
1400
|
+
});
|
|
1401
|
+
async function upgrade(ctx, positionals) {
|
|
1402
|
+
const { flags } = ctx;
|
|
1403
|
+
for (const [name, v] of [["--from", flags.from], ["--to", flags.to]]) if (v !== void 0 && !(name === "--to" && v === "latest") && !VERSION_RE.test(v)) throw new CliError("USAGE", `${name} must be a version like 0.2.0, got "${v}"`);
|
|
1404
|
+
const dryRun = !flags.apply;
|
|
1405
|
+
const pkg = readJson(path.join(ctx.cwd, "package.json"));
|
|
1406
|
+
const detected = declaredVersions(pkg);
|
|
1407
|
+
const from = flags.from ?? (Object.keys(detected).length ? detected : null);
|
|
1408
|
+
const to = flags.to ?? "latest";
|
|
1409
|
+
const loaded = await loadCodemods(ctx.cwd);
|
|
1410
|
+
if (!loaded) {
|
|
1411
|
+
const install = installCommand(detectPackageManager(ctx.cwd, pkg), [CODEMODS_PACKAGE], true).join(" ");
|
|
1412
|
+
return {
|
|
1413
|
+
code: 1,
|
|
1414
|
+
data: {
|
|
1415
|
+
dryRun,
|
|
1416
|
+
from,
|
|
1417
|
+
to,
|
|
1418
|
+
codemods: {
|
|
1419
|
+
installed: false,
|
|
1420
|
+
version: null,
|
|
1421
|
+
install
|
|
1422
|
+
}
|
|
1423
|
+
},
|
|
1424
|
+
text: [`${CODEMODS_PACKAGE} is not installed in this project. Install it, then run upgrade again:`, ` ${install}`],
|
|
1425
|
+
error: new CliError("CODEMODS", `${CODEMODS_PACKAGE} not found from ${ctx.cwd}`)
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
const { module: mods } = loaded;
|
|
1429
|
+
const codemods = {
|
|
1430
|
+
installed: true,
|
|
1431
|
+
version: mods.VERSION ?? null,
|
|
1432
|
+
install: null
|
|
1433
|
+
};
|
|
1434
|
+
const known = new Set(mods.transforms.map((t) => t.id));
|
|
1435
|
+
const unknown = [...flags.codemods, ...flags.skipCodemods].filter((id) => !known.has(id));
|
|
1436
|
+
if (unknown.length) throw new CliError("NOT_FOUND", `Unknown codemod(s): ${unknown.join(", ")}. Run gntik-ui upgrade --list.`);
|
|
1437
|
+
const selected = mods.selectTransforms({
|
|
1438
|
+
from: from ?? void 0,
|
|
1439
|
+
to,
|
|
1440
|
+
only: flags.codemods.length ? flags.codemods : void 0,
|
|
1441
|
+
skip: flags.skipCodemods,
|
|
1442
|
+
force: flags.codemods.length > 0
|
|
1443
|
+
});
|
|
1444
|
+
const selectedIds = new Set(selected.map((t) => t.id));
|
|
1445
|
+
const fromLabel = from === null ? "any version" : typeof from === "string" ? from : Object.entries(from).map(([n, v]) => `${n}@${v}`).join(", ");
|
|
1446
|
+
const header = `${CODEMODS_PACKAGE} ${codemods.version ?? "?"} · from ${fromLabel} → ${to}`;
|
|
1447
|
+
if (flags.list) {
|
|
1448
|
+
const rows = [[
|
|
1449
|
+
"ID",
|
|
1450
|
+
"PACKAGE",
|
|
1451
|
+
"VERSIONS",
|
|
1452
|
+
"SELECTED",
|
|
1453
|
+
"KIND"
|
|
1454
|
+
], ...mods.transforms.map((t) => [
|
|
1455
|
+
t.id,
|
|
1456
|
+
t.package,
|
|
1457
|
+
`${t.fromVersion} → ${t.toVersion}`,
|
|
1458
|
+
selectedIds.has(t.id) ? "yes" : "no",
|
|
1459
|
+
t.reportOnly ? "report" : "rewrite"
|
|
1460
|
+
])];
|
|
1461
|
+
return ok({
|
|
1462
|
+
dryRun,
|
|
1463
|
+
from,
|
|
1464
|
+
to,
|
|
1465
|
+
codemods,
|
|
1466
|
+
transforms: mods.transforms.map((t) => ({
|
|
1467
|
+
...metaOut(t),
|
|
1468
|
+
selected: selectedIds.has(t.id)
|
|
1469
|
+
}))
|
|
1470
|
+
}, [
|
|
1471
|
+
header,
|
|
1472
|
+
"",
|
|
1473
|
+
...table(rows),
|
|
1474
|
+
"",
|
|
1475
|
+
...mods.transforms.map((t) => `${t.id}: ${t.description}`)
|
|
1476
|
+
]);
|
|
1477
|
+
}
|
|
1478
|
+
const base = {
|
|
1479
|
+
dryRun,
|
|
1480
|
+
from,
|
|
1481
|
+
to,
|
|
1482
|
+
codemods,
|
|
1483
|
+
transforms: selected.map(metaOut)
|
|
1484
|
+
};
|
|
1485
|
+
if (!selected.length) return ok({
|
|
1486
|
+
...base,
|
|
1487
|
+
scanned: 0,
|
|
1488
|
+
files: [],
|
|
1489
|
+
changed: [],
|
|
1490
|
+
reports: [],
|
|
1491
|
+
errors: []
|
|
1492
|
+
}, [header, "No codemods apply to this upgrade."]);
|
|
1493
|
+
const result = await mods.runCodemods({
|
|
1494
|
+
cwd: ctx.cwd,
|
|
1495
|
+
transforms: selected.map((t) => t.id),
|
|
1496
|
+
dry: dryRun,
|
|
1497
|
+
files: positionals.length ? positionals : void 0
|
|
1498
|
+
});
|
|
1499
|
+
const files = [...result.files].sort((a, b) => a.file.localeCompare(b.file));
|
|
1500
|
+
const changed = files.filter((f) => f.status === "modified").map((f) => f.file);
|
|
1501
|
+
const reports = files.flatMap((f) => f.reports.map((r) => ({
|
|
1502
|
+
file: f.file,
|
|
1503
|
+
line: r.line,
|
|
1504
|
+
transform: r.transform,
|
|
1505
|
+
message: r.message
|
|
1506
|
+
})));
|
|
1507
|
+
const errors = files.filter((f) => f.status === "error").map((f) => ({
|
|
1508
|
+
file: f.file,
|
|
1509
|
+
message: f.error ?? "failed"
|
|
1510
|
+
}));
|
|
1511
|
+
const data = {
|
|
1512
|
+
...base,
|
|
1513
|
+
scanned: result.scanned,
|
|
1514
|
+
files,
|
|
1515
|
+
changed,
|
|
1516
|
+
reports,
|
|
1517
|
+
errors
|
|
1518
|
+
};
|
|
1519
|
+
const text = [
|
|
1520
|
+
header,
|
|
1521
|
+
`Codemods: ${selected.map((t) => t.id).join(", ")}`,
|
|
1522
|
+
`Scanned ${result.scanned} file(s).`
|
|
1523
|
+
];
|
|
1524
|
+
const verb = dryRun ? "Would modify" : "Modified";
|
|
1525
|
+
if (changed.length) text.push("", `${verb} ${changed.length} file(s):`, ...files.filter((f) => f.status === "modified").map((f) => ` ${f.file} (${f.transforms.join(", ")})`));
|
|
1526
|
+
else text.push("No files to change.");
|
|
1527
|
+
if (reports.length) text.push("", `Warnings (${reports.length}):`, ...reports.map((r) => ` ${r.file}${r.line ? `:${r.line}` : ""} [${r.transform}] ${r.message}`));
|
|
1528
|
+
if (errors.length) text.push("", `Errors (${errors.length}):`, ...errors.map((e) => ` ${e.file}: ${e.message}`));
|
|
1529
|
+
if (dryRun && changed.length) text.push("", "Dry run: nothing was written. Re-run with --apply to write the changes.");
|
|
1530
|
+
if (errors.length) return {
|
|
1531
|
+
code: 1,
|
|
1532
|
+
data,
|
|
1533
|
+
text,
|
|
1534
|
+
error: new CliError("ERROR", `${errors.length} file(s) could not be transformed`)
|
|
1535
|
+
};
|
|
1536
|
+
return ok(data, text);
|
|
1537
|
+
}
|
|
1538
|
+
//#endregion
|
|
1539
|
+
//#region src/help.ts
|
|
1540
|
+
const GLOBAL = `Global options:
|
|
1541
|
+
--json Machine-readable output on stdout (stable shape, see README)
|
|
1542
|
+
--cwd <dir> Project directory (default: current directory)
|
|
1543
|
+
-r, --registry <p> Registry: checkout dir, kit-registry.json path or base URL
|
|
1544
|
+
(default: $GNTIK_UI_REGISTRY, gntik-ui.json "registry", then GitHub main)
|
|
1545
|
+
-y, --yes Accepted for scripts; the CLI never prompts
|
|
1546
|
+
-h, --help Help for a command
|
|
1547
|
+
-v, --version Print the version
|
|
1548
|
+
|
|
1549
|
+
Exit codes: 0 ok · 1 error (registry, install, codemods) · 2 usage · 3 unknown item · 4 file conflicts`;
|
|
1550
|
+
const HELP = {
|
|
1551
|
+
main: `gntik-ui — add the gntik-ui design system to a product.
|
|
1552
|
+
|
|
1553
|
+
Usage: gntik-ui <command> [options]
|
|
1554
|
+
|
|
1555
|
+
Commands:
|
|
1556
|
+
init Set up a project (CSS imports, gntik-ui.json, packages)
|
|
1557
|
+
add <id...> Add components/layouts (npm) or copy blocks/templates into the project
|
|
1558
|
+
eject <id> Copy a component's source into the project so the team owns it
|
|
1559
|
+
list List registry items (--kind, --group)
|
|
1560
|
+
search <query> Fuzzy search on id, name, description and group
|
|
1561
|
+
docs <id> Show an item's description, files, dependencies and keyboard contract
|
|
1562
|
+
upgrade [paths...] Run the codemods for @gntik-ai/* breaking changes (dry run unless --apply)
|
|
1563
|
+
|
|
1564
|
+
${GLOBAL}`,
|
|
1565
|
+
init: `Usage: gntik-ui init [--brand gntik|musematic|falcone] [--dry-run] [--no-install] [--overwrite]
|
|
1566
|
+
|
|
1567
|
+
Detects the framework (Vite, Next, generic), package manager and CSS entry, then:
|
|
1568
|
+
- puts @import "tailwindcss", the Geist fonts and "@gntik-ai/ui/styles.css" at the top of the CSS entry
|
|
1569
|
+
- writes gntik-ui.json (aliases for components, blocks, templates; brand preset); kept if present
|
|
1570
|
+
unless --overwrite
|
|
1571
|
+
- prints the ThemeProvider + themeScript wiring for the framework
|
|
1572
|
+
- installs @gntik-ai/ui @gntik-ai/tokens and the fonts (skipped with --no-install or --dry-run)
|
|
1573
|
+
|
|
1574
|
+
${GLOBAL}`,
|
|
1575
|
+
add: `Usage: gntik-ui add <id...> [--dry-run] [--overwrite] [--no-install]
|
|
1576
|
+
|
|
1577
|
+
Components and layouts install from their npm package; the import line is printed.
|
|
1578
|
+
Blocks and templates are copied into the configured directories with their registry
|
|
1579
|
+
dependencies; imports are rewritten (relative between copied files, package imports for kit
|
|
1580
|
+
code). Existing files that differ are reported as conflicts and nothing is written unless
|
|
1581
|
+
--overwrite is given.
|
|
1582
|
+
|
|
1583
|
+
${GLOBAL}`,
|
|
1584
|
+
eject: `Usage: gntik-ui eject <id> [--no-deps] [--dry-run] [--overwrite] [--no-install]
|
|
1585
|
+
|
|
1586
|
+
Copies the item's source (and its registry dependencies, unless --no-deps) into the components
|
|
1587
|
+
directory, rewrites imports, and lists the imports in the project to switch to the local copy.
|
|
1588
|
+
|
|
1589
|
+
${GLOBAL}`,
|
|
1590
|
+
list: `Usage: gntik-ui list [--kind component|layout|block|template] [--group <group>]
|
|
1591
|
+
|
|
1592
|
+
${GLOBAL}`,
|
|
1593
|
+
search: `Usage: gntik-ui search <query> [--kind <kind>] [--limit <n>]
|
|
1594
|
+
|
|
1595
|
+
All terms must match (id, name, group or description); results are ranked.
|
|
1596
|
+
|
|
1597
|
+
${GLOBAL}`,
|
|
1598
|
+
docs: `Usage: gntik-ui docs <id>
|
|
1599
|
+
|
|
1600
|
+
Prints description, status, package, files, dependencies and the keyboard table from the
|
|
1601
|
+
item's *.doc.ts file.
|
|
1602
|
+
|
|
1603
|
+
${GLOBAL}`,
|
|
1604
|
+
upgrade: `Usage: gntik-ui upgrade [paths...] [--from <v>] [--to <v>] [--codemod <id>...] [--skip-codemod <id>...]
|
|
1605
|
+
[--list] [--apply]
|
|
1606
|
+
|
|
1607
|
+
Migrates the project's code across @gntik-ai/* breaking changes with the transforms in
|
|
1608
|
+
@gntik-ai/codemods, loaded from the project (install it with: pnpm add -D @gntik-ai/codemods).
|
|
1609
|
+
|
|
1610
|
+
--from <v> Version upgrading from (default: the @gntik-ai/* versions in package.json;
|
|
1611
|
+
every codemod when none are declared)
|
|
1612
|
+
--to <v> Version upgrading to (default: latest)
|
|
1613
|
+
--codemod <id> Run only these codemods, whatever the versions (repeatable or comma-separated)
|
|
1614
|
+
--skip-codemod <id> Leave these out (repeatable or comma-separated)
|
|
1615
|
+
--list List the codemods and which ones this upgrade selects; changes nothing
|
|
1616
|
+
--apply Write the changes. Without it the run is dry: it reports the files it would
|
|
1617
|
+
change and the warnings (report-only codemods never write)
|
|
1618
|
+
paths... Files or directories to process (default: the project, without node_modules,
|
|
1619
|
+
dist, build, .next, …)
|
|
1620
|
+
|
|
1621
|
+
Exit code 1 when @gntik-ai/codemods is missing (the install command is printed) or a file fails
|
|
1622
|
+
to parse.
|
|
1623
|
+
|
|
1624
|
+
${GLOBAL}`
|
|
1625
|
+
};
|
|
1626
|
+
//#endregion
|
|
1627
|
+
//#region src/cli.ts
|
|
1628
|
+
const VERSION = version;
|
|
1629
|
+
const HANDLERS = {
|
|
1630
|
+
init: (ctx) => init(ctx),
|
|
1631
|
+
add,
|
|
1632
|
+
eject,
|
|
1633
|
+
list: (ctx) => list(ctx),
|
|
1634
|
+
search,
|
|
1635
|
+
docs,
|
|
1636
|
+
upgrade
|
|
1637
|
+
};
|
|
1638
|
+
/** Runs the CLI and returns the exit code. Never calls process.exit. */
|
|
1639
|
+
async function run(argv, opts = {}) {
|
|
1640
|
+
const stdout = opts.stdout ?? ((s) => process.stdout.write(s));
|
|
1641
|
+
const stderr = opts.stderr ?? ((s) => process.stderr.write(s));
|
|
1642
|
+
const invocationCwd = opts.cwd ?? process.cwd();
|
|
1643
|
+
const wantsJson = argv.includes("--json");
|
|
1644
|
+
let command = null;
|
|
1645
|
+
const fail = (err) => {
|
|
1646
|
+
if (wantsJson) stdout(`${JSON.stringify({
|
|
1647
|
+
ok: false,
|
|
1648
|
+
command,
|
|
1649
|
+
error: {
|
|
1650
|
+
code: err.code,
|
|
1651
|
+
message: err.message
|
|
1652
|
+
}
|
|
1653
|
+
}, null, 2)}\n`);
|
|
1654
|
+
else stderr(`gntik-ui: ${err.message}\n`);
|
|
1655
|
+
return err.exitCode;
|
|
1656
|
+
};
|
|
1657
|
+
try {
|
|
1658
|
+
const args = parseCliArgs(argv);
|
|
1659
|
+
command = args.command ?? null;
|
|
1660
|
+
const { flags } = args;
|
|
1661
|
+
if (flags.version) {
|
|
1662
|
+
stdout(flags.json ? `${JSON.stringify({
|
|
1663
|
+
ok: true,
|
|
1664
|
+
command: "version",
|
|
1665
|
+
version: VERSION
|
|
1666
|
+
})}\n` : `${VERSION}\n`);
|
|
1667
|
+
return 0;
|
|
1668
|
+
}
|
|
1669
|
+
if (flags.help || !args.command) {
|
|
1670
|
+
if (!flags.help && args.positionals.length) throw new CliError("USAGE", `Unknown command "${args.positionals[0]}"`);
|
|
1671
|
+
const help = HELP[args.command ?? "main"];
|
|
1672
|
+
stdout(flags.json ? `${JSON.stringify({
|
|
1673
|
+
ok: true,
|
|
1674
|
+
command: command ?? "help",
|
|
1675
|
+
help
|
|
1676
|
+
})}\n` : `${help}\n`);
|
|
1677
|
+
return flags.help ? 0 : 2;
|
|
1678
|
+
}
|
|
1679
|
+
const ctx = {
|
|
1680
|
+
cwd: path.resolve(invocationCwd, flags.cwd ?? "."),
|
|
1681
|
+
invocationCwd,
|
|
1682
|
+
flags,
|
|
1683
|
+
exec: opts.exec ?? defaultExec,
|
|
1684
|
+
env: opts.env ?? process.env
|
|
1685
|
+
};
|
|
1686
|
+
const result = await HANDLERS[args.command](ctx, args.positionals);
|
|
1687
|
+
if (flags.json) {
|
|
1688
|
+
const out = {
|
|
1689
|
+
ok: result.code === 0,
|
|
1690
|
+
command,
|
|
1691
|
+
...result.data
|
|
1692
|
+
};
|
|
1693
|
+
if (result.error) out.error = {
|
|
1694
|
+
code: result.error.code,
|
|
1695
|
+
message: result.error.message
|
|
1696
|
+
};
|
|
1697
|
+
stdout(`${JSON.stringify(out, null, 2)}\n`);
|
|
1698
|
+
} else {
|
|
1699
|
+
if (result.text.length) stdout(`${result.text.join("\n")}\n`);
|
|
1700
|
+
if (result.error) stderr(`gntik-ui: ${result.error.message}\n`);
|
|
1701
|
+
}
|
|
1702
|
+
return result.code;
|
|
1703
|
+
} catch (e) {
|
|
1704
|
+
if (e instanceof CliError) return fail(e);
|
|
1705
|
+
return fail(new CliError("ERROR", e.message ?? String(e)));
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
//#endregion
|
|
1709
|
+
//#region src/index.ts
|
|
1710
|
+
process.exitCode = await run(process.argv.slice(2));
|
|
1711
|
+
//#endregion
|
|
1712
|
+
export {};
|