portable-agent-layer 0.90.0 → 0.91.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,10 +21,10 @@ export function timezoneProblem(input: string | undefined): string | undefined {
21
21
  return `Not a timezone Intl recognises: ${input}`;
22
22
  }
23
23
 
24
- /** Prompt for missing identity fields. Skips any field that already has a value. */
25
- export async function promptIdentity(): Promise<void> {
24
+ /** Prompt for missing identity fields; true when it asked anything. */
25
+ export async function promptIdentity(): Promise<boolean> {
26
26
  // Skip interactive prompts in non-TTY environments (tests, CI)
27
- if (!process.stdin.isTTY) return;
27
+ if (!process.stdin.isTTY) return false;
28
28
 
29
29
  const settings: PalSettingsData = { ...readSettings() };
30
30
  settings.identity ??= {};
@@ -40,12 +40,7 @@ export async function promptIdentity(): Promise<void> {
40
40
  const needsCatchphrase = !ai.catchphrase;
41
41
  const needsTimezone = !principal.timezone;
42
42
 
43
- if (!needsPrincipal && !needsAi && !needsCatchphrase && !needsTimezone) {
44
- clack.log.info("Identity already configured");
45
- return;
46
- }
47
-
48
- clack.intro("Identity Setup");
43
+ if (!needsPrincipal && !needsAi && !needsCatchphrase && !needsTimezone) return false;
49
44
 
50
45
  if (needsPrincipal) {
51
46
  const name = await clack.text({
@@ -55,20 +50,20 @@ export async function promptIdentity(): Promise<void> {
55
50
  });
56
51
  if (clack.isCancel(name)) {
57
52
  clack.cancel("Setup cancelled");
58
- return;
53
+ return true;
59
54
  }
60
55
  principal.name = name;
61
56
  }
62
57
 
63
58
  if (needsAi) {
64
59
  const name = await clack.text({
65
- message: "Name your AI",
60
+ message: "Name your assistant",
66
61
  defaultValue: "Assistant",
67
62
  placeholder: "e.g. Jarvis, Friday, Atlas",
68
63
  });
69
64
  if (clack.isCancel(name)) {
70
65
  clack.cancel("Setup cancelled");
71
- return;
66
+ return true;
72
67
  }
73
68
  ai.name = name;
74
69
  ai.fullName = `${name} — Personal AI`;
@@ -83,7 +78,7 @@ export async function promptIdentity(): Promise<void> {
83
78
  });
84
79
  if (clack.isCancel(catchphrase)) {
85
80
  clack.cancel("Setup cancelled");
86
- return;
81
+ return true;
87
82
  }
88
83
  ai.catchphrase = catchphrase;
89
84
  }
@@ -97,11 +92,11 @@ export async function promptIdentity(): Promise<void> {
97
92
  });
98
93
  if (clack.isCancel(tz)) {
99
94
  clack.cancel("Setup cancelled");
100
- return;
95
+ return true;
101
96
  }
102
97
  principal.timezone = tz;
103
98
  }
104
99
 
105
100
  writeSettings(settings);
106
- clack.outro("Identity saved ✓");
101
+ return true;
107
102
  }
