verify-release-artifact.ts 9.9 KB

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