Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions dist/commands/installer-hosts.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/commands/installer-hosts.js.map

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions dist/commitlore.mjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions installer/canonical-artifact.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
"tsconfig.json",
"src"
],
"sha256": "a282f5e66284c772c8f3241a41752d33602cf1f97963ceb2da2c12ebf8d1c220"
"sha256": "fcfc5f3245f6ab6f9f52b02a9009fe487e6c2ce8edf10e0cc7d59ae7ce7caa1d"
},
"artifact": {
"sha256": "424da7f436fef1ac93edfecb8f0462468188cb8777f84c14a8cdc5dbaf6412f7",
"sha256": "c312755d70a4d15b2743f95b9999694ef0fbc433f9ee645acc903c5275ef5a5d",
"files": [
{
"path": "dist/cli.d.ts",
Expand Down Expand Up @@ -470,11 +470,11 @@
},
{
"path": "dist/commands/installer-hosts.js",
"sha256": "86ff3416516d96127b0eec884d8b0213fdd27f2bde38a688c8533d55eafa69bc"
"sha256": "7861b16a068b507213a73c46c2f3b78cacd0e2de43870d74c38b28d826744707"
},
{
"path": "dist/commands/installer-hosts.js.map",
"sha256": "a04d4cde2809f2171bcd7613b30311d9070488529b85957eb9398d1d3c24bede"
"sha256": "65094d7209b453bb3553024aa3681531ccb845f57b57605078ae3a6b661adb8b"
},
{
"path": "dist/commands/mcp.d.ts",
Expand Down Expand Up @@ -586,7 +586,7 @@
},
{
"path": "dist/commitlore.mjs",
"sha256": "e5be60b859581ab513ea1971618d3366d644a60f61af6235d14714febb7c6fe9"
"sha256": "136348243607bca3c8f3130825d458a862444c440307d396f1977b34ea6da910"
},
{
"path": "dist/core/agent-configs.d.ts",
Expand Down
37 changes: 37 additions & 0 deletions src/commands/installer-hosts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,27 @@ const cliHost = async (host: string, wrapper: string): Promise<HostResult> => {
: { host, requested: true, outcome: 'failed', healthy: false, detail: `Codex registration was written but is unhealthy: ${problem.detail}` };
};

/**
* Refresh the marketplace, then install. Reported healthy only when both
* succeed — a plugin that is present at an old version is the case this exists
* to fix, so "already installed" is not success.
*/
const claudePluginHost = (): HostResult => {
const host = 'claude-code';
const run = (args: string[]): number | null =>
spawnSync('claude', args, { stdio: 'ignore', shell: false, timeout: 60_000 }).status;

if (run(['plugin', 'marketplace', 'add', 'MongLong0214/commitlore']) === null) {
return { host, requested: true, outcome: 'failed', healthy: false, detail: 'claude plugin marketplace add could not run' };
}
// Already-added is not an error, and update is what makes a new version
// visible. Both are attempted; only the install decides the verdict.
run(['plugin', 'marketplace', 'update', 'commitlore']);
return run(['plugin', 'install', 'commitlore@commitlore', '--scope', 'user']) === 0
? { host, requested: true, outcome: 'installed', healthy: true, detail: 'Claude Code plugin installed from the refreshed marketplace (restart running sessions to load it)' }
: { host, requested: true, outcome: 'failed', healthy: false, detail: 'claude plugin install failed — run manually: claude plugin marketplace update commitlore && claude plugin install commitlore@commitlore' };
};

export const inspectAndApplyHosts = async (options: Options): Promise<HostSummary> => {
const requested: Array<Promise<HostResult>> = [];
const notDetected: string[] = [];
Expand All @@ -230,6 +251,22 @@ export const inspectAndApplyHosts = async (options: Options): Promise<HostSummar
? { host: 'hermes', requested: true, outcome: 'installed', healthy: true, detail: 'Hermes setup verified' }
: { host: 'hermes', requested: true, outcome: 'failed', healthy: false, detail: 'Hermes setup failed' }));
} else notDetected.push('hermes');
/**
* Claude Code, which was in neither list until now (#689).
*
* `install.sh` defines `has_claude_code` and `wire_claude_code` and calls
* neither; this enumeration had no branch for it. So the plugin cache went
* untouched by every release and nothing said so — `notDetected: []` read as
* "every host was detected", which is how a missing host stays missing.
*
* It is a marketplace plugin rather than an MCP registration, so it needs the
* marketplace refreshed before installing: adding one that is already present
* is a no-op, and without the refresh a reinstall reinstates the same version
* (#660).
*/
if (hasCommand('claude')) {
requested.push(Promise.resolve(claudePluginHost()));
} else notDetected.push('claude-code');
const hosts = await Promise.all(requested);
return { schema: INSTALLER_HOSTS_SCHEMA, runtimeIdentity: runtimeIdentity(), ok: hosts.every((host) => host.healthy), hosts, notDetected };
};
Expand Down
89 changes: 89 additions & 0 deletions test/installer-host-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* #689: a host that is defined and never dispatched must not be possible.
*
* `install.sh` defines `has_claude_code` and `wire_claude_code` and calls
* neither, and the CLI's enumeration had no branch for it. So Claude Code
* appeared in neither `hosts` nor `notDetected`, the plugin cache went untouched
* by every release, and `notDetected: []` read as *every host was detected*.
*
* The defect was not the missing branch. It was that nothing could notice the
* branch was missing — which is what this file is for.
*/

