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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ jobs:
- name: Build
run: go build ./...

# The npm installer is the primary distribution path but has no Go
# coverage. Its redirect allowlist broke silently once already.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Test npm installer
run: node --test

lint:
runs-on: ubuntu-latest
steps:
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ jobs:
GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o bin/claude-sync-windows-arm64.exe ./cmd/claude-sync
fi

# Not continue-on-error: a silent npm publish failure left the registry
# 9 minor versions behind the git tags for 16 weeks. A failed publish must
# fail the release.
- name: Publish platform packages to npm
if: success()
continue-on-error: true
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
Expand Down
3 changes: 3 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ testdata/
!package.json
!README.md
!LICENSE

# JS tests (not shipped)
*.test.js
58 changes: 56 additions & 2 deletions install.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ const fs = require("fs");
const path = require("path");

const REPO = "tawanorg/claude-sync";
const ALLOWED_HOSTS = ["github.com", "objects.githubusercontent.com"];
// Release downloads start at github.com and redirect to a GitHub-run asset CDN.
// That CDN host is not stable — it was objects.githubusercontent.com and is now
// release-assets.githubusercontent.com — so pinning exact hostnames breaks the
// installer whenever GitHub moves it. Match the githubusercontent.com parent
// domain instead, which stays scoped to GitHub-controlled hosts.
const ALLOWED_HOSTS = ["github.com", "api.github.com"];
const ALLOWED_HOST_SUFFIX = ".githubusercontent.com";
const MAX_REDIRECTS = 5;