@@ -0,0 +1,50 @@
1
+ import { mix, PALETTE, type Style } from "./style";
2
+
3
+ const WORDMARK = [
4
+ "██████╗ █████╗ ██╗ ",
5
+ "██╔══██╗██╔══██╗██║ ",
6
+ "██████╔╝███████║██║ ",
7
+ "██╔═══╝ ██╔══██║██║ ",
8
+ "██║ ██║ ██║███████╗",
9
+ "╚═╝ ╚═╝ ╚═╝╚══════╝",
10
+ ];
11
+
12
+ const FULL_BANNER_WIDTH = 64;
13
+ const TAGLINE = "Your context, in every agent.";
14
+
15
+ function wordmarkRow(style: Style, row: string): string {
16
+ const chars = [...row];
17
+ const span = chars.length - 1;
18
+ return chars
19
+ .map((ch, i) => {
20
+ if (ch === " ") return ch;
21
+ const color = mix(PALETTE.violet, PALETTE.cyan, i / span);
22
+ return style.paint(ch === "█" ? color : mix(color, PALETTE.shade, 0.55), ch);
23
+ })
24
+ .join("");
25
+ }
26
+
27
+ export function compactBanner(style: Style, version: string): string {
28
+ const mark = `${style.glyph.ask} PAL`;
29
+ const name = `${style.glyph.dot} Portable Agent Layer`;
30
+ return `${style.gradient(mark)} ${style.soft(version)} ${style.dim(name)}`;
31
+ }
32
+
33
+ export function banner(style: Style, version: string): string[] {
34
+ const { term } = style;
35
+ if (!term.unicode || term.width < FULL_BANNER_WIDTH)
36
+ return ["", compactBanner(style, version), ""];
37
+ const side = [
38
+ "",
39
+ style.bold(style.gradient("Portable Agent Layer")),
40
+ style.soft(`v${version}`),
41
+ style.italic(style.soft(TAGLINE)),
42
+ "",
43
+ "",
44
+ ];
45
+ return [
46
+ "",
47
+ ...WORDMARK.map((row, i) => ` ${wordmarkRow(style, row)} ${side[i]}`.trimEnd()),
48
+ "",
49
+ ];
50
+ }
@@ -0,0 +1,112 @@
1
+ import { mark, railNotes, railRow, seconds } from "./rail";
2
+ import type { Style } from "./style";
3
+
4
+ type NoteLevel = "fail" | "warn" | "info";
5
+
6
+ export interface Note {
7
+ level: NoteLevel;
8
+ text: string;
9
+ }
10
+
11
+ export interface Done {
12
+ detail: string[];
13
+ notes?: Note[];
14
+ }
15
+
16
+ type Work = () => Promise<Done | null> | Done | null;
17
+
18
+ const FRAME_MS = 90;
19
+ const CLEAR_LINE = "\r\x1b[2K";
20
+
21
+ const INSTANT_MS = 50;
22
+
23
+ export const stepTime = (ms: number) => (ms < INSTANT_MS ? "" : seconds(ms));
24
+
25
+ function errorText(error: unknown): string {
26
+ return error instanceof Error ? error.message : String(error);
27
+ }
28
+
29
+ /**
30
+ * Prints the steps of a long command. At a terminal each step spins while it
31
+ * runs and settles into one line; anywhere else it prints one plain line when done.
32
+ */
33
+ export class LiveRail {
34
+ constructor(
35
+ private readonly style: Style,
36
+ private readonly write: (text: string) => void = (text) => {
37
+ process.stdout.write(text);
38
+ }
39
+ ) {}
40
+
41
+ line(text = ""): void {
42
+ this.write(`${text}\n`);
43
+ }
44
+
45
+ async step(name: string, pending: string, work: Work): Promise<Done | null> {
46
+ return this.style.term.rich
47
+ ? this.richStep(name, pending, work)
48
+ : this.plainStep(name, work);
49
+ }
50
+
51
+ private noteLines(note: Note): string[] {
52
+ if (note.level === "info") return railNotes(this.style, note.text, this.style.dim);
53
+ return railNotes(
54
+ this.style,
55
+ note.text,
56
+ (text) => text,
57
+ `${mark(this.style, note.level)} `
58
+ );
59
+ }
60
+
61
+ private async richStep(
62
+ name: string,
63
+ pending: string,
64
+ work: Work
65
+ ): Promise<Done | null> {
66
+ let frame = 0;
67
+ const draw = () =>
68
+ this.write(
69
+ `${CLEAR_LINE}${railRow(this.style, "spin", name, pending, "", frame)[0]}`
70
+ );
71
+ draw();
72
+ const spinner = setInterval(() => {
73
+ frame += 1;
74
+ draw();
75
+ }, FRAME_MS);
76
+ const started = performance.now();
77
+ try {
78
+ const done = await work();
79
+ clearInterval(spinner);
80
+ this.write(CLEAR_LINE);
81
+ if (done) this.settle(name, done, stepTime(performance.now() - started));
82
+ return done;
83
+ } catch (error) {
84
+ clearInterval(spinner);
85
+ this.write(CLEAR_LINE);
86
+ this.settle(name, { detail: [errorText(error)] }, "", "fail");
87
+ throw error;
88
+ }
89
+ }
90
+
91
+ private settle(name: string, done: Done, time: string, kind: "ok" | "fail" = "ok") {
92
+ const detail = done.detail.join(` ${this.style.glyph.dot} `);
93
+ for (const row of railRow(this.style, kind, name, detail, time)) this.line(row);
94
+ for (const note of done.notes ?? [])
95
+ for (const line of this.noteLines(note)) this.line(line);
96
+ }
97
+
98
+ private async plainStep(name: string, work: Work): Promise<Done | null> {
99
+ try {
100
+ const done = await work();
101
+ if (done) {
102
+ this.line(`ok ${name}: ${done.detail.join(", ")}`);
103
+ for (const note of done.notes ?? [])
104
+ this.line(`${note.level.padEnd(4)} ${note.text}`);
105
+ }
106
+ return done;
107
+ } catch (error) {
108
+ this.line(`fail ${name}: ${errorText(error)}`);
109
+ throw error;
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,119 @@
1
+ import { PALETTE, type Style, visibleWidth } from "./style";
2
+ import { wrapWords } from "./wrap";
3
+
4
+ const RAIL_WIDTH = 60;
5
+ const NAME_WIDTH = 13;
6
+
7
+ export type Mark = "ok" | "fail" | "warn" | "info" | "ask" | "todo" | "spin";
8
+
9
+ export function mark(style: Style, kind: Mark, frame = 0): string {
10
+ const { glyph, paint } = style;
11
+ switch (kind) {
12
+ case "ok":
13
+ return paint(PALETTE.green, glyph.ok);
14
+ case "fail":
15
+ return paint(PALETTE.rose, glyph.fail);
16
+ case "warn":
17
+ return paint(PALETTE.amber, glyph.warn);
18
+ case "info":
19
+ return paint(PALETTE.indigo, glyph.info);
20
+ case "ask":
21
+ return paint(PALETTE.cyan, glyph.ask);
22
+ case "todo":
23
+ return style.dim(glyph.todo);
24
+ case "spin":
25
+ return paint(PALETTE.violet, glyph.spinner[frame % glyph.spinner.length]);
26
+ }
27
+ }
28
+
29
+ const railGlyph = (style: Style, glyph: string) => style.paint(PALETTE.rail, glyph);
30
+
31
+ export const railTop = (style: Style, title: string) =>
32
+ `${railGlyph(style, style.glyph.top)} ${style.bold(title)}`;
33
+
34
+ export const railGap = (style: Style) => railGlyph(style, style.glyph.rail);
35
+
36
+ export const railNote = (style: Style, text: string) => `${railGap(style)} ${text}`;
37
+
38
+ export const railEnd = (style: Style, text: string) =>
39
+ `${railGlyph(style, style.glyph.end)} ${text}`;
40
+
41
+ function padBetween(left: string, right: string, width: number): string {
42
+ const room = width - visibleWidth(left) - visibleWidth(right);
43
+ return right ? `${left}${" ".repeat(Math.max(2, room))}${right}` : left;
44
+ }
45
+
46
+ /** One finished step: what it did on the same line, or under the name when the terminal is too narrow. */
47
+ export function railRow(
48
+ style: Style,
49
+ kind: Mark,
50
+ name: string,
51
+ detail: string,
52
+ time = "",
53
+ frame = 0
54
+ ): string[] {
55
+ const head = `${mark(style, kind, frame)} ${name.padEnd(NAME_WIDTH)}`;
56
+ const width = Math.min(style.term.width - 2, RAIL_WIDTH);
57
+ const wide = `${head}${style.soft(detail)}`;
58
+ if (visibleWidth(wide) + time.length + 2 <= width)
59
+ return [padBetween(wide, style.dim(time), width)];
60
+ const first = padBetween(
61
+ `${mark(style, kind, frame)} ${name}`,
62
+ style.dim(time),
63
+ width
64
+ );
65
+ return [first, ...railNotes(style, detail, style.dim)];
66
+ }
67
+
68
+ /** Text under a step, wrapped to the terminal so the rail stays unbroken. */
69
+ export function railNotes(
70
+ style: Style,
71
+ text: string,
72
+ paint: (text: string) => string,
73
+ lead = ""
74
+ ): string[] {
75
+ const width = Math.max(10, style.term.width - 3 - visibleWidth(lead));
76
+ const pad = " ".repeat(visibleWidth(lead));
77
+ return wrapWords(text, width).map((chunk, i) =>
78
+ railNote(style, `${i === 0 ? lead : pad}${paint(chunk.text)}`)
79
+ );
80
+ }
81
+
82
+ export interface HealthCount {
83
+ fails: number;
84
+ warns: number;
85
+ }
86
+
87
+ export function healthBadge(
88
+ style: Style,
89
+ { fails, warns }: HealthCount,
90
+ withGlyph = false
91
+ ) {
92
+ const { glyph } = style;
93
+ const lead = (g: string) => (withGlyph ? `${g} ` : "");
94
+ if (fails > 0) return style.badge(PALETTE.rose, `${lead(glyph.fail)}${fails} FAILING`);
95
+ if (warns > 0)
96
+ return style.badge(
97
+ PALETTE.amber,
98
+ `${lead(glyph.warn)}${warns} WARNING${warns === 1 ? "" : "S"}`
99
+ );
100
+ return style.badge(PALETTE.green, `${lead(glyph.healthy)}HEALTHY`);
101
+ }
102
+
103
+ /** A card when it fits; on a narrow terminal the same lines, unframed. */
104
+ export function box(style: Style, lines: string[], inner = 50): string[] {
105
+ if (style.term.width < inner + 4) return lines.map((line) => ` ${line}`.trimEnd());
106
+ const { box: b } = style.glyph;
107
+ const edge = (text: string) => railGlyph(style, text);
108
+ const padded = (line: string) =>
109
+ `${line}${" ".repeat(Math.max(0, inner - 2 - visibleWidth(line)))}`;
110
+ return [
111
+ edge(` ${b.tl}${b.h.repeat(inner)}${b.tr}`),
112
+ ...lines.map((line) => ` ${edge(b.v)} ${padded(line)}${edge(b.v)}`),
113
+ edge(` ${b.bl}${b.h.repeat(inner)}${b.br}`),
114
+ ];
115
+ }
116
+
117
+ export function seconds(ms: number): string {
118
+ return `${(ms / 1000).toFixed(1)}s`;
119
+ }
@@ -0,0 +1,132 @@
1
+ import { homedir } from "node:os";
2
+ import { detectTerminal, type Terminal } from "./terminal";
3
+
4
+ export type Rgb = readonly [number, number, number];
5
+
6
+ export const PALETTE = {
7
+ violet: [167, 139, 250],
8
+ indigo: [129, 140, 248],
9
+ cyan: [34, 211, 238],
10
+ green: [52, 211, 153],
11
+ amber: [251, 191, 36],
12
+ rose: [251, 113, 133],
13
+ dim: [100, 116, 139],
14
+ rail: [71, 85, 105],
15
+ soft: [148, 163, 184],
16
+ ink: [15, 23, 42],
17
+ shade: [11, 17, 32],
18
+ } as const satisfies Record<string, Rgb>;
19
+
20
+ const UNICODE_GLYPHS = {
21
+ ok: "✓",
22
+ fail: "✖",
23
+ warn: "▲",
24
+ healthy: "●",
25
+ info: "◇",
26
+ ask: "◆",
27
+ todo: "○",
28
+ spinner: ["◐", "◓", "◑", "◒"],
29
+ rail: "│",
30
+ top: "┌",
31
+ end: "└",
32
+ arrow: "→",
33
+ sparkle: "✦",
34
+ dot: "·",
35
+ dash: "—",
36
+ box: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" },
37
+ };
38
+
39
+ type Glyphs = typeof UNICODE_GLYPHS;
40
+
41
+ const ASCII_GLYPHS: Glyphs = {
42
+ ok: "v",
43
+ fail: "x",
44
+ warn: "!",
45
+ healthy: "*",
46
+ info: "*",
47
+ ask: "*",
48
+ todo: "o",
49
+ spinner: ["-", "\\", "|", "/"],
50
+ rail: "|",
51
+ top: "+",
52
+ end: "+",
53
+ arrow: "->",
54
+ sparkle: "*",
55
+ dot: "-",
56
+ dash: "-",
57
+ box: { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|" },
58
+ };
59
+
60
+ const RESET = "\x1b[0m";
61
+
62
+ export function visibleWidth(text: string): number {
63
+ return Bun.stringWidth(text);
64
+ }
65
+
66
+ export function mix(from: Rgb, to: Rgb, t: number): Rgb {
67
+ return [0, 1, 2].map((i) =>
68
+ Math.round(from[i] + (to[i] - from[i]) * t)
69
+ ) as unknown as Rgb;
70
+ }
71
+
72
+ function cubeLevel(channel: number): number {
73
+ return Math.round((channel / 255) * 5);
74
+ }
75
+
76
+ /** The nearest colour in the 6x6x6 cube of a 256-colour terminal. */
77
+ export function to256([r, g, b]: Rgb): number {
78
+ return 16 + 36 * cubeLevel(r) + 6 * cubeLevel(g) + cubeLevel(b);
79
+ }
80
+
81
+ function sgr(term: Terminal, layer: 38 | 48, rgb: Rgb): string {
82
+ if (term.color === "truecolor") return `\x1b[${layer};2;${rgb.join(";")}m`;
83
+ return `\x1b[${layer};5;${to256(rgb)}m`;
84
+ }
85
+
86
+ export function homeShortened(text: string, home = homedir()): string {
87
+ return home.length > 1 ? text.replaceAll(home, "~") : text;
88
+ }
89
+
90
+ export interface Style {
91
+ term: Terminal;
92
+ glyph: Glyphs;
93
+ paint: (rgb: Rgb, text: string) => string;
94
+ badge: (rgb: Rgb, text: string) => string;
95
+ bold: (text: string) => string;
96
+ italic: (text: string) => string;
97
+ gradient: (text: string) => string;
98
+ dim: (text: string) => string;
99
+ soft: (text: string) => string;
100
+ cmd: (text: string) => string;
101
+ }
102
+
103
+ export function createStyle(term: Terminal = detectTerminal()): Style {
104
+ const colored = term.color !== "none";
105
+ const wrap = (open: string, text: string) =>
106
+ colored && text ? `${open}${text}${RESET}` : text;
107
+ const paint = (rgb: Rgb, text: string) => wrap(sgr(term, 38, rgb), text);
108
+ const gradientChar = (ch: string, i: number, span: number) =>
109
+ ch === " "
110
+ ? ch
111
+ : `${sgr(term, 38, mix(PALETTE.violet, PALETTE.cyan, i / span))}${ch}`;
112
+ return {
113
+ term,
114
+ glyph: term.unicode ? UNICODE_GLYPHS : ASCII_GLYPHS,
115
+ paint,
116
+ badge: (rgb, text) =>
117
+ colored
118
+ ? `${sgr(term, 48, rgb)}${sgr(term, 38, PALETTE.ink)}\x1b[1m ${text} ${RESET}`
119
+ : `[${text}]`,
120
+ bold: (text) => wrap("\x1b[1m", text),
121
+ italic: (text) => wrap("\x1b[3m", text),
122
+ gradient: (text) => {
123
+ if (!colored) return text;
124
+ const chars = [...text];
125
+ const span = Math.max(1, chars.length - 1);
126
+ return `${chars.map((ch, i) => gradientChar(ch, i, span)).join("")}${RESET}`;
127
+ },
128
+ dim: (text) => paint(PALETTE.dim, text),
129
+ soft: (text) => paint(PALETTE.soft, text),
130
+ cmd: (text) => paint(PALETTE.cyan, text),
131
+ };
132
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * What the terminal PAL is printing to can show. Rich output (the banner, the
3
+ * rail, colours) is for a person at a terminal; anything else, an agent's tool
4
+ * call or a CI log, gets short plain lines.
5
+ */
6
+
7
+ type ColorDepth = "none" | "256" | "truecolor";
8
+
9
+ export interface Terminal {
10
+ rich: boolean;
11
+ color: ColorDepth;
12
+ unicode: boolean;
13
+ width: number;
14
+ }
15
+
16
+ export interface TerminalProbe {
17
+ env: Record<string, string | undefined>;
18
+ isTTY: boolean;
19
+ columns?: number;
20
+ platform: NodeJS.Platform;
21
+ }
22
+
23
+ const TRUECOLOR_PROGRAMS = new Set([
24
+ "iTerm.app",
25
+ "vscode",
26
+ "WezTerm",
27
+ "ghostty",
28
+ "Hyper",
29
+ ]);
30
+ const TRUECOLOR_TERMS = /truecolor|24bit|direct|kitty|alacritty|ghostty|wezterm/;
31
+
32
+ function forcedColor(env: TerminalProbe["env"]): ColorDepth | undefined {
33
+ const force = env.FORCE_COLOR;
34
+ if (force === undefined) return undefined;
35
+ if (force === "0" || force === "false") return "none";
36
+ return force === "3" ? "truecolor" : "256";
37
+ }
38
+
39
+ function supportsTruecolor({ env }: TerminalProbe): boolean {
40
+ return (
41
+ env.COLORTERM === "truecolor" ||
42
+ env.COLORTERM === "24bit" ||
43
+ env.WT_SESSION !== undefined ||
44
+ TRUECOLOR_PROGRAMS.has(env.TERM_PROGRAM ?? "") ||
45
+ TRUECOLOR_TERMS.test(env.TERM ?? "")
46
+ );
47
+ }
48
+
49
+ function colorDepth(probe: TerminalProbe, rich: boolean): ColorDepth {
50
+ if (probe.env.NO_COLOR) return "none";
51
+ const forced = forcedColor(probe.env);
52
+ if (forced === "none") return "none";
53
+ if (!rich && !forced) return "none";
54
+ if (forced === "truecolor" || supportsTruecolor(probe)) return "truecolor";
55
+ return "256";
56
+ }
57
+
58
+ /** The legacy Windows console draws box and block characters as question marks. */
59
+ function supportsUnicode({ env, platform }: TerminalProbe): boolean {
60
+ if (platform !== "win32") return true;
61
+ return Boolean(env.WT_SESSION || env.TERM_PROGRAM || env.ConEmuTask || env.TERM);
62
+ }
63
+
64
+ export function detectTerminal(probe: TerminalProbe = currentProbe()): Terminal {
65
+ const rich = probe.isTTY && probe.env.TERM !== "dumb";
66
+ return {
67
+ rich,
68
+ color: colorDepth(probe, rich),
69
+ unicode: supportsUnicode(probe),
70
+ width: probe.columns && probe.columns > 0 ? probe.columns : 80,
71
+ };
72
+ }
73
+
74
+ function currentProbe(): TerminalProbe {
75
+ return {
76
+ env: process.env,
77
+ isTTY: process.stdout.isTTY === true,
78
+ columns: process.stdout.columns,
79
+ platform: process.platform,
80
+ };
81
+ }
@@ -0,0 +1,19 @@
1
+ export interface Chunk {
2
+ text: string;
3
+ start: number;
4
+ }
5
+
6
+ export function wrapWords(text: string, width: number): Chunk[] {
7
+ const chunks: Chunk[] = [];
8
+ let line: Chunk | null = null;
9
+ for (const word of text.matchAll(/\S+/g)) {
10
+ const start = word.index ?? 0;
11
+ if (line && start + word[0].length - line.start <= width) {
12
+ line.text = text.slice(line.start, start + word[0].length);
13
+ continue;
14
+ }
15
+ line = { text: word[0], start };
16
+ chunks.push(line);
17
+ }
18
+ return chunks;
19
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `pal cli update`: the version jump, the download, then a fresh process that
3
+ * reinstalls and continues the same rail.
4
+ */
5
+
6
+ import type { UpdateCache } from "../hooks/handlers/update-check";
7
+ import { palPkg } from "../hooks/lib/paths";
8
+ import { type Captured, runCaptured } from "./install-flow";
9
+ import { type Done, LiveRail } from "./ui/live";
10
+ import { mark, railGap, railTop } from "./ui/rail";
11
+ import { createStyle, type Style } from "./ui/style";
12
+
13
+ const PACKAGE = "portable-agent-layer";
14
+
15
+ function upToDate(style: Style, version: string): string {
16
+ if (!style.term.rich) return `Already up to date: PAL ${version} is the latest`;
17
+ const checked = `${style.glyph.dot} checked just now`;
18
+ return ` ${mark(style, "ok")} ${style.gradient("PAL")} ${style.bold(version)} ${style.soft("is the latest")} ${style.dim(checked)}`;
19
+ }
20
+
21
+ function versionJump(style: Style, from: string, to: string): string {
22
+ if (!style.term.rich) return `PAL ${from} -> ${to}`;
23
+ const arrow = style.dim(style.glyph.arrow);
24
+ const pal = `${style.glyph.ask} PAL`;
25
+ return ` ${style.gradient(pal)} ${style.soft(from)} ${arrow} ${style.bold(style.gradient(to))}`;
26
+ }
27
+
28
+ type Run = (cmd: string, args: string[], cwd?: string) => Promise<Captured>;
29
+
30
+ export interface UpdateSteps {
31
+ check: () => Promise<UpdateCache>;
32
+ reinstall: (env: Record<string, string>) => number;
33
+ run?: Run;
34
+ }
35
+
36
+ /** `bun update -g` keeps the caret range, and on 0.x a caret allows only patch bumps. */
37
+ export function downloadCommand(
38
+ update: UpdateCache
39
+ ): [string, string[], string | undefined] {
40
+ return update.mode === "repo"
41
+ ? ["git", ["pull", "--ff-only"], palPkg()]
42
+ : ["bun", ["add", "-g", `${PACKAGE}@${update.latest}`], undefined];
43
+ }
44
+
45
+ class DownloadFailed extends Error {
46
+ constructor(
47
+ update: UpdateCache,
48
+ readonly output: string
49
+ ) {
50
+ super(
51
+ update.mode === "repo"
52
+ ? "git pull failed — local changes? Pull manually"
53
+ : `try: bun add -g ${PACKAGE}@${update.latest}`
54
+ );
55
+ }
56
+ }
57
+
58
+ async function download(update: UpdateCache, run: Run): Promise<Done> {
59
+ const [cmd, args, cwd] = downloadCommand(update);
60
+ const result = await run(cmd, args, cwd);
61
+ if (result.code !== 0) throw new DownloadFailed(update, result.output);
62
+ return { detail: [`${PACKAGE} ${update.latest}`] };
63
+ }
64
+
65
+ async function downloaded(rail: LiveRail, style: Style, update: UpdateCache, run: Run) {
66
+ try {
67
+ await rail.step("Download", "downloading…", () => download(update, run));
68
+ return true;
69
+ } catch (error) {
70
+ if (!(error instanceof DownloadFailed)) throw error;
71
+ for (const line of error.output.trim().split("\n").slice(-6))
72
+ rail.line(style.dim(` ${line}`));
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /** Exit code for the parent; the reinstall runs in a fresh process that reads PAL_UPDATED_FROM. */
78
+ export async function runUpdate(
79
+ { check, reinstall, run = runCaptured }: UpdateSteps,
80
+ style: Style = createStyle(),
81
+ rail: LiveRail = new LiveRail(style)
82
+ ): Promise<number> {
83
+ const update = await check();
84
+ rail.line();
85
+ if (!update.available) {
86
+ rail.line(upToDate(style, update.current));
87
+ rail.line();
88
+ return 0;
89
+ }
90
+ rail.line(versionJump(style, update.current, update.latest));
91
+ rail.line();
92
+ if (style.term.rich) {
93
+ rail.line(railTop(style, "Updating"));
94
+ rail.line(railGap(style));
95
+ }
96
+ if (!(await downloaded(rail, style, update, run))) return 1;
97
+ return reinstall({ PAL_UPDATED_FROM: update.current });
98
+ }