@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,45 @@
|
|
|
1
|
+
/** Project-level convenience commands assembled from the enabled hooks. */
|
|
2
|
+
import { usesCaddy } from '../settings.js';
|
|
3
|
+
import { inlineCommand, multilineCommand, shellQuote } from './toml.js';
|
|
4
|
+
const branchTemplate = '{{ branch | sanitize }}';
|
|
5
|
+
export function generateAliases(settings) {
|
|
6
|
+
const aliases = [];
|
|
7
|
+
const up = upCommand(settings);
|
|
8
|
+
if (up) {
|
|
9
|
+
aliases.push(`up = ${inlineCommand(up)}`);
|
|
10
|
+
}
|
|
11
|
+
if (usesCaddy(settings)) {
|
|
12
|
+
aliases.push(`url = ${multilineCommand(urlAliasBody(settings))}`);
|
|
13
|
+
}
|
|
14
|
+
if (settings.mcAlias) {
|
|
15
|
+
aliases.push(`mc = ${multilineCommand(mcAliasBody)}`);
|
|
16
|
+
}
|
|
17
|
+
return aliases.length > 0 ? `[aliases]\n${aliases.join('\n')}` : undefined;
|
|
18
|
+
}
|
|
19
|
+
export function upCommand(settings) {
|
|
20
|
+
const commands = [];
|
|
21
|
+
if (settings.tmux || usesCaddy(settings)) {
|
|
22
|
+
commands.push('wt hook pre-start');
|
|
23
|
+
}
|
|
24
|
+
if (settings.server) {
|
|
25
|
+
commands.push(usesCaddy(settings)
|
|
26
|
+
? 'wt hook post-start server proxy'
|
|
27
|
+
: 'wt hook post-start server');
|
|
28
|
+
}
|
|
29
|
+
return commands.length > 0 ? commands.join(' && ') : undefined;
|
|
30
|
+
}
|
|
31
|
+
export function urlAliasBody(settings) {
|
|
32
|
+
const suffix = shellQuote(`.${settings.hostLabel}.localhost:8080`);
|
|
33
|
+
return `{% if args %}BRANCH={{ args[0] | sanitize }}{% else %}BRANCH=${branchTemplate}{% endif %}
|
|
34
|
+
HOST_SUFFIX=${suffix}
|
|
35
|
+
URL="http://\${BRANCH}\${HOST_SUFFIX}"
|
|
36
|
+
if command -v open >/dev/null 2>&1; then
|
|
37
|
+
open "$URL"
|
|
38
|
+
elif command -v xdg-open >/dev/null 2>&1; then
|
|
39
|
+
xdg-open "$URL"
|
|
40
|
+
else
|
|
41
|
+
printf '%s\\n' "$URL"
|
|
42
|
+
fi`;
|
|
43
|
+
}
|
|
44
|
+
/** Keep this quoting aligned with Worktrunk's documented editor alias. */
|
|
45
|
+
export 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`;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Produces the human-maintained contract at the top of every generated file. */
|
|
2
|
+
import { agentCommands, maximumAgents } from '../agents.js';
|
|
3
|
+
import { usesCaddy } from '../settings.js';
|
|
4
|
+
import { commentText } from './toml.js';
|
|
5
|
+
/** Where the description column starts, measured from after the `# ` indent. */
|
|
6
|
+
const descriptionColumn = 23;
|
|
7
|
+
export function generateHeader(settings) {
|
|
8
|
+
const lines = [
|
|
9
|
+
'# Worktree automation shared by everyone on this repo.',
|
|
10
|
+
'#',
|
|
11
|
+
`# Generated by trunk ${commentText(settings.trunkVersion)} on ${settings.generatedOn} — edit it freely; trunk won't touch it again.`,
|
|
12
|
+
'#',
|
|
13
|
+
'# Setup on a new machine: install worktrunk (`wt`) with its shell integration, then run',
|
|
14
|
+
'# `wt config approvals add` here (again whenever this file changes).',
|
|
15
|
+
`# Package manager: ${settings.pm}.`,
|
|
16
|
+
];
|
|
17
|
+
const optionalTools = optionalToolNames(settings);
|
|
18
|
+
if (optionalTools.length > 0) {
|
|
19
|
+
lines.push(`# ${joinList(optionalTools)} ${optionalTools.length === 1 ? 'is' : 'are'} optional; each step skips itself when its tool is missing.`);
|
|
20
|
+
}
|
|
21
|
+
const environment = environmentLines(settings);
|
|
22
|
+
if (environment.length > 0) {
|
|
23
|
+
lines.push('# Each machine can tune the optional steps with env vars in its shell profile:', '#', ...environment, '#');
|
|
24
|
+
}
|
|
25
|
+
if (settings.tmux) {
|
|
26
|
+
lines.push("# P is this repo's tmux session prefix; keep it unique across your repos.");
|
|
27
|
+
}
|
|
28
|
+
if (settings.noRemote) {
|
|
29
|
+
lines.push(`# No git remote yet, so the repo name is written out below. After you add one`, '# (git remote add origin <url>), you can switch these back to', '# {{ remote_repo | lower }}.');
|
|
30
|
+
}
|
|
31
|
+
lines.push('# Hook bodies are POSIX sh. Worktrunk template expressions are intentional; avoid', '# introducing shell forms that collide with them (notably shell length expansion).');
|
|
32
|
+
return lines.join('\n');
|
|
33
|
+
}
|
|
34
|
+
function optionalToolNames(settings) {
|
|
35
|
+
const names = [];
|
|
36
|
+
if (settings.tmux) {
|
|
37
|
+
names.push('tmux');
|
|
38
|
+
}
|
|
39
|
+
if (usesCaddy(settings)) {
|
|
40
|
+
names.push('caddy');
|
|
41
|
+
}
|
|
42
|
+
for (const agent of settings.agents) {
|
|
43
|
+
names.push(agentCommands[agent]);
|
|
44
|
+
}
|
|
45
|
+
return names;
|
|
46
|
+
}
|
|
47
|
+
function environmentLines(settings) {
|
|
48
|
+
const lines = [];
|
|
49
|
+
if (settings.tmux) {
|
|
50
|
+
lines.push(settingLine('WT_TMUX=off', 'no tmux session'));
|
|
51
|
+
if (settings.agents.length > 0) {
|
|
52
|
+
const agents = JSON.stringify(settings.agents.join(' '));
|
|
53
|
+
lines.push(settingLine(`WT_AGENTS=${agents}`, 'agents started in the Agents window'), settingLine('', `(default ${agents}; "" starts none; max ${maximumAgents})`));
|
|
54
|
+
}
|
|
55
|
+
lines.push(settingLine('WT_EDITOR=hx', 'editor in the Editor window (default $EDITOR, then nvim)'));
|
|
56
|
+
}
|
|
57
|
+
if (usesCaddy(settings)) {
|
|
58
|
+
lines.push(settingLine('WT_PROXY=off', 'no Caddy route'));
|
|
59
|
+
}
|
|
60
|
+
return lines;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* One `VAR=value description` line. A long value pushes its own description
|
|
64
|
+
* right rather than running into it, so the block stays readable for any agent
|
|
65
|
+
* list.
|
|
66
|
+
*/
|
|
67
|
+
function settingLine(setting, description) {
|
|
68
|
+
const padded = setting.length >= descriptionColumn
|
|
69
|
+
? `${setting} `
|
|
70
|
+
: setting.padEnd(descriptionColumn);
|
|
71
|
+
return `# ${padded}${description}`;
|
|
72
|
+
}
|
|
73
|
+
function joinList(values) {
|
|
74
|
+
if (values.length < 2) {
|
|
75
|
+
return values[0] ?? '';
|
|
76
|
+
}
|
|
77
|
+
if (values.length === 2) {
|
|
78
|
+
return values.join(' and ');
|
|
79
|
+
}
|
|
80
|
+
return `${values.slice(0, -1).join(', ')}, and ${values.at(-1)}`;
|
|
81
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Deterministically composes a complete project `.config/wt.toml`. */
|
|
2
|
+
import { type Settings } from '../settings.js';
|
|
3
|
+
export type GeneratedHook = Readonly<{
|
|
4
|
+
type: 'pre-start' | 'post-start' | 'pre-remove';
|
|
5
|
+
name: string;
|
|
6
|
+
}>;
|
|
7
|
+
export declare function compose(settings: Settings): string;
|
|
8
|
+
export declare function expectedHooks(settings: Settings): readonly GeneratedHook[];
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Deterministically composes a complete project `.config/wt.toml`. */
|
|
2
|
+
import { assertSettings, usesCaddy } from '../settings.js';
|
|
3
|
+
import { generateAliases } from './aliases.js';
|
|
4
|
+
import { generateHeader } from './header.js';
|
|
5
|
+
import { generateList, generateUrlPreStart, proxyRemoveBody, proxyStartBody, } from './proxy.js';
|
|
6
|
+
import { generatePostStart } from './steps.js';
|
|
7
|
+
import { generateTmuxPreStart, tmuxRemoveBody } from './tmux.js';
|
|
8
|
+
import { multilineCommand, tomlString } from './toml.js';
|
|
9
|
+
export function compose(settings) {
|
|
10
|
+
assertSettings(settings);
|
|
11
|
+
const sections = [
|
|
12
|
+
generateHeader(settings),
|
|
13
|
+
generateAliases(settings),
|
|
14
|
+
generateTmuxPreStart(settings),
|
|
15
|
+
generateUrlPreStart(settings),
|
|
16
|
+
generatePostStart(settings, proxyStartBody(settings)),
|
|
17
|
+
generatePreRemove(settings),
|
|
18
|
+
generateCopyIgnoredStep(settings),
|
|
19
|
+
generateList(settings),
|
|
20
|
+
].filter(Boolean);
|
|
21
|
+
return `${sections.join('\n\n')}\n`;
|
|
22
|
+
}
|
|
23
|
+
export function expectedHooks(settings) {
|
|
24
|
+
const hooks = [];
|
|
25
|
+
if (settings.tmux) {
|
|
26
|
+
hooks.push({ type: 'pre-start', name: 'tmux' });
|
|
27
|
+
}
|
|
28
|
+
if (usesCaddy(settings)) {
|
|
29
|
+
hooks.push({ type: 'pre-start', name: 'url' });
|
|
30
|
+
}
|
|
31
|
+
if (settings.copyIgnored) {
|
|
32
|
+
hooks.push({ type: 'post-start', name: 'copy' });
|
|
33
|
+
}
|
|
34
|
+
hooks.push({ type: 'post-start', name: 'install' });
|
|
35
|
+
if (settings.server) {
|
|
36
|
+
hooks.push({ type: 'post-start', name: 'server' });
|
|
37
|
+
}
|
|
38
|
+
if (usesCaddy(settings)) {
|
|
39
|
+
hooks.push({ type: 'post-start', name: 'proxy' });
|
|
40
|
+
}
|
|
41
|
+
if (settings.tmux) {
|
|
42
|
+
hooks.push({ type: 'pre-remove', name: 'tmux' });
|
|
43
|
+
}
|
|
44
|
+
if (usesCaddy(settings)) {
|
|
45
|
+
hooks.push({ type: 'pre-remove', name: 'proxy' });
|
|
46
|
+
}
|
|
47
|
+
return Object.freeze(hooks.map(hook => Object.freeze(hook)));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Paths a repository already excluded from `wt step copy-ignored`. Only ever
|
|
51
|
+
* present when adopting a config that had them, so an empty list emits nothing.
|
|
52
|
+
*/
|
|
53
|
+
function generateCopyIgnoredStep(settings) {
|
|
54
|
+
const excludes = settings.copyIgnoredExclude ?? [];
|
|
55
|
+
if (!settings.copyIgnored || excludes.length === 0) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const values = excludes.map(value => tomlString(value)).join(', ');
|
|
59
|
+
return `[step.copy-ignored]\nexclude = [${values}]`;
|
|
60
|
+
}
|
|
61
|
+
function generatePreRemove(settings) {
|
|
62
|
+
const commands = [];
|
|
63
|
+
const tmux = tmuxRemoveBody(settings);
|
|
64
|
+
if (tmux) {
|
|
65
|
+
commands.push(`tmux = ${multilineCommand(tmux)}`);
|
|
66
|
+
}
|
|
67
|
+
const proxy = proxyRemoveBody(settings);
|
|
68
|
+
if (proxy) {
|
|
69
|
+
commands.push(`proxy = ${multilineCommand(proxy)}`);
|
|
70
|
+
}
|
|
71
|
+
return commands.length > 0
|
|
72
|
+
? `[pre-remove]\n${commands.join('\n')}`
|
|
73
|
+
: undefined;
|
|
74
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Caddy route lifecycle, terminal URL report, and `wt list` URL fragment. */
|
|
2
|
+
import { type Settings } from '../settings.js';
|
|
3
|
+
export declare function proxyStartBody(settings: Settings): string | undefined;
|
|
4
|
+
export declare function proxyRemoveBody(settings: Settings): string | undefined;
|
|
5
|
+
export declare function generateUrlPreStart(settings: Settings): string | undefined;
|
|
6
|
+
export declare function generateList(settings: Settings): string | undefined;
|
|
7
|
+
/** The generated route with a caller-selected branch placeholder. */
|
|
8
|
+
export declare function routeUrl(settings: Settings, branch: string): string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Caddy route lifecycle, terminal URL report, and `wt list` URL fragment. */
|
|
2
|
+
import { usesCaddy } from '../settings.js';
|
|
3
|
+
import { inlineCommand, multilineCommand, portExpression, repoExpression, shellQuote, } from './toml.js';
|
|
4
|
+
const branchTemplate = '{{ branch | sanitize }}';
|
|
5
|
+
export function proxyStartBody(settings) {
|
|
6
|
+
if (!usesCaddy(settings)) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
const remoteRepository = repoExpression(settings);
|
|
10
|
+
return `[ "\${WT_PROXY-on}" = off ] && exit 0
|
|
11
|
+
command -v caddy >/dev/null 2>&1 || { echo "caddy not found; skipping proxy"; exit 0; }
|
|
12
|
+
command -v curl >/dev/null 2>&1 || { echo "curl not found; skipping proxy"; exit 0; }
|
|
13
|
+
ID=wt:${remoteRepository}:${branchTemplate}
|
|
14
|
+
HOST=${branchTemplate}.${remoteRepository}.localhost
|
|
15
|
+
PORT=${portExpression(settings)}
|
|
16
|
+
curl -sf --max-time 0.5 http://localhost:2019/config/ >/dev/null || caddy start
|
|
17
|
+
curl -sf http://localhost:2019/config/apps/http/servers/wt >/dev/null || \\
|
|
18
|
+
curl -sfX PUT http://localhost:2019/config/apps/http/servers/wt \\
|
|
19
|
+
-H 'Content-Type: application/json' \\
|
|
20
|
+
-d '{"listen":[":8080"],"automatic_https":{"disable":true},"routes":[]}'
|
|
21
|
+
curl -sf -X DELETE "http://localhost:2019/id/$ID" >/dev/null || true
|
|
22
|
+
PAYLOAD=$(printf '{"@id":"%s","match":[{"host":["%s"]}],"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"127.0.0.1:%s"}]}]}' "$ID" "$HOST" "$PORT")
|
|
23
|
+
curl -sfX PUT http://localhost:2019/config/apps/http/servers/wt/routes/0 \\
|
|
24
|
+
-H 'Content-Type: application/json' \\
|
|
25
|
+
-d "$PAYLOAD"`;
|
|
26
|
+
}
|
|
27
|
+
export function proxyRemoveBody(settings) {
|
|
28
|
+
if (!usesCaddy(settings)) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
return `command -v curl >/dev/null 2>&1 || exit 0
|
|
32
|
+
ID=wt:${repoExpression(settings)}:${branchTemplate}
|
|
33
|
+
curl -sf -X DELETE "http://localhost:2019/id/$ID" >/dev/null || true`;
|
|
34
|
+
}
|
|
35
|
+
export function generateUrlPreStart(settings) {
|
|
36
|
+
if (!usesCaddy(settings)) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const suffix = shellQuote(`.${settings.hostLabel}.localhost:8080`);
|
|
40
|
+
const body = `BRANCH=${branchTemplate}
|
|
41
|
+
HOST_SUFFIX=${suffix}
|
|
42
|
+
URL="http://\${BRANCH}\${HOST_SUFFIX}"
|
|
43
|
+
printf '↗ Local server: %s\\n' "$URL"`;
|
|
44
|
+
return `[[pre-start]]\nurl = ${multilineCommand(body)}`;
|
|
45
|
+
}
|
|
46
|
+
export function generateList(settings) {
|
|
47
|
+
if (!usesCaddy(settings)) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const url = routeUrl(settings, branchTemplate);
|
|
51
|
+
return `[list]\nurl = ${inlineCommand(url)}`;
|
|
52
|
+
}
|
|
53
|
+
/** The generated route with a caller-selected branch placeholder. */
|
|
54
|
+
export function routeUrl(settings, branch) {
|
|
55
|
+
return `http://${branch}.${settings.hostLabel}.localhost:8080`;
|
|
56
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Dependency-copy, install, and development-server pipeline fragments. */
|
|
2
|
+
import type { Settings } from '../settings.js';
|
|
3
|
+
export declare function generatePostStart(settings: Settings, proxyBody: string | undefined): string;
|
|
4
|
+
export declare function installCommand(settings: Settings): string;
|
|
5
|
+
export declare function serverCommand(settings: Settings): string;
|
|
6
|
+
/** The command inside the tether, also used by the setup summary. */
|
|
7
|
+
export declare function developmentCommand(settings: Settings, port?: string): string;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { commentText, inlineCommand, multilineCommand, portExpression, shellQuote, } from './toml.js';
|
|
2
|
+
export function generatePostStart(settings, proxyBody) {
|
|
3
|
+
const blocks = [];
|
|
4
|
+
if (settings.copyIgnored) {
|
|
5
|
+
blocks.push(`[[post-start]]\ncopy = ${inlineCommand('wt step copy-ignored')}`);
|
|
6
|
+
}
|
|
7
|
+
blocks.push(`[[post-start]]\ninstall = ${inlineCommand(installCommand(settings))}`);
|
|
8
|
+
if (settings.server) {
|
|
9
|
+
const commands = [`server = ${inlineCommand(serverCommand(settings))}`];
|
|
10
|
+
if (proxyBody) {
|
|
11
|
+
commands.push(`proxy = ${multilineCommand(proxyBody)}`);
|
|
12
|
+
}
|
|
13
|
+
blocks.push(`${serverNotes(settings)}\n[[post-start]]\n${commands.join('\n')}`);
|
|
14
|
+
}
|
|
15
|
+
return blocks.join('\n\n');
|
|
16
|
+
}
|
|
17
|
+
export function installCommand(settings) {
|
|
18
|
+
const directory = settings.appDir ? shellQuote(settings.appDir) : undefined;
|
|
19
|
+
switch (settings.pm) {
|
|
20
|
+
case 'npm': {
|
|
21
|
+
return directory
|
|
22
|
+
? `npm --prefix ${directory} install --prefer-offline --no-audit --no-fund`
|
|
23
|
+
: 'npm install --prefer-offline --no-audit --no-fund';
|
|
24
|
+
}
|
|
25
|
+
case 'pnpm': {
|
|
26
|
+
return directory
|
|
27
|
+
? `pnpm --dir ${directory} install --prefer-offline`
|
|
28
|
+
: 'pnpm install --prefer-offline';
|
|
29
|
+
}
|
|
30
|
+
case 'bun': {
|
|
31
|
+
return directory ? `bun install --cwd=${directory}` : 'bun install';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function serverCommand(settings) {
|
|
36
|
+
return `wt step tether -- ${developmentCommand(settings)}`;
|
|
37
|
+
}
|
|
38
|
+
/** The command inside the tether, also used by the setup summary. */
|
|
39
|
+
export function developmentCommand(settings, port = portExpression(settings)) {
|
|
40
|
+
const script = shellQuote(settings.devScript);
|
|
41
|
+
const directory = settings.appDir ? shellQuote(settings.appDir) : undefined;
|
|
42
|
+
let command;
|
|
43
|
+
switch (settings.pm) {
|
|
44
|
+
case 'npm': {
|
|
45
|
+
command = `npm${directory ? ` --prefix ${directory}` : ''} run ${script} -- --port ${port}`;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case 'pnpm': {
|
|
49
|
+
command = `pnpm${directory ? ` --dir ${directory}` : ''} run ${script} --port ${port}`;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
case 'bun': {
|
|
53
|
+
// `--cwd` rather than `cd <dir> &&`: the whole command is one argument
|
|
54
|
+
// list after `tether --`, and a `&&` there would end the tethered
|
|
55
|
+
// command, leaving the server running loose in the wrong directory.
|
|
56
|
+
command = `bun${directory ? ` --cwd=${directory}` : ''} run ${script} --port ${port}`;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return command;
|
|
61
|
+
}
|
|
62
|
+
function serverNotes(settings) {
|
|
63
|
+
const lines = [
|
|
64
|
+
'# Dev server; `wt step tether` stops it when the worktree is removed, however it is',
|
|
65
|
+
'# removed. The port hashes repo + branch, so the same branch in two repos gets two ports.',
|
|
66
|
+
'# Adjust the command if this project needs something else:',
|
|
67
|
+
`# port from the environment (Nest, Express): PORT=<port> ${settings.pm} run start:dev`,
|
|
68
|
+
`# another script: ${settings.pm} run <script> --port <port>`,
|
|
69
|
+
];
|
|
70
|
+
if (settings.appDir) {
|
|
71
|
+
// Each package manager spells "run in this directory" differently.
|
|
72
|
+
const directoryFlag = {
|
|
73
|
+
npm: `--prefix ${commentText(settings.appDir)}`,
|
|
74
|
+
pnpm: `--dir ${commentText(settings.appDir)}`,
|
|
75
|
+
bun: `--cwd=${commentText(settings.appDir)}`,
|
|
76
|
+
}[settings.pm];
|
|
77
|
+
lines.push(`# app in a subfolder: ${settings.pm} ${directoryFlag} run ${commentText(settings.devScript)} --port <port>`);
|
|
78
|
+
}
|
|
79
|
+
if (settings.pm === 'pnpm') {
|
|
80
|
+
lines.push('# (pnpm passes a literal "--" through, so do not write `pnpm run dev -- --port`.)');
|
|
81
|
+
}
|
|
82
|
+
if (settings.scripts.length > 0) {
|
|
83
|
+
lines.push(`# Detected package scripts: ${settings.scripts
|
|
84
|
+
.map(script => commentText(script))
|
|
85
|
+
.join(', ')}`);
|
|
86
|
+
}
|
|
87
|
+
return lines.join('\n');
|
|
88
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type Settings } from '../settings.js';
|
|
2
|
+
export declare function generateTmuxPreStart(settings: Settings): string | undefined;
|
|
3
|
+
export declare function tmuxStartBody(settings: Settings): string;
|
|
4
|
+
export declare function tmuxRemoveBody(settings: Settings): string | undefined;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** Creates and tears down the deterministic tmux workspace for one worktree. */
|
|
2
|
+
import { agentCommands, maximumAgents } from '../agents.js';
|
|
3
|
+
import { multilineCommand, shellQuote } from './toml.js';
|
|
4
|
+
const branchTemplate = '{{ branch | sanitize }}';
|
|
5
|
+
const worktreeTemplate = '{{ worktree_path }}';
|
|
6
|
+
export function generateTmuxPreStart(settings) {
|
|
7
|
+
if (!settings.tmux) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
return `[[pre-start]]\ntmux = ${multilineCommand(tmuxStartBody(settings))}`;
|
|
11
|
+
}
|
|
12
|
+
export function tmuxStartBody(settings) {
|
|
13
|
+
const defaultAgents = settings.agents.join(' ');
|
|
14
|
+
const commandMappings = Object.entries(agentCommands)
|
|
15
|
+
.filter(([id, command]) => id !== command)
|
|
16
|
+
.map(([id, command]) => ` [ "$a" = ${shellQuote(id)} ] && cmd=${shellQuote(command)}`)
|
|
17
|
+
.join('\n');
|
|
18
|
+
const agentWindow = settings.agents.length === 0
|
|
19
|
+
? ''
|
|
20
|
+
: `
|
|
21
|
+
AG_W=$(tmux new-window -P -F '#{window_id}' -t "=$S" -c "$W" -n Agents)
|
|
22
|
+
n=0
|
|
23
|
+
for a in \${WT_AGENTS-${defaultAgents}}; do
|
|
24
|
+
[ "$n" -ge ${maximumAgents} ] && echo "only the first ${maximumAgents} agents are started" && break
|
|
25
|
+
cmd=$a
|
|
26
|
+
${commandMappings}
|
|
27
|
+
command -v "$cmd" >/dev/null 2>&1 || continue
|
|
28
|
+
[ "$n" -gt 0 ] && tmux split-window -h -t "$AG_W" -c "$W"
|
|
29
|
+
tmux send-keys -t "$AG_W" "$cmd" Enter
|
|
30
|
+
n=$((n + 1))
|
|
31
|
+
done
|
|
32
|
+
[ "$n" -eq 2 ] && tmux select-layout -t "$AG_W" even-horizontal >/dev/null
|
|
33
|
+
[ "$n" -gt 2 ] && tmux select-layout -t "$AG_W" tiled >/dev/null`;
|
|
34
|
+
return `command -v tmux >/dev/null 2>&1 || { echo "tmux not found; skipping session"; exit 0; }
|
|
35
|
+
[ "\${WT_TMUX-on}" = off ] && exit 0
|
|
36
|
+
P=${shellQuote(settings.prefix)}
|
|
37
|
+
B=${branchTemplate}
|
|
38
|
+
W=${worktreeTemplate}
|
|
39
|
+
S="\${P}_$B"
|
|
40
|
+
|
|
41
|
+
# Remove agent credentials before a new tmux server can inherit them.
|
|
42
|
+
for name in $(env | cut -d= -f1); do
|
|
43
|
+
case $name in
|
|
44
|
+
CLAUDE*|ANTHROPIC*|OPENCODE*|CODEX*|COPILOT*) unset "$name" ;;
|
|
45
|
+
esac
|
|
46
|
+
done
|
|
47
|
+
|
|
48
|
+
if tmux has-session -t "=$S" 2>/dev/null; then
|
|
49
|
+
session_path=$(tmux display-message -p -t "=$S" '#{session_path}')
|
|
50
|
+
if [ "$session_path" = "$W" ]; then
|
|
51
|
+
echo "tmux session $S already exists"
|
|
52
|
+
exit 0
|
|
53
|
+
fi
|
|
54
|
+
tmux kill-session -t "=$S" 2>/dev/null || true
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
if ! tmux new-session -d -s "$S" -c "$W" -n Editor; then
|
|
58
|
+
echo "could not create tmux session $S"
|
|
59
|
+
exit 0
|
|
60
|
+
fi
|
|
61
|
+
ED_W=$(tmux display-message -p -t "=$S:1" '#{window_id}')
|
|
62
|
+
editor=\${WT_EDITOR-\${EDITOR-nvim}}
|
|
63
|
+
if [ -n "$editor" ] && command -v "$editor" >/dev/null 2>&1; then
|
|
64
|
+
tmux send-keys -t "$ED_W" "$editor" Enter
|
|
65
|
+
fi${agentWindow}
|
|
66
|
+
TERM_W=$(tmux new-window -P -F '#{window_id}' -t "=$S" -c "$W" -n Terminal)
|
|
67
|
+
tmux split-window -h -t "$TERM_W" -c "$W"
|
|
68
|
+
tmux select-window -t "$TERM_W"
|
|
69
|
+
printf 'tmux session %s ready; attach with: tmux attach -t =%s\\n' "$S" "$S"`;
|
|
70
|
+
}
|
|
71
|
+
export function tmuxRemoveBody(settings) {
|
|
72
|
+
if (!settings.tmux) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
return `command -v tmux >/dev/null 2>&1 || exit 0
|
|
76
|
+
P=${shellQuote(settings.prefix)}
|
|
77
|
+
B=${branchTemplate}
|
|
78
|
+
S="\${P}_$B"
|
|
79
|
+
tmux has-session -t "=$S" 2>/dev/null || exit 0
|
|
80
|
+
PIDS=
|
|
81
|
+
collect_tree() {
|
|
82
|
+
parent=$1
|
|
83
|
+
for child in $(pgrep -P "$parent" 2>/dev/null); do
|
|
84
|
+
collect_tree "$child"
|
|
85
|
+
done
|
|
86
|
+
PIDS="$PIDS $parent"
|
|
87
|
+
}
|
|
88
|
+
for pane in $(tmux list-panes -s -t "=$S" -F '#{pane_id}'); do
|
|
89
|
+
[ "$pane" = "\${TMUX_PANE-}" ] && continue
|
|
90
|
+
pane_pid=$(tmux display-message -p -t "$pane" '#{pane_pid}')
|
|
91
|
+
[ -n "$pane_pid" ] && collect_tree "$pane_pid"
|
|
92
|
+
done
|
|
93
|
+
if [ -n "$PIDS" ]; then
|
|
94
|
+
kill -TERM $PIDS 2>/dev/null || true
|
|
95
|
+
fi
|
|
96
|
+
# Keep this delayed command on one line: tmux drops run-shell commands containing newlines.
|
|
97
|
+
tmux run-shell -b -d 3 "if [ -n '$PIDS' ]; then kill -KILL $PIDS 2>/dev/null || true; fi; tmux kill-session -t '=$S' 2>/dev/null || true"`;
|
|
98
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** TOML and shell escaping shared by every generated fragment. */
|
|
2
|
+
import type { Settings } from '../settings.js';
|
|
3
|
+
/**
|
|
4
|
+
* What stands in for the repository name in a generated template. With an
|
|
5
|
+
* origin this is Worktrunk's own variable; without one it is the name written
|
|
6
|
+
* out, because `remote_repo` would render empty.
|
|
7
|
+
*/
|
|
8
|
+
export declare function repoExpression(settings: Settings): string;
|
|
9
|
+
/** The port hashes repo and branch together; the repo half follows the same rule. */
|
|
10
|
+
export declare function portExpression(settings: Settings): string;
|
|
11
|
+
export declare function tomlString(value: string): string;
|
|
12
|
+
export declare function inlineCommand(body: string): string;
|
|
13
|
+
export declare function multilineCommand(body: string): string;
|
|
14
|
+
export declare function shellQuote(value: string): string;
|
|
15
|
+
export declare function commentText(value: string): string;
|
|
16
|
+
export declare function assertShellTemplate(body: string): void;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What stands in for the repository name in a generated template. With an
|
|
3
|
+
* origin this is Worktrunk's own variable; without one it is the name written
|
|
4
|
+
* out, because `remote_repo` would render empty.
|
|
5
|
+
*/
|
|
6
|
+
export function repoExpression(settings) {
|
|
7
|
+
return settings.noRemote ? settings.hostLabel : '{{ remote_repo | lower }}';
|
|
8
|
+
}
|
|
9
|
+
/** The port hashes repo and branch together; the repo half follows the same rule. */
|
|
10
|
+
export function portExpression(settings) {
|
|
11
|
+
return settings.noRemote
|
|
12
|
+
? `{{ ('${settings.hostLabel}/' ~ branch) | hash_port }}`
|
|
13
|
+
: "{{ (remote_repo ~ '/' ~ branch) | hash_port }}";
|
|
14
|
+
}
|
|
15
|
+
const allowedTemplates = Object.freeze([
|
|
16
|
+
'{{ branch | sanitize }}',
|
|
17
|
+
'{{ worktree_path }}',
|
|
18
|
+
'{{ remote_repo | lower }}',
|
|
19
|
+
"{{ (remote_repo ~ '/' ~ branch) | hash_port }}",
|
|
20
|
+
'{{ args }}',
|
|
21
|
+
'{{ args[0] | sanitize }}',
|
|
22
|
+
'{% if args %}',
|
|
23
|
+
'{% else %}',
|
|
24
|
+
'{% endif %}',
|
|
25
|
+
]);
|
|
26
|
+
export function tomlString(value) {
|
|
27
|
+
return JSON.stringify(value);
|
|
28
|
+
}
|
|
29
|
+
export function inlineCommand(body) {
|
|
30
|
+
assertShellTemplate(body);
|
|
31
|
+
return tomlString(body);
|
|
32
|
+
}
|
|
33
|
+
export function multilineCommand(body) {
|
|
34
|
+
assertShellTemplate(body);
|
|
35
|
+
if (body.includes("'''")) {
|
|
36
|
+
throw new TypeError("Generated command contains the TOML delimiter '''.");
|
|
37
|
+
}
|
|
38
|
+
return `'''\n${body.trim()}\n'''`;
|
|
39
|
+
}
|
|
40
|
+
export function shellQuote(value) {
|
|
41
|
+
if (/^[\w@%+=:,./-]+$/i.test(value)) {
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
return `'${value.split("'").join(`'"'"'`)}'`;
|
|
45
|
+
}
|
|
46
|
+
export function commentText(value) {
|
|
47
|
+
return value
|
|
48
|
+
.split(/\0|\r?\n/)
|
|
49
|
+
.join(' ')
|
|
50
|
+
.trim();
|
|
51
|
+
}
|
|
52
|
+
export function assertShellTemplate(body) {
|
|
53
|
+
if (body.includes('${#') || body.includes('{#')) {
|
|
54
|
+
throw new TypeError('Generated shell collides with Worktrunk template syntax.');
|
|
55
|
+
}
|
|
56
|
+
// The no-remote port form carries a literal project name, so it is matched
|
|
57
|
+
// by shape rather than listed.
|
|
58
|
+
let remainder = body.replaceAll(/{{ \('[^'{}]*\/' ~ branch\) \| hash_port }}/g, '');
|
|
59
|
+
for (const template of allowedTemplates) {
|
|
60
|
+
remainder = remainder.split(template).join('');
|
|
61
|
+
}
|
|
62
|
+
if (remainder.includes('{{') || remainder.includes('{%')) {
|
|
63
|
+
const line = remainder
|
|
64
|
+
.split(/\r?\n/)
|
|
65
|
+
.find(value => value.includes('{{') || value.includes('{%'));
|
|
66
|
+
throw new TypeError(`Unexpected Worktrunk template expression: ${line}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publishing the setup branch. `gh` is optional throughout: without it, or
|
|
3
|
+
* against a host it cannot speak to, trunk pushes and prints the compare URL
|
|
4
|
+
* so the user can open the pull request themselves.
|
|
5
|
+
*/
|
|
6
|
+
import { type CommandResult, type CommandRunner } from './process.js';
|
|
7
|
+
import type { ParsedRemote, SshAliases } from './repo.js';
|
|
8
|
+
export type GhOptions = Readonly<{
|
|
9
|
+
ghPath?: string;
|
|
10
|
+
gitPath?: string;
|
|
11
|
+
run?: CommandRunner;
|
|
12
|
+
env?: NodeJS.ProcessEnv;
|
|
13
|
+
}>;
|
|
14
|
+
/**
|
|
15
|
+
* `gh` only authenticates against GitHub, so an SSH alias is resolved to its
|
|
16
|
+
* real host before deciding. A non-GitHub remote still gets a push and a URL.
|
|
17
|
+
*/
|
|
18
|
+
export declare function canOpenPullRequest(remote: ParsedRemote, ghInstalled: boolean): boolean;
|
|
19
|
+
/** Where the user opens the pull request when trunk cannot do it for them. */
|
|
20
|
+
export declare function compareUrl(remote: ParsedRemote, branch: string): string | undefined;
|
|
21
|
+
export declare function pushBranch(worktreePath: string, branch: string, options?: GhOptions): Promise<CommandResult>;
|
|
22
|
+
/**
|
|
23
|
+
* `--fill` reuses the commit message, which is why the commit body is written
|
|
24
|
+
* for teammates: it becomes the pull request description.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createPullRequest(worktreePath: string, options?: GhOptions): Promise<CommandResult>;
|
|
27
|
+
/** The two commands trunk prints when it stops after the commit. */
|
|
28
|
+
export declare function publishCommands(branch: string, withPullRequest: boolean): readonly string[];
|
|
29
|
+
export type RepositoryOwner = Readonly<{
|
|
30
|
+
login: string;
|
|
31
|
+
kind: 'user' | 'org';
|
|
32
|
+
}>;
|
|
33
|
+
/**
|
|
34
|
+
* The accounts a repository can be created under: the signed-in user first,
|
|
35
|
+
* then the organisations they belong to. An empty list means gh could not
|
|
36
|
+
* answer, which the caller treats as "ask for the owner instead of guessing".
|
|
37
|
+
*/
|
|
38
|
+
export declare function listOwners(options?: GhOptions): Promise<readonly RepositoryOwner[]>;
|
|
39
|
+
/** Which account gh would act as, for the confirmation and for auth errors. */
|
|
40
|
+
export declare function activeAccount(options?: GhOptions): Promise<string | undefined>;
|
|
41
|
+
/**
|
|
42
|
+
* Creates the repository and nothing else: no push, no clone, no source. trunk
|
|
43
|
+
* builds the local side itself, so gh only has to make the remote exist.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createRepository(owner: string, name: string, options?: GhOptions & {
|
|
46
|
+
visibility?: 'private' | 'public';
|
|
47
|
+
}): Promise<CommandResult>;
|
|
48
|
+
/** What to run by hand; trunk never deletes a remote repository itself. */
|
|
49
|
+
export declare function deleteRepositoryCommand(owner: string, name: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* The SSH URL to use for a new remote. gh reports an https URL, but someone
|
|
52
|
+
* with several accounts reaches each through its own ssh alias, so the alias
|
|
53
|
+
* whose resolved host matches is preferred — and among those, one a sibling
|
|
54
|
+
* project already uses, since that is demonstrably the right account.
|
|
55
|
+
*/
|
|
56
|
+
export declare function sshRemoteUrl(owner: string, name: string, options: Readonly<{
|
|
57
|
+
realHost: string;
|
|
58
|
+
aliases: SshAliases;
|
|
59
|
+
siblingHosts?: readonly string[];
|
|
60
|
+
}>): string;
|