@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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { _isMacOsKeychainAvailableForTest, getHostedCredentialPath, resolveHostedApiKey, storeHostedApiKey, } from './credentials.js';
|
|
6
|
+
import { getConfigPath, makeHostedConfig, saveWebcmdConfig } from './config.js';
|
|
7
|
+
let tempDir;
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
if (tempDir)
|
|
10
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
11
|
+
tempDir = undefined;
|
|
12
|
+
});
|
|
13
|
+
describe('hosted credential storage', () => {
|
|
14
|
+
it('honors process-level file credential backend when no explicit env is injected', () => {
|
|
15
|
+
const previous = process.env.WEBCMD_CREDENTIAL_BACKEND;
|
|
16
|
+
process.env.WEBCMD_CREDENTIAL_BACKEND = 'file';
|
|
17
|
+
try {
|
|
18
|
+
expect(_isMacOsKeychainAvailableForTest({
|
|
19
|
+
platform: 'darwin',
|
|
20
|
+
existsSync: () => true,
|
|
21
|
+
})).toBe(false);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
if (previous === undefined)
|
|
25
|
+
delete process.env.WEBCMD_CREDENTIAL_BACKEND;
|
|
26
|
+
else
|
|
27
|
+
process.env.WEBCMD_CREDENTIAL_BACKEND = previous;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
it('stores hosted API keys in the protected file fallback', async () => {
|
|
31
|
+
tempDir = await mkdtemp(join(tmpdir(), 'webcmd-creds-'));
|
|
32
|
+
const env = {
|
|
33
|
+
WEBCMD_CONFIG_DIR: tempDir,
|
|
34
|
+
WEBCMD_CREDENTIAL_BACKEND: 'file',
|
|
35
|
+
};
|
|
36
|
+
const stored = await storeHostedApiKey('wcmd_live_test', {
|
|
37
|
+
env,
|
|
38
|
+
platform: 'linux',
|
|
39
|
+
randomUUID: () => '11111111-2222-4333-8444-555555555555',
|
|
40
|
+
});
|
|
41
|
+
expect(stored).toEqual({
|
|
42
|
+
apiKeyRef: 'wcmd_cred_11111111-2222-4333-8444-555555555555',
|
|
43
|
+
credentialBackend: 'file-fallback',
|
|
44
|
+
});
|
|
45
|
+
const credentialFile = JSON.parse(await readFile(getHostedCredentialPath({ env }), 'utf8'));
|
|
46
|
+
expect(credentialFile.credentials[stored.apiKeyRef]).toBe('wcmd_live_test');
|
|
47
|
+
const credentialMode = (await stat(getHostedCredentialPath({ env }))).mode & 0o777;
|
|
48
|
+
if (process.platform !== 'win32') {
|
|
49
|
+
expect(credentialMode).toBe(0o600);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
it('uses an injected OS credential store when available', async () => {
|
|
53
|
+
const secrets = new Map();
|
|
54
|
+
const credentialStore = {
|
|
55
|
+
backend: () => 'os',
|
|
56
|
+
put: vi.fn(async (reference, secret) => { secrets.set(reference, secret); }),
|
|
57
|
+
get: vi.fn(async (reference) => secrets.get(reference) ?? null),
|
|
58
|
+
delete: vi.fn(async (reference) => { secrets.delete(reference); }),
|
|
59
|
+
};
|
|
60
|
+
const stored = await storeHostedApiKey('wcmd_keychain_test', {
|
|
61
|
+
credentialStore,
|
|
62
|
+
randomUUID: () => 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
|
63
|
+
});
|
|
64
|
+
expect(stored).toEqual({
|
|
65
|
+
apiKeyRef: 'wcmd_cred_aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
|
66
|
+
credentialBackend: 'os',
|
|
67
|
+
});
|
|
68
|
+
expect(await credentialStore.get(stored.apiKeyRef)).toBe('wcmd_keychain_test');
|
|
69
|
+
});
|
|
70
|
+
it('migrates a legacy inline key to a credential reference without keeping the secret in config', async () => {
|
|
71
|
+
tempDir = await mkdtemp(join(tmpdir(), 'webcmd-creds-migrate-'));
|
|
72
|
+
const env = { WEBCMD_CONFIG_DIR: tempDir };
|
|
73
|
+
const legacy = makeHostedConfig({
|
|
74
|
+
apiBaseUrl: 'https://api.example.com',
|
|
75
|
+
apiKey: 'wcmd_legacy_key',
|
|
76
|
+
now: new Date('2026-07-08T00:00:00.000Z'),
|
|
77
|
+
});
|
|
78
|
+
saveWebcmdConfig(legacy, { env });
|
|
79
|
+
const resolved = await resolveHostedApiKey(legacy, {
|
|
80
|
+
env,
|
|
81
|
+
platform: 'linux',
|
|
82
|
+
randomUUID: () => 'bbbbbbbb-2222-4333-8444-555555555555',
|
|
83
|
+
});
|
|
84
|
+
expect(resolved).toMatchObject({
|
|
85
|
+
apiKey: 'wcmd_legacy_key',
|
|
86
|
+
backend: 'file-fallback',
|
|
87
|
+
migrated: true,
|
|
88
|
+
});
|
|
89
|
+
const configJson = await readFile(getConfigPath({ env }), 'utf8');
|
|
90
|
+
expect(configJson).toContain('wcmd_cred_bbbbbbbb-2222-4333-8444-555555555555');
|
|
91
|
+
expect(configJson).not.toContain('wcmd_legacy_key');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
const MiB = 1024 * 1024;
|
|
6
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
7
|
+
const IMAGE_CONTENT_TYPES = [
|
|
8
|
+
'image/jpeg',
|
|
9
|
+
'image/png',
|
|
10
|
+
'image/gif',
|
|
11
|
+
'image/webp',
|
|
12
|
+
];
|
|
13
|
+
const DOCUMENT_CONTENT_TYPES = [
|
|
14
|
+
'application/pdf',
|
|
15
|
+
'text/plain',
|
|
16
|
+
'text/markdown',
|
|
17
|
+
'text/csv',
|
|
18
|
+
'application/json',
|
|
19
|
+
];
|
|
20
|
+
const MERCURY_RECEIPT_CONTENT_TYPES = [
|
|
21
|
+
'image/jpeg',
|
|
22
|
+
'image/png',
|
|
23
|
+
'image/gif',
|
|
24
|
+
'image/webp',
|
|
25
|
+
'application/pdf',
|
|
26
|
+
];
|
|
27
|
+
const INSTAGRAM_MEDIA_CONTENT_TYPES = [
|
|
28
|
+
'image/jpeg',
|
|
29
|
+
'image/png',
|
|
30
|
+
'image/webp',
|
|
31
|
+
'video/mp4',
|
|
32
|
+
];
|
|
33
|
+
const EXPECTED_FILE_ARGUMENTS = {
|
|
34
|
+
'chatgpt/image': [{
|
|
35
|
+
name: 'image',
|
|
36
|
+
direction: 'input',
|
|
37
|
+
pathKind: 'file',
|
|
38
|
+
multiple: true,
|
|
39
|
+
required: false,
|
|
40
|
+
separator: ',',
|
|
41
|
+
contentTypes: IMAGE_CONTENT_TYPES,
|
|
42
|
+
maxBytes: 25 * MiB,
|
|
43
|
+
}, {
|
|
44
|
+
name: 'op',
|
|
45
|
+
direction: 'output',
|
|
46
|
+
pathKind: 'directory',
|
|
47
|
+
multiple: false,
|
|
48
|
+
required: false,
|
|
49
|
+
defaultPath: '~/Pictures/chatgpt',
|
|
50
|
+
}],
|
|
51
|
+
'chatgpt/project-file-add': [{
|
|
52
|
+
name: 'file',
|
|
53
|
+
direction: 'input',
|
|
54
|
+
pathKind: 'file',
|
|
55
|
+
multiple: true,
|
|
56
|
+
required: true,
|
|
57
|
+
separator: ',',
|
|
58
|
+
contentTypes: [
|
|
59
|
+
...DOCUMENT_CONTENT_TYPES,
|
|
60
|
+
...IMAGE_CONTENT_TYPES,
|
|
61
|
+
],
|
|
62
|
+
maxBytes: 25 * MiB,
|
|
63
|
+
}],
|
|
64
|
+
'claude/ask': [{
|
|
65
|
+
name: 'file',
|
|
66
|
+
direction: 'input',
|
|
67
|
+
pathKind: 'file',
|
|
68
|
+
multiple: false,
|
|
69
|
+
required: false,
|
|
70
|
+
contentTypes: [
|
|
71
|
+
...DOCUMENT_CONTENT_TYPES,
|
|
72
|
+
...IMAGE_CONTENT_TYPES,
|
|
73
|
+
],
|
|
74
|
+
maxBytes: 25 * MiB,
|
|
75
|
+
}],
|
|
76
|
+
'instagram/post': [{
|
|
77
|
+
name: 'media',
|
|
78
|
+
direction: 'input',
|
|
79
|
+
pathKind: 'file',
|
|
80
|
+
multiple: true,
|
|
81
|
+
required: false,
|
|
82
|
+
separator: ',',
|
|
83
|
+
contentTypes: INSTAGRAM_MEDIA_CONTENT_TYPES,
|
|
84
|
+
maxBytes: 250 * MiB,
|
|
85
|
+
}],
|
|
86
|
+
'instagram/reel': [{
|
|
87
|
+
name: 'video',
|
|
88
|
+
direction: 'input',
|
|
89
|
+
pathKind: 'file',
|
|
90
|
+
multiple: false,
|
|
91
|
+
required: false,
|
|
92
|
+
contentTypes: ['video/mp4'],
|
|
93
|
+
maxBytes: 250 * MiB,
|
|
94
|
+
}],
|
|
95
|
+
'mercury/reimbursement-draft': [{
|
|
96
|
+
name: 'receipt',
|
|
97
|
+
direction: 'input',
|
|
98
|
+
pathKind: 'file',
|
|
99
|
+
multiple: false,
|
|
100
|
+
required: true,
|
|
101
|
+
contentTypes: MERCURY_RECEIPT_CONTENT_TYPES,
|
|
102
|
+
maxBytes: 25 * MiB,
|
|
103
|
+
}],
|
|
104
|
+
'twitter/download': [{
|
|
105
|
+
name: 'output',
|
|
106
|
+
direction: 'output',
|
|
107
|
+
pathKind: 'directory',
|
|
108
|
+
multiple: false,
|
|
109
|
+
required: false,
|
|
110
|
+
}],
|
|
111
|
+
'twitter/post': [{
|
|
112
|
+
name: 'images',
|
|
113
|
+
direction: 'input',
|
|
114
|
+
pathKind: 'file',
|
|
115
|
+
multiple: true,
|
|
116
|
+
required: false,
|
|
117
|
+
separator: ',',
|
|
118
|
+
contentTypes: IMAGE_CONTENT_TYPES,
|
|
119
|
+
maxBytes: 25 * MiB,
|
|
120
|
+
}],
|
|
121
|
+
'twitter/quote': [{
|
|
122
|
+
name: 'image',
|
|
123
|
+
direction: 'input',
|
|
124
|
+
pathKind: 'file',
|
|
125
|
+
multiple: false,
|
|
126
|
+
required: false,
|
|
127
|
+
contentTypes: IMAGE_CONTENT_TYPES,
|
|
128
|
+
maxBytes: 25 * MiB,
|
|
129
|
+
}],
|
|
130
|
+
'twitter/reply': [{
|
|
131
|
+
name: 'image',
|
|
132
|
+
direction: 'input',
|
|
133
|
+
pathKind: 'file',
|
|
134
|
+
multiple: false,
|
|
135
|
+
required: false,
|
|
136
|
+
contentTypes: IMAGE_CONTENT_TYPES,
|
|
137
|
+
maxBytes: 25 * MiB,
|
|
138
|
+
}],
|
|
139
|
+
};
|
|
140
|
+
function readJson(name) {
|
|
141
|
+
return JSON.parse(readFileSync(path.join(packageRoot, name), 'utf8'));
|
|
142
|
+
}
|
|
143
|
+
function manifestCommand(manifest, command) {
|
|
144
|
+
const entry = manifest.find(item => `${item.site}/${item.name}` === command);
|
|
145
|
+
if (!entry)
|
|
146
|
+
throw new Error(`Missing manifest command: ${command}`);
|
|
147
|
+
return entry;
|
|
148
|
+
}
|
|
149
|
+
function contractCommand(contract, command) {
|
|
150
|
+
const entry = contract.commands.find(item => item.command === command);
|
|
151
|
+
if (!entry)
|
|
152
|
+
throw new Error(`Missing hosted contract command: ${command}`);
|
|
153
|
+
return entry;
|
|
154
|
+
}
|
|
155
|
+
describe('hosted file argument contract', () => {
|
|
156
|
+
it('declares every real local path argument in generated artifacts', () => {
|
|
157
|
+
const manifest = readJson('cli-manifest.json');
|
|
158
|
+
const contract = readJson('hosted-contract.json');
|
|
159
|
+
for (const [command, expected] of Object.entries(EXPECTED_FILE_ARGUMENTS)) {
|
|
160
|
+
expect(contractCommand(contract, command).fileArguments, command).toEqual(expected);
|
|
161
|
+
const manifestArgs = manifestCommand(manifest, command).args
|
|
162
|
+
.filter(arg => arg.file)
|
|
163
|
+
.map(arg => ({ name: arg.name, file: arg.file }));
|
|
164
|
+
expect(manifestArgs, command).toEqual(expected.map(({ name, required: _required, ...file }) => ({ name, file })));
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
it('does not treat Twitter remote image URLs as file arguments', () => {
|
|
168
|
+
const contract = readJson('hosted-contract.json');
|
|
169
|
+
expect(contractCommand(contract, 'twitter/quote').fileArguments.map(arg => arg.name))
|
|
170
|
+
.not.toContain('image-url');
|
|
171
|
+
expect(contractCommand(contract, 'twitter/reply').fileArguments.map(arg => arg.name))
|
|
172
|
+
.not.toContain('image-url');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { HostedClient } from './client.js';
|
|
2
|
+
import type { HostedArtifactReceipt, HostedCommand, HostedExecuteResponse } from './types.js';
|
|
3
|
+
export interface HostedPreparedFiles {
|
|
4
|
+
executionId: string;
|
|
5
|
+
args: Record<string, unknown>;
|
|
6
|
+
outputs: HostedOutputTarget[];
|
|
7
|
+
}
|
|
8
|
+
export interface HostedOutputTarget {
|
|
9
|
+
argument: string;
|
|
10
|
+
pathKind: 'file' | 'directory';
|
|
11
|
+
localPath: string;
|
|
12
|
+
}
|
|
13
|
+
export interface HostedMaterializedOutput {
|
|
14
|
+
argument: string;
|
|
15
|
+
artifact: HostedArtifactReceipt;
|
|
16
|
+
localPath: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function prepareHostedFiles(input: {
|
|
19
|
+
client: HostedClient;
|
|
20
|
+
command: HostedCommand;
|
|
21
|
+
args: Record<string, unknown>;
|
|
22
|
+
cwd?: string;
|
|
23
|
+
}): Promise<HostedPreparedFiles>;
|
|
24
|
+
export declare function materializeHostedOutputs(input: {
|
|
25
|
+
client: HostedClient;
|
|
26
|
+
response: HostedExecuteResponse;
|
|
27
|
+
outputs: HostedOutputTarget[];
|
|
28
|
+
}): Promise<HostedMaterializedOutput[]>;
|
|
29
|
+
export declare function rewriteHostedOutputResultPaths(value: unknown, materialized: readonly HostedMaterializedOutput[]): unknown;
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, open, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { CliError, EXIT_CODES } from '../errors.js';
|
|
7
|
+
export async function prepareHostedFiles(input) {
|
|
8
|
+
const fileArgs = input.command.args.filter(hasFileMetadata);
|
|
9
|
+
const cwd = input.cwd ?? process.cwd();
|
|
10
|
+
const remoteArgs = { ...input.args };
|
|
11
|
+
const inputs = [];
|
|
12
|
+
const outputs = [];
|
|
13
|
+
for (const arg of fileArgs) {
|
|
14
|
+
if (arg.file.direction === 'input') {
|
|
15
|
+
const values = valuesForFileArg(arg, input.args[arg.name]);
|
|
16
|
+
if (arg.required && values.length === 0) {
|
|
17
|
+
throw new CliError('HOSTED_FILE_INPUT_REQUIRED', `Argument "${arg.name}" requires a local file path.`, `Pass --${arg.name} <path>.`, EXIT_CODES.USAGE_ERROR);
|
|
18
|
+
}
|
|
19
|
+
for (const value of values) {
|
|
20
|
+
const localPath = resolveLocalPath(cwd, value, arg.name);
|
|
21
|
+
const body = await readLocalFileNoSymlink(localPath, arg.name);
|
|
22
|
+
assertLocalInputWithinLimit(arg, body);
|
|
23
|
+
const contentType = contentTypeForPath(localPath);
|
|
24
|
+
assertContentTypeAllowed(arg, contentType);
|
|
25
|
+
inputs.push({
|
|
26
|
+
argument: arg.name,
|
|
27
|
+
multiple: arg.file.multiple === true,
|
|
28
|
+
filename: path.basename(localPath),
|
|
29
|
+
contentType,
|
|
30
|
+
body,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const rawValue = input.args[arg.name] ?? arg.default ?? arg.file.defaultPath;
|
|
36
|
+
if (rawValue === undefined || rawValue === null || rawValue === '')
|
|
37
|
+
continue;
|
|
38
|
+
const values = valuesForFileArg(arg, rawValue);
|
|
39
|
+
const references = [];
|
|
40
|
+
for (const value of values) {
|
|
41
|
+
const localPath = resolveOutputPath(cwd, value, arg.name);
|
|
42
|
+
outputs.push({ argument: arg.name, pathKind: arg.file.pathKind, localPath });
|
|
43
|
+
references.push({
|
|
44
|
+
$webcmdArtifact: {
|
|
45
|
+
direction: 'output',
|
|
46
|
+
filename: path.basename(localPath) || arg.name,
|
|
47
|
+
contentType: 'application/octet-stream',
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (references.length > 0) {
|
|
52
|
+
remoteArgs[arg.name] = arg.file.multiple ? references : references[0];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const prepared = await input.client.prepareExecution({ command: input.command.command });
|
|
56
|
+
const referencesByArgument = new Map();
|
|
57
|
+
for (const upload of inputs) {
|
|
58
|
+
const uploaded = await input.client.uploadExecutionArtifact({
|
|
59
|
+
executionId: prepared.execution.id,
|
|
60
|
+
argument: upload.argument,
|
|
61
|
+
filename: upload.filename,
|
|
62
|
+
contentType: upload.contentType,
|
|
63
|
+
body: upload.body,
|
|
64
|
+
});
|
|
65
|
+
const references = referencesByArgument.get(upload.argument) ?? [];
|
|
66
|
+
references.push(uploaded.reference);
|
|
67
|
+
referencesByArgument.set(upload.argument, references);
|
|
68
|
+
}
|
|
69
|
+
for (const [argument, references] of referencesByArgument) {
|
|
70
|
+
const original = inputs.find(upload => upload.argument === argument);
|
|
71
|
+
remoteArgs[argument] = original?.multiple ? references : references[0];
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
executionId: prepared.execution.id,
|
|
75
|
+
args: remoteArgs,
|
|
76
|
+
outputs,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export async function materializeHostedOutputs(input) {
|
|
80
|
+
if (!input.outputs.length)
|
|
81
|
+
return [];
|
|
82
|
+
const receipts = input.response.artifacts ?? [];
|
|
83
|
+
const receiptsByArgument = new Map();
|
|
84
|
+
for (const receipt of receipts) {
|
|
85
|
+
if (receipt.direction !== 'output')
|
|
86
|
+
continue;
|
|
87
|
+
const bucket = receiptsByArgument.get(receipt.argument) ?? [];
|
|
88
|
+
bucket.push(receipt);
|
|
89
|
+
receiptsByArgument.set(receipt.argument, bucket);
|
|
90
|
+
}
|
|
91
|
+
const tempFiles = [];
|
|
92
|
+
const materialized = [];
|
|
93
|
+
try {
|
|
94
|
+
for (const output of input.outputs) {
|
|
95
|
+
if (output.pathKind === 'directory')
|
|
96
|
+
await mkdir(output.localPath, { recursive: true, mode: 0o700 });
|
|
97
|
+
const outputReceipts = receiptsByArgument.get(output.argument) ?? [];
|
|
98
|
+
const seenRelativePaths = new Set();
|
|
99
|
+
for (const receipt of outputReceipts) {
|
|
100
|
+
const target = targetPathForReceipt(output, receipt, seenRelativePaths);
|
|
101
|
+
const body = await input.client.downloadExecutionArtifact({
|
|
102
|
+
executionId: input.response.execution.id,
|
|
103
|
+
artifactId: receipt.artifactId,
|
|
104
|
+
});
|
|
105
|
+
assertReceiptBody(receipt, body);
|
|
106
|
+
const tempPath = await writeTempSibling(target, body);
|
|
107
|
+
tempFiles.push(tempPath);
|
|
108
|
+
await rename(tempPath, target);
|
|
109
|
+
tempFiles.splice(tempFiles.indexOf(tempPath), 1);
|
|
110
|
+
materialized.push({ argument: output.argument, artifact: receipt, localPath: target });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
await Promise.all(tempFiles.map(temp => rm(temp, { force: true }).catch(() => undefined)));
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
return materialized;
|
|
119
|
+
}
|
|
120
|
+
export function rewriteHostedOutputResultPaths(value, materialized) {
|
|
121
|
+
if (!materialized.length)
|
|
122
|
+
return value;
|
|
123
|
+
if (typeof value === 'string')
|
|
124
|
+
return rewritePathString(value, materialized);
|
|
125
|
+
if (Array.isArray(value))
|
|
126
|
+
return value.map(entry => rewriteHostedOutputResultPaths(entry, materialized));
|
|
127
|
+
if (!value || typeof value !== 'object')
|
|
128
|
+
return value;
|
|
129
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, rewriteHostedOutputResultPaths(entry, materialized)]));
|
|
130
|
+
}
|
|
131
|
+
function hasFileMetadata(arg) {
|
|
132
|
+
return !!arg.file;
|
|
133
|
+
}
|
|
134
|
+
function valuesForFileArg(arg, value) {
|
|
135
|
+
if (value === undefined || value === null || value === '')
|
|
136
|
+
return [];
|
|
137
|
+
if (Array.isArray(value)) {
|
|
138
|
+
if (!value.every(item => typeof item === 'string')) {
|
|
139
|
+
throw new CliError('HOSTED_FILE_ARGUMENT', `Argument "${arg.name}" must contain file path strings.`, undefined, EXIT_CODES.USAGE_ERROR);
|
|
140
|
+
}
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
if (typeof value !== 'string') {
|
|
144
|
+
throw new CliError('HOSTED_FILE_ARGUMENT', `Argument "${arg.name}" must be a file path string.`, undefined, EXIT_CODES.USAGE_ERROR);
|
|
145
|
+
}
|
|
146
|
+
if (arg.file.multiple && arg.file.separator === ',') {
|
|
147
|
+
return value.split(',').map(item => item.trim()).filter(Boolean);
|
|
148
|
+
}
|
|
149
|
+
return [value];
|
|
150
|
+
}
|
|
151
|
+
function resolveLocalPath(cwd, value, argName) {
|
|
152
|
+
if (!value.trim()) {
|
|
153
|
+
throw new CliError('HOSTED_FILE_ARGUMENT', `Argument "${argName}" must be a non-empty file path.`, undefined, EXIT_CODES.USAGE_ERROR);
|
|
154
|
+
}
|
|
155
|
+
return resolveUserPath(cwd, value);
|
|
156
|
+
}
|
|
157
|
+
function resolveOutputPath(cwd, value, argName) {
|
|
158
|
+
if (!value.trim()) {
|
|
159
|
+
throw new CliError('HOSTED_FILE_ARGUMENT', `Argument "${argName}" must be a non-empty output path.`, undefined, EXIT_CODES.USAGE_ERROR);
|
|
160
|
+
}
|
|
161
|
+
return resolveUserPath(cwd, value);
|
|
162
|
+
}
|
|
163
|
+
function resolveUserPath(cwd, value) {
|
|
164
|
+
if (value === '~')
|
|
165
|
+
return homedir();
|
|
166
|
+
if (value.startsWith('~/'))
|
|
167
|
+
return path.join(homedir(), value.slice(2));
|
|
168
|
+
return path.resolve(cwd, value);
|
|
169
|
+
}
|
|
170
|
+
async function readLocalFileNoSymlink(localPath, argName) {
|
|
171
|
+
let entry;
|
|
172
|
+
try {
|
|
173
|
+
entry = await lstat(localPath);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
throw new CliError('HOSTED_FILE_NOT_FOUND', `Input file for "${argName}" was not found.`, `Check the local path and try again.`, EXIT_CODES.USAGE_ERROR);
|
|
177
|
+
}
|
|
178
|
+
if (entry.isSymbolicLink() || !entry.isFile()) {
|
|
179
|
+
throw new CliError('HOSTED_FILE_NOT_FILE', `Input path for "${argName}" must be a regular file.`, undefined, EXIT_CODES.USAGE_ERROR);
|
|
180
|
+
}
|
|
181
|
+
const handle = await open(localPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
182
|
+
try {
|
|
183
|
+
return new Uint8Array(await handle.readFile());
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
await handle.close().catch(() => undefined);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function contentTypeForPath(localPath) {
|
|
190
|
+
const extension = path.extname(localPath).toLowerCase();
|
|
191
|
+
if (extension === '.jpg' || extension === '.jpeg')
|
|
192
|
+
return 'image/jpeg';
|
|
193
|
+
if (extension === '.png')
|
|
194
|
+
return 'image/png';
|
|
195
|
+
if (extension === '.gif')
|
|
196
|
+
return 'image/gif';
|
|
197
|
+
if (extension === '.webp')
|
|
198
|
+
return 'image/webp';
|
|
199
|
+
if (extension === '.mp4')
|
|
200
|
+
return 'video/mp4';
|
|
201
|
+
if (extension === '.pdf')
|
|
202
|
+
return 'application/pdf';
|
|
203
|
+
if (extension === '.json')
|
|
204
|
+
return 'application/json';
|
|
205
|
+
if (extension === '.csv')
|
|
206
|
+
return 'text/csv';
|
|
207
|
+
if (extension === '.md' || extension === '.markdown')
|
|
208
|
+
return 'text/markdown';
|
|
209
|
+
if (extension === '.txt')
|
|
210
|
+
return 'text/plain';
|
|
211
|
+
return 'application/octet-stream';
|
|
212
|
+
}
|
|
213
|
+
function assertLocalInputWithinLimit(arg, body) {
|
|
214
|
+
if (arg.file.maxBytes !== undefined && body.byteLength > arg.file.maxBytes) {
|
|
215
|
+
throw new CliError('HOSTED_FILE_TOO_LARGE', `Input file for "${arg.name}" exceeds the hosted argument size limit.`, `Maximum size is ${arg.file.maxBytes} bytes.`, EXIT_CODES.USAGE_ERROR);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function assertContentTypeAllowed(arg, contentType) {
|
|
219
|
+
const allowed = arg.file.contentTypes;
|
|
220
|
+
if (!allowed?.length)
|
|
221
|
+
return;
|
|
222
|
+
const normalized = contentType.toLowerCase();
|
|
223
|
+
const ok = allowed.some((entry) => {
|
|
224
|
+
const expected = entry.toLowerCase();
|
|
225
|
+
if (expected === '*/*')
|
|
226
|
+
return true;
|
|
227
|
+
if (expected.endsWith('/*'))
|
|
228
|
+
return normalized.startsWith(`${expected.slice(0, -1)}`);
|
|
229
|
+
return normalized === expected;
|
|
230
|
+
});
|
|
231
|
+
if (!ok) {
|
|
232
|
+
throw new CliError('HOSTED_FILE_CONTENT_TYPE', `Input file for "${arg.name}" has unsupported content type ${contentType}.`, `Allowed content types: ${allowed.join(', ')}`, EXIT_CODES.USAGE_ERROR);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function targetPathForReceipt(output, receipt, seenRelativePaths) {
|
|
236
|
+
if (output.pathKind === 'file')
|
|
237
|
+
return output.localPath;
|
|
238
|
+
const relativePath = receipt.relativePath;
|
|
239
|
+
if (!relativePath || !isSafeRelativePath(relativePath) || seenRelativePaths.has(relativePath)) {
|
|
240
|
+
throw new CliError('HOSTED_FILE_OUTPUT_INVALID', 'Webcmd Cloud returned an unsafe output artifact path.', undefined, EXIT_CODES.GENERIC_ERROR);
|
|
241
|
+
}
|
|
242
|
+
seenRelativePaths.add(relativePath);
|
|
243
|
+
return path.resolve(output.localPath, ...relativePath.split('/'));
|
|
244
|
+
}
|
|
245
|
+
function isSafeRelativePath(relativePath) {
|
|
246
|
+
if (path.posix.isAbsolute(relativePath) || relativePath.includes('\0'))
|
|
247
|
+
return false;
|
|
248
|
+
const parts = relativePath.split('/');
|
|
249
|
+
return parts.length > 0 && parts.every(part => part.length > 0 && part !== '.' && part !== '..');
|
|
250
|
+
}
|
|
251
|
+
function assertReceiptBody(receipt, body) {
|
|
252
|
+
if (body.byteLength !== receipt.byteSize) {
|
|
253
|
+
throw new CliError('HOSTED_FILE_HASH_MISMATCH', 'Downloaded artifact size did not match the hosted receipt.');
|
|
254
|
+
}
|
|
255
|
+
if (receipt.sha256 && sha256Hex(body) !== receipt.sha256) {
|
|
256
|
+
throw new CliError('HOSTED_FILE_HASH_MISMATCH', 'Downloaded artifact hash did not match the hosted receipt.');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async function writeTempSibling(targetPath, body) {
|
|
260
|
+
await mkdir(path.dirname(targetPath), { recursive: true, mode: 0o700 });
|
|
261
|
+
const tempPath = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
|
|
262
|
+
const handle = await open(tempPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
263
|
+
try {
|
|
264
|
+
await handle.writeFile(body);
|
|
265
|
+
await handle.sync();
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
await handle.close().catch(() => undefined);
|
|
269
|
+
}
|
|
270
|
+
return tempPath;
|
|
271
|
+
}
|
|
272
|
+
function sha256Hex(body) {
|
|
273
|
+
return createHash('sha256').update(body).digest('hex');
|
|
274
|
+
}
|
|
275
|
+
function rewritePathString(value, materialized) {
|
|
276
|
+
let next = value;
|
|
277
|
+
for (const output of materialized) {
|
|
278
|
+
const displayPath = displayLocalPath(output.localPath);
|
|
279
|
+
const tokens = [output.artifact.relativePath, output.artifact.filename]
|
|
280
|
+
.filter((token) => typeof token === 'string' && token.length > 0);
|
|
281
|
+
for (const token of tokens) {
|
|
282
|
+
const pattern = new RegExp(`(?:~|/|[A-Za-z]:[\\\\/])[^\\s"'<>|]*${escapeRegExp(token).replaceAll('/', '[\\\\/]')}`, 'g');
|
|
283
|
+
next = next.replace(pattern, displayPath);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return next;
|
|
287
|
+
}
|
|
288
|
+
function displayLocalPath(localPath) {
|
|
289
|
+
const home = homedir();
|
|
290
|
+
return localPath === home || localPath.startsWith(`${home}${path.sep}`)
|
|
291
|
+
? `~${localPath.slice(home.length)}`
|
|
292
|
+
: localPath;
|
|
293
|
+
}
|
|
294
|
+
function escapeRegExp(value) {
|
|
295
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
296
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|