@hfossli/dev 0.1.0
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/bin/_dev.js +7 -0
- package/package.json +12 -0
- package/src/cli.js +174 -0
- package/src/internal/commands/attach.js +43 -0
- package/src/internal/commands/cmd.js +25 -0
- package/src/internal/commands/logs.js +22 -0
- package/src/internal/commands/shared.js +64 -0
- package/src/internal/commands/start-restart.js +119 -0
- package/src/internal/commands/stop.js +40 -0
- package/src/internal/commands/tail.js +81 -0
- package/src/internal/commands/tool.js +32 -0
- package/src/internal/config/load-config.js +209 -0
- package/src/internal/config/normalize-config.js +108 -0
- package/src/internal/parse-args.js +116 -0
- package/src/internal/runtime/process.js +41 -0
- package/src/internal/runtime/shell.js +14 -0
- package/src/internal/runtime/tmux.js +170 -0
- package/src/internal/usage.js +44 -0
package/bin/_dev.js
ADDED
package/package.json
ADDED
package/src/cli.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
const path = require("node:path");
|
|
2
|
+
const { usage } = require("./internal/usage.js");
|
|
3
|
+
const { parseArgs } = require("./internal/parse-args.js");
|
|
4
|
+
const { findConfigPath, loadRuntimeConfig } = require("./internal/config/load-config.js");
|
|
5
|
+
const { getItemDescription, createUsageError } = require("./internal/config/normalize-config.js");
|
|
6
|
+
const { createTmuxController } = require("./internal/runtime/tmux.js");
|
|
7
|
+
const { die } = require("./internal/runtime/process.js");
|
|
8
|
+
const { sessionName } = require("@hfossli/dev-helpers/worktree");
|
|
9
|
+
const { handleTool } = require("./internal/commands/tool.js");
|
|
10
|
+
const { handleCmd } = require("./internal/commands/cmd.js");
|
|
11
|
+
const { handleStartOrRestart } = require("./internal/commands/start-restart.js");
|
|
12
|
+
const { handleStop } = require("./internal/commands/stop.js");
|
|
13
|
+
const { handleLogs } = require("./internal/commands/logs.js");
|
|
14
|
+
const { handleTail } = require("./internal/commands/tail.js");
|
|
15
|
+
const { handleAttach } = require("./internal/commands/attach.js");
|
|
16
|
+
|
|
17
|
+
function buildRuntime({ root, config, configPath }) {
|
|
18
|
+
const sessionId = sessionName(root);
|
|
19
|
+
const tmuxSession = `dev-e2e-${sessionId}`;
|
|
20
|
+
const apps = config.apps;
|
|
21
|
+
const tools = config.tools;
|
|
22
|
+
const appNames = Object.keys(apps).sort();
|
|
23
|
+
const toolNames = Object.keys(tools).sort();
|
|
24
|
+
const appEntries = appNames.map((name) => ({
|
|
25
|
+
name,
|
|
26
|
+
description: getItemDescription(apps[name]),
|
|
27
|
+
}));
|
|
28
|
+
const toolEntries = toolNames.map((name) => ({
|
|
29
|
+
name,
|
|
30
|
+
description: getItemDescription(tools[name]),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
appNames,
|
|
35
|
+
apps,
|
|
36
|
+
configPath,
|
|
37
|
+
root,
|
|
38
|
+
tmux: createTmuxController({
|
|
39
|
+
cliScriptPath: path.resolve(__dirname, "../bin/_dev.js"),
|
|
40
|
+
}),
|
|
41
|
+
tmuxSession,
|
|
42
|
+
toolNames,
|
|
43
|
+
tools,
|
|
44
|
+
usageText: usage(appEntries, toolEntries),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validateParsedArgs(parsed, baseUsageText) {
|
|
49
|
+
const validCommands = new Set([
|
|
50
|
+
"start",
|
|
51
|
+
"restart",
|
|
52
|
+
"stop",
|
|
53
|
+
"attach",
|
|
54
|
+
"logs",
|
|
55
|
+
"tail",
|
|
56
|
+
"tool",
|
|
57
|
+
"cmd",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
if (!validCommands.has(parsed.command)) {
|
|
61
|
+
const error = new Error(`Error: unknown command "${parsed.command}"`);
|
|
62
|
+
error.isUsageError = true;
|
|
63
|
+
error.usageText = baseUsageText;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isStartLike = parsed.command === "start" || parsed.command === "restart";
|
|
68
|
+
if (!isStartLike && parsed.attachRequested) {
|
|
69
|
+
throw createUsageError("Error: --attach is only supported for start/restart.");
|
|
70
|
+
}
|
|
71
|
+
const allowsLines =
|
|
72
|
+
parsed.command === "logs" ||
|
|
73
|
+
parsed.command === "tail" ||
|
|
74
|
+
(parsed.command === "attach" && !parsed.app) ||
|
|
75
|
+
(isStartLike && parsed.attachRequested && parsed.app === "all");
|
|
76
|
+
|
|
77
|
+
if (parsed.linesOverride !== null && !allowsLines) {
|
|
78
|
+
throw createUsageError(
|
|
79
|
+
"Error: --lines is only supported for logs, tail, attach, or start/restart all with --attach."
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (parsed.untilMarker !== null && parsed.command !== "tail") {
|
|
83
|
+
throw createUsageError("Error: --until-marker is only supported for tail.");
|
|
84
|
+
}
|
|
85
|
+
if (parsed.untilTimeoutSeconds !== null && parsed.command !== "tail") {
|
|
86
|
+
throw createUsageError("Error: --until-timeout is only supported for tail.");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderError(error, fallbackUsageText) {
|
|
91
|
+
if (error.onlyUsage) {
|
|
92
|
+
process.stderr.write(`${fallbackUsageText}\n`);
|
|
93
|
+
process.exit(error.exitCode || 2);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (error.isUsageError && error.usageText) {
|
|
97
|
+
process.stderr.write(`${error.message}\n\n${error.usageText}\n`);
|
|
98
|
+
process.exit(error.exitCode || 1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (error.isUsageError) {
|
|
102
|
+
die(error.message, error.exitCode || 1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function main(argv = process.argv.slice(2)) {
|
|
109
|
+
const baseUsageText = usage();
|
|
110
|
+
let parsed;
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
parsed = parseArgs(argv);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
renderError(error, baseUsageText);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (parsed.help) {
|
|
120
|
+
process.stdout.write(`${baseUsageText}\n`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!parsed.command) {
|
|
125
|
+
process.stderr.write(`${baseUsageText}\n`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
validateParsedArgs(parsed, baseUsageText);
|
|
131
|
+
|
|
132
|
+
const cwd = process.cwd();
|
|
133
|
+
const configPath = findConfigPath(cwd);
|
|
134
|
+
const root = path.dirname(configPath);
|
|
135
|
+
const session = sessionName(root);
|
|
136
|
+
const { config } = await loadRuntimeConfig({ cwd, configPath, root, session });
|
|
137
|
+
const runtime = buildRuntime({ root, config, configPath });
|
|
138
|
+
|
|
139
|
+
switch (parsed.command) {
|
|
140
|
+
case "tool":
|
|
141
|
+
handleTool(parsed, runtime);
|
|
142
|
+
return;
|
|
143
|
+
case "cmd":
|
|
144
|
+
handleCmd(parsed, runtime);
|
|
145
|
+
return;
|
|
146
|
+
case "start":
|
|
147
|
+
case "restart":
|
|
148
|
+
handleStartOrRestart(parsed, runtime);
|
|
149
|
+
return;
|
|
150
|
+
case "stop":
|
|
151
|
+
handleStop(parsed, runtime);
|
|
152
|
+
return;
|
|
153
|
+
case "logs":
|
|
154
|
+
handleLogs(parsed, runtime);
|
|
155
|
+
return;
|
|
156
|
+
case "tail":
|
|
157
|
+
handleTail(parsed, runtime);
|
|
158
|
+
return;
|
|
159
|
+
case "attach":
|
|
160
|
+
handleAttach(parsed, runtime);
|
|
161
|
+
return;
|
|
162
|
+
default:
|
|
163
|
+
throw createUsageError(`Error: unknown command "${parsed.command}"`);
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
renderError(error, baseUsageText);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
buildRuntime,
|
|
172
|
+
main,
|
|
173
|
+
validateParsedArgs,
|
|
174
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const { createUsageError } = require("../config/normalize-config.js");
|
|
2
|
+
const { ensureAppDefined, resolveLineCount } = require("./shared.js");
|
|
3
|
+
|
|
4
|
+
function handleAttach(parsed, runtime) {
|
|
5
|
+
const { appNames, apps, root, tmux, tmuxSession, usageText } = runtime;
|
|
6
|
+
|
|
7
|
+
tmux.ensureInstalled();
|
|
8
|
+
|
|
9
|
+
if (!tmux.sessionExists(tmuxSession)) {
|
|
10
|
+
throw createUsageError(`Error: session "${tmuxSession}" does not exist.`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
tmux.enableMouse(tmuxSession);
|
|
14
|
+
|
|
15
|
+
if (parsed.app) {
|
|
16
|
+
if (parsed.linesOverride !== null) {
|
|
17
|
+
const error = new Error("Error: attach <app> does not support --lines.");
|
|
18
|
+
error.isUsageError = true;
|
|
19
|
+
error.usageText = usageText;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
ensureAppDefined({ appName: parsed.app, apps, usageText });
|
|
24
|
+
if (!tmux.windowExists(tmuxSession, parsed.app)) {
|
|
25
|
+
throw createUsageError(`Error: window "${parsed.app}" does not exist in session "${tmuxSession}".`);
|
|
26
|
+
}
|
|
27
|
+
tmux.selectWindow(tmuxSession, parsed.app);
|
|
28
|
+
tmux.attachSession(tmuxSession);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
tmux.openSplitAttachWindow({
|
|
33
|
+
root,
|
|
34
|
+
tmuxSession,
|
|
35
|
+
appNames,
|
|
36
|
+
lines: resolveLineCount(parsed.linesOverride),
|
|
37
|
+
});
|
|
38
|
+
tmux.attachSession(tmuxSession);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = {
|
|
42
|
+
handleAttach,
|
|
43
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const { resolveStartCommand } = require("../config/normalize-config.js");
|
|
2
|
+
const { shellQuote } = require("../runtime/shell.js");
|
|
3
|
+
const { dieWithUsage } = require("./shared.js");
|
|
4
|
+
|
|
5
|
+
function handleCmd(parsed, runtime) {
|
|
6
|
+
const { apps, root, usageText } = runtime;
|
|
7
|
+
if (!parsed.app) {
|
|
8
|
+
const error = new Error(usageText);
|
|
9
|
+
error.exitCode = 2;
|
|
10
|
+
error.onlyUsage = true;
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (!Object.prototype.hasOwnProperty.call(apps, parsed.app)) {
|
|
15
|
+
dieWithUsage(`Error: unknown app "${parsed.app}"`, usageText);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const startCommand = resolveStartCommand(parsed.app, apps[parsed.app]);
|
|
19
|
+
const wrappedCommand = `cd ${shellQuote(root)} && ${startCommand}`;
|
|
20
|
+
process.stdout.write(`${wrappedCommand}\n`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = {
|
|
24
|
+
handleCmd,
|
|
25
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const { ensureAppTarget, resolveLineCount } = require("./shared.js");
|
|
2
|
+
|
|
3
|
+
function handleLogs(parsed, runtime) {
|
|
4
|
+
const { apps, tmux, tmuxSession, usageText } = runtime;
|
|
5
|
+
ensureAppTarget({
|
|
6
|
+
appName: parsed.app,
|
|
7
|
+
apps,
|
|
8
|
+
usageText,
|
|
9
|
+
tmuxSession,
|
|
10
|
+
tmux,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const lines = resolveLineCount(parsed.linesOverride);
|
|
14
|
+
const snapshot = tmux.captureWindowLogs(tmuxSession, parsed.app, lines);
|
|
15
|
+
if (snapshot) {
|
|
16
|
+
process.stdout.write(`${snapshot}\n`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = {
|
|
21
|
+
handleLogs,
|
|
22
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const { createUsageError } = require("../config/normalize-config.js");
|
|
2
|
+
|
|
3
|
+
function resolveLineCount(linesOverride) {
|
|
4
|
+
if (Number.isInteger(linesOverride) && linesOverride > 0) return linesOverride;
|
|
5
|
+
const fromEnv = Number.parseInt(String(process.env.TAIL_LINES || ""), 10);
|
|
6
|
+
if (Number.isInteger(fromEnv) && fromEnv > 0) return fromEnv;
|
|
7
|
+
return 120;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function dieWithUsage(message, usageText, code = 1) {
|
|
11
|
+
const error = createUsageError(message);
|
|
12
|
+
error.usageText = usageText;
|
|
13
|
+
error.exitCode = code;
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ensureAppDefined({ appName, apps, usageText }) {
|
|
18
|
+
if (!appName) {
|
|
19
|
+
const error = new Error(usageText);
|
|
20
|
+
error.exitCode = 2;
|
|
21
|
+
error.onlyUsage = true;
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!Object.prototype.hasOwnProperty.call(apps, appName)) {
|
|
26
|
+
dieWithUsage(`Error: unknown app "${appName}"`, usageText);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ensureAppTarget({ appName, apps, usageText, tmuxSession, tmux }) {
|
|
31
|
+
ensureAppDefined({ appName, apps, usageText });
|
|
32
|
+
tmux.ensureInstalled();
|
|
33
|
+
|
|
34
|
+
if (!tmux.sessionExists(tmuxSession)) {
|
|
35
|
+
throw createUsageError(`Error: session "${tmuxSession}" does not exist.`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!tmux.windowExists(tmuxSession, appName)) {
|
|
39
|
+
throw createUsageError(`Error: window "${appName}" does not exist in session "${tmuxSession}".`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getOverlapCount(previousLines, nextLines) {
|
|
44
|
+
const maxOverlap = Math.min(previousLines.length, nextLines.length);
|
|
45
|
+
for (let overlap = maxOverlap; overlap >= 0; overlap--) {
|
|
46
|
+
let equal = true;
|
|
47
|
+
for (let index = 0; index < overlap; index++) {
|
|
48
|
+
if (previousLines[previousLines.length - overlap + index] !== nextLines[index]) {
|
|
49
|
+
equal = false;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (equal) return overlap;
|
|
54
|
+
}
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = {
|
|
59
|
+
dieWithUsage,
|
|
60
|
+
ensureAppDefined,
|
|
61
|
+
ensureAppTarget,
|
|
62
|
+
getOverlapCount,
|
|
63
|
+
resolveLineCount,
|
|
64
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const { resolveStartCommand } = require("../config/normalize-config.js");
|
|
2
|
+
const { shellQuote } = require("../runtime/shell.js");
|
|
3
|
+
const { createUsageError } = require("../config/normalize-config.js");
|
|
4
|
+
const { dieWithUsage, resolveLineCount } = require("./shared.js");
|
|
5
|
+
|
|
6
|
+
function buildWrappedCommand(root, command) {
|
|
7
|
+
return `cd ${shellQuote(root)} && ${command}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function handleStartOrRestart(parsed, runtime) {
|
|
11
|
+
const { apps, appNames, root, tmux, tmuxSession, usageText } = runtime;
|
|
12
|
+
const isRestart = parsed.command === "restart";
|
|
13
|
+
|
|
14
|
+
if (!parsed.app) {
|
|
15
|
+
const error = new Error(usageText);
|
|
16
|
+
error.exitCode = 2;
|
|
17
|
+
error.onlyUsage = true;
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
tmux.ensureInstalled();
|
|
22
|
+
let sessionExists = tmux.sessionExists(tmuxSession);
|
|
23
|
+
|
|
24
|
+
if (parsed.app === "all") {
|
|
25
|
+
if (appNames.length === 0) {
|
|
26
|
+
throw createUsageError("Error: no apps are configured in the active dev config.");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!isRestart && sessionExists) {
|
|
30
|
+
const existingWindows = appNames.filter((name) => tmux.windowExists(tmuxSession, name));
|
|
31
|
+
if (existingWindows.length > 0) {
|
|
32
|
+
throw createUsageError(
|
|
33
|
+
`Error: cannot start all because these windows already exist in session "${tmuxSession}": ${existingWindows.join(", ")}.`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (isRestart && sessionExists) {
|
|
39
|
+
for (const name of appNames) {
|
|
40
|
+
if (tmux.windowExists(tmuxSession, name)) {
|
|
41
|
+
tmux.killWindow(tmuxSession, name);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
sessionExists = tmux.sessionExists(tmuxSession);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const startEntries = appNames.map((name) => ({
|
|
48
|
+
name,
|
|
49
|
+
wrappedCommand: buildWrappedCommand(root, resolveStartCommand(name, apps[name])),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
if (sessionExists) {
|
|
53
|
+
for (const entry of startEntries) {
|
|
54
|
+
tmux.newWindow(tmuxSession, entry.name, entry.wrappedCommand);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
const [firstEntry, ...restEntries] = startEntries;
|
|
58
|
+
tmux.newSession(tmuxSession, firstEntry.name, firstEntry.wrappedCommand);
|
|
59
|
+
for (const entry of restEntries) {
|
|
60
|
+
tmux.newWindow(tmuxSession, entry.name, entry.wrappedCommand);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
process.stdout.write(
|
|
65
|
+
`${isRestart ? "Restarted" : "Started"} apps ${appNames.join(", ")} in session "${tmuxSession}".\n`
|
|
66
|
+
);
|
|
67
|
+
for (const entry of startEntries) {
|
|
68
|
+
process.stdout.write(`- ${entry.name}: ${entry.wrappedCommand}\n`);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
if (!Object.prototype.hasOwnProperty.call(apps, parsed.app)) {
|
|
72
|
+
dieWithUsage(`Error: unknown app "${parsed.app}"`, usageText);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const wrappedCommand = buildWrappedCommand(root, resolveStartCommand(parsed.app, apps[parsed.app]));
|
|
76
|
+
|
|
77
|
+
if (!isRestart && sessionExists && tmux.windowExists(tmuxSession, parsed.app)) {
|
|
78
|
+
throw createUsageError(`Error: window "${parsed.app}" already exists in session "${tmuxSession}".`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (isRestart && sessionExists && tmux.windowExists(tmuxSession, parsed.app)) {
|
|
82
|
+
tmux.killWindow(tmuxSession, parsed.app);
|
|
83
|
+
sessionExists = tmux.sessionExists(tmuxSession);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (sessionExists) {
|
|
87
|
+
tmux.newWindow(tmuxSession, parsed.app, wrappedCommand);
|
|
88
|
+
} else {
|
|
89
|
+
tmux.newSession(tmuxSession, parsed.app, wrappedCommand);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
process.stdout.write(
|
|
93
|
+
`${isRestart ? "Restarted" : "Started"} app "${parsed.app}" in session "${tmuxSession}" (window "${parsed.app}").\n`
|
|
94
|
+
);
|
|
95
|
+
process.stdout.write(`Command: ${wrappedCommand}\n`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
tmux.enableMouse(tmuxSession);
|
|
99
|
+
|
|
100
|
+
if (parsed.attachRequested) {
|
|
101
|
+
if (parsed.app === "all") {
|
|
102
|
+
const lines = resolveLineCount(parsed.linesOverride);
|
|
103
|
+
tmux.openSplitAttachWindow({
|
|
104
|
+
root,
|
|
105
|
+
tmuxSession,
|
|
106
|
+
appNames,
|
|
107
|
+
lines,
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
tmux.selectWindow(tmuxSession, parsed.app);
|
|
111
|
+
}
|
|
112
|
+
tmux.attachSession(tmuxSession);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
handleStartOrRestart,
|
|
119
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const { createUsageError } = require("../config/normalize-config.js");
|
|
2
|
+
const { dieWithUsage } = require("./shared.js");
|
|
3
|
+
|
|
4
|
+
function handleStop(parsed, runtime) {
|
|
5
|
+
const { apps, tmux, tmuxSession, usageText } = runtime;
|
|
6
|
+
|
|
7
|
+
if (!parsed.app) {
|
|
8
|
+
const error = new Error(usageText);
|
|
9
|
+
error.exitCode = 2;
|
|
10
|
+
error.onlyUsage = true;
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
tmux.ensureInstalled();
|
|
15
|
+
|
|
16
|
+
if (parsed.app !== "all" && !Object.prototype.hasOwnProperty.call(apps, parsed.app)) {
|
|
17
|
+
dieWithUsage(`Error: unknown app "${parsed.app}"`, usageText);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!tmux.sessionExists(tmuxSession)) {
|
|
21
|
+
throw createUsageError(`Error: session "${tmuxSession}" does not exist.`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (parsed.app === "all") {
|
|
25
|
+
tmux.killSession(tmuxSession);
|
|
26
|
+
process.stdout.write(`Stopped session "${tmuxSession}".\n`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!tmux.windowExists(tmuxSession, parsed.app)) {
|
|
31
|
+
throw createUsageError(`Error: window "${parsed.app}" does not exist in session "${tmuxSession}".`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
tmux.killWindow(tmuxSession, parsed.app);
|
|
35
|
+
process.stdout.write(`Stopped app "${parsed.app}" in session "${tmuxSession}".\n`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
handleStop,
|
|
40
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const { ensureAppTarget, getOverlapCount, resolveLineCount } = require("./shared.js");
|
|
2
|
+
|
|
3
|
+
function handleTail(parsed, runtime) {
|
|
4
|
+
const { apps, tmux, tmuxSession, usageText } = runtime;
|
|
5
|
+
ensureAppTarget({
|
|
6
|
+
appName: parsed.app,
|
|
7
|
+
apps,
|
|
8
|
+
usageText,
|
|
9
|
+
tmuxSession,
|
|
10
|
+
tmux,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const lines = resolveLineCount(parsed.linesOverride);
|
|
14
|
+
const untilMarker = parsed.untilMarker;
|
|
15
|
+
const untilTimeoutSeconds = parsed.untilTimeoutSeconds;
|
|
16
|
+
let previousLines = [];
|
|
17
|
+
let timer = null;
|
|
18
|
+
let timeoutTimer = null;
|
|
19
|
+
let tailStopped = false;
|
|
20
|
+
|
|
21
|
+
const stopTail = (code = 0, message = "") => {
|
|
22
|
+
if (tailStopped) return;
|
|
23
|
+
tailStopped = true;
|
|
24
|
+
if (timer) clearInterval(timer);
|
|
25
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
26
|
+
if (message) {
|
|
27
|
+
const out = code === 0 ? process.stdout : process.stderr;
|
|
28
|
+
out.write(`${message}\n`);
|
|
29
|
+
}
|
|
30
|
+
process.exit(code);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const initialSnapshot = tmux.captureWindowLogs(tmuxSession, parsed.app, lines);
|
|
34
|
+
if (initialSnapshot) {
|
|
35
|
+
process.stdout.write(`${initialSnapshot}\n`);
|
|
36
|
+
previousLines = initialSnapshot.split(/\r?\n/);
|
|
37
|
+
}
|
|
38
|
+
if (untilMarker && initialSnapshot.includes(untilMarker)) {
|
|
39
|
+
stopTail(0);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (untilTimeoutSeconds !== null) {
|
|
44
|
+
const timeoutMs = Math.max(1, Math.round(untilTimeoutSeconds * 1000));
|
|
45
|
+
timeoutTimer = setTimeout(() => {
|
|
46
|
+
if (untilMarker) {
|
|
47
|
+
stopTail(
|
|
48
|
+
124,
|
|
49
|
+
`Error: marker "${untilMarker}" was not found within ${untilTimeoutSeconds} seconds.`
|
|
50
|
+
);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
stopTail(0);
|
|
54
|
+
}, timeoutMs);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
timer = setInterval(() => {
|
|
58
|
+
try {
|
|
59
|
+
const snapshot = tmux.captureWindowLogs(tmuxSession, parsed.app, lines);
|
|
60
|
+
const nextLines = snapshot ? snapshot.split(/\r?\n/) : [];
|
|
61
|
+
const overlap = getOverlapCount(previousLines, nextLines);
|
|
62
|
+
const appended = nextLines.slice(overlap);
|
|
63
|
+
if (appended.length > 0) {
|
|
64
|
+
process.stdout.write(`${appended.join("\n")}\n`);
|
|
65
|
+
}
|
|
66
|
+
previousLines = nextLines;
|
|
67
|
+
if (untilMarker && snapshot.includes(untilMarker)) {
|
|
68
|
+
stopTail(0);
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
stopTail(1, `Error: ${error.message}`);
|
|
72
|
+
}
|
|
73
|
+
}, 1000);
|
|
74
|
+
|
|
75
|
+
process.on("SIGINT", () => stopTail(0));
|
|
76
|
+
process.on("SIGTERM", () => stopTail(0));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
handleTail,
|
|
81
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const { withInheritedPath } = require("../runtime/shell.js");
|
|
2
|
+
const { resolveToolCommand } = require("../config/normalize-config.js");
|
|
3
|
+
const { run } = require("../runtime/process.js");
|
|
4
|
+
|
|
5
|
+
function handleTool(parsed, runtime) {
|
|
6
|
+
const { usageText, root, tools } = runtime;
|
|
7
|
+
const toolName = parsed.app;
|
|
8
|
+
if (!toolName) {
|
|
9
|
+
const error = new Error(usageText);
|
|
10
|
+
error.exitCode = 2;
|
|
11
|
+
error.onlyUsage = true;
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!Object.prototype.hasOwnProperty.call(tools, toolName)) {
|
|
16
|
+
const error = new Error(`Error: unknown tool "${toolName}"`);
|
|
17
|
+
error.isUsageError = true;
|
|
18
|
+
error.usageText = usageText;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const toolCommand = resolveToolCommand(toolName, tools[toolName], parsed.commandArgs);
|
|
23
|
+
const toolCommandWithPath = withInheritedPath(toolCommand);
|
|
24
|
+
|
|
25
|
+
process.stdout.write(`Running tool "${toolName}": ${toolCommand}\n`);
|
|
26
|
+
const shellPath = process.env.SHELL || "/bin/zsh";
|
|
27
|
+
run(shellPath, ["-lc", toolCommandWithPath], { cwd: root, stdio: "inherit" });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = {
|
|
31
|
+
handleTool,
|
|
32
|
+
};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { createRequire, stripTypeScriptTypes } = require("node:module");
|
|
4
|
+
const { pathToFileURL } = require("node:url");
|
|
5
|
+
const { isDefinedConfig } = require("@hfossli/dev-sdk");
|
|
6
|
+
const { sessionName } = require("@hfossli/dev-helpers/worktree");
|
|
7
|
+
const { normalizeRuntimeConfig } = require("./normalize-config.js");
|
|
8
|
+
const { createUsageError } = require("./normalize-config.js");
|
|
9
|
+
|
|
10
|
+
const CONFIG_CANDIDATE_FILENAMES = [
|
|
11
|
+
"dev.config.js",
|
|
12
|
+
"dev.config.ts",
|
|
13
|
+
"_dev.config.js",
|
|
14
|
+
"_dev.config.ts",
|
|
15
|
+
];
|
|
16
|
+
const LEGACY_CONFIG_FILENAMES = new Set(["_dev.config.js", "_dev.config.ts"]);
|
|
17
|
+
|
|
18
|
+
function stripConfigTypeScript(sourceCode) {
|
|
19
|
+
const originalEmitWarning = process.emitWarning;
|
|
20
|
+
process.emitWarning = function emitWarningPatched(warning, ...args) {
|
|
21
|
+
const type = typeof args[0] === "string" ? args[0] : warning && warning.name;
|
|
22
|
+
const message =
|
|
23
|
+
typeof warning === "string" ? warning : warning && warning.message ? warning.message : "";
|
|
24
|
+
if (type === "ExperimentalWarning" && message.includes("stripTypeScriptTypes")) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
return originalEmitWarning.call(this, warning, ...args);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
return stripTypeScriptTypes(sourceCode);
|
|
32
|
+
} finally {
|
|
33
|
+
process.emitWarning = originalEmitWarning;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function transformImportsToRequire(sourceCode) {
|
|
38
|
+
return sourceCode
|
|
39
|
+
.replace(
|
|
40
|
+
/^\s*import\s+\{([^}]+)\}\s+from\s+["']([^"']+)["'];?\s*$/gm,
|
|
41
|
+
(_match, imports, moduleName) => `const {${imports.trim()}} = require(${JSON.stringify(moduleName)});`
|
|
42
|
+
)
|
|
43
|
+
.replace(
|
|
44
|
+
/^\s*import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+["']([^"']+)["'];?\s*$/gm,
|
|
45
|
+
(_match, name, moduleName) => `const ${name} = require(${JSON.stringify(moduleName)});`
|
|
46
|
+
)
|
|
47
|
+
.replace(
|
|
48
|
+
/^\s*import\s+([A-Za-z_$][\w$]*)\s+from\s+["']([^"']+)["'];?\s*$/gm,
|
|
49
|
+
(_match, name, moduleName) =>
|
|
50
|
+
`const ${name} = (() => { const __imported = require(${JSON.stringify(moduleName)}); return __imported && __imported.default !== undefined ? __imported.default : __imported; })();`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function transformExportsToCommonJs(sourceCode) {
|
|
55
|
+
const transformed = sourceCode.replace(/\bexport\s+default\b/, "module.exports.default =");
|
|
56
|
+
if (transformed !== sourceCode) {
|
|
57
|
+
return transformed;
|
|
58
|
+
}
|
|
59
|
+
return sourceCode;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadConfigModuleFromTransformedSource(configPath, sourceCode) {
|
|
63
|
+
const stripped = configPath.endsWith(".ts") ? stripConfigTypeScript(sourceCode) : sourceCode;
|
|
64
|
+
const transformed = transformExportsToCommonJs(transformImportsToRequire(stripped));
|
|
65
|
+
|
|
66
|
+
const moduleObj = { exports: {} };
|
|
67
|
+
const localRequire = createRequire(configPath);
|
|
68
|
+
const dirname = path.dirname(configPath);
|
|
69
|
+
const runner = new Function(
|
|
70
|
+
"require",
|
|
71
|
+
"module",
|
|
72
|
+
"exports",
|
|
73
|
+
"__filename",
|
|
74
|
+
"__dirname",
|
|
75
|
+
transformed
|
|
76
|
+
);
|
|
77
|
+
runner(localRequire, moduleObj, moduleObj.exports, configPath, dirname);
|
|
78
|
+
return moduleObj.exports;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function loadModule(configPath) {
|
|
82
|
+
let loaded;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
loaded = require(configPath);
|
|
86
|
+
} catch (requireError) {
|
|
87
|
+
try {
|
|
88
|
+
const imported = await import(pathToFileURL(configPath).href);
|
|
89
|
+
loaded = imported;
|
|
90
|
+
} catch (importError) {
|
|
91
|
+
try {
|
|
92
|
+
const sourceCode = fs.readFileSync(configPath, "utf8");
|
|
93
|
+
loaded = loadConfigModuleFromTransformedSource(configPath, sourceCode);
|
|
94
|
+
} catch (transpileError) {
|
|
95
|
+
const detail =
|
|
96
|
+
transpileError && transpileError.message
|
|
97
|
+
? transpileError.message
|
|
98
|
+
: importError && importError.message
|
|
99
|
+
? importError.message
|
|
100
|
+
: requireError.message;
|
|
101
|
+
throw createUsageError(`Error: failed to load ${configPath}: ${detail}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!loaded || (typeof loaded !== "object" && typeof loaded !== "function")) {
|
|
107
|
+
throw createUsageError(`Error: ${configPath} must export a config function or module object.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return loaded;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function findConfigPath(cwd = process.cwd()) {
|
|
114
|
+
let currentDir = path.resolve(cwd);
|
|
115
|
+
|
|
116
|
+
while (true) {
|
|
117
|
+
for (const filename of CONFIG_CANDIDATE_FILENAMES) {
|
|
118
|
+
const candidate = path.join(currentDir, filename);
|
|
119
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
120
|
+
return candidate;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const parentDir = path.dirname(currentDir);
|
|
125
|
+
if (parentDir === currentDir) break;
|
|
126
|
+
currentDir = parentDir;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
throw createUsageError(
|
|
130
|
+
`Error: failed to load config from ${path.resolve(cwd)}: no ${CONFIG_CANDIDATE_FILENAMES.join(", ")} file was found in this directory or any parent directory.`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getFactoryCandidate(loaded) {
|
|
135
|
+
const candidates = [
|
|
136
|
+
typeof loaded === "function" ? loaded : null,
|
|
137
|
+
loaded && loaded.config,
|
|
138
|
+
loaded && loaded.default,
|
|
139
|
+
loaded && loaded.default && loaded.default.config,
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
return candidates.find((candidate) => typeof candidate === "function") || null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildConfigContext({ root, cwd, session }) {
|
|
146
|
+
return {
|
|
147
|
+
root,
|
|
148
|
+
cwd,
|
|
149
|
+
session,
|
|
150
|
+
env: { ...process.env },
|
|
151
|
+
platform: process.platform,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function warnLegacyConfig(configPath) {
|
|
156
|
+
process.stderr.write(
|
|
157
|
+
`[deprecation] ${configPath} is using the legacy config(session) API. ` +
|
|
158
|
+
`Switch to defineConfig(({ root, cwd, session, env, platform }) => ({ ... })).\n`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function warnLegacyFilename(configPath) {
|
|
163
|
+
process.stderr.write(
|
|
164
|
+
`[deprecation] ${configPath} uses the legacy config filename. ` +
|
|
165
|
+
`Rename it to dev.config.js or dev.config.ts.\n`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function loadRuntimeConfig({ configPath, root, cwd, session }) {
|
|
170
|
+
const resolvedConfigPath = configPath || findConfigPath(cwd);
|
|
171
|
+
const configRoot = root || path.dirname(resolvedConfigPath);
|
|
172
|
+
const resolvedSession = session || sessionName(configRoot);
|
|
173
|
+
const loaded = await loadModule(resolvedConfigPath);
|
|
174
|
+
const configFactory = getFactoryCandidate(loaded);
|
|
175
|
+
|
|
176
|
+
if (!configFactory) {
|
|
177
|
+
throw createUsageError(
|
|
178
|
+
`Error: ${resolvedConfigPath} must export defineConfig(...) or legacy config(session).`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const mode = isDefinedConfig(configFactory) ? "modern" : "legacy";
|
|
183
|
+
if (mode === "legacy") {
|
|
184
|
+
warnLegacyConfig(resolvedConfigPath);
|
|
185
|
+
}
|
|
186
|
+
if (LEGACY_CONFIG_FILENAMES.has(path.basename(resolvedConfigPath))) {
|
|
187
|
+
warnLegacyFilename(resolvedConfigPath);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const rawConfig =
|
|
191
|
+
mode === "modern"
|
|
192
|
+
? configFactory(buildConfigContext({ root: configRoot, cwd, session: resolvedSession }))
|
|
193
|
+
: configFactory(resolvedSession);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
configPath: resolvedConfigPath,
|
|
197
|
+
root: configRoot,
|
|
198
|
+
session: resolvedSession,
|
|
199
|
+
mode,
|
|
200
|
+
config: normalizeRuntimeConfig(rawConfig, resolvedConfigPath),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
buildConfigContext,
|
|
206
|
+
findConfigPath,
|
|
207
|
+
loadRuntimeConfig,
|
|
208
|
+
loadConfigModuleFromTransformedSource,
|
|
209
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const { shellQuote } = require("../runtime/shell.js");
|
|
2
|
+
|
|
3
|
+
function createUsageError(message) {
|
|
4
|
+
const error = new Error(message);
|
|
5
|
+
error.isUsageError = true;
|
|
6
|
+
return error;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function normalizeRuntimeConfig(cfg, configPath) {
|
|
10
|
+
if (!cfg || typeof cfg !== "object") {
|
|
11
|
+
throw createUsageError(`Error: config in ${configPath} must return an object.`);
|
|
12
|
+
}
|
|
13
|
+
if (!cfg.apps || typeof cfg.apps !== "object") {
|
|
14
|
+
throw createUsageError(`Error: config in ${configPath} must return an apps object.`);
|
|
15
|
+
}
|
|
16
|
+
if (cfg.tools !== undefined && (!cfg.tools || typeof cfg.tools !== "object")) {
|
|
17
|
+
throw createUsageError(`Error: config in ${configPath} must return tools as an object when provided.`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
for (const [appName, appDef] of Object.entries(cfg.apps)) {
|
|
21
|
+
if (typeof appDef === "string") continue;
|
|
22
|
+
if (!appDef || typeof appDef !== "object") {
|
|
23
|
+
throw createUsageError(`Error: app "${appName}" in ${configPath} must be a string or object.`);
|
|
24
|
+
}
|
|
25
|
+
if (appDef.description !== undefined && typeof appDef.description !== "string") {
|
|
26
|
+
throw createUsageError(`Error: app "${appName}" description in ${configPath} must be a string.`);
|
|
27
|
+
}
|
|
28
|
+
if (appDef.start !== undefined && typeof appDef.start !== "string" && typeof appDef.start !== "function") {
|
|
29
|
+
throw createUsageError(`Error: app "${appName}" start in ${configPath} must be a string or function.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (cfg.tools) {
|
|
34
|
+
for (const [toolName, toolDef] of Object.entries(cfg.tools)) {
|
|
35
|
+
if (typeof toolDef === "string" || typeof toolDef === "function") continue;
|
|
36
|
+
if (!toolDef || typeof toolDef !== "object") {
|
|
37
|
+
throw createUsageError(`Error: tool "${toolName}" in ${configPath} must be a string, function, or object.`);
|
|
38
|
+
}
|
|
39
|
+
if (toolDef.description !== undefined && typeof toolDef.description !== "string") {
|
|
40
|
+
throw createUsageError(`Error: tool "${toolName}" description in ${configPath} must be a string.`);
|
|
41
|
+
}
|
|
42
|
+
if (toolDef.run !== undefined && typeof toolDef.run !== "string" && typeof toolDef.run !== "function") {
|
|
43
|
+
throw createUsageError(`Error: tool "${toolName}" run in ${configPath} must be a string or function.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
apps: cfg.apps,
|
|
50
|
+
tools: cfg.tools && typeof cfg.tools === "object" ? cfg.tools : {},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getItemDescription(itemDef) {
|
|
55
|
+
if (!itemDef || typeof itemDef !== "object") return "";
|
|
56
|
+
if (typeof itemDef.description !== "string") return "";
|
|
57
|
+
return itemDef.description.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveStartCommand(appName, appDef) {
|
|
61
|
+
if (typeof appDef === "string") return appDef;
|
|
62
|
+
if (!appDef || typeof appDef !== "object") {
|
|
63
|
+
throw createUsageError(`Error: app "${appName}" must be a string command or an object with start.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { start } = appDef;
|
|
67
|
+
if (typeof start === "string") return start;
|
|
68
|
+
if (typeof start === "function") {
|
|
69
|
+
const command = start();
|
|
70
|
+
if (typeof command !== "string" || !command.trim()) {
|
|
71
|
+
throw createUsageError(`Error: app "${appName}" start() must return a non-empty string.`);
|
|
72
|
+
}
|
|
73
|
+
return command;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
throw createUsageError(`Error: app "${appName}" must define start as a string or function.`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolveToolCommand(toolName, toolDef, toolArgs) {
|
|
80
|
+
let executableDef = toolDef;
|
|
81
|
+
if (
|
|
82
|
+
toolDef &&
|
|
83
|
+
typeof toolDef === "object" &&
|
|
84
|
+
(typeof toolDef.run === "function" || typeof toolDef.run === "string")
|
|
85
|
+
) {
|
|
86
|
+
executableDef = toolDef.run;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typeof executableDef === "string") return executableDef;
|
|
90
|
+
if (typeof executableDef === "function") {
|
|
91
|
+
const quotedArgs = toolArgs.map((arg) => shellQuote(arg)).join(" ");
|
|
92
|
+
const plainArgs = toolArgs.join(" ");
|
|
93
|
+
const command = executableDef(quotedArgs, toolArgs, plainArgs);
|
|
94
|
+
if (typeof command !== "string" || !command.trim()) {
|
|
95
|
+
throw createUsageError(`Error: tool "${toolName}" must return a non-empty command string.`);
|
|
96
|
+
}
|
|
97
|
+
return command;
|
|
98
|
+
}
|
|
99
|
+
throw createUsageError(`Error: tool "${toolName}" must be a string/function or an object with run.`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
createUsageError,
|
|
104
|
+
getItemDescription,
|
|
105
|
+
normalizeRuntimeConfig,
|
|
106
|
+
resolveStartCommand,
|
|
107
|
+
resolveToolCommand,
|
|
108
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
function fail(message) {
|
|
2
|
+
const error = new Error(message);
|
|
3
|
+
error.isUsageError = true;
|
|
4
|
+
throw error;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function parsePositiveInteger(value, flag) {
|
|
8
|
+
const parsed = Number.parseInt(value, 10);
|
|
9
|
+
if (!value || !Number.isInteger(parsed) || parsed <= 0) {
|
|
10
|
+
fail(`Error: ${flag} requires a positive integer value.`);
|
|
11
|
+
}
|
|
12
|
+
return parsed;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parsePositiveNumber(value, flag) {
|
|
16
|
+
const parsed = Number(value);
|
|
17
|
+
if (!value || !Number.isFinite(parsed) || parsed <= 0) {
|
|
18
|
+
fail(`Error: ${flag} requires a positive number of seconds.`);
|
|
19
|
+
}
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseArgs(argv) {
|
|
24
|
+
const positional = [];
|
|
25
|
+
let linesOverride = null;
|
|
26
|
+
let untilMarker = null;
|
|
27
|
+
let untilTimeoutSeconds = null;
|
|
28
|
+
let attachRequested = false;
|
|
29
|
+
let help = false;
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < argv.length; i++) {
|
|
32
|
+
const arg = argv[i];
|
|
33
|
+
const command = positional[0] || "";
|
|
34
|
+
|
|
35
|
+
if (command === "tool") {
|
|
36
|
+
positional.push(arg);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (arg === "-h" || arg === "--help") {
|
|
41
|
+
help = true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (arg === "--attach" || arg === "-a") {
|
|
45
|
+
attachRequested = true;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (arg === "--lines") {
|
|
49
|
+
const value = argv[i + 1];
|
|
50
|
+
if (!value || value.startsWith("-")) {
|
|
51
|
+
fail("Error: --lines requires a positive integer value.");
|
|
52
|
+
}
|
|
53
|
+
linesOverride = parsePositiveInteger(value, "--lines");
|
|
54
|
+
i++;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (arg.startsWith("--lines=")) {
|
|
58
|
+
linesOverride = parsePositiveInteger(arg.slice("--lines=".length), "--lines");
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (arg === "--until-marker") {
|
|
62
|
+
const value = argv[i + 1];
|
|
63
|
+
if (!value || value.startsWith("-")) {
|
|
64
|
+
fail("Error: --until-marker requires a non-empty value.");
|
|
65
|
+
}
|
|
66
|
+
untilMarker = value;
|
|
67
|
+
i++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (arg.startsWith("--until-marker=")) {
|
|
71
|
+
untilMarker = arg.slice("--until-marker=".length);
|
|
72
|
+
if (!untilMarker) {
|
|
73
|
+
fail("Error: --until-marker requires a non-empty value.");
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (arg === "--until-timeout") {
|
|
78
|
+
const value = argv[i + 1];
|
|
79
|
+
if (!value || value.startsWith("-")) {
|
|
80
|
+
fail("Error: --until-timeout requires a positive number of seconds.");
|
|
81
|
+
}
|
|
82
|
+
untilTimeoutSeconds = parsePositiveNumber(value, "--until-timeout");
|
|
83
|
+
i++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (arg.startsWith("--until-timeout=")) {
|
|
87
|
+
untilTimeoutSeconds = parsePositiveNumber(
|
|
88
|
+
arg.slice("--until-timeout=".length),
|
|
89
|
+
"--until-timeout"
|
|
90
|
+
);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (arg.startsWith("-")) fail(`Error: unknown option "${arg}"`);
|
|
94
|
+
positional.push(arg);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const command = positional[0] || "";
|
|
98
|
+
if (command !== "tool" && positional.length > 2) {
|
|
99
|
+
fail(`Error: unexpected argument "${positional[2]}"`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
help,
|
|
104
|
+
command,
|
|
105
|
+
app: positional[1] || "",
|
|
106
|
+
commandArgs: command === "tool" ? positional.slice(2) : [],
|
|
107
|
+
linesOverride,
|
|
108
|
+
untilMarker,
|
|
109
|
+
untilTimeoutSeconds,
|
|
110
|
+
attachRequested,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
parseArgs,
|
|
116
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const { spawnSync } = require("node:child_process");
|
|
2
|
+
|
|
3
|
+
function die(message, code = 1) {
|
|
4
|
+
const text = String(message);
|
|
5
|
+
process.stderr.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
6
|
+
process.exit(code);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function run(cmd, args = [], options = {}) {
|
|
10
|
+
const result = spawnSync(cmd, args, {
|
|
11
|
+
cwd: options.cwd,
|
|
12
|
+
env: options.env,
|
|
13
|
+
encoding: "utf8",
|
|
14
|
+
stdio: options.stdio || "pipe",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const status = Number.isInteger(result.status) ? result.status : 1;
|
|
18
|
+
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
|
|
19
|
+
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
20
|
+
|
|
21
|
+
if (result.error && !options.allowFailure) {
|
|
22
|
+
throw result.error;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (status !== 0 && !options.allowFailure) {
|
|
26
|
+
const detail = stderr || stdout || `exit code ${status}`;
|
|
27
|
+
throw new Error(`${cmd} ${args.join(" ")} failed: ${detail}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
status,
|
|
32
|
+
stdout,
|
|
33
|
+
stderr,
|
|
34
|
+
error: result.error || null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
die,
|
|
40
|
+
run,
|
|
41
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
function shellQuote(value) {
|
|
2
|
+
return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function withInheritedPath(command) {
|
|
6
|
+
const inheritedPath = String(process.env.PATH || "");
|
|
7
|
+
if (!inheritedPath) return String(command || "");
|
|
8
|
+
return `export PATH=${shellQuote(inheritedPath)}; ${command}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
shellQuote,
|
|
13
|
+
withInheritedPath,
|
|
14
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
const path = require("node:path");
|
|
2
|
+
const { run } = require("./process.js");
|
|
3
|
+
const { shellQuote } = require("./shell.js");
|
|
4
|
+
|
|
5
|
+
function createTmuxController({ cliScriptPath, runCommand = run } = {}) {
|
|
6
|
+
function buildRemainOnExitArgs(target) {
|
|
7
|
+
return [";", "set-option", "-pt", target, "remain-on-exit", "failed"];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function ensureInstalled() {
|
|
11
|
+
const probe = runCommand("tmux", ["-V"], { allowFailure: true });
|
|
12
|
+
if (probe.status !== 0) {
|
|
13
|
+
const error = new Error("Error: tmux is required but was not found in PATH.");
|
|
14
|
+
error.isUsageError = true;
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sessionExists(tmuxSession) {
|
|
20
|
+
return runCommand("tmux", ["has-session", "-t", tmuxSession], { allowFailure: true }).status === 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function listWindows(tmuxSession) {
|
|
24
|
+
const listed = runCommand("tmux", ["list-windows", "-t", tmuxSession, "-F", "#{window_name}"], {
|
|
25
|
+
allowFailure: true,
|
|
26
|
+
});
|
|
27
|
+
if (listed.status !== 0) return [];
|
|
28
|
+
return listed.stdout
|
|
29
|
+
.split(/\r?\n/)
|
|
30
|
+
.map((line) => line.trim())
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function windowExists(tmuxSession, windowName) {
|
|
35
|
+
return listWindows(tmuxSession).includes(windowName);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function captureWindowLogs(tmuxSession, appName, lines) {
|
|
39
|
+
const captured = runCommand(
|
|
40
|
+
"tmux",
|
|
41
|
+
["capture-pane", "-p", "-J", "-S", `-${lines}`, "-t", `${tmuxSession}:${appName}`],
|
|
42
|
+
{ allowFailure: true }
|
|
43
|
+
);
|
|
44
|
+
if (captured.status !== 0) {
|
|
45
|
+
const detail = captured.stderr || captured.stdout || `exit code ${captured.status}`;
|
|
46
|
+
throw new Error(`failed to capture logs for "${appName}" in "${tmuxSession}": ${detail}`);
|
|
47
|
+
}
|
|
48
|
+
return captured.stdout;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function newWindow(tmuxSession, windowName, command) {
|
|
52
|
+
runCommand("tmux", [
|
|
53
|
+
"new-window",
|
|
54
|
+
"-d",
|
|
55
|
+
"-t",
|
|
56
|
+
tmuxSession,
|
|
57
|
+
"-n",
|
|
58
|
+
windowName,
|
|
59
|
+
command,
|
|
60
|
+
...buildRemainOnExitArgs(`${tmuxSession}:${windowName}`),
|
|
61
|
+
]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function newSession(tmuxSession, windowName, command) {
|
|
65
|
+
runCommand("tmux", [
|
|
66
|
+
"new-session",
|
|
67
|
+
"-d",
|
|
68
|
+
"-s",
|
|
69
|
+
tmuxSession,
|
|
70
|
+
"-n",
|
|
71
|
+
windowName,
|
|
72
|
+
command,
|
|
73
|
+
...buildRemainOnExitArgs(`${tmuxSession}:${windowName}`),
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function killWindow(tmuxSession, windowName) {
|
|
78
|
+
runCommand("tmux", ["kill-window", "-t", `${tmuxSession}:${windowName}`], {
|
|
79
|
+
allowFailure: true,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function killSession(tmuxSession) {
|
|
84
|
+
runCommand("tmux", ["kill-session", "-t", tmuxSession]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function selectWindow(tmuxSession, windowName) {
|
|
88
|
+
runCommand("tmux", ["select-window", "-t", `${tmuxSession}:${windowName}`], {
|
|
89
|
+
allowFailure: true,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function enableMouse(tmuxSession) {
|
|
94
|
+
runCommand("tmux", ["set-option", "-t", tmuxSession, "mouse", "on"]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getAttachEnv() {
|
|
98
|
+
const term = String(process.env.TERM || "").trim();
|
|
99
|
+
if (!term || term === "dumb") {
|
|
100
|
+
return { ...process.env, TERM: "xterm-256color" };
|
|
101
|
+
}
|
|
102
|
+
return process.env;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function attachSession(tmuxSession) {
|
|
106
|
+
runCommand("tmux", ["attach-session", "-t", tmuxSession], {
|
|
107
|
+
stdio: "inherit",
|
|
108
|
+
env: getAttachEnv(),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildTailPaneCommand({ root, appName, lines }) {
|
|
113
|
+
const scriptPath = cliScriptPath || path.resolve(process.argv[1] || "");
|
|
114
|
+
return (
|
|
115
|
+
`cd ${shellQuote(root)} && ` +
|
|
116
|
+
`node ${shellQuote(scriptPath)} tail ${shellQuote(appName)} --lines ${lines}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function openSplitAttachWindow({ root, tmuxSession, appNames, lines }) {
|
|
121
|
+
const splitWindowName = "split-attach";
|
|
122
|
+
const activeApps = appNames.filter((appName) => windowExists(tmuxSession, appName));
|
|
123
|
+
if (activeApps.length === 0) {
|
|
124
|
+
const error = new Error(`Error: no active app windows found in session "${tmuxSession}".`);
|
|
125
|
+
error.isUsageError = true;
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (windowExists(tmuxSession, splitWindowName)) {
|
|
130
|
+
killWindow(tmuxSession, splitWindowName);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const [firstApp, ...restApps] = activeApps;
|
|
134
|
+
newWindow(tmuxSession, splitWindowName, buildTailPaneCommand({ root, appName: firstApp, lines }));
|
|
135
|
+
|
|
136
|
+
for (const appName of restApps) {
|
|
137
|
+
runCommand("tmux", [
|
|
138
|
+
"split-window",
|
|
139
|
+
"-d",
|
|
140
|
+
"-t",
|
|
141
|
+
`${tmuxSession}:${splitWindowName}`,
|
|
142
|
+
"-v",
|
|
143
|
+
buildTailPaneCommand({ root, appName, lines }),
|
|
144
|
+
]);
|
|
145
|
+
runCommand("tmux", ["select-layout", "-t", `${tmuxSession}:${splitWindowName}`, "tiled"]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
selectWindow(tmuxSession, splitWindowName);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
attachSession,
|
|
153
|
+
captureWindowLogs,
|
|
154
|
+
enableMouse,
|
|
155
|
+
ensureInstalled,
|
|
156
|
+
killSession,
|
|
157
|
+
killWindow,
|
|
158
|
+
listWindows,
|
|
159
|
+
newSession,
|
|
160
|
+
newWindow,
|
|
161
|
+
openSplitAttachWindow,
|
|
162
|
+
selectWindow,
|
|
163
|
+
sessionExists,
|
|
164
|
+
windowExists,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = {
|
|
169
|
+
createTmuxController,
|
|
170
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
function formatAvailableEntries(entries = []) {
|
|
2
|
+
if (!entries || entries.length === 0) return [" (none)"];
|
|
3
|
+
return entries.map((entry) =>
|
|
4
|
+
entry.description ? ` ${entry.name}: ${entry.description}` : ` ${entry.name}`
|
|
5
|
+
);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function usage(appEntries = [], toolEntries = []) {
|
|
9
|
+
const appLines = formatAvailableEntries(appEntries);
|
|
10
|
+
const toolLines = formatAvailableEntries(toolEntries);
|
|
11
|
+
return [
|
|
12
|
+
"Usage:",
|
|
13
|
+
" dev start <app|all> [-a|--attach] [--lines <n>]",
|
|
14
|
+
" dev restart <app|all> [-a|--attach] [--lines <n>]",
|
|
15
|
+
" dev stop <app|all>",
|
|
16
|
+
" dev attach [app] [--lines <n>]",
|
|
17
|
+
" dev logs <app> [--lines <n>]",
|
|
18
|
+
" dev tail <app> [--lines <n>] [--until-marker <text>] [--until-timeout <seconds>]",
|
|
19
|
+
" dev tool <tool-name> [tool args...]",
|
|
20
|
+
"",
|
|
21
|
+
"Available apps:",
|
|
22
|
+
...appLines,
|
|
23
|
+
"",
|
|
24
|
+
"Available tools:",
|
|
25
|
+
...toolLines,
|
|
26
|
+
"",
|
|
27
|
+
"Examples:",
|
|
28
|
+
" npx dev start api",
|
|
29
|
+
" npx dev start api -a",
|
|
30
|
+
" npx dev start all --attach --lines 200",
|
|
31
|
+
" npx dev attach",
|
|
32
|
+
" npx dev attach web",
|
|
33
|
+
" npx dev restart all -a",
|
|
34
|
+
" npx dev stop all",
|
|
35
|
+
" npx dev logs api --lines 250",
|
|
36
|
+
" npx dev tail api --until-marker READY --until-timeout 30",
|
|
37
|
+
" npx dev tool axe tap --label \"Continue\"",
|
|
38
|
+
].join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = {
|
|
42
|
+
usage,
|
|
43
|
+
formatAvailableEntries,
|
|
44
|
+
};
|