valise-sh 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/valise CHANGED
@@ -7,37 +7,215 @@
7
7
 
8
8
  set -eu
9
9
 
10
- VALISE_VERSION="0.1.0"
11
-
12
- # XDG defaults per install-contracts.md
13
- VALISE_XDG_DATA="${XDG_DATA_HOME:-$HOME/.local/share}/valise"
14
- VALISE_XDG_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/valise"
15
- VALISE_XDG_STATE="${XDG_STATE_HOME:-$HOME/.local/state}/valise"
16
- VALISE_RELEASES_DIR="$VALISE_XDG_DATA/releases"
17
- VALISE_ACTIVE_LINK="$VALISE_XDG_CONFIG/active"
18
- VALISE_LOCK_DIR="$VALISE_XDG_STATE/lock"
10
+ VALISE_VERSION="0.2.0"
19
11
 
20
12
  usage() {
21
13
  printf '%s\n' \
22
14
  "Usage: valise <command> [options]" \
23
15
  "" \
24
- "Commands:" \
25
- " init [bash|zsh|ksh|fish] Output shell integration snippet" \
26
- " install Install or update the active release" \
27
- " verify-active [--print-root] Verify the sealed active release" \
28
- " doctor Run environment diagnostics" \
29
- " variant [list|use <sha>] List installed releases / switch active release" \
16
+ "Project commands (run in a valise project):" \
17
+ " new [dir] Scaffold a new valise project" \
18
+ " module <new|list|remove> Create, list, or remove project modules" \
19
+ " build [--variant=<name>] [--bundle=<name>] [out]" \
20
+ " Build a release tree (default: build/release)" \
21
+ " apply [--variant=<name>] [--bundle=<name>] [--no-integration]" \
22
+ " Build the project, install it, and integrate it" \
23
+ " status Show project, active release, integration, and drift" \
24
+ "" \
25
+ "Release commands:" \
26
+ " install [--no-integration] Install the release tree in the current directory" \
27
+ " release <list|use|prune|verify> Manage installed releases" \
28
+ " init [posix|fish] Print the shell integration snippet" \
29
+ " doctor [--pre-install] [--no-integration] Run environment diagnostics" \
30
+ "" \
31
+ "Plumbing:" \
32
+ " compile [--bundle=<name>] [--variant=<name>] <output-dir> Compile plans only" \
33
+ " registry [--check] [path] Generate or verify module-registry.tsv from metadata" \
30
34
  " make-manifest <tree> [--output <file>] Write a release manifest for a tree" \
31
- " compile [--bundle=<name>] [--variant=<name>] <output-dir> (consuming project)" \
32
- " explain-variant [name] Show variant details and per-shell impact (consuming project)" \
33
- " explain-bundles Explain installed bundle roots and closure (consuming project)" \
34
- " layer-lint <name> Validate a contribution layer structure (consuming project)" \
35
- " version Print version" \
35
+ " explain-variant [name] Show variant details and per-shell impact" \
36
+ " explain-bundles [--bundle=<name>] Explain bundle roots and module closure" \
37
+ " layer-lint <name> Validate a contribution layer structure" \
38
+ " version Print version" \
36
39
  "" \
37
40
  "Environment:" \
38
41
  " XDG_DATA_HOME Release storage root (default: ~/.local/share)" \
39
42
  " XDG_CONFIG_HOME Active pointer symlink (default: ~/.config)" \
40
- " XDG_STATE_HOME Lock and journal state (default: ~/.local/state)"
43
+ " XDG_STATE_HOME Lock and journal state (default: ~/.local/state)" \
44
+ "" \
45
+ "Run 'valise <command> --help' for command-specific usage."
46
+ }
47
+
48
+ # Per-command usage. Each command prints its own contract instead of the
49
+ # global list, so `valise compile --help` names the arguments that command
50
+ # actually accepts.
51
+
52
+ usage_init() {
53
+ printf '%s\n' \
54
+ "usage: valise init [posix|fish] [--source-tree]" \
55
+ "" \
56
+ "Print the shell integration snippet for the active release. valise install" \
57
+ "and valise apply write it for you; use this to wire a startup file by hand." \
58
+ "" \
59
+ " posix A managed block for any POSIX shell. It loads the plan for" \
60
+ " the running shell, or the loader when the release has none." \
61
+ " fish The Fish conf.d hook." \
62
+ " --source-tree Skip release verification in the Fish hook (development only)." \
63
+ "" \
64
+ "With no argument, fish when \$SHELL names fish, otherwise posix."
65
+ }
66
+
67
+ usage_install() {
68
+ printf '%s\n' \
69
+ "usage: valise install [--no-integration]" \
70
+ "" \
71
+ "Install the release tree in the current directory and make it active, then" \
72
+ "write the shell integration." \
73
+ "" \
74
+ "A managed block is written to ~/.profile; to the first of ~/.bash_profile and" \
75
+ "~/.bash_login that exists, because it shadows ~/.profile for bash; to ~/.bashrc" \
76
+ "and ~/.zshrc when that shell is \$SHELL or the file exists; and to ~/.zprofile" \
77
+ "when zsh is installed or the file exists. Symlinked files are left alone. A" \
78
+ "Fish conf.d hook is written when Fish is installed. Each install replaces the" \
79
+ "block in place." \
80
+ "" \
81
+ " --no-integration Write no startup file and no Fish hook."
82
+ }
83
+
84
+ usage_doctor() {
85
+ printf '%s\n' \
86
+ "usage: valise doctor [--pre-install] [--no-integration]" \
87
+ "" \
88
+ "Report the environment, the active release, and the shell integration." \
89
+ "" \
90
+ " --pre-install Skip the active-release and integration checks, for use" \
91
+ " before the first install." \
92
+ " --no-integration Check the environment and active release only."
93
+ }
94
+
95
+ usage_release() {
96
+ printf '%s\n' \
97
+ "usage: valise release <list|use|prune|verify> [options]" \
98
+ "" \
99
+ " list List installed releases and mark the active one." \
100
+ " use <sha256> Verify a release and make it active." \
101
+ " prune [--keep=N] Remove older releases, keeping the active one and the" \
102
+ " N newest by modification time. A release that fails" \
103
+ " verification is kept, not removed." \
104
+ " verify [--print-root] Verify the sealed active release and optionally" \
105
+ " print its root." \
106
+ "" \
107
+ "prune --keep=0 keeps only the active release."
108
+ }
109
+
110
+ usage_new() {
111
+ printf '%s\n' \
112
+ "usage: valise new [dir]" \
113
+ "" \
114
+ "Scaffold a new valise project in dir (default: the current directory)." \
115
+ "" \
116
+ "The directory must be empty or absent. The scaffold contains a seed module," \
117
+ "bundle roots, install variants, and project defaults."
118
+ }
119
+
120
+ usage_module() {
121
+ printf '%s\n' \
122
+ "usage: valise module <new|list|remove> [options]" \
123
+ "" \
124
+ " new <name> [--exports=<list>] Create a module and wire it into the seed." \
125
+ " list List the project's modules." \
126
+ " remove <name> Remove a module and its references in metadata," \
127
+ " bundle roots, and variant exclusions. Refuses" \
128
+ " if that would leave a bundle or module empty."
129
+ }
130
+
131
+ usage_build() {
132
+ printf '%s\n' \
133
+ "usage: valise build [--variant=<name>] [--bundle=<name>] [out]" \
134
+ "" \
135
+ "Build a release tree from the project in the current directory." \
136
+ "" \
137
+ " --variant=<name> Build variant whose exclusion list applies (default:" \
138
+ " default, or the project's project.conf)." \
139
+ " --bundle=<name> Bundle whose roots seed each plan (default: default," \
140
+ " or the project's project.conf)." \
141
+ " out Output directory (default: build/release). An existing" \
142
+ " default output is replaced after a successful build." \
143
+ " An explicit output must be empty or absent." \
144
+ "" \
145
+ "The tree carries generated/installed-modules.<shell> plans, the capability" \
146
+ "prelude, every module body, and shell/release."
147
+ }
148
+
149
+ usage_apply() {
150
+ printf '%s\n' \
151
+ "usage: valise apply [--variant=<name>] [--bundle=<name>] [--no-integration]" \
152
+ "" \
153
+ "Build the project, install the result, write the shell integration, and verify" \
154
+ "the active release. See 'valise install --help' for the files it writes." \
155
+ "" \
156
+ " --no-integration Write no startup file and no Fish hook."
157
+ }
158
+
159
+ usage_status() {
160
+ printf '%s\n' \
161
+ "usage: valise status" \
162
+ "" \
163
+ "Report the project, the active release, the integration, and build drift."
164
+ }
165
+
166
+ usage_compile() {
167
+ printf '%s\n' \
168
+ "usage: valise compile [--bundle=<name>] [--variant=<name>] <output-dir>" \
169
+ "" \
170
+ "Compile the project's modules into per-shell installed plans." \
171
+ "" \
172
+ " --bundle=<name> Bundle whose roots seed each plan (default: default)." \
173
+ " --variant=<name> Build variant whose exclusion list applies (default:" \
174
+ " default)." \
175
+ " output-dir Destination directory. It must be empty or absent." \
176
+ "" \
177
+ "Run from the consuming project root."
178
+ }
179
+
180
+ usage_registry() {
181
+ printf '%s\n' \
182
+ "usage: valise registry [--check] [registry-path]" \
183
+ "" \
184
+ "Generate the module registry from the project's metadata." \
185
+ "" \
186
+ " --check Compare the generated registry with the existing one and fail on" \
187
+ " drift instead of rewriting it." \
188
+ " registry-path Registry file (default: shell/release/module-registry.tsv)."
189
+ }
190
+
191
+ usage_explain_bundles() {
192
+ printf '%s\n' \
193
+ "usage: valise explain-bundles [--bundle=<name>]" \
194
+ "" \
195
+ "Show the roots and module closure each bundle resolves to, per shell." \
196
+ "" \
197
+ " --bundle=<name> Report one bundle instead of all of them."
198
+ }
199
+
200
+ usage_layer_lint() {
201
+ printf '%s\n' \
202
+ "usage: valise layer-lint <layer-name>" \
203
+ "" \
204
+ "Validate a contribution layer's structure, namespace, metadata, load paths," \
205
+ "file modes, and dependency closure." \
206
+ "" \
207
+ " layer-name Provider directory under shell/layers/, or a path to a layer" \
208
+ " tree containing module.d/."
209
+ }
210
+
211
+ usage_explain_variant() {
212
+ printf '%s\n' \
213
+ "usage: valise explain-variant [variant-name]" \
214
+ "" \
215
+ "Show a variant's description, excluded modules, and per-shell impact." \
216
+ "" \
217
+ " variant-name Variant from shell/release/install-variants" \
218
+ " (default: default)."
41
219
  }
42
220
 
43
221
  die() {
@@ -49,8 +227,84 @@ info() {
49
227
  printf 'valise: %s\n' "$*"
50
228
  }
51
229
 
52
- # POSIX shell path parent extraction avoids ambient dirname resolution in
53
- # manifest discovery and every subsequent source/destination validation path.
230
+ # Verification failure reporting. The default mode aborts the command, which
231
+ # is what install and release use. Quiet mode returns a nonzero status
232
+ # instead, so a diagnostic command can report the failure and carry on. The
233
+ # message text is identical in both modes.
234
+ _verify_quiet=0
235
+ _verify_fail() {
236
+ if [ "$_verify_quiet" -eq 1 ]; then
237
+ printf 'valise: error: %s\n' "$*" >&2
238
+ return 1
239
+ fi
240
+ die "$*"
241
+ }
242
+
243
+ # Lexically normalize an absolute path: collapse repeated slashes, drop "."
244
+ # segments, and resolve ".." against the preceding component. Relative paths
245
+ # are resolved against the physical working directory first.
246
+ path_normalize() {
247
+ _path_norm_value=$1
248
+ case "$_path_norm_value" in
249
+ /*) ;;
250
+ *) _path_norm_value=$(pwd -P)/$_path_norm_value ;;
251
+ esac
252
+ _path_norm_out=
253
+ if [ "${IFS+x}" = x ]; then
254
+ _path_norm_ifs_was_set=1
255
+ _path_norm_old_ifs=${IFS-}
256
+ else
257
+ _path_norm_ifs_was_set=0
258
+ _path_norm_old_ifs=
259
+ fi
260
+ case $- in
261
+ *f*) _path_norm_glob_was_set=1 ;;
262
+ *) _path_norm_glob_was_set=0 ;;
263
+ esac
264
+ set -f
265
+ IFS=/
266
+ for _path_norm_part in $_path_norm_value; do
267
+ case "$_path_norm_part" in
268
+ ''|.) continue ;;
269
+ ..)
270
+ case "$_path_norm_out" in
271
+ '') ;;
272
+ */*) _path_norm_out=${_path_norm_out%/*} ;;
273
+ *) _path_norm_out= ;;
274
+ esac
275
+ continue
276
+ ;;
277
+ esac
278
+ _path_norm_out=${_path_norm_out:+$_path_norm_out/}$_path_norm_part
279
+ done
280
+ if [ "$_path_norm_ifs_was_set" -eq 1 ]; then
281
+ IFS=$_path_norm_old_ifs
282
+ else
283
+ unset IFS
284
+ fi
285
+ if [ "$_path_norm_glob_was_set" -eq 0 ]; then
286
+ set +f
287
+ fi
288
+ printf '/%s\n' "$_path_norm_out"
289
+ }
290
+
291
+ # XDG defaults per install-contracts.md.
292
+ #
293
+ # The roots are normalized to absolute paths. A relative XDG_DATA_HOME would
294
+ # otherwise be written into the active symlink verbatim, and a relative
295
+ # symlink target resolves against the directory holding the link rather
296
+ # than the caller's working directory. The pointer would then dangle, and
297
+ # prune would compare the active release by a path spelling that never
298
+ # matches the release directory.
299
+ VALISE_XDG_DATA=$(path_normalize "${XDG_DATA_HOME:-$HOME/.local/share}/valise")
300
+ VALISE_XDG_CONFIG=$(path_normalize "${XDG_CONFIG_HOME:-$HOME/.config}/valise")
301
+ VALISE_XDG_STATE=$(path_normalize "${XDG_STATE_HOME:-$HOME/.local/state}/valise")
302
+ VALISE_RELEASES_DIR="$VALISE_XDG_DATA/releases"
303
+ VALISE_ACTIVE_LINK="$VALISE_XDG_CONFIG/active"
304
+ VALISE_LOCK_DIR="$VALISE_XDG_STATE/lock"
305
+
306
+ # POSIX path parent extraction avoids ambient dirname resolution in manifest
307
+ # discovery and every subsequent source/destination validation path.
54
308
  path_parent() {
55
309
  _path_parent_value=$1
56
310
  # Strip trailing slashes so "out/" and "out" share one parent (".").
@@ -71,6 +325,20 @@ path_parent() {
71
325
  printf '%s\n' "$_path_parent_value"
72
326
  }
73
327
 
328
+ # Directory of this script with symlinks resolved, so package installs that
329
+ # expose bin/valise through a symlink (bun, npm) still find ../lib.
330
+ valise_script_dir() {
331
+ _vsd_path=$0
332
+ while [ -L "$_vsd_path" ]; do
333
+ _vsd_target=$(readlink "$_vsd_path") || return 1
334
+ case "$_vsd_target" in
335
+ /*) _vsd_path=$_vsd_target ;;
336
+ *) _vsd_path=$(path_parent "$_vsd_path")/$_vsd_target ;;
337
+ esac
338
+ done
339
+ (cd "$(path_parent "$_vsd_path")" && pwd -P)
340
+ }
341
+
74
342
  # Emit a single-quoted literal for generated POSIX shell code. The generated
75
343
  # snippets must not interpolate user-controlled XDG paths into double quotes:
76
344
  # `$()` and backticks would still execute there. POSIX single quotes preserve
@@ -162,6 +430,79 @@ readlink_target() {
162
430
  "$_readlink_path" "$1"
163
431
  }
164
432
 
433
+ # active_release_dir
434
+ # Print the physical directory the active pointer names, or fail. The result
435
+ # is canonical, so a trailing slash, a relative target, or an alias symlink
436
+ # never changes the release identity.
437
+ active_release_dir() {
438
+ [ -L "$VALISE_ACTIVE_LINK" ] || return 1
439
+ _ard_target=$(readlink_target "$VALISE_ACTIVE_LINK" 2>/dev/null) || return 1
440
+ [ -n "$_ard_target" ] || return 1
441
+ case "$_ard_target" in
442
+ /*) : ;;
443
+ *)
444
+ _ard_parent=$(cd "$(path_parent "$VALISE_ACTIVE_LINK")" 2>/dev/null && pwd -P) || return 1
445
+ _ard_target="$_ard_parent/$_ard_target"
446
+ ;;
447
+ esac
448
+ _ard_target=$(path_normalize "$_ard_target") || return 1
449
+ (cd "$_ard_target" 2>/dev/null && pwd -P)
450
+ }
451
+
452
+ # active_release_id
453
+ # Print the active release's directory name. Identity is the name, not the
454
+ # path spelling.
455
+ active_release_id() {
456
+ _ari_dir=$(active_release_dir) || return 1
457
+ printf '%s\n' "${_ari_dir##*/}"
458
+ }
459
+
460
+ # Resolve the consuming project root for the commands that read project
461
+ # files (compile, build, explain-*, layer-lint). The working directory
462
+ # wins over the engine's own checkout, so a package install cannot supply a
463
+ # different source root than the project the caller is standing in. A
464
+ # project is recognised by shell/release/module-registry.tsv or by a
465
+ # shell/module.d directory.
466
+ #
467
+ # Prints the canonical project root, or fails.
468
+ resolve_project_root() {
469
+ _rpr_script_dir=$(valise_script_dir) || return 1
470
+ for _rpr_candidate in "." "$_rpr_script_dir/.." "$_rpr_script_dir/../.." "$_rpr_script_dir/../../.."; do
471
+ if [ -f "$_rpr_candidate/shell/release/module-registry.tsv" ] ||
472
+ [ -d "$_rpr_candidate/shell/module.d" ]; then
473
+ (cd "$_rpr_candidate" && pwd -P)
474
+ return 0
475
+ fi
476
+ done
477
+ return 1
478
+ }
479
+
480
+ # Resolve the engine checkout that ships this CLI. Its modules live in
481
+ # module.d/ at the top level, not under shell/.
482
+ resolve_engine_root() {
483
+ _rer_script_dir=$(valise_script_dir) || return 1
484
+ for _rer_candidate in "$_rer_script_dir/.." "$_rer_script_dir/../.." "$_rer_script_dir/../../.."; do
485
+ if [ -d "$_rer_candidate/module.d" ] && [ -d "$_rer_candidate/lib" ]; then
486
+ (cd "$_rer_candidate" && pwd -P)
487
+ return 0
488
+ fi
489
+ done
490
+ return 1
491
+ }
492
+
493
+ # Print the module names a layer is allowed to depend on: the engine's own
494
+ # modules, plus the primary-root modules of the consuming project. A layer
495
+ # may require a module in its own layer, or one of these; anything else is a
496
+ # dependency the layer does not own.
497
+ engine_module_names() {
498
+ _emn_root=$1
499
+ for _emn_meta in "$_emn_root"/module.d/*.meta "$_emn_root"/shell/module.d/*.meta; do
500
+ [ -f "$_emn_meta" ] || continue
501
+ _emn_name=${_emn_meta##*/}
502
+ printf '%s\n' "${_emn_name%.meta}"
503
+ done
504
+ }
505
+
165
506
  # Return a file's three-digit permission mode in the manifest's octal form.
166
507
  # BSD stat and GNU stat use different option spellings, so probe both.
