supipowers 0.2.7 → 0.4.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.
Files changed (55) hide show
  1. package/package.json +21 -6
  2. package/skills/debugging/SKILL.md +54 -15
  3. package/skills/fix-pr/SKILL.md +99 -0
  4. package/skills/planning/SKILL.md +70 -10
  5. package/skills/receiving-code-review/SKILL.md +87 -0
  6. package/skills/tdd/SKILL.md +83 -0
  7. package/skills/verification/SKILL.md +54 -0
  8. package/src/commands/fix-pr.ts +324 -0
  9. package/src/commands/plan.ts +96 -31
  10. package/src/commands/qa.ts +150 -29
  11. package/src/commands/release.ts +1 -1
  12. package/src/commands/review.ts +2 -2
  13. package/src/commands/run.ts +52 -2
  14. package/src/commands/supi.ts +1 -0
  15. package/src/commands/update.ts +2 -2
  16. package/src/discipline/debugging.ts +57 -0
  17. package/src/discipline/receiving-review.ts +65 -0
  18. package/src/discipline/tdd.ts +77 -0
  19. package/src/discipline/verification.ts +68 -0
  20. package/src/fix-pr/config.ts +36 -0
  21. package/src/fix-pr/prompt-builder.ts +201 -0
  22. package/src/fix-pr/scripts/diff-comments.sh +33 -0
  23. package/src/fix-pr/scripts/fetch-pr-comments.sh +25 -0
  24. package/src/fix-pr/scripts/trigger-review.sh +36 -0
  25. package/src/fix-pr/scripts/wait-and-check.sh +37 -0
  26. package/src/fix-pr/types.ts +71 -0
  27. package/src/git/branch-finish.ts +101 -0
  28. package/src/git/worktree.ts +119 -0
  29. package/src/index.ts +13 -2
  30. package/src/lsp/detector.ts +2 -2
  31. package/src/orchestrator/agent-prompts.ts +282 -0
  32. package/src/orchestrator/dispatcher.ts +150 -1
  33. package/src/orchestrator/prompts.ts +17 -31
  34. package/src/planning/plan-reviewer.ts +49 -0
  35. package/src/planning/plan-writer-prompt.ts +173 -0
  36. package/src/planning/prompt-builder.ts +178 -0
  37. package/src/planning/spec-reviewer.ts +43 -0
  38. package/src/qa/phases/discovery.ts +34 -0
  39. package/src/qa/phases/execution.ts +65 -0
  40. package/src/qa/phases/matrix.ts +41 -0
  41. package/src/qa/phases/reporting.ts +71 -0
  42. package/src/qa/session.ts +104 -0
  43. package/src/storage/fix-pr-sessions.ts +59 -0
  44. package/src/storage/qa-sessions.ts +83 -0
  45. package/src/storage/specs.ts +36 -0
  46. package/src/types.ts +70 -0
  47. package/src/visual/companion.ts +115 -0
  48. package/src/visual/prompt-instructions.ts +102 -0
  49. package/src/visual/scripts/frame-template.html +201 -0
  50. package/src/visual/scripts/helper.js +88 -0
  51. package/src/visual/scripts/index.js +148 -0
  52. package/src/visual/scripts/package.json +10 -0
  53. package/src/visual/scripts/start-server.sh +98 -0
  54. package/src/visual/scripts/stop-server.sh +21 -0
  55. package/src/visual/types.ts +16 -0
