@multicomma/arkcn 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/README.md +78 -0
- package/bin/arkcn.mjs +78 -0
- package/cli/commands.mjs +251 -0
- package/cli/project.mjs +168 -0
- package/cli/registry.mjs +50 -0
- package/cli/ui.mjs +46 -0
- package/mcp/server.mjs +149 -0
- package/package.json +201 -0
- package/registry/base.css +680 -0
- package/registry/index.json +1833 -0
- package/registry/items/accordion.json +18 -0
- package/registry/items/activity-feed.json +18 -0
- package/registry/items/alert-dialog.json +18 -0
- package/registry/items/alert.json +17 -0
- package/registry/items/angle-slider.json +17 -0
- package/registry/items/app-shell.json +28 -0
- package/registry/items/aspect-ratio.json +13 -0
- package/registry/items/attachment.json +19 -0
- package/registry/items/avatar.json +17 -0
- package/registry/items/badge.json +18 -0
- package/registry/items/breadcrumb.json +18 -0
- package/registry/items/bubble.json +18 -0
- package/registry/items/button-group.json +19 -0
- package/registry/items/button.json +18 -0
- package/registry/items/calendar.json +19 -0
- package/registry/items/canvas.json +24 -0
- package/registry/items/card.json +15 -0
- package/registry/items/carousel.json +19 -0
- package/registry/items/cascader.json +23 -0
- package/registry/items/chart.json +17 -0
- package/registry/items/chat.json +23 -0
- package/registry/items/checkbox.json +18 -0
- package/registry/items/clipboard.json +19 -0
- package/registry/items/collapsible.json +15 -0
- package/registry/items/color-picker.json +19 -0
- package/registry/items/combobox.json +20 -0
- package/registry/items/command.json +21 -0
- package/registry/items/comment-thread.json +25 -0
- package/registry/items/context-menu.json +18 -0
- package/registry/items/controllable.json +14 -0
- package/registry/items/data-grid.json +24 -0
- package/registry/items/data-table-adapter.json +14 -0
- package/registry/items/data-table.json +32 -0
- package/registry/items/date-input.json +17 -0
- package/registry/items/dialog.json +19 -0
- package/registry/items/direction.json +13 -0
- package/registry/items/drag-preview.json +16 -0
- package/registry/items/drawer.json +18 -0
- package/registry/items/dropdown-menu.json +18 -0
- package/registry/items/editable.json +17 -0
- package/registry/items/empty.json +17 -0
- package/registry/items/field.json +19 -0
- package/registry/items/file-upload.json +19 -0
- package/registry/items/floating-panel.json +19 -0
- package/registry/items/floating-toolbar.json +16 -0
- package/registry/items/form.json +14 -0
- package/registry/items/format.json +15 -0
- package/registry/items/gantt.json +22 -0
- package/registry/items/history.json +14 -0
- package/registry/items/hotkeys.json +18 -0
- package/registry/items/hover-card.json +17 -0
- package/registry/items/image-cropper.json +17 -0
- package/registry/items/input-group.json +20 -0
- package/registry/items/input-otp.json +18 -0
- package/registry/items/input.json +15 -0
- package/registry/items/item.json +19 -0
- package/registry/items/json-tree-view.json +17 -0
- package/registry/items/kanban.json +26 -0
- package/registry/items/kbd.json +15 -0
- package/registry/items/label.json +15 -0
- package/registry/items/live-region.json +16 -0
- package/registry/items/marker.json +18 -0
- package/registry/items/marquee.json +17 -0
- package/registry/items/menubar.json +19 -0
- package/registry/items/message.json +15 -0
- package/registry/items/navigation-menu.json +19 -0
- package/registry/items/node-graph.json +22 -0
- package/registry/items/number-input.json +18 -0
- package/registry/items/pagination.json +18 -0
- package/registry/items/password-input.json +18 -0
- package/registry/items/popconfirm.json +20 -0
- package/registry/items/popover.json +18 -0
- package/registry/items/progress.json +17 -0
- package/registry/items/qr-code.json +17 -0
- package/registry/items/query-builder.json +23 -0
- package/registry/items/radio-group.json +17 -0
- package/registry/items/rating-group.json +18 -0
- package/registry/items/resizable.json +17 -0
- package/registry/items/rich-text-editor.json +37 -0
- package/registry/items/row-layout.json +15 -0
- package/registry/items/scheduler.json +23 -0
- package/registry/items/scroll-area.json +17 -0
- package/registry/items/segment-group.json +17 -0
- package/registry/items/select.json +19 -0
- package/registry/items/separator.json +15 -0
- package/registry/items/sheet.json +19 -0
- package/registry/items/sidebar.json +27 -0
- package/registry/items/signature-pad.json +19 -0
- package/registry/items/skeleton.json +15 -0
- package/registry/items/slider.json +17 -0
- package/registry/items/sonner.json +17 -0
- package/registry/items/spinner.json +17 -0
- package/registry/items/steps.json +19 -0
- package/registry/items/swap.json +17 -0
- package/registry/items/switch.json +17 -0
- package/registry/items/table.json +16 -0
- package/registry/items/tabs.json +18 -0
- package/registry/items/tags-input.json +18 -0
- package/registry/items/textarea.json +15 -0
- package/registry/items/time.json +14 -0
- package/registry/items/timer.json +18 -0
- package/registry/items/toc.json +17 -0
- package/registry/items/toggle-group.json +19 -0
- package/registry/items/toggle.json +18 -0
- package/registry/items/tooltip.json +17 -0
- package/registry/items/tour.json +19 -0
- package/registry/items/transfer-list.json +24 -0
- package/registry/items/tree-select.json +24 -0
- package/registry/items/tree-view.json +18 -0
- package/registry/items/use-mobile.json +13 -0
- package/registry/items/utils.json +17 -0
- package/registry/items/virtual-list.json +16 -0
- package/src/components/ui/accordion.tsx +111 -0
- package/src/components/ui/activity-feed.tsx +228 -0
- package/src/components/ui/alert-dialog.tsx +204 -0
- package/src/components/ui/alert.tsx +55 -0
- package/src/components/ui/angle-slider.tsx +117 -0
- package/src/components/ui/app-shell.tsx +985 -0
- package/src/components/ui/aspect-ratio.tsx +35 -0
- package/src/components/ui/attachment.tsx +193 -0
- package/src/components/ui/avatar.tsx +94 -0
- package/src/components/ui/badge.tsx +45 -0
- package/src/components/ui/breadcrumb.tsx +93 -0
- package/src/components/ui/bubble.tsx +119 -0
- package/src/components/ui/button-group.tsx +79 -0
- package/src/components/ui/button.tsx +65 -0
- package/src/components/ui/calendar.tsx +172 -0
- package/src/components/ui/canvas.tsx +723 -0
- package/src/components/ui/card.tsx +69 -0
- package/src/components/ui/carousel.tsx +228 -0
- package/src/components/ui/cascader.tsx +754 -0
- package/src/components/ui/chart.tsx +303 -0
- package/src/components/ui/chat.tsx +544 -0
- package/src/components/ui/checkbox.tsx +94 -0
- package/src/components/ui/clipboard.tsx +94 -0
- package/src/components/ui/collapsible.tsx +32 -0
- package/src/components/ui/color-picker.tsx +441 -0
- package/src/components/ui/combobox.tsx +400 -0
- package/src/components/ui/command.tsx +292 -0
- package/src/components/ui/comment-thread.tsx +923 -0
- package/src/components/ui/context-menu.tsx +331 -0
- package/src/components/ui/data-grid.tsx +1227 -0
- package/src/components/ui/data-table.tsx +993 -0
- package/src/components/ui/date-input.tsx +103 -0
- package/src/components/ui/dialog.tsx +159 -0
- package/src/components/ui/direction.tsx +32 -0
- package/src/components/ui/drawer.tsx +200 -0
- package/src/components/ui/dropdown-menu.tsx +329 -0
- package/src/components/ui/editable.tsx +90 -0
- package/src/components/ui/empty.tsx +86 -0
- package/src/components/ui/field.tsx +218 -0
- package/src/components/ui/file-upload.tsx +204 -0
- package/src/components/ui/floating-panel.tsx +199 -0
- package/src/components/ui/floating-toolbar.tsx +79 -0
- package/src/components/ui/format.tsx +22 -0
- package/src/components/ui/gantt.tsx +950 -0
- package/src/components/ui/hotkeys.tsx +340 -0
- package/src/components/ui/hover-card.tsx +83 -0
- package/src/components/ui/image-cropper.tsx +100 -0
- package/src/components/ui/input-group.tsx +133 -0
- package/src/components/ui/input-otp.tsx +82 -0
- package/src/components/ui/input.tsx +18 -0
- package/src/components/ui/item.tsx +174 -0
- package/src/components/ui/json-tree-view.tsx +37 -0
- package/src/components/ui/kanban.tsx +884 -0
- package/src/components/ui/kbd.tsx +20 -0
- package/src/components/ui/label.tsx +30 -0
- package/src/components/ui/live-region.tsx +54 -0
- package/src/components/ui/marker.tsx +66 -0
- package/src/components/ui/marquee.tsx +61 -0
- package/src/components/ui/menubar.tsx +456 -0
- package/src/components/ui/message.tsx +74 -0
- package/src/components/ui/navigation-menu.tsx +202 -0
- package/src/components/ui/node-graph.tsx +1723 -0
- package/src/components/ui/number-input.tsx +148 -0
- package/src/components/ui/pagination.tsx +88 -0
- package/src/components/ui/password-input.tsx +104 -0
- package/src/components/ui/popconfirm.tsx +149 -0
- package/src/components/ui/popover.tsx +126 -0
- package/src/components/ui/progress.tsx +114 -0
- package/src/components/ui/qr-code.tsx +51 -0
- package/src/components/ui/query-builder.tsx +938 -0
- package/src/components/ui/radio-group.tsx +108 -0
- package/src/components/ui/rating-group.tsx +91 -0
- package/src/components/ui/resizable.tsx +41 -0
- package/src/components/ui/rich-text-editor.tsx +845 -0
- package/src/components/ui/scheduler.tsx +1164 -0
- package/src/components/ui/scroll-area.tsx +82 -0
- package/src/components/ui/segment-group.tsx +95 -0
- package/src/components/ui/select.tsx +354 -0
- package/src/components/ui/separator.tsx +32 -0
- package/src/components/ui/sheet.tsx +130 -0
- package/src/components/ui/sidebar.tsx +665 -0
- package/src/components/ui/signature-pad.tsx +112 -0
- package/src/components/ui/skeleton.tsx +7 -0
- package/src/components/ui/slider.tsx +183 -0
- package/src/components/ui/sonner.tsx +37 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/steps.tsx +152 -0
- package/src/components/ui/swap.tsx +33 -0
- package/src/components/ui/switch.tsx +83 -0
- package/src/components/ui/table.tsx +72 -0
- package/src/components/ui/tabs.tsx +100 -0
- package/src/components/ui/tags-input.tsx +153 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/timer.tsx +67 -0
- package/src/components/ui/toc.tsx +64 -0
- package/src/components/ui/toggle-group.tsx +89 -0
- package/src/components/ui/toggle.tsx +46 -0
- package/src/components/ui/tooltip.tsx +113 -0
- package/src/components/ui/tour.tsx +168 -0
- package/src/components/ui/transfer-list.tsx +700 -0
- package/src/components/ui/tree-select.tsx +638 -0
- package/src/components/ui/tree-view.tsx +217 -0
- package/src/components/ui/virtual-list.tsx +343 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/lib/controllable.ts +31 -0
- package/src/lib/data-table-adapter.ts +67 -0
- package/src/lib/drag-preview.ts +39 -0
- package/src/lib/form.ts +39 -0
- package/src/lib/history.ts +55 -0
- package/src/lib/row-layout.ts +81 -0
- package/src/lib/table.ts +483 -0
- package/src/lib/time.ts +37 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles/arkcn.css +764 -0
- package/src/styles/index.css +3 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# arkcn
|
|
2
|
+
|
|
3
|
+
shadcn/ui-style React components ported to [Ark UI](https://ark-ui.com), plus data-heavy primitives:
|
|
4
|
+
data grid, data table engine, kanban, gantt, scheduler, node graph, query builder, rich text editor
|
|
5
|
+
(tiptap), tree select, cascader, transfer list, virtual list, hotkeys, and more. Tailwind v4.
|
|
6
|
+
|
|
7
|
+
Every part is exported on its own, carries a `data-slot`, and is styled through Ark's data attributes.
|
|
8
|
+
Triggers are polymorphic via `asChild`.
|
|
9
|
+
|
|
10
|
+
## Install components
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx @multicomma/arkcn init # arkcn.json, src/lib/utils.ts, base styles, "@/" alias check, base deps
|
|
14
|
+
npx @multicomma/arkcn add button card dialog data-grid
|
|
15
|
+
npx @multicomma/arkcn list # everything in the registry (--type all, `list data-grid --docs`)
|
|
16
|
+
npx @multicomma/arkcn diff # which installed files you changed locally
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`init` writes `arkcn.json` (alias, `srcDir`, component/lib/hooks dirs, stylesheet), merges the
|
|
20
|
+
toolkit's tokens, custom variants, utilities, and keyframes into your stylesheet after
|
|
21
|
+
`@import "tailwindcss"` (between `/* arkcn:base */` markers, so re-running refreshes them),
|
|
22
|
+
makes sure the `@/*` path alias exists, and installs the base packages (Ark UI, lucide, clsx,
|
|
23
|
+
tailwind-merge, cva, tw-animate-css, the typography plugin). Tailwind v4 is required.
|
|
24
|
+
|
|
25
|
+
`add` copies the component **and everything it imports** (other components, `lib`, `hooks`) into
|
|
26
|
+
your project with imports rewritten to your alias, appends any component-specific CSS block, and
|
|
27
|
+
installs the packages those files need (tiptap for the editor, pragmatic drag and drop for kanban,
|
|
28
|
+
and so on). Files you have edited are kept unless you pass `--overwrite`; `diff --verbose` shows
|
|
29
|
+
what changed. `--no-install` skips the package manager, `--all` adds every component.
|
|
30
|
+
|
|
31
|
+
The components then belong to you: edit them freely, exactly as with shadcn/ui.
|
|
32
|
+
|
|
33
|
+
### Registry
|
|
34
|
+
|
|
35
|
+
The CLI reads a registry generated from this repo (`npm run build:registry` → `registry/`), bundled
|
|
36
|
+
in the npm package so a CLI version always installs a matching component set and works offline.
|
|
37
|
+
The same folder is committed here, so you can also read it from a URL, for example to pick up
|
|
38
|
+
components newer than your installed CLI:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx @multicomma/arkcn add kanban --registry https://raw.githubusercontent.com/itsdanreed/arkcn/main/registry
|
|
42
|
+
# or export ARKCN_REGISTRY=...
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Each item is `registry/items/<name>.json`: files with content, `registryDependencies`,
|
|
46
|
+
`dependencies` (with versions), an optional `css` fragment, and `docs`. `registry/index.json` is the
|
|
47
|
+
manifest and `registry/base.css` the init payload.
|
|
48
|
+
|
|
49
|
+
## MCP server
|
|
50
|
+
|
|
51
|
+
The package also ships an MCP server so agents (Claude Code, Cursor, and others) can browse the
|
|
52
|
+
registry, read documentation and source, and copy components into a project:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{ "mcpServers": { "@multicomma/arkcn": { "command": "npx", "args": ["-y", "@multicomma/arkcn", "mcp"] } } }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Tools: `list_components`, `search_components`, `get_component` (source, deps, css, docs),
|
|
59
|
+
`get_docs`, `plan_install` (dry run against a project), `add_components` (writes files and CSS,
|
|
60
|
+
returns the packages to install). Pass `--registry <url>` to read a hosted registry.
|
|
61
|
+
|
|
62
|
+
## Consume from node_modules instead
|
|
63
|
+
|
|
64
|
+
The demo app imports the package source directly: import `@multicomma/arkcn/styles.css`,
|
|
65
|
+
alias `@/components/ui`, `@/lib`, and `@/hooks` to `node_modules/@multicomma/arkcn/src/...`,
|
|
66
|
+
and add `@source "../node_modules/@multicomma/arkcn/src"` because Tailwind does not scan
|
|
67
|
+
`node_modules`. Heavy dependencies (tiptap, pragmatic drag and drop, recharts, embla, date-fns,
|
|
68
|
+
react-day-picker, input-otp, react-resizable-panels, sonner, next-themes) are optional peers.
|
|
69
|
+
|
|
70
|
+
The demo application lives in the `arkcn-demo` repository.
|
|
71
|
+
|
|
72
|
+
## Develop
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npm install
|
|
76
|
+
npm run check # registry build + typecheck + lint (oxlint with Tailwind rules, quotes, Ark part coverage, Prettier) + CLI e2e test
|
|
77
|
+
npm publish # prepublishOnly runs the same check
|
|
78
|
+
```
|
package/bin/arkcn.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util"
|
|
3
|
+
import { add, diff, init, list } from "../cli/commands.mjs"
|
|
4
|
+
import { createRegistry } from "../cli/registry.mjs"
|
|
5
|
+
import { c, fail, log } from "../cli/ui.mjs"
|
|
6
|
+
|
|
7
|
+
const help = `${c.bold("@multicomma/arkcn")} - copy Ark UI components into your project
|
|
8
|
+
|
|
9
|
+
${c.bold("Usage")}
|
|
10
|
+
npx @multicomma/arkcn init [--yes] [--alias @] [--css src/index.css] [--dir src] [--no-install]
|
|
11
|
+
npx @multicomma/arkcn add <name...> [--all] [--overwrite] [--yes] [--no-install]
|
|
12
|
+
npx @multicomma/arkcn list [name...] [--type ui|lib|hook|all] [--docs]
|
|
13
|
+
npx @multicomma/arkcn diff [name...] [--verbose]
|
|
14
|
+
npx @multicomma/arkcn mcp
|
|
15
|
+
|
|
16
|
+
${c.bold("Commands")}
|
|
17
|
+
init Write arkcn.json, lib/utils, the base styles, check the "@/" alias, install base deps
|
|
18
|
+
add Copy components (and everything they import) into your project, merge their styles, install deps
|
|
19
|
+
list Show the registry, or one item with --docs
|
|
20
|
+
diff Show which installed files were changed locally since they were added
|
|
21
|
+
mcp Start the Model Context Protocol server (stdio) for agents
|
|
22
|
+
|
|
23
|
+
${c.bold("Options")}
|
|
24
|
+
--registry <url|dir> Read the registry from a URL or directory instead of the bundled copy
|
|
25
|
+
(also ARKCN_REGISTRY), e.g. the GitHub raw URL of registry/
|
|
26
|
+
--cwd <dir> Project directory (default: current)
|
|
27
|
+
`
|
|
28
|
+
|
|
29
|
+
const { values, positionals } = parseArgs({
|
|
30
|
+
allowPositionals: true,
|
|
31
|
+
allowNegative: true,
|
|
32
|
+
options: {
|
|
33
|
+
help: { type: "boolean", short: "h" },
|
|
34
|
+
yes: { type: "boolean", short: "y" },
|
|
35
|
+
all: { type: "boolean" },
|
|
36
|
+
overwrite: { type: "boolean" },
|
|
37
|
+
install: { type: "boolean", default: true },
|
|
38
|
+
verbose: { type: "boolean", short: "v" },
|
|
39
|
+
docs: { type: "boolean" },
|
|
40
|
+
alias: { type: "string" },
|
|
41
|
+
css: { type: "string" },
|
|
42
|
+
dir: { type: "string" },
|
|
43
|
+
type: { type: "string" },
|
|
44
|
+
cwd: { type: "string" },
|
|
45
|
+
registry: { type: "string" },
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const [command, ...names] = positionals
|
|
50
|
+
const cwd = values.cwd ?? process.cwd()
|
|
51
|
+
const registry = createRegistry(values.registry ?? process.env.ARKCN_REGISTRY)
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
switch (command) {
|
|
55
|
+
case "init":
|
|
56
|
+
await init(cwd, registry, values)
|
|
57
|
+
break
|
|
58
|
+
case "add":
|
|
59
|
+
await add(cwd, registry, names, values)
|
|
60
|
+
break
|
|
61
|
+
case "list":
|
|
62
|
+
await list(cwd, registry, names, values)
|
|
63
|
+
break
|
|
64
|
+
case "diff":
|
|
65
|
+
await diff(cwd, registry, names, values)
|
|
66
|
+
break
|
|
67
|
+
case "mcp": {
|
|
68
|
+
const { startServer } = await import("../mcp/server.mjs")
|
|
69
|
+
await startServer({ registryBase: values.registry ?? process.env.ARKCN_REGISTRY })
|
|
70
|
+
break
|
|
71
|
+
}
|
|
72
|
+
default:
|
|
73
|
+
log(help)
|
|
74
|
+
if (command && !values.help) fail(`Unknown command: ${command}`)
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
fail(error instanceof Error ? error.message : String(error))
|
|
78
|
+
}
|
package/cli/commands.mjs
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import {
|
|
4
|
+
CONFIG_FILE,
|
|
5
|
+
defaultConfig,
|
|
6
|
+
detectBundlerAlias,
|
|
7
|
+
detectPackageManager,
|
|
8
|
+
ensureTsconfigAlias,
|
|
9
|
+
installDependencies,
|
|
10
|
+
installedVersion,
|
|
11
|
+
mergeBaseCss,
|
|
12
|
+
mergeComponentCss,
|
|
13
|
+
readConfig,
|
|
14
|
+
readPackageJson,
|
|
15
|
+
readProjectFile,
|
|
16
|
+
rewriteImports,
|
|
17
|
+
targetPath,
|
|
18
|
+
writeConfig,
|
|
19
|
+
writeProjectFile,
|
|
20
|
+
} from "./project.mjs"
|
|
21
|
+
import { c, confirm, fail, log, ok, table, warn } from "./ui.mjs"
|
|
22
|
+
|
|
23
|
+
/** Write an item's files. Returns what was written and what was kept. */
|
|
24
|
+
export function writeItem(cwd, config, item, { overwrite }) {
|
|
25
|
+
const written = []
|
|
26
|
+
const kept = []
|
|
27
|
+
for (const file of item.files) {
|
|
28
|
+
const rel = targetPath(config, file.path)
|
|
29
|
+
const content = rewriteImports(file.content, config)
|
|
30
|
+
const current = readProjectFile(cwd, rel)
|
|
31
|
+
if (current === content) continue
|
|
32
|
+
if (current !== null && !overwrite) {
|
|
33
|
+
kept.push(rel)
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
writeProjectFile(cwd, rel, content)
|
|
37
|
+
written.push(rel)
|
|
38
|
+
}
|
|
39
|
+
return { written, kept }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Files that exist locally and differ from the registry version. */
|
|
43
|
+
export function findConflicts(cwd, config, items) {
|
|
44
|
+
const conflicts = []
|
|
45
|
+
for (const item of items)
|
|
46
|
+
for (const file of item.files) {
|
|
47
|
+
const rel = targetPath(config, file.path)
|
|
48
|
+
const current = readProjectFile(cwd, rel)
|
|
49
|
+
if (current !== null && current !== rewriteImports(file.content, config)) conflicts.push(rel)
|
|
50
|
+
}
|
|
51
|
+
return conflicts
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Packages an item set needs that the project does not have yet. */
|
|
55
|
+
export function missingDependencies(cwd, items) {
|
|
56
|
+
const deps = new Map()
|
|
57
|
+
for (const item of items)
|
|
58
|
+
for (const [dep, version] of Object.entries(item.dependencies))
|
|
59
|
+
if (!installedVersion(cwd, dep)) deps.set(dep, version)
|
|
60
|
+
return [...deps].map(([dep, version]) => `${dep}@${version}`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `init`: config file, utils, base styles, alias check, base dependencies. */
|
|
64
|
+
export async function init(cwd, registry, flags) {
|
|
65
|
+
if (!readPackageJson(cwd)) fail(`No package.json in ${cwd}. Run this inside your app.`)
|
|
66
|
+
const existing = readConfig(cwd)
|
|
67
|
+
if (existing && !flags.yes) {
|
|
68
|
+
const again = await confirm(`${CONFIG_FILE} already exists. Re-run init and refresh the base styles?`, {
|
|
69
|
+
fallback: false,
|
|
70
|
+
})
|
|
71
|
+
if (!again) return
|
|
72
|
+
}
|
|
73
|
+
const config = {
|
|
74
|
+
...defaultConfig,
|
|
75
|
+
...existing,
|
|
76
|
+
...(flags.alias ? { alias: flags.alias } : {}),
|
|
77
|
+
...(flags.css ? { css: flags.css } : {}),
|
|
78
|
+
...(flags.dir
|
|
79
|
+
? {
|
|
80
|
+
srcDir: flags.dir,
|
|
81
|
+
componentsDir: `${flags.dir}/components/ui`,
|
|
82
|
+
libDir: `${flags.dir}/lib`,
|
|
83
|
+
hooksDir: `${flags.dir}/hooks`,
|
|
84
|
+
}
|
|
85
|
+
: {}),
|
|
86
|
+
}
|
|
87
|
+
writeConfig(cwd, config)
|
|
88
|
+
ok(`Wrote ${CONFIG_FILE}`)
|
|
89
|
+
|
|
90
|
+
const tailwind = installedVersion(cwd, "tailwindcss")
|
|
91
|
+
if (!tailwind) warn("tailwindcss is not in package.json; it will be installed (v4 is required).")
|
|
92
|
+
else if (!/(\^|~)?4\./.test(tailwind)) warn(`tailwindcss ${tailwind} found; the toolkit needs v4.`)
|
|
93
|
+
|
|
94
|
+
const alias = ensureTsconfigAlias(cwd, config.alias, config.srcDir)
|
|
95
|
+
if (alias === "added") ok(`Added "${config.alias}/*" -> "./${config.srcDir}/*" to tsconfig paths`)
|
|
96
|
+
else if (alias === "ok") ok(`tsconfig already maps "${config.alias}/*"`)
|
|
97
|
+
else warn(`Add "paths": { "${config.alias}/*": ["./${config.srcDir}/*"] } to your tsconfig compilerOptions.`)
|
|
98
|
+
const bundler = detectBundlerAlias(cwd, config.alias)
|
|
99
|
+
if (bundler.framework === "vite" && !bundler.ok)
|
|
100
|
+
warn(
|
|
101
|
+
`Add the alias to ${bundler.file}: resolve.alias { "${config.alias}": path.resolve(__dirname, "./${config.srcDir}") }`
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const { items } = await registry.closure(["utils"])
|
|
105
|
+
for (const item of items) writeItem(cwd, config, item, { overwrite: false })
|
|
106
|
+
ok(`Wrote ${targetPath(config, "lib/utils.ts")}`)
|
|
107
|
+
|
|
108
|
+
const result = mergeBaseCss(cwd, config, await registry.baseCss())
|
|
109
|
+
const verb = {
|
|
110
|
+
created: "Created",
|
|
111
|
+
updated: "Updated base styles in",
|
|
112
|
+
appended: "Appended base styles to",
|
|
113
|
+
unchanged: "Base styles already current in",
|
|
114
|
+
}
|
|
115
|
+
ok(`${verb[result]} ${config.css}`)
|
|
116
|
+
|
|
117
|
+
const index = await registry.index()
|
|
118
|
+
const deps = Object.entries(index.baseDependencies)
|
|
119
|
+
.filter(([dep]) => !installedVersion(cwd, dep))
|
|
120
|
+
.map(([dep, version]) => `${dep}@${version}`)
|
|
121
|
+
await install(cwd, deps, flags)
|
|
122
|
+
log(`\nNext: ${c.cyan("npx @multicomma/arkcn add button card dialog")}`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function install(cwd, deps, flags) {
|
|
126
|
+
if (deps.length === 0) return ok("No new dependencies")
|
|
127
|
+
if (flags.install === false) return log(`${c.dim("Skipped install. Needed:")} ${deps.join(" ")}`)
|
|
128
|
+
log(`${c.dim(`Installing with ${detectPackageManager(cwd)}:`)} ${deps.join(" ")}`)
|
|
129
|
+
const res = installDependencies(cwd, deps)
|
|
130
|
+
if (!res.ok) fail("Install failed. Install the packages above and re-run.")
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `add`: closure of components, files, CSS fragments, dependencies. */
|
|
134
|
+
export async function add(cwd, registry, names, flags) {
|
|
135
|
+
const config = readConfig(cwd)
|
|
136
|
+
if (!config) fail(`No ${CONFIG_FILE}. Run ${c.cyan("npx @multicomma/arkcn init")} first.`)
|
|
137
|
+
const index = await registry.index()
|
|
138
|
+
const wanted = flags.all ? index.items.filter((i) => i.type === "ui").map((i) => i.name) : names
|
|
139
|
+
if (wanted.length === 0) fail("Nothing to add. Pass component names or --all.")
|
|
140
|
+
const { items, missing } = await registry.closure(wanted)
|
|
141
|
+
if (missing.length) fail(`Unknown: ${missing.join(", ")}. See ${c.cyan("npx @multicomma/arkcn list")}.`)
|
|
142
|
+
|
|
143
|
+
let overwrite = !!flags.overwrite
|
|
144
|
+
const conflicts = findConflicts(cwd, config, items)
|
|
145
|
+
if (conflicts.length && !overwrite) {
|
|
146
|
+
warn(`These files exist and differ from the registry:\n ${conflicts.join("\n ")}`)
|
|
147
|
+
overwrite = await confirm("Overwrite them?", { yes: flags.yes, fallback: false })
|
|
148
|
+
if (!overwrite) log(c.dim("Keeping your versions; only new files will be written."))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let cssChanges = 0
|
|
152
|
+
for (const item of items) {
|
|
153
|
+
const { written, kept } = writeItem(cwd, config, item, { overwrite })
|
|
154
|
+
for (const rel of written) ok(`${item.name} -> ${rel}`)
|
|
155
|
+
for (const rel of kept) log(`${c.dim("kept")} ${rel}`)
|
|
156
|
+
if (item.css) {
|
|
157
|
+
const r = mergeComponentCss(cwd, config, item.name, item.css)
|
|
158
|
+
if (r !== "unchanged") {
|
|
159
|
+
cssChanges++
|
|
160
|
+
ok(`${item.name} styles ${r === "updated" ? "updated in" : "appended to"} ${config.css}`)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
await install(cwd, missingDependencies(cwd, items), flags)
|
|
165
|
+
const uiCount = items.filter((i) => i.type === "ui").length
|
|
166
|
+
log(
|
|
167
|
+
`\n${c.bold(`${uiCount} component(s), ${items.length - uiCount} helper(s)${cssChanges ? `, ${cssChanges} style block(s)` : ""}`)}`
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** `list`: everything in the registry, or one item in detail. */
|
|
172
|
+
export async function list(cwd, registry, names, flags) {
|
|
173
|
+
const index = await registry.index()
|
|
174
|
+
const config = readConfig(cwd)
|
|
175
|
+
if (names.length) {
|
|
176
|
+
for (const name of names) {
|
|
177
|
+
const item = await registry.item(name)
|
|
178
|
+
if (!item) return fail(`Unknown component: ${name}`)
|
|
179
|
+
log(`${c.bold(item.name)} ${c.dim(`(${item.type})`)}\n${item.description}\n`)
|
|
180
|
+
log(`${c.dim("files:")} ${item.files.map((f) => f.path).join(", ")}`)
|
|
181
|
+
log(`${c.dim("needs:")} ${item.registryDependencies.join(", ") || "-"}`)
|
|
182
|
+
log(`${c.dim("packages:")} ${Object.keys(item.dependencies).join(", ") || "-"}`)
|
|
183
|
+
if (item.css) log(`${c.dim("styles:")} yes`)
|
|
184
|
+
if (flags.docs && item.docs) log(`\n${item.docs}`)
|
|
185
|
+
}
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
const type = flags.type ?? "ui"
|
|
189
|
+
const rows = index.items
|
|
190
|
+
.filter((i) => type === "all" || i.type === type)
|
|
191
|
+
.map((i) => {
|
|
192
|
+
const installed = config && i.files.every((f) => existsSync(join(cwd, targetPath(config, f))))
|
|
193
|
+
return [i.name, i.type, installed ? c.green("installed") : "", i.description.slice(0, 70)]
|
|
194
|
+
})
|
|
195
|
+
table(rows, ["name", "type", "", "description"])
|
|
196
|
+
log(c.dim(`\n${rows.length} item(s) from ${registry.source}. \`list <name> --docs\` prints the documentation.`))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** `diff`: compare installed files with the registry. */
|
|
200
|
+
export async function diff(cwd, registry, names, flags) {
|
|
201
|
+
const config = readConfig(cwd)
|
|
202
|
+
if (!config) fail(`No ${CONFIG_FILE}. Run init first.`)
|
|
203
|
+
const index = await registry.index()
|
|
204
|
+
const targets = names.length ? names : index.items.map((i) => i.name)
|
|
205
|
+
let changed = 0
|
|
206
|
+
for (const name of targets) {
|
|
207
|
+
const item = await registry.item(name)
|
|
208
|
+
if (!item) return fail(`Unknown component: ${name}`)
|
|
209
|
+
for (const file of item.files) {
|
|
210
|
+
const rel = targetPath(config, file.path)
|
|
211
|
+
const current = readProjectFile(cwd, rel)
|
|
212
|
+
if (current === null) {
|
|
213
|
+
if (names.length) log(`${c.dim("missing")} ${rel}`)
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
const expected = rewriteImports(file.content, config)
|
|
217
|
+
if (current === expected) {
|
|
218
|
+
if (names.length) log(`${c.green("same")} ${rel}`)
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
changed++
|
|
222
|
+
log(`${c.yellow("changed")} ${rel}`)
|
|
223
|
+
if (flags.verbose) log(lineDiff(current, expected))
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (!changed) ok("Installed files match the registry")
|
|
227
|
+
else log(c.dim(`\n${changed} file(s) differ. \`add <name> --overwrite\` replaces them with the registry version.`))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Minimal LCS line diff (files are small). */
|
|
231
|
+
export function lineDiff(current, expected) {
|
|
232
|
+
const a = current.split("\n")
|
|
233
|
+
const b = expected.split("\n")
|
|
234
|
+
const dp = Array.from({ length: a.length + 1 }, () => new Uint16Array(b.length + 1))
|
|
235
|
+
for (let i = a.length - 1; i >= 0; i--)
|
|
236
|
+
for (let j = b.length - 1; j >= 0; j--)
|
|
237
|
+
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
|
|
238
|
+
let i = 0
|
|
239
|
+
let j = 0
|
|
240
|
+
const out = []
|
|
241
|
+
while (i < a.length && j < b.length) {
|
|
242
|
+
if (a[i] === b[j]) {
|
|
243
|
+
i++
|
|
244
|
+
j++
|
|
245
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) out.push(c.red(`- ${a[i++]}`))
|
|
246
|
+
else out.push(c.green(`+ ${b[j++]}`))
|
|
247
|
+
}
|
|
248
|
+
while (i < a.length) out.push(c.red(`- ${a[i++]}`))
|
|
249
|
+
while (j < b.length) out.push(c.green(`+ ${b[j++]}`))
|
|
250
|
+
return out.join("\n")
|
|
251
|
+
}
|
package/cli/project.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { dirname, join, relative } from "node:path"
|
|
3
|
+
import { spawnSync } from "node:child_process"
|
|
4
|
+
|
|
5
|
+
export const CONFIG_FILE = "arkcn.json"
|
|
6
|
+
|
|
7
|
+
export const defaultConfig = {
|
|
8
|
+
alias: "@",
|
|
9
|
+
/** What the alias points at; component dirs live under it. */
|
|
10
|
+
srcDir: "src",
|
|
11
|
+
componentsDir: "src/components/ui",
|
|
12
|
+
libDir: "src/lib",
|
|
13
|
+
hooksDir: "src/hooks",
|
|
14
|
+
css: "src/index.css",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function readConfig(cwd) {
|
|
18
|
+
const file = join(cwd, CONFIG_FILE)
|
|
19
|
+
if (!existsSync(file)) return null
|
|
20
|
+
return { ...defaultConfig, ...JSON.parse(readFileSync(file, "utf8")) }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function writeConfig(cwd, config) {
|
|
24
|
+
writeFileSync(join(cwd, CONFIG_FILE), JSON.stringify(config, null, 2) + "\n")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function detectPackageManager(cwd) {
|
|
28
|
+
if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm"
|
|
29
|
+
if (existsSync(join(cwd, "yarn.lock"))) return "yarn"
|
|
30
|
+
if (existsSync(join(cwd, "bun.lock")) || existsSync(join(cwd, "bun.lockb"))) return "bun"
|
|
31
|
+
return "npm"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readPackageJson(cwd) {
|
|
35
|
+
const file = join(cwd, "package.json")
|
|
36
|
+
if (!existsSync(file)) return null
|
|
37
|
+
return JSON.parse(readFileSync(file, "utf8"))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function installedVersion(cwd, dep) {
|
|
41
|
+
const pkg = readPackageJson(cwd)
|
|
42
|
+
return pkg?.dependencies?.[dep] ?? pkg?.devDependencies?.[dep] ?? null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Target path inside the project for a registry file path like "components/ui/button.tsx". */
|
|
46
|
+
export function targetPath(config, registryPath) {
|
|
47
|
+
if (registryPath.startsWith("components/ui/"))
|
|
48
|
+
return join(config.componentsDir, registryPath.slice("components/ui/".length))
|
|
49
|
+
if (registryPath.startsWith("lib/")) return join(config.libDir, registryPath.slice("lib/".length))
|
|
50
|
+
if (registryPath.startsWith("hooks/")) return join(config.hooksDir, registryPath.slice("hooks/".length))
|
|
51
|
+
return registryPath
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Rewrite "@/components/ui/x", "@/lib/x", "@/hooks/x" imports to the project's alias and dirs. */
|
|
55
|
+
export function rewriteImports(content, config) {
|
|
56
|
+
const alias = config.alias.replace(/\/$/, "")
|
|
57
|
+
const dirs = { "components/ui": config.componentsDir, lib: config.libDir, hooks: config.hooksDir }
|
|
58
|
+
const rootPrefix = new RegExp(`^${config.srcDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/`)
|
|
59
|
+
return content.replace(/from "@\/(components\/ui|lib|hooks)\/([^"]+)"/g, (_, kind, rest) => {
|
|
60
|
+
const dir = dirs[kind].replace(rootPrefix, "")
|
|
61
|
+
return `from "${alias}/${dir}/${rest}"`
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function writeProjectFile(cwd, rel, content) {
|
|
66
|
+
const file = join(cwd, rel)
|
|
67
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
68
|
+
writeFileSync(file, content)
|
|
69
|
+
return relative(cwd, file)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function readProjectFile(cwd, rel) {
|
|
73
|
+
const file = join(cwd, rel)
|
|
74
|
+
return existsSync(file) ? readFileSync(file, "utf8") : null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const BASE_START = "/* arkcn:base */"
|
|
78
|
+
const BASE_END = "/* arkcn:base:end */"
|
|
79
|
+
const fragmentStart = (name) => `/* arkcn:component ${name} */`
|
|
80
|
+
const fragmentEnd = (name) => `/* arkcn:component ${name}:end */`
|
|
81
|
+
|
|
82
|
+
function replaceBlock(css, start, end, block) {
|
|
83
|
+
const i = css.indexOf(start)
|
|
84
|
+
const j = css.indexOf(end)
|
|
85
|
+
if (i >= 0 && j > i) {
|
|
86
|
+
const current = css.slice(i, j + end.length + 1)
|
|
87
|
+
if (current === block) return { css, result: "unchanged" }
|
|
88
|
+
return { css: css.slice(0, i) + block + css.slice(j + end.length).replace(/^\n/, ""), result: "updated" }
|
|
89
|
+
}
|
|
90
|
+
return { css: css.trimEnd() + "\n\n" + block, result: "appended" }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Insert or replace the base block; creates the stylesheet (with the tailwind import) when missing. */
|
|
94
|
+
export function mergeBaseCss(cwd, config, baseCss) {
|
|
95
|
+
const block = `${BASE_START}\n${baseCss.trim()}\n${BASE_END}\n`
|
|
96
|
+
let css = readProjectFile(cwd, config.css)
|
|
97
|
+
if (css === null) {
|
|
98
|
+
writeProjectFile(cwd, config.css, `@import "tailwindcss";\n\n${block}`)
|
|
99
|
+
return "created"
|
|
100
|
+
}
|
|
101
|
+
if (!/@import\s+"tailwindcss"/.test(css)) css = `@import "tailwindcss";\n` + css
|
|
102
|
+
const merged = replaceBlock(css, BASE_START, BASE_END, block)
|
|
103
|
+
if (merged.result !== "unchanged") writeProjectFile(cwd, config.css, merged.css)
|
|
104
|
+
return merged.result
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function mergeComponentCss(cwd, config, name, fragment) {
|
|
108
|
+
const css = readProjectFile(cwd, config.css) ?? `@import "tailwindcss";\n`
|
|
109
|
+
const block = `${fragmentStart(name)}\n${fragment.trim()}\n${fragmentEnd(name)}\n`
|
|
110
|
+
const merged = replaceBlock(css, fragmentStart(name), fragmentEnd(name), block)
|
|
111
|
+
if (merged.result !== "unchanged") writeProjectFile(cwd, config.css, merged.css)
|
|
112
|
+
return merged.result
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function installDependencies(cwd, deps) {
|
|
116
|
+
if (deps.length === 0) return { ok: true, skipped: true }
|
|
117
|
+
const pm = detectPackageManager(cwd)
|
|
118
|
+
const args = pm === "npm" ? ["install", ...deps] : ["add", ...deps]
|
|
119
|
+
const result = spawnSync(pm, args, { cwd, stdio: "inherit" })
|
|
120
|
+
return { ok: result.status === 0, pm, args }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Ensure tsconfig paths has "<alias>/*": ["./<srcDir>/*"]. Returns "ok" | "added" | "manual". */
|
|
124
|
+
export function ensureTsconfigAlias(cwd, alias, srcDir = "src") {
|
|
125
|
+
for (const name of ["tsconfig.app.json", "tsconfig.json"]) {
|
|
126
|
+
const file = join(cwd, name)
|
|
127
|
+
if (!existsSync(file)) continue
|
|
128
|
+
const raw = readFileSync(file, "utf8")
|
|
129
|
+
let json
|
|
130
|
+
try {
|
|
131
|
+
json = JSON.parse(
|
|
132
|
+
raw
|
|
133
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
134
|
+
.replace(/^\s*\/\/.*$/gm, "")
|
|
135
|
+
.replace(/,(\s*[}\]])/g, "$1")
|
|
136
|
+
)
|
|
137
|
+
} catch {
|
|
138
|
+
return "manual"
|
|
139
|
+
}
|
|
140
|
+
const paths = json.compilerOptions?.paths ?? {}
|
|
141
|
+
if (paths[`${alias}/*`]) return "ok"
|
|
142
|
+
if (raw.includes("//") || raw.includes("/*")) return "manual"
|
|
143
|
+
json.compilerOptions = { ...json.compilerOptions, paths: { ...paths, [`${alias}/*`]: [`./${srcDir}/*`] } }
|
|
144
|
+
writeFileSync(file, JSON.stringify(json, null, 2) + "\n")
|
|
145
|
+
return "added"
|
|
146
|
+
}
|
|
147
|
+
return "manual"
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function detectBundlerAlias(cwd, alias) {
|
|
151
|
+
const candidates = [
|
|
152
|
+
"vite.config.ts",
|
|
153
|
+
"vite.config.js",
|
|
154
|
+
"vite.config.mts",
|
|
155
|
+
"next.config.ts",
|
|
156
|
+
"next.config.js",
|
|
157
|
+
"next.config.mjs",
|
|
158
|
+
]
|
|
159
|
+
for (const name of candidates) {
|
|
160
|
+
const file = join(cwd, name)
|
|
161
|
+
if (!existsSync(file)) continue
|
|
162
|
+
const raw = readFileSync(file, "utf8")
|
|
163
|
+
if (name.startsWith("next")) return { file: name, ok: true, framework: "next" }
|
|
164
|
+
const ok = raw.includes(`"${alias}"`) || raw.includes(`'${alias}'`) || raw.includes("tsconfigPaths")
|
|
165
|
+
return { file: name, ok, framework: "vite" }
|
|
166
|
+
}
|
|
167
|
+
return { file: null, ok: false, framework: "unknown" }
|
|
168
|
+
}
|
package/cli/registry.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
/** The registry shipped inside this package (generated by scripts/build-registry.mjs). */
|
|
5
|
+
export const bundledDir = new URL("../registry/", import.meta.url).pathname
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A registry source: the bundled directory by default, or a base URL (`--registry`,
|
|
9
|
+
* `ARKCN_REGISTRY`) such as https://raw.githubusercontent.com/itsdanreed/arkcn/main/registry
|
|
10
|
+
*/
|
|
11
|
+
export function createRegistry(base) {
|
|
12
|
+
const remote = base && /^https?:\/\//.test(base)
|
|
13
|
+
const read = async (rel) => {
|
|
14
|
+
if (!remote) return readFileSync(join(base ?? bundledDir, rel), "utf8")
|
|
15
|
+
const url = `${base.replace(/\/$/, "")}/${rel}`
|
|
16
|
+
const res = await fetch(url)
|
|
17
|
+
if (!res.ok) throw new Error(`Registry request failed: ${res.status} ${url}`)
|
|
18
|
+
return res.text()
|
|
19
|
+
}
|
|
20
|
+
const cache = new Map()
|
|
21
|
+
const cached = (rel, parse) => {
|
|
22
|
+
if (!cache.has(rel)) cache.set(rel, read(rel).then(parse))
|
|
23
|
+
return cache.get(rel)
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
source: remote ? base : "bundled",
|
|
27
|
+
index: () => cached("index.json", JSON.parse),
|
|
28
|
+
item: (name) => cached(`items/${name}.json`, JSON.parse).catch(() => null),
|
|
29
|
+
baseCss: () => cached("base.css", (s) => s),
|
|
30
|
+
/** Every item needed to install `names`, dependencies first, each once. */
|
|
31
|
+
async closure(names) {
|
|
32
|
+
const ordered = []
|
|
33
|
+
const seen = new Set()
|
|
34
|
+
const missing = []
|
|
35
|
+
const visit = async (name, chain) => {
|
|
36
|
+
if (seen.has(name)) return
|
|
37
|
+
const item = await this.item(name)
|
|
38
|
+
if (!item) {
|
|
39
|
+
missing.push(chain.length ? `${name} (needed by ${chain[chain.length - 1]})` : name)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
seen.add(name)
|
|
43
|
+
for (const dep of item.registryDependencies) await visit(dep, [...chain, name])
|
|
44
|
+
ordered.push(item)
|
|
45
|
+
}
|
|
46
|
+
for (const name of names) await visit(name, [])
|
|
47
|
+
return { items: ordered, missing }
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
package/cli/ui.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises"
|
|
2
|
+
import { stdin, stdout } from "node:process"
|
|
3
|
+
|
|
4
|
+
const tty = stdout.isTTY && !process.env.NO_COLOR
|
|
5
|
+
const ESC = "["
|
|
6
|
+
const paint = (code) => (s) => (tty ? `${ESC}${code}m${s}${ESC}0m` : String(s))
|
|
7
|
+
export const c = {
|
|
8
|
+
bold: paint("1"),
|
|
9
|
+
dim: paint("2"),
|
|
10
|
+
green: paint("32"),
|
|
11
|
+
yellow: paint("33"),
|
|
12
|
+
red: paint("31"),
|
|
13
|
+
cyan: paint("36"),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const log = (...args) => console.log(...args)
|
|
17
|
+
export const ok = (msg) => log(`${c.green("+")} ${msg}`)
|
|
18
|
+
export const warn = (msg) => log(`${c.yellow("!")} ${msg}`)
|
|
19
|
+
export const fail = (msg) => {
|
|
20
|
+
console.error(`${c.red("x")} ${msg}`)
|
|
21
|
+
process.exit(1)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function confirm(question, { yes = false, fallback = true } = {}) {
|
|
25
|
+
if (yes) return fallback
|
|
26
|
+
if (!stdin.isTTY) return fallback
|
|
27
|
+
const rl = createInterface({ input: stdin, output: stdout })
|
|
28
|
+
try {
|
|
29
|
+
const answer = (await rl.question(`${question} ${fallback ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase()
|
|
30
|
+
if (!answer) return fallback
|
|
31
|
+
return answer === "y" || answer === "yes"
|
|
32
|
+
} finally {
|
|
33
|
+
rl.close()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function table(rows, headers) {
|
|
38
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] ?? "").length)))
|
|
39
|
+
const line = (cells) =>
|
|
40
|
+
cells
|
|
41
|
+
.map((cell, i) => String(cell ?? "").padEnd(widths[i]))
|
|
42
|
+
.join(" ")
|
|
43
|
+
.trimEnd()
|
|
44
|
+
log(c.bold(line(headers)))
|
|
45
|
+
for (const r of rows) log(line(r))
|
|
46
|
+
}
|