valise-sh 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +152 -2
  2. package/README.md +99 -146
  3. package/bin/valise +57 -5376
  4. package/docs/architecture.md +77 -121
  5. package/docs/contrib-modules.md +172 -218
  6. package/docs/installation.md +137 -83
  7. package/docs/module-abi.md +125 -126
  8. package/docs/projects.md +249 -0
  9. package/docs/release-policy.md +68 -93
  10. package/docs/shared-modules.md +40 -39
  11. package/examples/README.md +12 -11
  12. package/examples/e2e/README.md +59 -66
  13. package/examples/e2e/bundles.tsv +6 -0
  14. package/examples/e2e/module.d/e2e_aliases.meta +1 -2
  15. package/examples/e2e/module.d/e2e_completions_bash.meta +0 -1
  16. package/examples/e2e/module.d/e2e_completions_zsh.meta +0 -1
  17. package/examples/e2e/module.d/e2e_env_setup.meta +2 -3
  18. package/examples/e2e/module.d/e2e_fish_features.meta +0 -1
  19. package/examples/e2e/module.d/e2e_generated_config.meta +1 -2
  20. package/examples/e2e/module.d/e2e_ksh_features.meta +0 -1
  21. package/examples/e2e/module.d/e2e_layer_probe.meta +1 -2
  22. package/examples/e2e/module.d/e2e_optional_network.meta +1 -2
  23. package/examples/e2e/module.d/e2e_platform_tools.meta +8 -0
  24. package/examples/e2e/module.d/e2e_platform_tools_bsd.meta +2 -3
  25. package/examples/e2e/module.d/e2e_platform_tools_darwin.meta +2 -3
  26. package/examples/e2e/module.d/e2e_platform_tools_linux.meta +2 -3
  27. package/examples/e2e/valise.conf +4 -0
  28. package/examples/e2e/variants.tsv +3 -0
  29. package/examples/local/module.d/hello_valise.meta +1 -2
  30. package/examples/profile-project/README.md +78 -108
  31. package/examples/profile-project/bundles.tsv +8 -0
  32. package/examples/profile-project/{shell/layers → layers}/demo/module.d/contrib_demo_greeting.meta +1 -2
  33. package/examples/profile-project/{shell/module.d → module.d}/greeting.meta +1 -2
  34. package/examples/profile-project/{shell/module.d → module.d}/profile_main.meta +0 -1
  35. package/examples/profile-project/{shell/modules → modules}/greeting.sh +1 -1
  36. package/examples/profile-project/valise.conf +4 -0
  37. package/examples/profile-project/variants.tsv +3 -0
  38. package/examples/shared/README.md +13 -34
  39. package/examples/shared/module.d/contrib_shared_paths.meta +2 -3
  40. package/examples/shared/module.d/contrib_shared_umask.meta +1 -2
  41. package/examples/shared/modules/shared/contrib_shared_paths.sh +2 -2
  42. package/lib/capabilities.sh +40 -13
  43. package/lib/cli/commands.sh +278 -0
  44. package/lib/cli/common.sh +326 -0
  45. package/lib/cli/devtools.sh +539 -0
  46. package/lib/cli/fish-hook.sh +986 -0
  47. package/lib/cli/integration.sh +337 -0
  48. package/lib/cli/lifecycle.sh +578 -0
  49. package/lib/cli/project.sh +1807 -0
  50. package/lib/cli/store.sh +1023 -0
  51. package/lib/cli/usage.sh +308 -0
  52. package/lib/compiler.awk +11 -4
  53. package/lib/fish/loader.fish +69 -9
  54. package/lib/fish/metadata.fish +49 -5
  55. package/lib/fish/profile.fish +170 -0
  56. package/lib/loader.sh +6 -8
  57. package/lib/metadata.sh +111 -5
  58. package/manifests/parser-support.tsv +2 -2
  59. package/module.d/valise_editor.meta +9 -0
  60. package/module.d/valise_history.meta +9 -0
  61. package/module.d/valise_pager.meta +9 -0
  62. package/module.d/valise_path.meta +9 -0
  63. package/module.d/{platform_bsd.meta → valise_platform_bsd.meta} +3 -4
  64. package/module.d/{platform_linux.meta → valise_platform_linux.meta} +3 -4
  65. package/module.d/{platform_macos.meta → valise_platform_macos.meta} +3 -4
  66. package/module.d/{platform_posix.meta → valise_platform_posix.meta} +2 -3
  67. package/module.d/valise_xdg.meta +9 -0
  68. package/modules/system/{contrib_editor.sh → valise_editor.sh} +2 -2
  69. package/modules/system/{contrib_history.sh → valise_history.sh} +2 -2
  70. package/modules/system/{contrib_pager.sh → valise_pager.sh} +2 -2
  71. package/modules/system/{contrib_path.sh → valise_path.sh} +3 -3
  72. package/modules/system/{contrib_xdg.sh → valise_xdg.sh} +2 -2
  73. package/package.json +91 -10
  74. package/docs/variants.md +0 -327
  75. package/entrypoints/.gitkeep +0 -0
  76. package/entrypoints/fish/profile.fish +0 -99
  77. package/examples/e2e/build-manifest.sh +0 -138
  78. package/examples/local/.gitkeep +0 -0
  79. package/examples/profile-project/shell/release/bundle-roots +0 -9
  80. package/examples/profile-project/shell/release/install-variants +0 -3
  81. package/examples/profile-project/shell/release/module-registry.tsv +0 -4
  82. package/examples/profile-project/shell/release/project.conf +0 -3
  83. package/lib/.gitkeep +0 -0
  84. package/manifests/.gitkeep +0 -0
  85. package/module.d/.gitkeep +0 -0
  86. package/module.d/contrib_editor.meta +0 -10
  87. package/module.d/contrib_history.meta +0 -10
  88. package/module.d/contrib_pager.meta +0 -10
  89. package/module.d/contrib_path.meta +0 -10
  90. package/module.d/contrib_xdg.meta +0 -10
  91. package/modules/.gitkeep +0 -0
  92. /package/examples/profile-project/{shell/layers → layers}/demo/modules/shared/contrib_demo_greeting.sh +0 -0
