@wringer/terminal 0.0.0-stage → 0.0.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -2
  3. package/dist/build-text-keys.d.ts +2 -0
  4. package/dist/call-sessions.d.ts +3 -0
  5. package/dist/create-ghostty-engine.d.ts +7 -0
  6. package/dist/create-raw-log.d.ts +5 -0
  7. package/dist/create-terminal-session.d.ts +43 -0
  8. package/dist/create-wasm-bridge.d.ts +10 -0
  9. package/dist/encode-ghostty-key.d.ts +8 -0
  10. package/dist/find-screen-match.d.ts +12 -0
  11. package/dist/index.d.ts +3 -0
  12. package/dist/key-names.d.ts +1 -0
  13. package/dist/parse-ghostty-layout.d.ts +5 -0
  14. package/dist/parse-key.d.ts +2 -0
  15. package/dist/pick-session.d.ts +13 -0
  16. package/dist/pick-unwrapped-text.d.ts +1 -0
  17. package/dist/pick-visible-text.d.ts +1 -0
  18. package/dist/read-ghostty-module.d.ts +12 -0
  19. package/dist/read-ghostty-text.d.ts +2 -0
  20. package/dist/screen-engine.d.ts +30 -0
  21. package/dist/session-service-contract.d.ts +116 -0
  22. package/dist/start-terminal-sessions.d.ts +18 -0
  23. package/dist/terminal.d.ts +99 -0
  24. package/dist/to-session-service-methods.d.ts +6 -0
  25. package/ghostty/ghostty-vt.json +7 -0
  26. package/ghostty/ghostty-vt.wasm +0 -0
  27. package/package.json +45 -4
  28. package/src/build-text-keys.ts +66 -0
  29. package/src/call-sessions.ts +22 -0
  30. package/src/create-ghostty-engine.ts +104 -0
  31. package/src/create-raw-log.ts +49 -0
  32. package/src/create-terminal-session.ts +231 -0
  33. package/src/create-wasm-bridge.ts +85 -0
  34. package/src/encode-ghostty-key.ts +94 -0
  35. package/src/find-screen-match.ts +37 -0
  36. package/src/index.ts +11 -0
  37. package/src/key-names.ts +47 -0
  38. package/src/parse-ghostty-layout.ts +39 -0
  39. package/src/parse-key.ts +83 -0
  40. package/src/pick-session.ts +47 -0
  41. package/src/pick-unwrapped-text.ts +47 -0
  42. package/src/pick-visible-text.ts +8 -0
  43. package/src/read-ghostty-module.ts +26 -0
  44. package/src/read-ghostty-text.ts +56 -0
  45. package/src/screen-engine.ts +41 -0
  46. package/src/session-service-contract.ts +83 -0
  47. package/src/start-terminal-sessions.ts +152 -0
  48. package/src/terminal.ts +323 -0
  49. package/src/to-session-service-methods.ts +34 -0
