claude-dev-env 8.51.4 → 8.52.1
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/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +12 -0
- package/_shared/process-tree/scripts/process_tree_kill.py +93 -6
- package/_shared/process-tree/scripts/test_process_tree_kill.py +149 -0
- package/bin/install-pstack-plugin.mjs +68 -33
- package/bin/install-pstack-plugin.test.mjs +77 -32
- package/bin/install.agents-home.test.mjs +1 -0
- package/bin/install.codex-rules.test.mjs +2 -0
- package/bin/install.cursor-rules.test.mjs +1 -0
- package/bin/install.mjs +18 -12
- package/bin/install.prune.test.mjs +1 -0
- package/bin/install.question-presentation.test.mjs +4 -0
- package/bin/install.settings-defaults.test.mjs +1 -0
- package/bin/install.shared-settings.test.mjs +1 -0
- package/bin/install.test.mjs +56 -0
- package/bin/install.transaction.test.mjs +8 -0
- package/bin/install.uninstall-transaction.test.mjs +2 -0
- package/bin/install.update-keeps-hooks.test.mjs +1 -0
- package/package.json +1 -1
- package/scripts/account_broker_support.py +22 -6
- package/scripts/test_account_broker_support.py +83 -24
|
@@ -25,3 +25,15 @@ PROCESS_TREE_KILL_TIMEOUT_SECONDS: int = 10
|
|
|
25
25
|
Gates the kill command alone. Each caller sets its own bound on the drain that
|
|
26
26
|
follows the kill.
|
|
27
27
|
"""
|
|
28
|
+
|
|
29
|
+
PROCESS_TREE_EXIT_WAIT_SECONDS: float = 2.0
|
|
30
|
+
"""Seconds ``terminate_process_tree`` waits for the signalled POSIX group to exit.
|
|
31
|
+
|
|
32
|
+
A SIGKILL takes effect when the kernel next schedules each target, so a
|
|
33
|
+
descendant can still run for a moment after ``killpg`` returns. The wait ends
|
|
34
|
+
as soon as no group member is still running; the bound applies only when a
|
|
35
|
+
member survived the signal.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
PROCESS_TREE_EXIT_POLL_SECONDS: float = 0.01
|
|
39
|
+
"""Pause between checks for running members of the signalled process group."""
|
|
@@ -25,6 +25,7 @@ import os
|
|
|
25
25
|
import signal
|
|
26
26
|
import subprocess
|
|
27
27
|
import sys
|
|
28
|
+
import time
|
|
28
29
|
from pathlib import Path
|
|
29
30
|
|
|
30
31
|
_config_directory = str(Path(__file__).resolve().parent / "config")
|
|
@@ -32,6 +33,8 @@ if _config_directory not in sys.path:
|
|
|
32
33
|
sys.path.insert(0, _config_directory)
|
|
33
34
|
|
|
34
35
|
from process_tree_scripts_constants.process_tree_kill_constants import ( # noqa: E402
|
|
36
|
+
PROCESS_TREE_EXIT_POLL_SECONDS,
|
|
37
|
+
PROCESS_TREE_EXIT_WAIT_SECONDS,
|
|
35
38
|
PROCESS_TREE_KILL_TIMEOUT_SECONDS,
|
|
36
39
|
WINDOWS_TASKKILL_COMMAND,
|
|
37
40
|
WINDOWS_TASKKILL_FORCE_FLAG,
|
|
@@ -126,19 +129,103 @@ def terminate_process_tree(process: subprocess.Popen[str]) -> None:
|
|
|
126
129
|
tree kill takes ok: grandchildren die, pipes close
|
|
127
130
|
tree kill misses the child ok: Popen.kill() ends the direct child
|
|
128
131
|
child exits mid-kill ok: the raised lookup error is swallowed
|
|
132
|
+
grandchild still running ok: returns once the POSIX group has exited
|
|
129
133
|
|
|
130
134
|
Falls back to ``Popen.kill()`` when the direct child survives the tree kill,
|
|
131
|
-
so the caller never waits on a live process.
|
|
135
|
+
so the caller never waits on a live process. On POSIX it then waits, bounded
|
|
136
|
+
by ``PROCESS_TREE_EXIT_WAIT_SECONDS``, until no group member is running, so
|
|
137
|
+
a descendant cannot touch files after this call returns.
|
|
132
138
|
|
|
133
139
|
Args:
|
|
134
140
|
process: The process whose tree is ended.
|
|
135
141
|
"""
|
|
136
142
|
if process.poll() is not None:
|
|
137
143
|
return
|
|
144
|
+
process_group_identifier = _posix_process_group(process.pid)
|
|
138
145
|
kill_process_tree_by_identifier(process.pid)
|
|
139
|
-
if process.poll() is
|
|
140
|
-
|
|
146
|
+
if process.poll() is None:
|
|
147
|
+
try:
|
|
148
|
+
process.kill()
|
|
149
|
+
except ProcessLookupError:
|
|
150
|
+
pass
|
|
151
|
+
if process_group_identifier is not None:
|
|
152
|
+
_wait_for_process_group_exit(process, process_group_identifier)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _posix_process_group(process_identifier: int) -> int | None:
|
|
156
|
+
if sys.platform == "win32":
|
|
157
|
+
return None
|
|
141
158
|
try:
|
|
142
|
-
|
|
143
|
-
except
|
|
144
|
-
return
|
|
159
|
+
return os.getpgid(process_identifier)
|
|
160
|
+
except OSError:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _wait_for_process_group_exit(
|
|
165
|
+
process: subprocess.Popen[str], process_group_identifier: int
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Block until no member of the signalled group is still running, bounded.
|
|
168
|
+
|
|
169
|
+
``killpg`` returns once the signal is queued, and each target dies only when
|
|
170
|
+
the kernel next schedules it. A grandchild preempted inside a system call
|
|
171
|
+
finishes that call first, so it can still truncate or write a file after
|
|
172
|
+
the caller has moved on. Waiting here keeps that work inside the call.
|
|
173
|
+
"""
|
|
174
|
+
deadline = time.monotonic() + PROCESS_TREE_EXIT_WAIT_SECONDS
|
|
175
|
+
while running_process_group_members(process, process_group_identifier):
|
|
176
|
+
if time.monotonic() >= deadline:
|
|
177
|
+
return
|
|
178
|
+
time.sleep(PROCESS_TREE_EXIT_POLL_SECONDS)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def running_process_group_members(
|
|
182
|
+
process: subprocess.Popen[str], process_group_identifier: int
|
|
183
|
+
) -> list[int]:
|
|
184
|
+
"""List the process ids in a POSIX group that have not exited yet.
|
|
185
|
+
|
|
186
|
+
Reads ``/proc`` where it exists, which tells a running member from a zombie
|
|
187
|
+
waiting on its reaper. Elsewhere it reaps the direct child and asks the
|
|
188
|
+
kernel whether the group still has any member.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
process: The direct child that leads the group.
|
|
192
|
+
process_group_identifier: The group the direct child leads.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Process ids still running, or an empty list once the group has exited.
|
|
196
|
+
"""
|
|
197
|
+
if sys.platform == "win32":
|
|
198
|
+
return []
|
|
199
|
+
proc_directory = Path("/proc")
|
|
200
|
+
if proc_directory.is_dir():
|
|
201
|
+
return _running_members_from_proc(proc_directory, process_group_identifier)
|
|
202
|
+
try:
|
|
203
|
+
process.wait(timeout=PROCESS_TREE_EXIT_POLL_SECONDS)
|
|
204
|
+
except subprocess.TimeoutExpired:
|
|
205
|
+
return [process.pid]
|
|
206
|
+
try:
|
|
207
|
+
os.killpg(process_group_identifier, 0)
|
|
208
|
+
except OSError:
|
|
209
|
+
return []
|
|
210
|
+
return [process_group_identifier]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _running_members_from_proc(
|
|
214
|
+
proc_directory: Path, process_group_identifier: int
|
|
215
|
+
) -> list[int]:
|
|
216
|
+
all_running: list[int] = []
|
|
217
|
+
for each_entry in proc_directory.iterdir():
|
|
218
|
+
if not each_entry.name.isdigit():
|
|
219
|
+
continue
|
|
220
|
+
try:
|
|
221
|
+
stat_text = (each_entry / "stat").read_text(
|
|
222
|
+
encoding="ascii", errors="replace"
|
|
223
|
+
)
|
|
224
|
+
except OSError:
|
|
225
|
+
continue
|
|
226
|
+
state, _parent_identifier, group_identifier, *_later_fields = stat_text.rsplit(
|
|
227
|
+
")", 1
|
|
228
|
+
)[1].split()
|
|
229
|
+
if int(group_identifier) == process_group_identifier and state != "Z":
|
|
230
|
+
all_running.append(int(each_entry.name))
|
|
231
|
+
return all_running
|
|
@@ -7,9 +7,12 @@ the taskkill argv it builds, the failures it swallows, and the
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import os
|
|
10
11
|
import subprocess
|
|
11
12
|
import sys
|
|
13
|
+
import time
|
|
12
14
|
from pathlib import Path
|
|
15
|
+
from typing import cast
|
|
13
16
|
|
|
14
17
|
import pytest
|
|
15
18
|
|
|
@@ -23,6 +26,8 @@ from process_tree_kill import ( # noqa: E402
|
|
|
23
26
|
terminate_process_tree,
|
|
24
27
|
)
|
|
25
28
|
from process_tree_scripts_constants.process_tree_kill_constants import ( # noqa: E402
|
|
29
|
+
PROCESS_TREE_EXIT_POLL_SECONDS,
|
|
30
|
+
PROCESS_TREE_EXIT_WAIT_SECONDS,
|
|
26
31
|
PROCESS_TREE_KILL_TIMEOUT_SECONDS,
|
|
27
32
|
WINDOWS_TASKKILL_COMMAND,
|
|
28
33
|
WINDOWS_TASKKILL_FORCE_FLAG,
|
|
@@ -276,3 +281,147 @@ def test_new_session_is_requested_off_windows_only(
|
|
|
276
281
|
|
|
277
282
|
_install_windows_platform(monkeypatch)
|
|
278
283
|
assert process_tree_kill.should_start_new_session() is False
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _install_posix_group_wait(
|
|
287
|
+
monkeypatch: pytest.MonkeyPatch, all_member_snapshots: list[list[int]]
|
|
288
|
+
) -> list[float]:
|
|
289
|
+
"""Script what the group wait sees after the kill, and record each pause.
|
|
290
|
+
|
|
291
|
+
The last snapshot repeats once the list runs dry, so a never-empty script
|
|
292
|
+
models a group that outlives the deadline.
|
|
293
|
+
"""
|
|
294
|
+
all_sleeps: list[float] = []
|
|
295
|
+
remaining_snapshots = list(all_member_snapshots)
|
|
296
|
+
|
|
297
|
+
def scripted_members(process: object, process_group_identifier: int) -> list[int]:
|
|
298
|
+
del process, process_group_identifier
|
|
299
|
+
if len(remaining_snapshots) > 1:
|
|
300
|
+
return remaining_snapshots.pop(0)
|
|
301
|
+
return remaining_snapshots[0]
|
|
302
|
+
|
|
303
|
+
_install_posix_platform(monkeypatch)
|
|
304
|
+
monkeypatch.setattr(
|
|
305
|
+
process_tree_kill.os,
|
|
306
|
+
"getpgid",
|
|
307
|
+
lambda process_identifier: FAKE_PROCESS_GROUP_IDENTIFIER,
|
|
308
|
+
raising=False,
|
|
309
|
+
)
|
|
310
|
+
monkeypatch.setattr(
|
|
311
|
+
process_tree_kill.os,
|
|
312
|
+
"killpg",
|
|
313
|
+
lambda group_identifier, signal_number: None,
|
|
314
|
+
raising=False,
|
|
315
|
+
)
|
|
316
|
+
monkeypatch.setattr(
|
|
317
|
+
process_tree_kill, "running_process_group_members", scripted_members
|
|
318
|
+
)
|
|
319
|
+
monkeypatch.setattr(process_tree_kill.time, "sleep", all_sleeps.append)
|
|
320
|
+
return all_sleeps
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_terminate_waits_until_no_group_member_is_running(
|
|
324
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
325
|
+
) -> None:
|
|
326
|
+
"""The POSIX kill returns only once the signalled group has emptied.
|
|
327
|
+
|
|
328
|
+
::
|
|
329
|
+
|
|
330
|
+
members [7], [7], [] ok: two pauses, then return
|
|
331
|
+
no wait flag: a grandchild still inside a system call
|
|
332
|
+
writes after the caller has moved on
|
|
333
|
+
"""
|
|
334
|
+
all_sleeps = _install_posix_group_wait(monkeypatch, [[7], [7], []])
|
|
335
|
+
|
|
336
|
+
terminate_process_tree(_as_popen(_FakeProcess([None, 0])))
|
|
337
|
+
|
|
338
|
+
assert all_sleeps == [PROCESS_TREE_EXIT_POLL_SECONDS] * 2
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_terminate_stops_waiting_at_the_exit_deadline(
|
|
342
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
343
|
+
) -> None:
|
|
344
|
+
"""A group member that survives the signal does not hold the caller forever."""
|
|
345
|
+
all_sleeps = _install_posix_group_wait(monkeypatch, [[7]])
|
|
346
|
+
clock = iter(
|
|
347
|
+
[0.0, 0.0, PROCESS_TREE_EXIT_WAIT_SECONDS / 2, PROCESS_TREE_EXIT_WAIT_SECONDS]
|
|
348
|
+
)
|
|
349
|
+
monkeypatch.setattr(process_tree_kill.time, "monotonic", lambda: next(clock))
|
|
350
|
+
|
|
351
|
+
terminate_process_tree(_as_popen(_FakeProcess([None, 0])))
|
|
352
|
+
|
|
353
|
+
assert all_sleeps == [PROCESS_TREE_EXIT_POLL_SECONDS] * 2
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _as_popen(fake_process: _FakeProcess) -> subprocess.Popen[str]:
|
|
357
|
+
return cast("subprocess.Popen[str]", fake_process)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _group_led_by(process: subprocess.Popen[str]) -> int:
|
|
361
|
+
"""Name the group a session-leading child owns; Windows has none, so its pid."""
|
|
362
|
+
if process_tree_kill.should_start_new_session():
|
|
363
|
+
return os.getpgid(process.pid)
|
|
364
|
+
return process.pid
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def test_running_members_leave_out_a_zombie() -> None:
|
|
368
|
+
"""A member that died but awaits its reaper is not running.
|
|
369
|
+
|
|
370
|
+
::
|
|
371
|
+
|
|
372
|
+
child sleeping ok: listed on POSIX, [] on Windows
|
|
373
|
+
child killed, not yet reaped ok: dropped, so the wait ends
|
|
374
|
+
"""
|
|
375
|
+
sleeper = subprocess.Popen(
|
|
376
|
+
[sys.executable, "-c", "import time; time.sleep(60)"],
|
|
377
|
+
start_new_session=process_tree_kill.should_start_new_session(),
|
|
378
|
+
)
|
|
379
|
+
group_identifier = _group_led_by(sleeper)
|
|
380
|
+
all_expected_running = (
|
|
381
|
+
[sleeper.pid] if process_tree_kill.should_start_new_session() else []
|
|
382
|
+
)
|
|
383
|
+
try:
|
|
384
|
+
assert (
|
|
385
|
+
process_tree_kill.running_process_group_members(sleeper, group_identifier)
|
|
386
|
+
== all_expected_running
|
|
387
|
+
)
|
|
388
|
+
sleeper.kill()
|
|
389
|
+
deadline = time.monotonic() + PROCESS_TREE_EXIT_WAIT_SECONDS
|
|
390
|
+
while process_tree_kill.running_process_group_members(
|
|
391
|
+
sleeper, group_identifier
|
|
392
|
+
):
|
|
393
|
+
assert time.monotonic() < deadline, "the killed sleeper still counts"
|
|
394
|
+
time.sleep(PROCESS_TREE_EXIT_POLL_SECONDS)
|
|
395
|
+
finally:
|
|
396
|
+
sleeper.wait()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def test_terminate_returns_with_the_grandchild_already_gone(tmp_path: Path) -> None:
|
|
400
|
+
"""Right after the kill returns, no process in the child's group still runs."""
|
|
401
|
+
heartbeat_file = tmp_path / "heartbeat.txt"
|
|
402
|
+
grandchild_source = (
|
|
403
|
+
"import pathlib, sys\n"
|
|
404
|
+
"heartbeat = pathlib.Path(sys.argv[1])\n"
|
|
405
|
+
"while True:\n"
|
|
406
|
+
" heartbeat.write_text('beat', encoding='utf-8')\n"
|
|
407
|
+
)
|
|
408
|
+
child_source = (
|
|
409
|
+
"import subprocess, sys, time\n"
|
|
410
|
+
"subprocess.Popen([sys.executable, '-c', sys.argv[1], sys.argv[2]])\n"
|
|
411
|
+
"time.sleep(60)\n"
|
|
412
|
+
)
|
|
413
|
+
child = subprocess.Popen(
|
|
414
|
+
[sys.executable, "-c", child_source, grandchild_source, str(heartbeat_file)],
|
|
415
|
+
start_new_session=process_tree_kill.should_start_new_session(),
|
|
416
|
+
)
|
|
417
|
+
group_identifier = _group_led_by(child)
|
|
418
|
+
while not heartbeat_file.exists():
|
|
419
|
+
time.sleep(PROCESS_TREE_EXIT_POLL_SECONDS)
|
|
420
|
+
|
|
421
|
+
terminate_process_tree(child)
|
|
422
|
+
still_running = process_tree_kill.running_process_group_members(
|
|
423
|
+
child, group_identifier
|
|
424
|
+
)
|
|
425
|
+
child.wait()
|
|
426
|
+
|
|
427
|
+
assert still_running == []
|
|
@@ -15,7 +15,7 @@ export const PSTACK_PLUGIN_SPEC = Object.freeze({
|
|
|
15
15
|
label: 'Pstack',
|
|
16
16
|
marketplaceRepository: PSTACK_MARKETPLACE_REPOSITORY,
|
|
17
17
|
marketplaceName: PSTACK_MARKETPLACE_NAME,
|
|
18
|
-
|
|
18
|
+
sparsePaths: Object.freeze([]),
|
|
19
19
|
pluginIdentifier: PSTACK_PLUGIN_IDENTIFIER,
|
|
20
20
|
hosts: PSTACK_PLUGIN_HOSTS,
|
|
21
21
|
optOutFlag: PSTACK_PLUGIN_OPT_OUT_FLAG,
|
|
@@ -27,13 +27,25 @@ export const USAGE_WRAPUP_PLUGIN_SPEC = Object.freeze({
|
|
|
27
27
|
label: 'Usage-wrapup',
|
|
28
28
|
marketplaceRepository: 'jl-cmd/claude-dev-env',
|
|
29
29
|
marketplaceName: 'claude-dev-env',
|
|
30
|
-
|
|
30
|
+
sparsePaths: Object.freeze(['.claude-plugin', 'packages/usage-wrapup']),
|
|
31
31
|
pluginIdentifier: 'usage-wrapup@claude-dev-env',
|
|
32
32
|
hosts: Object.freeze(['claude']),
|
|
33
33
|
optOutFlag: '--no-usage-wrapup',
|
|
34
34
|
optOutVariable: 'CDE_INSTALL_USAGE_WRAPUP',
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
export const SUBAGENT_MODELS_PLUGIN_SPEC = Object.freeze({
|
|
38
|
+
name: 'subagent-models',
|
|
39
|
+
label: 'Subagent-models',
|
|
40
|
+
marketplaceRepository: 'jl-cmd/claude-dev-env',
|
|
41
|
+
marketplaceName: 'claude-dev-env',
|
|
42
|
+
sparsePaths: Object.freeze(['.claude-plugin', 'packages/subagent-models']),
|
|
43
|
+
pluginIdentifier: 'subagent-models@claude-dev-env',
|
|
44
|
+
hosts: Object.freeze(['claude']),
|
|
45
|
+
optOutFlag: '--no-subagent-models',
|
|
46
|
+
optOutVariable: 'CDE_INSTALL_SUBAGENT_MODELS',
|
|
47
|
+
});
|
|
48
|
+
|
|
37
49
|
const HOST_DEFINITIONS = Object.freeze({
|
|
38
50
|
claude: Object.freeze({
|
|
39
51
|
executable: 'claude',
|
|
@@ -55,42 +67,60 @@ const HOST_DEFINITIONS = Object.freeze({
|
|
|
55
67
|
});
|
|
56
68
|
|
|
57
69
|
/**
|
|
58
|
-
* Read the marketplace and plugin commands one host installs a
|
|
70
|
+
* Read the marketplace and plugin commands one host installs a set of plugins with.
|
|
59
71
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
72
|
+
* Every plugin in the set comes from one marketplace. Removing a marketplace
|
|
73
|
+
* uninstalls each plugin it supplied, so the plan removes and adds that
|
|
74
|
+
* marketplace once for the whole set. The removal commands run first and take
|
|
75
|
+
* out each installed plugin, its cached versions, and the marketplace clone,
|
|
76
|
+
* so the install commands that follow fetch the newest published version into
|
|
77
|
+
* an empty slot.
|
|
63
78
|
*
|
|
64
79
|
* Claude Code and Codex publish a marketplace under different plugin verbs, so
|
|
65
80
|
* the install verb belongs to the host and the marketplace and identifier
|
|
66
81
|
* belong to the plugin spec. A spec whose marketplace is a large repository
|
|
67
|
-
*
|
|
68
|
-
* clone holds only the catalog and
|
|
82
|
+
* names its sparse paths, and the one marketplace add checks out every path
|
|
83
|
+
* the set names, so the clone holds only the catalog and those plugin folders
|
|
84
|
+
* and stays clear of Windows path-length limits.
|
|
69
85
|
*
|
|
70
|
-
* @param {typeof PSTACK_PLUGIN_SPEC}
|
|
71
|
-
* @param {string} host A host
|
|
86
|
+
* @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins to install, from one marketplace.
|
|
87
|
+
* @param {string} host A host every spec names, `claude` or `codex`.
|
|
72
88
|
* @returns {{executable: string, executableVariable: string, homeVariable: string,
|
|
73
89
|
* removalCommands: ReadonlyArray<ReadonlyArray<string>>,
|
|
74
90
|
* commands: ReadonlyArray<ReadonlyArray<string>>}} The host's install plan.
|
|
75
91
|
*/
|
|
76
|
-
export function
|
|
92
|
+
export function marketplacePluginsPlan(allSpecs, host) {
|
|
77
93
|
const hostDefinition = HOST_DEFINITIONS[host];
|
|
78
|
-
|
|
94
|
+
for (const eachSpec of allSpecs) {
|
|
95
|
+
if (!hostDefinition || !eachSpec.hosts.includes(host)) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Unsupported ${eachSpec.name} plugin host ${host}: this installer knows ${eachSpec.hosts.join(', ')}.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const [firstSpec] = allSpecs;
|
|
102
|
+
const otherMarketplaceSpec = allSpecs.find(
|
|
103
|
+
eachSpec => eachSpec.marketplaceRepository !== firstSpec.marketplaceRepository,
|
|
104
|
+
);
|
|
105
|
+
if (otherMarketplaceSpec) {
|
|
79
106
|
throw new Error(
|
|
80
|
-
|
|
107
|
+
`${otherMarketplaceSpec.name} comes from ${otherMarketplaceSpec.marketplaceRepository}, `
|
|
108
|
+
+ `and one plan installs from ${firstSpec.marketplaceRepository} only.`,
|
|
81
109
|
);
|
|
82
110
|
}
|
|
111
|
+
const allSparsePaths = [...new Set(allSpecs.flatMap(eachSpec => eachSpec.sparsePaths))];
|
|
112
|
+
const sparseArguments = allSparsePaths.length > 0 ? ['--sparse', ...allSparsePaths] : [];
|
|
83
113
|
return {
|
|
84
114
|
executable: hostDefinition.executable,
|
|
85
115
|
executableVariable: hostDefinition.executableVariable,
|
|
86
116
|
homeVariable: hostDefinition.homeVariable,
|
|
87
117
|
removalCommands: [
|
|
88
|
-
['plugin', hostDefinition.uninstallVerb,
|
|
89
|
-
['plugin', 'marketplace', 'remove',
|
|
118
|
+
...allSpecs.map(eachSpec => ['plugin', hostDefinition.uninstallVerb, eachSpec.pluginIdentifier]),
|
|
119
|
+
['plugin', 'marketplace', 'remove', firstSpec.marketplaceName],
|
|
90
120
|
],
|
|
91
121
|
commands: [
|
|
92
|
-
['plugin', 'marketplace', 'add',
|
|
93
|
-
['plugin', hostDefinition.installVerb,
|
|
122
|
+
['plugin', 'marketplace', 'add', firstSpec.marketplaceRepository, ...sparseArguments],
|
|
123
|
+
...allSpecs.map(eachSpec => ['plugin', hostDefinition.installVerb, eachSpec.pluginIdentifier]),
|
|
94
124
|
],
|
|
95
125
|
};
|
|
96
126
|
}
|
|
@@ -198,30 +228,31 @@ export function removeOrphanedPluginVersions(versionsDirectory) {
|
|
|
198
228
|
return removedPaths;
|
|
199
229
|
}
|
|
200
230
|
|
|
201
|
-
function absentHostOutcome(
|
|
231
|
+
function absentHostOutcome(allSpecs, host, executable) {
|
|
232
|
+
const pluginNames = allSpecs.map(eachSpec => eachSpec.name).join(', ');
|
|
202
233
|
return {
|
|
203
234
|
host,
|
|
204
235
|
executable,
|
|
205
236
|
status: 'skipped',
|
|
206
|
-
warning: `${executable} is not on PATH, so ${
|
|
237
|
+
warning: `${executable} is not on PATH, so ${pluginNames} was not installed for ${host}.`,
|
|
207
238
|
};
|
|
208
239
|
}
|
|
209
240
|
|
|
210
|
-
function installForHost(
|
|
211
|
-
const plan =
|
|
241
|
+
function installForHost(allSpecs, host, homeDirectory, environment, runCommand) {
|
|
242
|
+
const plan = marketplacePluginsPlan(allSpecs, host);
|
|
212
243
|
const executable = environment[plan.executableVariable] || plan.executable;
|
|
213
244
|
const commandEnvironment = { [plan.homeVariable]: homeDirectory };
|
|
214
245
|
for (const commandArguments of plan.removalCommands) {
|
|
215
246
|
const outcome = runCommand(executable, [...commandArguments], {
|
|
216
247
|
environment: commandEnvironment,
|
|
217
248
|
});
|
|
218
|
-
if (namesAnAbsentCommand(outcome)) return absentHostOutcome(
|
|
249
|
+
if (namesAnAbsentCommand(outcome)) return absentHostOutcome(allSpecs, host, executable);
|
|
219
250
|
}
|
|
220
251
|
for (const commandArguments of plan.commands) {
|
|
221
252
|
const outcome = runCommand(executable, [...commandArguments], {
|
|
222
253
|
environment: commandEnvironment,
|
|
223
254
|
});
|
|
224
|
-
if (namesAnAbsentCommand(outcome)) return absentHostOutcome(
|
|
255
|
+
if (namesAnAbsentCommand(outcome)) return absentHostOutcome(allSpecs, host, executable);
|
|
225
256
|
if (outcome.status !== 0 || outcome.error) {
|
|
226
257
|
const detail = firstLine(outcome.stderr) || outcome.error?.message || `exit ${outcome.status}`;
|
|
227
258
|
return {
|
|
@@ -234,17 +265,19 @@ function installForHost(spec, host, homeDirectory, environment, runCommand) {
|
|
|
234
265
|
}
|
|
235
266
|
const hostDefinition = HOST_DEFINITIONS[host];
|
|
236
267
|
if (hostDefinition.orphanedVersionsDirectory && homeDirectory) {
|
|
237
|
-
|
|
268
|
+
for (const eachSpec of allSpecs) {
|
|
269
|
+
removeOrphanedPluginVersions(hostDefinition.orphanedVersionsDirectory(homeDirectory, eachSpec));
|
|
270
|
+
}
|
|
238
271
|
}
|
|
239
272
|
return { host, executable, status: 'installed', warning: null };
|
|
240
273
|
}
|
|
241
274
|
|
|
242
275
|
/**
|
|
243
|
-
* Install
|
|
276
|
+
* Install a set of plugins from one marketplace into each host's own home.
|
|
244
277
|
*
|
|
245
|
-
* Each host first removes
|
|
246
|
-
* version never survives beside the new one. A removal that finds
|
|
247
|
-
* to remove exits non-zero on a fresh home, and the install goes on.
|
|
278
|
+
* Each host first removes every plugin in the set and then the marketplace,
|
|
279
|
+
* so an older version never survives beside the new one. A removal that finds
|
|
280
|
+
* nothing to remove exits non-zero on a fresh home, and the install goes on.
|
|
248
281
|
*
|
|
249
282
|
* Each host is one member of the batch. A host without its command-line tool
|
|
250
283
|
* is skipped and a host whose command fails is reported, so the rules, hooks,
|
|
@@ -253,22 +286,24 @@ function installForHost(spec, host, homeDirectory, environment, runCommand) {
|
|
|
253
286
|
* `cmd.exe`'s command-not-found exit code on Windows, and both read as
|
|
254
287
|
* skipped.
|
|
255
288
|
*
|
|
256
|
-
* @param {typeof PSTACK_PLUGIN_SPEC}
|
|
289
|
+
* @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins to install, from one marketplace.
|
|
257
290
|
* @param {object} [options] Install targets.
|
|
258
291
|
* @param {string} [options.claudeRoot] The managed Claude root to install into.
|
|
259
292
|
* @param {string} [options.codexHome] The Codex home to install into.
|
|
260
|
-
* @param {string[]} [options.hosts] The hosts to install. Defaults to the spec
|
|
293
|
+
* @param {string[]} [options.hosts] The hosts to install. Defaults to the hosts every spec names.
|
|
261
294
|
* @param {Record<string, string|undefined>} [options.environment] The process environment.
|
|
262
295
|
* @param {object} [dependencies] Seams for the command runner.
|
|
263
296
|
* @returns {{status: string, hosts: object[], warning: string|null}} The outcome per host.
|
|
264
297
|
*/
|
|
265
|
-
export function
|
|
298
|
+
export function installMarketplacePlugins(allSpecs, options = {}, dependencies = {}) {
|
|
266
299
|
const environment = options.environment ?? process.env;
|
|
267
300
|
const runCommand = dependencies.runCommand ?? runHostCommand;
|
|
268
301
|
const homeDirectories = { claude: options.claudeRoot, codex: options.codexHome };
|
|
269
|
-
const hosts = options.hosts ??
|
|
302
|
+
const hosts = options.hosts ?? allSpecs[0].hosts.filter(
|
|
303
|
+
eachHost => allSpecs.every(eachSpec => eachSpec.hosts.includes(eachHost)),
|
|
304
|
+
);
|
|
270
305
|
const hostOutcomes = hosts.map(host => installForHost(
|
|
271
|
-
|
|
306
|
+
allSpecs,
|
|
272
307
|
host,
|
|
273
308
|
homeDirectories[host],
|
|
274
309
|
environment,
|