@agentrhq/webcmd 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/cli-manifest.json +228 -24
- package/clis/chatgpt/ask.js +1 -1
- package/clis/chatgpt/ask.test.js +11 -0
- package/clis/chatgpt/commands.test.js +102 -1
- package/clis/chatgpt/deep-research-result.js +45 -3
- package/clis/chatgpt/image.js +22 -2
- package/clis/chatgpt/project-file-add.js +24 -1
- package/clis/chatgpt/utils.js +258 -52
- package/clis/chatgpt/utils.test.js +259 -1
- package/clis/claude/ask.js +21 -1
- package/clis/facebook/feed.js +110 -24
- package/clis/facebook/feed.test.js +62 -0
- package/clis/facebook/search.js +185 -38
- package/clis/facebook/search.test.js +95 -50
- package/clis/instagram/explore.js +30 -12
- package/clis/instagram/explore.test.js +77 -0
- package/clis/instagram/post.js +14 -1
- package/clis/instagram/reel.js +13 -1
- package/clis/linkedin/company.js +153 -0
- package/clis/linkedin/company.test.js +111 -0
- package/clis/linkedin/connections.js +135 -0
- package/clis/linkedin/connections.test.js +141 -0
- package/clis/mercury/reimbursement-draft.js +12 -1
- package/clis/producthunt/hot.js +1 -2
- package/clis/spotify/spotify.js +11 -11
- package/clis/twitter/article-evaluate.test.js +27 -0
- package/clis/twitter/article.js +73 -16
- package/clis/twitter/article.test.js +209 -0
- package/clis/twitter/download.js +6 -1
- package/clis/twitter/post.js +14 -1
- package/clis/twitter/profile.js +1 -1
- package/clis/twitter/profile.test.js +8 -0
- package/clis/twitter/quote.js +11 -1
- package/clis/twitter/reply.js +11 -1
- package/dist/src/browser/base-page.d.ts +26 -0
- package/dist/src/browser/base-page.js +23 -8
- package/dist/src/browser/base-page.test.js +45 -12
- package/dist/src/browser/command-catalog.d.ts +6 -0
- package/dist/src/browser/command-catalog.js +259 -0
- package/dist/src/browser/command-catalog.test.d.ts +1 -0
- package/dist/src/browser/command-catalog.test.js +79 -0
- package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
- package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
- package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +7 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.js +23 -9
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
- package/dist/src/browser/target-resolver.d.ts +1 -0
- package/dist/src/browser/target-resolver.js +99 -4
- package/dist/src/browser/target-resolver.test.js +141 -1
- package/dist/src/build-manifest.d.ts +6 -0
- package/dist/src/build-manifest.js +18 -1
- package/dist/src/build-manifest.test.js +57 -1
- package/dist/src/builtin-command-surface.d.ts +9 -0
- package/dist/src/builtin-command-surface.js +16 -0
- package/dist/src/check-hosted-contract.test.d.ts +1 -0
- package/dist/src/check-hosted-contract.test.js +56 -0
- package/dist/src/cli.js +58 -106
- package/dist/src/cli.test.js +71 -1
- package/dist/src/command-presentation.d.ts +95 -0
- package/dist/src/command-presentation.js +486 -0
- package/dist/src/command-presentation.test.d.ts +1 -0
- package/dist/src/command-presentation.test.js +97 -0
- package/dist/src/command-surface.d.ts +43 -0
- package/dist/src/command-surface.js +205 -0
- package/dist/src/command-surface.test.d.ts +1 -0
- package/dist/src/command-surface.test.js +406 -0
- package/dist/src/commanderAdapter.d.ts +5 -1
- package/dist/src/commanderAdapter.js +12 -55
- package/dist/src/commanderAdapter.test.js +15 -3
- package/dist/src/community-plugin-sync.d.ts +11 -0
- package/dist/src/community-plugin-sync.js +138 -0
- package/dist/src/community-plugin-sync.test.d.ts +1 -0
- package/dist/src/community-plugin-sync.test.js +123 -0
- package/dist/src/completion-fast.d.ts +4 -4
- package/dist/src/completion-fast.js +15 -34
- package/dist/src/completion-shared.d.ts +4 -0
- package/dist/src/completion-shared.js +38 -0
- package/dist/src/completion.js +3 -27
- package/dist/src/completion.test.js +31 -2
- package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
- package/dist/src/docs-sync-review-cli.test.js +322 -0
- package/dist/src/docs-sync-review.d.ts +117 -0
- package/dist/src/docs-sync-review.js +475 -0
- package/dist/src/docs-sync-review.test.d.ts +1 -0
- package/dist/src/docs-sync-review.test.js +324 -0
- package/dist/src/errors.d.ts +12 -3
- package/dist/src/errors.js +11 -7
- package/dist/src/errors.test.js +14 -1
- package/dist/src/execution.d.ts +1 -2
- package/dist/src/execution.js +4 -45
- package/dist/src/generate-release-notes-cli.test.js +7 -2
- package/dist/src/help.d.ts +4 -0
- package/dist/src/help.js +50 -255
- package/dist/src/help.test.js +27 -1
- package/dist/src/hosted/args.d.ts +2 -7
- package/dist/src/hosted/args.js +2 -99
- package/dist/src/hosted/args.test.js +15 -1
- package/dist/src/hosted/availability.d.ts +13 -0
- package/dist/src/hosted/availability.js +16 -0
- package/dist/src/hosted/availability.test.d.ts +1 -0
- package/dist/src/hosted/availability.test.js +191 -0
- package/dist/src/hosted/browser-args.d.ts +18 -0
- package/dist/src/hosted/browser-args.js +152 -0
- package/dist/src/hosted/browser-args.test.d.ts +1 -0
- package/dist/src/hosted/browser-args.test.js +182 -0
- package/dist/src/hosted/client.d.ts +31 -2
- package/dist/src/hosted/client.js +441 -53
- package/dist/src/hosted/client.test.js +651 -32
- package/dist/src/hosted/config.d.ts +7 -2
- package/dist/src/hosted/config.js +26 -4
- package/dist/src/hosted/config.test.js +34 -1
- package/dist/src/hosted/contract.d.ts +94 -0
- package/dist/src/hosted/contract.js +208 -0
- package/dist/src/hosted/contract.test.d.ts +1 -0
- package/dist/src/hosted/contract.test.js +361 -0
- package/dist/src/hosted/credentials.d.ts +38 -0
- package/dist/src/hosted/credentials.js +248 -0
- package/dist/src/hosted/credentials.test.d.ts +1 -0
- package/dist/src/hosted/credentials.test.js +93 -0
- package/dist/src/hosted/file-contract.test.d.ts +1 -0
- package/dist/src/hosted/file-contract.test.js +174 -0
- package/dist/src/hosted/files.d.ts +29 -0
- package/dist/src/hosted/files.js +296 -0
- package/dist/src/hosted/files.test.d.ts +1 -0
- package/dist/src/hosted/files.test.js +231 -0
- package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
- package/dist/src/hosted/main-lifecycle.test.js +213 -0
- package/dist/src/hosted/manifest.d.ts +5 -0
- package/dist/src/hosted/manifest.js +21 -66
- package/dist/src/hosted/manifest.test.js +120 -2
- package/dist/src/hosted/output-parity.test.d.ts +1 -0
- package/dist/src/hosted/output-parity.test.js +108 -0
- package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
- package/dist/src/hosted/root-command-surface.test.js +646 -0
- package/dist/src/hosted/runner.d.ts +7 -0
- package/dist/src/hosted/runner.js +554 -253
- package/dist/src/hosted/runner.test.js +1471 -53
- package/dist/src/hosted/setup.d.ts +3 -2
- package/dist/src/hosted/setup.js +45 -15
- package/dist/src/hosted/setup.test.js +133 -6
- package/dist/src/hosted/types.d.ts +101 -23
- package/dist/src/main.js +120 -108
- package/dist/src/manifest-types.d.ts +2 -0
- package/dist/src/output.d.ts +17 -2
- package/dist/src/output.js +88 -81
- package/dist/src/output.test.js +141 -51
- package/dist/src/pipeline/executor.test.js +1 -0
- package/dist/src/pipeline/steps/download.test.js +1 -0
- package/dist/src/plugin-create-cli.test.d.ts +1 -0
- package/dist/src/plugin-create-cli.test.js +37 -0
- package/dist/src/plugin-manifest.d.ts +12 -0
- package/dist/src/plugin-manifest.js +16 -0
- package/dist/src/plugin-manifest.test.js +19 -1
- package/dist/src/plugin-scaffold.d.ts +4 -1
- package/dist/src/plugin-scaffold.js +4 -1
- package/dist/src/plugin-scaffold.test.js +23 -8
- package/dist/src/plugin.js +4 -1
- package/dist/src/plugin.test.js +13 -0
- package/dist/src/registry.d.ts +2 -0
- package/dist/src/release-notes.js +12 -4
- package/dist/src/release-notes.test.js +27 -15
- package/dist/src/root-command-surface.d.ts +31 -0
- package/dist/src/root-command-surface.js +106 -0
- package/dist/src/serialization.d.ts +1 -16
- package/dist/src/serialization.js +5 -55
- package/dist/src/stream-write.d.ts +12 -0
- package/dist/src/stream-write.js +91 -0
- package/dist/src/stream-write.test.d.ts +1 -0
- package/dist/src/stream-write.test.js +186 -0
- package/dist/src/types.d.ts +5 -0
- package/dist/src/utils.d.ts +1 -1
- package/dist/src/utils.js +2 -8
- package/dist/src/utils.test.js +50 -0
- package/hosted-contract.json +36759 -0
- package/package.json +6 -1
- package/scripts/check-hosted-contract.mjs +108 -0
- package/scripts/docs-sync-review.ts +332 -0
- package/scripts/sync-community-plugins.ts +14 -0
- package/skills/webcmd-autofix/SKILL.md +26 -37
- package/skills/webcmd-usage/SKILL.md +7 -0
package/dist/src/output.test.js
CHANGED
|
@@ -1,53 +1,143 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { Writable } from 'node:stream';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { formatErrorEnvelope, formatOutput, render } from './output.js';
|
|
5
|
+
function sink(isTTY) {
|
|
6
|
+
let data = '';
|
|
7
|
+
const stream = new Writable({
|
|
8
|
+
write(chunk, _encoding, callback) {
|
|
9
|
+
data += String(chunk);
|
|
10
|
+
callback();
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
Object.defineProperty(stream, 'isTTY', { value: isTTY });
|
|
14
|
+
return { stream, text: () => data };
|
|
15
|
+
}
|
|
16
|
+
function rendered(data, options, isTTY) {
|
|
17
|
+
const stdout = sink(isTTY);
|
|
18
|
+
render(data, { ...options, stdout: stdout.stream });
|
|
19
|
+
return stdout.text();
|
|
20
|
+
}
|
|
21
|
+
describe('formatOutput', () => {
|
|
22
|
+
it.each([
|
|
23
|
+
{ name: 'null', data: null, options: { fmt: 'json', fmtExplicit: true }, isTTY: false },
|
|
24
|
+
{ name: 'empty arrays', data: [], options: { fmt: 'table', fmtExplicit: true }, isTTY: true },
|
|
25
|
+
{ name: 'one scalar field', data: { value: 'hello' }, options: { fmt: 'plain', fmtExplicit: true }, isTTY: false },
|
|
26
|
+
{
|
|
27
|
+
name: 'multiple rows',
|
|
28
|
+
data: [{ name: 'alice', score: 10 }, { name: 'bob', score: 20 }],
|
|
29
|
+
options: { fmt: 'table', fmtExplicit: true, columns: ['name', 'score'] },
|
|
30
|
+
isTTY: true,
|
|
31
|
+
},
|
|
32
|
+
])('renders $name byte-identically through the pure and stream APIs', ({ data, options, isTTY }) => {
|
|
33
|
+
expect(rendered(data, options, isTTY)).toBe(formatOutput(data, { ...options, isTTY }));
|
|
34
|
+
});
|
|
35
|
+
it('escapes commas, quotes, and newlines in CSV', () => {
|
|
36
|
+
expect(formatOutput([
|
|
37
|
+
{ name: 'a,b', note: 'said "hello"' },
|
|
38
|
+
{ name: 'plain', note: 'line 1\nline 2' },
|
|
39
|
+
], { fmt: 'csv', fmtExplicit: true, columns: ['name', 'note'], isTTY: false })).toBe('name,note\n"a,b","said ""hello"""\nplain,"line 1\nline 2"\n');
|
|
40
|
+
});
|
|
41
|
+
it('preserves the legacy literal Markdown table cells', () => {
|
|
42
|
+
expect(formatOutput([
|
|
43
|
+
{ name: 'a|b', note: 'line 1\nline 2' },
|
|
44
|
+
], { fmt: 'md', fmtExplicit: true, columns: ['name', 'note'], isTTY: false })).toBe('| name | note |\n| --- | --- |\n| a|b | line 1\nline 2 |\n');
|
|
45
|
+
});
|
|
46
|
+
it('uses YAML for an implicit table format on a non-TTY stream', () => {
|
|
47
|
+
expect(rendered([{ name: 'alice', score: 10 }], {
|
|
48
|
+
fmt: 'table',
|
|
49
|
+
fmtExplicit: false,
|
|
50
|
+
columns: ['name', 'score'],
|
|
51
|
+
}, false)).toBe('- name: alice\n score: 10\n\n');
|
|
52
|
+
});
|
|
53
|
+
it('uses a table for an implicit table format on a TTY stream', () => {
|
|
54
|
+
const output = rendered([{ name: 'alice', score: 10 }], {
|
|
55
|
+
fmt: 'table',
|
|
56
|
+
fmtExplicit: false,
|
|
57
|
+
columns: ['name', 'score'],
|
|
58
|
+
}, true);
|
|
59
|
+
expect(output).toContain('alice');
|
|
60
|
+
expect(output).toContain('1 items');
|
|
61
|
+
expect(output).not.toContain('name: alice');
|
|
62
|
+
});
|
|
63
|
+
it('honors an explicit table format on a non-TTY stream', () => {
|
|
64
|
+
const output = rendered([{ name: 'alice' }], {
|
|
65
|
+
fmt: 'table',
|
|
66
|
+
fmtExplicit: true,
|
|
67
|
+
columns: ['name'],
|
|
68
|
+
}, false);
|
|
69
|
+
expect(output).toContain('alice');
|
|
70
|
+
expect(output).not.toContain('name: alice');
|
|
71
|
+
});
|
|
72
|
+
it('honors an explicit JSON format on a non-TTY stream', () => {
|
|
73
|
+
expect(rendered([{ name: 'alice' }], {
|
|
74
|
+
fmt: 'json',
|
|
75
|
+
fmtExplicit: true,
|
|
76
|
+
}, false)).toBe('[\n {\n "name": "alice"\n }\n]\n');
|
|
77
|
+
});
|
|
78
|
+
it('writes values larger than 1 MiB completely', () => {
|
|
79
|
+
const value = 'x'.repeat((1024 * 1024) + 17);
|
|
80
|
+
const expected = `${value}\n`;
|
|
81
|
+
const output = rendered({ value }, { fmt: 'plain', fmtExplicit: true }, false);
|
|
82
|
+
expect(output.length).toBe(expected.length);
|
|
83
|
+
expect(createHash('sha256').update(output).digest('hex'))
|
|
84
|
+
.toBe(createHash('sha256').update(expected).digest('hex'));
|
|
85
|
+
});
|
|
86
|
+
it('does not resolve stream rendering before the write callback and drain', async () => {
|
|
87
|
+
let release;
|
|
88
|
+
const stdout = new Writable({
|
|
89
|
+
highWaterMark: 1,
|
|
90
|
+
write(_chunk, _encoding, callback) {
|
|
91
|
+
release = callback;
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
let settled = false;
|
|
95
|
+
const rendering = render({ value: 'slow' }, {
|
|
96
|
+
fmt: 'plain',
|
|
97
|
+
fmtExplicit: true,
|
|
98
|
+
stdout,
|
|
99
|
+
}).then(() => { settled = true; });
|
|
100
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
101
|
+
expect(settled).toBe(false);
|
|
102
|
+
release?.();
|
|
103
|
+
await rendering;
|
|
104
|
+
expect(settled).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
it('shows elapsed time when elapsed is zero', () => {
|
|
107
|
+
expect(formatOutput([{ name: 'alice' }], {
|
|
108
|
+
fmt: 'table',
|
|
109
|
+
fmtExplicit: true,
|
|
110
|
+
columns: ['name'],
|
|
111
|
+
elapsed: 0,
|
|
112
|
+
isTTY: true,
|
|
113
|
+
})).toContain('0.0s');
|
|
114
|
+
});
|
|
115
|
+
it('prints single Markdown payloads without wrapping them in a table', () => {
|
|
116
|
+
expect(formatOutput([{ markdown: '# Title\n\nBody' }], {
|
|
117
|
+
fmt: 'md',
|
|
118
|
+
fmtExplicit: true,
|
|
119
|
+
isTTY: false,
|
|
120
|
+
})).toBe('# Title\n\nBody\n');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe('formatErrorEnvelope', () => {
|
|
124
|
+
it('returns the local YAML envelope bytes without writing to stderr', () => {
|
|
125
|
+
expect(formatErrorEnvelope({
|
|
126
|
+
ok: false,
|
|
127
|
+
error: {
|
|
128
|
+
code: 'AUTH_REQUIRED',
|
|
129
|
+
message: 'Sign in first',
|
|
130
|
+
help: 'Run webcmd github login.',
|
|
131
|
+
exitCode: 77,
|
|
132
|
+
},
|
|
133
|
+
})).toBe([
|
|
134
|
+
'ok: false',
|
|
135
|
+
'error:',
|
|
136
|
+
' code: AUTH_REQUIRED',
|
|
137
|
+
' message: Sign in first',
|
|
138
|
+
' help: Run webcmd github login.',
|
|
139
|
+
' exitCode: 77',
|
|
140
|
+
'',
|
|
141
|
+
].join('\n'));
|
|
52
142
|
});
|
|
53
143
|
});
|
|
@@ -18,6 +18,7 @@ function createMockPage(overrides = {}) {
|
|
|
18
18
|
pressKey: vi.fn(),
|
|
19
19
|
getFormState: vi.fn().mockResolvedValue({}),
|
|
20
20
|
wait: vi.fn(),
|
|
21
|
+
sleep: vi.fn().mockResolvedValue(undefined),
|
|
21
22
|
tabs: vi.fn().mockResolvedValue([]),
|
|
22
23
|
selectTab: vi.fn(),
|
|
23
24
|
networkRequests: vi.fn().mockResolvedValue([]),
|
|
@@ -30,6 +30,7 @@ function createMockPage(getCookies) {
|
|
|
30
30
|
scrollTo: vi.fn(),
|
|
31
31
|
getFormState: vi.fn().mockResolvedValue({}),
|
|
32
32
|
wait: vi.fn(),
|
|
33
|
+
sleep: vi.fn().mockResolvedValue(undefined),
|
|
33
34
|
tabs: vi.fn().mockResolvedValue([]),
|
|
34
35
|
selectTab: vi.fn(),
|
|
35
36
|
networkRequests: vi.fn().mockResolvedValue([]),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { createProgram } from './cli.js';
|
|
6
|
+
describe('webcmd plugin create', () => {
|
|
7
|
+
const createdDirs = [];
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
for (const dir of createdDirs)
|
|
10
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
11
|
+
vi.restoreAllMocks();
|
|
12
|
+
process.exitCode = undefined;
|
|
13
|
+
});
|
|
14
|
+
it('writes author name and GitHub handle supplied as flags', async () => {
|
|
15
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-plugin-create-'));
|
|
16
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
17
|
+
createdDirs.push(dir);
|
|
18
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
19
|
+
const program = createProgram('', '');
|
|
20
|
+
program.exitOverride();
|
|
21
|
+
await program.parseAsync([
|
|
22
|
+
'node',
|
|
23
|
+
'webcmd',
|
|
24
|
+
'plugin',
|
|
25
|
+
'create',
|
|
26
|
+
'weather',
|
|
27
|
+
'--dir',
|
|
28
|
+
dir,
|
|
29
|
+
'--author-name',
|
|
30
|
+
'Rishabh',
|
|
31
|
+
'--author-handle',
|
|
32
|
+
'rishabhraj36',
|
|
33
|
+
]);
|
|
34
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'webcmd-plugin.json'), 'utf-8'));
|
|
35
|
+
expect(manifest.author).toEqual({ name: 'Rishabh', handle: 'rishabhraj36' });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
* 1. Single plugin: repo root IS the plugin directory.
|
|
6
6
|
* 2. Monorepo: repo contains multiple plugins declared in `plugins` field.
|
|
7
7
|
*/
|
|
8
|
+
export interface PluginAuthor {
|
|
9
|
+
/** Display name shown in generated catalogs. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** GitHub username used to link the author profile. */
|
|
12
|
+
handle: string;
|
|
13
|
+
}
|
|
8
14
|
export interface SubPluginEntry {
|
|
9
15
|
/** Relative path from repo root to the sub-plugin directory. */
|
|
10
16
|
path: string;
|
|
@@ -14,6 +20,8 @@ export interface SubPluginEntry {
|
|
|
14
20
|
webcmd?: string;
|
|
15
21
|
/** When true, this sub-plugin is skipped during install. */
|
|
16
22
|
disabled?: boolean;
|
|
23
|
+
/** Plugin author attribution. */
|
|
24
|
+
author?: PluginAuthor;
|
|
17
25
|
}
|
|
18
26
|
export interface PluginManifest {
|
|
19
27
|
/** Plugin name (single-plugin mode). */
|
|
@@ -24,10 +32,14 @@ export interface PluginManifest {
|
|
|
24
32
|
webcmd?: string;
|
|
25
33
|
/** Human-readable description. */
|
|
26
34
|
description?: string;
|
|
35
|
+
/** Plugin author attribution. */
|
|
36
|
+
author?: PluginAuthor;
|
|
27
37
|
/** Monorepo sub-plugins. Key = logical plugin name. */
|
|
28
38
|
plugins?: Record<string, SubPluginEntry>;
|
|
29
39
|
}
|
|
30
40
|
export declare const MANIFEST_FILENAME = "webcmd-plugin.json";
|
|
41
|
+
/** Validate explicit plugin author metadata without making a GitHub API call. */
|
|
42
|
+
export declare function validatePluginAuthor(value: unknown, label?: string): PluginAuthor;
|
|
31
43
|
/**
|
|
32
44
|
* Read and parse webcmd-plugin.json from a directory.
|
|
33
45
|
* Returns null if the file does not exist or is unparseable.
|
|
@@ -9,7 +9,23 @@ import * as fs from 'node:fs';
|
|
|
9
9
|
import * as path from 'node:path';
|
|
10
10
|
import { PKG_VERSION } from './version.js';
|
|
11
11
|
export const MANIFEST_FILENAME = 'webcmd-plugin.json';
|
|
12
|
+
const GITHUB_HANDLE_PATTERN = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i;
|
|
12
13
|
// ── Read / Validate ─────────────────────────────────────────────────────────
|
|
14
|
+
/** Validate explicit plugin author metadata without making a GitHub API call. */
|
|
15
|
+
export function validatePluginAuthor(value, label = 'author') {
|
|
16
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
17
|
+
throw new Error(`Invalid ${label}: expected an object`);
|
|
18
|
+
}
|
|
19
|
+
const author = value;
|
|
20
|
+
const name = typeof author.name === 'string' ? author.name.trim() : '';
|
|
21
|
+
if (!name)
|
|
22
|
+
throw new Error(`Invalid ${label}.name: expected a non-empty string`);
|
|
23
|
+
const handle = typeof author.handle === 'string' ? author.handle.trim() : '';
|
|
24
|
+
if (!GITHUB_HANDLE_PATTERN.test(handle)) {
|
|
25
|
+
throw new Error(`Invalid ${label}.handle: expected a valid GitHub username`);
|
|
26
|
+
}
|
|
27
|
+
return { name, handle };
|
|
28
|
+
}
|
|
13
29
|
/**
|
|
14
30
|
* Read and parse webcmd-plugin.json from a directory.
|
|
15
31
|
* Returns null if the file does not exist or is unparseable.
|
|
@@ -5,7 +5,25 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
|
5
5
|
import * as fs from 'node:fs';
|
|
6
6
|
import * as path from 'node:path';
|
|
7
7
|
import * as os from 'node:os';
|
|
8
|
-
import { _readPluginManifest as readPluginManifest, _isMonorepo as isMonorepo, _getEnabledPlugins as getEnabledPlugins, _parseVersion as parseVersion, _satisfiesRange as satisfiesRange, MANIFEST_FILENAME, } from './plugin-manifest.js';
|
|
8
|
+
import { _readPluginManifest as readPluginManifest, _isMonorepo as isMonorepo, _getEnabledPlugins as getEnabledPlugins, _parseVersion as parseVersion, _satisfiesRange as satisfiesRange, MANIFEST_FILENAME, validatePluginAuthor, } from './plugin-manifest.js';
|
|
9
|
+
// ── Author metadata ─────────────────────────────────────────────────────────
|
|
10
|
+
describe('validatePluginAuthor', () => {
|
|
11
|
+
it('normalizes valid author metadata', () => {
|
|
12
|
+
expect(validatePluginAuthor({ name: ' Rishabh ', handle: 'rishabhraj36' })).toEqual({
|
|
13
|
+
name: 'Rishabh',
|
|
14
|
+
handle: 'rishabhraj36',
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
it('rejects missing author fields', () => {
|
|
18
|
+
expect(() => validatePluginAuthor({ name: '', handle: 'rishabhraj36' })).toThrow('author.name');
|
|
19
|
+
expect(() => validatePluginAuthor({ name: 'Rishabh', handle: '' })).toThrow('author.handle');
|
|
20
|
+
});
|
|
21
|
+
it('rejects malformed GitHub handles', () => {
|
|
22
|
+
expect(() => validatePluginAuthor({ name: 'Rishabh', handle: '-wrong' })).toThrow('author.handle');
|
|
23
|
+
expect(() => validatePluginAuthor({ name: 'Rishabh', handle: 'wrong--handle' })).toThrow('author.handle');
|
|
24
|
+
expect(() => validatePluginAuthor({ name: 'Rishabh', handle: `${'a'.repeat(39)}b` })).toThrow('author.handle');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
9
27
|
// ── readPluginManifest ──────────────────────────────────────────────────────
|
|
10
28
|
describe('readPluginManifest', () => {
|
|
11
29
|
let tmpDir;
|
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
* greet.ts — sample TS command using func()
|
|
12
12
|
* README.md — basic documentation
|
|
13
13
|
*/
|
|
14
|
+
import { type PluginAuthor } from './plugin-manifest.js';
|
|
14
15
|
export interface ScaffoldOptions {
|
|
15
16
|
/** Directory to create the plugin in. Defaults to `./<name>` */
|
|
16
17
|
dir?: string;
|
|
17
18
|
/** Plugin description */
|
|
18
19
|
description?: string;
|
|
20
|
+
/** Required author attribution for the generated plugin manifest. */
|
|
21
|
+
author: PluginAuthor;
|
|
19
22
|
}
|
|
20
23
|
export interface ScaffoldResult {
|
|
21
24
|
name: string;
|
|
@@ -25,4 +28,4 @@ export interface ScaffoldResult {
|
|
|
25
28
|
/**
|
|
26
29
|
* Create a new plugin scaffold directory.
|
|
27
30
|
*/
|
|
28
|
-
export declare function createPluginScaffold(name: string, opts
|
|
31
|
+
export declare function createPluginScaffold(name: string, opts: ScaffoldOptions): ScaffoldResult;
|
|
@@ -13,16 +13,18 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import * as fs from 'node:fs';
|
|
15
15
|
import * as path from 'node:path';
|
|
16
|
+
import { validatePluginAuthor } from './plugin-manifest.js';
|
|
16
17
|
import { PKG_VERSION } from './version.js';
|
|
17
18
|
/**
|
|
18
19
|
* Create a new plugin scaffold directory.
|
|
19
20
|
*/
|
|
20
|
-
export function createPluginScaffold(name, opts
|
|
21
|
+
export function createPluginScaffold(name, opts) {
|
|
21
22
|
// Validate name
|
|
22
23
|
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
23
24
|
throw new Error(`Invalid plugin name "${name}". ` +
|
|
24
25
|
`Plugin names must start with a lowercase letter and contain only lowercase letters, digits, and hyphens.`);
|
|
25
26
|
}
|
|
27
|
+
const author = validatePluginAuthor(opts.author);
|
|
26
28
|
const targetDir = opts.dir
|
|
27
29
|
? path.resolve(opts.dir)
|
|
28
30
|
: path.resolve(name);
|
|
@@ -37,6 +39,7 @@ export function createPluginScaffold(name, opts = {}) {
|
|
|
37
39
|
version: '0.1.0',
|
|
38
40
|
description: opts.description ?? `A webcmd plugin: ${name}`,
|
|
39
41
|
webcmd: `>=${PKG_VERSION}`,
|
|
42
|
+
author,
|
|
40
43
|
};
|
|
41
44
|
writeFile(targetDir, 'webcmd-plugin.json', JSON.stringify(manifest, null, 2) + '\n');
|
|
42
45
|
files.push('webcmd-plugin.json');
|
|
@@ -8,6 +8,7 @@ import * as path from 'node:path';
|
|
|
8
8
|
import { createPluginScaffold } from './plugin-scaffold.js';
|
|
9
9
|
describe('createPluginScaffold', () => {
|
|
10
10
|
const createdDirs = [];
|
|
11
|
+
const author = { name: 'Rishabh', handle: 'rishabhraj36' };
|
|
11
12
|
afterEach(() => {
|
|
12
13
|
for (const dir of createdDirs) {
|
|
13
14
|
try {
|
|
@@ -20,7 +21,7 @@ describe('createPluginScaffold', () => {
|
|
|
20
21
|
it('creates all expected files', () => {
|
|
21
22
|
const dir = path.join(os.tmpdir(), `webcmd-scaffold-${Date.now()}`);
|
|
22
23
|
createdDirs.push(dir);
|
|
23
|
-
const result = createPluginScaffold('my-test', { dir });
|
|
24
|
+
const result = createPluginScaffold('my-test', { dir, author });
|
|
24
25
|
expect(result.name).toBe('my-test');
|
|
25
26
|
expect(result.dir).toBe(dir);
|
|
26
27
|
expect(result.files).toContain('webcmd-plugin.json');
|
|
@@ -36,25 +37,27 @@ describe('createPluginScaffold', () => {
|
|
|
36
37
|
it('generates valid webcmd-plugin.json', () => {
|
|
37
38
|
const dir = path.join(os.tmpdir(), `webcmd-scaffold-${Date.now()}`);
|
|
38
39
|
createdDirs.push(dir);
|
|
39
|
-
createPluginScaffold('test-manifest', { dir, description: 'Test desc' });
|
|
40
|
+
createPluginScaffold('test-manifest', { dir, description: 'Test desc', author });
|
|
40
41
|
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'webcmd-plugin.json'), 'utf-8'));
|
|
41
42
|
expect(manifest.name).toBe('test-manifest');
|
|
42
43
|
expect(manifest.version).toBe('0.1.0');
|
|
43
44
|
expect(manifest.description).toBe('Test desc');
|
|
44
45
|
expect(manifest.webcmd).toMatch(/^>=/);
|
|
46
|
+
expect(manifest.author).toEqual(author);
|
|
45
47
|
});
|
|
46
48
|
it('generates ESM package.json', () => {
|
|
47
49
|
const dir = path.join(os.tmpdir(), `webcmd-scaffold-${Date.now()}`);
|
|
48
50
|
createdDirs.push(dir);
|
|
49
|
-
createPluginScaffold('test-pkg', { dir });
|
|
51
|
+
createPluginScaffold('test-pkg', { dir, author });
|
|
50
52
|
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
|
|
51
53
|
expect(pkg.type).toBe('module');
|
|
52
54
|
expect(pkg.peerDependencies?.['@agentrhq/webcmd']).toBeDefined();
|
|
55
|
+
expect(pkg.author).toBeUndefined();
|
|
53
56
|
});
|
|
54
57
|
it('generates a TS sample that matches the current plugin API', () => {
|
|
55
58
|
const dir = path.join(os.tmpdir(), `webcmd-scaffold-${Date.now()}`);
|
|
56
59
|
createdDirs.push(dir);
|
|
57
|
-
createPluginScaffold('test-ts', { dir });
|
|
60
|
+
createPluginScaffold('test-ts', { dir, author });
|
|
58
61
|
const tsSample = fs.readFileSync(path.join(dir, 'greet.ts'), 'utf-8');
|
|
59
62
|
expect(tsSample).toContain(`import { cli, Strategy } from '@agentrhq/webcmd/registry';`);
|
|
60
63
|
expect(tsSample).toContain(`strategy: Strategy.PUBLIC`);
|
|
@@ -65,19 +68,31 @@ describe('createPluginScaffold', () => {
|
|
|
65
68
|
it('documents a supported local install flow', () => {
|
|
66
69
|
const dir = path.join(os.tmpdir(), `webcmd-scaffold-${Date.now()}`);
|
|
67
70
|
createdDirs.push(dir);
|
|
68
|
-
createPluginScaffold('test-readme', { dir });
|
|
71
|
+
createPluginScaffold('test-readme', { dir, author });
|
|
69
72
|
const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf-8');
|
|
70
73
|
expect(readme).toContain(`webcmd plugin install file://${dir}`);
|
|
71
74
|
});
|
|
72
75
|
it('rejects invalid names', () => {
|
|
73
|
-
expect(() => createPluginScaffold('Bad_Name')).toThrow('Invalid plugin name');
|
|
74
|
-
expect(() => createPluginScaffold('123start')).toThrow('Invalid plugin name');
|
|
76
|
+
expect(() => createPluginScaffold('Bad_Name', { author })).toThrow('Invalid plugin name');
|
|
77
|
+
expect(() => createPluginScaffold('123start', { author })).toThrow('Invalid plugin name');
|
|
78
|
+
});
|
|
79
|
+
it('rejects missing or malformed author metadata before creating files', () => {
|
|
80
|
+
const missingDir = path.join(os.tmpdir(), `webcmd-scaffold-missing-author-${Date.now()}`);
|
|
81
|
+
const malformedDir = path.join(os.tmpdir(), `webcmd-scaffold-malformed-author-${Date.now()}`);
|
|
82
|
+
createdDirs.push(missingDir, malformedDir);
|
|
83
|
+
expect(() => createPluginScaffold('missing-author', { dir: missingDir })).toThrow('author');
|
|
84
|
+
expect(() => createPluginScaffold('malformed-author', {
|
|
85
|
+
dir: malformedDir,
|
|
86
|
+
author: { name: 'Rishabh', handle: '-wrong' },
|
|
87
|
+
})).toThrow('author.handle');
|
|
88
|
+
expect(fs.existsSync(missingDir)).toBe(false);
|
|
89
|
+
expect(fs.existsSync(malformedDir)).toBe(false);
|
|
75
90
|
});
|
|
76
91
|
it('rejects non-empty directory', () => {
|
|
77
92
|
const dir = path.join(os.tmpdir(), `webcmd-scaffold-${Date.now()}`);
|
|
78
93
|
createdDirs.push(dir);
|
|
79
94
|
fs.mkdirSync(dir, { recursive: true });
|
|
80
95
|
fs.writeFileSync(path.join(dir, 'existing.txt'), 'x');
|
|
81
|
-
expect(() => createPluginScaffold('test', { dir })).toThrow('not empty');
|
|
96
|
+
expect(() => createPluginScaffold('test', { dir, author })).toThrow('not empty');
|
|
82
97
|
});
|
|
83
98
|
});
|
package/dist/src/plugin.js
CHANGED
|
@@ -485,7 +485,10 @@ function installDependencies(dir) {
|
|
|
485
485
|
if (!fs.existsSync(pkgJsonPath))
|
|
486
486
|
return;
|
|
487
487
|
try {
|
|
488
|
-
|
|
488
|
+
// Plugin repositories and their transitive dependencies are untrusted.
|
|
489
|
+
// Webcmd adapters do not require install-time lifecycle scripts, so deny
|
|
490
|
+
// preinstall/install/postinstall execution with the user's privileges.
|
|
491
|
+
execFileSync('npm', ['install', '--omit=dev', '--ignore-scripts'], {
|
|
489
492
|
cwd: dir,
|
|
490
493
|
encoding: 'utf-8',
|
|
491
494
|
stdio: ['pipe', 'pipe', 'pipe'],
|
package/dist/src/plugin.test.js
CHANGED
|
@@ -564,6 +564,19 @@ describe('installDependencies', () => {
|
|
|
564
564
|
expect(() => _installDependencies(failingDir)).toThrow('npm install failed');
|
|
565
565
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
566
566
|
});
|
|
567
|
+
it('runs npm install with --ignore-scripts so untrusted lifecycle scripts cannot execute', () => {
|
|
568
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-plugin-scripts-'));
|
|
569
|
+
const pluginDir = path.join(tmpDir, 'plugin-c');
|
|
570
|
+
fs.mkdirSync(pluginDir, { recursive: true });
|
|
571
|
+
fs.writeFileSync(path.join(pluginDir, 'package.json'), JSON.stringify({ name: 'plugin-c' }));
|
|
572
|
+
_installDependencies(pluginDir);
|
|
573
|
+
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
|
|
574
|
+
const [bin, args] = mockExecFileSync.mock.calls[0];
|
|
575
|
+
expect(bin).toBe('npm');
|
|
576
|
+
expect(args).toContain('install');
|
|
577
|
+
expect(args).toContain('--ignore-scripts');
|
|
578
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
579
|
+
});
|
|
567
580
|
});
|
|
568
581
|
describe('postInstallMonorepoLifecycle', () => {
|
|
569
582
|
let repoDir;
|
package/dist/src/registry.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Core registry: Strategy enum, Arg/CliCommand interfaces, cli() registration.
|
|
3
3
|
*/
|
|
4
4
|
import type { IPage } from './types.js';
|
|
5
|
+
import type { FileArgumentContract } from './hosted/contract.js';
|
|
5
6
|
export declare enum Strategy {
|
|
6
7
|
PUBLIC = "public",
|
|
7
8
|
LOCAL = "local",
|
|
@@ -18,6 +19,7 @@ export interface Arg {
|
|
|
18
19
|
positional?: boolean;
|
|
19
20
|
help?: string;
|
|
20
21
|
choices?: string[];
|
|
22
|
+
file?: FileArgumentContract;
|
|
21
23
|
}
|
|
22
24
|
export type CommandArgs = Record<string, any>;
|
|
23
25
|
export type BrowserCommandFunc = (page: IPage, kwargs: CommandArgs, debug?: boolean) => Promise<unknown>;
|
|
@@ -8,6 +8,7 @@ export const RELEASE_NOTE_SECTIONS = [
|
|
|
8
8
|
const SQUASH_MERGE_PR_NUMBER_PATTERN = /\(#(?<number>\d+)\)\s*$/;
|
|
9
9
|
const MERGE_COMMIT_PR_NUMBER_PATTERN = /^Merge pull request #(?<number>\d+) /;
|
|
10
10
|
const RELEASE_PLEASE_TITLE_PATTERN = /^chore(?:\([^)]+\))?: release(?:\s|$)/;
|
|
11
|
+
const CONTRIBUTOR_AVATAR_SIZE = 40;
|
|
11
12
|
const SERVICE_ACCOUNT_HANDLES = new Set([
|
|
12
13
|
'allcontributors',
|
|
13
14
|
'copilot-pull-request-reviewer',
|
|
@@ -82,9 +83,16 @@ function parseReleaseSemver(tag) {
|
|
|
82
83
|
}
|
|
83
84
|
export function isMajorRelease(context) {
|
|
84
85
|
const current = parseReleaseSemver(context.tag);
|
|
85
|
-
if (!current
|
|
86
|
+
if (!current)
|
|
86
87
|
return false;
|
|
87
88
|
const previous = parseReleaseSemver(context.previousTag);
|
|
89
|
+
if (current.major === 0) {
|
|
90
|
+
if (current.minor === 0 || current.patch !== 0)
|
|
91
|
+
return false;
|
|
92
|
+
if (!previous)
|
|
93
|
+
return true;
|
|
94
|
+
return previous.major === 0 && current.minor > previous.minor;
|
|
95
|
+
}
|
|
88
96
|
if (!previous)
|
|
89
97
|
return current.minor === 0 && current.patch === 0;
|
|
90
98
|
return current.major > previous.major;
|
|
@@ -122,7 +130,7 @@ function githubHandleUrl(handle) {
|
|
|
122
130
|
function formatContributorAvatar(handle) {
|
|
123
131
|
const escapedHandle = escapeHtml(handle);
|
|
124
132
|
const encodedHandle = encodeURIComponent(handle);
|
|
125
|
-
return `<a href="${githubHandleUrl(handle)}" title="@${escapedHandle}"><img src="https://github.com/${encodedHandle}.png?size
|
|
133
|
+
return `<a href="${githubHandleUrl(handle)}" title="@${escapedHandle}"><img src="https://github.com/${encodedHandle}.png?size=${CONTRIBUTOR_AVATAR_SIZE}" width="${CONTRIBUTOR_AVATAR_SIZE}" height="${CONTRIBUTOR_AVATAR_SIZE}" alt="@${escapedHandle}" /></a>`;
|
|
126
134
|
}
|
|
127
135
|
function formatContributorLink(handle) {
|
|
128
136
|
return `[@${handle}](${githubHandleUrl(handle)})`;
|
|
@@ -132,7 +140,7 @@ function formatContributorsSection(handles) {
|
|
|
132
140
|
return null;
|
|
133
141
|
return [
|
|
134
142
|
'## Contributors',
|
|
135
|
-
handles.map(formatContributorAvatar).join('
|
|
143
|
+
handles.map(formatContributorAvatar).join(' '),
|
|
136
144
|
'',
|
|
137
145
|
handles.map(formatContributorLink).join(' | '),
|
|
138
146
|
].join('\n');
|
|
@@ -140,7 +148,7 @@ function formatContributorsSection(handles) {
|
|
|
140
148
|
function stripContributorAvatarLines(notes) {
|
|
141
149
|
return notes
|
|
142
150
|
.split(/\r?\n/)
|
|
143
|
-
.filter((line) => !(/<img\b/i.test(line) && /https:\/\/github\.com\/[^"'\s>]+\.png\?size
|
|
151
|
+
.filter((line) => !(/<img\b/i.test(line) && /https:\/\/github\.com\/[^"'\s>]+\.png\?size=\d+/.test(line)))
|
|
144
152
|
.join('\n')
|
|
145
153
|
.replace(/(^|\n)(## Contributors)\n\n/g, '$1$2\n')
|
|
146
154
|
.replace(/\n{3,}/g, '\n\n')
|