167
508
  file_mode() {
@@ -187,6 +528,25 @@ file_bytes() {
187
528
  "$_wc_path" -c < "$1" | "$_tr_path" -d '[:space:]'
188
529
  }
189
530
 
531
+ # Return a file's modification time in whole epoch seconds. BSD stat and GNU
532
+ # stat use different option spellings, so probe both and select the result by
533
+ # validating the output rather than trusting exit status alone: GNU stat
534
+ # accepts -f as a filesystem-status flag and can exit successfully while
535
+ # printing something that is not a timestamp.
536
+ file_mtime() {
537
+ _mtime_stat=$(trusted_utility_path stat) || return 1
538
+ _mtime_value=$("$_mtime_stat" -c %Y "$1" 2>/dev/null || :)
539
+ case "$_mtime_value" in
540
+ '' | *[!0-9]*) ;;
541
+ *) printf '%s\n' "$_mtime_value"; return 0 ;;
542
+ esac
543
+ _mtime_value=$("$_mtime_stat" -f %m "$1" 2>/dev/null || :)
544
+ case "$_mtime_value" in
545
+ '' | *[!0-9]*) return 1 ;;
546
+ *) printf '%s\n' "$_mtime_value" ;;
547
+ esac
548
+ }
549
+
190
550
  file_sha256() {
191
551
  _sha_path=$(trusted_utility_path sha256sum 2>/dev/null || :)
192
552
  if [ -n "$_sha_path" ]; then
@@ -223,15 +583,13 @@ lower_hex() {
223
583
  # participates in lock integrity is resolved by absolute path so PATH cannot
224
584
  # forge ownership or suppress stale-lock cleanup.
225
585
  _lock_owned=0
226
- _active_tmp=""
227
- _active_backup=""
228
586
  _publish_tmp=""
229
587
  _manifest_rows=""
230
588
  _manifest_parse=""
231
589
  _stage=""
232
590
  _hook_tmp=""
233
591
  _mm_tmp=""
234
- _posix_tmp=""
592
+ _rr_tmp=""
235
593
  _mv_path=""
236
594
  _cleanup_failed=0
237
595
  acquire_lock() {
@@ -316,20 +674,6 @@ EOF
316
674
  release_lock() {
317
675
  _release_rm_path=$(trusted_utility_path rm 2>/dev/null || :)
318
676
  _release_rmdir_path=$(trusted_utility_path rmdir 2>/dev/null || :)
319
- if [ -n "$_active_tmp" ]; then
320
- [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_active_tmp" 2>/dev/null || :
321
- if [ -e "$_active_tmp" ] || [ -L "$_active_tmp" ]; then _cleanup_failed=1; fi
322
- _active_tmp=""
323
- fi
324
- if [ -n "$_active_backup" ]; then
325
- if [ -L "$_active_backup" ] && [ ! -e "$VALISE_ACTIVE_LINK" ] && [ ! -L "$VALISE_ACTIVE_LINK" ]; then
326
- [ -n "$_mv_path" ] && "$_mv_path" "$_active_backup" "$VALISE_ACTIVE_LINK" 2>/dev/null || :
327
- else
328
- [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_active_backup" 2>/dev/null || :
329
- fi
330
- if [ -e "$_active_backup" ] || [ -L "$_active_backup" ]; then _cleanup_failed=1; fi
331
- _active_backup=""
332
- fi
333
677
  if [ -n "$_publish_tmp" ]; then
334
678
  remove_tree "$_publish_tmp" || _cleanup_failed=1
335
679
  _publish_tmp=""
@@ -344,6 +688,41 @@ release_lock() {
344
688
  if [ -e "$_manifest_parse" ] || [ -L "$_manifest_parse" ]; then _cleanup_failed=1; fi
345
689
  _manifest_parse=""
346
690
  fi
691
+ if [ -n "${_pm_tmp:-}" ]; then
692
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_pm_tmp" 2>/dev/null || :
693
+ if [ -e "$_pm_tmp" ] || [ -L "$_pm_tmp" ]; then _cleanup_failed=1; fi
694
+ _pm_tmp=""
695
+ fi
696
+ if [ -n "${_rr_tmp:-}" ]; then
697
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_rr_tmp" 2>/dev/null || :
698
+ if [ -e "$_rr_tmp" ] || [ -L "$_rr_tmp" ]; then _cleanup_failed=1; fi
699
+ _rr_tmp=""
700
+ fi
701
+ if [ -n "${_pi_tmp:-}" ]; then
702
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_pi_tmp" 2>/dev/null || :
703
+ if [ -e "$_pi_tmp" ] || [ -L "$_pi_tmp" ]; then _cleanup_failed=1; fi
704
+ _pi_tmp=""
705
+ fi
706
+ if [ -n "${_eb_registry_tmp:-}" ]; then
707
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_eb_registry_tmp" 2>/dev/null || :
708
+ if [ -e "$_eb_registry_tmp" ] || [ -L "$_eb_registry_tmp" ]; then _cleanup_failed=1; fi
709
+ _eb_registry_tmp=""
710
+ fi
711
+ if [ -n "${_doctor_cand_file:-}" ]; then
712
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_doctor_cand_file" 2>/dev/null || :
713
+ if [ -e "$_doctor_cand_file" ] || [ -L "$_doctor_cand_file" ]; then _cleanup_failed=1; fi
714
+ _doctor_cand_file=""
715
+ fi
716
+ if [ -n "${_module_list_tmp:-}" ]; then
717
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_module_list_tmp" 2>/dev/null || :
718
+ if [ -e "$_module_list_tmp" ] || [ -L "$_module_list_tmp" ]; then _cleanup_failed=1; fi
719
+ _module_list_tmp=""
720
+ fi
721
+ if [ -n "${_rg_tmp:-}" ]; then
722
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_rg_tmp" 2>/dev/null || :
723
+ if [ -e "$_rg_tmp" ] || [ -L "$_rg_tmp" ]; then _cleanup_failed=1; fi
724
+ _rg_tmp=""
725
+ fi
347
726
  if [ -n "$_stage" ]; then
348
727
  remove_tree "$_stage" || _cleanup_failed=1
349
728
  _stage=""
@@ -358,11 +737,6 @@ release_lock() {
358
737
  if [ -e "$_mm_tmp" ] || [ -L "$_mm_tmp" ]; then _cleanup_failed=1; fi
359
738
  _mm_tmp=""
360
739
  fi
361
- if [ -n "$_posix_tmp" ]; then
362
- [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_posix_tmp" 2>/dev/null || :
363
- if [ -e "$_posix_tmp" ] || [ -L "$_posix_tmp" ]; then _cleanup_failed=1; fi
364
- _posix_tmp=""
365
- fi
366
740
  [ "$_lock_owned" -eq 1 ] || { [ "$_cleanup_failed" -eq 0 ]; return; }
367
741
  [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$VALISE_LOCK_DIR/pid" 2>/dev/null || :
368
742
  if [ -e "$VALISE_LOCK_DIR/pid" ] || [ -L "$VALISE_LOCK_DIR/pid" ]; then _cleanup_failed=1; fi
@@ -394,15 +768,23 @@ switch_active_link() {
394
768
  if [ -e "$VALISE_ACTIVE_LINK" ] && [ ! -L "$VALISE_ACTIVE_LINK" ]; then
395
769
  die "active path exists and is not a symlink: $VALISE_ACTIVE_LINK"
396
770
  fi
397
- # ln -sfn replaces the destination symlink. On both BSD and GNU systems,
398
- # this executes unlink(2) followed by symlink(2), which is not atomic but
399
- # is the most portable approach available in pure POSIX sh (mv -h is
400
- # BSD-only, mv -T is GNU-only). The -n flag prevents following a
401
- # destination that is a symlink to a directory, making the swap act on
402
- # the symlink itself rather than descending into its target.
403
- "$_ln_path" -sfn "$1" "$VALISE_ACTIVE_LINK" || die "failed to update active symlink"
771
+ # ln -sfn replaces the destination symlink. The -n flag prevents following a
772
+ # destination that is a symlink to a directory. If the swap fails after the
773
+ # old link was removed, restore the prior target so the pointer is never
774
+ # left missing.
775
+ _switch_old_target=
776
+ if [ -L "$VALISE_ACTIVE_LINK" ]; then
777
+ _switch_old_target=$(readlink_target "$VALISE_ACTIVE_LINK" 2>/dev/null || :)
778
+ fi
779
+ if ! "$_ln_path" -sfn "$1" "$VALISE_ACTIVE_LINK"; then
780
+ if [ -n "$_switch_old_target" ]; then
781
+ "$_ln_path" -sfn "$_switch_old_target" "$VALISE_ACTIVE_LINK" 2>/dev/null || :
782
+ fi
783
+ die "failed to update active symlink"
784
+ fi
404
785
  [ -L "$VALISE_ACTIVE_LINK" ] || die "active symlink was not created"
405
786
  [ "$($_readlink_path "$VALISE_ACTIVE_LINK" 2>/dev/null)" = "$1" ] || die "active symlink target mismatch"
787
+ unset _switch_old_target
406
788
  }
407
789
 
408
790
  source_path_is_physical() {
@@ -527,15 +909,10 @@ prepare_manifest_rows() {
527
909
  }
528
910
 
529
911
  locate_manifest() {
530
- _loc_script_dir=$(cd "$(path_parent "$0")" && pwd -P) || return 1
531
- _loc_repo_root="$_loc_script_dir/.."
532
- for _loc_candidate in "$_loc_repo_root/../shell/install/profile-release.manifest" "$_loc_repo_root/shell/install/profile-release.manifest" "./shell/install/profile-release.manifest"
533
- do
534
- if [ -f "$_loc_candidate" ]; then
535
- printf '%s\n' "$_loc_candidate"
536
- return 0
537
- fi
538
- done
912
+ if [ -f "./shell/install/profile-release.manifest" ]; then
913
+ printf '%s\n' "./shell/install/profile-release.manifest"
914
+ return 0
915
+ fi
539
916
  return 1
540
917
  }
541
918
 
@@ -558,40 +935,40 @@ _manifest_has_descendant() {
558
935
  verify_release_dir() {
559
936
  _verify_dir=$1
560
937
  _verify_manifest=$2
561
- _verify_canonical=$(cd "$_verify_dir" 2>/dev/null && pwd -P) || die "cannot resolve release root"
562
- [ "$(file_mode "$_verify_dir")" = "0555" ] || die "release root mode mismatch"
938
+ _verify_canonical=$(cd "$_verify_dir" 2>/dev/null && pwd -P) || _verify_fail "cannot resolve release root" || return 1
939
+ [ "$(file_mode "$_verify_dir")" = "0555" ] || _verify_fail "release root mode mismatch" || return 1
563
940
  _verify_snapshot="$_verify_dir/.valise-manifest"
564
941
  if [ -e "$_verify_snapshot" ] || [ -L "$_verify_snapshot" ]; then
565
- [ -f "$_verify_snapshot" ] || die "release manifest snapshot is not a regular file"
566
- [ ! -L "$_verify_snapshot" ] || die "release manifest snapshot must not be a symlink"
567
- [ "$(file_mode "$_verify_snapshot")" = "0444" ] || die "release manifest snapshot mode mismatch"
568
- [ "$(file_bytes "$_verify_snapshot")" = "$(file_bytes "$_verify_manifest")" ] || die "release manifest snapshot byte count mismatch"
569
- _verify_snapshot_digest=$(file_sha256 "$_verify_snapshot") || die "no SHA-256 utility available"
570
- _verify_manifest_digest=$(file_sha256 "$_verify_manifest") || die "no SHA-256 utility available"
571
- [ "$_verify_snapshot_digest" = "$_verify_manifest_digest" ] || die "release manifest snapshot digest mismatch"
942
+ [ -f "$_verify_snapshot" ] || _verify_fail "release manifest snapshot is not a regular file" || return 1
943
+ [ ! -L "$_verify_snapshot" ] || _verify_fail "release manifest snapshot must not be a symlink" || return 1
944
+ [ "$(file_mode "$_verify_snapshot")" = "0444" ] || _verify_fail "release manifest snapshot mode mismatch" || return 1
945
+ [ "$(file_bytes "$_verify_snapshot")" = "$(file_bytes "$_verify_manifest")" ] || _verify_fail "release manifest snapshot byte count mismatch" || return 1
946
+ _verify_snapshot_digest=$(file_sha256 "$_verify_snapshot") || _verify_fail "no SHA-256 utility available" || return 1
947
+ _verify_manifest_digest=$(file_sha256 "$_verify_manifest") || _verify_fail "no SHA-256 utility available" || return 1
948
+ [ "$_verify_snapshot_digest" = "$_verify_manifest_digest" ] || _verify_fail "release manifest snapshot digest mismatch" || return 1
572
949
  _verify_expected_id="${3:-}"
573
950
  if [ -z "$_verify_expected_id" ]; then
574
951
  _verify_expected_id=$(basename "$_verify_dir")
575
952
  fi
576
- [ "$_verify_snapshot_digest" = "$_verify_expected_id" ] || die "release manifest snapshot identity mismatch"
953
+ [ "$_verify_snapshot_digest" = "$_verify_expected_id" ] || _verify_fail "release manifest snapshot identity mismatch" || return 1
577
954
  fi
578
955
  while IFS="$(printf '\t')" read -r _dest _src _mode _imode _size _sha; do
579
956
  [ -n "$_dest" ] || continue
580
957
  case "$_dest" in shell-profile-release-manifest-v1|destination) continue ;; esac
581
958
  _verify_file="$_verify_dir/$_dest"
582
- [ -f "$_verify_file" ] || die "release file missing: $_dest"
583
- [ ! -L "$_verify_file" ] || die "release file must not be a symlink: $_dest"
584
- _verify_parent=$(cd "$(path_parent "$_verify_file")" 2>/dev/null && pwd -P) || die "release parent missing: $_dest"
585
- case "$_verify_parent/" in "$_verify_canonical/"*) : ;; *) die "release path escapes target: $_dest" ;; esac
586
- [ "$(file_mode "$_verify_file")" = "$_imode" ] || die "release mode mismatch for $_dest"
587
- [ "$(file_bytes "$_verify_file")" = "$_size" ] || die "release byte count mismatch for $_dest"
588
- case "$_sha" in *[!0123456789abcdefABCDEF]*) die "invalid release digest for $_dest" ;; esac
589
- [ "${#_sha}" -eq 64 ] || die "invalid release digest for $_dest"
590
- _verify_actual=$(file_sha256 "$_verify_file") || die "no trusted SHA-256 utility available"
591
- _verify_expected=$(printf '%s' "$_sha" | lower_hex) || die "no trusted tr utility available"
592
- [ "$_verify_actual" = "$_verify_expected" ] || die "release digest mismatch for $_dest"
959
+ [ -f "$_verify_file" ] || _verify_fail "release file missing: $_dest" || return 1
960
+ [ ! -L "$_verify_file" ] || _verify_fail "release file must not be a symlink: $_dest" || return 1
961
+ _verify_parent=$(cd "$(path_parent "$_verify_file")" 2>/dev/null && pwd -P) || _verify_fail "release parent missing: $_dest" || return 1
962
+ case "$_verify_parent/" in "$_verify_canonical/"*) : ;; *) _verify_fail "release path escapes target: $_dest" || return 1 ;; esac
963
+ [ "$(file_mode "$_verify_file")" = "$_imode" ] || _verify_fail "release mode mismatch for $_dest" || return 1
964
+ [ "$(file_bytes "$_verify_file")" = "$_size" ] || _verify_fail "release byte count mismatch for $_dest" || return 1
965
+ case "$_sha" in *[!0123456789abcdefABCDEF]*) _verify_fail "invalid release digest for $_dest" || return 1 ;; esac
966
+ [ "${#_sha}" -eq 64 ] || _verify_fail "invalid release digest for $_dest" || return 1
967
+ _verify_actual=$(file_sha256 "$_verify_file") || _verify_fail "no trusted SHA-256 utility available" || return 1
968
+ _verify_expected=$(printf '%s' "$_sha" | lower_hex) || _verify_fail "no trusted tr utility available" || return 1
969
+ [ "$_verify_actual" = "$_verify_expected" ] || _verify_fail "release digest mismatch for $_dest" || return 1
593
970
  done < "${_manifest_parse:-$_manifest_rows}"
594
- _find_path=$(trusted_utility_path find) || die "no trusted find utility available"
971
+ _find_path=$(trusted_utility_path find) || _verify_fail "no trusted find utility available" || return 1
595
972
  _verify_bad=$("$_find_path" "$_verify_dir" -print | while IFS= read -r _verify_path; do
596
973
  [ "$_verify_path" = "$_verify_dir" ] && continue
597
974
  if [ -L "$_verify_path" ]; then
@@ -612,7 +989,7 @@ verify_release_dir() {
612
989
  break
613
990
  fi
614
991
  done)
615
- [ -z "$_verify_bad" ] || die "release contains unmanifested or mutable path: $_verify_bad"
992
+ [ -z "$_verify_bad" ] || _verify_fail "release contains unmanifested or mutable path: $_verify_bad" || return 1
616
993
  }
617
994
 
618
995
  # --- Subcommands ---
@@ -628,7 +1005,8 @@ print_fish_integration() {
628
1005
  _fish_cli_literal=$(fish_quote "$_fish_cli_path") || return 1
629
1006
  _fish_source_tree_mode=${_fish_source_tree_mode:-0}
630
1007
  printf '%s\n' \
631
- "# valise shell integration" \
1008
+ "# valise Fish integration. Do not edit." \
1009
+ "# This hook loads the active valise release for Fish sessions." \
632
1010
  "begin" \
633
1011
  "set -l _valise_fish_source_tree_mode $_fish_source_tree_mode" \
634
1012
  "set -l _valise_fish_previous_root ''" \
@@ -1145,6 +1523,12 @@ print_fish_integration() {
1145
1523
  " end" \
1146
1524
  " builtin functions -e \"\$_valise_fish_persistence_verify_helper\"; or return 125" \
1147
1525
  " end" \
1526
+ " if builtin set -q _valise_fish_owned_cleanup; and builtin functions -q \"\$_valise_fish_owned_cleanup\"" \
1527
+ " builtin functions -e \"\$_valise_fish_owned_cleanup\"; or return 125" \
1528
+ " end" \
1529
+ " if builtin set -q _valise_fish_persistence_path_physical_helper; and builtin functions -q \"\$_valise_fish_persistence_path_physical_helper\"" \
1530
+ " builtin functions -e \"\$_valise_fish_persistence_path_physical_helper\"; or return 125" \
1531
+ " end" \
1148
1532
  " if builtin functions -q \"\$_valise_fish_root_restore_helper\"" \
1149
1533
  " if not builtin test (builtin functions \"\$_valise_fish_root_restore_helper\" | string collect) = \"\$_valise_fish_root_helper_definition\"" \
1150
1534
  " builtin printf '%s\\n' 'trusted root restore helper was modified' >&2" \
@@ -1292,7 +1676,7 @@ print_fish_integration() {
1292
1676
  "if test \"\$_valise_fish_source_tree_mode\" = 1" \
1293
1677
  " set _valise_fish_active_root (builtin path resolve -- ${_fish_active_literal})" \
1294
1678
  "else" \
1295
- " set _valise_fish_active_root (${_fish_cli_literal} verify-active --print-root 2>/dev/null)" \
1679
+ " set _valise_fish_active_root (${_fish_cli_literal} release verify --print-root 2>/dev/null)" \
1296
1680
  "end" \
1297
1681
  "set -l _valise_fish_owned_cleanup __valise_fish_owned_cleanup_(builtin random)" \
1298
1682
  "function \$_valise_fish_owned_cleanup --no-scope-shadowing" \
@@ -1426,7 +1810,7 @@ print_fish_integration() {
1426
1810
  " return 1" \
1427
1811
  "end" \
1428
1812
  "if test \"\$_valise_fish_source_tree_mode\" != 1" \
1429
- " set -l _valise_fish_reverified_root (${_fish_cli_literal} verify-active --print-root 2>/dev/null)" \
1813
+ " set -l _valise_fish_reverified_root (${_fish_cli_literal} release verify --print-root 2>/dev/null)" \
1430
1814
  " if not test -x ${_fish_cli_literal}; or not test \"\$_valise_fish_reverified_root\" = \"\$_valise_fish_active_root\"" \
1431
1815
  " printf '%s\\n' 'valise Fish integration: active release authentication failed' >&2" \
1432
1816
  " if not builtin functions -q \$_valise_fish_owned_cleanup; or not builtin test (builtin functions \$_valise_fish_owned_cleanup | string collect) = \"\$_valise_fish_owned_cleanup_definition\"" \
@@ -1524,7 +1908,7 @@ print_fish_integration() {
1524
1908
  " set _entrypoint_status 1" \
1525
1909
  " end" \
1526
1910
  " end" \
1527
- " # The entrypoint is selected only after verify-active authenticates its" \
1911
+ " # The entrypoint is selected only after release verify authenticates its" \
1528
1912
  " # immutable release. Its native metadata/loader state is therefore an" \
1529
1913
  " # intentional result of authenticated activation, not caller mutation." \
1530
1914
  " # Keep the exact caller-function and embedded-function checks above; those" \
@@ -2295,19 +2679,10 @@ print_fish_integration() {
2295
2679
  " end" \
2296
2680
  " \$_valise_fish_publish_active_helper; or builtin exit 125" \
2297
2681
  " else" \
2298
- " printf '%s\\n' 'valise Fish integration: native Fish loader infrastructure is unavailable' >&2" \
2682
+ " # No Fish entrypoint, no generated Fish plan and no native Fish loader:" \
2683
+ " # the release has nothing this hook can load. Fall through to the shared" \
2684
+ " # success cleanup so the hook stays silent and leaves no helper behind." \
2299
2685
  " builtin functions -e \$_valise_fish_legacy_runner 2>/dev/null" \
2300
- " if not builtin functions -q \$_valise_fish_owned_cleanup; or not builtin test (builtin functions \$_valise_fish_owned_cleanup | string collect) = \"\$_valise_fish_owned_cleanup_definition\"" \
2301
- " builtin printf '%s\\n' 'trusted ownership cleanup helper was modified' >&2" \
2302
- " builtin exit 125" \
2303
- " end" \
2304
- " \$_valise_fish_owned_cleanup" \
2305
- " if not builtin functions -q \$_valise_fish_root_restore_helper; or not builtin test (builtin functions \$_valise_fish_root_restore_helper | string collect) = \"\$_valise_fish_root_helper_definition\"" \
2306
- " builtin printf '%s\\n' 'trusted root restore helper was modified' >&2" \
2307
- " builtin exit 125" \
2308
- " end" \
2309
- " \$_valise_fish_root_restore_helper; or builtin exit 125" \
2310
- " return 1" \
2311
2686
  "end" \
2312
2687
  " end" \
2313
2688
  "end" \
@@ -2320,62 +2695,165 @@ print_fish_integration() {
2320
2695
  "end"
2321
2696
  }
2322
2697
 
2698
+ # Emit the POSIX integration block for the active release.
2699
+ #
2700
+ # It works in every POSIX shell and with both release shapes. A built release
2701
+ # carries generated/installed-modules.<shell> plans; an engine release carries
2702
+ # entrypoints/profile.sh and the loader. The plan for the running shell is tried
2703
+ # first, then the loader, so a startup file never silently loads nothing when
2704
+ # the release has a way to load. The guard makes several startup files safe: a
2705
+ # login shell that reads more than one of them loads the profile once.
2706
+ print_posix_integration() {
2707
+ _pix_active_literal=$(shell_quote "$VALISE_ACTIVE_LINK") || return 1
2708
+ printf '%s\n' \
2709
+ '# BEGIN valise managed block' \
2710
+ "_valise_active_link=$_pix_active_literal" \
2711
+ 'if [ -z "${_valise_profile_loaded:-}" ] || [ "${VALISE_RELOAD:-}" = 1 ]; then' \
2712
+ ' _valise_profile_loaded=1' \
2713
+ ' if [ -n "${BASH_VERSION-}" ]; then _valise_plan=bash; elif [ -n "${ZSH_VERSION-}" ]; then _valise_plan=zsh; elif [ -n "${KSH_VERSION-}" ]; then _valise_plan=ksh; else _valise_plan=; fi' \
2714
+ ' _valise_active=$(cd "$_valise_active_link" 2>/dev/null && pwd -P)' \
2715
+ ' if [ -n "$_valise_active" ]; then' \
2716
+ ' if [ -n "$_valise_plan" ] && [ -f "$_valise_active/generated/installed-modules.$_valise_plan" ]; then' \
2717
+ ' VALISE_MODULE_ROOT="$_valise_active"' \
2718
+ ' . "$_valise_active/generated/installed-modules.$_valise_plan"' \
2719
+ ' unset VALISE_MODULE_ROOT' \
2720
+ ' elif [ -f "$_valise_active/entrypoints/profile.sh" ]; then' \
2721
+ ' VALISE_ROOT="$_valise_active"' \
2722
+ ' . "$_valise_active/entrypoints/profile.sh"' \
2723
+ ' fi' \
2724
+ ' fi' \
2725
+ ' unset _valise_active _valise_plan' \
2726
+ 'fi' \
2727
+ 'unset _valise_active_link' \
2728
+ '# END valise managed block'
2729
+ unset _pix_active_literal
2730
+ }
2731
+
2323
2732
  cmd_init() {
2324
- _shell="${1:-}"
2325
- if [ "$#" -gt 0 ]; then
2326
- shift
2327
- fi
2733
+ _shell=""
2328
2734
  _fish_source_tree_mode=0
2329
2735
  for _arg do
2330
2736
  case "$_arg" in
2331
2737
  --source-tree) _fish_source_tree_mode=1 ;;
2332
- *) die "unknown init option: $_arg" ;;
2738
+ --help|-h) usage_init; return 0 ;;
2739
+ -*) die "unknown init option: $_arg" ;;
2740
+ *)
2741
+ [ -z "$_shell" ] || die "usage: valise init [posix|fish] [--source-tree]"
2742
+ _shell=$_arg
2743
+ ;;
2333
2744
  esac
2334
2745
  done
2335
2746
  if [ -z "$_shell" ]; then
2336
- # Detect current shell
2337
2747
  case "${SHELL:-}" in
2338
- *bash) _shell=bash ;;
2339
- *zsh) _shell=zsh ;;
2340
- *ksh) _shell=ksh ;;
2341
2748
  *fish) _shell=fish ;;
2342
- *) _shell=sh ;;
2749
+ *) _shell=posix ;;
2343
2750
  esac
2344
2751
  fi
2345
-
2346
2752
  case "$_shell" in
2347
- bash|zsh|ksh|sh)
2348
- # shell_quote() returns a POSIX single-quoted literal (e.g.
2349
- # '/home/u/.config/valise/active'). Embed that literal directly
2350
- # into the generated snippet as the right-hand side of an
2351
- # assignment to _valise_active_link. Every later use references
2352
- # "$_valise_active_link" in double quotes — no visually-unquoted
2353
- # shell_quote output remains in the emitted text.
2354
- _shell_active_literal=$(shell_quote "$VALISE_ACTIVE_LINK") || return 1
2355
- printf '%s\n' \
2356
- "# valise shell integration" \
2357
- "_valise_active_link=${_shell_active_literal}" \
2358
- "if [ -L \"\$_valise_active_link/entrypoints/profile.sh\" ] || [ -f \"\$_valise_active_link/entrypoints/profile.sh\" ]; then" \
2359
- " _valise_active=\$(cd \"\$_valise_active_link\" 2>/dev/null && pwd -P)" \
2360
- " if [ -n \"\$_valise_active\" ]; then" \
2361
- " VALISE_ROOT=\"\$_valise_active\"" \
2362
- " . \"\$_valise_active/entrypoints/profile.sh\"" \
2363
- " fi" \
2364
- " unset _valise_active" \
2365
- "fi" \
2366
- "unset _valise_active_link"
2367
- ;;
2368
- fish)
2369
- print_fish_integration
2370
- ;;
2371
- *)
2372
- die "unsupported shell: $_shell (use bash, zsh, ksh, or fish)"
2373
- ;;
2753
+ posix) print_posix_integration || return 1 ;;
2754
+ fish) print_fish_integration ;;
2755
+ *) die "unsupported shell family: $_shell (use posix or fish)" ;;
2374
2756
  esac
