@williamthorsen/nmr 0.48.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.meta/changelog.json +218 -0
  2. package/.readyup/kits/default.js +1527 -1460
  3. package/.readyup/manifest.json +96 -11
  4. package/CHANGELOG.md +65 -0
  5. package/README.md +19 -8
  6. package/agents/guidance/rulebooks/nmr.md +2 -2
  7. package/dist/esm/check-cache-build-output.d.ts +24 -0
  8. package/dist/esm/check-cache-build-output.js +49 -0
  9. package/dist/esm/check-cache-delegation.d.ts +27 -0
  10. package/dist/esm/check-cache-delegation.js +92 -0
  11. package/dist/esm/check-cache-entries.d.ts +109 -0
  12. package/dist/esm/check-cache-entries.js +87 -0
  13. package/dist/esm/check-cache-gate.d.ts +64 -0
  14. package/dist/esm/check-cache-gate.js +226 -0
  15. package/dist/esm/check-cache-key.d.ts +47 -0
  16. package/dist/esm/check-cache-key.js +77 -0
  17. package/dist/esm/check-cache-record.d.ts +69 -0
  18. package/dist/esm/check-cache-record.js +167 -0
  19. package/dist/esm/check-cache.d.ts +8 -176
  20. package/dist/esm/check-cache.js +9 -214
  21. package/dist/esm/child-env.d.ts +57 -0
  22. package/dist/esm/child-env.js +59 -0
  23. package/dist/esm/child-process.d.ts +55 -0
  24. package/dist/esm/child-process.js +112 -0
  25. package/dist/esm/commands/build.d.ts +0 -16
  26. package/dist/esm/commands/build.js +9 -214
  27. package/dist/esm/commands/clean.js +3 -2
  28. package/dist/esm/commands/compiler.d.ts +18 -0
  29. package/dist/esm/commands/compiler.js +49 -0
  30. package/dist/esm/commands/fmt.d.ts +0 -39
  31. package/dist/esm/commands/fmt.js +4 -81
  32. package/dist/esm/commands/format-targets.d.ts +39 -0
  33. package/dist/esm/commands/format-targets.js +83 -0
  34. package/dist/esm/commands/rewrite-specifiers.d.ts +17 -0
  35. package/dist/esm/commands/rewrite-specifiers.js +122 -0
  36. package/dist/esm/commands/tsconfig-chain.d.ts +8 -0
  37. package/dist/esm/commands/tsconfig-chain.js +63 -0
  38. package/dist/esm/config-check-cache.d.ts +17 -0
  39. package/dist/esm/config-check-cache.js +63 -0
  40. package/dist/esm/config-validation.d.ts +20 -0
  41. package/dist/esm/config-validation.js +236 -0
  42. package/dist/esm/config.js +4 -286
  43. package/dist/esm/crossing-diagnostics.d.ts +39 -0
  44. package/dist/esm/crossing-diagnostics.js +132 -0
  45. package/dist/esm/default-scripts.js +1 -1
  46. package/dist/esm/delegate-host.d.ts +43 -0
  47. package/dist/esm/delegate-host.js +191 -0
  48. package/dist/esm/delegation-target.d.ts +52 -0
  49. package/dist/esm/delegation-target.js +71 -0
  50. package/dist/esm/fan-out.d.ts +57 -0
  51. package/dist/esm/fan-out.js +146 -0
  52. package/dist/esm/glyphs.d.ts +1 -1
  53. package/dist/esm/help.js +10 -3
  54. package/dist/esm/helpers/deriveExcerpt.d.ts +9 -0
  55. package/dist/esm/helpers/deriveExcerpt.js +1 -1
  56. package/dist/esm/helpers/renderQuotedList.d.ts +2 -0
  57. package/dist/esm/helpers/renderQuotedList.js +9 -0
  58. package/dist/esm/hooks.d.ts +17 -0
  59. package/dist/esm/hooks.js +23 -0
  60. package/dist/esm/invocation-args.d.ts +40 -0
  61. package/dist/esm/invocation-args.js +178 -0
  62. package/dist/esm/invocation.d.ts +67 -0
  63. package/dist/esm/invocation.js +3 -0
  64. package/dist/esm/machine-lock.d.ts +2 -0
  65. package/dist/esm/machine-lock.js +1 -1
  66. package/dist/esm/output-channel.d.ts +23 -0
  67. package/dist/esm/output-channel.js +14 -0
  68. package/dist/esm/pass-manifest.d.ts +53 -0
  69. package/dist/esm/pass-manifest.js +89 -0
  70. package/dist/esm/presentation.d.ts +47 -0
  71. package/dist/esm/presentation.js +32 -0
  72. package/dist/esm/recording.d.ts +1 -1
  73. package/dist/esm/recording.js +2 -2
  74. package/dist/esm/remote-host-session.d.ts +62 -0
  75. package/dist/esm/remote-host-session.js +277 -0
  76. package/dist/esm/remote-probe-host.d.ts +22 -0
  77. package/dist/esm/remote-probe-host.js +50 -0
  78. package/dist/esm/remote-probe.d.ts +63 -0
  79. package/dist/esm/remote-probe.js +88 -0
  80. package/dist/esm/remote-run.d.ts +45 -65
  81. package/dist/esm/remote-run.js +128 -162
  82. package/dist/esm/remote-session.d.ts +40 -0
  83. package/dist/esm/remote-session.js +151 -0
  84. package/dist/esm/remote-snapshot.d.ts +21 -0
  85. package/dist/esm/remote-snapshot.js +80 -0
  86. package/dist/esm/replay-assembly.d.ts +2 -2
  87. package/dist/esm/replay-assembly.js +1 -1
  88. package/dist/esm/run-chain.d.ts +21 -0
  89. package/dist/esm/run-chain.js +108 -0
  90. package/dist/esm/runCli.d.ts +1 -9
  91. package/dist/esm/runCli.js +39 -1002
  92. package/dist/esm/runner.d.ts +1 -54
  93. package/dist/esm/runner.js +2 -125
  94. package/dist/esm/shell-syntax.d.ts +30 -0
  95. package/dist/esm/shell-syntax.js +120 -0
  96. package/dist/esm/steps.d.ts +0 -2
  97. package/dist/esm/steps.js +3 -119
  98. package/dist/esm/types.d.ts +5 -0
  99. package/dist/esm/user-config.d.ts +34 -0
  100. package/dist/esm/user-config.js +93 -0
  101. package/dist/esm/verbosity.d.ts +6 -0
  102. package/dist/esm/verbosity.js +7 -7
  103. package/dist/esm/verdict.d.ts +1 -1
  104. package/dist/esm/verdict.js +1 -1
  105. package/docs/check-cache.md +28 -12
  106. package/docs/delegate.md +103 -17
  107. package/docs/reporting.md +6 -1
  108. package/docs/scripts.md +2 -1
  109. package/package.json +4 -4
  110. /package/dist/esm/{prettier-default-config.d.ts → prettier-default.config.d.ts} +0 -0
  111. /package/dist/esm/{prettier-default-config.js → prettier-default.config.js} +0 -0
