@agentrhq/webcmd 0.2.5 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/README.md +13 -2
  2. package/cli-manifest.json +217 -13
  3. package/clis/_shared/site-auth.js +3 -3
  4. package/clis/_shared/site-auth.test.js +4 -4
  5. package/clis/chatgpt/ask.js +1 -1
  6. package/clis/chatgpt/ask.test.js +11 -0
  7. package/clis/chatgpt/commands.test.js +102 -1
  8. package/clis/chatgpt/deep-research-result.js +45 -3
  9. package/clis/chatgpt/image.js +22 -2
  10. package/clis/chatgpt/project-file-add.js +24 -1
  11. package/clis/chatgpt/utils.js +258 -52
  12. package/clis/chatgpt/utils.test.js +259 -1
  13. package/clis/claude/ask.js +21 -1
  14. package/clis/facebook/feed.js +110 -24
  15. package/clis/facebook/feed.test.js +62 -0
  16. package/clis/facebook/search.js +185 -38
  17. package/clis/facebook/search.test.js +95 -50
  18. package/clis/instagram/explore.js +30 -12
  19. package/clis/instagram/explore.test.js +77 -0
  20. package/clis/instagram/post.js +14 -1
  21. package/clis/instagram/reel.js +13 -1
  22. package/clis/linkedin/company.js +153 -0
  23. package/clis/linkedin/company.test.js +111 -0
  24. package/clis/linkedin/connections.js +135 -0
  25. package/clis/linkedin/connections.test.js +141 -0
  26. package/clis/mercury/reimbursement-draft.js +12 -1
  27. package/clis/slock/whoami.test.js +2 -2
  28. package/clis/twitter/article-evaluate.test.js +27 -0
  29. package/clis/twitter/article.js +73 -16
  30. package/clis/twitter/article.test.js +209 -0
  31. package/clis/twitter/download.js +6 -1
  32. package/clis/twitter/post.js +14 -1
  33. package/clis/twitter/profile.js +1 -1
  34. package/clis/twitter/profile.test.js +8 -0
  35. package/clis/twitter/quote.js +11 -1
  36. package/clis/twitter/reply.js +11 -1
  37. package/dist/src/browser/base-page.d.ts +26 -0
  38. package/dist/src/browser/base-page.js +23 -8
  39. package/dist/src/browser/base-page.test.js +45 -12
  40. package/dist/src/browser/command-catalog.d.ts +6 -0
  41. package/dist/src/browser/command-catalog.js +259 -0
  42. package/dist/src/browser/command-catalog.test.d.ts +1 -0
  43. package/dist/src/browser/command-catalog.test.js +79 -0
  44. package/dist/src/browser/daemon-client.d.ts +2 -1
  45. package/dist/src/browser/runtime/local-cloak/actions.js +4 -1
  46. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
  47. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
  48. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
  49. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
  50. package/dist/src/browser/runtime/local-cloak/provider.test.js +36 -0
  51. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +10 -2
  52. package/dist/src/browser/runtime/local-cloak/session-manager.js +25 -9
  53. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
  54. package/dist/src/browser/target-resolver.d.ts +1 -0
  55. package/dist/src/browser/target-resolver.js +99 -4
  56. package/dist/src/browser/target-resolver.test.js +141 -1
  57. package/dist/src/build-manifest.d.ts +6 -0
  58. package/dist/src/build-manifest.js +18 -1
  59. package/dist/src/build-manifest.test.js +57 -1
  60. package/dist/src/builtin-command-surface.d.ts +9 -0
  61. package/dist/src/builtin-command-surface.js +16 -0
  62. package/dist/src/check-hosted-contract.test.d.ts +1 -0
  63. package/dist/src/check-hosted-contract.test.js +56 -0
  64. package/dist/src/cli.js +63 -110
  65. package/dist/src/cli.test.js +82 -6
  66. package/dist/src/command-presentation.d.ts +95 -0
  67. package/dist/src/command-presentation.js +486 -0
  68. package/dist/src/command-presentation.test.d.ts +1 -0
  69. package/dist/src/command-presentation.test.js +97 -0
  70. package/dist/src/command-surface.d.ts +43 -0
  71. package/dist/src/command-surface.js +205 -0
  72. package/dist/src/command-surface.test.d.ts +1 -0
  73. package/dist/src/command-surface.test.js +406 -0
  74. package/dist/src/commanderAdapter.d.ts +5 -1
  75. package/dist/src/commanderAdapter.js +12 -55
  76. package/dist/src/commanderAdapter.test.js +15 -3
  77. package/dist/src/commands/auth.js +3 -2
  78. package/dist/src/commands/auth.test.js +6 -6
  79. package/dist/src/community-plugin-sync.d.ts +11 -0
  80. package/dist/src/community-plugin-sync.js +138 -0
  81. package/dist/src/community-plugin-sync.test.d.ts +1 -0
  82. package/dist/src/community-plugin-sync.test.js +123 -0
  83. package/dist/src/completion-fast.d.ts +4 -4
  84. package/dist/src/completion-fast.js +15 -34
  85. package/dist/src/completion-shared.d.ts +4 -0
  86. package/dist/src/completion-shared.js +38 -0
  87. package/dist/src/completion.js +3 -27
  88. package/dist/src/completion.test.js +31 -2
  89. package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
  90. package/dist/src/docs-sync-review-cli.test.js +322 -0
  91. package/dist/src/docs-sync-review.d.ts +117 -0
  92. package/dist/src/docs-sync-review.js +475 -0
  93. package/dist/src/docs-sync-review.test.d.ts +1 -0
  94. package/dist/src/docs-sync-review.test.js +324 -0
  95. package/dist/src/errors.d.ts +12 -3
  96. package/dist/src/errors.js +11 -7
  97. package/dist/src/errors.test.js +14 -1
  98. package/dist/src/execution.d.ts +1 -2
  99. package/dist/src/execution.js +4 -45
  100. package/dist/src/generate-release-notes-cli.test.js +11 -1
  101. package/dist/src/help.d.ts +4 -0
  102. package/dist/src/help.js +50 -255
  103. package/dist/src/help.test.js +27 -1
  104. package/dist/src/hosted/args.d.ts +4 -0
  105. package/dist/src/hosted/args.js +4 -0
  106. package/dist/src/hosted/args.test.d.ts +1 -0
  107. package/dist/src/hosted/args.test.js +49 -0
  108. package/dist/src/hosted/availability.d.ts +13 -0
  109. package/dist/src/hosted/availability.js +16 -0
  110. package/dist/src/hosted/availability.test.d.ts +1 -0
  111. package/dist/src/hosted/availability.test.js +180 -0
  112. package/dist/src/hosted/browser-args.d.ts +18 -0
  113. package/dist/src/hosted/browser-args.js +152 -0
  114. package/dist/src/hosted/browser-args.test.d.ts +1 -0
  115. package/dist/src/hosted/browser-args.test.js +182 -0
  116. package/dist/src/hosted/client.d.ts +59 -0
  117. package/dist/src/hosted/client.js +510 -0
  118. package/dist/src/hosted/client.test.d.ts +1 -0
  119. package/dist/src/hosted/client.test.js +738 -0
  120. package/dist/src/hosted/config.d.ts +55 -0
  121. package/dist/src/hosted/config.js +112 -0
  122. package/dist/src/hosted/config.test.d.ts +1 -0
  123. package/dist/src/hosted/config.test.js +81 -0
  124. package/dist/src/hosted/contract.d.ts +94 -0
  125. package/dist/src/hosted/contract.js +208 -0
  126. package/dist/src/hosted/contract.test.d.ts +1 -0
  127. package/dist/src/hosted/contract.test.js +361 -0
  128. package/dist/src/hosted/credentials.d.ts +38 -0
  129. package/dist/src/hosted/credentials.js +248 -0
  130. package/dist/src/hosted/credentials.test.d.ts +1 -0
  131. package/dist/src/hosted/credentials.test.js +93 -0
  132. package/dist/src/hosted/file-contract.test.d.ts +1 -0
  133. package/dist/src/hosted/file-contract.test.js +174 -0
  134. package/dist/src/hosted/files.d.ts +29 -0
  135. package/dist/src/hosted/files.js +296 -0
  136. package/dist/src/hosted/files.test.d.ts +1 -0
  137. package/dist/src/hosted/files.test.js +231 -0
  138. package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
  139. package/dist/src/hosted/main-lifecycle.test.js +213 -0
  140. package/dist/src/hosted/manifest.d.ts +14 -0
  141. package/dist/src/hosted/manifest.js +47 -0
  142. package/dist/src/hosted/manifest.test.d.ts +1 -0
  143. package/dist/src/hosted/manifest.test.js +164 -0
  144. package/dist/src/hosted/output-parity.test.d.ts +1 -0
  145. package/dist/src/hosted/output-parity.test.js +108 -0
  146. package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
  147. package/dist/src/hosted/root-command-surface.test.js +646 -0
  148. package/dist/src/hosted/runner.d.ts +19 -0
  149. package/dist/src/hosted/runner.js +705 -0
  150. package/dist/src/hosted/runner.test.d.ts +1 -0
  151. package/dist/src/hosted/runner.test.js +1607 -0
  152. package/dist/src/hosted/setup.d.ts +10 -0
  153. package/dist/src/hosted/setup.js +79 -0
  154. package/dist/src/hosted/setup.test.d.ts +1 -0
  155. package/dist/src/hosted/setup.test.js +195 -0
  156. package/dist/src/hosted/types.d.ts +175 -0
  157. package/dist/src/hosted/types.js +1 -0
  158. package/dist/src/main.js +123 -97
  159. package/dist/src/manifest-types.d.ts +2 -0
  160. package/dist/src/output.d.ts +17 -2
  161. package/dist/src/output.js +88 -81
  162. package/dist/src/output.test.js +141 -51
  163. package/dist/src/pipeline/executor.test.js +1 -0
  164. package/dist/src/pipeline/steps/download.test.js +1 -0
  165. package/dist/src/plugin-create-cli.test.d.ts +1 -0
  166. package/dist/src/plugin-create-cli.test.js +37 -0
  167. package/dist/src/plugin-manifest.d.ts +12 -0
  168. package/dist/src/plugin-manifest.js +16 -0
  169. package/dist/src/plugin-manifest.test.js +19 -1
  170. package/dist/src/plugin-scaffold.d.ts +4 -1
  171. package/dist/src/plugin-scaffold.js +4 -1
  172. package/dist/src/plugin-scaffold.test.js +23 -8
  173. package/dist/src/plugin.js +4 -1
  174. package/dist/src/plugin.test.js +13 -0
  175. package/dist/src/registry.d.ts +2 -0
  176. package/dist/src/release-notes.d.ts +6 -1
  177. package/dist/src/release-notes.js +192 -4
  178. package/dist/src/release-notes.test.js +143 -4
  179. package/dist/src/root-command-surface.d.ts +31 -0
  180. package/dist/src/root-command-surface.js +106 -0
  181. package/dist/src/serialization.d.ts +1 -16
  182. package/dist/src/serialization.js +5 -55
  183. package/dist/src/stream-write.d.ts +12 -0
  184. package/dist/src/stream-write.js +91 -0
  185. package/dist/src/stream-write.test.d.ts +1 -0
  186. package/dist/src/stream-write.test.js +186 -0
  187. package/dist/src/types.d.ts +5 -0
  188. package/dist/src/utils.d.ts +1 -1
  189. package/dist/src/utils.js +2 -8
  190. package/dist/src/utils.test.js +50 -0
  191. package/hosted-contract.json +36748 -0
  192. package/package.json +6 -1
  193. package/scripts/check-hosted-contract.mjs +108 -0
  194. package/scripts/docs-sync-review.ts +332 -0
  195. package/scripts/generate-release-notes.ts +1 -1
  196. package/scripts/sync-community-plugins.ts +14 -0
  197. package/skills/smart-search/SKILL.md +14 -3
  198. package/skills/webcmd-autofix/SKILL.md +26 -37
  199. package/skills/webcmd-usage/SKILL.md +21 -7
  200. package/clis/antigravity/SKILL.md +0 -38
