verify-release-artifact.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. import { spawnSync } from 'node:child_process';
  2. import {
  3. copyFileSync,
  4. mkdirSync,
  5. mkdtempSync,
  6. readdirSync,
  7. readFileSync,
  8. rmSync,
  9. writeFileSync,
  10. } from 'node:fs';
  11. import { tmpdir } from 'node:os';
  12. import path from 'node:path';
  13. import { fileURLToPath } from 'node:url';
  14. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  15. const repoRoot = path.resolve(__dirname, '..');
  16. const distDir = path.join(repoRoot, 'dist');
  17. const suspiciousPathPatterns = [
  18. /\/Users\/[^\s'"`]+(?:node_modules|oh-my-opencode-slim)[^\s'"`]*/,
  19. /\/home\/[^\s'"`]+(?:node_modules|oh-my-opencode-slim)[^\s'"`]*/,
  20. ];
  21. const suspiciousImportPatterns = [/from\s+["']vscode-jsonrpc\/node["']/];
  22. const packagedRequiredFiles = [
  23. 'package.json',
  24. 'README.md',
  25. 'LICENSE',
  26. 'dist/index.js',
  27. 'dist/index.d.ts',
  28. 'dist/server/index.js',
  29. 'dist/tui.js',
  30. 'dist/tui.d.ts',
  31. 'dist/cli/index.js',
  32. 'dist/marketplace-contract/index.js',
  33. 'dist/marketplace-contract/index.d.ts',
  34. 'oh-my-opencode-slim.schema.json',
  35. 'src/companion/companion-manifest.json',
  36. 'src/skills/simplify/SKILL.md',
  37. 'src/skills/codemap/SKILL.md',
  38. 'src/skills/clonedeps/SKILL.md',
  39. 'src/skills/deepwork/SKILL.md',
  40. 'src/skills/verification-planning/SKILL.md',
  41. 'src/skills/reflect/SKILL.md',
  42. 'src/skills/oh-my-opencode-slim/SKILL.md',
  43. 'src/skills/worktrees/SKILL.md',
  44. ];
  45. function fail(message: string): never {
  46. throw new Error(message);
  47. }
  48. function run(command: string, args: string[], options: { cwd?: string } = {}) {
  49. const result = spawnSync(command, args, {
  50. cwd: options.cwd ?? repoRoot,
  51. encoding: 'utf8',
  52. stdio: ['ignore', 'pipe', 'pipe'],
  53. });
  54. if (result.status !== 0) {
  55. const detail = [result.stdout, result.stderr].filter(Boolean).join('\n');
  56. fail(
  57. `Command failed: ${command} ${args.join(' ')}${detail ? `\n${detail}` : ''}`,
  58. );
  59. }
  60. return result.stdout.trim();
  61. }
  62. type PackEntry = {
  63. filename?: string;
  64. files?: Array<{ path: string }>;
  65. };
  66. function parsePackJson(output: string): PackEntry[] {
  67. // npm pack --json historically emitted an array of entries; npm >= 12
  68. // emits an object keyed by package name. Accept both shapes.
  69. const arrayStart = output.indexOf('[');
  70. const objectStart = output.indexOf('{');
  71. if (arrayStart !== -1 && (objectStart === -1 || arrayStart < objectStart)) {
  72. const end = output.lastIndexOf(']');
  73. if (end === -1 || end < arrayStart) {
  74. fail(`Could not locate npm pack JSON output:\n${output}`);
  75. }
  76. return JSON.parse(output.slice(arrayStart, end + 1)) as PackEntry[];
  77. }
  78. const end = output.lastIndexOf('}');
  79. if (objectStart === -1 || end === -1 || end < objectStart) {
  80. fail(`Could not locate npm pack JSON output:\n${output}`);
  81. }
  82. const parsed = JSON.parse(output.slice(objectStart, end + 1)) as Record<
  83. string,
  84. PackEntry | PackEntry[]
  85. >;
  86. return Object.values(parsed).flat() as PackEntry[];
  87. }
  88. function walkFiles(dir: string): string[] {
  89. const entries = readdirSync(dir, { withFileTypes: true });
  90. return entries.flatMap((entry) => {
  91. const fullPath = path.join(dir, entry.name);
  92. if (entry.isDirectory()) return walkFiles(fullPath);
  93. return [fullPath];
  94. });
  95. }
  96. function verifyDistHasNoLeakedPaths() {
  97. console.log('Checking dist for leaked machine paths...');
  98. const files = walkFiles(distDir).filter((file) =>
  99. /\.(?:js|d\.ts|map|json)$/.test(file),
  100. );
  101. const leaks: string[] = [];
  102. for (const file of files) {
  103. const content = readFileSync(file, 'utf8');
  104. for (const pattern of suspiciousPathPatterns) {
  105. const match = content.match(pattern);
  106. if (!match) continue;
  107. leaks.push(`${path.relative(repoRoot, file)}: ${match[0]}`);
  108. }
  109. for (const pattern of suspiciousImportPatterns) {
  110. const match = content.match(pattern);
  111. if (!match) continue;
  112. leaks.push(`${path.relative(repoRoot, file)}: ${match[0]}`);
  113. }
  114. }
  115. if (leaks.length > 0) {
  116. fail(
  117. `Built artifact contains machine-specific paths:\n${leaks.join('\n')}`,
  118. );
  119. }
  120. }
  121. function packArtifact() {
  122. console.log('Packing npm artifact...');
  123. const output = run('npm', ['pack', '--json', '--ignore-scripts'], {
  124. cwd: repoRoot,
  125. });
  126. const parsed = parsePackJson(output);
  127. const tarball = parsed[0]?.filename;
  128. if (!tarball) {
  129. fail(`npm pack did not return a tarball filename:\n${output}`);
  130. }
  131. const packagedFiles = new Set(
  132. (parsed[0]?.files ?? []).map((file) => file.path),
  133. );
  134. for (const requiredFile of packagedRequiredFiles) {
  135. if (!packagedFiles.has(requiredFile)) {
  136. fail(`npm pack artifact is missing required file: ${requiredFile}`);
  137. }
  138. }
  139. return path.join(repoRoot, tarball);
  140. }
  141. function verifyFreshInstall(tarballPath: string) {
  142. const tempRoot = mkdtempSync(path.join(tmpdir(), 'omos-release-'));
  143. try {
  144. console.log('Installing packed artifact into clean temp project...');
  145. const installDir = path.join(tempRoot, 'install');
  146. const tarballTarget = path.join(tempRoot, path.basename(tarballPath));
  147. copyFileSync(tarballPath, tarballTarget);
  148. mkdirSync(installDir, { recursive: true });
  149. writeFileSync(
  150. path.join(installDir, 'package.json'),
  151. JSON.stringify(
  152. { name: 'verify-release-artifact', private: true },
  153. null,
  154. 2,
  155. ),
  156. );
  157. run('bun', ['add', '--ignore-scripts', tarballTarget], {
  158. cwd: installDir,
  159. });
  160. const installedEntry = path.join(
  161. installDir,
  162. 'node_modules',
  163. 'oh-my-opencode-slim',
  164. 'dist',
  165. 'index.js',
  166. );
  167. const installedEntryContent = readFileSync(installedEntry, 'utf8');
  168. for (const pattern of suspiciousPathPatterns) {
  169. const match = installedEntryContent.match(pattern);
  170. if (match) {
  171. fail(
  172. `Installed package still contains machine-specific path: ${match[0]}`,
  173. );
  174. }
  175. }
  176. const smokeScript = [
  177. "import pkg from 'oh-my-opencode-slim';",
  178. "if (pkg?.id !== 'oh-my-opencode-slim') throw new Error('default export has an unexpected plugin id');",
  179. "if (typeof pkg.server !== 'function') throw new Error('default export is missing a server plugin factory');",
  180. "if (typeof pkg.setup !== 'function') throw new Error('default export is missing a v2 setup factory');",
  181. 'const asyncNoop = async () => ({});',
  182. 'const client = new Proxy({}, {',
  183. ' get(_target, property) {',
  184. " if (property === 'app') return { log: asyncNoop };",
  185. " if (property === 'session') return { abort: asyncNoop };",
  186. ' return new Proxy({}, { get: () => asyncNoop });',
  187. ' },',
  188. '});',
  189. 'globalThis.fetch = async () => new Response(',
  190. " '<!doctype html><html><head><title>Release smoke</title></head><body><main><h1>Release smoke</h1><p>packaged jsdom extraction works</p></main></body></html>',",
  191. " { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } },",
  192. ');',
  193. 'const plugin = await pkg.server({',
  194. ' client,',
  195. ' directory: process.cwd(),',
  196. ' worktree: process.cwd(),',
  197. " serverUrl: new URL('http://127.0.0.1:4096'),",
  198. '});',
  199. 'const webfetch = plugin?.tool?.webfetch;',
  200. "if (typeof webfetch?.execute !== 'function') throw new Error('server plugin did not register webfetch');",
  201. 'const result = await webfetch.execute({',
  202. " url: 'https://example.com/release-smoke',",
  203. " format: 'markdown',",
  204. ' timeout: 10,',
  205. ' extract_main: true,',
  206. " prefer_llms_txt: 'never',",
  207. ' include_metadata: false,',
  208. ' save_binary: false,',
  209. '}, {',
  210. ' ask: async () => undefined,',
  211. ' metadata: () => undefined,',
  212. ' abort: new AbortController().signal,',
  213. ' directory: process.cwd(),',
  214. " sessionID: 'release-smoke',",
  215. '});',
  216. "if (!String(result).includes('packaged jsdom extraction works')) throw new Error('packaged webfetch did not extract the expected document');",
  217. 'await plugin.dispose?.();',
  218. "console.log('package loads');",
  219. "console.log('packaged webfetch constructs and extracts a document');",
  220. 'process.exit(0);',
  221. ].join('\n');
  222. console.log('Importing installed package entrypoint...');
  223. run('node', ['--input-type=module', '--eval', smokeScript], {
  224. cwd: installDir,
  225. });
  226. const tuiSmokeScript = [
  227. "import pkg from 'oh-my-opencode-slim/tui';",
  228. "if (pkg?.id !== 'oh-my-opencode-slim:tui') throw new Error('TUI export has an unexpected plugin id');",
  229. "if (typeof pkg.tui !== 'function') throw new Error('TUI export is missing its v1 factory');",
  230. "if (typeof pkg.setup !== 'function') throw new Error('TUI export is missing its v2 setup factory');",
  231. "console.log('TUI package loads');",
  232. 'process.exit(0);',
  233. ].join('\n');
  234. console.log('Importing installed TUI entrypoint...');
  235. run('bun', ['--eval', tuiSmokeScript], { cwd: installDir });
  236. // v2 hosts install this package with `subpaths: ["server", ""]`; the
  237. // exports map must resolve ./server to the self-contained bundle.
  238. const serverSmokeScript = [
  239. "import pkg from 'oh-my-opencode-slim/server';",
  240. "if (pkg?.id !== 'oh-my-opencode-slim') throw new Error('server export has an unexpected plugin id');",
  241. "if (typeof pkg.server !== 'function') throw new Error('server export is missing a v1 plugin factory');",
  242. "if (typeof pkg.setup !== 'function') throw new Error('server export is missing a v2 setup factory');",
  243. "console.log('server package loads');",
  244. 'process.exit(0);',
  245. ].join('\n');
  246. console.log('Importing installed server subpath entrypoint...');
  247. run('node', ['--input-type=module', '--eval', serverSmokeScript], {
  248. cwd: installDir,
  249. });
  250. const contractSmokeScript = [
  251. "import { registryArtifactPath } from 'oh-my-opencode-slim/marketplace-contract';",
  252. "if (registryArtifactPath('community/example', '1.0.0') !== 'artifacts/community/example/1.0.0.json') throw new Error('marketplace contract export failed');",
  253. "console.log('marketplace contract package loads');",
  254. ].join('\n');
  255. console.log('Importing marketplace contract subpath...');
  256. run('node', ['--input-type=module', '--eval', contractSmokeScript], {
  257. cwd: installDir,
  258. });
  259. } finally {
  260. rmSync(tempRoot, { recursive: true, force: true });
  261. }
  262. }
  263. function cleanupTarball(tarballPath: string) {
  264. rmSync(tarballPath, { force: true });
  265. }
  266. function main() {
  267. verifyDistHasNoLeakedPaths();
  268. const tarballPath = packArtifact();
  269. try {
  270. verifyFreshInstall(tarballPath);
  271. } finally {
  272. cleanupTarball(tarballPath);
  273. }
  274. console.log('Release artifact verification passed.');
  275. }
  276. main();