@@ -0,0 +1,33 @@
1
+ #!/bin/bash
2
+ # Compares two JSONL comment snapshots, outputs only new/changed comments
3
+ # Usage: diff-comments.sh <prev_snapshot> <new_snapshot>
4
+ # Exit 0 if new comments found, exit 1 if identical
5
+ set -euo pipefail
6
+
7
+ PREV="$1"
8
+ NEW="$2"
9
+
10
+ # If no previous snapshot, all comments are new
11
+ if [[ ! -f "$PREV" ]]; then
12
+ cat "$NEW"
13
+ exit 0
14
+ fi
15
+
16
+ # Build fingerprint: id + updatedAt for each comment
17
+ prev_fingerprints=$(jq -r '[.id, .updatedAt] | @tsv' "$PREV" 2>/dev/null | sort)
18
+ new_fingerprints=$(jq -r '[.id, .updatedAt] | @tsv' "$NEW" 2>/dev/null | sort)
19
+
20
+ # Find IDs that are new or changed
21
+ new_ids=$(comm -13 <(echo "$prev_fingerprints") <(echo "$new_fingerprints") | cut -f1)
22
+
23
+ if [[ -z "$new_ids" ]]; then
24
+ exit 1
25
+ fi
26
+
27
+ # Output the full comment objects for new/changed IDs
28
+ while IFS= read -r id; do
29
+ [[ -z "$id" ]] && continue
30
+ jq -c "select(.id == $id)" "$NEW"
31
+ done <<< "$new_ids"
32
+
33
+ exit 0
@@ -0,0 +1,25 @@
1
+ #!/bin/bash
2
+ # Fetches all review comments for a PR, outputs JSONL
3
+ # Usage: fetch-pr-comments.sh <owner/repo> <pr_number> <output_file>
4
+ set -euo pipefail
5
+
6
+ REPO="$1"
7
+ PR="$2"
8
+ OUTPUT="$3"
9
+
10
+ # Ensure output directory exists
11
+ mkdir -p "$(dirname "$OUTPUT")"
12
+
13
+ # Fetch inline review comments (code-level)
14
+ gh api --paginate "repos/${REPO}/pulls/${PR}/comments" \
15
+ --jq '.[] | {id, path, line: .line, body, user: .user.login, createdAt: .created_at, updatedAt: .updated_at, inReplyToId: .in_reply_to_id, diffHunk: .diff_hunk, state: "COMMENTED"}' \
16
+ > "$OUTPUT" 2>/dev/null || true
17
+
18
+ # Fetch review-level comments (top-level reviews with body text)
19
+ gh api --paginate "repos/${REPO}/pulls/${PR}/reviews" \
20
+ --jq '.[] | select(.body != null and .body != "") | {id, path: null, line: null, body, user: .user.login, createdAt: .submitted_at, updatedAt: .submitted_at, inReplyToId: null, diffHunk: null, state}' \
21
+ >> "$OUTPUT" 2>/dev/null || true
22
+
23
+ # Output summary to stderr for caller
24
+ TOTAL=$(wc -l < "$OUTPUT" | tr -d ' ')
25
+ echo "{\"total\": ${TOTAL}}" >&2
@@ -0,0 +1,36 @@
1
+ #!/bin/bash
2
+ # Triggers automated reviewer to re-review a PR
3
+ # Usage: trigger-review.sh <owner/repo> <pr_number> <reviewer_type> <trigger_method>
4
+ set -euo pipefail
5
+
6
+ REPO="$1"
7
+ PR="$2"
8
+ REVIEWER="$3"
9
+ METHOD="${4:-}"
10
+
11
+ case "$REVIEWER" in
12
+ coderabbit)
13
+ gh api "repos/${REPO}/issues/${PR}/comments" -f body="$METHOD" >/dev/null 2>&1
14
+ echo '{"triggered": true, "reviewer": "coderabbit"}'
15
+ ;;
16
+ copilot)
17
+ if [[ -n "$METHOD" ]]; then
18
+ gh api "repos/${REPO}/issues/${PR}/comments" -f body="$METHOD" >/dev/null 2>&1
19
+ else
20
+ gh api "repos/${REPO}/pulls/${PR}/requested_reviewers" \
21
+ --method POST -f "reviewers[]=copilot" >/dev/null 2>&1 || true
22
+ fi
23
+ echo '{"triggered": true, "reviewer": "copilot"}'
24
+ ;;
25
+ gemini)
26
+ gh api "repos/${REPO}/issues/${PR}/comments" -f body="$METHOD" >/dev/null 2>&1
27
+ echo '{"triggered": true, "reviewer": "gemini"}'
28
+ ;;
29
+ none)
30
+ echo '{"triggered": false, "reviewer": "none"}'
31
+ ;;
32
+ *)
33
+ echo '{"triggered": false, "error": "unknown reviewer type: '"$REVIEWER"'"}'
34
+ exit 1
35
+ ;;
36
+ esac
@@ -0,0 +1,37 @@
1
+ #!/bin/bash
2
+ # Waits for delay, fetches new PR comments, diffs against previous snapshot
3
+ # Usage: wait-and-check.sh <session_dir> <delay_seconds> <iteration> <owner/repo> <pr_number>
4
+ # Output: new comment lines + JSON summary on last line
5
+ set -euo pipefail
6
+
7
+ SESSION_DIR="$1"
8
+ DELAY="$2"
9
+ ITERATION="$3"
10
+ REPO="$4"
11
+ PR="$5"
12
+
13
+ SNAPSHOTS_DIR="${SESSION_DIR}/snapshots"
14
+ PREV_ITERATION=$((ITERATION - 1))
15
+ PREV_SNAPSHOT="${SNAPSHOTS_DIR}/comments-${PREV_ITERATION}.jsonl"
16
+ NEW_SNAPSHOT="${SNAPSHOTS_DIR}/comments-${ITERATION}.jsonl"
17
+
18
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
19
+
20
+ # Wait for reviewer to process
21
+ echo "Waiting ${DELAY}s for reviewer to process changes..." >&2
22
+ sleep "$DELAY"
23
+
24
+ # Fetch new comments
25
+ echo "Fetching PR comments (iteration ${ITERATION})..." >&2
26
+ bash "${SCRIPT_DIR}/fetch-pr-comments.sh" "$REPO" "$PR" "$NEW_SNAPSHOT"
27
+
28
+ # Diff against previous
29
+ DIFF_OUTPUT=$(bash "${SCRIPT_DIR}/diff-comments.sh" "$PREV_SNAPSHOT" "$NEW_SNAPSHOT" 2>/dev/null) || true
30
+
31
+ if [[ -n "$DIFF_OUTPUT" ]]; then
32
+ DIFF_COUNT=$(echo "$DIFF_OUTPUT" | wc -l | tr -d ' ')
33
+ echo "$DIFF_OUTPUT"
34
+ echo "{\"hasNewComments\": true, \"count\": ${DIFF_COUNT}, \"iteration\": ${ITERATION}}"
35
+ else
36
+ echo "{\"hasNewComments\": false, \"count\": 0, \"iteration\": ${ITERATION}}"
37
+ fi
@@ -0,0 +1,71 @@
1
+ /** Supported automated PR reviewers */
2
+ export type ReviewerType = "coderabbit" | "copilot" | "gemini" | "none";
3
+
4
+ /** How to handle comment replies */
5
+ export type CommentReplyPolicy = "answer-all" | "answer-selective" | "no-answer";
6
+
7
+ /** Model preference for a specific role */
8
+ export interface ModelPref {
9
+ provider: string;
10
+ model: string;
11
+ tier: "low" | "high";
12
+ }
13
+
14
+ /** Per-repo fix-pr configuration */
15
+ export interface FixPrConfig {
16
+ reviewer: {
17
+ type: ReviewerType;
18
+ triggerMethod: string | null;
19
+ };
20
+ commentPolicy: CommentReplyPolicy;
21
+ loop: {
22
+ delaySeconds: number;
23
+ maxIterations: number;
24
+ };
25
+ models: {
26
+ orchestrator: ModelPref;
27
+ planner: ModelPref;
28
+ fixer: ModelPref;
29
+ };
30
+ }
31
+
32
+ /** A PR review comment from GitHub API */
33
+ export interface PrComment {
34
+ id: number;
35
+ path: string | null;
36
+ line: number | null;
37
+ body: string;
38
+ user: string;
39
+ createdAt: string;
40
+ updatedAt: string;
41
+ inReplyToId: number | null;
42
+ diffHunk: string | null;
43
+ state: string;
44
+ }
45
+
46
+ /** Assessment verdict for a single comment */
47
+ export type CommentVerdict = "accept" | "reject" | "investigate";
48
+
49
+ /** A group of related comments to fix together */
50
+ export interface FixGroup {
51
+ id: string;
52
+ commentIds: number[];
53
+ files: string[];
54
+ description: string;
55
+ }
56
+
57
+ /** Session status */
58
+ export type FixPrSessionStatus = "running" | "completed" | "failed";
59
+
60
+ /** Session ledger for a fix-pr run */
61
+ export interface FixPrSessionLedger {
62
+ id: string;
63
+ createdAt: string;
64
+ updatedAt: string;
65
+ prNumber: number;
66
+ repo: string;
67
+ status: FixPrSessionStatus;
68
+ iteration: number;
69
+ config: FixPrConfig;
70
+ commentsProcessed: number[];
71
+ }
@@ -0,0 +1,101 @@
1
+ export interface FinishOption {
2
+ id: "merge" | "pr" | "keep" | "discard";
3
+ label: string;
4
+ }
5
+
6
+ /** The 4 structured options for finishing a branch */
7
+ export const FINISH_OPTIONS: FinishOption[] = [
8
+ { id: "merge", label: "Merge back to base branch locally" },
9
+ { id: "pr", label: "Push and create a Pull Request" },
10
+ { id: "keep", label: "Keep the branch as-is (handle later)" },
11
+ { id: "discard", label: "Discard this work" },
12
+ ];
13
+
14
+ export interface BranchFinishPromptOptions {
15
+ branchName: string;
16
+ baseBranch: string;
17
+ worktreePath?: string;
18
+ }
19
+
20
+ /**
21
+ * Build the prompt that guides the agent through finishing a development branch.
22
+ * Follows superpowers' finishing-a-development-branch skill:
23
+ * - Verify tests pass first
24
+ * - Present exactly 4 options
25
+ * - Execute chosen option
26
+ * - Clean up worktree (conditional)
27
+ */
28
+ export function buildBranchFinishPrompt(options: BranchFinishPromptOptions): string {
29
+ const { branchName, baseBranch, worktreePath } = options;
30
+
31
+ const sections: string[] = [
32
+ "## Finish Development Branch",
33
+ "",
34
+ `Branch: \`${branchName}\` (base: \`${baseBranch}\`)`,
35
+ "",
36
+ "### Step 1: Verify tests pass",
37
+ "",
38
+ "Run the full test suite. All tests must pass before proceeding.",
39
+ "If tests fail, fix them first — do not offer options until green.",
40
+ "",
41
+ "### Step 2: Present options",
42
+ "",
43
+ "Ask the user:",
44
+ "",
45
+ "> Implementation complete. What would you like to do?",
46
+ ">",
47
+ `> 1. Merge back to \`${baseBranch}\` locally`,
48
+ "> 2. Push and create a Pull Request",
49
+ "> 3. Keep the branch as-is (handle later)",
50
+ "> 4. Discard this work",
51
+ "",
52
+ "### Option 1: Merge locally",
53
+ "",
54
+ "```bash",
55
+ `git checkout ${baseBranch}`,
56
+ "git pull",
57
+ `git merge ${branchName}`,
58
+ "# Verify tests pass on merged result",
59
+ `git branch -d ${branchName}`,
60
+ "```",
61
+ "",
62
+ "### Option 2: Push and create Pull Request",
63
+ "",
64
+ "```bash",
65
+ `git push -u origin ${branchName}`,
66
+ `gh pr create --title "<title>" --body "<summary>"`,
67
+ "```",
68
+ "",
69
+ "### Option 3: Keep as-is",
70
+ "",
71
+ `Report: "Keeping branch ${branchName}."`,
72
+ "Do NOT clean up worktree.",
73
+ "",
74
+ "### Option 4: Discard",
75
+ "",
76
+ "**Require explicit confirm before deleting.** Show what will be lost:",
77
+ "",
78
+ "```bash",
79
+ `git checkout ${baseBranch}`,
80
+ `git branch -D ${branchName}`,
81
+ "```",
82
+ ];
83
+
84
+ if (worktreePath) {
85
+ sections.push(
86
+ "",
87
+ "### Worktree cleanup",
88
+ "",
89
+ `Worktree at: \`${worktreePath}\``,
90
+ "",
91
+ "- **Options 1 and 4:** Clean up the worktree:",
92
+ " ```bash",
93
+ ` git worktree remove ${worktreePath}`,
94
+ " ```",
95
+ "- **Option 2:** Keep worktree (PR may need updates)",
96
+ "- **Option 3:** Keep worktree",
97
+ );
98
+ }
99
+
100
+ return sections.join("\n");
101
+ }
@@ -0,0 +1,119 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export interface ProjectSetup {
5
+ type: "node" | "rust" | "python" | "go" | "unknown";
6
+ installCommand: string | null;
7
+ testCommand: string | null;
8
+ }
9
+
10
+ /**
11
+ * Detect existing worktree directory following priority:
12
+ * .worktrees > worktrees > null
13
+ */
14
+ export function detectWorktreeDir(cwd: string): string | null {
15
+ const dotWorktrees = path.join(cwd, ".worktrees");
16
+ if (fs.existsSync(dotWorktrees)) return dotWorktrees;
17
+
18
+ const worktrees = path.join(cwd, "worktrees");
19
+ if (fs.existsSync(worktrees)) return worktrees;
20
+
21
+ return null;
22
+ }
23
+
24
+ /**
25
+ * Auto-detect project type and setup commands from project files.
26
+ */
27
+ export function detectProjectSetup(cwd: string): ProjectSetup {
28
+ if (fs.existsSync(path.join(cwd, "package.json"))) {
29
+ return { type: "node", installCommand: "npm install", testCommand: "npm test" };
30
+ }
31
+ if (fs.existsSync(path.join(cwd, "Cargo.toml"))) {
32
+ return { type: "rust", installCommand: "cargo build", testCommand: "cargo test" };
33
+ }
34
+ if (fs.existsSync(path.join(cwd, "requirements.txt"))) {
35
+ return { type: "python", installCommand: "pip install -r requirements.txt", testCommand: "pytest" };
36
+ }
37
+ if (fs.existsSync(path.join(cwd, "pyproject.toml"))) {
38
+ return { type: "python", installCommand: "poetry install", testCommand: "pytest" };
39
+ }
40
+ if (fs.existsSync(path.join(cwd, "go.mod"))) {
41
+ return { type: "go", installCommand: "go mod download", testCommand: "go test ./..." };
42
+ }
43
+ return { type: "unknown", installCommand: null, testCommand: null };
44
+ }
45
+
46
+ export interface WorktreePromptOptions {
47
+ branchName: string;
48
+ cwd: string;
49
+ }
50
+
51
+ /**
52
+ * Build the prompt that guides the agent through creating an isolated git worktree.
53
+ * Follows superpowers' using-git-worktrees skill:
54
+ * - Smart directory selection (.worktrees > worktrees > ask)
55
+ * - .gitignore verification
56
+ * - Project setup detection
57
+ * - Baseline test verification
58
+ */
59
+ export function buildWorktreePrompt(options: WorktreePromptOptions): string {
60
+ const { branchName, cwd } = options;
61
+
62
+ return [
63
+ "## Set Up Isolated Worktree",
64
+ "",
65
+ `Create an isolated workspace for branch \`${branchName}\`.`,
66
+ "",
67
+ "### Step 1: Select directory",
68
+ "",
69
+ "Check in priority order:",
70
+ `1. \`${cwd}/.worktrees/\` — if it exists, use it`,
71
+ `2. \`${cwd}/worktrees/\` — if it exists, use it`,
72
+ "3. Check CLAUDE.md for worktree directory preference",
73
+ "4. If none found, ask the user:",
74
+ ' - `.worktrees/` (project-local, hidden)',
75
+ ' - `~/.config/supipowers/worktrees/<project>/` (global location)',
76
+ "",
77
+ "### Step 2: Verify gitignore",
78
+ "",
79
+ "For project-local directories, verify the directory is ignored:",
80
+ "",
81
+ "```bash",
82
+ "git check-ignore -q .worktrees 2>/dev/null || git check-ignore -q worktrees 2>/dev/null",
83
+ "```",
84
+ "",
85
+ "If NOT ignored, add it to .gitignore and commit before proceeding.",
86
+ "",
87
+ "### Step 3: Create worktree",
88
+ "",
89
+ "```bash",
90
+ `git worktree add <dir>/${branchName} -b ${branchName}`,
91
+ `cd <dir>/${branchName}`,
92
+ "```",
93
+ "",
94
+ "### Step 4: Project setup",
95
+ "",
96
+ "Auto-detect and run appropriate setup:",
97
+ "",
98
+ "| File | Command |",
99
+ "|------|---------|",
100
+ "| `package.json` | `npm install` |",
101
+ "| `Cargo.toml` | `cargo build` |",
102
+ "| `requirements.txt` | `pip install -r requirements.txt` |",
103
+ "| `pyproject.toml` | `poetry install` |",
104
+ "| `go.mod` | `go mod download` |",
105
+ "",
106
+ "### Step 5: Verify baseline",
107
+ "",
108
+ "Run the test suite to verify a clean baseline before starting work.",
109
+ "If tests fail, report failures and ask whether to proceed or investigate.",
110
+ "",
111
+ "### Step 6: Report",
112
+ "",
113
+ "```",
114
+ "Worktree ready at <full-path>",
115
+ "Tests passing (<N> tests, 0 failures)",
116
+ `Ready to implement ${branchName}`,
117
+ "```",
118
+ ].join("\n");
119
+ }
package/src/index.ts CHANGED
@@ -5,12 +5,14 @@ import { homedir, tmpdir } from "node:os";
5
5
  import { registerSupiCommand, handleSupi } from "./commands/supi.js";
