plugin-updater 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ process.env.PLUGIN_UPDATER_CLI = "1";
4
4
  import fs from "fs";
5
5
  import path from "path";
6
6
  import os from "os";
7
+ import { resolveOpencodeConfigPath, insertPluginIntoJsonc, resolveInitApps, cwdApp } from "./init.js";
7
8
  function parseArgs(argv) {
8
9
  const parsed = { command: argv[0] ?? "", urls: [] };
9
10
  for (let i = 1; i < argv.length; i++) {
@@ -83,18 +84,76 @@ function registerClaudeHook(configDir) {
83
84
  console.log(`Registered SessionStart hook in ${settingsPath}`);
84
85
  }
85
86
  function registerOpencodePlugin(configDir) {
86
- const ocPath = path.join(configDir, "opencode.json");
87
- const oc = (fs.existsSync(ocPath) ? readJson(ocPath) : {}) ?? {};
88
- const plugins = Array.isArray(oc.plugin) ? oc.plugin : [];
89
- if (!plugins.some((p) => p === "plugin-updater" || p.startsWith("plugin-updater@"))) {
90
- plugins.unshift("plugin-updater");
87
+ // Edit the EXISTING opencode config (opencode.json, else opencode.jsonc) rather than
88
+ // always creating opencode.json opencode reads either and two files are confusing.
89
+ const ocPath = resolveOpencodeConfigPath(configDir);
90
+ const exists = fs.existsSync(ocPath);
91
+ const raw = exists ? fs.readFileSync(ocPath, "utf8") : "";
92
+ const parsed = (exists ? readJson(ocPath) : null) ?? null;
93
+ // opencode plugin entries may be a string OR a [name, options] tuple — guard accordingly
94
+ const plugins = Array.isArray(parsed?.plugin) ? parsed.plugin : [];
95
+ const has = plugins.some((p) => p === "plugin-updater"
96
+ || (typeof p === "string" && p.startsWith("plugin-updater@"))
97
+ || (Array.isArray(p) && p[0] === "plugin-updater"));
98
+ if (has) {
99
+ console.log(`plugin-updater already registered in ${ocPath}`);
100
+ return;
91
101
  }
92
- oc.plugin = plugins;
102
+ // Comment-preserving in-place insert for an existing file; fall back to a fresh JSON
103
+ // write only when there's no file or the text can't be safely edited.
104
+ if (exists && raw.trim()) {
105
+ const edited = insertPluginIntoJsonc(raw, "plugin-updater", Array.isArray(parsed?.plugin));
106
+ if (edited) {
107
+ fs.writeFileSync(ocPath, edited, "utf8");
108
+ console.log(`Registered plugin-updater in ${ocPath}`);
109
+ return;
110
+ }
111
+ }
112
+ const oc = (parsed ?? {});
113
+ oc.plugin = ["plugin-updater", ...plugins];
93
114
  if (!oc.$schema)
94
115
  oc.$schema = "https://opencode.ai/config.json";
95
116
  fs.writeFileSync(ocPath, JSON.stringify(oc, null, 2), "utf8");
96
117
  console.log(`Registered plugin-updater in ${ocPath}`);
97
118
  }
119
+ // which apps are installed on this machine (used when no --app is given)
120
+ function presentApps() {
121
+ const claude = fs.existsSync(path.join(os.homedir(), ".claude")) || binaryExists("claude");
122
+ const opencode = fs.existsSync(path.join(os.homedir(), ".opencode"))
123
+ || fs.existsSync(path.join(os.homedir(), ".config", "opencode"))
124
+ || binaryExists("opencode");
125
+ return { claude, opencode };
126
+ }
127
+ // interactive picker shown when both/neither app is detected and no --app was passed.
128
+ // defaultApp (cwd-inferred) may be null — then there is no default and an empty answer
129
+ // re-asks rather than guessing.
130
+ async function promptInitApps(_present, defaultApp) {
131
+ const readline = await import("readline/promises");
132
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
133
+ try {
134
+ console.log([
135
+ "Initialize plugins for which app?",
136
+ ` [1] opencode${defaultApp === "opencode" ? " (default)" : ""}`,
137
+ ` [2] claude${defaultApp === "claude" ? " (default)" : ""}`,
138
+ " [3] both",
139
+ ].join("\n"));
140
+ for (;;) {
141
+ const ans = (await rl.question(`> ${defaultApp ? `[${defaultApp}] ` : "1/2/3 "}`)).trim().toLowerCase();
142
+ if (ans === "1" || ans === "opencode")
143
+ return ["opencode"];
144
+ if (ans === "2" || ans === "claude")
145
+ return ["claude"];
146
+ if (ans === "3" || ans === "both")
147
+ return ["opencode", "claude"];
148
+ if (ans === "" && defaultApp)
149
+ return [defaultApp];
150
+ // empty with no default, or unrecognized → re-ask
151
+ }
152
+ }
153
+ finally {
154
+ rl.close();
155
+ }
156
+ }
98
157
  function addPluginEntry(configDir, url, branch, sync) {
99
158
  const cleanUrl = url.replace(/\.git$/, "");
100
159
  const name = cleanUrl.split("/").pop() ?? cleanUrl;
@@ -150,25 +209,41 @@ async function main() {
150
209
  console.log("usage: plugin-updater <init|add|remove|run> [git-urls-or-names...] [--app claude|opencode] [--branch name] [--sync]");
151
210
  process.exit(parsed.command ? 1 : 0);
152
211
  }
212
+ const updater = await import("./index.js");
213
+ // `init` may target one or both apps: explicit --app wins; otherwise a single
214
+ // detected app is used, and an ambiguous (both/neither) interactive run is prompted.
215
+ if (parsed.command === "init") {
216
+ const apps = await resolveInitApps(parsed.app, {
217
+ present: presentApps,
218
+ isTTY: Boolean(process.stdin.isTTY),
219
+ cwdApp,
220
+ prompt: promptInitApps,
221
+ });
222
+ for (const app of apps) {
223
+ process.env.PLUGIN_UPDATER_APP = app;
224
+ const configDir = getConfigDir(app);
225
+ if (!fs.existsSync(configDir))
226
+ fs.mkdirSync(configDir, { recursive: true });
227
+ console.log(`App: ${app} (${configDir})`);
228
+ ensurePluginsJson(configDir);
229
+ if (app === "claude")
230
+ registerClaudeHook(configDir);
231
+ else
232
+ registerOpencodePlugin(configDir);
233
+ for (const url of parsed.urls) {
234
+ await setupEntry(updater, configDir, url, parsed.branch, parsed.sync);
235
+ }
236
+ }
237
+ console.log("Init complete.");
238
+ return;
239
+ }
153
240
  const app = detectApp(parsed.app);
154
241
  process.env.PLUGIN_UPDATER_APP = app;
155
242
  const configDir = getConfigDir(app);
156
243
  if (!fs.existsSync(configDir))
157
244
  fs.mkdirSync(configDir, { recursive: true });
158
245
  console.log(`App: ${app} (${configDir})`);
159
- const updater = await import("./index.js");
160
- if (parsed.command === "init") {
161
- ensurePluginsJson(configDir);
162
- if (app === "claude")
163
- registerClaudeHook(configDir);
164
- else
165
- registerOpencodePlugin(configDir);
166
- for (const url of parsed.urls) {
167
- await setupEntry(updater, configDir, url, parsed.branch, parsed.sync);
168
- }
169
- console.log("Init complete.");
170
- }
171
- else if (parsed.command === "add") {
246
+ if (parsed.command === "add") {
172
247
  if (parsed.urls.length === 0)
173
248
  throw new Error("add requires at least one git url");
174
249
  for (const url of parsed.urls) {
package/dist/init.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export declare function resolveOpencodeConfigPath(configDir: string, exists?: (p: string) => boolean): string;
2
+ export declare function insertPluginIntoJsonc(raw: string, pluginName: string, hasPluginKey: boolean): string | null;
3
+ export interface PresentApps {
4
+ claude: boolean;
5
+ opencode: boolean;
6
+ }
7
+ export declare function cwdApp(cwd?: string): string | null;
8
+ export interface InitAppDeps {
9
+ present: () => PresentApps;
10
+ isTTY: boolean;
11
+ cwdApp: () => string | null;
12
+ prompt: (present: PresentApps, defaultApp: string | null) => Promise<string[]>;
13
+ }
14
+ export declare function resolveInitApps(explicit: string | undefined, deps: InitAppDeps): Promise<string[]>;
package/dist/init.js ADDED
@@ -0,0 +1,76 @@
1
+ // Init-time helpers split out of cli.ts so the comment-preserving opencode-config
2
+ // edit and the app-resolution logic are unit-testable without running the CLI entry.
3
+ import fs from "fs";
4
+ import path from "path";
5
+ // ── opencode config target ───────────────────────────────────────────────────
6
+ // `init` must NOT create a fresh opencode.json when the user already has an
7
+ // opencode.jsonc — opencode reads either, and a second file is confusing. Prefer an
8
+ // existing opencode.json, else an existing opencode.jsonc, else default to .json.
9
+ export function resolveOpencodeConfigPath(configDir, exists = fs.existsSync) {
10
+ const jsonPath = path.join(configDir, "opencode.json");
11
+ const jsoncPath = path.join(configDir, "opencode.jsonc");
12
+ if (exists(jsonPath))
13
+ return jsonPath;
14
+ if (exists(jsoncPath))
15
+ return jsoncPath;
16
+ return jsonPath;
17
+ }
18
+ // Insert `pluginName` as the first entry of the root `plugin` array, editing the raw
19
+ // text in place so // comments and formatting elsewhere survive (a JSON.stringify
20
+ // rewrite would strip them). `hasPluginKey` (from the PARSED root, so it can't be
21
+ // fooled by a nested "plugin" key or one inside a string) selects the branch: true →
22
+ // insert into the existing array; false → add a new `plugin` key. Returns the edited
23
+ // text, or null if it can't be safely edited (caller then falls back to a JSON write).
24
+ export function insertPluginIntoJsonc(raw, pluginName, hasPluginKey) {
25
+ const entry = JSON.stringify(pluginName);
26
+ if (hasPluginKey) {
27
+ // Insert right after the root array's `[`. Prefer a line-anchored match so a deeper
28
+ // `"plugin": [` earlier in the file can't be picked; fall back to the first match.
29
+ const m = raw.match(/^[ \t]*"plugin"\s*:\s*\[/m) ?? raw.match(/"plugin"\s*:\s*\[/);
30
+ if (!m || m.index === undefined)
31
+ return null;
32
+ const at = m.index + m[0].length;
33
+ const rest = raw.slice(at);
34
+ const isEmpty = /^\s*\]/.test(rest);
35
+ return raw.slice(0, at) + (isEmpty ? entry : `${entry}, `) + rest;
36
+ }
37
+ // No `plugin` key — insert one right after the root `{`.
38
+ const brace = raw.indexOf("{");
39
+ if (brace === -1)
40
+ return null;
41
+ const afterBrace = raw.slice(brace + 1);
42
+ const isEmptyObject = /^\s*}/.test(afterBrace);
43
+ if (isEmptyObject) {
44
+ return raw.slice(0, brace + 1) + `\n "plugin": [${entry}]\n` + afterBrace;
45
+ }
46
+ return raw.slice(0, brace + 1) + `\n "plugin": [${entry}],` + afterBrace;
47
+ }
48
+ // Infer the app ONLY from the current directory actually being an app's config dir
49
+ // (~/.claude or ~/.config/opencode|~/.opencode). Returns null otherwise — e.g. /workspace
50
+ // — so the prompt offers no default rather than silently assuming opencode.
51
+ export function cwdApp(cwd = process.cwd()) {
52
+ const c = cwd.replace(/\\/g, "/");
53
+ if (/(^|\/)\.claude(\/|$)/.test(c))
54
+ return "claude";
55
+ if (/(^|\/)\.opencode(\/|$)/.test(c) || /(^|\/)\.config\/opencode(\/|$)/.test(c))
56
+ return "opencode";
57
+ return null;
58
+ }
59
+ // Decide which app(s) `init` targets. Explicit --app always wins. A single detected
60
+ // app is used directly. When both or neither are detected we PROMPT (if interactive)
61
+ // so the user can pick one or both; non-interactively we keep the hard error rather
62
+ // than guess. The prompt default is the cwd-inferred app, or null (no default) when
63
+ // the cwd gives no signal.
64
+ export async function resolveInitApps(explicit, deps) {
65
+ if (explicit === "claude" || explicit === "opencode")
66
+ return [explicit];
67
+ if (explicit)
68
+ throw new Error(`Unknown app "${explicit}" - use claude or opencode`);
69
+ const p = deps.present();
70
+ if (p.claude !== p.opencode)
71
+ return [p.claude ? "claude" : "opencode"];
72
+ if (!deps.isTTY) {
73
+ throw new Error("Both apps (or neither) found - pass --app claude or --app opencode");
74
+ }
75
+ return deps.prompt(p, deps.cwdApp());
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plugin-updater",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Plugin lifecycle manager for OpenCode and Claude Code launchers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",