@@ -1,5 +1,145 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { resolveTargetJs } from './target-resolver.js';
2
+ import { boundingRectResolvedJs, resolveTargetJs } from './target-resolver.js';
3
+ /**
4
+ * Build a fake element usable by the generated boundingRectResolvedJs. Rects are
5
+ * {left,top,width,height}; getBoundingClientRect derives right/bottom.
6
+ */
7
+ function makeEl(opts) {
8
+ const attrs = opts.attrs ?? {};
9
+ const el = {
10
+ nodeType: 1,
11
+ tagName: opts.tag.toUpperCase(),
12
+ _cursor: opts.cursor ?? 'auto',
13
+ onclick: opts.onclick ? () => { } : null,
14
+ parentElement: null,
15
+ children: [],
16
+ scrollIntoView() { },
17
+ getBoundingClientRect() {
18
+ const r = opts.rect;
19
+ return { left: r.left, top: r.top, width: r.width, height: r.height, right: r.left + r.width, bottom: r.top + r.height };
20
+ },
21
+ getAttribute(k) { return k === 'role' ? (opts.role ?? null) : (attrs[k] ?? null); },
22
+ hasAttribute(k) { return k === 'role' ? opts.role != null : k in attrs; },
23
+ contains(other) {
24
+ if (other === el)
25
+ return true;
26
+ for (const c of el.children) {
27
+ if (c === other || (c.contains && c.contains(other)))
28
+ return true;
29
+ }
30
+ return false;
31
+ },
32
+ };
33
+ return el;
34
+ }
35
+ /** Resolve computed cursor with CSS inheritance, so the fake DOM matches the
36
+ * browser: a child with no own cursor inherits its ancestors' pointer cursor. */
37
+ function inheritedCursor(node) {
38
+ let n = node;
39
+ while (n) {
40
+ if (n._cursor && n._cursor !== 'auto')
41
+ return n._cursor;
42
+ n = n.parentElement;
43
+ }
44
+ return 'auto';
45
+ }
46
+ /** Run the generated boundingRectResolvedJs against a fake DOM. */
47
+ function runRect(resolved, elementAt) {
48
+ const fakeWindow = { __resolved: resolved, getComputedStyle: (n) => ({ cursor: inheritedCursor(n) }) };
49
+ const fakeDoc = { elementFromPoint: (x, y) => elementAt(x, y) };
50
+ const js = boundingRectResolvedJs({ skipScroll: true, forClick: true });
51
+ // js is an IIFE expression `(() => {...})()`; arrow captures window/document
52
+ // params. Wrap in parens so ASI doesn't turn `return\n(` into `return;`.
53
+ return new Function('window', 'document', 'return (' + js + ')')(fakeWindow, fakeDoc);
54
+ }
55
+ describe('boundingRectResolvedJs runtime behavior', () => {
56
+ it('reports hit=target and no retarget when the centre lands on the element', () => {
57
+ const btn = makeEl({ tag: 'button', rect: { left: 0, top: 0, width: 100, height: 40 } });
58
+ const out = runRect(btn, () => btn);
59
+ expect(out.visible).toBe(true);
60
+ expect(out.hit).toBe('target');
61
+ expect(out.retargeted).toBe(false);
62
+ expect(out.x).toBe(50);
63
+ expect(out.y).toBe(20);
64
+ });
65
+ it('retargets an <svg> icon to its clickable <div> ancestor (#2071)', () => {
66
+ const div = makeEl({ tag: 'div', rect: { left: 0, top: 0, width: 80, height: 80 }, cursor: 'pointer' });
67
+ const svg = makeEl({ tag: 'svg', rect: { left: 20, top: 20, width: 40, height: 40 } });
68
+ svg.parentElement = div;
69
+ div.children = [svg];
70
+ // elementFromPoint at the div centre returns the svg (topmost paint), which
71
+ // is contained by div → hit=target on the retargeted element.
72
+ const out = runRect(svg, () => svg);
73
+ expect(out.retargeted).toBe(true);
74
+ expect(out.hit).toBe('target');
75
+ // measured rect is the div's (80x80 → centre 40,40), not the svg's
76
+ expect(out.w).toBe(80);
77
+ expect(out.x).toBe(40);
78
+ expect(out.y).toBe(40);
79
+ });
80
+ it('retargets to an ancestor that owns an onclick handler even without cursor:pointer (#2071)', () => {
81
+ const div = makeEl({ tag: 'div', rect: { left: 0, top: 0, width: 60, height: 60 }, onclick: true });
82
+ const svg = makeEl({ tag: 'svg', rect: { left: 10, top: 10, width: 40, height: 40 } });
83
+ svg.parentElement = div;
84
+ div.children = [svg];
85
+ const out = runRect(svg, () => svg);
86
+ expect(out.retargeted).toBe(true);
87
+ expect(out.w).toBe(60);
88
+ });
89
+ it('does not retarget when no ancestor is clickable', () => {
90
+ const wrap = makeEl({ tag: 'div', rect: { left: 0, top: 0, width: 60, height: 60 } });
91
+ const span = makeEl({ tag: 'span', rect: { left: 10, top: 10, width: 40, height: 40 } });
92
+ span.parentElement = wrap;
93
+ wrap.children = [span];
94
+ const out = runRect(span, () => span);
95
+ expect(out.retargeted).toBe(false);
96
+ expect(out.w).toBe(40);
97
+ });
98
+ it('treats an ancestor hit as trustworthy (open shadow-DOM host / own wrapper) and keeps the centre', () => {
99
+ // elementFromPoint returns a light-DOM ancestor (e.g. the shadow host) that
100
+ // contains the target — CDP pierces to the target, so this must NOT fall back.
101
+ const host = makeEl({ tag: 'div', rect: { left: 0, top: 0, width: 120, height: 60 } });
102
+ const btn = makeEl({ tag: 'button', rect: { left: 10, top: 10, width: 100, height: 40 } });
103
+ btn.parentElement = host;
104
+ host.children = [btn];
105
+ const out = runRect(btn, () => host); // topmost at every point is the host (ancestor)
106
+ expect(out.hit).toBe('ancestor');
107
+ expect(out.retargeted).toBe(false);
108
+ // centre kept — no wasted probing when the hit is already trustworthy
109
+ expect(out.x).toBe(60);
110
+ expect(out.y).toBe(30);
111
+ });
112
+ it('hover/dblClick mode (forClick omitted) returns the plain element centre with no hit-test', () => {
113
+ const div = makeEl({ tag: 'div', rect: { left: 0, top: 0, width: 80, height: 80 }, cursor: 'pointer' });
114
+ const svg = makeEl({ tag: 'svg', rect: { left: 20, top: 20, width: 40, height: 40 } });
115
+ svg.parentElement = div;
116
+ div.children = [svg];
117
+ const js = boundingRectResolvedJs({ skipScroll: true }); // no forClick
118
+ const out = new Function('window', 'document', 'return (' + js + ')')({ __resolved: svg, getComputedStyle: () => ({ cursor: 'pointer' }) }, { elementFromPoint: () => div });
119
+ // no retarget, no hit field — svg's own centre (40,40), unchanged behavior
120
+ expect(out.hit).toBeUndefined();
121
+ expect(out.retargeted).toBeUndefined();
122
+ expect(out.x).toBe(40);
123
+ expect(out.y).toBe(40);
124
+ expect(out.w).toBe(40);
125
+ });
126
+ it('reports hit=other when an overlay covers the element and no probe point lands (#2076)', () => {
127
+ const btn = makeEl({ tag: 'button', rect: { left: 0, top: 0, width: 100, height: 40 } });
128
+ const overlay = makeEl({ tag: 'div', rect: { left: 0, top: 0, width: 1000, height: 1000 } });
129
+ const out = runRect(btn, () => overlay); // every point hits the overlay
130
+ expect(out.hit).toBe('other');
131
+ expect(out.retargeted).toBe(false);
132
+ });
133
+ it('recovers a hitting point by probing when the centre is occluded but a corner is clear (#2076)', () => {
134
+ const btn = makeEl({ tag: 'button', rect: { left: 0, top: 0, width: 100, height: 40 } });
135
+ const overlay = makeEl({ tag: 'div', rect: { left: 40, top: 15, width: 20, height: 10 } });
136
+ // Centre (50,20) is covered by the overlay; the top-left inset (3,3) is clear.
137
+ const out = runRect(btn, (x, y) => (x === 50 && y === 20 ? overlay : btn));
138
+ expect(out.hit).toBe('target');
139
+ // the returned point moved off the occluded centre (50,20)
140
+ expect(out.x === 50 && out.y === 20).toBe(false);
141
+ });
142
+ });
3
143
  /**
4
144
  * Tests for the target resolver JS generator.
5
145
  *
@@ -22,6 +22,7 @@
22
22
  * amount) to confirm an intentional removal.
23
23
  */
