mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
feat(install): install without administrator rights (--no-admin)
Codeman itself always lived under $HOME; administrator rights were only spent on what it needs first (system packages for Node.js, tmux and git, Homebrew on a Mac). An account without those rights could not install it: the installer died in ensure_sudo, or in Homebrew's own installer. install.sh now has a no-admin route, taken by itself when the account has no administrator rights (no sudo binary; outside sudo/wheel/admin and `sudo -n true` fails; a refused sudo password; a Mac account outside the admin group with no writable Homebrew) and forced with --no-admin or CODEMAN_NO_ADMIN=1: - Node.js: the official tarball from nodejs.org, checked against the SHASUMS256.txt published beside it, into ~/.codeman/runtime/node. - tmux: the tmux project's own build (tmux/tmux-builds 3.7c), checked against a SHA-256 pinned in the installer, into ~/.codeman/runtime/bin. - Both are linked from ~/.local/bin (never over a file that is not ours), and the runtime is on PATH and on the LaunchAgent's PATH. - git is never installed. Without a working git the source comes as an archive, the install is marked with .install-source, and it updates by re-running the installer: the new tree is built beside the old one and swapped in only after the build succeeded. A later re-run on a machine that has gained git turns it back into a clone. - Nothing on this route runs sudo, Homebrew or `xcode-select --install`, and the route is never decided by `sudo -v`. What it cannot supply is checked before the first download: a C/C++ toolchain on Linux (node-pty 1.1.0 compiles there), musl, and macOS below 15 (the tmux build's deployment target). Along the way: - check_git no longer trusts `command -v git`. On a Mac without the Xcode Command Line Tools /usr/bin/git is a stub that opens Apple's install dialog; it is judged by `xcode-select -p` and never executed. Any other git is asked for its version. - scripts/git-hooks.mjs answers "not a checkout" without running git, for the same reason: postinstall calls it on every npm install. - The in-app updater knows the install kind `archive` and points such an install at the installer instead of `npm i -g aicodeman@latest`. - download() and download_to_stdout() resolve DOWNLOADER themselves. Only main() used to set it, so `install.sh cloudflared` died on `set -u` with the message swallowed by the step log. - The AI CLI menu leaves out a Homebrew-only install command where there is no Homebrew. Verified with the real installer: a bare Ubuntu 24.04 container as an unprivileged user (install, a PTY round trip through the API, update by re-run, cloudflared, uninstall), the same without a compiler (stops before any download), with a broken then working git, with sudo present but not usable, and on macOS 26 (arm64) in an isolated HOME with sudo, brew, git, the compilers, launchctl and tailscale replaced by tripwires. Not verified on a real standard macOS account: that account was an administrator. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
46b85fa6d0
commit
ae28534406
13 files changed
+1449
-49
No files matched your search
@@ -18,6 +18,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
@@ -171,6 +172,26 @@ describe('resolveGitHooksDir (temp repos)', () => {
|
||||
expect(resolveGitHooksDir(dir)).toBeNull();
|
||||
});
|
||||
|
||||
it('never runs git for a directory that has no .git', () => {
|
||||
// A source-archive or npm install. On a Mac without the Xcode Command Line Tools
|
||||
// /usr/bin/git is a stub that opens Apple's install dialog, and postinstall calls this
|
||||
// on every `npm install`, so "not a checkout" must be answered without spawning git.
|
||||
const dir = join(scratch, `plain-${++counter}`);
|
||||
const bin = join(scratch, `fakebin-${counter}`);
|
||||
const ran = join(scratch, `git-ran-${counter}`);
|
||||
mkdirSync(dir);
|
||||
mkdirSync(bin);
|
||||
writeFileSync(join(bin, 'git'), `#!/bin/sh\necho ran > "${ran}"\nexit 1\n`, { mode: 0o755 });
|
||||
const path = process.env.PATH;
|
||||
process.env.PATH = `${bin}:${path}`;
|
||||
try {
|
||||
expect(resolveGitHooksDir(dir)).toBeNull();
|
||||
} finally {
|
||||
process.env.PATH = path;
|
||||
}
|
||||
expect(existsSync(ran), 'git was executed for a directory with no .git').toBe(false);
|
||||
});
|
||||
|
||||
it('returns null when a repo-local core.hooksPath points outside the repo', () => {
|
||||
const repo = newRepo();
|
||||
const outside = join(scratch, `shared-hooks-${counter}`);
|
||||
|
||||
@@ -12,9 +12,12 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
import { ARCHIVE_INSTALL_MARKER } from '../src/web/self-update.js';
|
||||
|
||||
const INSTALL_SH = fileURLToPath(new URL('../install.sh', import.meta.url));
|
||||
const SOURCE = readFileSync(INSTALL_SH, 'utf-8');
|
||||
@@ -536,3 +539,410 @@ it('install.sh never contains the unauthenticated tunnel opt-in', () => {
|
||||
expect(SOURCE).not.toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL');
|
||||
expect(SOURCE).not.toContain('--allow-unauthenticated-tunnel');
|
||||
});
|
||||
|
||||
describe('install.sh no-admin route', () => {
|
||||
// The route that installs without administrator rights: Node.js and tmux are downloaded
|
||||
// into the home folder, and a machine with no working git gets the source as an archive.
|
||||
// What can go wrong here is quiet: a sudo prompt that cannot succeed, a binary unpacked
|
||||
// before its checksum was checked, Apple's git stub opening a dialog. So each is pinned.
|
||||
const fn = (name: string, until: string) => {
|
||||
const start = SOURCE.indexOf(`${name}() {`);
|
||||
expect(start, `${name}() missing`).toBeGreaterThan(-1);
|
||||
const end = SOURCE.indexOf(until, start);
|
||||
expect(end, `${until} missing after ${name}()`).toBeGreaterThan(start);
|
||||
return SOURCE.slice(start, end);
|
||||
};
|
||||
const sectionStart = SOURCE.indexOf('# No-admin route: everything into the home folder');
|
||||
const sectionEnd = SOURCE.indexOf('# ---- End of the no-admin route');
|
||||
const section = SOURCE.slice(sectionStart, sectionEnd);
|
||||
const sectionCode = section.split('\n').filter((line) => !/^\s*#/.test(line));
|
||||
|
||||
/** Run a snippet with install.sh sourced as a library, in a real bash. */
|
||||
const drive = (script: string, env: NodeJS.ProcessEnv = {}) => {
|
||||
const clean = { ...process.env, ...env };
|
||||
for (const key of ['CODEMAN_NO_ADMIN', 'CODEMAN_TARBALL_URL', 'CODEMAN_REPO_URL', 'CODEMAN_BRANCH']) {
|
||||
if (!(key in env)) delete clean[key];
|
||||
}
|
||||
const full = `set -euo pipefail\nexport CODEMAN_INSTALL_SH_LIB=1\n. "$1"\n${script}`;
|
||||
return spawnSync('bash', ['-c', full, 'bash', INSTALL_SH], { encoding: 'utf-8', timeout: 30_000, env: clean });
|
||||
};
|
||||
|
||||
it('has its section markers, in order', () => {
|
||||
expect(sectionStart).toBeGreaterThan(-1);
|
||||
expect(sectionEnd).toBeGreaterThan(sectionStart);
|
||||
expect(sectionCode.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('never runs sudo, Homebrew or the Xcode tools installer', () => {
|
||||
// Command position only: the messages in this section name `brew install tmux` and
|
||||
// `apt-get install` as things an administrator can run, which is prose.
|
||||
const banned =
|
||||
/(?:^|[;&|]|\b(?:if|then|else|do|while|until)\s|!\s)\s*(?:sudo|brew|ensure_sudo|run_as_root|ensure_homebrew|sudo_session_start|sudo_session_try)\b/;
|
||||
const offenders = sectionCode.filter((line) => banned.test(line) || /xcode-select\s+--install/.test(line));
|
||||
expect(offenders, `privileged call on the no-admin route:\n ${offenders.join('\n ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('pins a SHA-256 for every tmux build it can download', () => {
|
||||
const digests = fn('tmux_build_sha256', '\n}\n');
|
||||
for (const platform of ['linux-x86_64', 'linux-arm64', 'macos-arm64', 'macos-x86_64']) {
|
||||
expect(digests, `no digest for ${platform}`).toMatch(new RegExp(`${platform}\\)\\s+printf '[0-9a-f]{64}'`));
|
||||
// Every platform the name mapper can produce has a digest, and nothing else does.
|
||||
expect(fn('userspace_tmux_platform', '\n}\n')).toContain(`printf '${platform}'`);
|
||||
}
|
||||
expect(digests.match(/[0-9a-f]{64}/g)?.length).toBe(4);
|
||||
// The asset name and URL are built from the same version the digests were taken for.
|
||||
const install = fn('install_tmux_userspace', '\n}\n');
|
||||
expect(install).toContain('name="tmux-$TMUX_BUILD_VERSION-$platform.tar.gz"');
|
||||
expect(install).toContain('tmux-builds/releases/download/v$TMUX_BUILD_VERSION/$name');
|
||||
});
|
||||
|
||||
it('checks a download before unpacking or running it', () => {
|
||||
for (const name of ['install_node_userspace', 'install_tmux_userspace']) {
|
||||
const body = fn(name, '\n}\n');
|
||||
const verify = body.indexOf('verify_sha256 "$tmp/$name" "$sha"');
|
||||
expect(verify, `${name} never verifies its download`).toBeGreaterThan(-1);
|
||||
expect(body.indexOf('tar -xzf'), `${name} unpacks before verifying`).toBeGreaterThan(verify);
|
||||
}
|
||||
// An empty expected digest must never pass (a lookup that came back with nothing).
|
||||
expect(fn('verify_sha256', '\n}\n')).toContain('[[ -n "$want" ]] || return 1');
|
||||
});
|
||||
|
||||
it('never executes /usr/bin/git on a Mac before the developer tools are proven', () => {
|
||||
// Without the Command Line Tools that path is a stub that opens Apple's install dialog.
|
||||
const body = fn('check_git', '\n}\n');
|
||||
const apple = body.indexOf('"$git_bin" == "/usr/bin/git"');
|
||||
const gate = body.indexOf('xcode-select -p');
|
||||
const run = body.indexOf('"$git_bin" --version');
|
||||
expect(apple).toBeGreaterThan(-1);
|
||||
expect(gate).toBeGreaterThan(apple);
|
||||
// The Apple branch returns on its own evidence; only other gits are executed.
|
||||
expect(body.slice(gate, run)).toMatch(/\n\s+return 1\n\s+fi\n/);
|
||||
});
|
||||
|
||||
it('judges git by whether it runs', () => {
|
||||
const result = drive(`
|
||||
bin=$(mktemp -d); trap 'rm -rf "$bin"' EXIT
|
||||
printf '#!/bin/sh\\nexit 127\\n' > "$bin/git"; chmod +x "$bin/git"
|
||||
PATH="$bin:$PATH"; hash -r
|
||||
check_git && echo "broken=present" || echo "broken=missing"
|
||||
printf '#!/bin/sh\\nexit 0\\n' > "$bin/git"
|
||||
check_git && echo "working=present" || echo "working=missing"
|
||||
# A Mac whose only git is Apple's stub, with and without the developer tools.
|
||||
uname() { echo Darwin; }
|
||||
command() { if [[ "$1" == "-v" && "$2" == "git" ]]; then echo /usr/bin/git; else builtin command "$@"; fi; }
|
||||
xcode-select() { return 2; }
|
||||
check_git && echo "stub=present" || echo "stub=missing"
|
||||
mkdir -p "$bin/dev/usr/bin"; cp "$bin/git" "$bin/dev/usr/bin/git"
|
||||
xcode-select() { echo "$bin/dev"; }
|
||||
check_git && echo "tools=present" || echo "tools=missing"
|
||||
`);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain('broken=missing');
|
||||
expect(result.stdout).toContain('working=present');
|
||||
expect(result.stdout).toContain('stub=missing');
|
||||
expect(result.stdout).toContain('tools=present');
|
||||
});
|
||||
|
||||
it('decides the route from what the account may do, without asking for a password', () => {
|
||||
// `sudo -v` would prompt, and a refused password is reported to the administrator.
|
||||
expect(fn('resolve_admin_mode', '\n}\n')).not.toMatch(/sudo -v/);
|
||||
const result = drive(`
|
||||
[[ $EUID -ne 0 ]] || { echo "skipped=root"; exit 0; }
|
||||
has_sudo=1
|
||||
command() { if [[ "$1" == "-v" && "$2" == "sudo" ]]; then [[ "$has_sudo" == "1" ]]; elif [[ "$1" == "-v" && "$2" == "brew" ]]; then return 1; else builtin command "$@"; fi; }
|
||||
groups="users"; id() { echo "$groups"; }
|
||||
sudo_ok=1; sudo() { return "$sudo_ok"; }
|
||||
probe() { NO_ADMIN="$1"; NO_ADMIN_REASON=""; resolve_admin_mode; echo "$2=$NO_ADMIN"; }
|
||||
uname() { echo Linux; }
|
||||
has_sudo=0; probe "" linux_no_sudo; has_sudo=1
|
||||
probe "" linux_not_in_group
|
||||
groups="users sudo"; probe "" linux_sudo_group
|
||||
groups="users wheel"; probe "" linux_wheel_group
|
||||
groups="users"; sudo_ok=0; probe "" linux_passwordless; sudo_ok=1
|
||||
probe 0 forced_off
|
||||
groups="users sudo"; probe 1 forced_on
|
||||
uname() { echo Darwin; }
|
||||
groups="staff everyone"; probe "" mac_standard
|
||||
groups="staff admin"; probe "" mac_admin
|
||||
`);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
if (result.stdout.includes('skipped=root')) return;
|
||||
for (const expected of [
|
||||
'linux_no_sudo=1',
|
||||
'linux_not_in_group=1',
|
||||
'linux_sudo_group=0',
|
||||
'linux_wheel_group=0',
|
||||
'linux_passwordless=0',
|
||||
'forced_off=0',
|
||||
'forced_on=1',
|
||||
'mac_standard=1',
|
||||
'mac_admin=0',
|
||||
]) {
|
||||
expect(result.stdout, result.stdout).toContain(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('stops before the first download when the machine cannot finish the install', () => {
|
||||
// No compiler on Linux, or a Mac older than the tmux build supports: say so while it
|
||||
// costs nothing, not after 50 MB of Node.js.
|
||||
const stop = (env: Record<string, string>) => {
|
||||
const r = drive(
|
||||
`
|
||||
NONINTERACTIVE=1; NO_ADMIN=1; NO_ADMIN_REASON=test
|
||||
download() { echo "DOWNLOAD $1"; }
|
||||
download_to_stdout() { echo "DOWNLOAD $1"; }
|
||||
check_node() { return 1; }; check_tmux() { return 1; }; check_git() { return 0; }
|
||||
detect_arch() { echo "$DRIVE_ARCH"; }
|
||||
check_build_tools() { [[ "$DRIVE_TOOLS" == "1" ]]; }; missing_build_tools() { printf 'make, g++'; }
|
||||
macos_major_version() { printf '%s' "$DRIVE_MACOS"; }
|
||||
is_musl_libc() { [[ "$DRIVE_MUSL" == "1" ]]; }
|
||||
ask_dependencies_no_admin "$DRIVE_OS"
|
||||
`,
|
||||
{ DRIVE_ARCH: 'x64', DRIVE_TOOLS: '1', DRIVE_MACOS: '26', DRIVE_MUSL: '0', ...env }
|
||||
);
|
||||
expect(r.status, 'the installer must stop').toBe(1);
|
||||
expect(r.stdout + r.stderr, 'something was downloaded before the stop').not.toContain('DOWNLOAD');
|
||||
return r.stderr;
|
||||
};
|
||||
expect(stop({ DRIVE_OS: 'linux', DRIVE_TOOLS: '0' })).toMatch(/make, g\+\+[\s\S]*administrator/);
|
||||
expect(stop({ DRIVE_OS: 'macos', DRIVE_ARCH: 'arm64', DRIVE_MACOS: '14' })).toMatch(/macOS 15 or newer/);
|
||||
expect(stop({ DRIVE_OS: 'linux', DRIVE_MUSL: '1' })).toMatch(/musl/);
|
||||
expect(stop({ DRIVE_OS: 'linux', DRIVE_ARCH: 'armv7' })).toMatch(/no Node\.js download/);
|
||||
});
|
||||
|
||||
it('picks the right Node.js tarball out of SHASUMS256.txt', () => {
|
||||
const sha = (c: string) => c.repeat(64);
|
||||
const sums = [
|
||||
`${sha('a')} node-v22.23.4-aix-ppc64.tar.gz`,
|
||||
`${sha('b')} node-v22.23.4-darwin-arm64.tar.gz`,
|
||||
`${sha('c')} node-v22.23.4-darwin-arm64.tar.xz`,
|
||||
`${sha('d')} node-v22.23.4-linux-x64-musl.tar.gz`,
|
||||
`${sha('e')} node-v22.23.4-linux-x64.tar.gz`,
|
||||
`nothex node-v22.23.4-linux-arm64.tar.gz`,
|
||||
].join('\n');
|
||||
const result = drive(
|
||||
`
|
||||
printf '%s\\n' "$SUMS" | pick_node_tarball linux-x64
|
||||
printf '%s\\n' "$SUMS" | pick_node_tarball darwin-arm64
|
||||
printf '%s\\n' "$SUMS" | pick_node_tarball linux-arm64 || echo "arm64=refused"
|
||||
printf '%s\\n' "$SUMS" | pick_node_tarball linux-s390x || echo "s390x=none"
|
||||
`,
|
||||
{ SUMS: sums }
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain(`${sha('e')} node-v22.23.4-linux-x64.tar.gz`);
|
||||
expect(result.stdout).toContain(`${sha('b')} node-v22.23.4-darwin-arm64.tar.gz`);
|
||||
expect(result.stdout).not.toContain('musl');
|
||||
// A line whose checksum is not 64 hex digits is not a checksum to trust.
|
||||
expect(result.stdout).toContain('arm64=refused');
|
||||
expect(result.stdout).toContain('s390x=none');
|
||||
});
|
||||
|
||||
it('refuses a download whose checksum does not match', () => {
|
||||
const result = drive(`
|
||||
f=$(mktemp); trap 'rm -f "$f"' EXIT
|
||||
printf 'codeman' > "$f"
|
||||
good=$(sha256_file "$f")
|
||||
echo "len=\${#good}"
|
||||
verify_sha256 "$f" "$good" && echo "match=ok"
|
||||
verify_sha256 "$f" "\${good%?}x" && echo "tampered=accepted" || echo "tampered=refused"
|
||||
verify_sha256 "$f" "" && echo "empty=accepted" || echo "empty=refused"
|
||||
`);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain('len=64');
|
||||
expect(result.stdout).toContain('match=ok');
|
||||
expect(result.stdout).toContain('empty=refused');
|
||||
// The last digit swapped for a character no digest contains.
|
||||
expect(result.stdout).toContain('tampered=refused');
|
||||
});
|
||||
|
||||
it('installs from an archive without git, then replaces that install beside itself', () => {
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'codeman-archive-install-'));
|
||||
try {
|
||||
const make = (version: string) => {
|
||||
const tree = join(scratch, `src-${version}`, `Codeman-${version}`);
|
||||
mkdirSync(join(tree, 'src'), { recursive: true });
|
||||
writeFileSync(join(tree, 'package.json'), `{ "version": "${version}" }\n`);
|
||||
writeFileSync(join(tree, 'src', `only-in-${version}.ts`), '');
|
||||
const tarball = join(scratch, `${version}.tar.gz`);
|
||||
const tar = spawnSync('tar', ['-czf', tarball, '-C', join(scratch, `src-${version}`), `Codeman-${version}`]);
|
||||
expect(tar.status, String(tar.stderr)).toBe(0);
|
||||
return tarball;
|
||||
};
|
||||
const first = make('1.0.0');
|
||||
const second = make('2.0.0');
|
||||
const app = join(scratch, 'app');
|
||||
const script = `
|
||||
LOG_FILE=/dev/null
|
||||
check_git() { return 1; }
|
||||
download() { cp "\${1#file://}" "$2"; }
|
||||
run_step() { shift; "$@"; }
|
||||
install_or_update_repo
|
||||
echo "staging=[$SOURCE_STAGING] cwd=$(pwd)"
|
||||
finish_source_swap
|
||||
echo "after=$(pwd)"
|
||||
`;
|
||||
const run = (tarball: string) =>
|
||||
drive(script, { CODEMAN_INSTALL_DIR: app, CODEMAN_TARBALL_URL: `file://${tarball}`, HOME: scratch });
|
||||
|
||||
// First install: straight into the install directory, marked as an archive install.
|
||||
const fresh = run(first);
|
||||
expect(fresh.status, fresh.stderr).toBe(0);
|
||||
expect(fresh.stdout).toContain('staging=[]');
|
||||
expect(readFileSync(join(app, 'package.json'), 'utf-8')).toContain('1.0.0');
|
||||
expect(existsSync(join(app, ARCHIVE_INSTALL_MARKER))).toBe(true);
|
||||
|
||||
// Second run: the new tree is built BESIDE the old one, which stays whole until the swap.
|
||||
writeFileSync(join(app, 'built-by-1.0.0'), '');
|
||||
const update = run(second);
|
||||
expect(update.status, update.stderr).toBe(0);
|
||||
expect(update.stdout).toContain(`staging=[${app}.new] cwd=${app}.new`);
|
||||
expect(update.stdout).toContain(`after=${app}`);
|
||||
expect(readFileSync(join(app, 'package.json'), 'utf-8')).toContain('2.0.0');
|
||||
// A whole replacement: what the old version had and the new one dropped is gone.
|
||||
expect(existsSync(join(app, 'src', 'only-in-1.0.0.ts'))).toBe(false);
|
||||
expect(existsSync(join(app, 'built-by-1.0.0'))).toBe(false);
|
||||
expect(existsSync(join(app, ARCHIVE_INSTALL_MARKER))).toBe(true);
|
||||
expect(existsSync(`${app}.new`)).toBe(false);
|
||||
expect(existsSync(`${app}.old`)).toBe(false);
|
||||
|
||||
// A folder that is neither a checkout nor one of ours is never written into.
|
||||
const foreign = join(scratch, 'foreign');
|
||||
mkdirSync(foreign);
|
||||
writeFileSync(join(foreign, 'notes.txt'), 'mine');
|
||||
const refused = drive(script, {
|
||||
CODEMAN_INSTALL_DIR: foreign,
|
||||
CODEMAN_TARBALL_URL: `file://${first}`,
|
||||
HOME: scratch,
|
||||
});
|
||||
expect(refused.status).toBe(1);
|
||||
expect(refused.stderr).toContain('is not a Codeman install');
|
||||
expect(readFileSync(join(foreign, 'notes.txt'), 'utf-8')).toBe('mine');
|
||||
} finally {
|
||||
rmSync(scratch, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('downloads from a subcommand that never ran the full flow', () => {
|
||||
// DOWNLOADER used to be set only by main(), so `install.sh cloudflared` (and now the
|
||||
// update of an archive install) died inside download() on an unbound variable.
|
||||
const result = drive(`
|
||||
unset DOWNLOADER
|
||||
curl() { echo "FETCH $*"; }
|
||||
wget() { echo "FETCH $*"; }
|
||||
download https://example.invalid/a /tmp/never-written
|
||||
unset DOWNLOADER
|
||||
download_to_stdout https://example.invalid/b
|
||||
`);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toMatch(/FETCH .*example\.invalid\/a/);
|
||||
expect(result.stdout).toMatch(/FETCH .*example\.invalid\/b/);
|
||||
});
|
||||
|
||||
it('builds the archive URL from the repository and branch, on GitHub only', () => {
|
||||
const url = (env: NodeJS.ProcessEnv) => drive('source_tarball_url || echo REFUSED', env).stdout.trim();
|
||||
expect(url({})).toBe('https://github.com/Ark0N/Codeman/archive/master.tar.gz');
|
||||
expect(url({ CODEMAN_REPO_URL: 'https://github.com/someone/fork.git', CODEMAN_BRANCH: 'feat/x' })).toBe(
|
||||
'https://github.com/someone/fork/archive/feat/x.tar.gz'
|
||||
);
|
||||
expect(url({ CODEMAN_REPO_URL: 'https://gitlab.com/someone/fork.git' })).toBe('REFUSED');
|
||||
expect(url({ CODEMAN_REPO_URL: 'git@github.com:someone/fork.git' })).toBe('REFUSED');
|
||||
expect(url({ CODEMAN_TARBALL_URL: 'https://mirror.example/codeman.tar.gz' })).toBe(
|
||||
'https://mirror.example/codeman.tar.gz'
|
||||
);
|
||||
});
|
||||
|
||||
it('shares the archive marker name with the in-app updater', () => {
|
||||
// install.sh writes it, src/web/self-update.ts reads it to tell such an install how to
|
||||
// update. A rename on one side would send archive installs back to the npm advice.
|
||||
expect(SOURCE).toContain(`SOURCE_MARKER="${ARCHIVE_INSTALL_MARKER}"`);
|
||||
});
|
||||
|
||||
it('treats a completed archive install as an install: quiet update on a re-run', () => {
|
||||
const dispatch = SOURCE.slice(SOURCE.indexOf('\nparse_flags "$@"'));
|
||||
expect(dispatch).toMatch(
|
||||
/-f "\$INSTALL_DIR\/\.install-complete" \]\] && \[\[ -d "\$INSTALL_DIR\/\.git" \|\| -f "\$INSTALL_DIR\/\$SOURCE_MARKER" \]\]/
|
||||
);
|
||||
const update = fn('update', '\nuninstall() {');
|
||||
expect(update).toContain('! -f "$INSTALL_DIR/$SOURCE_MARKER"');
|
||||
// The swap happens only after the build has succeeded.
|
||||
expect(update.indexOf('finish_source_swap')).toBeGreaterThan(update.indexOf('npm run build'));
|
||||
const main = fn(
|
||||
'main',
|
||||
'\n# ----------------------------------------------------------------------------\n# Phase 0'
|
||||
);
|
||||
expect(main.indexOf('finish_source_swap')).toBeGreaterThan(main.indexOf('npm run build'));
|
||||
expect(main.indexOf('finish_source_swap')).toBeLessThan(main.indexOf('install_symlink'));
|
||||
});
|
||||
|
||||
it('documents --no-admin where people look for flags, and resolves the route before any work', () => {
|
||||
expect(fn('usage', '\n}\n')).toContain('--no-admin');
|
||||
const dispatch = SOURCE.slice(SOURCE.indexOf('\nparse_flags "$@"'));
|
||||
expect(dispatch.indexOf('resolve_admin_mode')).toBeGreaterThan(-1);
|
||||
expect(dispatch.indexOf('resolve_admin_mode')).toBeLessThan(dispatch.lastIndexOf('case "$SUBCOMMAND" in'));
|
||||
expect(dispatch.indexOf('adopt_userspace_runtime')).toBeLessThan(dispatch.indexOf('resolve_admin_mode'));
|
||||
});
|
||||
|
||||
it('puts the downloaded runtime on the LaunchAgent PATH', () => {
|
||||
// The agent's PATH is a fixed list (Homebrew, /usr/local, ~/.local/bin, the system). A
|
||||
// Node.js in the home folder is outside it, and so is everything `npm install -g` puts
|
||||
// beside that Node.js: the AI CLIs, whose launchers start with `#!/usr/bin/env node`.
|
||||
// A machine with a real LaunchDaemon makes the function stand down, so skip there.
|
||||
if (existsSync('/Library/LaunchDaemons/com.codeman.web.plist')) return;
|
||||
const home = mkdtempSync(join(tmpdir(), 'codeman-agent-path-'));
|
||||
const result = drive(
|
||||
`
|
||||
mkdir -p "$RUNTIME_DIR/node/bin" "$RUNTIME_DIR/bin" "$HOME/.local/bin"
|
||||
printf '#!/bin/sh\n' > "$RUNTIME_DIR/node/bin/node"; chmod +x "$RUNTIME_DIR/node/bin/node"
|
||||
printf '#!/bin/sh\n' > "$RUNTIME_DIR/bin/tmux"; chmod +x "$RUNTIME_DIR/bin/tmux"
|
||||
ln -s "$RUNTIME_DIR/node/bin/node" "$HOME/.local/bin/node"
|
||||
PATH="$HOME/.local/bin:$PATH"; hash -r
|
||||
launchctl() { return 0; }
|
||||
sleep() { :; }
|
||||
BIND_HOST=127.0.0.1
|
||||
setup_launchd_service >/dev/null 2>&1
|
||||
cat "$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||
`,
|
||||
{ HOME: home }
|
||||
);
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const path = /<key>PATH<\/key>\s*<string>([^<]*)<\/string>/.exec(result.stdout)?.[1] ?? '';
|
||||
const dirs = path.split(':');
|
||||
expect(dirs).toContain(`${home}/.codeman/runtime/node/bin`);
|
||||
expect(dirs).toContain(`${home}/.codeman/runtime/bin`);
|
||||
expect(dirs).toContain(`${home}/.local/bin`);
|
||||
expect(dirs).toContain('/usr/bin');
|
||||
expect(new Set(dirs).size, `duplicate PATH entries: ${path}`).toBe(dirs.length);
|
||||
expect(result.stdout).toContain(`<string>${home}/.local/bin/node</string>`);
|
||||
});
|
||||
|
||||
it('links only into its own runtime, and removes only its own links', () => {
|
||||
// ~/.local/bin is the user's folder: a tmux or node they put there is never replaced on
|
||||
// install and never deleted on uninstall.
|
||||
const home = mkdtempSync(join(tmpdir(), 'codeman-links-'));
|
||||
const result = drive(
|
||||
`
|
||||
mkdir -p "$HOME/.local/bin" "$RUNTIME_DIR/bin"
|
||||
touch "$RUNTIME_DIR/bin/tmux" "$RUNTIME_DIR/bin/node"
|
||||
printf 'mine' > "$HOME/.local/bin/tmux"
|
||||
link_into_local_bin "$RUNTIME_DIR/bin/tmux" tmux && echo "foreign=replaced" || echo "foreign=kept"
|
||||
link_into_local_bin "$RUNTIME_DIR/bin/node" node && echo "fresh=linked"
|
||||
link_into_local_bin "$RUNTIME_DIR/bin/node" node && echo "ours=relinked"
|
||||
echo "tmux=$(cat "$HOME/.local/bin/tmux") node=$(readlink "$HOME/.local/bin/node")"
|
||||
`,
|
||||
{ HOME: home }
|
||||
);
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain('foreign=kept');
|
||||
expect(result.stdout).toContain('fresh=linked');
|
||||
expect(result.stdout).toContain('ours=relinked');
|
||||
expect(result.stdout).toMatch(/tmux=mine node=.*\/\.codeman\/runtime\/bin\/node/);
|
||||
|
||||
const body = fn('uninstall', '\nusage() {');
|
||||
const rm = body.indexOf('rm -f "$symlink_dir/$runtime_tool"');
|
||||
expect(rm).toBeGreaterThan(-1);
|
||||
expect(body.slice(body.lastIndexOf('if [[', rm), rm)).toContain('== "$RUNTIME_DIR/"*');
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,16 @@
|
||||
* npm test -- test/self-update.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, afterAll } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
ARCHIVE_INSTALL_MARKER,
|
||||
ARCHIVE_UPDATE_HINT,
|
||||
canSelfUpdateInPlace,
|
||||
detectInstallKind,
|
||||
isRunningInContainer,
|
||||
parseVersionFromTag,
|
||||
compareVersions,
|
||||
isNewerStableVersion,
|
||||
@@ -205,3 +213,39 @@ describe('expireStalledStatus (runtime staleness backstop)', () => {
|
||||
expect(expireStalledStatus(base({ phase: 'failed', updatedAt: NOW - 60 * MIN }), NOW)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectInstallKind', () => {
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'codeman-install-kind-'));
|
||||
afterAll(() => rmSync(scratch, { recursive: true, force: true }));
|
||||
let n = 0;
|
||||
const tree = (entries: string[]) => {
|
||||
const dir = join(scratch, `install-${++n}`);
|
||||
mkdirSync(dir);
|
||||
for (const entry of entries) {
|
||||
if (entry.endsWith('/')) mkdirSync(join(dir, entry));
|
||||
else writeFileSync(join(dir, entry), 'archive\n');
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
it('reads a source-archive install (install.sh without a working git) as archive', () => {
|
||||
const dir = tree(['src/', 'dist/', ARCHIVE_INSTALL_MARKER]);
|
||||
expect(detectInstallKind(dir)).toBe('archive');
|
||||
// Nothing to pull into, so it never self-updates in place; the hint names the installer.
|
||||
expect(canSelfUpdateInPlace('archive')).toBe(false);
|
||||
expect(ARCHIVE_UPDATE_HINT).toContain('getcodeman.com/install');
|
||||
expect(ARCHIVE_UPDATE_HINT).not.toContain('npm i -g');
|
||||
});
|
||||
|
||||
it('lets a real checkout win over a leftover marker', () => {
|
||||
// A re-run of the installer on a machine that gained git replaces the archive tree with
|
||||
// a clone; were a marker ever left beside .git, the checkout is still what counts.
|
||||
const dir = tree(['.git/', 'src/', ARCHIVE_INSTALL_MARKER]);
|
||||
expect(detectInstallKind(dir)).toBe(isRunningInContainer() ? 'docker-compose' : 'git');
|
||||
});
|
||||
|
||||
it('keeps npm and unknown as they were', () => {
|
||||
expect(detectInstallKind(tree(['dist/']))).toBe('npm');
|
||||
expect(detectInstallKind(tree(['dist/', 'src/']))).toBe('unknown');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user