@ryan_nookpi/pi-extension-setup-sh 0.1.0
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.
- package/README.md +63 -0
- package/constants.ts +10 -0
- package/context.ts +81 -0
- package/index.ts +172 -0
- package/package.json +42 -0
- package/runner.ts +156 -0
- package/state.ts +88 -0
- package/types.ts +69 -0
- package/utils.ts +140 -0
- package/widget.ts +135 -0
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# @ryan_nookpi/pi-extension-setup-sh
|
|
2
|
+
|
|
3
|
+
Automatically runs the nearest `setup.sh` when a pi session starts.
|
|
4
|
+
|
|
5
|
+
This is useful for repositories that need a predictable bootstrap step before the agent starts editing, testing, or running commands.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:@ryan_nookpi/pi-extension-setup-sh
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## What it does
|
|
14
|
+
|
|
15
|
+
- searches the current directory and parent directories for `setup.sh`
|
|
16
|
+
- runs `setup.sh` automatically on session startup
|
|
17
|
+
- skips automatic reruns when the same `setup.sh` already completed successfully
|
|
18
|
+
- shows running, pending, failed, cancelled, and stale states in the pi UI
|
|
19
|
+
- keeps per-project logs and state under pi's setup-sh state directory
|
|
20
|
+
- prevents duplicate setup runs with a lock file
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
/setup-sh
|
|
26
|
+
/setup-sh status
|
|
27
|
+
/setup-sh rerun
|
|
28
|
+
/setup-sh cancel
|
|
29
|
+
/setup-sh clear
|
|
30
|
+
/setup-sh help
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### `/setup-sh`
|
|
34
|
+
|
|
35
|
+
Runs the nearest `setup.sh` manually, or attaches to the current run if one is already active.
|
|
36
|
+
|
|
37
|
+
### `/setup-sh status`
|
|
38
|
+
|
|
39
|
+
Shows the current setup state and recent log lines.
|
|
40
|
+
|
|
41
|
+
### `/setup-sh rerun`
|
|
42
|
+
|
|
43
|
+
Runs `setup.sh` again even if the previous run succeeded.
|
|
44
|
+
|
|
45
|
+
### `/setup-sh cancel`
|
|
46
|
+
|
|
47
|
+
Sends a termination signal to the active setup process.
|
|
48
|
+
|
|
49
|
+
### `/setup-sh clear`
|
|
50
|
+
|
|
51
|
+
Hides the setup widget for the current session.
|
|
52
|
+
|
|
53
|
+
## Behavior
|
|
54
|
+
|
|
55
|
+
- `setup.sh` is executed with `/bin/zsh` from the directory containing the script.
|
|
56
|
+
- A successful automatic run is remembered by the script hash, so changing `setup.sh` allows it to run again.
|
|
57
|
+
- Long-running setup output becomes `pending` in the UI when logs have been idle for a while.
|
|
58
|
+
- Failed and stale states stay visible so you can inspect them with `/setup-sh status` or rerun with `/setup-sh rerun`.
|
|
59
|
+
|
|
60
|
+
## Requirements
|
|
61
|
+
|
|
62
|
+
- pi
|
|
63
|
+
- a readable `setup.sh` file in the project directory or one of its parents
|
package/constants.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const WIDGET_KEY = "setup-sh";
|
|
5
|
+
export const STATUS_KEY = "setup-sh";
|
|
6
|
+
export const STATE_ROOT = path.join(os.homedir(), ".pi", "agent", "state", "setup-sh");
|
|
7
|
+
export const LOCK_STALE_MS = 12 * 60 * 60 * 1000;
|
|
8
|
+
export const PENDING_AFTER_MS = 60 * 1000;
|
|
9
|
+
export const WIDGET_REFRESH_MS = 1000;
|
|
10
|
+
export const LOG_TAIL_LINES = 8;
|
package/context.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { STATE_ROOT } from "./constants.js";
|
|
6
|
+
import type { SetupContext, StatePaths } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export function statePaths(repoKey: string): StatePaths {
|
|
9
|
+
const rootDir = STATE_ROOT;
|
|
10
|
+
const locksDir = path.join(rootDir, "locks");
|
|
11
|
+
const logsDir = path.join(rootDir, "logs");
|
|
12
|
+
const statesDir = path.join(rootDir, "states");
|
|
13
|
+
const exitsDir = path.join(rootDir, "exits");
|
|
14
|
+
const wrappersDir = path.join(rootDir, "wrappers");
|
|
15
|
+
return {
|
|
16
|
+
rootDir,
|
|
17
|
+
locksDir,
|
|
18
|
+
logsDir,
|
|
19
|
+
statesDir,
|
|
20
|
+
exitsDir,
|
|
21
|
+
wrappersDir,
|
|
22
|
+
lockPath: path.join(locksDir, `${repoKey}.lock.json`),
|
|
23
|
+
statePath: path.join(statesDir, `${repoKey}.json`),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function repoKeyFor(realRepoRoot: string): string {
|
|
28
|
+
return createHash("sha256").update(realRepoRoot).digest("hex").slice(0, 16);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fileHash(filePath: string): string {
|
|
32
|
+
return createHash("sha256").update(readFileSync(filePath)).digest("hex");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isExecutableOrReadable(filePath: string): boolean {
|
|
36
|
+
try {
|
|
37
|
+
statSync(filePath);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function findSetupPath(cwd: string): string | null {
|
|
45
|
+
let current = path.resolve(cwd);
|
|
46
|
+
try {
|
|
47
|
+
current = realpathSync(current);
|
|
48
|
+
} catch {
|
|
49
|
+
// Keep resolved cwd when the directory cannot be canonicalized yet.
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
while (true) {
|
|
53
|
+
const candidate = path.join(current, "setup.sh");
|
|
54
|
+
if (isExecutableOrReadable(candidate)) return candidate;
|
|
55
|
+
const parent = path.dirname(current);
|
|
56
|
+
if (parent === current) return null;
|
|
57
|
+
current = parent;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveSetupContext(cwd: string): SetupContext | null {
|
|
62
|
+
const setupPath = findSetupPath(cwd);
|
|
63
|
+
if (!setupPath) return null;
|
|
64
|
+
const realSetupPath = realpathSync(setupPath);
|
|
65
|
+
const repoRoot = realpathSync(path.dirname(realSetupPath));
|
|
66
|
+
const repoKey = repoKeyFor(repoRoot);
|
|
67
|
+
return {
|
|
68
|
+
repoRoot,
|
|
69
|
+
setupPath: realSetupPath,
|
|
70
|
+
repoKey,
|
|
71
|
+
setupHash: fileHash(realSetupPath),
|
|
72
|
+
paths: statePaths(repoKey),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function displayPath(targetPath: string): string {
|
|
77
|
+
const home = os.homedir();
|
|
78
|
+
if (targetPath === home) return "~";
|
|
79
|
+
if (targetPath.startsWith(`${home}${path.sep}`)) return `~${targetPath.slice(home.length)}`;
|
|
80
|
+
return targetPath;
|
|
81
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { PENDING_AFTER_MS, STATE_ROOT, STATUS_KEY, WIDGET_KEY, WIDGET_REFRESH_MS } from "./constants.js";
|
|
3
|
+
import { displayPath, findSetupPath, repoKeyFor, resolveSetupContext } from "./context.js";
|
|
4
|
+
import { cancelSetup, createWrapperScript, startSetup } from "./runner.js";
|
|
5
|
+
import { finalizeRunIfNeeded } from "./state.js";
|
|
6
|
+
import type { SetupContext, StartMode, StartResult } from "./types.js";
|
|
7
|
+
import { formatDuration, shellQuote, stripAnsi } from "./utils.js";
|
|
8
|
+
import {
|
|
9
|
+
formatSnapshotLine,
|
|
10
|
+
installWidget,
|
|
11
|
+
refreshWatcher,
|
|
12
|
+
renderWidgetLine,
|
|
13
|
+
showStatus,
|
|
14
|
+
stopWatcher,
|
|
15
|
+
type Watcher,
|
|
16
|
+
} from "./widget.js";
|
|
17
|
+
|
|
18
|
+
function commandHelp(): string {
|
|
19
|
+
return [
|
|
20
|
+
"/setup-sh [status|rerun|cancel|clear]",
|
|
21
|
+
"- 인자 없음: 현재 폴더의 setup.sh 실행 또는 실행 상태 표시",
|
|
22
|
+
"- status: 상세 상태와 로그 tail 표시",
|
|
23
|
+
"- rerun: 성공/실패 상태와 무관하게 재실행",
|
|
24
|
+
"- cancel: 실행 중인 setup.sh 종료",
|
|
25
|
+
"- clear: 현재 세션의 setup widget 숨김",
|
|
26
|
+
].join("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function notifyStartResult(ctx: ExtensionContext, result: StartResult): Promise<void> {
|
|
30
|
+
if (result.kind === "started") {
|
|
31
|
+
ctx.ui.notify(`setup.sh 실행 시작: ${displayPath(result.context.repoRoot)}`, "info");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (result.kind === "running") {
|
|
35
|
+
ctx.ui.notify(`setup.sh가 이미 실행 중입니다: ${displayPath(result.context.repoRoot)}`, "info");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (result.kind === "skipped") {
|
|
39
|
+
ctx.ui.notify(result.reason, "info");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (result.kind === "failed") {
|
|
43
|
+
ctx.ui.notify(result.reason, "error");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const __test__ = {
|
|
48
|
+
STATE_ROOT,
|
|
49
|
+
PENDING_AFTER_MS,
|
|
50
|
+
displayPath,
|
|
51
|
+
findSetupPath,
|
|
52
|
+
formatDuration,
|
|
53
|
+
formatSnapshotLine,
|
|
54
|
+
repoKeyFor,
|
|
55
|
+
renderWidgetLine,
|
|
56
|
+
resolveSetupContext,
|
|
57
|
+
createWrapperScript,
|
|
58
|
+
shellQuote,
|
|
59
|
+
stripAnsi,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export default function setupShExtension(pi: ExtensionAPI): void {
|
|
63
|
+
let watcher: Watcher | null = null;
|
|
64
|
+
const hiddenRepoKeys = new Set<string>();
|
|
65
|
+
|
|
66
|
+
async function watch(ctx: ExtensionContext, context: SetupContext): Promise<void> {
|
|
67
|
+
if (!ctx.hasUI || hiddenRepoKeys.has(context.repoKey)) return;
|
|
68
|
+
stopWatcher(ctx, watcher);
|
|
69
|
+
watcher = {
|
|
70
|
+
context,
|
|
71
|
+
interval: setInterval(() => {
|
|
72
|
+
if (watcher) void refreshWatcher(ctx, watcher);
|
|
73
|
+
}, WIDGET_REFRESH_MS),
|
|
74
|
+
snapshot: null,
|
|
75
|
+
tui: null,
|
|
76
|
+
disposed: false,
|
|
77
|
+
};
|
|
78
|
+
installWidget(ctx, watcher);
|
|
79
|
+
await refreshWatcher(ctx, watcher);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function runOrAttach(ctx: ExtensionContext, mode: StartMode): Promise<StartResult> {
|
|
83
|
+
const result = await startSetup(ctx.cwd, mode);
|
|
84
|
+
if (result.kind !== "no-setup" && result.kind !== "failed") {
|
|
85
|
+
hiddenRepoKeys.delete(result.context.repoKey);
|
|
86
|
+
await watch(ctx, result.context);
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
pi.on("session_start", async (event, ctx) => {
|
|
92
|
+
const context = resolveSetupContext(ctx.cwd);
|
|
93
|
+
if (!context) {
|
|
94
|
+
if (ctx.hasUI) {
|
|
95
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
96
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (event.reason === "reload") {
|
|
102
|
+
await finalizeRunIfNeeded(context);
|
|
103
|
+
await watch(ctx, context);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const result = await runOrAttach(ctx, "auto");
|
|
108
|
+
if (!ctx.hasUI) return;
|
|
109
|
+
if (result.kind === "started") {
|
|
110
|
+
ctx.ui.notify(`setup.sh 자동 실행 시작: ${displayPath(result.context.repoRoot)}`, "info");
|
|
111
|
+
}
|
|
112
|
+
if (result.kind === "running") ctx.ui.notify(`setup.sh 실행 중: ${displayPath(result.context.repoRoot)}`, "info");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
116
|
+
stopWatcher(ctx, watcher);
|
|
117
|
+
watcher = null;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
pi.registerCommand("setup-sh", {
|
|
121
|
+
description: "Run or inspect ./setup.sh for the current folder",
|
|
122
|
+
getArgumentCompletions: (prefix: string) => {
|
|
123
|
+
const values = ["status", "rerun", "cancel", "clear", "help"];
|
|
124
|
+
return values
|
|
125
|
+
.filter((value) => value.startsWith(prefix.trim()))
|
|
126
|
+
.map((value) => ({ value, label: value, description: `/setup-sh ${value}` }));
|
|
127
|
+
},
|
|
128
|
+
handler: async (args, ctx) => {
|
|
129
|
+
const action = args.trim() || "run";
|
|
130
|
+
const context = resolveSetupContext(ctx.cwd);
|
|
131
|
+
if (action === "help") {
|
|
132
|
+
ctx.ui.notify(commandHelp(), "info");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!context) {
|
|
136
|
+
ctx.ui.notify("현재 폴더 또는 상위 폴더에서 setup.sh를 찾지 못했습니다.", "warning");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (action === "clear") {
|
|
141
|
+
hiddenRepoKeys.add(context.repoKey);
|
|
142
|
+
stopWatcher(ctx, watcher);
|
|
143
|
+
watcher = null;
|
|
144
|
+
ctx.ui.notify("setup.sh widget을 숨겼습니다.", "info");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (action === "status") {
|
|
149
|
+
hiddenRepoKeys.delete(context.repoKey);
|
|
150
|
+
await watch(ctx, context);
|
|
151
|
+
await showStatus(ctx, context);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (action === "cancel") {
|
|
156
|
+
const message = await cancelSetup(context);
|
|
157
|
+
hiddenRepoKeys.delete(context.repoKey);
|
|
158
|
+
await watch(ctx, context);
|
|
159
|
+
ctx.ui.notify(message, "warning");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (action !== "run" && action !== "rerun") {
|
|
164
|
+
ctx.ui.notify(commandHelp(), "warning");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const mode: StartMode = action === "rerun" ? "rerun" : "manual";
|
|
169
|
+
await notifyStartResult(ctx, await runOrAttach(ctx, mode));
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ryan_nookpi/pi-extension-setup-sh",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Auto-run setup.sh extension for pi.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/Jonghakseo/pi-extension.git",
|
|
9
|
+
"directory": "packages/setup-sh"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/Jonghakseo/pi-extension/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/Jonghakseo/pi-extension/tree/main/packages/setup-sh#readme",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
"constants.ts",
|
|
21
|
+
"context.ts",
|
|
22
|
+
"index.ts",
|
|
23
|
+
"runner.ts",
|
|
24
|
+
"state.ts",
|
|
25
|
+
"types.ts",
|
|
26
|
+
"utils.ts",
|
|
27
|
+
"widget.ts",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./index.ts"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
37
|
+
"@mariozechner/pi-tui": "*"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/runner.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, openSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { chmod } from "node:fs/promises";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { resolveSetupContext } from "./context.js";
|
|
6
|
+
import { createLock, ensureStateDirs, finalizeRunIfNeeded } from "./state.js";
|
|
7
|
+
import type { LockRecord, RunRecord, SetupContext, StartMode, StartResult } from "./types.js";
|
|
8
|
+
import { isoNow, isPidAlive, maybeUnlink, shellQuote, writeJsonAtomic } from "./utils.js";
|
|
9
|
+
|
|
10
|
+
export function createWrapperScript(record: LockRecord, wrapperPath: string): string {
|
|
11
|
+
const setupPath = shellQuote(record.setupPath);
|
|
12
|
+
const repoRoot = shellQuote(record.repoRoot);
|
|
13
|
+
const lockPath = shellQuote(record.lockPath);
|
|
14
|
+
const exitPath = shellQuote(record.exitPath);
|
|
15
|
+
return `#!/bin/zsh
|
|
16
|
+
set +e
|
|
17
|
+
write_exit() {
|
|
18
|
+
local code="$1"
|
|
19
|
+
local setup_status="failed"
|
|
20
|
+
if [ "$code" = "0" ]; then
|
|
21
|
+
setup_status="success"
|
|
22
|
+
elif [ "$code" = "130" ] || [ "$code" = "143" ]; then
|
|
23
|
+
setup_status="cancelled"
|
|
24
|
+
fi
|
|
25
|
+
local finished_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
|
26
|
+
printf '{"status":"%s","exitCode":%s,"finishedAt":"%s"}\n' "$setup_status" "$code" "$finished_at" > ${exitPath}.tmp
|
|
27
|
+
mv ${exitPath}.tmp ${exitPath}
|
|
28
|
+
rm -f ${lockPath}
|
|
29
|
+
}
|
|
30
|
+
trap 'write_exit 130; exit 130' INT TERM HUP
|
|
31
|
+
printf '▶ setup.sh start %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
|
32
|
+
cd ${repoRoot}
|
|
33
|
+
/bin/zsh ${setupPath}
|
|
34
|
+
code=$?
|
|
35
|
+
write_exit "$code"
|
|
36
|
+
printf '■ setup.sh exit %s\n' "$code"
|
|
37
|
+
rm -f ${shellQuote(wrapperPath)}
|
|
38
|
+
exit "$code"
|
|
39
|
+
`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function initialLockRecord(context: SetupContext, runId: string, startedAt: string): LockRecord {
|
|
43
|
+
const logPath = path.join(context.paths.logsDir, `${context.repoKey}-${runId}.log`);
|
|
44
|
+
const exitPath = path.join(context.paths.exitsDir, `${context.repoKey}-${runId}.exit.json`);
|
|
45
|
+
return {
|
|
46
|
+
repoKey: context.repoKey,
|
|
47
|
+
repoRoot: context.repoRoot,
|
|
48
|
+
setupPath: context.setupPath,
|
|
49
|
+
setupHash: context.setupHash,
|
|
50
|
+
runId,
|
|
51
|
+
pid: process.pid,
|
|
52
|
+
coordinatorPid: process.pid,
|
|
53
|
+
startedAt,
|
|
54
|
+
logPath,
|
|
55
|
+
exitPath,
|
|
56
|
+
lockPath: context.paths.lockPath,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function handleLockedSetup(context: SetupContext): Promise<StartResult> {
|
|
61
|
+
const running = await finalizeRunIfNeeded(context);
|
|
62
|
+
return running?.status === "running"
|
|
63
|
+
? { kind: "running", context, record: running, owner: running.coordinatorPid === process.pid ? "self" : "other" }
|
|
64
|
+
: { kind: "skipped", context, record: running ?? undefined, reason: "setup.sh is locked by another session" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function spawnSetup(context: SetupContext, record: LockRecord, mode: StartMode): Promise<StartResult> {
|
|
68
|
+
const wrapperPath = path.join(context.paths.wrappersDir, `${context.repoKey}-${record.runId}.zsh`);
|
|
69
|
+
try {
|
|
70
|
+
const logFd = openSync(record.logPath, "a");
|
|
71
|
+
try {
|
|
72
|
+
writeFileSync(wrapperPath, createWrapperScript(record, wrapperPath), "utf-8");
|
|
73
|
+
await chmod(wrapperPath, 0o700);
|
|
74
|
+
const child = spawn("/bin/zsh", [wrapperPath], {
|
|
75
|
+
cwd: context.repoRoot,
|
|
76
|
+
detached: true,
|
|
77
|
+
stdio: ["ignore", logFd, logFd],
|
|
78
|
+
env: process.env,
|
|
79
|
+
});
|
|
80
|
+
child.unref();
|
|
81
|
+
|
|
82
|
+
if (!child.pid) throw new Error("Failed to start setup.sh");
|
|
83
|
+
|
|
84
|
+
const run: RunRecord = { ...record, pid: child.pid, status: "running", mode };
|
|
85
|
+
await writeJsonAtomic(context.paths.lockPath, run);
|
|
86
|
+
await writeJsonAtomic(context.paths.statePath, run);
|
|
87
|
+
child.on("exit", () => {
|
|
88
|
+
void finalizeRunIfNeeded(context);
|
|
89
|
+
});
|
|
90
|
+
return { kind: "started", context, record: run };
|
|
91
|
+
} finally {
|
|
92
|
+
closeSync(logFd);
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
await maybeUnlink(context.paths.lockPath);
|
|
96
|
+
const failed: RunRecord = {
|
|
97
|
+
...record,
|
|
98
|
+
status: "failed",
|
|
99
|
+
mode,
|
|
100
|
+
finishedAt: isoNow(),
|
|
101
|
+
message: error instanceof Error ? error.message : String(error),
|
|
102
|
+
};
|
|
103
|
+
await writeJsonAtomic(context.paths.statePath, failed);
|
|
104
|
+
return { kind: "failed", context, reason: failed.message ?? "Failed to start setup.sh" };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function startSetup(cwd: string, mode: StartMode): Promise<StartResult> {
|
|
109
|
+
let context: SetupContext | null = null;
|
|
110
|
+
try {
|
|
111
|
+
context = resolveSetupContext(cwd);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return { kind: "failed", reason: error instanceof Error ? error.message : String(error) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!context) return { kind: "no-setup", reason: "setup.sh not found" };
|
|
117
|
+
await ensureStateDirs(context.paths);
|
|
118
|
+
const current = await finalizeRunIfNeeded(context);
|
|
119
|
+
if (current?.status === "running") {
|
|
120
|
+
return {
|
|
121
|
+
kind: "running",
|
|
122
|
+
context,
|
|
123
|
+
record: current,
|
|
124
|
+
owner: current.coordinatorPid === process.pid ? "self" : "other",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (mode === "auto" && current?.status === "success" && current.setupHash === context.setupHash) {
|
|
129
|
+
return { kind: "skipped", context, record: current, reason: "setup.sh already completed for this folder" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const runId = `${Date.now()}-${process.pid}`;
|
|
133
|
+
const record = initialLockRecord(context, runId, isoNow());
|
|
134
|
+
const lockResult = await createLock(context, record);
|
|
135
|
+
if (lockResult === "locked") return handleLockedSetup(context);
|
|
136
|
+
return spawnSetup(context, record, mode);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function cancelSetup(context: SetupContext): Promise<string> {
|
|
140
|
+
const record = await finalizeRunIfNeeded(context);
|
|
141
|
+
if (!record || record.status !== "running") return "실행 중인 setup.sh가 없습니다.";
|
|
142
|
+
if (!isPidAlive(record.pid)) {
|
|
143
|
+
await finalizeRunIfNeeded(context);
|
|
144
|
+
return "setup.sh 프로세스가 이미 종료되었습니다.";
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
process.kill(-record.pid, "SIGTERM");
|
|
148
|
+
} catch {
|
|
149
|
+
try {
|
|
150
|
+
process.kill(record.pid, "SIGTERM");
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return `setup.sh 종료 신호 전송 실패: ${error instanceof Error ? error.message : String(error)}`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return `setup.sh 종료 신호를 보냈습니다. pid=${record.pid}`;
|
|
156
|
+
}
|
package/state.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { mkdir, open } from "node:fs/promises";
|
|
2
|
+
import { LOCK_STALE_MS } from "./constants.js";
|
|
3
|
+
import type { ExitRecord, LockRecord, RunRecord, SetupContext, StatePaths } from "./types.js";
|
|
4
|
+
import { elapsedMs, isoNow, isPidAlive, maybeUnlink, readJson, writeJsonAtomic } from "./utils.js";
|
|
5
|
+
|
|
6
|
+
export async function ensureStateDirs(
|
|
7
|
+
paths: Pick<StatePaths, "rootDir" | "locksDir" | "logsDir" | "statesDir" | "exitsDir" | "wrappersDir">,
|
|
8
|
+
): Promise<void> {
|
|
9
|
+
await Promise.all([
|
|
10
|
+
mkdir(paths.rootDir, { recursive: true }),
|
|
11
|
+
mkdir(paths.locksDir, { recursive: true }),
|
|
12
|
+
mkdir(paths.logsDir, { recursive: true }),
|
|
13
|
+
mkdir(paths.statesDir, { recursive: true }),
|
|
14
|
+
mkdir(paths.exitsDir, { recursive: true }),
|
|
15
|
+
mkdir(paths.wrappersDir, { recursive: true }),
|
|
16
|
+
]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function finalizeRunIfNeeded(context: SetupContext): Promise<RunRecord | null> {
|
|
20
|
+
const record = await readJson<RunRecord>(context.paths.statePath);
|
|
21
|
+
if (!record) {
|
|
22
|
+
const lock = await readJson<LockRecord>(context.paths.lockPath);
|
|
23
|
+
if (lock && isPidAlive(lock.pid)) return { ...lock, status: "running", mode: "auto" };
|
|
24
|
+
if (lock) await maybeUnlink(context.paths.lockPath);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (record.status !== "running") return record;
|
|
28
|
+
|
|
29
|
+
const exitRecord = await readJson<ExitRecord>(record.exitPath);
|
|
30
|
+
if (exitRecord) {
|
|
31
|
+
const next: RunRecord = {
|
|
32
|
+
...record,
|
|
33
|
+
status: exitRecord.status,
|
|
34
|
+
exitCode: exitRecord.exitCode,
|
|
35
|
+
finishedAt: exitRecord.finishedAt,
|
|
36
|
+
message:
|
|
37
|
+
exitRecord.status === "success"
|
|
38
|
+
? "setup.sh completed"
|
|
39
|
+
: exitRecord.status === "cancelled"
|
|
40
|
+
? "setup.sh cancelled"
|
|
41
|
+
: `setup.sh exited with code ${exitRecord.exitCode}`,
|
|
42
|
+
};
|
|
43
|
+
await writeJsonAtomic(context.paths.statePath, next);
|
|
44
|
+
await maybeUnlink(context.paths.lockPath);
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const lock = await readJson<LockRecord>(context.paths.lockPath);
|
|
49
|
+
if (lock && isPidAlive(lock.pid)) return record;
|
|
50
|
+
|
|
51
|
+
const lockAge = lock ? elapsedMs(lock.startedAt) : elapsedMs(record.startedAt);
|
|
52
|
+
if (!lock || lockAge > LOCK_STALE_MS || !isPidAlive(record.pid)) {
|
|
53
|
+
const next: RunRecord = {
|
|
54
|
+
...record,
|
|
55
|
+
status: "stale",
|
|
56
|
+
finishedAt: isoNow(),
|
|
57
|
+
message: "setup.sh stopped without writing an exit status",
|
|
58
|
+
};
|
|
59
|
+
await writeJsonAtomic(context.paths.statePath, next);
|
|
60
|
+
await maybeUnlink(context.paths.lockPath);
|
|
61
|
+
return next;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return record;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function createLock(context: SetupContext, record: LockRecord): Promise<"acquired" | "locked"> {
|
|
68
|
+
await ensureStateDirs(context.paths);
|
|
69
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
70
|
+
try {
|
|
71
|
+
const handle = await open(context.paths.lockPath, "wx");
|
|
72
|
+
try {
|
|
73
|
+
await handle.writeFile(`${JSON.stringify(record, null, 2)}\n`, "utf-8");
|
|
74
|
+
} finally {
|
|
75
|
+
await handle.close();
|
|
76
|
+
}
|
|
77
|
+
return "acquired";
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const code = typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
|
|
80
|
+
if (code !== "EEXIST") throw error;
|
|
81
|
+
|
|
82
|
+
const existing = await readJson<LockRecord>(context.paths.lockPath);
|
|
83
|
+
if (existing && isPidAlive(existing.pid)) return "locked";
|
|
84
|
+
await maybeUnlink(context.paths.lockPath);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return "locked";
|
|
88
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export type SetupStatus = "running" | "success" | "failed" | "cancelled" | "stale";
|
|
2
|
+
export type DisplayStatus = SetupStatus | "pending" | "skipped";
|
|
3
|
+
export type StartMode = "auto" | "manual" | "rerun";
|
|
4
|
+
|
|
5
|
+
export type SetupContext = {
|
|
6
|
+
repoRoot: string;
|
|
7
|
+
setupPath: string;
|
|
8
|
+
repoKey: string;
|
|
9
|
+
setupHash: string;
|
|
10
|
+
paths: StatePaths;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type StatePaths = {
|
|
14
|
+
rootDir: string;
|
|
15
|
+
locksDir: string;
|
|
16
|
+
logsDir: string;
|
|
17
|
+
statesDir: string;
|
|
18
|
+
exitsDir: string;
|
|
19
|
+
wrappersDir: string;
|
|
20
|
+
lockPath: string;
|
|
21
|
+
statePath: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type LockRecord = {
|
|
25
|
+
repoKey: string;
|
|
26
|
+
repoRoot: string;
|
|
27
|
+
setupPath: string;
|
|
28
|
+
setupHash: string;
|
|
29
|
+
runId: string;
|
|
30
|
+
pid: number;
|
|
31
|
+
coordinatorPid: number;
|
|
32
|
+
startedAt: string;
|
|
33
|
+
logPath: string;
|
|
34
|
+
exitPath: string;
|
|
35
|
+
lockPath: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type RunRecord = LockRecord & {
|
|
39
|
+
status: SetupStatus;
|
|
40
|
+
mode: StartMode;
|
|
41
|
+
finishedAt?: string;
|
|
42
|
+
exitCode?: number;
|
|
43
|
+
message?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type ExitRecord = {
|
|
47
|
+
status: SetupStatus;
|
|
48
|
+
exitCode: number;
|
|
49
|
+
finishedAt: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type StartResult =
|
|
53
|
+
| { kind: "started"; context: SetupContext; record: RunRecord }
|
|
54
|
+
| { kind: "running"; context: SetupContext; record: RunRecord; owner: "self" | "other" }
|
|
55
|
+
| { kind: "skipped"; context: SetupContext; record?: RunRecord; reason: string }
|
|
56
|
+
| { kind: "no-setup"; reason: string }
|
|
57
|
+
| { kind: "failed"; context?: SetupContext; reason: string };
|
|
58
|
+
|
|
59
|
+
export type Snapshot = {
|
|
60
|
+
visible: boolean;
|
|
61
|
+
status: DisplayStatus;
|
|
62
|
+
repoRoot: string;
|
|
63
|
+
owner: "self" | "other" | "none";
|
|
64
|
+
startedAt?: string;
|
|
65
|
+
finishedAt?: string;
|
|
66
|
+
exitCode?: number;
|
|
67
|
+
message?: string;
|
|
68
|
+
logPath?: string;
|
|
69
|
+
};
|
package/utils.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { LOG_TAIL_LINES } from "./constants.js";
|
|
5
|
+
|
|
6
|
+
export async function readJson<T>(filePath: string): Promise<T | null> {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(await readFile(filePath, "utf-8")) as T;
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {
|
|
15
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
16
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
17
|
+
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
|
|
18
|
+
await rename(tmp, filePath);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isPidAlive(pid: number | undefined): boolean {
|
|
22
|
+
if (!pid || !Number.isInteger(pid) || pid <= 0) return false;
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
return true;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
const code = typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
|
|
28
|
+
return code === "EPERM";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isoNow(): string {
|
|
33
|
+
return new Date().toISOString();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function elapsedMs(startedAt?: string, finishedAt?: string): number {
|
|
37
|
+
if (!startedAt) return 0;
|
|
38
|
+
const start = Date.parse(startedAt);
|
|
39
|
+
if (!Number.isFinite(start)) return 0;
|
|
40
|
+
const end = finishedAt ? Date.parse(finishedAt) : Date.now();
|
|
41
|
+
return Math.max(0, end - start);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatDuration(ms: number): string {
|
|
45
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
46
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
47
|
+
const seconds = totalSeconds % 60;
|
|
48
|
+
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function skipCsi(value: string, index: number): number {
|
|
52
|
+
let next = index + 1;
|
|
53
|
+
while (next < value.length) {
|
|
54
|
+
const code = value.charCodeAt(next);
|
|
55
|
+
next++;
|
|
56
|
+
if (code >= 0x40 && code <= 0x7e) break;
|
|
57
|
+
}
|
|
58
|
+
return next;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function skipOsc(value: string, index: number): number {
|
|
62
|
+
let next = index + 1;
|
|
63
|
+
while (next < value.length) {
|
|
64
|
+
if (value.charCodeAt(next) === 0x07) return next + 1;
|
|
65
|
+
if (value.charCodeAt(next) === 0x1b && value[next + 1] === "\\") return next + 2;
|
|
66
|
+
next++;
|
|
67
|
+
}
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function stripAnsi(value: string): string {
|
|
72
|
+
let output = "";
|
|
73
|
+
let index = 0;
|
|
74
|
+
while (index < value.length) {
|
|
75
|
+
if (value.charCodeAt(index) !== 0x1b) {
|
|
76
|
+
output += value[index];
|
|
77
|
+
index++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const marker = value[index + 1];
|
|
82
|
+
if (marker === "[") {
|
|
83
|
+
index = skipCsi(value, index + 1);
|
|
84
|
+
} else if (marker === "]") {
|
|
85
|
+
index = skipOsc(value, index + 1);
|
|
86
|
+
} else {
|
|
87
|
+
index++;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return output;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function readLastLogLine(logPath: string | undefined): string | undefined {
|
|
94
|
+
if (!logPath) return undefined;
|
|
95
|
+
let text = "";
|
|
96
|
+
try {
|
|
97
|
+
text = readFileSync(logPath, "utf-8");
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
const lines = text
|
|
102
|
+
.split(/\r?\n/)
|
|
103
|
+
.map((line) => stripAnsi(line).trim())
|
|
104
|
+
.filter((line) => line.length > 0);
|
|
105
|
+
return lines.at(-1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function readLogTail(logPath: string | undefined, maxLines = LOG_TAIL_LINES): string[] {
|
|
109
|
+
if (!logPath) return [];
|
|
110
|
+
try {
|
|
111
|
+
return readFileSync(logPath, "utf-8")
|
|
112
|
+
.split(/\r?\n/)
|
|
113
|
+
.map((line) => stripAnsi(line).trimEnd())
|
|
114
|
+
.filter((line) => line.trim().length > 0)
|
|
115
|
+
.slice(-maxLines);
|
|
116
|
+
} catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function getLogIdleMs(logPath: string | undefined, startedAt?: string): number {
|
|
122
|
+
try {
|
|
123
|
+
if (logPath) return Date.now() - statSync(logPath).mtimeMs;
|
|
124
|
+
} catch {
|
|
125
|
+
// Fall through to startedAt based idle duration.
|
|
126
|
+
}
|
|
127
|
+
return elapsedMs(startedAt);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function maybeUnlink(filePath: string): Promise<void> {
|
|
131
|
+
try {
|
|
132
|
+
await unlink(filePath);
|
|
133
|
+
} catch {
|
|
134
|
+
// Ignore missing files and concurrent cleanup.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function shellQuote(value: string): string {
|
|
139
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
140
|
+
}
|
package/widget.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { TUI } from "@mariozechner/pi-tui";
|
|
3
|
+
import { truncateToWidth } from "@mariozechner/pi-tui";
|
|
4
|
+
import { PENDING_AFTER_MS, STATUS_KEY, WIDGET_KEY } from "./constants.js";
|
|
5
|
+
import { displayPath } from "./context.js";
|
|
6
|
+
import { finalizeRunIfNeeded } from "./state.js";
|
|
7
|
+
import type { DisplayStatus, RunRecord, SetupContext, Snapshot } from "./types.js";
|
|
8
|
+
import { elapsedMs, formatDuration, getLogIdleMs, readLastLogLine, readLogTail } from "./utils.js";
|
|
9
|
+
|
|
10
|
+
export type Watcher = {
|
|
11
|
+
context: SetupContext;
|
|
12
|
+
interval: ReturnType<typeof setInterval>;
|
|
13
|
+
snapshot: Snapshot | null;
|
|
14
|
+
tui: TUI | null;
|
|
15
|
+
disposed: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function snapshotFromRecord(record: RunRecord, localPid = process.pid): Snapshot {
|
|
19
|
+
const logIdleMs = getLogIdleMs(record.logPath, record.startedAt);
|
|
20
|
+
const status: DisplayStatus = record.status === "running" && logIdleMs > PENDING_AFTER_MS ? "pending" : record.status;
|
|
21
|
+
const message =
|
|
22
|
+
record.status === "running" ? readLastLogLine(record.logPath) : (record.message ?? readLastLogLine(record.logPath));
|
|
23
|
+
return {
|
|
24
|
+
visible: true,
|
|
25
|
+
status,
|
|
26
|
+
repoRoot: record.repoRoot,
|
|
27
|
+
owner: record.coordinatorPid === localPid ? "self" : "other",
|
|
28
|
+
startedAt: record.startedAt,
|
|
29
|
+
finishedAt: record.finishedAt,
|
|
30
|
+
exitCode: record.exitCode,
|
|
31
|
+
message,
|
|
32
|
+
logPath: record.logPath,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function getSnapshot(context: SetupContext): Promise<Snapshot | null> {
|
|
37
|
+
const record = await finalizeRunIfNeeded(context);
|
|
38
|
+
if (!record || record.status === "success") return null;
|
|
39
|
+
return snapshotFromRecord(record);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatSnapshotLine(snapshot: Snapshot, theme: Pick<Theme, "fg">): string {
|
|
43
|
+
const duration = formatDuration(elapsedMs(snapshot.startedAt, snapshot.finishedAt));
|
|
44
|
+
const repo = displayPath(snapshot.repoRoot);
|
|
45
|
+
const owner =
|
|
46
|
+
snapshot.owner === "other" && (snapshot.status === "running" || snapshot.status === "pending") ? " elsewhere" : "";
|
|
47
|
+
const exit = snapshot.exitCode !== undefined && snapshot.status !== "success" ? ` exit ${snapshot.exitCode}` : "";
|
|
48
|
+
const suffix = snapshot.message ? ` · ${snapshot.message}` : "";
|
|
49
|
+
|
|
50
|
+
if (snapshot.status === "success") return `${theme.fg("success", "✅")} setup.sh done ${duration} · ${repo}`;
|
|
51
|
+
if (snapshot.status === "failed") {
|
|
52
|
+
return `${theme.fg("error", "❌")} setup.sh failed${exit} · ${repo}${suffix} · /setup-sh status`;
|
|
53
|
+
}
|
|
54
|
+
if (snapshot.status === "cancelled") return `${theme.fg("warning", "⚠️")} setup.sh cancelled · ${repo}${suffix}`;
|
|
55
|
+
if (snapshot.status === "stale") {
|
|
56
|
+
return `${theme.fg("warning", "⚠️")} setup.sh stale · ${repo}${suffix} · /setup-sh rerun`;
|
|
57
|
+
}
|
|
58
|
+
if (snapshot.status === "pending") {
|
|
59
|
+
return `${theme.fg("warning", "⏳")} setup.sh pending${owner} ${duration} · ${repo}${suffix}`;
|
|
60
|
+
}
|
|
61
|
+
return `${theme.fg("accent", "🔧")} setup.sh running${owner} ${duration} · ${repo}${suffix}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function plainSnapshotLine(snapshot: Snapshot): string {
|
|
65
|
+
return formatSnapshotLine(snapshot, { fg: (_color, text) => text });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function renderWidgetLine(snapshot: Snapshot | null, width: number, theme: Pick<Theme, "fg">): string[] {
|
|
69
|
+
if (!snapshot?.visible) return [];
|
|
70
|
+
return [truncateToWidth(formatSnapshotLine(snapshot, theme), width, "…")];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function updateStatus(ctx: ExtensionContext, snapshot: Snapshot | null): void {
|
|
74
|
+
if (!snapshot?.visible) {
|
|
75
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (snapshot.status === "failed" || snapshot.status === "stale") {
|
|
79
|
+
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("error", "❌ setup"));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (snapshot.status === "pending") {
|
|
83
|
+
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", "⏳ setup"));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (snapshot.status === "running") {
|
|
87
|
+
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", "🔧 setup"));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function installWidget(ctx: ExtensionContext, watcher: Watcher): void {
|
|
94
|
+
if (!ctx.hasUI) return;
|
|
95
|
+
ctx.ui.setWidget(WIDGET_KEY, (tui: TUI, theme: Theme) => {
|
|
96
|
+
watcher.tui = tui;
|
|
97
|
+
return {
|
|
98
|
+
render: (width: number) => renderWidgetLine(watcher.snapshot, width || tui.terminal?.columns || 120, theme),
|
|
99
|
+
invalidate: () => {},
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function refreshWatcher(ctx: ExtensionContext, watcher: Watcher): Promise<void> {
|
|
105
|
+
if (watcher.disposed) return;
|
|
106
|
+
watcher.snapshot = await getSnapshot(watcher.context);
|
|
107
|
+
updateStatus(ctx, watcher.snapshot);
|
|
108
|
+
if (!watcher.snapshot) {
|
|
109
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
watcher.tui?.requestRender();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function stopWatcher(ctx: ExtensionContext, watcher: Watcher | null): void {
|
|
116
|
+
if (!watcher) return;
|
|
117
|
+
watcher.disposed = true;
|
|
118
|
+
clearInterval(watcher.interval);
|
|
119
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
120
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function showStatus(ctx: ExtensionContext, context: SetupContext): Promise<void> {
|
|
124
|
+
const record = await finalizeRunIfNeeded(context);
|
|
125
|
+
if (!record) {
|
|
126
|
+
ctx.ui.notify(`setup.sh 상태 없음: ${displayPath(context.repoRoot)}`, "info");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const snapshot = snapshotFromRecord(record);
|
|
130
|
+
const tail = readLogTail(record.logPath);
|
|
131
|
+
const body = [plainSnapshotLine(snapshot), `Log: ${record.logPath}`, tail.length > 0 ? "" : undefined, ...tail]
|
|
132
|
+
.filter((line): line is string => line !== undefined)
|
|
133
|
+
.join("\n");
|
|
134
|
+
ctx.ui.notify(body, record.status === "failed" || record.status === "stale" ? "error" : "info");
|
|
135
|
+
}
|