plugin-updater 1.0.0 → 1.0.9

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,29 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ permissions:
9
+ id-token: write
10
+ contents: read
11
+
12
+ jobs:
13
+ publish:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: actions/setup-node@v4
19
+ with:
20
+ node-version: '24'
21
+ registry-url: 'https://registry.npmjs.org'
22
+
23
+ - name: Set version from tag
24
+ run: |
25
+ VERSION="${{ github.ref_name }}"
26
+ VERSION="${VERSION#v}"
27
+ npm version $VERSION --allow-same-version --no-git-tag-version
28
+
29
+ - run: npm publish --provenance --access public
package/.idea/misc.xml ADDED
@@ -0,0 +1,7 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="KubernetesApiProvider"><![CDATA[{}]]></component>
4
+ <component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="25" project-jdk-type="JavaSDK">
5
+ <output url="file://$PROJECT_DIR$/out" />
6
+ </component>
7
+ </project>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/plugin-updater.iml" filepath="$PROJECT_DIR$/.idea/plugin-updater.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
@@ -0,0 +1,9 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="JAVA_MODULE" version="4">
3
+ <component name="NewModuleRootManager" inherit-compiler-output="true">
4
+ <exclude-output />
5
+ <content url="file://$MODULE_DIR$" />
6
+ <orderEntry type="inheritedJdk" />
7
+ <orderEntry type="sourceFolder" forTests="false" />
8
+ </component>
9
+ </module>
package/.idea/vcs.xml ADDED
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="" vcs="Git" />
5
+ </component>
6
+ </project>
package/SPEC.md ADDED
@@ -0,0 +1,13 @@
1
+ # Plugin Updater - Specifications & Test Requirements
2
+
3
+ ## Goal
4
+ Reliable core update mechanism for all OpenCode and Claude Code plugins.
5
+
6
+ ## Requirements
7
+ - [ ] **Installation Order**: Must be installed FIRST in OpenCode, as it is responsible for installing all other plugins.
8
+ - [ ] **Reliability**: Must never fail or crash, as the entire ecosystem depends on it.
9
+ - [ ] **Launch Detection (Early Launch)**:
10
+ - The updater exports an `earlyLaunch(configDir)` function.
11
+ - Hub plugins (opencode-hub / claude-hub) MUST detect the updater and call `earlyLaunch` before OpenCode invokes it, deferring update flow management to the Hub.
12
+ - If launched directly via the normal application command (no hub / optional dependency), the updater executes its update routine automatically and MUST NOT install the Hub, as the Hub is strictly optional.
13
+ - Path resolution relies on the `configDir` passed by `earlyLaunch` or inferred from `process.argv`/input, NEVER relying on static environment variables like `CC_LAUNCHER`.
package/index.js CHANGED
@@ -1,32 +1,78 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const os = require('os');
3
4
  const { execSync } = require('child_process');
4
5
 
5
- const REPOS_DIR = path.join(require('os').homedir(), '.config', 'github');
6
+ let EARLY_LAUNCH_CONFIG_DIR = null;
6
7
 