@@ -0,0 +1,578 @@
1
+ # Copyright (c) 2026 Joseph Cumines
2
+ # SPDX-License-Identifier: MIT
3
+ # valise CLI: lifecycle: the previous-release record and rollback, the install
4
+ # transaction, unmanaged integration detection, uninstall, and the install
5
+ # preview behind apply --dry-run.
6
+ # Sourced by bin/valise; not executable on its own.
7
+
8
+ _prev_tmp=""
9
+ _dry_tmp=""
10
+ _un_tmp=""
11
+ _install_txn=0
12
+ _txn_old_target=""
13
+ _txn_had_previous=0
14
+ _txn_old_previous=""
15
+ _txn_changed=""
16
+
17
+ # The startup files valise may write a managed block into.
18
+ VALISE_STARTUP_NAMES='.profile .bash_profile .bash_login .bashrc .zprofile .zshrc'
19
+
20
+ previous_record_path() {
21
+ printf '%s\n' "$VALISE_XDG_STATE/previous"
22
+ }
23
+
24
+ # Startup files install created from nothing, one path per line. Uninstall
25
+ # deletes such a file when removing the block leaves it empty.
26
+ startup_created_path() {
27
+ printf '%s\n' "$VALISE_XDG_STATE/startup-created"
28
+ }
29
+
30
+ fish_hook_path() {
31
+ printf '%s\n' "${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/valise.fish"
32
+ }
33
+
34
+ # release_name_valid <name>: a release directory name valise will act on.
35
+ release_name_valid() {
36
+ case "$1" in
37
+ '' | *[!A-Za-z0-9_.-]* | .* | *- | *..*) return 1 ;;
38
+ esac
39
+ return 0
40
+ }
41
+
42
+ # write_previous_record <release-id>
43
+ write_previous_record() {
44
+ _wpr_record=$(previous_record_path)
45
+ [ ! -L "$_wpr_record" ] || die "previous-release record must not be a symlink: $_wpr_record"
46
+ _wpr_mkdir=$(trusted_utility_path mkdir) || die "no trusted mkdir utility available"
47
+ "$_wpr_mkdir" -p "$VALISE_XDG_STATE" || die "cannot create state directory: $VALISE_XDG_STATE"
48
+ _prev_tmp=$(trusted_mktemp_at "$VALISE_XDG_STATE" file "$VALISE_XDG_STATE/.previous.XXXXXX") ||
49
+ die "cannot create previous-release staging file"
50
+ printf '%s\n' "$1" >"$_prev_tmp" || die "cannot write previous-release record"
51
+ _wpr_chmod=$(trusted_utility_path chmod) || die "no trusted chmod utility available"
52
+ "$_wpr_chmod" 0644 "$_prev_tmp" || die "cannot set previous-release record mode"
53
+ _wpr_mv=$(trusted_utility_path mv) || die "no trusted mv utility available"
54
+ "$_wpr_mv" -f "$_prev_tmp" "$_wpr_record" || die "cannot publish previous-release record"
55
+ _prev_tmp=""
56
+ unset _wpr_record _wpr_mkdir _wpr_chmod _wpr_mv
57
+ }
58
+
59
+ # read_previous_record: print the recorded previous release, or fail.
60
+ read_previous_record() {
61
+ _rpr_record=$(previous_record_path)
62
+ [ -f "$_rpr_record" ] && [ ! -L "$_rpr_record" ] || return 1
63
+ IFS= read -r _rpr_id <"$_rpr_record" || [ -n "${_rpr_id:-}" ] || return 1
64
+ release_name_valid "$_rpr_id" || return 1
65
+ printf '%s\n' "$_rpr_id"
66
+ }
67
+
68
+ # note_switch <old-target> <new-target>
69
+ # Record the release the active link named before a switch, when it named an
70
+ # installed release and the switch changed it.
71
+ note_switch() {
72
+ [ -n "$1" ] && [ "$1" != "$2" ] || return 0
73
+ case "$1" in
74
+ "$VALISE_RELEASES_DIR"/*) _ns_id=${1#"$VALISE_RELEASES_DIR"/} ;;
75
+ *) return 0 ;;
76
+ esac
77
+ case "$_ns_id" in */*) unset _ns_id; return 0 ;; esac
78
+ release_name_valid "$_ns_id" || { unset _ns_id; return 0; }
79
+ write_previous_record "$_ns_id"
80
+ unset _ns_id
81
+ }
82
+
83
+ # release_activate <release-id>
84
+ # Verify an installed release, make it active, and move the command stubs to
85
+ # it when install published them. Used by release use and release rollback.
86
+ release_activate() {
87
+ _ra_id=$1
88
+ release_name_valid "$_ra_id" || die "invalid release name: $_ra_id"
89
+ _ra_dir="$VALISE_RELEASES_DIR/$_ra_id"
90
+ [ -e "$_ra_dir" ] || die "release not found: $_ra_id"
91
+ [ -d "$_ra_dir" ] || die "release target is not a directory: $_ra_id"
92
+ [ ! -L "$_ra_dir" ] || die "release target must not be a symlink: $_ra_id"
93
+ _ra_root=$(cd "$VALISE_RELEASES_DIR" 2>/dev/null && pwd -P) || die "release root unavailable"
94
+ _ra_parent=$(cd "$(path_parent "$_ra_dir")" 2>/dev/null && pwd -P) || die "release target parent unavailable"
95
+ case "$_ra_parent/" in
96
+ "$_ra_root/"*) : ;;
97
+ *) die "release target escapes release root: $_ra_id" ;;
98
+ esac
99
+ _ra_manifest="$_ra_dir/.valise-manifest"
100
+ [ -f "$_ra_manifest" ] && [ ! -L "$_ra_manifest" ] ||
101
+ die "release manifest snapshot is missing or not a regular file: $_ra_id"
102
+ prepare_manifest_rows "$_ra_manifest" || die "cannot snapshot the release manifest"
103
+ validate_manifest "$_manifest_rows" || die "invalid release manifest snapshot"
104
+ verify_release_dir "$_ra_dir" "$_manifest_rows" "$_ra_id"
105
+ release_commands "$_ra_dir" >/dev/null || die "release $_ra_id lists an invalid command name"
106
+ _ra_config_parent=$(path_parent "$VALISE_ACTIVE_LINK")
107
+ if [ ! -d "$_ra_config_parent" ]; then
108
+ _ra_mkdir=$(trusted_utility_path mkdir) || die "no trusted mkdir utility available"
109
+ "$_ra_mkdir" -p "$_ra_config_parent" || die "cannot create $_ra_config_parent"
110
+ fi
111
+ acquire_lock
112
+ switch_active_link "$_ra_dir"
113
+ # Commands follow the active release only where install published them: a
114
+ # ledger exists once an install ran with integration.
115
+ if [ -f "$(commands_ledger_path)" ]; then
116
+ reconcile_commands "$_ra_dir"
117
+ fi
118
+ release_lock
119
+ unset _ra_id _ra_dir _ra_root _ra_parent _ra_manifest _ra_config_parent _ra_mkdir
120
+ }
121
+
122
+ cmd_release_rollback() {
123
+ case "${1:-}" in
124
+ --help | -h) usage_release; return 0 ;;
125
+ '') ;;
126
+ *) die "unknown release rollback option: $1" ;;
127
+ esac
128
+ _rr_id=$(read_previous_record) ||
129
+ die "no previous release is recorded (one is recorded each time the active release changes)"
130
+ release_activate "$_rr_id"
131
+ info "rolled back to release $_rr_id"
132
+ unset _rr_id
133
+ }
134
+
135
+ # --- Install transaction ---
136
+
137
+ # install_txn_begin: remember what an install may have to restore.
138
+ install_txn_begin() {
139
+ _txn_old_target=
140
+ if [ -L "$VALISE_ACTIVE_LINK" ]; then
141
+ _txn_old_target=$(readlink_target "$VALISE_ACTIVE_LINK" 2>/dev/null || :)
142
+ fi
143
+ _txn_had_previous=0
144
+ _txn_old_previous=
145
+ if _txn_old_previous=$(read_previous_record); then
146
+ _txn_had_previous=1
147
+ fi
148
+ _txn_changed=
149
+ }
150
+
151
+ # install_txn_abort: run from the exit and signal handlers while an install
152
+ # that has switched the active link is unfinished. Puts the previous active
153
+ # release back and says what had already changed. It must not call die.
154
+ install_txn_abort() {
155
+ _install_txn=0
156
+ _ita_ln=$(trusted_utility_path ln 2>/dev/null || :)
157
+ _ita_rm=$(trusted_utility_path rm 2>/dev/null || :)
158
+ if [ -n "$_txn_old_target" ]; then
159
+ if [ -n "$_ita_ln" ] && "$_ita_ln" -sfn "$_txn_old_target" "$VALISE_ACTIVE_LINK" 2>/dev/null; then
160
+ printf 'valise: install failed; the active release is back to %s\n' "${_txn_old_target##*/}" >&2
161
+ else
162
+ printf 'valise: install failed and the previous active release could not be restored: %s\n' "$_txn_old_target" >&2
163
+ fi
164
+ else
165
+ if [ -n "$_ita_rm" ] && "$_ita_rm" -f "$VALISE_ACTIVE_LINK" 2>/dev/null; then
166
+ printf 'valise: install failed; no release is active, as before the install\n' >&2
167
+ else
168
+ printf 'valise: install failed and the new active link could not be removed: %s\n' "$VALISE_ACTIVE_LINK" >&2
169
+ fi
170
+ fi
171
+ if [ "$_txn_had_previous" -eq 1 ]; then
172
+ (
173
+ trap '[ -z "${_prev_tmp:-}" ] || [ -z "$_ita_rm" ] || "$_ita_rm" -f "$_prev_tmp"' EXIT
174
+ write_previous_record "$_txn_old_previous"
175
+ ) >/dev/null 2>&1 ||
176
+ printf 'valise: cannot restore the previous-release record\n' >&2
177
+ elif [ -n "$_ita_rm" ]; then
178
+ "$_ita_rm" -f "$(previous_record_path)" 2>/dev/null || :
179
+ fi
180
+ # The subshells remove their own staging files: a die inside them exits
181
+ # the subshell, and this process's lock handler never saw those names.
182
+ if [ -f "$(commands_ledger_path)" ]; then
183
+ if [ -n "$_txn_old_target" ] && [ -d "$_txn_old_target" ]; then
184
+ (
185
+ trap 'for _t in "${_rc_tmp:-}" "${_rc_stub:-}" "${_rc_pub:-}"; do [ -z "$_t" ] || [ -z "$_ita_rm" ] || "$_ita_rm" -f "$_t"; done' EXIT
186
+ reconcile_commands "$_txn_old_target"
187
+ ) >/dev/null 2>&1 ||
188
+ printf 'valise: cannot move the command stubs back to the restored release\n' >&2
189
+ elif [ -z "$_txn_old_target" ]; then
190
+ # No release was active before, so no stub may point at one now.
191
+ (remove_owned_commands) >/dev/null 2>&1 ||
192
+ printf 'valise: cannot remove the command stubs this install published\n' >&2
193
+ fi
194
+ fi
195
+ if [ -n "$_txn_changed" ]; then
196
+ printf 'valise: already updated (they load whichever release is active):%s\n' "$_txn_changed" >&2
197
+ fi
198
+ unset _ita_ln _ita_rm
199
+ }
200
+
201
+ # The line a managed block carries when install appended it to a file whose
202
+ # last line had no newline.
203
+ VALISE_NONL_NOTE='# valise: the line before this block had no trailing newline'
204
+
205
+ # --- Integration checks ---
206
+
207
+ # preflight_dir_writable <dir> <what>: fail unless <dir>, or the nearest
208
+ # ancestor that exists when <dir> does not, is a writable directory. Install
209
+ # creates missing directories and stages files beside their targets.
210
+ preflight_dir_writable() {
211
+ _pdw_dir=$1
212
+ while [ ! -e "$_pdw_dir" ] && [ ! -L "$_pdw_dir" ]; do
213
+ _pdw_dir=$(path_parent "$_pdw_dir")
214
+ done
215
+ [ -d "$_pdw_dir" ] || die "$2 directory is not a directory: $_pdw_dir"
216
+ [ -w "$_pdw_dir" ] || die "cannot write the $2 in $_pdw_dir"
217
+ unset _pdw_dir
218
+ }
219
+
220
+ # integration_preflight <release-dir>: fail before anything changes when
221
+ # install could not write the integration or command stubs it is about to
222
+ # write.
223
+ integration_preflight() {
224
+ if [ -n "${HOME:-}" ]; then
225
+ _ipf_home=$(path_normalize "$HOME") || die "cannot resolve HOME"
226
+ _ipf_targets=$(posix_startup_targets "$_ipf_home")
227
+ while IFS= read -r _ipf_target; do
228
+ [ -n "$_ipf_target" ] || continue
229
+ # A symlinked startup file is left for the user; install only warns.
230
+ [ ! -L "$_ipf_target" ] || continue
231
+ if [ -e "$_ipf_target" ]; then
232
+ [ -f "$_ipf_target" ] || die "startup path is not a regular file: $_ipf_target"
233
+ [ -r "$_ipf_target" ] || die "cannot read startup file: $_ipf_target"
234
+ integration_markers_paired "$_ipf_target" ||
235
+ die "unpaired valise managed block markers in $_ipf_target (restore or remove the BEGIN and END lines, then retry)"
236
+ fi
237
+ _ipf_parent=$(path_parent "$_ipf_target")
238
+ [ -d "$_ipf_parent" ] && [ -w "$_ipf_parent" ] ||
239
+ die "cannot write startup files in $_ipf_parent"
240
+ done <<EOF
241
+ $_ipf_targets
242
+ EOF
243
+ fi
244
+ if [ -n "$(trusted_utility_path fish 2>/dev/null || :)" ]; then
245
+ _ipf_hook=$(fish_hook_path)
246
+ [ ! -L "$_ipf_hook" ] || die "Fish hook path must not be a symlink: $_ipf_hook"
247
+ [ ! -d "$_ipf_hook" ] || die "Fish hook path is a directory: $_ipf_hook"
248
+ if [ -e "$_ipf_hook" ] && [ ! -f "$_ipf_hook" ]; then
249
+ die "Fish hook path is not a regular file: $_ipf_hook"
250
+ fi
251
+ preflight_dir_writable "$(path_parent "$_ipf_hook")" "Fish hook"
252
+ fi
253
+ # Stubs are written when the release publishes commands and removed when a
254
+ # ledger lists stubs this release no longer provides.
255
+ if [ -n "$(release_commands "$1")" ] || [ -f "$(commands_ledger_path)" ]; then
256
+ _ipf_bin=$(commands_bin_dir) || die "cannot resolve the command directory: set HOME or VALISE_BIN_DIR"
257
+ preflight_dir_writable "$_ipf_bin" "command stubs"
258
+ fi
259
+ unset _ipf_home _ipf_targets _ipf_target _ipf_parent _ipf_hook _ipf_bin
260
+ }
261
+
262
+ # integration_unmanaged_lines <file>
263
+ # Print file:line: text for each line outside a managed block that loads a
264
+ # valise release by hand: a reference to the active link, a 0.1 generated
265
+ # module list, or a line that sources a compiled plan. Comments are skipped.
266
+ integration_unmanaged_lines() {
267
+ [ -f "$1" ] && [ -r "$1" ] || return 0
268
+ _iul_awk=$(trusted_utility_path awk) || return 1
269
+ "$_iul_awk" '
270
+ /^# BEGIN valise managed block$/ { inside = 1; next }
271
+ /^# END valise managed block$/ { inside = 0; next }
272
+ inside { next }
273
+ /^[[:space:]]*#/ { next }
274
+ index($0, "valise/active") || index($0, "installed-modules.") ||
275
+ ($0 ~ /(^|[;&|[:space:]])(\.|source)[[:space:]]/ && $0 ~ /plans\/(sh|bash|zsh|ksh)\.sh/) {
276
+ printf "%s:%d: %s\n", FILENAME, NR, $0
277
+ }
278
+ ' "$1"
279
+ }
280
+
281
+ # integration_unmanaged_report: print every unmanaged line in the startup
282
+ # files valise knows about; succeed when there are none.
283
+ integration_unmanaged_report() {
284
+ [ -n "${HOME:-}" ] || return 0
285
+ _iur_home=$(path_normalize "$HOME") || return 0
286
+ _iur_found=0
287
+ for _iur_name in $VALISE_STARTUP_NAMES; do
288
+ _iur_lines=$(integration_unmanaged_lines "$_iur_home/$_iur_name") || _iur_lines=
289
+ [ -n "$_iur_lines" ] || continue
290
+ printf '%s\n' "$_iur_lines"
291
+ _iur_found=1
292
+ done
293
+ _iur_rc=$_iur_found
294
+ unset _iur_home _iur_found _iur_name _iur_lines
295
+ [ "$_iur_rc" -eq 0 ]
296
+ }
297
+
298
+ # startup_created_add <path>: remember that install created this file.
299
+ startup_created_add() {
300
+ _sca_ledger=$(startup_created_path)
301
+ [ ! -L "$_sca_ledger" ] || die "startup ledger must not be a symlink: $_sca_ledger"
302
+ if [ -f "$_sca_ledger" ]; then
303
+ while IFS= read -r _sca_line; do
304
+ if [ "$_sca_line" = "$1" ]; then
305
+ unset _sca_ledger _sca_line
306
+ return 0
307
+ fi
308
+ done <"$_sca_ledger"
309
+ fi
310
+ printf '%s\n' "$1" >>"$_sca_ledger" || die "cannot record created startup file"
311
+ unset _sca_ledger _sca_line
312
+ }
313
+
314
+ startup_created_has() {
315
+ _sch_ledger=$(startup_created_path)
316
+ [ -f "$_sch_ledger" ] && [ ! -L "$_sch_ledger" ] || return 1
317
+ while IFS= read -r _sch_line; do
318
+ [ "$_sch_line" != "$1" ] || { unset _sch_ledger _sch_line; return 0; }
319
+ done <"$_sch_ledger"
320
+ unset _sch_ledger _sch_line
321
+ return 1
322
+ }
323
+
324
+ # --- Uninstall ---
325
+
326
+ # integration_has_posix_block <file>: the file holds a managed block marker.
327
+ integration_has_posix_block() {
328
+ _ihpb_awk=$(trusted_utility_path awk) || return 1
329
+ "$_ihpb_awk" '/^# BEGIN valise managed block$/ { found = 1; exit } END { exit !found }' "$1" 2>/dev/null
330
+ }
331
+
332
+ # uninstall_strip_block <file>: remove the managed block from one startup file,
333
+ # keeping every other byte and the file mode. A file install created that is
334
+ # left empty is deleted.
335
+ uninstall_strip_block() {
336
+ _usb_target=$1
337
+ _usb_awk=$(trusted_utility_path awk) || die "no trusted awk utility available"
338
+ _usb_parent=$(path_parent "$_usb_target")
339
+ _un_tmp=$(trusted_mktemp_at "$_usb_parent" file "$_usb_parent/.valise-uninstall.XXXXXX") ||
340
+ die "cannot create uninstall staging file in $_usb_parent"
341
+ # A block carrying the no-newline note was appended to a last line that
342
+ # had no newline; when nothing follows the block, that newline goes too.
343
+ # Text after the block keeps the file's own ending (nonl: no final newline).
344
+ _usb_tail=$(trusted_utility_path tail) || die "no trusted tail utility available"
345
+ _usb_nonl=0
346
+ [ ! -s "$_usb_target" ] || [ -z "$("$_usb_tail" -c 1 "$_usb_target")" ] || _usb_nonl=1
347
+ _VALISE_NOTE=$VALISE_NONL_NOTE "$_usb_awk" -v nonl="$_usb_nonl" '
348
+ /^# BEGIN valise managed block$/ { inblock = 1; next }
349
+ inblock && $0 == ENVIRON["_VALISE_NOTE"] { had_note = 1; next }
350
+ inblock && /^# END valise managed block$/ { inblock = 0; closed = 1; next }
351
+ inblock { next }
352
+ { if (closed) after = 1; line[++n] = $0 }
353
+ END {
354
+ for (i = 1; i <= n; i++) {
355
+ if (i == n && ((had_note && !after) || (nonl && after))) printf "%s", line[i]
356
+ else print line[i]
357
+ }
358
+ }
359
+ ' "$_usb_target" >"$_un_tmp" || die "cannot read startup file: $_usb_target"
360
+ _usb_rm=$(trusted_utility_path rm) || die "no trusted rm utility available"
361
+ if [ ! -s "$_un_tmp" ] && startup_created_has "$_usb_target"; then
362
+ "$_usb_rm" -f "$_usb_target" || die "cannot remove startup file: $_usb_target"
363
+ "$_usb_rm" -f "$_un_tmp" || die "cannot remove uninstall staging file"
364
+ _un_tmp=""
365
+ info "removed startup file valise created: $_usb_target"
366
+ else
367
+ _usb_mode=$(file_mode "$_usb_target")
368
+ [ -n "$_usb_mode" ] || die "cannot read startup file mode: $_usb_target"
369
+ _usb_chmod=$(trusted_utility_path chmod) || die "no trusted chmod utility available"
370
+ "$_usb_chmod" "$_usb_mode" "$_un_tmp" || die "cannot set startup file mode"
371
+ _usb_mv=$(trusted_utility_path mv) || die "no trusted mv utility available"
372
+ "$_usb_mv" -f "$_un_tmp" "$_usb_target" || die "cannot rewrite startup file: $_usb_target"
373
+ _un_tmp=""
374
+ info "removed the managed block from $_usb_target"
375
+ fi
376
+ unset _usb_target _usb_awk _usb_tail _usb_nonl _usb_parent _usb_rm _usb_mode _usb_chmod _usb_mv
377
+ }
378
+
379
+ cmd_uninstall() {
380
+ _un_keep=0
381
+ for _un_arg do
382
+ case "$_un_arg" in
383
+ --keep-releases) _un_keep=1 ;;
384
+ --help | -h) usage_uninstall; return 0 ;;
385
+ *) die "unknown uninstall option: $_un_arg (usage: valise uninstall [--keep-releases])" ;;
386
+ esac
387
+ done
388
+ _un_rm=$(trusted_utility_path rm) || die "no trusted rm utility available"
389
+ _un_rmdir=$(trusted_utility_path rmdir) || die "no trusted rmdir utility available"
390
+ _un_mkdir=$(trusted_utility_path mkdir) || die "no trusted mkdir utility available"
391
+ "$_un_mkdir" -p "$VALISE_XDG_STATE" || die "cannot create state directory: $VALISE_XDG_STATE"
392
+ acquire_lock
393
+
394
+ _un_home=
395
+ if [ -n "${HOME:-}" ]; then
396
+ _un_home=$(path_normalize "$HOME") || die "cannot resolve HOME"
397
+ # Check every file before changing any, so a damaged block stops the
398
+ # uninstall with nothing removed.
399
+ for _un_name in $VALISE_STARTUP_NAMES; do
400
+ _un_file="$_un_home/$_un_name"
401
+ [ -f "$_un_file" ] && [ ! -L "$_un_file" ] || continue
402
+ integration_markers_paired "$_un_file" ||
403
+ die "unpaired valise managed block markers in $_un_file (restore or remove the BEGIN and END lines, then retry)"
404
+ done
405
+ for _un_name in $VALISE_STARTUP_NAMES; do
406
+ _un_file="$_un_home/$_un_name"
407
+ if [ -L "$_un_file" ]; then
408
+ if [ -f "$_un_file" ] && integration_has_posix_block "$_un_file"; then
409
+ info "warning: $_un_file is a symlink; remove the valise managed block from it yourself"
410
+ fi
411
+ continue
412
+ fi
413
+ [ -f "$_un_file" ] || continue
414
+ integration_has_posix_block "$_un_file" || continue
415
+ uninstall_strip_block "$_un_file"
416
+ done
417
+ _un_hook=$(fish_hook_path)
418
+ if [ -f "$_un_hook" ] && [ ! -L "$_un_hook" ]; then
419
+ IFS= read -r _un_first <"$_un_hook" || :
420
+ if [ "${_un_first:-}" = '# valise Fish integration. Do not edit.' ]; then
421
+ "$_un_rm" -f "$_un_hook" || die "cannot remove the Fish hook: $_un_hook"
422
+ info "removed the Fish hook: $_un_hook"
423
+ else
424
+ info "warning: leaving $_un_hook: it is not the valise Fish hook"
425
+ fi
426
+ elif [ -e "$_un_hook" ] || [ -L "$_un_hook" ]; then
427
+ info "warning: leaving $_un_hook: it is not a regular file"
428
+ fi
429
+ _un_lines=$(integration_unmanaged_report) || {
430
+ info "warning: these lines load valise outside a managed block; remove them yourself:"
431
+ printf '%s\n' "$_un_lines"
432
+ }
433
+ else
434
+ info "warning: HOME is not set; startup files were not checked"
435
+ fi
436
+
437
+ remove_owned_commands
438
+
439
+ if [ -L "$VALISE_ACTIVE_LINK" ]; then
440
+ "$_un_rm" -f "$VALISE_ACTIVE_LINK" || die "cannot remove the active link: $VALISE_ACTIVE_LINK"
441
+ info "removed the active release link"
442
+ elif [ -e "$VALISE_ACTIVE_LINK" ]; then
443
+ info "warning: leaving $VALISE_ACTIVE_LINK: it is not a symlink"
444
+ fi
445
+ for _un_record in "$(previous_record_path)" "$(startup_created_path)"; do
446
+ if [ -f "$_un_record" ] || [ -L "$_un_record" ]; then
447
+ "$_un_rm" -f "$_un_record" || die "cannot remove $_un_record"
448
+ fi
449
+ done
450
+ if [ "$_un_keep" -eq 0 ]; then
451
+ if [ -d "$VALISE_RELEASES_DIR" ] && [ ! -L "$VALISE_RELEASES_DIR" ]; then
452
+ remove_tree "$VALISE_RELEASES_DIR" || die "cannot remove installed releases: $VALISE_RELEASES_DIR"
453
+ info "removed installed releases"
454
+ fi
455
+ "$_un_rmdir" "$VALISE_XDG_DATA" 2>/dev/null || :
456
+ else
457
+ info "kept installed releases in $VALISE_RELEASES_DIR"
458
+ fi
459
+ "$_un_rmdir" "$VALISE_XDG_CONFIG" 2>/dev/null || :
460
+ release_lock
461
+ "$_un_rmdir" "$VALISE_XDG_STATE" 2>/dev/null || :
462
+ info "valise integration removed; the valise CLI itself is still installed"
463
+ unset _un_keep _un_arg _un_rm _un_rmdir _un_mkdir _un_home _un_name _un_file _un_hook _un_first \
464
+ _un_lines _un_record
465
+ }
466
+
467
+ # --- Install preview ---
468
+
469
+ # _dry_render_compare <target> <render-command...>
470
+ # Print how install would treat one file: current, update, or create.
471
+ _dry_render_compare() {
472
+ _drc_target=$1
473
+ shift
474
+ _dry_tmp=$(trusted_mktemp file "${TMPDIR:-/tmp}/valise-dry-run.XXXXXX") || die "cannot create preview file"
475
+ "$@" >"$_dry_tmp" || die "cannot render $_drc_target"
476
+ _drc_cmp=$(trusted_utility_path cmp) || die "no trusted cmp utility available"
477
+ if [ -f "$_drc_target" ] && "$_drc_cmp" -s "$_dry_tmp" "$_drc_target"; then
478
+ printf ' current: %s\n' "$_drc_target"
479
+ elif [ -e "$_drc_target" ]; then
480
+ printf ' would update: %s\n' "$_drc_target"
481
+ else
482
+ printf ' would create: %s\n' "$_drc_target"
483
+ fi
484
+ _drc_rm=$(trusted_utility_path rm) || die "no trusted rm utility available"
485
+ "$_drc_rm" -f "$_dry_tmp" || die "cannot remove preview file"
486
+ _dry_tmp=""
487
+ unset _drc_target _drc_cmp _drc_rm
488
+ }
489
+
490
+ # install_preview <integration>
491
+ # Describe what valise install would do with the release tree in the current
492
+ # directory, writing nothing outside the temporary directory.
493
+ install_preview() {
494
+ _ip_integration=$1
495
+ _ip_source=$(locate_manifest) || die "cannot find manifest.tsv in the current directory"
496
+ _ip_root=$(cd "$(path_parent "$_ip_source")" && pwd -P) || die "cannot resolve manifest source root"
497
+ prepare_manifest_rows "$_ip_source" || die "cannot normalize manifest.tsv"
498
+ validate_manifest "$_manifest_rows" || die "invalid manifest.tsv schema or row"
499
+ _ip_id=$(file_sha256 "$_manifest_rows") || die "no trusted SHA-256 utility available"
500
+ printf 'release %s\n' "$_ip_id"
501
+ if [ -d "$VALISE_RELEASES_DIR/$_ip_id" ]; then
502
+ printf ' already installed\n'
503
+ else
504
+ printf ' would install to %s\n' "$VALISE_RELEASES_DIR/$_ip_id"
505
+ fi
506
+ _ip_active=$(active_release_id 2>/dev/null || :)
507
+ if [ "$_ip_active" = "$_ip_id" ]; then
508
+ printf ' already active\n'
509
+ else
510
+ printf ' would become active (now: %s)\n' "${_ip_active:-none}"
511
+ fi
512
+ if [ "$_ip_integration" -eq 0 ]; then
513
+ printf 'integration: skipped (--no-integration)\n'
514
+ unset _ip_integration _ip_source _ip_root _ip_id _ip_active
515
+ return 0
516
+ fi
517
+ printf 'startup files\n'
518
+ if [ -n "${HOME:-}" ]; then
519
+ _ip_home=$(path_normalize "$HOME") || die "cannot resolve HOME"
520
+ _ip_snippet=$(print_posix_integration) || die "cannot generate integration block"
521
+ _ip_targets=$(posix_startup_targets "$_ip_home")
522
+ while IFS= read -r _ip_target; do
523
+ [ -n "$_ip_target" ] || continue
524
+ if [ -L "$_ip_target" ]; then
525
+ printf ' left alone (symlink): %s\n' "$_ip_target"
526
+ elif [ -e "$_ip_target" ] && ! integration_markers_paired "$_ip_target"; then
527
+ printf ' would fail (unpaired markers): %s\n' "$_ip_target"
528
+ else
529
+ _dry_render_compare "$_ip_target" integration_render "$_ip_target" "$_ip_snippet"
530
+ fi
531
+ done <<EOF
532
+ $_ip_targets
533
+ EOF
534
+ _ip_lines=$(integration_unmanaged_report) ||
535
+ printf ' warning: valise loaded outside a managed block:\n%s\n' "$_ip_lines"
536
+ else
537
+ printf ' none (HOME is not set)\n'
538
+ fi
539
+ if [ -n "$(trusted_utility_path fish 2>/dev/null || :)" ]; then
540
+ _dry_render_compare "$(fish_hook_path)" print_fish_integration
541
+ fi
542
+ printf 'commands\n'
543
+ _ip_any=0
544
+ _ip_want=$(release_commands "$_ip_root") || die "the release lists an invalid command name"
545
+ if _ip_bin=$(commands_bin_dir); then
546
+ _ip_ledger=$(commands_ledger_path)
547
+ _ip_awk=$(trusted_utility_path awk) || die "no trusted awk utility available"
548
+ if [ -f "$_ip_ledger" ]; then
549
+ while IFS="$(printf '\t')" read -r _ip_name _ip_path _ip_sha; do
550
+ [ -n "$_ip_name" ] || continue
551
+ case " $_ip_want " in *" $_ip_name "*) [ "$_ip_path" != "$_ip_bin/$_ip_name" ] || continue ;; esac
552
+ printf ' would remove: %s\n' "$_ip_path"
553
+ _ip_any=1
554
+ done <"$_ip_ledger"
555
+ fi
556
+ for _ip_name in $_ip_want; do
557
+ _ip_path="$_ip_bin/$_ip_name"
558
+ _ip_sha=
559
+ [ ! -f "$_ip_ledger" ] ||
560
+ _ip_sha=$("$_ip_awk" -F '\t' -v n="$_ip_name" -v p="$_ip_path" '$1 == n && $2 == p { print $3; exit }' "$_ip_ledger")
561
+ if [ -n "$_ip_sha" ] && commands_file_is "$_ip_path" "$_ip_sha"; then
562
+ printf ' current: %s\n' "$_ip_path"
563
+ _ip_any=1
564
+ elif [ -e "$_ip_path" ] || [ -L "$_ip_path" ]; then
565
+ printf ' would skip (exists, not owned by valise): %s\n' "$_ip_path"
566
+ _ip_any=1
567
+ else
568
+ printf ' would publish: %s\n' "$_ip_path"
569
+ _ip_any=1
570
+ fi
571
+ done
572
+ else
573
+ [ -z "$_ip_want" ] || { printf ' none (HOME is not set)\n'; _ip_any=1; }
574
+ fi
575
+ [ "$_ip_any" -eq 1 ] || printf ' none\n'
576
+ unset _ip_integration _ip_source _ip_root _ip_id _ip_active _ip_home _ip_snippet _ip_targets _ip_target \
577
+ _ip_lines _ip_want _ip_bin _ip_ledger _ip_awk _ip_name _ip_path _ip_sha _ip_any
578
+ }