2375
2757
  }
2376
2758
 
2759
+ # Report whether a startup file carries valise integration: the POSIX managed
2760
+ # block, or the header of the Fish hook, which valise owns as a whole file.
2761
+ integration_has_managed_block() {
2762
+ _ihb_awk=$(trusted_utility_path awk) || return 1
2763
+ "$_ihb_awk" '
2764
+ index($0, "# BEGIN valise managed block") { found = 1; exit }
2765
+ index($0, "# valise Fish integration") { found = 1; exit }
2766
+ END { exit !found }
2767
+ ' "$1" 2>/dev/null
2768
+ }
2769
+
2770
+ # The startup files a shell reads at login and for interactive shells. A login
2771
+ # bash reads the first of .bash_profile, .bash_login and .profile that exists.
2772
+ #
2773
+ # integration_shell_files <shell> <home>
2774
+ # prints one candidate path per line.
2775
+ integration_shell_files() {
2776
+ case "$1" in
2777
+ bash)
2778
+ if [ -e "$2/.bash_profile" ]; then
2779
+ printf '%s\n' "$2/.bash_profile"
2780
+ elif [ -e "$2/.bash_login" ]; then
2781
+ printf '%s\n' "$2/.bash_login"
2782
+ else
2783
+ printf '%s\n' "$2/.profile"
2784
+ fi
2785
+ printf '%s\n' "$2/.bashrc"
2786
+ ;;
2787
+ zsh)
2788
+ printf '%s\n' "$2/.zprofile" "$2/.zshrc"
2789
+ ;;
2790
+ fish)
2791
+ printf '%s\n' "${XDG_CONFIG_HOME:-$2/.config}/fish/conf.d/valise.fish"
2792
+ ;;
2793
+ *)
2794
+ printf '%s\n' "$2/.profile"
2795
+ ;;
2796
+ esac
2797
+ }
2798
+
2799
+ # The startup files install manages. ~/.profile is always included: it serves
2800
+ # sh, ksh and login bash. Files that belong to another shell are only created
2801
+ # for the user's own shell, except ~/.zprofile, because zsh reads nothing else at
2802
+ # login. Existing files are always kept current.
2803
+ #
2804
+ # posix_startup_targets <home>
2805
+ # prints one path per line.
2806
+ posix_startup_targets() {
2807
+ printf '%s\n' "$1/.profile"
2808
+ if [ -e "$1/.bash_profile" ] || [ -L "$1/.bash_profile" ]; then
2809
+ printf '%s\n' "$1/.bash_profile"
2810
+ elif [ -e "$1/.bash_login" ] || [ -L "$1/.bash_login" ]; then
2811
+ printf '%s\n' "$1/.bash_login"
2812
+ fi
2813
+ case "${SHELL:-}" in
2814
+ */bash | bash) printf '%s\n' "$1/.bashrc" ;;
2815
+ *) if [ -e "$1/.bashrc" ] || [ -L "$1/.bashrc" ]; then printf '%s\n' "$1/.bashrc"; fi ;;
2816
+ esac
2817
+ if [ -e "$1/.zprofile" ] || [ -L "$1/.zprofile" ] || command -v zsh >/dev/null 2>&1; then
2818
+ printf '%s\n' "$1/.zprofile"
2819
+ fi
2820
+ case "${SHELL:-}" in
2821
+ */zsh | zsh) printf '%s\n' "$1/.zshrc" ;;
2822
+ *) if [ -e "$1/.zshrc" ] || [ -L "$1/.zshrc" ]; then printf '%s\n' "$1/.zshrc"; fi ;;
2823
+ esac
2824
+ }
2825
+
2826
+ # Write the managed block to every startup file install manages.
2827
+ write_posix_integration() {
2828
+ if [ -z "${HOME:-}" ]; then
2829
+ info "warning: HOME is not set; no startup file was written"
2830
+ return 0
2831
+ fi
2832
+ _wpi_home=$(path_normalize "$HOME") || die "cannot resolve HOME"
2833
+ _wpi_snippet=$(print_posix_integration) || die "cannot generate integration block"
2834
+ _wpi_targets=$(posix_startup_targets "$_wpi_home")
2835
+ while IFS= read -r _wpi_target; do
2836
+ [ -n "$_wpi_target" ] || continue
2837
+ project_write_integration "$_wpi_target" "$_wpi_snippet"
2838
+ done <<EOF
2839
+ $_wpi_targets
2840
+ EOF
2841
+ info "to remove the integration, delete the valise managed block from these files"
2842
+ unset _wpi_home _wpi_snippet _wpi_targets _wpi_target
2843
+ }
2844
+
2377
2845
  cmd_doctor() {
2378
2846
  _status=0
2847
+ _doctor_pre_install=0
2848
+ _doctor_check_integration=1
2849
+ for _doctor_arg do
2850
+ case "$_doctor_arg" in
2851
+ --pre-install) _doctor_pre_install=1; _doctor_check_integration=0 ;;
2852
+ --no-integration) _doctor_check_integration=0 ;;
2853
+ --help|-h) usage_doctor; return 0 ;;
2854
+ *) die "unknown doctor option: $_doctor_arg (usage: valise doctor [--pre-install] [--no-integration])" ;;
2855
+ esac
2856
+ done
2379
2857
 
2380
2858
  info "checking environment..."
2381
2859
 
@@ -2412,22 +2890,115 @@ cmd_doctor() {
2412
2890
  done
2413
2891
 
2414
2892
  # Check active symlink
2893
+ _doctor_active_ok=0
2415
2894
  if [ -L "$VALISE_ACTIVE_LINK" ]; then
2416
- _target=$(readlink_target "$VALISE_ACTIVE_LINK" 2>/dev/null || :)
2417
- if [ -d "$_target" ]; then
2895
+ _target=$(active_release_dir 2>/dev/null || :)
2896
+ if [ -n "$_target" ]; then
2418
2897
  printf ' ok: active release: %s\n' "$_target"
2898
+ _doctor_active_target=$_target
2899
+ _doctor_active_ok=1
2419
2900
  else
2420
- printf ' FAIL: active symlink points to missing directory: %s\n' "$_target"
2901
+ printf ' FAIL: active symlink does not resolve to a release directory\n'
2421
2902
  _status=1
2422
2903
  fi
2423
2904
  elif [ -e "$VALISE_ACTIVE_LINK" ]; then
2424
2905
  printf ' FAIL: active pointer is not a symlink: %s\n' "$VALISE_ACTIVE_LINK"
2425
2906
  _status=1
2426
- else
2907
+ elif [ "$_doctor_pre_install" -eq 1 ]; then
2427
2908
  printf ' info: no active release configured\n'
2909
+ else
2910
+ # With no release there is nothing for a shell to load, so doctor reports
2911
+ # the missing release as a failure. --pre-install is the explicit
2912
+ # opt-out for a machine that has not installed a release yet.
2913
+ printf ' FAIL: no active release configured\n'
2914
+ _status=1
2915
+ fi
2916
+
2917
+ # The active release must verify against its own manifest. A release that
2918
+ # fails verification is not usable, so doctor reports it as a failure.
2919
+ if [ "$_doctor_active_ok" -eq 1 ] && [ "$_doctor_pre_install" -eq 0 ]; then
2920
+ _doctor_active_manifest="$_doctor_active_target/.valise-manifest"
2921
+ if [ -f "$_doctor_active_manifest" ] && [ ! -L "$_doctor_active_manifest" ]; then
2922
+ _doctor_active_id=${_doctor_active_target##*/}
2923
+ if prepare_manifest_rows "$_doctor_active_manifest" &&
2924
+ validate_manifest "$_manifest_rows"; then
2925
+ _verify_quiet=1
2926
+ if verify_release_dir "$_doctor_active_target" "$_manifest_rows" "$_doctor_active_id"; then
2927
+ _verify_quiet=0
2928
+ printf ' ok: active release verifies: %s\n' "$_doctor_active_id"
2929
+ else
2930
+ _verify_quiet=0
2931
+ printf ' FAIL: active release does not verify: %s\n' "$_doctor_active_id"
2932
+ _status=1
2933
+ fi
2934
+ else
2935
+ printf ' FAIL: active release manifest is invalid: %s\n' "$_doctor_active_manifest"
2936
+ _status=1
2937
+ fi
2938
+ else
2939
+ printf ' FAIL: active release manifest is missing: %s\n' "$_doctor_active_manifest"
2940
+ _status=1
2941
+ fi
2942
+ elif [ "$_doctor_pre_install" -eq 1 ]; then
2943
+ printf ' info: skipping the active release check (--pre-install)\n'
2944
+ fi
2945
+
2946
+ if [ -n "${HOME:-}" ]; then
2947
+ # Check the startup files the running shell actually reads. The shell is
2948
+ # taken from $SHELL, which is the same signal install uses to pick its
2949
+ # per-shell startup file.
2950
+ if [ "$_doctor_check_integration" -eq 1 ]; then
2951
+ _doctor_shell=""
2952
+ case "${SHELL:-}" in
2953
+ */bash|bash) _doctor_shell=bash ;;
2954
+ */zsh|zsh) _doctor_shell=zsh ;;
2955
+ */ksh|ksh) _doctor_shell=ksh ;;
2956
+ */fish|fish) _doctor_shell=fish ;;
2957
+ esac
2958
+ if [ -n "$_doctor_shell" ]; then
2959
+ printf ' integration shell: %s\n' "$_doctor_shell"
2960
+ else
2961
+ printf ' integration shell: unknown (using ~/.profile)\n'
2962
+ fi
2963
+ _doctor_found=0
2964
+ _doctor_candidates=""
2965
+ # The candidate paths are read one per line. Word splitting on $(...)
2966
+ # would break a home directory containing whitespace, so the list goes
2967
+ # through a staging file instead.
2968
+ _doctor_cand_file=$(trusted_mktemp file "${TMPDIR:-/tmp}/valise-doctor-candidates.XXXXXX") ||
2969
+ die "cannot create doctor candidate staging file"
2970
+ integration_shell_files "$_doctor_shell" "$HOME" >"$_doctor_cand_file"
2971
+ while IFS= read -r _doctor_candidate; do
2972
+ [ -n "$_doctor_candidate" ] || continue
2973
+ _doctor_candidates="$_doctor_candidates $_doctor_candidate"
2974
+ if [ -L "$_doctor_candidate" ]; then
2975
+ printf ' warn: startup file is a symlink: %s\n' "$_doctor_candidate"
2976
+ continue
2977
+ fi
2978
+ if [ ! -f "$_doctor_candidate" ]; then
2979
+ printf ' info: startup file is not present: %s\n' "$_doctor_candidate"
2980
+ continue
2981
+ fi
2982
+ if integration_has_managed_block "$_doctor_candidate"; then
2983
+ printf ' ok: startup file contains the valise managed block: %s\n' "$_doctor_candidate"
2984
+ _doctor_found=1
2985
+ else
2986
+ printf ' warn: startup file exists but has no valise managed block: %s\n' "$_doctor_candidate"
2987
+ fi
2988
+ done <"$_doctor_cand_file"
2989
+ _doctor_rm=$(trusted_utility_path rm) || die "no trusted rm utility available"
2990
+ "$_doctor_rm" -f "$_doctor_cand_file" || die "cannot remove doctor candidate staging file"
2991
+ if [ "$_doctor_found" -eq 0 ]; then
2992
+ printf ' FAIL: no valise integration found for %s (checked:%s)\n' \
2993
+ "${_doctor_shell:-the current shell}" "$_doctor_candidates"
2994
+ _status=1
2995
+ fi
2996
+ else
2997
+ printf ' info: skipping the shell integration check\n'
2998
+ fi
2428
2999
  fi
2429
3000
 
2430
- # Platform check (informational only; install and variant use are portable
3001
+ # Platform check (informational only; install and release use are portable
2431
3002
  # POSIX and not gated on any particular uname).
2432
3003
  _uname=$(uname -s 2>/dev/null || echo unknown)
2433
3004
  printf ' platform: %s\n' "$_uname"
@@ -2471,7 +3042,8 @@ cmd_verify_active() {
2471
3042
  for _verify_arg do
2472
3043
  case "$_verify_arg" in
2473
3044
  --print-root) _verify_print_root=1 ;;
2474
- *) die "unknown verify-active option: $_verify_arg" ;;
3045
+ --help|-h) usage_release; return 0 ;;
3046
+ *) die "unknown release verify option: $_verify_arg" ;;
2475
3047
  esac
2476
3048
  done
2477
3049
  [ -L "$VALISE_ACTIVE_LINK" ] || die "active pointer is not a symlink"
@@ -2505,27 +3077,36 @@ cmd_verify_active() {
2505
3077
  fi
2506
3078
  return 0
2507
3079
  }
2508
- cmd_variant() {
3080
+ cmd_release() {
2509
3081
  _subcmd="${1:-list}"
2510
3082
  case "$_subcmd" in
3083
+ --help|-h) usage_release; return 0 ;;
2511
3084
  list)
3085
+ case "${2:-}" in
3086
+ --help|-h) usage_release; return 0 ;;
3087
+ esac
2512
3088
  if [ -d "$VALISE_RELEASES_DIR" ]; then
3089
+ _list_active_id=$(active_release_id 2>/dev/null || :)
2513
3090
  for _rel in "$VALISE_RELEASES_DIR"/*/; do
2514
3091
  [ -d "$_rel" ] || continue
2515
3092
  _name=$(basename "$_rel")
2516
- if [ -L "$VALISE_ACTIVE_LINK" ] && [ "$(readlink_target "$VALISE_ACTIVE_LINK" 2>/dev/null)" = "$_rel" ]; then
3093
+ if [ -n "$_list_active_id" ] && [ "$_name" = "$_list_active_id" ]; then
2517
3094
  printf '* %s (active)\n' "$_name"
2518
3095
  else
2519
3096
  printf ' %s\n' "$_name"
2520
3097
  fi
2521
3098
  done
3099
+ unset _list_active_id
2522
3100
  else
2523
3101
  info "no releases installed"
2524
3102
  fi
2525
3103
  ;;
2526
3104
  use)
3105
+ case "${2:-}" in
3106
+ --help|-h) usage_release; return 0 ;;
3107
+ esac
2527
3108
  _target="${2:-}"
2528
- [ -n "$_target" ] || die "usage: valise variant use <sha256>"
3109
+ [ -n "$_target" ] || die "usage: valise release use <sha256>"
2529
3110
  case "$_target" in
2530
3111
  *[!A-Za-z0-9_.-]*|.*|*-|.-*|*..*) die "invalid release name: $_target" ;;
2531
3112
  esac
@@ -2545,7 +3126,8 @@ cmd_variant() {
2545
3126
  [ ! -L "$_target_manifest" ] || die "release manifest snapshot must not be a symlink"
2546
3127
  _manifest="$_target_manifest"
2547
3128
  else
2548
- _manifest=$(locate_manifest) || die "cannot find profile-release.manifest to verify release"
3129
+ _manifest=$(locate_manifest) ||
3130
+ die "cannot find shell/install/profile-release.manifest in the current directory"
2549
3131
  fi
2550
3132
  prepare_manifest_rows "$_manifest" || die "cannot snapshot profile-release.manifest"
2551
3133
  _manifest="$_manifest_rows"
@@ -2561,21 +3143,139 @@ cmd_variant() {
2561
3143
  release_lock
2562
3144
  info "switched to release $_target"
2563
3145
  ;;
3146
+ prune)
3147
+ shift
3148
+ _prune_keep=3
3149
+ _prune_keep_set=0
3150
+ for _prune_arg do
3151
+ case "$_prune_arg" in
3152
+ --keep=*)
3153
+ _prune_keep_set=1
3154
+ _prune_keep=${_prune_arg#--keep=}
3155
+ case "$_prune_keep" in
3156
+ '' | *[!0-9]*) die "--keep requires a non-negative integer: $_prune_keep" ;;
3157
+ esac
3158
+ # Strip leading zeros so the shell does not read the value as
3159
+ # octal in the arithmetic comparison below, and so the digit
3160
+ # count measures the value rather than its spelling.
3161
+ while [ "$_prune_keep" != "0" ] && [ "${_prune_keep#0}" != "$_prune_keep" ]; do
3162
+ _prune_keep=${_prune_keep#0}
3163
+ done
3164
+ # A value beyond the shell's integer range makes the comparison
3165
+ # below fail, which would read as "keep nothing" and remove every
3166
+ # release. Reject it instead. Nine digits is the bound: every
3167
+ # nine-digit value fits in a signed 32-bit integer, so no
3168
+ # lexicographic comparison against the maximum is needed.
3169
+ if [ "${#_prune_keep}" -gt 9 ]; then
3170
+ die "--keep is too large: $_prune_keep (maximum is 999999999)"
3171
+ fi
3172
+ ;;
3173
+ --help|-h) usage_release; return 0 ;;
3174
+ *) die "unknown release prune option: $_prune_arg" ;;
3175
+ esac
3176
+ done
3177
+ [ -d "$VALISE_RELEASES_DIR" ] || die "no releases installed"
3178
+ # The active release is identified by its canonical directory name, not
3179
+ # by a path spelling. A trailing slash, a relative target, or an alias
3180
+ # symlink must never make prune delete the release the pointer names.
3181
+ _prune_active_id=$(active_release_id 2>/dev/null || :)
3182
+ _prune_active=$_prune_active_id
3183
+ # Collect the candidate releases with their modification times. The
3184
+ # active release is excluded here and kept unconditionally below.
3185
+ _prune_sort=$(trusted_mktemp file "${TMPDIR:-/tmp}/valise-prune.XXXXXX") ||
3186
+ die "cannot create prune staging file"
3187
+ for _prune_rel in "$VALISE_RELEASES_DIR"/*/; do
3188
+ [ -d "$_prune_rel" ] || continue
3189
+ [ ! -L "$_prune_rel" ] || continue
3190
+ _prune_dir=${_prune_rel%/}
3191
+ _prune_name=${_prune_dir##*/}
3192
+ [ "$_prune_name" = "$_prune_active_id" ] && continue
3193
+ _prune_mtime=$(file_mtime "$_prune_dir") || _prune_mtime=0
3194
+ printf '%s\t%s\n' "$_prune_mtime" "$_prune_name" >>"$_prune_sort"
3195
+ done
3196
+ _prune_sort_path=$(trusted_utility_path sort) || die "no trusted sort utility available"
3197
+ _prune_sorted=$(trusted_mktemp file "${TMPDIR:-/tmp}/valise-prune-sorted.XXXXXX") ||
3198
+ die "cannot create prune ordering file"
3199
+ # Newest first; equal timestamps fall back to the release name so the
3200
+ # ordering is deterministic.
3201
+ "$_prune_sort_path" -k1,1nr -k2,2 "$_prune_sort" >"$_prune_sorted" ||
3202
+ die "cannot order releases for pruning"
3203
+ _prune_index=0
3204
+ _prune_removed=0
3205
+ _prune_kept=0
3206
+ while IFS="$(printf '\t')" read -r _prune_mtime _prune_name; do
3207
+ [ -n "$_prune_name" ] || continue
3208
+ _prune_index=$((_prune_index + 1))
3209
+ if [ "$_prune_index" -le "$_prune_keep" ]; then
3210
+ _prune_kept=$((_prune_kept + 1))
3211
+ continue
3212
+ fi
3213
+ _prune_target="$VALISE_RELEASES_DIR/$_prune_name"
3214
+ # A release is only removed after it verifies against its own
3215
+ # manifest. A corrupted release is kept, never deleted, so a damaged
3216
+ # installation can still be inspected and repaired.
3217
+ _prune_manifest="$_prune_target/.valise-manifest"
3218
+ if [ ! -f "$_prune_manifest" ] || [ -L "$_prune_manifest" ]; then
3219
+ info "keeping unverifiable release $_prune_name (no manifest snapshot)"
3220
+ _prune_kept=$((_prune_kept + 1))
3221
+ continue
3222
+ fi
3223
+ if ! prepare_manifest_rows "$_prune_manifest" ||
3224
+ ! validate_manifest "$_manifest_rows"; then
3225
+ info "keeping unverifiable release $_prune_name (invalid manifest)"
3226
+ _prune_kept=$((_prune_kept + 1))
3227
+ continue
3228
+ fi
3229
+ _verify_quiet=1
3230
+ if ! verify_release_dir "$_prune_target" "$_manifest_rows" "$_prune_name"; then
3231
+ _verify_quiet=0
3232
+ info "keeping unverifiable release $_prune_name (failed verification)"
3233
+ _prune_kept=$((_prune_kept + 1))
3234
+ continue
3235
+ fi
3236
+ _verify_quiet=0
3237
+ acquire_lock
3238
+ if remove_tree "$_prune_target"; then
3239
+ _prune_removed=$((_prune_removed + 1))
3240
+ info "removed release $_prune_name"
3241
+ else
3242
+ info "keeping release $_prune_name (could not be removed)"
3243
+ _prune_kept=$((_prune_kept + 1))
3244
+ fi
3245
+ release_lock
3246
+ done <"$_prune_sorted"
3247
+ _prune_rm=$(trusted_utility_path rm) || die "no trusted rm utility available"
3248
+ "$_prune_rm" -f "$_prune_sort" "$_prune_sorted" || die "cannot remove prune staging files"
3249
+ if [ -n "$_prune_active" ]; then
3250
+ info "prune kept the active release plus $_prune_kept other release(s); removed $_prune_removed"
3251
+ else
3252
+ info "prune kept $_prune_kept release(s); removed $_prune_removed (no active release)"
3253
+ fi
3254
+ ;;
3255
+ verify)
3256
+ if [ "$#" -gt 0 ]; then
3257
+ shift
3258
+ fi
3259
+ cmd_verify_active "$@"
3260
+ ;;
2564
3261
  *)
2565
- die "unknown variant subcommand: $_subcmd (use list or use)"
3262
+ die "unknown release subcommand: $_subcmd (use list, use, prune, or verify)"
2566
3263
  ;;
2567
3264
  esac
2568
3265
  }
2569
3266
 
2570
3267
  cmd_install() {
3268
+ _install_integration=1
2571
3269
  for _arg do
2572
3270
  case "$_arg" in
2573
- --help|-h) usage; return 0 ;;
2574
- --*) die "unknown install option: $_arg (usage: valise install)" ;;
3271
+ --help|-h) usage_install; return 0 ;;
3272
+ --no-integration) _install_integration=0 ;;
3273
+ *) die "unknown install option: $_arg (usage: valise install [--no-integration])" ;;
2575
3274
  esac
