Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
116 changes: 101 additions & 15 deletions scripts/zip-extension.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
});

Expand All @@ -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();
}
Loading