dsh-unplug 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/CONTRIBUTING.md +13 -0
- package/README.md +52 -0
- package/SECURITY.md +13 -0
- package/cordis.patch.yml +4 -0
- package/lib/bin.js +5 -0
- package/lib/cli.js +164 -0
- package/lib/index.js +151 -0
- package/lib/lib/audit.js +59 -0
- package/lib/lib/manifest.js +65 -0
- package/lib/lib/patches.js +138 -0
- package/lib/lib/paths.js +13 -0
- package/lib/lib/remove.js +134 -0
- package/lib/lib/report.js +4 -0
- package/lib/lib/resolve.js +38 -0
- package/lib/lib/version.js +49 -0
- package/lib/types/bin.d.ts +2 -0
- package/lib/types/cli.d.ts +7 -0
- package/lib/types/index.d.ts +16 -0
- package/lib/types/lib/audit.d.ts +21 -0
- package/lib/types/lib/manifest.d.ts +22 -0
- package/lib/types/lib/patches.d.ts +41 -0
- package/lib/types/lib/paths.d.ts +4 -0
- package/lib/types/lib/remove.d.ts +33 -0
- package/lib/types/lib/report.d.ts +10 -0
- package/lib/types/lib/resolve.d.ts +15 -0
- package/lib/types/lib/version.d.ts +8 -0
- package/package.json +68 -0
package/CHANGELOG.md
ADDED
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Contributing to dsh-unplug
|
|
2
|
+
|
|
3
|
+
1. Fork the repository.
|
|
4
|
+
2. Create a feature branch.
|
|
5
|
+
3. Run `pnpm install && pnpm typecheck && pnpm test` before committing.
|
|
6
|
+
4. Open a pull request.
|
|
7
|
+
|
|
8
|
+
By contributing, you agree that your contributions will be licensed under the same
|
|
9
|
+
license as this project.
|
|
10
|
+
|
|
11
|
+
## Code of Conduct
|
|
12
|
+
|
|
13
|
+
Be respectful, constructive, and evidence-based. No personal attacks.
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# dsh-unplug
|
|
2
|
+
|
|
3
|
+
Plug/unplug any DeepSeek Harness plugin cleanly.
|
|
4
|
+
|
|
5
|
+
**Why?** The official `dsh plugin remove` only handles bundles in the profile's
|
|
6
|
+
`dsh.profile.bundles` list. It does not:
|
|
7
|
+
|
|
8
|
+
- Remove patch rows from `cordis.patch.yml` (profile or home level)
|
|
9
|
+
- Disable/enable without deleting
|
|
10
|
+
- Audit for orphaned rows or dangling bundle references
|
|
11
|
+
|
|
12
|
+
`dsh-unplug` fills all three gaps.
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
dsh plugin add dsh-unplug
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## CLI
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
dsh-unplug list # show every mounted plugin row
|
|
24
|
+
dsh-unplug remove <plugin> --yes # fully unplug (bundle + patch + deps)
|
|
25
|
+
dsh-unplug disable <plugin> # disable without deleting
|
|
26
|
+
dsh-unplug enable <plugin> # re-enable
|
|
27
|
+
dsh-unplug audit # detect orphaned/dangling state
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Options: `--profile <name>` (default: `default`), `--home <path>`, `--json`, `--yes`.
|
|
31
|
+
|
|
32
|
+
## Agent-callable tool
|
|
33
|
+
|
|
34
|
+
Once installed, the agent gains the `unplug` tool:
|
|
35
|
+
|
|
36
|
+
- `list` / `audit` — always allowed (read-only)
|
|
37
|
+
- `remove <plugin> force: true` — requires explicit confirmation
|
|
38
|
+
- `disable` / `enable` — reversible, no confirmation needed
|
|
39
|
+
|
|
40
|
+
Returns a `dsh-unplug/v1` report.
|
|
41
|
+
|
|
42
|
+
## How it works
|
|
43
|
+
|
|
44
|
+
- Bundle list (`dsh.profile.bundles`) — remove entry + pnpm remove
|
|
45
|
+
- Disabled list (`dsh.profile.disabledBundles`) — move between bundles/disabled lists
|
|
46
|
+
- Profile `cordis.patch.yml` — delete entry or toggle `disabled: true`
|
|
47
|
+
- Home `cordis.patch.yml` — same as above
|
|
48
|
+
- Orphaned rows — detect + report via audit
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported Versions
|
|
4
|
+
|
|
5
|
+
| Version | Supported |
|
|
6
|
+
| ------- | --------- |
|
|
7
|
+
| 0.1.x | Yes |
|
|
8
|
+
|
|
9
|
+
## Reporting a Vulnerability
|
|
10
|
+
|
|
11
|
+
This plugin is read-only by default; destructive operations (remove) require explicit
|
|
12
|
+
`--yes` / `force: true`. If you find a way to bypass these guards, please open an
|
|
13
|
+
issue at [github.com/zoahdev/dsh-unplug/issues](https://github.com/zoahdev/dsh-unplug/issues).
|
package/cordis.patch.yml
ADDED
package/lib/bin.js
ADDED
package/lib/cli.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI entry for dsh-unplug.
|
|
3
|
+
* dsh-unplug <command> [options]
|
|
4
|
+
* Commands: list, remove, disable, enable, audit
|
|
5
|
+
* Options: --profile, --home, --yes, --json
|
|
6
|
+
*/
|
|
7
|
+
import { dshHome } from './lib/paths.js';
|
|
8
|
+
import { audit } from './lib/audit.js';
|
|
9
|
+
import { removePlugin, setPluginEnabled } from './lib/remove.js';
|
|
10
|
+
function usage() {
|
|
11
|
+
return [
|
|
12
|
+
'dsh-unplug — plug/unplug any DeepSeek Harness plugin cleanly',
|
|
13
|
+
'',
|
|
14
|
+
'Usage:',
|
|
15
|
+
' dsh-unplug <command> [options]',
|
|
16
|
+
'',
|
|
17
|
+
'Commands:',
|
|
18
|
+
' list List all mounted plugin rows across bundles and patches',
|
|
19
|
+
' remove <plugin> Fully unplug a plugin (bundles + patch rows + dependencies)',
|
|
20
|
+
' disable <plugin> Disable without deleting (removes from bundles, keeps deps)',
|
|
21
|
+
' enable <plugin> Re-enable a disabled plugin (re-adds to bundles, clears disabled flag)',
|
|
22
|
+
' audit Detect orphaned rows, dangling bundles, missing patch files',
|
|
23
|
+
'',
|
|
24
|
+
'Options:',
|
|
25
|
+
' --profile <name> Profile name (default: default)',
|
|
26
|
+
' --home <path> DSH_HOME path (default: env DSH_HOME or ~/.dsh)',
|
|
27
|
+
' --yes Confirm destructive operations (remove)',
|
|
28
|
+
' --json Output machine-readable JSON report',
|
|
29
|
+
' --help Show this help',
|
|
30
|
+
'',
|
|
31
|
+
'Examples:',
|
|
32
|
+
' dsh-unplug list',
|
|
33
|
+
' dsh-unplug remove dsh-disk-audit --yes',
|
|
34
|
+
' dsh-unplug disable dsh-readme-forge --profile my-profile',
|
|
35
|
+
' dsh-unplug enable dsh-readme-forge --json',
|
|
36
|
+
].join('\n');
|
|
37
|
+
}
|
|
38
|
+
function parseArgs(argv) {
|
|
39
|
+
const options = { command: '', profile: 'default', home: '', yes: false, json: false };
|
|
40
|
+
const positional = [];
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const arg = argv[i];
|
|
43
|
+
switch (arg) {
|
|
44
|
+
case '--help':
|
|
45
|
+
case '-h': return { help: true };
|
|
46
|
+
case '--json':
|
|
47
|
+
options.json = true;
|
|
48
|
+
break;
|
|
49
|
+
case '--yes':
|
|
50
|
+
options.yes = true;
|
|
51
|
+
break;
|
|
52
|
+
case '--profile':
|
|
53
|
+
options.profile = argv[++i];
|
|
54
|
+
if (options.profile === undefined)
|
|
55
|
+
return { error: '--profile requires a value' };
|
|
56
|
+
break;
|
|
57
|
+
case '--home':
|
|
58
|
+
options.home = argv[++i];
|
|
59
|
+
if (options.home === undefined)
|
|
60
|
+
return { error: '--home requires a path' };
|
|
61
|
+
break;
|
|
62
|
+
default:
|
|
63
|
+
if (arg.startsWith('-'))
|
|
64
|
+
return { error: `unknown option: ${arg}` };
|
|
65
|
+
positional.push(arg);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (positional.length === 0)
|
|
69
|
+
return { error: 'missing command (try --help)' };
|
|
70
|
+
options.command = positional[0];
|
|
71
|
+
options.plugin = positional[1];
|
|
72
|
+
return options;
|
|
73
|
+
}
|
|
74
|
+
function printResult(value, json) {
|
|
75
|
+
const report = value;
|
|
76
|
+
if (json) {
|
|
77
|
+
console.log(JSON.stringify(report, null, 2));
|
|
78
|
+
return report.ok === true ? 0 : 1;
|
|
79
|
+
}
|
|
80
|
+
if (report.ok === false) {
|
|
81
|
+
const err = report.error ?? {};
|
|
82
|
+
console.error(`Error: ${err.message ?? 'unknown error'} (${err.code ?? 'UNKNOWN'})`);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
switch (report.command ?? '') {
|
|
86
|
+
case 'list':
|
|
87
|
+
case 'audit':
|
|
88
|
+
console.log(`Profile: ${report.profile ?? 'default'}`);
|
|
89
|
+
console.log(`Bundles (${(report.bundles ?? []).length}): ${(report.bundles ?? []).join(', ') || '(none)'}`);
|
|
90
|
+
console.log(`Disabled (${(report.disabledBundles ?? []).length}): ${(report.disabledBundles ?? []).join(', ') || '(none)'}`);
|
|
91
|
+
console.log(`Rows (${(report.rows ?? []).length})`);
|
|
92
|
+
for (const row of (report.rows ?? [])) {
|
|
93
|
+
console.log(` ${row.disabled ? '🔴' : '🟢'} ${row.id} (${row.name ?? ''}) [${row.layer}]`);
|
|
94
|
+
}
|
|
95
|
+
if ((report.issues ?? []).length > 0) {
|
|
96
|
+
console.log(`Issues (${report.issues.length}):`);
|
|
97
|
+
for (const issue of report.issues) {
|
|
98
|
+
console.log(` ⚠ ${issue.detail}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
case 'remove':
|
|
103
|
+
console.log(`Removed: bundle=${report.removedBundle} patchRows=${report.patchRowsRemoved} pnpm=${report.pnpmRemoved}`);
|
|
104
|
+
if (report.note)
|
|
105
|
+
console.log(`Note: ${report.note}`);
|
|
106
|
+
break;
|
|
107
|
+
case 'disable':
|
|
108
|
+
case 'enable':
|
|
109
|
+
console.log(`${report.enabled ? 'Enabled' : 'Disabled'}: bundleMoved=${report.bundleMoved} patchChanged=${report.patchChanged}`);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
export async function main(argv) {
|
|
115
|
+
const parsed = parseArgs(argv);
|
|
116
|
+
if ('help' in parsed) {
|
|
117
|
+
console.log(usage());
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
if ('error' in parsed) {
|
|
121
|
+
console.error(parsed.error);
|
|
122
|
+
console.error(usage());
|
|
123
|
+
return 2;
|
|
124
|
+
}
|
|
125
|
+
const { command, plugin, profile, home, yes, json } = parsed;
|
|
126
|
+
const resolvedHome = home || dshHome();
|
|
127
|
+
try {
|
|
128
|
+
switch (command) {
|
|
129
|
+
case 'list':
|
|
130
|
+
case 'audit': {
|
|
131
|
+
const result = audit(resolvedHome, profile);
|
|
132
|
+
return printResult({ ...result, command, profile }, json);
|
|
133
|
+
}
|
|
134
|
+
case 'remove': {
|
|
135
|
+
if (!yes) {
|
|
136
|
+
console.error('remove requires --yes to confirm');
|
|
137
|
+
return 2;
|
|
138
|
+
}
|
|
139
|
+
if (!plugin) {
|
|
140
|
+
console.error('remove requires a plugin name');
|
|
141
|
+
return 2;
|
|
142
|
+
}
|
|
143
|
+
const result = removePlugin(resolvedHome, profile, plugin, { force: yes });
|
|
144
|
+
return printResult({ ...result, command }, json);
|
|
145
|
+
}
|
|
146
|
+
case 'disable':
|
|
147
|
+
case 'enable': {
|
|
148
|
+
if (!plugin) {
|
|
149
|
+
console.error(`${command} requires a plugin name`);
|
|
150
|
+
return 2;
|
|
151
|
+
}
|
|
152
|
+
const result = setPluginEnabled(resolvedHome, profile, plugin, command === 'enable');
|
|
153
|
+
return printResult({ ...result, command }, json);
|
|
154
|
+
}
|
|
155
|
+
default:
|
|
156
|
+
console.error(`unknown command: ${command} (try --help)`);
|
|
157
|
+
return 2;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
162
|
+
return 1;
|
|
163
|
+
}
|
|
164
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-unplug — plug/unplug any DeepSeek Harness plugin cleanly.
|
|
3
|
+
* @module dsh-unplug
|
|
4
|
+
*/
|
|
5
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
6
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { satisfiesCaret } from './lib/version.js';
|
|
9
|
+
import { dshHome } from './lib/paths.js';
|
|
10
|
+
import { audit } from './lib/audit.js';
|
|
11
|
+
import { removePlugin, setPluginEnabled } from './lib/remove.js';
|
|
12
|
+
export const name = 'dsh-unplug';
|
|
13
|
+
export const inject = ['tools'];
|
|
14
|
+
export const TESTED_PEER_RANGE = '^0.1.0-rc.6';
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
export function resolvedDshToolsVersion() {
|
|
17
|
+
try {
|
|
18
|
+
const pkg = require('@deepseek-ai/dsh-tools/package.json');
|
|
19
|
+
return pkg.version ?? 'unknown';
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return 'unresolved';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function assertPeerCompatible() {
|
|
26
|
+
const version = resolvedDshToolsVersion();
|
|
27
|
+
if (!satisfiesCaret(version, TESTED_PEER_RANGE)) {
|
|
28
|
+
throw new Error(`dsh-unplug: resolved @deepseek-ai/dsh-tools ${version}, but this plugin is tested with `
|
|
29
|
+
+ `${TESTED_PEER_RANGE}. Upgrade DeepSeek Harness to 0.1.0-rc.6 or later, then reinstall this plugin.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export const Config = Schema.object({
|
|
33
|
+
defaultProfile: Schema.string().default('default'),
|
|
34
|
+
});
|
|
35
|
+
function errorReport(code, message, profile, command) {
|
|
36
|
+
return { schema: 'dsh-unplug/v1', ok: false, command, profile, error: { code, message } };
|
|
37
|
+
}
|
|
38
|
+
export function apply(ctx, config) {
|
|
39
|
+
assertPeerCompatible();
|
|
40
|
+
ctx.tools.register(defineTool({
|
|
41
|
+
name: 'unplug',
|
|
42
|
+
description: 'Manage plugins: list every mounted layer, remove a plugin cleanly (bundles + patch rows + dependencies), '
|
|
43
|
+
+ 'disable/enable without deleting, and audit for orphaned/dangling plugin state. '
|
|
44
|
+
+ 'Read-only commands (list, audit) are always allowed. Destructive commands (remove) require `force: true`. '
|
|
45
|
+
+ 'Returns a dsh-unplug/v1 report.',
|
|
46
|
+
parameters: {
|
|
47
|
+
command: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
required: true,
|
|
50
|
+
enum: ['list', 'remove', 'disable', 'enable', 'audit'],
|
|
51
|
+
description: 'Action to perform',
|
|
52
|
+
},
|
|
53
|
+
plugin: {
|
|
54
|
+
type: 'string',
|
|
55
|
+
description: 'Plugin id or package name to target (required for remove/disable/enable)',
|
|
56
|
+
},
|
|
57
|
+
profile: {
|
|
58
|
+
type: 'string',
|
|
59
|
+
description: 'Profile name (defaults to the configured defaultProfile)',
|
|
60
|
+
},
|
|
61
|
+
force: {
|
|
62
|
+
type: 'boolean',
|
|
63
|
+
description: 'Required for remove: confirm the destructive operation',
|
|
64
|
+
},
|
|
65
|
+
home: {
|
|
66
|
+
type: 'string',
|
|
67
|
+
description: 'DSH_HOME path (defaults to env DSH_HOME or ~/.dsh)',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
output: {
|
|
71
|
+
schema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
properties: {
|
|
75
|
+
schema: { type: 'string' },
|
|
76
|
+
ok: { type: 'boolean' },
|
|
77
|
+
command: { type: 'string' },
|
|
78
|
+
plugin: { type: 'string' },
|
|
79
|
+
profile: { type: 'string' },
|
|
80
|
+
removedBundle: { type: 'boolean' },
|
|
81
|
+
patchRowsRemoved: { type: 'number' },
|
|
82
|
+
pnpmRemoved: { type: 'boolean' },
|
|
83
|
+
note: { type: 'string' },
|
|
84
|
+
enabled: { type: 'boolean' },
|
|
85
|
+
bundleMoved: { type: 'boolean' },
|
|
86
|
+
patchChanged: { type: 'boolean' },
|
|
87
|
+
error: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
additionalProperties: false,
|
|
90
|
+
properties: { code: { type: 'string' }, message: { type: 'string' } },
|
|
91
|
+
},
|
|
92
|
+
bundles: { type: 'array' },
|
|
93
|
+
disabledBundles: { type: 'array' },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
97
|
+
},
|
|
98
|
+
async execute(args) {
|
|
99
|
+
const home = args.home ?? dshHome();
|
|
100
|
+
const profile = args.profile ?? config.defaultProfile ?? 'default';
|
|
101
|
+
try {
|
|
102
|
+
switch (args.command) {
|
|
103
|
+
case 'list':
|
|
104
|
+
case 'audit': {
|
|
105
|
+
const result = audit(home, profile);
|
|
106
|
+
return { schema: 'dsh-unplug/v1', ok: true, command: args.command, profile, bundles: result.bundles, disabledBundles: result.disabledBundles };
|
|
107
|
+
}
|
|
108
|
+
case 'remove': {
|
|
109
|
+
if (!args.force)
|
|
110
|
+
return errorReport('UNPLUG_REQUIRES_FORCE', 'Remove requires `force: true` to confirm the destructive operation', profile, args.command);
|
|
111
|
+
if (!args.plugin)
|
|
112
|
+
return errorReport('UNPLUG_MISSING_PLUGIN', 'remove requires a plugin id or package name', profile, args.command);
|
|
113
|
+
const result = removePlugin(home, profile, args.plugin, { force: args.force });
|
|
114
|
+
return {
|
|
115
|
+
schema: result.schema,
|
|
116
|
+
ok: result.ok,
|
|
117
|
+
command: args.command,
|
|
118
|
+
plugin: result.plugin,
|
|
119
|
+
profile: result.profile,
|
|
120
|
+
removedBundle: result.removedBundle,
|
|
121
|
+
patchRowsRemoved: result.patchRowsRemoved,
|
|
122
|
+
pnpmRemoved: result.pnpmRemoved,
|
|
123
|
+
note: result.note,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
case 'disable':
|
|
127
|
+
case 'enable': {
|
|
128
|
+
if (!args.plugin)
|
|
129
|
+
return errorReport('UNPLUG_MISSING_PLUGIN', `${args.command} requires a plugin id or package name`, profile, args.command);
|
|
130
|
+
const result = setPluginEnabled(home, profile, args.plugin, args.command === 'enable');
|
|
131
|
+
return {
|
|
132
|
+
schema: result.schema,
|
|
133
|
+
ok: result.ok,
|
|
134
|
+
command: args.command,
|
|
135
|
+
plugin: result.plugin,
|
|
136
|
+
profile: result.profile,
|
|
137
|
+
enabled: result.enabled,
|
|
138
|
+
bundleMoved: result.bundleMoved,
|
|
139
|
+
patchChanged: result.patchChanged,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
default:
|
|
143
|
+
return errorReport('UNPLUG_UNKNOWN_COMMAND', `Unknown command: ${args.command}`, profile, args.command);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
return errorReport('UNPLUG_ERROR', error instanceof Error ? error.message : String(error), profile, args.command);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
}));
|
|
151
|
+
}
|
package/lib/lib/audit.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parsePatch } from './patches.js';
|
|
4
|
+
import { bundlesOf, disabledBundlesOf, readProfileManifest } from './manifest.js';
|
|
5
|
+
import { isResolvable, resolveBundleManifest, resolveBundlePatchPath } from './resolve.js';
|
|
6
|
+
function rowsFromPatch(file, layer) {
|
|
7
|
+
if (file === undefined || !existsSync(file))
|
|
8
|
+
return [];
|
|
9
|
+
const text = readFileSync(file, 'utf8');
|
|
10
|
+
return parsePatch(text).entries.map((entry) => ({
|
|
11
|
+
id: entry.id,
|
|
12
|
+
name: entry.name,
|
|
13
|
+
op: entry.op,
|
|
14
|
+
disabled: entry.disabled,
|
|
15
|
+
layer,
|
|
16
|
+
file,
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
export function audit(home, profileName) {
|
|
20
|
+
const profileDir = path.join(home, 'profiles', profileName);
|
|
21
|
+
const manifest = existsSync(path.join(profileDir, 'package.json'))
|
|
22
|
+
? readProfileManifest(profileDir)
|
|
23
|
+
: { dependencies: {}, dsh: { profile: { bundles: [], disabledBundles: [] } } };
|
|
24
|
+
const bundles = bundlesOf(manifest);
|
|
25
|
+
const disabledBundles = disabledBundlesOf(manifest);
|
|
26
|
+
const rows = [];
|
|
27
|
+
const issues = [];
|
|
28
|
+
for (const pkg of bundles) {
|
|
29
|
+
if (!isResolvable(profileDir, pkg)) {
|
|
30
|
+
issues.push({ kind: 'dangling-bundle', detail: `bundle "${pkg}" is listed but not resolvable from ${profileDir}` });
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const bundleManifest = resolveBundleManifest(profileDir, pkg);
|
|
34
|
+
const patchPath = resolveBundlePatchPath(profileDir, pkg);
|
|
35
|
+
if (bundleManifest?.dsh?.bundle?.patch === undefined) {
|
|
36
|
+
issues.push({ kind: 'bundle-without-patch', detail: `bundle "${pkg}" declares no dsh.bundle.patch` });
|
|
37
|
+
}
|
|
38
|
+
else if (patchPath === undefined || !existsSync(patchPath)) {
|
|
39
|
+
issues.push({ kind: 'missing-patch-file', detail: `bundle "${pkg}" patch file missing: ${String(patchPath)}` });
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
for (const row of rowsFromPatch(patchPath, 'bundle')) {
|
|
43
|
+
row.bundle = pkg;
|
|
44
|
+
rows.push(row);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const profilePatch = path.join(profileDir, 'cordis.patch.yml');
|
|
49
|
+
const homePatch = path.join(home, 'cordis.patch.yml');
|
|
50
|
+
rows.push(...rowsFromPatch(profilePatch, 'profile-patch'));
|
|
51
|
+
rows.push(...rowsFromPatch(homePatch, 'home-patch'));
|
|
52
|
+
const resolvableNames = new Set([...bundles, ...disabledBundles]);
|
|
53
|
+
for (const row of rows) {
|
|
54
|
+
if (row.name !== undefined && !resolvableNames.has(row.name) && row.layer !== 'home-patch') {
|
|
55
|
+
issues.push({ kind: 'orphan-row', detail: `patch row "${row.id}" references "${row.name}" which is not an installed bundle` });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { rows, issues, bundles, disabledBundles };
|
|
59
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export const PROFILE_MANIFEST = 'package.json';
|
|
4
|
+
export function readProfileManifest(dir) {
|
|
5
|
+
const text = readFileSync(path.join(dir, PROFILE_MANIFEST), 'utf8');
|
|
6
|
+
return JSON.parse(text);
|
|
7
|
+
}
|
|
8
|
+
export function writeProfileManifest(dir, manifest) {
|
|
9
|
+
writeFileSync(path.join(dir, PROFILE_MANIFEST), JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
10
|
+
}
|
|
11
|
+
export function bundlesOf(manifest) {
|
|
12
|
+
return manifest.dsh?.profile?.bundles ?? [];
|
|
13
|
+
}
|
|
14
|
+
export function disabledBundlesOf(manifest) {
|
|
15
|
+
return manifest.dsh?.profile?.disabledBundles ?? [];
|
|
16
|
+
}
|
|
17
|
+
export function removeBundle(manifest, pkg) {
|
|
18
|
+
const bundles = bundlesOf(manifest);
|
|
19
|
+
const disabled = disabledBundlesOf(manifest);
|
|
20
|
+
const removed = bundles.includes(pkg);
|
|
21
|
+
const disabledRemoved = disabled.includes(pkg);
|
|
22
|
+
if (!removed && !disabledRemoved)
|
|
23
|
+
return { removed: false, disabledRemoved: false };
|
|
24
|
+
if (manifest.dsh === undefined)
|
|
25
|
+
manifest.dsh = {};
|
|
26
|
+
if (manifest.dsh.profile === undefined)
|
|
27
|
+
manifest.dsh.profile = {};
|
|
28
|
+
if (removed)
|
|
29
|
+
manifest.dsh.profile.bundles = bundles.filter((entry) => entry !== pkg);
|
|
30
|
+
if (disabledRemoved)
|
|
31
|
+
manifest.dsh.profile.disabledBundles = disabled.filter((entry) => entry !== pkg);
|
|
32
|
+
if (manifest.dependencies !== undefined)
|
|
33
|
+
delete manifest.dependencies[pkg];
|
|
34
|
+
return { removed, disabledRemoved };
|
|
35
|
+
}
|
|
36
|
+
export function disableBundle(manifest, pkg) {
|
|
37
|
+
const bundles = bundlesOf(manifest);
|
|
38
|
+
if (!bundles.includes(pkg))
|
|
39
|
+
return false;
|
|
40
|
+
if (manifest.dsh === undefined)
|
|
41
|
+
manifest.dsh = {};
|
|
42
|
+
if (manifest.dsh.profile === undefined)
|
|
43
|
+
manifest.dsh.profile = {};
|
|
44
|
+
manifest.dsh.profile.bundles = bundles.filter((entry) => entry !== pkg);
|
|
45
|
+
const disabled = disabledBundlesOf(manifest);
|
|
46
|
+
if (!disabled.includes(pkg))
|
|
47
|
+
disabled.push(pkg);
|
|
48
|
+
manifest.dsh.profile.disabledBundles = disabled;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
export function enableBundle(manifest, pkg) {
|
|
52
|
+
const disabled = disabledBundlesOf(manifest);
|
|
53
|
+
if (!disabled.includes(pkg))
|
|
54
|
+
return false;
|
|
55
|
+
if (manifest.dsh === undefined)
|
|
56
|
+
manifest.dsh = {};
|
|
57
|
+
if (manifest.dsh.profile === undefined)
|
|
58
|
+
manifest.dsh.profile = {};
|
|
59
|
+
manifest.dsh.profile.disabledBundles = disabled.filter((entry) => entry !== pkg);
|
|
60
|
+
const bundles = bundlesOf(manifest);
|
|
61
|
+
if (!bundles.includes(pkg))
|
|
62
|
+
bundles.push(pkg);
|
|
63
|
+
manifest.dsh.profile.bundles = bundles;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, formatting-preserving editor for dsh cordis.patch.yml layers.
|
|
3
|
+
*
|
|
4
|
+
* The patch shape we handle is the one `dsh plugin` and every bundle uses:
|
|
5
|
+
*
|
|
6
|
+
* - insert:
|
|
7
|
+
* - id: disk-audit
|
|
8
|
+
* name: dsh-disk-audit
|
|
9
|
+
* config: {}
|
|
10
|
+
* - override:
|
|
11
|
+
* - id: unplug
|
|
12
|
+
* disabled: true
|
|
13
|
+
*
|
|
14
|
+
* We only need to find entries by id/name, toggle `disabled`, and delete an
|
|
15
|
+
* entry (plus its now-empty op block). Everything else is preserved verbatim.
|
|
16
|
+
*/
|
|
17
|
+
const OP_RE = /^(\s*)-\s*([a-zA-Z_$][\w$-]*)\s*:\s*$/;
|
|
18
|
+
const ENTRY_RE = /^(\s*)-\s*id:\s*(.+?)\s*$/;
|
|
19
|
+
const KEY_RE = /^(\s*)(name|disabled):\s*(.+?)\s*$/;
|
|
20
|
+
/** Parse a patch text into lines plus entry descriptors (0-based line numbers). */
|
|
21
|
+
export function parsePatch(text) {
|
|
22
|
+
const lines = text.split(/\r?\n/);
|
|
23
|
+
const entries = [];
|
|
24
|
+
let op = '';
|
|
25
|
+
let opIndent = -1;
|
|
26
|
+
for (let i = 0; i < lines.length; i++) {
|
|
27
|
+
const line = lines[i];
|
|
28
|
+
const opMatch = OP_RE.exec(line);
|
|
29
|
+
if (opMatch !== null) {
|
|
30
|
+
op = opMatch[2];
|
|
31
|
+
opIndent = opMatch[1].length;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const entryMatch = ENTRY_RE.exec(line);
|
|
35
|
+
if (entryMatch === null || entryMatch[1].length <= opIndent) {
|
|
36
|
+
if (line.trim() !== '' && (line.match(/^\s*-\s*/) !== null) && op !== '') {
|
|
37
|
+
// Another list under this op we do not model (e.g. `- $patch`).
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const indent = entryMatch[1].length;
|
|
43
|
+
const id = entryMatch[2];
|
|
44
|
+
let end = i + 1;
|
|
45
|
+
let name;
|
|
46
|
+
let disabled = false;
|
|
47
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
48
|
+
const next = lines[j];
|
|
49
|
+
if (next.trim() === '')
|
|
50
|
+
continue;
|
|
51
|
+
if (!/^\s/.test(next) || (next.match(/^\s*/)?.[0].length ?? 0) <= indent)
|
|
52
|
+
break;
|
|
53
|
+
const keyMatch = KEY_RE.exec(next);
|
|
54
|
+
if (keyMatch !== null && keyMatch[1].length > indent) {
|
|
55
|
+
if (keyMatch[2] === 'name')
|
|
56
|
+
name = keyMatch[3];
|
|
57
|
+
if (keyMatch[2] === 'disabled')
|
|
58
|
+
disabled = keyMatch[3].toLowerCase() !== 'false';
|
|
59
|
+
}
|
|
60
|
+
end = j + 1;
|
|
61
|
+
}
|
|
62
|
+
entries.push({ op, id, name, disabled, indent, startLine: i, endLine: end });
|
|
63
|
+
i = end - 1;
|
|
64
|
+
}
|
|
65
|
+
return { lines, entries };
|
|
66
|
+
}
|
|
67
|
+
function entryKey(entry, plugin) {
|
|
68
|
+
return entry.id === plugin || entry.name === plugin;
|
|
69
|
+
}
|
|
70
|
+
/** Set or clear `disabled:` on the first matching entry. */
|
|
71
|
+
export function setEntryDisabled(text, plugin, disabled) {
|
|
72
|
+
const { lines, entries } = parsePatch(text);
|
|
73
|
+
const entry = entries.find((candidate) => entryKey(candidate, plugin));
|
|
74
|
+
if (entry === undefined)
|
|
75
|
+
return { text, found: false, changed: false };
|
|
76
|
+
let changed = false;
|
|
77
|
+
const next = [];
|
|
78
|
+
for (let i = 0; i < lines.length; i++) {
|
|
79
|
+
if (i === entry.startLine) {
|
|
80
|
+
next.push(lines[i]);
|
|
81
|
+
if (disabled && !entry.disabled) {
|
|
82
|
+
const keyIndent = ' '.repeat(entry.indent + 2);
|
|
83
|
+
next.push(`${keyIndent}disabled: true`);
|
|
84
|
+
changed = true;
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (i >= entry.startLine + 1 && i < entry.endLine) {
|
|
89
|
+
if (i === entry.startLine + 1 && disabled && entry.disabled) {
|
|
90
|
+
// already disabled; leave as-is
|
|
91
|
+
next.push(lines[i]);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const keyMatch = KEY_RE.exec(lines[i]);
|
|
95
|
+
if (keyMatch !== null && keyMatch[2] === 'disabled' && !disabled) {
|
|
96
|
+
changed = true;
|
|
97
|
+
continue; // drop the disabled line
|
|
98
|
+
}
|
|
99
|
+
next.push(lines[i]);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
next.push(lines[i]);
|
|
103
|
+
}
|
|
104
|
+
if (!disabled && entry.disabled)
|
|
105
|
+
changed = true;
|
|
106
|
+
return { text: next.join('\n'), found: true, changed };
|
|
107
|
+
}
|
|
108
|
+
/** Delete the first matching entry (and its op block when it becomes empty). */
|
|
109
|
+
export function removeEntry(text, plugin) {
|
|
110
|
+
const { lines, entries } = parsePatch(text);
|
|
111
|
+
const entry = entries.find((candidate) => entryKey(candidate, plugin));
|
|
112
|
+
if (entry === undefined)
|
|
113
|
+
return { text, found: false, changed: false };
|
|
114
|
+
// Find the op header that owns this entry.
|
|
115
|
+
let opLine = -1;
|
|
116
|
+
for (let i = entry.startLine - 1; i >= 0; i--) {
|
|
117
|
+
const opMatch = OP_RE.exec(lines[i]);
|
|
118
|
+
if (opMatch !== null && opMatch[1].length < entry.indent) {
|
|
119
|
+
opLine = i;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
if (lines[i].trim() !== '' && (lines[i].match(/^\s*/)?.[0].length ?? 0) <= entry.indent - 2)
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
const drop = new Set();
|
|
126
|
+
for (let i = entry.startLine; i < entry.endLine; i++)
|
|
127
|
+
drop.add(i);
|
|
128
|
+
if (opLine >= 0) {
|
|
129
|
+
const remaining = entries.filter((candidate) => candidate.op === entry.op && candidate !== entry);
|
|
130
|
+
if (remaining.length === 0)
|
|
131
|
+
drop.add(opLine);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
text: lines.filter((_, i) => !drop.has(i)).join('\n'),
|
|
135
|
+
found: true,
|
|
136
|
+
changed: true,
|
|
137
|
+
};
|
|
138
|
+
}
|
package/lib/lib/paths.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/** Locate $DSH_HOME (or the conventional ~/.dsh fallback). */
|
|
4
|
+
export function dshHome(env = process.env) {
|
|
5
|
+
const explicit = env.DSH_HOME?.trim();
|
|
6
|
+
return explicit !== undefined && explicit !== '' ? explicit : path.join(os.homedir(), '.dsh');
|
|
7
|
+
}
|
|
8
|
+
export function profilesDir(home) {
|
|
9
|
+
return path.join(home, 'profiles');
|
|
10
|
+
}
|
|
11
|
+
export function profileDir(home, name) {
|
|
12
|
+
return path.join(profilesDir(home), name);
|
|
13
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { disableBundle, enableBundle, removeBundle, readProfileManifest, writeProfileManifest } from './manifest.js';
|
|
5
|
+
import { removeEntry, setEntryDisabled } from './patches.js';
|
|
6
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { audit } from './audit.js';
|
|
8
|
+
import { parsePatch } from './patches.js';
|
|
9
|
+
function resolvePlugin(home, profileDir, profileName, plugin) {
|
|
10
|
+
const manifest = readProfileManifest(profileDir);
|
|
11
|
+
const allBundles = [...(manifest.dsh?.profile?.bundles ?? []), ...(manifest.dsh?.profile?.disabledBundles ?? [])];
|
|
12
|
+
// Direct bundle name match
|
|
13
|
+
if (allBundles.includes(plugin))
|
|
14
|
+
return { bundleName: plugin, rowId: plugin };
|
|
15
|
+
// Check patch rows for id/name match
|
|
16
|
+
for (const file of [path.join(profileDir, 'cordis.patch.yml'), path.join(home, 'cordis.patch.yml')]) {
|
|
17
|
+
if (!existsSync(file))
|
|
18
|
+
continue;
|
|
19
|
+
for (const entry of parsePatch(readFileSync(file, 'utf8')).entries) {
|
|
20
|
+
if (entry.id === plugin)
|
|
21
|
+
return { bundleName: entry.name, rowId: entry.id };
|
|
22
|
+
if (entry.name === plugin)
|
|
23
|
+
return { bundleName: entry.name, rowId: entry.id };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { bundleName: undefined, rowId: undefined };
|
|
27
|
+
}
|
|
28
|
+
function stripRows(file, plugin) {
|
|
29
|
+
if (file === undefined || !existsSync(file))
|
|
30
|
+
return 0;
|
|
31
|
+
let text = readFileSync(file, 'utf8');
|
|
32
|
+
let removed = 0;
|
|
33
|
+
for (;;) {
|
|
34
|
+
const result = removeEntry(text, plugin);
|
|
35
|
+
if (!result.found || !result.changed)
|
|
36
|
+
break;
|
|
37
|
+
text = result.text;
|
|
38
|
+
removed++;
|
|
39
|
+
}
|
|
40
|
+
if (removed > 0)
|
|
41
|
+
writeFileSync(file, text, 'utf8');
|
|
42
|
+
return removed;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Fully unplug a plugin from one profile: bundle list, disabled list,
|
|
46
|
+
* dependencies, patch rows (profile + home), then pnpm sync and re-audit.
|
|
47
|
+
*/
|
|
48
|
+
export function removePlugin(home, profileName, plugin, options) {
|
|
49
|
+
const profileDir = path.join(home, 'profiles', profileName);
|
|
50
|
+
const manifestPath = path.join(profileDir, 'package.json');
|
|
51
|
+
if (!existsSync(manifestPath)) {
|
|
52
|
+
throw new Error(`profile "${profileName}" not found at ${profileDir}`);
|
|
53
|
+
}
|
|
54
|
+
const resolved = resolvePlugin(home, profileDir, profileName, plugin);
|
|
55
|
+
const pkgName = resolved.bundleName ?? plugin;
|
|
56
|
+
const rowId = resolved.rowId ?? plugin;
|
|
57
|
+
const manifest = readProfileManifest(profileDir);
|
|
58
|
+
const { removed, disabledRemoved } = removeBundle(manifest, pkgName);
|
|
59
|
+
if (removed || disabledRemoved)
|
|
60
|
+
writeProfileManifest(profileDir, manifest);
|
|
61
|
+
let patchRowsRemoved = stripRows(path.join(profileDir, 'cordis.patch.yml'), rowId);
|
|
62
|
+
patchRowsRemoved += stripRows(path.join(home, 'cordis.patch.yml'), rowId);
|
|
63
|
+
let pnpmRemoved = false;
|
|
64
|
+
let note;
|
|
65
|
+
if (options.runPnpm !== false) {
|
|
66
|
+
const result = spawnSync('pnpm', ['remove', pkgName], {
|
|
67
|
+
cwd: profileDir,
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
timeout: 60000,
|
|
70
|
+
windowsHide: true,
|
|
71
|
+
shell: process.platform === 'win32',
|
|
72
|
+
});
|
|
73
|
+
if (result.status === 0)
|
|
74
|
+
pnpmRemoved = true;
|
|
75
|
+
else
|
|
76
|
+
note = `pnpm remove exited ${result.status ?? 'none'}: ${(result.stderr || result.stdout || '').trim().slice(0, 300)}`;
|
|
77
|
+
}
|
|
78
|
+
const post = audit(home, profileName);
|
|
79
|
+
const ok = removed || disabledRemoved || patchRowsRemoved > 0;
|
|
80
|
+
if (!ok) {
|
|
81
|
+
throw new Error(`plugin "${plugin}" is not mounted in profile "${profileName}" (nothing to unplug)`);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
schema: 'dsh-unplug/v1',
|
|
85
|
+
ok: true,
|
|
86
|
+
plugin,
|
|
87
|
+
profile: profileName,
|
|
88
|
+
removedBundle: removed,
|
|
89
|
+
patchRowsRemoved,
|
|
90
|
+
pnpmRemoved,
|
|
91
|
+
note,
|
|
92
|
+
audit: post,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Disable or enable a plugin across bundle list and patch layers. */
|
|
96
|
+
export function setPluginEnabled(home, profileName, plugin, enabled) {
|
|
97
|
+
const profileDir = path.join(home, 'profiles', profileName);
|
|
98
|
+
const manifestPath = path.join(profileDir, 'package.json');
|
|
99
|
+
if (!existsSync(manifestPath)) {
|
|
100
|
+
throw new Error(`profile "${profileName}" not found at ${profileDir}`);
|
|
101
|
+
}
|
|
102
|
+
const resolved = resolvePlugin(home, profileDir, profileName, plugin);
|
|
103
|
+
const pkgName = resolved.bundleName ?? plugin;
|
|
104
|
+
const rowId = resolved.rowId ?? plugin;
|
|
105
|
+
const manifest = readProfileManifest(profileDir);
|
|
106
|
+
const bundleMoved = enabled ? enableBundle(manifest, pkgName) : disableBundle(manifest, pkgName);
|
|
107
|
+
if (bundleMoved)
|
|
108
|
+
writeProfileManifest(profileDir, manifest);
|
|
109
|
+
let patchChanged = false;
|
|
110
|
+
const files = [path.join(profileDir, 'cordis.patch.yml'), path.join(home, 'cordis.patch.yml')];
|
|
111
|
+
for (const file of files) {
|
|
112
|
+
if (!existsSync(file))
|
|
113
|
+
continue;
|
|
114
|
+
const text = readFileSync(file, 'utf8');
|
|
115
|
+
const result = setEntryDisabled(text, rowId, !enabled);
|
|
116
|
+
if (result.found && result.changed) {
|
|
117
|
+
writeFileSync(file, result.text, 'utf8');
|
|
118
|
+
patchChanged = true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!bundleMoved && !patchChanged) {
|
|
122
|
+
throw new Error(`plugin "${plugin}" is not mounted in profile "${profileName}" (nothing to ${enabled ? 'enable' : 'disable'})`);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
schema: 'dsh-unplug/v1',
|
|
126
|
+
ok: true,
|
|
127
|
+
plugin,
|
|
128
|
+
profile: profileName,
|
|
129
|
+
enabled,
|
|
130
|
+
bundleMoved,
|
|
131
|
+
patchChanged,
|
|
132
|
+
audit: audit(home, profileName),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
/** Resolve an installed bundle's package.json from a profile directory. */
|
|
6
|
+
export function resolveBundleManifest(profileDir, pkg) {
|
|
7
|
+
try {
|
|
8
|
+
const resolved = require.resolve(`${pkg}/package.json`, { paths: [profileDir] });
|
|
9
|
+
return JSON.parse(readFileSync(resolved, 'utf8'));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Resolve a bundle's patch file path relative to its package root. */
|
|
16
|
+
export function resolveBundlePatchPath(profileDir, pkg) {
|
|
17
|
+
const manifest = resolveBundleManifest(profileDir, pkg);
|
|
18
|
+
const patch = manifest?.dsh?.bundle?.patch;
|
|
19
|
+
if (patch === undefined)
|
|
20
|
+
return undefined;
|
|
21
|
+
try {
|
|
22
|
+
const pkgJson = require.resolve(`${pkg}/package.json`, { paths: [profileDir] });
|
|
23
|
+
return path.resolve(path.dirname(pkgJson), patch);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Check whether a package is resolvable from a profile directory. */
|
|
30
|
+
export function isResolvable(profileDir, pkg) {
|
|
31
|
+
try {
|
|
32
|
+
require.resolve(`${pkg}/package.json`, { paths: [profileDir] });
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Minimal caret-range matching for the peer guard (subset of node-semver). */
|
|
2
|
+
export function parseVersion(input) {
|
|
3
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(input.trim());
|
|
4
|
+
if (match === null)
|
|
5
|
+
return null;
|
|
6
|
+
const prerelease = match[4] !== undefined && match[4] !== '' ? match[4].split('.') : null;
|
|
7
|
+
return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease };
|
|
8
|
+
}
|
|
9
|
+
function compareIdentifiers(a, b) {
|
|
10
|
+
if (b === null)
|
|
11
|
+
return 1;
|
|
12
|
+
const length = Math.max(a.length, b.length);
|
|
13
|
+
for (let i = 0; i < length; i++) {
|
|
14
|
+
const left = a[i];
|
|
15
|
+
const right = b[i];
|
|
16
|
+
if (left === undefined)
|
|
17
|
+
return -1;
|
|
18
|
+
if (right === undefined)
|
|
19
|
+
return 1;
|
|
20
|
+
if (left !== right)
|
|
21
|
+
return left < right ? -1 : 1;
|
|
22
|
+
}
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
export function satisfiesCaret(version, range) {
|
|
26
|
+
const m = /^\^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(range.trim());
|
|
27
|
+
if (m === null)
|
|
28
|
+
return false;
|
|
29
|
+
const v = parseVersion(version);
|
|
30
|
+
if (v === null)
|
|
31
|
+
return false;
|
|
32
|
+
const major = Number(m[1]);
|
|
33
|
+
const minor = Number(m[2]);
|
|
34
|
+
const patch = Number(m[3]);
|
|
35
|
+
const pre = m[4] !== undefined && m[4] !== '' ? m[4].split('.') : null;
|
|
36
|
+
if (v.major !== major || v.minor !== minor || v.patch < patch)
|
|
37
|
+
return false;
|
|
38
|
+
if (v.patch === patch) {
|
|
39
|
+
if (pre === null && v.prerelease !== null)
|
|
40
|
+
return false;
|
|
41
|
+
if (pre !== null && compareIdentifiers(v.prerelease ?? [], pre) < 0)
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (major > 0)
|
|
45
|
+
return v.major === major;
|
|
46
|
+
if (minor > 0)
|
|
47
|
+
return v.minor === minor;
|
|
48
|
+
return v.patch === patch;
|
|
49
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-unplug — plug/unplug any DeepSeek Harness plugin cleanly.
|
|
3
|
+
* @module dsh-unplug
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
7
|
+
export declare const name = "dsh-unplug";
|
|
8
|
+
export declare const inject: string[];
|
|
9
|
+
export declare const TESTED_PEER_RANGE = "^0.1.0-rc.6";
|
|
10
|
+
export declare function resolvedDshToolsVersion(): string;
|
|
11
|
+
export declare function assertPeerCompatible(): void;
|
|
12
|
+
export interface Config {
|
|
13
|
+
defaultProfile?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const Config: Schema<Config>;
|
|
16
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type AuditKind = 'dangling-bundle' | 'orphan-row' | 'bundle-without-patch' | 'missing-patch-file';
|
|
2
|
+
export interface AuditIssue {
|
|
3
|
+
kind: AuditKind;
|
|
4
|
+
detail: string;
|
|
5
|
+
}
|
|
6
|
+
export interface LayerRow {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string | undefined;
|
|
9
|
+
op: string;
|
|
10
|
+
disabled: boolean;
|
|
11
|
+
layer: 'bundle' | 'profile-patch' | 'home-patch';
|
|
12
|
+
bundle?: string;
|
|
13
|
+
file?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface AuditResult {
|
|
16
|
+
rows: LayerRow[];
|
|
17
|
+
issues: AuditIssue[];
|
|
18
|
+
bundles: string[];
|
|
19
|
+
disabledBundles: string[];
|
|
20
|
+
}
|
|
21
|
+
export declare function audit(home: string, profileName: string): AuditResult;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface ProfileManifest {
|
|
2
|
+
name?: string;
|
|
3
|
+
private?: boolean;
|
|
4
|
+
dependencies?: Record<string, string>;
|
|
5
|
+
dsh?: {
|
|
6
|
+
profile?: {
|
|
7
|
+
bundles?: string[];
|
|
8
|
+
disabledBundles?: string[];
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export declare const PROFILE_MANIFEST = "package.json";
|
|
13
|
+
export declare function readProfileManifest(dir: string): ProfileManifest;
|
|
14
|
+
export declare function writeProfileManifest(dir: string, manifest: ProfileManifest): void;
|
|
15
|
+
export declare function bundlesOf(manifest: ProfileManifest): string[];
|
|
16
|
+
export declare function disabledBundlesOf(manifest: ProfileManifest): string[];
|
|
17
|
+
export declare function removeBundle(manifest: ProfileManifest, pkg: string): {
|
|
18
|
+
removed: boolean;
|
|
19
|
+
disabledRemoved: boolean;
|
|
20
|
+
};
|
|
21
|
+
export declare function disableBundle(manifest: ProfileManifest, pkg: string): boolean;
|
|
22
|
+
export declare function enableBundle(manifest: ProfileManifest, pkg: string): boolean;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, formatting-preserving editor for dsh cordis.patch.yml layers.
|
|
3
|
+
*
|
|
4
|
+
* The patch shape we handle is the one `dsh plugin` and every bundle uses:
|
|
5
|
+
*
|
|
6
|
+
* - insert:
|
|
7
|
+
* - id: disk-audit
|
|
8
|
+
* name: dsh-disk-audit
|
|
9
|
+
* config: {}
|
|
10
|
+
* - override:
|
|
11
|
+
* - id: unplug
|
|
12
|
+
* disabled: true
|
|
13
|
+
*
|
|
14
|
+
* We only need to find entries by id/name, toggle `disabled`, and delete an
|
|
15
|
+
* entry (plus its now-empty op block). Everything else is preserved verbatim.
|
|
16
|
+
*/
|
|
17
|
+
export type PatchOp = 'insert' | 'override' | 'remove' | string;
|
|
18
|
+
export interface PatchEntry {
|
|
19
|
+
op: PatchOp;
|
|
20
|
+
id: string;
|
|
21
|
+
name: string | undefined;
|
|
22
|
+
disabled: boolean;
|
|
23
|
+
indent: number;
|
|
24
|
+
startLine: number;
|
|
25
|
+
endLine: number;
|
|
26
|
+
}
|
|
27
|
+
export interface ParsedPatch {
|
|
28
|
+
lines: string[];
|
|
29
|
+
entries: PatchEntry[];
|
|
30
|
+
}
|
|
31
|
+
/** Parse a patch text into lines plus entry descriptors (0-based line numbers). */
|
|
32
|
+
export declare function parsePatch(text: string): ParsedPatch;
|
|
33
|
+
export interface EditResult {
|
|
34
|
+
text: string;
|
|
35
|
+
found: boolean;
|
|
36
|
+
changed: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** Set or clear `disabled:` on the first matching entry. */
|
|
39
|
+
export declare function setEntryDisabled(text: string, plugin: string, disabled: boolean): EditResult;
|
|
40
|
+
/** Delete the first matching entry (and its op block when it becomes empty). */
|
|
41
|
+
export declare function removeEntry(text: string, plugin: string): EditResult;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type AuditResult } from './audit.js';
|
|
2
|
+
export interface RemoveResult {
|
|
3
|
+
schema: 'dsh-unplug/v1';
|
|
4
|
+
ok: boolean;
|
|
5
|
+
plugin: string;
|
|
6
|
+
profile: string;
|
|
7
|
+
removedBundle: boolean;
|
|
8
|
+
patchRowsRemoved: number;
|
|
9
|
+
pnpmRemoved: boolean;
|
|
10
|
+
note?: string;
|
|
11
|
+
audit: AuditResult;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Fully unplug a plugin from one profile: bundle list, disabled list,
|
|
15
|
+
* dependencies, patch rows (profile + home), then pnpm sync and re-audit.
|
|
16
|
+
*/
|
|
17
|
+
export declare function removePlugin(home: string, profileName: string, plugin: string, options: {
|
|
18
|
+
force: boolean;
|
|
19
|
+
runPnpm?: boolean;
|
|
20
|
+
}): RemoveResult;
|
|
21
|
+
export interface ToggleResult {
|
|
22
|
+
schema: 'dsh-unplug/v1';
|
|
23
|
+
ok: boolean;
|
|
24
|
+
plugin: string;
|
|
25
|
+
profile: string;
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
bundleMoved: boolean;
|
|
28
|
+
patchChanged: boolean;
|
|
29
|
+
note?: string;
|
|
30
|
+
audit: AuditResult;
|
|
31
|
+
}
|
|
32
|
+
/** Disable or enable a plugin across bundle list and patch layers. */
|
|
33
|
+
export declare function setPluginEnabled(home: string, profileName: string, plugin: string, enabled: boolean): ToggleResult;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Helpers for the dsh-unplug/v1 report envelope. */
|
|
2
|
+
export interface UnplugError {
|
|
3
|
+
schema: 'dsh-unplug/v1';
|
|
4
|
+
ok: false;
|
|
5
|
+
error: {
|
|
6
|
+
code: string;
|
|
7
|
+
message: string;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export declare function errorReport(code: string, message: string): UnplugError;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface BundleManifest {
|
|
2
|
+
name?: string;
|
|
3
|
+
version?: string;
|
|
4
|
+
dsh?: {
|
|
5
|
+
bundle?: {
|
|
6
|
+
patch?: string;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/** Resolve an installed bundle's package.json from a profile directory. */
|
|
11
|
+
export declare function resolveBundleManifest(profileDir: string, pkg: string): BundleManifest | undefined;
|
|
12
|
+
/** Resolve a bundle's patch file path relative to its package root. */
|
|
13
|
+
export declare function resolveBundlePatchPath(profileDir: string, pkg: string): string | undefined;
|
|
14
|
+
/** Check whether a package is resolvable from a profile directory. */
|
|
15
|
+
export declare function isResolvable(profileDir: string, pkg: string): boolean;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Minimal caret-range matching for the peer guard (subset of node-semver). */
|
|
2
|
+
export declare function parseVersion(input: string): {
|
|
3
|
+
major: number;
|
|
4
|
+
minor: number;
|
|
5
|
+
patch: number;
|
|
6
|
+
prerelease: string[] | null;
|
|
7
|
+
} | null;
|
|
8
|
+
export declare function satisfiesCaret(version: string, range: string): boolean;
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-unplug",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Plug/unplug any DeepSeek Harness plugin cleanly: list every mounted layer, disable/enable without deleting, remove bundles + patch rows + dependencies in one pass, and audit for orphaned/dangling plugin state. Zero runtime dependencies; read-only by default.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/types/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"dsh-unplug": "lib/bin.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"lib",
|
|
13
|
+
"cordis.patch.yml",
|
|
14
|
+
"README.md",
|
|
15
|
+
"SECURITY.md",
|
|
16
|
+
"CONTRIBUTING.md",
|
|
17
|
+
"CHANGELOG.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.json",
|
|
21
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"test:integration": "node scripts/integration-test.mjs"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"dsh",
|
|
27
|
+
"dsh-plugin",
|
|
28
|
+
"plugin-manager",
|
|
29
|
+
"uninstall",
|
|
30
|
+
"lifecycle",
|
|
31
|
+
"unplug"
|
|
32
|
+
],
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
35
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
42
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
|
43
|
+
"@types/node": "^22.20.0",
|
|
44
|
+
"typescript": "^5.6.3",
|
|
45
|
+
"vitest": "^2.1.9"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=18"
|
|
49
|
+
},
|
|
50
|
+
"pnpm": {
|
|
51
|
+
"onlyBuiltDependencies": [
|
|
52
|
+
"esbuild"
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"dsh": {
|
|
56
|
+
"bundle": {
|
|
57
|
+
"patch": "./cordis.patch.yml"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"repository": {
|
|
61
|
+
"type": "git",
|
|
62
|
+
"url": "git+https://github.com/zoahdev/dsh-unplug.git"
|
|
63
|
+
},
|
|
64
|
+
"bugs": {
|
|
65
|
+
"url": "https://github.com/zoahdev/dsh-unplug/issues"
|
|
66
|
+
},
|
|
67
|
+
"homepage": "https://github.com/zoahdev/dsh-unplug"
|
|
68
|
+
}
|