2576
3275
  done
2577
3276
 
2578
- _manifest_source=$(locate_manifest) || die "cannot find profile-release.manifest; run from repository root or installed tree"
3277
+ _manifest_source=$(locate_manifest) ||
3278
+ die "cannot find shell/install/profile-release.manifest in the current directory (run valise install from the release tree)"
2579
3279
  _manifest_root=$(cd "$(path_parent "$_manifest_source")/../.." && pwd -P) || die "cannot resolve manifest source root"
2580
3280
  prepare_manifest_rows "$_manifest_source" || die "cannot normalize profile-release.manifest"
2581
3281
  _manifest="$_manifest_rows"
@@ -2712,21 +3412,18 @@ cmd_install() {
2712
3412
 
2713
3413
  # Update active symlink atomically without clobbering a non-symlink path.
2714
3414
  if [ ! -L "$VALISE_ACTIVE_LINK" ] || [ "$(readlink_target "$VALISE_ACTIVE_LINK" 2>/dev/null)" != "$_target_dir" ]; then
2715
- acquire_lock
2716
3415
  if [ -e "$VALISE_ACTIVE_LINK" ] && [ ! -L "$VALISE_ACTIVE_LINK" ]; then
2717
- release_lock
2718
3416
  die "active path exists and is not a symlink: $VALISE_ACTIVE_LINK"
2719
3417
  fi
2720
3418
  switch_active_link "$_target_dir"
2721
- release_lock
2722
3419
  info "active release set to $_release_id"
2723
3420
  fi
2724
3421
 
2725
- # Deploy the Fish conf.d hook when Fish is available. Installed releases
2726
- # currently expose the generated Fish plan; older releases may expose the
2727
- # project Fish entrypoint, so the emitted integration handles both.
3422
+ # Deploy the Fish conf.d hook when Fish is available. A built release has no
3423
+ # Fish surface; an engine release exposes the project Fish entrypoint, so the
3424
+ # emitted integration handles that shape.
2728
3425
  _fish_path=$(trusted_utility_path fish 2>/dev/null || :)
2729
- if [ -n "$_fish_path" ]; then
3426
+ if [ "$_install_integration" -eq 1 ] && [ -n "$_fish_path" ]; then
2730
3427
  _fish_conf_dir="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d"
2731
3428
  _fish_hook="$_fish_conf_dir/valise.fish"
2732
3429
  _mkdir_path=$(trusted_utility_path mkdir) || die "no trusted mkdir utility available"
@@ -2740,12 +3437,7 @@ cmd_install() {
2740
3437
  # shell redirection below into an attacker-selected victim.
2741
3438
  _hook_tmp=$(trusted_mktemp_at "$_fish_conf_dir" file "$_fish_conf_dir/.valise-hook.XXXXXX") || die "cannot create Fish hook staging file"
2742
3439
  [ -f "$_hook_tmp" ] && [ ! -L "$_hook_tmp" ] || die "Fish hook staging path is unsafe"
2743
- {
2744
- printf '%s\n' \
2745
- "# Managed by valise install — do not edit." \
2746
- "# This hook loads the active valise release for Fish sessions."
2747
- print_fish_integration
2748
- } >"$_hook_tmp" || die "cannot write Fish hook"
3440
+ print_fish_integration >"$_hook_tmp" || die "cannot write Fish hook"
2749
3441
  _chmod_path=$(trusted_utility_path chmod) || die "no trusted chmod utility available"
2750
3442
  "$_chmod_path" 0644 "$_hook_tmp" || die "cannot set Fish hook mode"
2751
3443
  _cat_path=$(trusted_utility_path cat) || die "no trusted cat utility available"
@@ -2762,97 +3454,14 @@ cmd_install() {
2762
3454
  _hook_actual=$("$_cat_path" "$_fish_hook") || die "cannot read published Fish hook"
2763
3455
  [ "$_hook_actual" = "$_hook_expected" ] || die "published Fish hook contents mismatch"
2764
3456
  info "Fish hook deployed: $_fish_hook"
3457
+ info "remove this file to uninstall the Fish integration"
2765
3458
  fi
2766
3459
 
2767
- # Deploy POSIX shell integration to the platform-appropriate profile file.
2768
- # macOS: Terminal.app and iTerm2 open every window/tab as a login shell,
2769
- # so .bash_profile / .zprofile is sourced per terminal.
2770
- # Linux: terminal emulators open non-login interactive shells, so
2771
- # .bashrc / .zshrc / .kshrc is sourced per terminal.
2772
- _posix_shell="${SHELL:-}"
2773
- _posix_shell_name=""
2774
- case "$_posix_shell" in
2775
- */bash|bash) _posix_shell_name=bash ;;
2776
- */zsh|zsh) _posix_shell_name=zsh ;;
2777
- */ksh|ksh) _posix_shell_name=ksh ;;
2778
- esac
2779
- if [ -n "$_posix_shell_name" ] && [ -n "${HOME:-}" ]; then
2780
- _posix_platform=$(trusted_uname -s 2>/dev/null || echo unknown)
2781
- _posix_profile=""
2782
- case "$_posix_platform" in
2783
- Darwin)
2784
- case "$_posix_shell_name" in
2785
- bash) _posix_profile="$HOME/.bash_profile" ;;
2786
- zsh) _posix_profile="$HOME/.zprofile" ;;
2787
- ksh) _posix_profile="$HOME/.kshrc" ;;
2788
- esac
2789
- ;;
2790
- *)
2791
- case "$_posix_shell_name" in
2792
- bash) _posix_profile="$HOME/.bashrc" ;;
2793
- zsh) _posix_profile="$HOME/.zshrc" ;;
2794
- ksh) _posix_profile="$HOME/.kshrc" ;;
2795
- esac
2796
- ;;
2797
- esac
2798
- if [ -n "$_posix_profile" ]; then
2799
- if [ -L "$_posix_profile" ]; then
2800
- info "shell integration skipped: $_posix_profile is a symlink (managed externally)"
2801
- else
2802
- _posix_marker="managed by valise install"
2803
- _posix_already=0
2804
- if [ -f "$_posix_profile" ]; then
2805
- _awk_path=$(trusted_utility_path awk) || die "no trusted awk utility available"
2806
- if "$_awk_path" -v m="$_posix_marker" 'index($0,m){found=1;exit} END{exit !found}' "$_posix_profile" 2>/dev/null; then
2807
- _posix_already=1
2808
- fi
2809
- fi
2810
- if [ "$_posix_already" -eq 1 ]; then
2811
- info "shell integration already present: $_posix_profile"
2812
- else
2813
- _posix_active_literal=$(shell_quote "$VALISE_ACTIVE_LINK") || die "cannot quote active link path"
2814
- _posix_profile_dir=$(path_parent "$_posix_profile")
2815
- if [ ! -d "$_posix_profile_dir" ]; then
2816
- _mkdir_path=$(trusted_utility_path mkdir) || die "no trusted mkdir utility available"
2817
- "$_mkdir_path" -p "$_posix_profile_dir" || die "cannot create profile directory: $_posix_profile_dir"
2818
- fi
2819
- _posix_tmp=$(trusted_mktemp_at "$_posix_profile_dir" file "$_posix_profile_dir/.valise-profile.XXXXXX") || die "cannot create profile staging file"
2820
- [ -f "$_posix_tmp" ] && [ ! -L "$_posix_tmp" ] || die "profile staging path is unsafe"
2821
- if [ -f "$_posix_profile" ]; then
2822
- _cat_path=$(trusted_utility_path cat) || die "no trusted cat utility available"
2823
- "$_cat_path" "$_posix_profile" > "$_posix_tmp" || die "cannot copy existing profile"
2824
- printf '\n' >> "$_posix_tmp"
2825
- fi
2826
- {
2827
- printf '%s\n' "# valise shell integration — $_posix_marker"
2828
- printf '%s\n' "_valise_active_link=${_posix_active_literal}"
2829
- printf '%s\n' "if [ -L \"\$_valise_active_link/entrypoints/profile.sh\" ] || [ -f \"\$_valise_active_link/entrypoints/profile.sh\" ]; then"
2830
- printf '%s\n' " _valise_active=\$(cd \"\$_valise_active_link\" 2>/dev/null && pwd -P)"
2831
- printf '%s\n' " if [ -n \"\$_valise_active\" ]; then"
2832
- printf '%s\n' " VALISE_ROOT=\"\$_valise_active\""
2833
- printf '%s\n' " . \"\$_valise_active/entrypoints/profile.sh\""
2834
- printf '%s\n' " fi"
2835
- printf '%s\n' " unset _valise_active"
2836
- printf '%s\n' "fi"
2837
- printf '%s\n' "unset _valise_active_link"
2838
- } >> "$_posix_tmp" || die "cannot write profile snippet"
2839
- _chmod_path=$(trusted_utility_path chmod) || die "no trusted chmod utility available"
2840
- if [ -f "$_posix_profile" ]; then
2841
- _posix_existing_mode=$(file_mode "$_posix_profile")
2842
- [ -n "$_posix_existing_mode" ] || die "cannot read existing profile mode: $_posix_profile"
2843
- "$_chmod_path" "$_posix_existing_mode" "$_posix_tmp" || die "cannot preserve profile mode"
2844
- else
2845
- "$_chmod_path" 0644 "$_posix_tmp" || die "cannot set profile mode"
2846
- fi
2847
- _mv_path=$(trusted_utility_path mv) || die "no trusted mv utility available"
2848
- "$_mv_path" -f "$_posix_tmp" "$_posix_profile" || die "cannot publish profile snippet"
2849
- _posix_tmp=""
2850
- info "shell integration deployed: $_posix_profile"
2851
- fi
2852
- fi
2853
- fi
3460
+ if [ "$_install_integration" -eq 1 ]; then
3461
+ write_posix_integration
2854
3462
  fi
2855
3463
 
3464
+ release_lock
2856
3465
  info "installation complete"
2857
3466
  info "run 'valise init' to print the shell integration snippet for manual setup"
2858
3467
  }
@@ -2903,9 +3512,19 @@ cmd_make_manifest() {
2903
3512
  _mm_out="$_mm_cwd/$_mm_out"
2904
3513
  ;;
2905
3514
  esac
3515
+ _mm_out=$(path_normalize "$_mm_out") || die "cannot normalize manifest output path"
2906
3516
  _mm_out_dir=$(path_parent "$_mm_out") || die "cannot resolve manifest parent"
2907
3517
  _mm_mkdir_path=$(trusted_utility_path mkdir) || die "no trusted mkdir utility available"
2908
3518
  "$_mm_mkdir_path" -p "$_mm_out_dir" || die "cannot create manifest directory: $_mm_out_dir"
3519
+ # Resolve the parent physically: find reports physical paths, and /tmp is a
3520
+ # symlink to /private/tmp on macOS. A lexical comparison would include the
3521
+ # manifest in itself on the second run.
3522
+ _mm_out_dir=$(cd "$_mm_out_dir" && pwd -P) || die "cannot resolve manifest parent: $_mm_out_dir"
3523
+ _mm_out="$_mm_out_dir/${_mm_out##*/}"
3524
+ if [ -e "$_mm_out" ] || [ -L "$_mm_out" ]; then
3525
+ [ -f "$_mm_out" ] && [ ! -L "$_mm_out" ] ||
3526
+ die "manifest output is not a regular file: $_mm_out"
3527
+ fi
2909
3528
  _mm_find_path=$(trusted_utility_path find) || die "no trusted find utility available"
2910
3529
  _mm_sort_path=$(trusted_utility_path sort) || die "no trusted sort utility available"
2911
3530
  # Symlinks never enter a sealed release: find without -L reports them as
@@ -2959,27 +3578,48 @@ _MM_SORTED_EOF
2959
3578
  info "manifest written: $_mm_out ($_mm_count files)"
2960
3579
  }
2961
3580
 
3581
+ # release_bundle_roots <src-root> <shell> [bundle]
3582
+ # Print the seed modules for <shell> from shell/release/bundle-roots, a
3583
+ # tab-separated file of: bundle, shell, space-separated roots ("default" is
3584
+ # the bundle used when none is requested). A bundle with no rows is an error;
3585
+ # a shell with no row has no roots.
3586
+ release_bundle_roots() {
3587
+ _rbr_file="$1/shell/release/bundle-roots"
3588
+ [ -f "$_rbr_file" ] || { printf 'valise: error: missing %s\n' "$_rbr_file" >&2; return 1; }
3589
+ awk -F '\t' -v b="${3:-default}" -v s="$2" '
3590
+ /^[[:space:]]*#/ || NF == 0 { next }
3591
+ $1 == b { known = 1; if ($2 == s && $3 != "") print $3 }
3592
+ END { if (!known) { print "valise: error: unknown bundle: " b > "/dev/stderr"; exit 1 } }
3593
+ ' "$_rbr_file"
3594
+ }
3595
+
2962
3596
  cmd_compile() {
2963
3597
  _out_dir=""
2964
- _variant="workstation-full"
3598
+ _variant="default"
2965
3599
  _bundle=""
2966
3600
  _bundle_requested=0
2967
3601
  for _arg do
2968
3602
  case "$_arg" in
2969
3603
  --variant=*) _variant="${_arg#--variant=}" ;;
2970
3604
  --bundle=*) _bundle="${_arg#--bundle=}"; _bundle_requested=1 ;;
2971
- --help|-h) usage; return 0 ;;
2972
- *) [ -z "$_out_dir" ] && _out_dir="$_arg" ;;
3605
+ --help|-h) usage_compile; return 0 ;;
3606
+ --*) die "unknown compile option: $_arg" ;;
3607
+ *) [ -z "$_out_dir" ] || die "usage: valise compile [--bundle=<name>] [--variant=<name>] <output-dir>"; _out_dir=$_arg ;;
2973
3608
  esac
2974
3609
  done
2975
3610
  [ -n "$_out_dir" ] || die "usage: valise compile [--bundle=<name>] [--variant=<name>] <output-dir>"
2976
- _script_dir=$(cd "$(path_parent "$0")" && pwd -P)
3611
+ _out_dir=$(path_normalize "$_out_dir")
3612
+ [ "$_out_dir" != "/" ] || die "output directory must not be /"
3613
+ _script_dir=$(valise_script_dir)
2977
3614
  _awk_compiler="$_script_dir/../lib/compiler.awk"
2978
3615
  [ -f "$_awk_compiler" ] || die "compiler.awk not found at $_awk_compiler"
2979
3616
 
2980
3617
  # Find source tree root (look for shell/release/ relative to script or cwd)
2981
3618
  _src_root=""
2982
- for _candidate in "$_script_dir/../../.." "$_script_dir/../.." "$_script_dir/.." "."; do
3619
+ # A project invoking the CLI from its root must win over a source tree that
3620
+ # happens to contain the package. Build resolves its prelude from the
3621
+ # working tree, so compile and build must agree on this root.
3622
+ for _candidate in "." "$_script_dir/.." "$_script_dir/../.." "$_script_dir/../../.."; do
2983
3623
  if [ -f "$_candidate/shell/release/module-registry.tsv" ]; then
2984
3624
  _src_root=$(cd "$_candidate" && pwd -P)
2985
3625
  break