import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { describe, expect, it } from 'vitest';

const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');

const read = (relative: string): string => readFileSync(join(REPO_ROOT, relative), 'utf8');

/** Every `wire_<name>` the shell defines. */
const definedHosts = (shell: string): string[] =>
[...shell.matchAll(/^wire_([a-z_]+)\(\) \{/gm)].map((match) => match[1] ?? '');

describe('#689 every host the installer knows about is reachable', () => {
// The shell defines has_/wire_ pairs that the CLI is the one to act on. A pair
// with no counterpart anywhere is a host nobody will ever wire.
it('names every shell-defined host somewhere in the CLI enumeration', () => {
const shell = read('install.sh');
const enumeration = read('src/commands/installer-hosts.ts');

// A host is a `has_`/`wire_` pair. `wire_mcp_servers_json` is a shared
// helper and `wire_codex_mcp` an internal of `wire_codex`; neither has a
// `has_`. Filtering on underscores instead would have dropped
// `wire_claude_code` too, which is exactly the host this guard exists for.
const hosts = definedHosts(shell).filter((name) => shell.includes(`has_${name}(`));
expect(hosts.length, 'no wire_ definitions found — the pattern went stale').toBeGreaterThan(3);

// Mentioning the name is not dispatching it: the first attempt at this
// guard passed while `claude-code` appeared only inside a helper nothing
// called — the defect itself. `notDetected.push('<host>')` is written once,
// in the enumeration, and only for a host the enumeration handles.
// The two layers do not always agree on a name: the shell pair is
// `has_gemini`/`wire_gemini` and the enumeration row is `gemini-cli`. That
// is a real alias rather than a gap, so it is written down here — a guard
// that cannot express a legitimate difference becomes noise and then gets
// deleted.
const ALIASES: Readonly<Record<string, string>> = { gemini: 'gemini-cli' };

const dispatched = (name: string): boolean => {
const spelled = ALIASES[name] ?? name.replace(/_/g, '-');
// Either its own branch, or a row in the candidate table the loop walks —
// that loop pushes `notDetected.push(host)` for every row it skips.
return [spelled, name].some(
(form) =>
enumeration.includes(`notDetected.push('${form}')`) || enumeration.includes(`['${form}',`),
);
};
const missing = hosts.filter((name) => !dispatched(name));

expect(
missing,
`defined in install.sh and unknown to the enumeration: ${missing.join(', ')}`,
).toEqual([]);
});

it('reports claude-code as a host, since that is the one that was missing', () => {
const enumeration = read('src/commands/installer-hosts.ts');

expect(enumeration, 'the host itself').toContain("'claude-code'");
// Adding a marketplace that is already present is a no-op, so without the
// update a reinstall reinstates the same version — this is the whole of #660.
expect(enumeration, 'the refresh that makes a new version visible').toContain('marketplace');
expect(enumeration).toContain('update');
});

it('puts an undetected host in notDetected rather than nowhere', () => {
const enumeration = read('src/commands/installer-hosts.ts');

for (const host of ['codex', 'hermes', 'claude-code']) {
expect(
enumeration,
`${host} has no notDetected branch, so its absence would be silent`,
).toContain(`notDetected.push('${host}')`);
}
});
});
Loading