@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.
- package/LICENSE +21 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +69 -0
- package/dist/commands/clone.d.ts +6 -0
- package/dist/commands/clone.js +114 -0
- package/dist/commands/init.d.ts +6 -0
- package/dist/commands/init.js +235 -0
- package/dist/commands/new.d.ts +6 -0
- package/dist/commands/new.js +357 -0
- package/dist/core/adopt.d.ts +14 -0
- package/dist/core/adopt.js +157 -0
- package/dist/core/agents.d.ts +30 -0
- package/dist/core/agents.js +31 -0
- package/dist/core/arguments.d.ts +92 -0
- package/dist/core/arguments.js +93 -0
- package/dist/core/detect.d.ts +28 -0
- package/dist/core/detect.js +169 -0
- package/dist/core/diff.d.ts +37 -0
- package/dist/core/diff.js +140 -0
- package/dist/core/env.d.ts +63 -0
- package/dist/core/env.js +140 -0
- package/dist/core/generate/aliases.d.ts +7 -0
- package/dist/core/generate/aliases.js +45 -0
- package/dist/core/generate/header.d.ts +2 -0
- package/dist/core/generate/header.js +81 -0
- package/dist/core/generate/index.d.ts +8 -0
- package/dist/core/generate/index.js +74 -0
- package/dist/core/generate/proxy.d.ts +8 -0
- package/dist/core/generate/proxy.js +56 -0
- package/dist/core/generate/steps.d.ts +7 -0
- package/dist/core/generate/steps.js +88 -0
- package/dist/core/generate/tmux.d.ts +4 -0
- package/dist/core/generate/tmux.js +98 -0
- package/dist/core/generate/toml.d.ts +16 -0
- package/dist/core/generate/toml.js +68 -0
- package/dist/core/gh.d.ts +60 -0
- package/dist/core/gh.js +101 -0
- package/dist/core/git.d.ts +79 -0
- package/dist/core/git.js +211 -0
- package/dist/core/journal.d.ts +54 -0
- package/dist/core/journal.js +147 -0
- package/dist/core/log.d.ts +8 -0
- package/dist/core/log.js +38 -0
- package/dist/core/pipeline.d.ts +119 -0
- package/dist/core/pipeline.js +473 -0
- package/dist/core/platform.d.ts +10 -0
- package/dist/core/platform.js +26 -0
- package/dist/core/prefix.d.ts +25 -0
- package/dist/core/prefix.js +59 -0
- package/dist/core/process.d.ts +27 -0
- package/dist/core/process.js +43 -0
- package/dist/core/repo.d.ts +79 -0
- package/dist/core/repo.js +294 -0
- package/dist/core/resolve.d.ts +127 -0
- package/dist/core/resolve.js +488 -0
- package/dist/core/result.d.ts +27 -0
- package/dist/core/result.js +32 -0
- package/dist/core/settings.d.ts +51 -0
- package/dist/core/settings.js +83 -0
- package/dist/core/tmuxRename.d.ts +36 -0
- package/dist/core/tmuxRename.js +79 -0
- package/dist/core/validate.d.ts +22 -0
- package/dist/core/validate.js +111 -0
- package/dist/core/version.d.ts +2 -0
- package/dist/core/version.js +35 -0
- package/dist/core/words.d.ts +16 -0
- package/dist/core/words.js +198 -0
- package/dist/core/wt.d.ts +41 -0
- package/dist/core/wt.js +59 -0
- package/dist/ui/SetupForm.d.ts +55 -0
- package/dist/ui/SetupForm.js +354 -0
- package/dist/ui/Summary.d.ts +15 -0
- package/dist/ui/Summary.js +74 -0
- package/dist/ui/fields/MultiSelect.d.ts +17 -0
- package/dist/ui/fields/MultiSelect.js +66 -0
- package/dist/ui/fields/Select.d.ts +17 -0
- package/dist/ui/fields/Select.js +37 -0
- package/dist/ui/fields/TextInput.d.ts +13 -0
- package/dist/ui/fields/TextInput.js +50 -0
- package/package.json +76 -0
- 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;
|
package/dist/core/env.js
ADDED
|
@@ -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";
|