@@ -2988,18 +3628,16 @@ cmd_compile() {
2988
3628
  [ -n "$_src_root" ] || die "cannot find source tree (need shell/release/module-registry.tsv)"
2989
3629
 
2990
3630
  _registry="$_src_root/shell/release/module-registry.tsv"
2991
- _roots_json="$_src_root/shell/release/profile-release.json"
2992
- _release_compiler="$_src_root/hack/compile-shell-profile-release.py"
2993
- [ -f "$_release_compiler" ] || die "release compiler not found: $_release_compiler"
2994
- _prelude="$_src_root/submodule/valise-sh/lib/capabilities.sh"
3631
+ _prelude="$_script_dir/../lib/capabilities.sh"
2995
3632
  _variants_file="$_src_root/shell/release/install-variants"
3633
+ [ -f "$_variants_file" ] || die "install-variants not found: $_variants_file"
2996
3634
  _variant_matches=$(awk -F'\t' -v v="$_variant" '$1 == v { count++ } END { print count + 0 }' "$_variants_file")
2997
3635
  [ "$_variant_matches" -eq 1 ] || die "unknown or ambiguous variant: $_variant"
2998
3636
  # Extract prelude function names
2999
3637
  # Extract sp_cap_ functions matching Python SHELL_FUNCTION_DEFINITION regex
3000
3638
  # Pattern: ^name() { (file-appearance order, no sorting)
3001
3639
  [ -r "$_prelude" ] || die "capabilities prelude not found: $_prelude"
3002
- _prelude_fns=$(sed -n 's/^\([a-z_][a-zA-Z0-9_]*\)()[[:space:]]*{.*/\1/p' "$_prelude" | grep '^sp_cap_' | tr '\n' ' ')
3640
+ _prelude_fns=$(sed -n 's/^\([a-z_][a-zA-Z0-9_]*\)()[[:space:]]*[({].*/\1/p' "$_prelude" | grep '^sp_cap_' | tr '\n' ' ')
3003
3641
  [ -n "$_prelude_fns" ] || die "capabilities prelude contains no sp_cap_ functions"
3004
3642
 
3005
3643
  # Extract exclusion list and validate each exclusion against the identity
@@ -3010,9 +3648,6 @@ cmd_compile() {
3010
3648
  if [ -f "$_variants_file" ]; then
3011
3649
  _exclude_list=$(awk -F' ' -v v="$_variant" '$1 == v { print $3 }' "$_variants_file")
3012
3650
  fi
3013
- if [ -z "$_exclude_list" ] && [ "$_variant" != "workstation-full" ]; then
3014
- info "warning: variant $_variant not found in install-variants, using default"
3015
- fi
3016
3651
  for _excluded_module in $_exclude_list; do
3017
3652
  _excluded_count=$(awk -F ' ' -v wanted="$_excluded_module" '
3018
3653
  NR > 1 {
@@ -3029,7 +3664,7 @@ cmd_compile() {
3029
3664
  if [ "$_bundle_requested" -eq 1 ]; then
3030
3665
  [ -n "$_bundle" ] || die "bundle name must not be empty"
3031
3666
  # Validate bundle identity before creating the output transaction.
3032
- python3 "$_release_compiler" --root "$_src_root" --bundle-roots bash --bundle "$_bundle" >/dev/null ||
3667
+ release_bundle_roots "$_src_root" bash "$_bundle" >/dev/null ||
3033
3668
  die "failed to resolve bundle roots for bash"
3034
3669
  fi
3035
3670
 
@@ -3037,11 +3672,8 @@ cmd_compile() {
3037
3672
  # rejected before any plan is generated, so a failed compile can never leave
3038
3673
  # a partially replaced output tree. An empty existing directory is supported
3039
3674
  # for callers that pre-create their destination; it is replaced only after
3040
- # every plan has compiled successfully. Trailing slashes are normalized so
3041
- # "out/" and "out" are the same destination (see path_parent).
3042
- while [ "$_out_dir" != "/" ] && [ "${_out_dir%/}" != "$_out_dir" ]; do
3043
- _out_dir=${_out_dir%/}
3044
- done
3675
+ # every plan has compiled successfully. Dot segments and trailing slashes
3676
+ # were normalized above so one destination always has one staging path.
3045
3677
  _out_parent=$(path_parent "$_out_dir")
3046
3678
  _mkdir_path=$(trusted_utility_path mkdir) || die "no trusted mkdir utility available"
3047
3679
  if [ ! -d "$_out_parent" ]; then
@@ -3063,27 +3695,25 @@ cmd_compile() {
3063
3695
  _stage=$(trusted_mktemp_at "$_out_parent" dir "$_out_parent/.valise-compile.XXXXXX") ||
3064
3696
  die "cannot create compile staging directory"
3065
3697
 
3066
- # Fish plans require native Fish syntax; awk compiler emits POSIX sh only.
3067
- # Python compiler (hack/compile-shell-profile-release.py) handles Fish correctly.
3068
- info "fish plan generation requires Python compiler (awk compiler is POSIX-only)"
3069
- for _shell in bash zsh ksh wrapper_lifecycle; do
3698
+ # Fish plans require native Fish syntax; the plan compiler emits POSIX sh
3699
+ # only. A Fish integration uses the native Fish loader instead.
3700
+ info "fish plans are not generated; a Fish session uses the native Fish loader only when the release provides one"
3701
+ for _shell in bash zsh ksh; do
3070
3702
  # Extract roots for this shell from JSON using POSIX tools
3071
3703
  if [ "$_bundle_requested" -eq 1 ]; then
3072
- _roots=$(python3 "$_release_compiler" --root "$_src_root" --bundle-roots "$_shell" --bundle "$_bundle") || die "failed to resolve bundle roots for $_shell"
3704
+ _roots=$(release_bundle_roots "$_src_root" "$_shell" "$_bundle") || die "failed to resolve bundle roots for $_shell"
3073
3705
  else
3074
- _roots=$(python3 "$_release_compiler" --root "$_src_root" --bundle-roots "$_shell") || die "failed to resolve bundle roots for $_shell"
3706
+ _roots=$(release_bundle_roots "$_src_root" "$_shell") || die "failed to resolve bundle roots for $_shell"
3075
3707
  fi
3076
3708
 
3077
3709
  [ -n "$_roots" ] || continue
3078
3710
 
3079
- # wrapper_lifecycle uses bash shell filtering (matches Python compiler)
3080
- _effective_shell="$_shell"
3081
- [ "$_shell" != "wrapper_lifecycle" ] || _effective_shell="bash"
3082
-
3083
3711
  _plan_file="installed-modules.$_shell"
3084
- [ "$_shell" != "wrapper_lifecycle" ] || _plan_file="wrapper-lifecycle.bash"
3085
3712
 
3086
- # Filter registry for modules matching this shell and build input stream
3713
+ # Filter registry for modules matching this shell and build the compiler's
3714
+ # input stream. The preprocessor runs to a file first so a validation
3715
+ # failure is not hidden by the following compiler stage.
3716
+ _preprocessed="$_stage/.preprocessed.$_shell"
3087
3717
  {
3088
3718
  # Filter seeds through exclusion list
3089
3719
  _filtered_roots=""
@@ -3099,6 +3729,8 @@ cmd_compile() {
3099
3729
  esac
3100
3730
  [ "$_excluded" -eq 1 ] && break
3101
3731
  done
3732
+ # Bare seed names are project modules, as for exclusions above.
3733
+ case "$_seed" in *@*) ;; *) _seed="$_seed@shell/module.d" ;; esac
3102
3734
  [ "$_excluded" -eq 1 ] || _filtered_roots="$_filtered_roots $_seed"
3103
3735
  done
3104
3736
  _filtered_roots="${_filtered_roots# }"
@@ -3109,7 +3741,7 @@ cmd_compile() {
3109
3741
  # Preprocess the registry with source-qualified identities. This keeps
3110
3742
  # duplicate module names distinct and gives contribution bodies their
3111
3743
  # installed layer prefix.
3112
- awk -F' ' -v shell="$_effective_shell" -v excludes="$_exclude_list" '
3744
+ awk -F' ' -v shell="$_shell" -v excludes="$_exclude_list" '
3113
3745
  function shell_matches(shells, wanted, count, i, values) {
3114
3746
  if (shells == "any") return 1
3115
3747
  count = split(shells, values, " ")
@@ -3121,25 +3753,86 @@ cmd_compile() {
3121
3753
  NR == 1 { next }
3122
3754
  {
3123
3755
  name = $1; msrc = $11
3124
- if (name == "" || msrc == "shell/helpers" || !shell_matches($6, shell)) next
3756
+ if (name == "" || !shell_matches($6, shell)) next
3757
+ if (name !~ /^[a-z_][a-zA-Z0-9_]*$/) {
3758
+ print "valise: error: invalid module name in registry: " name > "/dev/stderr"
3759
+ exit 1
3760
+ }
3761
+ if ($7 == "") {
3762
+ print "valise: error: missing platforms for registry module: " name > "/dev/stderr"
3763
+ exit 1
3764
+ }
3765
+ platform_count = split($7, platform_values, " ")
3766
+ for (platform_i = 1; platform_i <= platform_count; platform_i++) {
3767
+ if (platform_values[platform_i] !~ /^(any|darwin|linux|bsd|freebsd|openbsd|netbsd)$/) {
3768
+ print "valise: error: invalid platform for registry module " name ": " platform_values[platform_i] > "/dev/stderr"
3769
+ exit 1
3770
+ }
3771
+ }
3772
+ if ($3 !~ /^(read-only|path-env|generated-file|update-network|install-time|local-secret|local-state|work-only)$/) {
3773
+ print "valise: error: invalid side_effect for registry module " name ": " $3 > "/dev/stderr"
3774
+ exit 1
3775
+ }
3776
+ if (msrc != "shell/module.d") {
3777
+ if (msrc !~ /^shell\/layers\/[^\/]+\/module\.d$/) {
3778
+ print "valise: error: invalid registry source for module " name ": " msrc > "/dev/stderr"
3779
+ exit 1
3780
+ }
3781
+ layer = msrc
3782
+ sub(/^shell\/layers\//, "", layer)
3783
+ sub(/\/module\.d$/, "", layer)
3784
+ if (layer !~ /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/ || layer ~ /\.\./) {
3785
+ print "valise: error: invalid layer source in registry for module " name ": " msrc > "/dev/stderr"
3786
+ exit 1
3787
+ }
3788
+ }
3789
+ export_count = split($8, export_values, " ")
3790
+ for (export_i = 1; export_i <= export_count; export_i++) {
3791
+ if (export_values[export_i] !~ /^[a-z_][a-zA-Z0-9_]*$/ ||
3792
+ export_values[export_i] ~ /^(case|do|done|elif|else|esac|fi|for|if|in|then|until|while|time)$/) {
3793
+ print "valise: error: invalid export for registry module " name ": " export_values[export_i] > "/dev/stderr"
3794
+ exit 1
3795
+ }
3796
+ }
3125
3797
  identity = name "@" msrc
3126
3798
  row_exists[identity] = 1
3127
3799
  row_name[identity] = name
3128
3800
  row_source[identity] = msrc
3129
3801
  row_req[identity] = $4
3802
+ row_opt[identity] = $5
3130
3803
  row_load[identity] = $9
3804
+ row_platforms[identity] = $7
3805
+ row_side_effect[identity] = $3
3806
+ row_exports[identity] = $8
3807
+ }
3808
+ function excluded_by_variant(identity, name, source, ei, ex) {
3809
+ # An exclusion names a module identity. A qualified exclusion
3810
+ # (name@source) matches that exact source; a bare exclusion names a
3811
+ # primary module. Both forms apply to every registry source, so a
3812
+ # layer module can be excluded by its qualified identity.
3813
+ for (ei = 1; ei <= n_ex; ei++) {
3814
+ ex = ex_arr[ei]
3815
+ if (ex == "") continue
3816
+ if (index(ex, "@") > 0) {
3817
+ if (ex == identity) return 1
3818
+ } else {
3819
+ if (ex == name && source == "shell/module.d") return 1
3820
+ }
3821
+ }
3822
+ return 0
3131
3823
  }
3132
3824
  END {
3825
+ # Excluded modules are reported to the compiler rather than silently
3826
+ # dropped, so an exclusion that breaks a required dependency is
3827
+ # diagnosed by name instead of surfacing as "unknown module".
3828
+ n_ex = 0
3829
+ if (excludes != "") n_ex = split(excludes, ex_arr, " ")
3133
3830
  for (identity in row_exists) {
3134
3831
  name = row_name[identity]; source = row_source[identity]
3135
- excluded = 0
3136
- if (source == "shell/module.d" && excludes != "") {
3137
- n_ex = split(excludes, ex_arr, " ")
3138
- for (ei = 1; ei <= n_ex; ei++) {
3139
- if (ex_arr[ei] == name || ex_arr[ei] == identity) { excluded = 1; break }
3140
- }
3832
+ if (excluded_by_variant(identity, name, source)) {
3833
+ printf "EXCLUDED:%s\t%s\n", identity, name
3834
+ continue
3141
3835
  }
3142
- if (excluded) continue
3143
3836
  req = ""
3144
3837
  n_req = split(row_req[identity], req_arr, " ")
3145
3838
  for (ri = 1; ri <= n_req; ri++) {
@@ -3156,13 +3849,44 @@ cmd_compile() {
3156
3849
  }
3157
3850
  req = req (req == "" ? "" : " ") resolved
3158
3851
  }
3852
+ # An optional dependency that is absent for this shell or removed by
3853
+ # the variant is simply not there; only a present one joins the plan.
3854
+ opt = ""
3855
+ n_opt = split(row_opt[identity], opt_arr, " ")
3856
+ for (ri = 1; ri <= n_opt; ri++) {
3857
+ dep = opt_arr[ri]
3858
+ if (dep == "") continue
3859
+ resolved = ""
3860
+ candidate = dep "@" source
3861
+ if ((candidate in row_exists) && !excluded_by_variant(candidate, dep, source)) {
3862
+ resolved = candidate
3863
+ } else if (!(candidate in row_exists)) {
3864
+ candidate_count = 0
3865
+ for (candidate_key in row_exists) {
3866
+ if (row_name[candidate_key] == dep) { resolved = candidate_key; candidate_count++ }
3867
+ }
3868
+ if (candidate_count != 1 || excluded_by_variant(resolved, dep, row_source[resolved])) resolved = ""
3869
+ }
3870
+ if (resolved != "") opt = opt (opt == "" ? "" : " ") resolved
3871
+ }
3159
3872
  load_path = row_load[identity]
3160
- if (source == "shell/layers/contrib/module.d") load_path = "layers/contrib/" load_path
3161
- printf "MOD:%s\t%s\t%s\n", identity, req, load_path
3873
+ # A meta module has no body, so it has no installed path to prefix.
3874
+ if (load_path != "" && source ~ /^shell\/layers\/[^\/]+\/module\.d$/) {
3875
+ layer = source
3876
+ sub(/^shell\/layers\//, "", layer)
3877
+ sub(/\/module\.d$/, "", layer)
3878
+ load_path = "layers/" layer "/" load_path
3879
+ }
3880
+ printf "MOD:%s\t%s\t%s\t%s\t%s\t%s\t%s\n", identity, req, load_path, row_platforms[identity], row_side_effect[identity], row_exports[identity], opt
3162
3881
  }
3163
3882
  }
3164
3883
  ' "$_registry"
3165
- } | awk -v target_shell="$_effective_shell" -v plan_name="$_shell" -f "$_awk_compiler" > "$_stage/$_plan_file"
3884
+ } >"$_preprocessed" || die "cannot preprocess registry for $_shell"
3885
+ awk -v target_shell="$_shell" -v plan_name="$_shell" -f "$_awk_compiler" \
3886
+ "$_preprocessed" >"$_stage/$_plan_file" || die "cannot compile plan for $_shell"
3887
+ _rm_path=$(trusted_utility_path rm) || die "no trusted rm utility available"
3888
+ "$_rm_path" -f "$_preprocessed" || die "cannot remove compiler preprocessing file"
3889
+ _preprocessed=""
3166
3890
 
3167
3891
  info "compiled plan for $_shell"
3168
3892
  done
@@ -3182,9 +3906,1011 @@ cmd_compile() {
3182
3906
  }
3183
3907
 
3184
3908
 
3909
+ # registry_generate <project-root> <output-file> [check]
3910
+ # Write the deterministic module registry implied by the project's metadata.
3911
+ # With check=1, compare the generated bytes with output-file and fail on drift.
3912
+ registry_generate() {
3913
+ _rg_root=$1
3914
+ _rg_out=$2
3915
+ _rg_check=${3:-0}
3916
+ export LC_ALL=C
3917
+ _rg_script_dir=$(valise_script_dir) || return 1
3918
+ _rg_metadata="$_rg_script_dir/../lib/metadata.sh"
3919
+ [ -r "$_rg_metadata" ] || die "metadata library not found: $_rg_metadata"
3920
+ _rg_out=$(path_normalize "$_rg_out") || return 1
3921
+ _rg_parent=$(path_parent "$_rg_out")
3922
+ _rg_mkdir=$(trusted_utility_path mkdir) || die "no trusted mkdir utility available"
3923
+ _rg_mv=$(trusted_utility_path mv) || die "no trusted mv utility available"
3924
+ _rg_chmod=$(trusted_utility_path chmod) || die "no trusted chmod utility available"
3925
+ _rg_rm=$(trusted_utility_path rm) || die "no trusted rm utility available"
3926
+ _rg_cmp=$(trusted_utility_path cmp) || die "no trusted cmp utility available"
3927
+ if [ "$_rg_check" = 1 ]; then
3928
+ [ -d "$_rg_parent" ] || die "module registry is missing: $_rg_out"
3929
+ [ -f "$_rg_out" ] && [ ! -L "$_rg_out" ] || die "module registry is missing: $_rg_out"
3930
+ _rg_tmp=$(trusted_mktemp file "${TMPDIR:-/tmp}/valise-registry.XXXXXX") ||
3931
+ die "cannot create registry comparison file"
3932
+ else
3933
+ if [ -e "$_rg_out" ] || [ -L "$_rg_out" ]; then
3934
+ [ -f "$_rg_out" ] && [ ! -L "$_rg_out" ] || die "registry output path is not a regular file: $_rg_out"
3935
+ fi
3936
+ [ -d "$_rg_parent" ] || "$_rg_mkdir" -p "$_rg_parent" || die "cannot create registry output parent: $_rg_parent"
3937
+ _rg_tmp=$(trusted_mktemp_at "$_rg_parent" file "$_rg_parent/.valise-registry.XXXXXX") ||
3938
+ die "cannot create registry staging file"
3939
+ fi
3940
+ (
3941
+ # shellcheck disable=SC1090
3942
+ . "$_rg_metadata"
3943
+ printf 'name\tversion\tside_effect\trequires\toptional\tshells\tplatforms\texports\tload\tupdates\tsource\n'
3944
+ for _rg_meta in "$_rg_root"/shell/module.d/*.meta "$_rg_root"/shell/layers/*/module.d/*.meta; do
3945
+ [ -f "$_rg_meta" ] || continue
3946
+ source_path_is_physical "$_rg_root" "${_rg_meta#"$_rg_root"/}" || {
3947
+ printf 'valise: error: registry metadata path contains a symlink: %s\n' "${_rg_meta#"$_rg_root"/}" >&2
3948
+ exit 1
3949
+ }
3950
+ _rg_rel=${_rg_meta#"$_rg_root"/}
3951
+ _rg_source=${_rg_rel%/*}
3952
+ case "$_rg_source" in
3953
+ shell/module.d) ;;
3954
+ shell/layers/*/module.d)
3955
+ _rg_layer=${_rg_source#shell/layers/}
3956
+ _rg_layer=${_rg_layer%/module.d}
3957
+ case "$_rg_layer" in
3958
+ [A-Za-z0-9_]*) ;;
3959
+ *)
3960
+ printf 'valise: error: invalid layer source for registry: %s\n' "$_rg_rel" >&2
3961
+ exit 1
3962
+ ;;
3963
+ esac
3964
+ case "$_rg_layer" in
3965
+ */*|*..*|*[!A-Za-z0-9_.-]*)
3966
+ printf 'valise: error: invalid layer source for registry: %s\n' "$_rg_rel" >&2
3967
+ exit 1
3968
+ ;;
3969
+ esac
3970
+ ;;
3971
+ *)
3972
+ printf 'valise: error: unsupported metadata source for registry: %s\n' "$_rg_rel" >&2
3973
+ exit 1
3974
+ ;;
3975
+ esac
3976
+ _rg_record=$(sp_metadata_read "$_rg_meta") || {
3977
+ printf 'valise: error: %s: %s\n' "$_rg_rel" "${_rg_record:-invalid metadata}" >&2
3978
+ exit 1
3979
+ }
3980
+ _rg_name=
3981
+ _rg_version=
3982
+ _rg_side_effect=
3983
+ _rg_requires=
3984
+ _rg_optional=
3985
+ _rg_shells=
3986
+ _rg_platforms=
3987
+ _rg_exports=
3988
+ _rg_load=
3989
+ _rg_updates=
3990
+ while IFS= read -r _rg_line; do
3991
+ case "$_rg_line" in
3992
+ name=*) _rg_name=${_rg_line#name=} ;;
3993
+ version=*) _rg_version=${_rg_line#version=} ;;
3994
+ side_effect=*) _rg_side_effect=${_rg_line#side_effect=} ;;
3995
+ requires=*) _rg_requires=${_rg_line#requires=} ;;
3996
+ optional=*) _rg_optional=${_rg_line#optional=} ;;
3997
+ shells=*) _rg_shells=${_rg_line#shells=} ;;
3998
+ platforms=*) _rg_platforms=${_rg_line#platforms=} ;;
3999
+ exports=*) _rg_exports=${_rg_line#exports=} ;;
4000
+ load=*) _rg_load=${_rg_line#load=} ;;
4001
+ updates=*) _rg_updates=${_rg_line#updates=} ;;
4002
+ esac
4003
+ done <<EOF
4004
+ $_rg_record
4005
+ EOF
4006
+ _rg_file_name=${_rg_meta##*/}
4007
+ _rg_file_name=${_rg_file_name%.meta}
4008
+ if [ "$_rg_name" != "$_rg_file_name" ]; then
4009
+ printf 'valise: error: %s: metadata name %s does not match file name %s\n' "$_rg_rel" "$_rg_name" "$_rg_file_name" >&2
4010
+ exit 1
4011
+ fi
4012
+ if [ "$_rg_source" != "shell/module.d" ]; then
4013
+ case "$_rg_name" in
4014
+ contrib_*) ;;
4015
+ *)
4016
+ printf 'valise: error: %s: layer module name %s must use the contrib_ prefix\n' "$_rg_rel" "$_rg_name" >&2
4017
+ exit 1
4018
+ ;;
4019
+ esac
4020
+ fi
4021
+ if [ -n "$_rg_load" ]; then
4022
+ _rg_module_root=${_rg_source%/module.d}
4023
+ if ! source_path_is_physical "$_rg_root" "$_rg_module_root/$_rg_load"; then
4024
+ printf 'valise: error: %s: load path contains a symlink: %s\n' "$_rg_rel" "$_rg_load" >&2
4025
+ exit 1
4026
+ fi
4027
+ if [ ! -f "$_rg_root/$_rg_module_root/$_rg_load" ]; then
4028
+ printf 'valise: error: %s: load path does not exist: %s\n' "$_rg_rel" "$_rg_load" >&2
4029
+ exit 1
4030
+ fi
4031
+ fi
4032
+ printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
4033
+ "$_rg_name" "$_rg_version" "$_rg_side_effect" "$_rg_requires" \
4034
+ "$_rg_optional" "$_rg_shells" "$_rg_platforms" "$_rg_exports" \
4035
+ "$_rg_load" "$_rg_updates" "$_rg_source"
4036
+ done
4037
+ ) >"$_rg_tmp" || {
4038
+ "$_rg_rm" -f "$_rg_tmp" 2>/dev/null || :
4039
+ die "cannot generate module registry"
4040
+ }
4041
+ "$_rg_chmod" 0644 "$_rg_tmp" || {
4042
+ "$_rg_rm" -f "$_rg_tmp" 2>/dev/null || :
4043
+ die "cannot set registry file mode"
4044
+ }
4045
+ if [ "$_rg_check" = 1 ]; then
4046
+ if [ ! -f "$_rg_out" ]; then
4047
+ "$_rg_rm" -f "$_rg_tmp" 2>/dev/null || :
4048
+ die "module registry is missing: $_rg_out"
4049
+ fi
4050
+ if ! "$_rg_cmp" -s "$_rg_tmp" "$_rg_out"; then
4051
+ "$_rg_rm" -f "$_rg_tmp" 2>/dev/null || :
4052
+ die "module registry is out of date: $_rg_out"
4053
+ fi
4054
+ "$_rg_rm" -f "$_rg_tmp" 2>/dev/null || :
4055
+ _rg_tmp=""
4056
+ return 0
4057
+ fi
4058
+ "$_rg_mv" "$_rg_tmp" "$_rg_out" || {
4059
+ "$_rg_rm" -f "$_rg_tmp" 2>/dev/null || :
4060
+ die "cannot publish module registry"
4061
+ }
4062
+ _rg_tmp=""
4063
+ }
4064
+
4065
+ cmd_registry() {
4066
+ _registry_out=""
4067
+ _registry_check=0
4068
+ for _registry_arg do
4069
+ case "$_registry_arg" in
4070
+ --check) _registry_check=1 ;;
4071
+ --help|-h) usage_registry; return 0 ;;
4072
+ --*) die "unknown registry option: $_registry_arg" ;;
4073
+ *) [ -z "$_registry_out" ] || die "usage: valise registry [--check] [registry-path]"; _registry_out=$_registry_arg ;;
4074
+ esac
4075
+ done
4076
+ _registry_root=$(pwd -P) || die "cannot resolve working directory"
4077
+ [ -d "$_registry_root/shell" ] || die "run from the consuming project root (need shell/)"
4078
+ [ -n "$_registry_out" ] || _registry_out="$_registry_root/shell/release/module-registry.tsv"
4079
+ registry_generate "$_registry_root" "$_registry_out" "$_registry_check"
4080
+ }
4081
+
4082
+
4083
+ project_config_load() {
4084
+ _pc_root=$(pwd -P) || die "cannot resolve working directory"
4085
+ [ -d "$_pc_root/shell/module.d" ] || die "run from the consuming project root (need shell/module.d)"
4086
+ _project_root=$_pc_root
4087
+ _project_variant=default
4088
+ _project_bundle=default
4089
+ [ -n "${HOME:-}" ] || die "HOME is required for project commands"
4090
+ _project_conf="$_project_root/shell/release/project.conf"
4091
+ if [ -f "$_project_conf" ]; then
4092
+ [ ! -L "$_project_conf" ] || die "project.conf must not be a symlink: $_project_conf"
4093
+ while IFS= read -r _pc_line || [ -n "$_pc_line" ]; do
4094
+ case "$_pc_line" in
4095
+ '' | '#'*) continue ;;
4096
+ variant=*) _project_variant=${_pc_line#variant=} ;;
4097
+ bundle=*) _project_bundle=${_pc_line#bundle=} ;;
4098
+ *) die "invalid project.conf line: $_pc_line" ;;
4099
+ esac
4100
+ done <"$_project_conf"
4101
+ fi
4102
+ [ -n "$_project_variant" ] || die "project variant must not be empty"
4103
+ [ -n "$_project_bundle" ] || die "project bundle must not be empty"
4104
+ unset _pc_root _pc_line _project_conf
4105
+ }
4106
+
4107
+ project_meta_add_require() {
4108
+ _pm_meta=$1
4109
+ _pm_module=$2
4110
+ [ -f "$_pm_meta" ] && [ ! -L "$_pm_meta" ] || die "seed metadata file is missing or unsafe: $_pm_meta"
4111
+ _pm_parent=$(path_parent "$_pm_meta")
4112
+ _pm_tmp=$(trusted_mktemp_at "$_pm_parent" file "$_pm_parent/.valise-meta.XXXXXX") ||
4113
+ die "cannot create seed metadata staging file"
4114
+ _pm_awk=$(trusted_utility_path awk) || die "no trusted awk utility available"
4115
+ "$_pm_awk" -v module="$_pm_module" '
4116
+ BEGIN { have = 0 }
4117
+ /^requires=/ {
4118
+ value = substr($0, 10)
4119
+ count = split(value, parts, " ")
4120
+ output = ""
4121
+ seen = 0
4122
+ for (i = 1; i <= count; i++) {
4123
+ if (parts[i] == "") continue
4124
+ if (parts[i] == module) seen = 1
4125
+ output = output (output == "" ? "" : " ") parts[i]
4126
+ }
4127
+ if (!seen) output = output (output == "" ? "" : " ") module
4128
+ print "requires=" output
4129
+ have = 1
4130
+ next
4131
+ }
4132
+ { print }
4133
+ END { if (!have) print "requires=" module }
4134
+ ' "$_pm_meta" >"$_pm_tmp" || {
4135
+ _pm_rm=$(trusted_utility_path rm) || _pm_rm=""
4136
+ [ -z "$_pm_rm" ] || "$_pm_rm" -f "$_pm_tmp" 2>/dev/null || :
4137
+ die "cannot update seed metadata: $_pm_meta"
4138
+ }
4139
+ _pm_mode=$(file_mode "$_pm_meta")
4140
+ [ -n "$_pm_mode" ] || die "cannot read seed metadata mode: $_pm_meta"
4141
+ _pm_chmod=$(trusted_utility_path chmod) || die "no trusted chmod utility available"
4142
+ "$_pm_chmod" "$_pm_mode" "$_pm_tmp" || die "cannot preserve seed metadata mode"
4143
+ _pm_mv=$(trusted_utility_path mv) || die "no trusted mv utility available"
4144
+ "$_pm_mv" "$_pm_tmp" "$_pm_meta" || die "cannot publish seed metadata: $_pm_meta"
4145
+ _pm_tmp=""
4146
+ unset _pm_meta _pm_module _pm_parent _pm_awk _pm_mode _pm_chmod _pm_mv _pm_rm
4147
+ }
4148
+
4149
+ project_meta_remove_module() {
4150
+ _pm_meta=$1
4151
+ _pm_module=$2
4152
+ [ -f "$_pm_meta" ] && [ ! -L "$_pm_meta" ] || die "metadata file is missing or unsafe: $_pm_meta"
4153
+ _pm_parent=$(path_parent "$_pm_meta")
4154
+ _pm_tmp=$(trusted_mktemp_at "$_pm_parent" file "$_pm_parent/.valise-meta.XXXXXX") ||
4155
+ die "cannot create metadata staging file"
4156
+ _pm_awk=$(trusted_utility_path awk) || die "no trusted awk utility available"
4157
+ "$_pm_awk" -v module="$_pm_module" '
4158
+ /^requires=/ {
4159
+ value = substr($0, 10)
4160
+ count = split(value, parts, " ")
4161
+ output = ""
4162
+ for (i = 1; i <= count; i++) {
4163
+ if (parts[i] == "" || parts[i] == module) continue
4164
+ output = output (output == "" ? "" : " ") parts[i]
4165
+ }
4166
+ print "requires=" output
4167
+ next
4168
+ }
4169
+ /^optional=/ {
4170
+ value = substr($0, 10)
4171
+ count = split(value, parts, " ")
4172
+ output = ""
4173
+ for (i = 1; i <= count; i++) {
4174
+ if (parts[i] == "" || parts[i] == module) continue
4175
+ output = output (output == "" ? "" : " ") parts[i]
4176
+ }
4177
+ print "optional=" output
4178
+ next
4179
+ }
4180
+ { print }
4181
+ ' "$_pm_meta" >"$_pm_tmp" || {
4182
+ _pm_rm=$(trusted_utility_path rm) || _pm_rm=""
4183
+ [ -z "$_pm_rm" ] || "$_pm_rm" -f "$_pm_tmp" 2>/dev/null || :
4184
+ die "cannot update metadata: $_pm_meta"
4185
+ }
4186
+ _pm_mode=$(file_mode "$_pm_meta")
4187
+ [ -n "$_pm_mode" ] || die "cannot read metadata mode: $_pm_meta"
4188
+ _pm_chmod=$(trusted_utility_path chmod) || die "no trusted chmod utility available"
4189
+ "$_pm_chmod" "$_pm_mode" "$_pm_tmp" || die "cannot preserve metadata mode"
4190
+ _pm_mv=$(trusted_utility_path mv) || die "no trusted mv utility available"
4191
+ "$_pm_mv" "$_pm_tmp" "$_pm_meta" || die "cannot publish metadata: $_pm_meta"
4192
+ _pm_tmp=""
4193
+ unset _pm_meta _pm_module _pm_parent _pm_awk _pm_mode _pm_chmod _pm_mv _pm_rm
4194
+ }
4195
+
4196
+ # project_meta_loses_all_targets <meta> <module>
4197
+ # Succeed when removing module from requires= would leave no load target at all.
4198
+ project_meta_loses_all_targets() {
4199
+ _pl_awk=$(trusted_utility_path awk) || die "no trusted awk utility available"
4200
+ "$_pl_awk" -v module="$2" '
4201
+ /^load=./ { target = 1 }
4202
+ /^requires=/ {
4203
+ count = split(substr($0, 10), parts, " ")
4204
+ for (i = 1; i <= count; i++) {
4205
+ if (parts[i] == module) named = 1
4206
+ else if (parts[i] != "") other = 1
4207
+ }
4208
+ }
4209
+ END { exit (named && !other && !target) ? 0 : 1 }
4210
+ ' "$1"
4211
+ }
4212
+
4213
+ # project_release_filter <file> <column> <module> <source>
4214
+ # Print a release file without the module's names in the given column. Bare names
4215
+ # in these files mean primary modules; a layer module is named module@source.
4216
+ # Exit 3 when a row that named the module is left with no names.
4217
+ project_release_filter() {
4218
+ _rf_awk=$(trusted_utility_path awk) || die "no trusted awk utility available"
4219
+ "$_rf_awk" -F '\t' -v OFS='\t' -v column="$2" -v module="$3" -v source="$4" '
4220
+ /^[[:space:]]*#/ || NF < column { print; next }
4221
+ {
4222
+ count = split($column, parts, " ")
4223
+ output = ""
4224
+ hit = 0
4225
+ for (i = 1; i <= count; i++) {
4226
+ if (parts[i] == "") continue
4227
+ if (parts[i] == module "@" source || (source == "shell/module.d" && parts[i] == module)) {
4228
+ hit = 1
4229
+ continue
4230
+ }
4231
+ output = output (output == "" ? "" : " ") parts[i]
4232
+ }
4233
+ if (hit) {
4234
+ $column = output
4235
+ if (output == "") emptied = 1
4236
+ }
4237
+ print
4238
+ }
4239
+ END { if (emptied) exit 3 }
4240
+ ' "$1"
4241
+ }
4242
+
4243
+ # project_release_strip <file> <column> <module> <source>
4244
+ project_release_strip() {
4245
+ [ -f "$1" ] || return 0
4246
+ _rs_parent=$(path_parent "$1")
4247
+ _rr_tmp=$(trusted_mktemp_at "$_rs_parent" file "$_rs_parent/.valise-release.XXXXXX") ||
4248
+ die "cannot create release file staging file"
4249
+ _rs_status=0
4250
+ project_release_filter "$1" "$2" "$3" "$4" >"$_rr_tmp" || _rs_status=$?
4251
+ case "$_rs_status" in
4252
+ 0 | 3) ;;
4253
+ *) die "cannot update release file: $1" ;;
4254
+ esac
4255
+ _rs_mode=$(file_mode "$1")
4256
+ [ -n "$_rs_mode" ] || die "cannot read release file mode: $1"
4257
+ _rs_chmod=$(trusted_utility_path chmod) || die "no trusted chmod utility available"
4258
+ "$_rs_chmod" "$_rs_mode" "$_rr_tmp" || die "cannot preserve release file mode"
4259
+ _rs_mv=$(trusted_utility_path mv) || die "no trusted mv utility available"
4260
+ "$_rs_mv" "$_rr_tmp" "$1" || die "cannot publish release file: $1"
4261
+ _rr_tmp=""
4262
+ unset _rs_parent _rs_status _rs_mode _rs_chmod _rs_mv
4263
+ }
4264
+
4265
+ # project_write_integration <file> <block>
4266
+ # Replace the valise managed block in place, or append one. A symlink belongs to
4267
+ # whatever manages it, so it is reported and left alone. Unpaired markers mean the
4268
+ # file was edited by hand: rewriting could delete the user's lines, so refuse.
4269
+ project_write_integration() {
4270
+ _pi_target=$1
4271
+ _pi_snippet=$2
4272
+ if [ -L "$_pi_target" ]; then
4273
+ info "warning: $_pi_target is a symlink; add the output of 'valise init' to it yourself"
4274
+ unset _pi_target _pi_snippet
4275
+ return 0
4276
+ fi
4277
+ _pi_awk=$(trusted_utility_path awk) || die "no trusted awk utility available"
4278
+ if [ -f "$_pi_target" ]; then
4279
+ "$_pi_awk" '
4280
+ BEGIN { open = 0; bad = 0 }
4281
+ /^# BEGIN valise managed block$/ { if (open) bad = 1; open = 1; next }
4282
+ /^# END valise managed block$/ { if (!open) bad = 1; open = 0; next }
4283
+ END { exit (bad || open) ? 1 : 0 }
4284
+ ' "$_pi_target" ||
4285
+ die "unpaired valise managed block markers in $_pi_target (restore or remove the BEGIN and END lines, then retry)"
4286
+ elif [ -e "$_pi_target" ]; then
4287
+ die "startup path is not a regular file: $_pi_target"
4288
+ fi
4289
+ _pi_parent=$(path_parent "$_pi_target")
4290
+ _pi_tmp=$(trusted_mktemp_at "$_pi_parent" file "$_pi_parent/.valise-integration.XXXXXX") ||
4291
+ die "cannot create integration staging file"
4292
+ if [ -f "$_pi_target" ]; then
4293
+ # The block travels through ENVIRON: awk -v would interpret its backslashes.
4294
+ _VALISE_BLOCK=$_pi_snippet "$_pi_awk" '
4295
+ /^# BEGIN valise managed block$/ {
4296
+ if (!written) print ENVIRON["_VALISE_BLOCK"]
4297
+ written = 1
4298
+ skip = 1
4299
+ next
4300
+ }
4301
+ /^# END valise managed block$/ { skip = 0; next }
4302
+ !skip { print; last = $0; lines++ }
4303
+ END {
4304
+ if (!written) {
4305
+ if (lines > 0 && last != "") print ""
4306
+ print ENVIRON["_VALISE_BLOCK"]
4307
+ }
4308
+ }
4309
+ ' "$_pi_target" >"$_pi_tmp" || die "cannot read startup file: $_pi_target"
4310
+ _pi_cmp=$(trusted_utility_path cmp) || die "no trusted cmp utility available"
4311
+ if "$_pi_cmp" -s "$_pi_tmp" "$_pi_target"; then
4312
+ _pi_rm=$(trusted_utility_path rm) || die "no trusted rm utility available"
4313
+ "$_pi_rm" -f "$_pi_tmp" || die "cannot remove unchanged integration staging file"
4314
+ _pi_tmp=""
4315
+ info "shell integration current: $_pi_target"
4316
+ unset _pi_target _pi_snippet _pi_parent _pi_awk _pi_cmp _pi_rm
4317
+ return 0
4318
+ fi
4319
+ _pi_mode=$(file_mode "$_pi_target")
4320
+ [ -n "$_pi_mode" ] || die "cannot read startup file mode: $_pi_target"
4321
+ else
4322
+ printf '%s\n' "$_pi_snippet" >"$_pi_tmp" || die "cannot write integration staging file"
4323
+ _pi_mode=0644
4324
+ fi
4325
+ _pi_chmod=$(trusted_utility_path chmod) || die "no trusted chmod utility available"
4326
+ "$_pi_chmod" "$_pi_mode" "$_pi_tmp" || die "cannot set startup file mode"
4327
+ _pi_mv=$(trusted_utility_path mv) || die "no trusted mv utility available"
4328
+ "$_pi_mv" "$_pi_tmp" "$_pi_target" || die "cannot publish startup file: $_pi_target"
4329
+ _pi_tmp=""
4330
+ info "shell integration written: $_pi_target"
4331
+ unset _pi_target _pi_snippet _pi_parent _pi_awk _pi_cmp _pi_rm _pi_mode _pi_chmod _pi_mv
4332
+ }
4333
+
4334
+ cmd_new() {
4335
+ _new_target=
4336
+ for _new_arg do
4337
+ case "$_new_arg" in
4338
+ --help|-h) usage_new; return 0 ;;
4339
+ --*) die "unknown new option: $_new_arg" ;;
4340
+ *) [ -z "$_new_target" ] || die "usage: valise new [dir]"; _new_target=$_new_arg ;;
4341
+ esac
4342
+ done
4343
+ [ -n "$_new_target" ] || _new_target=.
4344
+ _new_target=$(path_normalize "$_new_target") || die "cannot resolve new project path"
4345
+ [ "$_new_target" != "/" ] || die "new project path must not be /"
4346
+ if [ -e "$_new_target" ] || [ -L "$_new_target" ]; then
4347
+ [ -d "$_new_target" ] && [ ! -L "$_new_target" ] || die "new project path is not a directory: $_new_target"
4348
+ for _new_entry in "$_new_target"/* "$_new_target"/.[!.]* "$_new_target"/..?*; do
4349
+ if [ -e "$_new_entry" ] || [ -L "$_new_entry" ]; then
4350
+ die "new project directory must be empty: $_new_target"
4351
+ fi
4352
+ done
4353
+ fi
4354
+ _new_mkdir=$(trusted_utility_path mkdir) || die "no trusted mkdir utility available"
4355
+ "$_new_mkdir" -p "$_new_target/shell/module.d" "$_new_target/shell/modules" "$_new_target/shell/release" ||
4356
+ die "cannot create project directories: $_new_target"
4357
+ printf '%s\n' \
4358
+ 'name=profile_main' \
4359
+ 'version=' \
4360
+ 'side_effect=read-only' \
4361
+ 'requires=' \
4362
+ 'optional=' \
4363
+ 'shells=bash zsh ksh' \
4364
+ 'platforms=any' \
4365
+ 'exports=profile_main_loaded' \
4366
+ 'updates=' \
4367
+ 'load=modules/profile_main.sh' \
4368
+ >"$_new_target/shell/module.d/profile_main.meta"
4369
+ printf '%s\n' \
4370
+ '# Shared project initialization. Add shell functions or exports here.' \
4371
+ 'profile_main_loaded() { :; }' \
4372
+ >"$_new_target/shell/modules/profile_main.sh"
4373
+ printf 'variant_name\tdescription\texcluded_modules\ndefault\tAll project modules\t\n' \
4374
+ >"$_new_target/shell/release/install-variants"
4375
+ printf '# bundle\tshell\troots\ndefault\tbash\tprofile_main\ndefault\tzsh\tprofile_main\ndefault\tksh\tprofile_main\n' \
4376
+ >"$_new_target/shell/release/bundle-roots"
4377
+ printf 'variant=default\nbundle=default\n' \
4378
+ >"$_new_target/shell/release/project.conf"
4379
+ printf '%s\n' \
4380
+ "created valise project: $_new_target" \
4381
+ "next steps:" \
4382
+ " cd ${_new_target#"$(pwd -P)"/}" \
4383
+ " valise module new hello --exports=hello" \
4384
+ " valise apply"
4385
+ unset _new_target _new_arg _new_entry _new_mkdir
4386
+ }
4387
+
4388
+ cmd_module() {
4389
+ _module_subcmd=${1:-}
4390
+ [ -n "$_module_subcmd" ] || die "usage: valise module <new|list|remove> [args]"
4391
+ case "$_module_subcmd" in
4392
+ --help|-h) usage_module; return 0 ;;
4393
+ new)
4394
+ shift
4395
+ _module_name=
4396
+ _module_exports=
4397
+ for _module_arg do
4398
+ case "$_module_arg" in
4399
+ --exports=*) _module_exports=${_module_arg#--exports=} ;;
4400
+ --help|-h) usage_module; return 0 ;;
4401
+ --*) die "unknown module new option: $_module_arg" ;;
4402
+ *) [ -z "$_module_name" ] || die "usage: valise module new <name> [--exports=<list>]"; _module_name=$_module_arg ;;
4403
+ esac
4404
+ done
4405
+ [ -n "$_module_name" ] || die "module new requires a module name"
4406
+ case "$_module_name" in
4407
+ [abcdefghijklmnopqrstuvwxyz_]*) ;;
4408
+ *) die "invalid module name: $_module_name" ;;
4409
+ esac
4410
+ case "$_module_name" in
4411
+ *[!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_]*) die "invalid module name: $_module_name" ;;
4412
+ esac
4413
+ project_config_load
4414
+ _module_seed="$_project_root/shell/module.d/profile_main.meta"
4415
+ [ -f "$_module_seed" ] && [ ! -L "$_module_seed" ] ||
4416
+ die "seed metadata file is missing or unsafe: $_module_seed"
4417
+ [ -d "$_project_root/shell/modules" ] ||
4418
+ die "module body directory is missing: $_project_root/shell/modules"
4419
+ [ -w "$_project_root/shell/module.d" ] && [ -w "$_project_root/shell/modules" ] ||
4420
+ die "module directories are not writable: $_project_root/shell"
4421
+ _module_meta="$_project_root/shell/module.d/$_module_name.meta"
4422
+ _module_body="$_project_root/shell/modules/$_module_name.sh"
4423
+ [ ! -e "$_module_meta" ] && [ ! -L "$_module_meta" ] || die "module metadata already exists: $_module_name"
4424
+ [ ! -e "$_module_body" ] && [ ! -L "$_module_body" ] || die "module body already exists: $_module_name"
4425
+ _module_export_words=
4426
+ if [ -n "$_module_exports" ]; then
4427
+ _module_old_ifs=${IFS-}
4428
+ IFS=', '
4429
+ for _module_export in $_module_exports; do
4430
+ [ -n "$_module_export" ] || continue
4431
+ case "$_module_export" in
4432
+ [abcdefghijklmnopqrstuvwxyz_]*) ;;
4433
+ *) die "invalid export name: $_module_export" ;;
4434
+ esac
4435
+ case "$_module_export" in
4436
+ *[!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_]*) die "invalid export name: $_module_export" ;;
4437
+ esac
4438
+ case "$_module_export" in
4439
+ case|do|done|elif|else|esac|fi|for|if|in|then|until|while|time)
4440
+ die "invalid export name (reserved word): $_module_export"
4441
+ ;;
4442
+ esac
4443
+ _module_export_words="$_module_export_words $_module_export"
4444
+ done
4445
+ if [ "${_module_old_ifs+x}" = x ]; then
4446
+ IFS=$_module_old_ifs
4447
+ else
4448
+ unset IFS
4449
+ fi
4450
+ fi
4451
+ _module_export_words=${_module_export_words# }
4452
+ _module_awk=$(trusted_utility_path awk) || die "no trusted awk utility available"
4453
+ for _module_export in $_module_export_words; do
4454
+ for _module_candidate in "$_project_root"/shell/module.d/*.meta "$_project_root"/shell/layers/*/module.d/*.meta; do
4455
+ [ -f "$_module_candidate" ] || continue
4456
+ if "$_module_awk" -F= -v wanted="$_module_export" '
4457
+ $1 == "exports" {
4458
+ count = split(substr($0, length($1) + 2), words, " ")
4459
+ for (i = 1; i <= count; i++) {
4460
+ if (words[i] == wanted) found = 1
4461
+ }
4462
+ }
4463
+ END { exit found ? 0 : 1 }
4464
+ ' "$_module_candidate" >/dev/null 2>&1; then
4465
+ die "export already exists in another module: $_module_export"
4466
+ fi
4467
+ done
4468
+ done
4469
+ printf '%s\n' \
4470
+ "name=$_module_name" \
4471
+ 'version=' \
4472
+ 'side_effect=read-only' \
4473
+ 'requires=' \
4474
+ 'optional=' \
4475
+ 'shells=sh' \
4476
+ 'platforms=any' \
4477
+ "exports=$_module_export_words" \
4478
+ 'updates=' \
4479
+ "load=modules/$_module_name.sh" \
4480
+ >"$_module_meta"
4481
+ {
4482
+ printf '%s\n' "# Edit this module body; valise apply reloads it."
4483
+ if [ -n "$_module_export_words" ]; then
4484
+ _module_old_ifs=${IFS-}
4485
+ IFS=' '
4486
+ for _module_export in $_module_export_words; do
4487
+ printf '%s() {\n printf "%%s\\n" "hello from %s"\n}\n\n' "$_module_export" "$_module_name"
4488
+ done
4489
+ if [ "${_module_old_ifs+x}" = x ]; then
4490
+ IFS=$_module_old_ifs
4491
+ else
4492
+ unset IFS
4493
+ fi
4494
+ else
4495
+ printf '%s\n' "# This module has no declared exports."
4496
+ fi
4497
+ } >"$_module_body"
4498
+ project_meta_add_require "$_project_root/shell/module.d/profile_main.meta" "$_module_name"
4499
+ info "created module $_module_name and wired it into profile_main"
4500
+ ;;
4501
+ list)
4502
+ shift
4503
+ [ "$#" -eq 0 ] || die "usage: valise module list"
4504
+ project_config_load
4505
+ _module_list_tmp=$(trusted_mktemp file "${TMPDIR:-/tmp}/valise-module-list.XXXXXX") ||
4506
+ die "cannot create module list staging file"
4507
+ registry_generate "$_project_root" "$_module_list_tmp"
4508
+ _module_awk=$(trusted_utility_path awk) || die "no trusted awk utility available"
4509
+ "$_module_awk" -F '\t' 'NR > 1 { print $1 }' "$_module_list_tmp"
4510
+ _module_rm=$(trusted_utility_path rm) || die "no trusted rm utility available"
4511
+ "$_module_rm" -f "$_module_list_tmp" || die "cannot remove module list staging file"
4512
+ _module_list_tmp=""
4513
+ ;;
4514
+ remove)
4515
+ shift
4516
+ _module_name=${1:-}
4517
+ [ -n "$_module_name" ] || die "module remove requires a module name"
4518
+ project_config_load
4519
+ _module_meta=
4520
+ _module_source=
4521
+ for _module_candidate in "$_project_root"/shell/module.d/*.meta "$_project_root"/shell/layers/*/module.d/*.meta; do
4522
+ [ -f "$_module_candidate" ] || continue
4523
+ _module_candidate_name=${_module_candidate##*/}
4524
+ _module_candidate_name=${_module_candidate_name%.meta}
4525
+ if [ "$_module_candidate_name" = "$_module_name" ]; then
4526
+ _module_meta=$_module_candidate
4527
+ _module_source=${_module_candidate%/*}
4528
+ break
4529
+ fi
4530
+ done
4531
+ [ -n "$_module_meta" ] || die "module not found: $_module_name"
4532
+ # Everything that would make the removal fail is checked here, before any
4533
+ # file changes: a half-applied removal leaves a project that cannot build.
4534
+ _module_registry_source=${_module_source#"$_project_root"/}
4535
+ _module_bundle_roots=$_project_root/shell/release/bundle-roots
4536
+ _module_variants=$_project_root/shell/release/install-variants
4537
+ for _module_candidate in "$_project_root"/shell/module.d/*.meta "$_project_root"/shell/layers/*/module.d/*.meta; do
4538
+ [ -f "$_module_candidate" ] || continue
4539
+ [ "$_module_candidate" != "$_module_meta" ] || continue
4540
+ if project_meta_loses_all_targets "$_module_candidate" "$_module_name"; then
4541
+ _module_candidate_name=${_module_candidate##*/}
4542
+ die "cannot remove $_module_name: ${_module_candidate_name%.meta} requires only this module and would have nothing to load; add another dependency or remove ${_module_candidate_name%.meta} first"
4543
+ fi
4544
+ done
4545
+ for _module_candidate in "$_module_bundle_roots" "$_module_variants"; do
4546
+ if [ -e "$_module_candidate" ] || [ -L "$_module_candidate" ]; then
4547
+ [ -f "$_module_candidate" ] && [ ! -L "$_module_candidate" ] || die "release file is missing or unsafe: $_module_candidate"
4548
+ fi
4549
+ done
4550
+ if [ -f "$_module_bundle_roots" ]; then
4551
+ _module_status=0
4552
+ project_release_filter "$_module_bundle_roots" 3 "$_module_name" "$_module_registry_source" >/dev/null || _module_status=$?
4553
+ case "$_module_status" in
4554
+ 0) ;;
4555
+ 3) die "cannot remove $_module_name: it is the only root of a bundle in shell/release/bundle-roots; add another root first" ;;
4556
+ *) die "cannot read release file: $_module_bundle_roots" ;;
4557
+ esac
4558
+ fi
4559
+ _module_awk=$(trusted_utility_path awk) || die "no trusted awk utility available"
4560
+ _module_load=$("$_module_awk" -F= '$1 == "load" { print substr($0, length($1) + 2); exit }' "$_module_meta")
4561
+ _module_body_path=
4562
+ _module_body_shared=0
4563
+ if [ -n "$_module_load" ]; then
4564
+ case "$_module_load" in
4565
+ ''|/*|.|..|./*|../*|*/.|*/..|*/./*|*/../*|*//*)
4566
+ die "invalid module load path: $_module_load"
4567
+ ;;
4568
+ esac
4569
+ _module_root=${_module_source%/module.d}
4570
+ source_path_is_physical "$_module_root" "$_module_load" ||
4571
+ die "module load path contains a symlink: $_module_load"
4572
+ _module_body_path="$_module_root/$_module_load"
4573
+ [ ! -d "$_module_body_path" ] || die "module load path is a directory: $_module_load"
4574
+ [ -f "$_module_body_path" ] || die "module body is missing: $_module_load"
4575
+ # Another module in the same source may declare the same body. Keep it
4576
+ # when that is true: removing the metadata is enough, and deleting the
4577
+ # body would break the module that still loads it.
4578
+ for _module_candidate in "$_module_source"/*.meta; do
4579
+ [ -f "$_module_candidate" ] || continue
4580
+ [ "$_module_candidate" != "$_module_meta" ] || continue
4581
+ _module_candidate_load=$("$_module_awk" -F= '$1 == "load" { print substr($0, length($1) + 2); exit }' "$_module_candidate")
4582
+ if [ "$_module_candidate_load" = "$_module_load" ]; then
4583
+ _module_body_shared=1
4584
+ break
4585
+ fi
4586
+ done
4587
+ [ -w "$(path_parent "$_module_body_path")" ] ||
4588
+ die "module body directory is not writable: $(path_parent "$_module_body_path")"
4589
+ fi
4590
+ # Every path the removal rewrites must be writable before the first change.
4591
+ [ -w "$_module_source" ] || die "module metadata directory is not writable: $_module_source"
4592
+ [ -w "$_project_root/shell/release" ] || die "release directory is not writable: $_project_root/shell/release"
4593
+ for _module_candidate in "$_project_root"/shell/module.d/*.meta "$_project_root"/shell/layers/*/module.d/*.meta; do
4594
+ [ -f "$_module_candidate" ] || continue
4595
+ _module_candidate_parent=$(path_parent "$_module_candidate")
4596
+ [ -w "$_module_candidate_parent" ] ||
4597
+ die "metadata directory is not writable: $_module_candidate_parent"
4598
+ done
4599
+ for _module_candidate in "$_module_bundle_roots" "$_module_variants"; do
4600
+ if [ -f "$_module_candidate" ]; then
4601
+ [ -w "$_module_candidate" ] || die "release file is not writable: $_module_candidate"
4602
+ fi
4603
+ done
4604
+ _module_rm=$(trusted_utility_path rm) || die "no trusted rm utility available"
4605
+ if [ -n "$_module_body_path" ] && [ "$_module_body_shared" -eq 0 ]; then
4606
+ "$_module_rm" -f "$_module_body_path" || die "cannot remove module body: $_module_load"
4607
+ fi
4608
+ "$_module_rm" -f "$_module_meta" || die "cannot remove module metadata: $_module_name"
4609
+ for _module_candidate in "$_project_root"/shell/module.d/*.meta "$_project_root"/shell/layers/*/module.d/*.meta; do
4610
+ [ -f "$_module_candidate" ] || continue
4611
+ project_meta_remove_module "$_module_candidate" "$_module_name"
4612
+ done
4613
+ project_release_strip "$_module_bundle_roots" 3 "$_module_name" "$_module_registry_source"
4614
+ project_release_strip "$_module_variants" 3 "$_module_name" "$_module_registry_source"
4615
+ registry_generate "$_project_root" "$_project_root/shell/release/module-registry.tsv"
4616
+ info "removed module $_module_name"
4617
+ ;;
4618
+ *)
4619
+ die "unknown module subcommand: $_module_subcmd (use new, list, or remove)"
4620
+ ;;
4621
+ esac
4622
+ }
4623
+
4624
+ cmd_build() {
4625
+ _build_out=
4626
+ _build_variant=
4627
+ _build_bundle=
4628
+ for _build_arg do
4629
+ case "$_build_arg" in
4630
+ --variant=*) _build_variant=${_build_arg#--variant=} ;;
4631
+ --bundle=*) _build_bundle=${_build_arg#--bundle=} ;;
4632
+ --help|-h) usage_build; return 0 ;;
4633
+ --*) die "unknown build option: $_build_arg" ;;
4634
+ *) [ -z "$_build_out" ] || die "usage: valise build [--variant=<name>] [--bundle=<name>] [out]"; _build_out=$_build_arg ;;
4635
+ esac
4636
+ done
4637
+ project_config_load
4638
+ [ -n "$_build_variant" ] || _build_variant=$_project_variant
4639
+ [ -n "$_build_bundle" ] || _build_bundle=$_project_bundle
4640
+ [ -n "$_build_out" ] || _build_out=$_project_root/build/release
4641
+ _build_out=$(path_normalize "$_build_out") || die "cannot resolve build output path"
4642
+ # The previous default output stays in place until the new tree has compiled
4643
+ # and been manifested, so a failed build never destroys a good one.
4644
+ if [ "$_build_out" = "$_project_root/build/release" ]; then
4645
+ _build_release_tree --replace-default --variant="$_build_variant" --bundle="$_build_bundle" "$_build_out"
4646
+ else
4647
+ _build_release_tree --variant="$_build_variant" --bundle="$_build_bundle" "$_build_out"
4648
+ fi
4649
+ unset _build_out _build_arg _build_variant _build_bundle
4650
+ }
4651
+
4652
+ cmd_apply() {
4653
+ _apply_variant=
4654
+ _apply_bundle=
4655
+ _apply_install_arg=
4656
+ for _apply_arg do
4657
+ case "$_apply_arg" in
4658
+ --variant=*) _apply_variant=${_apply_arg#--variant=} ;;
4659
+ --bundle=*) _apply_bundle=${_apply_arg#--bundle=} ;;
4660
+ --no-integration) _apply_install_arg=--no-integration ;;
4661
+ --help|-h) usage_apply; return 0 ;;
4662
+ *) die "unknown apply option: $_apply_arg" ;;
4663
+ esac
4664
+ done
4665
+ project_config_load
4666
+ [ -n "$_apply_variant" ] || _apply_variant=$_project_variant
4667
+ [ -n "$_apply_bundle" ] || _apply_bundle=$_project_bundle
4668
+ cmd_build --variant="$_apply_variant" --bundle="$_apply_bundle"
4669
+ _apply_build_out=$_project_root/build/release
4670
+ _apply_old_pwd=$(pwd -P) || die "cannot resolve current directory"
4671
+ cd "$_apply_build_out" || die "cannot enter build output: $_apply_build_out"
4672
+ cmd_install $_apply_install_arg || die "valise install failed"
4673
+ cd "$_apply_old_pwd" || die "cannot restore project directory: $_apply_old_pwd"
4674
+ cmd_verify_active
4675
+ info "applied project: $_project_root"
4676
+ unset _apply_arg _apply_variant _apply_bundle _apply_install_arg _apply_build_out _apply_old_pwd
4677
+ }
4678
+
4679
+ cmd_status() {
4680
+ for _status_arg do
4681
+ case "$_status_arg" in
4682
+ --help|-h) usage_status; return 0 ;;
4683
+ *) die "unknown status option: $_status_arg" ;;
4684
+ esac
4685
+ done
4686
+ project_config_load
4687
+ printf 'project: %s\n' "$_project_root"
4688
+ printf 'variant: %s\n' "$_project_variant"
4689
+ printf 'bundle: %s\n' "$_project_bundle"
4690
+ _status_home=$(path_normalize "$HOME") || die "cannot resolve HOME"
4691
+ _status_targets=$(posix_startup_targets "$_status_home")
4692
+ while IFS= read -r _status_target; do
4693
+ [ -n "$_status_target" ] || continue
4694
+ if [ -L "$_status_target" ]; then
4695
+ printf 'integration: %s: symlink, not managed by valise\n' "$_status_target"
4696
+ elif [ ! -f "$_status_target" ]; then
4697
+ printf 'integration: %s: missing\n' "$_status_target"
4698
+ elif integration_has_managed_block "$_status_target"; then
4699
+ printf 'integration: %s: managed block present\n' "$_status_target"
4700
+ else
4701
+ printf 'integration: %s: managed block missing\n' "$_status_target"
4702
+ fi
4703
+ done <<EOF
4704
+ $_status_targets
4705
+ EOF
4706
+ unset _status_home _status_targets _status_target
4707
+ if (registry_generate "$_project_root" "$_project_root/shell/release/module-registry.tsv" 1) >/dev/null 2>&1; then
4708
+ printf 'registry: up to date\n'
4709
+ else
4710
+ printf 'registry: out of date\n'
4711
+ fi
4712
+ if [ -L "$VALISE_ACTIVE_LINK" ]; then
4713
+ _status_active_target=$(active_release_dir 2>/dev/null || :)
4714
+ if [ -n "$_status_active_target" ]; then
4715
+ _status_active_id=${_status_active_target##*/}
4716
+ printf 'active release: %s\n' "$_status_active_id"
4717
+ else
4718
+ printf 'active release: broken symlink\n'
4719
+ fi
4720
+ else
4721
+ printf 'active release: none\n'
4722
+ fi
4723
+ _status_build_manifest="$_project_root/build/release/shell/install/profile-release.manifest"
4724
+ if [ -f "$_status_build_manifest" ]; then
4725
+ _status_build_hash=$(file_sha256 "$_status_build_manifest") || die "cannot hash build manifest"
4726
+ printf 'build output: %s\n' "$_project_root/build/release"
4727
+ if [ -n "${_status_active_target:-}" ] && [ -d "$_status_active_target" ]; then
4728
+ if [ -f "$_status_active_target/.valise-manifest" ]; then
4729
+ _status_active_manifest="$_status_active_target/.valise-manifest"
4730
+ else
4731
+ _status_active_manifest="$_status_active_target/shell/install/profile-release.manifest"
4732
+ fi
4733
+ if [ -f "$_status_active_manifest" ]; then
4734
+ _status_active_hash=$(file_sha256 "$_status_active_manifest") || die "cannot hash active manifest"
4735
+ if [ "$_status_build_hash" = "$_status_active_hash" ]; then
4736
+ printf 'drift: active release matches build output\n'
4737
+ else
4738
+ printf 'drift: active release differs from build output\n'
4739
+ fi
4740
+ else
4741
+ printf 'drift: active manifest missing\n'
4742
+ fi
4743
+ else
4744
+ printf 'drift: no active release to compare\n'
4745
+ fi
4746
+ else
4747
+ printf 'build output: not built\n'
4748
+ fi
4749
+ unset _status_awk _status_active_target _status_active_id _status_build_manifest _status_build_hash _status_active_manifest _status_active_hash
4750
+ }
4751
+
4752
+
4753
+ # _build_release_tree [--variant=<name>] [--bundle=<name>] <out-dir>
4754
+ # Internal implementation of valise build. Build a release tree for compiled
4755
+ # plans from the consuming project in the current directory. The tree holds exactly what the plans source, at the paths
4756
+ # they source it from:
4757
+ # generated/installed-modules.<shell> valise compile output
4758
+ # vendor/shell-profile/lib/capabilities.sh the engine capability prelude
4759
+ # <load> and layers/<provider>/<load> every registry module body, with
4760
+ # its .meta beside it in module.d/
4761
+ # shell/release/ the registry, roots and variants
4762
+ _build_release_tree() {
4763
+ _as_out=""
4764
+ _as_variant=""
4765
+ _as_bundle=""
4766
+ _as_variant_set=0
4767
+ _as_bundle_set=0
4768
+ _as_replace_default=0
4769
+ for _as_arg do
4770
+ case "$_as_arg" in
4771
+ --variant=*) [ "$_as_variant_set" -eq 0 ] || die "duplicate --variant option"; _as_variant=${_as_arg#--variant=}; _as_variant_set=1 ;;
4772
+ --bundle=*) [ "$_as_bundle_set" -eq 0 ] || die "duplicate --bundle option"; _as_bundle=${_as_arg#--bundle=}; _as_bundle_set=1 ;;
4773
+ --replace-default) _as_replace_default=1 ;;
4774
+ --*) die "unknown build option: $_as_arg" ;;
4775
+ *) [ -z "$_as_out" ] || die "usage: valise build [--variant=<name>] [--bundle=<name>] [out]"; _as_out=$_as_arg ;;
4776
+ esac
4777
+ done
4778
+ [ -n "$_as_out" ] || die "usage: valise build [--variant=<name>] [--bundle=<name>] [out]"
4779
+ [ "$_as_variant_set" -eq 0 ] || [ -n "$_as_variant" ] || die "variant name must not be empty"
4780
+ [ "$_as_bundle_set" -eq 0 ] || [ -n "$_as_bundle" ] || die "bundle name must not be empty"
4781
+ _as_src=$(pwd -P) || die "cannot resolve working directory"
4782
+ [ -d "$_as_src/shell/module.d" ] || die "run from the consuming project root (need shell/module.d)"
4783
+ _as_out=$(path_normalize "$_as_out")
4784
+ [ "$_as_out" != "/" ] || die "output directory must not be /"
4785
+ case "$_as_out" in
4786
+ "$_as_src"/*)
4787
+ source_path_is_physical "$_as_src" "${_as_out#"$_as_src"/}" ||
4788
+ die "output path contains a symlink: $_as_out"
4789
+ ;;
4790
+ esac
4791
+ _as_output_existed=0
4792
+ if [ -e "$_as_out" ] || [ -L "$_as_out" ]; then
4793
+ [ -d "$_as_out" ] && [ ! -L "$_as_out" ] || die "output path is not a directory: $_as_out"
4794
+ _as_output_empty=1
4795
+ for _as_entry in "$_as_out"/* "$_as_out"/.[!.]* "$_as_out"/..?*; do
4796
+ if [ -e "$_as_entry" ] || [ -L "$_as_entry" ]; then
4797
+ _as_output_empty=0
4798
+ break
4799
+ fi
4800
+ done
4801
+ [ "$_as_output_empty" -eq 1 ] || [ "$_as_replace_default" -eq 1 ] ||
4802
+ die "output directory must be empty: $_as_out"
4803
+ _as_output_existed=1
4804
+ fi
4805
+ _as_registry="$_as_src/shell/release/module-registry.tsv"
4806
+ registry_generate "$_as_src" "$_as_registry"
4807
+ _as_script_dir=$(valise_script_dir) || die "cannot locate the valise engine"
4808
+ _as_prelude="$_as_script_dir/../lib/capabilities.sh"
4809
+ [ -r "$_as_prelude" ] || die "capabilities prelude not found: $_as_prelude"
4810
+ _as_parent=$(path_parent "$_as_out")
4811
+ _as_mkdir=$(trusted_utility_path mkdir) || die "no trusted mkdir utility available"
4812
+ _as_cp=$(trusted_utility_path cp) || die "no trusted cp utility available"
4813
+ _as_find=$(trusted_utility_path find) || die "no trusted find utility available"
4814
+ _as_chmod=$(trusted_utility_path chmod) || die "no trusted chmod utility available"
4815
+ _as_awk=$(trusted_utility_path awk) || die "no trusted awk utility available"
4816
+ [ -d "$_as_parent" ] || "$_as_mkdir" -p "$_as_parent" || die "cannot create output parent: $_as_parent"
4817
+ # Build beside the destination and publish only after the complete tree and
4818
+ # manifest exist, so a failed compile or copy never leaves a partial release.
4819
+ _stage=$(trusted_mktemp_at "$_as_parent" dir "$_as_parent/.valise-build.XXXXXX") ||
4820
+ die "cannot create build staging directory"
4821
+
4822
+ if [ -n "$_as_variant" ] && [ -n "$_as_bundle" ]; then
4823
+ "$0" compile --variant="$_as_variant" --bundle="$_as_bundle" "$_stage/generated" || die "valise compile failed"
4824
+ elif [ -n "$_as_variant" ]; then
4825
+ "$0" compile --variant="$_as_variant" "$_stage/generated" || die "valise compile failed"
4826
+ elif [ -n "$_as_bundle" ]; then
4827
+ "$0" compile --bundle="$_as_bundle" "$_stage/generated" || die "valise compile failed"
4828
+ else
4829
+ "$0" compile "$_stage/generated" || die "valise compile failed"
4830
+ fi
4831
+ "$_as_mkdir" -p "$_stage/vendor/shell-profile/lib" "$_stage/shell" || die "cannot create release tree"
4832
+ "$_as_cp" "$_as_prelude" "$_stage/vendor/shell-profile/lib/capabilities.sh" || die "cannot copy prelude"
4833
+ "$_as_cp" -R "$_as_src/shell/release" "$_stage/shell/release" || die "cannot copy shell/release"
4834
+
4835
+ # Registry columns: 1 name, 9 load, 11 source (the directory holding the
4836
+ # .meta). A primary module resolves against the parent of its source
4837
+ # directory; a layer module under shell/layers/<provider>/module.d lands at
4838
+ # layers/<provider>/ in the release, matching the compiled plan paths.
4839
+ _as_tab=$(printf '\t')
4840
+ _as_rows="$_stage/.registry-rows"
4841
+ "$_as_awk" -F "$_as_tab" 'NR > 1 && $1 != "" { print $1 "|" $9 "|" $11 }' \
4842
+ "$_as_registry" >"$_as_rows" || die "cannot read module registry"
4843
+ while IFS='|' read -r _as_name _as_load _as_source; do
4844
+ [ -n "$_as_name" ] || continue
4845
+ # Module names index both source and destination paths. Reject anything
4846
+ # that is not a metadata identifier before either path is constructed.
4847
+ case "$_as_name" in
4848
+ [abcdefghijklmnopqrstuvwxyz_]*) ;;
4849
+ *) die "invalid module name in registry: $_as_name" ;;
4850
+ esac
4851
+ case "$_as_name" in
4852
+ *[!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_]*) die "invalid module name in registry: $_as_name" ;;
4853
+ esac
4854
+ case "$_as_source" in
4855
+ shell/module.d) _as_prefix= ;;
4856
+ shell/layers/*/module.d)
4857
+ _as_layer=${_as_source#shell/layers/}
4858
+ _as_layer=${_as_layer%/module.d}
4859
+ case "$_as_layer" in
4860
+ [A-Za-z0-9_]*) ;;
4861
+ *) die "invalid layer source in registry: $_as_source" ;;
4862
+ esac
4863
+ case "$_as_layer" in
4864
+ */*|*..*|*[!A-Za-z0-9_.-]*) die "invalid layer source in registry: $_as_source" ;;
4865
+ esac
4866
+ _as_prefix="layers/$_as_layer/"
4867
+ ;;
4868
+ *) die "unsupported registry source: $_as_source (expected shell/module.d or shell/layers/<provider>/module.d)" ;;
4869
+ esac
4870
+ case "$_as_load" in
4871
+ /*|.|..|./*|../*|*/.|*/..|*/./*|*/../*|*//*) die "invalid load path in registry for $_as_name: $_as_load" ;;
4872
+ esac
4873
+ _as_root=${_as_source%/module.d}
4874
+ _as_pairs="$_as_source/$_as_name.meta:${_as_prefix}module.d/$_as_name.meta"
4875
+ if [ -n "$_as_load" ]; then
4876
+ _as_pairs="$_as_pairs $_as_root/$_as_load:$_as_prefix$_as_load"
4877
+ fi
4878
+ for _as_pair in $_as_pairs; do
4879
+ _as_from="$_as_src/${_as_pair%%:*}"
4880
+ _as_to="$_stage/${_as_pair#*:}"
4881
+ source_path_is_physical "$_as_src" "${_as_pair%%:*}" ||
4882
+ die "registry file path contains a symlink: ${_as_pair%%:*}"
4883
+ [ -f "$_as_from" ] && [ ! -L "$_as_from" ] ||
4884
+ die "registry file missing or a symlink: ${_as_pair%%:*}"
4885
+ [ ! -e "$_as_to" ] && [ ! -L "$_as_to" ] || die "release path collision: ${_as_pair#*:}"
4886
+ "$_as_mkdir" -p "$(path_parent "$_as_to")" &&
4887
+ "$_as_cp" "$_as_from" "$_as_to" || die "cannot copy ${_as_pair%%:*}"
4888
+ done
4889
+ done <"$_as_rows"
4890
+ _as_rm=$(trusted_utility_path rm) || die "no trusted rm utility available"
4891
+ "$_as_rm" -f "$_as_rows" || die "cannot remove build scratch file"
4892
+ "$_as_find" "$_stage" -type f -exec "$_as_chmod" 0644 {} + || die "cannot normalise file modes"
4893
+ "$0" make-manifest "$_stage" || die "valise make-manifest failed"
4894
+
4895
+ _as_mv=$(trusted_utility_path mv) || die "no trusted mv utility available"
4896
+ if [ "$_as_output_existed" -eq 1 ]; then
4897
+ if [ "$_as_replace_default" -eq 1 ]; then
4898
+ remove_tree "$_as_out" || die "cannot clear previous build output: $_as_out"
4899
+ else
4900
+ _as_rmdir=$(trusted_utility_path rmdir) || die "no trusted rmdir utility available"
4901
+ "$_as_rmdir" "$_as_out" || die "cannot clear empty output directory"
4902
+ fi
4903
+ fi
4904
+ "$_as_mv" "$_stage" "$_as_out" || die "cannot publish release tree"
4905
+ _stage=""
4906
+ }
4907
+
3185
4908
  cmd_explain_variant() {
3186
- _variant="${1:-workstation-full}"
3187
- _script_dir=$(cd "$(path_parent "$0")" && pwd -P)
4909
+ _variant="${1:-default}"
4910
+ case "$_variant" in
4911
+ --help|-h) usage_explain_variant; return 0 ;;
4912
+ esac
4913
+ _script_dir=$(valise_script_dir)
3188
4914
  _src_root=""
3189
4915
  for _candidate in "$_script_dir/../../.." "$_script_dir/../.." "$_script_dir/.." "."; do
3190
4916
  if [ -f "$_candidate/shell/release/install-variants" ]; then
@@ -3196,9 +4922,8 @@ cmd_explain_variant() {
3196
4922
 
3197
4923
  _variants_file="$_src_root/shell/release/install-variants"
3198
4924
  [ -f "$_variants_file" ] || die "install-variants file not found"
3199
-
3200
- _release_compiler="$_src_root/hack/compile-shell-profile-release.py"
3201
- [ -f "$_release_compiler" ] || die "release compiler not found: $_release_compiler"
4925
+ _variant_matches=$(awk -F'\t' -v v="$_variant" '$1 == v { count++ } END { print count + 0 }' "$_variants_file")
4926
+ [ "$_variant_matches" -eq 1 ] || die "unknown or ambiguous variant: $_variant"
3202
4927
 
3203
4928
  _exclude_list=$(awk -F' ' -v v="$_variant" '$1 == v { print $3 }' "$_variants_file")
3204
4929
  _desc=$(awk -F' ' -v v="$_variant" '$1 == v { print $2 }' "$_variants_file")
@@ -3213,8 +4938,8 @@ cmd_explain_variant() {
3213
4938
  Per-shell impact:
3214
4939
  '
3215
4940
 
3216
- for _shell in bash zsh ksh wrapper_lifecycle fish; do
3217
- _roots=$(python3 "$_release_compiler" --root "$_src_root" --bundle-roots "$_shell") || continue
4941
+ for _shell in bash zsh ksh fish; do
4942
+ _roots=$(release_bundle_roots "$_src_root" "$_shell") || continue
3218
4943
 
3219
4944
  _count=0
3220
4945
  _excluded=0
@@ -3242,42 +4967,432 @@ Per-shell impact:
3242
4967
  }
3243
4968
 
3244
4969
  cmd_explain_bundles() {
3245
- _script_dir=$(cd "$(path_parent "$0")" && pwd -P)
3246
- _src_root=""
3247
- for _candidate in "$_script_dir/../../.." "$_script_dir/../.." "$_script_dir/.." "."; do
3248
- if [ -f "$_candidate/shell/release/bundles.json" ] && [ -f "$_candidate/shell/release/profile-release.json" ]; then
3249
- _src_root=$(cd "$_candidate" && pwd -P)
3250
- break
3251
- fi
4970
+ _eb_bundle=""
4971
+ _eb_bundle_requested=0
4972
+ for _eb_arg do
4973
+ case "$_eb_arg" in
4974
+ --bundle=*) _eb_bundle_requested=1; _eb_bundle=${_eb_arg#--bundle=} ;;
4975
+ --help|-h) usage_explain_bundles; return 0 ;;
4976
+ *) die "unknown explain-bundles option: $_eb_arg" ;;
4977
+ esac
3252
4978
  done
3253
- [ -n "$_src_root" ] || die "cannot find source tree with bundle and release manifests"
3254
- command -v python3 >/dev/null 2>&1 || die "python3 required for explain-bundles"
3255
- _compiler="$_src_root/hack/compile-shell-profile-release.py"
3256
- [ -f "$_compiler" ] || die "release compiler not found: $_compiler"
3257
- python3 "$_compiler" --root "$_src_root" --explain-bundles
3258
- _rc=$?
3259
- [ "$_rc" -eq 0 ] || die "explain-bundles failed"
4979
+ [ "$_eb_bundle_requested" -eq 0 ] || [ -n "$_eb_bundle" ] || die "bundle name must not be empty"
4980
+
4981
+ _eb_root=$(resolve_project_root) || die "cannot find source tree (need shell/release/bundle-roots)"
4982
+ _eb_roots_file="$_eb_root/shell/release/bundle-roots"
4983
+
4984
+ [ -f "$_eb_roots_file" ] || die "cannot find shell/release/bundle-roots in the project"
4985
+
4986
+ # The registry is the module source of truth. Generate it into a scratch
4987
+ # file when the project has not written one yet, so explain-bundles never
4988
+ # mutates the project.
4989
+ _eb_registry="$_eb_root/shell/release/module-registry.tsv"
4990
+ _eb_registry_tmp=""
4991
+ if [ ! -f "$_eb_registry" ]; then
4992
+ _eb_registry_tmp=$(trusted_mktemp file "${TMPDIR:-/tmp}/valise-explain-registry.XXXXXX") ||
4993
+ die "cannot create registry scratch file"
4994
+ registry_generate "$_eb_root" "$_eb_registry_tmp"
4995
+ _eb_registry="$_eb_registry_tmp"
4996
+ fi
4997
+
4998
+ # The default variant's exclusion list applies to the closure exactly as
4999
+ # it does at compile time.
5000
+ _eb_awk=$(trusted_utility_path awk) || die "no trusted awk utility available"
5001
+ _eb_excludes=""
5002
+ _eb_variants="$_eb_root/shell/release/install-variants"
5003
+ if [ -f "$_eb_variants" ]; then
5004
+ _eb_excludes=$("$_eb_awk" -F '\t' '$1 == "default" { print $3; exit }' "$_eb_variants")
5005
+ fi
5006
+
5007
+ "$_eb_awk" -F '\t' \
5008
+ -v roots_file="$_eb_roots_file" \
5009
+ -v bundle="$_eb_bundle" \
5010
+ -v bundle_requested="$_eb_bundle_requested" \
5011
+ -v excludes="$_eb_excludes" '
5012
+ function shell_matches(shells, wanted, count, i, values) {
5013
+ if (shells == "") return 0
5014
+ count = split(shells, values, " ")
5015
+ for (i = 1; i <= count; i++) {
5016
+ if (values[i] == wanted) return 1
5017
+ # shells=sh means every POSIX shell, not Fish.
5018
+ if (wanted != "fish" && values[i] == "sh") return 1
5019
+ }
5020
+ return 0
5021
+ }
5022
+ function is_excluded(id, i, ex) {
5023
+ for (i = 1; i <= excl_count; i++) {
5024
+ ex = excl_list[i]
5025
+ if (index(ex, "@") > 0) {
5026
+ if (ex == id) return 1
5027
+ } else if (ex == mod_id[id] && mod_src[id] == "shell/module.d") {
5028
+ return 1
5029
+ }
5030
+ }
5031
+ return 0
5032
+ }
5033
+ function resolve_dep(dep, src, candidate, count, found, key) {
5034
+ candidate = dep "@" src
5035
+ if (candidate in mod_id && shell_matches(mod_shells[candidate], eff_shell) && !is_excluded(candidate)) return candidate
5036
+ count = 0; found = ""
5037
+ for (key in mod_id) {
5038
+ if (mod_id[key] == dep && shell_matches(mod_shells[key], eff_shell) && !is_excluded(key)) {
5039
+ count++; found = key
5040
+ }
5041
+ }
5042
+ if (count == 1) return found
5043
+ return ""
5044
+ }
5045
+ function add_module(id, deps, count, i, dep, resolved) {
5046
+ if (id in closure_seen) return
5047
+ if (!(id in mod_id)) {
5048
+ if (!(id in missing)) { missing[id] = 1; missing_count++ }
5049
+ return
5050
+ }
5051
+ if (!shell_matches(mod_shells[id], eff_shell)) {
5052
+ if (!(id in skipped)) { skipped[id] = 1; skipped_count++ }
5053
+ return
5054
+ }
5055
+ if (is_excluded(id)) return
5056
+ closure_seen[id] = 1
5057
+ closure_list[++closure_count] = id
5058
+ count = split(mod_req[id], deps, " ")
5059
+ for (i = 1; i <= count; i++) {
5060
+ dep = deps[i]
5061
+ if (dep == "") continue
5062
+ resolved = resolve_dep(dep, mod_src[id])
5063
+ if (resolved == "") {
5064
+ if (!(dep in unresolved)) { unresolved[dep] = 1; unresolved_count++ }
5065
+ continue
5066
+ }
5067
+ add_module(resolved)
5068
+ }
5069
+ # An optional dependency joins the closure only when it is present for
5070
+ # this shell and not excluded; an absent optional is silent.
5071
+ count = split(mod_opt[id], deps, " ")
5072
+ for (i = 1; i <= count; i++) {
5073
+ dep = deps[i]
5074
+ if (dep == "") continue
5075
+ resolved = resolve_dep(dep, mod_src[id])
5076
+ if (resolved == "") continue
5077
+ add_module(resolved)
5078
+ }
5079
+ }
5080
+ function sort_closure( i, j, key) {
5081
+ for (i = 2; i <= closure_count; i++) {
5082
+ key = closure_list[i]
5083
+ j = i - 1
5084
+ while (j >= 1 && closure_list[j] > key) {
5085
+ closure_list[j + 1] = closure_list[j]
5086
+ j--
5087
+ }
5088
+ closure_list[j + 1] = key
5089
+ }
5090
+ }
5091
+ function seed_dropped(seed, seed_name, seed_src, at, e) {
5092
+ seed_name = seed; seed_src = "shell/module.d"
5093
+ at = index(seed, "@")
5094
+ if (at > 0) { seed_name = substr(seed, 1, at - 1); seed_src = substr(seed, at + 1) }
5095
+ for (e = 1; e <= excl_count; e++) {
5096
+ if (index(excl_list[e], "@") > 0) {
5097
+ if (excl_list[e] == seed) return 1
5098
+ } else {
5099
+ if (excl_list[e] == seed_name && seed_src == "shell/module.d") return 1
5100
+ }
5101
+ }
5102
+ return 0
5103
+ }
5104
+ FILENAME == roots_file {
5105
+ if ($0 ~ /^[[:space:]]*#/ || NF < 2) next
5106
+ # The first row names the columns; it is not a bundle definition.
5107
+ if (!roots_header_seen) {
5108
+ roots_header_seen = 1
5109
+ if ($1 == "bundle" && $2 == "shell") next
5110
+ }
5111
+ b = $1; sh = $2; rts = $3
5112
+ if (b == "" || sh == "") next
5113
+ if (!(b in bundle_seen)) { bundle_seen[b] = 1; bundle_order[++bundle_count] = b }
5114
+ key = b SUBSEP sh
5115
+ if (key in bundle_roots) bundle_roots[key] = bundle_roots[key] " " rts
5116
+ else bundle_roots[key] = rts
5117
+ if (!(sh in shell_seen)) { shell_seen[sh] = 1; shell_order[++shell_count] = sh }
5118
+ next
5119
+ }
5120
+ FNR == 1 { next }
5121
+ {
5122
+ if (NF < 11) next
5123
+ name = $1; src = $11
5124
+ if (name == "" || src == "") next
5125
+ id = name "@" src
5126
+ mod_id[id] = name
5127
+ mod_src[id] = src
5128
+ mod_req[id] = $4
5129
+ mod_opt[id] = $5
5130
+ mod_shells[id] = $6
5131
+ mod_ver[id] = $2
5132
+ mod_exports[id] = $8
5133
+ mod_load[id] = $9
5134
+ }
5135
+ END {
5136
+ if (bundle_requested == 1 && !(bundle in bundle_seen)) {
5137
+ printf "valise: error: unknown bundle: %s\n", bundle > "/dev/stderr"
5138
+ exit 1
5139
+ }
5140
+ excl_count = 0
5141
+ if (excludes != "") {
5142
+ n = split(excludes, ex_arr, " ")
5143
+ for (i = 1; i <= n; i++) if (ex_arr[i] != "") excl_list[++excl_count] = ex_arr[i]
5144
+ }
5145
+ printf "Installed bundle resolution:\n\n"
5146
+ for (bi = 1; bi <= bundle_count; bi++) {
5147
+ b = bundle_order[bi]
5148
+ if (bundle_requested == 1 && b != bundle) continue
5149
+ printf "Bundle: %s\n", b
5150
+ for (si = 1; si <= shell_count; si++) {
5151
+ sh = shell_order[si]
5152
+ key = b SUBSEP sh
5153
+ if (!(key in bundle_roots)) continue
5154
+ eff_shell = sh
5155
+ closure_count = 0; missing_count = 0; skipped_count = 0; unresolved_count = 0
5156
+ delete closure_seen
5157
+ delete missing
5158
+ delete unresolved
5159
+ delete skipped
5160
+ root_count = 0
5161
+ n = split(bundle_roots[key], seeds, " ")
5162
+ for (i = 1; i <= n; i++) {
5163
+ seed = seeds[i]
5164
+ if (seed == "") continue
5165
+ if (seed_dropped(seed)) continue
5166
+ root_count++
5167
+ seed_name = seed; seed_src = "shell/module.d"
5168
+ at = index(seed, "@")
5169
+ if (at > 0) { seed_name = substr(seed, 1, at - 1); seed_src = substr(seed, at + 1) }
5170
+ add_module(seed_name "@" seed_src)
5171
+ }
5172
+ sort_closure()
5173
+ printf " %s: %d root(s), %d module(s)\n", sh, root_count, closure_count
5174
+ for (i = 1; i <= closure_count; i++) {
5175
+ id = closure_list[i]
5176
+ printf " module %s (%s)\n", mod_id[id], mod_src[id]
5177
+ }
5178
+ for (id in missing) printf " MISSING module %s\n", id
5179
+ for (dep in unresolved) printf " UNRESOLVED dependency %s\n", dep
5180
+ }
5181
+ printf "\n"
5182
+ }
5183
+ }
5184
+ ' "$_eb_roots_file" "$_eb_registry"
5185
+ _eb_rc=$?
5186
+ if [ -n "$_eb_registry_tmp" ]; then
5187
+ _eb_rm=$(trusted_utility_path rm) || die "no trusted rm utility available"
5188
+ "$_eb_rm" -f "$_eb_registry_tmp" || die "cannot remove registry scratch file"
5189
+ fi
5190
+ [ "$_eb_rc" -eq 0 ] || die "explain-bundles failed"
3260
5191
  }
3261
5192
 
3262
5193
  cmd_layer_lint() {
3263
- _layer_name="${1:-}"
3264
- [ -n "$_layer_name" ] || die "usage: valise layer-lint <layer-name>"
5194
+ _ll_name="${1:-}"
5195
+ # A failing shift is a special-builtin error: POSIX requires the shell to
5196
+ # abort, and the || guard does not prevent that. Shift only when there is
5197
+ # an argument to shift, so a bare "valise layer-lint" reaches the usage
5198
+ # check on every shell instead of dying with a shell-specific status.
5199
+ if [ "$#" -gt 0 ]; then shift; fi
5200
+ case "$_ll_name" in
5201
+ --help|-h) usage_layer_lint; return 0 ;;
5202
+ esac
5203
+ for _ll_arg do
5204
+ case "$_ll_arg" in
5205
+ --help|-h) usage_layer_lint; return 0 ;;
5206
+ *) die "unknown layer-lint option: $_ll_arg" ;;
5207
+ esac
5208
+ done
5209
+ [ -n "$_ll_name" ] || die "usage: valise layer-lint <layer-name>"
3265
5210
 
3266
- _script_dir=$(cd "$(path_parent "$0")" && pwd -P)
3267
- _src_root=""
3268
- for _candidate in "$_script_dir/../../.." "$_script_dir/../.." "$_script_dir/.." "."; do
3269
- if [ -d "$_candidate/shell/layers" ]; then
3270
- _src_root=$(cd "$_candidate" && pwd -P)
3271
- break
5211
+ # The layer lives in the consuming project, but the layer may also be
5212
+ # given as a path. The engine checkout supplies the module names a layer
5213
+ # may depend on.
5214
+ _ll_root=$(resolve_project_root 2>/dev/null || :)
5215
+ _ll_engine=$(resolve_engine_root 2>/dev/null || :)
5216
+ [ -n "$_ll_root" ] || [ -n "$_ll_engine" ] ||
5217
+ die "cannot find source tree (need shell/layers/ or module.d/)"
5218
+ _ll_layers="$_ll_root/shell/layers"
5219
+ [ -n "$_ll_layers" ] || _ll_layers=""
5220
+
5221
+ # A layer is addressed by its provider directory name, or by a path to a
5222
+ # layer tree that contains module.d/.
5223
+ _ll_layer=""
5224
+ if [ -n "$_ll_layers" ] && [ -d "$_ll_layers/$_ll_name" ]; then
5225
+ _ll_layer="$_ll_layers/$_ll_name"
5226
+ elif [ -d "$_ll_name/module.d" ]; then
5227
+ _ll_layer=$(cd "$_ll_name" && pwd -P) || die "cannot resolve layer path: $_ll_name"
5228
+ elif [ -n "$_ll_engine" ] && [ -d "$_ll_engine/shell/layers/$_ll_name" ]; then
5229
+ _ll_layer="$_ll_engine/shell/layers/$_ll_name"
5230
+ else
5231
+ die "layer not found: $_ll_name (expected $_ll_layers/$_ll_name or a path containing module.d/)"
5232
+ fi
5233
+
5234
+ [ -d "$_ll_layer/module.d" ] || die "layer has no module.d directory: $_ll_layer"
5235
+
5236
+ _ll_script_dir=$(valise_script_dir)
5237
+ _ll_metadata="$_ll_script_dir/../lib/metadata.sh"
5238
+ [ -r "$_ll_metadata" ] || die "metadata library not found: $_ll_metadata"
5239
+
5240
+ _ll_status=0
5241
+ _ll_fail() {
5242
+ printf ' FAIL: %s\n' "$*" >&2
5243
+ _ll_status=1
5244
+ }
5245
+
5246
+ # The engine's own modules and the project's primary-root modules are the
5247
+ # ones a layer may depend on. A layer module may require another module
5248
+ # in its own layer, or one of these; anything else is a dependency the
5249
+ # layer does not own.
5250
+ # The name lists are space-delimited with a LEADING and a TRAILING space,
5251
+ # so that " $name " matches every entry including the first and the last.
5252
+ _ll_engine_names=" "
5253
+ for _ll_engine_root in "$_ll_engine" "$_ll_root"; do
5254
+ [ -n "$_ll_engine_root" ] || continue
5255
+ for _ll_engine_name in $(engine_module_names "$_ll_engine_root"); do
5256
+ case "$_ll_engine_names" in
5257
+ *" $_ll_engine_name "*) continue ;;
5258
+ esac
5259
+ _ll_engine_names="$_ll_engine_names$_ll_engine_name "
5260
+ done
5261
+ done
5262
+
5263
+ _ll_meta_dir="$_ll_layer/module.d"
5264
+ _ll_count=0
5265
+ _ll_layer_names=" "
5266
+ for _ll_meta in "$_ll_meta_dir"/*.meta; do
5267
+ [ -f "$_ll_meta" ] || continue
5268
+ _ll_count=$((_ll_count + 1))
5269
+ _ll_file=${_ll_meta##*/}
5270
+ _ll_mod_name=${_ll_file%.meta}
5271
+
5272
+ # Structure: the metadata file must not be a symlink and must carry a
5273
+ # safe mode.
5274
+ if [ -L "$_ll_meta" ]; then
5275
+ _ll_fail "$_ll_file: metadata file must not be a symlink"
5276
+ continue
5277
+ fi
5278
+ _ll_mode=$(file_mode "$_ll_meta") || _ll_mode=""
5279
+ case "$_ll_mode" in
5280
+ 0644 | 0444) ;;
5281
+ *) _ll_fail "$_ll_file: metadata mode must be 0644 or 0444, got ${_ll_mode:-unknown}" ;;
5282
+ esac
5283
+
5284
+ # Namespace: a layer-only module must carry the contrib_ prefix.
5285
+ case "$_ll_mod_name" in
5286
+ contrib_*) ;;
5287
+ *) _ll_fail "$_ll_file: layer module name must use the contrib_ prefix" ;;
5288
+ esac
5289
+
5290
+ # Metadata: the record must validate as data.
5291
+ _ll_record=$(. "$_ll_metadata"; sp_metadata_read "$_ll_meta") || {
5292
+ _ll_fail "$_ll_file: invalid metadata"
5293
+ continue
5294
+ }
5295
+ _ll_m_name=
5296
+ _ll_m_version=
5297
+ _ll_m_requires=
5298
+ _ll_m_optional=
5299
+ _ll_m_load=
5300
+ while IFS= read -r _ll_line; do
5301
+ case "$_ll_line" in
5302
+ name=*) _ll_m_name=${_ll_line#name=} ;;
5303
+ version=*) _ll_m_version=${_ll_line#version=} ;;
5304
+ requires=*) _ll_m_requires=${_ll_line#requires=} ;;
5305
+ optional=*) _ll_m_optional=${_ll_line#optional=} ;;
5306
+ load=*) _ll_m_load=${_ll_line#load=} ;;
5307
+ esac
5308
+ done <<EOF
5309
+ $_ll_record
5310
+ EOF
5311
+ if [ "$_ll_m_name" != "$_ll_mod_name" ]; then
5312
+ _ll_fail "$_ll_file: metadata name $_ll_m_name does not match the file name $_ll_mod_name"
5313
+ fi
5314
+ if [ -z "$_ll_m_version" ]; then
5315
+ _ll_fail "$_ll_file: layer modules must declare a version"
5316
+ fi
5317
+
5318
+ # Load target: the body must exist, must not be a symlink, and must
5319
+ # carry a safe mode.
5320
+ if [ -n "$_ll_m_load" ]; then
5321
+ _ll_body="$_ll_layer/$_ll_m_load"
5322
+ if [ -L "$_ll_body" ]; then
5323
+ _ll_fail "$_ll_file: load target must not be a symlink: $_ll_m_load"
5324
+ elif [ ! -f "$_ll_body" ]; then
5325
+ _ll_fail "$_ll_file: load target does not exist: $_ll_m_load"
5326
+ else
5327
+ _ll_body_mode=$(file_mode "$_ll_body") || _ll_body_mode=""
5328
+ case "$_ll_body_mode" in
5329
+ 0644 | 0444 | 0755 | 0555) ;;
5330
+ *) _ll_fail "$_ll_file: load target mode must be 0644, 0444, 0755 or 0555, got ${_ll_body_mode:-unknown}" ;;
5331
+ esac
5332
+ fi
5333
+ else
5334
+ _ll_fail "$_ll_file: layer module must declare a load target"
3272
5335
  fi
