@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,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tmux session prefix. Sessions are named `<prefix>_<branch>`, so the prefix
|
|
3
|
+
* is what keeps one repository's sessions apart from another's, and it has to
|
|
4
|
+
* survive tmux's own naming rules.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Suggests a prefix from a repository name: the first word in full, then the
|
|
8
|
+
* initial of each word after it (`acme-web-backend` becomes `acme-wb`). Short
|
|
9
|
+
* enough to read in a tmux status line, and stable for a given name.
|
|
10
|
+
*
|
|
11
|
+
* Two repositories can suggest the same prefix; the setup form shows the value
|
|
12
|
+
* so the user can change it.
|
|
13
|
+
*/
|
|
14
|
+
export function initials(name) {
|
|
15
|
+
// Any run of punctuation separates words, which also absorbs doubled and
|
|
16
|
+
// trailing hyphens.
|
|
17
|
+
const parts = name
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.split(/[^a-z\d]+/)
|
|
20
|
+
.filter(Boolean);
|
|
21
|
+
if (parts.length < 2) {
|
|
22
|
+
return parts[0] ?? '';
|
|
23
|
+
}
|
|
24
|
+
return `${parts[0]}-${parts
|
|
25
|
+
.slice(1)
|
|
26
|
+
.map(part => part[0])
|
|
27
|
+
.join('')}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Checks a prefix the user typed. Each rule explains itself, and nothing is
|
|
31
|
+
* silently rewritten: a surprising prefix is worse than an error message.
|
|
32
|
+
*/
|
|
33
|
+
export function validatePrefix(prefix) {
|
|
34
|
+
if (prefix.length === 0) {
|
|
35
|
+
return invalid('Prefix cannot be empty.');
|
|
36
|
+
}
|
|
37
|
+
if (prefix.length > 24) {
|
|
38
|
+
return invalid('Prefix must be at most 24 characters.');
|
|
39
|
+
}
|
|
40
|
+
if (prefix.startsWith('-')) {
|
|
41
|
+
return invalid('Prefix cannot start with a hyphen.');
|
|
42
|
+
}
|
|
43
|
+
if (prefix.includes('_')) {
|
|
44
|
+
return invalid('Prefix cannot contain underscores because "_" separates it from the branch.');
|
|
45
|
+
}
|
|
46
|
+
if (prefix.includes('.') || prefix.includes(':')) {
|
|
47
|
+
return invalid('Prefix cannot contain "." or ":" because tmux rewrites them.');
|
|
48
|
+
}
|
|
49
|
+
if (/[A-Z]/.test(prefix)) {
|
|
50
|
+
return invalid('Prefix must use lowercase letters.');
|
|
51
|
+
}
|
|
52
|
+
if (!/^[a-z\d][a-z\d-]*$/.test(prefix)) {
|
|
53
|
+
return invalid('Prefix may contain only lowercase letters, digits, and hyphens.');
|
|
54
|
+
}
|
|
55
|
+
return Object.freeze({ valid: true });
|
|
56
|
+
}
|
|
57
|
+
function invalid(reason) {
|
|
58
|
+
return Object.freeze({ valid: false, reason });
|
|
59
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type CommandResult = Readonly<{
|
|
2
|
+
code: number | undefined;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
}>;
|
|
6
|
+
export type CommandOptions = Readonly<{
|
|
7
|
+
cwd?: string;
|
|
8
|
+
env?: NodeJS.ProcessEnv;
|
|
9
|
+
}>;
|
|
10
|
+
/**
|
|
11
|
+
* Runs a command with trunk's own terminal, so the child can ask the user its
|
|
12
|
+
* own questions and stream its own progress. Nothing is captured, so callers
|
|
13
|
+
* get the exit code only.
|
|
14
|
+
*/
|
|
15
|
+
export type AttachedRunner = (command: string, arguments_: readonly string[], options?: CommandOptions) => Promise<number>;
|
|
16
|
+
export declare const runAttached: AttachedRunner;
|
|
17
|
+
/**
|
|
18
|
+
* The shape callers accept, so a test can pass a stub in place of
|
|
19
|
+
* {@link runCommand} and never touch the real machine.
|
|
20
|
+
*/
|
|
21
|
+
export type CommandRunner = (command: string, arguments_: readonly string[], options?: CommandOptions) => Promise<CommandResult>;
|
|
22
|
+
/**
|
|
23
|
+
* Runs a command to completion and collects its output. A non-zero exit is a
|
|
24
|
+
* normal result, not a rejection; only a process that could not be started at
|
|
25
|
+
* all rejects.
|
|
26
|
+
*/
|
|
27
|
+
export declare const runCommand: CommandRunner;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one way trunk runs another program. Everything goes through `spawn`
|
|
3
|
+
* without a shell, so an argument containing a space or a quote can never be
|
|
4
|
+
* reinterpreted as shell syntax.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
export const runAttached = async (command, arguments_, options = {}) => new Promise((resolve, reject) => {
|
|
8
|
+
const child = spawn(command, arguments_, {
|
|
9
|
+
cwd: options.cwd,
|
|
10
|
+
env: options.env,
|
|
11
|
+
stdio: 'inherit',
|
|
12
|
+
});
|
|
13
|
+
child.once('error', reject);
|
|
14
|
+
child.once('close', code => {
|
|
15
|
+
resolve(code ?? 1);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
/**
|
|
19
|
+
* Runs a command to completion and collects its output. A non-zero exit is a
|
|
20
|
+
* normal result, not a rejection; only a process that could not be started at
|
|
21
|
+
* all rejects.
|
|
22
|
+
*/
|
|
23
|
+
export const runCommand = async (command, arguments_, options = {}) => new Promise((resolve, reject) => {
|
|
24
|
+
const child = spawn(command, arguments_, {
|
|
25
|
+
cwd: options.cwd,
|
|
26
|
+
env: options.env,
|
|
27
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
28
|
+
});
|
|
29
|
+
let stdout = '';
|
|
30
|
+
let stderr = '';
|
|
31
|
+
child.stdout.setEncoding('utf8');
|
|
32
|
+
child.stderr.setEncoding('utf8');
|
|
33
|
+
child.stdout.on('data', (chunk) => {
|
|
34
|
+
stdout += chunk;
|
|
35
|
+
});
|
|
36
|
+
child.stderr.on('data', (chunk) => {
|
|
37
|
+
stderr += chunk;
|
|
38
|
+
});
|
|
39
|
+
child.once('error', reject);
|
|
40
|
+
child.once('close', code => {
|
|
41
|
+
resolve(Object.freeze({ code: code ?? undefined, stdout, stderr }));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type GitOptions } from './git.js';
|
|
2
|
+
/** A remote on a hosting service, parsed from any URL form git accepts. */
|
|
3
|
+
export type Remote = Readonly<{
|
|
4
|
+
kind: 'hosted';
|
|
5
|
+
/** The URL exactly as the user typed it. */
|
|
6
|
+
url: string;
|
|
7
|
+
/** The host as written, which may be an SSH alias such as `github.com-work`. */
|
|
8
|
+
host: string;
|
|
9
|
+
/** What that alias resolves to in ~/.ssh/config, for deciding gh can reach it. */
|
|
10
|
+
realHost: string;
|
|
11
|
+
owner: string;
|
|
12
|
+
/** Repository name without the `.git` suffix. */
|
|
13
|
+
repo: string;
|
|
14
|
+
/**
|
|
15
|
+
* `host/owner/repo`, matching what `wt config show` prints as its Identifier.
|
|
16
|
+
* The alias is kept verbatim, because that is the string wt keys its
|
|
17
|
+
* per-project settings on.
|
|
18
|
+
*/
|
|
19
|
+
identifier: string;
|
|
20
|
+
}>;
|
|
21
|
+
export type LocalRemote = Readonly<{
|
|
22
|
+
kind: 'local';
|
|
23
|
+
url: string;
|
|
24
|
+
path: string;
|
|
25
|
+
repo: string;
|
|
26
|
+
identifier: string;
|
|
27
|
+
}>;
|
|
28
|
+
export type ParsedRemote = Remote | LocalRemote;
|
|
29
|
+
export type SshAliases = Readonly<Record<string, string>>;
|
|
30
|
+
/**
|
|
31
|
+
* - `bare-project`: the directory is a trunk-layout project root, holding a bare
|
|
32
|
+
* `.git` with worktrees beside it. This is the layout trunk sets up.
|
|
33
|
+
* - `bare-inside`: somewhere inside such a project, for example one of its
|
|
34
|
+
* worktrees; `projectRoot` points at the top.
|
|
35
|
+
* - `plain-clone`: an ordinary clone, which trunk refuses to convert in place.
|
|
36
|
+
* - `empty`: nothing there yet, which is what clone and new want.
|
|
37
|
+
* - `occupied`: files that are not a repository.
|
|
38
|
+
*/
|
|
39
|
+
export type LayoutKind = 'bare-project' | 'bare-inside' | 'plain-clone' | 'empty' | 'occupied';
|
|
40
|
+
export type RepositoryLayout = Readonly<{
|
|
41
|
+
kind: LayoutKind;
|
|
42
|
+
path: string;
|
|
43
|
+
/**
|
|
44
|
+
* The directory whose git marker produced this classification. It differs
|
|
45
|
+
* from `path` when the answer came from an ancestor, which is how a plain
|
|
46
|
+
* subdirectory of a repository is reported; callers that need the target
|
|
47
|
+
* itself to be the repository must compare the two.
|
|
48
|
+
*/
|
|
49
|
+
matchedAt?: string;
|
|
50
|
+
projectRoot?: string;
|
|
51
|
+
gitDir?: string;
|
|
52
|
+
worktreeRoot?: string;
|
|
53
|
+
}>;
|
|
54
|
+
/**
|
|
55
|
+
* Parses any remote git understands: scp-style (`git@host:owner/repo.git`),
|
|
56
|
+
* https, ssh:// and a plain local path. The repository name comes from here and
|
|
57
|
+
* nowhere else — in trunk's layout the project folder holds a bare `.git`, so
|
|
58
|
+
* the folder name is not the project's identity.
|
|
59
|
+
*/
|
|
60
|
+
export declare function parseRemote(url: string, aliases?: SshAliases, workingDirectory?: string): ParsedRemote;
|
|
61
|
+
/**
|
|
62
|
+
* Reads `Host`/`HostName` pairs out of an ssh config. Parsed here rather than
|
|
63
|
+
* shelled out to `ssh -G` so it stays fast and testable; this only needs to
|
|
64
|
+
* cover the common forms, since a miss just leaves the alias unresolved.
|
|
65
|
+
*/
|
|
66
|
+
export declare function parseSshAliases(contents: string): SshAliases;
|
|
67
|
+
/** Best effort: no ssh config, or one that cannot be read, simply means no aliases. */
|
|
68
|
+
export declare function loadSshAliases(configPath?: string): Promise<SshAliases>;
|
|
69
|
+
/**
|
|
70
|
+
* Turns an alias into the host it really points at. An exact entry wins over a
|
|
71
|
+
* pattern such as `*.internal`, and an unknown host is returned unchanged.
|
|
72
|
+
*/
|
|
73
|
+
export declare function resolveSshAlias(host: string, aliases: SshAliases): string;
|
|
74
|
+
/**
|
|
75
|
+
* Works out what a directory is, so a command knows whether to set it up,
|
|
76
|
+
* refuse it, or resolve upwards to the project it belongs to. A missing
|
|
77
|
+
* directory counts as empty: that is a target waiting to be created.
|
|
78
|
+
*/
|
|
79
|
+
export declare function detectLayout(path: string, gitOptions?: GitOptions): Promise<RepositoryLayout>;
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a repository is and what shape a directory is in: parsing a remote URL
|
|
3
|
+
* into the identity wt uses, resolving SSH host aliases, and classifying a
|
|
4
|
+
* directory as a trunk-layout project, a plain clone, empty or occupied.
|
|
5
|
+
*/
|
|
6
|
+
import { readFile, readdir, realpath, stat } from 'node:fs/promises';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
9
|
+
import process from 'node:process';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { gitCommonDirectory, isBareRepository } from './git.js';
|
|
12
|
+
/** Entries an otherwise empty directory may hold and still count as empty. */
|
|
13
|
+
const ignorableEntries = new Set(['.DS_Store', '.localized', 'Thumbs.db']);
|
|
14
|
+
/**
|
|
15
|
+
* Parses any remote git understands: scp-style (`git@host:owner/repo.git`),
|
|
16
|
+
* https, ssh:// and a plain local path. The repository name comes from here and
|
|
17
|
+
* nowhere else — in trunk's layout the project folder holds a bare `.git`, so
|
|
18
|
+
* the folder name is not the project's identity.
|
|
19
|
+
*/
|
|
20
|
+
export function parseRemote(url, aliases = {}, workingDirectory = process.cwd()) {
|
|
21
|
+
const value = url.trim();
|
|
22
|
+
if (!value) {
|
|
23
|
+
throw new TypeError('Remote URL cannot be empty.');
|
|
24
|
+
}
|
|
25
|
+
if (/^[a-z][a-z\d+.-]*:\/\//i.test(value)) {
|
|
26
|
+
const parsed = new URL(value);
|
|
27
|
+
if (parsed.protocol === 'file:') {
|
|
28
|
+
return localRemote(value, fileURLToPath(parsed), workingDirectory);
|
|
29
|
+
}
|
|
30
|
+
if (['http:', 'https:', 'ssh:'].includes(parsed.protocol)) {
|
|
31
|
+
return hostedRemote(value, parsed.hostname, parsed.pathname, aliases);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// The scp-style form `[user@]host:path` has no scheme to match on. The
|
|
35
|
+
// Windows check keeps `C:\repo` from being read as a host named C.
|
|
36
|
+
const scp = /^(?:[^@/]+@)?([^:/]+):(.+)$/.exec(value);
|
|
37
|
+
if (scp && !looksLikeLocalWindowsPath(value)) {
|
|
38
|
+
return hostedRemote(value, scp[1], scp[2], aliases);
|
|
39
|
+
}
|
|
40
|
+
return localRemote(value, value, workingDirectory);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Reads `Host`/`HostName` pairs out of an ssh config. Parsed here rather than
|
|
44
|
+
* shelled out to `ssh -G` so it stays fast and testable; this only needs to
|
|
45
|
+
* cover the common forms, since a miss just leaves the alias unresolved.
|
|
46
|
+
*/
|
|
47
|
+
export function parseSshAliases(contents) {
|
|
48
|
+
const aliases = {};
|
|
49
|
+
// Directives apply to the Host block above them, so the current block is
|
|
50
|
+
// carried down the file. ssh honours the first value it sees for a host.
|
|
51
|
+
let hosts = [];
|
|
52
|
+
for (const originalLine of contents.split(/\r?\n/)) {
|
|
53
|
+
const line = originalLine.replace(/\s+#.*$/, '').trim();
|
|
54
|
+
if (!line) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const parsed = parseSshDirective(line);
|
|
58
|
+
if (!parsed) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const { directive, values } = parsed;
|
|
62
|
+
if (directive.toLowerCase() === 'host') {
|
|
63
|
+
hosts = values.filter(host => !host.startsWith('!'));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (directive.toLowerCase() !== 'hostname' || values.length === 0) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
for (const host of hosts) {
|
|
70
|
+
if (!hasAlias(aliases, host)) {
|
|
71
|
+
aliases[host] = values[0];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return Object.freeze(aliases);
|
|
76
|
+
}
|
|
77
|
+
/** Best effort: no ssh config, or one that cannot be read, simply means no aliases. */
|
|
78
|
+
export async function loadSshAliases(configPath = join(homedir(), '.ssh', 'config')) {
|
|
79
|
+
try {
|
|
80
|
+
return parseSshAliases(await readFile(configPath, 'utf8'));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return Object.freeze({});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Turns an alias into the host it really points at. An exact entry wins over a
|
|
88
|
+
* pattern such as `*.internal`, and an unknown host is returned unchanged.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveSshAlias(host, aliases) {
|
|
91
|
+
const entries = Object.entries(aliases);
|
|
92
|
+
const exact = entries.find(([alias]) => alias.toLowerCase() === host.toLowerCase());
|
|
93
|
+
const matched = exact ?? entries.find(([alias]) => hostMatches(alias, host));
|
|
94
|
+
return matched?.[1].split('%h').join(host) ?? host;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Works out what a directory is, so a command knows whether to set it up,
|
|
98
|
+
* refuse it, or resolve upwards to the project it belongs to. A missing
|
|
99
|
+
* directory counts as empty: that is a target waiting to be created.
|
|
100
|
+
*/
|
|
101
|
+
export async function detectLayout(path, gitOptions = {}) {
|
|
102
|
+
const resolvedPath = resolve(path);
|
|
103
|
+
const targetStat = await safeStat(resolvedPath);
|
|
104
|
+
if (!targetStat) {
|
|
105
|
+
return layout('empty', resolvedPath);
|
|
106
|
+
}
|
|
107
|
+
const target = await realpath(resolvedPath);
|
|
108
|
+
if (!targetStat.isDirectory()) {
|
|
109
|
+
return layout('occupied', target);
|
|
110
|
+
}
|
|
111
|
+
const targetEntries = await readdir(target);
|
|
112
|
+
if (targetEntries.every(entry => ignorableEntries.has(entry))) {
|
|
113
|
+
return layout('empty', target);
|
|
114
|
+
}
|
|
115
|
+
return searchLayout(target, target, gitOptions);
|
|
116
|
+
}
|
|
117
|
+
function hostedRemote(url, host, remotePath, aliases) {
|
|
118
|
+
const parts = remotePath
|
|
119
|
+
.replace(/^\/+/, '')
|
|
120
|
+
.replace(/\/+$/, '')
|
|
121
|
+
.split('/')
|
|
122
|
+
.filter(Boolean);
|
|
123
|
+
if (parts.length !== 2) {
|
|
124
|
+
throw new TypeError(`Remote must contain an owner and repository: ${url}`);
|
|
125
|
+
}
|
|
126
|
+
const owner = parts[0];
|
|
127
|
+
const repo = stripGitSuffix(parts[1]);
|
|
128
|
+
if (!owner || !repo) {
|
|
129
|
+
throw new TypeError(`Remote must contain an owner and repository: ${url}`);
|
|
130
|
+
}
|
|
131
|
+
return Object.freeze({
|
|
132
|
+
kind: 'hosted',
|
|
133
|
+
url,
|
|
134
|
+
host,
|
|
135
|
+
realHost: resolveSshAlias(host, aliases),
|
|
136
|
+
owner,
|
|
137
|
+
repo,
|
|
138
|
+
identifier: `${host}/${owner}/${repo}`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function localRemote(url, path, workingDirectory) {
|
|
142
|
+
const absolutePath = isAbsolute(path)
|
|
143
|
+
? resolve(path)
|
|
144
|
+
: resolve(workingDirectory, path);
|
|
145
|
+
const identityPath = stripGitSuffix(absolutePath);
|
|
146
|
+
const repo = basename(identityPath);
|
|
147
|
+
if (!repo) {
|
|
148
|
+
throw new TypeError(`Local remote does not name a repository: ${url}`);
|
|
149
|
+
}
|
|
150
|
+
return Object.freeze({
|
|
151
|
+
kind: 'local',
|
|
152
|
+
url,
|
|
153
|
+
path: absolutePath,
|
|
154
|
+
repo,
|
|
155
|
+
identifier: url.startsWith('file:') ? stripGitSuffix(url) : identityPath,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
async function layoutFromGitDirectory(candidate, gitOptions) {
|
|
159
|
+
const { gitDirectory, target, matchedAt, worktreeRoot } = candidate;
|
|
160
|
+
const commonDirectory = await gitCommonDirectory(gitDirectory, gitOptions);
|
|
161
|
+
if (!commonDirectory) {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
const bare = await isBareRepository(commonDirectory, gitOptions);
|
|
165
|
+
if (bare === undefined) {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
// In trunk's layout the bare git directory sits at `<project>/.git`, so the
|
|
169
|
+
// project root is its parent either way.
|
|
170
|
+
const projectRoot = dirname(commonDirectory);
|
|
171
|
+
if (!bare) {
|
|
172
|
+
return layout('plain-clone', target, {
|
|
173
|
+
matchedAt,
|
|
174
|
+
projectRoot,
|
|
175
|
+
gitDir: commonDirectory,
|
|
176
|
+
worktreeRoot,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return layout(target === projectRoot ? 'bare-project' : 'bare-inside', target, {
|
|
180
|
+
matchedAt,
|
|
181
|
+
projectRoot,
|
|
182
|
+
gitDir: commonDirectory,
|
|
183
|
+
worktreeRoot,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Walks from the target up towards the filesystem root looking for a git
|
|
188
|
+
* marker, the way git itself resolves a repository. That is what lets a command
|
|
189
|
+
* run from inside a worktree or a subdirectory; `matchedAt` records how far up
|
|
190
|
+
* the answer came from.
|
|
191
|
+
*/
|
|
192
|
+
async function searchLayout(current, target, gitOptions) {
|
|
193
|
+
// Being inside `<project>/.git` itself: there is no `.git` entry to find here.
|
|
194
|
+
if (basename(current) === '.git') {
|
|
195
|
+
const direct = await layoutFromGitDirectory({ gitDirectory: current, target, matchedAt: current }, gitOptions);
|
|
196
|
+
if (direct) {
|
|
197
|
+
return direct;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// A `.git` directory is a repository; a `.git` file is a linked worktree
|
|
201
|
+
// pointing at one.
|
|
202
|
+
const marker = join(current, '.git');
|
|
203
|
+
const markerStat = await safeStat(marker);
|
|
204
|
+
if (markerStat?.isDirectory()) {
|
|
205
|
+
const detected = await layoutFromGitDirectory({ gitDirectory: marker, target, matchedAt: current }, gitOptions);
|
|
206
|
+
if (detected) {
|
|
207
|
+
return detected;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
else if (markerStat?.isFile()) {
|
|
211
|
+
const gitDirectory = await gitDirectoryFromFile(marker);
|
|
212
|
+
if (gitDirectory) {
|
|
213
|
+
const detected = await layoutFromGitDirectory({ gitDirectory, target, matchedAt: current, worktreeRoot: current }, gitOptions);
|
|
214
|
+
if (detected) {
|
|
215
|
+
return detected;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const parent = dirname(current);
|
|
220
|
+
return parent === current
|
|
221
|
+
? layout('occupied', target)
|
|
222
|
+
: searchLayout(parent, target, gitOptions);
|
|
223
|
+
}
|
|
224
|
+
async function gitDirectoryFromFile(path) {
|
|
225
|
+
try {
|
|
226
|
+
const contents = await readFile(path, 'utf8');
|
|
227
|
+
const match = /^gitdir:\s*(.+)\s*$/i.exec(contents.trim());
|
|
228
|
+
if (!match) {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
return isAbsolute(match[1])
|
|
232
|
+
? resolve(match[1])
|
|
233
|
+
: resolve(dirname(path), match[1]);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async function safeStat(path) {
|
|
240
|
+
try {
|
|
241
|
+
return await stat(path);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (isMissingFileError(error)) {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function layout(kind, path, details = {}) {
|
|
251
|
+
return Object.freeze({ kind, path, ...details });
|
|
252
|
+
}
|
|
253
|
+
function stripGitSuffix(value) {
|
|
254
|
+
return value.replace(/\.git$/i, '');
|
|
255
|
+
}
|
|
256
|
+
function looksLikeLocalWindowsPath(value) {
|
|
257
|
+
return /^[a-z]:[\\/]/i.test(value);
|
|
258
|
+
}
|
|
259
|
+
function hasAlias(aliases, host) {
|
|
260
|
+
return Object.keys(aliases).some(alias => alias.toLowerCase() === host.toLowerCase());
|
|
261
|
+
}
|
|
262
|
+
function parseSshDirective(line) {
|
|
263
|
+
const match = /^([^\s=]+)(?:\s*=\s*|\s+)(.+)$/.exec(line);
|
|
264
|
+
if (!match) {
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
directive: match[1],
|
|
269
|
+
values: match[2].trim().split(/\s+/),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function hostMatches(pattern, host) {
|
|
273
|
+
const specialCharacters = '\\^$.*+?()[]{}|';
|
|
274
|
+
const expression = [...pattern]
|
|
275
|
+
.map(character => {
|
|
276
|
+
if (character === '*') {
|
|
277
|
+
return '.*';
|
|
278
|
+
}
|
|
279
|
+
if (character === '?') {
|
|
280
|
+
return '.';
|
|
281
|
+
}
|
|
282
|
+
return specialCharacters.includes(character)
|
|
283
|
+
? `\\${character}`
|
|
284
|
+
: character;
|
|
285
|
+
})
|
|
286
|
+
.join('');
|
|
287
|
+
return new RegExp(`^${expression}$`, 'i').test(host);
|
|
288
|
+
}
|
|
289
|
+
function isMissingFileError(error) {
|
|
290
|
+
if (!(error instanceof Error) || !('code' in error)) {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
return error.code === 'ENOENT' || error.code === 'ENOTDIR';
|
|
294
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns flags, adopted values, environment detection and built-in defaults into
|
|
3
|
+
* the one Settings object consumed by the generator. This module is deliberately
|
|
4
|
+
* synchronous and headless so scripted and interactive setup share the same path.
|
|
5
|
+
*/
|
|
6
|
+
import { maximumAgents, type AgentId } from './agents.js';
|
|
7
|
+
import type { CliFlags } from './arguments.js';
|
|
8
|
+
import type { PackageDetection, PackageManager } from './detect.js';
|
|
9
|
+
import type { ToolProbe } from './env.js';
|
|
10
|
+
import { type Settings } from './settings.js';
|
|
11
|
+
export declare const setupFieldOrder: readonly ["prefix", "pm", "tmux", "agents", "copyIgnored", "server", "caddy", "mcAlias"];
|
|
12
|
+
export type SetupField = (typeof setupFieldOrder)[number];
|
|
13
|
+
export type SetupValues = Readonly<{
|
|
14
|
+
prefix: string;
|
|
15
|
+
pm: PackageManager;
|
|
16
|
+
tmux: boolean;
|
|
17
|
+
agents: readonly AgentId[];
|
|
18
|
+
copyIgnored: boolean;
|
|
19
|
+
server: boolean;
|
|
20
|
+
caddy: boolean;
|
|
21
|
+
mcAlias: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
export type SetupFlags = Readonly<{
|
|
24
|
+
prefix?: string;
|
|
25
|
+
pm?: string;
|
|
26
|
+
tmux?: boolean;
|
|
27
|
+
agents?: string;
|
|
28
|
+
copyIgnored?: boolean;
|
|
29
|
+
server?: boolean;
|
|
30
|
+
caddy?: boolean;
|
|
31
|
+
mcAlias?: boolean;
|
|
32
|
+
}>;
|
|
33
|
+
export type FixedSettings = Readonly<Pick<Settings, 'trunkVersion' | 'generatedOn' | 'repoName' | 'hostLabel'> & Partial<Pick<Settings, 'appDir' | 'devScript' | 'scripts'>>>;
|
|
34
|
+
export type ResolveOptions = Readonly<{
|
|
35
|
+
fixed: FixedSettings;
|
|
36
|
+
flags?: SetupFlags;
|
|
37
|
+
/** Values entered by the form. Flags still take precedence over them. */
|
|
38
|
+
answers?: Partial<SetupValues>;
|
|
39
|
+
/** Best-effort values read from an existing wt.toml during overwrite. */
|
|
40
|
+
existing?: Partial<SetupValues>;
|
|
41
|
+
packageDetection?: PackageDetection;
|
|
42
|
+
tools?: Pick<ToolProbe, 'tmux' | 'caddy' | 'brew' | 'agents'>;
|
|
43
|
+
/** Overrides for callers with a command-specific built-in default. */
|
|
44
|
+
defaults?: Partial<SetupValues>;
|
|
45
|
+
/** `--yes`: accept every proposed value without opening the form. */
|
|
46
|
+
acceptDefaults?: boolean;
|
|
47
|
+
}>;
|
|
48
|
+
export type ValueSource = 'flag' | 'answer' | 'existing' | 'detection' | 'built-in' | 'dependency';
|
|
49
|
+
export type ResolutionWarning = Readonly<{
|
|
50
|
+
field?: SetupField;
|
|
51
|
+
message: string;
|
|
52
|
+
}>;
|
|
53
|
+
export type AgentOption = Readonly<{
|
|
54
|
+
id: AgentId;
|
|
55
|
+
command: string;
|
|
56
|
+
installed: boolean;
|
|
57
|
+
}>;
|
|
58
|
+
export type CaddyAvailability = Readonly<{
|
|
59
|
+
kind: 'installed';
|
|
60
|
+
}> | Readonly<{
|
|
61
|
+
kind: 'brew-installable';
|
|
62
|
+
executable: string;
|
|
63
|
+
arguments: readonly ['install', 'caddy'];
|
|
64
|
+
}> | Readonly<{
|
|
65
|
+
kind: 'missing';
|
|
66
|
+
installUrl: 'https://caddyserver.com/docs/install';
|
|
67
|
+
}>;
|
|
68
|
+
type QuestionBase<Field extends SetupField, Value> = Readonly<{
|
|
69
|
+
field: Field;
|
|
70
|
+
label: string;
|
|
71
|
+
flag: string;
|
|
72
|
+
defaultValue: Value;
|
|
73
|
+
source: Exclude<ValueSource, 'flag' | 'answer' | 'dependency'>;
|
|
74
|
+
}>;
|
|
75
|
+
export type OpenQuestion = (QuestionBase<'prefix', string> & Readonly<{
|
|
76
|
+
kind: 'text';
|
|
77
|
+
help: 'keep it unique across your repos';
|
|
78
|
+
}>) | (QuestionBase<'pm', PackageManager> & Readonly<{
|
|
79
|
+
kind: 'select';
|
|
80
|
+
options: readonly PackageManager[];
|
|
81
|
+
needsConfirmation: boolean;
|
|
82
|
+
}>) | (QuestionBase<'tmux', boolean> & Readonly<{
|
|
83
|
+
kind: 'boolean';
|
|
84
|
+
installed: boolean;
|
|
85
|
+
}>) | (QuestionBase<'agents', readonly AgentId[]> & Readonly<{
|
|
86
|
+
kind: 'multi-select';
|
|
87
|
+
maximum: typeof maximumAgents;
|
|
88
|
+
options: readonly AgentOption[];
|
|
89
|
+
}>) | (QuestionBase<'copyIgnored', boolean> & Readonly<{
|
|
90
|
+
kind: 'boolean';
|
|
91
|
+
}>) | (QuestionBase<'server', boolean> & Readonly<{
|
|
92
|
+
kind: 'boolean';
|
|
93
|
+
}>) | (QuestionBase<'caddy', boolean> & Readonly<{
|
|
94
|
+
kind: 'boolean';
|
|
95
|
+
availability: CaddyAvailability;
|
|
96
|
+
}>) | (QuestionBase<'mcAlias', boolean> & Readonly<{
|
|
97
|
+
kind: 'boolean';
|
|
98
|
+
}>);
|
|
99
|
+
type ResolutionDetails = Readonly<{
|
|
100
|
+
draft: Settings;
|
|
101
|
+
questions: readonly OpenQuestion[];
|
|
102
|
+
sources: Readonly<Record<SetupField, ValueSource>>;
|
|
103
|
+
warnings: readonly ResolutionWarning[];
|
|
104
|
+
}>;
|
|
105
|
+
export type CompleteResolution = ResolutionDetails & Readonly<{
|
|
106
|
+
kind: 'complete';
|
|
107
|
+
settings: Settings;
|
|
108
|
+
}>;
|
|
109
|
+
export type NeedsInputResolution = ResolutionDetails & Readonly<{
|
|
110
|
+
kind: 'questions';
|
|
111
|
+
questions: readonly OpenQuestion[];
|
|
112
|
+
}>;
|
|
113
|
+
export type Resolution = CompleteResolution | NeedsInputResolution;
|
|
114
|
+
/** Resolve a setup without reading the terminal or filesystem. */
|
|
115
|
+
export declare function resolve(options: ResolveOptions): Resolution;
|
|
116
|
+
/** Maps Meow's names onto the names used by Settings. */
|
|
117
|
+
export declare function setupFlagsFromCli(flags: CliFlags): SetupFlags;
|
|
118
|
+
export type RerunCommand = Readonly<{
|
|
119
|
+
arguments: readonly string[];
|
|
120
|
+
display: string;
|
|
121
|
+
}>;
|
|
122
|
+
/**
|
|
123
|
+
* Makes a non-interactive rerun explicit and reproducible instead of merely
|
|
124
|
+
* suggesting `--yes`, whose defaults could change with the machine.
|
|
125
|
+
*/
|
|
126
|
+
export declare function buildRerunCommand(executable: string, originalArguments: readonly string[], resolution: NeedsInputResolution): RerunCommand;
|
|
127
|
+
export {};
|