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.
Files changed (48) hide show
  1. package/.github/workflows/release.yml +51 -0
  2. package/.release-it.json +22 -0
  3. package/CHANGELOG.md +12 -0
  4. package/README.md +56 -0
  5. package/bin/regpick.js +3 -0
  6. package/docs/mvp-decisions.md +77 -0
  7. package/examples/README.md +26 -0
  8. package/examples/complex-ui-registry/registry.json +35 -0
  9. package/examples/simple-utils-registry/registry.json +28 -0
  10. package/package.json +39 -0
  11. package/regpick.config.schema.json +40 -0
  12. package/src/commands/add.ts +261 -0
  13. package/src/commands/init.ts +89 -0
  14. package/src/commands/list.ts +54 -0
  15. package/src/commands/pack.ts +97 -0
  16. package/src/commands/update.ts +139 -0
  17. package/src/core/__tests__/result-errors.test.ts +19 -0
  18. package/src/core/errors.ts +36 -0
  19. package/src/core/result.ts +19 -0
  20. package/src/domain/__tests__/addPlan.test.ts +64 -0
  21. package/src/domain/__tests__/initCore.test.ts +28 -0
  22. package/src/domain/__tests__/listCore.test.ts +29 -0
  23. package/src/domain/__tests__/pathPolicy.test.ts +64 -0
  24. package/src/domain/__tests__/registryModel.test.ts +32 -0
  25. package/src/domain/__tests__/selection.test.ts +58 -0
  26. package/src/domain/addPlan.ts +51 -0
  27. package/src/domain/aliasCore.ts +13 -0
  28. package/src/domain/initCore.ts +15 -0
  29. package/src/domain/listCore.ts +34 -0
  30. package/src/domain/packCore.ts +44 -0
  31. package/src/domain/pathPolicy.ts +61 -0
  32. package/src/domain/registryModel.ts +100 -0
  33. package/src/domain/selection.ts +47 -0
  34. package/src/index.ts +117 -0
  35. package/src/shell/cli/args.ts +37 -0
  36. package/src/shell/config.ts +105 -0
  37. package/src/shell/installer.ts +70 -0
  38. package/src/shell/lockfile.ts +35 -0
  39. package/src/shell/packageManagers/__tests__/resolver.test.ts +61 -0
  40. package/src/shell/packageManagers/__tests__/strategy.test.ts +40 -0
  41. package/src/shell/packageManagers/resolver.ts +27 -0
  42. package/src/shell/packageManagers/strategy.ts +65 -0
  43. package/src/shell/registry.ts +182 -0
  44. package/src/shell/runtime/ports.ts +200 -0
  45. package/src/types.ts +92 -0
  46. package/test-clack.ts +2 -0
  47. package/tsconfig.json +15 -0
  48. package/tsdown.config.ts +8 -0
