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/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
+ }