plugin-updater 1.0.37 → 1.0.39

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.
@@ -0,0 +1,8 @@
1
+ import type { Plugin } from "./types.js";
2
+ export declare function readOpencodeJson(configDir: string): {
3
+ plugins: string[];
4
+ raw: Record<string, unknown>;
5
+ };
6
+ export declare function writeOpencodeJson(configDir: string, data: Record<string, unknown>): void;
7
+ export declare function getPluginsPath(configDir: string): string;
8
+ export declare function getPlugins(configDir: string): Plugin[];
package/dist/config.js ADDED
@@ -0,0 +1,48 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { isOpencodeHookInvocation } from "./env.js";
4
+ import { writeLog } from "./log.js";
5
+ export function readOpencodeJson(configDir) {
6
+ const ocPath = path.join(configDir, "opencode.json");
7
+ if (!fs.existsSync(ocPath))
8
+ return { plugins: [], raw: {} };
9
+ try {
10
+ const stripped = fs.readFileSync(ocPath, "utf8").replace(/^\s*\/\/[^\n]*/gm, "");
11
+ const parsed = JSON.parse(stripped);
12
+ const plugins = (parsed.plugin || []);
13
+ return { plugins: plugins.filter((p) => typeof p === "string"), raw: parsed };
14
+ }
15
+ catch {
16
+ return { plugins: [], raw: {} };
17
+ }
18
+ }
19
+ export function writeOpencodeJson(configDir, data) {
20
+ const ocPath = path.join(configDir, "opencode.json");
21
+ fs.writeFileSync(ocPath, JSON.stringify(data, null, 2), "utf8");
22
+ }
23
+ export function getPluginsPath(configDir) {
24
+ if (isOpencodeHookInvocation(configDir))
25
+ return "";
26
+ const preferred = path.join(configDir, "config", "plugins.json");
27
+ const fallback = path.join(configDir, "plugins.json");
28
+ if (fs.existsSync(preferred))
29
+ return preferred;
30
+ if (fs.existsSync(fallback))
31
+ return fallback;
32
+ return preferred;
33
+ }
34
+ // single source of truth for the git-plugin list; consumers (loaders, TUI)
35
+ // must read through this rather than touching plugins.json directly
36
+ export function getPlugins(configDir) {
37
+ if (isOpencodeHookInvocation(configDir))
38
+ return [];
39
+ const file = getPluginsPath(configDir);
40
+ try {
41
+ if (fs.existsSync(file))
42
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
43
+ }
44
+ catch (e) {
45
+ writeLog(`Failed to parse ${file}: ${e.message}`, true);
46
+ }
47
+ return [];
48
+ }
@@ -0,0 +1 @@
1
+ export declare function startDeclaredDaemon(sourceDir: string, configDir: string, pluginName: string): Promise<void>;
package/dist/daemon.js ADDED
@@ -0,0 +1,55 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { getAppName } from "./env.js";
4
+ import { writeLog } from "./log.js";
5
+ async function isDaemonHealthy(url) {
6
+ try {
7
+ const controller = new AbortController();
8
+ const timer = setTimeout(() => controller.abort(), 1500);
9
+ const res = await fetch(url, { signal: controller.signal });
10
+ clearTimeout(timer);
11
+ return res.ok;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ // idempotent: health-check the declared endpoint, spawn detached only if down.
18
+ // the daemon outlives this process so the proxy persists across the session.
19
+ export async function startDeclaredDaemon(sourceDir, configDir, pluginName) {
20
+ try {
21
+ const pkg = JSON.parse(fs.readFileSync(path.join(sourceDir, "package.json"), "utf8"));
22
+ const daemon = pkg.claudeHub?.daemon;
23
+ if (!daemon?.script)
24
+ return;
25
+ const healthUrl = daemon.healthCheckUrl
26
+ || (daemon.port ? `http://127.0.0.1:${daemon.port}/health` : "");
27
+ if (healthUrl && (await isDaemonHealthy(healthUrl))) {
28
+ writeLog(`Daemon for ${pluginName} already running`);
29
+ return;
30
+ }
31
+ const scriptPath = path.join(sourceDir, daemon.script);
32
+ if (!fs.existsSync(scriptPath)) {
33
+ writeLog(`Daemon script missing for ${pluginName}: ${scriptPath}`, true);
34
+ return;
35
+ }
36
+ const runtime = daemon.runtime || "node";
37
+ const { spawn } = await import("child_process");
38
+ const child = spawn(runtime, [scriptPath], {
39
+ cwd: sourceDir,
40
+ detached: true,
41
+ stdio: "ignore",
42
+ env: {
43
+ ...process.env,
44
+ HUB_CONFIG_DIR: configDir,
45
+ HUB_APP_NAME: getAppName() === "claude" ? "Claude Code" : "OpenCode",
46
+ ...(daemon.port ? { HUB_PROXY_PORT: String(daemon.port) } : {}),
47
+ },
48
+ });
49
+ child.unref();
50
+ writeLog(`Started daemon for ${pluginName} (${runtime} ${daemon.script})`);
51
+ }
52
+ catch (e) {
53
+ writeLog(`Daemon start failed for ${pluginName}: ${e.message}`, true);
54
+ }
55
+ }
@@ -0,0 +1 @@
1
+ export declare function deployToExecutionDir(pluginName: string, executionPath: string, changed: boolean, configDir: string): Promise<boolean>;
package/dist/deploy.js ADDED
@@ -0,0 +1,135 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { execSync } from "child_process";
4
+ import { getAppName, getReposDir } from "./env.js";
5
+ import { writeLog } from "./log.js";
6
+ import { buildInTempDir } from "./git.js";
7
+ import { startDeclaredDaemon } from "./daemon.js";
8
+ async function callPluginCleanup(pluginExecutionFile, configDir) {
9
+ if (!fs.existsSync(pluginExecutionFile))
10
+ return;
11
+ try {
12
+ const mod = await import(pluginExecutionFile);
13
+ if (typeof mod.cleanup === "function") {
14
+ writeLog(`Calling cleanup() on ${pluginExecutionFile}`);
15
+ await mod.cleanup(configDir);
16
+ writeLog(`cleanup() complete for ${pluginExecutionFile}`);
17
+ }
18
+ }
19
+ catch (e) {
20
+ writeLog(`cleanup() call failed for ${pluginExecutionFile}: ${e.message}`, true);
21
+ }
22
+ }
23
+ // under claude, deployed plugins declare env/daemon in package.json#claudeHub;
24
+ // merge the env into settings.json so providers work without a login
25
+ function applyClaudeManifest(sourceDir, configDir, pluginName) {
26
+ if (getAppName() !== "claude")
27
+ return;
28
+ try {
29
+ const pkg = JSON.parse(fs.readFileSync(path.join(sourceDir, "package.json"), "utf8"));
30
+ const manifest = pkg.claudeHub;
31
+ if (!manifest?.env || typeof manifest.env !== "object")
32
+ return;
33
+ const settingsPath = path.join(configDir, "settings.json");
34
+ let settings = {};
35
+ try {
36
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
37
+ }
38
+ catch { /* fresh file */ }
39
+ const env = (settings.env ?? {});
40
+ for (const [key, value] of Object.entries(manifest.env)) {
41
+ env[key] = String(value);
42
+ writeLog(`settings.json env ${key} set by ${pluginName}`);
43
+ }
44
+ settings.env = env;
45
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8");
46
+ }
47
+ catch (e) {
48
+ writeLog(`claudeHub manifest handling failed for ${pluginName}: ${e.message}`, true);
49
+ }
50
+ }
51
+ export async function deployToExecutionDir(pluginName, executionPath, changed, configDir) {
52
+ const sourceDir = path.join(getReposDir(), pluginName);
53
+ if (!fs.existsSync(sourceDir))
54
+ return false;
55
+ const packageJsonPath = path.join(sourceDir, "package.json");
56
+ let entryFile = "index.js";
57
+ const pluginExecutionFile = path.join(executionPath, `${pluginName}.js`);
58
+ if (!changed && fs.existsSync(pluginExecutionFile)) {
59
+ writeLog(`Skipping install/build for ${pluginName} (no changes and deployed file exists)`);
60
+ }
61
+ else if (fs.existsSync(packageJsonPath)) {
62
+ try {
63
+ buildInTempDir(pluginName, sourceDir);
64
+ const runtimeDeps = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")).dependencies;
65
+ if (runtimeDeps && Object.keys(runtimeDeps).length > 0) {
66
+ writeLog(`Installing runtime dependencies for ${pluginName}`);
67
+ execSync("npm install --omit=dev", { cwd: sourceDir, stdio: "pipe" });
68
+ writeLog(`Finished runtime dependencies for ${pluginName}`);
69
+ }
70
+ }
71
+ catch (error) {
72
+ const err = error;
73
+ const stderr = err.stderr ? err.stderr.toString().trim() : "";
74
+ const stdout = err.stdout ? err.stdout.toString().trim() : "";
75
+ writeLog(`Build/Install failed for ${pluginName}: ${err.message}`, true);
76
+ if (stderr)
77
+ writeLog(`npm stderr: ${stderr}`, true);
78
+ if (stdout)
79
+ writeLog(`npm stdout: ${stdout}`, true);
80
+ }
81
+ }
82
+ if (fs.existsSync(packageJsonPath)) {
83
+ try {
84
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
85
+ if (pkg.main)
86
+ entryFile = pkg.main;
87
+ }
88
+ catch { /* ignore */ }
89
+ }
90
+ const distPath = path.join(sourceDir, "dist");
91
+ let deploySource = path.join(sourceDir, entryFile);
92
+ if (fs.existsSync(path.join(distPath, entryFile))) {
93
+ deploySource = path.join(distPath, entryFile);
94
+ }
95
+ else if (fs.existsSync(path.join(distPath, "index.js"))) {
96
+ deploySource = path.join(distPath, "index.js");
97
+ }
98
+ if (!fs.existsSync(executionPath))
99
+ fs.mkdirSync(executionPath, { recursive: true });
100
+ await callPluginCleanup(pluginExecutionFile, configDir);
101
+ try {
102
+ writeLog(`Running copy for ${pluginName}`);
103
+ fs.copyFileSync(deploySource, pluginExecutionFile);
104
+ writeLog(`Finished copy for ${pluginName}`);
105
+ }
106
+ catch (e) {
107
+ writeLog(`Copy failed for ${pluginName}: ${e.message}`, true);
108
+ }
109
+ applyClaudeManifest(sourceDir, configDir, pluginName);
110
+ await startDeclaredDaemon(sourceDir, configDir, pluginName);
111
+ // Claude Code never imports deployed plugin files, so under claude the
112
+ // updater is the runtime and invokes the plugin's activate() itself
113
+ if (getAppName() === "claude") {
114
+ try {
115
+ const deployed = await import(pluginExecutionFile);
116
+ if (typeof deployed.activate === "function") {
117
+ writeLog(`Activating ${pluginName}`);
118
+ // tells the plugin the updater is the caller, so it must not start
119
+ // another earlyLaunch and recurse back into the updater
120
+ process.env.PLUGIN_UPDATER_ACTIVATION = "1";
121
+ try {
122
+ await deployed.activate();
123
+ }
124
+ finally {
125
+ delete process.env.PLUGIN_UPDATER_ACTIVATION;
126
+ }
127
+ writeLog(`Activated ${pluginName}`);
128
+ }
129
+ }
130
+ catch (e) {
131
+ writeLog(`Activation failed for ${pluginName}: ${e.message}`, true);
132
+ }
133
+ }
134
+ return true;
135
+ }
package/dist/env.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export declare function setEarlyLaunchConfigDir(dir: string): void;
2
+ export declare function getAppName(): string;
3
+ export declare function getAppConfigDir(appName: string): string;
4
+ export declare function getReposDir(): string;
5
+ export declare function isOpencodeHookInvocation(firstArgument: unknown): boolean;
package/dist/env.js ADDED
@@ -0,0 +1,32 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ // set by earlyLaunch/direct-update so all path resolution targets that dir
5
+ let earlyLaunchConfigDir = null;
6
+ export function setEarlyLaunchConfigDir(dir) {
7
+ earlyLaunchConfigDir = dir;
8
+ }
9
+ // the CLI runs without "claude" in argv, so it forces the app via env
10
+ export function getAppName() {
11
+ const override = process.env.PLUGIN_UPDATER_APP;
12
+ if (override === "claude" || override === "opencode")
13
+ return override;
14
+ return process.argv.join(" ").includes("claude") ? "claude" : "opencode";
15
+ }
16
+ export function getAppConfigDir(appName) {
17
+ if (earlyLaunchConfigDir)
18
+ return earlyLaunchConfigDir;
19
+ const home = os.homedir();
20
+ const directPath = path.join(home, `.${appName}`);
21
+ const configPath = path.join(home, ".config", appName);
22
+ return fs.existsSync(directPath) ? directPath : configPath;
23
+ }
24
+ export function getReposDir() {
25
+ return path.join(getAppConfigDir(getAppName()), "repos");
26
+ }
27
+ // opencode invokes every exported function as a plugin hook, passing a context
28
+ // object instead of our protocol arguments; exports detect that and return an
29
+ // inert value so opencode gets a valid (empty) plugin instance
30
+ export function isOpencodeHookInvocation(firstArgument) {
31
+ return typeof firstArgument !== "string";
32
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export declare function executeGit(command: string, cwd: string): boolean;
2
+ export declare function updatePlugin(pluginName: string, gitUrl: string, branch: string | undefined, commitHash: string | null, updateInterval?: number): {
3
+ success: boolean;
4
+ changed: boolean;
5
+ };
6
+ export declare function buildInTempDir(pluginName: string, sourceDir: string): void;
package/dist/git.js ADDED
@@ -0,0 +1,128 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import { execSync } from "child_process";
5
+ import { getReposDir } from "./env.js";
6
+ import { writeLog } from "./log.js";
7
+ const BUILD_OUTPUT_DIRS = ["dist", path.join("core", "dist")];
8
+ export function executeGit(command, cwd) {
9
+ writeLog(`Executing git: ${command} in ${cwd}`);
10
+ try {
11
+ execSync(command, {
12
+ cwd,
13
+ stdio: "pipe",
14
+ env: { ...process.env, GCM_INTERACTIVE: "never", GIT_TERMINAL_PROMPT: "0" },
15
+ });
16
+ return true;
17
+ }
18
+ catch (error) {
19
+ const err = error;
20
+ const stderr = err.stderr ? err.stderr.toString().trim() : "";
21
+ writeLog(`Git error in ${cwd}: ${err.message} | stderr: ${stderr}`, true);
22
+ return false;
23
+ }
24
+ }
25
+ export function updatePlugin(pluginName, gitUrl, branch, commitHash, updateInterval = 1) {
26
+ const reposDir = getReposDir();
27
+ const targetDir = path.join(reposDir, pluginName);
28
+ const lastCheckFile = path.join(targetDir, ".lastcheck");
29
+ let didChange = false;
30
+ if (!fs.existsSync(targetDir)) {
31
+ if (!fs.existsSync(reposDir))
32
+ fs.mkdirSync(reposDir, { recursive: true });
33
+ const branchFlag = branch ? `--branch ${branch}` : "";
34
+ const cloned = executeGit(`git clone --recurse-submodules ${branchFlag} ${gitUrl} ${pluginName}`, reposDir);
35
+ if (!cloned)
36
+ return { success: false, changed: false };
37
+ fs.writeFileSync(lastCheckFile, Date.now().toString());
38
+ didChange = true;
39
+ }
40
+ else {
41
+ const lastCheck = fs.existsSync(lastCheckFile)
42
+ ? parseInt(fs.readFileSync(lastCheckFile, "utf8"), 10)
43
+ : 0;
44
+ const intervalMs = updateInterval * 3_600_000;
45
+ const elapsed = Date.now() - lastCheck;
46
+ if (elapsed < intervalMs) {
47
+ writeLog(`Fast-path: ${pluginName} skipping update check (checked ${Math.floor(elapsed / 60_000)} min ago, interval ${updateInterval}h)`);
48
+ return { success: true, changed: false };
49
+ }
50
+ fs.writeFileSync(lastCheckFile, Date.now().toString());
51
+ executeGit("git fetch origin", targetDir);
52
+ let beforeHash = "";
53
+ try {
54
+ beforeHash = execSync("git rev-parse HEAD", { cwd: targetDir }).toString().trim();
55
+ }
56
+ catch { /* ignore */ }
57
+ if (commitHash) {
58
+ executeGit(`git checkout ${commitHash}`, targetDir);
59
+ }
60
+ else if (branch) {
61
+ executeGit(`git checkout ${branch}`, targetDir);
62
+ executeGit(`git pull --ff-only origin ${branch}`, targetDir);
63
+ }
64
+ else {
65
+ // the updater owns repos/: hard-sync to the remote so force-pushed
66
+ // branches and rewritten submodule history cannot strand the clone
67
+ executeGit("git fetch origin", targetDir);
68
+ executeGit("git checkout main || git checkout master", targetDir);
69
+ executeGit("git reset --hard @{upstream}", targetDir);
70
+ }
71
+ executeGit("git submodule sync --recursive", targetDir);
72
+ const submodulesOk = executeGit("git submodule update --init --recursive --force", targetDir);
73
+ if (!submodulesOk) {
74
+ writeLog(`Submodule sync failed for ${pluginName}, recloning`, true);
75
+ try {
76
+ fs.rmSync(targetDir, { recursive: true, force: true });
77
+ }
78
+ catch { /* ignore */ }
79
+ const recloneBranchFlag = branch ? `--branch ${branch}` : "";
80
+ executeGit(`git clone --recurse-submodules ${recloneBranchFlag} ${gitUrl} ${pluginName}`, reposDir);
81
+ fs.writeFileSync(lastCheckFile, Date.now().toString());
82
+ didChange = true;
83
+ }
84
+ let afterHash = "";
85
+ try {
86
+ afterHash = execSync("git rev-parse HEAD", { cwd: targetDir }).toString().trim();
87
+ }
88
+ catch { /* ignore */ }
89
+ if (beforeHash !== afterHash)
90
+ didChange = true;
91
+ }
92
+ return { success: true, changed: didChange };
93
+ }
94
+ // npm install creates node_modules/.bin symlinks, which fail on filesystems
95
+ // without symlink support (e.g. Windows-backed Docker bind mounts) — build in
96
+ // the OS temp dir and copy the outputs back instead
97
+ export function buildInTempDir(pluginName, sourceDir) {
98
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `plugin-updater-${pluginName}-`));
99
+ try {
100
+ fs.cpSync(sourceDir, tempDir, {
101
+ recursive: true,
102
+ filter: (src) => {
103
+ const name = path.basename(src);
104
+ return name !== ".git" && name !== "node_modules";
105
+ },
106
+ });
107
+ writeLog(`Running npm install for ${pluginName}`);
108
+ execSync("npm install", { cwd: tempDir, stdio: "pipe" });
109
+ writeLog(`Finished npm install for ${pluginName}`);
110
+ const pkg = JSON.parse(fs.readFileSync(path.join(tempDir, "package.json"), "utf8"));
111
+ if (pkg.scripts?.build) {
112
+ execSync("npm run build", { cwd: tempDir, stdio: "pipe" });
113
+ writeLog(`Finished npm run build for ${pluginName}`);
114
+ }
115
+ else {
116
+ writeLog(`Skipped npm run build for ${pluginName} (no build script found)`);
117
+ }
118
+ for (const outputDir of BUILD_OUTPUT_DIRS) {
119
+ const builtDir = path.join(tempDir, outputDir);
120
+ if (fs.existsSync(builtDir)) {
121
+ fs.cpSync(builtDir, path.join(sourceDir, outputDir), { recursive: true });
122
+ }
123
+ }
124
+ }
125
+ finally {
126
+ fs.rmSync(tempDir, { recursive: true, force: true });
127
+ }
128
+ }
package/dist/index.d.ts CHANGED
@@ -1,22 +1,6 @@
1
- interface Plugin {
2
- name: string;
3
- url?: string;
4
- branch?: string;
5
- enabled?: boolean;
6
- autoUpdate?: boolean;
7
- updateInterval?: number;
8
- }
9
- interface NpmPlugin {
10
- name: string;
11
- version: string;
12
- installed: boolean;
13
- raw: string;
14
- }
15
- export declare function getNpmPlugins(configDir: string): NpmPlugin[];
16
- export declare function installNpmPlugin(name: string, configDir: string): string;
17
- export declare function uninstallNpmPlugin(name: string, configDir: string): string;
18
- export declare function updateNpmPlugin(name: string, configDir: string, updateInterval?: number): string;
1
+ import type { Plugin } from "./types.js";
2
+ export { getNpmPlugins, installNpmPlugin, uninstallNpmPlugin, updateNpmPlugin } from "./npm.js";
3
+ export { getPlugins, getPluginsPath } from "./config.js";
19
4
  export declare function updatePluginPublic(pluginName: string, gitUrl: string, branch?: string, commitHash?: string): Promise<void | object>;
20
5
  export declare function earlyLaunch(configDir: string, plugins: Plugin[]): Promise<void | object>;
21
6
  export declare function activate(opencodeHookInput?: unknown): Promise<void | object>;
22
- export {};
package/dist/index.js CHANGED
@@ -1,538 +1,13 @@
1
- import fs from "fs";
1
+ import { getAppConfigDir, getAppName, isOpencodeHookInvocation, setEarlyLaunchConfigDir } from "./env.js";
2
+ import { writeLog } from "./log.js";
3
+ import { getPlugins, readOpencodeJson } from "./config.js";
4
+ import { selfUpdate, updateNpmPlugin } from "./npm.js";
5
+ import { updatePlugin } from "./git.js";
6
+ import { deployToExecutionDir } from "./deploy.js";
2
7
  import path from "path";
3
- import os from "os";
4
- import { execSync } from "child_process";
5
- let EARLY_LAUNCH_CONFIG_DIR = null;
6
- let PLUGIN_CONFIG = null;
7
- const START_TIME = new Date().toISOString().replace(/:/g, "-").split(".")[0];
8
- // the CLI runs without "claude" in argv, so it forces the app via env
9
- function getAppName() {
10
- const override = process.env.PLUGIN_UPDATER_APP;
11
- if (override === "claude" || override === "opencode")
12
- return override;
13
- return process.argv.join(" ").includes("claude") ? "claude" : "opencode";
14
- }
15
- function getPluginConfig() {
16
- if (PLUGIN_CONFIG !== null)
17
- return PLUGIN_CONFIG;
18
- try {
19
- const configDir = getAppConfigDir(getAppName());
20
- const preferred = path.join(configDir, "config", "plugin-updater.json");
21
- const fallback = path.join(configDir, "plugin-updater.json");
22
- const p = fs.existsSync(preferred) ? preferred : fs.existsSync(fallback) ? fallback : null;
23
- PLUGIN_CONFIG = p ? JSON.parse(fs.readFileSync(p, "utf8")) : {};
24
- }
25
- catch {
26
- PLUGIN_CONFIG = {};
27
- }
28
- return PLUGIN_CONFIG ?? {};
29
- }
30
- function getAppConfigDir(appName) {
31
- if (EARLY_LAUNCH_CONFIG_DIR)
32
- return EARLY_LAUNCH_CONFIG_DIR;
33
- const home = os.homedir();
34
- const directPath = path.join(home, `.${appName}`);
35
- const configPath = path.join(home, ".config", appName);
36
- return fs.existsSync(directPath) ? directPath : configPath;
37
- }
38
- function writeLog(message, isError = false) {
39
- const loggingEnabled = getPluginConfig().logging !== false;
40
- try {
41
- if (loggingEnabled) {
42
- const date = new Date();
43
- const dateStr = date.toISOString().split("T")[0];
44
- const configDir = getAppConfigDir(getAppName());
45
- const logsDir = path.join(configDir, "logs", dateStr);
46
- if (!fs.existsSync(logsDir))
47
- fs.mkdirSync(logsDir, { recursive: true });
48
- const logFile = path.join(logsDir, `plugin-updater-${START_TIME}.log`);
49
- const prefix = isError ? "[ERROR]" : "[INFO]";
50
- fs.appendFileSync(logFile, `[${date.toISOString()}] ${prefix} ${message}\n`);
51
- }
52
- }
53
- catch { /* never crash on log failure */ }
54
- if (process.env.PLUGIN_UPDATER_LIBRARY_MODE === "1" && process.env.PLUGIN_UPDATER_CLI !== "1")
55
- return;
56
- if (isError)
57
- console.error(message);
58
- else if (loggingEnabled)
59
- console.log(message);
60
- }
61
- let NPM_GLOBAL_ROOT = null;
62
- function getReposDir() {
63
- return path.join(getAppConfigDir(getAppName()), "repos");
64
- }
65
- function executeGit(command, cwd) {
66
- writeLog(`Executing git: ${command} in ${cwd}`);
67
- try {
68
- execSync(command, {
69
- cwd,
70
- stdio: "pipe",
71
- env: { ...process.env, GCM_INTERACTIVE: "never", GIT_TERMINAL_PROMPT: "0" },
72
- });
73
- return true;
74
- }
75
- catch (error) {
76
- const err = error;
77
- const stderr = err.stderr ? err.stderr.toString().trim() : "";
78
- writeLog(`Git error in ${cwd}: ${err.message} | stderr: ${stderr}`, true);
79
- return false;
80
- }
81
- }
82
- function getNpmGlobalRoot() {
83
- if (NPM_GLOBAL_ROOT !== null)
84
- return NPM_GLOBAL_ROOT;
85
- try {
86
- NPM_GLOBAL_ROOT = execSync("npm root -g", { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
87
- }
88
- catch {
89
- NPM_GLOBAL_ROOT = "";
90
- }
91
- return NPM_GLOBAL_ROOT;
92
- }
93
- function resolveNpmPluginVersion(name, configDir) {
94
- try {
95
- // opencode installs npm plugins into ~/.cache/opencode/packages/<name>@<spec>/
96
- const packageCache = path.join(os.homedir(), ".cache", "opencode", "packages");
97
- if (fs.existsSync(packageCache)) {
98
- for (const entry of fs.readdirSync(packageCache)) {
99
- if (entry !== name && !entry.startsWith(`${name}@`))
100
- continue;
101
- const cachedPkg = path.join(packageCache, entry, "node_modules", name, "package.json");
102
- if (fs.existsSync(cachedPkg)) {
103
- return JSON.parse(fs.readFileSync(cachedPkg, "utf8")).version || "";
104
- }
105
- }
106
- }
107
- const cacheDir = path.join(configDir, "cache", "node_modules");
108
- const globalNpm = getNpmGlobalRoot();
109
- const candidates = [
110
- path.join(cacheDir, name, "package.json"),
111
- path.join(configDir, "node_modules", name, "package.json"),
112
- globalNpm ? path.join(globalNpm, name, "package.json") : "",
113
- ].filter((p) => p !== "");
114
- for (const p of candidates) {
115
- if (fs.existsSync(p)) {
116
- return JSON.parse(fs.readFileSync(p, "utf8")).version || "";
117
- }
118
- }
119
- // Try resolving via node resolution as last resort
120
- try {
121
- const resolved = require.resolve(path.join(name, "package.json"));
122
- return JSON.parse(fs.readFileSync(resolved, "utf8")).version || "";
123
- }
124
- catch { /* not resolvable */ }
125
- }
126
- catch { /* ignore */ }
127
- return "";
128
- }
129
- function readOpencodeJson(configDir) {
130
- const ocPath = path.join(configDir, "opencode.json");
131
- if (!fs.existsSync(ocPath))
132
- return { plugins: [], raw: {} };
133
- try {
134
- const stripped = fs.readFileSync(ocPath, "utf8").replace(/^\s*\/\/[^\n]*/gm, "");
135
- const parsed = JSON.parse(stripped);
136
- const plugins = (parsed.plugin || []);
137
- return { plugins: plugins.filter((p) => typeof p === "string"), raw: parsed };
138
- }
139
- catch {
140
- return { plugins: [], raw: {} };
141
- }
142
- }
143
- function writeOpencodeJson(configDir, data) {
144
- const ocPath = path.join(configDir, "opencode.json");
145
- fs.writeFileSync(ocPath, JSON.stringify(data, null, 2), "utf8");
146
- }
147
- // ── Public API ────────────────────────────────────────────────────────────────
148
- // opencode invokes every exported function as a plugin hook, passing a context
149
- // object instead of our protocol arguments; exports detect that and return an
150
- // inert value so opencode gets a valid (empty) plugin instance
151
- function isOpencodeHookInvocation(firstArgument) {
152
- return typeof firstArgument !== "string";
153
- }
154
- export function getNpmPlugins(configDir) {
155
- if (isOpencodeHookInvocation(configDir))
156
- return [];
157
- const { plugins } = readOpencodeJson(configDir);
158
- return plugins.map((raw) => {
159
- const name = raw.replace(/@[^@/]+$/, "") || raw;
160
- const version = resolveNpmPluginVersion(name, configDir);
161
- return { name, version, installed: version !== "", raw };
162
- });
163
- }
164
- export function installNpmPlugin(name, configDir) {
165
- if (isOpencodeHookInvocation(name))
166
- return "";
167
- writeLog(`Installing npm plugin: ${name}`);
168
- try {
169
- const { plugins, raw } = readOpencodeJson(configDir);
170
- if (!plugins.includes(name)) {
171
- raw.plugin = [...plugins, name];
172
- writeOpencodeJson(configDir, raw);
173
- }
174
- execSync(`npm install -g ${name}`, { stdio: "pipe" });
175
- writeLog(`Installed npm plugin: ${name}`);
176
- return "";
177
- }
178
- catch (e) {
179
- const msg = e.message;
180
- writeLog(`Failed to install ${name}: ${msg}`, true);
181
- return msg;
182
- }
183
- }
184
- export function uninstallNpmPlugin(name, configDir) {
185
- if (isOpencodeHookInvocation(name))
186
- return "";
187
- writeLog(`Uninstalling npm plugin: ${name}`);
188
- try {
189
- const { plugins, raw } = readOpencodeJson(configDir);
190
- raw.plugin = plugins.filter((p) => {
191
- const pName = p.replace(/@[^@/]+$/, "") || p;
192
- return pName !== name;
193
- });
194
- writeOpencodeJson(configDir, raw);
195
- execSync(`npm uninstall -g ${name}`, { stdio: "pipe" });
196
- writeLog(`Uninstalled npm plugin: ${name}`);
197
- return "";
198
- }
199
- catch (e) {
200
- const msg = e.message;
201
- writeLog(`Failed to uninstall ${name}: ${msg}`, true);
202
- return msg;
203
- }
204
- }
205
- export function updateNpmPlugin(name, configDir, updateInterval = 1) {
206
- if (isOpencodeHookInvocation(name))
207
- return "";
208
- writeLog(`Updating npm plugin: ${name}`);
209
- const checkFile = path.join(configDir, "cache", `.npm-lastcheck-${name.replace(/[^a-z0-9]/gi, "_")}`);
210
- try {
211
- if (!fs.existsSync(path.join(configDir, "cache"))) {
212
- fs.mkdirSync(path.join(configDir, "cache"), { recursive: true });
213
- }
214
- const lastCheck = fs.existsSync(checkFile)
215
- ? parseInt(fs.readFileSync(checkFile, "utf8"), 10)
216
- : 0;
217
- const elapsed = Date.now() - lastCheck;
218
- if (elapsed < updateInterval * 3_600_000) {
219
- writeLog(`Skipping npm update for ${name} (checked ${Math.floor(elapsed / 60_000)} min ago)`);
220
- return "";
221
- }
222
- fs.writeFileSync(checkFile, Date.now().toString());
223
- execSync(`npm update -g ${name}`, { stdio: "pipe" });
224
- writeLog(`Updated npm plugin: ${name}`);
225
- return "";
226
- }
227
- catch (e) {
228
- const msg = e.message;
229
- writeLog(`Failed to update ${name}: ${msg}`, true);
230
- return msg;
231
- }
232
- }
233
- function selfUpdate(configDir) {
234
- writeLog("Running self-update for plugin-updater");
235
- updateNpmPlugin("plugin-updater", configDir);
236
- }
237
- function updatePlugin(pluginName, gitUrl, branch, commitHash, updateInterval = 1) {
238
- const reposDir = getReposDir();
239
- const targetDir = path.join(reposDir, pluginName);
240
- const lastCheckFile = path.join(targetDir, ".lastcheck");
241
- let didChange = false;
242
- if (!fs.existsSync(targetDir)) {
243
- if (!fs.existsSync(reposDir))
244
- fs.mkdirSync(reposDir, { recursive: true });
245
- const branchFlag = branch ? `--branch ${branch}` : "";
246
- const cloned = executeGit(`git clone --recurse-submodules ${branchFlag} ${gitUrl} ${pluginName}`, reposDir);
247
- if (!cloned)
248
- return { success: false, changed: false };
249
- fs.writeFileSync(lastCheckFile, Date.now().toString());
250
- didChange = true;
251
- }
252
- else {
253
- const lastCheck = fs.existsSync(lastCheckFile)
254
- ? parseInt(fs.readFileSync(lastCheckFile, "utf8"), 10)
255
- : 0;
256
- const intervalMs = updateInterval * 3_600_000;
257
- const elapsed = Date.now() - lastCheck;
258
- if (elapsed < intervalMs) {
259
- writeLog(`Fast-path: ${pluginName} skipping update check (checked ${Math.floor(elapsed / 60_000)} min ago, interval ${updateInterval}h)`);
260
- return { success: true, changed: false };
261
- }
262
- fs.writeFileSync(lastCheckFile, Date.now().toString());
263
- executeGit("git fetch origin", targetDir);
264
- let beforeHash = "";
265
- try {
266
- beforeHash = execSync("git rev-parse HEAD", { cwd: targetDir }).toString().trim();
267
- }
268
- catch { /* ignore */ }
269
- if (commitHash) {
270
- executeGit(`git checkout ${commitHash}`, targetDir);
271
- }
272
- else if (branch) {
273
- executeGit(`git checkout ${branch}`, targetDir);
274
- executeGit(`git pull --ff-only origin ${branch}`, targetDir);
275
- }
276
- else {
277
- // the updater owns repos/: hard-sync to the remote so force-pushed
278
- // branches and rewritten submodule history cannot strand the clone
279
- executeGit("git fetch origin", targetDir);
280
- executeGit("git checkout main || git checkout master", targetDir);
281
- executeGit("git reset --hard @{upstream}", targetDir);
282
- }
283
- executeGit("git submodule sync --recursive", targetDir);
284
- const submodulesOk = executeGit("git submodule update --init --recursive --force", targetDir);
285
- if (!submodulesOk) {
286
- writeLog(`Submodule sync failed for ${pluginName}, recloning`, true);
287
- try {
288
- fs.rmSync(targetDir, { recursive: true, force: true });
289
- }
290
- catch { /* ignore */ }
291
- const recloneBranchFlag = branch ? `--branch ${branch}` : "";
292
- executeGit(`git clone --recurse-submodules ${recloneBranchFlag} ${gitUrl} ${pluginName}`, reposDir);
293
- fs.writeFileSync(lastCheckFile, Date.now().toString());
294
- didChange = true;
295
- }
296
- let afterHash = "";
297
- try {
298
- afterHash = execSync("git rev-parse HEAD", { cwd: targetDir }).toString().trim();
299
- }
300
- catch { /* ignore */ }
301
- if (beforeHash !== afterHash)
302
- didChange = true;
303
- }
304
- return { success: true, changed: didChange };
305
- }
306
- async function callPluginCleanup(pluginExecutionFile, configDir) {
307
- if (!fs.existsSync(pluginExecutionFile))
308
- return;
309
- try {
310
- const mod = await import(pluginExecutionFile);
311
- if (typeof mod.cleanup === "function") {
312
- writeLog(`Calling cleanup() on ${pluginExecutionFile}`);
313
- await mod.cleanup(configDir);
314
- writeLog(`cleanup() complete for ${pluginExecutionFile}`);
315
- }
316
- }
317
- catch (e) {
318
- writeLog(`cleanup() call failed for ${pluginExecutionFile}: ${e.message}`, true);
319
- }
320
- }
321
- const BUILD_OUTPUT_DIRS = ["dist", path.join("core", "dist")];
322
- // npm install creates node_modules/.bin symlinks, which fail on filesystems
323
- // without symlink support (e.g. Windows-backed Docker bind mounts) — build in
324
- // the OS temp dir and copy the outputs back instead
325
- function buildInTempDir(pluginName, sourceDir) {
326
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `plugin-updater-${pluginName}-`));
327
- try {
328
- fs.cpSync(sourceDir, tempDir, {
329
- recursive: true,
330
- filter: (src) => {
331
- const name = path.basename(src);
332
- return name !== ".git" && name !== "node_modules";
333
- },
334
- });
335
- writeLog(`Running npm install for ${pluginName}`);
336
- execSync("npm install", { cwd: tempDir, stdio: "pipe" });
337
- writeLog(`Finished npm install for ${pluginName}`);
338
- const pkg = JSON.parse(fs.readFileSync(path.join(tempDir, "package.json"), "utf8"));
339
- if (pkg.scripts?.build) {
340
- execSync("npm run build", { cwd: tempDir, stdio: "pipe" });
341
- writeLog(`Finished npm run build for ${pluginName}`);
342
- }
343
- else {
344
- writeLog(`Skipped npm run build for ${pluginName} (no build script found)`);
345
- }
346
- for (const outputDir of BUILD_OUTPUT_DIRS) {
347
- const builtDir = path.join(tempDir, outputDir);
348
- if (fs.existsSync(builtDir)) {
349
- fs.cpSync(builtDir, path.join(sourceDir, outputDir), { recursive: true });
350
- }
351
- }
352
- }
353
- finally {
354
- fs.rmSync(tempDir, { recursive: true, force: true });
355
- }
356
- }
357
- async function deployToExecutionDir(pluginName, executionPath, changed, configDir) {
358
- const sourceDir = path.join(getReposDir(), pluginName);
359
- if (!fs.existsSync(sourceDir))
360
- return false;
361
- const packageJsonPath = path.join(sourceDir, "package.json");
362
- let entryFile = "index.js";
363
- const pluginExecutionFile = path.join(executionPath, `${pluginName}.js`);
364
- if (!changed && fs.existsSync(pluginExecutionFile)) {
365
- writeLog(`Skipping install/build for ${pluginName} (no changes and deployed file exists)`);
366
- }
367
- else {
368
- if (fs.existsSync(packageJsonPath)) {
369
- try {
370
- buildInTempDir(pluginName, sourceDir);
371
- const runtimeDeps = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")).dependencies;
372
- if (runtimeDeps && Object.keys(runtimeDeps).length > 0) {
373
- writeLog(`Installing runtime dependencies for ${pluginName}`);
374
- execSync("npm install --omit=dev", { cwd: sourceDir, stdio: "pipe" });
375
- writeLog(`Finished runtime dependencies for ${pluginName}`);
376
- }
377
- }
378
- catch (error) {
379
- const err = error;
380
- const stderr = err.stderr ? err.stderr.toString().trim() : "";
381
- const stdout = err.stdout ? err.stdout.toString().trim() : "";
382
- writeLog(`Build/Install failed for ${pluginName}: ${err.message}`, true);
383
- if (stderr)
384
- writeLog(`npm stderr: ${stderr}`, true);
385
- if (stdout)
386
- writeLog(`npm stdout: ${stdout}`, true);
387
- }
388
- }
389
- }
390
- if (fs.existsSync(packageJsonPath)) {
391
- try {
392
- const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
393
- if (pkg.main)
394
- entryFile = pkg.main;
395
- }
396
- catch { /* ignore */ }
397
- }
398
- const distPath = path.join(sourceDir, "dist");
399
- let deploySource = path.join(sourceDir, entryFile);
400
- if (fs.existsSync(path.join(distPath, entryFile))) {
401
- deploySource = path.join(distPath, entryFile);
402
- }
403
- else if (fs.existsSync(path.join(distPath, "index.js"))) {
404
- deploySource = path.join(distPath, "index.js");
405
- }
406
- if (!fs.existsSync(executionPath))
407
- fs.mkdirSync(executionPath, { recursive: true });
408
- await callPluginCleanup(pluginExecutionFile, configDir);
409
- try {
410
- writeLog(`Running copy for ${pluginName}`);
411
- fs.copyFileSync(deploySource, pluginExecutionFile);
412
- writeLog(`Finished copy for ${pluginName}`);
413
- }
414
- catch (e) {
415
- const err = e;
416
- writeLog(`Copy failed for ${pluginName}: ${err.message}`, true);
417
- }
418
- applyClaudeManifest(sourceDir, configDir, pluginName);
419
- await startDeclaredDaemon(sourceDir, configDir, pluginName);
420
- // Claude Code never imports deployed plugin files, so under claude the
421
- // updater is the runtime and invokes the plugin's activate() itself
422
- if (getAppName() === "claude") {
423
- try {
424
- const deployed = await import(pluginExecutionFile);
425
- if (typeof deployed.activate === "function") {
426
- writeLog(`Activating ${pluginName}`);
427
- // tells the plugin the updater is the caller, so it must not start
428
- // another earlyLaunch and recurse back into the updater
429
- process.env.PLUGIN_UPDATER_ACTIVATION = "1";
430
- try {
431
- await deployed.activate();
432
- }
433
- finally {
434
- delete process.env.PLUGIN_UPDATER_ACTIVATION;
435
- }
436
- writeLog(`Activated ${pluginName}`);
437
- }
438
- }
439
- catch (e) {
440
- writeLog(`Activation failed for ${pluginName}: ${e.message}`, true);
441
- }
442
- }
443
- return true;
444
- }
445
- function applyClaudeManifest(sourceDir, configDir, pluginName) {
446
- if (getAppName() !== "claude")
447
- return;
448
- try {
449
- const pkg = JSON.parse(fs.readFileSync(path.join(sourceDir, "package.json"), "utf8"));
450
- const manifest = pkg.claudeHub;
451
- if (!manifest)
452
- return;
453
- if (manifest.env && typeof manifest.env === "object") {
454
- const settingsPath = path.join(configDir, "settings.json");
455
- let settings = {};
456
- try {
457
- settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
458
- }
459
- catch { /* fresh file */ }
460
- const env = (settings.env ?? {});
461
- for (const [key, value] of Object.entries(manifest.env)) {
462
- env[key] = String(value);
463
- writeLog(`settings.json env ${key} set by ${pluginName}`);
464
- }
465
- settings.env = env;
466
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8");
467
- }
468
- }
469
- catch (e) {
470
- writeLog(`claudeHub manifest handling failed for ${pluginName}: ${e.message}`, true);
471
- }
472
- }
473
- async function isDaemonHealthy(url) {
474
- try {
475
- const controller = new AbortController();
476
- const timer = setTimeout(() => controller.abort(), 1500);
477
- const res = await fetch(url, { signal: controller.signal });
478
- clearTimeout(timer);
479
- return res.ok;
480
- }
481
- catch {
482
- return false;
483
- }
484
- }
485
- // idempotent: health-check the declared endpoint, spawn detached only if down.
486
- // the daemon outlives this process so the proxy persists across the session.
487
- async function startDeclaredDaemon(sourceDir, configDir, pluginName) {
488
- try {
489
- const pkg = JSON.parse(fs.readFileSync(path.join(sourceDir, "package.json"), "utf8"));
490
- const daemon = pkg.claudeHub?.daemon;
491
- if (!daemon?.script)
492
- return;
493
- const healthUrl = daemon.healthCheckUrl
494
- || (daemon.port ? `http://127.0.0.1:${daemon.port}/health` : "");
495
- if (healthUrl && (await isDaemonHealthy(healthUrl))) {
496
- writeLog(`Daemon for ${pluginName} already running`);
497
- return;
498
- }
499
- const scriptPath = path.join(sourceDir, daemon.script);
500
- if (!fs.existsSync(scriptPath)) {
501
- writeLog(`Daemon script missing for ${pluginName}: ${scriptPath}`, true);
502
- return;
503
- }
504
- const runtime = daemon.runtime || "node";
505
- const { spawn } = await import("child_process");
506
- const child = spawn(runtime, [scriptPath], {
507
- cwd: sourceDir,
508
- detached: true,
509
- stdio: "ignore",
510
- env: {
511
- ...process.env,
512
- HUB_CONFIG_DIR: configDir,
513
- HUB_APP_NAME: getAppName() === "claude" ? "Claude Code" : "OpenCode",
514
- ...(daemon.port ? { HUB_PROXY_PORT: String(daemon.port) } : {}),
515
- },
516
- });
517
- child.unref();
518
- writeLog(`Started daemon for ${pluginName} (${runtime} ${daemon.script})`);
519
- }
520
- catch (e) {
521
- writeLog(`Daemon start failed for ${pluginName}: ${e.message}`, true);
522
- }
523
- }
524
- async function pluginUpdaterEntry(input) {
525
- const appName = getAppName();
526
- const configDir = getAppConfigDir(appName);
527
- const pluginsDir = path.join(configDir, "plugin");
528
- writeLog(`Starting plugin updater for ${appName}`);
529
- if (input?.action === "updatePlugin" && input.configDir && input.pluginName && input.gitUrl) {
530
- EARLY_LAUNCH_CONFIG_DIR = input.configDir;
531
- writeLog(`Direct update request for ${input.pluginName}`);
532
- const updateResult = updatePlugin(input.pluginName, input.gitUrl, input.branch, input.commitHash ?? null);
533
- await deployToExecutionDir(input.pluginName, pluginsDir, updateResult.changed, input.configDir);
534
- }
535
- }
8
+ // re-exported public API (consumers import these from "plugin-updater")
9
+ export { getNpmPlugins, installNpmPlugin, uninstallNpmPlugin, updateNpmPlugin } from "./npm.js";
10
+ export { getPlugins, getPluginsPath } from "./config.js";
536
11
  export async function updatePluginPublic(pluginName, gitUrl, branch, commitHash) {
537
12
  if (isOpencodeHookInvocation(pluginName))
538
13
  return {};
@@ -547,11 +22,10 @@ export async function updatePluginPublic(pluginName, gitUrl, branch, commitHash)
547
22
  export async function earlyLaunch(configDir, plugins) {
548
23
  if (isOpencodeHookInvocation(configDir))
549
24
  return {};
550
- EARLY_LAUNCH_CONFIG_DIR = configDir;
25
+ setEarlyLaunchConfigDir(configDir);
551
26
  writeLog("Starting earlyLaunch updater sequence");
552
- // Self-update first
553
27
  selfUpdate(configDir);
554
- // Update npm plugins listed in opencode.json
28
+ // npm plugins listed in opencode.json
555
29
  const { plugins: npmNames } = readOpencodeJson(configDir);
556
30
  for (const raw of npmNames) {
557
31
  const name = raw.replace(/@[^@/]+$/, "") || raw;
@@ -565,7 +39,6 @@ export async function earlyLaunch(configDir, plugins) {
565
39
  writeLog(`Failed npm update for ${name}: ${e.message}`, true);
566
40
  }
567
41
  }
568
- // Git plugins
569
42
  if (!plugins || !Array.isArray(plugins)) {
570
43
  writeLog("No git plugins provided to earlyLaunch", true);
571
44
  return;
@@ -594,8 +67,7 @@ export async function earlyLaunch(configDir, plugins) {
594
67
  await deployToExecutionDir(plugin.name, path.join(configDir, "plugin"), updateResult.changed, configDir);
595
68
  }
596
69
  catch (e) {
597
- const err = e;
598
- writeLog(`Failed to process ${plugin.name}: ${err.message}`, true);
70
+ writeLog(`Failed to process ${plugin.name}: ${e.message}`, true);
599
71
  }
600
72
  }
601
73
  }
@@ -607,17 +79,8 @@ export async function activate(opencodeHookInput) {
607
79
  const appName = getAppName();
608
80
  const configDir = getAppConfigDir(appName);
609
81
  writeLog(`Plugin updater activating for ${appName}`);
610
- const pluginsJsonPath = path.join(configDir, "config", "plugins.json");
611
- let gitPlugins = [];
612
- if (fs.existsSync(pluginsJsonPath)) {
613
- try {
614
- gitPlugins = JSON.parse(fs.readFileSync(pluginsJsonPath, "utf-8"));
615
- writeLog(`Found ${gitPlugins.length} git plugins in plugins.json`);
616
- }
617
- catch (e) {
618
- writeLog(`Failed to parse plugins.json: ${e.message}`, true);
619
- }
620
- }
82
+ const gitPlugins = getPlugins(configDir);
83
+ writeLog(`Found ${gitPlugins.length} git plugins in plugins.json`);
621
84
  await earlyLaunch(configDir, gitPlugins);
622
85
  }
623
86
  // consumers like the loader TUI import this module for its API only — running
package/dist/log.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare function getPluginConfig(): Record<string, unknown>;
2
+ export declare function writeLog(message: string, isError?: boolean): void;
package/dist/log.js ADDED
@@ -0,0 +1,43 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { getAppConfigDir, getAppName } from "./env.js";
4
+ const START_TIME = new Date().toISOString().replace(/:/g, "-").split(".")[0];
5
+ let pluginConfig = null;
6
+ export function getPluginConfig() {
7
+ if (pluginConfig !== null)
8
+ return pluginConfig;
9
+ try {
10
+ const configDir = getAppConfigDir(getAppName());
11
+ const preferred = path.join(configDir, "config", "plugin-updater.json");
12
+ const fallback = path.join(configDir, "plugin-updater.json");
13
+ const p = fs.existsSync(preferred) ? preferred : fs.existsSync(fallback) ? fallback : null;
14
+ pluginConfig = p ? JSON.parse(fs.readFileSync(p, "utf8")) : {};
15
+ }
16
+ catch {
17
+ pluginConfig = {};
18
+ }
19
+ return pluginConfig ?? {};
20
+ }
21
+ export function writeLog(message, isError = false) {
22
+ const loggingEnabled = getPluginConfig().logging !== false;
23
+ try {
24
+ if (loggingEnabled) {
25
+ const date = new Date();
26
+ const dateStr = date.toISOString().split("T")[0];
27
+ const configDir = getAppConfigDir(getAppName());
28
+ const logsDir = path.join(configDir, "logs", dateStr);
29
+ if (!fs.existsSync(logsDir))
30
+ fs.mkdirSync(logsDir, { recursive: true });
31
+ const logFile = path.join(logsDir, `plugin-updater-${START_TIME}.log`);
32
+ const prefix = isError ? "[ERROR]" : "[INFO]";
33
+ fs.appendFileSync(logFile, `[${date.toISOString()}] ${prefix} ${message}\n`);
34
+ }
35
+ }
36
+ catch { /* never crash on log failure */ }
37
+ if (process.env.PLUGIN_UPDATER_LIBRARY_MODE === "1" && process.env.PLUGIN_UPDATER_CLI !== "1")
38
+ return;
39
+ if (isError)
40
+ console.error(message);
41
+ else if (loggingEnabled)
42
+ console.log(message);
43
+ }
package/dist/npm.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { NpmPlugin } from "./types.js";
2
+ export declare function getNpmGlobalRoot(): string;
3
+ export declare function resolveNpmPluginVersion(name: string, configDir: string): string;
4
+ export declare function getNpmPlugins(configDir: string): NpmPlugin[];
5
+ export declare function installNpmPlugin(name: string, configDir: string): string;
6
+ export declare function uninstallNpmPlugin(name: string, configDir: string): string;
7
+ export declare function updateNpmPlugin(name: string, configDir: string, updateInterval?: number): string;
8
+ export declare function selfUpdate(configDir: string): void;
package/dist/npm.js ADDED
@@ -0,0 +1,137 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import { execSync } from "child_process";
5
+ import { isOpencodeHookInvocation } from "./env.js";
6
+ import { writeLog } from "./log.js";
7
+ import { readOpencodeJson, writeOpencodeJson } from "./config.js";
8
+ let npmGlobalRoot = null;
9
+ export function getNpmGlobalRoot() {
10
+ if (npmGlobalRoot !== null)
11
+ return npmGlobalRoot;
12
+ try {
13
+ npmGlobalRoot = execSync("npm root -g", { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
14
+ }
15
+ catch {
16
+ npmGlobalRoot = "";
17
+ }
18
+ return npmGlobalRoot;
19
+ }
20
+ export function resolveNpmPluginVersion(name, configDir) {
21
+ try {
22
+ // opencode installs npm plugins into ~/.cache/opencode/packages/<name>@<spec>/
23
+ const packageCache = path.join(os.homedir(), ".cache", "opencode", "packages");
24
+ if (fs.existsSync(packageCache)) {
25
+ for (const entry of fs.readdirSync(packageCache)) {
26
+ if (entry !== name && !entry.startsWith(`${name}@`))
27
+ continue;
28
+ const cachedPkg = path.join(packageCache, entry, "node_modules", name, "package.json");
29
+ if (fs.existsSync(cachedPkg)) {
30
+ return JSON.parse(fs.readFileSync(cachedPkg, "utf8")).version || "";
31
+ }
32
+ }
33
+ }
34
+ const cacheDir = path.join(configDir, "cache", "node_modules");
35
+ const globalNpm = getNpmGlobalRoot();
36
+ const candidates = [
37
+ path.join(cacheDir, name, "package.json"),
38
+ path.join(configDir, "node_modules", name, "package.json"),
39
+ globalNpm ? path.join(globalNpm, name, "package.json") : "",
40
+ ].filter((p) => p !== "");
41
+ for (const p of candidates) {
42
+ if (fs.existsSync(p)) {
43
+ return JSON.parse(fs.readFileSync(p, "utf8")).version || "";
44
+ }
45
+ }
46
+ try {
47
+ const resolved = require.resolve(path.join(name, "package.json"));
48
+ return JSON.parse(fs.readFileSync(resolved, "utf8")).version || "";
49
+ }
50
+ catch { /* not resolvable */ }
51
+ }
52
+ catch { /* ignore */ }
53
+ return "";
54
+ }
55
+ export function getNpmPlugins(configDir) {
56
+ if (isOpencodeHookInvocation(configDir))
57
+ return [];
58
+ const { plugins } = readOpencodeJson(configDir);
59
+ return plugins.map((raw) => {
60
+ const name = raw.replace(/@[^@/]+$/, "") || raw;
61
+ const version = resolveNpmPluginVersion(name, configDir);
62
+ return { name, version, installed: version !== "", raw };
63
+ });
64
+ }
65
+ export function installNpmPlugin(name, configDir) {
66
+ if (isOpencodeHookInvocation(name))
67
+ return "";
68
+ writeLog(`Installing npm plugin: ${name}`);
69
+ try {
70
+ const { plugins, raw } = readOpencodeJson(configDir);
71
+ if (!plugins.includes(name)) {
72
+ raw.plugin = [...plugins, name];
73
+ writeOpencodeJson(configDir, raw);
74
+ }
75
+ execSync(`npm install -g ${name}`, { stdio: "pipe" });
76
+ writeLog(`Installed npm plugin: ${name}`);
77
+ return "";
78
+ }
79
+ catch (e) {
80
+ const msg = e.message;
81
+ writeLog(`Failed to install ${name}: ${msg}`, true);
82
+ return msg;
83
+ }
84
+ }
85
+ export function uninstallNpmPlugin(name, configDir) {
86
+ if (isOpencodeHookInvocation(name))
87
+ return "";
88
+ writeLog(`Uninstalling npm plugin: ${name}`);
89
+ try {
90
+ const { plugins, raw } = readOpencodeJson(configDir);
91
+ raw.plugin = plugins.filter((p) => {
92
+ const pName = p.replace(/@[^@/]+$/, "") || p;
93
+ return pName !== name;
94
+ });
95
+ writeOpencodeJson(configDir, raw);
96
+ execSync(`npm uninstall -g ${name}`, { stdio: "pipe" });
97
+ writeLog(`Uninstalled npm plugin: ${name}`);
98
+ return "";
99
+ }
100
+ catch (e) {
101
+ const msg = e.message;
102
+ writeLog(`Failed to uninstall ${name}: ${msg}`, true);
103
+ return msg;
104
+ }
105
+ }
106
+ export function updateNpmPlugin(name, configDir, updateInterval = 1) {
107
+ if (isOpencodeHookInvocation(name))
108
+ return "";
109
+ writeLog(`Updating npm plugin: ${name}`);
110
+ const checkFile = path.join(configDir, "cache", `.npm-lastcheck-${name.replace(/[^a-z0-9]/gi, "_")}`);
111
+ try {
112
+ if (!fs.existsSync(path.join(configDir, "cache"))) {
113
+ fs.mkdirSync(path.join(configDir, "cache"), { recursive: true });
114
+ }
115
+ const lastCheck = fs.existsSync(checkFile)
116
+ ? parseInt(fs.readFileSync(checkFile, "utf8"), 10)
117
+ : 0;
118
+ const elapsed = Date.now() - lastCheck;
119
+ if (elapsed < updateInterval * 3_600_000) {
120
+ writeLog(`Skipping npm update for ${name} (checked ${Math.floor(elapsed / 60_000)} min ago)`);
121
+ return "";
122
+ }
123
+ fs.writeFileSync(checkFile, Date.now().toString());
124
+ execSync(`npm update -g ${name}`, { stdio: "pipe" });
125
+ writeLog(`Updated npm plugin: ${name}`);
126
+ return "";
127
+ }
128
+ catch (e) {
129
+ const msg = e.message;
130
+ writeLog(`Failed to update ${name}: ${msg}`, true);
131
+ return msg;
132
+ }
133
+ }
134
+ export function selfUpdate(configDir) {
135
+ writeLog("Running self-update for plugin-updater");
136
+ updateNpmPlugin("plugin-updater", configDir);
137
+ }
@@ -0,0 +1,20 @@
1
+ export interface Plugin {
2
+ name: string;
3
+ url?: string;
4
+ branch?: string;
5
+ enabled?: boolean;
6
+ autoUpdate?: boolean;
7
+ updateInterval?: number;
8
+ }
9
+ export interface NpmPlugin {
10
+ name: string;
11
+ version: string;
12
+ installed: boolean;
13
+ raw: string;
14
+ }
15
+ export interface DaemonManifest {
16
+ script: string;
17
+ runtime?: string;
18
+ port?: number;
19
+ healthCheckUrl?: string;
20
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plugin-updater",
3
- "version": "1.0.37",
3
+ "version": "1.0.39",
4
4
  "description": "Plugin lifecycle manager for OpenCode and Claude Code launchers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",