uipkge-ng 0.1.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/LICENSE +22 -0
- package/README.md +371 -0
- package/dist/args.js +7 -0
- package/dist/commands/add.js +216 -0
- package/dist/commands/build.js +121 -0
- package/dist/commands/diff.js +140 -0
- package/dist/commands/info.js +122 -0
- package/dist/commands/init.js +154 -0
- package/dist/commands/list.js +103 -0
- package/dist/commands/mcp.js +149 -0
- package/dist/commands/view.js +48 -0
- package/dist/config.js +56 -0
- package/dist/env.js +33 -0
- package/dist/errors.js +9 -0
- package/dist/extras.js +111 -0
- package/dist/files.js +49 -0
- package/dist/index.js +162 -0
- package/dist/layout.js +114 -0
- package/dist/output.js +26 -0
- package/dist/packages.js +68 -0
- package/dist/project.js +145 -0
- package/dist/prompts.js +27 -0
- package/dist/registry.js +187 -0
- package/dist/resolve.js +60 -0
- package/dist/setup.js +169 -0
- package/package.json +50 -0
package/dist/layout.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Where components go and how they import each other, derived from the
|
|
2
|
+
// components.json aliases and the project's tsconfig `paths`.
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { parse } from 'jsonc-parser';
|
|
6
|
+
import { DEFAULT_ALIASES } from './config.js';
|
|
7
|
+
import { UipkgeError } from './errors.js';
|
|
8
|
+
/** Folders the registry targets and the aliases its sources import. */
|
|
9
|
+
export const REGISTRY_DIRS = { ui: 'src/app/components/ui', lib: 'src/lib', blocks: 'src/app/components/blocks' };
|
|
10
|
+
const REGISTRY_IMPORTS = { ui: '@/ui', lib: '@/lib' };
|
|
11
|
+
const posix = (p) => p.replace(/\\/g, '/');
|
|
12
|
+
/** The tsconfig an app's sources compile with: tsconfig.json, else (multi-project workspaces) tsconfig.app.json. */
|
|
13
|
+
export function appTsconfig(root) {
|
|
14
|
+
return !existsSync(path.join(root, 'tsconfig.json')) && existsSync(path.join(root, 'tsconfig.app.json')) ? 'tsconfig.app.json' : 'tsconfig.json';
|
|
15
|
+
}
|
|
16
|
+
/** compilerOptions the way TypeScript sees them, following relative `extends`. */
|
|
17
|
+
export function readTsChain(file, seen = new Set()) {
|
|
18
|
+
if (!existsSync(file) || seen.has(file))
|
|
19
|
+
return { ownPaths: false };
|
|
20
|
+
seen.add(file);
|
|
21
|
+
const doc = (parse(readFileSync(file, 'utf8'), [], { allowTrailingComma: true }) ?? {});
|
|
22
|
+
const dir = path.dirname(file);
|
|
23
|
+
const own = doc.compilerOptions ?? {};
|
|
24
|
+
const ext = typeof doc.extends === 'string' && doc.extends.startsWith('.') ? doc.extends : undefined;
|
|
25
|
+
const parent = ext ? readTsChain(path.resolve(dir, ext.endsWith('.json') ? ext : `${ext}.json`), seen) : { ownPaths: false };
|
|
26
|
+
return {
|
|
27
|
+
baseUrl: own.baseUrl ? path.resolve(dir, own.baseUrl) : parent.baseUrl,
|
|
28
|
+
paths: own.paths ?? parent.paths,
|
|
29
|
+
pathsDir: own.paths ? dir : parent.pathsDir,
|
|
30
|
+
ownPaths: Boolean(own.paths),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function readTsPaths(root, file = appTsconfig(root)) {
|
|
34
|
+
const chain = readTsChain(path.join(root, file));
|
|
35
|
+
return { base: chain.baseUrl ?? chain.pathsDir ?? root, paths: chain.paths ?? {} };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolves an import alias to a project-relative folder the way TypeScript
|
|
39
|
+
* does: exact keys first, then the wildcard key with the longest prefix.
|
|
40
|
+
* Returns null when no mapping covers it.
|
|
41
|
+
*/
|
|
42
|
+
export function aliasToDir(alias, ts, root) {
|
|
43
|
+
const exact = ts.paths[alias]?.[0];
|
|
44
|
+
let target = exact;
|
|
45
|
+
if (!target) {
|
|
46
|
+
let best = -1;
|
|
47
|
+
for (const [pattern, targets] of Object.entries(ts.paths)) {
|
|
48
|
+
const star = pattern.indexOf('*');
|
|
49
|
+
if (star === -1 || !targets[0])
|
|
50
|
+
continue;
|
|
51
|
+
const prefix = pattern.slice(0, star);
|
|
52
|
+
const suffix = pattern.slice(star + 1);
|
|
53
|
+
const probe = `${alias}/`;
|
|
54
|
+
if (probe.startsWith(prefix) && prefix.length > best && (suffix === '' || suffix === '/')) {
|
|
55
|
+
best = prefix.length;
|
|
56
|
+
const rest = probe.slice(prefix.length).replace(/\/$/, '');
|
|
57
|
+
target = targets[0].replace('*', rest);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!target)
|
|
62
|
+
return null;
|
|
63
|
+
const abs = path.resolve(ts.base, target.replace(/\/\*$/, '').replace(/\/$/, ''));
|
|
64
|
+
const rel = posix(path.relative(root, abs));
|
|
65
|
+
return rel.startsWith('..') ? null : rel || '.';
|
|
66
|
+
}
|
|
67
|
+
export function resolveLayout(root, aliases = DEFAULT_ALIASES, ts = readTsPaths(root)) {
|
|
68
|
+
const dirs = {};
|
|
69
|
+
for (const key of ['ui', 'lib', 'blocks']) {
|
|
70
|
+
const dir = aliasToDir(aliases[key], ts, root);
|
|
71
|
+
if (!dir) {
|
|
72
|
+
throw new UipkgeError(`The "${key}" alias "${aliases[key]}" in components.json is not mapped in ${appTsconfig(root)} "paths".`, `Add a mapping such as "${aliases[key]}/*": ["./src/…/*"], or run \`uipkge-ng init\` for the defaults.`);
|
|
73
|
+
}
|
|
74
|
+
dirs[key] = dir;
|
|
75
|
+
}
|
|
76
|
+
return { dirs, aliases };
|
|
77
|
+
}
|
|
78
|
+
const CODE_FILE = /\.(m?[jt]sx?|html)$/;
|
|
79
|
+
/** Rewrites `'@/ui/…'` / `'@/lib/…'` specifiers to the project's aliases. */
|
|
80
|
+
export function rewriteImports(content, aliases) {
|
|
81
|
+
let out = content;
|
|
82
|
+
for (const key of ['ui', 'lib']) {
|
|
83
|
+
const from = REGISTRY_IMPORTS[key];
|
|
84
|
+
const to = aliases[key].replace(/\/+$/, '');
|
|
85
|
+
if (to === from)
|
|
86
|
+
continue;
|
|
87
|
+
out = out.replace(new RegExp(`(['"\`])${from.replace(/[/@]/g, '\\$&')}/`, 'g'), `$1${to}/`);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The item as it should land in this project: targets moved from the
|
|
93
|
+
* registry's folders to the project's, imports rewritten to its aliases.
|
|
94
|
+
* `diff` compares against this too, so alias changes never read as edits.
|
|
95
|
+
*/
|
|
96
|
+
export function adaptItem(item, layout) {
|
|
97
|
+
const moves = ['ui', 'blocks', 'lib']
|
|
98
|
+
.filter(k => layout.dirs[k] !== REGISTRY_DIRS[k])
|
|
99
|
+
.map(k => ({ from: `~/${REGISTRY_DIRS[k]}/`, to: `~/${layout.dirs[k] === '.' ? '' : `${layout.dirs[k]}/`}` }));
|
|
100
|
+
const rewrite = layout.aliases.ui !== REGISTRY_IMPORTS.ui || layout.aliases.lib !== REGISTRY_IMPORTS.lib;
|
|
101
|
+
if (!moves.length && !rewrite)
|
|
102
|
+
return item;
|
|
103
|
+
return {
|
|
104
|
+
...item,
|
|
105
|
+
files: item.files.map(f => {
|
|
106
|
+
let target = f.target;
|
|
107
|
+
const move = target && moves.find(m => target.startsWith(m.from));
|
|
108
|
+
if (target && move)
|
|
109
|
+
target = move.to + target.slice(move.from.length);
|
|
110
|
+
const content = rewrite && typeof f.content === 'string' && CODE_FILE.test(target ?? f.path ?? '') ? rewriteImports(f.content, layout.aliases) : f.content;
|
|
111
|
+
return { ...f, target, content };
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
114
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Line output. Colours only on a real terminal, never with NO_COLOR or TERM=dumb.
|
|
2
|
+
const useColor = (stream) => Boolean(stream.isTTY) && !('NO_COLOR' in process.env) && process.env.TERM !== 'dumb';
|
|
3
|
+
const wrap = (open, close) => (text) => useColor(process.stdout) ? `\u001b[${open}m${text}\u001b[${close}m` : text;
|
|
4
|
+
export const color = {
|
|
5
|
+
bold: wrap(1, 22),
|
|
6
|
+
dim: wrap(2, 22),
|
|
7
|
+
green: wrap(32, 39),
|
|
8
|
+
yellow: wrap(33, 39),
|
|
9
|
+
red: wrap(31, 39),
|
|
10
|
+
cyan: wrap(36, 39),
|
|
11
|
+
};
|
|
12
|
+
let silent = false;
|
|
13
|
+
/** `--silent`: everything but errors is muted. */
|
|
14
|
+
export const setSilent = (value) => void (silent = value);
|
|
15
|
+
export const isSilent = () => silent;
|
|
16
|
+
const out = (text) => void (silent || process.stdout.write(text));
|
|
17
|
+
export const log = {
|
|
18
|
+
info: (message) => out(`${message}\n`),
|
|
19
|
+
step: (message) => out(`${color.dim('›')} ${message}\n`),
|
|
20
|
+
success: (message) => out(`${color.green('✔')} ${message}\n`),
|
|
21
|
+
warn: (message) => out(`${color.yellow('⚠')} ${message}\n`),
|
|
22
|
+
error: (message) => process.stderr.write(`${useColor(process.stderr) ? '\u001b[31m✖\u001b[39m' : '✖'} ${message}\n`),
|
|
23
|
+
blank: () => out('\n'),
|
|
24
|
+
};
|
|
25
|
+
/** Human wording for a count: "1 component", "3 components". */
|
|
26
|
+
export const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
|
package/dist/packages.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { UipkgeError } from './errors.js';
|
|
3
|
+
import { isSilent } from './output.js';
|
|
4
|
+
/** `@scope/name@^1.2` → { name: '@scope/name', version: '^1.2' }; `clsx` → { name: 'clsx' }. */
|
|
5
|
+
export function parseSpec(spec) {
|
|
6
|
+
const at = spec.lastIndexOf('@');
|
|
7
|
+
if (at > 0)
|
|
8
|
+
return { name: spec.slice(0, at), version: spec.slice(at + 1) || undefined };
|
|
9
|
+
return { name: spec };
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Registry items list `@angular/forms` etc. without a version. Installing
|
|
13
|
+
* "latest" into an Angular 21 app would pull a mismatched major, so unversioned
|
|
14
|
+
* `@angular/*` packages are pinned to the project's own `@angular/core` range.
|
|
15
|
+
*/
|
|
16
|
+
export function pinAngular(specs, angularCoreRange) {
|
|
17
|
+
return specs.map(spec => {
|
|
18
|
+
const { name, version } = parseSpec(spec);
|
|
19
|
+
return name.startsWith('@angular/') && !version ? `${name}@${angularCoreRange}` : spec;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** Drops packages the project already declares (any version). */
|
|
23
|
+
export function missingPackages(specs, declared) {
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const spec of specs) {
|
|
27
|
+
const { name } = parseSpec(spec);
|
|
28
|
+
if (declared[name] || seen.has(name))
|
|
29
|
+
continue;
|
|
30
|
+
seen.add(name);
|
|
31
|
+
out.push(spec);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
export function installArgs(pm, packages, dev) {
|
|
36
|
+
const add = pm === 'npm' ? 'install' : 'add';
|
|
37
|
+
const devFlag = dev ? [pm === 'bun' ? '--dev' : '-D'] : [];
|
|
38
|
+
return [add, ...devFlag, ...packages];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Runs the package manager in the project root. Its output is shown as-is,
|
|
42
|
+
* except under --silent, where it is buffered and only printed if it fails.
|
|
43
|
+
*/
|
|
44
|
+
export function installPackages(pm, root, packages, dev = false) {
|
|
45
|
+
if (!packages.length)
|
|
46
|
+
return Promise.resolve();
|
|
47
|
+
const args = installArgs(pm, packages, dev);
|
|
48
|
+
const quiet = isSilent();
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const child = spawn(pm, args, {
|
|
51
|
+
cwd: root,
|
|
52
|
+
stdio: quiet ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
53
|
+
// npm/pnpm/yarn are .cmd shims on Windows.
|
|
54
|
+
shell: process.platform === 'win32',
|
|
55
|
+
});
|
|
56
|
+
const captured = [];
|
|
57
|
+
child.stdout?.on('data', (chunk) => captured.push(chunk));
|
|
58
|
+
child.stderr?.on('data', (chunk) => captured.push(chunk));
|
|
59
|
+
child.on('error', error => reject(new UipkgeError(`Could not run ${pm}: ${error.message}`, `Is ${pm} installed and on your PATH?`)));
|
|
60
|
+
child.on('exit', code => {
|
|
61
|
+
if (code === 0)
|
|
62
|
+
return resolve();
|
|
63
|
+
if (quiet && captured.length)
|
|
64
|
+
process.stderr.write(Buffer.concat(captured));
|
|
65
|
+
reject(new UipkgeError(`\`${pm} ${args.join(' ')}\` failed (exit ${code}).`));
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { parse } from 'jsonc-parser';
|
|
5
|
+
import { UipkgeError } from './errors.js';
|
|
6
|
+
export function declaredDependencies(pkg) {
|
|
7
|
+
return { ...pkg.dependencies, ...pkg.devDependencies };
|
|
8
|
+
}
|
|
9
|
+
/** Nearest directory at or above `start` that holds a package.json. */
|
|
10
|
+
export function findProjectRoot(start) {
|
|
11
|
+
let dir = path.resolve(start);
|
|
12
|
+
for (;;) {
|
|
13
|
+
if (existsSync(path.join(dir, 'package.json')))
|
|
14
|
+
return dir;
|
|
15
|
+
const parent = path.dirname(dir);
|
|
16
|
+
if (parent === dir)
|
|
17
|
+
return null;
|
|
18
|
+
dir = parent;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const LOCKFILES = [
|
|
22
|
+
['bun.lock', 'bun'],
|
|
23
|
+
['bun.lockb', 'bun'],
|
|
24
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
25
|
+
['yarn.lock', 'yarn'],
|
|
26
|
+
['package-lock.json', 'npm'],
|
|
27
|
+
];
|
|
28
|
+
/** Lockfile at `root` or above (a workspace member's lockfile lives at the workspace root), else `packageManager`. */
|
|
29
|
+
export function detectPackageManager(root, pkg) {
|
|
30
|
+
for (let dir = path.resolve(root);; dir = path.dirname(dir)) {
|
|
31
|
+
for (const [file, pm] of LOCKFILES)
|
|
32
|
+
if (existsSync(path.join(dir, file)))
|
|
33
|
+
return pm;
|
|
34
|
+
if (path.dirname(dir) === dir)
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
const declared = pkg.packageManager?.split('@')[0];
|
|
38
|
+
if (declared === 'pnpm' || declared === 'yarn' || declared === 'bun' || declared === 'npm')
|
|
39
|
+
return declared;
|
|
40
|
+
return 'npm';
|
|
41
|
+
}
|
|
42
|
+
export async function readPackageJson(root) {
|
|
43
|
+
const file = path.join(root, 'package.json');
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
throw new UipkgeError(`Could not read ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const isInside = (child, parent) => {
|
|
52
|
+
const rel = path.relative(parent, child);
|
|
53
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
54
|
+
};
|
|
55
|
+
/** Application projects in angular.json (libraries have no global styles to theme). */
|
|
56
|
+
export function angularApps(packageRoot) {
|
|
57
|
+
const file = path.join(packageRoot, 'angular.json');
|
|
58
|
+
if (!existsSync(file))
|
|
59
|
+
return [];
|
|
60
|
+
const doc = (parse(readFileSync(file, 'utf8')) ?? {});
|
|
61
|
+
return Object.entries(doc.projects ?? {})
|
|
62
|
+
.filter(([, p]) => (p.projectType ?? 'application') === 'application')
|
|
63
|
+
.map(([name, p]) => {
|
|
64
|
+
const root = path.resolve(packageRoot, p.root ?? '');
|
|
65
|
+
const styles = ((p.architect ?? p.targets)?.build?.options?.styles ?? [])
|
|
66
|
+
.map(s => (typeof s === 'string' ? s : s.input))
|
|
67
|
+
.filter((s) => Boolean(s))
|
|
68
|
+
.map(s => path.relative(root, path.resolve(packageRoot, s)).replace(/\\/g, '/'));
|
|
69
|
+
return { name, root, styles };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Which Angular app `cwd` belongs to. An existing components.json between cwd
|
|
74
|
+
* and the package root wins; otherwise the angular.json project containing
|
|
75
|
+
* cwd, the only app, or the only initialized app. Null outside a package.
|
|
76
|
+
*/
|
|
77
|
+
export function findApp(cwd) {
|
|
78
|
+
const packageRoot = findProjectRoot(cwd);
|
|
79
|
+
if (!packageRoot)
|
|
80
|
+
return null;
|
|
81
|
+
const apps = angularApps(packageRoot);
|
|
82
|
+
for (let dir = path.resolve(cwd); isInside(dir, packageRoot); dir = path.dirname(dir)) {
|
|
83
|
+
if (existsSync(path.join(dir, 'components.json')))
|
|
84
|
+
return { root: dir, packageRoot, app: apps.find(a => a.root === dir) };
|
|
85
|
+
if (dir === packageRoot)
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
const containing = apps.filter(a => a.root !== packageRoot && isInside(path.resolve(cwd), a.root)).sort((a, b) => b.root.length - a.root.length)[0];
|
|
89
|
+
const initialized = apps.filter(a => existsSync(path.join(a.root, 'components.json')));
|
|
90
|
+
const app = containing ?? (apps.length === 1 ? apps[0] : apps.find(a => a.root === packageRoot) ?? (initialized.length === 1 ? initialized[0] : undefined));
|
|
91
|
+
if (app)
|
|
92
|
+
return { root: app.root, packageRoot, app };
|
|
93
|
+
return { root: packageRoot, packageRoot, ambiguous: apps.length > 1 ? apps : undefined };
|
|
94
|
+
}
|
|
95
|
+
/** The folder uipkge reads components.json from for `cwd`, or null outside a package. */
|
|
96
|
+
export const findAppRoot = (cwd) => findApp(cwd)?.root ?? null;
|
|
97
|
+
/** Loads the Angular project containing `cwd`, or explains why it can't be used. */
|
|
98
|
+
export async function loadProject(cwd) {
|
|
99
|
+
if (!existsSync(cwd))
|
|
100
|
+
throw new UipkgeError(`The directory ${cwd} does not exist.`);
|
|
101
|
+
const location = findApp(cwd);
|
|
102
|
+
if (!location)
|
|
103
|
+
throw new UipkgeError('No package.json found.', 'Run this inside your Angular project.');
|
|
104
|
+
const { root, packageRoot } = location;
|
|
105
|
+
if (location.ambiguous) {
|
|
106
|
+
const first = path.relative(cwd, location.ambiguous[0].root) || '.';
|
|
107
|
+
throw new UipkgeError(`This workspace has ${location.ambiguous.length} apps: ${location.ambiguous.map(a => a.name).join(', ')}.`, `Run uipkge-ng inside the app's folder, or pass it with \`-c ${first}\`.`);
|
|
108
|
+
}
|
|
109
|
+
const packageJson = await readPackageJson(packageRoot);
|
|
110
|
+
const deps = declaredDependencies(packageJson);
|
|
111
|
+
const angularCore = deps['@angular/core'];
|
|
112
|
+
if (!angularCore) {
|
|
113
|
+
throw new UipkgeError(`${root} is not an Angular project (no @angular/core in package.json).`);
|
|
114
|
+
}
|
|
115
|
+
if (deps['nx'] || existsSync(path.join(packageRoot, 'nx.json'))) {
|
|
116
|
+
throw new UipkgeError('Nx workspaces are not supported yet.', 'Run uipkge-ng inside a standalone Angular CLI app.');
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
root,
|
|
120
|
+
packageRoot,
|
|
121
|
+
app: location.app,
|
|
122
|
+
packageJson,
|
|
123
|
+
angularCore,
|
|
124
|
+
isAnalog: Boolean(deps['@analogjs/platform'] || deps['@analogjs/vite-plugin-angular']),
|
|
125
|
+
hasTailwind: Boolean(deps['tailwindcss']),
|
|
126
|
+
packageManager: detectPackageManager(packageRoot, packageJson),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Resolves a registry `target` (`~/src/app/…`) to an absolute path inside the
|
|
131
|
+
* project. Targets come from the network, so anything that would escape the
|
|
132
|
+
* project root (absolute paths, `..`) is refused rather than written.
|
|
133
|
+
*/
|
|
134
|
+
export function resolveTarget(root, target) {
|
|
135
|
+
const relative = target.replace(/^~\//, '');
|
|
136
|
+
if (path.isAbsolute(relative) || /^[a-zA-Z]:/.test(relative)) {
|
|
137
|
+
throw new UipkgeError(`Refusing to write outside the project: ${target}`);
|
|
138
|
+
}
|
|
139
|
+
const absolute = path.resolve(root, relative);
|
|
140
|
+
const rel = path.relative(root, absolute);
|
|
141
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
142
|
+
throw new UipkgeError(`Refusing to write outside the project: ${target}`);
|
|
143
|
+
}
|
|
144
|
+
return absolute;
|
|
145
|
+
}
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import { UipkgeError } from './errors.js';
|
|
3
|
+
/** Prompts only when a person is at a real terminal — never in CI or pipes. */
|
|
4
|
+
export function isInteractive() {
|
|
5
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY) && !process.env.CI;
|
|
6
|
+
}
|
|
7
|
+
export class CancelledError extends UipkgeError {
|
|
8
|
+
constructor() {
|
|
9
|
+
super('Cancelled. Nothing was changed.');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export async function confirm(message, initialValue = true) {
|
|
13
|
+
const answer = await p.confirm({ message, initialValue });
|
|
14
|
+
if (p.isCancel(answer))
|
|
15
|
+
throw new CancelledError();
|
|
16
|
+
return answer;
|
|
17
|
+
}
|
|
18
|
+
export async function pickComponents(options) {
|
|
19
|
+
const answer = await p.autocompleteMultiselect({
|
|
20
|
+
message: 'Which components do you want to add? (type to search, space to select)',
|
|
21
|
+
options,
|
|
22
|
+
required: true,
|
|
23
|
+
});
|
|
24
|
+
if (p.isCancel(answer))
|
|
25
|
+
throw new CancelledError();
|
|
26
|
+
return answer;
|
|
27
|
+
}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { UipkgeError } from './errors.js';
|
|
4
|
+
export const DEFAULT_REGISTRY_URL = 'https://uipkge.dev/r/angular';
|
|
5
|
+
/** Items only `init` installs; never listed or added by name. */
|
|
6
|
+
export const FOUNDATION_ITEMS = new Set(['init', 'tailwind', 'utils']);
|
|
7
|
+
export function resolveRegistryUrl(configured) {
|
|
8
|
+
const url = process.env.UIPKGE_REGISTRY_URL || configured || DEFAULT_REGISTRY_URL;
|
|
9
|
+
return url.replace(/\/+$/, '');
|
|
10
|
+
}
|
|
11
|
+
/** A registry answered 404: the item isn't there (as opposed to unreachable or refused). */
|
|
12
|
+
export class NotFoundError extends UipkgeError {
|
|
13
|
+
url;
|
|
14
|
+
constructor(url) {
|
|
15
|
+
super(`Not found in the registry: ${url}`);
|
|
16
|
+
this.url = url;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const NAMESPACED = /^(@[a-z0-9][\w-]*)\/([^/\s]+)$/i;
|
|
20
|
+
/** A name from the default registry, `@ns/name` from a named registry, an absolute item URL, or a local item file. */
|
|
21
|
+
export function refKind(ref) {
|
|
22
|
+
if (/^https?:\/\//.test(ref))
|
|
23
|
+
return 'url';
|
|
24
|
+
if (NAMESPACED.test(ref) && !ref.endsWith('.json'))
|
|
25
|
+
return 'namespaced';
|
|
26
|
+
if (/^(\.{1,2}[\\/]|[\\/]|~[\\/]|[a-zA-Z]:[\\/])/.test(ref) || ref.endsWith('.json'))
|
|
27
|
+
return 'file';
|
|
28
|
+
return 'name';
|
|
29
|
+
}
|
|
30
|
+
/** `https://…/r/angular/popper.json` → `popper`; `@acme/button` → `button`; plain names pass through. */
|
|
31
|
+
export function itemNameFromRef(ref) {
|
|
32
|
+
const namespaced = ref.match(NAMESPACED);
|
|
33
|
+
if (namespaced)
|
|
34
|
+
return namespaced[2];
|
|
35
|
+
const last = ref.split(/[?#]/)[0].split('/').pop() ?? ref;
|
|
36
|
+
return last.replace(/\.json$/, '');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Expands `${VAR}` from `env`. A missing variable is an error (a request with
|
|
40
|
+
* "Bearer " and no token would just fail later with a confusing 401).
|
|
41
|
+
*/
|
|
42
|
+
export function expandEnv(value, env, where) {
|
|
43
|
+
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => {
|
|
44
|
+
const found = env[name];
|
|
45
|
+
if (found === undefined || found === '') {
|
|
46
|
+
throw new UipkgeError(`${where} needs the environment variable ${name}, which is not set.`, `Set ${name} in your shell or in .env / .env.local.`);
|
|
47
|
+
}
|
|
48
|
+
return found;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export function namedRegistryFrom(ns, config, env) {
|
|
52
|
+
const raw = typeof config === 'string' ? { url: config } : config;
|
|
53
|
+
if (!raw.url || !raw.url.includes('{name}')) {
|
|
54
|
+
throw new UipkgeError(`Registry "${ns}" needs a url containing {name}, e.g. "https://example.com/r/{name}.json".`);
|
|
55
|
+
}
|
|
56
|
+
const where = `Registry "${ns}"`;
|
|
57
|
+
const expand = (obj) => Object.fromEntries(Object.entries(obj ?? {}).map(([k, v]) => [k, expandEnv(v, env, where)]));
|
|
58
|
+
return { url: expandEnv(raw.url, env, where), headers: expand(raw.headers), params: expand(raw.params) };
|
|
59
|
+
}
|
|
60
|
+
/** `{name}` substituted, `params` appended as a query string. */
|
|
61
|
+
export function namedItemUrl(reg, name) {
|
|
62
|
+
const url = new URL(reg.url.replace('{name}', encodeURIComponent(name)));
|
|
63
|
+
for (const [k, v] of Object.entries(reg.params))
|
|
64
|
+
url.searchParams.set(k, v);
|
|
65
|
+
return url.toString();
|
|
66
|
+
}
|
|
67
|
+
export function createRegistry(baseUrl, fetchOrOptions, cwdArg) {
|
|
68
|
+
// Back-compat signature: createRegistry(url, fetch?, cwd?).
|
|
69
|
+
const options = typeof fetchOrOptions === 'function' ? { fetch: fetchOrOptions, cwd: cwdArg } : { ...fetchOrOptions, cwd: fetchOrOptions?.cwd ?? cwdArg };
|
|
70
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
71
|
+
const cwd = options.cwd ?? process.cwd();
|
|
72
|
+
const env = options.env ?? process.env;
|
|
73
|
+
const rawRegistries = options.registries ?? {};
|
|
74
|
+
const named = new Map();
|
|
75
|
+
const indexes = new Map();
|
|
76
|
+
const items = new Map();
|
|
77
|
+
function namespace(ns) {
|
|
78
|
+
let reg = named.get(ns);
|
|
79
|
+
if (!reg) {
|
|
80
|
+
const raw = rawRegistries[ns];
|
|
81
|
+
if (!raw) {
|
|
82
|
+
const known = Object.keys(rawRegistries);
|
|
83
|
+
throw new UipkgeError(`Unknown registry "${ns}".`, known.length ? `Configured registries: ${known.join(', ')}.` : 'Add it under "registries" in components.json.');
|
|
84
|
+
}
|
|
85
|
+
reg = namedRegistryFrom(ns, raw, env);
|
|
86
|
+
named.set(ns, reg);
|
|
87
|
+
}
|
|
88
|
+
return reg;
|
|
89
|
+
}
|
|
90
|
+
async function getJson(url, headers) {
|
|
91
|
+
let res;
|
|
92
|
+
try {
|
|
93
|
+
res = await fetchImpl(url, headers && Object.keys(headers).length ? { headers } : undefined);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
throw new UipkgeError(`Could not reach ${url}: ${error instanceof Error ? error.message : String(error)}`, 'Check your connection, or set UIPKGE_REGISTRY_URL if you use a mirror.');
|
|
97
|
+
}
|
|
98
|
+
if (res.status === 404)
|
|
99
|
+
throw new NotFoundError(url);
|
|
100
|
+
if (res.status === 401 || res.status === 403) {
|
|
101
|
+
throw new UipkgeError(`The registry refused access (${res.status}): ${url}`, 'Check the token/headers configured for this registry.');
|
|
102
|
+
}
|
|
103
|
+
if (!res.ok)
|
|
104
|
+
throw new UipkgeError(`Registry request failed (${res.status}): ${url}`);
|
|
105
|
+
try {
|
|
106
|
+
return await res.json();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new UipkgeError(`The registry returned invalid JSON: ${url}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function asIndex(data, url) {
|
|
113
|
+
const index = data;
|
|
114
|
+
if (!index || !Array.isArray(index.items))
|
|
115
|
+
throw new UipkgeError(`Unexpected registry index format: ${url}`);
|
|
116
|
+
return index;
|
|
117
|
+
}
|
|
118
|
+
function asItem(data, where) {
|
|
119
|
+
const item = data;
|
|
120
|
+
if (!item || typeof item.name !== 'string' || !Array.isArray(item.files)) {
|
|
121
|
+
throw new UipkgeError(`Unexpected registry item format: ${where}`);
|
|
122
|
+
}
|
|
123
|
+
return item;
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
baseUrl,
|
|
127
|
+
namespaces: Object.keys(rawRegistries),
|
|
128
|
+
async index(ns) {
|
|
129
|
+
const key = ns ?? '';
|
|
130
|
+
let pending = indexes.get(key);
|
|
131
|
+
if (!pending) {
|
|
132
|
+
if (ns) {
|
|
133
|
+
const reg = namespace(ns);
|
|
134
|
+
const url = namedItemUrl(reg, 'registry');
|
|
135
|
+
pending = getJson(url, reg.headers).then(d => asIndex(d, url));
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const url = `${baseUrl}/registry.json`;
|
|
139
|
+
pending = getJson(url).then(d => asIndex(d, url));
|
|
140
|
+
}
|
|
141
|
+
indexes.set(key, pending);
|
|
142
|
+
}
|
|
143
|
+
return pending;
|
|
144
|
+
},
|
|
145
|
+
async item(ref) {
|
|
146
|
+
const kind = refKind(ref);
|
|
147
|
+
let key;
|
|
148
|
+
let load;
|
|
149
|
+
if (kind === 'file') {
|
|
150
|
+
key = path.resolve(cwd, ref.replace(/^~(?=[\\/])/, process.env.HOME ?? '~'));
|
|
151
|
+
load = () => readLocal(key);
|
|
152
|
+
}
|
|
153
|
+
else if (kind === 'namespaced') {
|
|
154
|
+
const [, ns, name] = ref.match(NAMESPACED);
|
|
155
|
+
const reg = namespace(ns);
|
|
156
|
+
key = namedItemUrl(reg, name);
|
|
157
|
+
// Headers go only to this registry's own URLs.
|
|
158
|
+
load = () => getJson(key, reg.headers);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
key = kind === 'url' ? ref : `${baseUrl}/${ref}.json`;
|
|
162
|
+
load = () => getJson(key);
|
|
163
|
+
}
|
|
164
|
+
let pending = items.get(key);
|
|
165
|
+
if (!pending) {
|
|
166
|
+
pending = load().then(d => asItem(d, key));
|
|
167
|
+
items.set(key, pending);
|
|
168
|
+
}
|
|
169
|
+
return pending;
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
async function readLocal(file) {
|
|
174
|
+
let text;
|
|
175
|
+
try {
|
|
176
|
+
text = await readFile(file, 'utf8');
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
throw new UipkgeError(`Could not read the item file ${file}.`);
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
return JSON.parse(text);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
throw new UipkgeError(`${file} is not valid JSON.`);
|
|
186
|
+
}
|
|
187
|
+
}
|
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { UipkgeError } from './errors.js';
|
|
2
|
+
import { FOUNDATION_ITEMS, itemNameFromRef, refKind } from './registry.js';
|
|
3
|
+
/** Throws one error listing every name the index does not know. Nothing is installed. */
|
|
4
|
+
export function assertKnownNames(names, known, consequence = '') {
|
|
5
|
+
const unknown = names.filter(n => !known.has(n));
|
|
6
|
+
if (!unknown.length)
|
|
7
|
+
return;
|
|
8
|
+
const list = unknown.map(n => `\`${n}\``).join(', ');
|
|
9
|
+
throw new UipkgeError(`${unknown.length === 1 ? 'Component' : 'Components'} not found in the registry: ${list}.${consequence ? ` ${consequence}` : ''}`, 'See what is available with `uipkge-ng list`.');
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Walks `registryDependencies` depth-first so every dependency is written before
|
|
13
|
+
* its dependents. Each item is visited once (shared deps, cycles). An installed
|
|
14
|
+
* item is skipped — unless `overwrite` — but its dependencies are still walked,
|
|
15
|
+
* so a partially installed tree gets completed.
|
|
16
|
+
*/
|
|
17
|
+
export async function planInstall(registry, names, isInstalled, overwrite,
|
|
18
|
+
/** Applied to each fetched item before anything else (e.g. `--path` relocation of requested items). */
|
|
19
|
+
transform = item => item) {
|
|
20
|
+
// Only bare names are looked up in the index; URLs and local files stand alone.
|
|
21
|
+
const bare = names.filter(n => refKind(n) === 'name');
|
|
22
|
+
if (bare.length) {
|
|
23
|
+
const index = await registry.index();
|
|
24
|
+
assertKnownNames(bare, new Set(index.items.map(i => i.name)), 'Nothing was installed.');
|
|
25
|
+
}
|
|
26
|
+
const visited = new Set();
|
|
27
|
+
const install = [];
|
|
28
|
+
const skipped = [];
|
|
29
|
+
const dependencies = new Set();
|
|
30
|
+
const devDependencies = new Set();
|
|
31
|
+
async function visit(ref, direct) {
|
|
32
|
+
// The default registry's foundation is init's job; a component depending on
|
|
33
|
+
// `utils` must not reinstall it. `@acme/utils` is someone else's item.
|
|
34
|
+
const kind = refKind(ref);
|
|
35
|
+
const ownRegistry = kind === 'name' || (kind === 'url' && ref.startsWith(`${registry.baseUrl}/`));
|
|
36
|
+
if (ownRegistry && FOUNDATION_ITEMS.has(itemNameFromRef(ref)))
|
|
37
|
+
return undefined;
|
|
38
|
+
const item = transform(await registry.item(ref), direct);
|
|
39
|
+
if (visited.has(item.name))
|
|
40
|
+
return item.name;
|
|
41
|
+
visited.add(item.name);
|
|
42
|
+
for (const dep of item.registryDependencies ?? [])
|
|
43
|
+
await visit(dep, false);
|
|
44
|
+
if (isInstalled(item) && !overwrite) {
|
|
45
|
+
skipped.push(item.name);
|
|
46
|
+
return item.name;
|
|
47
|
+
}
|
|
48
|
+
install.push(item);
|
|
49
|
+
item.dependencies?.forEach(d => dependencies.add(d));
|
|
50
|
+
item.devDependencies?.forEach(d => devDependencies.add(d));
|
|
51
|
+
return item.name;
|
|
52
|
+
}
|
|
53
|
+
const requested = [];
|
|
54
|
+
for (const name of names) {
|
|
55
|
+
const resolved = await visit(name, true);
|
|
56
|
+
if (resolved && !requested.includes(resolved))
|
|
57
|
+
requested.push(resolved);
|
|
58
|
+
}
|
|
59
|
+
return { install, skipped, requested, dependencies: [...dependencies], devDependencies: [...devDependencies] };
|
|
60
|
+
}
|