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
22 changes: 11 additions & 11 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ main() {
fi
ok "✅ Node $(node --version)"

local meta tarball extract_dir version_dir launcher
local meta tarball extract_dir version_dir launcher command_hint
CLEANUP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/clawfix-install.XXXXXX")"
tmp="$CLEANUP_DIR"
trap 'rm -rf "${CLEANUP_DIR:-}"' EXIT
Expand Down Expand Up @@ -259,8 +259,13 @@ EOF
log " Binary: $launcher"
log ""

command_hint="$launcher"
case ":${PATH}:" in
*":${BIN_DIR}:"*) ;;
*":${BIN_DIR}:"*)
if [ "$(command -v clawfix 2>/dev/null || true)" = "$launcher" ]; then
command_hint="clawfix"
fi
;;
*)
warn "⚠️ ${BIN_DIR} is not on PATH"
log " Add this to your shell profile:"
Expand All @@ -269,18 +274,13 @@ EOF
;;
esac

if command -v clawfix >/dev/null 2>&1; then
ok "✅ Ready: clawfix --version"
clawfix --version || true
else
ok "✅ Ready: ${launcher} --version"
"$launcher" --version || true
fi
ok "✅ Verifying installed binary: ${launcher} --version"
"$launcher" --version || die "Installed ClawFix failed its version check"

log ""
info "Next:"
log " clawfix --dry-run"
log " clawfix"
log " ${command_hint} --dry-run"
log " ${command_hint}"
log ""
}

