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/lib/sources.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
6
|
+
/** Registry tarball names must be plain npm package names, nothing fancier. */
|
|
7
|
+
const NPM_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
|
8
|
+
/**
|
|
9
|
+
* Parse a registry source url: a github repo, optionally with a
|
|
10
|
+
* `/tree/<branch>/<subpath>` suffix (how the curated list links monorepo
|
|
11
|
+
* subpackages, e.g. dsh-plugins#theme-gallery).
|
|
12
|
+
*/
|
|
13
|
+
export function parseSourceUrl(url) {
|
|
14
|
+
const m = /^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\/tree\/[^/]+\/(.+?))?\/?$/.exec(url);
|
|
15
|
+
if (m === null || !REPO_RE.test(m[1]))
|
|
16
|
+
return null;
|
|
17
|
+
const subpath = m[2] ?? null;
|
|
18
|
+
if (subpath !== null) {
|
|
19
|
+
if (!/^[A-Za-z0-9_./-]+$/.test(subpath))
|
|
20
|
+
return null;
|
|
21
|
+
// No empty/dot segments: `..` would escape the repo in the #path: selector.
|
|
22
|
+
if (subpath.split('/').some(seg => seg === '' || seg === '.' || seg === '..'))
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return { repo: m[1], subpath };
|
|
26
|
+
}
|
|
27
|
+
/** GitHub `owner/repo` for a registry URL, or null when it is not a GitHub repo URL. */
|
|
28
|
+
export function repoOf(url) {
|
|
29
|
+
return parseSourceUrl(url)?.repo ?? null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The pnpm install target for a registry entry. Registry tarballs beat
|
|
33
|
+
* full-repo GitHub downloads: smaller, prebuilt, and CDN/mirror served. The
|
|
34
|
+
* npm name comes from our curated registry, which only maps repo-verified
|
|
35
|
+
* packages (name-squatting protection).
|
|
36
|
+
* @returns the target spec, or null when the source url is unsupported.
|
|
37
|
+
*/
|
|
38
|
+
export function installTargetFor(entry) {
|
|
39
|
+
const source = parseSourceUrl(entry.url);
|
|
40
|
+
if (source === null)
|
|
41
|
+
return null;
|
|
42
|
+
if (typeof entry.npm === 'string' && NPM_NAME_RE.test(entry.npm))
|
|
43
|
+
return entry.npm;
|
|
44
|
+
return source.subpath !== null
|
|
45
|
+
? `github:${source.repo}#path:/${source.subpath}`
|
|
46
|
+
: `github:${source.repo}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The name an entry is ALREADY installed under, or null — the server-side
|
|
50
|
+
* duplicate guard (#27): the same plugin listed under an alias entry must
|
|
51
|
+
* never install twice (two loader entries with one id brick the next boot).
|
|
52
|
+
*
|
|
53
|
+
* Identity is subpath-aware so monorepo siblings stay independent: an entry
|
|
54
|
+
* with a /tree/ subpath identifies as repo#path:/sub (never the bare repo),
|
|
55
|
+
* while an installed dependency contributes its bare repo AND its #path:
|
|
56
|
+
* form — so a collection root still matches the pieces it was retargeted
|
|
57
|
+
* into, but two different subpackages of one repo never cross-match.
|
|
58
|
+
*/
|
|
59
|
+
export function findInstalledAlias(entry, installed) {
|
|
60
|
+
const source = parseSourceUrl(entry.url);
|
|
61
|
+
const ids = new Set([entry.name.toLowerCase()]);
|
|
62
|
+
if (typeof entry.npm === 'string' && entry.npm !== '')
|
|
63
|
+
ids.add(entry.npm.toLowerCase());
|
|
64
|
+
if (source !== null) {
|
|
65
|
+
ids.add(source.subpath === null
|
|
66
|
+
? source.repo.toLowerCase()
|
|
67
|
+
: `${source.repo.toLowerCase()}#path:/${source.subpath.toLowerCase()}`);
|
|
68
|
+
}
|
|
69
|
+
for (const [name, spec] of Object.entries(installed)) {
|
|
70
|
+
const dep = new Set([name.toLowerCase()]);
|
|
71
|
+
const scoped = /^@([^/]+)\/(.+)$/.exec(name);
|
|
72
|
+
if (scoped !== null)
|
|
73
|
+
dep.add(`${scoped[1]}/${scoped[2]}`.toLowerCase());
|
|
74
|
+
const m = /github:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?:#path:\/([A-Za-z0-9_./-]+))?/i.exec(spec);
|
|
75
|
+
if (m !== null) {
|
|
76
|
+
dep.add(m[1].toLowerCase());
|
|
77
|
+
if (m[2] !== undefined)
|
|
78
|
+
dep.add(`${m[1].toLowerCase()}#path:/${m[2].toLowerCase()}`);
|
|
79
|
+
}
|
|
80
|
+
for (const id of dep)
|
|
81
|
+
if (ids.has(id))
|
|
82
|
+
return name;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
package/lib/themes.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
import { loadRegistry } from './registry.js';
|
|
8
|
+
import { hotMount, hotUnmount, listHotMounts, writeDisabledThemes } from './hot.js';
|
|
9
|
+
import { logEvent } from './log.js';
|
|
10
|
+
import { profileDir, readInstalled } from './profile.js';
|
|
11
|
+
import { repoOf } from './sources.js';
|
|
12
|
+
/**
|
|
13
|
+
* Create the theme manager. `disabledThemes` is the live, shared set of
|
|
14
|
+
* themes the user switched off — the caller owns reading it at boot and
|
|
15
|
+
* replaying it; the manager mutates and persists it on switches.
|
|
16
|
+
*/
|
|
17
|
+
export function createThemeManager(host, profile, disabledThemes) {
|
|
18
|
+
/** Installed package names classified as themes by the registry's theme category. */
|
|
19
|
+
async function installedThemeNames() {
|
|
20
|
+
const names = new Set();
|
|
21
|
+
try {
|
|
22
|
+
const { registry } = await loadRegistry();
|
|
23
|
+
const themeEntries = registry.plugins.filter(p => p.category === 'theme');
|
|
24
|
+
const themeNames = new Set(themeEntries.map(p => p.name));
|
|
25
|
+
const themeRepos = new Set(themeEntries.map(p => repoOf(p.url)).filter((r) => r !== null).map(r => r.toLowerCase()));
|
|
26
|
+
for (const [name, spec] of Object.entries(readInstalled(profile))) {
|
|
27
|
+
if (themeNames.has(name)) {
|
|
28
|
+
names.add(name);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const match = /github:([^#\s]+)/.exec(String(spec).toLowerCase());
|
|
32
|
+
if (match !== null && themeRepos.has(match[1]))
|
|
33
|
+
names.add(name);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch { /* registry unavailable — nothing classifies as a theme */ }
|
|
37
|
+
return names;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Live-toggle a bundle-layer plugin through its loader entry. Bundle trees
|
|
41
|
+
* are in-memory (write is a no-op), so this never touches any file — the
|
|
42
|
+
* market persists the choice itself and replays it at boot.
|
|
43
|
+
* @returns true when a matching live entry was found and updated.
|
|
44
|
+
*/
|
|
45
|
+
async function setEntryDisabled(name, disabledFlag) {
|
|
46
|
+
let found = false;
|
|
47
|
+
for (const entry of host.loader.entries()) {
|
|
48
|
+
if (entry.options.name !== name)
|
|
49
|
+
continue;
|
|
50
|
+
// A disable can land while the entry's init is still in flight: the
|
|
51
|
+
// options flip but the finishing init brings the fiber up anyway, and a
|
|
52
|
+
// plain re-update no-ops on the empty diff. Force the update and verify
|
|
53
|
+
// the live state, retrying until reality matches the flag.
|
|
54
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
55
|
+
try {
|
|
56
|
+
await entry.update({ disabled: disabledFlag ? true : null }, false, true);
|
|
57
|
+
found = true;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
logEvent('warn', 'toggle', `${name}: entry update failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
const live = entry.fiber !== undefined;
|
|
64
|
+
if (live !== disabledFlag)
|
|
65
|
+
break;
|
|
66
|
+
await new Promise(resolvePromise => setTimeout(resolvePromise, 200));
|
|
67
|
+
}
|
|
68
|
+
logEvent('info', 'toggle', `${name} -> ${disabledFlag ? 'off' : 'on'}: fiber=${String(entry.fiber !== undefined)}`);
|
|
69
|
+
}
|
|
70
|
+
if (!found)
|
|
71
|
+
logEvent('info', 'toggle', `${name}: no loader entry matched`);
|
|
72
|
+
return found;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Make `name` the one active theme: deactivate every other installed theme
|
|
76
|
+
* (market hot mounts unmount; bundle-layer entries live-disable) and bring
|
|
77
|
+
* it up. The choice persists in state.json and is replayed at boot.
|
|
78
|
+
*/
|
|
79
|
+
async function activateTheme(name) {
|
|
80
|
+
const dir = profileDir(profile);
|
|
81
|
+
const themes = await installedThemeNames();
|
|
82
|
+
for (const other of themes) {
|
|
83
|
+
if (other === name)
|
|
84
|
+
continue;
|
|
85
|
+
if (listHotMounts().includes(other)) {
|
|
86
|
+
await hotUnmount(other);
|
|
87
|
+
disabledThemes.add(other);
|
|
88
|
+
}
|
|
89
|
+
else if (await setEntryDisabled(other, true)) {
|
|
90
|
+
disabledThemes.add(other);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
disabledThemes.delete(name);
|
|
94
|
+
writeDisabledThemes(dir, disabledThemes);
|
|
95
|
+
if (listHotMounts().includes(name))
|
|
96
|
+
return true;
|
|
97
|
+
if (await setEntryDisabled(name, false))
|
|
98
|
+
return true;
|
|
99
|
+
return hotMount(host, dir, name);
|
|
100
|
+
}
|
|
101
|
+
return { installedThemeNames, setEntryDisabled, activateTheme };
|
|
102
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process layer: re-invoking the dsh CLI that launched this host, spawning
|
|
3
|
+
* `dsh plugin` commands with timeouts and live progress, and provisioning
|
|
4
|
+
* pnpm. This is the only module that starts child processes.
|
|
5
|
+
*
|
|
6
|
+
* Installs run through node:child_process, not ctx.shell: the shell service is
|
|
7
|
+
* the agent's sandboxed executor and denies writes to the profile directory.
|
|
8
|
+
*/
|
|
9
|
+
import type { ChildProcess } from 'node:child_process';
|
|
10
|
+
/**
|
|
11
|
+
* Windows npm/corepack/pnpm are `.cmd` shims. Node's `spawn` without a shell
|
|
12
|
+
* cannot start them (ENOENT / EINVAL). Same pattern as dsh's `plugin` forwarder.
|
|
13
|
+
*/
|
|
14
|
+
export declare const winCmdShim: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Argv re-invoking the CLI that launched this host process, so installs work
|
|
17
|
+
* whether dsh runs from a global bin, a local install, or repo source
|
|
18
|
+
* (`node --import tsx/esm .../bin.ts`). Falls back to a PATH `dsh`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function dshArgv(): {
|
|
21
|
+
file: string;
|
|
22
|
+
args: string[];
|
|
23
|
+
cwd: string | undefined;
|
|
24
|
+
viaShell: boolean;
|
|
25
|
+
};
|
|
26
|
+
/** Outcome of one spawned plugin command. */
|
|
27
|
+
export interface InstallResult {
|
|
28
|
+
exitCode: number | null;
|
|
29
|
+
timedOut: boolean;
|
|
30
|
+
stdout: string;
|
|
31
|
+
stderr: string;
|
|
32
|
+
}
|
|
33
|
+
/** The shape every orchestration function takes to run plugin commands (injectable in tests). */
|
|
34
|
+
export type PluginRunner = (profile: string, pluginArgs: string[]) => Promise<InstallResult>;
|
|
35
|
+
/**
|
|
36
|
+
* Kill a spawned child and, on Windows, its whole process tree — `kill()`
|
|
37
|
+
* there only terminates the wrapper, leaving pnpm children running.
|
|
38
|
+
* (Contributed in #7 by @mraing.)
|
|
39
|
+
*/
|
|
40
|
+
export declare function killChild(child: ChildProcess): void;
|
|
41
|
+
/** Probe `pnpm --version` on PATH. */
|
|
42
|
+
export declare function probePnpm(): Promise<boolean>;
|
|
43
|
+
/**
|
|
44
|
+
* Provision pnpm without user involvement: corepack (ships with Node) first,
|
|
45
|
+
* a global npm install as fallback.
|
|
46
|
+
* @returns true when `pnpm --version` succeeds afterwards.
|
|
47
|
+
*/
|
|
48
|
+
export declare function provisionPnpm(): Promise<boolean>;
|
|
49
|
+
/** Live progress of the running plugin command, for the status route. */
|
|
50
|
+
export interface InstallProgress {
|
|
51
|
+
active: boolean;
|
|
52
|
+
target: string;
|
|
53
|
+
startedAt: number;
|
|
54
|
+
lastLine: string;
|
|
55
|
+
}
|
|
56
|
+
/** Singleton progress state; the status route reads it, runDshPlugin writes it. */
|
|
57
|
+
export declare const progress: InstallProgress;
|
|
58
|
+
/** Identifies this host process; the client scopes its pending-restart flags to it. */
|
|
59
|
+
export declare const BOOT_ID: string;
|
|
60
|
+
/** Run one `dsh plugin --profile <p> …` command with timeout and progress tracking. */
|
|
61
|
+
export declare function runDshPlugin(profile: string, pluginArgs: string[]): Promise<InstallResult>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal HTTP helpers shared by every market route: JSON serialization,
|
|
3
|
+
* same-origin enforcement for mutating endpoints, and a size-capped JSON
|
|
4
|
+
* body reader.
|
|
5
|
+
*/
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
7
|
+
/** Write a JSON payload with no-store caching. */
|
|
8
|
+
export declare function sendJson(response: ServerResponse, status: number, payload: unknown): void;
|
|
9
|
+
/** True when the request's Origin matches its Host — required on every POST route. */
|
|
10
|
+
export declare function sameOrigin(request: IncomingMessage): boolean;
|
|
11
|
+
/** Read and parse a JSON request body, rejecting anything over 4 KiB. */
|
|
12
|
+
export declare function readJsonBody(request: IncomingMessage): Promise<unknown>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install orchestration: collection-repo retargeting, post-install
|
|
3
|
+
* validation that keeps broken pieces from bricking the next boot, and
|
|
4
|
+
* update staleness detection. Every function takes the plugin runner as a
|
|
5
|
+
* parameter so tests can substitute a recording fake.
|
|
6
|
+
*/
|
|
7
|
+
import type { InstallResult, PluginRunner } from './dsh-cli.ts';
|
|
8
|
+
/**
|
|
9
|
+
* Run one plugin command with automatic recovery from the pnpm-major drift
|
|
10
|
+
* failure (#20 bug 2): when the modules directory was built by a different
|
|
11
|
+
* pnpm major, pnpm's documented remedy is one `install` to recreate it —
|
|
12
|
+
* do that silently and retry the original command once. Any recognized
|
|
13
|
+
* failure that survives gets its bilingual explanation appended to stderr
|
|
14
|
+
* so the UI shows an actionable message instead of a wall of text (#20 bug 3).
|
|
15
|
+
*/
|
|
16
|
+
export declare function withHoistRecovery(run: PluginRunner, profile: string, pluginArgs: string[]): Promise<InstallResult>;
|
|
17
|
+
/**
|
|
18
|
+
* Some registry entries point at collection repos whose actual plugin lives
|
|
19
|
+
* in a subdirectory — the root has no package.json (or a workspace root with
|
|
20
|
+
* no dsh surface), and pnpm installs the bare fileset with exit 0. Detect
|
|
21
|
+
* that junk install, drop it, and re-add each plugin subdirectory through
|
|
22
|
+
* pnpm's `#path:` selector (#18).
|
|
23
|
+
* @returns overall success (true when nothing needed retargeting).
|
|
24
|
+
*/
|
|
25
|
+
export declare function retargetCollections(run: PluginRunner, profile: string, before: Set<string>, target: string): Promise<boolean>;
|
|
26
|
+
/**
|
|
27
|
+
* Fake-success guard (#18): validate every package the install added. A
|
|
28
|
+
* piece without a dsh manifest or without its declared entry artifact
|
|
29
|
+
* (source-only checkout, build blocked by pnpm allowBuilds) would brick the
|
|
30
|
+
* next boot, so it is removed on the spot.
|
|
31
|
+
* @returns names kept and names removed as broken.
|
|
32
|
+
*/
|
|
33
|
+
export declare function validateAddedPlugins(run: PluginRunner, profile: string, before: Set<string>): Promise<{
|
|
34
|
+
keep: string[];
|
|
35
|
+
removedBroken: string[];
|
|
36
|
+
}>;
|
|
37
|
+
/**
|
|
38
|
+
* Whether a clean-exit update actually changed nothing — pnpm's
|
|
39
|
+
* minimumReleaseAge silently keeps the old version and exits 0 when the new
|
|
40
|
+
* release is "too young" (#13, #22), so a clean exit alone does not mean the
|
|
41
|
+
* update happened.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isStaleUpdate(check: {
|
|
44
|
+
isGit: boolean;
|
|
45
|
+
beforeVersion: string | null;
|
|
46
|
+
afterVersion: string | null;
|
|
47
|
+
beforeCommit: string | null;
|
|
48
|
+
afterCommit: string | null;
|
|
49
|
+
}): boolean;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pnpm compatibility layer — everything the market needs to know about how
|
|
3
|
+
* different pnpm majors behave inside a dsh profile directory, kept pure and
|
|
4
|
+
* separately testable (test/unit + test/integration exercise this module
|
|
5
|
+
* against real pnpm 9/10/11).
|
|
6
|
+
*
|
|
7
|
+
* Verified behavior matrix (2026-08, pnpm 9.15.9 / 10.28.2 / 11.21.0):
|
|
8
|
+
* - workspace root, `add` without -w: pnpm 9 fails ERR_PNPM_ADDING_TO_ROOT;
|
|
9
|
+
* pnpm 10/11 succeed.
|
|
10
|
+
* - `add -w` where NO pnpm-workspace.yaml exists: ALL majors fail with
|
|
11
|
+
* "--workspace-root may only be used inside a workspace".
|
|
12
|
+
* - modules dir built by pnpm 9, then pnpm 10/11 mutate it:
|
|
13
|
+
* ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF (defaults drifted between majors).
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Decide the argv for a `dsh plugin <add|remove> …` call in the given profile.
|
|
17
|
+
*
|
|
18
|
+
* pnpm 9 refuses to add at a workspace root without -w (#17, #20); every
|
|
19
|
+
* pnpm major refuses -w when the directory is NOT a workspace. So the flag
|
|
20
|
+
* is injected exactly when the profile has a pnpm-workspace.yaml.
|
|
21
|
+
* @param profileDir - resolved profile directory (owns pnpm-workspace.yaml, or not).
|
|
22
|
+
* @param pluginArgs - the raw args, e.g. ['add', 'dshmarket@latest'].
|
|
23
|
+
* @returns args with -w injected when — and only when — the profile is a workspace root.
|
|
24
|
+
*/
|
|
25
|
+
export declare function pluginArgsFor(profileDir: string, pluginArgs: string[]): string[];
|
|
26
|
+
/** One recognized pnpm failure, with a bilingual explanation for the UI. */
|
|
27
|
+
export interface PnpmFailure {
|
|
28
|
+
code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing';
|
|
29
|
+
/** Bilingual, actionable message shown to the user instead of the raw wall of text. */
|
|
30
|
+
message: string;
|
|
31
|
+
/** True when re-running `pnpm install` in the profile is the documented recovery. */
|
|
32
|
+
recoverable: boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Map a failed pnpm run's combined output to a known failure mode.
|
|
36
|
+
*
|
|
37
|
+
* dsh's own wrapper line ("dsh: pnpm failed in profile directory …") names no
|
|
38
|
+
* cause, so the market must recognize pnpm's real diagnostics itself (#20).
|
|
39
|
+
* @param output - stdout+stderr of the failed run.
|
|
40
|
+
* @returns the classified failure, or null when unrecognized (raw output is then shown as-is).
|
|
41
|
+
*/
|
|
42
|
+
export declare function classifyPnpmFailure(output: string): PnpmFailure | null;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile filesystem reads — everything the market learns from a dsh
|
|
3
|
+
* profile directory (manifest, lockfile, installed package trees). Pure
|
|
4
|
+
* functions of the directory contents; no processes, no network.
|
|
5
|
+
*/
|
|
6
|
+
/** Resolve a profile name to its directory under DSH_HOME (default ~/.dsh). */
|
|
7
|
+
export declare function profileDir(profile: string): string;
|
|
8
|
+
/** Community dependencies of the profile (official in-box scope filtered out). */
|
|
9
|
+
export declare function readInstalled(profile: string): Record<string, string>;
|
|
10
|
+
/** The version actually present in the profile's node_modules, or null. */
|
|
11
|
+
export declare function readInstalledVersion(profile: string, name: string): string | null;
|
|
12
|
+
/** Pinned commit per `owner/repo` from the profile lockfile's codeload tarball URLs. */
|
|
13
|
+
export declare function readLockCommits(profile: string): Map<string, string>;
|
|
14
|
+
/** True when the installed package's manifest declares a dsh plugin surface. */
|
|
15
|
+
export declare function hasDshManifest(dir: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* True when the package's declared entry artifact actually exists — github
|
|
18
|
+
* source checkouts of build-required plugins ship no lib/, and promoting one
|
|
19
|
+
* into the bundle layer bricks the next boot (ERR_MODULE_NOT_FOUND kills the
|
|
20
|
+
* whole profile, #18).
|
|
21
|
+
*/
|
|
22
|
+
export declare function entryArtifactExists(dir: string): boolean;
|
|
23
|
+
/** Plugin subdirectories (depth 2) of a collection checkout, as relative paths. */
|
|
24
|
+
export declare function pluginSubdirs(root: string): string[];
|
package/lib/types/routes.d.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTTP routes bridging the browser market UI to the host
|
|
3
|
-
*
|
|
2
|
+
* HTTP routes bridging the browser market UI to the host. This layer only
|
|
3
|
+
* parses requests, calls the service modules, and serializes responses —
|
|
4
|
+
* process spawning lives in dsh-cli.ts, filesystem reads in profile.ts,
|
|
5
|
+
* orchestration in install.ts / themes.ts / updates.ts.
|
|
4
6
|
*
|
|
5
7
|
* Security: the install route executes a shell command, so it accepts only
|
|
6
8
|
* same-origin POSTs and only sources present in the curated registry.
|
|
7
9
|
*/
|
|
8
10
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
11
|
+
import { type LoaderEntry } from './themes.ts';
|
|
12
|
+
export type { LoaderEntry } from './themes.ts';
|
|
13
|
+
export type { UpdateStatus } from './updates.ts';
|
|
9
14
|
export interface WebServerService {
|
|
10
15
|
register(route: {
|
|
11
16
|
kind: 'exact' | 'prefix';
|
|
@@ -13,18 +18,6 @@ export interface WebServerService {
|
|
|
13
18
|
handler: (request: IncomingMessage, response: ServerResponse) => void | Promise<void>;
|
|
14
19
|
}): () => void;
|
|
15
20
|
}
|
|
16
|
-
/** The slice of a cordis loader entry the market needs for live enable/disable. */
|
|
17
|
-
export interface LoaderEntry {
|
|
18
|
-
options: {
|
|
19
|
-
id?: string;
|
|
20
|
-
name?: string;
|
|
21
|
-
disabled?: boolean | null;
|
|
22
|
-
};
|
|
23
|
-
fiber?: unknown;
|
|
24
|
-
update(options: {
|
|
25
|
-
disabled: boolean | null;
|
|
26
|
-
}, create?: boolean, force?: boolean): Promise<void>;
|
|
27
|
-
}
|
|
28
21
|
export interface MarketHost {
|
|
29
22
|
webServer: WebServerService;
|
|
30
23
|
loader: {
|
|
@@ -50,13 +43,6 @@ export interface MarketConfig {
|
|
|
50
43
|
/** Profile the market installs into; matches the profile serving this UI. */
|
|
51
44
|
profile: string;
|
|
52
45
|
}
|
|
53
|
-
export interface UpdateStatus {
|
|
54
|
-
kind: 'github' | 'npm' | 'linked';
|
|
55
|
-
version: string | null;
|
|
56
|
-
current: string | null;
|
|
57
|
-
latest: string | null;
|
|
58
|
-
updateAvailable: boolean;
|
|
59
|
-
}
|
|
60
46
|
/**
|
|
61
47
|
* Register the market's HTTP routes.
|
|
62
48
|
* @param host - Acquired webServer + shell services.
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
* Parse a registry source url: a github repo, optionally with a
|
|
7
|
+
* `/tree/<branch>/<subpath>` suffix (how the curated list links monorepo
|
|
8
|
+
* subpackages, e.g. dsh-plugins#theme-gallery).
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseSourceUrl(url: string): {
|
|
11
|
+
repo: string;
|
|
12
|
+
subpath: string | null;
|
|
13
|
+
} | null;
|
|
14
|
+
/** GitHub `owner/repo` for a registry URL, or null when it is not a GitHub repo URL. */
|
|
15
|
+
export declare function repoOf(url: string): string | null;
|
|
16
|
+
/**
|
|
17
|
+
* The pnpm install target for a registry entry. Registry tarballs beat
|
|
18
|
+
* full-repo GitHub downloads: smaller, prebuilt, and CDN/mirror served. The
|
|
19
|
+
* npm name comes from our curated registry, which only maps repo-verified
|
|
20
|
+
* packages (name-squatting protection).
|
|
21
|
+
* @returns the target spec, or null when the source url is unsupported.
|
|
22
|
+
*/
|
|
23
|
+
export declare function installTargetFor(entry: {
|
|
24
|
+
url: string;
|
|
25
|
+
npm?: unknown;
|
|
26
|
+
}): string | null;
|
|
27
|
+
/**
|
|
28
|
+
* The name an entry is ALREADY installed under, or null — the server-side
|
|
29
|
+
* duplicate guard (#27): the same plugin listed under an alias entry must
|
|
30
|
+
* never install twice (two loader entries with one id brick the next boot).
|
|
31
|
+
*
|
|
32
|
+
* Identity is subpath-aware so monorepo siblings stay independent: an entry
|
|
33
|
+
* with a /tree/ subpath identifies as repo#path:/sub (never the bare repo),
|
|
34
|
+
* while an installed dependency contributes its bare repo AND its #path:
|
|
35
|
+
* form — so a collection root still matches the pieces it was retargeted
|
|
36
|
+
* into, but two different subpackages of one repo never cross-match.
|
|
37
|
+
*/
|
|
38
|
+
export declare function findInstalledAlias(entry: {
|
|
39
|
+
name: string;
|
|
40
|
+
npm?: unknown;
|
|
41
|
+
url: string;
|
|
42
|
+
}, installed: Record<string, string>): string | null;
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
/** The slice of a cordis loader entry the market needs for live enable/disable. */
|
|
8
|
+
export interface LoaderEntry {
|
|
9
|
+
options: {
|
|
10
|
+
id?: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
disabled?: boolean | null;
|
|
13
|
+
};
|
|
14
|
+
fiber?: unknown;
|
|
15
|
+
update(options: {
|
|
16
|
+
disabled: boolean | null;
|
|
17
|
+
}, create?: boolean, force?: boolean): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/** The host surface the theme manager needs (loader entries + hot-mount context). */
|
|
20
|
+
export interface ThemeHost {
|
|
21
|
+
loader: {
|
|
22
|
+
entries(): Iterable<LoaderEntry>;
|
|
23
|
+
};
|
|
24
|
+
plugin(plugin: unknown, config: unknown): {
|
|
25
|
+
await(): Promise<unknown>;
|
|
26
|
+
dispose(): Promise<unknown> | void;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Manages theme exclusivity for one profile. */
|
|
30
|
+
export interface ThemeManager {
|
|
31
|
+
installedThemeNames(): Promise<Set<string>>;
|
|
32
|
+
setEntryDisabled(name: string, disabledFlag: boolean): Promise<boolean>;
|
|
33
|
+
activateTheme(name: string): Promise<boolean>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Create the theme manager. `disabledThemes` is the live, shared set of
|
|
37
|
+
* themes the user switched off — the caller owns reading it at boot and
|
|
38
|
+
* replaying it; the manager mutates and persists it on switches.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createThemeManager(host: ThemeHost, profile: string, disabledThemes: Set<string>): ThemeManager;
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
export interface UpdateStatus {
|
|
7
|
+
kind: 'github' | 'npm' | 'linked';
|
|
8
|
+
version: string | null;
|
|
9
|
+
current: string | null;
|
|
10
|
+
latest: string | null;
|
|
11
|
+
updateAvailable: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Drop the cached listing (after a successful install/update/uninstall). */
|
|
14
|
+
export declare function invalidateUpdates(): void;
|
|
15
|
+
/** Per-plugin update checks; a failed check reports no update rather than failing the listing. */
|
|
16
|
+
export declare function checkUpdates(profile: string, force?: boolean): Promise<Record<string, UpdateStatus>>;
|
package/lib/updates.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
import { readInstalled, readInstalledVersion, readLockCommits } from './profile.js';
|
|
7
|
+
const UPDATES_TTL_MS = 30 * 60 * 1000;
|
|
8
|
+
let updatesCache = null;
|
|
9
|
+
/** Drop the cached listing (after a successful install/update/uninstall). */
|
|
10
|
+
export function invalidateUpdates() {
|
|
11
|
+
updatesCache = null;
|
|
12
|
+
}
|
|
13
|
+
async function fetchJson(url) {
|
|
14
|
+
const res = await fetch(url, {
|
|
15
|
+
headers: { accept: 'application/json', 'user-agent': 'dsh-market' },
|
|
16
|
+
signal: AbortSignal.timeout(4000),
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok)
|
|
19
|
+
throw new Error(`HTTP ${res.status}`);
|
|
20
|
+
return res.json();
|
|
21
|
+
}
|
|
22
|
+
/** Per-plugin update checks; a failed check reports no update rather than failing the listing. */
|
|
23
|
+
export async function checkUpdates(profile, force = false) {
|
|
24
|
+
if (!force && updatesCache && Date.now() - updatesCache.at < UPDATES_TTL_MS)
|
|
25
|
+
return updatesCache.data;
|
|
26
|
+
const installed = readInstalled(profile);
|
|
27
|
+
const lockCommits = readLockCommits(profile);
|
|
28
|
+
const result = {};
|
|
29
|
+
await Promise.all(Object.entries(installed).map(async ([name, spec]) => {
|
|
30
|
+
const version = readInstalledVersion(profile, name);
|
|
31
|
+
if (spec.startsWith('link:') || spec.startsWith('file:')) {
|
|
32
|
+
result[name] = { kind: 'linked', version, current: null, latest: null, updateAvailable: false };
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const gh = /^(?:github:)?([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:#.*)?$/.exec(spec);
|
|
36
|
+
try {
|
|
37
|
+
if (spec.startsWith('github:') && gh !== null) {
|
|
38
|
+
const current = lockCommits.get(gh[1].toLowerCase()) ?? null;
|
|
39
|
+
const head = (await fetchJson(`https://api.github.com/repos/${gh[1]}/commits/HEAD`));
|
|
40
|
+
const latest = typeof head.sha === 'string' ? head.sha : null;
|
|
41
|
+
result[name] = {
|
|
42
|
+
kind: 'github', version, current, latest,
|
|
43
|
+
updateAvailable: current !== null && latest !== null && current !== latest,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const meta = (await fetchJson(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`));
|
|
48
|
+
const latest = typeof meta.version === 'string' ? meta.version : null;
|
|
49
|
+
result[name] = {
|
|
50
|
+
kind: 'npm', version, current: version, latest,
|
|
51
|
+
updateAvailable: version !== null && latest !== null && version !== latest,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
result[name] = { kind: spec.startsWith('github:') ? 'github' : 'npm', version, current: null, latest: null, updateAvailable: false };
|
|
57
|
+
}
|
|
58
|
+
}));
|
|
59
|
+
updatesCache = { at: Date.now(), data: result };
|
|
60
|
+
return result;
|
|
61
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dshmarket",
|
|
3
3
|
"description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
|
|
4
|
-
"version": "1.2.
|
|
4
|
+
"version": "1.2.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
"build": "tsc -p tsconfig.json && npm run build:client",
|
|
26
26
|
"build:client": "tsdown && node scripts/normalize-client-banner.mjs",
|
|
27
27
|
"check": "npm run typecheck && npm run build",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:compat": "vitest run --config vitest.compat.config.ts",
|
|
28
30
|
"prepack": "npm run build && node scripts/preflight.mjs",
|
|
29
31
|
"prepare": "npm run build",
|
|
30
32
|
"snapshot": "curl -sf https://awesome-dsh-plugin.com/plugins.json -o data/registry-snapshot.json"
|
|
@@ -70,7 +72,8 @@
|
|
|
70
72
|
"lightningcss": "^1.33.0",
|
|
71
73
|
"react": "^18.3.1",
|
|
72
74
|
"tsdown": "^0.22.14",
|
|
73
|
-
"typescript": "^7.0.2"
|
|
75
|
+
"typescript": "^7.0.2",
|
|
76
|
+
"vitest": "^4.1.10"
|
|
74
77
|
},
|
|
75
78
|
"publishConfig": {
|
|
76
79
|
"registry": "https://registry.npmjs.org/"
|
|
@@ -67,5 +67,8 @@
|
|
|
67
67
|
.empty{color:var(--dsw-alias-label-secondary,#9ca3af);font-size:13px;padding:32px;text-align:center}
|
|
68
68
|
.err{color:var(--dsw-alias-state-error-primary,#dc2626);font-size:12px;margin:8px 0;white-space:pre-wrap;word-break:break-all}
|
|
69
69
|
.irow{background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:12px;padding:12px 14px;display:flex;align-items:center;gap:10px;margin-bottom:8px}
|
|
70
|
-
.irow>.src,.irow>.owner
|
|
70
|
+
.irow>.src,.irow>.owner{white-space:nowrap;flex-shrink:0}
|
|
71
|
+
/* Buttons sharing the row with a description must keep one line (#26, thanks @yanshuai2002) */
|
|
72
|
+
.dangerBtn.dangerBtn,.warnBtn.warnBtn,.dangerArmed.dangerArmed{white-space:nowrap;flex-shrink:0}
|
|
71
73
|
.spec{font-size:11px;color:var(--dsw-alias-label-secondary,#9ca3af);font-family:ui-monospace,Menlo,monospace}
|
|
74
|
+
.staleAction{margin-top:8px}
|