function getPlatform() {
Expand Down Expand Up @@ -89,7 +95,17 @@ function getChecksumsUrl(version) {
function isAllowedHost(url) {
try {
const parsed = new URL(url);
return ALLOWED_HOSTS.includes(parsed.hostname);
// https only: without this an http:// redirect to an allowed host would
// downgrade the transport and defeat the checksum's purpose.
if (parsed.protocol !== "https:") {
return false;
}
if (ALLOWED_HOSTS.includes(parsed.hostname)) {
return true;
}
// Suffix match, not endsWith on the bare domain: "evilgithubusercontent.com"
// must not pass, so the leading dot is required.
return parsed.hostname.endsWith(ALLOWED_HOST_SUFFIX);
} catch {
return false;
}
Expand Down Expand Up @@ -222,13 +238,51 @@ function hashFile(filePath) {
});
}

// Platform-specific packages ship the binary as an optionalDependency, which
// installs faster and works offline. This download is the fallback for when one
// of them is missing — a platform npm publish that failed, an install with
// --no-optional, or a platform we have not packaged. Both paths write to the
// same place bin/claude-sync.js looks, so either satisfies the wrapper.
const PLATFORM_PACKAGES = {
"darwin-arm64": "@tawandotorg/claude-sync-darwin-arm64",
"darwin-x64": "@tawandotorg/claude-sync-darwin-x64",
"linux-arm64": "@tawandotorg/claude-sync-linux-arm64",
"linux-x64": "@tawandotorg/claude-sync-linux-x64",
"win32-arm64": "@tawandotorg/claude-sync-win32-arm64",
"win32-x64": "@tawandotorg/claude-sync-win32-x64",
};

// platformPackageBinary returns the path to a binary already provided by the
// matching optionalDependency, or null when none is installed.
function platformPackageBinary() {
const packageName = PLATFORM_PACKAGES[`${process.platform}-${process.arch}`];
if (!packageName) {
return null;
}

try {
const packageDir = path.dirname(require.resolve(`${packageName}/package.json`));
const binaryName = process.platform === "win32" ? "claude-sync.exe" : "claude-sync";
const binaryPath = path.join(packageDir, binaryName);
return fs.existsSync(binaryPath) ? binaryPath : null;
} catch (e) {
return null;
}
}

async function install() {
const binDir = path.join(__dirname, "bin");
const platform = getPlatform();
const binaryName = platform === "windows" ? "claude-sync.exe" : "claude-sync";
const binaryPath = path.join(binDir, binaryName);
const assetName = getBinaryName();

const fromPackage = platformPackageBinary();
if (fromPackage) {
console.log(`✓ Using platform package binary: ${fromPackage}`);
return;
}

// Create bin directory
if (!fs.existsSync(binDir)) {
fs.mkdirSync(binDir, { recursive: true });
Expand Down
60 changes: 60 additions & 0 deletions install.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Tests for the npm postinstall installer.
//
// These exist because the redirect allowlist silently broke the installer: it
// pinned objects.githubusercontent.com, GitHub moved release downloads to
// release-assets.githubusercontent.com, and every install failed. Nobody
// noticed for months because npm publishing was also broken, so the change
// never reached a user. Run with: node --test

const { test } = require("node:test");
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");

// install.js runs its install() on require, so pull the pure helpers out of the
// source rather than importing it.
function loadHelper(name) {
const src = fs.readFileSync(path.join(__dirname, "install.js"), "utf8");
const consts = src.match(
/const ALLOWED_HOSTS = \[[^\]]*\];\nconst ALLOWED_HOST_SUFFIX = "[^"]*";/
);
const fn = src.match(new RegExp(`function ${name}\\(url\\) \\{[\\s\\S]*?\\n\\}`));
assert.ok(consts, "ALLOWED_HOSTS/ALLOWED_HOST_SUFFIX not found in install.js");
assert.ok(fn, `${name} not found in install.js`);
// eslint-disable-next-line no-eval
return eval(`${consts[0]}\n${fn[0]}\n${name}`);
}

test("isAllowedHost accepts GitHub release download hosts", () => {
const isAllowedHost = loadHelper("isAllowedHost");

for (const url of [
"https://github.com/tawanorg/claude-sync/releases/download/v1.0.0/x",
"https://api.github.com/repos/tawanorg/claude-sync/releases/latest",
// The historical asset CDN.
"https://objects.githubusercontent.com/foo",
// The current asset CDN — the host whose absence broke every install.
"https://release-assets.githubusercontent.com/foo",
]) {
assert.strictEqual(isAllowedHost(url), true, `should allow ${url}`);
}
});

test("isAllowedHost rejects lookalike and untrusted hosts", () => {
const isAllowedHost = loadHelper("isAllowedHost");

for (const url of [
// Suffix match must require the leading dot.
"https://evilgithubusercontent.com/foo",
// Domain must be the parent, not a prefix of someone else's.
"https://githubusercontent.com.evil.com/foo",
"https://github.com.evil.com/foo",
"https://evil.com/foo",
// A downgrade to http would defeat the point of verifying a checksum.
"http://github.com/foo",
"http://release-assets.githubusercontent.com/foo",
"not a url",
]) {
assert.strictEqual(isAllowedHost(url), false, `should reject ${url}`);
}
});
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
"bin": {
"claude-sync": "./bin/claude-sync.js"
},
"scripts": {
"postinstall": "node install.js",
"test": "node --test"
},
"keywords": [
"claude",
"claude-code",
Expand Down
42 changes: 38 additions & 4 deletions scripts/publish-npm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
NPM_DIR="$ROOT_DIR/npm"

# Platform packages that failed to publish, reported at the end so one bad
# platform does not hide the others or block the root package. Kept as a plain
# string rather than an array: this script runs under `set -u`, where expanding
# an empty array is an error on some bash versions.
FAILED_PLATFORMS=""

# Map: npm platform → Go binary name
declare -A PLATFORM_MAP=(
["darwin-arm64"]="claude-sync-darwin-arm64"
Expand Down Expand Up @@ -54,8 +60,18 @@ for platform in "${!PLATFORM_MAP[@]}"; do
# Update version in package.json
cd "$pkg_dir"
npm version "$VERSION" --no-git-tag-version --allow-same-version 2>/dev/null
npm publish --access public
echo " Published!"

# A platform publish must not abort the run. The root package carries a
# postinstall that downloads the binary from GitHub Releases, so it is still
# worth publishing even when a platform package fails — users fall back
# instead of getting nothing. Failures are collected and re-raised at the end
# so CI still goes red.
if npm publish --access public; then
echo " Published!"
else
echo " ERROR: failed to publish @tawandotorg/claude-sync-${platform}@${VERSION}"
FAILED_PLATFORMS="$FAILED_PLATFORMS $platform"
fi

# Clean up binary (don't commit binaries)
rm -f "$pkg_dir/$dst_binary"
Expand All @@ -72,5 +88,23 @@ done
rm -f package.json.bak

npm version "$VERSION" --no-git-tag-version --allow-same-version 2>/dev/null
npm publish --access public
echo "Published @tawandotorg/claude-sync@${VERSION}!"

ROOT_FAILED=0
if npm publish --access public; then
echo "Published @tawandotorg/claude-sync@${VERSION}!"
else
echo "ERROR: failed to publish @tawandotorg/claude-sync@${VERSION}"
ROOT_FAILED=1
fi

if [ -n "$FAILED_PLATFORMS" ]; then
echo
echo "Platform packages that failed to publish:$FAILED_PLATFORMS"
echo "A 404 on PUT for a package that has never been published usually means"
echo "NPM_TOKEN cannot create new packages in the @tawandotorg scope — grant"
echo "the token read/write on the whole scope, not just existing packages."
fi

if [ -n "$FAILED_PLATFORMS" ] || [ "$ROOT_FAILED" -eq 1 ]; then
exit 1
fi
Loading