6
6
  import { registerConfigCommand, handleConfig } from "./commands/config.js";
7
7
  import { registerStatusCommand, handleStatus } from "./commands/status.js";
8
- import { registerPlanCommand } from "./commands/plan.js";
8
+ import { registerPlanCommand, getActiveVisualSessionDir, setActiveVisualSessionDir } from "./commands/plan.js";
9
+ import { getScriptsDir } from "./visual/companion.js";
9
10
  import { registerRunCommand } from "./commands/run.js";
10
11
  import { registerReviewCommand } from "./commands/review.js";
11
12
  import { registerQaCommand } from "./commands/qa.js";
12
13
  import { registerReleaseCommand } from "./commands/release.js";
13
14
  import { registerUpdateCommand, handleUpdate } from "./commands/update.js";
15
+ import { registerFixPrCommand } from "./commands/fix-pr.js";
14
16
 
15
17
  // TUI-only commands — intercepted at the input level to prevent
16
18
  // message submission and "Working..." indicator
@@ -42,6 +44,7 @@ export default function supipowers(pi: ExtensionAPI): void {
42
44
  registerQaCommand(pi);
43
45
  registerReleaseCommand(pi);
44
46
  registerUpdateCommand(pi);
47
+ registerFixPrCommand(pi);
45
48
 
46
49
  // Intercept TUI-only commands at the input level — this runs BEFORE
47
50
  // message submission, so no chat message appears and no "Working..." indicator
@@ -61,13 +64,21 @@ export default function supipowers(pi: ExtensionAPI): void {
61
64
 
62
65
  // Session start
63
66
  pi.on("session_start", async (_event, ctx) => {
67
+ // Clean up any leftover visual companion from a previous session
68
+ const previousVisualDir = getActiveVisualSessionDir();
69
+ if (previousVisualDir) {
70
+ const stopScript = join(getScriptsDir(), "stop-server.sh");
71
+ pi.exec("bash", [stopScript, previousVisualDir], { cwd: getScriptsDir() }).catch(() => {});
72
+ setActiveVisualSessionDir(null);
73
+ }
74
+
64
75
  // Check for updates in the background
65
76
  const currentVersion = getInstalledVersion();
66
77
  if (!currentVersion) return;
67
78
 
68
79
  pi.exec("npm", ["view", "supipowers", "version"], { cwd: tmpdir() })
69
80
  .then((result) => {
70
- if (result.exitCode !== 0) return;
81
+ if (result.code !== 0) return;
71
82
  const latest = result.stdout.trim();
72
83
  if (latest && latest !== currentVersion) {
73
84
  ctx.ui.notify(
@@ -10,13 +10,13 @@ export interface LspServerStatus {
10
10
  * Check which LSP servers are installed by looking for their binaries.
11
11
  */
12
12
  export async function checkInstalledServers(
13
- exec: (cmd: string, args: string[]) => Promise<{ stdout: string; exitCode: number }>
13
+ exec: (cmd: string, args: string[]) => Promise<{ stdout: string; code: number }>
14
14
  ): Promise<LspServerStatus[]> {
15
15
  const results: LspServerStatus[] = [];
16
16
  for (const server of LSP_SERVERS) {
17
17
  try {
18
18
  const result = await exec("which", [server.server]);
19
- results.push({ server, installed: result.exitCode === 0 });
19
+ results.push({ server, installed: result.code === 0 });
20
20
  } catch {
21
21
  results.push({ server, installed: false });
22
22
  }