@amsterdamdatalabs/enact-extensions 0.1.28 → 0.1.34
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/extensions/enact-factory/skills/cmux-leader/SKILL.md +52 -93
- package/extensions/enact-factory/skills/cmux-send/SKILL.md +10 -22
- package/package.json +1 -1
- package/scripts/enact-extensions.mjs +6 -7
- package/scripts/hooks/session-start-drift-check.mjs +13 -23
- package/scripts/lib/auto-update.mjs +75 -0
- package/scripts/lib/postinstall-offer.mjs +83 -0
- package/scripts/postinstall.mjs +38 -0
|
@@ -1,111 +1,70 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cmux-leader
|
|
3
|
-
description: "Become the cmux LEADER — spawn, brief, poll, unblock, and verify
|
|
3
|
+
description: "Become the cmux LEADER — spawn, root-per-repo, brief, poll, unblock, and verify parallel claude follower terminals from a neutral leader. Use when orchestrating multi-session work across sibling terminals. Followers reply via the cmux-send skill."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# cmux-leader — you are the LEADER
|
|
7
7
|
|
|
8
|
-
You
|
|
9
|
-
|
|
10
|
-
running in sibling terminals of the same workspace. You coordinate; followers do the hands-on work.
|
|
8
|
+
You run inside a cmux workspace and orchestrate **follower** claude sessions in sibling terminals.
|
|
9
|
+
You coordinate; followers do the hands-on work. Keep your own context for the plan and the gates.
|
|
11
10
|
|
|
12
|
-
## Hard rules
|
|
11
|
+
## Hard rules (learned the hard way)
|
|
13
12
|
|
|
14
|
-
- **
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- **
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
- **Lead from a NEUTRAL
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
13
|
+
- **One surface per directory.** A working tree cannot have two drivers — two followers, or leader +
|
|
14
|
+
a follower, in the same repo causes git collisions and duplicate PRs. If two must touch one repo,
|
|
15
|
+
give each its own `git worktree`.
|
|
16
|
+
- **Verify cwd BEFORE launching claude.** `new-split`/`new-surface` do NOT take a working-directory
|
|
17
|
+
flag — a new pane inherits the parent's cwd. Always `cd <repo>` then confirm `pwd` *before* you run
|
|
18
|
+
`claude`, or the follower boots in the wrong repo.
|
|
19
|
+
- **Lead from a NEUTRAL dir** (the common parent of all repos). The enact-loop Stop hook resolves the
|
|
20
|
+
active loop by SHELL cwd — if you `cd` into a follower's repo you inherit its Stop boulder and it
|
|
21
|
+
blocks your exit. Inspect a follower's repo with `git -C <path> …`, never a persistent `cd`.
|
|
22
|
+
- **Spawn on demand.** Don't pre-create followers for ungreenlit work — idle sessions burn tokens.
|
|
23
|
+
Create one only when a concrete task is ready; `close-surface` it when its work is done.
|
|
24
|
+
- **Before opening a PR, check no follower already opened one** for that branch (avoid duplicate PRs).
|
|
25
|
+
- **Don't accept self-reported green** — verify against real output (tests, build, PR state).
|
|
26
|
+
- **Max 5 claude sessions**, horizontal splits, one workspace.
|
|
27
27
|
|
|
28
|
-
##
|
|
28
|
+
## Commands I use
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
|
-
|
|
32
|
-
cmux
|
|
33
|
-
cmux list-
|
|
34
|
-
cmux
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
cmux send --surface surface:N "your prompt here\n" # \n = Enter
|
|
52
|
-
cmux read-screen --surface surface:N --lines 12 # VERIFY it submitted
|
|
53
|
-
cmux send-key --surface surface:N enter # if it parked in the ❯ box (booting/thinking)
|
|
54
|
-
cmux send-key --surface surface:N ctrl+c # interrupt a runaway follower
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
**Always `read-screen` after every `send`** to confirm it landed. If still in the `❯` box, follow with
|
|
58
|
-
`send-key … enter` — NOT `ctrl+b`. `ctrl+b` only flushes a *queued* message when the follower has a
|
|
59
|
-
**running bash command** to background; while it is purely thinking, `ctrl+b` does nothing — just wait.
|
|
60
|
-
|
|
61
|
-
## Spawn a new follower (horizontal split, rooted in the work dir)
|
|
31
|
+
# Orient (re-discover every turn; surface numbers renumber)
|
|
32
|
+
cmux list-panes
|
|
33
|
+
cmux list-pane-surfaces [--pane pane:N] # name each surface
|
|
34
|
+
cmux tree # process/Surface tree (optional)
|
|
35
|
+
|
|
36
|
+
# Read a follower
|
|
37
|
+
cmux read-screen --surface surface:N [--lines 40] [--scrollback]
|
|
38
|
+
|
|
39
|
+
# Drive a follower (\n = Enter)
|
|
40
|
+
cmux send --surface surface:N "text\n"
|
|
41
|
+
cmux send-key --surface surface:N enter # submit a parked message
|
|
42
|
+
cmux send-key --surface surface:N ctrl+c # interrupt a runaway
|
|
43
|
+
cmux send-key --surface surface:N ctrl+b # flush a QUEUED msg — only if a command is running
|
|
44
|
+
|
|
45
|
+
# Spawn a follower (root it correctly, THEN launch claude)
|
|
46
|
+
cmux new-split right --focus false # -> surface:N
|
|
47
|
+
cmux send --surface surface:N "cd /abs/repo && pwd\n"
|
|
48
|
+
cmux read-screen --surface surface:N --lines 5 # CONFIRM pwd == the repo
|
|
49
|
+
cmux send --surface surface:N "claude\n"
|
|
62
50
|
|
|
63
|
-
|
|
64
|
-
cmux
|
|
65
|
-
cmux send --surface surface:N "cd /abs/path/to/repo && claude\n"
|
|
66
|
-
cmux read-screen --surface surface:N --lines 20 # confirm boot
|
|
67
|
-
cmux send --surface surface:N "You are a FOLLOWER under the cmux leader. <role + task>.\n"
|
|
68
|
-
cmux send-key --surface surface:N enter # Enter explicitly (it was booting)
|
|
51
|
+
# Close a finished/dead follower
|
|
52
|
+
cmux close-surface --surface surface:N
|
|
69
53
|
```
|
|
70
54
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
```bash
|
|
74
|
-
cmux new-surface --type terminal --working-directory /abs/path/to/repo --pane pane:M --focus false
|
|
75
|
-
cmux send --surface surface:N "claude\n"
|
|
76
|
-
```
|
|
55
|
+
## Messaging — always use the cmux-send skill
|
|
77
56
|
|
|
78
|
-
|
|
57
|
+
Every follower briefing must tell the follower to use the **cmux-send** skill. Each message: prefix
|
|
58
|
+
`[LEADER] to [FOLLOWER-<name>] `, end with `Respond back once you are done with using prefix
|
|
59
|
+
"[FOLLOWER-<name>] to [LEADER] "`. After every `send`, `read-screen` to confirm it submitted; if it's
|
|
60
|
+
parked in the `❯` box, `send-key … enter` (NOT `ctrl+b`).
|
|
79
61
|
|
|
80
62
|
## Leader loop
|
|
81
63
|
|
|
82
|
-
1. **
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
4. **
|
|
89
|
-
unless you're the session that owns it.
|
|
90
|
-
5. **Verify & gate.** When a follower claims done, verify against real output (tests, build, PR state)
|
|
91
|
-
before you treat the slice as green. Don't accept self-reported green.
|
|
92
|
-
6. **Close.** Summarize across followers; drive the merge / final gate from the leader.
|
|
93
|
-
|
|
94
|
-
## Quick reference (verified surface)
|
|
95
|
-
|
|
96
|
-
| Need | Command |
|
|
97
|
-
|---|---|
|
|
98
|
-
| List sessions | `cmux list-pane-surfaces [--pane pane:N]` |
|
|
99
|
-
| Read a session | `cmux read-screen --surface surface:N --lines 40` |
|
|
100
|
-
| Send + submit | `cmux send --surface surface:N "text\n"` |
|
|
101
|
-
| Submit a parked msg | `cmux send-key --surface surface:N enter` |
|
|
102
|
-
| Flush a queued msg (only if a command is running) | `cmux send-key --surface surface:N ctrl+b` |
|
|
103
|
-
| Interrupt | `cmux send-key --surface surface:N ctrl+c` |
|
|
104
|
-
| New right split | `cmux new-split right --focus false` |
|
|
105
|
-
| Terminal w/ cwd | `cmux new-surface --type terminal --working-directory <path> --pane pane:N` |
|
|
106
|
-
| Focus a pane | `cmux focus-pane --pane pane:N` |
|
|
107
|
-
|
|
108
|
-
Gotchas: agent-session ≠ scriptable (terminal only) · `\n` may not submit during boot/thinking (use
|
|
109
|
-
`send-key enter`) · `read-screen` takes `--surface`, not `--pane` · a `send` to a busy follower queues
|
|
110
|
-
and delivers at turn-end — `ctrl+b` flushes it **only if a command is running** · leader must not `cd`
|
|
111
|
-
into a follower repo (Stop-hook boulder capture — use `git -C`).
|
|
64
|
+
1. **Shard** the goal into independent slices, one repo per follower.
|
|
65
|
+
2. **Spawn & brief** — split → `cd <repo>` (verify pwd) → `claude` → self-contained briefing (role,
|
|
66
|
+
scope, exit criteria, "use cmux-send to reply, report in one line").
|
|
67
|
+
3. **Poll on a ~15-min cadence** — never go idle while followers run. Re-discover topology, `read-screen`
|
|
68
|
+
each, classify (working / idle-done / parked / blocked / ctx≥80%), and act: parked → `send-key enter`;
|
|
69
|
+
ctx≥80% → tell it to `/compact`; blocked → answer; idle-done → assign next or `close-surface`.
|
|
70
|
+
4. **Verify & gate** — confirm real output before treating a slice as green; drive merges from the leader.
|
|
@@ -1,28 +1,26 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cmux-send
|
|
3
|
-
description: "How to correctly
|
|
3
|
+
description: "How to correctly message a cmux surface — the send→verify→enter protocol and the [from] to [to] envelope. Use whenever messaging a cmux session (leader→follower or follower→leader)."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# cmux-send — message a cmux surface correctly
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Two parts are easy to get wrong: **submitting** the message and **wrapping** it.
|
|
9
9
|
|
|
10
10
|
## 1. Submit protocol (always verify the Enter landed)
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
13
|
cmux send --surface surface:N "<one-line message>\n" # \n = Enter
|
|
14
14
|
cmux read-screen --surface surface:N --lines 12 # VERIFY it submitted
|
|
15
|
-
|
|
16
|
-
cmux send-key --surface surface:N enter
|
|
15
|
+
cmux send-key --surface surface:N enter # if still in the ❯ box (booting/thinking)
|
|
17
16
|
```
|
|
18
17
|
|
|
19
|
-
- **Always `read-screen` after `send`.** A `\n` may
|
|
20
|
-
the text parks in the `❯` box.
|
|
21
|
-
- **To submit a parked message
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
- **Always `read-screen` after `send`.** A `\n` may not submit while the follower is booting or
|
|
19
|
+
thinking — the text parks in the `❯` box.
|
|
20
|
+
- **To submit a parked message use `send-key … enter`, NOT `ctrl+b`.** `ctrl+b` only flushes a *queued*
|
|
21
|
+
message when the follower has a **running bash command** to background; while it is purely thinking,
|
|
22
|
+
`ctrl+b` does nothing — just wait.
|
|
24
23
|
- **One line only.** Every literal `\n` submits, so a multi-line prompt fires early and fragments.
|
|
25
|
-
Compose the whole message on a single line; the trailing `\n` is the only submit.
|
|
26
24
|
- `read-screen` takes `--surface`, not `--pane`.
|
|
27
25
|
|
|
28
26
|
## 2. Message envelope (every message)
|
|
@@ -30,8 +28,8 @@ cmux send-key --surface surface:N enter
|
|
|
30
28
|
- **Start with:** `[<from-surface-name>] to [<to-surface-name>] `
|
|
31
29
|
- **End with:** `Respond back once you are done with using prefix "[<to-surface-name>] to [<from-surface-name>] "`
|
|
32
30
|
|
|
33
|
-
Surface names are the human labels (e.g. `LEADER`, `FOLLOWER-enact-wiki`), not `surface:N`. Map them
|
|
34
|
-
`cmux list-pane-surfaces [--pane pane:N]`.
|
|
31
|
+
Surface names are the human labels (e.g. `LEADER`, `FOLLOWER-enact-wiki`), not `surface:N`. Map them
|
|
32
|
+
with `cmux list-pane-surfaces [--pane pane:N]`.
|
|
35
33
|
|
|
36
34
|
### Example (leader → follower)
|
|
37
35
|
|
|
@@ -40,13 +38,3 @@ cmux send --surface surface:3 "[LEADER] to [FOLLOWER-enact-wiki] <instruction on
|
|
|
40
38
|
cmux read-screen --surface surface:3 --lines 12
|
|
41
39
|
# still in the ❯ box? -> cmux send-key --surface surface:3 enter
|
|
42
40
|
```
|
|
43
|
-
|
|
44
|
-
## Quick reference
|
|
45
|
-
|
|
46
|
-
| Need | Command |
|
|
47
|
-
|---|---|
|
|
48
|
-
| Send + submit | `cmux send --surface surface:N "text\n"` |
|
|
49
|
-
| Verify it landed | `cmux read-screen --surface surface:N --lines 12` |
|
|
50
|
-
| Submit a parked message | `cmux send-key --surface surface:N enter` |
|
|
51
|
-
| Flush a QUEUED msg (only if a command is running) | `cmux send-key --surface surface:N ctrl+b` |
|
|
52
|
-
| Interrupt a runaway | `cmux send-key --surface surface:N ctrl+c` |
|
package/package.json
CHANGED
|
@@ -960,13 +960,12 @@ if (command === "hook") {
|
|
|
960
960
|
(async () => {
|
|
961
961
|
if (subCommand === "session-start") {
|
|
962
962
|
try {
|
|
963
|
-
const {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
);
|
|
963
|
+
const { handleSessionStartDrift } = await import("./lib/auto-update.mjs");
|
|
964
|
+
// Opt-in is the USER's: gate auto-apply on the env var they set.
|
|
965
|
+
const autoApply = !!process.env.ENACT_EXTENSIONS_AUTO_UPDATE;
|
|
966
|
+
const result = handleSessionStartDrift({ autoApply });
|
|
967
|
+
if (result.line) {
|
|
968
|
+
process.stdout.write(`${result.line}\n`);
|
|
970
969
|
}
|
|
971
970
|
} catch {
|
|
972
971
|
// Fail-silent for session-start hooks.
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* Registered as a session-start hook for Claude, Codex, Cursor, and Enact.
|
|
6
6
|
* Checks whether any installed enact-extensions plugins have drifted from
|
|
7
7
|
* their canonical source (i.e. are outdated) and prints a concise advisory
|
|
8
|
-
* if so.
|
|
8
|
+
* if so. When the user opts in via ENACT_EXTENSIONS_AUTO_UPDATE, the drifted
|
|
9
|
+
* surfaces are refreshed in place instead of merely advised.
|
|
9
10
|
*
|
|
10
11
|
* Design constraints:
|
|
11
12
|
* - FAIL-SILENT: wrap everything in try/catch; never throw; always exit 0.
|
|
12
|
-
* - FAST: imports
|
|
13
|
-
* - READ-ONLY:
|
|
13
|
+
* - FAST: imports the helper directly (no subprocess) for speed.
|
|
14
|
+
* - READ-ONLY by default: only mutates state when the user opts into auto-apply.
|
|
14
15
|
* - NO OUTPUT on success (or error): only print when there are outdated entries.
|
|
15
16
|
* - DEPENDENCY-FREE: only node builtins + enact-extensions own modules.
|
|
16
17
|
*/
|
|
@@ -24,33 +25,22 @@ const REPO_ROOT = resolve(__dirname, "..", "..");
|
|
|
24
25
|
|
|
25
26
|
try {
|
|
26
27
|
// Dynamically import so any module-load failure is caught by the outer try/catch.
|
|
27
|
-
const {
|
|
28
|
+
const { handleSessionStartDrift } = await import(join(REPO_ROOT, "scripts", "lib", "auto-update.mjs"));
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
// Opt-in is the USER's: gate auto-apply on the env var they set.
|
|
31
|
+
const autoApply = !!process.env.ENACT_EXTENSIONS_AUTO_UPDATE;
|
|
32
|
+
|
|
33
|
+
let result;
|
|
30
34
|
try {
|
|
31
|
-
|
|
35
|
+
result = handleSessionStartDrift({ autoApply });
|
|
32
36
|
} catch {
|
|
33
|
-
//
|
|
37
|
+
// Drift resolution threw (ledger missing, I/O error, etc.) — stay silent.
|
|
34
38
|
process.exit(0);
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (outdated.length === 0) {
|
|
41
|
-
// Nothing to report.
|
|
42
|
-
process.exit(0);
|
|
41
|
+
if (result.line) {
|
|
42
|
+
process.stdout.write(`${result.line}\n`);
|
|
43
43
|
}
|
|
44
|
-
|
|
45
|
-
// Build a concise list of (plugin, surface) pairs.
|
|
46
|
-
const pairs = outdated
|
|
47
|
-
.map((e) => `${e.name}/${e.platform}`)
|
|
48
|
-
.join(", ");
|
|
49
|
-
|
|
50
|
-
// Single advisory line to stdout.
|
|
51
|
-
process.stdout.write(
|
|
52
|
-
`[enact-extensions] ${outdated.length} plugin surface${outdated.length === 1 ? "" : "s"} outdated: ${pairs}. Run: enact-extensions update --all\n`,
|
|
53
|
-
);
|
|
54
44
|
} catch {
|
|
55
45
|
// Any top-level error (import failure, parse error, etc.) — silent exit 0.
|
|
56
46
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auto-update.mjs — SessionStart drift resolution: advise by default, or
|
|
3
|
+
* auto-apply when opted in via ENACT_EXTENSIONS_AUTO_UPDATE.
|
|
4
|
+
*
|
|
5
|
+
* Single export:
|
|
6
|
+
* handleSessionStartDrift({ autoApply, home, cwd }) → { outdated, applied, updated, line }
|
|
7
|
+
*
|
|
8
|
+
* Behaviour:
|
|
9
|
+
* - Compute the outdated (drifted) surfaces from the ledger vs canonical source.
|
|
10
|
+
* - No outdated surfaces → { outdated: [], applied: false, line: null }.
|
|
11
|
+
* - Outdated + autoApply=false → advisory line (default; mutates nothing).
|
|
12
|
+
* - Outdated + autoApply=true → runUpdate({ all: true }); summary line naming
|
|
13
|
+
* what was refreshed. If the refresh updated
|
|
14
|
+
* nothing actionable (e.g. all orphaned), fall
|
|
15
|
+
* back to the advisory line.
|
|
16
|
+
*
|
|
17
|
+
* The opt-in is the USER's: callers derive `autoApply` from
|
|
18
|
+
* process.env.ENACT_EXTENSIONS_AUTO_UPDATE. This module never reads env itself.
|
|
19
|
+
* Reuses computeOutdated + runUpdate; it adds no new install/refresh logic.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { computeOutdated } from "./outdated.mjs";
|
|
23
|
+
import { runUpdate } from "./run-update.mjs";
|
|
24
|
+
|
|
25
|
+
/** Format the "name/platform, ..." pair list for a set of entries. */
|
|
26
|
+
function pairs(entries) {
|
|
27
|
+
return entries.map((e) => `${e.name}/${e.platform}`).join(", ");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The default advisory line (unchanged wording: names surfaces + manual command). */
|
|
31
|
+
export function driftAdvisoryLine(outdated) {
|
|
32
|
+
return (
|
|
33
|
+
`[enact-extensions] ${outdated.length} plugin surface${outdated.length === 1 ? "" : "s"} ` +
|
|
34
|
+
`outdated: ${pairs(outdated)}. Run: enact-extensions update --all`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The auto-apply summary line, naming the surfaces that were refreshed. */
|
|
39
|
+
export function autoUpdateSummaryLine(updated) {
|
|
40
|
+
return (
|
|
41
|
+
`[enact-extensions] auto-updated ${updated.length} plugin surface${updated.length === 1 ? "" : "s"}: ` +
|
|
42
|
+
`${pairs(updated)}`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve SessionStart drift: advise by default, or auto-apply when opted in.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} [opts]
|
|
50
|
+
* @param {boolean} [opts.autoApply] — when true, refresh outdated surfaces in place.
|
|
51
|
+
* @param {string} [opts.home] — ledger home override (defaults to os.homedir()).
|
|
52
|
+
* @param {string} [opts.cwd] — canonical-source resolution dir (defaults to cwd).
|
|
53
|
+
* @returns {{ outdated: object[], applied: boolean, updated: object[], line: string|null }}
|
|
54
|
+
*/
|
|
55
|
+
export function handleSessionStartDrift({ autoApply, home, cwd } = {}) {
|
|
56
|
+
const outdated = (computeOutdated({ home, cwd }) ?? []).filter(
|
|
57
|
+
(e) => e && e.status === "outdated",
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (outdated.length === 0) {
|
|
61
|
+
return { outdated, applied: false, updated: [], line: null };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (autoApply) {
|
|
65
|
+
const summary = runUpdate({ all: true, home, cwd });
|
|
66
|
+
const updated = summary?.updated ?? [];
|
|
67
|
+
if (updated.length === 0) {
|
|
68
|
+
// Nothing actionable was refreshed (e.g. orphaned source) — advise instead.
|
|
69
|
+
return { outdated, applied: false, updated, line: driftAdvisoryLine(outdated) };
|
|
70
|
+
}
|
|
71
|
+
return { outdated, applied: true, updated, line: autoUpdateSummaryLine(updated) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { outdated, applied: false, updated: [], line: driftAdvisoryLine(outdated) };
|
|
75
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* postinstall-offer.mjs — interactive drift-refresh offer shown at postinstall.
|
|
3
|
+
*
|
|
4
|
+
* Exports ONE function:
|
|
5
|
+
* offerDriftRefresh({ isTTY, computeOutdatedFn, promptYesNo, runUpdateFn, write })
|
|
6
|
+
*
|
|
7
|
+
* Contract:
|
|
8
|
+
* - non-TTY → silent; return { offered: false, updated: false }
|
|
9
|
+
* - TTY + zero outdated → silent; return { offered: false, updated: false }
|
|
10
|
+
* - TTY + outdated entries → write summary, then:
|
|
11
|
+
* - autoYes set → apply WITHOUT prompting; return { offered: true, updated: true }
|
|
12
|
+
* - else prompt:
|
|
13
|
+
* - user yes → runUpdateFn({ all: true }); return { offered: true, updated: true }
|
|
14
|
+
* - user no → write manual command hint; return { offered: true, updated: false }
|
|
15
|
+
* - Any error → fail-silent; return { offered: false, updated: false }
|
|
16
|
+
* (except: if runUpdateFn throws after a yes answer,
|
|
17
|
+
* return { offered: true, updated: false })
|
|
18
|
+
*
|
|
19
|
+
* `autoYes` is the USER's opt-in (callers derive it from
|
|
20
|
+
* process.env.ENACT_EXTENSIONS_AUTO_UPDATE); this module never reads env itself.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Offer the user a drift refresh if any installed plugins are outdated.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} opts
|
|
27
|
+
* @param {boolean} opts.isTTY - whether the terminal is interactive
|
|
28
|
+
* @param {boolean} [opts.autoYes] - when true, apply without prompting (user opt-in)
|
|
29
|
+
* @param {() => Promise<import("./outdated.mjs").OutdatedEntry[]>} opts.computeOutdatedFn
|
|
30
|
+
* @param {(question: string) => Promise<boolean>} opts.promptYesNo
|
|
31
|
+
* @param {(opts: { all: boolean }) => void | Promise<void>} opts.runUpdateFn
|
|
32
|
+
* @param {(msg: string) => void} opts.write
|
|
33
|
+
* @returns {Promise<{ offered: boolean, updated: boolean }>}
|
|
34
|
+
*/
|
|
35
|
+
export async function offerDriftRefresh({ isTTY, autoYes, computeOutdatedFn, promptYesNo, runUpdateFn, write }) {
|
|
36
|
+
try {
|
|
37
|
+
// Non-interactive: exit immediately without any work.
|
|
38
|
+
if (!isTTY) {
|
|
39
|
+
return { offered: false, updated: false };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Compute outdated entries and filter to status === "outdated" only.
|
|
43
|
+
const entries = await computeOutdatedFn();
|
|
44
|
+
const outdated = entries.filter((e) => e.status === "outdated");
|
|
45
|
+
|
|
46
|
+
// Nothing to do.
|
|
47
|
+
if (outdated.length === 0) {
|
|
48
|
+
return { offered: false, updated: false };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Write summary line listing the outdated name/platform pairs.
|
|
52
|
+
const pairs = outdated.map((e) => `${e.name}/${e.platform}`).join(", ");
|
|
53
|
+
write(`\nenact-extensions: ${outdated.length} plugin(s) have drifted from their source: ${pairs}\n`);
|
|
54
|
+
|
|
55
|
+
// Opt-in auto-apply: skip the prompt and refresh directly.
|
|
56
|
+
if (autoYes) {
|
|
57
|
+
try {
|
|
58
|
+
await runUpdateFn({ all: true });
|
|
59
|
+
return { offered: true, updated: true };
|
|
60
|
+
} catch {
|
|
61
|
+
return { offered: true, updated: false };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Prompt user.
|
|
66
|
+
const yes = await promptYesNo("Update all now? [Y/n] ");
|
|
67
|
+
|
|
68
|
+
if (yes) {
|
|
69
|
+
try {
|
|
70
|
+
await runUpdateFn({ all: true });
|
|
71
|
+
return { offered: true, updated: true };
|
|
72
|
+
} catch {
|
|
73
|
+
return { offered: true, updated: false };
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
write(`Run \`enact-extensions update --all\` to update manually.\n`);
|
|
77
|
+
return { offered: true, updated: false };
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Fail-silent: never throw from postinstall-related code.
|
|
81
|
+
return { offered: false, updated: false };
|
|
82
|
+
}
|
|
83
|
+
}
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -48,6 +48,44 @@ try {
|
|
|
48
48
|
stdio: "inherit",
|
|
49
49
|
env: { ...process.env },
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
// Offer a drift refresh if any installed plugins are outdated.
|
|
53
|
+
const { offerDriftRefresh } = await import("./lib/postinstall-offer.mjs");
|
|
54
|
+
const { computeOutdated } = await import("./lib/outdated.mjs");
|
|
55
|
+
const { runUpdate } = await import("./lib/run-update.mjs");
|
|
56
|
+
const { createInterface } = await import("node:readline");
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* promptYesNo — asks a yes/no question on stdin/stdout.
|
|
60
|
+
* Default is YES: empty input / "y" / "yes" (case-insensitive) → true.
|
|
61
|
+
* EOF (Ctrl-D) resolves as false (decline) via the 'close' event so the
|
|
62
|
+
* script can never hang waiting for a promise that will never settle.
|
|
63
|
+
* @param {string} question
|
|
64
|
+
* @returns {Promise<boolean>}
|
|
65
|
+
*/
|
|
66
|
+
function promptYesNo(question) {
|
|
67
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
// EOF / Ctrl-D fires 'close' before the question callback — treat as decline.
|
|
70
|
+
rl.once("close", () => resolve(false));
|
|
71
|
+
rl.question(question, (answer) => {
|
|
72
|
+
rl.close();
|
|
73
|
+
const t = (answer ?? "").trim().toLowerCase();
|
|
74
|
+
resolve(t === "" || t === "y" || t === "yes");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await offerDriftRefresh({
|
|
80
|
+
isTTY: true,
|
|
81
|
+
// User opt-in: ENACT_EXTENSIONS_AUTO_UPDATE=1 applies drift refreshes without prompting.
|
|
82
|
+
autoYes: !!env.ENACT_EXTENSIONS_AUTO_UPDATE,
|
|
83
|
+
computeOutdatedFn: () => computeOutdated(),
|
|
84
|
+
runUpdateFn: (opts) => runUpdate(opts),
|
|
85
|
+
write: (s) => process.stdout.write(s),
|
|
86
|
+
promptYesNo,
|
|
87
|
+
});
|
|
88
|
+
|
|
51
89
|
process.exit(0);
|
|
52
90
|
} catch {
|
|
53
91
|
// CRITICAL: never let an error propagate from postinstall.
|