@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,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trunk new <name> [dir]`: a project from nothing — a bare-layout repository,
|
|
3
|
+
* a first commit carrying `.config/wt.toml`, and optionally the GitHub
|
|
4
|
+
* repository behind it.
|
|
5
|
+
*
|
|
6
|
+
* The difference from clone and init is that there is no history to work from:
|
|
7
|
+
* the first worktree has to be created before any commit exists, and without a
|
|
8
|
+
* remote the generated config cannot use `remote_repo` at all.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdir, readdir, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { dirname, join, resolve as resolvePath } from 'node:path';
|
|
13
|
+
import { compose } from '../core/generate/index.js';
|
|
14
|
+
import { activeAccount, createRepository, deleteRepositoryCommand, listOwners, sshRemoteUrl, } from '../core/gh.js';
|
|
15
|
+
import { addWorktree, commitPath, configureOriginFetch, runGit, setHeadBranch, } from '../core/git.js';
|
|
16
|
+
import { confirmStep, createContext, errorMessage, finish, folderState, interrupted, outputOf, } from '../core/pipeline.js';
|
|
17
|
+
import { loadSshAliases, parseRemote } from '../core/repo.js';
|
|
18
|
+
import { setupFlagsFromCli } from '../core/resolve.js';
|
|
19
|
+
import { badUsage, exitCodes } from '../core/result.js';
|
|
20
|
+
import { validateGeneratedConfig } from '../core/validate.js';
|
|
21
|
+
import { trunkVersion } from '../core/version.js';
|
|
22
|
+
/** GitHub's rule for a repository name, checked before anything is created. */
|
|
23
|
+
const validName = /^[\w.-]+$/;
|
|
24
|
+
const gitignore = `node_modules/
|
|
25
|
+
.env
|
|
26
|
+
.env.*
|
|
27
|
+
dist/
|
|
28
|
+
build/
|
|
29
|
+
.next/
|
|
30
|
+
coverage/
|
|
31
|
+
`;
|
|
32
|
+
export async function runNew(arguments_, flags, tools, dependencies = {}) {
|
|
33
|
+
const [name, directory, ...extra] = arguments_;
|
|
34
|
+
if (!name) {
|
|
35
|
+
return badUsage('trunk new needs a project name');
|
|
36
|
+
}
|
|
37
|
+
if (extra.length > 0) {
|
|
38
|
+
return badUsage(`unexpected argument: ${extra[0]}`);
|
|
39
|
+
}
|
|
40
|
+
if (!validName.test(name)) {
|
|
41
|
+
return badUsage(`${name} is not a valid repository name; use letters, digits, dot, dash or underscore`);
|
|
42
|
+
}
|
|
43
|
+
const context = createContext(flags, tools, dependencies);
|
|
44
|
+
const projectDirectory = resolvePath(context.cwd, directory ?? name);
|
|
45
|
+
const emptiness = await folderState(projectDirectory);
|
|
46
|
+
if (emptiness === 'occupied') {
|
|
47
|
+
return badUsage(`${projectDirectory} already exists and is not empty; choose another folder`);
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return await create(context, { name, projectDirectory, emptiness });
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
return interrupted(context, projectDirectory, errorMessage(error));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function create(context, project) {
|
|
57
|
+
const { name, projectDirectory, emptiness } = project;
|
|
58
|
+
const gitDirectory = join(projectDirectory, '.git');
|
|
59
|
+
const branch = 'main';
|
|
60
|
+
const remote = await decideRemote(context, name, projectDirectory);
|
|
61
|
+
if (remote.kind === 'aborted') {
|
|
62
|
+
return interrupted(context, projectDirectory, 'aborted');
|
|
63
|
+
}
|
|
64
|
+
if (emptiness === 'missing') {
|
|
65
|
+
context.journal.record({ kind: 'folder', path: projectDirectory });
|
|
66
|
+
}
|
|
67
|
+
context.journal.record({ kind: 'bare-repo', path: gitDirectory });
|
|
68
|
+
await mkdir(projectDirectory, { recursive: true });
|
|
69
|
+
const initialised = await runGit(['init', '--bare', gitDirectory], context.git);
|
|
70
|
+
if (initialised.code !== 0) {
|
|
71
|
+
return interrupted(context, projectDirectory, 'git init --bare failed');
|
|
72
|
+
}
|
|
73
|
+
await setHeadBranch(gitDirectory, branch, context.git);
|
|
74
|
+
if (remote.kind === 'remote') {
|
|
75
|
+
const failure = await setUpRemote(context, remote, name, gitDirectory);
|
|
76
|
+
if (failure) {
|
|
77
|
+
return interrupted(context, projectDirectory, failure);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// A bare repository has no commits, so the first worktree starts an orphan
|
|
81
|
+
// branch rather than checking one out.
|
|
82
|
+
const worktree = join(projectDirectory, branch);
|
|
83
|
+
context.journal.record({ kind: 'worktree', path: worktree, branch });
|
|
84
|
+
const added = await addOrphanWorktree(context, gitDirectory, worktree, branch);
|
|
85
|
+
if (!added) {
|
|
86
|
+
return interrupted(context, projectDirectory, `could not create the ${branch} worktree`);
|
|
87
|
+
}
|
|
88
|
+
// No lockfile exists yet, so nothing can be detected; the form asks instead.
|
|
89
|
+
const collected = await context.collect({
|
|
90
|
+
folder: worktree,
|
|
91
|
+
resolveOptions: {
|
|
92
|
+
fixed: {
|
|
93
|
+
trunkVersion: trunkVersion(),
|
|
94
|
+
generatedOn: today(context.now()),
|
|
95
|
+
repoName: name,
|
|
96
|
+
hostLabel: name.toLowerCase(),
|
|
97
|
+
},
|
|
98
|
+
flags: setupFlagsFromCli(context.flags),
|
|
99
|
+
tools: context.tools,
|
|
100
|
+
},
|
|
101
|
+
invocation: context.invocation,
|
|
102
|
+
yes: context.flags.yes ?? false,
|
|
103
|
+
interactive: context.interactive,
|
|
104
|
+
});
|
|
105
|
+
if (collected.kind === 'outcome') {
|
|
106
|
+
return collected.outcome.code === exitCodes.userAborted
|
|
107
|
+
? interrupted(context, projectDirectory, 'aborted')
|
|
108
|
+
: collected.outcome;
|
|
109
|
+
}
|
|
110
|
+
const settings = {
|
|
111
|
+
...collected.settings,
|
|
112
|
+
noRemote: remote.kind !== 'remote',
|
|
113
|
+
};
|
|
114
|
+
await writeProjectFiles(worktree, settings);
|
|
115
|
+
context.report('success', 'wrote .config/wt.toml and .gitignore');
|
|
116
|
+
try {
|
|
117
|
+
const validation = await validateGeneratedConfig(worktree, settings, {
|
|
118
|
+
wtPath: context.wtPath,
|
|
119
|
+
env: context.env,
|
|
120
|
+
});
|
|
121
|
+
for (const [label, value] of [
|
|
122
|
+
['session', validation.preview.session],
|
|
123
|
+
['port', validation.preview.port],
|
|
124
|
+
['url', validation.preview.url],
|
|
125
|
+
]) {
|
|
126
|
+
if (value !== undefined) {
|
|
127
|
+
context.report('info', `${label} ${value}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
return interrupted(context, projectDirectory, errorMessage(error));
|
|
133
|
+
}
|
|
134
|
+
// The whole repository is new, so there is no setup branch to review: the
|
|
135
|
+
// first commit is the setup.
|
|
136
|
+
const committed = await commitAll(context, worktree);
|
|
137
|
+
if (committed) {
|
|
138
|
+
return interrupted(context, projectDirectory, committed);
|
|
139
|
+
}
|
|
140
|
+
context.journal.annotate(worktree, 'first commit');
|
|
141
|
+
context.report('success', 'committed the first commit');
|
|
142
|
+
if (remote.kind === 'remote') {
|
|
143
|
+
const pushed = await context.run(context.git.gitPath ?? 'git', ['-C', worktree, 'push', '-u', 'origin', branch], { env: context.env });
|
|
144
|
+
context.report(pushed.code === 0 ? 'success' : 'warning', pushed.code === 0
|
|
145
|
+
? `pushed ${branch} to ${remote.url}`
|
|
146
|
+
: `push failed: ${pushed.stderr.trim() || pushed.stdout.trim()}`);
|
|
147
|
+
}
|
|
148
|
+
context.report('info', 'nothing to install yet; add dependencies first');
|
|
149
|
+
return finish(context, projectDirectory, worktree, branch);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Whether to create the GitHub repository, and under which account. `--yes`
|
|
153
|
+
* alone only says yes when gh can actually answer for an account, so a scripted
|
|
154
|
+
* run on a machine without gh quietly stays local instead of failing.
|
|
155
|
+
*/
|
|
156
|
+
async function decideRemote(context, name, projectDirectory) {
|
|
157
|
+
const ghPath = context.tools.gh.path;
|
|
158
|
+
const wanted = context.flags.remote;
|
|
159
|
+
if (wanted === false || !ghPath) {
|
|
160
|
+
if (wanted === true && !ghPath) {
|
|
161
|
+
context.report('warning', 'gh is not installed; creating a local repo');
|
|
162
|
+
}
|
|
163
|
+
return { kind: 'none' };
|
|
164
|
+
}
|
|
165
|
+
const account = await activeAccount({
|
|
166
|
+
ghPath,
|
|
167
|
+
run: context.run,
|
|
168
|
+
env: context.env,
|
|
169
|
+
});
|
|
170
|
+
if (!account) {
|
|
171
|
+
if (wanted === true) {
|
|
172
|
+
context.report('warning', 'gh is not signed in; run `gh auth login`, or `gh auth switch` to pick an account');
|
|
173
|
+
}
|
|
174
|
+
return { kind: 'none' };
|
|
175
|
+
}
|
|
176
|
+
if (wanted !== true) {
|
|
177
|
+
const answer = await confirmStep(context, `create the GitHub repository as ${account}?`);
|
|
178
|
+
if (answer === undefined) {
|
|
179
|
+
return { kind: 'aborted' };
|
|
180
|
+
}
|
|
181
|
+
if (!answer) {
|
|
182
|
+
return { kind: 'none' };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const owner = await chooseOwner(context, account);
|
|
186
|
+
if (!owner) {
|
|
187
|
+
return { kind: 'aborted' };
|
|
188
|
+
}
|
|
189
|
+
const aliases = await loadSshAliases(join(context.env?.['HOME'] ?? context.env?.['USERPROFILE'] ?? homedir(), '.ssh', 'config'));
|
|
190
|
+
const url = sshRemoteUrl(owner, name, {
|
|
191
|
+
realHost: 'github.com',
|
|
192
|
+
aliases,
|
|
193
|
+
siblingHosts: await siblingHosts(context, projectDirectory),
|
|
194
|
+
});
|
|
195
|
+
context.report('info', `remote will be ${url}`);
|
|
196
|
+
return {
|
|
197
|
+
kind: 'remote',
|
|
198
|
+
owner,
|
|
199
|
+
url,
|
|
200
|
+
visibility: context.flags.public ? 'public' : 'private',
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
async function chooseOwner(context, account) {
|
|
204
|
+
const flagged = context.flags.owner;
|
|
205
|
+
if (flagged) {
|
|
206
|
+
return flagged;
|
|
207
|
+
}
|
|
208
|
+
if (!context.interactive || (context.flags.yes ?? false)) {
|
|
209
|
+
return account;
|
|
210
|
+
}
|
|
211
|
+
const owners = await listOwners({
|
|
212
|
+
ghPath: context.tools.gh.path,
|
|
213
|
+
run: context.run,
|
|
214
|
+
env: context.env,
|
|
215
|
+
});
|
|
216
|
+
if (owners.length < 2) {
|
|
217
|
+
return account;
|
|
218
|
+
}
|
|
219
|
+
const answer = await context.prompts?.choose('which account should own the repository?', owners.map(owner => owner.login));
|
|
220
|
+
return answer ?? account;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The ssh hosts sibling projects already use. A folder full of projects reached
|
|
224
|
+
* through one alias is the best evidence of which account this one belongs to.
|
|
225
|
+
*/
|
|
226
|
+
async function siblingHosts(context, projectDirectory) {
|
|
227
|
+
const parent = dirname(projectDirectory);
|
|
228
|
+
let entries;
|
|
229
|
+
try {
|
|
230
|
+
entries = await readdir(parent, { withFileTypes: true });
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return Object.freeze([]);
|
|
234
|
+
}
|
|
235
|
+
const hosts = await Promise.all(entries
|
|
236
|
+
.filter(entry => entry.isDirectory())
|
|
237
|
+
.map(async (entry) => {
|
|
238
|
+
const sibling = join(parent, entry.name);
|
|
239
|
+
if (sibling === projectDirectory) {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
const listed = await context.run(context.git.gitPath ?? 'git', ['-C', sibling, 'config', '--get', 'remote.origin.url'], { env: context.env });
|
|
243
|
+
if (listed.code !== 0 || !listed.stdout.trim()) {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
const remote = parseRemote(listed.stdout.trim(), {}, sibling);
|
|
248
|
+
return remote.kind === 'hosted' ? remote.host : undefined;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
}));
|
|
254
|
+
return Object.freeze([
|
|
255
|
+
...new Set(hosts.filter((host) => host !== undefined)),
|
|
256
|
+
]);
|
|
257
|
+
}
|
|
258
|
+
async function createRemote(context, remote, name) {
|
|
259
|
+
const result = await createRepository(remote.owner, name, {
|
|
260
|
+
ghPath: context.tools.gh.path,
|
|
261
|
+
run: context.run,
|
|
262
|
+
env: context.env,
|
|
263
|
+
visibility: remote.visibility,
|
|
264
|
+
});
|
|
265
|
+
if (result.code === 0) {
|
|
266
|
+
context.journal.record({
|
|
267
|
+
kind: 'github-repo',
|
|
268
|
+
path: `${remote.owner}/${name}`,
|
|
269
|
+
note: 'trunk will not delete it',
|
|
270
|
+
});
|
|
271
|
+
context.report('success', `created ${remote.owner}/${name} on GitHub`);
|
|
272
|
+
context.report('info', `trunk will not delete it; to undo: ${deleteRepositoryCommand(remote.owner, name)}`);
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
return `gh repo create failed: ${result.stderr.trim() || result.stdout.trim()}. Check the active account or run \`gh auth switch\``;
|
|
276
|
+
}
|
|
277
|
+
async function setUpRemote(context, remote, name, gitDirectory) {
|
|
278
|
+
const created = await createRemote(context, remote, name);
|
|
279
|
+
if (created) {
|
|
280
|
+
return created;
|
|
281
|
+
}
|
|
282
|
+
const origin = await runGit(['--git-dir', gitDirectory, 'remote', 'add', 'origin', remote.url], context.git);
|
|
283
|
+
if (origin.code !== 0) {
|
|
284
|
+
return `could not add origin: ${outputOf(origin)}`;
|
|
285
|
+
}
|
|
286
|
+
const configured = await configureOriginFetch(gitDirectory, context.git);
|
|
287
|
+
return configured.code === 0
|
|
288
|
+
? undefined
|
|
289
|
+
: `could not configure origin: ${outputOf(configured)}`;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* `--orphan` starts a branch with no history, which is what an empty bare
|
|
293
|
+
* repository needs. It arrived in git 2.42, so older versions get an empty root
|
|
294
|
+
* commit instead rather than a failure.
|
|
295
|
+
*/
|
|
296
|
+
async function addOrphanWorktree(context, gitDirectory, worktree, branch) {
|
|
297
|
+
const orphan = await runGit([
|
|
298
|
+
'--git-dir',
|
|
299
|
+
gitDirectory,
|
|
300
|
+
'worktree',
|
|
301
|
+
'add',
|
|
302
|
+
'--orphan',
|
|
303
|
+
'-b',
|
|
304
|
+
branch,
|
|
305
|
+
worktree,
|
|
306
|
+
], context.git);
|
|
307
|
+
if (orphan.code === 0) {
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
const tree = await runGit(['--git-dir', gitDirectory, 'hash-object', '-t', 'tree', '/dev/null'], context.git);
|
|
311
|
+
if (tree.code !== 0) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
const commit = await runGit([
|
|
315
|
+
'--git-dir',
|
|
316
|
+
gitDirectory,
|
|
317
|
+
'commit-tree',
|
|
318
|
+
tree.stdout.trim(),
|
|
319
|
+
'-m',
|
|
320
|
+
'Initial commit',
|
|
321
|
+
], context.git);
|
|
322
|
+
if (commit.code !== 0) {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
const pointed = await runGit([
|
|
326
|
+
'--git-dir',
|
|
327
|
+
gitDirectory,
|
|
328
|
+
'update-ref',
|
|
329
|
+
`refs/heads/${branch}`,
|
|
330
|
+
commit.stdout.trim(),
|
|
331
|
+
], context.git);
|
|
332
|
+
if (pointed.code !== 0) {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
const added = await addWorktree(gitDirectory, worktree, branch, context.git);
|
|
336
|
+
return added.code === 0;
|
|
337
|
+
}
|
|
338
|
+
async function writeProjectFiles(worktree, settings) {
|
|
339
|
+
await mkdir(join(worktree, '.config'), { recursive: true });
|
|
340
|
+
await Promise.all([
|
|
341
|
+
writeFile(join(worktree, '.config', 'wt.toml'), compose(settings)),
|
|
342
|
+
writeFile(join(worktree, '.gitignore'), gitignore),
|
|
343
|
+
]);
|
|
344
|
+
}
|
|
345
|
+
async function commitAll(context, worktree) {
|
|
346
|
+
const staged = await runGit(['-C', worktree, 'add', '--', '.config/wt.toml', '.gitignore'], context.git);
|
|
347
|
+
if (staged.code !== 0) {
|
|
348
|
+
return `could not stage the first commit: ${staged.stderr.trim()}`;
|
|
349
|
+
}
|
|
350
|
+
const committed = await commitPath(worktree, '.', 'Initial commit', context.git);
|
|
351
|
+
return committed.code === 0
|
|
352
|
+
? undefined
|
|
353
|
+
: `commit failed: ${committed.stderr.trim() || committed.stdout.trim()}`;
|
|
354
|
+
}
|
|
355
|
+
function today(date) {
|
|
356
|
+
return date.toISOString().slice(0, 10);
|
|
357
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SetupValues } from './resolve.js';
|
|
2
|
+
export type AdoptionNote = Readonly<{
|
|
3
|
+
field?: keyof SetupValues;
|
|
4
|
+
message: string;
|
|
5
|
+
}>;
|
|
6
|
+
export type Adoption = Readonly<{
|
|
7
|
+
values: Partial<SetupValues>;
|
|
8
|
+
/** `[step.copy-ignored] exclude`, carried over rather than regenerated. */
|
|
9
|
+
copyIgnoredExclude?: readonly string[];
|
|
10
|
+
/** What could not be read, for the report before the diff. */
|
|
11
|
+
notes: readonly AdoptionNote[];
|
|
12
|
+
}>;
|
|
13
|
+
/** Values recovered from an existing config, plus what could not be recovered. */
|
|
14
|
+
export declare function adoptConfig(source: string): Adoption;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads what it can out of a `wt.toml` that already exists, so overwriting a
|
|
3
|
+
* configured repository starts from that repository's own choices instead of
|
|
4
|
+
* from trunk's defaults.
|
|
5
|
+
*
|
|
6
|
+
* Adoption is best-effort by design: a file trunk cannot parse, or a value it
|
|
7
|
+
* does not recognise, is simply not adopted. Nothing here ever fails a run —
|
|
8
|
+
* the caller falls back to detection and says so.
|
|
9
|
+
*/
|
|
10
|
+
import * as TOML from '@iarna/toml';
|
|
11
|
+
import { isAgentId, maximumAgents } from './agents.js';
|
|
12
|
+
const packageManagers = ['bun', 'pnpm', 'npm'];
|
|
13
|
+
/** Values recovered from an existing config, plus what could not be recovered. */
|
|
14
|
+
export function adoptConfig(source) {
|
|
15
|
+
const notes = [];
|
|
16
|
+
let document;
|
|
17
|
+
try {
|
|
18
|
+
document = TOML.parse(source);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
return Object.freeze({
|
|
22
|
+
values: Object.freeze({}),
|
|
23
|
+
notes: Object.freeze([
|
|
24
|
+
Object.freeze({
|
|
25
|
+
message: `the existing config could not be parsed, so nothing was adopted: ${error instanceof Error ? error.message : String(error)}`,
|
|
26
|
+
}),
|
|
27
|
+
]),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const hooks = collectCommands(document);
|
|
31
|
+
const values = {};
|
|
32
|
+
const tmuxBody = hooks.get('pre-start:tmux');
|
|
33
|
+
values.tmux = tmuxBody !== undefined;
|
|
34
|
+
if (tmuxBody) {
|
|
35
|
+
const prefix = readPrefix(tmuxBody);
|
|
36
|
+
if (prefix) {
|
|
37
|
+
values.prefix = prefix;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
notes.push({ field: 'prefix', message: 'could not read the tmux prefix' });
|
|
41
|
+
}
|
|
42
|
+
values.agents = readAgents(tmuxBody, notes);
|
|
43
|
+
}
|
|
44
|
+
const install = hooks.get('post-start:install');
|
|
45
|
+
const packageManager = install ? readPackageManager(install) : undefined;
|
|
46
|
+
if (packageManager) {
|
|
47
|
+
values.pm = packageManager;
|
|
48
|
+
}
|
|
49
|
+
else if (install) {
|
|
50
|
+
notes.push({
|
|
51
|
+
field: 'pm',
|
|
52
|
+
message: `could not tell which package manager ${JSON.stringify(install)} uses`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
values.copyIgnored = hooks.has('post-start:copy');
|
|
56
|
+
values.server = hooks.has('post-start:server');
|
|
57
|
+
values.caddy = hooks.has('post-start:proxy');
|
|
58
|
+
values.mcAlias = hooks.has('alias:mc');
|
|
59
|
+
const excludes = readExcludes(document);
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
values: Object.freeze(values),
|
|
62
|
+
copyIgnoredExclude: excludes,
|
|
63
|
+
notes: Object.freeze(notes),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** `P=<prefix>` in the tmux hook, with or without shell quoting. */
|
|
67
|
+
function readPrefix(body) {
|
|
68
|
+
const match = /^P=(.+)$/m.exec(body);
|
|
69
|
+
if (!match) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return unquote(match[1].trim()) || undefined;
|
|
73
|
+
}
|
|
74
|
+
/** The default list in `for a in ${WT_AGENTS-claude codex}`. */
|
|
75
|
+
function readAgents(body, notes) {
|
|
76
|
+
const match = /\${WT_AGENTS-([^}]*)}/.exec(body);
|
|
77
|
+
if (!match) {
|
|
78
|
+
return Object.freeze([]);
|
|
79
|
+
}
|
|
80
|
+
const found = [];
|
|
81
|
+
for (const name of match[1].trim().split(/\s+/).filter(Boolean)) {
|
|
82
|
+
if (isAgentId(name)) {
|
|
83
|
+
found.push(name);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
notes.push({ field: 'agents', message: `unknown agent ${name}, skipped` });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (found.length > maximumAgents) {
|
|
90
|
+
notes.push({
|
|
91
|
+
field: 'agents',
|
|
92
|
+
message: `the existing config starts ${found.length} agents; keeping the first ${maximumAgents}`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return Object.freeze(found.slice(0, maximumAgents));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The install command names its package manager. Matched on a word boundary so
|
|
99
|
+
* a path such as `./node_modules/.bin/npm` does not turn pnpm into npm.
|
|
100
|
+
*/
|
|
101
|
+
function readPackageManager(command) {
|
|
102
|
+
return packageManagers.find(name => new RegExp(String.raw `(?:^|[\s/])${name}(?:\s|$)`).test(command));
|
|
103
|
+
}
|
|
104
|
+
function readExcludes(document) {
|
|
105
|
+
const { step } = document;
|
|
106
|
+
if (typeof step !== 'object' || step === null) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
const { 'copy-ignored': copyIgnored } = step;
|
|
110
|
+
if (typeof copyIgnored !== 'object' || copyIgnored === null) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
const { exclude } = copyIgnored;
|
|
114
|
+
if (!Array.isArray(exclude)) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
const values = exclude.filter((value) => typeof value === 'string');
|
|
118
|
+
return values.length > 0 ? Object.freeze(values) : undefined;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Every command body in the file, keyed `<table>:<name>`, so the presence of a
|
|
122
|
+
* step and the text of its command are one lookup.
|
|
123
|
+
*/
|
|
124
|
+
function collectCommands(document) {
|
|
125
|
+
const commands = new Map();
|
|
126
|
+
for (const table of ['pre-start', 'post-start', 'pre-remove']) {
|
|
127
|
+
for (const [name, body] of tableEntries(document[table])) {
|
|
128
|
+
commands.set(`${table}:${name}`, body);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const [name, body] of tableEntries(document['aliases'])) {
|
|
132
|
+
commands.set(`alias:${name}`, body);
|
|
133
|
+
}
|
|
134
|
+
return commands;
|
|
135
|
+
}
|
|
136
|
+
function tableEntries(value) {
|
|
137
|
+
const tables = Array.isArray(value) ? value : [value];
|
|
138
|
+
const entries = [];
|
|
139
|
+
for (const table of tables) {
|
|
140
|
+
if (typeof table !== 'object' || table === null) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
for (const [name, body] of Object.entries(table)) {
|
|
144
|
+
if (typeof body === 'string') {
|
|
145
|
+
entries.push([name, body]);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return entries;
|
|
150
|
+
}
|
|
151
|
+
function unquote(value) {
|
|
152
|
+
if ((value.startsWith("'") && value.endsWith("'")) ||
|
|
153
|
+
(value.startsWith('"') && value.endsWith('"'))) {
|
|
154
|
+
return value.slice(1, -1);
|
|
155
|
+
}
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coding agents trunk knows how to start, and the commands that start them.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately a module of its own with no imports: both the tool probe
|
|
5
|
+
* and the wt.toml generator need it, and the generator has no business pulling
|
|
6
|
+
* in the filesystem and process machinery the probe depends on.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Agent id as the user writes it in `--agents` and `WT_AGENTS`, mapped to the
|
|
10
|
+
* command that starts it. Most match; antigravity ships as `agy`. Keep this the
|
|
11
|
+
* only place that knows the difference.
|
|
12
|
+
*/
|
|
13
|
+
export declare const agentCommands: Readonly<{
|
|
14
|
+
claude: "claude";
|
|
15
|
+
codex: "codex";
|
|
16
|
+
opencode: "opencode";
|
|
17
|
+
copilot: "copilot";
|
|
18
|
+
antigravity: "agy";
|
|
19
|
+
pi: "pi";
|
|
20
|
+
}>;
|
|
21
|
+
export type AgentId = keyof typeof agentCommands;
|
|
22
|
+
/** Every id, in the order they are offered and started. */
|
|
23
|
+
export declare const agentIds: readonly AgentId[];
|
|
24
|
+
/**
|
|
25
|
+
* How many agents a worktree starts at once. Beyond this the tmux panes are too
|
|
26
|
+
* small to be useful, so the generated hook stops and says so.
|
|
27
|
+
*/
|
|
28
|
+
export declare const maximumAgents = 4;
|
|
29
|
+
/** Narrows an arbitrary string, for reading `--agents` and config files. */
|
|
30
|
+
export declare function isAgentId(value: string): value is AgentId;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coding agents trunk knows how to start, and the commands that start them.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately a module of its own with no imports: both the tool probe
|
|
5
|
+
* and the wt.toml generator need it, and the generator has no business pulling
|
|
6
|
+
* in the filesystem and process machinery the probe depends on.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Agent id as the user writes it in `--agents` and `WT_AGENTS`, mapped to the
|
|
10
|
+
* command that starts it. Most match; antigravity ships as `agy`. Keep this the
|
|
11
|
+
* only place that knows the difference.
|
|
12
|
+
*/
|
|
13
|
+
export const agentCommands = Object.freeze({
|
|
14
|
+
claude: 'claude',
|
|
15
|
+
codex: 'codex',
|
|
16
|
+
opencode: 'opencode',
|
|
17
|
+
copilot: 'copilot',
|
|
18
|
+
antigravity: 'agy',
|
|
19
|
+
pi: 'pi',
|
|
20
|
+
});
|
|
21
|
+
/** Every id, in the order they are offered and started. */
|
|
22
|
+
export const agentIds = Object.freeze(Object.keys(agentCommands));
|
|
23
|
+
/**
|
|
24
|
+
* How many agents a worktree starts at once. Beyond this the tmux panes are too
|
|
25
|
+
* small to be useful, so the generated hook stops and says so.
|
|
26
|
+
*/
|
|
27
|
+
export const maximumAgents = 4;
|
|
28
|
+
/** Narrows an arbitrary string, for reading `--agents` and config files. */
|
|
29
|
+
export function isAgentId(value) {
|
|
30
|
+
return Object.hasOwn(agentCommands, value);
|
|
31
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type TypedFlags } from 'meow';
|
|
2
|
+
/** Printed for `trunk` with no command and for `--help`. */
|
|
3
|
+
export declare const helpText = "\n\tUsage\n\t $ trunk clone <url> [dir] set up a bare-layout project from a remote\n\t $ trunk init [dir] set up an existing bare-layout project\n\t $ trunk new <name> create a new project (optionally on GitHub)\n\n\tOptions\n\t --yes accept detected defaults, no form (needed without a TTY)\n\t --prefix <name> tmux session prefix\n\t --pm <npm|pnpm|bun> package manager\n\t --agents <a,b> up to 4 of claude,codex,opencode,copilot,antigravity,pi\n\t --server/--no-server dev server step\n\t --caddy/--no-caddy Caddy route step\n\t --tmux/--no-tmux tmux session hooks\n\t --copy/--no-copy wt step copy-ignored\n\t --mc/--no-mc `wt mc` alias\n\t --direct commit on the current branch instead of chore/trunk-setup\n\n\tOptions for `trunk new`\n\t --remote/--no-remote create the GitHub repository too\n\t --owner <name> account or organisation that owns it\n\t --public create it public instead of private\n";
|
|
4
|
+
/**
|
|
5
|
+
* Every flag is declared here even when a later phase is what reads it, so that
|
|
6
|
+
* an unknown flag is always an error rather than silently ignored.
|
|
7
|
+
*/
|
|
8
|
+
export declare const flagDefinitions: {
|
|
9
|
+
readonly yes: {
|
|
10
|
+
readonly type: "boolean";
|
|
11
|
+
};
|
|
12
|
+
readonly prefix: {
|
|
13
|
+
readonly type: "string";
|
|
14
|
+
};
|
|
15
|
+
readonly pm: {
|
|
16
|
+
readonly type: "string";
|
|
17
|
+
};
|
|
18
|
+
readonly agents: {
|
|
19
|
+
readonly type: "string";
|
|
20
|
+
};
|
|
21
|
+
readonly server: {
|
|
22
|
+
readonly type: "boolean";
|
|
23
|
+
};
|
|
24
|
+
readonly caddy: {
|
|
25
|
+
readonly type: "boolean";
|
|
26
|
+
};
|
|
27
|
+
readonly tmux: {
|
|
28
|
+
readonly type: "boolean";
|
|
29
|
+
};
|
|
30
|
+
readonly copyIgnored: {
|
|
31
|
+
readonly type: "boolean";
|
|
32
|
+
readonly alias: "copy";
|
|
33
|
+
};
|
|
34
|
+
readonly mc: {
|
|
35
|
+
readonly type: "boolean";
|
|
36
|
+
};
|
|
37
|
+
readonly direct: {
|
|
38
|
+
readonly type: "boolean";
|
|
39
|
+
};
|
|
40
|
+
readonly remote: {
|
|
41
|
+
readonly type: "boolean";
|
|
42
|
+
};
|
|
43
|
+
readonly owner: {
|
|
44
|
+
readonly type: "string";
|
|
45
|
+
};
|
|
46
|
+
readonly public: {
|
|
47
|
+
readonly type: "boolean";
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
export type CliFlags = TypedFlags<typeof flagDefinitions>;
|
|
51
|
+
export declare function parseArguments(argv?: readonly string[]): import("meow").Result<{
|
|
52
|
+
readonly yes: {
|
|
53
|
+
readonly type: "boolean";
|
|
54
|
+
};
|
|
55
|
+
readonly prefix: {
|
|
56
|
+
readonly type: "string";
|
|
57
|
+
};
|
|
58
|
+
readonly pm: {
|
|
59
|
+
readonly type: "string";
|
|
60
|
+
};
|
|
61
|
+
readonly agents: {
|
|
62
|
+
readonly type: "string";
|
|
63
|
+
};
|
|
64
|
+
readonly server: {
|
|
65
|
+
readonly type: "boolean";
|
|
66
|
+
};
|
|
67
|
+
readonly caddy: {
|
|
68
|
+
readonly type: "boolean";
|
|
69
|
+
};
|
|
70
|
+
readonly tmux: {
|
|
71
|
+
readonly type: "boolean";
|
|
72
|
+
};
|
|
73
|
+
readonly copyIgnored: {
|
|
74
|
+
readonly type: "boolean";
|
|
75
|
+
readonly alias: "copy";
|
|
76
|
+
};
|
|
77
|
+
readonly mc: {
|
|
78
|
+
readonly type: "boolean";
|
|
79
|
+
};
|
|
80
|
+
readonly direct: {
|
|
81
|
+
readonly type: "boolean";
|
|
82
|
+
};
|
|
83
|
+
readonly remote: {
|
|
84
|
+
readonly type: "boolean";
|
|
85
|
+
};
|
|
86
|
+
readonly owner: {
|
|
87
|
+
readonly type: "string";
|
|
88
|
+
};
|
|
89
|
+
readonly public: {
|
|
90
|
+
readonly type: "boolean";
|
|
91
|
+
};
|
|
92
|
+
}>;
|