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,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
|
package/.release-it.json
ADDED
|
@@ -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,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
|
+
}
|