5336
+
5337
+ _ll_layer_names="$_ll_layer_names$_ll_mod_name "
3273
5338
  done
3274
- [ -n "$_src_root" ] || die "cannot find source tree with shell/layers/"
3275
5339
 
3276
- _validator="$_src_root/hack/validate-layer.py"
3277
- [ -f "$_validator" ] || die "layer validator not found: $_validator"
5340
+ if [ "$_ll_count" -eq 0 ]; then
5341
+ _ll_fail "$_ll_layer: layer contains no modules"
5342
+ fi
5343
+
5344
+ # Dependencies: every requirement must resolve inside the layer or to an
5345
+ # engine module. An optional dependency must resolve the same way.
5346
+ for _ll_meta in "$_ll_meta_dir"/*.meta; do
5347
+ [ -f "$_ll_meta" ] || continue
5348
+ _ll_file=${_ll_meta##*/}
5349
+ _ll_record=$(. "$_ll_metadata"; sp_metadata_read "$_ll_meta") || continue
5350
+ _ll_m_requires=
5351
+ _ll_m_optional=
5352
+ while IFS= read -r _ll_line; do
5353
+ case "$_ll_line" in
5354
+ requires=*) _ll_m_requires=${_ll_line#requires=} ;;
5355
+ optional=*) _ll_m_optional=${_ll_line#optional=} ;;
5356
+ esac
5357
+ done <<EOF
5358
+ $_ll_record
5359
+ EOF
5360
+ for _ll_kind in requires optional; do
5361
+ if [ "$_ll_kind" = requires ]; then
5362
+ _ll_deps=$_ll_m_requires
5363
+ else
5364
+ _ll_deps=$_ll_m_optional
5365
+ fi
5366
+ [ -n "$_ll_deps" ] || continue
5367
+ _ll_ifs_was_set=0
5368
+ if [ "${IFS+x}" = x ]; then
5369
+ _ll_ifs_was_set=1
5370
+ _ll_ifs=${IFS-}
5371
+ fi
5372
+ IFS=' '
5373
+ for _ll_dep in $_ll_deps; do
5374
+ [ -n "$_ll_dep" ] || continue
5375
+ case "$_ll_layer_names" in
5376
+ *" $_ll_dep "*) continue ;;
5377
+ esac
5378
+ case "$_ll_engine_names" in
5379
+ *" $_ll_dep "*) continue ;;
5380
+ esac
5381
+ _ll_fail "$_ll_file: $_ll_kind dependency $_ll_dep is neither in this layer nor an engine module"
5382
+ done
5383
+ if [ "$_ll_ifs_was_set" -eq 1 ]; then
5384
+ IFS=$_ll_ifs
5385
+ else
5386
+ unset IFS
5387
+ fi
5388
+ done
5389
+ done
3278
5390
 
