@cod3vil/trunk 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +69 -0
  4. package/dist/commands/clone.d.ts +6 -0
  5. package/dist/commands/clone.js +114 -0
  6. package/dist/commands/init.d.ts +6 -0
  7. package/dist/commands/init.js +235 -0
  8. package/dist/commands/new.d.ts +6 -0
  9. package/dist/commands/new.js +357 -0
  10. package/dist/core/adopt.d.ts +14 -0
  11. package/dist/core/adopt.js +157 -0
  12. package/dist/core/agents.d.ts +30 -0
  13. package/dist/core/agents.js +31 -0
  14. package/dist/core/arguments.d.ts +92 -0
  15. package/dist/core/arguments.js +93 -0
  16. package/dist/core/detect.d.ts +28 -0
  17. package/dist/core/detect.js +169 -0
  18. package/dist/core/diff.d.ts +37 -0
  19. package/dist/core/diff.js +140 -0
  20. package/dist/core/env.d.ts +63 -0
  21. package/dist/core/env.js +140 -0
  22. package/dist/core/generate/aliases.d.ts +7 -0
  23. package/dist/core/generate/aliases.js +45 -0
  24. package/dist/core/generate/header.d.ts +2 -0
  25. package/dist/core/generate/header.js +81 -0
  26. package/dist/core/generate/index.d.ts +8 -0
  27. package/dist/core/generate/index.js +74 -0
  28. package/dist/core/generate/proxy.d.ts +8 -0
  29. package/dist/core/generate/proxy.js +56 -0
  30. package/dist/core/generate/steps.d.ts +7 -0
  31. package/dist/core/generate/steps.js +88 -0
  32. package/dist/core/generate/tmux.d.ts +4 -0
  33. package/dist/core/generate/tmux.js +98 -0
  34. package/dist/core/generate/toml.d.ts +16 -0
  35. package/dist/core/generate/toml.js +68 -0
  36. package/dist/core/gh.d.ts +60 -0
  37. package/dist/core/gh.js +101 -0
  38. package/dist/core/git.d.ts +79 -0
  39. package/dist/core/git.js +211 -0
  40. package/dist/core/journal.d.ts +54 -0
  41. package/dist/core/journal.js +147 -0
  42. package/dist/core/log.d.ts +8 -0
  43. package/dist/core/log.js +38 -0
  44. package/dist/core/pipeline.d.ts +119 -0
  45. package/dist/core/pipeline.js +473 -0
  46. package/dist/core/platform.d.ts +10 -0
  47. package/dist/core/platform.js +26 -0
  48. package/dist/core/prefix.d.ts +25 -0
  49. package/dist/core/prefix.js +59 -0
  50. package/dist/core/process.d.ts +27 -0
  51. package/dist/core/process.js +43 -0
  52. package/dist/core/repo.d.ts +79 -0
  53. package/dist/core/repo.js +294 -0
  54. package/dist/core/resolve.d.ts +127 -0
  55. package/dist/core/resolve.js +488 -0
  56. package/dist/core/result.d.ts +27 -0
  57. package/dist/core/result.js +32 -0
  58. package/dist/core/settings.d.ts +51 -0
  59. package/dist/core/settings.js +83 -0
  60. package/dist/core/tmuxRename.d.ts +36 -0
  61. package/dist/core/tmuxRename.js +79 -0
  62. package/dist/core/validate.d.ts +22 -0
  63. package/dist/core/validate.js +111 -0
  64. package/dist/core/version.d.ts +2 -0
  65. package/dist/core/version.js +35 -0
  66. package/dist/core/words.d.ts +16 -0
  67. package/dist/core/words.js +198 -0
  68. package/dist/core/wt.d.ts +41 -0
  69. package/dist/core/wt.js +59 -0
  70. package/dist/ui/SetupForm.d.ts +55 -0
  71. package/dist/ui/SetupForm.js +354 -0
  72. package/dist/ui/Summary.d.ts +15 -0
  73. package/dist/ui/Summary.js +74 -0
  74. package/dist/ui/fields/MultiSelect.d.ts +17 -0
  75. package/dist/ui/fields/MultiSelect.js +66 -0
  76. package/dist/ui/fields/Select.d.ts +17 -0
  77. package/dist/ui/fields/Select.js +37 -0
  78. package/dist/ui/fields/TextInput.d.ts +13 -0
  79. package/dist/ui/fields/TextInput.js +50 -0
  80. package/package.json +76 -0
  81. package/readme.md +147 -0
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The command line surface: the help text and every flag trunk accepts. It is
3
+ * kept apart from `cli.tsx` so tests can parse an argument list without running
4
+ * a command, and so the help text stays the single description of the CLI.
5
+ */
6
+ import process from 'node:process';
7
+ import meow from 'meow';
8
+ import { trunkVersion } from './version.js';
9
+ /** Printed for `trunk` with no command and for `--help`. */
10
+ export const helpText = `
11
+ Usage
12
+ $ trunk clone <url> [dir] set up a bare-layout project from a remote
13
+ $ trunk init [dir] set up an existing bare-layout project
14
+ $ trunk new <name> create a new project (optionally on GitHub)
15
+
16
+ Options
17
+ --yes accept detected defaults, no form (needed without a TTY)
18
+ --prefix <name> tmux session prefix
19
+ --pm <npm|pnpm|bun> package manager
20
+ --agents <a,b> up to 4 of claude,codex,opencode,copilot,antigravity,pi
21
+ --server/--no-server dev server step
22
+ --caddy/--no-caddy Caddy route step
23
+ --tmux/--no-tmux tmux session hooks
24
+ --copy/--no-copy wt step copy-ignored
25
+ --mc/--no-mc \`wt mc\` alias
26
+ --direct commit on the current branch instead of chore/trunk-setup
27
+
28
+ Options for \`trunk new\`
29
+ --remote/--no-remote create the GitHub repository too
30
+ --owner <name> account or organisation that owns it
31
+ --public create it public instead of private
32
+ `;
33
+ /**
34
+ * Every flag is declared here even when a later phase is what reads it, so that
35
+ * an unknown flag is always an error rather than silently ignored.
36
+ */
37
+ export const flagDefinitions = {
38
+ yes: {
39
+ type: 'boolean',
40
+ },
41
+ prefix: {
42
+ type: 'string',
43
+ },
44
+ pm: {
45
+ type: 'string',
46
+ },
47
+ agents: {
48
+ type: 'string',
49
+ },
50
+ server: {
51
+ type: 'boolean',
52
+ },
53
+ caddy: {
54
+ type: 'boolean',
55
+ },
56
+ tmux: {
57
+ type: 'boolean',
58
+ },
59
+ // Named for the wt step it controls, but spelled `--copy` on the command line.
60
+ copyIgnored: {
61
+ type: 'boolean',
62
+ alias: 'copy',
63
+ },
64
+ mc: {
65
+ type: 'boolean',
66
+ },
67
+ direct: {
68
+ type: 'boolean',
69
+ },
70
+ // `trunk new` only.
71
+ remote: {
72
+ type: 'boolean',
73
+ },
74
+ owner: {
75
+ type: 'string',
76
+ },
77
+ public: {
78
+ type: 'boolean',
79
+ },
80
+ };
81
+ export function parseArguments(argv = process.argv.slice(2)) {
82
+ return meow(helpText, {
83
+ importMeta: import.meta,
84
+ argv,
85
+ version: trunkVersion(),
86
+ // Booleans stay tri-state: a flag that was never passed reads as
87
+ // `undefined`, which is how the setup form tells "leave it to me" apart
88
+ // from an explicit `--no-server`.
89
+ booleanDefault: undefined,
90
+ allowUnknownFlags: false,
91
+ flags: flagDefinitions,
92
+ });
93
+ }
@@ -0,0 +1,28 @@
1
+ export type PackageManager = 'npm' | 'pnpm' | 'bun';
2
+ export type LockfilePackageManager = PackageManager | 'yarn';
3
+ export type LockfileMatch = Readonly<{
4
+ file: string;
5
+ packageManager: LockfilePackageManager;
6
+ }>;
7
+ export type PackageDetection = Readonly<{
8
+ /** What trunk will use. Falls back to npm when nothing says otherwise. */
9
+ packageManager: PackageManager;
10
+ /** Every lockfile found, nearest directory first. */
11
+ matches: readonly LockfileMatch[];
12
+ /** Several package managers claim the same directory. */
13
+ ambiguous: boolean;
14
+ /** The guess is worth showing the user before it is written down. */
15
+ needsConfirmation: boolean;
16
+ /** Script names from package.json, used for the notes in the generated file. */
17
+ scripts: readonly string[];
18
+ /** Set when package.json is not at the repository root, e.g. `backend`. */
19
+ appDir?: string;
20
+ packageJson?: string;
21
+ warnings: readonly string[];
22
+ }>;
23
+ /**
24
+ * Inspects a worktree and reports how to install and run it. Nothing here ever
25
+ * fails: an unclear project produces a usable default plus warnings, and the
26
+ * setup form asks the user to confirm.
27
+ */
28
+ export declare function detectPackageManager(worktreeRoot: string): Promise<PackageDetection>;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Works out how a project installs and runs: which package manager its lockfile
3
+ * implies, which scripts it defines, and whether the app sits in a subdirectory
4
+ * rather than at the repository root.
5
+ */
6
+ import { access, readdir, readFile } from 'node:fs/promises';
7
+ import { join, relative, sep } from 'node:path';
8
+ /**
9
+ * Order matters: when a directory holds several lockfiles the first match wins,
10
+ * so this doubles as the tie-break priority. Yarn is listed to be recognised and
11
+ * reported, not to be used.
12
+ */
13
+ const lockfiles = [
14
+ { file: 'bun.lock', packageManager: 'bun' },
15
+ { file: 'bun.lockb', packageManager: 'bun' },
16
+ { file: 'pnpm-lock.yaml', packageManager: 'pnpm' },
17
+ { file: 'package-lock.json', packageManager: 'npm' },
18
+ { file: 'yarn.lock', packageManager: 'yarn' },
19
+ ];
20
+ /**
21
+ * Inspects a worktree and reports how to install and run it. Nothing here ever
22
+ * fails: an unclear project produces a usable default plus warnings, and the
23
+ * setup form asks the user to confirm.
24
+ */
25
+ export async function detectPackageManager(worktreeRoot) {
26
+ const warnings = [];
27
+ const packageDirectories = await findPackageDirectories(worktreeRoot);
28
+ const packageDirectory = packageDirectories[0] ?? worktreeRoot;
29
+ if (packageDirectories.length > 1) {
30
+ warnings.push(`Multiple package directories found (${packageDirectories
31
+ .map(directory => relativePath(worktreeRoot, directory))
32
+ .join(', ')}); using ${relativePath(worktreeRoot, packageDirectory)}.`);
33
+ }
34
+ // The lockfile usually sits with the package.json, but a repository whose app
35
+ // lives one level down often keeps it at the root instead. Search both, and
36
+ // let the deeper directory win when each has one.
37
+ const searchDirectories = packageDirectory === worktreeRoot
38
+ ? [packageDirectory]
39
+ : [packageDirectory, worktreeRoot];
40
+ const matchesByDirectory = await Promise.all(searchDirectories.map(async (directory) => findLockfiles(directory, worktreeRoot)));
41
+ const matches = matchesByDirectory.flat();
42
+ const chosen = matchesByDirectory.find(found => found.length > 0) ?? [];
43
+ const supportedMatch = chosen.find((match) => match.packageManager !== 'yarn');
44
+ const yarnFound = chosen.some(match => match.packageManager === 'yarn');
45
+ const packageManager = supportedMatch?.packageManager ?? 'npm';
46
+ const ambiguous = new Set(chosen.map(match => match.packageManager)).size > 1;
47
+ if (chosen.length > 1) {
48
+ warnings.push(`Multiple lockfiles found (${chosen
49
+ .map(match => match.file)
50
+ .join(', ')}); using ${packageManager}.`);
51
+ }
52
+ if (chosen.length > 0 && chosen !== matchesByDirectory[0]) {
53
+ warnings.push(`Lockfile ${chosen[0].file} sits at the repository root while package.json is in ${relativePath(worktreeRoot, packageDirectory)}.`);
54
+ }
55
+ if (yarnFound) {
56
+ warnings.push('Yarn is unsupported; falling back to npm when needed.');
57
+ }
58
+ if (matches.length === 0) {
59
+ warnings.push('No lockfile found; defaulting to npm.');
60
+ }
61
+ const packageJsonPath = join(packageDirectory, 'package.json');
62
+ const packageJsonFound = await pathExists(packageJsonPath);
63
+ const scripts = packageJsonFound
64
+ ? await readScripts(packageJsonPath, warnings)
65
+ : [];
66
+ const appDirectory = packageDirectories.length > 0 && packageDirectory !== worktreeRoot
67
+ ? relativePath(worktreeRoot, packageDirectory)
68
+ : undefined;
69
+ return Object.freeze({
70
+ packageManager,
71
+ matches: Object.freeze(matches),
72
+ ambiguous,
73
+ needsConfirmation: chosen.length !== 1 || yarnFound || packageDirectories.length > 1,
74
+ scripts: Object.freeze(scripts),
75
+ appDir: appDirectory,
76
+ packageJson: packageJsonFound
77
+ ? relativePath(worktreeRoot, packageJsonPath)
78
+ : undefined,
79
+ warnings: Object.freeze(warnings),
80
+ });
81
+ }
82
+ async function findLockfiles(directory, worktreeRoot) {
83
+ const found = await Promise.all(lockfiles.map(async (lockfile) => {
84
+ const path = join(directory, lockfile.file);
85
+ return (await pathExists(path))
86
+ ? Object.freeze({
87
+ file: relativePath(worktreeRoot, path),
88
+ packageManager: lockfile.packageManager,
89
+ })
90
+ : undefined;
91
+ }));
92
+ return found.filter((match) => match !== undefined);
93
+ }
94
+ /**
95
+ * Finds where the app lives: the root when it has a package.json, otherwise the
96
+ * directories one level down that do. Several matches are returned so the caller
97
+ * can say so; the list is sorted to keep the choice stable between runs.
98
+ */
99
+ async function findPackageDirectories(root) {
100
+ if (await pathExists(join(root, 'package.json'))) {
101
+ return [root];
102
+ }
103
+ let entries;
104
+ try {
105
+ entries = await readdir(root, { withFileTypes: true });
106
+ }
107
+ catch (error) {
108
+ if (isMissingFileError(error)) {
109
+ return [];
110
+ }
111
+ throw error;
112
+ }
113
+ const directories = entries
114
+ .filter(
115
+ // Dependencies and tool directories carry package.json files of their
116
+ // own and are never the project's app.
117
+ entry => entry.isDirectory() &&
118
+ !entry.name.startsWith('.') &&
119
+ entry.name !== 'node_modules')
120
+ .map(entry => entry.name)
121
+ .sort((left, right) => left.localeCompare(right));
122
+ const candidates = await Promise.all(directories.map(async (directory) => {
123
+ const path = join(root, directory);
124
+ return (await pathExists(join(path, 'package.json'))) ? path : undefined;
125
+ }));
126
+ return candidates.filter((candidate) => candidate !== undefined);
127
+ }
128
+ async function readScripts(path, warnings) {
129
+ try {
130
+ const value = JSON.parse(await readFile(path, 'utf8'));
131
+ if (!isRecord(value) || !isRecord(value['scripts'])) {
132
+ return [];
133
+ }
134
+ return Object.entries(value['scripts'])
135
+ .filter((entry) => typeof entry[1] === 'string')
136
+ .map(([name]) => name)
137
+ .sort((left, right) => left.localeCompare(right));
138
+ }
139
+ catch (error) {
140
+ const message = error instanceof Error ? error.message : String(error);
141
+ warnings.push(`Could not read ${path}: ${message}`);
142
+ return [];
143
+ }
144
+ }
145
+ function isRecord(value) {
146
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
147
+ }
148
+ async function pathExists(path) {
149
+ try {
150
+ await access(path);
151
+ return true;
152
+ }
153
+ catch (error) {
154
+ if (isMissingFileError(error)) {
155
+ return false;
156
+ }
157
+ throw error;
158
+ }
159
+ }
160
+ function isMissingFileError(error) {
161
+ if (!(error instanceof Error) || !('code' in error)) {
162
+ return false;
163
+ }
164
+ return error.code === 'ENOENT' || error.code === 'ENOTDIR';
165
+ }
166
+ function relativePath(root, path) {
167
+ const value = relative(root, path).split(sep).join('/');
168
+ return value || '.';
169
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * A line diff for the one screen that shows one: the confirmation before an
3
+ * existing `wt.toml` is overwritten.
4
+ *
5
+ * Anything trunk cannot model is lost on overwrite — a repo-specific hook, a
6
+ * second alias, a comment someone wrote for their team. This is the user's only
7
+ * chance to notice, so removed lines have to be visible and countable.
8
+ */
9
+ export type DiffLineKind = 'added' | 'removed' | 'context';
10
+ export type DiffLine = Readonly<{
11
+ kind: DiffLineKind;
12
+ text: string;
13
+ }>;
14
+ export type Diff = Readonly<{
15
+ lines: readonly DiffLine[];
16
+ added: number;
17
+ removed: number;
18
+ /** True when the two texts are identical, so the screen can be skipped. */
19
+ unchanged: boolean;
20
+ }>;
21
+ /**
22
+ * Longest-common-subsequence diff. The files involved are a few hundred lines
23
+ * at most, so the quadratic table costs nothing and the output is minimal,
24
+ * which matters more here than speed.
25
+ */
26
+ export declare function diffLines(before: string, after: string): Diff;
27
+ /**
28
+ * The changed lines with a little surrounding context, so a long file does not
29
+ * bury what actually changed. Runs of unchanged lines longer than twice the
30
+ * context collapse to a single marker.
31
+ */
32
+ export declare function collapseContext(diff: Diff, context?: number): ReadonlyArray<DiffLine | Readonly<{
33
+ kind: 'skipped';
34
+ count: number;
35
+ }>>;
36
+ /** `+`/`-`/space prefixes, for a terminal without colour. */
37
+ export declare function formatDiff(diff: Diff, context?: number): readonly string[];
@@ -0,0 +1,140 @@
1
+ /**
2
+ * A line diff for the one screen that shows one: the confirmation before an
3
+ * existing `wt.toml` is overwritten.
4
+ *
5
+ * Anything trunk cannot model is lost on overwrite — a repo-specific hook, a
6
+ * second alias, a comment someone wrote for their team. This is the user's only
7
+ * chance to notice, so removed lines have to be visible and countable.
8
+ */
9
+ /**
10
+ * Longest-common-subsequence diff. The files involved are a few hundred lines
11
+ * at most, so the quadratic table costs nothing and the output is minimal,
12
+ * which matters more here than speed.
13
+ */
14
+ export function diffLines(before, after) {
15
+ const source = splitLines(before);
16
+ const target = splitLines(after);
17
+ const common = longestCommonSubsequence(source, target);
18
+ const lines = [];
19
+ let added = 0;
20
+ let removed = 0;
21
+ let sourceIndex = 0;
22
+ let targetIndex = 0;
23
+ for (const anchor of common) {
24
+ while (sourceIndex < anchor.source) {
25
+ lines.push({ kind: 'removed', text: source[sourceIndex] });
26
+ removed += 1;
27
+ sourceIndex += 1;
28
+ }
29
+ while (targetIndex < anchor.target) {
30
+ lines.push({ kind: 'added', text: target[targetIndex] });
31
+ added += 1;
32
+ targetIndex += 1;
33
+ }
34
+ lines.push({ kind: 'context', text: source[sourceIndex] });
35
+ sourceIndex += 1;
36
+ targetIndex += 1;
37
+ }
38
+ while (sourceIndex < source.length) {
39
+ lines.push({ kind: 'removed', text: source[sourceIndex] });
40
+ removed += 1;
41
+ sourceIndex += 1;
42
+ }
43
+ while (targetIndex < target.length) {
44
+ lines.push({ kind: 'added', text: target[targetIndex] });
45
+ added += 1;
46
+ targetIndex += 1;
47
+ }
48
+ return Object.freeze({
49
+ lines: Object.freeze(lines.map(line => Object.freeze(line))),
50
+ added,
51
+ removed,
52
+ unchanged: added === 0 && removed === 0,
53
+ });
54
+ }
55
+ /**
56
+ * The changed lines with a little surrounding context, so a long file does not
57
+ * bury what actually changed. Runs of unchanged lines longer than twice the
58
+ * context collapse to a single marker.
59
+ */
60
+ export function collapseContext(diff, context = 2) {
61
+ const keep = new Set();
62
+ for (const [index, line] of diff.lines.entries()) {
63
+ if (line.kind === 'context') {
64
+ continue;
65
+ }
66
+ for (let nearby = Math.max(0, index - context); nearby <= Math.min(diff.lines.length - 1, index + context); nearby += 1) {
67
+ keep.add(nearby);
68
+ }
69
+ }
70
+ const output = [];
71
+ let skipped = 0;
72
+ for (const [index, line] of diff.lines.entries()) {
73
+ if (keep.has(index)) {
74
+ if (skipped > 0) {
75
+ output.push(Object.freeze({ kind: 'skipped', count: skipped }));
76
+ skipped = 0;
77
+ }
78
+ output.push(line);
79
+ }
80
+ else {
81
+ skipped += 1;
82
+ }
83
+ }
84
+ if (skipped > 0) {
85
+ output.push(Object.freeze({ kind: 'skipped', count: skipped }));
86
+ }
87
+ return Object.freeze(output);
88
+ }
89
+ /** `+`/`-`/space prefixes, for a terminal without colour. */
90
+ export function formatDiff(diff, context = 2) {
91
+ return Object.freeze(collapseContext(diff, context).map(line => {
92
+ switch (line.kind) {
93
+ case 'added': {
94
+ return `+ ${line.text}`;
95
+ }
96
+ case 'removed': {
97
+ return `- ${line.text}`;
98
+ }
99
+ case 'skipped': {
100
+ return ` … ${line.count} unchanged ${line.count === 1 ? 'line' : 'lines'}`;
101
+ }
102
+ default: {
103
+ return ` ${line.text}`;
104
+ }
105
+ }
106
+ }));
107
+ }
108
+ function splitLines(value) {
109
+ const lines = value.split(/\r?\n/);
110
+ // A trailing newline is a line terminator, not an empty final line.
111
+ return lines.at(-1) === '' ? lines.slice(0, -1) : lines;
112
+ }
113
+ function longestCommonSubsequence(source, target) {
114
+ const lengths = Array.from({ length: source.length + 1 }, () => Array.from({ length: target.length + 1 }, () => 0));
115
+ for (let row = source.length - 1; row >= 0; row -= 1) {
116
+ for (let column = target.length - 1; column >= 0; column -= 1) {
117
+ lengths[row][column] =
118
+ source[row] === target[column]
119
+ ? lengths[row + 1][column + 1] + 1
120
+ : Math.max(lengths[row + 1][column], lengths[row][column + 1]);
121
+ }
122
+ }
123
+ const anchors = [];
124
+ let row = 0;
125
+ let column = 0;
126
+ while (row < source.length && column < target.length) {
127
+ if (source[row] === target[column]) {
128
+ anchors.push(Object.freeze({ source: row, target: column }));
129
+ row += 1;
130
+ column += 1;
131
+ }
132
+ else if (lengths[row + 1][column] >= lengths[row][column + 1]) {
133
+ row += 1;
134
+ }
135
+ else {
136
+ column += 1;
137
+ }
138
+ }
139
+ return Object.freeze(anchors);
140
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Which tools this machine has. Nothing here is assumed to exist: every tool is
3
+ * looked up on PATH at run time, git and wt are the only hard requirements, and
4
+ * the rest merely shape the defaults trunk offers.
5
+ */
6
+ import { type Stats } from 'node:fs';
7
+ import { type AgentId } from './agents.js';
8
+ import { type CommandRunner } from './process.js';
9
+ import { type Outcome } from './result.js';
10
+ export type Tool = Readonly<{
11
+ name: string;
12
+ path?: string;
13
+ version?: string;
14
+ }>;
15
+ export type ToolProbe = Readonly<{
16
+ git: Tool;
17
+ wt: Tool;
18
+ tmux: Tool;
19
+ caddy: Tool;
20
+ brew: Tool;
21
+ gh: Tool;
22
+ agents: Readonly<Record<AgentId, Tool>>;
23
+ }>;
24
+ export type SemanticVersion = Readonly<{
25
+ major: number;
26
+ minor: number;
27
+ patch: number;
28
+ }>;
29
+ export type ProbeOptions = Readonly<{
30
+ path?: string;
31
+ access?: (path: string, mode: number) => Promise<void>;
32
+ stat?: (path: string) => Promise<Pick<Stats, 'isFile'>>;
33
+ run?: CommandRunner;
34
+ }>;
35
+ /** The wt release trunk's templates were written against. Older only warns. */
36
+ export declare const minimumWtVersion: Readonly<{
37
+ major: 0;
38
+ minor: 77;
39
+ patch: 0;
40
+ }>;
41
+ /**
42
+ * Looks up every tool trunk cares about in one pass. The options exist so tests
43
+ * can supply their own PATH, filesystem and command runner.
44
+ */
45
+ export declare function probe(options?: ProbeOptions): Promise<ToolProbe>;
46
+ /**
47
+ * `which`, done in-process. Walking PATH ourselves rather than shelling out
48
+ * means the answer does not depend on the user's shell, its aliases or its
49
+ * startup files, and it behaves the same when no shell exists at all.
50
+ */
51
+ export declare function resolveExecutable(command: string, pathValue?: string, accessExecutable?: (path: string, mode: number) => Promise<void>, statPath?: (path: string) => Promise<Pick<Stats, 'isFile'>>): Promise<string | undefined>;
52
+ /**
53
+ * Git and wt are the two tools trunk cannot work around. Everything else is
54
+ * optional and only turns a step off or softens a default.
55
+ */
56
+ export declare function checkRequiredTools(tools: ToolProbe): Outcome | undefined;
57
+ /** Reads a version out of a line like `wt v0.77.0`. */
58
+ export declare function parseWtVersion(version: string | undefined): SemanticVersion | undefined;
59
+ /**
60
+ * Warns when wt is older than the templates expect. Patch releases are ignored,
61
+ * and a version that cannot be parsed is left alone rather than guessed at.
62
+ */
63
+ export declare function wtVersionWarning(version: string | undefined): string | undefined;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Which tools this machine has. Nothing here is assumed to exist: every tool is
3
+ * looked up on PATH at run time, git and wt are the only hard requirements, and
4
+ * the rest merely shape the defaults trunk offers.
5
+ */
6
+ import { constants } from 'node:fs';
7
+ import { access, stat } from 'node:fs/promises';
8
+ import { delimiter, resolve } from 'node:path';
9
+ import process from 'node:process';
10
+ import { agentCommands } from './agents.js';
11
+ import { runCommand } from './process.js';
12
+ import { unsupportedEnvironment } from './result.js';
13
+ /** The wt release trunk's templates were written against. Older only warns. */
14
+ export const minimumWtVersion = Object.freeze({ major: 0, minor: 77, patch: 0 });
15
+ /** Each tool spells its version query differently; brew's is too slow to ask. */
16
+ const versionArguments = Object.freeze({
17
+ git: ['--version'],
18
+ wt: ['--version'],
19
+ tmux: ['-V'],
20
+ caddy: ['version'],
21
+ gh: ['--version'],
22
+ });
23
+ /**
24
+ * Looks up every tool trunk cares about in one pass. The options exist so tests
25
+ * can supply their own PATH, filesystem and command runner.
26
+ */
27
+ export async function probe(options = {}) {
28
+ const pathValue = options.path ?? process.env['PATH'] ?? '';
29
+ const accessExecutable = options.access ?? access;
30
+ const statPath = options.stat ?? stat;
31
+ const run = options.run ?? runCommand;
32
+ const context = { pathValue, accessExecutable, statPath, run };
33
+ const toolNames = ['git', 'wt', 'tmux', 'caddy', 'brew', 'gh'];
34
+ const toolEntries = await Promise.all(toolNames.map(async (name) => [
35
+ name,
36
+ await probeTool(name, versionArguments[name], context),
37
+ ]));
38
+ const tools = Object.fromEntries(toolEntries);
39
+ const agentEntries = await Promise.all(Object.entries(agentCommands).map(async ([id, command]) => [id, await probeTool(command, undefined, context)]));
40
+ const agents = Object.freeze(Object.fromEntries(agentEntries));
41
+ return Object.freeze({ ...tools, agents });
42
+ }
43
+ /**
44
+ * `which`, done in-process. Walking PATH ourselves rather than shelling out
45
+ * means the answer does not depend on the user's shell, its aliases or its
46
+ * startup files, and it behaves the same when no shell exists at all.
47
+ */
48
+ export async function resolveExecutable(command, pathValue = process.env['PATH'] ?? '', accessExecutable = access, statPath = stat) {
49
+ const directories = pathValue.split(delimiter);
50
+ const visit = async (index) => {
51
+ if (index >= directories.length) {
52
+ return undefined;
53
+ }
54
+ const directory = directories[index] ?? '';
55
+ // An empty PATH entry means the working directory, as in a POSIX shell.
56
+ const candidate = resolve(directory || '.', command);
57
+ try {
58
+ await accessExecutable(candidate, constants.X_OK);
59
+ const status = await statPath(candidate);
60
+ // Directories are executable too, so the file check is what matters.
61
+ if (status.isFile()) {
62
+ return candidate;
63
+ }
64
+ return await visit(index + 1);
65
+ }
66
+ catch {
67
+ return visit(index + 1);
68
+ }
69
+ };
70
+ return visit(0);
71
+ }
72
+ /**
73
+ * Git and wt are the two tools trunk cannot work around. Everything else is
74
+ * optional and only turns a step off or softens a default.
75
+ */
76
+ export function checkRequiredTools(tools) {
77
+ const missing = [tools.git, tools.wt]
78
+ .filter(tool => tool.path === undefined)
79
+ .map(tool => tool.name);
80
+ if (missing.length === 0) {
81
+ return undefined;
82
+ }
83
+ return unsupportedEnvironment(`Missing required ${missing.length === 1 ? 'tool' : 'tools'}: ${missing.join(', ')}. Install Git and Worktrunk before continuing: https://worktrunk.dev`);
84
+ }
85
+ /** Reads a version out of a line like `wt v0.77.0`. */
86
+ export function parseWtVersion(version) {
87
+ const match = /\b(?:wt\s+)?v?(\d+)\.(\d+)(?:\.(\d+))?\b/i.exec(version ?? '');
88
+ if (!match) {
89
+ return undefined;
90
+ }
91
+ return Object.freeze({
92
+ major: Number(match[1]),
93
+ minor: Number(match[2]),
94
+ patch: Number(match[3] ?? 0),
95
+ });
96
+ }
97
+ /**
98
+ * Warns when wt is older than the templates expect. Patch releases are ignored,
99
+ * and a version that cannot be parsed is left alone rather than guessed at.
100
+ */
101
+ export function wtVersionWarning(version) {
102
+ const parsed = parseWtVersion(version);
103
+ if (!parsed) {
104
+ return undefined;
105
+ }
106
+ if (parsed.major > minimumWtVersion.major ||
107
+ (parsed.major === minimumWtVersion.major &&
108
+ parsed.minor >= minimumWtVersion.minor)) {
109
+ return undefined;
110
+ }
111
+ return `Worktrunk ${version} is older than the tested ${formatVersion(minimumWtVersion)}; generated templates may need adjustment.`;
112
+ }
113
+ async function probeTool(name, arguments_, context) {
114
+ const path = await resolveExecutable(name, context.pathValue, context.accessExecutable, context.statPath);
115
+ if (!path) {
116
+ return Object.freeze({ name });
117
+ }
118
+ if (!arguments_) {
119
+ return Object.freeze({ name, path });
120
+ }
121
+ try {
122
+ const result = await context.run(path, arguments_);
123
+ // Some tools print their version on stderr, and a version is never
124
+ // required, so a tool that fails this call still counts as present.
125
+ const version = firstLine(result.stdout) ?? firstLine(result.stderr);
126
+ return Object.freeze({ name, path, version });
127
+ }
128
+ catch {
129
+ return Object.freeze({ name, path });
130
+ }
131
+ }
132
+ function firstLine(value) {
133
+ return value
134
+ .split(/\r?\n/)
135
+ .map(line => line.trim())
136
+ .find(Boolean);
137
+ }
138
+ function formatVersion(version) {
139
+ return `v${version.major}.${version.minor}.${version.patch}`;
140
+ }
@@ -0,0 +1,7 @@
1
+ /** Project-level convenience commands assembled from the enabled hooks. */
2
+ import { type Settings } from '../settings.js';
3
+ export declare function generateAliases(settings: Settings): string | undefined;
4
+ export declare function upCommand(settings: Settings): string | undefined;
5
+ export declare function urlAliasBody(settings: Settings): string;
6
+ /** Keep this quoting aligned with Worktrunk's documented editor alias. */
7
+ export declare const mcAliasBody = "WORKTRUNK_COMMIT__GENERATION__COMMAND='f=$(mktemp); printf \"\\n\\n\" > \"$f\"; sed \"s/^/# /\" >> \"$f\"; ${EDITOR:-vi} \"$f\" < /dev/tty > /dev/tty; grep -v \"^#\" \"$f\"' wt merge";