@@ -0,0 +1,323 @@
1
+ import { WringerError, defineDriver } from '@wringer/core';
2
+ import type { DriverSession, Fence } from '@wringer/core';
3
+ import { z } from 'zod';
4
+ import { callSessions } from './call-sessions.ts';
5
+ import { readGhosttyModule } from './read-ghostty-module.ts';
6
+ import { screenSchema, sessionInfoSchema } from './session-service-contract.ts';
7
+ import { startTerminalSessions } from './start-terminal-sessions.ts';
8
+
9
+ const optionsSchema = z.strictObject({
10
+ cols: z.number().int().min(10).max(500).default(120),
11
+ rows: z.number().int().min(2).max(200).default(40),
12
+ });
13
+
14
+ const programSchema = z.strictObject({
15
+ command: z.array(z.string().min(1)).min(1),
16
+ env: z.record(z.string(), z.string()).optional(),
17
+ cwd: z.string().min(1).optional(),
18
+ });
19
+
20
+ const targetConfigSchema = z.strictObject({
21
+ programs: z.record(z.string().regex(/^[a-z0-9][a-z0-9-]*$/u), programSchema),
22
+ });
23
+
24
+ const sessionFlag = z.string().min(1).optional();
25
+ const defaultWaitMs = 10_000;
26
+
27
+ // A wait ends this long before the command's deadline, so the command reports the screen it saw.
28
+ const waitMarginMs = 2000;
29
+ const startArgsSchema = z.strictObject({ program: z.string().min(1) });
30
+
31
+ const typeArgsSchema = z
32
+ .strictObject({
33
+ text: z.string().optional(),
34
+ secretEnv: z
35
+ .string()
36
+ .regex(/^[A-Za-z_][A-Za-z0-9_]*$/u)
37
+ .optional(),
38
+ session: sessionFlag,
39
+ })
40
+ .refine((args) => (args.text === undefined) !== (args.secretEnv === undefined), {
41
+ message: 'Pass --text, or --secret-env.',
42
+ });
43
+
44
+ const pressArgsSchema = z.strictObject({
45
+ keys: z.array(z.string().min(1)).min(1),
46
+ session: sessionFlag,
47
+ });
48
+
49
+ const waitArgsSchema = z
50
+ .strictObject({
51
+ text: z.string().min(1).optional(),
52
+ regex: z
53
+ .string()
54
+ .min(1)
55
+ .refine((source) => isRegexSource(source), { message: 'The regex does not compile.' })
56
+ .optional(),
57
+ timeoutMs: z.number().int().positive().max(100_000).default(defaultWaitMs),
58
+ session: sessionFlag,
59
+ })
60
+ .refine((args) => (args.text === undefined) !== (args.regex === undefined), {
61
+ message: 'Pass --text, or --regex.',
62
+ });
63
+
64
+ const sessionArgsSchema = z.strictObject({ session: sessionFlag });
65
+ const sessionDataSchema = z.strictObject({ session: z.string() });
66
+ const startDataSchema = z.strictObject({ session: z.string(), pid: z.number().int() });
67
+ const pressDataSchema = z.strictObject({ session: z.string(), keys: z.array(z.string()) });
68
+
69
+ const waitDataSchema = z.strictObject({
70
+ session: z.string(),
71
+ match: z.strictObject({ row: z.number().int(), col: z.number().int() }),
72
+ screen: screenSchema,
73
+ });
74
+
75
+ const screenDataSchema = z.strictObject({ session: z.string(), ...screenSchema.shape });
76
+ const stopDataSchema = z.strictObject({ session: z.string(), exitCode: z.number().int() });
77
+ const listDataSchema = z.strictObject({ sessions: z.array(sessionInfoSchema) });
78
+
79
+ export type TerminalScreen = z.infer<typeof screenSchema>;
80
+
81
+ export type TerminalSessionInfo = z.infer<typeof sessionInfoSchema>;
82
+
83
+ export type TerminalWaitRequest = z.input<typeof waitArgsSchema>;
84
+
85
+ export type TerminalStartRequest = z.infer<typeof startArgsSchema>;
86
+
87
+ export interface TerminalClient {
88
+ readonly start: (request: TerminalStartRequest) => Promise<z.infer<typeof startDataSchema>>;
89
+ readonly type: (
90
+ request: z.input<typeof typeArgsSchema>,
91
+ ) => Promise<z.infer<typeof sessionDataSchema>>;
92
+ readonly press: (
93
+ request: z.input<typeof pressArgsSchema>,
94
+ ) => Promise<z.infer<typeof pressDataSchema>>;
95
+ readonly wait: (request: TerminalWaitRequest) => Promise<z.infer<typeof waitDataSchema>>;
96
+ readonly screen: (
97
+ request?: z.input<typeof sessionArgsSchema>,
98
+ ) => Promise<z.infer<typeof screenDataSchema>>;
99
+ readonly stop: (
100
+ request?: z.input<typeof sessionArgsSchema>,
101
+ ) => Promise<z.infer<typeof stopDataSchema>>;
102
+ readonly list: () => Promise<z.infer<typeof listDataSchema>>;
103
+ }
104
+
105
+ // Drives text programs that the target names, each in a pseudo-terminal that the run's supervisor
106
+ // holds, so a program keeps its screen between commands. Starting a program, typing, pressing keys
107
+ // and stopping are writes. Reading the screen, waiting for it and listing sessions are reads.
108
+ export const terminal = defineDriver({
109
+ name: 'terminal',
110
+ options: optionsSchema,
111
+ targetConfig: targetConfigSchema,
112
+ services: {
113
+ sessions: {
114
+ start: async (ctx) =>
115
+ startTerminalSessions({
116
+ programs: ctx.config.programs,
117
+ cols: ctx.options.cols,
118
+ rows: ctx.options.rows,
119
+ module: await readGhosttyModule(),
120
+ root: process.cwd(),
121
+ env: process.env,
122
+ rawLogPath: (sessionID) => ctx.run.childLog(`terminal-${sessionID}`).path,
123
+
124
+ // The supervisor holds the sessions, and its stderr goes to the supervisor's log.
125
+ reportError: (message) => {
126
+ process.stderr.write(`${message}\n`);
127
+ },
128
+ }),
129
+ },
130
+ },
131
+ client: (session, fence): TerminalClient => makeClient(session, fence),
132
+ commands: (command) => [
133
+ command({
134
+ path: ['terminal', 'start'],
135
+ summary: 'Start one of the programs the target names, and print its session',
136
+ args: startArgsSchema,
137
+ positionals: ['program'],
138
+ data: startDataSchema,
139
+ effect: 'write',
140
+ run: (args, ctx) => ctx.client.start(args),
141
+ }),
142
+ command({
143
+ path: ['terminal', 'type'],
144
+ summary: 'Type --text, or the value of the env var --secret-env, as key events',
145
+ args: typeArgsSchema,
146
+ data: sessionDataSchema,
147
+ effect: 'write',
148
+ run: (args, ctx) => ctx.client.type(args),
149
+ }),
150
+ command({
151
+ path: ['terminal', 'press'],
152
+ summary: 'Press named keys in order, such as ctrl+c, enter, up, f5 or alt+x',
153
+ args: pressArgsSchema,
154
+ positionals: ['keys'],
155
+ data: pressDataSchema,
156
+ effect: 'write',
157
+ run: (args, ctx) => ctx.client.press(args),
158
+ }),
159
+ command({
160
+ path: ['terminal', 'wait'],
161
+ summary: 'Wait until the screen shows --text or matches --regex',
162
+ args: waitArgsSchema,
163
+ data: waitDataSchema,
164
+ effect: 'read',
165
+ run: (args, ctx) => ctx.client.wait(args),
166
+ }),
167
+ command({
168
+ path: ['terminal', 'screen'],
169
+ summary: 'Print the screen as text, with the cursor, and keep it as evidence',
170
+ args: sessionArgsSchema,
171
+ data: screenDataSchema,
172
+ effect: 'read',
173
+ run: (args, ctx) => ctx.client.screen(args),
174
+ }),
175
+ command({
176
+ path: ['terminal', 'stop'],
177
+ summary: 'Stop the program and print its exit code',
178
+ args: sessionArgsSchema,
179
+ data: stopDataSchema,
180
+ effect: 'write',
181
+ run: (args, ctx) => ctx.client.stop(args),
182
+ }),
183
+ command({
184
+ path: ['terminal', 'list'],
185
+ summary: 'List the terminal sessions of this run',
186
+ args: z.strictObject({}),
187
+ data: listDataSchema,
188
+ effect: 'read',
189
+ run: (_args, ctx) => ctx.client.list(),
190
+ }),
191
+ ],
192
+ });
193
+
194
+ function isRegexSource(source: string): boolean {
195
+ try {
196
+ return new RegExp(source, 'u') instanceof RegExp;
197
+ } catch {
198
+ return false;
199
+ }
200
+ }
201
+
202
+ type TerminalOptions = z.infer<typeof optionsSchema>;
203
+
204
+ type TerminalConfig = z.infer<typeof targetConfigSchema>;
205
+
206
+ type TerminalSession = DriverSession<TerminalOptions, TerminalConfig>;
207
+
208
+ function makeClient(session: TerminalSession, fence: Fence): TerminalClient {
209
+ const sessions = session.service('sessions');
210
+
211
+ const writeScreenEvidence = async (text: string): Promise<void> => {
212
+ await session.evidence.writeFile('screen', 'screen.txt', text);
213
+ };
214
+
215
+ const start = fence({
216
+ effect: () => 'write',
217
+ describe: (request: TerminalStartRequest) => `terminal start ${request.program}`,
218
+ run: (request) => callSessions(sessions, 'start', request),
219
+ });
220
+
221
+ const type = fence({
222
+ effect: () => 'write',
223
+ describe: () => 'terminal type',
224
+ run: (request: z.input<typeof typeArgsSchema>) => {
225
+ const text = readTypedText(session, request);
226
+
227
+ return callSessions(sessions, 'type', { session: request.session, text });
228
+ },
229
+ });
230
+
231
+ const press = fence({
232
+ effect: () => 'write',
233
+ describe: (request: z.input<typeof pressArgsSchema>) =>
234
+ `terminal press ${request.keys.join(' ')}`,
235
+ run: async (request) => {
236
+ const answer = await callSessions(sessions, 'press', request);
237
+
238
+ return { session: answer.session, keys: request.keys };
239
+ },
240
+ });
241
+
242
+ const wait = fence({
243
+ effect: () => 'read',
244
+ describe: () => 'terminal wait',
245
+ run: async (request: TerminalWaitRequest) => {
246
+ const args = waitArgsSchema.parse(request);
247
+ const timeoutMs = Math.max(1, Math.min(args.timeoutMs, session.timeLeftMs() - waitMarginMs));
248
+
249
+ const answer = await callSessions(sessions, 'wait', {
250
+ session: args.session,
251
+ text: args.text,
252
+ regex: args.regex,
253
+ timeoutMs,
254
+ });
255
+
256
+ if (answer.match === null) {
257
+ await writeScreenEvidence(answer.screen.text);
258
+
259
+ throw new WringerError('check-failed', {
260
+ message: `The screen of ${answer.session} did not show ${args.text === undefined ? `a match for /${args.regex ?? ''}/` : `"${args.text}"`} within ${String(timeoutMs)} ms.`,
261
+ hint: 'Read the screen.txt evidence for what the program showed.',
262
+ });
263
+ }
264
+
265
+ return { session: answer.session, match: answer.match, screen: answer.screen };
266
+ },
267
+ });
268
+
269
+ const screen = fence({
270
+ effect: () => 'read',
271
+ describe: () => 'terminal screen',
272
+ run: async (request: z.input<typeof sessionArgsSchema>) => {
273
+ const answer = await callSessions(sessions, 'screen', { session: request.session });
274
+
275
+ await writeScreenEvidence(answer.screen.text);
276
+
277
+ return { session: answer.session, ...answer.screen };
278
+ },
279
+ });
280
+
281
+ const stop = fence({
282
+ effect: () => 'write',
283
+ describe: () => 'terminal stop',
284
+ run: (request: z.input<typeof sessionArgsSchema>) =>
285
+ callSessions(sessions, 'stop', { session: request.session }),
286
+ });
287
+
288
+ const list = fence({
289
+ effect: () => 'read',
290
+ describe: () => 'terminal list',
291
+ run: (request: null) => callSessions(sessions, 'list', request),
292
+ });
293
+
294
+ return {
295
+ start,
296
+ type,
297
+ press,
298
+ wait,
299
+ screen: (request = {}) => screen(request),
300
+ stop: (request = {}) => stop(request),
301
+ list: () => list(null),
302
+ };
303
+ }
304
+
305
+ // A secret joins the command's set before any key is written, so no evidence holds it.
306
+ function readTypedText(session: TerminalSession, request: z.input<typeof typeArgsSchema>): string {
307
+ if (request.secretEnv === undefined) {
308
+ return request.text ?? '';
309
+ }
310
+
311
+ const value = process.env[request.secretEnv];
312
+
313
+ if (value === undefined || value === '') {
314
+ throw new WringerError('usage', {
315
+ message: `The environment variable ${request.secretEnv} is not set.`,
316
+ hint: 'Set it in the shell that runs the command.',
317
+ });
318
+ }
319
+
320
+ session.secrets.add({ name: request.secretEnv, value });
321
+
322
+ return value;
323
+ }
@@ -0,0 +1,34 @@
1
+ import type { ServiceMethod } from '@wringer/core';
2
+ import type { z } from 'zod';
3
+ import { sessionServiceContract } from './session-service-contract.ts';
4
+ import type { SessionArgs, SessionMethod, SessionResult } from './session-service-contract.ts';
5
+
6
+ export type SessionHandlers = {
7
+ readonly [Method in SessionMethod]: (
8
+ args: SessionArgs<Method>,
9
+ signal: AbortSignal,
10
+ ) => Promise<SessionResult<Method>>;
11
+ };
12
+
13
+ // Each method parses its request against the contract, so a handler only sees the args the client
14
+ // was typed to send.
15
+ export function toSessionServiceMethods(
16
+ handlers: SessionHandlers,
17
+ ): Readonly<Record<SessionMethod, ServiceMethod>> {
18
+ return {
19
+ start: bindMethod(sessionServiceContract.start.args, handlers.start),
20
+ type: bindMethod(sessionServiceContract.type.args, handlers.type),
21
+ press: bindMethod(sessionServiceContract.press.args, handlers.press),
22
+ wait: bindMethod(sessionServiceContract.wait.args, handlers.wait),
23
+ screen: bindMethod(sessionServiceContract.screen.args, handlers.screen),
24
+ stop: bindMethod(sessionServiceContract.stop.args, handlers.stop),
25
+ list: bindMethod(sessionServiceContract.list.args, handlers.list),
26
+ };
27
+ }
28
+
29
+ function bindMethod<Schema extends z.ZodType>(
30
+ schema: Schema,
31
+ handler: (args: z.output<Schema>, signal: AbortSignal) => Promise<unknown>,
32
+ ): ServiceMethod {
33
+ return (args, signal) => Promise.try(() => handler(schema.parse(args), signal));
34
+ }