-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathshell-module.nix
More file actions
889 lines (815 loc) · 30.6 KB
/
Copy pathshell-module.nix
File metadata and controls
889 lines (815 loc) · 30.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
{
name,
config,
lib,
pkgs,
mkScript,
runners,
presets,
dnvrState,
dnvrSpecialArgs,
...
}: let
inherit (lib) mkOption types;
processValues = lib.attrValues config.processes;
processPackages = lib.concatMap (p: p.packages) processValues;
# An env value that is exactly `<scheme>://<rest>` for a scheme present
# in `refHandlers` is a ref; semantics live in the refHandlers option.
matchUrl = v:
if !(builtins.isString v)
then null
else let
m = builtins.match "([a-z][a-z0-9+.-]*)://(.+)" v;
in
if m == null
then null
else {
scheme = lib.elemAt m 0;
rest = lib.elemAt m 1;
};
parseRef = v: let
u = matchUrl v;
in
if u == null || !(config.refHandlers ? ${u.scheme})
then null
else u // {handler = config.refHandlers.${u.scheme};};
# dnvr://<proc>/<key> — process names can't contain dots (dnvr-state
# splits `<proc>.<key>` on the first dot); keys can.
parseDnvrRef = v: let
m = builtins.match "dnvr://([A-Za-z0-9_-]+)/([A-Za-z0-9._-]+)" v;
in
if m == null
then null
else {
proc = lib.elemAt m 0;
key = lib.elemAt m 1;
};
refsOf = lib.filterAttrs (_: v: parseRef v != null);
plainOf = lib.filterAttrs (_: v: parseRef v == null);
dnvrRefsOf = lib.filterAttrs (_: v: parseRef v != null && (parseRef v).scheme == "dnvr");
processRefs = lib.mapAttrs (_: p: refsOf p.env) config.processes;
envRefs = refsOf config.env;
# Ref-valued vars bind to the process that declares them (resolved in its
# wrapper); only plain values flow into the shared runner/shell env.
allEnv = lib.foldl' (a: p: a // plainOf p.env) {} processValues // plainOf config.env;
knownProcs = lib.attrNames config.processes;
# consumer -> [producers], for every process (empty list when no dnvr
# refs). Only the dnvr scheme creates edges — other handlers resolve
# values without implying process dependencies.
depGraph =
lib.mapAttrs (
procName: refs:
lib.unique (lib.filter (d: d != procName)
(map (v: (parseDnvrRef v).proc)
(lib.filter (v: parseDnvrRef v != null) (lib.attrValues (dnvrRefsOf refs)))))
)
processRefs;
dnvrRefErrors = owner: var: v: let
r = parseDnvrRef v;
in
if r == null
then ["${owner}: env.${var} = \"${v}\" is a malformed dnvr:// ref (expected dnvr://<process>/<key>)"]
else
lib.optional (!(lib.elem r.proc knownProcs))
"${owner}: env.${var} = \"${v}\" references unknown process '${r.proc}' (processes: ${lib.concatStringsSep ", " knownProcs})";
sorted = lib.toposort (a: b: lib.elem a (depGraph.${b} or [])) knownProcs;
refProblems =
lib.concatLists (lib.mapAttrsToList (
procName: refs:
lib.concatLists (lib.mapAttrsToList (
var: v:
dnvrRefErrors "process '${procName}'" var v
++ lib.optional (parseDnvrRef v != null && (parseDnvrRef v).proc == procName)
"process '${procName}': env.${var} = \"${v}\" references itself — it would wait for its own key and time out"
)
(dnvrRefsOf refs))
)
processRefs)
++ lib.mapAttrsToList (
var: v: "env.${var} = \"${v}\": this scheme resolves only at process start (inShell = false) — move it to the consuming process's env, or read it live with `dnvr-state get`"
)
(lib.filterAttrs (_: v: !(parseRef v).handler.inShell) envRefs)
++ lib.optional (sorted ? cycle)
"dependency cycle among processes: ${lib.concatStringsSep " -> " sorted.cycle} — each would wait for the other's key";
allScripts = lib.foldl' (a: p: a // p.scripts) {} processValues // config.scripts;
# The dnvr CLI dispatches scripts after its built-in subcommands, so a
# script by one of these names would exist but never be reachable.
reservedCliNames = ["up" "ps" "logs" "state" "completions" "help"];
scriptProblems =
map (n: "scripts.${n} shadows the built-in `dnvr ${n}` subcommand — pick another name")
(lib.filter (n: lib.elem n reservedCliNames) (lib.attrNames allScripts));
problems = refProblems ++ scriptProblems;
checkProblems = x:
if problems == []
then x
else throw "dnvr shell '${name}': invalid configuration:\n - ${lib.concatStringsSep "\n - " problems}";
scriptPkgs =
lib.mapAttrsToList
(n: s:
mkScript {
name = n;
inherit (s) shell text runtimeInputs;
})
allScripts;
# Ref-cache plumbing shared by the process wrapper (hard-fail) and the
# devshell hook (best-effort). Cached values are plaintext files keyed by
# the ref URL — dev-grade by design; `dnvr state cache-clear` flushes.
refCacheDir = "$DNVR_STATE/ref-cache";
refCachePath = url: "${refCacheDir}/${builtins.hashString "sha256" url}";
refCacheFresh = url: ttl: ''[ -f "${refCachePath url}" ] && [ -n "$(${pkgs.findutils}/bin/find "${refCachePath url}" -newermt "${toString ttl} seconds ago" 2>/dev/null)" ]'';
refCacheWrite = var: url: ''
${pkgs.coreutils}/bin/mkdir -p "${refCacheDir}"
(umask 077; printf '%s' "''$${var}" > "${refCachePath url}.tmp$$" && ${pkgs.coreutils}/bin/mv "${refCachePath url}.tmp$$" "${refCachePath url}")'';
# At process start resolution is authoritative: cache-fresh value or a
# live resolve; a resolver failure aborts the process (set -e).
procResolveRef = var: v: let
r = parseRef v;
ttl = r.handler.cache.ttl;
in
if ttl == null
then ''
${var}="$( ${r.handler.command v} )"
export ${var}
''
else ''
if ${refCacheFresh v ttl}; then
${var}="$(${pkgs.coreutils}/bin/cat "${refCachePath v}")"
else
${var}="$( ${r.handler.command v} )"
${refCacheWrite var v}
fi
export ${var}
'';
# At shell entry resolution is best-effort: warn and skip on failure.
entryResolveRef = var: v: let
r = parseRef v;
ttl = r.handler.cache.ttl;
pathPrefix =
lib.optionalString (r.handler.runtimeInputs != [])
"PATH=${lib.makeBinPath r.handler.runtimeInputs}:$PATH ";
run = "${pathPrefix}${r.handler.command v}";
warn = ''echo "dnvr: could not resolve ${var} (${v})" >&2'';
in
if ttl == null
then ''
if ${var}="$( ${run} 2>/dev/null )"; then
export ${var}
else
${warn}
fi
''
else ''
if ${refCacheFresh v ttl}; then
export ${var}="$(${pkgs.coreutils}/bin/cat "${refCachePath v}")"
elif ${var}="$( ${run} 2>/dev/null )"; then
${refCacheWrite var v}
export ${var}
else
${warn}
fi
'';
# Everything with an inShell handler exports at entry: process refs (so
# ad-hoc scripts see the same values the process will) and shell-level
# refs (entry-only; they never reach the runner).
entryRefs =
lib.filterAttrs (_: v: (parseRef v).handler.inShell)
(lib.foldl' (a: r: a // r) {} (lib.attrValues processRefs) // envRefs);
refEntryExports = lib.concatStrings (lib.mapAttrsToList entryResolveRef entryRefs);
# Every process runs with DNVR_RUNTIME_DIR pointing at runtime/<procName>
# and dnvr-state on PATH, so `dnvr-state set` needs no self-identification.
# Derivation commands and commands with env refs become a store script
# (set -euo pipefail; refs resolve before the exec). Plain string commands
# get the same env via a string preamble instead — they keep the runner's
# sh semantics and are not shellchecked.
# Launching is probe → wipe → claim, made atomic by two locks in the
# runtime dir:
#
# launch.lock — mutex over the launch sequence below, held only for
# its few lines. Concurrent launchers serialize here, so the probe
# can be trusted for the duration of the wipe and claim.
# pid — the liveness source. The winner opens it on fd 9 and holds
# an exclusive flock for life (fds survive exec; the kernel drops
# the lock on death, SIGKILL included); its content is the live
# pid. `dnvr ps` and `dnvr-state get/wait` read liveness from the
# lock, never from the pid number, so a recycled pid can't
# masquerade as running.
#
# Under launch.lock: a held pid lock means a live instance — fail fast
# without touching its state. A free pid lock proves any keys on disk
# are a dead incarnation's, so the wipe is safe; and because the wipe
# runs BEFORE the pid lock is taken, readers — which trust a key only
# under a held pid lock — can never observe a stale key as live: every
# key readable under a held lock was written by the current
# incarnation. The pid is written in place, not via dnvr-state — its
# tmp+mv would detach the locked inode from the path — and opened
# O_APPEND, truncated only after its lock is won. Nothing ever unlinks
# pid or launch.lock (the wipe spares them); path lock identity is the
# liveness source.
# The runner receives only {command, runner_settings} — the devshell-facing
# buckets (packages, env, scripts) must not leak into runner configs.
claimPidFile = procName: ''
exec 8>>"$DNVR_RUNTIME_DIR/launch.lock"
flock -n 8 || {
echo "[${procName}] another launch is in progress" >&2
exit 1
}
if ! flock -ns "$DNVR_RUNTIME_DIR/pid" true 2>/dev/null; then
echo "[${procName}] pid file is locked — already running?" >&2
exit 1
fi
${pkgs.findutils}/bin/find "$DNVR_RUNTIME_DIR" -mindepth 1 -maxdepth 1 ! -name pid ! -name launch.lock -exec ${pkgs.coreutils}/bin/rm -rf {} +
exec 9>>"$DNVR_RUNTIME_DIR/pid"
flock -n 9 || {
echo "[${procName}] pid file is locked — already running?" >&2
exit 1
}
: > "$DNVR_RUNTIME_DIR/pid"
printf '%s\n' "$$" >&9
exec 8>&-
'';
wrapProcess = procName: p: let
refs = processRefs.${procName};
resolveRefs = lib.concatStrings (lib.mapAttrsToList procResolveRef refs);
handlerInputs =
lib.unique (lib.concatMap (v: (parseRef v).handler.runtimeInputs) (lib.attrValues refs));
wrapped =
if lib.isDerivation p.command || refs != {}
then
pkgs.writeShellApplication {
name = "${procName}-scoped";
runtimeInputs = [dnvrState pkgs.flock] ++ handlerInputs;
text = ''
: "''${DNVR_STATE:?DNVR_STATE must be set}"
export DNVR_RUNTIME_DIR="$DNVR_STATE/runtime/${procName}"
mkdir -p "$DNVR_RUNTIME_DIR"
${claimPidFile procName}
${resolveRefs}${
if lib.isDerivation p.command
then ''exec ${lib.getExe p.command} "$@"''
else p.command
}
'';
}
else ''
export PATH=${dnvrState}/bin:${pkgs.flock}/bin:"$PATH" DNVR_RUNTIME_DIR="$DNVR_STATE/runtime/${procName}"
mkdir -p "$DNVR_RUNTIME_DIR"
${claimPidFile procName}
${p.command}'';
in {
command = wrapped;
inherit (p) runner_settings;
};
wrappedProcesses = lib.mapAttrs wrapProcess config.processes;
upScript = config.runner {
name = "${name}-up";
processes = wrappedProcesses;
env = allEnv;
prerun = config.prerun;
};
inherit (import ./env-export.nix {inherit lib;}) exportLine refersToRoot;
# Values referencing $DNVR_ROOT can't go through mkShell's static `env`
# attr — nothing would expand them there. They export in the shellHook
# instead, right after DNVR_ROOT is set.
envForShell =
lib.mapAttrs (_: v: toString v)
(lib.filterAttrs (_: v: !(refersToRoot v)) allEnv);
rootedEnvExports =
lib.concatStringsSep "\n"
(lib.mapAttrsToList exportLine (lib.filterAttrs (_: refersToRoot) allEnv));
# Banner: rendered by `gum style` at runtime. The nix string holds only plain
# text — no raw ANSI bytes that would otherwise trip nix's strict JSON parser
# when nix-direnv captures the dev-env via `nix print-dev-env --json`.
# Padding uses plain text length so alignment is correct regardless of how
# gum colors the output.
padTo = width: s: let
pad = width - (lib.stringLength s);
in
lib.optionalString (pad > 0) (lib.fixedWidthString pad " " "");
titleSuffix = lib.optionalString (config.description != "") " — ${config.description}";
scriptRows =
lib.mapAttrsToList (n: s: {
name = n;
desc = s.description;
})
allScripts;
# What `dnvr --list` offers (and thereby what <tab> completes and what
# --help shows): `up` first, then the scripts. `state` and `completions`
# still work as subcommands but stay out of both.
listRows =
[
{
name = "up";
desc = "launch process group (${lib.concatStringsSep ", " (map procLabel (lib.attrNames wrappedProcesses))})";
}
{
name = "ps";
desc = "process status (pid + liveness)";
}
{
name = "logs";
desc = "clean process logs for humans and agents";
}
]
++ scriptRows;
# Column width for `dnvr ps`: longest label, header included.
psWidth =
2 + lib.foldl' lib.max (lib.stringLength "PROCESS") (map lib.stringLength knownProcs);
psRows =
lib.concatMapStrings (n: ''
__dnvr_ps_row ${lib.escapeShellArg n} "$DNVR_STATE/runtime/${n}/pid"
'')
knownProcs;
logCases = lib.concatStrings (lib.imap0 (index: n: let
logName = lib.replaceStrings ["/"] ["_"] n;
in ''
${lib.escapeShellArg n})
__dnvr_log="$DNVR_STATE/logs/tmux-${name}-up/${logName}.log"
__dnvr_index=${toString index}
;;
'') knownProcs);
# "api→pg" in listings when api consumes one of pg's keys.
procLabel = n:
if depGraph.${n} == []
then n
else "${n}→${lib.concatStringsSep "," (depGraph.${n})}";
renderRows = prefix: rows: let
nameWidth = lib.foldl' lib.max 0 (map (c: lib.stringLength c.name) rows);
in
map (c: " ${prefix}${c.name}${padTo (nameWidth + 2) c.name}${c.desc}") rows;
helpText = lib.concatStringsSep "\n" (
["dnvr: ${name}${titleSuffix}"]
++ ["" "commands:"]
++ renderRows "dnvr " listRows
++ [
""
"Scripts are also on the shell PATH directly; `dnvr <script>` and `<script>` are equivalent."
"Completion is automatic in shells started inside this env. For an already-running"
"nushell: `overlay use .dnvr/dnvr-completions.nu`. Other shells: `dnvr completions <shell>`."
]
);
listText = lib.concatMapStrings (c: "${c.name}\t${c.desc}\n") listRows;
bashCompletion = ''
_dnvr() {
local cur
cur="''${COMP_WORDS[COMP_CWORD]}"
[ "$COMP_CWORD" -eq 1 ] || return 0
mapfile -t COMPREPLY < <(compgen -W "$(dnvr --list 2>/dev/null | cut -f1)" -- "$cur")
}
complete -F _dnvr dnvr
'';
zshFunction = ''
_dnvr() {
local -a lines cmds
lines=("''${(@f)$(dnvr --list 2>/dev/null)}")
cmds=("''${lines[@]//$'\t'/:}")
_describe -V -t commands 'dnvr command' cmds
}
'';
# For eval'ing into a live shell (`dnvr completions zsh`).
zshCompletion = zshFunction + "compdef _dnvr dnvr\n";
# Autoloadable fpath file (share/zsh/site-functions/_dnvr).
zshCompletionFile = "#compdef dnvr\n" + zshFunction + "_dnvr \"$@\"\n";
fishCompletion = ''
complete -c dnvr -f
complete -c dnvr -n __fish_use_subcommand -a '(dnvr --list 2>/dev/null)'
'';
# Exported so the file works as a module: nushell vendor-autoloads it in
# shells started inside the env, and `overlay use .dnvr/dnvr-completions.nu`
# loads it into an already-running REPL (venv activate.nu style).
nuCompletion = ''
export def "nu-complete dnvr" [] {
if (which dnvr | is-empty) {
return []
}
{
options: {sort: false}
completions: (^dnvr --list | lines | each {|line|
let parts = ($line | split row "\t")
{
value: ($parts | first)
description: (if ($parts | length) > 1 { $parts | get 1 } else { "" })
}
})
}
}
export extern "dnvr" [
command?: string@"nu-complete dnvr"
...args: string
]
'';
# Completion files in the standard discovery locations, wired up via
# XDG_DATA_DIRS and NIX_PROFILES in the shellHook. bash-completion resolves
# XDG_DATA_DIRS lazily at first <tab>, so it works even when the env
# arrives via direnv; fish and nushell (≥0.96 vendor autoload) read it at
# shell startup. zsh discovery rides NIX_PROFILES instead: nix-darwin's
# /etc/zshenv, NixOS, and home-manager's .zshrc all scan
# $profile/share/zsh/site-functions for every listed profile before
# compinit, with append semantics — so any nix-managed zsh started inside
# the devshell registers _dnvr automatically and nothing else changes.
#
# Deliberately NOT exported: FPATH. zsh imports an inherited FPATH verbatim
# as its entire fpath, dropping its compiled-in function directory — every
# autoload (compinit, add-zsh-hook, ...) then fails and the shell is
# unusable. And for a zsh already running when direnv applies the env it
# would do nothing anyway: compinit has already run, so a new completion
# file is never registered; that case is `dnvr completions zsh` (see
# README).
dnvrShare = pkgs.linkFarm "dnvr-completions" [
{
name = "share/bash-completion/completions/dnvr";
path = pkgs.writeText "dnvr.bash" bashCompletion;
}
{
name = "share/zsh/site-functions/_dnvr";
path = pkgs.writeText "_dnvr" zshCompletionFile;
}
{
name = "share/fish/vendor_completions.d/dnvr.fish";
path = pkgs.writeText "dnvr.fish" fishCompletion;
}
{
name = "share/nushell/vendor/autoload/dnvr-completions.nu";
path = nuCompletionFile;
}
];
# Named so the module name differs from the `dnvr` extern — nushell
# forbids `export extern "dnvr"` from a module itself named `dnvr`.
nuCompletionFile = pkgs.writeText "dnvr-completions.nu" nuCompletion;
scriptDispatch = lib.concatMapStrings (n: ''
"${n}")
shift
exec "${n}" "$@"
;;
'') (lib.attrNames allScripts);
dnvrCli = pkgs.writeShellApplication {
name = "dnvr";
runtimeInputs =
[upScript dnvrState pkgs.flock pkgs.coreutils pkgs.tmux pkgs.gawk pkgs.ansifilter]
++ scriptPkgs;
# The help/list/completions bodies are single-quoted on purpose (printf
# '%s' with escapeShellArg); SC2016 flags the $ inside them.
excludeShellChecks = ["SC2016"];
text = ''
# label, pidfile -> one `dnvr ps` table row. Liveness comes from the
# exclusive flock the process holds on its pid file for life — never
# from the file's presence or the pid number, so recycled pids can't
# lie. Files persist after exit (nothing cleans them; the next launch
# wipes them): unlocked file -> `exited`, no file -> `stopped`.
__dnvr_ps_row() {
local pid="-" status="stopped"
if [ -f "$2" ]; then
read -r pid < "$2" || true
[ -n "$pid" ] || pid="-"
if flock -ns "$2" true 2>/dev/null; then
status=exited
else
status=running
fi
fi
printf '%-${toString psWidth}s %-8s %s\n' "$1" "$pid" "$status"
}
cmd="''${1:-}"
case "$cmd" in
"" | --help | -h | help)
printf '%s\n' ${lib.escapeShellArg helpText}
;;
--list)
printf '%s' ${lib.escapeShellArg listText}
;;
up)
shift
exec "${name}-up" "$@"
;;
ps)
printf '%-${toString psWidth}s %-8s %s\n' PROCESS PID STATUS
${psRows}
;;
logs)
shift
__dnvr_follow=false
__dnvr_ansi=false
__dnvr_lines=
while [ "$#" -gt 0 ]; do
case "$1" in
-f | --follow) __dnvr_follow=true ;;
--ansi) __dnvr_ansi=true ;;
-n | --tail)
[ "$#" -ge 2 ] || {
echo "dnvr logs: $1 needs a line count" >&2
exit 64
}
__dnvr_lines="$2"
shift
;;
--tail=*) __dnvr_lines="''${1#*=}" ;;
--) shift; break ;;
-*)
echo "dnvr logs: unknown option '$1'" >&2
exit 64
;;
*) break ;;
esac
shift
done
__dnvr_process="''${1:-}"
if [ -z "$__dnvr_process" ] || [ "$#" -ne 1 ]; then
echo "usage: dnvr logs [-f|--follow] [-n|--tail LINES] [--ansi] <process>" >&2
exit 64
fi
case "$__dnvr_lines" in
*[!0-9]*)
echo "dnvr logs: line count must be a non-negative integer" >&2
exit 64
;;
esac
case "$__dnvr_process" in
${logCases}
*)
echo "dnvr logs: unknown process '$__dnvr_process'" >&2
exit 64
;;
esac
__dnvr_socket="$DNVR_STATE/runtime/tmux-${name}-up.sock"
__dnvr_pane=
if [ -S "$__dnvr_socket" ]; then
__dnvr_pane=$(tmux -S "$__dnvr_socket" list-panes -s -t =dnvr \
-F '#{@dnvr_index}:#{pane_id}' 2>/dev/null \
| awk -F : -v wanted="$__dnvr_index" '$1 == wanted { print $2; exit }')
fi
[ -n "$__dnvr_pane" ] || {
echo "dnvr logs: no pane for '$__dnvr_process' (run 'dnvr up' first)" >&2
exit 1
}
if "$__dnvr_follow"; then
[ -f "$__dnvr_log" ] || {
echo "dnvr logs: no log yet for '$__dnvr_process'" >&2
exit 1
}
__dnvr_tail=(-n "''${__dnvr_lines:-+1}" -F)
if "$__dnvr_ansi"; then
exec tail "''${__dnvr_tail[@]}" "$__dnvr_log"
else
tail "''${__dnvr_tail[@]}" "$__dnvr_log" | ansifilter
fi
else
__dnvr_capture=(-p -J -S - -t "$__dnvr_pane")
"$__dnvr_ansi" && __dnvr_capture=(-e "''${__dnvr_capture[@]}")
if "$__dnvr_ansi"; then
if [ -n "$__dnvr_lines" ]; then
tmux -S "$__dnvr_socket" capture-pane "''${__dnvr_capture[@]}" \
| tail -n "$__dnvr_lines"
else
exec tmux -S "$__dnvr_socket" capture-pane "''${__dnvr_capture[@]}"
fi
else
# capture-pane includes unused screen rows and tmux's synthetic
# "Pane is dead" footer. Keep real internal blank lines while
# trimming that display-only tail from agent-facing output.
tmux -S "$__dnvr_socket" capture-pane "''${__dnvr_capture[@]}" \
| awk '
/^Pane is dead \(status [0-9]+,/ { next }
{ line[NR] = $0; if ($0 !~ /^[[:space:]]*$/) last = NR }
END { for (i = 1; i <= last; i++) print line[i] }
' \
| if [ -n "$__dnvr_lines" ]; then
tail -n "$__dnvr_lines"
else
cat
fi
fi
fi
;;
state)
shift
exec dnvr-state "$@"
;;
completions)
case "''${2:-}" in
bash)
printf '%s' ${lib.escapeShellArg bashCompletion}
;;
zsh)
printf '%s' ${lib.escapeShellArg zshCompletion}
;;
fish)
printf '%s' ${lib.escapeShellArg fishCompletion}
;;
nu | nushell)
printf '%s' ${lib.escapeShellArg nuCompletion}
;;
*)
echo "usage: dnvr completions <bash|zsh|fish|nushell>" >&2
exit 64
;;
esac
;;
${scriptDispatch}
*)
echo "dnvr: unknown command '$cmd' (try 'dnvr --help')" >&2
exit 64
;;
esac
'';
};
bannerLines = let
rows =
[
{
name = "dnvr up";
desc = "launch process group";
}
]
++ scriptRows
++ [
{
name = "dnvr --help";
desc = "list everything in this shell";
}
];
in
["dnvr: ${name}${titleSuffix}"]
++ ["commands:"]
++ renderRows "" rows;
# Stamp in $DNVR_STATE; only re-print if the stamp is missing or older than
# 3 days. Otherwise direnv would blast the banner on every cd in / reload.
bannerScript = ''
__dnvr_banner_stamp="$DNVR_STATE/.banner-shown-${name}"
if [ ! -f "$__dnvr_banner_stamp" ] || [ -z "$(${pkgs.findutils}/bin/find "$__dnvr_banner_stamp" -newermt '3 days ago' 2>/dev/null)" ]; then
${pkgs.gum}/bin/gum style \
--border rounded \
--border-foreground 6 \
--foreground 7 \
--padding "0 1" \
--margin "1 0 0 0" \
${lib.escapeShellArgs bannerLines}
${pkgs.coreutils}/bin/touch "$__dnvr_banner_stamp"
fi
unset __dnvr_banner_stamp
'';
in {
options = {
description = mkOption {
type = types.str;
default = "";
description = "One-line summary shown in the entry banner.";
};
packages = mkOption {
type = types.listOf types.package;
default = [];
};
scripts = mkOption {
type = types.attrsOf (types.submoduleWith {
modules = [./script-module.nix];
specialArgs = dnvrSpecialArgs;
});
default = {};
description = "Scripts exposed on the devshell's PATH.";
};
processes = mkOption {
type = types.attrsOf (types.submoduleWith {
modules = [./process-module.nix];
specialArgs = dnvrSpecialArgs;
});
default = {};
description = ''
Processes that the runner orchestrates. Each is a module — import a
preset (`imports = [presets.postgres]`) or set `command` directly.
Processes also contribute packages, env, and scripts to the devshell.
'';
};
env = mkOption {
type = types.attrsOf types.anything;
default = {};
description = ''
Env vars set on the devshell and exported to the runner. Refs of
schemes with `inShell = true` (e.g. op://) are allowed here: they
resolve best-effort at shell entry and never reach the runner.
Schemes with `inShell = false` (dnvr://) are an eval error here —
they resolve at process start, so they belong on the consuming
process.
'';
};
refHandlers = mkOption {
type = types.attrsOf (types.submodule {
options = {
command = mkOption {
type = types.functionTo types.str;
description = ''
Given the whole ref value (e.g. "op://vault/item/field"),
return a shell command whose stdout becomes the var. Runs in
the process wrapper before the command starts; a failing
resolver aborts the process.
'';
};
inShell = mkOption {
type = types.bool;
default = true;
description = ''
Also resolve refs of this scheme at devshell entry —
best-effort: a failure warns on stderr and skips the export,
never blocking the shell. The built-in dnvr handler disables
this; its values are runtime-published and would be absent or
stale at entry.
'';
};
cache.ttl = mkOption {
type = types.nullOr types.int;
default = null;
description = ''
Seconds a resolved value stays cached — a plaintext file under
`$DNVR_STATE/ref-cache` (written with umask 077), keyed by the
ref URL. Used at shell entry and process start alike. null
disables caching. Flush with `dnvr state cache-clear`.
'';
};
runtimeInputs = mkOption {
type = types.listOf types.package;
default = [];
description = "Packages the resolver needs on PATH.";
};
};
});
default = {};
description = ''
URL-scheme handlers for env refs, keyed by scheme. An env value
that is exactly `<scheme>://…` with a handler here is resolved by
it; schemes without handlers pass through as plain values. The
built-in `dnvr` entry resolves `dnvr://<proc>/<key>` via
dnvr-state and is the only scheme that creates dependency edges.
'';
};
dependencies = mkOption {
type = types.attrsOf (types.listOf types.str);
readOnly = true;
description = ''
Dependency graph derived from dnvr:// env refs:
`<process> -> [processes whose keys it consumes]`. Every process is
a key; processes without refs map to `[]`.
'';
};
prerun = mkOption {
type = types.lines;
default = "";
description = ''
Shell code executed inside the up-script before the runner exec.
Has DNVR_ROOT and DNVR_STATE available. Anything `export`ed here
flows into the runner, which propagates to every process. Use this
for dynamic-port picking and similar runtime env setup.
'';
};
shellHook = mkOption {
type = types.lines;
default = "";
};
runner = mkOption {
type = types.functionTo types.package;
default = runners.tmux;
description = "Function `{name, processes, env, prerun}: drv` that produces the up-script.";
};
shell = mkOption {
type = types.package;
readOnly = true;
description = "The final devshell derivation.";
};
up = mkOption {
type = types.package;
readOnly = true;
description = "The runner up-script as a standalone derivation.";
};
cli = mkOption {
type = types.package;
readOnly = true;
description = "The shell-scoped dnvr CLI package.";
};
};
config.refHandlers.dnvr = {
command = url: let
r = parseDnvrRef url;
in "dnvr-state wait ${r.proc}.${r.key} --timeout 120";
inShell = false;
runtimeInputs = [dnvrState];
};
config.dependencies = depGraph;
config.up = checkProblems upScript;
config.cli = checkProblems dnvrCli;
config.shell = checkProblems (pkgs.mkShell ({
name = "dnvr-${name}";
packages = config.packages ++ processPackages ++ scriptPkgs ++ [dnvrState config.cli];
shellHook = ''
export DNVR_ROOT="$(${pkgs.git}/bin/git rev-parse --show-toplevel 2>/dev/null || pwd)"
export DNVR_STATE="$DNVR_ROOT/.dnvr"
mkdir -p "$DNVR_STATE"
${rootedEnvExports}
${refEntryExports}
export XDG_DATA_DIRS="${dnvrShare}/share''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
export NIX_PROFILES="''${NIX_PROFILES:+$NIX_PROFILES }${dnvrShare}"
${pkgs.coreutils}/bin/install -m 0644 ${nuCompletionFile} "$DNVR_STATE/dnvr-completions.nu"
if [ -n "''${BASH_VERSION:-}" ] && [[ $- == *i* ]]; then
eval "$(dnvr completions bash)"
fi
${bannerScript}
${config.shellHook}
'';
}
// (lib.optionalAttrs (envForShell != {}) {env = envForShell;})));
}