mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(install): ask three questions up front, then install unattended and end on the URL with a QR code
The installer used to ask about ten things, half of them after a multi-minute build, and the question that matters most (how do I reach the dashboard) came last. It now looks at what is on the machine, asks at most three questions (access, an optional tailnet name, service), and does the rest unattended. - Every step that needs a human runs before the build: one consent for all missing packages, one sudo prompt kept warm for the run, the AI CLI menu, and the Tailscale install/login/operator/HTTPS-toggle preflight (the toggle is polled and the admin page opened in a browser, instead of "re-check now?"). - The build, the service and `tailscale serve` run behind spinners with their output in ~/.codeman/install.log; the tail is shown on failure and a failed dependency install names its step. - The done screen leads with the URL (tailnet, network, this machine) and a terminal QR code from the qrcode package Codeman already ships. `install.sh status` prints it again. - Tailscale is two halves: tailscale_prepare (question phase) decides the serve SHAPE, tailscale_apply (after the build) issues the one serve command. When :443 already belongs to another app, Codeman goes under a sub-path (serve --set-path /codeman + CODEMAN_BASE_URL in the unit; serve strips the prefix, Codeman's ingress tolerates that, --base-url covers the URLs it emits) or a second port, instead of replace-or-nothing. - Renaming the node to codeman-<hostname> is opt-in and defaults to no everywhere (the tailnet name is the machine's ssh identity); --name and `install.sh name` do it, uninstall offers the old name back. Serve config is keyed by the DNS name, so a rename takes our mapping down first and re-adds it under the new name. - Flags pipe through `bash -s --`: --tailscale|--lan|--local, --name|--no-rename, --service|--run|--no-start, --yes, --password, --port. --port is now also written into the service file. - npm install runs with CODEMAN_NO_AUTOSTART=1: postinstall otherwise builds and starts a detached `codeman web` on 127.0.0.1:3000, which made the service crash-loop on EADDRINUSE while the done screen reported "running" off the orphan (fresh Ubuntu 24 sandbox). - The LAN address comes from the default route, not the first interface. - A foreign /Library/LaunchDaemons/com.codeman.web.plist is left alone instead of being replaced by a LaunchAgent. - The cloudflared question leaves the main flow (`install.sh cloudflared`). - "Continue WITHOUT a password?" defaults to yes (owner decision). Tests: the invariants test pins no `serve reset`, no funnel, no Tailscale Service, every serve mutation through ts_cmd_serve, rename before shape, flag/header parity, the rename default and the NO_AUTOSTART opt-out; the CI bash 3.2 step drives the question phase with stubbed tailscale state. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -179,8 +179,13 @@ describe('install.sh runtime safety', () => {
|
||||
/if \[\[ -n "\$\{CODEMAN_INSTALL_SH_LIB:-\}" \]\]; then return 0 2>\/dev\/null \|\| exit 0; fi/
|
||||
);
|
||||
const guardAt = SOURCE.indexOf('CODEMAN_INSTALL_SH_LIB');
|
||||
const dispatchAt = SOURCE.indexOf('case "${1:-}" in');
|
||||
const dispatchAt = SOURCE.indexOf('case "$SUBCOMMAND" in');
|
||||
expect(dispatchAt, 'the dispatch case must exist').toBeGreaterThan(-1);
|
||||
expect(guardAt, 'the sourcing guard must precede the dispatch case').toBeLessThan(dispatchAt);
|
||||
// parse_flags runs only in the dispatch tail, after the guard: a sourced copy must
|
||||
// never consume the harness's own arguments.
|
||||
const parseAt = SOURCE.indexOf('\nparse_flags "$@"');
|
||||
expect(parseAt, 'parse_flags must be invoked after the sourcing guard').toBeGreaterThan(guardAt);
|
||||
});
|
||||
|
||||
it('still sets the strict flags it has always run under', () => {
|
||||
@@ -205,6 +210,104 @@ describe('install.sh DeepSeek identity probe', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.sh owns the build and the start', () => {
|
||||
it('runs npm install with CODEMAN_NO_AUTOSTART=1', () => {
|
||||
// scripts/postinstall.js builds dist/ and starts a detached `codeman web` on its
|
||||
// own unless told not to. Under the installer that orphan made the service
|
||||
// crash-loop on EADDRINUSE while the done screen reported "running" off the
|
||||
// orphan (fresh Ubuntu 24 sandbox, 2026-09-20). Every npm install here must
|
||||
// carry the opt-out.
|
||||
// Executed installs only: the catalogue's `npm install -g` literals and the
|
||||
// failure message that quotes the command are prose here.
|
||||
const installs = CODE_LINES.filter(
|
||||
(line) => /\bnpm install\b/.test(line) && !/npm install -g/.test(line) && !/\b(error|warn|info|echo) "/.test(line)
|
||||
);
|
||||
expect(installs.length, 'expected the one npm install call').toBeGreaterThan(0);
|
||||
for (const line of installs) {
|
||||
expect(line, `npm install without CODEMAN_NO_AUTOSTART=1:\n ${line}`).toContain('CODEMAN_NO_AUTOSTART=1');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.sh Tailscale safety rules', () => {
|
||||
// Every rule here protects config that is not ours. `serve reset` destroys a user's
|
||||
// unrelated serve mappings (the maintainer's own node carries two); funnel is the
|
||||
// public internet, a different risk class than tailnet-only serve; advertising a
|
||||
// Tailscale Service requires a tagged node and admin approval and is documented
|
||||
// as a hint only. All three are pinned as absences.
|
||||
it('never runs `tailscale serve reset`', () => {
|
||||
const offenders = CODE_LINES.filter((line) => /serve\s+reset\b/.test(line));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('never runs `tailscale funnel` and never advertises a Tailscale Service', () => {
|
||||
// The installer's own `--service` flag (run as a service) is not Tailscale's
|
||||
// `--service=svc:<name>`; the pin keys on the svc: prefix and the serve form.
|
||||
const offenders = CODE_LINES.filter(
|
||||
(line) => /\bfunnel\b/.test(line) || /\bsvc:/.test(line) || /\bserve\b.*--service/.test(line)
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('routes every serve mutation through ts_cmd_serve (the sudo-aware wrapper)', () => {
|
||||
// A bare `tailscale serve --bg` or `set --hostname` would fail for a non-operator
|
||||
// user on Linux, exactly the state the wrapper exists to handle.
|
||||
const mutations = CODE_LINES.filter((line) => /\bserve --(bg|https)/.test(line) || /\bset --hostname\b/.test(line));
|
||||
expect(mutations.length).toBeGreaterThan(0);
|
||||
for (const line of mutations) {
|
||||
// Prose in warn/info strings and manual-command hints are fine; executed lines
|
||||
// must start with the wrapper.
|
||||
const executed = /^\s*(if\s+)?(!\s*)?(out=\$\()?ts_cmd_serve\b/.test(line);
|
||||
const quoted = /(info|warn|echo -e|success) /.test(line) || /Run: /.test(line) || /Configuring: /.test(line);
|
||||
expect(executed || quoted, `serve mutation outside ts_cmd_serve:\n ${line}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('decides the rename before the serve shape, and applies serve only after the build', () => {
|
||||
// Serve config is keyed by the DNS name it was written under: renaming after
|
||||
// configuring would orphan the mapping (and only `serve reset` could remove the
|
||||
// stale key). tailscale_prepare therefore asks the name first, chooses the shape
|
||||
// second, and main() applies the shape only after the build and the service.
|
||||
const prepare = SOURCE.slice(SOURCE.indexOf('tailscale_prepare() {'), SOURCE.indexOf('tailscale_apply() {'));
|
||||
expect(prepare.indexOf('tailscale_choose_name')).toBeGreaterThan(-1);
|
||||
expect(prepare.indexOf('tailscale_choose_name')).toBeLessThan(prepare.indexOf('tailscale_choose_mapping'));
|
||||
const main = SOURCE.slice(SOURCE.indexOf('\nmain() {'), SOURCE.indexOf('\npreflight_detect() {'));
|
||||
const order = [
|
||||
'choose_network_binding',
|
||||
'choose_launch_mode',
|
||||
'install_or_update_repo',
|
||||
'npm_install_deps',
|
||||
'run_step "Building Codeman"',
|
||||
'tailscale_apply',
|
||||
'print_done_screen',
|
||||
];
|
||||
const positions = order.map((needle) => main.indexOf(needle));
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
expect(positions[i], `${order[i]} missing from main()`).toBeGreaterThan(-1);
|
||||
if (i > 0) expect(positions[i], `${order[i]} must come after ${order[i - 1]}`).toBeGreaterThan(positions[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('documents every flag it parses', () => {
|
||||
// The header comment is the only manual most people read (it is what `curl` shows
|
||||
// them if they look). A flag parse_flags accepts and the header does not mention
|
||||
// is a flag nobody finds.
|
||||
const header = SOURCE.slice(0, SOURCE.indexOf('set -euo pipefail'));
|
||||
const parse = SOURCE.slice(SOURCE.indexOf('parse_flags() {'), SOURCE.indexOf('# Sourcing guard'));
|
||||
const flags = Array.from(parse.matchAll(/^\s+(--[a-z-]+)(?:[|)=\s])/gm), (m) => m[1]);
|
||||
expect(flags.length).toBeGreaterThan(5);
|
||||
for (const flag of new Set(flags)) {
|
||||
expect(header.includes(flag), `${flag} is parsed but not documented in the header`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('renames only as an opt-in: the question defaults to no and --yes never renames', () => {
|
||||
const fn = SOURCE.slice(SOURCE.indexOf('tailscale_choose_name() {'), SOURCE.indexOf('tailscale_rename_node() {'));
|
||||
expect(fn).toMatch(/prompt_yes_no "Rename this machine to \$suggested\?" "n"/);
|
||||
expect(fn).toMatch(/\[\[ "\$ASSUME_YES" == "1" \]\]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.sh AI CLI install menu', () => {
|
||||
// The menu is the one interactive path in the script, which is why it used to be the
|
||||
// only part nothing exercised: choosing "s" (Skip) once fell straight into the shared
|
||||
|
||||
Reference in New Issue
Block a user