@vellumai/assistant 0.8.11 → 0.8.12-staging.1
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/ARCHITECTURE.md +15 -17
- package/README.md +0 -6
- package/bun.lock +6 -122
- package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
- package/node_modules/@vellumai/gateway-client/package.json +3 -1
- package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
- package/openapi.yaml +126 -4
- package/package.json +1 -3
- package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
- package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
- package/src/__tests__/anthropic-provider.test.ts +129 -0
- package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
- package/src/__tests__/btw-routes.test.ts +7 -34
- package/src/__tests__/checker.test.ts +6 -12
- package/src/__tests__/config-loader-backfill.test.ts +4 -2
- package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
- package/src/__tests__/config-watcher.test.ts +2 -2
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
- package/src/__tests__/conversation-error.test.ts +2 -6
- package/src/__tests__/conversation-history-web-search.test.ts +8 -0
- package/src/__tests__/conversation-title-service.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/disk-pressure-tools.test.ts +1 -1
- package/src/__tests__/exploration-drift-hook.test.ts +692 -0
- package/src/__tests__/filing-service.test.ts +8 -3
- package/src/__tests__/guardian-action-store.test.ts +0 -167
- package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
- package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
- package/src/__tests__/heartbeat-service.test.ts +5 -2
- package/src/__tests__/identity-intro-cache.test.ts +12 -5
- package/src/__tests__/identity-routes.test.ts +16 -57
- package/src/__tests__/injector-chain.test.ts +8 -3
- package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
- package/src/__tests__/llm-usage-store.test.ts +11 -0
- package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
- package/src/__tests__/model-intents.test.ts +1 -1
- package/src/__tests__/oauth-cli.test.ts +19 -8
- package/src/__tests__/openai-provider.test.ts +34 -0
- package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
- package/src/__tests__/recurrence-engine.test.ts +45 -0
- package/src/__tests__/schedule-routes.test.ts +34 -0
- package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
- package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
- package/src/__tests__/skill-tool-factory.test.ts +49 -0
- package/src/__tests__/subagent-role-registry.test.ts +24 -1
- package/src/__tests__/subagent-tools.test.ts +1 -0
- package/src/__tests__/system-prompt.test.ts +109 -11
- package/src/__tests__/tool-error-hook.test.ts +1 -0
- package/src/__tests__/tool-result-spool.test.ts +337 -0
- package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
- package/src/__tests__/validate-input.test.ts +95 -1
- package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
- package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
- package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
- package/src/agent/loop.ts +81 -24
- package/src/api/events/usage-progress.ts +28 -0
- package/src/api/index.ts +6 -0
- package/src/background-wake/wake-intent-hooks.test.ts +2 -0
- package/src/bundler/app-bundler.ts +25 -42
- package/src/calls/call-controller.ts +1 -1
- package/src/cli/commands/plugins.ts +248 -15
- package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
- package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
- package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
- package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +301 -0
- package/src/cli/lib/inspect-plugin.ts +252 -0
- package/src/cli/lib/install-from-github.ts +214 -21
- package/src/cli/lib/list-installed-plugins.ts +17 -6
- package/src/cli/lib/plugin-artifact.ts +103 -0
- package/src/cli/lib/plugin-details.ts +18 -1
- package/src/cli/lib/plugin-fingerprint.ts +197 -0
- package/src/cli/lib/upgrade-plugin.ts +219 -0
- package/src/config/bundled-skills/subagent/SKILL.md +2 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
- package/src/config/call-site-defaults.ts +13 -2
- package/src/config/feature-flag-registry.json +8 -16
- package/src/config/loader.ts +52 -59
- package/src/config/schema.ts +0 -2
- package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
- package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
- package/src/config/schemas/llm.ts +10 -0
- package/src/config/schemas/memory-v2.ts +13 -0
- package/src/config/schemas/memory-v3.ts +92 -0
- package/src/context/post-turn-tool-result-truncation.ts +32 -18
- package/src/context/tool-result-spool.ts +104 -0
- package/src/credential-execution/feature-gates.ts +0 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
- package/src/daemon/conversation-error.ts +6 -15
- package/src/daemon/conversation.ts +9 -0
- package/src/daemon/disk-pressure-policy.ts +0 -1
- package/src/daemon/lifecycle.ts +1 -20
- package/src/daemon/message-types/conversations.ts +2 -15
- package/src/daemon/trust-context.ts +1 -1
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
- package/src/home/__tests__/home-content-refresh.test.ts +114 -0
- package/src/home/__tests__/suggested-prompts.test.ts +86 -5
- package/src/home/home-content-refresh.ts +43 -31
- package/src/home/home-greeting-cache.ts +8 -1
- package/src/home/home-greeting.ts +13 -9
- package/src/home/suggested-prompts.ts +77 -24
- package/src/ipc/routes/trust-rules.test.ts +66 -72
- package/src/media/image-credentials.ts +2 -2
- package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
- package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
- package/src/memory/conversation-attention-store.ts +1 -0
- package/src/memory/conversation-bootstrap.ts +18 -9
- package/src/memory/conversation-crud.ts +12 -2
- package/src/memory/conversation-title-service.ts +53 -9
- package/src/memory/delivery-channels.ts +0 -69
- package/src/memory/graph/extraction-job.ts +0 -15
- package/src/memory/guardian-action-store.ts +1 -376
- package/src/memory/llm-usage-store.ts +5 -1
- package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
- package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
- package/src/memory/v2/__tests__/injection.test.ts +70 -0
- package/src/memory/v2/__tests__/static-context.test.ts +12 -0
- package/src/memory/v2/consolidation-job.ts +93 -9
- package/src/memory/v2/injection.ts +53 -0
- package/src/memory/v2/prompts/consolidation.ts +1 -0
- package/src/memory/v2/static-context.ts +13 -1
- package/src/memory/v2/sweep-job.ts +1 -1
- package/src/memory/v2/types.ts +5 -0
- package/src/plugin-api/types.ts +7 -0
- package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
- package/src/plugins/defaults/exploration-drift/package.json +15 -0
- package/src/plugins/defaults/index.ts +25 -0
- package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
- package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
- package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
- package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
- package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
- package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
- package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
- package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
- package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
- package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
- package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
- package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
- package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
- package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
- package/src/prompts/cache-boundary.ts +17 -0
- package/src/prompts/sections.ts +50 -17
- package/src/prompts/system-prompt.ts +12 -4
- package/src/prompts/templates/system-sections.ts +22 -0
- package/src/providers/anthropic/client.ts +74 -28
- package/src/providers/gemini/client.ts +5 -1
- package/src/providers/minimax/client.ts +9 -0
- package/src/providers/model-intents.ts +2 -2
- package/src/providers/openai/chat-completions-provider.ts +2 -1
- package/src/providers/openai/responses-provider.ts +5 -1
- package/src/providers/retry.ts +8 -0
- package/src/providers/types.ts +11 -0
- package/src/runtime/AGENTS.md +6 -0
- package/src/runtime/__tests__/agent-wake.test.ts +2 -2
- package/src/runtime/agent-wake.ts +5 -5
- package/src/runtime/background-job-runner.ts +2 -2
- package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
- package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
- package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
- package/src/runtime/migrations/vbundle-importer.ts +9 -4
- package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
- package/src/runtime/pre-first-message-gate.ts +1 -1
- package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
- package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
- package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +1 -0
- package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
- package/src/runtime/routes/btw-routes.ts +0 -14
- package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
- package/src/runtime/routes/conversation-list-routes.ts +77 -5
- package/src/runtime/routes/conversation-management-routes.ts +54 -0
- package/src/runtime/routes/gateway-log-routes.ts +14 -64
- package/src/runtime/routes/home-feed-routes.ts +10 -0
- package/src/runtime/routes/identity-intro-cache.ts +1 -1
- package/src/runtime/routes/identity-routes.ts +76 -20
- package/src/runtime/routes/inbound-message-handler.ts +0 -36
- package/src/runtime/routes/plugins-routes.ts +21 -0
- package/src/runtime/routes/schedule-routes.ts +19 -2
- package/src/runtime/routes/trust-rules-routes.ts +14 -67
- package/src/schedule/recurrence-engine.ts +34 -0
- package/src/schedule/scheduler.ts +1 -0
- package/src/skills/validate-input.ts +41 -1
- package/src/subagent/types.ts +26 -1
- package/src/telemetry/types.ts +15 -1
- package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
- package/src/telemetry/usage-telemetry-reporter.ts +1 -0
- package/src/tools/apps/executors.ts +1 -1
- package/src/tools/skills/skill-tool-factory.ts +19 -8
- package/src/usage/types.ts +8 -1
- package/src/util/platform.ts +16 -0
- package/src/watcher/engine.ts +1 -0
- package/src/workspace/adaptive-thinking-repair.ts +113 -0
- package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
- package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
- package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
- package/src/workspace/migrations/registry.ts +4 -0
- package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
- package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
- package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
- package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
- package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
- package/src/__tests__/update-bulletin-job.test.ts +0 -292
- package/src/config/schemas/updates.ts +0 -14
- package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
- package/src/memory/conversation-starters-cadence.ts +0 -78
- package/src/prompts/update-bulletin-job.ts +0 -180
- package/src/runtime/guardian-action-followup-executor.ts +0 -306
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for {@link upgradePlugin}.
|
|
3
|
+
*
|
|
4
|
+
* An upgrade is drift detection (the same exact SHA comparison
|
|
5
|
+
* {@link inspectPlugin} performs) followed by a forced re-install at the
|
|
6
|
+
* marketplace pin. The marketplace + GitHub Contents API are replaced with an
|
|
7
|
+
* in-memory fixture passed via `fetch`, the clone is replaced with a fake
|
|
8
|
+
* {@link GitRunner} that materializes a tree, and the install target is a real
|
|
9
|
+
* temp directory passed via `workspacePluginsDir` — no globals are patched.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
mkdtempSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
23
|
+
|
|
24
|
+
import type { FetchLike, GitRunner } from "../install-from-github.js";
|
|
25
|
+
import { PluginNotInstalledError } from "../uninstall-plugin.js";
|
|
26
|
+
import { PluginNotUpgradableError, upgradePlugin } from "../upgrade-plugin.js";
|
|
27
|
+
|
|
28
|
+
const SHA_A = "a".repeat(40);
|
|
29
|
+
const SHA_B = "b".repeat(40);
|
|
30
|
+
const CANON_REPO = "vellum-ai/vellum-assistant";
|
|
31
|
+
const MANIFEST_URL = `https://api.github.com/repos/${CANON_REPO}/contents/plugins/marketplace.json`;
|
|
32
|
+
const CONTENTS = `https://api.github.com/repos/${CANON_REPO}/contents/`;
|
|
33
|
+
|
|
34
|
+
/** A marketplace manifest pinning `name` to `ref`. */
|
|
35
|
+
function manifestWith(name: string, ref: string): unknown {
|
|
36
|
+
return {
|
|
37
|
+
name: "vellum",
|
|
38
|
+
plugins: [
|
|
39
|
+
{
|
|
40
|
+
name,
|
|
41
|
+
source: { source: "github", repo: `example-org/${name}`, ref },
|
|
42
|
+
description: "A test plugin.",
|
|
43
|
+
category: "developer",
|
|
44
|
+
license: "MIT",
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build a `fetch` that serves the marketplace manifest and answers the GitHub
|
|
52
|
+
* Contents API listing (used by the adapter-stub lookup) with a 404, so the
|
|
53
|
+
* clone is treated as a raw external tree. `manifest: undefined` answers the
|
|
54
|
+
* manifest with 404; `manifestStatus` overrides the manifest status to
|
|
55
|
+
* simulate a transient marketplace failure.
|
|
56
|
+
*/
|
|
57
|
+
function makeFetch(opts: {
|
|
58
|
+
manifest?: unknown;
|
|
59
|
+
manifestStatus?: number;
|
|
60
|
+
}): FetchLike {
|
|
61
|
+
return (async (input: RequestInfo | URL) => {
|
|
62
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
63
|
+
if (url.startsWith(MANIFEST_URL)) {
|
|
64
|
+
if (opts.manifestStatus !== undefined && opts.manifestStatus !== 200) {
|
|
65
|
+
return new Response("manifest unavailable", {
|
|
66
|
+
status: opts.manifestStatus,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (opts.manifest === undefined) {
|
|
70
|
+
return new Response("not found", { status: 404 });
|
|
71
|
+
}
|
|
72
|
+
return new Response(JSON.stringify(opts.manifest), { status: 200 });
|
|
73
|
+
}
|
|
74
|
+
// No adapter stub: the Contents API listing for plugins/<name> is empty.
|
|
75
|
+
if (url.startsWith(CONTENTS)) {
|
|
76
|
+
return new Response("not found", { status: 404 });
|
|
77
|
+
}
|
|
78
|
+
return new Response(`unexpected url: ${url}`, { status: 500 });
|
|
79
|
+
}) as FetchLike;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A fake clone that materializes one file and reports `commit` at HEAD. */
|
|
83
|
+
function fakeGitRunner(commit: string, calls?: string[][]): GitRunner {
|
|
84
|
+
return async (args, { cwd }) => {
|
|
85
|
+
calls?.push([...args]);
|
|
86
|
+
switch (args[0]) {
|
|
87
|
+
case "fetch": {
|
|
88
|
+
mkdirSync(join(cwd, ".git"), { recursive: true });
|
|
89
|
+
writeFileSync(join(cwd, ".git", "config"), "[core]\n");
|
|
90
|
+
writeFileSync(join(cwd, "package.json"), '{"name":"level-up"}');
|
|
91
|
+
return { stdout: "" };
|
|
92
|
+
}
|
|
93
|
+
case "rev-parse":
|
|
94
|
+
return { stdout: `${commit}\n` };
|
|
95
|
+
default:
|
|
96
|
+
return { stdout: "" };
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A git runner that fails the test if any git command runs. */
|
|
102
|
+
const unusedGitRunner: GitRunner = async (args) => {
|
|
103
|
+
throw new Error(`git should not run for this upgrade: ${args.join(" ")}`);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Materialize an installed plugin copy with an optional provenance sidecar. */
|
|
107
|
+
function installCopy(
|
|
108
|
+
pluginsDir: string,
|
|
109
|
+
name: string,
|
|
110
|
+
sidecar: { commit: string } | null,
|
|
111
|
+
): void {
|
|
112
|
+
const dir = join(pluginsDir, name);
|
|
113
|
+
mkdirSync(dir, { recursive: true });
|
|
114
|
+
writeFileSync(
|
|
115
|
+
join(dir, "package.json"),
|
|
116
|
+
JSON.stringify({ name, version: "0.1.0", description: "Installed copy." }),
|
|
117
|
+
);
|
|
118
|
+
if (sidecar !== null) {
|
|
119
|
+
writeFileSync(
|
|
120
|
+
join(dir, "install-meta.json"),
|
|
121
|
+
JSON.stringify({
|
|
122
|
+
origin: "vellum",
|
|
123
|
+
name,
|
|
124
|
+
source: {
|
|
125
|
+
kind: "github",
|
|
126
|
+
owner: "example-org",
|
|
127
|
+
repo: name,
|
|
128
|
+
ref: sidecar.commit,
|
|
129
|
+
},
|
|
130
|
+
commit: sidecar.commit,
|
|
131
|
+
installedAt: "2026-06-10T12:00:00.000Z",
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Read the commit recorded in a copy's provenance sidecar, if present. */
|
|
138
|
+
function sidecarCommit(pluginsDir: string, name: string): string | null {
|
|
139
|
+
const path = join(pluginsDir, name, "install-meta.json");
|
|
140
|
+
if (!existsSync(path)) return null;
|
|
141
|
+
return JSON.parse(readFileSync(path, "utf-8")).commit ?? null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let ws: string;
|
|
145
|
+
let pluginsDir: string;
|
|
146
|
+
|
|
147
|
+
beforeEach(() => {
|
|
148
|
+
ws = mkdtempSync(join(tmpdir(), "upgrade-plugin-"));
|
|
149
|
+
pluginsDir = join(ws, "plugins");
|
|
150
|
+
mkdirSync(pluginsDir, { recursive: true });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
rmSync(ws, { recursive: true, force: true });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("upgradePlugin", () => {
|
|
158
|
+
test("upgrades to the marketplace pin when it has advanced", async () => {
|
|
159
|
+
// GIVEN an installed copy pinned to SHA_A
|
|
160
|
+
installCopy(pluginsDir, "level-up", { commit: SHA_A });
|
|
161
|
+
// AND the marketplace now pins SHA_B
|
|
162
|
+
const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
|
|
163
|
+
const runGit = fakeGitRunner(SHA_B);
|
|
164
|
+
|
|
165
|
+
// WHEN the plugin is upgraded
|
|
166
|
+
const result = await upgradePlugin(
|
|
167
|
+
{ name: "level-up" },
|
|
168
|
+
{ fetch, runGit, workspacePluginsDir: pluginsDir },
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// THEN it reports the move from the old commit to the pin
|
|
172
|
+
expect(result.outcome).toBe("upgraded");
|
|
173
|
+
expect(result.fromCommit).toBe(SHA_A);
|
|
174
|
+
expect(result.toCommit).toBe(SHA_B);
|
|
175
|
+
expect(result.fileCount).toBeGreaterThan(0);
|
|
176
|
+
// AND the new pin is recorded in the provenance sidecar on disk
|
|
177
|
+
expect(sidecarCommit(pluginsDir, "level-up")).toBe(SHA_B);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("is a no-op when the installed commit already equals the pin", async () => {
|
|
181
|
+
// GIVEN an installed copy already pinned to SHA_A
|
|
182
|
+
installCopy(pluginsDir, "level-up", { commit: SHA_A });
|
|
183
|
+
// AND the marketplace pins the same SHA_A
|
|
184
|
+
const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_A) });
|
|
185
|
+
|
|
186
|
+
// WHEN the plugin is upgraded (git must never run)
|
|
187
|
+
const result = await upgradePlugin(
|
|
188
|
+
{ name: "level-up" },
|
|
189
|
+
{ fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// THEN it reports already-up-to-date and makes no changes
|
|
193
|
+
expect(result.outcome).toBe("already-up-to-date");
|
|
194
|
+
expect(result.fileCount).toBeNull();
|
|
195
|
+
expect(result.toCommit).toBe(SHA_A);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("a dry run reports the move without modifying the install", async () => {
|
|
199
|
+
// GIVEN an installed copy pinned to SHA_A and a marketplace pin of SHA_B
|
|
200
|
+
installCopy(pluginsDir, "level-up", { commit: SHA_A });
|
|
201
|
+
const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
|
|
202
|
+
|
|
203
|
+
// WHEN the plugin is upgraded with dryRun (git must never run)
|
|
204
|
+
const result = await upgradePlugin(
|
|
205
|
+
{ name: "level-up", dryRun: true },
|
|
206
|
+
{ fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// THEN it reports what would change but leaves the install untouched
|
|
210
|
+
expect(result.outcome).toBe("would-upgrade");
|
|
211
|
+
expect(result.dryRun).toBe(true);
|
|
212
|
+
expect(result.fileCount).toBeNull();
|
|
213
|
+
expect(sidecarCommit(pluginsDir, "level-up")).toBe(SHA_A);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("re-pins and records provenance for an install with none", async () => {
|
|
217
|
+
// GIVEN an installed copy with no provenance sidecar
|
|
218
|
+
installCopy(pluginsDir, "level-up", null);
|
|
219
|
+
// AND the marketplace pins SHA_B
|
|
220
|
+
const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
|
|
221
|
+
const runGit = fakeGitRunner(SHA_B);
|
|
222
|
+
|
|
223
|
+
// WHEN the plugin is upgraded
|
|
224
|
+
const result = await upgradePlugin(
|
|
225
|
+
{ name: "level-up" },
|
|
226
|
+
{ fetch, runGit, workspacePluginsDir: pluginsDir },
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// THEN it upgrades, flags the missing provenance, and records the new pin
|
|
230
|
+
expect(result.outcome).toBe("upgraded");
|
|
231
|
+
expect(result.fromCommit).toBeNull();
|
|
232
|
+
expect(result.provenanceWasUnknown).toBe(true);
|
|
233
|
+
expect(sidecarCommit(pluginsDir, "level-up")).toBe(SHA_B);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("throws PluginNotInstalledError when nothing is installed", async () => {
|
|
237
|
+
// GIVEN no installed copy, though the marketplace has an entry
|
|
238
|
+
const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
|
|
239
|
+
|
|
240
|
+
// WHEN an upgrade is attempted
|
|
241
|
+
// THEN it refuses because there is no install to advance
|
|
242
|
+
await expect(
|
|
243
|
+
upgradePlugin(
|
|
244
|
+
{ name: "level-up" },
|
|
245
|
+
{ fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
|
|
246
|
+
),
|
|
247
|
+
).rejects.toBeInstanceOf(PluginNotInstalledError);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("throws PluginNotUpgradableError when not in the marketplace", async () => {
|
|
251
|
+
// GIVEN an installed copy but an empty marketplace catalog
|
|
252
|
+
installCopy(pluginsDir, "level-up", { commit: SHA_A });
|
|
253
|
+
const fetch = makeFetch({ manifest: undefined });
|
|
254
|
+
|
|
255
|
+
// WHEN an upgrade is attempted
|
|
256
|
+
// THEN there is no pin to advance to
|
|
257
|
+
await expect(
|
|
258
|
+
upgradePlugin(
|
|
259
|
+
{ name: "level-up" },
|
|
260
|
+
{ fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
|
|
261
|
+
),
|
|
262
|
+
).rejects.toBeInstanceOf(PluginNotUpgradableError);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("throws PluginNotUpgradableError when the marketplace is unreachable", async () => {
|
|
266
|
+
// GIVEN an installed copy and a marketplace fetch that fails transiently
|
|
267
|
+
installCopy(pluginsDir, "level-up", { commit: SHA_A });
|
|
268
|
+
const fetch = makeFetch({ manifestStatus: 500 });
|
|
269
|
+
|
|
270
|
+
// WHEN an upgrade is attempted
|
|
271
|
+
// THEN the latest pin cannot be determined, so it refuses
|
|
272
|
+
await expect(
|
|
273
|
+
upgradePlugin(
|
|
274
|
+
{ name: "level-up" },
|
|
275
|
+
{ fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
|
|
276
|
+
),
|
|
277
|
+
).rejects.toBeInstanceOf(PluginNotUpgradableError);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("preserves the existing install when the re-install clone fails", async () => {
|
|
281
|
+
// GIVEN an installed copy pinned to SHA_A and an advanced marketplace pin
|
|
282
|
+
installCopy(pluginsDir, "level-up", { commit: SHA_A });
|
|
283
|
+
const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
|
|
284
|
+
// AND a clone that fails mid-fetch
|
|
285
|
+
const failingGit: GitRunner = async (args) => {
|
|
286
|
+
if (args[0] === "fetch") throw new Error("network down");
|
|
287
|
+
return { stdout: "" };
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// WHEN the upgrade is attempted
|
|
291
|
+
// THEN it surfaces the clone failure
|
|
292
|
+
await expect(
|
|
293
|
+
upgradePlugin(
|
|
294
|
+
{ name: "level-up" },
|
|
295
|
+
{ fetch, runGit: failingGit, workspacePluginsDir: pluginsDir },
|
|
296
|
+
),
|
|
297
|
+
).rejects.toThrow("network down");
|
|
298
|
+
// AND the previously installed copy is left intact at its old pin
|
|
299
|
+
expect(sidecarCommit(pluginsDir, "level-up")).toBe(SHA_A);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inspect a single plugin: what is installed locally versus what the curated
|
|
3
|
+
* marketplace currently pins, and whether the two have drifted.
|
|
4
|
+
*
|
|
5
|
+
* The marketplace pins every plugin to a full, immutable commit SHA (see
|
|
6
|
+
* {@link ./plugin-marketplace}); an install records the exact commit it
|
|
7
|
+
* materialized in an `install-meta.json` provenance sidecar (see
|
|
8
|
+
* {@link ./install-from-github}). Drift detection is therefore an exact
|
|
9
|
+
* commit-SHA comparison — the pin only moves when a curator bumps it, so a
|
|
10
|
+
* mismatch means a newer pin is available. The local `package.json` version is
|
|
11
|
+
* surfaced as informational metadata, not the drift signal: a semver string may
|
|
12
|
+
* not change between pins, whereas the SHA always determines the bytes.
|
|
13
|
+
*
|
|
14
|
+
* Designed for direct programmatic use with an injected `fetch`, mirroring the
|
|
15
|
+
* sibling plugin libraries. The CLI command `assistant plugins inspect <name>`
|
|
16
|
+
* is a thin wrapper that supplies production deps and formats the result.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_PLUGIN_REF,
|
|
21
|
+
type FetchLike,
|
|
22
|
+
INSTALL_META_FILENAME,
|
|
23
|
+
type InstallMeta,
|
|
24
|
+
readInstallMeta,
|
|
25
|
+
sanitizePluginName,
|
|
26
|
+
} from "./install-from-github.js";
|
|
27
|
+
import {
|
|
28
|
+
type InstalledPluginInfo,
|
|
29
|
+
readInstalledPlugin,
|
|
30
|
+
} from "./list-installed-plugins.js";
|
|
31
|
+
import {
|
|
32
|
+
compareFingerprint,
|
|
33
|
+
type FingerprintComparison,
|
|
34
|
+
} from "./plugin-fingerprint.js";
|
|
35
|
+
import {
|
|
36
|
+
fetchMarketplaceEntries,
|
|
37
|
+
type MarketplaceEntry,
|
|
38
|
+
} from "./plugin-marketplace.js";
|
|
39
|
+
|
|
40
|
+
/** Full commit SHA (40 hex SHA-1 or 64 hex SHA-256). */
|
|
41
|
+
const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Drift classification between the installed copy and the marketplace pin.
|
|
45
|
+
*
|
|
46
|
+
* - `up-to-date` — installed commit equals the current marketplace pin.
|
|
47
|
+
* - `update-available` — installed commit differs from the pin; a newer
|
|
48
|
+
* reviewed revision is available via `plugins install --force`.
|
|
49
|
+
* - `not-installed` — no local copy; the marketplace metadata is shown as a
|
|
50
|
+
* preview of what would be installed.
|
|
51
|
+
* - `not-in-marketplace` — installed but no catalog entry claims the name, so
|
|
52
|
+
* there is no advertised remote to compare against.
|
|
53
|
+
* - `unknown-provenance` — installed and in the catalog, but no resolvable
|
|
54
|
+
* commit was recorded (an older or manually-copied install); reinstall to
|
|
55
|
+
* record provenance.
|
|
56
|
+
* - `remote-unavailable` — installed, but the marketplace could not be reached
|
|
57
|
+
* to determine the current pin (rate-limit / network); local info is shown.
|
|
58
|
+
*/
|
|
59
|
+
export type PluginUpdateStatus =
|
|
60
|
+
| "up-to-date"
|
|
61
|
+
| "update-available"
|
|
62
|
+
| "not-installed"
|
|
63
|
+
| "not-in-marketplace"
|
|
64
|
+
| "unknown-provenance"
|
|
65
|
+
| "remote-unavailable";
|
|
66
|
+
|
|
67
|
+
/** Locally installed copy of a plugin. */
|
|
68
|
+
export interface PluginLocalInfo {
|
|
69
|
+
/** Absolute path to the installed plugin directory. */
|
|
70
|
+
readonly target: string;
|
|
71
|
+
/** Resolved commit the copy was installed at; `null` when no provenance was recorded. */
|
|
72
|
+
readonly commit: string | null;
|
|
73
|
+
/** `package.json` `version`, when present. */
|
|
74
|
+
readonly version: string | null;
|
|
75
|
+
/** `package.json` `description`, when present. */
|
|
76
|
+
readonly description: string | null;
|
|
77
|
+
/** ISO-8601 install timestamp from the provenance sidecar; `null` when absent. */
|
|
78
|
+
readonly installedAt: string | null;
|
|
79
|
+
/** Source coordinates recorded at install time; `null` when no sidecar exists. */
|
|
80
|
+
readonly source: InstallMeta["source"] | null;
|
|
81
|
+
/**
|
|
82
|
+
* Local-edit state relative to the install-time fingerprint: `null` when no
|
|
83
|
+
* fingerprint was recorded (an older or manually-copied install), so
|
|
84
|
+
* modification cannot be determined.
|
|
85
|
+
*/
|
|
86
|
+
readonly localChanges: FingerprintComparison | null;
|
|
87
|
+
/** Non-fatal issues with the installed copy (e.g. malformed `package.json`). */
|
|
88
|
+
readonly issues: readonly string[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The marketplace's current pin and advertised metadata for a plugin. */
|
|
92
|
+
export interface PluginRemoteInfo {
|
|
93
|
+
/** `owner/repo` of the external plugin repository. */
|
|
94
|
+
readonly repo: string;
|
|
95
|
+
/** Repo-relative directory holding the plugin root; `""` = repo root. */
|
|
96
|
+
readonly path: string;
|
|
97
|
+
/** Pinned commit SHA the marketplace currently resolves installs to. */
|
|
98
|
+
readonly commit: string;
|
|
99
|
+
readonly description: string | null;
|
|
100
|
+
readonly homepage: string | null;
|
|
101
|
+
readonly license: string | null;
|
|
102
|
+
readonly category: string | null;
|
|
103
|
+
/** Ref of the canonical repo the marketplace manifest was read from. */
|
|
104
|
+
readonly marketplaceRef: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Resolved inspection of a single plugin. */
|
|
108
|
+
export interface PluginInspection {
|
|
109
|
+
/** Install name. Matches `assistant plugins install <name>`. */
|
|
110
|
+
readonly name: string;
|
|
111
|
+
/** Whether a copy is materialized under the workspace plugins directory. */
|
|
112
|
+
readonly installed: boolean;
|
|
113
|
+
/** Drift classification between the installed copy and the marketplace pin. */
|
|
114
|
+
readonly status: PluginUpdateStatus;
|
|
115
|
+
/** Locally installed copy; `null` when the plugin is not installed. */
|
|
116
|
+
readonly local: PluginLocalInfo | null;
|
|
117
|
+
/** Marketplace pin + metadata; `null` when no entry claims the name or it was unreachable. */
|
|
118
|
+
readonly remote: PluginRemoteInfo | null;
|
|
119
|
+
/** Marketplace fetch error message, when the catalog could not be read. */
|
|
120
|
+
readonly remoteError: string | null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Neither an installed copy nor a marketplace entry claims the name. */
|
|
124
|
+
export class PluginInspectNotFoundError extends Error {
|
|
125
|
+
constructor(readonly pluginName: string) {
|
|
126
|
+
super(
|
|
127
|
+
`Plugin "${pluginName}" is not installed and has no marketplace entry.`,
|
|
128
|
+
);
|
|
129
|
+
this.name = "PluginInspectNotFoundError";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Options that control which plugin to inspect. */
|
|
134
|
+
export interface InspectPluginOptions {
|
|
135
|
+
/** Install name (kebab-case directory name). */
|
|
136
|
+
readonly name: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Dependencies injected by the caller. */
|
|
140
|
+
export interface InspectPluginDeps {
|
|
141
|
+
/** HTTP client. Production callers pass `globalThis.fetch.bind(globalThis)`. */
|
|
142
|
+
readonly fetch: FetchLike;
|
|
143
|
+
/** Override the workspace plugins directory. Falls back to the live workspace. */
|
|
144
|
+
readonly workspacePluginsDir?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readLocal(
|
|
148
|
+
entry: InstalledPluginInfo,
|
|
149
|
+
manifest: InstallMeta | null,
|
|
150
|
+
): PluginLocalInfo {
|
|
151
|
+
// The provenance commit is authoritative; fall back to the recorded ref only
|
|
152
|
+
// when it is itself a full SHA (marketplace installs always pin one), so a
|
|
153
|
+
// sidecar written before the commit could be read still yields a comparable
|
|
154
|
+
// revision instead of dropping to "unknown".
|
|
155
|
+
const commit =
|
|
156
|
+
manifest?.commit ??
|
|
157
|
+
(manifest && FULL_SHA_RE.test(manifest.source.ref)
|
|
158
|
+
? manifest.source.ref
|
|
159
|
+
: null);
|
|
160
|
+
// Compare the on-disk tree against the install-time baseline, applying the
|
|
161
|
+
// same exclusion so the sidecar is never counted as a local addition.
|
|
162
|
+
const localChanges = manifest?.fingerprint
|
|
163
|
+
? compareFingerprint(entry.target, manifest.fingerprint, [
|
|
164
|
+
INSTALL_META_FILENAME,
|
|
165
|
+
])
|
|
166
|
+
: null;
|
|
167
|
+
return {
|
|
168
|
+
target: entry.target,
|
|
169
|
+
commit,
|
|
170
|
+
version: entry.packageJson?.version ?? null,
|
|
171
|
+
description: entry.packageJson?.description ?? null,
|
|
172
|
+
installedAt: manifest?.installedAt || null,
|
|
173
|
+
source: manifest?.source ?? null,
|
|
174
|
+
localChanges,
|
|
175
|
+
issues: entry.issues,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readRemote(
|
|
180
|
+
entry: MarketplaceEntry,
|
|
181
|
+
marketplaceRef: string,
|
|
182
|
+
): PluginRemoteInfo {
|
|
183
|
+
return {
|
|
184
|
+
repo: entry.source.repo,
|
|
185
|
+
path: entry.source.path ?? "",
|
|
186
|
+
commit: entry.source.ref,
|
|
187
|
+
description: entry.description ?? null,
|
|
188
|
+
homepage: entry.homepage ?? null,
|
|
189
|
+
license: entry.license ?? null,
|
|
190
|
+
category: entry.category ?? null,
|
|
191
|
+
marketplaceRef,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Resolve the local-vs-remote inspection for a single plugin.
|
|
197
|
+
*
|
|
198
|
+
* Throws {@link PluginInspectNotFoundError} only when the plugin is neither
|
|
199
|
+
* installed nor present in the marketplace — there is nothing to show. A
|
|
200
|
+
* marketplace fetch failure for an *installed* plugin is not fatal: the local
|
|
201
|
+
* copy is reported with `status: "remote-unavailable"`.
|
|
202
|
+
*/
|
|
203
|
+
export async function inspectPlugin(
|
|
204
|
+
opts: InspectPluginOptions,
|
|
205
|
+
deps: InspectPluginDeps,
|
|
206
|
+
): Promise<PluginInspection> {
|
|
207
|
+
const name = sanitizePluginName(opts.name);
|
|
208
|
+
const marketplaceRef = DEFAULT_PLUGIN_REF;
|
|
209
|
+
|
|
210
|
+
const entry = readInstalledPlugin(name, {
|
|
211
|
+
workspacePluginsDir: deps.workspacePluginsDir,
|
|
212
|
+
});
|
|
213
|
+
const installed = entry !== null;
|
|
214
|
+
const local = entry ? readLocal(entry, readInstallMeta(entry.target)) : null;
|
|
215
|
+
|
|
216
|
+
let remote: PluginRemoteInfo | null = null;
|
|
217
|
+
let remoteError: string | null = null;
|
|
218
|
+
try {
|
|
219
|
+
const entries = await fetchMarketplaceEntries(
|
|
220
|
+
{ fetch: deps.fetch },
|
|
221
|
+
{ ref: marketplaceRef },
|
|
222
|
+
);
|
|
223
|
+
const match = entries.find((e) => e.name === name);
|
|
224
|
+
if (match) remote = readRemote(match, marketplaceRef);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
remoteError = err instanceof Error ? err.message : String(err);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!installed && !remote) {
|
|
230
|
+
// A reachable-but-empty catalog with no local copy is a genuine not-found;
|
|
231
|
+
// a fetch failure with no local copy leaves nothing to report either.
|
|
232
|
+
throw new PluginInspectNotFoundError(name);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const status = classify(installed, local, remote, remoteError);
|
|
236
|
+
return { name, installed, status, local, remote, remoteError };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function classify(
|
|
240
|
+
installed: boolean,
|
|
241
|
+
local: PluginLocalInfo | null,
|
|
242
|
+
remote: PluginRemoteInfo | null,
|
|
243
|
+
remoteError: string | null,
|
|
244
|
+
): PluginUpdateStatus {
|
|
245
|
+
if (!installed) return "not-installed";
|
|
246
|
+
if (remoteError && !remote) return "remote-unavailable";
|
|
247
|
+
if (!remote) return "not-in-marketplace";
|
|
248
|
+
if (!local?.commit) return "unknown-provenance";
|
|
249
|
+
return local.commit.toLowerCase() === remote.commit.toLowerCase()
|
|
250
|
+
? "up-to-date"
|
|
251
|
+
: "update-available";
|
|
252
|
+
}
|