@grafana/create-plugin 7.12.3 → 7.14.0

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/codemods/additions/additions.js +20 -7
  3. package/dist/codemods/additions/prompts/docs-content.md +119 -0
  4. package/dist/codemods/additions/prompts/example-prompt.md +56 -0
  5. package/dist/codemods/additions/scripts/docs-content.js +30 -0
  6. package/dist/codemods/additions/scripts/docs.js +250 -0
  7. package/dist/codemods/additions/scripts/experimental-app-sdk.js +39 -33
  8. package/dist/codemods/agentic/definitions.js +31 -0
  9. package/dist/codemods/agentic/detect.js +57 -0
  10. package/dist/codemods/agentic/handoff.js +35 -0
  11. package/dist/codemods/agentic/index.js +143 -0
  12. package/dist/codemods/agentic/prompts.js +115 -0
  13. package/dist/codemods/agentic/resolve.js +120 -0
  14. package/dist/codemods/agentic/runner.js +79 -0
  15. package/dist/codemods/agentic/verify.js +55 -0
  16. package/dist/codemods/migrations/migrations.js +12 -0
  17. package/dist/codemods/migrations/scripts/017-jest-30.js +32 -0
  18. package/dist/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.js +115 -0
  19. package/dist/codemods/utils.agents.js +64 -0
  20. package/dist/codemods/utils.js +51 -8
  21. package/dist/codemods/utils.workflows.js +94 -0
  22. package/dist/commands/add.command.js +74 -12
  23. package/dist/utils/utils.cli.js +7 -2
  24. package/dist/utils/utils.console.js +10 -1
  25. package/dist/utils/utils.git.js +10 -1
  26. package/dist/utils/utils.handlebars.js +9 -2
  27. package/dist/utils/utils.packageManager.js +2 -2
  28. package/dist/utils/utils.templates.js +38 -24
  29. package/package.json +1 -1
  30. package/src/codemods/AGENTS.md +25 -0
  31. package/src/codemods/additions/additions.test.ts +69 -4
  32. package/src/codemods/additions/additions.ts +50 -8
  33. package/src/codemods/additions/prompts/docs-content.md +119 -0
  34. package/src/codemods/additions/prompts/example-prompt.md +56 -0
  35. package/src/codemods/additions/scripts/docs-content.test.ts +94 -0
  36. package/src/codemods/additions/scripts/docs-content.ts +32 -0
  37. package/src/codemods/additions/scripts/docs.test.ts +1037 -0
  38. package/src/codemods/additions/scripts/docs.ts +353 -0
  39. package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +40 -80
  40. package/src/codemods/additions/scripts/experimental-app-sdk.ts +42 -34
  41. package/src/codemods/agentic/definitions.test.ts +67 -0
  42. package/src/codemods/agentic/definitions.ts +33 -0
  43. package/src/codemods/agentic/detect.test.ts +136 -0
  44. package/src/codemods/agentic/detect.ts +75 -0
  45. package/src/codemods/agentic/handoff.test.ts +102 -0
  46. package/src/codemods/agentic/handoff.ts +45 -0
  47. package/src/codemods/agentic/index.test.ts +342 -0
  48. package/src/codemods/agentic/index.ts +221 -0
  49. package/src/codemods/agentic/prompts.test.ts +233 -0
  50. package/src/codemods/agentic/prompts.ts +166 -0
  51. package/src/codemods/agentic/resolve.test.ts +220 -0
  52. package/src/codemods/agentic/resolve.ts +165 -0
  53. package/src/codemods/agentic/runner.test.ts +146 -0
  54. package/src/codemods/agentic/runner.ts +103 -0
  55. package/src/codemods/agentic/types.ts +50 -0
  56. package/src/codemods/agentic/verify.test.ts +106 -0
  57. package/src/codemods/agentic/verify.ts +93 -0
  58. package/src/codemods/migrations/migrations.ts +14 -0
  59. package/src/codemods/migrations/scripts/004-eslint9-flat-config.ts +1 -3
  60. package/src/codemods/migrations/scripts/017-jest-30.test.ts +72 -0
  61. package/src/codemods/migrations/scripts/017-jest-30.ts +39 -0
  62. package/src/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.test.ts +174 -0
  63. package/src/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.ts +141 -0
  64. package/src/codemods/utils.agents.test.ts +140 -0
  65. package/src/codemods/utils.agents.ts +81 -0
  66. package/src/codemods/utils.test.ts +131 -1
  67. package/src/codemods/utils.ts +79 -8
  68. package/src/codemods/utils.workflows.test.ts +153 -0
  69. package/src/codemods/utils.workflows.ts +140 -0
  70. package/src/commands/add.command.ts +92 -9
  71. package/src/utils/utils.cli.ts +5 -0
  72. package/src/utils/utils.git.test.ts +57 -0
  73. package/src/utils/utils.git.ts +25 -0
  74. package/src/utils/utils.handlebars.ts +9 -0
  75. package/src/utils/utils.packageManager.test.ts +23 -0
  76. package/src/utils/utils.packageManager.ts +3 -2
  77. package/src/utils/utils.templates.ts +50 -25
  78. package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +1 -1
  79. package/templates/common/.config/webpack/webpack.config.ts +4 -5
  80. package/templates/common/_package.json +9 -8
  81. package/templates/docs/common/workflows/validate-docs.yml +52 -0
  82. package/templates/docs/panel/docs/AGENTS.md +107 -0
  83. package/templates/docs/panel/docs/CLAUDE.md +1 -0
  84. package/templates/docs/panel/docs/README.md +20 -0
  85. package/templates/docs/panel/docs/data-formats.md +27 -0
  86. package/templates/docs/panel/docs/examples.md +35 -0
  87. package/templates/docs/panel/docs/index.md +26 -0
  88. package/templates/docs/panel/docs/options.md +53 -0
  89. package/templates/docs/panel/docs/troubleshooting.md +35 -0
  90. package/templates/github/workflows/bundle-stats.yml +1 -1
  91. package/templates/github/workflows/ci.yml +7 -7
  92. package/templates/github/workflows/is-compatible.yml +3 -3
  93. package/templates/github/workflows/release.yml +1 -1