@@ -0,0 +1,63 @@
1
+ import { type HostList } from './remote-run.js';
2
+ /**
3
+ * The host on which to run, or that `ssh` could not connect to any host, with what it printed for the first one
4
+ * listed.
5
+ */
6
+ export type HostChoice = {
7
+ kind: 'chosen';
8
+ host: string;
9
+ } | {
10
+ kind: 'unreachable';
11
+ host: string;
12
+ sshMessage: string;
13
+ };
14
+ /**
15
+ * The host that the probe results settle on, or that every host is unreachable.
16
+ *
17
+ * @internal
18
+ */
19
+ export type HostSelection = {
20
+ kind: 'chosen';
21
+ host: string;
22
+ } | {
23
+ kind: 'unreachable';
24
+ };
25
+ /** What a probe found, with the last line that `ssh` printed when it could not connect. */
26
+ export interface ProbeReport {
27
+ result: ProbeResult;
28
+ sshMessage: string;
29
+ }
30
+ /**
31
+ * What a probe found on a host: whether a delegation started now would wait there, that the host connected but did
32
+ * not answer, or that `ssh` could not connect to it.
33
+ */
34
+ export type ProbeResult = 'busy' | 'free' | 'no-answer' | 'unreachable';
35
+ /**
36
+ * Probes every host at once and resolves to the one on which to run, per `selectHost`, as soon as the results that
37
+ * have arrived settle it. Reports each result that arrives before then, and aborts the probes still running once it
38
+ * has chosen.
39
+ */
40
+ export declare function chooseHost(options: {
41
+ hosts: HostList;
42
+ onSettled: (host: string, result: ProbeResult) => void;
43
+ probe: (host: string, signal: AbortSignal) => Promise<ProbeReport>;
44
+ }): Promise<HostChoice>;
45
+ /**
46
+ * Asks `host` whether a delegation from this checkout would wait there: whether its machine lock, or the lock of the
47
+ * checkout at `checkoutPath`, has a live holder. A host to which `ssh` cannot connect is unreachable, and one that
48
+ * fails to run the probe, or does not print its report, did not answer.
49
+ */
50
+ export declare function probeHost(options: {
51
+ checkoutPath: string;
52
+ env: NodeJS.ProcessEnv;
53
+ host: string;
54
+ signal: AbortSignal;
55
+ }): Promise<ProbeReport>;
56
+ /**
57
+ * Returns the host on which to run, or `undefined` while a host whose result could change the choice has not
58
+ * answered: the first free host in list order, otherwise the first busy one, otherwise the first one that connected.
59
+ * When `ssh` could not connect to any host, returns that every host is unreachable.
60
+ *
61
+ * @internal
62
+ */
63
+ export declare function selectHost(hosts: HostList, results: ReadonlyMap<string, ProbeResult>): HostSelection | undefined;
@@ -0,0 +1,88 @@
1
+ import path from 'node:path';
2
+ import { quoteForShell } from "./helpers/quoteForShell.js";
3
+ import { isObject } from "./helpers/type-guards.js";
4
+ import { LOCK_DIR_ENV_VAR, LOCK_FILE, STALE_AFTER_MS } from "./machine-lock.js";
5
+ import { CHECKOUT_LOCK_PATH } from "./remote-host-session.js";
6
+ import { probeHostLocks } from "./remote-probe-host.js";
7
+ import { DELEGATION_FAILURE_EXIT_CODE, readLastLine, runSsh } from "./remote-run.js";
8
+ export function chooseHost(options) {
9
+ const { hosts, onSettled, probe } = options;
10
+ const controller = new AbortController();
11
+ const results = new Map();
12
+ const sshMessages = new Map();
13
+ return new Promise((resolve) => {
14
+ for (const host of hosts) {
15
+ void settle(host, resolve);
16
+ }
17
+ });
18
+ async function settle(host, resolve) {
19
+ let report;
20
+ try {
21
+ report = await probe(host, controller.signal);
22
+ }
23
+ catch {
24
+ report = { result: 'no-answer', sshMessage: '' };
25
+ }
26
+ if (controller.signal.aborted)
27
+ return;
28
+ results.set(host, report.result);
29
+ sshMessages.set(host, report.sshMessage);
30
+ onSettled(host, report.result);
31
+ const selection = selectHost(hosts, results);
32
+ if (selection === undefined)
33
+ return;
34
+ controller.abort();
35
+ const [first] = hosts;
36
+ resolve(selection.kind === 'chosen'
37
+ ? selection
38
+ : { kind: 'unreachable', host: first, sshMessage: sshMessages.get(first) ?? '' });
39
+ }
40
+ }
41
+ export async function probeHost(options) {
42
+ const { checkoutPath, env, host, signal } = options;
43
+ const params = {
44
+ checkoutLockPath: path.posix.join(checkoutPath, CHECKOUT_LOCK_PATH),
45
+ lockDirEnvVar: LOCK_DIR_ENV_VAR,
46
+ machineLockFile: LOCK_FILE,
47
+ staleAfterMs: STALE_AFTER_MS,
48
+ };
49
+ const source = `(${probeHostLocks.toString()})(JSON.parse(process.argv[1]));`;
50
+ const { exitCode, output } = await runSsh(host, `exec node -e ${quoteForShell(source)} ${quoteForShell(JSON.stringify(params))}`, env, signal);
51
+ if (exitCode === DELEGATION_FAILURE_EXIT_CODE) {
52
+ return { result: 'unreachable', sshMessage: readLastLine(output) };
53
+ }
54
+ if (exitCode !== 0) {
55
+ return { result: 'no-answer', sshMessage: '' };
56
+ }
57
+ const busy = output
58
+ .split('\n')
59
+ .map(readBusyField)
60
+ .findLast((value) => value !== undefined);
61
+ if (busy === undefined) {
62
+ return { result: 'no-answer', sshMessage: '' };
63
+ }
64
+ return { result: busy ? 'busy' : 'free', sshMessage: '' };
65
+ }
66
+ export function selectHost(hosts, results) {
67
+ for (const host of hosts) {
68
+ const result = results.get(host);
69
+ if (result === undefined) {
70
+ return undefined;
71
+ }
72
+ if (result === 'free') {
73
+ return { kind: 'chosen', host };
74
+ }
75
+ }
76
+ const host = hosts.find((candidate) => results.get(candidate) === 'busy') ??
77
+ hosts.find((candidate) => results.get(candidate) === 'no-answer');
78
+ return host === undefined ? { kind: 'unreachable' } : { kind: 'chosen', host };
79
+ }
80
+ function readBusyField(line) {
81
+ try {
82
+ const value = JSON.parse(line);
83
+ return isObject(value) && typeof value['busy'] === 'boolean' ? value['busy'] : undefined;
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
@@ -1,78 +1,61 @@
1
1
  import type { Writable } from 'node:stream';
2
2
  import { type OutputStyle } from '@williamthorsen/nmr-core';
3
- /** Names the host to which nmr delegates a command when the invocation does not say otherwise. */
3
+ /** Names the hosts to which nmr delegates a command when the invocation does not say otherwise. */
4
4
  export declare const DELEGATE_ENV_VAR = "NMR_DELEGATE";
5
- /** The flag that names the host on which to run the command. */
5
+ /** The flag that runs the command on a host that `NMR_DELEGATE` or the user config names. */
6
6
  export declare const DELEGATE_FLAG = "--delegate";
7
+ /** The flag that names the hosts on which to run the command. */
8
+ export declare const DELEGATE_TO_FLAG = "--delegate-to";
7
9
  /** The exit status of a delegation that failed outside its command, which matches `ssh`'s own. */
8
10
  export declare const DELEGATION_FAILURE_EXIT_CODE = 255;
9
- /** The flag that runs the command here, whatever `NMR_DELEGATE` names. */
11
+ /** The flag that runs the command here, whatever `NMR_DELEGATE` or the user config says. */
10
12
  export declare const NO_DELEGATE_FLAG = "--no-delegate";
13
+ /** Names the ssh command that the environment supplies, which git and nmr run in place of plain `ssh`. */
14
+ export declare const SSH_COMMAND_ENV_VAR = "GIT_SSH_COMMAND";
11
15
  /** Keeps `ssh` from prompting or waiting on a terminal, and bounds the wait for a host that does not answer. */
12
16
  export declare const SSH_OPTIONS: readonly string[];
13
- /** Where a delegation runs, or why the invocation cannot run where it asked to. */
14
- export type DelegationTarget = {
15
- kind: 'local';
16
- } | {
17
- kind: 'remote';
18
- host: string;
19
- source: 'env' | 'flag';
20
- } | {
21
- kind: 'refused';
22
- error: string;
23
- };
17
+ /** Candidate hosts in order of preference. */
18
+ export type HostList = readonly [string, ...string[]];
24
19
  /** The phases of a delegation that run on the host, each reported by name when it fails. */
25
20
  export type RemotePhase = 'check out' | 'install' | 'prepare' | 'push' | 'run';
26
- /** The status of a delegated command, or the phase that kept it from finishing and what that phase printed. */
21
+ /**
22
+ * The status of a delegated command and the pass-manifest entries that its run reported, unvalidated, or the phase
23
+ * that kept it from finishing and what that phase printed, with how long the delegation waited for another
24
+ * delegation's checkout. A failed `prepare` also reports whether `ssh` could not connect to the host.
25
+ */
27
26
  export type RemoteRunResult = {
28
27
  ok: true;
29
28
  exitCode: number;
29
+ passes: unknown[];
30
+ waitedMs: number;
30
31
  } | {
31
32
  ok: false;
33
+ isUnreachable: boolean;
32
34
  message: string;
33
35
  output: string;
34
- phase: RemotePhase;
35
- };
36
- /** A commit that records the working tree, or why one could not be made. */
37
- export type SnapshotResult = {
38
- ok: true;
39
- sha: string;
40
- toplevel: string;
36
+ phase: 'prepare';
37
+ waitedMs: number;
41
38
  } | {
42
39
  ok: false;
43
- isUnsupported: boolean;
44
- reason: string;
40
+ message: string;
41
+ output: string;
42
+ phase: Exclude<RemotePhase, 'prepare'>;
43
+ waitedMs: number;
45
44
  };
46
45
  /**
47
- * Composes the script that runs nmr on the host, from the directory that corresponds to the local one.
48
- *
49
- * A Node launcher starts nmr in its own process group, with stdin closed, and reads the connection's stdin, which the
50
- * local `ssh` holds open and closes when it exits, as on Ctrl-C; on that end of input, the launcher stops nmr's whole
51
- * group, so that an interrupted run does not continue on the host. The launcher rather than the shell's job control
52
- * makes the group, because a shell such as dash refuses job control without a terminal. `--no-delegate` keeps a host
53
- * whose environment sets `NMR_DELEGATE` from passing the command on again.
54
- *
55
- * The script writes nmr's status to `statusPath` before it exits, so that a 255 from `ssh` can be told apart: It is
56
- * nmr's own when the file records it, and a dropped connection otherwise.
46
+ * Returns the `GIT_SSH_COMMAND` with which `git push` reaches the host: the environment's ssh command, or plain
47
+ * `ssh`, followed by nmr's own options.
57
48
  *
58
49
  * @internal
59
50
  */
60
- export declare function composeRunScript(options: {
61
- nmrArgs: readonly string[];
62
- remoteEnv: Readonly<Record<string, string>>;
63
- runDir: string;
64
- statusPath: string;
65
- }): string;
51
+ export declare function composeGitSshCommand(env: NodeJS.ProcessEnv): string;
66
52
  /**
67
- * Builds a commit whose tree is the working tree as it is on disk: tracked changes, staged or not, and untracked
68
- * files that git does not ignore. The commit is built in a temporary index, so the repository's index, refs, and
69
- * stash are untouched; the only write is to the object database.
53
+ * Returns the argv that runs `remoteCommand` on `host` with nmr's own options. An ssh command in the environment
54
+ * runs under `/bin/sh` with the arguments appended, as git runs it, because it may contain shell quoting.
70
55
  *
71
- * The identity and date are fixed, so that the same tree on the same `HEAD` always yields the same commit and a
72
- * repeated push transfers nothing. A submodule, or a nested repository that `git add` would record as one, is
73
- * refused: Its content is in a repository that the snapshot does not include.
56
+ * @internal
74
57
  */
75
- export declare function createSnapshot(cwd: string, env: NodeJS.ProcessEnv): SnapshotResult;
58
+ export declare function composeSshArgv(env: NodeJS.ProcessEnv, host: string, remoteCommand: string): string[];
76
59
  /**
77
60
  * Returns the checkout's path on the host, relative to its home directory: a key derived from this machine and the
78
61
  * repository, followed by the repository's own directory name, which nmr on the host reports as the root's scope.
@@ -81,34 +64,24 @@ export declare function createSnapshot(cwd: string, env: NodeJS.ProcessEnv): Sna
81
64
  */
82
65
  export declare function deriveCheckoutPath(hostname: string, toplevel: string): string;
83
66
  /**
84
- * Renders the warning for a `--delegate` or `--no-delegate` that appears after the command name, which makes it an
85
- * argument to the command rather than a flag to nmr.
67
+ * Renders the warning for a delegation flag that appears after the command name, which makes it an argument to the
68
+ * command rather than a flag to nmr.
86
69
  */
87
70
  export declare function formatMisplacedDelegationWarning(command: string, style: OutputStyle): string;
88
- /**
89
- * Decides where an invocation runs. `--no-delegate` outranks `--delegate`, which outranks `NMR_DELEGATE`.
90
- *
91
- * A command that rewrites the tree, or that runs until it is stopped, cannot be delegated, and neither can `--log`,
92
- * which reads this machine's recordings. Asked for by the flag, such an invocation is refused; asked for by the
93
- * variable, which sets a default for every invocation, it runs here.
94
- */
95
- export declare function resolveDelegationTarget(options: {
96
- command: string;
97
- env: NodeJS.ProcessEnv;
98
- flagHost: string | undefined;
99
- isOptedOut: boolean;
100
- isReading: boolean;
101
- }): DelegationTarget;
71
+ /** Returns the last line of `output` that is not blank, trimmed, or an empty string when there is none. */
72
+ export declare function readLastLine(output: string): string;
102
73
  /**
103
74
  * Runs an nmr invocation on `host` against a snapshot of the local working tree, streaming the command's output to
104
75
  * `stdout` and `stderr` and returning its exit status.
105
76
  *
106
77
  * The host keeps one checkout per local repository and host name, under `~/.cache/nmr/delegate/`, so concurrent
107
78
  * delegations from different worktrees do not share a checkout, and `node_modules` and build output persist
108
- * between runs. Each phase is its own connection, so that a failure names its phase; what a phase other than `run`
109
- * prints is returned rather than streamed, because it matters only when the phase fails.
79
+ * between runs. Check out, install, and run take one connection, whose session holds the checkout's lock, so that two
80
+ * delegations from one worktree run one after the other; what a phase other than `run` prints is returned rather than
81
+ * streamed, because it matters only when the phase fails.
110
82
  */
111
83
  export declare function runRemotely(options: {
84
+ command: string;
112
85
  cwd: string;
113
86
  env: NodeJS.ProcessEnv;
114
87
  host: string;
@@ -122,6 +95,13 @@ export declare function runRemotely(options: {
122
95
  stderr: Writable;
123
96
  stdout: Writable;
124
97
  }): Promise<RemoteRunResult>;
98
+ /**
99
+ * Runs a script on the host through `wrapRemoteScript`, capturing what it prints. An abort stops the `ssh` process.
100
+ */
101
+ export declare function runSsh(host: string, script: string, env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<{
102
+ exitCode: number;
103
+ output: string;
104
+ }>;
125
105
  /**
126
106
  * Wraps a script to run under `/bin/sh` with the `PATH` that the user's interactive login shell sets. pnpm's own
127
107
  * installer and the common version managers set it up in the interactive startup file, such as `~/.zshrc`, which a