Files
kandev/apps/cli/src/service/args.ts
T
José Almeida 041a19f060 feat: service-managed UI self-update (#1210)
* feat: add service-managed self-update

* chore: add temporary self-update test scripts

* chore: simplify temporary script markdown fences

* chore: run frontend in temporary self-update scripts

* fix: show self-update job progress after apply

* chore: add real npm self-update test script

* fix: isolate npm self-update prefix

* fix: detect npm service installs from bin shim

* chore: reset service before real npm update test

* chore: expand real npm status diagnostics

* fix: preserve service home during self-update

* fix(cli): tee self-update helper output to a log file

* fix(cli): avoid launchd bootstrap race when reloading the macOS service

* fix(backend): run macOS self-update helper once via transient launchd plist

* fix(web): show self-update progress and lock the apply button until the update lands

* fix(cli): reinstall via the kandev wrapper on Homebrew self-update

* chore: remove temp self-update test scripts

* fix(web): drop unused apply path from useUpdates hook

* fix(backend): snapshot install state once in apply preflight

* docs: align self-update docs with transient LaunchAgent flow

* chore(release): run cli bundle from apps workspace root

* fix(backend): guard against concurrent self-update applies

* fix(backend): check scheme and helper paths in self-update apply

* fix(backend): drop duplicate binary name from npx manual command

* fix(cli): allow --help for service self-update without --intent

* fix(cli): harden self-update dir perms and darwin uid resolution

* fix(web): set required POST fields last in applyUpdate

* docs: use JSON body notation for the apply endpoint

* fix(backend): expire self-update guard so a stuck helper cannot wedge apply

* refactor(cli): collapse redundant writeSync branch in self-update log
2026-06-01 17:49:15 +01:00

172 lines
4.9 KiB
TypeScript

import { ParseError } from "../args";
export type ServiceAction =
| "install"
| "uninstall"
| "start"
| "stop"
| "restart"
| "status"
| "logs"
| "config"
| "self-update";
export type ServiceArgs = {
action: ServiceAction;
/** Install as a system unit/daemon instead of a per-user one. */
system?: boolean;
/** Backend port to bake into the unit (passed via KANDEV_SERVER_PORT). */
port?: number;
/** Override KANDEV_HOME_DIR baked into the unit. */
homeDir?: string;
/** Skip the Linux enable-linger prompt during install. */
noBootStart?: boolean;
/** Tail logs (only valid with `logs`). */
follow?: boolean;
/** Print help and exit. */
showHelp?: boolean;
/** Intent JSON path consumed by the hidden self-update helper. */
intent?: string;
/** Print the self-update command plan without mutating the install. */
dryRun?: boolean;
};
const VALID_ACTIONS = new Set<ServiceAction>([
"install",
"uninstall",
"start",
"stop",
"restart",
"status",
"logs",
"config",
"self-update",
]);
export function parseServiceArgs(argv: string[]): ServiceArgs {
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
return { action: "install", showHelp: true };
}
const head = argv[0];
if (!VALID_ACTIONS.has(head as ServiceAction)) {
throw new ParseError(
`unknown service action "${head}". expected one of: ${[...VALID_ACTIONS].join(", ")}`,
);
}
const out: ServiceArgs = { action: head as ServiceAction };
for (let i = 1; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") {
out.showHelp = true;
continue;
}
if (arg === "--system") {
out.system = true;
continue;
}
if (arg === "--no-boot-start") {
out.noBootStart = true;
continue;
}
if (arg === "-f" || arg === "--follow") {
out.follow = true;
continue;
}
if (arg === "--dry-run") {
out.dryRun = true;
continue;
}
if (arg === "--intent") {
out.intent = takeValue(argv, i, "--intent");
i += 1;
continue;
}
if (arg.startsWith("--intent=")) {
const value = arg.slice("--intent=".length);
if (value.length === 0) throw new ParseError("--intent requires a value");
out.intent = value;
continue;
}
if (arg === "--port") {
out.port = parsePort(takeValue(argv, i, "--port"), "--port");
i += 1;
continue;
}
if (arg.startsWith("--port=")) {
out.port = parsePort(arg.slice("--port=".length), "--port");
continue;
}
if (arg === "--home-dir") {
out.homeDir = takeValue(argv, i, "--home-dir");
i += 1;
continue;
}
if (arg.startsWith("--home-dir=")) {
const value = arg.slice("--home-dir=".length);
if (value.length === 0) throw new ParseError("--home-dir requires a value");
out.homeDir = value;
continue;
}
throw new ParseError(`unknown flag "${arg}" for kandev service ${head}`);
}
validateActionFlags(out);
return out;
}
/**
* Reject flag combinations that silently no-op so the user gets immediate
* feedback instead of a successful command that ignored their input. The
* matrix is small enough that explicit checks beat a generic flag-applicability
* table.
*/
function validateActionFlags(args: ServiceArgs): void {
if (args.follow && args.action !== "logs") {
throw new ParseError(`--follow only applies to 'kandev service logs', not '${args.action}'`);
}
if (args.dryRun && args.action !== "self-update") {
throw new ParseError(
`--dry-run only applies to 'kandev service self-update', not '${args.action}'`,
);
}
if (args.intent && args.action !== "self-update") {
throw new ParseError(
`--intent only applies to 'kandev service self-update', not '${args.action}'`,
);
}
if (args.action === "self-update" && !args.showHelp && !args.intent) {
throw new ParseError("kandev service self-update requires --intent <path>");
}
const installOnly: Array<keyof ServiceArgs> = ["port", "homeDir", "noBootStart"];
if (args.action !== "install") {
for (const flag of installOnly) {
if (args[flag] !== undefined) {
const display =
flag === "homeDir"
? "--home-dir"
: flag === "noBootStart"
? "--no-boot-start"
: `--${flag}`;
throw new ParseError(
`${display} only applies to 'kandev service install', not '${args.action}'`,
);
}
}
}
}
function takeValue(argv: string[], i: number, flag: string): string {
const v = argv[i + 1];
if (v === undefined || v.startsWith("-")) {
throw new ParseError(`${flag} requires a value`);
}
return v;
}
function parsePort(raw: string, flag: string): number {
const n = Number(raw);
if (raw === "" || !Number.isInteger(n) || n < 1 || n > 65535) {
throw new ParseError(`${flag} value must be an integer between 1 and 65535, got "${raw}"`);
}
return n;
}