Skip to content

Commit a318aa4

Browse files
committed
feat: subTasks
1 parent 3a79f26 commit a318aa4

File tree

4 files changed

+167
-85
lines changed

4 files changed

+167
-85
lines changed

src/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export interface RunpCommand extends RunpCommonOptions {
4545
dependsOn: Array<string | number>;
4646
/** Set cwd for command */
4747
cwd: string;
48+
subCommands?: RunpCommand[];
4849
}
4950

5051
export interface RunpFeedback {
@@ -53,10 +54,11 @@ export interface RunpFeedback {
5354
updateOutput(output: string): void;
5455
}
5556

56-
export interface RunpCommandRaw extends Omit<Partial<RunpCommand>, 'args' | 'dependsOn'> {
57-
cmd: RunpCommand['cmd'];
57+
export interface RunpCommandRaw extends Omit<Partial<RunpCommand>, 'args' | 'dependsOn' | 'subCommands'> {
58+
cmd?: RunpCommand['cmd'];
5859
args?: (string | false | undefined | null)[];
5960
dependsOn?: string | number | Array<string | number>;
61+
subCommands?: RunpCommandRaw[];
6062
}
6163

6264
type Commands = (string | [cmd: string, ...args: string[]] | RunpCommandRaw | false | undefined | null)[];
@@ -195,6 +197,7 @@ export async function resolveCommands(options: RunpOptions) {
195197
const cleanCommand: RunpCommand = {
196198
...command,
197199
id: command.id ?? getFreeId(),
200+
cmd: command.cmd ?? '',
198201
args: command.args?.filter((x): x is string => typeof x === 'string') ?? [],
199202
cwd: resolve(command.cwd ?? '.'),
200203
dependsOn: [],
@@ -204,6 +207,7 @@ export async function resolveCommands(options: RunpOptions) {
204207
displayTimeOver: command.displayTimeOver ?? options.displayTimeOver,
205208
linearOutput: command.linearOutput ?? options.linearOutput,
206209
env: command.env ?? process.env,
210+
subCommands: command.subCommands?.length ? await resolveCommands({ ...options, commands: command.subCommands }) : [],
207211
};
208212

209213
const cmd = cleanCommand.cmd;

src/task.ts

Lines changed: 88 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export interface TaskState {
1818
rawOutput: string;
1919
output: string;
2020
time?: number;
21-
subTasks?: Task[];
21+
subTasks: Task[];
2222
}
2323

2424
export function task(command: RunpCommand, allTasks: () => Task[], q = createQueue()): Task {
@@ -31,6 +31,7 @@ export function task(command: RunpCommand, allTasks: () => Task[], q = createQue
3131
title: (name ?? fullCmd) + cwdDisplay,
3232
rawOutput: '',
3333
output: '',
34+
subTasks: command.subCommands?.map((subCommand) => task(subCommand, allTasks, q)) ?? [],
3435
});
3536

3637
const result = new Promise<RunpResult>((resolve) => {
@@ -79,91 +80,100 @@ export function task(command: RunpCommand, allTasks: () => Task[], q = createQue
7980
}
8081

8182
const start = performance.now();
82-
state.set('status', 'inProgress');
83-
84-
if (cmd instanceof Function) {
85-
try {
86-
await cmd({
87-
updateStatus(status) {
88-
state.set('statusString', status);
89-
},
90-
updateTitle(title) {
91-
state.set('title', title);
92-
},
93-
updateOutput(output) {
94-
state.set('rawOutput', (rawOutput) => rawOutput + output);
95-
state.set('output', output);
96-
writeLine(output, thisTask);
97-
},
83+
try {
84+
state.set('status', 'inProgress');
85+
86+
if (cmd instanceof Function) {
87+
function updateStatus(status: string) {
88+
state.set('statusString', status);
89+
}
90+
91+
function updateTitle(title: string) {
92+
state.set('title', title);
93+
}
94+
95+
function updateOutput(output: string) {
96+
state.set('rawOutput', (rawOutput) => rawOutput + output);
97+
state.set('output', output);
98+
writeLine(output, thisTask);
99+
}
100+
101+
try {
102+
await cmd({ updateStatus, updateTitle, updateOutput });
103+
} catch (error) {
104+
const message = error instanceof Error ? error.toString() : typeof error === 'object' ? JSON.stringify(error) : String(error);
105+
updateOutput(message);
106+
107+
throw error;
108+
}
109+
} else if (cmd) {
110+
const isTTY = process.stdout.isTTY || process.env.RUNP_TTY;
111+
112+
await new Promise<void>((resolve, reject) => {
113+
const subProcess = spawn(cmd, args, {
114+
shell: process.platform === 'win32',
115+
stdio: 'pipe',
116+
cwd,
117+
env: {
118+
...env,
119+
FORCE_COLOR: isTTY ? '1' : undefined, // Some libs color output when this env var is set
120+
RUNP: RUNP_TASK_V, // Tell child processes, especially runp itself, that they are running in runp
121+
RUNP_TTY: isTTY ? '1' : undefined, // Runp child processes can print as if they were running in a tty
122+
},
123+
});
124+
125+
const append = (data: any) => {
126+
const chunk = data.toString();
127+
state.set('rawOutput', (rawOutput) => rawOutput + chunk);
128+
state.set('output', state.get().rawOutput.includes(RUNP_TASK_DELEGATE) ? '' : state.get().rawOutput);
129+
if (!chunk.includes(RUNP_TASK_DELEGATE)) {
130+
writeLine?.(data.toString(), thisTask);
131+
}
132+
};
133+
subProcess.stdout.on('data', append);
134+
subProcess.stderr.on('data', append);
135+
136+
subProcess.on('close', async (code) => {
137+
if (code) {
138+
reject(new Error(`Process exited with code ${code}`));
139+
return;
140+
}
141+
142+
const { rawOutput } = state.get();
143+
144+
const delegationStart = rawOutput.indexOf(RUNP_TASK_DELEGATE);
145+
const delegationEnd = rawOutput.indexOf(RUNP_TASK_DELEGATE, delegationStart + 1);
146+
if (delegationStart >= 0 && delegationEnd > delegationStart) {
147+
const json = rawOutput.slice(delegationStart + RUNP_TASK_DELEGATE.length, delegationEnd);
148+
const commands = JSON.parse(json) as RunpCommand[];
149+
const tasks: Task[] = commands.map((command) => task(command, () => tasks, q));
150+
151+
state.set('output', '');
152+
state.set('subTasks', (subTasks) => [...tasks, ...subTasks]);
153+
}
154+
155+
resolve();
156+
});
157+
158+
subProcess.on('error', reject);
98159
});
99-
100-
state.set('status', 'done');
101-
} catch (error) {
102-
state.set('status', 'error');
103-
state.set('output', String(error));
104-
writeLine(String(error), thisTask);
105-
} finally {
106-
state.set('time', performance.now() - start);
107-
}
108-
109-
return;
110-
}
111-
112-
const isTTY = process.stdout.isTTY || process.env.RUNP_TTY;
113-
114-
const subProcess = spawn(cmd, args, {
115-
shell: process.platform === 'win32',
116-
stdio: 'pipe',
117-
cwd,
118-
env: {
119-
...env,
120-
FORCE_COLOR: isTTY ? '1' : undefined, // Some libs color output when this env var is set
121-
RUNP: RUNP_TASK_V, // Tell child processes, especially runp itself, that they are running in runp
122-
RUNP_TTY: isTTY ? '1' : undefined, // Runp child processes can print as if they were running in a tty
123-
},
124-
});
125-
126-
const append = (data: any) => {
127-
const chunk = data.toString();
128-
state.set('rawOutput', (rawOutput) => rawOutput + chunk);
129-
state.set('output', state.get().rawOutput.includes(RUNP_TASK_DELEGATE) ? '' : state.get().rawOutput);
130-
if (!chunk.includes(RUNP_TASK_DELEGATE)) {
131-
writeLine?.(data.toString(), thisTask);
132-
}
133-
};
134-
subProcess.stdout.on('data', append);
135-
subProcess.stderr.on('data', append);
136-
137-
subProcess.on('close', async (code) => {
138-
const { rawOutput } = state.get();
139-
140-
const delegationStart = rawOutput.indexOf(RUNP_TASK_DELEGATE);
141-
const delegationEnd = rawOutput.indexOf(RUNP_TASK_DELEGATE, delegationStart + 1);
142-
if (delegationStart >= 0 && delegationEnd > delegationStart) {
143-
const json = rawOutput.slice(delegationStart + RUNP_TASK_DELEGATE.length, delegationEnd);
144-
const commands = JSON.parse(json) as RunpCommand[];
145-
const tasks: Task[] = commands.map((command) => task(command, () => tasks, q));
146-
147-
state.set('output', '');
148-
state.set('subTasks', tasks);
149160
}
150161

151162
const { subTasks } = state.get();
152-
let hasErrors = !!code;
163+
const subResults = await Promise.all(subTasks.map((task) => task.run(writeLine)));
153164

154-
if (subTasks) {
155-
const subResults = await Promise.all(subTasks.map((task) => task.run(writeLine)));
156-
hasErrors = subResults.some((x) => x.result === 'error');
165+
if (subResults.some((x) => x.result === 'error')) {
166+
throw new Error('Subtask failed');
157167
}
158168

159-
state.set('status', hasErrors ? 'error' : 'done');
169+
state.set('status', 'done');
170+
} catch {
171+
state.set('status', 'error');
172+
// state.set('output', String(error));
173+
// writeLine(String(error), thisTask);
174+
} finally {
160175
state.set('time', performance.now() - start);
161-
});
162-
163-
subProcess.on('error', (error) => {
164-
state.set('output', String(error));
165-
writeLine(String(error), thisTask);
166-
});
176+
}
167177
});
168178

169179
return result;

src/trackLinearOutput.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,32 @@ export function trackLinearOutput(tasks: Task[], writeLine: (line: string, optio
4444
writeLine(text);
4545
};
4646

47-
tasks.forEach((task) =>
47+
for (const task of tasks) {
48+
task.run(writeLineGrouped);
49+
}
50+
51+
const tracked = new Set<Task>();
52+
tasks.forEach(trackCompletion);
53+
54+
function trackCompletion(task: Task) {
55+
if (tracked.has(task)) {
56+
return;
57+
}
58+
tracked.add(task);
59+
4860
task.result
4961
.catch(() => undefined)
5062
.finally(() => {
5163
if (task === currentTask) {
5264
endTask();
5365
currentTask = undefined;
5466
}
55-
}),
56-
);
67+
});
5768

58-
for (const task of tasks) {
59-
task.run(writeLineGrouped);
69+
task.state
70+
.map((x) => x.subTasks)
71+
.subscribe((subTasks) => {
72+
subTasks.forEach(trackCompletion);
73+
});
6074
}
6175
}

test/subCommands.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { expect, test } from 'vitest';
2+
import { runp } from '../src';
3+
import { TestTerminal } from './_helpers';
4+
5+
test('subCommands', async () => {
6+
const term = new TestTerminal({ cols: 30, rows: 20 });
7+
8+
await runp({
9+
commands: [
10+
{
11+
name: 'command',
12+
subCommands: [
13+
{
14+
name: 'subCommand 1',
15+
cmd: 'echo',
16+
args: ['subCommand 1'],
17+
},
18+
{
19+
name: 'subCommand 2',
20+
cmd: 'echo',
21+
args: ['subCommand 2'],
22+
},
23+
],
24+
},
25+
],
26+
target: term,
27+
linearOutput: true,
28+
});
29+
30+
expect(term.getBuffer()).toMatchInlineSnapshot(`
31+
[
32+
" ",
33+
"-- [subCommand 1] -> ",
34+
" ",
35+
"subCommand 1 ",
36+
" ",
37+
"<- [subCommand 1] -- ",
38+
" ",
39+
"-- [subCommand 2] -> ",
40+
" ",
41+
"subCommand 2 ",
42+
" ",
43+
"<- [subCommand 2] -- ",
44+
" ",
45+
"✓ command [#.###s] ",
46+
" ✓ subCommand 1 [#.###s] ",
47+
" ✓ subCommand 2 [#.###s] ",
48+
" ",
49+
" ",
50+
" ",
51+
" ",
52+
]
53+
`);
54+
});

0 commit comments

Comments
 (0)