@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.
@@ -1,111 +1,70 @@
1
1
  ---
2
2
  name: cmux-leader
3
- description: "Become the cmux LEADER — spawn, brief, poll, unblock, and verify up to 5 parallel claude follower terminals in one cmux workspace. Use when orchestrating multi-session work across sibling terminals, driving followers, or coordinating parallel agents. Followers message back via the cmux-send skill."
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 are a Claude Code session running **inside a cmux workspace**. You act as the
9
- **leader/orchestrator**: you spawn, brief, poll, unblock, and verify **follower** claude sessions
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
- - **Terminal surfaces only.** Followers are `claude` running in a **terminal** surface. `agent-session`
15
- surfaces (`new-surface --type agent-session`) are NOT scriptable `send` / `read-screen` fail on them
16
- with `Surface is not a terminal`. Always launch followers as `claude` in a terminal.
17
- - **Max 5 parallel claude sessions**, all **horizontal splits** in **one** workspace.
18
- - **Start each follower from the directory its work lives in** so enact-context warms the right repo
19
- (`enact-context: ON` must print on that session's shell start).
20
- - **Lead from a NEUTRAL parent dir** (e.g. the common parent of all repos). The enact-loop **Stop hook
21
- resolves the active loop by SHELL cwd** — if you `cd` into a follower's repo you inherit that loop's
22
- Stop boulder and it blocks YOUR exit. To inspect a follower's repo use `git -C <path> …` /
23
- absolute paths; **never `cd` into a follower repo.**
24
- - **You orchestrate, you don't do the followers' work.** Assign poll unblock verify. Keep your own
25
- context for the plan and the merge/verification gates.
26
- - One leader per workspace. If another session is already leading, don't fight it — coordinate.
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
- ## Orient first (always run these)
28
+ ## Commands I use
29
29
 
30
30
  ```bash
31
- cmux current-window # which window
32
- cmux workspace list # workspaces (this leader's is [selected])
33
- cmux list-panes # panes in the workspace
34
- cmux list-pane-surfaces # surfaces (sessions) in the focused pane
35
- cmux list-pane-surfaces --pane pane:N # surfaces in a specific pane
36
- ```
37
-
38
- Identify each surface by name (e.g. `FOLLOWER-enact-wiki`). A surface you can `read-screen` is a
39
- terminal session you can drive. Re-discover topology each session — never hardcode surface numbers.
40
-
41
- ## Read a follower
42
-
43
- ```bash
44
- cmux read-screen --surface surface:N --lines 40 # tail of the live screen
45
- cmux read-screen --surface surface:N --scrollback # full scrollback
46
- ```
47
-
48
- ## Drive a follower (see the cmux-send skill for the full messaging protocol)
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
- ```bash
64
- cmux new-split right --focus false # -> "OK surface:N pane:M ..."
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
- One-shot terminal with an explicit cwd (no `cd` needed):
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
- Keep all splits horizontal; stop at 5 claude sessions total.
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. **Plan & shard.** Break the goal into independent slices, one per follower (≤5). Pick each slice's
83
- repo/dir so enact-context warms the right context.
84
- 2. **Spawn & brief.** Split `cd <dir> && claude` → send a self-contained briefing (role, scope,
85
- exit criteria, "report back in one line when done/blocked") using the cmux-send envelope.
86
- 3. **Poll.** Round-robin `read-screen` each follower. Watch for: finished, blocked, asking a question,
87
- or idle at the `❯` prompt.
88
- 4. **Unblock.** Answer questions / re-scope via `send`. Never reach into a follower's repo yourself
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 send a message to a cmux follower surface — the send→verify→enter protocol and the [from] to [to] message envelope. Use whenever messaging a cmux session (leader→follower or follower→leader)."
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
- Sending a cmux message has two parts that are easy to get wrong: **submitting** it, and **wrapping** it.
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
- # If the text is still sitting in the `❯` box (not submitted):
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 NOT submit if the follower was booting or thinking —
20
- the text parks in the `❯` box.
21
- - **To submit a parked message, use `send-key … enter` NOT `ctrl+b`.** `ctrl+b` only flushes a
22
- *queued* message when the follower has a **running bash command** to background; on an idle or
23
- purely-thinking follower it does nothing.
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 with
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amsterdamdatalabs/enact-extensions",
3
- "version": "0.1.28",
3
+ "version": "0.1.34",
4
4
  "description": "Create and validate Enact multi-platform plugin manifests",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -960,13 +960,12 @@ if (command === "hook") {
960
960
  (async () => {
961
961
  if (subCommand === "session-start") {
962
962
  try {
963
- const { computeOutdated } = await import("./lib/outdated.mjs");
964
- const outdated = (computeOutdated() ?? []).filter((entry) => entry?.status === "outdated");
965
- if (outdated.length > 0) {
966
- const pairs = outdated.map((entry) => `${entry.name}/${entry.platform}`).join(", ");
967
- process.stdout.write(
968
- `[enact-extensions] ${outdated.length} plugin surface${outdated.length === 1 ? "" : "s"} outdated: ${pairs}. Run: enact-extensions update --all\n`,
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 computeOutdated directly (no subprocess) for speed.
13
- * - READ-ONLY: never writes any state.
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 { computeOutdated } = await import(join(REPO_ROOT, "scripts", "lib", "outdated.mjs"));
28
+ const { handleSessionStartDrift } = await import(join(REPO_ROOT, "scripts", "lib", "auto-update.mjs"));
28
29
 
29
- let entries;
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
- entries = computeOutdated();
35
+ result = handleSessionStartDrift({ autoApply });
32
36
  } catch {
33
- // computeOutdated threw (ledger missing, I/O error, etc.) — stay silent.
37
+ // Drift resolution threw (ledger missing, I/O error, etc.) — stay silent.
34
38
  process.exit(0);
35
39
  }
36
40
 
37
- // Filter to only outdated entries (not fresh, not orphaned).
38
- const outdated = (entries ?? []).filter((e) => e && e.status === "outdated");
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
+ }
@@ -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.