Expand Down
61 changes: 50 additions & 11 deletions src/landing.js
Original file line number Diff line number Diff line change
Expand Up @@ -509,11 +509,11 @@ const LANDING_HTML = `<!DOCTYPE html>

<div class="command-box">
<span class="prompt">$</span>
<code id="cmd-install"><span>curl -fsSL</span> <span>https://clawfix.dev/install</span> <span>-o install.sh</span></code>
<button type="button" class="copy-btn" id="copyBtn-install" aria-live="polite" onclick="copyCommand('install')">Copy</button>
<code id="cmd-install"><span>curl -fsSL</span> <span>https://clawfix.dev/install</span> <span>-o install.sh</span> <span>&amp;&amp; bash install.sh</span></code>
<button type="button" class="copy-btn" id="copyBtn-install" data-copy-command="install" aria-live="polite">Copy</button>
</div>
<p class="command-hint" style="margin-bottom: 6px;">
<strong style="color:var(--green)">Recommended</strong> Verify with <code>shasum -a 256 install.sh</code> against <a href="/install/sha256">the published hash</a>, then run <code>bash install.sh</code>. No global npm.
<strong style="color:var(--green)">Recommended</strong> Downloads and runs the installer. To inspect first, omit <code>&amp;&amp; bash install.sh</code>, verify with <code>shasum -a 256 install.sh</code> against <a href="/install/sha256">the published hash</a>, then run it. No global npm.
</p>
<p class="command-hint" style="margin-bottom: 18px;">
Prefer npm? <code id="cmd-npx">npx clawfix@0.12.0</code> · Dry run: <code>npx clawfix@0.12.0 --dry-run</code> · macOS, Linux, WSL · <span style="white-space:nowrap">Node.js 22+</span>
Expand Down Expand Up @@ -870,14 +870,53 @@ gateway-not-running repair. Want me to?
</footer>

<script>
function copyCommand(type) {
const cmd = document.getElementById('cmd-' + type).textContent;
navigator.clipboard.writeText(cmd).then(() => {
const btn = document.getElementById('copyBtn-' + type);
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy'; }, 2000);
});
}
function copyTextFallback(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, text.length);

try {
return Boolean(document.execCommand && document.execCommand('copy'));
} catch {
return false;
} finally {
document.body.removeChild(textarea);
}
}

async function copyCommand(type) {
const command = document.getElementById('cmd-' + type);
const btn = document.getElementById('copyBtn-' + type);
if (!command || !btn) return false;

const text = command.textContent;
let copied = false;

if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try {
await navigator.clipboard.writeText(text);
copied = true;
} catch {
copied = false;
}
}

if (!copied) copied = copyTextFallback(text);

btn.textContent = copied ? 'Copied!' : 'Copy failed';
setTimeout(() => { btn.textContent = 'Copy'; }, 2000);
return copied;
}

document.querySelectorAll('[data-copy-command]').forEach((button) => {
button.addEventListener('click', () => copyCommand(button.dataset.copyCommand));
});
</script>
</body>
</html>`;
23 changes: 19 additions & 4 deletions test/install-script.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,16 @@ test('install script installs a pinned package into a local prefix without npm i
const packDir = join(packRoot, 'package');
const prefix = join(work, 'prefix');
const binDir = join(work, 'bin');
const oldBinDir = join(work, 'old-bin');
let server;
try {
await run('mkdir', ['-p', packDir, join(registryRoot, 'clawfix'), prefix, binDir]);
await run('mkdir', ['-p', packDir, join(registryRoot, 'clawfix'), prefix, binDir, oldBinDir]);

await writeFile(
join(oldBinDir, 'clawfix'),
'#!/usr/bin/env bash\necho "clawfix v0.9.0-old"\n',
);
await chmod(join(oldBinDir, 'clawfix'), 0o755);

// Minimal package matching published clawfix bin layout
const pkg = {
Expand Down Expand Up @@ -129,7 +137,7 @@ test('install script installs a pinned package into a local prefix without npm i

// Fake registry HTTP server
const tarballBytes = await readFile(tarball);
const server = createServer((req, res) => {
server = createServer((req, res) => {
if (req.url === '/clawfix/0.10.0') {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({
Expand Down Expand Up @@ -161,20 +169,27 @@ test('install script installs a pinned package into a local prefix without npm i
CLAWFIX_PREFIX: prefix,
CLAWFIX_BIN_DIR: binDir,
CLAWFIX_REGISTRY: registry,
PATH: `${binDir}:${process.env.PATH}`,
PATH: `${oldBinDir}:${binDir}:${process.env.PATH}`,
HOME: work,
});

assert.equal(result.code, 0, result.stdout + result.stderr);
assert.match(result.stdout, /clawfix v0\.10\.0-test/);
assert.doesNotMatch(result.stdout, /clawfix v0\.9\.0-old/);
assert.match(result.stdout, new RegExp(`${join(binDir, 'clawfix')} --dry-run`));
await access(join(binDir, 'clawfix'));
await access(join(prefix, 'versions/0.10.0/bin/clawfix.js'));

const version = await run(join(binDir, 'clawfix'), []);
assert.equal(version.code, 0, version.stderr);
assert.match(version.stdout, /clawfix v0\.10\.0-test/);

server.close();
} finally {
if (server?.listening) {
await new Promise((resolve, reject) => {
server.close(error => error ? reject(error) : resolve());
});
}
await rm(work, { recursive: true, force: true });
}
});
164 changes: 164 additions & 0 deletions test/landing-install.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import vm from 'node:vm';

const landingPath = new URL('../src/landing.js', import.meta.url);

async function readLandingSource() {
return readFile(landingPath, 'utf8');
}

function installCommandFrom(source) {
const match = source.match(/<code id="cmd-install">([\s\S]*?)<\/code>/);
assert.ok(match, 'install command exists');
return match[1]
.replace(/<[^>]+>/g, '')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}

function copyScriptFrom(source) {
const scripts = [...source.matchAll(/<script>([\s\S]*?)<\/script>/g)];
assert.ok(scripts.length > 0, 'copy script exists');
return scripts.at(-1)[1];
}

test('primary website command downloads and runs the installer', async () => {
const landing = await readLandingSource();
const command = installCommandFrom(landing);

assert.equal(
command,
'curl -fsSL https://clawfix.dev/install -o install.sh && bash install.sh',
);
assert.doesNotMatch(command, /curl[^\n]*\|\s*(?:ba)?sh/);
assert.doesNotMatch(landing, /onclick="copyCommand/);
});

async function createCopyHarness({ clipboard, execCommand = () => true } = {}) {
const landing = await readLandingSource();
const command = { textContent: installCommandFrom(landing) };
let clickHandler;
const button = {
dataset: { copyCommand: 'install' },
textContent: 'Copy',
addEventListener(event, handler) {
assert.equal(event, 'click');
clickHandler = handler;
},
};
const textarea = {
style: {},
value: '',
selected: false,
setAttribute() {},
select() { this.selected = true; },
setSelectionRange() {},
};
let appended = false;
let removed = false;
let resetButton;

const document = {
body: {
appendChild(node) {
assert.equal(node, textarea);
appended = true;
},
removeChild(node) {
assert.equal(node, textarea);
removed = true;
},
},
createElement(tag) {
assert.equal(tag, 'textarea');
return textarea;
},
execCommand(commandName) {
assert.equal(commandName, 'copy');
return execCommand(commandName);
},
querySelectorAll(selector) {
assert.equal(selector, '[data-copy-command]');
return [button];
},
getElementById(id) {
if (id === 'cmd-install') return command;
if (id === 'copyBtn-install') return button;
return null;
},
};

const context = vm.createContext({
document,
navigator: clipboard ? { clipboard } : {},
setTimeout(callback) {
resetButton = callback;
return 1;
},
});
vm.runInContext(copyScriptFrom(landing), context);

assert.equal(typeof clickHandler, 'function');
return {
button,
click: clickHandler,
command,
get appended() { return appended; },
get removed() { return removed; },
get resetButton() { return resetButton; },
textarea,
};
}

test('copy button falls back when the Clipboard API is unavailable', async () => {
const harness = await createCopyHarness();
const copied = await harness.click();

assert.equal(copied, true);
assert.equal(harness.textarea.value, harness.command.textContent);
assert.equal(harness.textarea.selected, true);
assert.equal(harness.appended, true);
assert.equal(harness.removed, true);
assert.equal(harness.button.textContent, 'Copied!');
harness.resetButton();
assert.equal(harness.button.textContent, 'Copy');
});

test('copy button falls back when Clipboard API permission is rejected', async () => {
let attemptedText;
const harness = await createCopyHarness({
clipboard: {
async writeText(text) {
attemptedText = text;
throw new Error('clipboard permission denied');
},
},
});

const copied = await harness.click();

assert.equal(attemptedText, harness.command.textContent);
assert.equal(copied, true);
assert.equal(harness.textarea.value, harness.command.textContent);
assert.equal(harness.removed, true);
assert.equal(harness.button.textContent, 'Copied!');
});

test('copy button reports failure when both clipboard paths fail', async () => {
const harness = await createCopyHarness({
execCommand() {
throw new Error('legacy copy denied');
},
});

const copied = await harness.click();

assert.equal(copied, false);
assert.equal(harness.removed, true);
assert.equal(harness.button.textContent, 'Copy failed');
harness.resetButton();
assert.equal(harness.button.textContent, 'Copy');
});