@@ -0,0 +1,51 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ workflow_dispatch:
8
+ inputs:
9
+ dry_run:
10
+ description: 'Dry run (do not actually release)'
11
+ required: true
12
+ type: boolean
13
+ default: false
14
+
15
+ permissions:
16
+ contents: write
17
+
18
+ jobs:
19
+ release:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - name: Checkout repository
23
+ uses: actions/checkout@v4
24
+ with:
25
+ fetch-depth: 0
26
+
27
+ - name: Setup Node.js
28
+ uses: actions/setup-node@v4
29
+ with:
30
+ node-version: 20
31
+ cache: 'npm'
32
+ registry-url: 'https://registry.npmjs.org'
33
+
34
+ - name: Install dependencies
35
+ run: npm ci
36
+
37
+ - name: Configure Git user
38
+ run: |
39
+ git config --global user.name 'github-actions[bot]'
40
+ git config --global user.email 'github-actions[bot]@users.noreply.github.com'
41
+
42
+ - name: Run release-it
43
+ env:
44
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
45
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
46
+ run: |
47
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ github.event.inputs.dry_run }}" = "true" ]; then
48
+ npm run release -- --dry-run
49
+ else
50
+ npm run release -- --ci
51
+ fi
@@ -0,0 +1,22 @@
1
+ {
2
+ "git": {
3
+ "commitMessage": "chore: release v${version}",
4
+ "tagName": "v${version}",
5
+ "requireCleanWorkingDir": true
6
+ },
7
+ "plugins": {
8
+ "@release-it/conventional-changelog": {
9
+ "preset": "angular",
10
+ "infile": "CHANGELOG.md"
11
+ }
12
+ },
13
+ "github": {
14
+ "release": true
15
+ },
16
+ "npm": {
17
+ "publish": true
18
+ },
19
+ "hooks": {
20
+ "before:init": ["npm run test", "npm run build"]
21
+ }
22
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## [0.2.3](https://github.com/wojtowicz-artur/regpick/compare/v0.2.2...v0.2.3) (2026-02-27)
4
+
5
+ ## [0.2.2](https://github.com/wojtowicz-artur/regpick/compare/v0.2.1...v0.2.2) (2026-02-26)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * refine dry run condition in release workflow ([1973f02](https://github.com/wojtowicz-artur/regpick/commit/1973f022ea3912e6999fe763b8e0c116a4edc177))
11
+
12
+ ## [0.2.1](https://github.com/wojtowicz-artur/regpick/compare/v0.2.0...v0.2.1) (2026-02-26)
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # regpick
2
+
3
+ Lightweight CLI for selecting and installing registry entries from shadcn-compatible registries (v2), with support for local directory-based fat item JSON files.
4
+
5
+ ## Commands
6
+
7
+ - `regpick init`
8
+ - `regpick list [registry-name-or-url]`
9
+ - `regpick add [registry-name-or-url]`
10
+
11
+ ## Quick start
12
+
13
+ ```bash
14
+ cd /path/to/project
15
+ cd /path/to/packages/regpick
16
+ npm run build
17
+ node ./dist/index.mjs init
18
+ node ./dist/index.mjs list tebra
19
+ node ./dist/index.mjs add tebra
20
+ ```
21
+
22
+ ## Config (`regpick.json`)
23
+
24
+ ```json
25
+ {
26
+ "registries": {
27
+ "tebra": "./tebra-icon-registry/registry"
28
+ },
29
+ "targetsByType": {
30
+ "registry:icon": "src/components/ui/icons",
31
+ "registry:component": "src/components/ui",
32
+ "registry:file": "src/components/ui"
33
+ },
34
+ "overwritePolicy": "prompt",
35
+ "packageManager": "auto",
36
+ "preferManifestTarget": true,
37
+ "allowOutsideProject": false
38
+ }
39
+ ```
40
+
41
+ Optional JSON schema path (if the file is available in your project):
42
+
43
+ ```json
44
+ {
45
+ "$schema": "./packages/regpick/regpick.config.schema.json"
46
+ }
47
+ ```
48
+
49
+ ## Notes
50
+
51
+ - Supports:
52
+ - full `registry.json` (with inline item definitions),
53
+ - item references (`url` / `href`) in `items[]`,
54
+ - single item JSON (`registry:file` style),
55
+ - directory source containing many item JSON files.
56
+ - For safety, path traversal writes outside project root are blocked by default.
package/bin/regpick.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+
3
+ import "../dist/index.mjs";
@@ -0,0 +1,77 @@
1
+ # MVP Decisions and Documentation Gaps Closed
2
+
3
+ This file closes the open items from the implementation plan for prototype scope.
4
+
5
+ ## 1) `regpick.json` contract
6
+
7
+ - Formalized via `regpick.config.schema.json`.
8
+ - Supported fields:
9
+ - `registries` (alias -> URL/path),
10
+ - `targetsByType`,
11
+ - `overwritePolicy` (`prompt` | `overwrite` | `skip`),
12
+ - `packageManager` (`auto` | `npm` | `yarn` | `pnpm`),
13
+ - `preferManifestTarget`,
14
+ - `allowOutsideProject`.
15
+
16
+ ## 2) `registry.json` support scope (MVP)
17
+
18
+ - Supported inputs:
19
+ - top-level object with `items[]`,
20
+ - top-level array of items,
21
+ - single item JSON with `files[]`.
22
+ - Supported `items[]` entries:
23
+ - inline entries containing `files[]`,
24
+ - references via `url` / `href` / `path` to separate item JSON files.
25
+
26
+ ## 3) Target path priority
27
+
28
+ Order used by installer:
29
+ 1. `file.target` from manifest if `preferManifestTarget = true`,
30
+ 2. `targetsByType[itemType] + basename(file.path)` if mapped,
31
+ 3. `file.target` if present and not already used,
32
+ 4. fallback `src/<basename>`.
33
+
34
+ ## 4) Overwrite behavior
35
+
36
+ - `overwritePolicy = prompt`: per-file interactive choice (`overwrite` / `skip` / `abort`).
37
+ - `overwritePolicy = overwrite`: overwrite silently.
38
+ - `overwritePolicy = skip`: skip existing files.
39
+ - `--yes` bypasses overwrite prompt with overwrite behavior.
40
+
41
+ ## 5) Dependency installation rules
42
+
43
+ - Candidate deps come from selected item `dependencies` and `devDependencies`.
44
+ - Existing declarations are read from project `package.json`.
45
+ - Missing packages can be installed after prompt.
46
+ - Package manager detection:
47
+ - `pnpm-lock.yaml` -> `pnpm`,
48
+ - `yarn.lock` -> `yarn`,
49
+ - `package-lock.json` -> `npm`,
50
+ - fallback `npm`.
51
+
52
+ ## 6) Offline/cache for MVP
53
+
54
+ - No cache layer in MVP.
55
+ - Rationale: keep prototype minimal and deterministic.
56
+
57
+ ## 7) Path security
58
+
59
+ - Writes outside the project root are blocked by default.
60
+ - Can be relaxed with `allowOutsideProject: true` for advanced use.
61
+ - Relative traversal (`../`) is effectively blocked by absolute path boundary check.
62
+
63
+ ## 8) Versioning policy for MVP
64
+
65
+ - Config schema is versioned by package release (`regpick` semver).
66
+ - For MVP, no separate manifest protocol version pinning beyond compatibility parser logic.
67
+
68
+ ## 9) Runtime adapters and error model
69
+
70
+ - Runtime side effects are routed through adapter ports in `src/shell/runtime/ports.ts`:
71
+ - `FileSystemPort`,
72
+ - `HttpPort`,
73
+ - `PromptPort`,
74
+ - `ProcessPort`.
75
+ - Commands receive adapters through `CommandContext.runtime` instead of importing IO libraries directly.
76
+ - The app now uses a shared typed result model from `src/core/result.ts` (`Result`, `ok`, `err`).
77
+ - Domain and shell errors are mapped to `AppError` in `src/core/errors.ts` and surfaced consistently in CLI output.
@@ -0,0 +1,26 @@
1
+ # Przykłady użycia `regpick`
2
+
3
+ Ten folder zawiera przykłady różnych rodzajów rejestrów i komponentów, które można dystrybuować za pomocą `regpick`. Pokazują one elastyczność narzędzia - od prostych funkcji pomocniczych po złożone komponenty UI z własnymi zależnościami NPM.
4
+
5
+ ## Jak przetestować przykłady?
6
+
7
+ Możesz uruchomić komendę `list` lub `add` bezpośrednio na plikach JSON z tego folderu:
8
+
9
+ ```bash
10
+ # Wylistowanie zawartości prostego rejestru:
11
+ npx regpick list ./examples/simple-utils-registry/registry.json
12
+
13
+ # Interaktywne dodanie komponentu ze złożonego rejestru:
14
+ npx regpick add ./examples/complex-ui-registry/registry.json
15
+ ```
16
+
17
+ ## Dostępne przykłady:
18
+
19
+ ### 1. `simple-utils-registry`
20
+ Pokazuje najprostsze zastosowanie: dystrybucję czystej logiki biznesowej (funkcje pomocnicze, hooki) bez dodatkowych zależności NPM. Idealne do uwspólniania kodu między projektami backendowymi i frontendowymi.
21
+
22
+ ### 2. `complex-ui-registry`
23
+ Pokazuje zaawansowane możliwości:
24
+ - Komponenty składające się z wielu plików (np. główny plik TSX + plik ze stylami CSS/Tailwind).
25
+ - Automatyczne instalowanie zależności NPM (np. `lucide-react`, `clsx`, `tailwind-merge`).
26
+ - Różne typy elementów w jednym rejestrze (`registry:component`, `registry:hook`, `registry:ui`).
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "Tebra UI Components",
3
+ "description": "Złożone komponenty UI dla projektów React z TailwindCSS.",
4
+ "items": [
5
+ {
6
+ "name": "button",
7
+ "type": "registry:component",
8
+ "description": "Główny przycisk systemowy ze wsparciem dla ikon i wariantów.",
9
+ "dependencies": ["lucide-react", "clsx", "tailwind-merge"],
10
+ "devDependencies": ["@types/react"],
11
+ "files": [
12
+ {
13
+ "path": "Button.tsx",
14
+ "content": "import * as React from 'react';\nimport { Slot } from '@radix-ui/react-slot';\nimport { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\nimport { Loader2 } from 'lucide-react';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n\nexport interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n asChild?: boolean;\n isLoading?: boolean;\n}\n\nexport const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n ({ className, asChild = false, isLoading, children, ...props }, ref) => {\n const Comp = asChild ? Slot : \"button\";\n return (\n <Comp\n className={cn(\"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50\", className)}\n ref={ref}\n {...props}\n >\n {isLoading && <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />}\n {children}\n </Comp>\n );\n }\n);\nButton.displayName = \"Button\";\n"
15
+ }
16
+ ]
17
+ },
18
+ {
19
+ "name": "auth-form",
20
+ "type": "registry:feature",
21
+ "description": "Kompletny formularz logowania z walidacją i obsługą stanu.",
22
+ "dependencies": ["react-hook-form", "zod", "@hookform/resolvers"],
23
+ "files": [
24
+ {
25
+ "path": "AuthForm.tsx",
26
+ "content": "import React from 'react';\nimport { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport * as z from 'zod';\nimport { authApi } from './api';\n\nconst schema = z.object({\n email: z.string().email(),\n password: z.string().min(8),\n});\n\ntype FormData = z.infer<typeof schema>;\n\nexport function AuthForm() {\n const { register, handleSubmit } = useForm<FormData>({\n resolver: zodResolver(schema)\n });\n\n const onSubmit = async (data: FormData) => {\n await authApi.login(data);\n };\n\n return (\n <form onSubmit={handleSubmit(onSubmit)} className=\"space-y-4\">\n <input {...register('email')} type=\"email\" placeholder=\"Email\" />\n <input {...register('password')} type=\"password\" placeholder=\"Hasło\" />\n <button type=\"submit\">Zaloguj</button>\n </form>\n );\n}\n"
27
+ },
28
+ {
29
+ "path": "api.ts",
30
+ "content": "export const authApi = {\n login: async (credentials: any) => {\n // Mock API call\n return new Promise(resolve => setTimeout(resolve, 1000));\n }\n};\n"
31
+ }
32
+ ]
33
+ }
34
+ ]
35
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "Tebra Utils Registry",
3
+ "description": "Kolekcja prostych funkcji pomocniczych używanych w organizacji.",
4
+ "items": [
5
+ {
6
+ "name": "format-date",
7
+ "type": "registry:util",
8
+ "description": "Funkcja do bezpiecznego formatowania dat",
9
+ "files": [
10
+ {
11
+ "path": "formatDate.ts",
12
+ "content": "export function formatDate(date: Date | string): string {\n const d = new Date(date);\n if (isNaN(d.getTime())) return '';\n return new Intl.DateTimeFormat('pl-PL', {\n year: 'numeric',\n month: 'long',\n day: 'numeric'\n }).format(d);\n}\n"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "name": "use-debounce",
18
+ "type": "registry:hook",
19
+ "description": "React hook do opóźniania wywołań (debounce)",
20
+ "files": [
21
+ {
22
+ "path": "useDebounce.ts",
23
+ "content": "import { useState, useEffect } from 'react';\n\nexport function useDebounce<T>(value: T, delay: number): T {\n const [debouncedValue, setDebouncedValue] = useState<T>(value);\n\n useEffect(() => {\n const handler = setTimeout(() => {\n setDebouncedValue(value);\n }, delay);\n\n return () => {\n clearTimeout(handler);\n };\n }, [value, delay]);\n\n return debouncedValue;\n}\n"
24
+ }
25
+ ]
26
+ }
27
+ ]
28
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "regpick",
3
+ "version": "0.2.3",
4
+ "description": "Lightweight CLI for selecting and installing registry entries.",
5
+ "type": "module",
6
+ "bin": {
7
+ "regpick": "./bin/regpick.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsdown",
11
+ "test": "vitest run",
12
+ "release": "release-it",
13
+ "start": "node ./dist/index.mjs",
14
+ "dev": "tsx ./src/index.ts",
15
+ "init": "node ./dist/index.mjs init",
16
+ "list": "node ./dist/index.mjs list",
17
+ "add": "node ./dist/index.mjs add",
18
+ "validate:tebra": "node ./dist/index.mjs list ../../tebra-icon-registry/registry"
19
+ },
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "dependencies": {
24
+ "@clack/prompts": "^1.0.1",
25
+ "cosmiconfig": "^9.0.0",
26
+ "diff": "^8.0.3",
27
+ "picocolors": "^1.1.1"
28
+ },
29
+ "devDependencies": {
30
+ "@release-it/conventional-changelog": "^10.0.5",
31
+ "@types/diff": "^7.0.2",
32
+ "@types/node": "^24.3.0",
33
+ "release-it": "^19.2.4",
34
+ "tsdown": "^0.21.0-beta.2",
35
+ "tsx": "^4.20.5",
36
+ "typescript": "^5.9.2",
37
+ "vitest": "^2.1.8"
38
+ }
39
+ }
@@ -0,0 +1,40 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "regpick config",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "properties": {
7
+ "registries": {
8
+ "type": "object",
9
+ "description": "Alias map for registry URLs or local paths.",
10
+ "additionalProperties": {
11
+ "type": "string"
12
+ }
13
+ },
14
+ "targetsByType": {
15
+ "type": "object",
16
+ "description": "Maps registry item types to local output directories.",
17
+ "additionalProperties": {
18
+ "type": "string"
19
+ }
20
+ },
21
+ "overwritePolicy": {
22
+ "type": "string",
23
+ "enum": ["prompt", "overwrite", "skip"],
24
+ "default": "prompt"
25
+ },
26
+ "packageManager": {
27
+ "type": "string",
28
+ "enum": ["auto", "npm", "yarn", "pnpm"],
29
+ "default": "auto"
30
+ },
31
+ "preferManifestTarget": {
32
+ "type": "boolean",
33
+ "default": true
34
+ },
35
+ "allowOutsideProject": {
36
+ "type": "boolean",
37
+ "default": false
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,261 @@
1
+ import path from "node:path";
2
+
3
+ import { appError, type AppError } from "../core/errors.js";
4
+ import { err, ok, type Result } from "../core/result.js";
5
+ import type { CommandContext, CommandOutcome, PlannedWrite, RegpickConfig, RegistryItem } from "../types.js";
6
+ import { buildInstallPlan } from "../domain/addPlan.js";
7
+ import { selectItemsFromFlags } from "../domain/selection.js";
8
+ import { readConfig, resolveRegistrySource } from "../shell/config.js";
9
+ import { resolveFileContent, loadRegistry } from "../shell/registry.js";
10
+ import { collectMissingDependencies, installDependencies } from "../shell/installer.js";
11
+ import { resolvePackageManager } from "../shell/packageManagers/resolver.js";
12
+ import { readLockfile, writeLockfile, computeHash } from "../shell/lockfile.js";
13
+ import { applyAliases } from "../domain/aliasCore.js";
14
+
15
+ async function promptForSource(
16
+ context: CommandContext,
17
+ config: RegpickConfig,
18
+ positionals: string[],
19
+ ): Promise<Result<string | null, AppError>> {
20
+ const argValue = positionals[1];
21
+ if (argValue) {
22
+ return ok(resolveRegistrySource(argValue, config));
23
+ }
24
+
25
+ const aliases = Object.entries(config.registries || {}).map(([alias, value]) => ({
26
+ label: `${alias} -> ${value}`,
27
+ value: alias,
28
+ }));
29
+
30
+ if (aliases.length) {
31
+ const picked = await context.runtime.prompt.multiselect({
32
+ message: "Pick registry alias (or cancel and provide URL/path manually)",
33
+ options: aliases,
34
+ maxItems: 1,
35
+ required: false,
36
+ });
37
+
38
+ if (context.runtime.prompt.isCancel(picked)) {
39
+ return err(appError("UserCancelled", "Operation cancelled."));
40
+ }
41
+
42
+ if (Array.isArray(picked) && picked.length > 0) {
43
+ return ok(resolveRegistrySource(String(picked[0]), config));
44
+ }
45
+ }
46
+
47
+ const manual = await context.runtime.prompt.text({
48
+ message: "Registry URL/path:",
49
+ placeholder: "https://example.com/registry.json",
50
+ });
51
+
52
+ if (context.runtime.prompt.isCancel(manual)) {
53
+ return err(appError("UserCancelled", "Operation cancelled."));
54
+ }
55
+
56
+ return ok(String(manual));
57
+ }
58
+
59
+ function mapOptions(items: RegistryItem[]): Array<{ value: string; label: string; hint: string }> {
60
+ return items.map((item) => ({
61
+ value: item.name,
62
+ label: `${item.name} (${item.type || "registry:file"})`,
63
+ hint: item.description || `${item.files.length} file(s)`,
64
+ }));
65
+ }
66
+
67
+ async function promptForItems(
68
+ context: CommandContext,
69
+ items: RegistryItem[],
70
+ ): Promise<Result<RegistryItem[], AppError>> {
71
+ if (!items.length) {
72
+ return ok([]);
73
+ }
74
+
75
+ const selectedNames = await context.runtime.prompt.autocompleteMultiselect({
76
+ message: "Select items to install",
77
+ options: mapOptions(items),
78
+ maxItems: 10,
79
+ required: true,
80
+ });
81
+
82
+ if (context.runtime.prompt.isCancel(selectedNames)) {
83
+ return err(appError("UserCancelled", "Operation cancelled."));
84
+ }
85
+
86
+ const selectedValues = Array.isArray(selectedNames) ? selectedNames : [];
87
+ const selectedSet = new Set(selectedValues.map((entry: string) => String(entry)));
88
+ return ok(items.filter((item) => selectedSet.has(item.name)));
89
+ }
90
+
91
+ export async function runAddCommand(
92
+ context: CommandContext,
93
+ ): Promise<Result<CommandOutcome, AppError>> {
94
+ const assumeYes = Boolean(context.args.flags.yes);
95
+ const { config } = await readConfig(context.cwd);
96
+ const sourceResult = await promptForSource(context, config, context.args.positionals);
97
+ if (!sourceResult.ok) {
98
+ return sourceResult;
99
+ }
100
+ const source = sourceResult.value;
101
+ if (!source) {
102
+ return ok({ kind: "noop", message: "No registry source provided." });
103
+ }
104
+
105
+ const registryResult = await loadRegistry(source, context.cwd, context.runtime);
106
+ if (!registryResult.ok) {
107
+ return registryResult;
108
+ }
109
+ const { items } = registryResult.value;
110
+ if (!items.length) {
111
+ context.runtime.prompt.warn("No installable items in registry.");
112
+ return ok({ kind: "noop", message: "No installable items in registry." });
113
+ }
114
+
115
+ const preselected = selectItemsFromFlags(items, context);
116
+ const promptedSelectionResult = preselected.ok && preselected.value ? preselected : await promptForItems(context, items);
117
+ if (!promptedSelectionResult.ok) {
118
+ return promptedSelectionResult;
119
+ }
120
+ const selectedItems = promptedSelectionResult.value;
121
+
122
+ if (!selectedItems || !selectedItems.length) {
123
+ context.runtime.prompt.warn("No items selected.");
124
+ return ok({ kind: "noop", message: "No items selected." });
125
+ }
126
+
127
+ if (!assumeYes) {
128
+ const proceed = await context.runtime.prompt.confirm({
129
+ message: `Install ${selectedItems.length} item(s)?`,
130
+ initialValue: true,
131
+ });
132
+
133
+ if (context.runtime.prompt.isCancel(proceed) || !proceed) {
134
+ return err(appError("UserCancelled", "Operation cancelled."));
135
+ }
136
+ }
137
+
138
+ const existingTargets = new Set<string>();
139
+ const installPlanProbeRes = buildInstallPlan(selectedItems, context.cwd, config);
140
+ if (!installPlanProbeRes.ok) return installPlanProbeRes;
141
+ const installPlanProbe = installPlanProbeRes.value;
142
+
143
+ for (const write of installPlanProbe.plannedWrites) {
144
+ if (await context.runtime.fs.pathExists(write.absoluteTarget)) {
145
+ existingTargets.add(write.absoluteTarget);
146
+ }
147
+ }
148
+
149
+ const installPlanRes = buildInstallPlan(selectedItems, context.cwd, config, existingTargets);
150
+ if (!installPlanRes.ok) return installPlanRes;
151
+ const installPlan = installPlanRes.value;
152
+
153
+ // --- UI INTERACTION PHASE: Gather Overwrite Decisions ---
154
+ const finalWrites: PlannedWrite[] = [];
155
+ for (const write of installPlan.plannedWrites) {
156
+ if (existingTargets.has(write.absoluteTarget)) {
157
+ if (assumeYes || config.overwritePolicy === "overwrite") {
158
+ finalWrites.push(write);
159
+ } else if (config.overwritePolicy === "skip") {
160
+ context.runtime.prompt.warn(`Skipped existing file: ${write.absoluteTarget}`);
161
+ } else {
162
+ const answer = await context.runtime.prompt.select({
163
+ message: `File exists: ${write.absoluteTarget}`,
164
+ options: [
165
+ { value: "overwrite", label: "Overwrite this file" },
166
+ { value: "skip", label: "Skip this file" },
167
+ { value: "abort", label: "Abort installation" },
168
+ ],
169
+ });
170
+ if (context.runtime.prompt.isCancel(answer) || answer === "abort") {
171
+ return err(appError("UserCancelled", "Installation aborted by user."));
172
+ }
173
+ if (answer === "overwrite") {
174
+ finalWrites.push(write);
175
+ }
176
+ }
177
+ } else {
178
+ finalWrites.push(write);
179
+ }
180
+ }
181
+
182
+ // --- UI INTERACTION PHASE: Gather Dependency Decisions ---
183
+ const { missingDependencies, missingDevDependencies } = collectMissingDependencies(selectedItems, context.cwd, context.runtime);
184
+ let shouldInstallDeps = false;
185
+ if (missingDependencies.length || missingDevDependencies.length) {
186
+ if (assumeYes) {
187
+ shouldInstallDeps = true;
188
+ } else {
189
+ const packageManager = resolvePackageManager(context.cwd, config.packageManager, context.runtime);
190
+ const messageParts: string[] = [];
191
+ if (missingDependencies.length) messageParts.push(`dependencies: ${missingDependencies.join(", ")}`);
192
+ if (missingDevDependencies.length) messageParts.push(`devDependencies: ${missingDevDependencies.join(", ")}`);
193
+
194
+ const proceed = await context.runtime.prompt.confirm({
195
+ message: `Install missing packages with ${packageManager}? (${messageParts.join(" | ")})`,
196
+ initialValue: true,
197
+ });
198
+
199
+ if (context.runtime.prompt.isCancel(proceed)) {
200
+ return err(appError("UserCancelled", "Dependency installation cancelled by user."));
201
+ }
202
+ shouldInstallDeps = Boolean(proceed);
203
+ if (!shouldInstallDeps) {
204
+ context.runtime.prompt.warn("Skipped dependency installation.");
205
+ }
206
+ }
207
+ }
208
+
209
+ // --- EXECUTION PHASE: Pure IO without UI interruptions ---
210
+ let writtenFiles = 0;
211
+ const lockfile = await readLockfile(context.cwd, context.runtime);
212
+ const hashesAcc: Record<string, string[]> = {};
213
+
214
+ for (const write of finalWrites) {
215
+ const item = selectedItems.find((entry) => entry.name === write.itemName);
216
+ if (!item) continue;
217
+
218
+ const contentResult = await resolveFileContent(write.sourceFile, item, context.cwd, context.runtime);
219
+ if (!contentResult.ok) {
220
+ return contentResult;
221
+ }
222
+
223
+ let content = applyAliases(contentResult.value, config);
224
+
225
+ const ensureRes = await context.runtime.fs.ensureDir(path.dirname(write.absoluteTarget));
226
+ if (!ensureRes.ok) return ensureRes;
227
+ const writeRes = await context.runtime.fs.writeFile(write.absoluteTarget, content, "utf8");
228
+ if (!writeRes.ok) return writeRes;
229
+
230
+ // Update lockfile
231
+ const contentHash = computeHash(content);
232
+ if (!hashesAcc[item.name]) hashesAcc[item.name] = [];
233
+ hashesAcc[item.name].push(contentHash);
234
+
235
+ writtenFiles += 1;
236
+ context.runtime.prompt.success(`Wrote ${write.relativeTarget}`);
237
+ }
238
+
239
+ if (writtenFiles > 0) {
240
+ for (const [itemName, fileHashes] of Object.entries(hashesAcc)) {
241
+ const combinedHash = computeHash(fileHashes.sort().join(""));
242
+ lockfile.components[itemName] = {
243
+ source: source,
244
+ hash: combinedHash,
245
+ };
246
+ }
247
+ await writeLockfile(context.cwd, lockfile, context.runtime);
248
+ }
249
+
250
+ if (shouldInstallDeps) {
251
+ const packageManager = resolvePackageManager(context.cwd, config.packageManager, context.runtime);
252
+ const depsRes = installDependencies(context.cwd, packageManager, missingDependencies, missingDevDependencies, context.runtime);
253
+ if (!depsRes.ok) return depsRes;
254
+ }
255
+
256
+ context.runtime.prompt.info(`Installed ${selectedItems.length} item(s), wrote ${writtenFiles} file(s).`);
257
+ return ok({
258
+ kind: "success",
259
+ message: `Installed ${selectedItems.length} item(s), wrote ${writtenFiles} file(s).`,
260
+ });
261
+ }