@cod3vil/trunk 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +69 -0
  4. package/dist/commands/clone.d.ts +6 -0
  5. package/dist/commands/clone.js +114 -0
  6. package/dist/commands/init.d.ts +6 -0
  7. package/dist/commands/init.js +235 -0
  8. package/dist/commands/new.d.ts +6 -0
  9. package/dist/commands/new.js +357 -0
  10. package/dist/core/adopt.d.ts +14 -0
  11. package/dist/core/adopt.js +157 -0
  12. package/dist/core/agents.d.ts +30 -0
  13. package/dist/core/agents.js +31 -0
  14. package/dist/core/arguments.d.ts +92 -0
  15. package/dist/core/arguments.js +93 -0
  16. package/dist/core/detect.d.ts +28 -0
  17. package/dist/core/detect.js +169 -0
  18. package/dist/core/diff.d.ts +37 -0
  19. package/dist/core/diff.js +140 -0
  20. package/dist/core/env.d.ts +63 -0
  21. package/dist/core/env.js +140 -0
  22. package/dist/core/generate/aliases.d.ts +7 -0
  23. package/dist/core/generate/aliases.js +45 -0
  24. package/dist/core/generate/header.d.ts +2 -0
  25. package/dist/core/generate/header.js +81 -0
  26. package/dist/core/generate/index.d.ts +8 -0
  27. package/dist/core/generate/index.js +74 -0
  28. package/dist/core/generate/proxy.d.ts +8 -0
  29. package/dist/core/generate/proxy.js +56 -0
  30. package/dist/core/generate/steps.d.ts +7 -0
  31. package/dist/core/generate/steps.js +88 -0
  32. package/dist/core/generate/tmux.d.ts +4 -0
  33. package/dist/core/generate/tmux.js +98 -0
  34. package/dist/core/generate/toml.d.ts +16 -0
  35. package/dist/core/generate/toml.js +68 -0
  36. package/dist/core/gh.d.ts +60 -0
  37. package/dist/core/gh.js +101 -0
  38. package/dist/core/git.d.ts +79 -0
  39. package/dist/core/git.js +211 -0
  40. package/dist/core/journal.d.ts +54 -0
  41. package/dist/core/journal.js +147 -0
  42. package/dist/core/log.d.ts +8 -0
  43. package/dist/core/log.js +38 -0
  44. package/dist/core/pipeline.d.ts +119 -0
  45. package/dist/core/pipeline.js +473 -0
  46. package/dist/core/platform.d.ts +10 -0
  47. package/dist/core/platform.js +26 -0
  48. package/dist/core/prefix.d.ts +25 -0
  49. package/dist/core/prefix.js +59 -0
  50. package/dist/core/process.d.ts +27 -0
  51. package/dist/core/process.js +43 -0
  52. package/dist/core/repo.d.ts +79 -0
  53. package/dist/core/repo.js +294 -0
  54. package/dist/core/resolve.d.ts +127 -0
  55. package/dist/core/resolve.js +488 -0
  56. package/dist/core/result.d.ts +27 -0
  57. package/dist/core/result.js +32 -0
  58. package/dist/core/settings.d.ts +51 -0
  59. package/dist/core/settings.js +83 -0
  60. package/dist/core/tmuxRename.d.ts +36 -0
  61. package/dist/core/tmuxRename.js +79 -0
  62. package/dist/core/validate.d.ts +22 -0
  63. package/dist/core/validate.js +111 -0
  64. package/dist/core/version.d.ts +2 -0
  65. package/dist/core/version.js +35 -0
  66. package/dist/core/words.d.ts +16 -0
  67. package/dist/core/words.js +198 -0
  68. package/dist/core/wt.d.ts +41 -0
  69. package/dist/core/wt.js +59 -0
  70. package/dist/ui/SetupForm.d.ts +55 -0
  71. package/dist/ui/SetupForm.js +354 -0
  72. package/dist/ui/Summary.d.ts +15 -0
  73. package/dist/ui/Summary.js +74 -0
  74. package/dist/ui/fields/MultiSelect.d.ts +17 -0
  75. package/dist/ui/fields/MultiSelect.js +66 -0
  76. package/dist/ui/fields/Select.d.ts +17 -0
  77. package/dist/ui/fields/Select.js +37 -0
  78. package/dist/ui/fields/TextInput.d.ts +13 -0
  79. package/dist/ui/fields/TextInput.js +50 -0
  80. package/package.json +76 -0
  81. package/readme.md +147 -0
@@ -0,0 +1,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,2 @@
1
+ import { type Settings } from '../settings.js';
2
+ export declare function generateHeader(settings: Settings): string;
@@ -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;