@@ -0,0 +1,165 @@
1
+ import { confirmPrompt, output, selectPrompt } from '../../utils/utils.console.js';
2
+ import { AGENT_DEFINITIONS } from './definitions.js';
3
+ import { detectInstalledAgents, isInsideAgent, resolveAgentFromPath } from './detect.js';
4
+ import { AgenticResolution, InstalledAgent } from './types.js';
5
+
6
+ export interface ResolveAgenticOptions {
7
+ // raw minimist value: undefined = flag absent, '' = bare --agent, '<id>' or '<path>' = --agent=..., false = --no-agent
8
+ agentFlag: string | boolean | undefined;
9
+ isTTY?: boolean;
10
+ env?: NodeJS.ProcessEnv;
11
+ detect?: typeof detectInstalledAgents;
12
+ }
13
+
14
+ export async function resolveAgenticMode(options: ResolveAgenticOptions): Promise<AgenticResolution> {
15
+ const isTTY = options.isTTY ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
16
+ const detect = options.detect ?? detectInstalledAgents;
17
+
18
+ // checked before the inside-agent probe: --no-agent means no agent at all, and handing the step to an
19
+ // outer agent via a directive block would contradict that
20
+ if (options.agentFlag === false) {
21
+ return { mode: 'opted-out', reason: 'flag' };
22
+ }
23
+
24
+ if (isInsideAgent(options.env)) {
25
+ output.log({
26
+ title: 'Running inside an AI agent. The agent step will be handed to it instead of spawning another agent.',
27
+ });
28
+ return { mode: 'inside-agent' };
29
+ }
30
+
31
+ // the agent session is interactive, so it cannot run without a terminal to attach to
32
+ if (!isTTY) {
33
+ return { mode: 'unavailable', reason: 'no-tty' };
34
+ }
35
+
36
+ // --agent=<path>: an agent installed outside PATH
37
+ if (typeof options.agentFlag === 'string' && looksLikePath(options.agentFlag)) {
38
+ return { mode: 'enabled', agent: await resolveAgentFromPath(options.agentFlag) };
39
+ }
40
+
41
+ const installedAgents = await detect();
42
+
43
+ // --agent=<id>: pin a specific agent
44
+ if (typeof options.agentFlag === 'string' && options.agentFlag !== '') {
45
+ return { mode: 'enabled', agent: pinAgent(options.agentFlag, installedAgents) };
46
+ }
47
+
48
+ // bare --agent: enable without asking
49
+ if (options.agentFlag !== undefined) {
50
+ if (installedAgents.length === 0) {
51
+ throw new Error(
52
+ `--agent was passed but no supported agent CLI was found on this system. Supported agents: ${supportedAgentList()}. Pass --agent=<path> if one is installed outside your PATH.`
53
+ );
54
+ }
55
+ return resolveFromPick(installedAgents);
56
+ }
57
+
58
+ // no flag: opt in interactively
59
+ if (installedAgents.length === 0) {
60
+ return { mode: 'unavailable', reason: 'no-agents' };
61
+ }
62
+
63
+ return optInInteractively(installedAgents);
64
+ }
65
+
66
+ export const NO_AGENT_CHOICE = "Don't use an agent";
67
+
68
+ // names the agent and its binary before asking, so the user knows exactly what will run on their behalf
69
+ async function optInInteractively(installedAgents: InstalledAgent[]): Promise<AgenticResolution> {
70
+ output.log({
71
+ title: 'This addition includes instructions for an AI agent.',
72
+ body: [
73
+ 'The agent runs on your own account and can edit files and run commands in this repository.',
74
+ ...installedAgents.map((agent) => `${agent.definition.displayName}: ${agent.binaryPath}`),
75
+ ],
76
+ });
77
+
78
+ if (installedAgents.length === 1) {
79
+ const [installedAgent] = installedAgents;
80
+ const optedIn = await safeConfirm(`Apply the instructions with ${installedAgent.definition.displayName}?`);
81
+ if (!optedIn) {
82
+ return { mode: 'opted-out', reason: 'declined' };
83
+ }
84
+ return { mode: 'enabled', agent: installedAgent };
85
+ }
86
+
87
+ const pickedAgent = await safeSelectAgent('Which agent should apply the instructions?', installedAgents, [
88
+ NO_AGENT_CHOICE,
89
+ ]);
90
+ if (!pickedAgent) {
91
+ return { mode: 'opted-out', reason: 'declined' };
92
+ }
93
+ return { mode: 'enabled', agent: pickedAgent };
94
+ }
95
+
96
+ function looksLikePath(agentFlag: string): boolean {
97
+ return agentFlag.includes('/');
98
+ }
99
+
100
+ async function resolveFromPick(installedAgents: InstalledAgent[]): Promise<AgenticResolution> {
101
+ const pickedAgent = await pickAgent(installedAgents);
102
+ if (!pickedAgent) {
103
+ return { mode: 'opted-out', reason: 'declined' };
104
+ }
105
+ return { mode: 'enabled', agent: pickedAgent };
106
+ }
107
+
108
+ function pinAgent(agentId: string, installedAgents: InstalledAgent[]): InstalledAgent {
109
+ const isKnownAgent = AGENT_DEFINITIONS.some((definition) => definition.id === agentId);
110
+ if (!isKnownAgent) {
111
+ throw new Error(
112
+ `Unknown agent "${agentId}". Supported agents: ${supportedAgentList()}. Pass a path instead to use an agent installed outside your PATH.`
113
+ );
114
+ }
115
+
116
+ const installedAgent = installedAgents.find((agent) => agent.definition.id === agentId);
117
+ if (!installedAgent) {
118
+ const installedList =
119
+ installedAgents.length > 0 ? installedAgents.map((agent) => agent.definition.id).join(', ') : 'none';
120
+ throw new Error(
121
+ `Agent "${agentId}" is not installed. Installed agents: ${installedList}. Pass --agent=<path> if it is installed outside your PATH.`
122
+ );
123
+ }
124
+
125
+ return installedAgent;
126
+ }
127
+
128
+ async function pickAgent(installedAgents: InstalledAgent[]): Promise<InstalledAgent | undefined> {
129
+ if (installedAgents.length === 1) {
130
+ output.log({ title: `Using ${installedAgents[0].definition.displayName} for this addition.` });
131
+ return installedAgents[0];
132
+ }
133
+
134
+ return safeSelectAgent('Multiple agents are installed. Which one should apply this addition?', installedAgents);
135
+ }
136
+
137
+ async function safeSelectAgent(
138
+ message: string,
139
+ installedAgents: InstalledAgent[],
140
+ extraChoices: string[] = []
141
+ ): Promise<InstalledAgent | undefined> {
142
+ try {
143
+ const choice = await selectPrompt(message, [
144
+ ...installedAgents.map((agent) => agent.definition.displayName),
145
+ ...extraChoices,
146
+ ]);
147
+ return installedAgents.find((agent) => agent.definition.displayName === choice);
148
+ } catch {
149
+ // enquirer rejects when the prompt is cancelled (ctrl+c) — treat as a decline
150
+ return undefined;
151
+ }
152
+ }
153
+
154
+ async function safeConfirm(message: string): Promise<boolean> {
155
+ try {
156
+ return await confirmPrompt(message);
157
+ } catch {
158
+ // enquirer rejects when the prompt is cancelled (ctrl+c) — treat as a decline
159
+ return false;
160
+ }
161
+ }
162
+
163
+ function supportedAgentList(): string {
164
+ return AGENT_DEFINITIONS.map((definition) => `${definition.displayName} (${definition.id})`).join(', ');
165
+ }
@@ -0,0 +1,146 @@
1
+ import { spawn, ChildProcess } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { runAgentSession, AgentSessionOptions } from './runner.js';
7
+
8
+ vi.mock('node:child_process');
9
+
10
+ const spawnMock = vi.mocked(spawn);
11
+
12
+ interface FakeChildBehavior {
13
+ onSigint?: 'exit' | 'ignore';
14
+ }
15
+
16
+ class FakeChild extends EventEmitter {
17
+ kill = vi.fn((signal: NodeJS.Signals) => {
18
+ if (signal === 'SIGINT' && this.behavior.onSigint === 'exit') {
19
+ setTimeout(() => this.emit('exit', 130, null), 5);
20
+ }
21
+ if (signal === 'SIGKILL') {
22
+ setTimeout(() => this.emit('exit', null, 'SIGKILL'), 5);
23
+ }
24
+ return true;
25
+ });
26
+
27
+ constructor(private behavior: FakeChildBehavior = { onSigint: 'exit' }) {
28
+ super();
29
+ }
30
+ }
31
+
32
+ function stubSpawn(child: FakeChild) {
33
+ spawnMock.mockReturnValue(child as unknown as ChildProcess);
34
+ }
35
+
36
+ describe('runAgentSession', () => {
37
+ let dir: string;
38
+ let handoffPath: string;
39
+ let sessionOptions: AgentSessionOptions;
40
+
41
+ beforeEach(() => {
42
+ vi.clearAllMocks();
43
+ dir = mkdtempSync(join(tmpdir(), 'cp-runner-test-'));
44
+ handoffPath = join(dir, '013-example.json');
45
+ sessionOptions = {
46
+ binaryPath: '/usr/local/bin/claude',
47
+ args: ['--system-prompt', 'sys', 'user'],
48
+ cwd: dir,
49
+ handoffPath,
50
+ pollIntervalMs: 10,
51
+ killGraceMs: 5000,
52
+ };
53
+ });
54
+
55
+ afterEach(() => {
56
+ rmSync(dir, { recursive: true, force: true });
57
+ });
58
+
59
+ it('should resolve with the handoff and stop the session once the handoff file appears', async () => {
60
+ const child = new FakeChild();
61
+ stubSpawn(child);
62
+ writeFileSync(handoffPath, '{"status":"success","summary":"applied"}');
63
+
64
+ const result = await runAgentSession(sessionOptions);
65
+
66
+ expect(result).toEqual({ outcome: 'handoff', handoff: { status: 'success', summary: 'applied' } });
67
+ expect(child.kill).toHaveBeenCalledWith('SIGINT');
68
+ expect(spawnMock).toHaveBeenCalledWith(
69
+ sessionOptions.binaryPath,
70
+ sessionOptions.args,
71
+ expect.objectContaining({ stdio: 'inherit', cwd: dir })
72
+ );
73
+ });
74
+
75
+ it('should escalate to SIGKILL when the agent ignores SIGINT after a valid handoff', async () => {
76
+ const child = new FakeChild({ onSigint: 'ignore' });
77
+ stubSpawn(child);
78
+ writeFileSync(handoffPath, '{"status":"success","summary":"applied"}');
79
+
80
+ const result = await runAgentSession({ ...sessionOptions, killGraceMs: 30 });
81
+
82
+ expect(result).toEqual({ outcome: 'handoff', handoff: { status: 'success', summary: 'applied' } });
83
+ expect(child.kill).toHaveBeenCalledWith('SIGINT');
84
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL');
85
+ });
86
+
87
+ it('should treat an exit after a self-written handoff as a handoff', async () => {
88
+ const child = new FakeChild();
89
+ stubSpawn(child);
90
+
91
+ const resultPromise = runAgentSession({ ...sessionOptions, pollIntervalMs: 60_000 });
92
+ writeFileSync(handoffPath, '{"status":"failed","summary":"blocked"}');
93
+ setTimeout(() => child.emit('exit', 0, null), 10);
94
+
95
+ await expect(resultPromise).resolves.toEqual({
96
+ outcome: 'handoff',
97
+ handoff: { status: 'failed', summary: 'blocked' },
98
+ });
99
+ });
100
+
101
+ it('should classify an exit without a handoff as ambiguous', async () => {
102
+ const child = new FakeChild();
103
+ stubSpawn(child);
104
+
105
+ const resultPromise = runAgentSession(sessionOptions);
106
+ setTimeout(() => child.emit('exit', 1, null), 10);
107
+
108
+ await expect(resultPromise).resolves.toEqual({ outcome: 'ambiguous-exit', exitCode: 1, signal: null });
109
+ });
110
+
111
+ it('should classify an exit with an invalid handoff file as ambiguous', async () => {
112
+ const child = new FakeChild();
113
+ stubSpawn(child);
114
+ writeFileSync(handoffPath, 'not json at all');
115
+
116
+ const resultPromise = runAgentSession({ ...sessionOptions, pollIntervalMs: 60_000 });
117
+ setTimeout(() => child.emit('exit', 0, null), 10);
118
+
119
+ await expect(resultPromise).resolves.toEqual({ outcome: 'ambiguous-exit', exitCode: 0, signal: null });
120
+ });
121
+
122
+ it.each([
123
+ [130, null],
124
+ [143, null],
125
+ [null, 'SIGINT'],
126
+ [null, 'SIGTERM'],
127
+ ])('should classify exit code %s / signal %s as user-aborted', async (exitCode, signal) => {
128
+ const child = new FakeChild();
129
+ stubSpawn(child);
130
+
131
+ const resultPromise = runAgentSession(sessionOptions);
132
+ setTimeout(() => child.emit('exit', exitCode, signal), 10);
133
+
134
+ await expect(resultPromise).resolves.toEqual({ outcome: 'user-aborted' });
135
+ });
136
+
137
+ it('should classify a spawn error as ambiguous', async () => {
138
+ const child = new FakeChild();
139
+ stubSpawn(child);
140
+
141
+ const resultPromise = runAgentSession(sessionOptions);
142
+ setTimeout(() => child.emit('error', new Error('spawn ENOENT')), 5);
143
+
144
+ await expect(resultPromise).resolves.toEqual({ outcome: 'ambiguous-exit', exitCode: null, signal: null });
145
+ });
146
+ });
@@ -0,0 +1,103 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { parseHandoff } from './handoff.js';
4
+ import { AgentSessionResult, Handoff } from './types.js';
5
+
6
+ const DEFAULT_POLL_INTERVAL_MS = 500;
7
+ const DEFAULT_KILL_GRACE_MS = 5000;
8
+
9
+ export interface AgentSessionOptions {
10
+ binaryPath: string;
11
+ args: string[];
12
+ env?: Record<string, string>;
13
+ cwd: string;
14
+ handoffPath: string;
15
+ pollIntervalMs?: number;
16
+ killGraceMs?: number;
17
+ }
18
+
19
+ export function runAgentSession(options: AgentSessionOptions): Promise<AgentSessionResult> {
20
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
21
+ const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
22
+
23
+ return new Promise((resolve) => {
24
+ const child = spawn(options.binaryPath, options.args, {
25
+ stdio: 'inherit',
26
+ cwd: options.cwd,
27
+ env: { ...process.env, ...options.env },
28
+ });
29
+
30
+ let settled = false;
31
+ // set once the poller finds a valid handoff; the session is then being torn down
32
+ // and the exit handler must report the handoff rather than classify the exit
33
+ let pendingHandoff: Handoff | undefined;
34
+ let killTimer: NodeJS.Timeout | undefined;
35
+
36
+ const pollTimer = setInterval(async () => {
37
+ const handoff = await readHandoff(options.handoffPath);
38
+ if (!handoff || settled || pendingHandoff) {
39
+ return;
40
+ }
41
+ pendingHandoff = handoff;
42
+ clearInterval(pollTimer);
43
+ child.kill('SIGINT');
44
+ killTimer = setTimeout(() => child.kill('SIGKILL'), killGraceMs);
45
+ }, pollIntervalMs);
46
+
47
+ function settle(result: AgentSessionResult) {
48
+ if (settled) {
49
+ return;
50
+ }
51
+ settled = true;
52
+ clearInterval(pollTimer);
53
+ if (killTimer) {
54
+ clearTimeout(killTimer);
55
+ }
56
+ resetTerminal();
57
+ resolve(result);
58
+ }
59
+
60
+ child.on('error', () => {
61
+ settle({ outcome: 'ambiguous-exit', exitCode: null, signal: null });
62
+ });
63
+
64
+ child.on('exit', async (exitCode, signal) => {
65
+ if (pendingHandoff) {
66
+ settle({ outcome: 'handoff', handoff: pendingHandoff });
67
+ return;
68
+ }
69
+ // the agent may write the handoff and end the session on its own before the poller fires
70
+ const handoff = await readHandoff(options.handoffPath);
71
+ if (handoff) {
72
+ settle({ outcome: 'handoff', handoff });
73
+ return;
74
+ }
75
+ if (isUserAbort(exitCode, signal)) {
76
+ settle({ outcome: 'user-aborted' });
77
+ return;
78
+ }
79
+ settle({ outcome: 'ambiguous-exit', exitCode, signal });
80
+ });
81
+ });
82
+ }
83
+
84
+ async function readHandoff(handoffPath: string): Promise<Handoff | undefined> {
85
+ try {
86
+ const raw = await readFile(handoffPath, 'utf-8');
87
+ return parseHandoff(raw);
88
+ } catch {
89
+ // tolerate ENOENT while the agent is still working (and cache dir pruning mid-session)
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ function isUserAbort(exitCode: number | null, signal: NodeJS.Signals | null): boolean {
95
+ return exitCode === 130 || exitCode === 143 || signal === 'SIGINT' || signal === 'SIGTERM';
96
+ }
97
+
98
+ function resetTerminal(): void {
99
+ if (process.stdout.isTTY) {
100
+ // clear any styling the killed agent TUI left behind on the inherited stdio
101
+ process.stdout.write('\x1B[0m\n');
102
+ }
103
+ }
@@ -0,0 +1,50 @@
1
+ export type AgentId = 'claude-code' | 'codex';
2
+
3
+ export interface AgentInvocationContext {
4
+ systemPrompt: string;
5
+ userPrompt: string;
6
+ workspaceRoot: string;
7
+ handoffDir: string;
8
+ }
9
+
10
+ export interface AgentInvocation {
11
+ args: string[];
12
+ env?: Record<string, string>;
13
+ }
14
+
15
+ export interface AgentDefinition {
16
+ id: AgentId;
17
+ displayName: string;
18
+ // candidate binary names probed on PATH, in order
19
+ binaryNames: string[];
20
+ buildInteractive: (invocationContext: AgentInvocationContext) => AgentInvocation;
21
+ }
22
+
23
+ export interface InstalledAgent {
24
+ definition: AgentDefinition;
25
+ binaryPath: string;
26
+ }
27
+
28
+ // 'unavailable' and 'opted-out' are deliberately distinct: the first means no agent could be used, the
29
+ // second means one could have been but the user said no. Only the first is an error.
30
+ export type AgenticResolution =
31
+ | { mode: 'inside-agent' }
32
+ | { mode: 'unavailable'; reason: 'no-tty' | 'no-agents' }
33
+ | { mode: 'opted-out'; reason: 'flag' | 'declined' }
34
+ | { mode: 'enabled'; agent: InstalledAgent };
35
+
36
+ export interface Handoff {
37
+ status: 'success' | 'failed';
38
+ summary: string;
39
+ }
40
+
41
+ export type AgentSessionResult =
42
+ | { outcome: 'handoff'; handoff: Handoff }
43
+ | { outcome: 'ambiguous-exit'; exitCode: number | null; signal: NodeJS.Signals | null }
44
+ | { outcome: 'user-aborted' };
45
+
46
+ // no 'deferred' kind: deferral is decided from the resolution before a session is ever started
47
+ export type AgenticStepResult =
48
+ | { kind: 'applied'; summary: string }
49
+ // the agent session ended without a valid handoff and the user chose to continue
50
+ | { kind: 'assumed-applied' };
@@ -0,0 +1,106 @@
1
+ import { mkdtempSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { buildVerifyWarning, runVerifyScripts } from './verify.js';
5
+
6
+ function createWorkspace(scripts?: Record<string, string>): string {
7
+ const cwd = mkdtempSync(join(tmpdir(), 'cp-verify-'));
8
+ writeFileSync(join(cwd, 'package.json'), JSON.stringify(scripts ? { scripts } : {}));
9
+ return cwd;
10
+ }
11
+
12
+ describe('runVerifyScripts', () => {
13
+ it('should run every script in order and record its outcome', () => {
14
+ const cwd = createWorkspace({ typecheck: 'tsc', build: 'rspack', lint: 'eslint' });
15
+ const runScript = vi.fn((_pm: string, script: string) => script !== 'build');
16
+
17
+ const results = runVerifyScripts({
18
+ scripts: ['typecheck', 'build', 'lint'],
19
+ cwd,
20
+ packageManagerName: 'pnpm',
21
+ runScript,
22
+ });
23
+
24
+ expect(runScript.mock.calls).toEqual([
25
+ ['pnpm', 'typecheck', cwd],
26
+ ['pnpm', 'build', cwd],
27
+ ['pnpm', 'lint', cwd],
28
+ ]);
29
+ expect(results).toEqual([
30
+ { script: 'typecheck', outcome: 'passed' },
31
+ { script: 'build', outcome: 'failed' },
32
+ { script: 'lint', outcome: 'passed' },
33
+ ]);
34
+ });
35
+
36
+ it('should skip scripts the plugin does not define', () => {
37
+ const cwd = createWorkspace({ build: 'rspack' });
38
+ const runScript = vi.fn(() => true);
39
+
40
+ const results = runVerifyScripts({ scripts: ['typecheck', 'build'], cwd, packageManagerName: 'npm', runScript });
41
+
42
+ expect(runScript).toHaveBeenCalledTimes(1);
43
+ expect(results).toEqual([
44
+ { script: 'typecheck', outcome: 'missing' },
45
+ { script: 'build', outcome: 'passed' },
46
+ ]);
47
+ });
48
+
49
+ it('should treat every script as missing when package.json cannot be read', () => {
50
+ const cwd = mkdtempSync(join(tmpdir(), 'cp-verify-'));
51
+ const runScript = vi.fn(() => true);
52
+
53
+ const results = runVerifyScripts({ scripts: ['build'], cwd, packageManagerName: 'npm', runScript });
54
+
55
+ expect(runScript).not.toHaveBeenCalled();
56
+ expect(results).toEqual([{ script: 'build', outcome: 'missing' }]);
57
+ });
58
+
59
+ it('should run nothing when the addition has no verify scripts', () => {
60
+ const runScript = vi.fn(() => true);
61
+
62
+ expect(runVerifyScripts({ scripts: [], cwd: createWorkspace(), packageManagerName: 'npm', runScript })).toEqual([]);
63
+ expect(runScript).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it('should report a real script exit code', () => {
67
+ const cwd = createWorkspace({ pass: 'node -e "process.exit(0)"', fail: 'node -e "process.exit(1)"' });
68
+
69
+ const results = runVerifyScripts({ scripts: ['pass', 'fail'], cwd, packageManagerName: 'npm' });
70
+
71
+ expect(results).toEqual([
72
+ { script: 'pass', outcome: 'passed' },
73
+ { script: 'fail', outcome: 'failed' },
74
+ ]);
75
+ });
76
+ });
77
+
78
+ describe('buildVerifyWarning', () => {
79
+ it('should return undefined when every check passed', () => {
80
+ expect(buildVerifyWarning('rspack', 'npm', [{ script: 'build', outcome: 'passed' }])).toBeUndefined();
81
+ });
82
+
83
+ it('should return undefined when there were no checks', () => {
84
+ expect(buildVerifyWarning('rspack', 'npm', [])).toBeUndefined();
85
+ });
86
+
87
+ it('should list failed and skipped checks', () => {
88
+ const warning = buildVerifyWarning('rspack', 'pnpm', [
89
+ { script: 'typecheck', outcome: 'missing' },
90
+ { script: 'build', outcome: 'failed' },
91
+ ]);
92
+
93
+ expect(warning?.title).toBe('rspack was applied, but 1 of its checks failed.');
94
+ expect(warning?.body).toEqual([
95
+ 'pnpm run typecheck skipped: package.json has no "typecheck" script',
96
+ 'pnpm run build failed',
97
+ expect.stringContaining('git diff'),
98
+ ]);
99
+ });
100
+
101
+ it('should say checks could not run when none failed but some were missing', () => {
102
+ const warning = buildVerifyWarning('rspack', 'npm', [{ script: 'typecheck', outcome: 'missing' }]);
103
+
104
+ expect(warning?.title).toBe('rspack was applied, but some of its checks could not run.');
105
+ });
106
+ });
@@ -0,0 +1,93 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ export type VerifyOutcome = 'passed' | 'failed' | 'missing';
6
+
7
+ export interface VerifyResult {
8
+ script: string;
9
+ outcome: VerifyOutcome;
10
+ }
11
+
12
+ export interface VerifyOptions {
13
+ scripts: string[];
14
+ cwd: string;
15
+ packageManagerName: string;
16
+ runScript?: (packageManagerName: string, script: string, cwd: string) => boolean;
17
+ }
18
+
19
+ export interface VerifyWarning {
20
+ title: string;
21
+ body: string[];
22
+ }
23
+
24
+ // the agent runs the prompt's own Verify step and reports on itself. these scripts are run by
25
+ // create-plugin after the session, so the final result never rests on the agent's account alone
26
+ export function runVerifyScripts(options: VerifyOptions): VerifyResult[] {
27
+ const { scripts, cwd, packageManagerName, runScript = runPackageScript } = options;
28
+ const availableScripts = readPackageScripts(cwd);
29
+ const results: VerifyResult[] = [];
30
+
31
+ // sequential, and every script runs even after a failure, so the user sees the whole picture at once
32
+ for (const script of scripts) {
33
+ if (!availableScripts.has(script)) {
34
+ results.push({ script, outcome: 'missing' });
35
+ continue;
36
+ }
37
+ const hasPassed = runScript(packageManagerName, script, cwd);
38
+ results.push({ script, outcome: hasPassed ? 'passed' : 'failed' });
39
+ }
40
+
41
+ return results;
42
+ }
43
+
44
+ export function buildVerifyWarning(
45
+ additionName: string,
46
+ packageManagerName: string,
47
+ results: VerifyResult[]
48
+ ): VerifyWarning | undefined {
49
+ const failedResults = results.filter((result) => result.outcome !== 'passed');
50
+ if (failedResults.length === 0) {
51
+ return undefined;
52
+ }
53
+
54
+ const failedCount = failedResults.filter((result) => result.outcome === 'failed').length;
55
+ const title =
56
+ failedCount > 0
57
+ ? `${additionName} was applied, but ${failedCount} of its checks failed.`
58
+ : `${additionName} was applied, but some of its checks could not run.`;
59
+
60
+ return {
61
+ title,
62
+ body: [
63
+ ...failedResults.map(({ script, outcome }) =>
64
+ outcome === 'failed'
65
+ ? `${packageManagerName} run ${script} failed`
66
+ : `${packageManagerName} run ${script} skipped: package.json has no "${script}" script`
67
+ ),
68
+ 'Nothing was committed. Review the changes with git diff before you commit them.',
69
+ ],
70
+ };
71
+ }
72
+
73
+ function readPackageScripts(cwd: string): Set<string> {
74
+ try {
75
+ const packageJson: { scripts?: Record<string, string> } = JSON.parse(
76
+ readFileSync(join(cwd, 'package.json'), 'utf-8')
77
+ );
78
+ return new Set(Object.keys(packageJson.scripts ?? {}));
79
+ } catch {
80
+ return new Set();
81
+ }
82
+ }
83
+
84
+ function runPackageScript(packageManagerName: string, script: string, cwd: string): boolean {
85
+ try {
86
+ // inherit stdio so the user sees the check's own output, exactly as if they ran it themselves.
87
+ // script names come from the additions registry, never from user input
88
+ execSync(`${packageManagerName} run ${script}`, { cwd, stdio: 'inherit' });
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
@@ -113,6 +113,20 @@ export default [
113
113
  'Fix @grafana/ui Combobox dropdown crashing in Jest: add an IntersectionObserver polyfill stub so ScrollIndicators (mounted when the dropdown opens) no longer throws ReferenceError in jsdom.',
114
114
  scriptPath: import.meta.resolve('./scripts/016-jest-intersection-observer.js'),
115
115
  },
116
+ {
117
+ name: '017-jest-30',
118
+ version: '7.13.0',
119
+ description:
120
+ 'Update Jest to v30, which replaces micromatch with picomatch and drops the braces dependency flagged by vulnerability scanners.',
121
+ scriptPath: import.meta.resolve('./scripts/017-jest-30.js'),
122
+ },
123
+ {
124
+ name: '018-replace-eslint-webpack-plugin',
125
+ version: '7.13.0',
126
+ description:
127
+ 'Replace eslint-webpack-plugin with diagnostics-webpack-plugin in the webpack config. eslint-webpack-plugin depends on micromatch, which pulls in the braces dependency flagged by vulnerability scanners.',
128
+ scriptPath: import.meta.resolve('./scripts/018-replace-eslint-webpack-plugin.js'),
129
+ },
116
130
  // Do not use LEGACY_UPDATE_CUTOFF_VERSION for new migrations. It is only used above to force migrations to run
117
131
  // for those written before the switch to updates as migrations.
118
132
  //