refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
This commit is contained in:
@@ -7,24 +7,24 @@
|
||||
* `<configRoot>/package.json` unconditionally — no existence check, no merge,
|
||||
* no backup. On OpenCode and Kilo that file is documented, user-writable
|
||||
* territory (it is where local-plugin npm dependencies are declared), so every
|
||||
* install and every `/gsd-update` destroyed the user's `name`, `type`,
|
||||
* install and every `/msd-update` destroyed the user's `name`, `type`,
|
||||
* `dependencies`, and `scripts`.
|
||||
*
|
||||
* The uninstall path had always read the file first and unlinked it only on an
|
||||
* exact content match. The defect was that asymmetry: the discipline existed,
|
||||
* it just was not applied on the write side.
|
||||
*
|
||||
* The fix moves the marker into the directories GSD actually fills with its own
|
||||
* The fix moves the marker into the directories MSD actually fills with its own
|
||||
* `.js` files — `hooks/` and the `nativePlugin.dir` — and routes install and
|
||||
* uninstall through one shared ownership predicate. These tests pin both
|
||||
* halves: the config root is never written, and a user-authored package.json is
|
||||
* never overwritten even where GSD does write.
|
||||
* never overwritten even where MSD does write.
|
||||
*
|
||||
* Coverage maps to the issue's acceptance criteria:
|
||||
* AC1 — a user-authored config-root package.json survives a fresh install
|
||||
* AC2 — it survives a second install (the `/gsd-update` re-install path)
|
||||
* AC3 — GSD's staged .js files still resolve as CommonJS
|
||||
* AC4 — uninstall removes only markers GSD wrote
|
||||
* AC2 — it survives a second install (the `/msd-update` re-install path)
|
||||
* AC3 — MSD's staged .js files still resolve as CommonJS
|
||||
* AC4 — uninstall removes only markers MSD wrote
|
||||
*/
|
||||
|
||||
const { test, describe, before } = require('node:test');
|
||||
@@ -43,7 +43,7 @@ const {
|
||||
classifyMarker,
|
||||
ensureCommonJsMarker,
|
||||
removeCommonJsMarker,
|
||||
} = require('../gsd-core/bin/lib/commonjs-marker.cjs');
|
||||
} = require('../msd-core/bin/lib/commonjs-marker.cjs');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
@@ -70,12 +70,12 @@ function mkTmp(prefix) {
|
||||
* Run the real installer against a throwaway config root.
|
||||
*
|
||||
* HOME/USERPROFILE/CLAUDE_CONFIG_DIR are all redirected into the temp tree so
|
||||
* the installer can never reach the developer's live profile — gsd-core's
|
||||
* the installer can never reach the developer's live profile — msd-core's
|
||||
* installer resolves through exactly those variables.
|
||||
*/
|
||||
function runInstall(root, runtime, extraArgs = []) {
|
||||
const env = { ...process.env, HOME: root, USERPROFILE: root, CLAUDE_CONFIG_DIR: root };
|
||||
delete env.GSD_TEST_MODE;
|
||||
delete env.MSD_TEST_MODE;
|
||||
const result = runNode(
|
||||
[INSTALL_SCRIPT, `--${runtime}`, '--global', '--config-dir', root, ...extraArgs],
|
||||
{ cwd: root, env, timeoutMs: INSTALL_TIMEOUT_MS },
|
||||
@@ -89,14 +89,14 @@ function runInstall(root, runtime, extraArgs = []) {
|
||||
}
|
||||
|
||||
describe('commonjs-marker: ownership predicate', () => {
|
||||
test('classifies absent, GSD-owned, and foreign package.json files', (t) => {
|
||||
test('classifies absent, MSD-owned, and foreign package.json files', (t) => {
|
||||
const dir = mkTmp('cjs-marker-classify-');
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
assert.equal(classifyMarker(dir), 'absent');
|
||||
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), `${COMMONJS_MARKER}\n`);
|
||||
assert.equal(classifyMarker(dir), 'gsd-owned');
|
||||
assert.equal(classifyMarker(dir), 'msd-owned');
|
||||
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), USER_PACKAGE_JSON);
|
||||
assert.equal(classifyMarker(dir), 'foreign');
|
||||
@@ -130,12 +130,12 @@ describe('commonjs-marker: ownership predicate', () => {
|
||||
|
||||
// A DANGLING symlink is the dangerous case: existsSync() reports false for
|
||||
// it, so an existsSync-based guard would classify `absent` and then write
|
||||
// straight through the link, landing outside the directory GSD owns.
|
||||
// straight through the link, landing outside the directory MSD owns.
|
||||
fs.symlinkSync(outside, link);
|
||||
assert.equal(classifyMarker(owned), 'foreign');
|
||||
assert.equal(ensureCommonJsMarker(owned), 'preserved-foreign');
|
||||
assert.ok(!fs.existsSync(outside), 'the write must not follow the symlink out of the directory');
|
||||
assert.equal(removeCommonJsMarker(owned), false, 'a symlink is never GSD-owned');
|
||||
assert.equal(removeCommonJsMarker(owned), false, 'a symlink is never MSD-owned');
|
||||
assert.ok(fs.lstatSync(link).isSymbolicLink(), 'the symlink itself must survive');
|
||||
});
|
||||
|
||||
@@ -148,12 +148,12 @@ describe('commonjs-marker: ownership predicate', () => {
|
||||
// (cursor/windsurf/codex). That copy probed with `fs.existsSync`, which
|
||||
// follows symlinks and reports false for a DANGLING one — so it classified
|
||||
// the link `absent` and wrote straight through it, landing outside the
|
||||
// directory GSD owns. #2544 makes it delegate to commonjs-marker instead.
|
||||
// directory MSD owns. #2544 makes it delegate to commonjs-marker instead.
|
||||
//
|
||||
// This asserts the hardening reaches that path, not just the module: it is
|
||||
// the only coverage that fails if the duplicate is ever reintroduced, since
|
||||
// the two implementations agree on every non-adversarial input.
|
||||
const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
|
||||
const hooksSurface = require('../msd-core/bin/lib/runtime-hooks-surface.cjs');
|
||||
|
||||
const outside = path.join(dir, 'escaped.json');
|
||||
const owned = path.join(dir, 'hooks');
|
||||
@@ -163,29 +163,29 @@ describe('commonjs-marker: ownership predicate', () => {
|
||||
assert.equal(
|
||||
hooksSurface.ensureCommonJsMarker(owned),
|
||||
false,
|
||||
'a dangling symlink is not GSD-owned, so the marker must not be reported present',
|
||||
'a dangling symlink is not MSD-owned, so the marker must not be reported present',
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(outside),
|
||||
'the write must not follow the symlink out of the hooks directory',
|
||||
);
|
||||
assert.equal(
|
||||
hooksSurface.removeCommonJsMarkerIfGsdOwned(owned),
|
||||
hooksSurface.removeCommonJsMarkerIfMsdOwned(owned),
|
||||
false,
|
||||
'a symlink is never GSD-owned, so uninstall must not remove it',
|
||||
'a symlink is never MSD-owned, so uninstall must not remove it',
|
||||
);
|
||||
});
|
||||
|
||||
// The #2717 writers mkdir hooks/ unconditionally, then staged their scripts
|
||||
// conditionally on the source existing — so with an empty hooks source they
|
||||
// created a directory, filled it with nothing, and marked it as GSD's anyway.
|
||||
// That is the same write-into-territory-GSD-did-not-fill this issue is about,
|
||||
// created a directory, filled it with nothing, and marked it as MSD's anyway.
|
||||
// That is the same write-into-territory-MSD-did-not-fill this issue is about,
|
||||
// and it is what `stagedHooks` guards on the shared-bundle path. These pin the
|
||||
// matching gate on the two dedicated writers.
|
||||
for (const rt of ['cursor', 'windsurf']) {
|
||||
test(`${rt}: staging zero hook scripts leaves hooks/ marker-free`, (t) => {
|
||||
const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
|
||||
const root = mkTmp(`gsd-2544-${rt}-nostage-`);
|
||||
const hooksSurface = require('../msd-core/bin/lib/runtime-hooks-surface.cjs');
|
||||
const root = mkTmp(`msd-2544-${rt}-nostage-`);
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
// A src tree whose hooks/ dir exists but holds none of the runtime's
|
||||
@@ -212,7 +212,7 @@ describe('commonjs-marker: ownership predicate', () => {
|
||||
});
|
||||
}
|
||||
|
||||
test('removeCommonJsMarker removes only GSD-owned markers', (t) => {
|
||||
test('removeCommonJsMarker removes only MSD-owned markers', (t) => {
|
||||
const dir = mkTmp('cjs-marker-remove-');
|
||||
t.after(() => cleanup(dir));
|
||||
const target = path.join(dir, 'package.json');
|
||||
@@ -277,7 +277,7 @@ describe('commonjs-marker: fault injection', () => {
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), `${COMMONJS_MARKER}\n`);
|
||||
|
||||
// Present-but-unreadable never downgrades to the permissive answer — the
|
||||
// file's bytes are exactly GSD's marker, and it must STILL classify foreign
|
||||
// file's bytes are exactly MSD's marker, and it must STILL classify foreign
|
||||
// because we could not prove it.
|
||||
const result = withPatched('readFileSync', () => { throw errWith('EACCES'); },
|
||||
() => classifyMarker(dir));
|
||||
@@ -360,7 +360,7 @@ describe('#2544 regression: install must not clobber the config-root package.jso
|
||||
|
||||
for (const runtime of ['opencode', 'claude']) {
|
||||
test(`${runtime}: a user-authored package.json survives install and re-install`, (t) => {
|
||||
const root = mkTmp(`gsd-2544-${runtime}-`);
|
||||
const root = mkTmp(`msd-2544-${runtime}-`);
|
||||
t.after(() => cleanup(root));
|
||||
const userPkg = path.join(root, 'package.json');
|
||||
fs.writeFileSync(userPkg, USER_PACKAGE_JSON);
|
||||
@@ -374,7 +374,7 @@ describe('#2544 regression: install must not clobber the config-root package.jso
|
||||
'fresh install must not modify the user-authored config-root package.json',
|
||||
);
|
||||
|
||||
// AC2 — the /gsd-update re-install path leaves it untouched too.
|
||||
// AC2 — the /msd-update re-install path leaves it untouched too.
|
||||
runInstall(root, runtime);
|
||||
assert.equal(
|
||||
sha256(fs.readFileSync(userPkg)),
|
||||
@@ -389,8 +389,8 @@ describe('#2544 regression: install must not clobber the config-root package.jso
|
||||
assert.deepEqual(parsed.dependencies, { shescape: '^2.1.0', zod: '^3.23.8' });
|
||||
assert.deepEqual(parsed.scripts, { postinstall: 'echo user-owned' });
|
||||
|
||||
// AC3 — GSD's own staged scripts still get a CommonJS marker, from the
|
||||
// directory GSD owns, so `require` keeps working under "type": "module".
|
||||
// AC3 — MSD's own staged scripts still get a CommonJS marker, from the
|
||||
// directory MSD owns, so `require` keeps working under "type": "module".
|
||||
const hooksMarker = path.join(root, 'hooks', 'package.json');
|
||||
assert.ok(fs.existsSync(hooksMarker), 'hooks/package.json marker must be staged');
|
||||
assert.equal(JSON.parse(fs.readFileSync(hooksMarker, 'utf8')).type, 'commonjs');
|
||||
@@ -398,10 +398,10 @@ describe('#2544 regression: install must not clobber the config-root package.jso
|
||||
}
|
||||
|
||||
test('staged hook helpers still load as CommonJS under a "type": "module" config root', (t) => {
|
||||
const root = mkTmp('gsd-2544-esm-');
|
||||
const root = mkTmp('msd-2544-esm-');
|
||||
t.after(() => cleanup(root));
|
||||
// The config root declares ESM — the exact shape that breaks Node's
|
||||
// walk-up resolution for GSD's staged .js files.
|
||||
// walk-up resolution for MSD's staged .js files.
|
||||
fs.writeFileSync(path.join(root, 'package.json'), USER_PACKAGE_JSON);
|
||||
runInstall(root, 'opencode');
|
||||
|
||||
@@ -423,32 +423,32 @@ describe('#2544 regression: install must not clobber the config-root package.jso
|
||||
});
|
||||
|
||||
test('opencode: the native plugin dir gets its own marker', (t) => {
|
||||
const root = mkTmp('gsd-2544-plugin-');
|
||||
const root = mkTmp('msd-2544-plugin-');
|
||||
t.after(() => cleanup(root));
|
||||
runInstall(root, 'opencode');
|
||||
|
||||
// The adapter is staged as .js, so it needs a marker in its own directory
|
||||
// now that the config root no longer carries one. A package.json here is
|
||||
// inert to plugin discovery: OpenCode globs plugins/*.{ts,js}.
|
||||
assert.ok(fs.existsSync(path.join(root, 'plugins', 'gsd-core.js')));
|
||||
assert.ok(fs.existsSync(path.join(root, 'plugins', 'msd-core.js')));
|
||||
const pluginMarker = path.join(root, 'plugins', 'package.json');
|
||||
assert.ok(fs.existsSync(pluginMarker), 'plugins/package.json marker must be staged');
|
||||
assert.equal(JSON.parse(fs.readFileSync(pluginMarker, 'utf8')).type, 'commonjs');
|
||||
});
|
||||
|
||||
test('install writes no package.json at the config root when none existed', (t) => {
|
||||
const root = mkTmp('gsd-2544-noroot-');
|
||||
const root = mkTmp('msd-2544-noroot-');
|
||||
t.after(() => cleanup(root));
|
||||
runInstall(root, 'opencode');
|
||||
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(root, 'package.json')),
|
||||
'GSD must not create a package.json in the runtime config root',
|
||||
'MSD must not create a package.json in the runtime config root',
|
||||
);
|
||||
});
|
||||
|
||||
test('uninstall removes GSD markers but preserves a user-authored one', (t) => {
|
||||
const root = mkTmp('gsd-2544-uninstall-');
|
||||
test('uninstall removes MSD markers but preserves a user-authored one', (t) => {
|
||||
const root = mkTmp('msd-2544-uninstall-');
|
||||
t.after(() => cleanup(root));
|
||||
const userPkg = path.join(root, 'package.json');
|
||||
fs.writeFileSync(userPkg, USER_PACKAGE_JSON);
|
||||
@@ -457,7 +457,7 @@ describe('#2544 regression: install must not clobber the config-root package.jso
|
||||
runInstall(root, 'opencode');
|
||||
runInstall(root, 'opencode', ['--uninstall']);
|
||||
|
||||
// AC4 — GSD's own markers are gone; the user's file is untouched.
|
||||
// AC4 — MSD's own markers are gone; the user's file is untouched.
|
||||
assert.ok(fs.existsSync(userPkg), 'uninstall must not remove a user-authored package.json');
|
||||
assert.equal(sha256(fs.readFileSync(userPkg)), before);
|
||||
assert.ok(
|
||||
@@ -470,19 +470,19 @@ describe('#2544 regression: install must not clobber the config-root package.jso
|
||||
);
|
||||
});
|
||||
|
||||
test('uninstall does not prune a plugin dir GSD removed nothing from', (t) => {
|
||||
const root = mkTmp('gsd-2544-rmdir-');
|
||||
test('uninstall does not prune a plugin dir MSD removed nothing from', (t) => {
|
||||
const root = mkTmp('msd-2544-rmdir-');
|
||||
t.after(() => cleanup(root));
|
||||
runInstall(root, 'opencode');
|
||||
|
||||
// Strip GSD's own artifacts by hand, leaving an EMPTY plugins/ directory
|
||||
// that — from uninstall's point of view — GSD never filled. Hoisting the
|
||||
// Strip MSD's own artifacts by hand, leaving an EMPTY plugins/ directory
|
||||
// that — from uninstall's point of view — MSD never filled. Hoisting the
|
||||
// rmdir out of the adapter-exists guard (so the marker-only case could
|
||||
// prune) must not widen it into deleting a user-created empty plugin dir:
|
||||
// that is the same "don't touch territory GSD didn't fill" principle this
|
||||
// that is the same "don't touch territory MSD didn't fill" principle this
|
||||
// issue is about, inverted.
|
||||
const pluginsDir = path.join(root, 'plugins');
|
||||
fs.unlinkSync(path.join(pluginsDir, 'gsd-core.js'));
|
||||
fs.unlinkSync(path.join(pluginsDir, 'msd-core.js'));
|
||||
fs.unlinkSync(path.join(pluginsDir, 'package.json'));
|
||||
assert.deepEqual(fs.readdirSync(pluginsDir), [], 'precondition: the dir is empty');
|
||||
|
||||
@@ -490,19 +490,19 @@ describe('#2544 regression: install must not clobber the config-root package.jso
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(pluginsDir),
|
||||
'an empty plugin dir GSD removed nothing from must survive uninstall',
|
||||
'an empty plugin dir MSD removed nothing from must survive uninstall',
|
||||
);
|
||||
});
|
||||
|
||||
test('uninstall reclaims the plugin-dir marker even if the adapter is already gone', (t) => {
|
||||
const root = mkTmp('gsd-2544-partial-');
|
||||
const root = mkTmp('msd-2544-partial-');
|
||||
t.after(() => cleanup(root));
|
||||
runInstall(root, 'opencode');
|
||||
|
||||
// Model a partial install / hand-deleted adapter. The marker cleanup must
|
||||
// not be gated on the adapter still being present, or it is stranded and
|
||||
// the directory can never prune.
|
||||
fs.unlinkSync(path.join(root, 'plugins', 'gsd-core.js'));
|
||||
fs.unlinkSync(path.join(root, 'plugins', 'msd-core.js'));
|
||||
runInstall(root, 'opencode', ['--uninstall']);
|
||||
|
||||
assert.ok(
|
||||
@@ -511,12 +511,12 @@ describe('#2544 regression: install must not clobber the config-root package.jso
|
||||
);
|
||||
});
|
||||
|
||||
test('a pre-existing hooks/ dir GSD never fills stays marker-free', (t) => {
|
||||
const root = mkTmp('gsd-2544-stagedhooks-');
|
||||
test('a pre-existing hooks/ dir MSD never fills stays marker-free', (t) => {
|
||||
const root = mkTmp('msd-2544-stagedhooks-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
// Scope, stated precisely: this pins the OUTCOME — a pre-existing,
|
||||
// GSD-untouched hooks/ stays marker-free — for a runtime GSD stages no .js
|
||||
// MSD-untouched hooks/ stays marker-free — for a runtime MSD stages no .js
|
||||
// into. It does NOT exercise installSharedHooksBundle's `stagedHooks` gate:
|
||||
// zcode declares skipSharedHooksInstall, so the outer guard in bin/install.js
|
||||
// skips that helper entirely and the gate is never evaluated. For a runtime
|
||||
@@ -528,10 +528,10 @@ describe('#2544 regression: install must not clobber the config-root package.jso
|
||||
// ZCode, not Windsurf. Windsurf was the original choice because
|
||||
// hostBehaviors.skipSharedHooksInstall kept it out of the shared bundle —
|
||||
// but #2717 then began staging cursor/windsurf/codex .js hooks via dedicated
|
||||
// paths and writing the marker beside them, so for those three GSD now DOES
|
||||
// paths and writing the marker beside them, so for those three MSD now DOES
|
||||
// fill hooks/ and the marker is correct. ZCode is the durable choice: per
|
||||
// #1821 it has hooksSurface:'none' AND no plugin surface to spawn hooks, so
|
||||
// GSD stages no .js there by either route. (Measured on this tree: zcode
|
||||
// MSD stages no .js there by either route. (Measured on this tree: zcode
|
||||
// stages 0 .js hooks and gets no marker; windsurf stages 2 and gets one.)
|
||||
const userHooks = path.join(root, 'hooks');
|
||||
fs.mkdirSync(userHooks, { recursive: true });
|
||||
@@ -541,7 +541,7 @@ describe('#2544 regression: install must not clobber the config-root package.jso
|
||||
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(userHooks, 'package.json')),
|
||||
'GSD must not mark a hooks/ directory it never staged into',
|
||||
'MSD must not mark a hooks/ directory it never staged into',
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(userHooks, 'my-hook.js')),
|
||||
@@ -550,7 +550,7 @@ describe('#2544 regression: install must not clobber the config-root package.jso
|
||||
});
|
||||
|
||||
test('pi: the extensions/ marker is installed and reclaimed on uninstall', (t) => {
|
||||
const root = mkTmp('gsd-2544-pi-');
|
||||
const root = mkTmp('msd-2544-pi-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
runInstall(root, 'pi');
|
||||
@@ -563,7 +563,7 @@ describe('#2544 regression: install must not clobber the config-root package.jso
|
||||
});
|
||||
|
||||
test('pi: a user-authored extensions/package.json survives install and uninstall', (t) => {
|
||||
const root = mkTmp('gsd-2544-pi-user-');
|
||||
const root = mkTmp('msd-2544-pi-user-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const extDir = path.join(root, 'extensions');
|
||||
@@ -582,7 +582,7 @@ describe('#2544 regression: install must not clobber the config-root package.jso
|
||||
});
|
||||
|
||||
test('kimi: the marker lives under hooks/, and the legacy root marker is retired', (t) => {
|
||||
const root = mkTmp('gsd-2544-kimi-');
|
||||
const root = mkTmp('msd-2544-kimi-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
// HOME is redirected to `root`, so kimi's native hook root
|
||||
@@ -612,7 +612,7 @@ describe('#2544 regression: install must not clobber the config-root package.jso
|
||||
});
|
||||
|
||||
test('kimi: a user-authored package.json at the .kimi root is never retired', (t) => {
|
||||
const root = mkTmp('gsd-2544-kimi-user-');
|
||||
const root = mkTmp('msd-2544-kimi-user-');
|
||||
t.after(() => cleanup(root));
|
||||
const kimiRoot = path.join(root, '.kimi');
|
||||
fs.mkdirSync(kimiRoot, { recursive: true });
|
||||
@@ -627,7 +627,7 @@ describe('#2544 regression: install must not clobber the config-root package.jso
|
||||
});
|
||||
|
||||
test('kimi: uninstall reclaims the hooks/ marker', (t) => {
|
||||
const root = mkTmp('gsd-2544-kimi-uninstall-');
|
||||
const root = mkTmp('msd-2544-kimi-uninstall-');
|
||||
t.after(() => cleanup(root));
|
||||
const kimiRoot = path.join(root, '.kimi');
|
||||
|
||||
@@ -642,7 +642,7 @@ describe('#2544 regression: install must not clobber the config-root package.jso
|
||||
});
|
||||
|
||||
test('uninstall retires a pre-#2544 config-root marker', (t) => {
|
||||
const root = mkTmp('gsd-2544-legacy-');
|
||||
const root = mkTmp('msd-2544-legacy-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
runInstall(root, 'opencode');
|
||||
|
||||
Reference in New Issue
Block a user