3279
- command -v python3 >/dev/null 2>&1 || die "python3 required for layer-lint"
3280
- python3 "$_validator" "$_layer_name"
5391
+ if [ "$_ll_status" -eq 0 ]; then
5392
+ printf 'layer %s: %d module(s) validated\n' "$_ll_name" "$_ll_count"
5393
+ return 0
5394
+ fi
5395
+ die "layer $_ll_name failed validation"
3281
5396
  }
3282
5397
 
3283
5398
  # --- Main dispatch ---
@@ -3293,13 +5408,18 @@ case "$_cmd" in
3293
5408
  init) cmd_init "$@" ;;
3294
5409
  install) cmd_install "$@" ;;
3295
5410
  _verify-release-root) cmd_verify_release_root "$@" ;;
3296
- verify-active) cmd_verify_active "$@" ;;
3297
- doctor) cmd_doctor ;;
3298
- variant) cmd_variant "$@" ;;
5411
+ doctor) cmd_doctor "$@" ;;
5412
+ release) cmd_release "$@" ;;
3299
5413
  make-manifest) cmd_make_manifest "$@" ;;
5414
+ registry) cmd_registry "$@" ;;
5415
+ new) cmd_new "$@" ;;
5416
+ module) cmd_module "$@" ;;
5417
+ build) cmd_build "$@" ;;
5418
+ apply) cmd_apply "$@" ;;
5419
+ status) cmd_status "$@" ;;
3300
5420
  compile) cmd_compile "$@" ;;
3301
5421
  explain-variant) cmd_explain_variant "$@" ;;
3302
- explain-bundles) cmd_explain_bundles ;;
5422
+ explain-bundles) cmd_explain_bundles "$@" ;;
3303
5423
  layer-lint) cmd_layer_lint "$@" ;;
3304
5424
  version|--version|-v) cmd_version ;;
3305
5425
  help|--help|-h|'') usage ;;