@deepseek-ai/dsh-plugin-manager 0.1.6-alpha.2
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/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +133 -0
- package/README.zh.md +133 -0
- package/lib/index.js +1174 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +604 -0
- package/lib/typert.remote-client.d.ts +37 -0
- package/lib/typert.remote-client.js +393 -0
- package/lib/types/build-approval.d.ts +12 -0
- package/lib/types/build-approval.js +56 -0
- package/lib/types/failure.d.ts +10 -0
- package/lib/types/failure.js +8 -0
- package/lib/types/index.d.ts +107 -0
- package/lib/types/index.js +623 -0
- package/lib/types/install-failure.d.ts +23 -0
- package/lib/types/install-failure.js +33 -0
- package/lib/types/install-spec.d.ts +44 -0
- package/lib/types/install-spec.js +73 -0
- package/lib/types/operations.d.ts +93 -0
- package/lib/types/operations.js +177 -0
- package/lib/types/patch.d.ts +9 -0
- package/lib/types/patch.js +50 -0
- package/lib/types/tools.d.ts +8 -0
- package/lib/types/tools.js +74 -0
- package/lib/types/types.d.ts +169 -0
- package/lib/types/types.js +2 -0
- package/package.json +102 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a failed pnpm run was, read off how it ended and what it printed:
|
|
3
|
+
* pnpm names its failures with stable `ERR_PNPM_*` codes and Node's errno
|
|
4
|
+
* names, which the run's captured tail carries whatever the locale.
|
|
5
|
+
* @module @deepseek-ai/dsh-plugin-manager/install-failure
|
|
6
|
+
*/
|
|
7
|
+
/** Patterns in the order they decide: a specific code before the generic network family. */
|
|
8
|
+
const LOG_KINDS = [
|
|
9
|
+
['build-blocked', /ERR_PNPM_IGNORED_BUILDS|Ignored build scripts/],
|
|
10
|
+
['not-found', /ERR_PNPM_FETCH_404|\bE404\b|404 Not Found|Not Found - GET/],
|
|
11
|
+
['no-matching-version', /ERR_PNPM_NO_MATCHING_VERSION|\bETARGET\b|No matching version/],
|
|
12
|
+
['disk-full', /\bENOSPC\b|no space left on device/i],
|
|
13
|
+
['permission', /\bEACCES\b|\bEPERM\b|permission denied/i],
|
|
14
|
+
['integrity', /ERR_PNPM_TARBALL_INTEGRITY|ERR_PNPM_BAD_TARBALL_SIZE|\bEINTEGRITY\b/],
|
|
15
|
+
['network', /\bENOTFOUND\b|\bECONNRESET\b|\bETIMEDOUT\b|\bECONNREFUSED\b|\bEAI_AGAIN\b|ERR_PNPM_META_FETCH_FAIL|ERR_PNPM_FETCH_5\d\d|ERR_PNPM_FETCH_TIMEOUT|socket hang up|Could not resolve host|unable to access/],
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* Classify a failed run.
|
|
19
|
+
* @param facts - how the run ended and what it printed.
|
|
20
|
+
* @returns the kind, `unknown` when nothing in the facts names one.
|
|
21
|
+
*/
|
|
22
|
+
export function classifyInstallFailure(facts) {
|
|
23
|
+
if (facts.timedOut === true)
|
|
24
|
+
return 'timeout';
|
|
25
|
+
if (facts.cause?.code === 'ENOENT')
|
|
26
|
+
return 'pnpm-missing';
|
|
27
|
+
for (const [kind, pattern] of LOG_KINDS) {
|
|
28
|
+
if (pattern.test(facts.log))
|
|
29
|
+
return kind;
|
|
30
|
+
}
|
|
31
|
+
return 'unknown';
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=install-failure.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading an install spec before pnpm sees it: which of pnpm's spec forms it
|
|
3
|
+
* takes, and for a registry name whether it is one the registry can accept.
|
|
4
|
+
* @module @deepseek-ai/dsh-plugin-manager/install-spec
|
|
5
|
+
*/
|
|
6
|
+
/** One spec read into its form and the parts an inspection needs. */
|
|
7
|
+
export type ParsedInstallSpec = {
|
|
8
|
+
readonly kind: 'registry';
|
|
9
|
+
readonly spec: string;
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly range?: string;
|
|
12
|
+
} | {
|
|
13
|
+
readonly kind: 'path';
|
|
14
|
+
readonly spec: string;
|
|
15
|
+
readonly path: string;
|
|
16
|
+
} | {
|
|
17
|
+
readonly kind: 'tarball';
|
|
18
|
+
readonly spec: string;
|
|
19
|
+
readonly path?: string;
|
|
20
|
+
} | {
|
|
21
|
+
readonly kind: 'git';
|
|
22
|
+
readonly spec: string;
|
|
23
|
+
};
|
|
24
|
+
/** A spec neither pnpm nor the registry would take; `reason` is what the person reads. */
|
|
25
|
+
export declare class InvalidInstallSpecError extends Error {
|
|
26
|
+
readonly spec: string;
|
|
27
|
+
readonly reason: string;
|
|
28
|
+
/**
|
|
29
|
+
* @param spec - the spec as typed, trimmed.
|
|
30
|
+
* @param reason - why it is refused, as one sentence.
|
|
31
|
+
*/
|
|
32
|
+
constructor(spec: string, reason: string);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Read a spec into its form. A path must be absolute: the Host's working
|
|
36
|
+
* directory means nothing to the person typing into a browser, and a
|
|
37
|
+
* relative path resolved against the profile would point inside it.
|
|
38
|
+
* @param raw - the spec as typed.
|
|
39
|
+
* @returns the parsed spec.
|
|
40
|
+
* @throws {InvalidInstallSpecError} for an empty spec, a relative path, a name the
|
|
41
|
+
* registry would refuse, or a URL that is neither a git host nor a tarball.
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseInstallSpec(raw: string): ParsedInstallSpec;
|
|
44
|
+
//# sourceMappingURL=install-spec.d.ts.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading an install spec before pnpm sees it: which of pnpm's spec forms it
|
|
3
|
+
* takes, and for a registry name whether it is one the registry can accept.
|
|
4
|
+
* @module @deepseek-ai/dsh-plugin-manager/install-spec
|
|
5
|
+
*/
|
|
6
|
+
import { isAbsolute } from 'node:path';
|
|
7
|
+
/** The forms pnpm resolves through a git host: a host shorthand, a git URL, or a hosted repository URL. */
|
|
8
|
+
const GIT_SHORTHAND = /^(?:github|gitlab|bitbucket|gist):/i;
|
|
9
|
+
const GIT_URL = /^git(?:\+[a-z]+)?:\/\/|^git@[^:]+:/i;
|
|
10
|
+
const HOSTED_REPOSITORY_URL = /^https?:\/\/[^/]+\/[^/]+\/[^/#]+(?:\.git)?(?:#.*)?$/i;
|
|
11
|
+
/** A tarball, on disk or over HTTP. */
|
|
12
|
+
const TARBALL_SPEC = /\.(?:tgz|tar\.gz)(?:#.*)?$/i;
|
|
13
|
+
/** An npm package name: lowercase URL-safe segments, an optional scope, no leading dot or underscore. */
|
|
14
|
+
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
|
|
15
|
+
const PACKAGE_NAME_MAX_LENGTH = 214;
|
|
16
|
+
/** A spec neither pnpm nor the registry would take; `reason` is what the person reads. */
|
|
17
|
+
export class InvalidInstallSpecError extends Error {
|
|
18
|
+
spec;
|
|
19
|
+
reason;
|
|
20
|
+
/**
|
|
21
|
+
* @param spec - the spec as typed, trimmed.
|
|
22
|
+
* @param reason - why it is refused, as one sentence.
|
|
23
|
+
*/
|
|
24
|
+
constructor(spec, reason) {
|
|
25
|
+
super(`plugin-manager: ${reason}: ${spec}`);
|
|
26
|
+
this.spec = spec;
|
|
27
|
+
this.reason = reason;
|
|
28
|
+
this.name = 'InvalidInstallSpecError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function invalid(spec, reason) {
|
|
32
|
+
return new InvalidInstallSpecError(spec, reason);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Read a spec into its form. A path must be absolute: the Host's working
|
|
36
|
+
* directory means nothing to the person typing into a browser, and a
|
|
37
|
+
* relative path resolved against the profile would point inside it.
|
|
38
|
+
* @param raw - the spec as typed.
|
|
39
|
+
* @returns the parsed spec.
|
|
40
|
+
* @throws {InvalidInstallSpecError} for an empty spec, a relative path, a name the
|
|
41
|
+
* registry would refuse, or a URL that is neither a git host nor a tarball.
|
|
42
|
+
*/
|
|
43
|
+
export function parseInstallSpec(raw) {
|
|
44
|
+
const spec = raw.trim();
|
|
45
|
+
if (spec === '')
|
|
46
|
+
throw invalid(spec, 'the package spec must not be empty');
|
|
47
|
+
const path = spec.replace(/^(?:file|link):/, '');
|
|
48
|
+
if (path !== spec || isAbsolute(path)) {
|
|
49
|
+
if (!isAbsolute(path))
|
|
50
|
+
throw invalid(spec, 'a local path must be absolute');
|
|
51
|
+
return TARBALL_SPEC.test(path) ? { kind: 'tarball', spec, path } : { kind: 'path', spec, path };
|
|
52
|
+
}
|
|
53
|
+
if (/^\.{1,2}(?:[\\/]|$)/.test(spec))
|
|
54
|
+
throw invalid(spec, 'a local path must be absolute');
|
|
55
|
+
const git = GIT_SHORTHAND.test(spec) || GIT_URL.test(spec) || HOSTED_REPOSITORY_URL.test(spec);
|
|
56
|
+
if (git && !TARBALL_SPEC.test(spec))
|
|
57
|
+
return { kind: 'git', spec };
|
|
58
|
+
if (/^https?:\/\//i.test(spec)) {
|
|
59
|
+
if (TARBALL_SPEC.test(spec))
|
|
60
|
+
return { kind: 'tarball', spec };
|
|
61
|
+
throw invalid(spec, 'a URL must point at a git repository or a tarball');
|
|
62
|
+
}
|
|
63
|
+
const at = spec.indexOf('@', 1);
|
|
64
|
+
const name = at === -1 ? spec : spec.slice(0, at);
|
|
65
|
+
const range = at === -1 ? undefined : spec.slice(at + 1);
|
|
66
|
+
if (name.length > PACKAGE_NAME_MAX_LENGTH || !PACKAGE_NAME.test(name)) {
|
|
67
|
+
throw invalid(spec, 'not a package name the registry accepts');
|
|
68
|
+
}
|
|
69
|
+
if (range === '')
|
|
70
|
+
throw invalid(spec, 'a version after @ must not be empty');
|
|
71
|
+
return range === undefined ? { kind: 'registry', spec, name } : { kind: 'registry', spec, name, range };
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=install-spec.js.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type ProfileManifest } from '@deepseek-ai/dsh-app-boot';
|
|
2
|
+
import type { PackageResult } from './types.ts';
|
|
3
|
+
/** Profile and invocation locations supplied by the launcher. */
|
|
4
|
+
export interface PackageOperationContext {
|
|
5
|
+
profile: string;
|
|
6
|
+
/** Explicit directory for an application-owned profile; named CLI profiles resolve under home. */
|
|
7
|
+
dir?: string;
|
|
8
|
+
installAnchor: string;
|
|
9
|
+
cwd: string;
|
|
10
|
+
home?: string;
|
|
11
|
+
}
|
|
12
|
+
/** Output and cancellation policy for one pnpm operation. */
|
|
13
|
+
export interface PackageOperationOptions {
|
|
14
|
+
/** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. Defaults to `pnpm`. */
|
|
15
|
+
command?: string;
|
|
16
|
+
/** Prefix arguments for an application-owned executable. */
|
|
17
|
+
args?: readonly string[];
|
|
18
|
+
/** Application runtime environment, applied only to this package operation. */
|
|
19
|
+
env?: Readonly<Record<string, string>>;
|
|
20
|
+
/** CLI inherits authentication and terminal descriptors; service scrubs secrets and captures output. */
|
|
21
|
+
execution: 'cli' | 'service';
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
outputBytes: number;
|
|
24
|
+
onOutput?: (text: string, stream: 'stdout' | 'stderr') => void;
|
|
25
|
+
activateNewBundles?: boolean;
|
|
26
|
+
lockWaitMs?: number;
|
|
27
|
+
}
|
|
28
|
+
/** Resolve relative package specs against the caller's directory.
|
|
29
|
+
* @param argument One pnpm argument.
|
|
30
|
+
* @param cwd Invocation directory, never the profile directory.
|
|
31
|
+
* @returns Anchored argument.
|
|
32
|
+
*/
|
|
33
|
+
export declare function anchorPathSpec(argument: string, cwd: string): string;
|
|
34
|
+
/** Read bundle metadata without loading its JavaScript.
|
|
35
|
+
* @param name Installed dependency or installation-owned package name.
|
|
36
|
+
* @param dir Profile directory.
|
|
37
|
+
* @param anchor Installation manifest.
|
|
38
|
+
* @returns Resolved metadata, or undefined for packages without bundle metadata.
|
|
39
|
+
*/
|
|
40
|
+
export declare function bundleManifest(name: string, dir: string, anchor: string): ProfileManifest | undefined;
|
|
41
|
+
/** Atomically save a profile manifest while retaining unrelated fields.
|
|
42
|
+
* @param dir Profile directory.
|
|
43
|
+
* @param manifest Updated document.
|
|
44
|
+
*/
|
|
45
|
+
export declare function saveManifest(dir: string, manifest: ProfileManifest): Promise<void>;
|
|
46
|
+
/** Execute pnpm inside a profile whose caller already holds the profile write lock.
|
|
47
|
+
* @param context Launcher-owned profile and resolution locations.
|
|
48
|
+
* @param args Pnpm arguments, before relative path anchoring.
|
|
49
|
+
* @param options Output, activation and cancellation policy.
|
|
50
|
+
* @returns Exit status and diagnostic path; service output is bounded, CLI output uses inherited descriptors.
|
|
51
|
+
*/
|
|
52
|
+
export declare function runProfilePnpm(context: PackageOperationContext, args: readonly string[], options: PackageOperationOptions): Promise<PackageResult>;
|
|
53
|
+
/** Initialize and run the dsh plugin command with the same write lock as the service.
|
|
54
|
+
* @param context Launcher-owned locations.
|
|
55
|
+
* @param args Pnpm arguments.
|
|
56
|
+
* @param options Output and cancellation policy.
|
|
57
|
+
* @returns Completed package-manager result.
|
|
58
|
+
*/
|
|
59
|
+
export declare function runPluginCommand(context: PackageOperationContext, args: readonly string[], options: PackageOperationOptions): Promise<PackageResult>;
|
|
60
|
+
/** What one registry lookup answered. */
|
|
61
|
+
export interface PackageViewResult {
|
|
62
|
+
/** pnpm's exit code, null when it ended without one or never started. */
|
|
63
|
+
exitCode: number | null;
|
|
64
|
+
stdout: string;
|
|
65
|
+
stderr: string;
|
|
66
|
+
/** The lookup ran past its bound and was killed. */
|
|
67
|
+
timedOut: boolean;
|
|
68
|
+
/** The failure of starting pnpm at all, when that is what happened. */
|
|
69
|
+
cause?: unknown;
|
|
70
|
+
}
|
|
71
|
+
/** Bounds of one registry lookup. */
|
|
72
|
+
export interface PackageViewOptions {
|
|
73
|
+
/** The pnpm executable name or path. Defaults to `pnpm`. */
|
|
74
|
+
command?: string;
|
|
75
|
+
/** Prefix arguments for an application-owned executable. */
|
|
76
|
+
args?: readonly string[];
|
|
77
|
+
/** Application runtime environment, applied only to this package operation. */
|
|
78
|
+
env?: Readonly<Record<string, string>>;
|
|
79
|
+
/** Ends the lookup early; the caller's signal, when it has one. */
|
|
80
|
+
signal?: AbortSignal;
|
|
81
|
+
/** Bound on the lookup, in milliseconds. */
|
|
82
|
+
timeoutMs: number;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Ask the registry what a spec names through `pnpm view`, run in the profile
|
|
86
|
+
* directory so the registry, proxy, and authentication settings of an install apply.
|
|
87
|
+
* @param dir Profile directory.
|
|
88
|
+
* @param spec One registry spec: a package name with an optional range.
|
|
89
|
+
* @param options Cancellation and the time bound.
|
|
90
|
+
* @returns pnpm's exit, output, and how the lookup ended.
|
|
91
|
+
*/
|
|
92
|
+
export declare function viewProfilePackage(dir: string, spec: string, options: PackageViewOptions): Promise<PackageViewResult>;
|
|
93
|
+
//# sourceMappingURL=operations.d.ts.map
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/** Shared profile package operations used by dsh plugin and the running manager. */
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, mkdtemp, open } from 'node:fs/promises';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { execa } from 'execa';
|
|
6
|
+
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
|
|
7
|
+
import { DEFAULT_PROFILE_BUNDLES, initProfile, PROFILE_TEMPLATES, readProfileManifest, resolveBundleDir, resolveProfileDir, loadOverlayPatches, } from '@deepseek-ai/dsh-app-boot';
|
|
8
|
+
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess';
|
|
9
|
+
/** Resolve relative package specs against the caller's directory.
|
|
10
|
+
* @param argument One pnpm argument.
|
|
11
|
+
* @param cwd Invocation directory, never the profile directory.
|
|
12
|
+
* @returns Anchored argument.
|
|
13
|
+
*/
|
|
14
|
+
export function anchorPathSpec(argument, cwd) {
|
|
15
|
+
const match = /^(?<prefix>(?:file|link):)?(?<path>\.{1,2}(?:[/\\].*)?)$/.exec(argument);
|
|
16
|
+
if (match?.groups?.path === undefined)
|
|
17
|
+
return argument;
|
|
18
|
+
return `${match.groups.prefix ?? ''}${resolve(cwd, match.groups.path)}`;
|
|
19
|
+
}
|
|
20
|
+
/** Read bundle metadata without loading its JavaScript.
|
|
21
|
+
* @param name Installed dependency or installation-owned package name.
|
|
22
|
+
* @param dir Profile directory.
|
|
23
|
+
* @param anchor Installation manifest.
|
|
24
|
+
* @returns Resolved metadata, or undefined for packages without bundle metadata.
|
|
25
|
+
*/
|
|
26
|
+
export function bundleManifest(name, dir, anchor) {
|
|
27
|
+
const packageDir = resolveBundleDir('dsh', name, anchor, dir);
|
|
28
|
+
const manifest = readProfileManifest('dsh', packageDir);
|
|
29
|
+
return manifest.dsh?.bundle?.patch === undefined ? undefined : manifest;
|
|
30
|
+
}
|
|
31
|
+
/** Atomically save a profile manifest while retaining unrelated fields.
|
|
32
|
+
* @param dir Profile directory.
|
|
33
|
+
* @param manifest Updated document.
|
|
34
|
+
*/
|
|
35
|
+
export async function saveManifest(dir, manifest) {
|
|
36
|
+
await writeFileAtomic(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n', { mode: 0o600 });
|
|
37
|
+
}
|
|
38
|
+
/** Reconcile package removals and newly installed bundles without re-enabling retained dependencies. */
|
|
39
|
+
async function reconcile(before, dir, anchor, options) {
|
|
40
|
+
const after = readProfileManifest('dsh', dir);
|
|
41
|
+
const dependencies = Object.keys(after.dependencies ?? {});
|
|
42
|
+
const beforeDeps = new Set(Object.keys(before.dependencies ?? {}));
|
|
43
|
+
const previous = after.dsh?.profile?.bundles ?? [];
|
|
44
|
+
const bundles = previous.filter((name) => {
|
|
45
|
+
if (!beforeDeps.has(name) && !dependencies.includes(name))
|
|
46
|
+
return true;
|
|
47
|
+
return dependencies.includes(name) && bundleManifest(name, dir, anchor) !== undefined;
|
|
48
|
+
});
|
|
49
|
+
for (const name of dependencies) {
|
|
50
|
+
if (beforeDeps.has(name))
|
|
51
|
+
continue;
|
|
52
|
+
const metadata = bundleManifest(name, dir, anchor);
|
|
53
|
+
if (metadata?.dsh?.bundle === undefined) {
|
|
54
|
+
options.onOutput?.(`dsh: warning: ${name} declares no dsh.bundle — installed as a plain dependency, not a profile layer\n`, 'stderr');
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
loadOverlayPatches('dsh', join(resolveBundleDir('dsh', name, anchor, dir), metadata.dsh.bundle.patch));
|
|
58
|
+
if (!bundles.includes(name)) {
|
|
59
|
+
bundles.push(name);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (JSON.stringify(previous) === JSON.stringify(bundles))
|
|
63
|
+
return;
|
|
64
|
+
after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles } };
|
|
65
|
+
await saveManifest(dir, after);
|
|
66
|
+
}
|
|
67
|
+
/** Execute pnpm inside a profile whose caller already holds the profile write lock.
|
|
68
|
+
* @param context Launcher-owned profile and resolution locations.
|
|
69
|
+
* @param args Pnpm arguments, before relative path anchoring.
|
|
70
|
+
* @param options Output, activation and cancellation policy.
|
|
71
|
+
* @returns Exit status and diagnostic path; service output is bounded, CLI output uses inherited descriptors.
|
|
72
|
+
*/
|
|
73
|
+
export async function runProfilePnpm(context, args, options) {
|
|
74
|
+
const dir = context.dir ?? resolveProfileDir(context.profile, context.home);
|
|
75
|
+
const before = readProfileManifest('dsh', dir);
|
|
76
|
+
const logRoot = join(dir, '.plugin-manager', 'logs');
|
|
77
|
+
await mkdir(logRoot, { recursive: true, mode: 0o700 });
|
|
78
|
+
const logDir = await mkdtemp(join(logRoot, 'operation-'));
|
|
79
|
+
const logPath = join(logDir, 'pnpm.log');
|
|
80
|
+
const log = await open(logPath, 'wx', 0o600);
|
|
81
|
+
let output = Buffer.alloc(0);
|
|
82
|
+
let truncated = false;
|
|
83
|
+
const cancellation = new AbortController();
|
|
84
|
+
const child = execa(options.command ?? 'pnpm', [...options.args ?? [], ...args.map(arg => anchorPathSpec(arg, context.cwd))], {
|
|
85
|
+
cwd: dir, env: { ...(options.execution === 'cli' ? process.env : scrubbedParentEnv()), ...options.env }, extendEnv: false, reject: false,
|
|
86
|
+
stdout: options.execution === 'cli' ? 'inherit' : 'pipe',
|
|
87
|
+
stderr: options.execution === 'cli' ? 'inherit' : 'pipe',
|
|
88
|
+
buffer: false, stdin: options.execution === 'cli' ? 'inherit' : 'ignore', cancelSignal: options.signal === undefined
|
|
89
|
+
? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal]),
|
|
90
|
+
});
|
|
91
|
+
let writes = Promise.resolve();
|
|
92
|
+
const collect = async (stream, kind) => {
|
|
93
|
+
try {
|
|
94
|
+
for await (const chunk of stream) {
|
|
95
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
96
|
+
writes = writes.then(async () => { await log.write(bytes); });
|
|
97
|
+
await writes;
|
|
98
|
+
options.onOutput?.(bytes.toString('utf8'), kind);
|
|
99
|
+
output = Buffer.concat([output, bytes]);
|
|
100
|
+
if (output.length > options.outputBytes) {
|
|
101
|
+
truncated = true;
|
|
102
|
+
output = output.subarray(output.length - options.outputBytes);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
cancellation.abort();
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
let exitCode;
|
|
112
|
+
try {
|
|
113
|
+
const [completion, ...streams] = await Promise.allSettled([child,
|
|
114
|
+
...child.stdout === null ? [] : [collect(child.stdout, 'stdout')],
|
|
115
|
+
...child.stderr === null ? [] : [collect(child.stderr, 'stderr')],
|
|
116
|
+
]);
|
|
117
|
+
for (const stream of streams)
|
|
118
|
+
if (stream.status === 'rejected')
|
|
119
|
+
throw stream.reason;
|
|
120
|
+
if (completion.status === 'rejected')
|
|
121
|
+
throw completion.reason;
|
|
122
|
+
const result = completion.value;
|
|
123
|
+
exitCode = result.exitCode ?? (result.code === 'ENOENT' ? 127 : 1);
|
|
124
|
+
if (result.failed && output.length === 0) {
|
|
125
|
+
const diagnostic = result.shortMessage ?? 'pnpm failed';
|
|
126
|
+
await log.write(diagnostic);
|
|
127
|
+
truncated = Buffer.byteLength(diagnostic) > options.outputBytes;
|
|
128
|
+
output = Buffer.from(diagnostic).subarray(0, options.outputBytes);
|
|
129
|
+
}
|
|
130
|
+
if (exitCode === 0 && options.activateNewBundles !== false)
|
|
131
|
+
await reconcile(before, dir, context.installAnchor, options);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
await log.close();
|
|
135
|
+
}
|
|
136
|
+
return { exitCode, output: output.toString('utf8'), truncated, logPath };
|
|
137
|
+
}
|
|
138
|
+
/** Initialize and run the dsh plugin command with the same write lock as the service.
|
|
139
|
+
* @param context Launcher-owned locations.
|
|
140
|
+
* @param args Pnpm arguments.
|
|
141
|
+
* @param options Output and cancellation policy.
|
|
142
|
+
* @returns Completed package-manager result.
|
|
143
|
+
*/
|
|
144
|
+
export async function runPluginCommand(context, args, options) {
|
|
145
|
+
const dir = context.dir ?? resolveProfileDir(context.profile, context.home);
|
|
146
|
+
await mkdir(dir, { recursive: true });
|
|
147
|
+
return withFileLock(join(dir, 'package.json'), async () => {
|
|
148
|
+
if (!existsSync(join(dir, 'package.json'))) {
|
|
149
|
+
const template = PROFILE_TEMPLATES[context.profile];
|
|
150
|
+
initProfile(dir, template?.bundles ?? DEFAULT_PROFILE_BUNDLES);
|
|
151
|
+
options.onOutput?.(`dsh: initialized profile ${context.profile} at ${dir}\n`, 'stderr');
|
|
152
|
+
}
|
|
153
|
+
return runProfilePnpm(context, args, options);
|
|
154
|
+
}, options.lockWaitMs === undefined ? undefined : { waitMs: options.lockWaitMs });
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Ask the registry what a spec names through `pnpm view`, run in the profile
|
|
158
|
+
* directory so the registry, proxy, and authentication settings of an install apply.
|
|
159
|
+
* @param dir Profile directory.
|
|
160
|
+
* @param spec One registry spec: a package name with an optional range.
|
|
161
|
+
* @param options Cancellation and the time bound.
|
|
162
|
+
* @returns pnpm's exit, output, and how the lookup ended.
|
|
163
|
+
*/
|
|
164
|
+
export async function viewProfilePackage(dir, spec, options) {
|
|
165
|
+
const result = await execa(options.command ?? 'pnpm', [...options.args ?? [], 'view', spec, 'name', 'version', 'description', 'dsh', '--json'], {
|
|
166
|
+
cwd: dir, env: { ...scrubbedParentEnv(), ...options.env }, extendEnv: false, reject: false, stdin: 'ignore',
|
|
167
|
+
timeout: options.timeoutMs, ...options.signal === undefined ? {} : { cancelSignal: options.signal },
|
|
168
|
+
});
|
|
169
|
+
const cause = result.exitCode === undefined && !result.timedOut && !result.isCanceled
|
|
170
|
+
? Object.assign(new Error(result.shortMessage), { code: result.code })
|
|
171
|
+
: undefined;
|
|
172
|
+
return {
|
|
173
|
+
exitCode: result.exitCode ?? null, stdout: result.stdout, stderr: result.stderr, timedOut: result.timedOut,
|
|
174
|
+
...cause === undefined ? {} : { cause },
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
//# sourceMappingURL=operations.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Replace the last matching override or append one after existing insertions.
|
|
2
|
+
* @param filename Current profile patch file.
|
|
3
|
+
* @param id Unique composition entry id.
|
|
4
|
+
* @param name Module name used to match name-qualified overrides.
|
|
5
|
+
* @param enabled Desired entry enablement.
|
|
6
|
+
* @returns Whether the file changed.
|
|
7
|
+
*/
|
|
8
|
+
export declare function writePluginEnabled(filename: string, id: string, name: string, enabled: boolean): Promise<boolean>;
|
|
9
|
+
//# sourceMappingURL=patch.d.ts.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Comment-preserving profile plugin enablement edits. */
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { isMap, isSeq, parseDocument } from 'yaml';
|
|
4
|
+
import { loadOptionalPatches } from '@deepseek-ai/dsh-app-boot';
|
|
5
|
+
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
|
|
6
|
+
/** Replace the last matching override or append one after existing insertions.
|
|
7
|
+
* @param filename Current profile patch file.
|
|
8
|
+
* @param id Unique composition entry id.
|
|
9
|
+
* @param name Module name used to match name-qualified overrides.
|
|
10
|
+
* @param enabled Desired entry enablement.
|
|
11
|
+
* @returns Whether the file changed.
|
|
12
|
+
*/
|
|
13
|
+
export async function writePluginEnabled(filename, id, name, enabled) {
|
|
14
|
+
let text;
|
|
15
|
+
try {
|
|
16
|
+
text = await readFile(filename, 'utf8');
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code !== 'ENOENT')
|
|
20
|
+
throw error;
|
|
21
|
+
text = '[]\n';
|
|
22
|
+
}
|
|
23
|
+
const document = parseDocument(text, {
|
|
24
|
+
customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: (value) => value }],
|
|
25
|
+
});
|
|
26
|
+
const error = document.errors[0];
|
|
27
|
+
if (error !== undefined)
|
|
28
|
+
throw error;
|
|
29
|
+
if (!isSeq(document.contents))
|
|
30
|
+
throw new Error('Profile patch must be a YAML sequence');
|
|
31
|
+
loadOptionalPatches('dsh', filename);
|
|
32
|
+
const items = document.contents.items;
|
|
33
|
+
const target = items.findLast((item, index) => {
|
|
34
|
+
if (!isMap(item) || document.getIn([index, 'id']) !== id || item.has('insert'))
|
|
35
|
+
return false;
|
|
36
|
+
const expectedName = document.getIn([index, 'name']);
|
|
37
|
+
return !expectedName || expectedName === name;
|
|
38
|
+
});
|
|
39
|
+
if (isMap(target)) {
|
|
40
|
+
if (document.getIn([items.indexOf(target), 'disabled']) === !enabled)
|
|
41
|
+
return false;
|
|
42
|
+
document.setIn([items.indexOf(target), 'disabled'], !enabled);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
document.add({ id, disabled: !enabled });
|
|
46
|
+
}
|
|
47
|
+
await writeFileAtomic(filename, String(document), { mode: 0o600 });
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=patch.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
/** Required services for the management tool. */
|
|
3
|
+
export declare const inject: string[];
|
|
4
|
+
/** Register one management tool for discovery and the four persistent actions.
|
|
5
|
+
* @param ctx Agent-scoped tool registration context.
|
|
6
|
+
*/
|
|
7
|
+
export declare function apply(ctx: Context): void;
|
|
8
|
+
//# sourceMappingURL=tools.d.ts.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Agent-facing current-profile management using the same service as Web controls. */
|
|
2
|
+
import { assertNever } from '@deepseek-ai/dsh-util-values';
|
|
3
|
+
import { approveEscalation } from '@deepseek-ai/dsh-sandbox';
|
|
4
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
5
|
+
/** Required services for the management tool. */
|
|
6
|
+
export const inject = ['tools', 'pluginManager', 'sandboxPolicy'];
|
|
7
|
+
/** Register one management tool for discovery and the four persistent actions.
|
|
8
|
+
* @param ctx Agent-scoped tool registration context.
|
|
9
|
+
*/
|
|
10
|
+
export function apply(ctx) {
|
|
11
|
+
ctx.tools.register(defineTool({
|
|
12
|
+
name: 'plugin_manager',
|
|
13
|
+
description: 'List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart.',
|
|
14
|
+
parameters: {
|
|
15
|
+
action: { type: 'string', required: true, enum: ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle'], description: 'Management operation.' },
|
|
16
|
+
target: { type: 'string', description: 'Plugin entry id, bundle package name, or installation spec, according to action.' },
|
|
17
|
+
enabled: { type: 'boolean', description: 'Required for set operations; defaults to true for installation.' },
|
|
18
|
+
approvedBuilds: { type: 'array', items: { type: 'string' }, description: 'For install_bundle: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission for this profile.' },
|
|
19
|
+
offset: { type: 'number', description: 'Zero-based list offset; defaults to 0.' },
|
|
20
|
+
limit: { type: 'number', description: 'List page size, from 1 to 100; defaults to 25.' },
|
|
21
|
+
},
|
|
22
|
+
output: {
|
|
23
|
+
schema: { type: 'string' },
|
|
24
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
25
|
+
},
|
|
26
|
+
async execute(args, exec) {
|
|
27
|
+
const policy = ctx.sandboxPolicy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session });
|
|
28
|
+
await approveEscalation({
|
|
29
|
+
requestedMode: 'danger-full-access', effectiveMode: policy.mode, subject: 'plugin management operation',
|
|
30
|
+
justification: `plugin_manager ${JSON.stringify(args)}. Profile changes persist across sessions; installed Host code runs outside the workspace sandbox.`,
|
|
31
|
+
}, { approver: ctx.get('approval'), agent: exec.agent, callId: exec.callId,
|
|
32
|
+
toolName: 'plugin_manager', signal: exec.signal });
|
|
33
|
+
exec.signal.throwIfAborted();
|
|
34
|
+
const manager = ctx.pluginManager;
|
|
35
|
+
switch (args.action) {
|
|
36
|
+
case 'list_plugins':
|
|
37
|
+
case 'list_bundles': {
|
|
38
|
+
const offset = args.offset ?? 0;
|
|
39
|
+
const limit = args.limit ?? 25;
|
|
40
|
+
if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
41
|
+
throw new Error('offset must be a non-negative integer and limit must be an integer from 1 to 100');
|
|
42
|
+
}
|
|
43
|
+
const rows = args.action === 'list_plugins' ? await manager.listPlugins() : await manager.listBundles();
|
|
44
|
+
const entries = rows.slice(offset, offset + limit);
|
|
45
|
+
return JSON.stringify({ entries, total: rows.length,
|
|
46
|
+
nextOffset: offset + entries.length < rows.length ? offset + entries.length : null });
|
|
47
|
+
}
|
|
48
|
+
case 'set_plugin':
|
|
49
|
+
case 'set_bundle': {
|
|
50
|
+
if (args.target === undefined || args.enabled === undefined)
|
|
51
|
+
throw new Error('target and enabled are required');
|
|
52
|
+
return JSON.stringify(await (args.action === 'set_plugin'
|
|
53
|
+
? manager.setPluginEnabled(args.target, args.enabled)
|
|
54
|
+
: manager.setBundleEnabled(args.target, args.enabled)));
|
|
55
|
+
}
|
|
56
|
+
case 'install_bundle':
|
|
57
|
+
if (args.target === undefined)
|
|
58
|
+
throw new Error('target package spec is required');
|
|
59
|
+
return JSON.stringify(await manager.installBundle(args.target, {
|
|
60
|
+
...args.enabled === undefined ? {} : { enabled: args.enabled },
|
|
61
|
+
...args.approvedBuilds === undefined ? {} : { approvedBuilds: args.approvedBuilds },
|
|
62
|
+
}));
|
|
63
|
+
case 'remove_bundle':
|
|
64
|
+
if (args.target === undefined)
|
|
65
|
+
throw new Error('target bundle name is required');
|
|
66
|
+
return JSON.stringify(await manager.removeBundle(args.target));
|
|
67
|
+
/* v8 ignore next -- tool JSON validation rejects actions outside the declared enum */
|
|
68
|
+
default: return assertNever(args.action);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
presentCall: args => ({ card: 'generic', title: 'Manage profile plugins', kind: args.action.startsWith('list_') ? 'read' : 'other', rawInput: args }),
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=tools.js.map
|