7
- function executeGit(cmd, dir) {
8
+ function getAppConfigDir(appName) {
9
+ if (EARLY_LAUNCH_CONFIG_DIR) {
10
+ return EARLY_LAUNCH_CONFIG_DIR;
11
+ }
12
+ const home = os.homedir();
13
+ const directPath = path.join(home, `.${appName}`);
14
+ const configPath = path.join(home, ".config", appName);
15
+ return fs.existsSync(directPath) ? directPath : configPath;
16
+ }
17
+
18
+ function writeLog(message, isError = false) {
8
19
  try {
9
- return execSync(cmd, { cwd: dir, timeout: 60000, stdio: "ignore" });
20
+ const date = new Date();
21
+ const dateStr = date.toISOString().split('T')[0];
22
+ const isClaude = process.argv.join(' ').includes('claude');
23
+ const appName = isClaude ? "claude" : "opencode";
24
+ const configDir = getAppConfigDir(appName);
25
+
26
+ const logsDir = path.join(configDir, "logs", dateStr);
27
+ if (!fs.existsSync(logsDir)) {
28
+ fs.mkdirSync(logsDir, { recursive: true });
29
+ }
30
+
31
+ const logFile = path.join(logsDir, `updater-${dateStr}.log`);
32
+ const prefix = isError ? "[ERROR]" : "[INFO]";
33
+ const logMsg = `[${date.toISOString()}] ${prefix} ${message}\n`;
34
+
35
+ fs.appendFileSync(logFile, logMsg);
10
36
  } catch (e) {
11
- console.error(`[Updater] Git command failed: ${cmd} in ${dir}`);
37
+ // Silent fallback if logging fails
38
+ }
39
+ if (isError) console.error(message);
40
+ else console.log(message);
41
+ }
42
+
43
+ function getReposDir() {
44
+ const isClaude = process.argv.join(' ').includes('claude');
45
+ const appName = isClaude ? "claude" : "opencode";
46
+ return path.join(getAppConfigDir(appName), "repos");
47
+ }
48
+
49
+ function executeGit(command, cwd) {
50
+ writeLog(`Executing git: ${command} in ${cwd}`);
51
+ try {
52
+ execSync(command, { cwd, stdio: "ignore" });
53
+ return true;
54
+ } catch (error) {
55
+ writeLog(`Git error in ${cwd}: ${error.message}`, true);
12
56
  return false;
13
57
  }
14
58
  }
15
59
 
16
- module.exports = {
60
+ const updaterAPI = {
17
61
  name: "plugin-updater",
18
62
 
19
- /**
20
- * Called by the launcher (OpenCode/Claude Code) to sync a specific plugin
21
- */
63
+ earlyLaunch: function(configDir) {
64
+ EARLY_LAUNCH_CONFIG_DIR = configDir;
65
+ global.__PLUGIN_UPDATER_HANDLED_BY_HUB__ = true;
66
+ },
67
+
22
68
  updatePlugin: function(pluginName, gitUrl, branch = null, commitHash = null) {
23
- const targetDir = path.join(REPOS_DIR, pluginName);
69
+ const reposDir = getReposDir();
70
+ const targetDir = path.join(reposDir, pluginName);
24
71
 
25
- // 1. Ensure directory exists and clone or pull
26
72
  if (!fs.existsSync(targetDir)) {
27
- if (!fs.existsSync(REPOS_DIR)) fs.mkdirSync(REPOS_DIR, { recursive: true });
73
+ if (!fs.existsSync(reposDir)) fs.mkdirSync(reposDir, { recursive: true });
28
74
  const branchFlag = branch ? `--branch ${branch}` : "";
29
- executeGit(`git clone --recurse-submodules ${branchFlag} ${gitUrl} ${pluginName}`, REPOS_DIR);
75
+ executeGit(`git clone --recurse-submodules ${branchFlag} ${gitUrl} ${pluginName}`, reposDir);
30
76
  } else {
31
77
  executeGit("git fetch origin", targetDir);
32
78
  if (commitHash) {
@@ -40,53 +86,134 @@ module.exports = {
40
86
  }
41
87
  executeGit("git submodule update --init --recursive", targetDir);
42
88
  }
43
-
44
89
  return true;
45
90
  },
46
91
 
47
- /**
48
- * Called to deploy the compiled output to the execution directory
49
- */
50
92
  deployToExecutionDir: function(pluginName, executionPath) {
51
- const sourceDir = path.join(REPOS_DIR, pluginName);
93
+ const sourceDir = path.join(getReposDir(), pluginName);
52
94
  if (!fs.existsSync(sourceDir)) return false;
53
95
 
54
- // Build if package.json exists
55
- if (fs.existsSync(path.join(sourceDir, "package.json"))) {
96
+ const packageJsonPath = path.join(sourceDir, "package.json");
97
+ if (fs.existsSync(packageJsonPath)) {
56
98
  try {
99
+ writeLog(`Running npm install for ${pluginName}`);
57
100
  execSync("npm install", { cwd: sourceDir, stdio: "ignore" });
58
- execSync("npm run build", { cwd: sourceDir, stdio: "ignore" });
59
- } catch (e) {
60
- // Fallback or ignore if no build step
101
+ writeLog(`Finished npm install for ${pluginName}`);
102
+
103
+ // Safely check if a build script exists before executing
104
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
105
+ if (pkg.scripts && pkg.scripts.build) {
106
+ execSync("npm run build", { cwd: sourceDir, stdio: "ignore" });
107
+ writeLog(`Finished npm run build for ${pluginName}`);
108
+ } else {
109
+ writeLog(`Skipped npm run build for ${pluginName} (no build script found)`);
110
+ }
111
+ } catch (error) {
112
+ writeLog(`Build/Install failed for ${pluginName}: ${error.message}`, true);
61
113
  }
62
114
  }
63
115
 
64
- // Determine deployment source (prefer dist, fallback to root)
65
116
  const distPath = path.join(sourceDir, "dist");
66
117
  const deploySource = fs.existsSync(distPath) ? distPath : sourceDir;
118
+ const pluginExecutionPath = path.join(executionPath, pluginName);
67
119
 
68
- // Copy to execution path
69
- if (!fs.existsSync(executionPath)) {
70
- fs.mkdirSync(executionPath, { recursive: true });
120
+ if (!fs.existsSync(pluginExecutionPath)) {
121
+ fs.mkdirSync(pluginExecutionPath, { recursive: true });
71
122
  }
72
123
 
73
124
  try {
74
- // Platform agnostic copy (using Node fs)
75
- fs.cpSync(deploySource, executionPath, { recursive: true, force: true });
76
- return true;
125
+ writeLog(`Running cpSync for ${pluginName}`);
126
+ fs.cpSync(deploySource, pluginExecutionPath, { recursive: true, force: true });
127
+ writeLog(`Finished cpSync for ${pluginName}`);
77
128
  } catch (e) {
78
- console.error(`[Updater] Deploy failed: ${e.message}`);
79
- return false;
129
+ writeLog(`cpSync failed for ${pluginName}: ${e.message}`, true);
130
+ }
131
+ return true;
132
+ },
133
+
134
+ rebuild: function(pluginName) {
135
+ const isClaude = process.argv.join(' ').includes('claude');
136
+ const configDir = getAppConfigDir(isClaude ? "claude" : "opencode");
137
+ this.deployToExecutionDir(pluginName, path.join(configDir, "plugin"));
138
+ return "Rebuilt " + pluginName;
139
+ },
140
+
141
+ downgrade: function(pluginName, commitHash) {
142
+ const reposDir = getReposDir();
143
+ const targetDir = path.join(reposDir, pluginName);
144
+ if (fs.existsSync(targetDir)) {
145
+ executeGit(`git fetch origin`, targetDir);
146
+ executeGit(`git checkout ${commitHash}`, targetDir);
147
+ executeGit(`git submodule update --init --recursive`, targetDir);
148
+ return this.rebuild(pluginName);
80
149
  }
150
+ return "Repo not found";
81
151
  },
82
152
 
83
- /**
84
- * Specific logic to install/update the launcher itself
85
- */
86
- installLauncher: function() {
87
- // Logic to install opencode-hub / claude-hub if they are missing
88
- this.updatePlugin("core-hub", "https://github.com/intisy/core-hub.git");
89
- this.updatePlugin("opencode-hub", "https://github.com/intisy/opencode-hub.git");
90
- this.updatePlugin("claude-hub", "https://github.com/intisy/claude-hub.git");
153
+ disable: function(plugin) {
154
+ const isClaude = process.argv.join(' ').includes('claude');
155
+ const configDir = getAppConfigDir(isClaude ? "claude" : "opencode");
156
+ const pluginsJsonPath = path.join(configDir, "config", "plugins.json");
157
+ if (fs.existsSync(pluginsJsonPath)) {
158
+ let plugins = JSON.parse(fs.readFileSync(pluginsJsonPath, "utf-8"));
159
+ const pluginIndex = plugins.findIndex(p => p.name === plugin.name);
160
+ if (pluginIndex >= 0) {
161
+ plugins[pluginIndex].enabled = false;
162
+ fs.writeFileSync(pluginsJsonPath, JSON.stringify(plugins, null, 2), "utf-8");
163
+ }
164
+ }
165
+ const pluginExecutionPath = path.join(configDir, "plugin", plugin.name);
166
+ if (fs.existsSync(pluginExecutionPath)) {
167
+ try { fs.rmSync(pluginExecutionPath, { recursive: true, force: true }); } catch (e) {}
168
+ }
169
+ },
170
+
171
+ uninstall: function(plugin) {
172
+ this.disable(plugin);
173
+ const targetDir = path.join(getReposDir(), plugin.name);
174
+ if (fs.existsSync(targetDir)) {
175
+ try { fs.rmSync(targetDir, { recursive: true, force: true }); } catch (e) {}
176
+ }
91
177
  }
92
178
  };
179
+
180
+ const pluginUpdaterEntry = async function(input) {
181
+ const configDir = (input && input.configDir) ? input.configDir : path.dirname(getReposDir());
182
+
183
+ // 1. GUARANTEE BASE DIRECTORIES EXIST ON LAUNCH
184
+ const reposDir = path.join(configDir, "repos");
185
+ const pluginsDir = path.join(configDir, "plugin");
186
+ if (!fs.existsSync(reposDir)) fs.mkdirSync(reposDir, { recursive: true });
187
+ if (!fs.existsSync(pluginsDir)) fs.mkdirSync(pluginsDir, { recursive: true });
188
+
189
+ if (!global.__PLUGIN_UPDATER_HANDLED_BY_HUB__) {
190
+ updaterAPI.earlyLaunch(configDir);
191
+
192
+ const pluginsJsonPath = path.join(configDir, "config", "plugins.json");
193
+ if (fs.existsSync(pluginsJsonPath)) {
194
+ try {
195
+ const plugins = JSON.parse(fs.readFileSync(pluginsJsonPath, "utf-8"));
196
+ for (const plugin of plugins) {
197
+ if (plugin.url && plugin.enabled !== false && plugin.type !== "npm") {
198
+ const branch = plugin.branch || null;
199
+ const commit = plugin.commit || null;
200
+ updaterAPI.updatePlugin(plugin.name, plugin.url, branch, commit);
201
+ updaterAPI.deployToExecutionDir(plugin.name, pluginsDir);
202
+ }
203
+ }
204
+ } catch (e) {
205
+ writeLog(`Failed to parse plugins.json: ${e.message}`, true);
206
+ }
207
+ }
208
+ }
209
+ return {};
210
+ };
211
+
212
+ const apiMethods = { ...updaterAPI };
213
+ delete apiMethods.name;
214
+ Object.assign(pluginUpdaterEntry, apiMethods);
215
+
216
+ // Guarantee the entry point can be activated as a standard plugin
217
+ pluginUpdaterEntry.activate = async function() { return await pluginUpdaterEntry(); };
218
+
219
+ module.exports = pluginUpdaterEntry;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plugin-updater",
3
- "version": "1.0.0",
3
+ "version": "1.0.9",
4
4
  "description": "Plugin lifecycle manager for OpenCode and Claude Code launchers",
5
5
  "main": "index.js",
6
6
  "license": "MIT",
@@ -9,5 +9,11 @@
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/intisy/plugin-updater.git"
11
11
  },
12
- "keywords": ["opencode", "claude", "plugin", "updater", "lifecycle"]
12
+ "keywords": [
13
+ "opencode",
14
+ "claude",
15
+ "plugin",
16
+ "updater",
17
+ "lifecycle"
18
+ ]
13
19
  }