merge feat/no-admin-install into the graphical installer branch

The graphical install runs install.sh --no-admin, so this branch needs both
PR #763 (the app) and PR #768 (the route that needs no password).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 02:01:55 +01:00
commit ed74ae41bc
16 files changed
+1466 -78

No files matched your search

+6
View File
@@ -428,14 +428,20 @@ describe('EditHistorySync', () => {
host.append('main', editLine('t1', T0, '/w/a.ts'));
const orig = host.read;
let first = true;
let inRead!: () => void;
const reachedRead = new Promise<void>((r) => (inRead = r));
host.read = async (...a) => {
if (first) {
first = false;
inRead();
await gate;
}
return orig(...a);
};
const slow = sync.sync(local);
// Only once s1 holds the gate: the sessions load their stores independently, so a later one
// could reach the read first, take the gate and hang the loop below before `release()`.
await reachedRead;
for (let i = 1; i <= MAX_LOADED_STORES + 1; i++) await sync.sync({ ...local, id: `s1-${i}` });
release();
expect(await slow).toEqual({ state: 'ok' });
+21
View File
@@ -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}`);
+411 -1
View File
@@ -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/"*');
});
});
+45 -1
View File
@@ -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');
});
});