plugnz 0.0.4 → 0.0.6
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/dist/plugnz.mjs +20000 -0
- package/package.json +13 -10
- package/bin/plgnz.mjs +0 -3
- package/bin/plugnz.mjs +0 -5
- package/src/cli.ts +0 -489
- package/src/compatibility.ts +0 -23
- package/src/consumer-profiles.ts +0 -77
- package/src/conversion.ts +0 -544
- package/src/doctor.ts +0 -277
- package/src/exec.ts +0 -91
- package/src/fingerprint.ts +0 -77
- package/src/globals.d.ts +0 -97
- package/src/hermes-identity.ts +0 -4
- package/src/host.ts +0 -131
- package/src/hosts/claude-code-writer.ts +0 -589
- package/src/hosts/claude-code.ts +0 -124
- package/src/hosts/codex-writer.ts +0 -418
- package/src/hosts/codex.ts +0 -183
- package/src/hosts/cursor-writer.ts +0 -279
- package/src/hosts/cursor.ts +0 -107
- package/src/hosts/dcode-writer.ts +0 -150
- package/src/hosts/dcode.ts +0 -74
- package/src/hosts/grok-writer.ts +0 -254
- package/src/hosts/grok.ts +0 -94
- package/src/hosts/hermes-writer.ts +0 -223
- package/src/hosts/hermes.ts +0 -88
- package/src/hosts/index.ts +0 -25
- package/src/hosts/kimi-writer.ts +0 -238
- package/src/hosts/kimi.ts +0 -140
- package/src/hosts/omp-writer.ts +0 -179
- package/src/hosts/omp.ts +0 -153
- package/src/hosts/opencode-writer.ts +0 -112
- package/src/hosts/opencode.ts +0 -38
- package/src/hosts/pi-writer.ts +0 -111
- package/src/hosts/pi.ts +0 -48
- package/src/hosts/writers.ts +0 -23
- package/src/hosts/zcode-cli-writer.ts +0 -239
- package/src/hosts/zcode-cli.ts +0 -195
- package/src/mcp-write.ts +0 -81
- package/src/mcp.ts +0 -161
- package/src/paths.ts +0 -101
- package/src/pin.ts +0 -135
- package/src/source.ts +0 -224
- package/src/state-write.ts +0 -25
- package/src/state.ts +0 -118
- package/src/update.ts +0 -230
package/src/source.ts
DELETED
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
-
import { join, basename, isAbsolute, resolve } from 'node:path';
|
|
4
|
-
import { cacheRoot } from './paths';
|
|
5
|
-
import { isGitUrl } from './exec';
|
|
6
|
-
import { fingerprintTree } from './fingerprint';
|
|
7
|
-
|
|
8
|
-
export interface PluginSource {
|
|
9
|
-
dir: string;
|
|
10
|
-
name: string;
|
|
11
|
-
version?: string;
|
|
12
|
-
marketplace?: string;
|
|
13
|
-
/** Stable digest of the source directory bytes, independent of version/SHA. */
|
|
14
|
-
contentFingerprint?: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Identity fields shared by canonical Agent Plugins and supported native inputs. */
|
|
18
|
-
export interface PluginManifest {
|
|
19
|
-
name: string;
|
|
20
|
-
version?: string;
|
|
21
|
-
description?: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface ResolvedSource {
|
|
25
|
-
sourceUri: string;
|
|
26
|
-
sha: string;
|
|
27
|
-
isGit: boolean;
|
|
28
|
-
plugins: PluginSource[];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function resolveSource(source: string): ResolvedSource {
|
|
32
|
-
const sourceUri = normalizeSource(source);
|
|
33
|
-
const isGit = isGitUrl(sourceUri);
|
|
34
|
-
let targetDir = sourceUri;
|
|
35
|
-
let sha = 'local';
|
|
36
|
-
|
|
37
|
-
if (isGit) {
|
|
38
|
-
const ls = spawnSync('git', ['ls-remote', sourceUri, 'HEAD'], { encoding: 'utf8' });
|
|
39
|
-
if (ls.status !== 0) throw new Error(`Failed to resolve git remote: ${sourceUri}`);
|
|
40
|
-
sha = ls.stdout.split('\t')[0] || '';
|
|
41
|
-
if (!sha || sha.length !== 40) throw new Error(`Invalid sha from git ls-remote: ${sha}`);
|
|
42
|
-
|
|
43
|
-
const cacheDir = cacheRoot();
|
|
44
|
-
mkdirSync(cacheDir, { recursive: true });
|
|
45
|
-
targetDir = join(cacheDir, sha);
|
|
46
|
-
|
|
47
|
-
if (!existsSync(targetDir)) {
|
|
48
|
-
const clone = spawnSync('git', ['clone', '--depth=1', sourceUri, targetDir]);
|
|
49
|
-
if (clone.status !== 0) throw new Error(`Failed to clone ${sourceUri}`);
|
|
50
|
-
}
|
|
51
|
-
} else {
|
|
52
|
-
// An absolute source (what `add` records in state.json, and what `update`
|
|
53
|
-
// feeds back) must not be re-rooted at the cwd — path.join does not reset
|
|
54
|
-
// on an absolute second argument.
|
|
55
|
-
targetDir = sourceUri;
|
|
56
|
-
if (!existsSync(targetDir)) throw new Error(`Local source not found: ${targetDir}`);
|
|
57
|
-
const rev = spawnSync('git', ['-C', targetDir, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
|
|
58
|
-
if (rev.status === 0) {
|
|
59
|
-
const match = rev.stdout.trim().match(/^[0-9a-f]{40}$/i);
|
|
60
|
-
if (match) sha = match[0];
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
targetDir = assertSafeSourceTree(targetDir);
|
|
64
|
-
|
|
65
|
-
const plugins: PluginSource[] = [];
|
|
66
|
-
|
|
67
|
-
// 1. Marketplace index
|
|
68
|
-
const mp1 = join(targetDir, '.claude-plugin', 'marketplace.json');
|
|
69
|
-
const mp2 = join(targetDir, '.omp-plugin', 'marketplace.json');
|
|
70
|
-
const mp3 = join(targetDir, 'marketplace.json');
|
|
71
|
-
const mpPath = existsSync(mp1) ? mp1 : existsSync(mp2) ? mp2 : existsSync(mp3) ? mp3 : null;
|
|
72
|
-
|
|
73
|
-
if (mpPath !== null) {
|
|
74
|
-
const data = parseMarketplace(mpPath);
|
|
75
|
-
const marketplaceName = typeof data['name'] === 'string' ? data['name'] : 'local';
|
|
76
|
-
assertSafeIdentity(marketplaceName, 'marketplace name');
|
|
77
|
-
const entries = data['plugins'];
|
|
78
|
-
if (!Array.isArray(entries)) throw new Error(`Malformed marketplace manifest: plugins must be an array (${mpPath})`);
|
|
79
|
-
if (entries.length === 0) throw new Error(`No plugins discovered in marketplace: ${mpPath}`);
|
|
80
|
-
for (const entry of entries) {
|
|
81
|
-
if (!isRecord(entry) || typeof entry['source'] !== 'string' || entry['source'].trim() === '') {
|
|
82
|
-
throw new Error(`Malformed marketplace manifest: every plugin needs a source (${mpPath})`);
|
|
83
|
-
}
|
|
84
|
-
const pDir = resolve(targetDir, entry['source']);
|
|
85
|
-
if (!isInside(targetDir, pDir)) throw new Error(`Marketplace plugin source escapes collection root: ${entry['source']}`);
|
|
86
|
-
if (!existsSync(pDir) || !statSync(pDir).isDirectory()) throw new Error(`Marketplace plugin source is not a directory: ${entry['source']}`);
|
|
87
|
-
plugins.push(pluginFromDir(pDir, marketplaceName));
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (plugins.length > 0) return resolvedSource(sourceUri, sha, isGit, plugins);
|
|
92
|
-
|
|
93
|
-
// 2. Root plugin
|
|
94
|
-
if (isPluginDir(targetDir)) {
|
|
95
|
-
plugins.push(pluginFromDir(targetDir));
|
|
96
|
-
return resolvedSource(sourceUri, sha, isGit, plugins);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// 3. Recursive scan (1 level deep)
|
|
100
|
-
for (const entry of readdirSync(targetDir)) {
|
|
101
|
-
const subDir = join(targetDir, entry);
|
|
102
|
-
if (statSync(subDir).isDirectory() && isPluginDir(subDir)) {
|
|
103
|
-
plugins.push(pluginFromDir(subDir));
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (plugins.length === 0) throw new Error(`No plugins discovered in source: ${sourceUri}`);
|
|
108
|
-
return resolvedSource(sourceUri, sha, isGit, plugins);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function resolvedSource(sourceUri: string, sha: string, isGit: boolean, plugins: PluginSource[]): ResolvedSource {
|
|
112
|
-
const seen = new Set<string>();
|
|
113
|
-
for (const plugin of plugins) {
|
|
114
|
-
const identity = `${plugin.name}@${plugin.marketplace ?? 'local'}`;
|
|
115
|
-
if (seen.has(identity)) throw new Error(`Duplicate plugin identity discovered: ${identity}`);
|
|
116
|
-
seen.add(identity);
|
|
117
|
-
}
|
|
118
|
-
return { sourceUri, sha, isGit, plugins };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Owner/repo is the public GitHub shorthand; all local roots become absolute. */
|
|
122
|
-
export function normalizeSource(source: string): string {
|
|
123
|
-
if (source.startsWith('./') || source.startsWith('../') || isAbsolute(source)) return resolve(source);
|
|
124
|
-
if (/^[^/\s]+\/[^/\s]+$/.test(source)) return `https://github.com/${source}.git`;
|
|
125
|
-
if (isGitUrl(source)) return source;
|
|
126
|
-
return resolve(source);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function withFingerprint(plugin: PluginSource): PluginSource {
|
|
130
|
-
return { ...plugin, contentFingerprint: fingerprintTree(plugin.dir) };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function pluginFromDir(dir: string, marketplace?: string): PluginSource {
|
|
134
|
-
const manifest = readPluginManifest(dir);
|
|
135
|
-
const name = manifest?.name ?? inferredPluginName(dir);
|
|
136
|
-
return withFingerprint({ dir, name, ...(manifest?.version === undefined ? {} : { version: manifest.version }), ...(marketplace === undefined ? {} : { marketplace }) });
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function assertSafeSourceTree(path: string): string {
|
|
140
|
-
assertNoSymlinks(path);
|
|
141
|
-
const canonical = realpath(path);
|
|
142
|
-
assertNoSymlinks(canonical);
|
|
143
|
-
return canonical;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function assertNoSymlinks(path: string): void {
|
|
147
|
-
const links = spawnSync('find', [path, '-type', 'l', '-print'], { encoding: 'utf8' });
|
|
148
|
-
if (links.status !== 0) throw new Error(`Could not inspect source tree: ${path}`);
|
|
149
|
-
if (links.stdout.trim() !== '') throw new Error(`Symlink resources are not supported: ${links.stdout.trim()}`);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function parseMarketplace(path: string): Record<string, unknown> {
|
|
153
|
-
try {
|
|
154
|
-
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
155
|
-
if (!isRecord(parsed)) throw new Error('manifest must be an object');
|
|
156
|
-
return parsed;
|
|
157
|
-
} catch (error) {
|
|
158
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
159
|
-
throw new Error(`Malformed marketplace manifest: ${path} (${detail})`);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function realpath(path: string): string {
|
|
164
|
-
const result = spawnSync('realpath', [path], { encoding: 'utf8' });
|
|
165
|
-
if (result.status !== 0 || result.stdout.trim() === '') throw new Error(`Could not resolve source path: ${path}`);
|
|
166
|
-
return result.stdout.trim();
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function isInside(root: string, candidate: string): boolean {
|
|
170
|
-
return candidate === root || candidate.startsWith(`${root}/`);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
174
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function isPluginDir(dir: string): boolean {
|
|
178
|
-
return manifestPaths(dir).some(existsSync) || existsSync(join(dir, '.mcp.json')) || existsSync(join(dir, 'mcp.json'));
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Canonical manifests take precedence. `.claude-plugin/plugin.json` is a
|
|
183
|
-
* supported source-only fallback; it is never rewritten into a source tree.
|
|
184
|
-
*/
|
|
185
|
-
export function readPluginManifest(dir: string): PluginManifest | undefined {
|
|
186
|
-
let selected: PluginManifest | undefined;
|
|
187
|
-
for (const path of manifestPaths(dir)) {
|
|
188
|
-
if (!existsSync(path)) continue;
|
|
189
|
-
const current = parsePluginManifest(path);
|
|
190
|
-
if (selected === undefined) {
|
|
191
|
-
selected = current;
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
if (current.name !== selected.name || (current.version !== undefined && selected.version !== undefined && current.version !== selected.version)) {
|
|
195
|
-
throw new Error(`Conflicting plugin manifest identity: ${path}`);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
return selected;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function manifestPaths(dir: string): string[] {
|
|
202
|
-
return [join(dir, 'plugin.json'), join(dir, '.plugin', 'plugin.json'), join(dir, '.claude-plugin', 'plugin.json')];
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function parsePluginManifest(path: string): PluginManifest {
|
|
206
|
-
let data: unknown;
|
|
207
|
-
try { data = JSON.parse(readFileSync(path, 'utf8')); }
|
|
208
|
-
catch (error) { throw new Error(`Malformed plugin manifest: ${path} (${(error as Error).message})`); }
|
|
209
|
-
if (!isRecord(data) || typeof data['name'] !== 'string') throw new Error(`Plugin manifest needs a name: ${path}`);
|
|
210
|
-
assertSafeIdentity(data['name'], 'plugin name');
|
|
211
|
-
if (data['version'] !== undefined && (typeof data['version'] !== 'string' || data['version'].trim() === '')) throw new Error(`Plugin manifest version is invalid: ${path}`);
|
|
212
|
-
if (data['description'] !== undefined && typeof data['description'] !== 'string') throw new Error(`Plugin manifest description is invalid: ${path}`);
|
|
213
|
-
return { name: data['name'], ...(typeof data['version'] === 'string' ? { version: data['version'] } : {}), ...(typeof data['description'] === 'string' ? { description: data['description'] } : {}) };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function inferredPluginName(dir: string): string {
|
|
217
|
-
const inferred = basename(dir);
|
|
218
|
-
assertSafeIdentity(inferred, 'plugin name');
|
|
219
|
-
return inferred;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function assertSafeIdentity(value: string, label: string): void {
|
|
223
|
-
if (!/^[a-z0-9][a-z0-9._-]*$/iu.test(value)) throw new Error(`Unsafe ${label}: ${value}`);
|
|
224
|
-
}
|
package/src/state-write.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The write side of the install ledger (src/state.ts) — `add`, `pin` and
|
|
3
|
-
* `update` write through this; doctor imports only the readers, and a module
|
|
4
|
-
* is evaluated whole, so keeping `writeState` out of src/state.ts keeps
|
|
5
|
-
* writer code out of doctor's import graph (AGENTS.md;
|
|
6
|
-
* test/doctor-imports.test.ts pins it).
|
|
7
|
-
*/
|
|
8
|
-
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
-
import * as fs from 'node:fs';
|
|
10
|
-
import { basename, dirname, join } from 'node:path';
|
|
11
|
-
import { stateFile } from './paths';
|
|
12
|
-
import type { InstallRecord } from './state';
|
|
13
|
-
|
|
14
|
-
export function writeState(records: InstallRecord[], file: string = stateFile()): void {
|
|
15
|
-
const parent = dirname(file);
|
|
16
|
-
mkdirSync(parent, { recursive: true });
|
|
17
|
-
const temporary = join(parent, `.${basename(file)}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
|
|
18
|
-
try {
|
|
19
|
-
writeFileSync(temporary, JSON.stringify({ version: 1, installs: records }, null, 2));
|
|
20
|
-
(fs as unknown as { renameSync(from: string, to: string): void }).renameSync(temporary, file);
|
|
21
|
-
} catch (error) {
|
|
22
|
-
rmSync(temporary, { force: true });
|
|
23
|
-
throw error;
|
|
24
|
-
}
|
|
25
|
-
}
|
package/src/state.ts
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* open-plugin's own install ledger — `$OPEN_PLUGIN_HOME/state.json`
|
|
3
|
-
* (see src/paths.ts `stateFile()`). Readers only: doctor imports this module,
|
|
4
|
-
* so the writer (`writeState`) lives in src/state-write.ts — a module is
|
|
5
|
-
* evaluated whole, and doctor must not load writer code (AGENTS.md).
|
|
6
|
-
*
|
|
7
|
-
* Schema (version 1):
|
|
8
|
-
* {
|
|
9
|
-
* "version": 1,
|
|
10
|
-
* "installs": [
|
|
11
|
-
* {
|
|
12
|
-
* "host": "claude-code",
|
|
13
|
-
* "id": "omakase@oh-my-ai-sdk", // same id as listInstalled()
|
|
14
|
-
* "source": "/path/to/git/checkout", // local git source whose HEAD is the freshness yardstick
|
|
15
|
-
* "sourceSha": "4f6f7f0a414e…", // source HEAD at install time
|
|
16
|
-
* "installedAt": "2026-09-16T14:08:23.259Z",
|
|
17
|
-
* "pins": ["omakase"] // server names `pin` rewrote to an absolute path
|
|
18
|
-
* }
|
|
19
|
-
* ]
|
|
20
|
-
* }
|
|
21
|
-
*
|
|
22
|
-
* `pins` (optional) is what `update` re-applies: re-adding a plugin restores
|
|
23
|
-
* the source's bare `command`, so a recorded pin must be re-run on the fresh
|
|
24
|
-
* copy. Absent means `pin` never rewrote anything for that install.
|
|
25
|
-
*/
|
|
26
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
27
|
-
import { stateFile } from './paths';
|
|
28
|
-
|
|
29
|
-
export interface InstallRecord {
|
|
30
|
-
host: string;
|
|
31
|
-
id: string;
|
|
32
|
-
source: string;
|
|
33
|
-
sourceSha: string;
|
|
34
|
-
installedAt?: string;
|
|
35
|
-
/** Server names `pin` rewrote to an absolute path in this host's copy, sorted. */
|
|
36
|
-
pins?: string[];
|
|
37
|
-
/** Reserved for the content-based refresh increment; legacy records remain valid. */
|
|
38
|
-
fingerprint?: string;
|
|
39
|
-
/** Canonical selected package directory whose bytes produced `fingerprint`. */
|
|
40
|
-
sourceDir?: string;
|
|
41
|
-
/** Raw-byte fingerprint of the activated host-native representation. */
|
|
42
|
-
installedFingerprint?: string;
|
|
43
|
-
/** Reserved for explicit owned-artifact cleanup; legacy records remain valid. */
|
|
44
|
-
ownership?: string;
|
|
45
|
-
/** Durable intent written before a host mutation and cleared only after it completes. */
|
|
46
|
-
pending?: 'install' | 'remove';
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const RECORD_FIELDS = new Set(['host', 'id', 'source', 'sourceSha', 'installedAt', 'pins', 'fingerprint', 'sourceDir', 'installedFingerprint', 'ownership', 'pending']);
|
|
50
|
-
|
|
51
|
-
function asRecord(v: unknown): Record<string, unknown> | null {
|
|
52
|
-
return typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function coerceInstall(v: unknown): InstallRecord {
|
|
56
|
-
const rec = asRecord(v);
|
|
57
|
-
if (!rec) throw new Error('Invalid state.json: install record must be an object');
|
|
58
|
-
const unknown = Object.keys(rec).find((key) => !RECORD_FIELDS.has(key));
|
|
59
|
-
if (unknown !== undefined) throw new Error(`Invalid state.json: unsupported install field '${unknown}'`);
|
|
60
|
-
const host = typeof rec['host'] === 'string' ? rec['host'] : null;
|
|
61
|
-
const id = typeof rec['id'] === 'string' ? rec['id'] : null;
|
|
62
|
-
const source = typeof rec['source'] === 'string' ? rec['source'] : null;
|
|
63
|
-
const sourceSha = typeof rec['sourceSha'] === 'string' ? rec['sourceSha'] : null;
|
|
64
|
-
if (host === null || id === null || source === null || sourceSha === null) {
|
|
65
|
-
throw new Error('Invalid state.json: install record is missing required fields');
|
|
66
|
-
}
|
|
67
|
-
if (rec['installedAt'] !== undefined && typeof rec['installedAt'] !== 'string') throw new Error('Invalid state.json: installedAt must be a string');
|
|
68
|
-
if (rec['pins'] !== undefined && (!Array.isArray(rec['pins']) || rec['pins'].some((pin) => typeof pin !== 'string' || pin.length === 0))) {
|
|
69
|
-
throw new Error('Invalid state.json: pins must be non-empty strings');
|
|
70
|
-
}
|
|
71
|
-
if (rec['fingerprint'] !== undefined && typeof rec['fingerprint'] !== 'string') throw new Error('Invalid state.json: fingerprint must be a string');
|
|
72
|
-
if (rec['sourceDir'] !== undefined && typeof rec['sourceDir'] !== 'string') throw new Error('Invalid state.json: sourceDir must be a string');
|
|
73
|
-
if (rec['installedFingerprint'] !== undefined && typeof rec['installedFingerprint'] !== 'string') throw new Error('Invalid state.json: installedFingerprint must be a string');
|
|
74
|
-
if (rec['ownership'] !== undefined && typeof rec['ownership'] !== 'string') throw new Error('Invalid state.json: ownership must be a string');
|
|
75
|
-
if (rec['pending'] !== undefined && rec['pending'] !== 'install' && rec['pending'] !== 'remove') throw new Error('Invalid state.json: pending must be install or remove');
|
|
76
|
-
const installedAt = rec['installedAt'] as string | undefined;
|
|
77
|
-
const rawPins = rec['pins'];
|
|
78
|
-
const pins = rawPins as string[] | undefined;
|
|
79
|
-
const record: InstallRecord = { host, id, source, sourceSha };
|
|
80
|
-
if (installedAt !== undefined) record.installedAt = installedAt;
|
|
81
|
-
if (pins !== undefined && pins.length > 0) record.pins = pins;
|
|
82
|
-
if (typeof rec['fingerprint'] === 'string') record.fingerprint = rec['fingerprint'];
|
|
83
|
-
if (typeof rec['sourceDir'] === 'string') record.sourceDir = rec['sourceDir'];
|
|
84
|
-
if (typeof rec['installedFingerprint'] === 'string') record.installedFingerprint = rec['installedFingerprint'];
|
|
85
|
-
if (typeof rec['ownership'] === 'string') record.ownership = rec['ownership'];
|
|
86
|
-
if (rec['pending'] === 'install' || rec['pending'] === 'remove') record.pending = rec['pending'];
|
|
87
|
-
return record;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Read the ledger; a missing file is empty, while corrupt state is surfaced. */
|
|
91
|
-
export function readState(file: string = stateFile()): InstallRecord[] {
|
|
92
|
-
if (!existsSync(file)) return [];
|
|
93
|
-
let text: string;
|
|
94
|
-
try {
|
|
95
|
-
text = readFileSync(file, 'utf8');
|
|
96
|
-
} catch (error) {
|
|
97
|
-
throw new Error(`Unable to read state.json: ${(error as Error).message}`);
|
|
98
|
-
}
|
|
99
|
-
let parsed: unknown;
|
|
100
|
-
try {
|
|
101
|
-
parsed = JSON.parse(text);
|
|
102
|
-
} catch (error) {
|
|
103
|
-
throw new Error(`Invalid state.json: ${(error as Error).message}`);
|
|
104
|
-
}
|
|
105
|
-
const root = asRecord(parsed);
|
|
106
|
-
if (root === null) throw new Error('Invalid state.json: root must be an object');
|
|
107
|
-
const unknownRoot = Object.keys(root).find((key) => key !== 'version' && key !== 'installs');
|
|
108
|
-
if (unknownRoot !== undefined) throw new Error(`Invalid state.json: unsupported root field '${unknownRoot}'`);
|
|
109
|
-
if (root['version'] !== 1) throw new Error(`Unsupported state.json version: ${String(root['version'])}`);
|
|
110
|
-
const installs = root['installs'];
|
|
111
|
-
if (!Array.isArray(installs)) throw new Error('Invalid state.json: installs must be an array');
|
|
112
|
-
return installs.map(coerceInstall);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Find the ledger row for one install on one host. */
|
|
116
|
-
export function findRecord(records: InstallRecord[], host: string, id: string): InstallRecord | undefined {
|
|
117
|
-
return records.find((r) => r.host === host && r.id === id);
|
|
118
|
-
}
|
package/src/update.ts
DELETED
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `plgnz update [name]` — bring installed plugins up to their source.
|
|
3
|
-
*
|
|
4
|
-
* The ledger (`state.json`) is the whole authority here. `update` walks the
|
|
5
|
-
* records *this tool wrote* — never the host stores — and for each one:
|
|
6
|
-
*
|
|
7
|
-
* 1. re-resolves the recorded source (a git source is re-read at its head),
|
|
8
|
-
* 2. re-runs the host's `add` against that source, which is idempotent:
|
|
9
|
-
* version-addressed stores (claude-code, codex, omp) land in a fresh
|
|
10
|
-
* slot, and the copy-based stores (kimi, cursor) replace the copy —
|
|
11
|
-
* re-materializing it rather than keeping the first one,
|
|
12
|
-
* 3. re-applies the pins `pin` recorded, because the fresh copy carries the
|
|
13
|
-
* source's bare `command` again.
|
|
14
|
-
*
|
|
15
|
-
* A plugin with no record — one another tool installed — is reported, never
|
|
16
|
-
* modified: plgnz has no basis for saying what "up to date" means for it,
|
|
17
|
-
* and a re-add would silently take ownership of someone else's install.
|
|
18
|
-
*/
|
|
19
|
-
import type { HostWriter, InstalledPlugin } from './host';
|
|
20
|
-
import type { Mark } from './doctor';
|
|
21
|
-
import { writers as allWriters } from './hosts/writers';
|
|
22
|
-
import { resolveSource, type PluginSource } from './source';
|
|
23
|
-
import { readState, type InstallRecord } from './state';
|
|
24
|
-
import { writeState } from './state-write';
|
|
25
|
-
import { fingerprintInstallation } from './fingerprint';
|
|
26
|
-
import { CompatibilityError } from './compatibility';
|
|
27
|
-
|
|
28
|
-
export interface UpdateFinding {
|
|
29
|
-
host: string;
|
|
30
|
-
mark: Mark;
|
|
31
|
-
/** A typed adapter refusal keeps its public InstallOutcome status. */
|
|
32
|
-
status?: 'unsupported' | 'unverified';
|
|
33
|
-
message: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface UpdateResult {
|
|
37
|
-
findings: UpdateFinding[];
|
|
38
|
-
exitCode: number;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface UpdateOptions {
|
|
42
|
-
dryRun?: boolean;
|
|
43
|
-
/** Ledger to read/update; defaults to the real `state.json`. */
|
|
44
|
-
state?: InstallRecord[];
|
|
45
|
-
/** Hosts to consider; defaults to the registry. */
|
|
46
|
-
writers?: readonly HostWriter[];
|
|
47
|
-
/** Test seam for ledger persistence failure regressions. */
|
|
48
|
-
writeState?: (records: InstallRecord[]) => void;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** The plugin name part of a host-native id (`name@marketplace` or bare name). */
|
|
52
|
-
function idName(id: string): string {
|
|
53
|
-
const at = id.indexOf('@');
|
|
54
|
-
return at === -1 ? id : id.slice(0, at);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function idOf(plugin: PluginSource): string {
|
|
58
|
-
return plugin.marketplace === undefined ? plugin.name : `${plugin.name}@${plugin.marketplace}`;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function shortSha(sha: string): string {
|
|
62
|
-
return sha.length > 8 ? sha.slice(0, 8) : sha;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export async function runUpdate(name?: string, options: UpdateOptions = {}): Promise<UpdateResult> {
|
|
66
|
-
const hosts = options.writers ?? allWriters;
|
|
67
|
-
let state = options.state ?? readState();
|
|
68
|
-
const save = options.writeState ?? writeState;
|
|
69
|
-
const prefix = options.dryRun === true ? '[dry-run] ' : '';
|
|
70
|
-
const findings: UpdateFinding[] = [];
|
|
71
|
-
|
|
72
|
-
const selectedHosts = new Set(hosts.map((host) => host.id));
|
|
73
|
-
const records = state.filter((record) => selectedHosts.has(record.host)).filter((record) => name === undefined || record.id === name || idName(record.id) === name);
|
|
74
|
-
if (name !== undefined && records.length === 0) {
|
|
75
|
-
findings.push({
|
|
76
|
-
host: 'plgnz',
|
|
77
|
-
mark: '✗',
|
|
78
|
-
message: `no install record for '${name}' in state.json — not installed by plgnz; refusing to modify it`,
|
|
79
|
-
});
|
|
80
|
-
return { findings, exitCode: 1 };
|
|
81
|
-
}
|
|
82
|
-
if (records.length === 0) {
|
|
83
|
-
findings.push({
|
|
84
|
-
host: 'plgnz',
|
|
85
|
-
mark: '!',
|
|
86
|
-
message: 'nothing to update — state.json has no install records',
|
|
87
|
-
});
|
|
88
|
-
return { findings, exitCode: 0 };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
for (const initialRecord of records) {
|
|
92
|
-
const found = state.find((candidate) => candidate.host === initialRecord.host && candidate.id === initialRecord.id);
|
|
93
|
-
if (found === undefined) continue;
|
|
94
|
-
let record: InstallRecord = found;
|
|
95
|
-
const host = hosts.find((w) => w.id === record.host);
|
|
96
|
-
if (host === undefined) {
|
|
97
|
-
findings.push({
|
|
98
|
-
host: record.host,
|
|
99
|
-
mark: '✗',
|
|
100
|
-
message: `unknown host in state.json for '${record.id}' — no host module owns it`,
|
|
101
|
-
});
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
if (!host.detect()) {
|
|
105
|
-
findings.push({
|
|
106
|
-
host: host.id,
|
|
107
|
-
mark: '!',
|
|
108
|
-
message: `host not present on this machine — '${record.id}' skipped`,
|
|
109
|
-
});
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
if (record.pending === 'remove') {
|
|
113
|
-
findings.push({ host: host.id, mark: '✗', message: `removal of '${record.id}' is pending — retry remove before update` });
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
let resolved;
|
|
118
|
-
try {
|
|
119
|
-
resolved = resolveSource(record.source);
|
|
120
|
-
} catch (e) {
|
|
121
|
-
findings.push({
|
|
122
|
-
host: host.id,
|
|
123
|
-
mark: '✗',
|
|
124
|
-
message: `cannot resolve source of '${record.id}': ${record.source} — ${(e as Error).message}`,
|
|
125
|
-
});
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const plugin = resolved.plugins.find((p) => idOf(p) === record.id) ?? resolved.plugins.find((p) => p.name === idName(record.id));
|
|
130
|
-
if (plugin === undefined) {
|
|
131
|
-
findings.push({
|
|
132
|
-
host: host.id,
|
|
133
|
-
mark: '✗',
|
|
134
|
-
message: `source ${record.source} no longer provides '${record.id}' — re-add it by hand`,
|
|
135
|
-
});
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
try {
|
|
140
|
-
if (options.dryRun !== true) {
|
|
141
|
-
const pending = { ...record, pending: 'install' as const };
|
|
142
|
-
const next = state.map((candidate) => candidate === record ? pending : candidate);
|
|
143
|
-
save(next);
|
|
144
|
-
state = next;
|
|
145
|
-
record = pending;
|
|
146
|
-
}
|
|
147
|
-
await host.add(plugin, resolved, { dryRun: options.dryRun });
|
|
148
|
-
} catch (e) {
|
|
149
|
-
findings.push(e instanceof CompatibilityError
|
|
150
|
-
? { host: host.id, mark: e.status === 'unverified' ? '!' : '✗', status: e.status, message: `re-add of '${record.id}' refused — ${e.message}` }
|
|
151
|
-
: { host: host.id, mark: '✗', message: `re-add of '${record.id}' failed — ${(e as Error).message}` });
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
let pinsOk: boolean;
|
|
155
|
-
try {
|
|
156
|
-
pinsOk = await repin(host, record, plugin, options, findings, prefix);
|
|
157
|
-
} catch (error) {
|
|
158
|
-
findings.push({ host: host.id, mark: '✗', message: `updated '${record.id}' but pin finalization failed — ${(error as Error).message}` });
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
if (!pinsOk) continue;
|
|
162
|
-
|
|
163
|
-
if (options.dryRun === true) {
|
|
164
|
-
findings.push({ host: host.id, mark: '✓', message: `${prefix}updated '${record.id}' from ${record.source} → ${shortSha(resolved.sha)}` });
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
try {
|
|
168
|
-
const installed = host.listInstalled().find((candidate) => candidate.id === record.id);
|
|
169
|
-
if (installed === undefined || installed.enabled === false || installed.path === undefined) throw new Error('updated native representation is not enabled or has no readable path');
|
|
170
|
-
const finalized: InstallRecord = {
|
|
171
|
-
...record,
|
|
172
|
-
sourceSha: resolved.sha,
|
|
173
|
-
installedAt: new Date().toISOString(),
|
|
174
|
-
ownership: record.ownership ?? 'plgnz',
|
|
175
|
-
sourceDir: plugin.dir,
|
|
176
|
-
installedFingerprint: fingerprintInstallation(installed),
|
|
177
|
-
...(plugin.contentFingerprint !== undefined ? { fingerprint: plugin.contentFingerprint } : {}),
|
|
178
|
-
};
|
|
179
|
-
delete finalized.pending;
|
|
180
|
-
const next = state.map((candidate) => candidate === record ? finalized : candidate);
|
|
181
|
-
save(next);
|
|
182
|
-
state = next;
|
|
183
|
-
findings.push({ host: host.id, mark: '✓', message: `updated '${record.id}' from ${record.source} → ${shortSha(resolved.sha)}` });
|
|
184
|
-
} catch (error) {
|
|
185
|
-
findings.push({ host: host.id, mark: '✗', message: `updated '${record.id}' but could not finalize its ledger record — ${(error as Error).message}` });
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return { findings, exitCode: findings.some((f) => f.mark === '✗' || f.status === 'unverified') ? 1 : 0 };
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** Re-apply the pins recorded for this install onto the freshly added copy. */
|
|
193
|
-
async function repin(
|
|
194
|
-
host: HostWriter,
|
|
195
|
-
record: InstallRecord,
|
|
196
|
-
plugin: PluginSource,
|
|
197
|
-
options: UpdateOptions,
|
|
198
|
-
findings: UpdateFinding[],
|
|
199
|
-
prefix: string,
|
|
200
|
-
): Promise<boolean> {
|
|
201
|
-
const pins = record.pins ?? [];
|
|
202
|
-
if (pins.length === 0) return true;
|
|
203
|
-
const installed: InstalledPlugin | undefined = host.listInstalled().find((p) => p.id === record.id);
|
|
204
|
-
if (installed === undefined) {
|
|
205
|
-
findings.push({
|
|
206
|
-
host: host.id,
|
|
207
|
-
mark: '!',
|
|
208
|
-
message: `cannot re-apply pins for '${record.id}' — not found in the ${host.id} store after the update`,
|
|
209
|
-
});
|
|
210
|
-
return false;
|
|
211
|
-
}
|
|
212
|
-
const outcome = await host.pin(installed, { only: pins, dryRun: options.dryRun });
|
|
213
|
-
for (const change of outcome.changes) {
|
|
214
|
-
findings.push({
|
|
215
|
-
host: host.id,
|
|
216
|
-
mark: '✓',
|
|
217
|
-
message: `${prefix}re-pinned '${change.server}': '${change.from}' → ${change.to} (plugin ${plugin.name})`,
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
for (const refusal of outcome.refusals) {
|
|
221
|
-
findings.push({
|
|
222
|
-
host: host.id,
|
|
223
|
-
mark: '✗',
|
|
224
|
-
message:
|
|
225
|
-
`${prefix}failed to re-apply the pin for '${refusal.server}': bare command '${refusal.command}' ` +
|
|
226
|
-
`not found on PATH (plugin ${plugin.name})`,
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
return outcome.refusals.length === 0;
|
|
230
|
-
}
|