regpick 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yml +51 -0
- package/.release-it.json +22 -0
- package/CHANGELOG.md +12 -0
- package/README.md +56 -0
- package/bin/regpick.js +3 -0
- package/docs/mvp-decisions.md +77 -0
- package/examples/README.md +26 -0
- package/examples/complex-ui-registry/registry.json +35 -0
- package/examples/simple-utils-registry/registry.json +28 -0
- package/package.json +39 -0
- package/regpick.config.schema.json +40 -0
- package/src/commands/add.ts +261 -0
- package/src/commands/init.ts +89 -0
- package/src/commands/list.ts +54 -0
- package/src/commands/pack.ts +97 -0
- package/src/commands/update.ts +139 -0
- package/src/core/__tests__/result-errors.test.ts +19 -0
- package/src/core/errors.ts +36 -0
- package/src/core/result.ts +19 -0
- package/src/domain/__tests__/addPlan.test.ts +64 -0
- package/src/domain/__tests__/initCore.test.ts +28 -0
- package/src/domain/__tests__/listCore.test.ts +29 -0
- package/src/domain/__tests__/pathPolicy.test.ts +64 -0
- package/src/domain/__tests__/registryModel.test.ts +32 -0
- package/src/domain/__tests__/selection.test.ts +58 -0
- package/src/domain/addPlan.ts +51 -0
- package/src/domain/aliasCore.ts +13 -0
- package/src/domain/initCore.ts +15 -0
- package/src/domain/listCore.ts +34 -0
- package/src/domain/packCore.ts +44 -0
- package/src/domain/pathPolicy.ts +61 -0
- package/src/domain/registryModel.ts +100 -0
- package/src/domain/selection.ts +47 -0
- package/src/index.ts +117 -0
- package/src/shell/cli/args.ts +37 -0
- package/src/shell/config.ts +105 -0
- package/src/shell/installer.ts +70 -0
- package/src/shell/lockfile.ts +35 -0
- package/src/shell/packageManagers/__tests__/resolver.test.ts +61 -0
- package/src/shell/packageManagers/__tests__/strategy.test.ts +40 -0
- package/src/shell/packageManagers/resolver.ts +27 -0
- package/src/shell/packageManagers/strategy.ts +65 -0
- package/src/shell/registry.ts +182 -0
- package/src/shell/runtime/ports.ts +200 -0
- package/src/types.ts +92 -0
- package/test-clack.ts +2 -0
- package/tsconfig.json +15 -0
- package/tsdown.config.ts +8 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { appError, type AppError } from "../core/errors.js";
|
|
2
|
+
import { err, ok, type Result } from "../core/result.js";
|
|
3
|
+
import type { CommandContext, CommandOutcome } from "../types.js";
|
|
4
|
+
import {
|
|
5
|
+
decideInitAfterFirstWrite,
|
|
6
|
+
decideInitAfterOverwritePrompt,
|
|
7
|
+
} from "../domain/initCore.js";
|
|
8
|
+
import { getConfigPath, writeConfig, readConfig } from "../shell/config.js";
|
|
9
|
+
|
|
10
|
+
export async function runInitCommand(
|
|
11
|
+
context: CommandContext,
|
|
12
|
+
): Promise<Result<CommandOutcome, AppError>> {
|
|
13
|
+
const outputPath = getConfigPath(context.cwd);
|
|
14
|
+
const existsRes = await context.runtime.fs.stat(outputPath);
|
|
15
|
+
|
|
16
|
+
if (existsRes.ok) {
|
|
17
|
+
const shouldOverwrite = await context.runtime.prompt.confirm({
|
|
18
|
+
message: `${outputPath} already exists. Overwrite?`,
|
|
19
|
+
initialValue: false,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const secondDecision = decideInitAfterOverwritePrompt(
|
|
23
|
+
context.runtime.prompt.isCancel(shouldOverwrite),
|
|
24
|
+
Boolean(shouldOverwrite),
|
|
25
|
+
);
|
|
26
|
+
if (secondDecision === "cancelled") {
|
|
27
|
+
return err(appError("UserCancelled", "Operation cancelled."));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (secondDecision === "keep") {
|
|
31
|
+
context.runtime.prompt.info("Keeping existing configuration.");
|
|
32
|
+
return ok({ kind: "noop", message: "Keeping existing configuration." });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { config: existingConfig } = await readConfig(context.cwd);
|
|
37
|
+
|
|
38
|
+
const packageManager = await context.runtime.prompt.select({
|
|
39
|
+
message: "Jakiego menedżera pakietów używasz?",
|
|
40
|
+
options: [
|
|
41
|
+
{ value: "auto", label: "Auto (wykrywanie)" },
|
|
42
|
+
{ value: "npm", label: "npm" },
|
|
43
|
+
{ value: "yarn", label: "yarn" },
|
|
44
|
+
{ value: "pnpm", label: "pnpm" }
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (context.runtime.prompt.isCancel(packageManager)) {
|
|
49
|
+
return err(appError("UserCancelled", "Operation cancelled."));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const componentsFolder = await context.runtime.prompt.text({
|
|
53
|
+
message: "W jakim folderze trzymasz komponenty UI?",
|
|
54
|
+
placeholder: "src/components/ui",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (context.runtime.prompt.isCancel(componentsFolder)) {
|
|
58
|
+
return err(appError("UserCancelled", "Operation cancelled."));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const overwritePolicy = await context.runtime.prompt.select({
|
|
62
|
+
message: "Czy chcesz nadpisywać pliki automatycznie, czy wolisz być pytany?",
|
|
63
|
+
options: [
|
|
64
|
+
{ value: "prompt", label: "Pytaj (prompt)" },
|
|
65
|
+
{ value: "overwrite", label: "Zawsze nadpisuj (overwrite)" },
|
|
66
|
+
{ value: "skip", label: "Pomijaj nadpisywanie (skip)" }
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (context.runtime.prompt.isCancel(overwritePolicy)) {
|
|
71
|
+
return err(appError("UserCancelled", "Operation cancelled."));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const newConfig = {
|
|
75
|
+
...existingConfig,
|
|
76
|
+
packageManager: String(packageManager) as any,
|
|
77
|
+
overwritePolicy: String(overwritePolicy) as any,
|
|
78
|
+
targetsByType: {
|
|
79
|
+
...existingConfig.targetsByType,
|
|
80
|
+
"registry:component": String(componentsFolder || "src/components/ui"),
|
|
81
|
+
"registry:file": String(componentsFolder || "src/components/ui"),
|
|
82
|
+
"registry:icon": `${String(componentsFolder || "src/components/ui")}/icons`,
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
await writeConfig(context.cwd, newConfig, { overwrite: true });
|
|
87
|
+
context.runtime.prompt.success(`${existsRes.ok ? "Overwrote" : "Created"} ${outputPath}`);
|
|
88
|
+
return ok({ kind: "success", message: `${existsRes.ok ? "Overwrote" : "Created"} ${outputPath}` });
|
|
89
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { appError, type AppError } from "../core/errors.js";
|
|
2
|
+
import { err, ok, type Result } from "../core/result.js";
|
|
3
|
+
import type { CommandContext, CommandOutcome, RegistryItem } from "../types.js";
|
|
4
|
+
import { resolveListSourceDecision } from "../domain/listCore.js";
|
|
5
|
+
import { readConfig } from "../shell/config.js";
|
|
6
|
+
import { loadRegistry } from "../shell/registry.js";
|
|
7
|
+
|
|
8
|
+
function formatItemLabel(item: RegistryItem): string {
|
|
9
|
+
const type = item.type || "registry:file";
|
|
10
|
+
const fileCount = Array.isArray(item.files) ? item.files.length : 0;
|
|
11
|
+
return `${item.name} (${type}, files: ${fileCount})`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function runListCommand(
|
|
15
|
+
context: CommandContext,
|
|
16
|
+
): Promise<Result<CommandOutcome, AppError>> {
|
|
17
|
+
const { config } = await readConfig(context.cwd);
|
|
18
|
+
const sourceDecision = resolveListSourceDecision(context.args.positionals[1], config.registries);
|
|
19
|
+
|
|
20
|
+
let source = sourceDecision.source;
|
|
21
|
+
if (sourceDecision.requiresPrompt) {
|
|
22
|
+
const response = await context.runtime.prompt.text({
|
|
23
|
+
message: "Registry URL/path:",
|
|
24
|
+
placeholder: "https://example.com/registry.json",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (context.runtime.prompt.isCancel(response)) {
|
|
28
|
+
return err(appError("UserCancelled", "Operation cancelled."));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
source = String(response);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!source) {
|
|
35
|
+
return ok({ kind: "noop", message: "No registry source provided." });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const registryResult = await loadRegistry(source, context.cwd, context.runtime);
|
|
39
|
+
if (!registryResult.ok) {
|
|
40
|
+
return registryResult;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { items } = registryResult.value;
|
|
44
|
+
if (!items.length) {
|
|
45
|
+
context.runtime.prompt.warn("No items found in registry.");
|
|
46
|
+
return ok({ kind: "noop", message: "No items found in registry." });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
context.runtime.prompt.info(`Found ${items.length} items.`);
|
|
50
|
+
for (const item of items) {
|
|
51
|
+
console.log(`- ${formatItemLabel(item)}`);
|
|
52
|
+
}
|
|
53
|
+
return ok({ kind: "success", message: `Listed ${items.length} item(s).` });
|
|
54
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
|
|
4
|
+
import { appError, type AppError } from "../core/errors.js";
|
|
5
|
+
import { err, ok, type Result } from "../core/result.js";
|
|
6
|
+
import type { CommandContext, CommandOutcome, RegistryItem } from "../types.js";
|
|
7
|
+
import { extractDependencies } from "../domain/packCore.js";
|
|
8
|
+
|
|
9
|
+
async function getFilesRecursive(
|
|
10
|
+
dir: string,
|
|
11
|
+
context: CommandContext,
|
|
12
|
+
): Promise<Result<string[], AppError>> {
|
|
13
|
+
const result: string[] = [];
|
|
14
|
+
|
|
15
|
+
async function scan(currentDir: string): Promise<Result<void, AppError>> {
|
|
16
|
+
const dirRes = await context.runtime.fs.readdir(currentDir);
|
|
17
|
+
if (!dirRes.ok) return dirRes;
|
|
18
|
+
|
|
19
|
+
for (const file of dirRes.value) {
|
|
20
|
+
const fullPath = path.join(currentDir, file);
|
|
21
|
+
const statRes = await context.runtime.fs.stat(fullPath);
|
|
22
|
+
if (!statRes.ok) return statRes;
|
|
23
|
+
|
|
24
|
+
if (statRes.value.isDirectory()) {
|
|
25
|
+
const scanRes = await scan(fullPath);
|
|
26
|
+
if (!scanRes.ok) return scanRes;
|
|
27
|
+
} else {
|
|
28
|
+
if (fullPath.endsWith(".ts") || fullPath.endsWith(".tsx")) {
|
|
29
|
+
result.push(fullPath);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return ok(undefined);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const scanRes = await scan(dir);
|
|
37
|
+
if (!scanRes.ok) return err(scanRes.error);
|
|
38
|
+
return ok(result);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function runPackCommand(
|
|
42
|
+
context: CommandContext,
|
|
43
|
+
): Promise<Result<CommandOutcome, AppError>> {
|
|
44
|
+
const targetDirArg = context.args.positionals[1] || ".";
|
|
45
|
+
const targetDir = path.resolve(context.cwd, targetDirArg);
|
|
46
|
+
|
|
47
|
+
const statRes = await context.runtime.fs.stat(targetDir);
|
|
48
|
+
if (!statRes.ok || !statRes.value.isDirectory()) {
|
|
49
|
+
return err(appError("ValidationError", `Target is not a directory: ${targetDir}`));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
context.runtime.prompt.info(`Scanning ${targetDir} for components...`);
|
|
53
|
+
|
|
54
|
+
const filesRes = await getFilesRecursive(targetDir, context);
|
|
55
|
+
if (!filesRes.ok) return filesRes;
|
|
56
|
+
|
|
57
|
+
const files = filesRes.value;
|
|
58
|
+
if (files.length === 0) {
|
|
59
|
+
context.runtime.prompt.warn("No .ts or .tsx files found.");
|
|
60
|
+
return ok({ kind: "noop", message: "No files found." });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const items: RegistryItem[] = [];
|
|
64
|
+
|
|
65
|
+
for (const file of files) {
|
|
66
|
+
const contentRes = await context.runtime.fs.readFile(file, "utf8");
|
|
67
|
+
if (!contentRes.ok) return err(contentRes.error);
|
|
68
|
+
|
|
69
|
+
const dependencies = extractDependencies(contentRes.value);
|
|
70
|
+
const relativePath = path.relative(targetDir, file).replace(/\\/g, "/");
|
|
71
|
+
const name = path.basename(file, path.extname(file));
|
|
72
|
+
|
|
73
|
+
items.push({
|
|
74
|
+
name,
|
|
75
|
+
title: name,
|
|
76
|
+
description: "Packed component",
|
|
77
|
+
type: "registry:component",
|
|
78
|
+
dependencies,
|
|
79
|
+
devDependencies: [],
|
|
80
|
+
registryDependencies: [],
|
|
81
|
+
files: [
|
|
82
|
+
{
|
|
83
|
+
path: relativePath,
|
|
84
|
+
type: "registry:component",
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
} as any);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const registry = { items };
|
|
91
|
+
const outPath = path.join(context.cwd, "registry.json");
|
|
92
|
+
const writeRes = await context.runtime.fs.writeJson(outPath, registry, { spaces: 2 });
|
|
93
|
+
if (!writeRes.ok) return err(writeRes.error);
|
|
94
|
+
|
|
95
|
+
context.runtime.prompt.success(`Packed ${items.length} components into registry.json`);
|
|
96
|
+
return ok({ kind: "success", message: `Generated registry.json` });
|
|
97
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import * as diff from "diff";
|
|
4
|
+
|
|
5
|
+
import { appError, type AppError } from "../core/errors.js";
|
|
6
|
+
import { err, ok, type Result } from "../core/result.js";
|
|
7
|
+
import type { CommandContext, CommandOutcome, RegistryItem } from "../types.js";
|
|
8
|
+
import { readLockfile, computeHash, writeLockfile } from "../shell/lockfile.js";
|
|
9
|
+
import { loadRegistry, resolveFileContent } from "../shell/registry.js";
|
|
10
|
+
import { resolveOutputPathFromPolicy } from "../domain/pathPolicy.js";
|
|
11
|
+
import { readConfig } from "../shell/config.js";
|
|
12
|
+
import { applyAliases } from "../domain/aliasCore.js";
|
|
13
|
+
|
|
14
|
+
function printDiff(oldContent: string, newContent: string) {
|
|
15
|
+
const changes = diff.diffLines(oldContent, newContent);
|
|
16
|
+
for (const part of changes) {
|
|
17
|
+
const color = part.added ? pc.green : part.removed ? pc.red : pc.gray;
|
|
18
|
+
const prefix = part.added ? "+ " : part.removed ? "- " : " ";
|
|
19
|
+
const lines = part.value.replace(/\n$/, "").split("\n");
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
console.log(color(`${prefix}${line}`));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runUpdateCommand(
|
|
27
|
+
context: CommandContext,
|
|
28
|
+
): Promise<Result<CommandOutcome, AppError>> {
|
|
29
|
+
const lockfile = await readLockfile(context.cwd, context.runtime);
|
|
30
|
+
const componentNames = Object.keys(lockfile.components);
|
|
31
|
+
|
|
32
|
+
if (componentNames.length === 0) {
|
|
33
|
+
context.runtime.prompt.info("No components installed. Nothing to update.");
|
|
34
|
+
return ok({ kind: "noop", message: "No components to update." });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { config } = await readConfig(context.cwd);
|
|
38
|
+
|
|
39
|
+
// Group by source
|
|
40
|
+
const bySource: Record<string, string[]> = {};
|
|
41
|
+
for (const name of componentNames) {
|
|
42
|
+
const source = lockfile.components[name].source;
|
|
43
|
+
if (source) {
|
|
44
|
+
if (!bySource[source]) bySource[source] = [];
|
|
45
|
+
bySource[source].push(name);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let updatedCount = 0;
|
|
50
|
+
|
|
51
|
+
for (const [source, itemsToUpdate] of Object.entries(bySource)) {
|
|
52
|
+
const registryRes = await loadRegistry(source, context.cwd, context.runtime);
|
|
53
|
+
if (!registryRes.ok) {
|
|
54
|
+
context.runtime.prompt.warn(`Failed to load registry ${source}`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const registryItems = registryRes.value.items;
|
|
59
|
+
|
|
60
|
+
for (const itemName of itemsToUpdate) {
|
|
61
|
+
const registryItem = registryItems.find(i => i.name === itemName);
|
|
62
|
+
if (!registryItem) continue;
|
|
63
|
+
|
|
64
|
+
// Get remote contents
|
|
65
|
+
const remoteContents: string[] = [];
|
|
66
|
+
const remoteFiles: { target: string; content: string }[] = [];
|
|
67
|
+
|
|
68
|
+
for (const file of registryItem.files) {
|
|
69
|
+
const contentRes = await resolveFileContent(file, registryItem, context.cwd, context.runtime);
|
|
70
|
+
if (!contentRes.ok) continue;
|
|
71
|
+
|
|
72
|
+
let content = applyAliases(contentRes.value, config);
|
|
73
|
+
remoteContents.push(content);
|
|
74
|
+
|
|
75
|
+
const outputRes = resolveOutputPathFromPolicy(registryItem, file, context.cwd, config);
|
|
76
|
+
if (outputRes.ok) {
|
|
77
|
+
remoteFiles.push({ target: outputRes.value.absoluteTarget, content: content });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const newHash = computeHash(remoteContents.sort().join(""));
|
|
82
|
+
const currentHash = lockfile.components[itemName].hash;
|
|
83
|
+
|
|
84
|
+
if (newHash !== currentHash) {
|
|
85
|
+
context.runtime.prompt.info(`Update available for ${itemName}`);
|
|
86
|
+
|
|
87
|
+
const action = await context.runtime.prompt.select({
|
|
88
|
+
message: `What do you want to do with ${itemName}?`,
|
|
89
|
+
options: [
|
|
90
|
+
{ value: "diff", label: "Show diff" },
|
|
91
|
+
{ value: "update", label: "Update" },
|
|
92
|
+
{ value: "skip", label: "Skip" }
|
|
93
|
+
]
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (context.runtime.prompt.isCancel(action) || action === "skip") {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (action === "diff") {
|
|
101
|
+
for (const rf of remoteFiles) {
|
|
102
|
+
const localContentRes = await context.runtime.fs.readFile(rf.target, "utf8");
|
|
103
|
+
const localContent = localContentRes.ok ? localContentRes.value : "";
|
|
104
|
+
console.log(pc.bold(`\nDiff for ${rf.target}:`));
|
|
105
|
+
printDiff(localContent, rf.content);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const confirm = await context.runtime.prompt.confirm({
|
|
109
|
+
message: `Update ${itemName} now?`,
|
|
110
|
+
initialValue: true
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (context.runtime.prompt.isCancel(confirm) || !confirm) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Apply update
|
|
119
|
+
for (const rf of remoteFiles) {
|
|
120
|
+
const ensureRes = await context.runtime.fs.ensureDir(path.dirname(rf.target));
|
|
121
|
+
if (!ensureRes.ok) return ensureRes;
|
|
122
|
+
const writeRes = await context.runtime.fs.writeFile(rf.target, rf.content, "utf8");
|
|
123
|
+
if (!writeRes.ok) return writeRes;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
lockfile.components[itemName].hash = newHash;
|
|
127
|
+
updatedCount++;
|
|
128
|
+
context.runtime.prompt.success(`Updated ${itemName}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (updatedCount > 0) {
|
|
134
|
+
await writeLockfile(context.cwd, lockfile, context.runtime);
|
|
135
|
+
return ok({ kind: "success", message: `Updated ${updatedCount} components.` });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return ok({ kind: "noop", message: "All components are up to date." });
|
|
139
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { appError, toAppError } from "../errors.js";
|
|
4
|
+
import { err, isErr, isOk, ok } from "../result.js";
|
|
5
|
+
|
|
6
|
+
describe("result and error model", () => {
|
|
7
|
+
it("creates ok/err results", () => {
|
|
8
|
+
const success = ok(42);
|
|
9
|
+
const failure = err(appError("ValidationError", "invalid"));
|
|
10
|
+
expect(isOk(success)).toBe(true);
|
|
11
|
+
expect(isErr(failure)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("normalizes unknown to AppError", () => {
|
|
15
|
+
const converted = toAppError(new Error("boom"), "RuntimeError");
|
|
16
|
+
expect(converted.kind).toBe("RuntimeError");
|
|
17
|
+
expect(converted.message).toBe("boom");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type AppErrorKind =
|
|
2
|
+
| "ConfigError"
|
|
3
|
+
| "RegistryError"
|
|
4
|
+
| "InstallError"
|
|
5
|
+
| "UserCancelled"
|
|
6
|
+
| "ValidationError"
|
|
7
|
+
| "RuntimeError";
|
|
8
|
+
|
|
9
|
+
export type AppError = {
|
|
10
|
+
kind: AppErrorKind;
|
|
11
|
+
message: string;
|
|
12
|
+
cause?: unknown;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function appError(kind: AppErrorKind, message: string, cause?: unknown): AppError {
|
|
16
|
+
return { kind, message, cause };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function toAppError(error: unknown, fallbackKind: AppErrorKind = "RuntimeError"): AppError {
|
|
20
|
+
if (
|
|
21
|
+
typeof error === "object" &&
|
|
22
|
+
error !== null &&
|
|
23
|
+
"kind" in error &&
|
|
24
|
+
"message" in error &&
|
|
25
|
+
typeof (error as { kind?: unknown }).kind === "string" &&
|
|
26
|
+
typeof (error as { message?: unknown }).message === "string"
|
|
27
|
+
) {
|
|
28
|
+
return error as AppError;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (error instanceof Error) {
|
|
32
|
+
return appError(fallbackKind, error.message, error);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return appError(fallbackKind, String(error));
|
|
36
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type Ok<T> = { ok: true; value: T };
|
|
2
|
+
export type Err<E> = { ok: false; error: E };
|
|
3
|
+
export type Result<T, E> = Ok<T> | Err<E>;
|
|
4
|
+
|
|
5
|
+
export function ok<T>(value: T): Ok<T> {
|
|
6
|
+
return { ok: true, value };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function err<E>(error: E): Err<E> {
|
|
10
|
+
return { ok: false, error };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
|
|
14
|
+
return result.ok;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isErr<T, E>(result: Result<T, E>): result is Err<E> {
|
|
18
|
+
return !result.ok;
|
|
19
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { RegpickConfig, RegistryItem } from "../../types.js";
|
|
4
|
+
import { buildInstallPlan } from "../addPlan.js";
|
|
5
|
+
|
|
6
|
+
const config: RegpickConfig = {
|
|
7
|
+
registries: {},
|
|
8
|
+
targetsByType: {
|
|
9
|
+
"registry:icon": "src/components/ui/icons",
|
|
10
|
+
"registry:file": "src/components/ui",
|
|
11
|
+
},
|
|
12
|
+
overwritePolicy: "prompt",
|
|
13
|
+
packageManager: "auto",
|
|
14
|
+
preferManifestTarget: false,
|
|
15
|
+
allowOutsideProject: false,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const items: RegistryItem[] = [
|
|
19
|
+
{
|
|
20
|
+
name: "check",
|
|
21
|
+
title: "Check",
|
|
22
|
+
description: "",
|
|
23
|
+
type: "registry:icon",
|
|
24
|
+
dependencies: ["react"],
|
|
25
|
+
devDependencies: ["@types/react"],
|
|
26
|
+
registryDependencies: [],
|
|
27
|
+
files: [{ type: "registry:file", path: "icons/check.tsx" }],
|
|
28
|
+
sourceMeta: { type: "directory", baseDir: "/registry" },
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "calendar",
|
|
32
|
+
title: "Calendar",
|
|
33
|
+
description: "",
|
|
34
|
+
type: "registry:icon",
|
|
35
|
+
dependencies: ["react", "clsx"],
|
|
36
|
+
devDependencies: [],
|
|
37
|
+
registryDependencies: [],
|
|
38
|
+
files: [{ type: "registry:file", path: "icons/calendar.tsx" }],
|
|
39
|
+
sourceMeta: { type: "directory", baseDir: "/registry" },
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
describe("add plan core", () => {
|
|
44
|
+
it("builds deduplicated dependency plan", () => {
|
|
45
|
+
const planRes = buildInstallPlan(items, "/tmp/project", config);
|
|
46
|
+
expect(planRes.ok).toBe(true);
|
|
47
|
+
if (planRes.ok) {
|
|
48
|
+
expect(planRes.value.dependencyPlan.dependencies).toEqual(["react", "clsx"]);
|
|
49
|
+
expect(planRes.value.dependencyPlan.devDependencies).toEqual(["@types/react"]);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("detects conflicts based on existing target paths", () => {
|
|
54
|
+
const firstPlanRes = buildInstallPlan(items, "/tmp/project", config);
|
|
55
|
+
expect(firstPlanRes.ok).toBe(true);
|
|
56
|
+
if (!firstPlanRes.ok) return;
|
|
57
|
+
const existingTargets = new Set([firstPlanRes.value.plannedWrites[0].absoluteTarget]);
|
|
58
|
+
const planWithConflictsRes = buildInstallPlan(items, "/tmp/project", config, existingTargets);
|
|
59
|
+
expect(planWithConflictsRes.ok).toBe(true);
|
|
60
|
+
if (!planWithConflictsRes.ok) return;
|
|
61
|
+
expect(planWithConflictsRes.value.conflicts).toHaveLength(1);
|
|
62
|
+
expect(planWithConflictsRes.value.conflicts[0].itemName).toBe("check");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
decideInitAfterFirstWrite,
|
|
5
|
+
decideInitAfterOverwritePrompt,
|
|
6
|
+
} from "../initCore.js";
|
|
7
|
+
|
|
8
|
+
describe("init core", () => {
|
|
9
|
+
it("returns created when first write succeeds", () => {
|
|
10
|
+
expect(decideInitAfterFirstWrite(true)).toBe("created");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("asks for overwrite when first write does not happen", () => {
|
|
14
|
+
expect(decideInitAfterFirstWrite(false)).toBe("ask-overwrite");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns cancelled when prompt is cancelled", () => {
|
|
18
|
+
expect(decideInitAfterOverwritePrompt(true, false)).toBe("cancelled");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns overwrite when user confirms overwrite", () => {
|
|
22
|
+
expect(decideInitAfterOverwritePrompt(false, true)).toBe("overwrite");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns keep when user rejects overwrite", () => {
|
|
26
|
+
expect(decideInitAfterOverwritePrompt(false, false)).toBe("keep");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { RegistryItem } from "../../types.js";
|
|
4
|
+
import {
|
|
5
|
+
resolveListSourceDecision,
|
|
6
|
+
resolveRegistrySourceFromAliases,
|
|
7
|
+
} from "../listCore.js";
|
|
8
|
+
|
|
9
|
+
describe("list core", () => {
|
|
10
|
+
it("resolves alias to configured source", () => {
|
|
11
|
+
expect(resolveRegistrySourceFromAliases("tebra", { tebra: "./registry" })).toBe("./registry");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("uses provided input first", () => {
|
|
15
|
+
const decision = resolveListSourceDecision("tebra", { tebra: "./registry" });
|
|
16
|
+
expect(decision).toEqual({ source: "./registry", requiresPrompt: false });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("uses first alias when input is missing", () => {
|
|
20
|
+
const decision = resolveListSourceDecision(undefined, { alpha: "./a", beta: "./b" });
|
|
21
|
+
expect(decision).toEqual({ source: "./a", requiresPrompt: false });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("requires prompt when no input and no aliases", () => {
|
|
25
|
+
const decision = resolveListSourceDecision(undefined, {});
|
|
26
|
+
expect(decision).toEqual({ source: null, requiresPrompt: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { RegpickConfig, RegistryItem } from "../../types.js";
|
|
4
|
+
import { resolveOutputPathFromPolicy } from "../pathPolicy.js";
|
|
5
|
+
|
|
6
|
+
const baseConfig: RegpickConfig = {
|
|
7
|
+
registries: {},
|
|
8
|
+
targetsByType: {
|
|
9
|
+
"registry:icon": "src/components/ui/icons",
|
|
10
|
+
"registry:file": "src/components/ui",
|
|
11
|
+
},
|
|
12
|
+
overwritePolicy: "prompt",
|
|
13
|
+
packageManager: "auto",
|
|
14
|
+
preferManifestTarget: true,
|
|
15
|
+
allowOutsideProject: false,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const item: RegistryItem = {
|
|
19
|
+
name: "check",
|
|
20
|
+
title: "Check",
|
|
21
|
+
description: "",
|
|
22
|
+
type: "registry:icon",
|
|
23
|
+
dependencies: [],
|
|
24
|
+
devDependencies: [],
|
|
25
|
+
registryDependencies: [],
|
|
26
|
+
files: [{ type: "registry:file", path: "icons/check.tsx", target: "src/custom/check.tsx" }],
|
|
27
|
+
sourceMeta: { type: "directory", baseDir: "/registry" },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe("path policy core", () => {
|
|
31
|
+
it("prefers manifest target by default", () => {
|
|
32
|
+
const outputRes = resolveOutputPathFromPolicy(item, item.files[0], "/tmp/project", baseConfig);
|
|
33
|
+
expect(outputRes.ok).toBe(true);
|
|
34
|
+
if (outputRes.ok) {
|
|
35
|
+
expect(outputRes.value.relativeTarget).toBe("src/custom/check.tsx");
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("uses mapped type target when preferManifestTarget is false", () => {
|
|
40
|
+
const outputRes = resolveOutputPathFromPolicy(
|
|
41
|
+
item,
|
|
42
|
+
item.files[0],
|
|
43
|
+
"/tmp/project",
|
|
44
|
+
{ ...baseConfig, preferManifestTarget: false },
|
|
45
|
+
);
|
|
46
|
+
expect(outputRes.ok).toBe(true);
|
|
47
|
+
if (outputRes.ok) {
|
|
48
|
+
expect(outputRes.value.relativeTarget).toBe("src/components/ui/check.tsx");
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("blocks writes outside project root", () => {
|
|
53
|
+
const outputRes = resolveOutputPathFromPolicy(
|
|
54
|
+
item,
|
|
55
|
+
{ ...item.files[0], target: "../outside/check.tsx" },
|
|
56
|
+
"/tmp/project",
|
|
57
|
+
baseConfig,
|
|
58
|
+
);
|
|
59
|
+
expect(outputRes.ok).toBe(false);
|
|
60
|
+
if (!outputRes.ok) {
|
|
61
|
+
expect(outputRes.error.message).toMatch(/Refusing to write outside project/);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { extractItemReferences, normalizeManifestInline } from "../registryModel.js";
|
|
4
|
+
|
|
5
|
+
describe("registry model core", () => {
|
|
6
|
+
it("normalizes inline items from registry.json", () => {
|
|
7
|
+
const payload = {
|
|
8
|
+
items: [
|
|
9
|
+
{
|
|
10
|
+
name: "check",
|
|
11
|
+
type: "registry:icon",
|
|
12
|
+
files: [{ path: "icons/check.tsx", type: "registry:file" }],
|
|
13
|
+
},
|
|
14
|
+
{ name: "external", url: "./external-item.json" },
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const normalized = normalizeManifestInline(payload, { type: "file", baseDir: "/registry" });
|
|
19
|
+
expect(normalized.ok).toBe(true);
|
|
20
|
+
if (normalized.ok) {
|
|
21
|
+
expect(normalized.value).toHaveLength(1);
|
|
22
|
+
expect(normalized.value[0].name).toBe("check");
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("extracts item references from registry.json entries", () => {
|
|
27
|
+
const payload = {
|
|
28
|
+
items: [{ name: "a", url: "./a.json" }, { name: "b", href: "./b.json" }, { name: "c", path: "./c.json" }],
|
|
29
|
+
};
|
|
30
|
+
expect(extractItemReferences(payload)).toEqual(["./a.json", "./b.json", "./c.json"]);
|
|
31
|
+
});
|
|
32
|
+
});
|