Repository navigation
Expand file tree
/
Copy pathrun-tool.ts
More file actions
173 lines (166 loc) · 5.54 KB
/
Copy pathrun-tool.ts
File metadata and controls
173 lines (166 loc) · 5.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
/**
* The TUI host for a tool: its screens on their own store, with no program
* run, no task stream and no WizardRun. The CLI builds the launch values, owns
* the signals and applies the exit code.
*/
/* eslint-disable no-console */
import { buildSession, logIn } from '@programs';
import type { ToolId } from '@tools';
import { classifyRunFailure, emitWizardError } from '@shared/errors';
import { checkLocalServices, getLocalDev } from '@shared/local-dev';
import { VERSION } from '@shared/version';
import { analytics } from '@utils/analytics';
import { runCleanups } from '@utils/cleanup';
import { getLogFilePath, logToFile } from '@utils/debug';
import { flushAnalytics } from '@utils/flush-analytics';
import { startHostExit, wizardAbort } from '@host/wizard-abort';
import { printAbortOutro } from '@shared/console-log';
import { getTuiTool } from '@tui/tools/index';
import { oauthCredentials } from './auth/login.js';
import { startTUI } from './start-tui.js';
import type { WizardStore } from './store.js';
import type { TuiTool } from './tools/types.js';
import type { TuiToolLaunch } from './launch.js';
/**
* Run `toolId`'s screens. Resolves with the code a screen ends them with
* (after the analytics flush), an abort's, 1 on a crash, or 130 or 143 on a
* signal. Rejects when the TUI cannot start, as with no terminal.
*/
export async function runTuiTool(
toolId: ToolId,
launch: TuiToolLaunch,
): Promise<number> {
const exit = startHostExit();
// A signal before the TUI mounts has nothing to unmount.
let unmountTui = (): void => undefined;
let stopping = false;
// Ctrl+C or a signal: restore the terminal, flush the cancelled event, then end.
const stop = (code: number): void => {
if (stopping || exit.ended) return;
stopping = true;
unmountTui();
void (async () => {
try {
await analytics.shutdown('cancelled');
} catch {
// never block the end on a flush failure
}
exit.end(code);
})();
};
const onAbort = (): void =>
stop(launch.signal.reason === 'SIGTERM' ? 143 : 130);
// A signal that landed before this host subscribed ends the run too.
if (launch.signal.aborted) onAbort();
else launch.signal.addEventListener('abort', onAbort, { once: true });
// Doctor ran as a program, so it probed the local services and labelled its exit line; the other tools did neither.
const isDoctor = toolId === 'posthog-doctor';
const localError = isDoctor
? await localServicesError(launch.session.baseUrl)
: undefined;
if (stopping) return exit.exited;
if (localError) {
wizardAbort(
{ present: printAbortOutro, exit },
{ message: localError },
).catch((error: unknown) => exit.fail(error));
return exit.exited;
}
const tool = getTuiTool(toolId);
if (!tool) throw new Error(`The ${toolId} tool has no screens.`);
const tui = startTUI(VERSION, toolId, () => stop(130));
unmountTui = () => tui.unmount();
tui.store.launch(
buildSession(launch.session),
launch.session,
isDoctor ? toolId : null,
);
driveTuiTool(tui.store, tool, {
toolId,
signal: launch.signal,
}).then(
async (code) => {
if (stopping || exit.ended) return;
stopping = true;
tui.unmount();
await flushAnalytics();
exit.end(code);
},
(error: unknown) => {
if (stopping || exit.ended) return;
stopping = true;
logToFile('[run-wizard] FATAL:', error);
runCleanups();
try {
tui.unmount();
} catch {
// terminal may already be torn down
}
reportFatal(error);
exit.end(1);
},
);
return exit.exited;
}
/**
* Drive a tool's screens on a mounted store: run its `start`, and resolve with
* the code the first screen exit request carries. Rejects when `start` does.
* The intro hands off to a tool through here too.
*/
export function driveTuiTool(
store: WizardStore,
tool: TuiTool,
{ toolId, signal }: { toolId: string; signal: AbortSignal },
): Promise<number> {
return new Promise<number>((resolve, reject) => {
const settle = (): void => {
const code = store.exitRequest;
if (code === null) return;
unsubscribe();
resolve(code);
};
const unsubscribe = store.subscribe(settle);
settle();
tool
.start?.({
store,
signal,
logIn: async () => {
await logIn(toolId, store.sessions, {
provider: oauthCredentials(store),
signal,
});
},
})
.catch((error: unknown) => {
unsubscribe();
reject(error);
});
});
}
/** Before the TUI mounts: the local dev services this run points at, if one is down. */
export function localServicesError(
baseUrl: string | undefined,
): Promise<string | undefined> {
const local = getLocalDev();
return checkLocalServices({
...local,
// An explicit --base-url wins over --local-posthog (see buildSession),
// so don't probe :8010 when one was given.
localPosthog: local.localPosthog && !baseUrl,
});
}
/** Print a crash with the log path and the PHW line. */
export function reportFatal(error: unknown): void {
// Print after unmount: anything printed into the alt screen is wiped.
// A coded failure is a decision with its own message; anything else is
// unexpected and goes out whole.
const failure = classifyRunFailure(error);
if (failure.coded) {
console.error(failure.message);
} else {
console.error('Wizard run failed:', error);
}
console.error(`Full logs: ${getLogFilePath()}`);
emitWizardError({ code: failure.code, message: failure.message });
}