24
24
  import type { ManifestEntry } from './manifest-types.js';
25
+ import { type HostedBrowserCommandContract } from './hosted/contract.js';
25
26
  export type { ManifestEntry } from './manifest-types.js';
26
27
  /**
27
28
  * Thrown by `loadManifestEntries` when an adapter file looks like a CLI
@@ -66,6 +67,11 @@ export declare function loadManifestEntries(filePath: string, site: string, impo
66
67
  export declare function scanClisDir(clisDir: string, importer?: (moduleHref: string) => Promise<unknown>): Promise<BuildManifestResult>;
67
68
  export declare function buildManifest(): Promise<BuildManifestResult>;
68
69
  export declare function serializeManifest(manifest: ManifestEntry[]): string;
70
+ export interface BuildManifestArtifacts {
71
+ manifestJson: string;
72
+ hostedContractJson: string;
73
+ }
74
+ export declare function buildManifestArtifacts(entries: ManifestEntry[], packageVersion: string, browserCatalog: readonly HostedBrowserCommandContract[]): BuildManifestArtifacts;
69
75
  /**
70
76
  * Metadata audit: every positional arg must carry a non-empty `help` string.
71
77
  *
@@ -28,10 +28,12 @@ import { getErrorMessage } from './errors.js';
28
28
  import { fullName, getRegistry } from './registry.js';
29
29
  import { findPackageRoot, getCliManifestPath } from './package-paths.js';
30
30
  import { isRecord } from './utils.js';
31
+ import { buildHostedContract, serializeHostedContract, } from './hosted/contract.js';
31
32
  const PACKAGE_ROOT = findPackageRoot(fileURLToPath(import.meta.url));
32
33
  const CLIS_DIR = path.join(PACKAGE_ROOT, 'clis');
33
34
  // Write manifest next to clis/ so both dev and installed runtime can find it.
34
35
  const OUTPUT = getCliManifestPath(CLIS_DIR);
36
+ const HOSTED_CONTRACT_OUTPUT = path.join(PACKAGE_ROOT, 'hosted-contract.json');
35
37
  // Module is treated as a CLI command source if it either:
36
38
  // 1. Calls `cli(...)` directly (the common case), or
37
39
  // 2. Calls a factory `make<Pascal>Command(...)` from clis/_shared/ that
@@ -64,6 +66,7 @@ function toManifestArgs(args) {
64
66
  positional: arg.positional || undefined,
65
67
  help: arg.help ?? '',
66
68
  choices: arg.choices,
69
+ file: arg.file,
67
70
  }));
68
71
  }
69
72
  function toModulePath(filePath, site) {
@@ -211,6 +214,12 @@ export async function buildManifest() {
211
214
  export function serializeManifest(manifest) {
212
215
  return `${JSON.stringify(manifest, null, 2)}\n`;
213
216
  }
217
+ export function buildManifestArtifacts(entries, packageVersion, browserCatalog) {
218
+ return {
219
+ manifestJson: serializeManifest(entries),
220
+ hostedContractJson: serializeHostedContract(buildHostedContract(entries, browserCatalog, packageVersion)),
221
+ };
222
+ }
214
223
  export function findManifestMetadataIssues(entries) {
215
224
  const issues = [];
216
225
  for (const entry of entries) {
@@ -330,9 +339,17 @@ async function main() {
330
339
  process.exit(1);
331
340
  }
332
341
  }
342
+ const packagePath = path.resolve(PACKAGE_ROOT, 'package.json');
343
+ const packageMetadata = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
344
+ if (typeof packageMetadata.name !== 'string' || typeof packageMetadata.version !== 'string') {
345
+ throw new Error(`Package name and version are required in ${packagePath}`);
346
+ }
347
+ const artifacts = buildManifestArtifacts(entries, packageMetadata.version, []);
333
348
  fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
334
- fs.writeFileSync(OUTPUT, serializeManifest(entries));
349
+ fs.writeFileSync(OUTPUT, artifacts.manifestJson);
350
+ fs.writeFileSync(HOSTED_CONTRACT_OUTPUT, artifacts.hostedContractJson);
335
351
  console.log(`✅ Manifest compiled: ${entries.length} entries → ${OUTPUT}`);
352
+ console.log(`✅ Hosted contract compiled: ${packageMetadata.name}@${packageMetadata.version} → ${HOSTED_CONTRACT_OUTPUT}`);
336
353
  // Restore executable permissions on bin entries.
337
354
  // tsc does not preserve the +x bit, so after a clean rebuild the CLI
338
355
  // entry-point loses its executable permission, causing "Permission denied".
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
5
  import { cli, getRegistry, Strategy } from './registry.js';
6
- import { ManifestImportError, diffRemovedEntries, findManifestMetadataIssues, loadManifestEntries, normalizeManifestPath, parseBuildManifestArgs, scanClisDir, serializeManifest, } from './build-manifest.js';
6
+ import { ManifestImportError, buildManifestArtifacts, diffRemovedEntries, findManifestMetadataIssues, loadManifestEntries, normalizeManifestPath, parseBuildManifestArgs, scanClisDir, serializeManifest, } from './build-manifest.js';
7
7
  describe('manifest helper rules', () => {
8
8
  const tempDirs = [];
9
9
  afterEach(() => {
@@ -81,6 +81,62 @@ describe('manifest helper rules', () => {
81
81
  expect(entries[0].sourceFile).not.toContain('\\');
82
82
  getRegistry().delete(key);
83
83
  });
84
+ it('preserves file metadata and builds both artifacts from one normalized snapshot', async () => {
85
+ const site = `manifest-files-${Date.now()}`;
86
+ const key = `${site}/upload`;
87
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-manifest-'));
88
+ tempDirs.push(dir);
89
+ const file = path.join(dir, `${site}.ts`);
90
+ fs.writeFileSync(file, `export const command = cli({ site: '${site}', name: 'upload', access: 'write' });`);
91
+ const entries = await loadManifestEntries(file, site, async () => ({
92
+ command: cli({
93
+ site,
94
+ name: 'upload',
95
+ access: 'write',
96
+ description: 'upload a document',
97
+ strategy: Strategy.PUBLIC,
98
+ browser: false,
99
+ args: [{
100
+ name: 'path',
101
+ type: 'str',
102
+ positional: true,
103
+ required: true,
104
+ help: 'Document path',
105
+ file: {
106
+ direction: 'input',
107
+ pathKind: 'file',
108
+ contentTypes: ['application/pdf'],
109
+ maxBytes: 5_000_000,
110
+ },
111
+ }],
112
+ }),
113
+ }));
114
+ expect(entries[0].args[0].file).toEqual({
115
+ direction: 'input',
116
+ pathKind: 'file',
117
+ contentTypes: ['application/pdf'],
118
+ maxBytes: 5_000_000,
119
+ });
120
+ const artifacts = buildManifestArtifacts(entries, '7.6.5', []);
121
+ expect(JSON.parse(artifacts.manifestJson)).toEqual(entries);
122
+ expect(JSON.parse(artifacts.hostedContractJson)).toMatchObject({
123
+ schemaVersion: 1,
124
+ webcmdVersion: '7.6.5',
125
+ commands: [{
126
+ command: `${site}/upload`,
127
+ fileArguments: [{
128
+ name: 'path',
129
+ direction: 'input',
130
+ pathKind: 'file',
131
+ multiple: false,
132
+ required: true,
133
+ contentTypes: ['application/pdf'],
134
+ maxBytes: 5_000_000,
135
+ }],
136
+ }],
137
+ });
138
+ getRegistry().delete(key);
139
+ });
84
140
  it('serializes freshPage for persistent browser commands', async () => {
85
141
  const site = `manifest-fresh-page-${Date.now()}`;
86
142
  const key = `${site}/checkout`;
@@ -0,0 +1,9 @@
1
+ import type { Command } from 'commander';
2
+ export declare const LIST_COMMAND_DESCRIPTION = "List all available CLI commands";
3
+ export declare const LIST_FORMAT_DESCRIPTION = "Output format: table, json, yaml, md, csv";
4
+ export declare const COMPLETION_COMMAND_DESCRIPTION = "Output shell completion script";
5
+ export declare const COMPLETION_SHELL_DESCRIPTION = "Shell type: bash, zsh, or fish";
6
+ /** Configure built-in grammar shared by the local and hosted runtimes. */
7
+ export declare function configureListCommandSurface(command: Command): Command;
8
+ /** Configure completion grammar shared by the local and hosted runtimes. */
9
+ export declare function configureCompletionCommandSurface(command: Command): Command;
@@ -0,0 +1,16 @@
1
+ export const LIST_COMMAND_DESCRIPTION = 'List all available CLI commands';
2
+ export const LIST_FORMAT_DESCRIPTION = 'Output format: table, json, yaml, md, csv';
3
+ export const COMPLETION_COMMAND_DESCRIPTION = 'Output shell completion script';
4
+ export const COMPLETION_SHELL_DESCRIPTION = 'Shell type: bash, zsh, or fish';
5
+ /** Configure built-in grammar shared by the local and hosted runtimes. */
6
+ export function configureListCommandSurface(command) {
7
+ return command
8
+ .description(LIST_COMMAND_DESCRIPTION)
9
+ .option('-f, --format <fmt>', LIST_FORMAT_DESCRIPTION, 'table');
10
+ }
11
+ /** Configure completion grammar shared by the local and hosted runtimes. */
12
+ export function configureCompletionCommandSurface(command) {
13
+ return command
14
+ .description(COMPLETION_COMMAND_DESCRIPTION)
15
+ .argument('<shell>', COMPLETION_SHELL_DESCRIPTION);
16
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,56 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { afterEach, describe, expect, it } from 'vitest';
8
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
+ const checkerPath = path.join(packageRoot, 'scripts/check-hosted-contract.mjs');
10
+ const committedArtifactNames = ['cli-manifest.json'];
11
+ const fixtureRoots = [];
12
+ afterEach(() => {
13
+ for (const fixtureRoot of fixtureRoots.splice(0)) {
14
+ rmSync(fixtureRoot, { recursive: true, force: true });
15
+ }
16
+ });
17
+ function createCommittedArtifactFixture() {
18
+ const fixtureRoot = mkdtempSync(path.join(tmpdir(), 'webcmd-contract-committed-'));
19
+ fixtureRoots.push(fixtureRoot);
20
+ for (const artifactName of committedArtifactNames) {
21
+ copyFileSync(path.join(packageRoot, artifactName), path.join(fixtureRoot, artifactName));
22
+ }
23
+ return fixtureRoot;
24
+ }
25
+ function rootArtifactHashes() {
26
+ return Object.fromEntries(committedArtifactNames.map((artifactName) => [
27
+ artifactName,
28
+ createHash('sha256').update(readFileSync(path.join(packageRoot, artifactName))).digest('hex'),
29
+ ]));
30
+ }
31
+ function runChecker(committedRoot) {
32
+ return spawnSync(process.execPath, [checkerPath], {
33
+ cwd: packageRoot,
34
+ encoding: 'utf8',
35
+ env: { ...process.env, WEBCMD_CONTRACT_COMMITTED_ROOT: committedRoot },
36
+ });
37
+ }
38
+ describe('hosted contract reproducibility checker', () => {
39
+ it('accepts a byte-identical committed manifest and generates the package contract', () => {
40
+ const result = runChecker(createCommittedArtifactFixture());
41
+ expect(result.status).toBe(0);
42
+ expect(result.stdout).toContain('hosted-contract.json generated successfully.');
43
+ }, 10_000);
44
+ it('rejects one stale byte without mutating root artifacts', () => {
45
+ const before = rootArtifactHashes();
46
+ const fixtureRoot = createCommittedArtifactFixture();
47
+ const stalePath = path.join(fixtureRoot, 'cli-manifest.json');
48
+ const staleBytes = readFileSync(stalePath);
49
+ staleBytes[0] ^= 1;
50
+ writeFileSync(stalePath, staleBytes);
51
+ const result = runChecker(fixtureRoot);
52
+ expect(result.status).toBe(1);
53
+ expect(result.stderr).toContain('cli-manifest.json');
54
+ expect(rootArtifactHashes()).toEqual(before);
55
+ }, 10_000);
56
+ });