dshmarket 1.2.2 → 1.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/README.md +8 -0
- package/README.zh.md +8 -0
- package/client/client.js +106 -71
- package/client/client.js.map +1 -1
- package/lib/dsh-cli.js +164 -0
- package/lib/http.js +39 -0
- package/lib/install.js +110 -0
- package/lib/pnpm-compat.js +72 -0
- package/lib/profile.js +121 -0
- package/lib/routes.js +63 -552
- package/lib/sources.js +85 -0
- package/lib/themes.js +102 -0
- package/lib/types/dsh-cli.d.ts +61 -0
- package/lib/types/http.d.ts +12 -0
- package/lib/types/install.d.ts +49 -0
- package/lib/types/pnpm-compat.d.ts +42 -0
- package/lib/types/profile.d.ts +24 -0
- package/lib/types/routes.d.ts +7 -21
- package/lib/types/sources.d.ts +42 -0
- package/lib/types/themes.d.ts +40 -0
- package/lib/types/updates.d.ts +16 -0
- package/lib/updates.js +61 -0
- package/package.json +5 -2
- package/src/client/Market.module.css +4 -1
- package/src/client/MarketSection.tsx +23 -27
- package/src/client/locales.ts +2 -0
- package/src/client/market-data.ts +60 -4
- package/src/dsh-cli.ts +194 -0
- package/src/http.ts +41 -0
- package/src/install.ts +121 -0
- package/src/pnpm-compat.ts +83 -0
- package/src/profile.ts +120 -0
- package/src/routes.ts +68 -583
- package/src/sources.ts +84 -0
- package/src/themes.ts +120 -0
- package/src/updates.ts +70 -0
package/src/sources.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry-source knowledge: how a curated registry entry's URL maps to an
|
|
3
|
+
* installable pnpm target. Pure string logic, no I/O.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/
|
|
7
|
+
|
|
8
|
+
/** Registry tarball names must be plain npm package names, nothing fancier. */
|
|
9
|
+
const NPM_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse a registry source url: a github repo, optionally with a
|
|
13
|
+
* `/tree/<branch>/<subpath>` suffix (how the curated list links monorepo
|
|
14
|
+
* subpackages, e.g. dsh-plugins#theme-gallery).
|
|
15
|
+
*/
|
|
16
|
+
export function parseSourceUrl(url: string): { repo: string; subpath: string | null } | null {
|
|
17
|
+
const m = /^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\/tree\/[^/]+\/(.+?))?\/?$/.exec(url)
|
|
18
|
+
if (m === null || !REPO_RE.test(m[1])) return null
|
|
19
|
+
const subpath = m[2] ?? null
|
|
20
|
+
if (subpath !== null) {
|
|
21
|
+
if (!/^[A-Za-z0-9_./-]+$/.test(subpath)) return null
|
|
22
|
+
// No empty/dot segments: `..` would escape the repo in the #path: selector.
|
|
23
|
+
if (subpath.split('/').some(seg => seg === '' || seg === '.' || seg === '..')) return null
|
|
24
|
+
}
|
|
25
|
+
return { repo: m[1], subpath }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** GitHub `owner/repo` for a registry URL, or null when it is not a GitHub repo URL. */
|
|
29
|
+
export function repoOf(url: string): string | null {
|
|
30
|
+
return parseSourceUrl(url)?.repo ?? null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The pnpm install target for a registry entry. Registry tarballs beat
|
|
35
|
+
* full-repo GitHub downloads: smaller, prebuilt, and CDN/mirror served. The
|
|
36
|
+
* npm name comes from our curated registry, which only maps repo-verified
|
|
37
|
+
* packages (name-squatting protection).
|
|
38
|
+
* @returns the target spec, or null when the source url is unsupported.
|
|
39
|
+
*/
|
|
40
|
+
export function installTargetFor(entry: { url: string; npm?: unknown }): string | null {
|
|
41
|
+
const source = parseSourceUrl(entry.url)
|
|
42
|
+
if (source === null) return null
|
|
43
|
+
if (typeof entry.npm === 'string' && NPM_NAME_RE.test(entry.npm)) return entry.npm
|
|
44
|
+
return source.subpath !== null
|
|
45
|
+
? `github:${source.repo}#path:/${source.subpath}`
|
|
46
|
+
: `github:${source.repo}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The name an entry is ALREADY installed under, or null — the server-side
|
|
51
|
+
* duplicate guard (#27): the same plugin listed under an alias entry must
|
|
52
|
+
* never install twice (two loader entries with one id brick the next boot).
|
|
53
|
+
*
|
|
54
|
+
* Identity is subpath-aware so monorepo siblings stay independent: an entry
|
|
55
|
+
* with a /tree/ subpath identifies as repo#path:/sub (never the bare repo),
|
|
56
|
+
* while an installed dependency contributes its bare repo AND its #path:
|
|
57
|
+
* form — so a collection root still matches the pieces it was retargeted
|
|
58
|
+
* into, but two different subpackages of one repo never cross-match.
|
|
59
|
+
*/
|
|
60
|
+
export function findInstalledAlias(
|
|
61
|
+
entry: { name: string; npm?: unknown; url: string },
|
|
62
|
+
installed: Record<string, string>,
|
|
63
|
+
): string | null {
|
|
64
|
+
const source = parseSourceUrl(entry.url)
|
|
65
|
+
const ids = new Set<string>([entry.name.toLowerCase()])
|
|
66
|
+
if (typeof entry.npm === 'string' && entry.npm !== '') ids.add(entry.npm.toLowerCase())
|
|
67
|
+
if (source !== null) {
|
|
68
|
+
ids.add(source.subpath === null
|
|
69
|
+
? source.repo.toLowerCase()
|
|
70
|
+
: `${source.repo.toLowerCase()}#path:/${source.subpath.toLowerCase()}`)
|
|
71
|
+
}
|
|
72
|
+
for (const [name, spec] of Object.entries(installed)) {
|
|
73
|
+
const dep = new Set<string>([name.toLowerCase()])
|
|
74
|
+
const scoped = /^@([^/]+)\/(.+)$/.exec(name)
|
|
75
|
+
if (scoped !== null) dep.add(`${scoped[1]}/${scoped[2]}`.toLowerCase())
|
|
76
|
+
const m = /github:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?:#path:\/([A-Za-z0-9_./-]+))?/i.exec(spec)
|
|
77
|
+
if (m !== null) {
|
|
78
|
+
dep.add(m[1].toLowerCase())
|
|
79
|
+
if (m[2] !== undefined) dep.add(`${m[1].toLowerCase()}#path:/${m[2].toLowerCase()}`)
|
|
80
|
+
}
|
|
81
|
+
for (const id of dep) if (ids.has(id)) return name
|
|
82
|
+
}
|
|
83
|
+
return null
|
|
84
|
+
}
|
package/src/themes.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme lifecycle: classifying installed packages as themes (by the
|
|
3
|
+
* registry's theme category), live-toggling bundle-layer entries through
|
|
4
|
+
* the loader, and keeping exactly one theme active with the choice
|
|
5
|
+
* persisted across restarts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { loadRegistry } from './registry.ts'
|
|
9
|
+
import { hotMount, hotUnmount, listHotMounts, writeDisabledThemes } from './hot.ts'
|
|
10
|
+
import { logEvent } from './log.ts'
|
|
11
|
+
import { profileDir, readInstalled } from './profile.ts'
|
|
12
|
+
import { repoOf } from './sources.ts'
|
|
13
|
+
|
|
14
|
+
/** The slice of a cordis loader entry the market needs for live enable/disable. */
|
|
15
|
+
export interface LoaderEntry {
|
|
16
|
+
options: { id?: string; name?: string; disabled?: boolean | null }
|
|
17
|
+
fiber?: unknown
|
|
18
|
+
update(options: { disabled: boolean | null }, create?: boolean, force?: boolean): Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The host surface the theme manager needs (loader entries + hot-mount context). */
|
|
22
|
+
export interface ThemeHost {
|
|
23
|
+
loader: { entries(): Iterable<LoaderEntry> }
|
|
24
|
+
plugin(plugin: unknown, config: unknown): { await(): Promise<unknown>; dispose(): Promise<unknown> | void }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Manages theme exclusivity for one profile. */
|
|
28
|
+
export interface ThemeManager {
|
|
29
|
+
installedThemeNames(): Promise<Set<string>>
|
|
30
|
+
setEntryDisabled(name: string, disabledFlag: boolean): Promise<boolean>
|
|
31
|
+
activateTheme(name: string): Promise<boolean>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create the theme manager. `disabledThemes` is the live, shared set of
|
|
36
|
+
* themes the user switched off — the caller owns reading it at boot and
|
|
37
|
+
* replaying it; the manager mutates and persists it on switches.
|
|
38
|
+
*/
|
|
39
|
+
export function createThemeManager(host: ThemeHost, profile: string, disabledThemes: Set<string>): ThemeManager {
|
|
40
|
+
/** Installed package names classified as themes by the registry's theme category. */
|
|
41
|
+
async function installedThemeNames(): Promise<Set<string>> {
|
|
42
|
+
const names = new Set<string>()
|
|
43
|
+
try {
|
|
44
|
+
const { registry } = await loadRegistry()
|
|
45
|
+
const themeEntries = registry.plugins.filter(p => p.category === 'theme')
|
|
46
|
+
const themeNames = new Set(themeEntries.map(p => p.name))
|
|
47
|
+
const themeRepos = new Set(
|
|
48
|
+
themeEntries.map(p => repoOf(p.url)).filter((r): r is string => r !== null).map(r => r.toLowerCase()),
|
|
49
|
+
)
|
|
50
|
+
for (const [name, spec] of Object.entries(readInstalled(profile))) {
|
|
51
|
+
if (themeNames.has(name)) {
|
|
52
|
+
names.add(name)
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
const match = /github:([^#\s]+)/.exec(String(spec).toLowerCase())
|
|
56
|
+
if (match !== null && themeRepos.has(match[1])) names.add(name)
|
|
57
|
+
}
|
|
58
|
+
} catch { /* registry unavailable — nothing classifies as a theme */ }
|
|
59
|
+
return names
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Live-toggle a bundle-layer plugin through its loader entry. Bundle trees
|
|
64
|
+
* are in-memory (write is a no-op), so this never touches any file — the
|
|
65
|
+
* market persists the choice itself and replays it at boot.
|
|
66
|
+
* @returns true when a matching live entry was found and updated.
|
|
67
|
+
*/
|
|
68
|
+
async function setEntryDisabled(name: string, disabledFlag: boolean): Promise<boolean> {
|
|
69
|
+
let found = false
|
|
70
|
+
for (const entry of host.loader.entries()) {
|
|
71
|
+
if (entry.options.name !== name) continue
|
|
72
|
+
// A disable can land while the entry's init is still in flight: the
|
|
73
|
+
// options flip but the finishing init brings the fiber up anyway, and a
|
|
74
|
+
// plain re-update no-ops on the empty diff. Force the update and verify
|
|
75
|
+
// the live state, retrying until reality matches the flag.
|
|
76
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
77
|
+
try {
|
|
78
|
+
await entry.update({ disabled: disabledFlag ? true : null }, false, true)
|
|
79
|
+
found = true
|
|
80
|
+
} catch (error) {
|
|
81
|
+
logEvent('warn', 'toggle', `${name}: entry update failed — ${error instanceof Error ? error.message : String(error)}`)
|
|
82
|
+
break
|
|
83
|
+
}
|
|
84
|
+
const live = entry.fiber !== undefined
|
|
85
|
+
if (live !== disabledFlag) break
|
|
86
|
+
await new Promise(resolvePromise => setTimeout(resolvePromise, 200))
|
|
87
|
+
}
|
|
88
|
+
logEvent('info', 'toggle',
|
|
89
|
+
`${name} -> ${disabledFlag ? 'off' : 'on'}: fiber=${String(entry.fiber !== undefined)}`)
|
|
90
|
+
}
|
|
91
|
+
if (!found) logEvent('info', 'toggle', `${name}: no loader entry matched`)
|
|
92
|
+
return found
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Make `name` the one active theme: deactivate every other installed theme
|
|
97
|
+
* (market hot mounts unmount; bundle-layer entries live-disable) and bring
|
|
98
|
+
* it up. The choice persists in state.json and is replayed at boot.
|
|
99
|
+
*/
|
|
100
|
+
async function activateTheme(name: string): Promise<boolean> {
|
|
101
|
+
const dir = profileDir(profile)
|
|
102
|
+
const themes = await installedThemeNames()
|
|
103
|
+
for (const other of themes) {
|
|
104
|
+
if (other === name) continue
|
|
105
|
+
if (listHotMounts().includes(other)) {
|
|
106
|
+
await hotUnmount(other)
|
|
107
|
+
disabledThemes.add(other)
|
|
108
|
+
} else if (await setEntryDisabled(other, true)) {
|
|
109
|
+
disabledThemes.add(other)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
disabledThemes.delete(name)
|
|
113
|
+
writeDisabledThemes(dir, disabledThemes)
|
|
114
|
+
if (listHotMounts().includes(name)) return true
|
|
115
|
+
if (await setEntryDisabled(name, false)) return true
|
|
116
|
+
return hotMount(host, dir, name)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { installedThemeNames, setEntryDisabled, activateTheme }
|
|
120
|
+
}
|
package/src/updates.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update detection: per-plugin comparison of what the profile has against
|
|
3
|
+
* the source of truth — git HEAD for github installs, the npm latest
|
|
4
|
+
* dist-tag for registry installs — with a TTL cache.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readInstalled, readInstalledVersion, readLockCommits } from './profile.ts'
|
|
8
|
+
|
|
9
|
+
export interface UpdateStatus {
|
|
10
|
+
kind: 'github' | 'npm' | 'linked'
|
|
11
|
+
version: string | null
|
|
12
|
+
current: string | null
|
|
13
|
+
latest: string | null
|
|
14
|
+
updateAvailable: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const UPDATES_TTL_MS = 30 * 60 * 1000
|
|
18
|
+
let updatesCache: { at: number; data: Record<string, UpdateStatus> } | null = null
|
|
19
|
+
|
|
20
|
+
/** Drop the cached listing (after a successful install/update/uninstall). */
|
|
21
|
+
export function invalidateUpdates(): void {
|
|
22
|
+
updatesCache = null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function fetchJson(url: string): Promise<unknown> {
|
|
26
|
+
const res = await fetch(url, {
|
|
27
|
+
headers: { accept: 'application/json', 'user-agent': 'dsh-market' },
|
|
28
|
+
signal: AbortSignal.timeout(4000),
|
|
29
|
+
})
|
|
30
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
31
|
+
return res.json() as unknown
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Per-plugin update checks; a failed check reports no update rather than failing the listing. */
|
|
35
|
+
export async function checkUpdates(profile: string, force = false): Promise<Record<string, UpdateStatus>> {
|
|
36
|
+
if (!force && updatesCache && Date.now() - updatesCache.at < UPDATES_TTL_MS) return updatesCache.data
|
|
37
|
+
const installed = readInstalled(profile)
|
|
38
|
+
const lockCommits = readLockCommits(profile)
|
|
39
|
+
const result: Record<string, UpdateStatus> = {}
|
|
40
|
+
await Promise.all(Object.entries(installed).map(async ([name, spec]) => {
|
|
41
|
+
const version = readInstalledVersion(profile, name)
|
|
42
|
+
if (spec.startsWith('link:') || spec.startsWith('file:')) {
|
|
43
|
+
result[name] = { kind: 'linked', version, current: null, latest: null, updateAvailable: false }
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
const gh = /^(?:github:)?([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:#.*)?$/.exec(spec)
|
|
47
|
+
try {
|
|
48
|
+
if (spec.startsWith('github:') && gh !== null) {
|
|
49
|
+
const current = lockCommits.get(gh[1].toLowerCase()) ?? null
|
|
50
|
+
const head = (await fetchJson(`https://api.github.com/repos/${gh[1]}/commits/HEAD`)) as { sha?: string }
|
|
51
|
+
const latest = typeof head.sha === 'string' ? head.sha : null
|
|
52
|
+
result[name] = {
|
|
53
|
+
kind: 'github', version, current, latest,
|
|
54
|
+
updateAvailable: current !== null && latest !== null && current !== latest,
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
const meta = (await fetchJson(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`)) as { version?: string }
|
|
58
|
+
const latest = typeof meta.version === 'string' ? meta.version : null
|
|
59
|
+
result[name] = {
|
|
60
|
+
kind: 'npm', version, current: version, latest,
|
|
61
|
+
updateAvailable: version !== null && latest !== null && version !== latest,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
result[name] = { kind: spec.startsWith('github:') ? 'github' : 'npm', version, current: null, latest: null, updateAvailable: false }
|
|
66
|
+
}
|
|
67
|
+
}))
|
|
68
|
+
updatesCache = { at: Date.now(), data: result }
|
|
69
|
+
return result
|
|
70
|
+
}
|