From c00a26e836f202a9b745ef3140d4404072b553de Mon Sep 17 00:00:00 2001 From: Jordan Paulino Date: Wed, 13 May 2026 01:23:47 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8D=95=20fix(zip):=20strip=20source=20map?= =?UTF-8?q?s=20+=20verify=20manifest=20at=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chrome Web Store rejects uploads where `manifest.json` isn't at the zip root with "No manifest found in package." The most common cause is right-clicking `dist/` in Finder/Explorer → Compress, which produces a zip with a `dist/` wrapper. Even when our script is used, source maps and macOS `__MACOSX/` resource forks would silently triple the upload size and occasionally trip Web Store validation. This hardens `scripts/zip-extension.mjs`: - **Strips `*.map`, `.DS_Store`, and `__MACOSX/`** by default. Pass `INCLUDE_SOURCEMAPS=1 npm run zip` to opt back in for sideload-debug builds. Cuts the zip from ~333 KB → ~101 KB (3.3× smaller). - **Verifies after zipping** that `manifest.json` is at the root by shelling out to `unzip -p` (with a PowerShell fallback). Refuses to hand off a zip that would be rejected by the store. - **Asserts `dist/manifest.json` exists** before zipping (catches a half-broken build). - **Prints explicit upload instructions** so there's no ambiguity about which file to upload and where. README's "Releasing" section now warns against Finder/Explorer compression and documents the `INCLUDE_SOURCEMAPS=1` escape hatch. Tested locally: $ npm run zip → clay-slip-v2.0.2.zip (101.0 KB) → manifest.json verified at root Negative test (zipped with a `dist/` wrapper) correctly trips the verification guard with exit code 2. Co-authored-by: Cursor --- README.md | 3 + scripts/zip-extension.mjs | 116 +++++++++++++++++++++++++++++++++----- 2 files changed, 104 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2be6958..5d831ec 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,9 @@ Releases are automated by `.github/workflows/release.yml`. The flow is: You can also kick off the workflow manually from the Actions tab against an existing tag (useful if a release run fails midway). Want to package locally without going through CI? `npm run release:dry` produces an identical `clay-slip-vX.Y.Z.zip` next to the repo. +> **⚠️ Always use `npm run zip` (or `release:dry`) to package — never zip `dist/` from Finder / Explorer.** +> Right-clicking the folder produces a zip with a `dist/` wrapper, which the Chrome Web Store rejects with _"No manifest found in package."_ Our script zips the **contents** of `dist/` (so `manifest.json` is at the root), strips source maps and macOS metadata, and verifies the zip layout before declaring success. Pass `INCLUDE_SOURCEMAPS=1` if you need maps for debugging a sideloaded build. + ## Migration notes (1.0 → 2.0) This release is a full rewrite. There are no breaking _features_ — every capability of 1.0 is still present, plus a much larger set of new ones — but every implementation file changed: diff --git a/scripts/zip-extension.mjs b/scripts/zip-extension.mjs index cb94b50..61a746a 100644 --- a/scripts/zip-extension.mjs +++ b/scripts/zip-extension.mjs @@ -1,13 +1,31 @@ // Pack the built `dist/` directory into `clay-slip-vX.Y.Z.zip`, ready to // upload to the Chrome Web Store dashboard or to share for sideloading. // -// npm run zip (assumes `dist/` already exists) -// npm run release:dry (validate + build + zip in one shot) +// npm run zip (assumes `dist/` already exists) +// npm run release:dry (validate + build + zip in one shot) +// INCLUDE_SOURCEMAPS=1 npm run zip (keep .map files; useful for debugging) // -// Cross-platform: shells out to `zip` on macOS/Linux and PowerShell's +// Why this script exists instead of `cd dist && zip -r ../slip.zip .`: +// - The Chrome Web Store rejects uploads where `manifest.json` is not at +// the *root* of the zip. Right-clicking `dist/` in Finder → Compress +// produces a zip with a `dist/` folder wrapper, which fails validation +// with "No manifest found in package." +// - macOS adds `__MACOSX/` resource forks and `.DS_Store` files to zips +// made by Finder. Some Web Store checks choke on them. +// - Production uploads don't need source maps; stripping them halves the +// upload size and avoids leaking source. +// +// This script: +// 1. Wipes any stale zip with the same name. +// 2. Zips the *contents* of `dist/` (so `manifest.json` is at the root). +// 3. Excludes `*.map`, `.DS_Store`, and `__MACOSX/` by default. +// 4. Verifies after the fact that `manifest.json` is at the root; refuses +// to ship the zip if not. +// +// Cross-platform: uses `zip` on macOS/Linux and PowerShell's // `Compress-Archive` on Windows. Either is preinstalled on a clean dev box. -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { existsSync, statSync, rmSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; @@ -18,34 +36,43 @@ const distDir = join(root, 'dist'); const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); const outName = `clay-slip-v${pkg.version}.zip`; const outPath = join(root, outName); +const includeMaps = process.env.INCLUDE_SOURCEMAPS === '1'; if (!existsSync(distDir) || !statSync(distDir).isDirectory()) { console.error(`✖ ${distDir} not found. Run \`npm run build\` first.`); process.exit(1); } -if (existsSync(outPath)) { - rmSync(outPath); +if (!existsSync(join(distDir, 'manifest.json'))) { + console.error(`✖ ${distDir}/manifest.json missing — the build looks broken.`); + process.exit(1); } +if (existsSync(outPath)) rmSync(outPath); + const isWindows = process.platform === 'win32'; +// Patterns to drop. Source maps are dropped by default; opt in via env. +const excludeUnix = ['.DS_Store', '__MACOSX/*', ...(includeMaps ? [] : ['*.map'])]; + const cmd = isWindows ? 'powershell.exe' : 'zip'; const args = isWindows - ? [ - '-NoProfile', - '-Command', - `Compress-Archive -Path 'dist/*' -DestinationPath '${outName}' -Force`, - ] - : ['-r', outPath, '.']; + ? ['-NoProfile', '-Command', buildPowerShellCommand(outName, includeMaps)] + : ['-r', '-X', outPath, '.', ...excludeUnix.flatMap((p) => ['--exclude', p])]; +// On Unix we run *inside* dist/ so paths in the zip are relative to it +// (manifest.json at the root, not dist/manifest.json). const cwd = isWindows ? root : distDir; const child = spawn(cmd, args, { cwd, stdio: 'inherit' }); child.on('error', (err) => { console.error(`✖ Failed to launch packager: ${err.message}`); - if (!isWindows) console.error(' Make sure the `zip` command is installed (it ships with macOS/most distros).'); + if (!isWindows) { + console.error( + ' Make sure the `zip` command is installed (it ships with macOS and most distros).' + ); + } process.exit(1); }); @@ -54,6 +81,65 @@ child.on('exit', (code) => { console.error(`✖ Packager exited with code ${code}`); process.exit(code ?? 1); } - const size = (statSync(outPath).size / 1024).toFixed(1); - console.log(`✓ wrote ${outName} (${size} KB)`); + verifyManifestAtRoot(outPath); + const sizeKb = (statSync(outPath).size / 1024).toFixed(1); + console.log(''); + console.log(`✓ wrote ${outName} (${sizeKb} KB${includeMaps ? ', with source maps' : ''})`); + console.log(''); + console.log('Next steps:'); + console.log(' 1. Upload this file to the Chrome Web Store dashboard:'); + console.log(' https://chrome.google.com/webstore/devconsole'); + console.log(' 2. Pick this extension → Package → Upload new package'); + console.log(` 3. Choose: ${outName}`); }); + +/** + * Read the zip's central directory and assert that `manifest.json` exists + * at the root (no folder prefix). Catches the most common upload-rejection + * mistakes: zipping the parent folder, or zipping with a `dist/` wrapper. + */ +function verifyManifestAtRoot(zipPath) { + // `unzip -p zipPath manifest.json` exits 0 only when the file exists at + // exactly that path inside the zip. It's available on macOS/Linux and + // ships with Git for Windows (via the bundled MSYS2 tools), so it's a + // safe baseline for our supported environments. + const probe = spawnSync('unzip', ['-p', zipPath, 'manifest.json'], { + stdio: ['ignore', 'ignore', 'ignore'], + }); + + if (probe.status === 0) return; + + // No `unzip` available (rare on Windows without Git Bash). Fall back to + // a PowerShell probe so we still catch obvious mistakes. + if (isWindows && probe.error) { + const ps = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-Command', + `Add-Type -AssemblyName System.IO.Compression.FileSystem; $z=[System.IO.Compression.ZipFile]::OpenRead('${zipPath}'); try { if ($z.Entries | Where-Object { $_.FullName -eq 'manifest.json' }) { exit 0 } else { exit 1 } } finally { $z.Dispose() }`, + ], + { stdio: ['ignore', 'ignore', 'ignore'] } + ); + if (ps.status === 0) return; + } + + console.error(''); + console.error(`✖ ${outName} does not contain manifest.json at the root.`); + console.error(' This zip would be rejected by the Chrome Web Store.'); + console.error(' Likely cause: the script ran outside dist/ or with a folder wrapper.'); + console.error(' Re-run `npm run build && npm run zip`.'); + process.exit(2); +} + +function buildPowerShellCommand(zipName, keepMaps) { + // Build a Compress-Archive invocation that filters out `.DS_Store`, + // `__MACOSX`, and (by default) `*.map`. + const filters = ["$_.Name -ne '.DS_Store'", "$_.FullName -notmatch '__MACOSX'"]; + if (!keepMaps) filters.push("$_.Name -notlike '*.map'"); + const where = filters.join(' -and '); + return ` + $items = Get-ChildItem -Path 'dist' -Recurse -File | Where-Object { ${where} }; + Compress-Archive -Path $items.FullName -DestinationPath '${zipName}' -Force + `.trim(); +}