@mrclrchtr/supi-installer 0.0.0-stage → 9.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 +85 -2
- package/bin/supi-installer.mjs +13 -0
- package/package.json +25 -4
- package/src/catalog.mjs +29 -0
- package/src/cli.mjs +108 -0
- package/src/execution.mjs +115 -0
- package/src/files.mjs +38 -0
- package/src/format.mjs +80 -0
- package/src/inventory.mjs +95 -0
- package/src/matrix.mjs +75 -0
- package/src/paths.mjs +22 -0
- package/src/process-tree.mjs +29 -0
- package/src/process.mjs +72 -0
- package/src/sources.mjs +88 -0
- package/src/types.ts +65 -0
- package/src/ui/controls.mjs +90 -0
- package/src/ui/terminal.mjs +90 -0
- package/src/ui/view.mjs +105 -0
package/README.md
CHANGED
|
@@ -1,3 +1,86 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mrclrchtr/supi-installer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Select direct SuPi package declarations in Project and Global scopes. This is a standalone Node CLI, not a PI extension.
|
|
4
|
+
|
|
5
|
+
## Start
|
|
6
|
+
|
|
7
|
+
1. Install [PI](https://pi.dev) and its required Node runtime. Make sure `pi` is on `PATH`.
|
|
8
|
+
2. Open PI in the target project directory. Use `/trust` to save approval for that directory or an ancestor.
|
|
9
|
+
3. Run the installer in a terminal:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npx @mrclrchtr/supi-installer@latest
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
To select another exact project directory:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npx @mrclrchtr/supi-installer@latest --project /path/to/project
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The installer does not find a Git root, install PI, or grant project trust. Without saved project approval, it exits even for global-only changes. `PI_CODING_AGENT_DIR` selects a different PI agent directory when set.
|
|
22
|
+
|
|
23
|
+
## Installation Matrix
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
Package Project Global
|
|
27
|
+
supi-code-intelligence [ ] [x]
|
|
28
|
+
supi-web [x] [ ]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- A checkbox represents a direct declaration in that scope, not effective capability availability.
|
|
32
|
+
- Project uses `<project>/.pi/settings.json`. Global uses PI's agent settings.
|
|
33
|
+
- The matrix starts from current declarations, not a preset.
|
|
34
|
+
- Clearing Project does not disable a global declaration. Filters and bundled extensions can also affect which capabilities load.
|
|
35
|
+
- Existing pins and resource filters remain unchanged unless their declaration is removed.
|
|
36
|
+
- New declarations use unpinned official npm sources. Use PI for later updates.
|
|
37
|
+
|
|
38
|
+
| Key | Action |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| Up / Down | Select a package |
|
|
41
|
+
| Left / Right | Select Project or Global |
|
|
42
|
+
| Space | Change the focused checkbox |
|
|
43
|
+
| `r` | Replace the focused column with Recommended |
|
|
44
|
+
| `a` | Replace the focused column with All current packages |
|
|
45
|
+
| `c` | Clear editable declarations in the focused column |
|
|
46
|
+
| Enter | Review exact scoped changes |
|
|
47
|
+
| `y` in review | Confirm and apply |
|
|
48
|
+
| `e` in review | Return to editing |
|
|
49
|
+
| Escape / Ctrl-C | Cancel |
|
|
50
|
+
|
|
51
|
+
Use a terminal with at least 60 columns and 12 rows. Rows and the review scroll. Bracketed paste cannot edit or confirm a draft.
|
|
52
|
+
|
|
53
|
+
Recommended contains the eight release-stack packages. All also includes beta packages, DevTools, Consulting, and Prompt Improver. Presets replace the draft for one column only; review can therefore include removals.
|
|
54
|
+
|
|
55
|
+
## Removal and failures
|
|
56
|
+
|
|
57
|
+
- Normal removal uses `pi remove` in the selected scope. It does not purge SuPi configuration, credentials, sessions, reports, or local checkouts.
|
|
58
|
+
- Known obsolete standalone declarations have removal-only cells. They cannot be added to a new scope.
|
|
59
|
+
- Recognized Git/local SuPi installations and legacy root installations block their scope. Remove the conflicting source with PI before using the matrix. The installer does not migrate it.
|
|
60
|
+
- Unknown and unrelated declarations remain unmanaged and unchanged.
|
|
61
|
+
- Settings and trust are checked again before and between commands. An observed external edit stops new commands and requires a new review.
|
|
62
|
+
- Additions run before removals. A failed command does not stop independent changes, including a removal in the other scope of the same row.
|
|
63
|
+
- **A failed destination install can still be followed by source removal, leaving neither declaration.** There is no automatic rollback.
|
|
64
|
+
- Cancellation stops new commands and stops the active child where possible. The final report lists successes, failures, unattempted operations, and observed declarations.
|
|
65
|
+
|
|
66
|
+
Run `/reload` or restart PI after package changes.
|
|
67
|
+
|
|
68
|
+
## Read-only inventory
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
npx @mrclrchtr/supi-installer@latest --dry-run
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
This prints current declarations and scope restrictions without a terminal or package changes. It still requires PI and saved project trust. It is not a scripted target selection or an update/repair command.
|
|
75
|
+
|
|
76
|
+
Use `--help` or `--version` without a trust requirement. Unattended package changes are not supported.
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
node packages/supi-installer/bin/supi-installer.mjs --help
|
|
82
|
+
pnpm vitest run packages/supi-installer/ --reporter=agent
|
|
83
|
+
pnpm exec tsc -b packages/supi-installer/tsconfig.json packages/supi-installer/__tests__/tsconfig.json
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The CLI uses checked JavaScript and has no build step. Tests use temporary settings, trust stores, and a fake PI executable. See the [installer contract](../../docs/conventions/installer.md) for the full design and verification requirements.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runCli } from "../src/cli.mjs";
|
|
3
|
+
|
|
4
|
+
const controller = new AbortController();
|
|
5
|
+
const cancel = () => controller.abort();
|
|
6
|
+
process.on("SIGINT", cancel);
|
|
7
|
+
process.on("SIGTERM", cancel);
|
|
8
|
+
try {
|
|
9
|
+
process.exitCode = await runCli(process.argv.slice(2), { signal: controller.signal });
|
|
10
|
+
} finally {
|
|
11
|
+
process.removeListener("SIGINT", cancel);
|
|
12
|
+
process.removeListener("SIGTERM", cancel);
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrclrchtr/supi-installer",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "9.1.0",
|
|
4
|
+
"description": "Select SuPi packages in project and global PI scopes",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mrclrchtr/supi.git",
|
|
10
|
+
"directory": "packages/supi-installer"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"supi-installer": "bin/supi-installer.mjs"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"src",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=22.19.0"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"cross-spawn": "^7.0.6",
|
|
25
|
+
"string-width": "^8.3.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/catalog.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** @param {string} name @param {string} description @param {string[]} labels @param {boolean} [recommended] @returns {import('./types.ts').CatalogItem} */
|
|
2
|
+
function item(name, description, labels, recommended = false) {
|
|
3
|
+
return { name: `supi-${name}`, description, labels, recommended };
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** The only installable package catalog. Preset membership is explicit. */
|
|
7
|
+
export const catalog = [
|
|
8
|
+
item("agent", "Profile-based foreground Agent Runs", ["Beta", "Agent"]),
|
|
9
|
+
item("ask-user", "Structured choice and text forms", ["Agent"], true),
|
|
10
|
+
item("bash-timeout", "Default shell timeouts", ["Beta"]),
|
|
11
|
+
item("cache", "Prompt-cache history and analysis", ["Beta"]),
|
|
12
|
+
item("claude-md", "Skills for project instructions", ["Beta"]),
|
|
13
|
+
item("code-intelligence", "Code navigation, diagnostics, and refactoring", ["Agent"], true),
|
|
14
|
+
item("consulting", "Bounded external agent consultations", ["Beta", "Opt-in", "Agent"]),
|
|
15
|
+
item("context", "Context pressure and token-use reports", ["Human"], true),
|
|
16
|
+
item("debug", "Debug events and tool feedback", ["DevTool"]),
|
|
17
|
+
item("extras", "Session controls and workflow shortcuts", ["Human"], true),
|
|
18
|
+
item("insights", "Historical session reports", ["Beta", "Human"]),
|
|
19
|
+
item("prompt-improver", "User-approved prompt improvement", ["Human"]),
|
|
20
|
+
item("prompt-suggestions", "Optional model-powered ghost text", ["Human"], true),
|
|
21
|
+
item("review", "Inspection-only reviews of Git targets", ["Beta", "Agent"]),
|
|
22
|
+
item("settings", "Shared project and global settings UI", ["Human"], true),
|
|
23
|
+
item("skills", "Scoped skill controls and shortcuts", ["Human"], true),
|
|
24
|
+
item("web", "Public web pages, search, and library docs", ["Agent"], true),
|
|
25
|
+
...["agents", "rtk", "lsp", "tree-sitter"].map((name) => ({
|
|
26
|
+
...item(name, "Obsolete direct installation; removal only", ["Removal only"]),
|
|
27
|
+
obsolete: true,
|
|
28
|
+
})),
|
|
29
|
+
];
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { executePlan } from "./execution.mjs";
|
|
4
|
+
import { cleanLine, formatInventory, formatReport } from "./format.mjs";
|
|
5
|
+
import { createContext, readInventory } from "./inventory.mjs";
|
|
6
|
+
import { createMatrix } from "./matrix.mjs";
|
|
7
|
+
import { createPiRunner, requirePi } from "./process.mjs";
|
|
8
|
+
import { runTerminal } from "./ui/terminal.mjs";
|
|
9
|
+
|
|
10
|
+
const HELP = `Usage: supi-installer [--project <path>] [--dry-run]
|
|
11
|
+
|
|
12
|
+
Select direct SuPi package declarations in Project and Global scopes.
|
|
13
|
+
|
|
14
|
+
--project <path> Use this exact directory; do not infer a Git root
|
|
15
|
+
--dry-run Print current declarations; do not change packages
|
|
16
|
+
--help, -h Show help
|
|
17
|
+
--version, -v Show the installer version
|
|
18
|
+
|
|
19
|
+
Requires PI on PATH and saved PI trust for the project, even for global work.
|
|
20
|
+
Open PI in that directory and use /trust before starting the installer.
|
|
21
|
+
|
|
22
|
+
Arrows: select row/scope. Space: toggle. r/a/c: Recommended/All/Clear column.
|
|
23
|
+
Enter: review. y: confirm in review. Escape: cancel.
|
|
24
|
+
No unattended package changes are supported.
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
/** CLI boundary shared by the executable and terminal tests. @param {string[]} args @param {{input?: NodeJS.ReadStream, output?: NodeJS.WriteStream, error?: NodeJS.WriteStream, runner?: import('./types.ts').Runner, signal?: AbortSignal}} [options] */
|
|
28
|
+
export async function runCli(args, options = {}) {
|
|
29
|
+
const input = options.input ?? process.stdin;
|
|
30
|
+
const output = options.output ?? process.stdout;
|
|
31
|
+
const errorOutput = options.error ?? process.stderr;
|
|
32
|
+
try {
|
|
33
|
+
const { values } = parseArgs({
|
|
34
|
+
args,
|
|
35
|
+
strict: true,
|
|
36
|
+
allowPositionals: false,
|
|
37
|
+
options: {
|
|
38
|
+
project: { type: "string" },
|
|
39
|
+
"dry-run": { type: "boolean" },
|
|
40
|
+
help: { type: "boolean", short: "h" },
|
|
41
|
+
version: { type: "boolean", short: "v" },
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
if (values.help) {
|
|
45
|
+
output.write(HELP);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
if (values.version) {
|
|
49
|
+
output.write(
|
|
50
|
+
`${JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version}\n`,
|
|
51
|
+
);
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
if (!values["dry-run"] && (!input.isTTY || !output.isTTY))
|
|
55
|
+
throw new Error(
|
|
56
|
+
"An interactive terminal is required. Use --dry-run to print current declarations.",
|
|
57
|
+
);
|
|
58
|
+
const context = createContext({ project: values.project });
|
|
59
|
+
const inventory = readInventory(context);
|
|
60
|
+
const runner =
|
|
61
|
+
options.runner ??
|
|
62
|
+
createPiRunner({ env: { ...process.env, PI_CODING_AGENT_DIR: context.agentDir } });
|
|
63
|
+
await requirePi(runner, context.project, options.signal);
|
|
64
|
+
if (values["dry-run"]) {
|
|
65
|
+
output.write(`${formatInventory(inventory).join("\n")}\n`);
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
return await runInteractive(inventory, { input, output, runner, signal: options.signal });
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (options.signal?.aborted) {
|
|
71
|
+
errorOutput.write("Cancelled. No more commands will start.\n");
|
|
72
|
+
return 130;
|
|
73
|
+
}
|
|
74
|
+
errorOutput.write(
|
|
75
|
+
`Error: ${cleanLine(error instanceof Error ? error.message : String(error))}\n`,
|
|
76
|
+
);
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @param {import('./types.ts').Inventory} inventory @param {{input: NodeJS.ReadStream, output: NodeJS.WriteStream, runner: import('./types.ts').Runner, signal?: AbortSignal}} options */
|
|
82
|
+
async function runInteractive(inventory, options) {
|
|
83
|
+
const { input, output, runner } = options;
|
|
84
|
+
const plan = await runTerminal(createMatrix(inventory), {
|
|
85
|
+
input,
|
|
86
|
+
output,
|
|
87
|
+
signal: options.signal,
|
|
88
|
+
});
|
|
89
|
+
if (plan === null) {
|
|
90
|
+
output.write("Cancelled. No package commands ran.\n");
|
|
91
|
+
return options.signal?.aborted ? 130 : 0;
|
|
92
|
+
}
|
|
93
|
+
if (plan.length === 0) {
|
|
94
|
+
output.write("No changes. No package commands ran.\n");
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
const report = await executePlan(inventory, plan, {
|
|
98
|
+
runner,
|
|
99
|
+
signal: options.signal,
|
|
100
|
+
onProgress: (operation) => {
|
|
101
|
+
output.write(
|
|
102
|
+
`${cleanLine(`${operation.action.toUpperCase()} ${operation.scope}: ${operation.source}`)}\n`,
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
output.write(`${formatReport(report)}\n`);
|
|
107
|
+
return report.cancelled ? 130 : report.ok ? 0 : 1;
|
|
108
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { isRecord } from "./files.mjs";
|
|
2
|
+
import { readInventory } from "./inventory.mjs";
|
|
3
|
+
import { npmName } from "./sources.mjs";
|
|
4
|
+
|
|
5
|
+
/** @typedef {import('./types.ts').Inventory} Inventory */
|
|
6
|
+
/** @typedef {import('./types.ts').Operation} Operation */
|
|
7
|
+
|
|
8
|
+
/** Ignore JSON formatting, but not object values or package order. @param {unknown} value */
|
|
9
|
+
function canonical(value) {
|
|
10
|
+
return JSON.stringify(value, (_key, item) =>
|
|
11
|
+
isRecord(item)
|
|
12
|
+
? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b)))
|
|
13
|
+
: item,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @param {Inventory} inventory @param {Operation} operation */
|
|
18
|
+
function expectedEntries(inventory, operation) {
|
|
19
|
+
const entries = inventory.entries[operation.scope];
|
|
20
|
+
return operation.action === "install"
|
|
21
|
+
? [...entries, operation.source]
|
|
22
|
+
: entries.filter(
|
|
23
|
+
(entry) =>
|
|
24
|
+
npmName(typeof entry === "string" ? entry : entry.source) !==
|
|
25
|
+
`@mrclrchtr/${operation.name}`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A child may change its own declaration, not unrelated state. This detects observed edits, not an atomic filesystem transaction. @param {Inventory} before @param {Inventory} after @param {Operation} operation */
|
|
30
|
+
function isAllowedChange(before, after, operation) {
|
|
31
|
+
const path = before.context.settingsPaths[operation.scope];
|
|
32
|
+
const paths = new Set([...Object.keys(before.files), ...Object.keys(after.files)]);
|
|
33
|
+
for (const file of paths) {
|
|
34
|
+
if (file !== path && before.files[file] !== after.files[file]) return false;
|
|
35
|
+
}
|
|
36
|
+
const otherFields = /** @param {Inventory} inventory */ (inventory) => {
|
|
37
|
+
const data = JSON.parse((inventory.files[path] ?? "{}").replace(/^\uFEFF/, ""));
|
|
38
|
+
delete data.packages;
|
|
39
|
+
return canonical(data);
|
|
40
|
+
};
|
|
41
|
+
if (otherFields(before) !== otherFields(after)) return false;
|
|
42
|
+
const actual = canonical(after.entries[operation.scope]);
|
|
43
|
+
return (
|
|
44
|
+
actual === canonical(before.entries[operation.scope]) ||
|
|
45
|
+
actual === canonical(expectedEntries(before, operation))
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Run one command and check that only its declaration changed. @param {Inventory} before @param {import('./types.ts').OperationResult} result @param {{runner: import('./types.ts').Runner, signal: AbortSignal, onProgress?: (operation: Operation) => void}} options */
|
|
50
|
+
async function attemptOperation(before, result, options) {
|
|
51
|
+
const operation = result.operation;
|
|
52
|
+
options.onProgress?.(operation);
|
|
53
|
+
const args = [
|
|
54
|
+
operation.action,
|
|
55
|
+
operation.source,
|
|
56
|
+
...(operation.scope === "project" ? ["--local"] : []),
|
|
57
|
+
];
|
|
58
|
+
let outcome;
|
|
59
|
+
try {
|
|
60
|
+
outcome = await options.runner(args, { cwd: before.context.project, signal: options.signal });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
outcome = { code: 1, output: error instanceof Error ? error.message : String(error) };
|
|
63
|
+
}
|
|
64
|
+
result.output = outcome.output;
|
|
65
|
+
result.status = outcome.code === 0 ? "success" : "failed";
|
|
66
|
+
const after = readInventory(before.context);
|
|
67
|
+
if (!isAllowedChange(before, after, operation))
|
|
68
|
+
throw new Error(
|
|
69
|
+
"Settings changed outside the selected operation. Remaining commands were not started; review a new plan.",
|
|
70
|
+
);
|
|
71
|
+
if (
|
|
72
|
+
outcome.code === 0 &&
|
|
73
|
+
canonical(after.entries[operation.scope]) !== canonical(expectedEntries(before, operation))
|
|
74
|
+
) {
|
|
75
|
+
result.status = "failed";
|
|
76
|
+
result.output += "\nPI reported success, but the expected declaration change was not found.";
|
|
77
|
+
}
|
|
78
|
+
return after;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Execute a confirmed plan. Failures do not guard scope moves or roll back successful changes. @param {Inventory} initial @param {Operation[]} operations @param {{runner: import('./types.ts').Runner, signal?: AbortSignal, onProgress?: (operation: Operation) => void}} options @returns {Promise<import('./types.ts').ExecutionReport>} */
|
|
82
|
+
export async function executePlan(initial, operations, options) {
|
|
83
|
+
const signal = options.signal ?? new AbortController().signal;
|
|
84
|
+
const results = operations.map((operation) => ({
|
|
85
|
+
operation,
|
|
86
|
+
status: /** @type {'success' | 'failed' | 'unattempted'} */ ("unattempted"),
|
|
87
|
+
output: "",
|
|
88
|
+
}));
|
|
89
|
+
/** @type {import('./types.ts').ExecutionReport} */
|
|
90
|
+
const report = { results, ok: true, cancelled: false };
|
|
91
|
+
let expected = initial;
|
|
92
|
+
try {
|
|
93
|
+
for (const result of results) {
|
|
94
|
+
if (signal.aborted) break;
|
|
95
|
+
const before = readInventory(initial.context);
|
|
96
|
+
if (before.fingerprint !== expected.fingerprint)
|
|
97
|
+
throw new Error(
|
|
98
|
+
"Settings or trust changed after review. Open the installer and review a new plan.",
|
|
99
|
+
);
|
|
100
|
+
expected = await attemptOperation(before, result, { ...options, signal });
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
report.error = error instanceof Error ? error.message : String(error);
|
|
104
|
+
}
|
|
105
|
+
report.cancelled = signal.aborted;
|
|
106
|
+
try {
|
|
107
|
+
report.inventory = readInventory(initial.context);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
delete report.inventory;
|
|
110
|
+
report.error = error instanceof Error ? error.message : String(error);
|
|
111
|
+
}
|
|
112
|
+
report.ok =
|
|
113
|
+
!report.error && !report.cancelled && results.every((result) => result.status === "success");
|
|
114
|
+
return report;
|
|
115
|
+
}
|
package/src/files.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
5
|
+
export function isRecord(value) {
|
|
6
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Read without creating files. Only absence means an empty document. @param {string} path */
|
|
10
|
+
export function readOptional(path) {
|
|
11
|
+
try {
|
|
12
|
+
return readFileSync(path, "utf8");
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
|
|
15
|
+
throw new Error(`Cannot read ${path}`, { cause: error });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @param {string} path @param {Record<string, string | null>} files */
|
|
20
|
+
export function readObject(path, files) {
|
|
21
|
+
const text = readOptional(path);
|
|
22
|
+
files[path] = text;
|
|
23
|
+
if (text === null) return {};
|
|
24
|
+
try {
|
|
25
|
+
const value = JSON.parse(text.replace(/^\uFEFF/, ""));
|
|
26
|
+
if (!isRecord(value)) throw new Error("Expected a JSON object");
|
|
27
|
+
return value;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw new Error(`Invalid JSON object in ${path}`, { cause: error });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Include raw text so external edits cannot silently reuse an old plan. @param {Record<string, string | null>} files */
|
|
34
|
+
export function fingerprint(files) {
|
|
35
|
+
return createHash("sha256")
|
|
36
|
+
.update(JSON.stringify(Object.entries(files).sort(([a], [b]) => a.localeCompare(b))))
|
|
37
|
+
.digest("hex");
|
|
38
|
+
}
|
package/src/format.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { sourceOf } from "./sources.mjs";
|
|
2
|
+
|
|
3
|
+
/** Keep settings and child output from sending terminal control sequences. @param {string} text */
|
|
4
|
+
export function cleanLine(text) {
|
|
5
|
+
return text.replace(/[\p{Cc}\p{Cf}]/gu, " ");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Read-only declarations, not a claim about loaded resources. @param {import('./types.ts').Inventory} inventory */
|
|
9
|
+
export function formatInventory(inventory) {
|
|
10
|
+
const lines = [
|
|
11
|
+
"Direct package declarations",
|
|
12
|
+
`Project: ${inventory.context.project}`,
|
|
13
|
+
`Global: ${inventory.context.settingsPaths.global}`,
|
|
14
|
+
];
|
|
15
|
+
if (inventory.context.project === inventory.context.home)
|
|
16
|
+
lines.push("Warning: Project is your home directory.");
|
|
17
|
+
for (const scope of /** @type {const} */ (["project", "global"]))
|
|
18
|
+
lines.push(...formatScope(inventory, scope));
|
|
19
|
+
return lines.map(cleanLine);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @param {import('./types.ts').Inventory} inventory @param {import('./types.ts').Scope} scope */
|
|
23
|
+
function formatScope(inventory, scope) {
|
|
24
|
+
const lines = ["", `${scope.toUpperCase()} (${inventory.context.settingsPaths[scope]})`];
|
|
25
|
+
if (inventory.entries[scope].length === 0) lines.push(" No package declarations.");
|
|
26
|
+
for (const entry of inventory.entries[scope])
|
|
27
|
+
lines.push(
|
|
28
|
+
` ${sourceOf(entry)}${typeof entry === "string" ? "" : ` (filtered/object entry: ${JSON.stringify(entry)})`}`,
|
|
29
|
+
);
|
|
30
|
+
for (const source of inventory.blocked[scope]) {
|
|
31
|
+
lines.push(
|
|
32
|
+
` BLOCKED: ${source}`,
|
|
33
|
+
` Remove this source with PI before changing this scope: pi remove <source>${scope === "project" ? " --local" : ""}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
for (const source of inventory.unmanaged[scope]) lines.push(` Unmanaged; retained: ${source}`);
|
|
37
|
+
return lines;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Show exact scoped sources and the accepted independent-failure policy. @param {import('./matrix.mjs').Matrix} matrix @param {import('./types.ts').Operation[]} operations */
|
|
41
|
+
export function formatReview(matrix, operations) {
|
|
42
|
+
return [
|
|
43
|
+
...formatInventory(matrix.inventory),
|
|
44
|
+
"",
|
|
45
|
+
"Planned changes:",
|
|
46
|
+
...operations.map(
|
|
47
|
+
(operation) => `${operation.action.toUpperCase()} ${operation.scope}: ${operation.source}`,
|
|
48
|
+
),
|
|
49
|
+
...(operations.length === 0 ? ["No changes. No package commands will run."] : []),
|
|
50
|
+
"",
|
|
51
|
+
"Clearing Project can expose a global declaration.",
|
|
52
|
+
"Removal retains user data. Bundled behavior can remain available.",
|
|
53
|
+
"Commands are independent. A failed destination install does NOT prevent source removal.",
|
|
54
|
+
"A failed scope move can leave neither declaration. There is no automatic rollback.",
|
|
55
|
+
].map(cleanLine);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {import('./types.ts').ExecutionReport} report */
|
|
59
|
+
export function formatReport(report) {
|
|
60
|
+
const lines = [report.ok ? "Changes complete." : "Changes did not complete successfully."];
|
|
61
|
+
for (const { operation, status, output } of report.results) {
|
|
62
|
+
lines.push(
|
|
63
|
+
`${status.toUpperCase()}: ${operation.action} ${operation.scope}: ${operation.source}`,
|
|
64
|
+
);
|
|
65
|
+
if (output.trim())
|
|
66
|
+
lines.push(
|
|
67
|
+
...output
|
|
68
|
+
.trim()
|
|
69
|
+
.split(/\r?\n/)
|
|
70
|
+
.map((line) => ` ${line}`),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (report.cancelled) lines.push("Cancelled. No more commands will start.");
|
|
74
|
+
if (report.error) lines.push(`Error: ${report.error}`);
|
|
75
|
+
if (report.inventory) lines.push("", ...formatInventory(report.inventory));
|
|
76
|
+
else lines.push("Final declarations could not be read. Inspect them with PI before retrying.");
|
|
77
|
+
if (report.results.some((result) => result.status !== "unattempted"))
|
|
78
|
+
lines.push("", "Run /reload or restart PI after package changes.");
|
|
79
|
+
return lines.map(cleanLine).join("\n");
|
|
80
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fingerprint, isRecord, readObject } from "./files.mjs";
|
|
5
|
+
import { canonicalPath, expandPath } from "./paths.mjs";
|
|
6
|
+
import { classifySources } from "./sources.mjs";
|
|
7
|
+
|
|
8
|
+
/** @typedef {import('./types.ts').Context} Context */
|
|
9
|
+
/** @typedef {import('./types.ts').PackageEntry} PackageEntry */
|
|
10
|
+
|
|
11
|
+
/** Resolve a project directory without selecting a Git root. @param {{project?: string, cwd?: string, agentDir?: string, home?: string}} [options] @returns {Context} */
|
|
12
|
+
export function createContext(options = {}) {
|
|
13
|
+
const home = options.home ?? homedir();
|
|
14
|
+
const cwd = options.cwd ?? process.cwd();
|
|
15
|
+
const project = realpathSync(resolve(cwd, expandPath(options.project ?? cwd, home)));
|
|
16
|
+
if (!statSync(project).isDirectory()) throw new Error(`Not a project directory: ${project}`);
|
|
17
|
+
const agentDir = resolve(
|
|
18
|
+
cwd,
|
|
19
|
+
expandPath(
|
|
20
|
+
options.agentDir ?? (process.env.PI_CODING_AGENT_DIR || join(home, ".pi/agent")),
|
|
21
|
+
home,
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
const settingsPaths = {
|
|
25
|
+
project: join(project, ".pi/settings.json"),
|
|
26
|
+
global: join(agentDir, "settings.json"),
|
|
27
|
+
};
|
|
28
|
+
if (canonicalPath(settingsPaths.project) === canonicalPath(settingsPaths.global))
|
|
29
|
+
throw new Error(
|
|
30
|
+
"Global and project settings resolve to the same file. Select separate scopes before using the installer.",
|
|
31
|
+
);
|
|
32
|
+
return { project, agentDir, home, settingsPaths };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {Context} context @param {Record<string, string | null>} files */
|
|
36
|
+
function requireTrust(context, files) {
|
|
37
|
+
const path = join(context.agentDir, "trust.json");
|
|
38
|
+
const data = readObject(path, files);
|
|
39
|
+
if (Object.values(data).some((value) => value !== true && value !== false && value !== null))
|
|
40
|
+
throw new Error(`Invalid decision in ${path}`);
|
|
41
|
+
let current = context.project;
|
|
42
|
+
while (true) {
|
|
43
|
+
const decision = Object.hasOwn(data, current) ? data[current] : null;
|
|
44
|
+
if (decision === true) return;
|
|
45
|
+
if (decision === false || dirname(current) === current) break;
|
|
46
|
+
current = dirname(current);
|
|
47
|
+
}
|
|
48
|
+
throw new Error(
|
|
49
|
+
`A saved PI trust approval is required for ${context.project}. Open PI in that directory, use /trust, then rerun the installer.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Validate only the package fields that this CLI must interpret. @param {unknown} entry @param {string} path @returns {PackageEntry} */
|
|
54
|
+
function validateEntry(entry, path) {
|
|
55
|
+
if (typeof entry === "string" && entry.trim()) return entry;
|
|
56
|
+
if (!isRecord(entry) || typeof entry.source !== "string" || !entry.source.trim())
|
|
57
|
+
throw new Error(`Invalid package source in ${path}`);
|
|
58
|
+
for (const key of ["extensions", "skills", "prompts", "themes"]) {
|
|
59
|
+
if (
|
|
60
|
+
entry[key] !== undefined &&
|
|
61
|
+
(!Array.isArray(entry[key]) || !entry[key].every((value) => typeof value === "string"))
|
|
62
|
+
)
|
|
63
|
+
throw new Error(`Invalid ${key} in ${path}`);
|
|
64
|
+
}
|
|
65
|
+
if (entry.autoload !== undefined && typeof entry.autoload !== "boolean")
|
|
66
|
+
throw new Error(`Invalid autoload in ${path}`);
|
|
67
|
+
return /** @type {PackageEntry} */ (entry);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Observe both scopes without starting PI or changing settings or trust. @param {Context} context @returns {import('./types.ts').Inventory} */
|
|
71
|
+
export function readInventory(context) {
|
|
72
|
+
/** @type {Record<string, string | null>} */
|
|
73
|
+
const files = {};
|
|
74
|
+
requireTrust(context, files);
|
|
75
|
+
/** @type {Record<import('./types.ts').Scope, PackageEntry[]>} */
|
|
76
|
+
const entries = { project: [], global: [] };
|
|
77
|
+
for (const scope of /** @type {const} */ (["project", "global"])) {
|
|
78
|
+
const path = context.settingsPaths[scope];
|
|
79
|
+
const data = readObject(path, files);
|
|
80
|
+
if (data.packages !== undefined && !Array.isArray(data.packages))
|
|
81
|
+
throw new Error(`Invalid packages in ${path}`);
|
|
82
|
+
entries[scope] = (data.packages ?? []).map((entry) => validateEntry(entry, path));
|
|
83
|
+
}
|
|
84
|
+
const inventory = {
|
|
85
|
+
context,
|
|
86
|
+
entries,
|
|
87
|
+
blocked: { project: [], global: [] },
|
|
88
|
+
unmanaged: { project: [], global: [] },
|
|
89
|
+
files,
|
|
90
|
+
fingerprint: "",
|
|
91
|
+
};
|
|
92
|
+
classifySources(inventory);
|
|
93
|
+
inventory.fingerprint = fingerprint(files);
|
|
94
|
+
return inventory;
|
|
95
|
+
}
|
package/src/matrix.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { catalog } from "./catalog.mjs";
|
|
2
|
+
import { npmName, sourceOf } from "./sources.mjs";
|
|
3
|
+
|
|
4
|
+
/** @typedef {{ inventory: import('./types.ts').Inventory, rows: import('./types.ts').MatrixRow[] }} Matrix */
|
|
5
|
+
/** @typedef {import('./types.ts').Scope} Scope */
|
|
6
|
+
|
|
7
|
+
/** Initialize from declarations, never from a preset. @param {import('./types.ts').Inventory} inventory @returns {Matrix} */
|
|
8
|
+
export function createMatrix(inventory) {
|
|
9
|
+
const rows = catalog.map((item) => {
|
|
10
|
+
const find = /** @param {Scope} scope */ (scope) =>
|
|
11
|
+
inventory.entries[scope].find(
|
|
12
|
+
(entry) => npmName(sourceOf(entry)) === `@mrclrchtr/${item.name}`,
|
|
13
|
+
);
|
|
14
|
+
const initial = { project: find("project"), global: find("global") };
|
|
15
|
+
return {
|
|
16
|
+
item,
|
|
17
|
+
initial,
|
|
18
|
+
selected: { project: Boolean(initial.project), global: Boolean(initial.global) },
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
return {
|
|
22
|
+
inventory,
|
|
23
|
+
rows: rows.filter((row) => !row.item.obsolete || row.initial.project || row.initial.global),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @param {Matrix} matrix @param {number} index @param {Scope} scope */
|
|
28
|
+
export function canEdit(matrix, index, scope) {
|
|
29
|
+
const row = matrix.rows[index];
|
|
30
|
+
return Boolean(
|
|
31
|
+
row &&
|
|
32
|
+
matrix.inventory.blocked[scope].length === 0 &&
|
|
33
|
+
(!row.item.obsolete || row.initial[scope]),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Removal-only rows permit undo but not new declarations. @param {Matrix} matrix @param {number} index @param {Scope} scope */
|
|
38
|
+
export function toggleCell(matrix, index, scope) {
|
|
39
|
+
if (!canEdit(matrix, index, scope)) return false;
|
|
40
|
+
const row = matrix.rows[index];
|
|
41
|
+
row.selected[scope] = !row.selected[scope];
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Replace only the focused scope's draft. @param {Matrix} matrix @param {Scope} scope @param {'recommended' | 'all' | 'clear'} preset */
|
|
46
|
+
export function applyPreset(matrix, scope, preset) {
|
|
47
|
+
for (const [index, row] of matrix.rows.entries()) {
|
|
48
|
+
if (canEdit(matrix, index, scope))
|
|
49
|
+
row.selected[scope] =
|
|
50
|
+
!row.item.obsolete &&
|
|
51
|
+
(preset === "all" || (preset === "recommended" && row.item.recommended));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Create a plan with additions before removals, without scope-move guards. @param {Matrix} matrix @returns {import('./types.ts').Operation[]} */
|
|
56
|
+
export function getPlan(matrix) {
|
|
57
|
+
/** @type {import('./types.ts').Operation[]} */
|
|
58
|
+
const operations = [];
|
|
59
|
+
for (const [index, row] of matrix.rows.entries()) {
|
|
60
|
+
for (const scope of /** @type {const} */ (["project", "global"])) {
|
|
61
|
+
const entry = row.initial[scope];
|
|
62
|
+
if (!canEdit(matrix, index, scope) || row.selected[scope] === Boolean(entry)) continue;
|
|
63
|
+
operations.push({
|
|
64
|
+
action: row.selected[scope] ? "install" : "remove",
|
|
65
|
+
scope,
|
|
66
|
+
name: row.item.name,
|
|
67
|
+
source: entry ? sourceOf(entry) : `npm:@mrclrchtr/${row.item.name}`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return [
|
|
72
|
+
...operations.filter((op) => op.action === "install"),
|
|
73
|
+
...operations.filter((op) => op.action === "remove"),
|
|
74
|
+
];
|
|
75
|
+
}
|
package/src/paths.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/** Resolve existing file aliases; a missing file keeps its absolute path. @param {string} path */
|
|
7
|
+
export function canonicalPath(path) {
|
|
8
|
+
try {
|
|
9
|
+
return realpathSync(path);
|
|
10
|
+
} catch (error) {
|
|
11
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return path;
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Match PI's tilde and file URL path forms. @param {string} path @param {string} [home] */
|
|
17
|
+
export function expandPath(path, home = homedir()) {
|
|
18
|
+
if (path === "~") return home;
|
|
19
|
+
if (path.startsWith("~/") || (process.platform === "win32" && path.startsWith("~\\")))
|
|
20
|
+
return join(home, path.slice(2));
|
|
21
|
+
return path.startsWith("file://") ? fileURLToPath(path) : path;
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Stop the owned command group on Unix, or the npm shim and its descendants on Windows. @param {number | undefined} pid @param {boolean} force */
|
|
5
|
+
export async function terminateTree(pid, force) {
|
|
6
|
+
if (pid === undefined) return;
|
|
7
|
+
if (process.platform === "win32") {
|
|
8
|
+
await new Promise((resolve, reject) => {
|
|
9
|
+
const command = join(process.env.SystemRoot || "C:\\Windows", "System32", "taskkill.exe");
|
|
10
|
+
const child = spawn(command, ["/PID", String(pid), "/T", "/F"], {
|
|
11
|
+
windowsHide: true,
|
|
12
|
+
shell: false,
|
|
13
|
+
stdio: "ignore",
|
|
14
|
+
});
|
|
15
|
+
child.once("error", reject);
|
|
16
|
+
child.once("close", (code) => {
|
|
17
|
+
// A second stop can find that the root process has already exited.
|
|
18
|
+
if (code === 0 || code === 128) resolve(undefined);
|
|
19
|
+
else reject(new Error(`Could not stop the PI process tree (taskkill status ${code}).`));
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
process.kill(-pid, force ? "SIGKILL" : "SIGTERM");
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/process.mjs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import spawn from "cross-spawn";
|
|
2
|
+
import { terminateTree } from "./process-tree.mjs";
|
|
3
|
+
|
|
4
|
+
/** Portable PI command boundary. cross-spawn handles Windows npm shims and escaping. @param {{command?: string, prefix?: string[], env?: NodeJS.ProcessEnv}} [options] @returns {import('./types.ts').Runner} */
|
|
5
|
+
export function createPiRunner(options = {}) {
|
|
6
|
+
return (args, { cwd, signal }) =>
|
|
7
|
+
new Promise((resolve) => {
|
|
8
|
+
if (signal.aborted) {
|
|
9
|
+
resolve({ code: 130, output: "Cancelled" });
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const child = spawn(options.command ?? "pi", [...(options.prefix ?? []), ...args], {
|
|
13
|
+
cwd,
|
|
14
|
+
env: options.env ?? process.env,
|
|
15
|
+
detached: process.platform !== "win32",
|
|
16
|
+
shell: false,
|
|
17
|
+
windowsHide: true,
|
|
18
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
19
|
+
});
|
|
20
|
+
let output = "";
|
|
21
|
+
const append = /** @param {Buffer} chunk */ (chunk) => {
|
|
22
|
+
output = `${output}${chunk.toString()}`.slice(-65_536);
|
|
23
|
+
};
|
|
24
|
+
child.stdout?.on("data", append);
|
|
25
|
+
child.stderr?.on("data", append);
|
|
26
|
+
let failed = false;
|
|
27
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
28
|
+
let killTimer;
|
|
29
|
+
let termination = Promise.resolve();
|
|
30
|
+
const stopError = /** @param {unknown} error */ (error) => {
|
|
31
|
+
output += `\n${error instanceof Error ? error.message : String(error)}`;
|
|
32
|
+
};
|
|
33
|
+
const stop = () => {
|
|
34
|
+
termination = terminateTree(child.pid, false).catch(stopError);
|
|
35
|
+
killTimer = setTimeout(() => {
|
|
36
|
+
void terminateTree(child.pid, true).catch(stopError);
|
|
37
|
+
}, 2_000);
|
|
38
|
+
killTimer.unref();
|
|
39
|
+
};
|
|
40
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
41
|
+
child.once("error", (error) => {
|
|
42
|
+
failed = true;
|
|
43
|
+
output += `\n${error.message}`;
|
|
44
|
+
});
|
|
45
|
+
/** @param {number | null} code */
|
|
46
|
+
async function finish(code) {
|
|
47
|
+
signal.removeEventListener("abort", stop);
|
|
48
|
+
clearTimeout(killTimer);
|
|
49
|
+
if (signal.aborted) {
|
|
50
|
+
await termination;
|
|
51
|
+
await terminateTree(child.pid, true).catch(stopError);
|
|
52
|
+
}
|
|
53
|
+
resolve({ code: signal.aborted ? 130 : failed ? 1 : (code ?? 1), output });
|
|
54
|
+
}
|
|
55
|
+
child.once("close", (code) => {
|
|
56
|
+
void finish(code);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Check the executable without starting an agent session. The caller can cancel; a stalled version check also has a time limit. @param {import('./types.ts').Runner} runner @param {string} cwd @param {AbortSignal} [signal] */
|
|
62
|
+
export async function requirePi(runner, cwd, signal = new AbortController().signal) {
|
|
63
|
+
if (signal.aborted) throw new Error("Cancelled while checking PI.");
|
|
64
|
+
const timeout = AbortSignal.timeout(10_000);
|
|
65
|
+
const result = await runner(["--version"], { cwd, signal: AbortSignal.any([signal, timeout]) });
|
|
66
|
+
if (signal.aborted) throw new Error("Cancelled while checking PI.");
|
|
67
|
+
if (timeout.aborted) throw new Error("PI did not respond to --version within 10 seconds.");
|
|
68
|
+
if (result.code !== 0)
|
|
69
|
+
throw new Error(
|
|
70
|
+
"PI is not available on PATH. Install PI from https://pi.dev, then rerun the installer.",
|
|
71
|
+
);
|
|
72
|
+
}
|
package/src/sources.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { catalog } from "./catalog.mjs";
|
|
4
|
+
import { readObject, readOptional } from "./files.mjs";
|
|
5
|
+
import { expandPath } from "./paths.mjs";
|
|
6
|
+
|
|
7
|
+
/** @param {import('./types.ts').PackageEntry} entry */
|
|
8
|
+
export function sourceOf(entry) {
|
|
9
|
+
return typeof entry === "string" ? entry : entry.source;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Pins do not change the npm package identity. @param {string} source */
|
|
13
|
+
export function npmName(source) {
|
|
14
|
+
if (!source.startsWith("npm:")) return undefined;
|
|
15
|
+
return /^(@[^/\s]+\/[^@\s]+|[^@\s]+)(?:@.+)?$/.exec(source.slice(4).trim())?.[1];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** @param {string} source */
|
|
19
|
+
function isRootGit(source) {
|
|
20
|
+
if (!/^(git:|github:|https?:|ssh:|git@)/.test(source)) return false;
|
|
21
|
+
const normalized = source
|
|
22
|
+
.replace(/^(git:(?!\/\/)|github:)/, "")
|
|
23
|
+
.replace(/^[a-z]+:\/\//, "")
|
|
24
|
+
.replace(/^git@/, "")
|
|
25
|
+
.replace(/^github\.com:/, "github.com/");
|
|
26
|
+
return /^(?:github\.com\/)?mrclrchtr\/supi(?:\.git)?\/?(?:[@#][^\s]+)?$/i.test(normalized);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Inspect local manifests as data, never as executable modules. @param {string} source @param {string} base @param {import('./types.ts').Inventory} inventory */
|
|
30
|
+
function localPackageName(source, base, inventory) {
|
|
31
|
+
let path = resolve(base, expandPath(source, inventory.context.home));
|
|
32
|
+
try {
|
|
33
|
+
if (!statSync(path).isDirectory()) path = dirname(path);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
while (true) {
|
|
39
|
+
const manifest = join(path, "package.json");
|
|
40
|
+
const text = readOptional(manifest);
|
|
41
|
+
inventory.files[manifest] = text;
|
|
42
|
+
if (text !== null) return readObject(manifest, inventory.files).name;
|
|
43
|
+
if (dirname(path) === path) return undefined;
|
|
44
|
+
path = dirname(path);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const managedNames = new Set(catalog.map((item) => `@mrclrchtr/${item.name}`));
|
|
49
|
+
|
|
50
|
+
/** @param {unknown} name */
|
|
51
|
+
function isSuPiName(name) {
|
|
52
|
+
return (
|
|
53
|
+
typeof name === "string" &&
|
|
54
|
+
(managedNames.has(name) || name === "@mrclrchtr/supi" || name === "@mrclrchtr/supi-workspace")
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {string} source @param {import('./types.ts').Scope} scope @param {import('./types.ts').Inventory} inventory */
|
|
59
|
+
function isBlocked(source, scope, inventory) {
|
|
60
|
+
const name = npmName(source);
|
|
61
|
+
if (name === "@mrclrchtr/supi" || name === "@mrclrchtr/supi-workspace" || isRootGit(source))
|
|
62
|
+
return true;
|
|
63
|
+
if (/^(npm:|git:|github:|https?:|ssh:|git@|builtin:)/.test(source)) return false;
|
|
64
|
+
return isSuPiName(
|
|
65
|
+
localPackageName(source, dirname(inventory.context.settingsPaths[scope]), inventory),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Classify sources without changing declarations. @param {import('./types.ts').Inventory} inventory */
|
|
70
|
+
export function classifySources(inventory) {
|
|
71
|
+
for (const scope of /** @type {const} */ (["project", "global"])) {
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
for (const entry of inventory.entries[scope]) {
|
|
74
|
+
const source = sourceOf(entry);
|
|
75
|
+
const name = npmName(source);
|
|
76
|
+
if (name && managedNames.has(name)) {
|
|
77
|
+
if (seen.has(name))
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Duplicate managed package ${name} in ${inventory.context.settingsPaths[scope]}`,
|
|
80
|
+
);
|
|
81
|
+
seen.add(name);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (isBlocked(source, scope, inventory)) inventory.blocked[scope].push(source);
|
|
85
|
+
else inventory.unmanaged[scope].push(source);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Installer scope names match the two matrix columns. */
|
|
2
|
+
export type Scope = "project" | "global";
|
|
3
|
+
/** Preserve object entries, including fields not owned by the installer. */
|
|
4
|
+
export type PackageEntry = string | { source: string; [key: string]: unknown };
|
|
5
|
+
/** Paths used by inventory reads and PI child commands. */
|
|
6
|
+
export interface Context {
|
|
7
|
+
project: string;
|
|
8
|
+
agentDir: string;
|
|
9
|
+
home: string;
|
|
10
|
+
settingsPaths: Record<Scope, string>;
|
|
11
|
+
}
|
|
12
|
+
/** One read-only observation of declarations and source restrictions. */
|
|
13
|
+
export interface Inventory {
|
|
14
|
+
context: Context;
|
|
15
|
+
entries: Record<Scope, PackageEntry[]>;
|
|
16
|
+
blocked: Record<Scope, string[]>;
|
|
17
|
+
unmanaged: Record<Scope, string[]>;
|
|
18
|
+
files: Record<string, string | null>;
|
|
19
|
+
fingerprint: string;
|
|
20
|
+
}
|
|
21
|
+
/** Catalog metadata is shipped with the CLI. */
|
|
22
|
+
export interface CatalogItem {
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
labels: string[];
|
|
26
|
+
recommended: boolean;
|
|
27
|
+
obsolete?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/** A row retains the source entry separately from the draft selections. */
|
|
30
|
+
export interface MatrixRow {
|
|
31
|
+
item: CatalogItem;
|
|
32
|
+
initial: Record<Scope, PackageEntry | undefined>;
|
|
33
|
+
selected: Record<Scope, boolean>;
|
|
34
|
+
}
|
|
35
|
+
/** A reviewed change to one direct declaration. */
|
|
36
|
+
export interface Operation {
|
|
37
|
+
action: "install" | "remove";
|
|
38
|
+
scope: Scope;
|
|
39
|
+
name: string;
|
|
40
|
+
source: string;
|
|
41
|
+
}
|
|
42
|
+
/** Child outcome includes errors that can occur before a process starts. */
|
|
43
|
+
export interface CommandResult {
|
|
44
|
+
code: number;
|
|
45
|
+
output: string;
|
|
46
|
+
}
|
|
47
|
+
/** The process boundary can use a fake PI executable in tests. */
|
|
48
|
+
export type Runner = (
|
|
49
|
+
args: string[],
|
|
50
|
+
options: { cwd: string; signal: AbortSignal },
|
|
51
|
+
) => Promise<CommandResult>;
|
|
52
|
+
/** Report all planned operations, including those not attempted. */
|
|
53
|
+
export interface OperationResult {
|
|
54
|
+
operation: Operation;
|
|
55
|
+
status: "success" | "failed" | "unattempted";
|
|
56
|
+
output: string;
|
|
57
|
+
}
|
|
58
|
+
/** Execution never promises rollback or a complete filesystem inventory. */
|
|
59
|
+
export interface ExecutionReport {
|
|
60
|
+
results: OperationResult[];
|
|
61
|
+
inventory?: Inventory;
|
|
62
|
+
error?: string;
|
|
63
|
+
cancelled: boolean;
|
|
64
|
+
ok: boolean;
|
|
65
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { applyPreset, toggleCell } from "../matrix.mjs";
|
|
2
|
+
|
|
3
|
+
/** Ignore bracketed paste; only key input can edit or confirm a draft. @param {import('./view.mjs').View} view @param {import('node:readline').Key} key @param {{pasting: boolean, width: number, height: number}} input */
|
|
4
|
+
export function handleKey(view, key, input) {
|
|
5
|
+
if (key.sequence === "\u001b[200~") {
|
|
6
|
+
input.pasting = true;
|
|
7
|
+
return "ignore";
|
|
8
|
+
}
|
|
9
|
+
if (key.sequence === "\u001b[201~") {
|
|
10
|
+
input.pasting = false;
|
|
11
|
+
return "ignore";
|
|
12
|
+
}
|
|
13
|
+
if (input.pasting) return "ignore";
|
|
14
|
+
if (key.name === "escape" || (key.ctrl && key.name === "c")) return "cancel";
|
|
15
|
+
if (input.width < 60 || input.height < 12) return "ignore";
|
|
16
|
+
if (view.review) return editReview(view, key.name) ? "apply" : "draw";
|
|
17
|
+
editMatrix(view, key.name);
|
|
18
|
+
return "draw";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Set the alternate screen, cursor, and bracketed-paste modes. @param {NodeJS.WriteStream} output @param {boolean} enabled */
|
|
22
|
+
export function screenMode(output, enabled) {
|
|
23
|
+
const prefix = "\u001b[?";
|
|
24
|
+
output.write(`${prefix}1049${enabled ? "h" : "l"}`);
|
|
25
|
+
output.write(`${prefix}25${enabled ? "l" : "h"}`);
|
|
26
|
+
output.write(`${prefix}2004${enabled ? "h" : "l"}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @param {{input: NodeJS.ReadStream, output: NodeJS.WriteStream, wasRaw: boolean, wasFlowing: boolean | null}} state */
|
|
30
|
+
export function restoreTerminal({ input, output, wasRaw, wasFlowing }) {
|
|
31
|
+
input.setRawMode(wasRaw);
|
|
32
|
+
if (wasFlowing !== true) input.pause();
|
|
33
|
+
if (!output.destroyed) screenMode(output, false);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @param {import('./view.mjs').View} view @param {string | undefined} name */
|
|
37
|
+
export function editMatrix(view, name) {
|
|
38
|
+
switch (name) {
|
|
39
|
+
case "up":
|
|
40
|
+
view.index = Math.max(0, view.index - 1);
|
|
41
|
+
break;
|
|
42
|
+
case "down":
|
|
43
|
+
view.index = Math.min(view.matrix.rows.length - 1, view.index + 1);
|
|
44
|
+
break;
|
|
45
|
+
case "left":
|
|
46
|
+
view.scope = "project";
|
|
47
|
+
break;
|
|
48
|
+
case "right":
|
|
49
|
+
view.scope = "global";
|
|
50
|
+
break;
|
|
51
|
+
case "space":
|
|
52
|
+
toggleCell(view.matrix, view.index, view.scope);
|
|
53
|
+
break;
|
|
54
|
+
case "r":
|
|
55
|
+
applyPreset(view.matrix, view.scope, "recommended");
|
|
56
|
+
break;
|
|
57
|
+
case "a":
|
|
58
|
+
applyPreset(view.matrix, view.scope, "all");
|
|
59
|
+
break;
|
|
60
|
+
case "c":
|
|
61
|
+
applyPreset(view.matrix, view.scope, "clear");
|
|
62
|
+
break;
|
|
63
|
+
case "return":
|
|
64
|
+
view.review = true;
|
|
65
|
+
view.offset = 0;
|
|
66
|
+
break;
|
|
67
|
+
default:
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @param {import('./view.mjs').View} view @param {string | undefined} name */
|
|
73
|
+
export function editReview(view, name) {
|
|
74
|
+
switch (name) {
|
|
75
|
+
case "y":
|
|
76
|
+
return true;
|
|
77
|
+
case "e":
|
|
78
|
+
view.review = false;
|
|
79
|
+
break;
|
|
80
|
+
case "up":
|
|
81
|
+
view.offset--;
|
|
82
|
+
break;
|
|
83
|
+
case "down":
|
|
84
|
+
view.offset++;
|
|
85
|
+
break;
|
|
86
|
+
default:
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { emitKeypressEvents } from "node:readline";
|
|
2
|
+
import { getPlan } from "../matrix.mjs";
|
|
3
|
+
import { handleKey, restoreTerminal, screenMode } from "./controls.mjs";
|
|
4
|
+
import { renderView } from "./view.mjs";
|
|
5
|
+
|
|
6
|
+
/** Own raw mode and screen state only while editing. Confirmation returns a plan; it never executes commands. @param {import('../matrix.mjs').Matrix} matrix @param {{input: NodeJS.ReadStream, output: NodeJS.WriteStream, signal?: AbortSignal}} options @returns {Promise<import('../types.ts').Operation[] | null>} */
|
|
7
|
+
export function runTerminal(matrix, { input, output, signal }) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
/** @type {import('./view.mjs').View} */
|
|
10
|
+
const view = { matrix, index: 0, scope: "project", review: false, offset: 0 };
|
|
11
|
+
const wasRaw = Boolean(input.isRaw);
|
|
12
|
+
const wasFlowing = input.readableFlowing;
|
|
13
|
+
let settled = false;
|
|
14
|
+
const keyState = { pasting: false, width: 80, height: 24 };
|
|
15
|
+
const size = () => ({ width: output.columns ?? 80, height: output.rows ?? 24 });
|
|
16
|
+
const draw = () => {
|
|
17
|
+
output.write(`\u001b[H\u001b[2J${renderView(view, size())}`);
|
|
18
|
+
};
|
|
19
|
+
/** @param {import('../types.ts').Operation[] | null} value @param {Error} [error] */
|
|
20
|
+
function finish(value, error) {
|
|
21
|
+
if (settled) return;
|
|
22
|
+
settled = true;
|
|
23
|
+
input.removeListener("keypress", onKey);
|
|
24
|
+
input.removeListener("end", cancel);
|
|
25
|
+
input.removeListener("close", cancel);
|
|
26
|
+
input.removeListener("error", fail);
|
|
27
|
+
output.removeListener("resize", draw);
|
|
28
|
+
output.removeListener("error", fail);
|
|
29
|
+
signal?.removeEventListener("abort", cancel);
|
|
30
|
+
try {
|
|
31
|
+
restoreTerminal({ input, output, wasRaw, wasFlowing });
|
|
32
|
+
} catch (cleanupError) {
|
|
33
|
+
reject(cleanupError);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (error) reject(error);
|
|
37
|
+
else resolve(value);
|
|
38
|
+
}
|
|
39
|
+
function cancel() {
|
|
40
|
+
finish(null);
|
|
41
|
+
}
|
|
42
|
+
/** @param {Error} error */
|
|
43
|
+
function fail(error) {
|
|
44
|
+
finish(null, error);
|
|
45
|
+
}
|
|
46
|
+
/** @param {string} _text @param {import('node:readline').Key} key */
|
|
47
|
+
function onKey(_text, key) {
|
|
48
|
+
try {
|
|
49
|
+
Object.assign(keyState, size());
|
|
50
|
+
switch (handleKey(view, key, keyState)) {
|
|
51
|
+
case "cancel":
|
|
52
|
+
cancel();
|
|
53
|
+
break;
|
|
54
|
+
case "apply":
|
|
55
|
+
finish(getPlan(matrix));
|
|
56
|
+
break;
|
|
57
|
+
case "draw":
|
|
58
|
+
draw();
|
|
59
|
+
break;
|
|
60
|
+
case "ignore":
|
|
61
|
+
break;
|
|
62
|
+
default:
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
emitKeypressEvents(input);
|
|
71
|
+
input.on("keypress", onKey);
|
|
72
|
+
input.once("end", cancel);
|
|
73
|
+
input.once("close", cancel);
|
|
74
|
+
input.once("error", fail);
|
|
75
|
+
output.on("resize", draw);
|
|
76
|
+
output.once("error", fail);
|
|
77
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
78
|
+
if (signal?.aborted) {
|
|
79
|
+
cancel();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
input.setRawMode(true);
|
|
83
|
+
input.resume();
|
|
84
|
+
screenMode(output, true);
|
|
85
|
+
draw();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
package/src/ui/view.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import stringWidth from "string-width";
|
|
2
|
+
import { cleanLine, formatReview } from "../format.mjs";
|
|
3
|
+
import { getPlan } from "../matrix.mjs";
|
|
4
|
+
import { sourceOf } from "../sources.mjs";
|
|
5
|
+
|
|
6
|
+
/** @typedef {{matrix: import('../matrix.mjs').Matrix, index: number, scope: import('../types.ts').Scope, review: boolean, offset: number}} View */
|
|
7
|
+
|
|
8
|
+
/** Clip by terminal cells, not UTF-16 units. @param {string} line @param {number} width */
|
|
9
|
+
function clipLine(line, width) {
|
|
10
|
+
let result = "";
|
|
11
|
+
let size = 0;
|
|
12
|
+
for (const char of cleanLine(line)) {
|
|
13
|
+
size += stringWidth(char);
|
|
14
|
+
if (size > width) break;
|
|
15
|
+
result += char;
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Keep every source character available in the scrollable review. @param {string} line @param {number} width */
|
|
21
|
+
function wrapLine(line, width) {
|
|
22
|
+
const lines = [];
|
|
23
|
+
let part = "";
|
|
24
|
+
let size = 0;
|
|
25
|
+
for (const char of cleanLine(line)) {
|
|
26
|
+
const cells = stringWidth(char);
|
|
27
|
+
if (size + cells > width) {
|
|
28
|
+
lines.push(part);
|
|
29
|
+
part = "";
|
|
30
|
+
size = 0;
|
|
31
|
+
}
|
|
32
|
+
part += char;
|
|
33
|
+
size += cells;
|
|
34
|
+
}
|
|
35
|
+
return [...lines, part];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @param {View} view @param {number} index @param {import('../types.ts').Scope} scope */
|
|
39
|
+
function cell(view, index, scope) {
|
|
40
|
+
const row = view.matrix.rows[index];
|
|
41
|
+
const locked =
|
|
42
|
+
view.matrix.inventory.blocked[scope].length > 0 || (row.item.obsolete && !row.initial[scope]);
|
|
43
|
+
const value = row.selected[scope] ? "x" : " ";
|
|
44
|
+
return `${view.index === index && view.scope === scope ? ">" : " "}${locked ? `(${value})` : `[${value}]`}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @param {View} view @param {number} height */
|
|
48
|
+
function matrixLines(view, height) {
|
|
49
|
+
const { matrix, index, scope } = view;
|
|
50
|
+
const { context, blocked, unmanaged } = matrix.inventory;
|
|
51
|
+
const count = Math.max(1, height - 10);
|
|
52
|
+
const start = Math.max(0, Math.min(index - Math.floor(count / 2), matrix.rows.length - count));
|
|
53
|
+
const row = matrix.rows[index];
|
|
54
|
+
const source = row.initial[scope];
|
|
55
|
+
const status = blocked[scope].length
|
|
56
|
+
? `BLOCKED ${scope}: ${blocked[scope][0]}`
|
|
57
|
+
: source
|
|
58
|
+
? sourceOf(source)
|
|
59
|
+
: scope === "project" && row.selected.global
|
|
60
|
+
? "No project declaration; available from global."
|
|
61
|
+
: "No direct declaration.";
|
|
62
|
+
return [
|
|
63
|
+
"SuPi installer | checked = direct declaration",
|
|
64
|
+
`Project: ${context.project}${context.project === context.home ? " [HOME WARNING]" : ""}`,
|
|
65
|
+
`Global: ${context.settingsPaths.global}`,
|
|
66
|
+
`${"Package".padEnd(31)}Project Global`,
|
|
67
|
+
...matrix.rows.slice(start, start + count).map((item, offset) => {
|
|
68
|
+
const position = start + offset;
|
|
69
|
+
return `${position === index ? ">" : " "} ${item.item.name.padEnd(29)}${cell(view, position, "project").padEnd(10)}${cell(view, position, "global")}`;
|
|
70
|
+
}),
|
|
71
|
+
`${index + 1}/${matrix.rows.length} | Focus: ${scope} | Unmanaged: ${unmanaged.project.length + unmanaged.global.length}`,
|
|
72
|
+
`${row.item.description} [${row.item.labels.join(", ")}]`,
|
|
73
|
+
`${status}${typeof source === "object" ? " [filtered]" : ""}`,
|
|
74
|
+
"Up/Down: row Left/Right: scope Space: toggle",
|
|
75
|
+
"r: Recommended a: All c: Clear (focused column)",
|
|
76
|
+
"Enter: review sources and changes Esc: cancel",
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Render a bounded frame. Review content scrolls separately from the matrix. @param {View} view @param {{width: number, height: number}} size */
|
|
81
|
+
export function renderView(view, { width, height }) {
|
|
82
|
+
let lines;
|
|
83
|
+
if (width < 60 || height < 12)
|
|
84
|
+
lines = ["Resize terminal to at least 60 columns and 12 rows.", "Esc: cancel"];
|
|
85
|
+
else if (view.review) {
|
|
86
|
+
const operations = getPlan(view.matrix);
|
|
87
|
+
const content = formatReview(view.matrix, operations).flatMap((line) =>
|
|
88
|
+
wrapLine(line, width - 1),
|
|
89
|
+
);
|
|
90
|
+
const count = height - 5;
|
|
91
|
+
view.offset = Math.max(0, Math.min(view.offset, Math.max(0, content.length - count)));
|
|
92
|
+
lines = [
|
|
93
|
+
"Review | failed installs do not block removals",
|
|
94
|
+
"A failed move can leave neither declaration. No rollback.",
|
|
95
|
+
`${operations.length} changes | lines ${view.offset + 1}-${Math.min(content.length, view.offset + count)}/${content.length}`,
|
|
96
|
+
...content.slice(view.offset, view.offset + count),
|
|
97
|
+
"",
|
|
98
|
+
"y: Apply e: Edit Up/Down: scroll Esc: cancel",
|
|
99
|
+
];
|
|
100
|
+
} else lines = matrixLines(view, height);
|
|
101
|
+
return lines
|
|
102
|
+
.slice(0, height)
|
|
103
|
+
.map((line) => clipLine(line, Math.max(1, width - 1)))
|
|
104
|
+
.join("\r\n");
|
|
105
|
+
}
|