enbu 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/README.md +401 -0
- package/dist/main.mjs +1046 -0
- package/dist/main.mjs.map +1 -0
- package/package.json +58 -0
package/dist/main.mjs
ADDED
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { err, fromPromise, ok } from "neverthrow";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import { constants } from "node:fs";
|
|
7
|
+
import { glob } from "glob";
|
|
8
|
+
import { checkAgentBrowser, closeSession } from "@packages/agent-browser-adapter";
|
|
9
|
+
import { executeFlow, parseFlowYaml } from "@packages/core";
|
|
10
|
+
|
|
11
|
+
//#region src/args-parser.ts
|
|
12
|
+
/** デフォルトタイムアウト: 30秒 */
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
14
|
+
/**
|
|
15
|
+
* コマンドライン引数をパースする
|
|
16
|
+
*
|
|
17
|
+
* process.argvから渡された引数を解析し、ParsedArgs型に変換する。
|
|
18
|
+
* バージョンフラグが指定されている場合は、他の引数に関わらずバージョンモードとして返す。
|
|
19
|
+
* ヘルプフラグが指定されている場合は、他の引数に関わらずヘルプモードとして返す。
|
|
20
|
+
* 最初の位置引数がinitの場合はinitコマンド、それ以外はrunコマンドとして扱う。
|
|
21
|
+
*
|
|
22
|
+
* @param argv - process.argv(インデックス2以降)
|
|
23
|
+
* @returns パース済み引数、またはエラー
|
|
24
|
+
*/
|
|
25
|
+
const parseArgs = (argv) => {
|
|
26
|
+
if (argv.includes("-V") || argv.includes("--version")) return ok({
|
|
27
|
+
command: "run",
|
|
28
|
+
help: false,
|
|
29
|
+
version: true,
|
|
30
|
+
verbose: false,
|
|
31
|
+
files: [],
|
|
32
|
+
headed: false,
|
|
33
|
+
env: {},
|
|
34
|
+
timeout: DEFAULT_TIMEOUT_MS,
|
|
35
|
+
screenshot: false,
|
|
36
|
+
bail: false
|
|
37
|
+
});
|
|
38
|
+
if (argv.includes("-h") || argv.includes("--help")) return ok({
|
|
39
|
+
command: "run",
|
|
40
|
+
help: true,
|
|
41
|
+
version: false,
|
|
42
|
+
verbose: false,
|
|
43
|
+
files: [],
|
|
44
|
+
headed: false,
|
|
45
|
+
env: {},
|
|
46
|
+
timeout: DEFAULT_TIMEOUT_MS,
|
|
47
|
+
screenshot: false,
|
|
48
|
+
bail: false
|
|
49
|
+
});
|
|
50
|
+
const verbose = argv.includes("-v") || argv.includes("--verbose");
|
|
51
|
+
if (argv[0] === "init") return parseInitArgs(argv.slice(1), verbose);
|
|
52
|
+
return parseRunArgs(argv, verbose);
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* initコマンドの引数をパースする
|
|
56
|
+
*
|
|
57
|
+
* initコマンドで利用可能なオプション(--force)を解析する。
|
|
58
|
+
* 未知のオプションが指定された場合はエラーを返す。
|
|
59
|
+
*
|
|
60
|
+
* @param argv - initコマンド以降の引数配列
|
|
61
|
+
* @param verbose - verboseフラグが有効かどうか
|
|
62
|
+
* @returns パース済みのinit引数、またはエラー
|
|
63
|
+
*/
|
|
64
|
+
const parseInitArgs = (argv, verbose) => {
|
|
65
|
+
const force = argv.includes("--force");
|
|
66
|
+
for (const arg of argv) if (arg.startsWith("--")) {
|
|
67
|
+
if (arg !== "--force" && arg !== "--verbose") return err({
|
|
68
|
+
type: "invalid_args",
|
|
69
|
+
message: `Unknown option for init command: ${arg}`
|
|
70
|
+
});
|
|
71
|
+
} else if (arg.startsWith("-")) {
|
|
72
|
+
if (arg !== "-v") return err({
|
|
73
|
+
type: "invalid_args",
|
|
74
|
+
message: `Unknown option for init command: ${arg}`
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return ok({
|
|
78
|
+
command: "init",
|
|
79
|
+
help: false,
|
|
80
|
+
version: false,
|
|
81
|
+
verbose,
|
|
82
|
+
force
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* runコマンドの引数をパースする
|
|
87
|
+
*
|
|
88
|
+
* runコマンドで利用可能なオプション(--headed, --env, --timeout等)を解析する。
|
|
89
|
+
* --envは複数回指定可能で、KEY=VALUE形式でなければならない。
|
|
90
|
+
* --timeoutは正の整数値でなければならない。
|
|
91
|
+
* 位置引数(オプションフラグでない引数)は全てフローファイルパスとして扱う。
|
|
92
|
+
*
|
|
93
|
+
* @param argv - runコマンドの引数配列
|
|
94
|
+
* @param verbose - verboseフラグが有効かどうか
|
|
95
|
+
* @returns パース済みのrun引数、またはエラー
|
|
96
|
+
*/
|
|
97
|
+
const parseRunArgs = (argv, verbose) => {
|
|
98
|
+
const state = {
|
|
99
|
+
files: [],
|
|
100
|
+
env: {},
|
|
101
|
+
headed: false,
|
|
102
|
+
timeout: DEFAULT_TIMEOUT_MS,
|
|
103
|
+
screenshot: false,
|
|
104
|
+
bail: false,
|
|
105
|
+
session: void 0
|
|
106
|
+
};
|
|
107
|
+
for (let i = 0; i < argv.length; i++) {
|
|
108
|
+
const arg = argv[i];
|
|
109
|
+
const continueResult = processRunArg(arg, argv, i, state).match((newIndex) => {
|
|
110
|
+
i = newIndex;
|
|
111
|
+
return ok(void 0);
|
|
112
|
+
}, (error) => err(error));
|
|
113
|
+
if (continueResult.isErr()) return continueResult;
|
|
114
|
+
}
|
|
115
|
+
return ok({
|
|
116
|
+
command: "run",
|
|
117
|
+
help: false,
|
|
118
|
+
version: false,
|
|
119
|
+
verbose,
|
|
120
|
+
files: state.files,
|
|
121
|
+
headed: state.headed,
|
|
122
|
+
env: state.env,
|
|
123
|
+
timeout: state.timeout,
|
|
124
|
+
screenshot: state.screenshot,
|
|
125
|
+
bail: state.bail,
|
|
126
|
+
session: state.session
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* runコマンドの単一引数を処理する
|
|
131
|
+
*
|
|
132
|
+
* 各オプションに応じた処理を行い、新しいインデックスを返す。
|
|
133
|
+
* 値を取る引数(--env, --timeout, --session)の場合はインデックスを+1する。
|
|
134
|
+
*
|
|
135
|
+
* @param arg - 現在の引数
|
|
136
|
+
* @param argv - 全引数配列
|
|
137
|
+
* @param currentIndex - 現在のインデックス
|
|
138
|
+
* @param state - パース状態を保持するオブジェクト
|
|
139
|
+
* @returns 成功時: 新しいインデックス、失敗時: エラー
|
|
140
|
+
*/
|
|
141
|
+
const processRunArg = (arg, argv, currentIndex, state) => {
|
|
142
|
+
switch (arg) {
|
|
143
|
+
case "--env": return parseEnvOption(argv, currentIndex, state);
|
|
144
|
+
case "--timeout": return parseTimeoutOption(argv, currentIndex, state);
|
|
145
|
+
case "--session": return parseSessionOption(argv, currentIndex, state);
|
|
146
|
+
default: return processFlagArg(arg, currentIndex, state);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* フラグ引数を処理する(値を取らない引数)
|
|
151
|
+
*
|
|
152
|
+
* @param arg - 現在の引数
|
|
153
|
+
* @param currentIndex - 現在のインデックス
|
|
154
|
+
* @param state - パース状態を保持するオブジェクト
|
|
155
|
+
* @returns 成功時: 現在のインデックス、失敗時: エラー
|
|
156
|
+
*/
|
|
157
|
+
const processFlagArg = (arg, currentIndex, state) => {
|
|
158
|
+
if (arg === "--headed") {
|
|
159
|
+
state.headed = true;
|
|
160
|
+
return ok(currentIndex);
|
|
161
|
+
}
|
|
162
|
+
if (arg === "--screenshot") {
|
|
163
|
+
state.screenshot = true;
|
|
164
|
+
return ok(currentIndex);
|
|
165
|
+
}
|
|
166
|
+
if (arg === "--bail") {
|
|
167
|
+
state.bail = true;
|
|
168
|
+
return ok(currentIndex);
|
|
169
|
+
}
|
|
170
|
+
if (arg === "-v" || arg === "--verbose") return ok(currentIndex);
|
|
171
|
+
if (arg.startsWith("--")) return err({
|
|
172
|
+
type: "invalid_args",
|
|
173
|
+
message: `Unknown option: ${arg}`
|
|
174
|
+
});
|
|
175
|
+
state.files.push(arg);
|
|
176
|
+
return ok(currentIndex);
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* --env オプションをパースする
|
|
180
|
+
*/
|
|
181
|
+
const parseEnvOption = (argv, currentIndex, state) => {
|
|
182
|
+
const nextArg = argv[currentIndex + 1];
|
|
183
|
+
if (!nextArg) return err({
|
|
184
|
+
type: "invalid_args",
|
|
185
|
+
message: "--env requires KEY=VALUE argument"
|
|
186
|
+
});
|
|
187
|
+
const envResult = parseEnvArg(nextArg);
|
|
188
|
+
if (envResult.isErr()) return err(envResult.error);
|
|
189
|
+
const [key, value] = envResult.value;
|
|
190
|
+
state.env[key] = value;
|
|
191
|
+
return ok(currentIndex + 1);
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* --timeout オプションをパースする
|
|
195
|
+
*/
|
|
196
|
+
const parseTimeoutOption = (argv, currentIndex, state) => {
|
|
197
|
+
const nextArg = argv[currentIndex + 1];
|
|
198
|
+
if (!nextArg) return err({
|
|
199
|
+
type: "invalid_args",
|
|
200
|
+
message: "--timeout requires a number in milliseconds"
|
|
201
|
+
});
|
|
202
|
+
const timeoutNum = Number.parseInt(nextArg, 10);
|
|
203
|
+
if (Number.isNaN(timeoutNum) || timeoutNum <= 0) return err({
|
|
204
|
+
type: "invalid_args",
|
|
205
|
+
message: `--timeout must be a positive number, got: ${nextArg}`
|
|
206
|
+
});
|
|
207
|
+
state.timeout = timeoutNum;
|
|
208
|
+
return ok(currentIndex + 1);
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* --session オプションをパースする
|
|
212
|
+
*/
|
|
213
|
+
const parseSessionOption = (argv, currentIndex, state) => {
|
|
214
|
+
const nextArg = argv[currentIndex + 1];
|
|
215
|
+
if (!nextArg) return err({
|
|
216
|
+
type: "invalid_args",
|
|
217
|
+
message: "--session requires a session name"
|
|
218
|
+
});
|
|
219
|
+
state.session = nextArg;
|
|
220
|
+
return ok(currentIndex + 1);
|
|
221
|
+
};
|
|
222
|
+
/**
|
|
223
|
+
* --env KEY=VALUE 引数をパースする
|
|
224
|
+
*
|
|
225
|
+
* 環境変数の設定値をKEY=VALUE形式から解析する。
|
|
226
|
+
* =が含まれていない、またはKEYが空の場合はエラーを返す。
|
|
227
|
+
* VALUEは空文字列でも許可される。
|
|
228
|
+
*
|
|
229
|
+
* @param arg - KEY=VALUE形式の文字列
|
|
230
|
+
* @returns 成功時: [KEY, VALUE]のタプル、失敗時: エラー
|
|
231
|
+
*/
|
|
232
|
+
const parseEnvArg = (arg) => {
|
|
233
|
+
const index = arg.indexOf("=");
|
|
234
|
+
if (index === -1) return err({
|
|
235
|
+
type: "invalid_args",
|
|
236
|
+
message: `--env argument must be in KEY=VALUE format, got: ${arg}`
|
|
237
|
+
});
|
|
238
|
+
const key = arg.slice(0, index);
|
|
239
|
+
const value = arg.slice(index + 1);
|
|
240
|
+
if (key.length === 0) return err({
|
|
241
|
+
type: "invalid_args",
|
|
242
|
+
message: `--env KEY cannot be empty, got: ${arg}`
|
|
243
|
+
});
|
|
244
|
+
return ok([key, value]);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/output/formatter.ts
|
|
249
|
+
/**
|
|
250
|
+
* 出力フォーマッター
|
|
251
|
+
*
|
|
252
|
+
* console.log禁止のため、process.stdout.write / process.stderr.write を使用する。
|
|
253
|
+
* 進捗表示やメッセージの整形を担当するクラス。
|
|
254
|
+
* 状態を持つため、classとして実装することが許可されている。
|
|
255
|
+
*/
|
|
256
|
+
var OutputFormatter = class {
|
|
257
|
+
/** 詳細ログ出力を行うかどうか */
|
|
258
|
+
verbose;
|
|
259
|
+
/** スピナーのアニメーションフレーム */
|
|
260
|
+
spinnerFrames = [
|
|
261
|
+
"⠋",
|
|
262
|
+
"⠙",
|
|
263
|
+
"⠹",
|
|
264
|
+
"⠸",
|
|
265
|
+
"⠼",
|
|
266
|
+
"⠴",
|
|
267
|
+
"⠦",
|
|
268
|
+
"⠧",
|
|
269
|
+
"⠇",
|
|
270
|
+
"⠏"
|
|
271
|
+
];
|
|
272
|
+
/** 現在表示中のスピナーフレームのインデックス */
|
|
273
|
+
spinnerIndex = 0;
|
|
274
|
+
/** スピナーのsetIntervalのID(停止時にclearIntervalするため) */
|
|
275
|
+
spinnerIntervalId = null;
|
|
276
|
+
/** 現在表示中のスピナーメッセージ */
|
|
277
|
+
currentSpinnerMessage = "";
|
|
278
|
+
/**
|
|
279
|
+
* OutputFormatterのコンストラクタ
|
|
280
|
+
*
|
|
281
|
+
* @param verbose - 詳細ログ出力を行うかどうか
|
|
282
|
+
*/
|
|
283
|
+
constructor(verbose) {
|
|
284
|
+
this.verbose = verbose;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* 通常メッセージを出力
|
|
288
|
+
*
|
|
289
|
+
* 標準出力(stdout)にメッセージを出力します。
|
|
290
|
+
* 一般的な情報や進捗状況を表示する際に使用します。
|
|
291
|
+
*
|
|
292
|
+
* @param message - 出力するメッセージ
|
|
293
|
+
*/
|
|
294
|
+
info(message) {
|
|
295
|
+
this.write("stdout", message);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* エラーメッセージを出力
|
|
299
|
+
*
|
|
300
|
+
* 標準エラー出力(stderr)にメッセージを出力します。
|
|
301
|
+
* エラーや警告メッセージを表示する際に使用します。
|
|
302
|
+
*
|
|
303
|
+
* @param message - 出力するエラーメッセージ
|
|
304
|
+
*/
|
|
305
|
+
error(message) {
|
|
306
|
+
this.write("stderr", message);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* デバッグメッセージを出力(verboseモード時のみ)
|
|
310
|
+
*
|
|
311
|
+
* verboseフラグがtrueの場合のみ、標準エラー出力(stderr)に
|
|
312
|
+
* デバッグメッセージを出力します。
|
|
313
|
+
* "[DEBUG]"プレフィックスが自動的に付与されます。
|
|
314
|
+
*
|
|
315
|
+
* @param message - 出力するデバッグメッセージ
|
|
316
|
+
*/
|
|
317
|
+
debug(message) {
|
|
318
|
+
if (this.verbose) this.write("stderr", `[DEBUG] ${message}`);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* 成功マーク付きメッセージ
|
|
322
|
+
*
|
|
323
|
+
* チェックマーク(✓)付きの成功メッセージを出力します。
|
|
324
|
+
* オプションで実行時間(ミリ秒)を指定すると、秒単位で表示されます。
|
|
325
|
+
*
|
|
326
|
+
* @param message - 出力するメッセージ
|
|
327
|
+
* @param durationMs - 実行時間(ミリ秒、省略可)
|
|
328
|
+
*/
|
|
329
|
+
success(message, durationMs) {
|
|
330
|
+
const duration = durationMs !== void 0 ? ` (${(durationMs / 1e3).toFixed(1)}s)` : "";
|
|
331
|
+
this.info(` ✓ ${message}${duration}`);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* 失敗マーク付きメッセージ
|
|
335
|
+
*
|
|
336
|
+
* バツマーク(✗)付きの失敗メッセージを出力します。
|
|
337
|
+
* オプションで実行時間(ミリ秒)を指定すると、秒単位で表示されます。
|
|
338
|
+
*
|
|
339
|
+
* @param message - 出力するメッセージ
|
|
340
|
+
* @param durationMs - 実行時間(ミリ秒、省略可)
|
|
341
|
+
*/
|
|
342
|
+
failure(message, durationMs) {
|
|
343
|
+
const duration = durationMs !== void 0 ? ` (${(durationMs / 1e3).toFixed(1)}s)` : "";
|
|
344
|
+
this.error(` ✗ ${message}${duration}`);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* インデント付きメッセージ(エラー詳細等)
|
|
348
|
+
*
|
|
349
|
+
* 指定されたレベル分のインデント(2スペース × レベル)を付けて
|
|
350
|
+
* メッセージを標準エラー出力に出力します。
|
|
351
|
+
* エラーの詳細情報や補足説明を階層的に表示する際に使用します。
|
|
352
|
+
*
|
|
353
|
+
* @param message - 出力するメッセージ
|
|
354
|
+
* @param level - インデントレベル(デフォルト: 1)
|
|
355
|
+
*/
|
|
356
|
+
indent(message, level = 1) {
|
|
357
|
+
const indent = " ".repeat(level);
|
|
358
|
+
this.error(`${indent}${message}`);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* スピナーを開始
|
|
362
|
+
*
|
|
363
|
+
* 指定されたメッセージと共にスピナーアニメーションを開始します。
|
|
364
|
+
* スピナーは80msごとにフレームが更新されます。
|
|
365
|
+
* 既に実行中のスピナーがある場合は、停止してから新しいスピナーを開始します。
|
|
366
|
+
*
|
|
367
|
+
* 注意: 必ずstopSpinner()を呼び出してスピナーを停止してください。
|
|
368
|
+
* 停止しないとsetIntervalが残り続けます。
|
|
369
|
+
*
|
|
370
|
+
* @param message - スピナーと共に表示するメッセージ
|
|
371
|
+
*/
|
|
372
|
+
startSpinner(message) {
|
|
373
|
+
this.currentSpinnerMessage = message;
|
|
374
|
+
this.spinnerIndex = 0;
|
|
375
|
+
if (this.spinnerIntervalId !== null) clearInterval(this.spinnerIntervalId);
|
|
376
|
+
this.renderSpinner();
|
|
377
|
+
this.spinnerIntervalId = setInterval(() => {
|
|
378
|
+
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length;
|
|
379
|
+
this.renderSpinner();
|
|
380
|
+
}, 80);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* スピナーを停止
|
|
384
|
+
*
|
|
385
|
+
* 実行中のスピナーを停止し、スピナー行をクリアします。
|
|
386
|
+
* setIntervalを適切にクリアして、リソースリークを防ぎます。
|
|
387
|
+
* スピナーが実行中でない場合は何もしません。
|
|
388
|
+
*/
|
|
389
|
+
stopSpinner() {
|
|
390
|
+
if (this.spinnerIntervalId !== null) {
|
|
391
|
+
clearInterval(this.spinnerIntervalId);
|
|
392
|
+
this.spinnerIntervalId = null;
|
|
393
|
+
}
|
|
394
|
+
this.clearLine();
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* セクション区切り線
|
|
398
|
+
*
|
|
399
|
+
* 視覚的にセクションを区切るための水平線を出力します。
|
|
400
|
+
* サマリー表示や大きな処理の区切りに使用します。
|
|
401
|
+
*/
|
|
402
|
+
separator() {
|
|
403
|
+
this.info("────────────────────────────────────────");
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* 改行
|
|
407
|
+
*
|
|
408
|
+
* 空行を出力します。
|
|
409
|
+
* メッセージ間に視覚的な余白を作る際に使用します。
|
|
410
|
+
*/
|
|
411
|
+
newline() {
|
|
412
|
+
this.write("stdout", "");
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* 内部:書き込み処理
|
|
416
|
+
*
|
|
417
|
+
* 指定された出力先(stdout または stderr)にメッセージを書き込みます。
|
|
418
|
+
* メッセージの末尾には自動的に改行文字が付与されます。
|
|
419
|
+
*
|
|
420
|
+
* @param target - 出力先('stdout' または 'stderr')
|
|
421
|
+
* @param message - 出力するメッセージ
|
|
422
|
+
*/
|
|
423
|
+
write(target, message) {
|
|
424
|
+
(target === "stdout" ? process.stdout : process.stderr).write(`${message}\n`);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* 内部:スピナーを描画
|
|
428
|
+
*
|
|
429
|
+
* 現在のスピナーフレームとメッセージを画面に描画します。
|
|
430
|
+
* カーソルを行頭に戻してから描画することで、
|
|
431
|
+
* 前のフレームを上書きしてアニメーション効果を実現します。
|
|
432
|
+
*/
|
|
433
|
+
renderSpinner() {
|
|
434
|
+
const frame = this.spinnerFrames[this.spinnerIndex];
|
|
435
|
+
this.clearLine();
|
|
436
|
+
process.stdout.write(` ${frame} ${this.currentSpinnerMessage}`);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* 内部:現在行をクリア
|
|
440
|
+
*
|
|
441
|
+
* カーソルを行頭に戻し(\r)、行全体をクリア(\x1b[K)します。
|
|
442
|
+
* スピナーのアニメーションや、前の出力を上書きする際に使用します。
|
|
443
|
+
* ANSIエスケープシーケンスを使用しています。
|
|
444
|
+
*/
|
|
445
|
+
clearLine() {
|
|
446
|
+
process.stdout.write("\r\x1B[K");
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
/**
|
|
450
|
+
* ヘルプメッセージを表示
|
|
451
|
+
*
|
|
452
|
+
* CLIの使用方法を標準出力に表示します。
|
|
453
|
+
* コマンドラインオプション、サブコマンド、使用例などを含みます。
|
|
454
|
+
* --help フラグまたは -h フラグが指定された際に呼び出されます。
|
|
455
|
+
*/
|
|
456
|
+
const showHelp = () => {
|
|
457
|
+
process.stdout.write(`
|
|
458
|
+
enbu - CLI for agent-browser workflow automation
|
|
459
|
+
|
|
460
|
+
USAGE:
|
|
461
|
+
npx enbu [command] [options] [flow-files...]
|
|
462
|
+
|
|
463
|
+
COMMANDS:
|
|
464
|
+
init Initialize a new project
|
|
465
|
+
(default) Run flow files
|
|
466
|
+
|
|
467
|
+
OPTIONS:
|
|
468
|
+
-h, --help Show this help message
|
|
469
|
+
-V, --version Show version number
|
|
470
|
+
-v, --verbose Enable verbose logging
|
|
471
|
+
--headed Show browser (disable headless mode)
|
|
472
|
+
--env KEY=VALUE Set environment variable (can be used multiple times)
|
|
473
|
+
--timeout <ms> Set timeout in milliseconds (default: 30000)
|
|
474
|
+
--screenshot Save screenshot on failure
|
|
475
|
+
--bail Stop on first failure
|
|
476
|
+
--session <name> Set agent-browser session name
|
|
477
|
+
|
|
478
|
+
EXAMPLES:
|
|
479
|
+
npx enbu init
|
|
480
|
+
npx enbu
|
|
481
|
+
npx enbu login.flow.yaml
|
|
482
|
+
npx enbu --headed --env USER=test login.flow.yaml
|
|
483
|
+
npx enbu --bail login.flow.yaml checkout.flow.yaml
|
|
484
|
+
|
|
485
|
+
For more information, visit: https://github.com/9wick/enbu
|
|
486
|
+
`);
|
|
487
|
+
};
|
|
488
|
+
/**
|
|
489
|
+
* バージョン情報を表示
|
|
490
|
+
*
|
|
491
|
+
* CLIのバージョン番号を標準出力に表示します。
|
|
492
|
+
* ビルド時に埋め込まれたバージョン情報(__VERSION__定数)を使用します。
|
|
493
|
+
* --version フラグまたは -V フラグが指定された際に呼び出されます。
|
|
494
|
+
*
|
|
495
|
+
* 実装の詳細:
|
|
496
|
+
* tsdown.config.tsのdefineオプションにより、__VERSION__定数が
|
|
497
|
+
* ビルド時にpackage.jsonのバージョン情報で置換されます。
|
|
498
|
+
* これにより、実行時のファイル読み込みオーバーヘッドがなく、
|
|
499
|
+
* ビルド構造の変更にも影響を受けません。
|
|
500
|
+
*/
|
|
501
|
+
const showVersion = () => {
|
|
502
|
+
process.stdout.write(`0.1.0\n`);
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
//#endregion
|
|
506
|
+
//#region src/utils/fs.ts
|
|
507
|
+
/**
|
|
508
|
+
* ファイルが存在するか確認
|
|
509
|
+
*
|
|
510
|
+
* 指定されたパスにファイルまたはディレクトリが存在するかを確認する。
|
|
511
|
+
* アクセス権限がない場合もfalseを返す。
|
|
512
|
+
*
|
|
513
|
+
* @param path - 確認対象のファイルまたはディレクトリのパス
|
|
514
|
+
* @returns ファイルが存在する場合はtrue、存在しない場合またはアクセスできない場合はfalse
|
|
515
|
+
*/
|
|
516
|
+
const fileExists = (path) => {
|
|
517
|
+
return access(path, constants.F_OK).then(() => true).catch(() => false);
|
|
518
|
+
};
|
|
519
|
+
/**
|
|
520
|
+
* ディレクトリを作成(存在しない場合のみ)
|
|
521
|
+
*
|
|
522
|
+
* 指定されたパスにディレクトリを作成する。
|
|
523
|
+
* recursive オプションにより、親ディレクトリが存在しない場合も自動的に作成される。
|
|
524
|
+
* 既にディレクトリが存在する場合はエラーにならず、成功として扱われる。
|
|
525
|
+
*
|
|
526
|
+
* @param path - 作成するディレクトリのパス
|
|
527
|
+
* @returns 成功時: void、失敗時: CliError(type: 'execution_error')
|
|
528
|
+
*/
|
|
529
|
+
const createDirectory = (path) => {
|
|
530
|
+
return fromPromise(mkdir(path, { recursive: true }), (error) => ({
|
|
531
|
+
type: "execution_error",
|
|
532
|
+
message: `Failed to create directory: ${path}`,
|
|
533
|
+
cause: error
|
|
534
|
+
})).map(() => void 0);
|
|
535
|
+
};
|
|
536
|
+
/**
|
|
537
|
+
* ファイルを書き込み
|
|
538
|
+
*
|
|
539
|
+
* 指定されたパスにテキストファイルを書き込む。
|
|
540
|
+
* ファイルが既に存在する場合は上書きされる。
|
|
541
|
+
* 親ディレクトリが存在しない場合はエラーとなるため、事前に createDirectory を実行すること。
|
|
542
|
+
*
|
|
543
|
+
* @param path - 書き込み先のファイルパス
|
|
544
|
+
* @param content - 書き込むテキスト内容
|
|
545
|
+
* @returns 成功時: void、失敗時: CliError(type: 'execution_error')
|
|
546
|
+
*/
|
|
547
|
+
const writeFileContent = (path, content) => {
|
|
548
|
+
return fromPromise(writeFile(path, content, "utf-8"), (error) => ({
|
|
549
|
+
type: "execution_error",
|
|
550
|
+
message: `Failed to write file: ${path}`,
|
|
551
|
+
cause: error
|
|
552
|
+
})).map(() => void 0);
|
|
553
|
+
};
|
|
554
|
+
/**
|
|
555
|
+
* ファイルを読み込み
|
|
556
|
+
*
|
|
557
|
+
* 指定されたパスからテキストファイルを読み込む。
|
|
558
|
+
* ファイルが存在しない場合やアクセス権限がない場合はエラーを返す。
|
|
559
|
+
* バイナリファイルの読み込みには対応していないため、UTF-8テキストファイルのみを対象とする。
|
|
560
|
+
*
|
|
561
|
+
* @param path - 読み込むファイルのパス
|
|
562
|
+
* @returns 成功時: ファイルの内容(文字列)、失敗時: CliError(type: 'execution_error')
|
|
563
|
+
*/
|
|
564
|
+
const readFileContent = (path) => {
|
|
565
|
+
return fromPromise(readFile(path, "utf-8"), (error) => ({
|
|
566
|
+
type: "execution_error",
|
|
567
|
+
message: `Failed to read file: ${path}`,
|
|
568
|
+
cause: error
|
|
569
|
+
}));
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
//#endregion
|
|
573
|
+
//#region src/commands/init.ts
|
|
574
|
+
/** 生成するディレクトリ */
|
|
575
|
+
const ABFLOW_DIR = ".abflow";
|
|
576
|
+
/** サンプルフローファイルの内容 */
|
|
577
|
+
const SAMPLE_FLOW_YAML = `# enbuのサンプルフロー
|
|
578
|
+
steps:
|
|
579
|
+
- open: https://example.com
|
|
580
|
+
- click: "More information..."
|
|
581
|
+
- assertVisible: "Example Domain"
|
|
582
|
+
`;
|
|
583
|
+
/**
|
|
584
|
+
* initコマンドを実行
|
|
585
|
+
*
|
|
586
|
+
* プロジェクトの初期化を行い、以下の処理を実行する:
|
|
587
|
+
* 1. .abflow/ ディレクトリを作成
|
|
588
|
+
* 2. サンプルフローファイル example.enbu.yaml を生成
|
|
589
|
+
* 3. .gitignore への追記を対話的に提案
|
|
590
|
+
*
|
|
591
|
+
* forceフラグがtrueの場合、既存ファイルを上書きする。
|
|
592
|
+
* forceフラグがfalseの場合、既存ファイルはスキップされる。
|
|
593
|
+
*
|
|
594
|
+
* @param args - initコマンドの引数
|
|
595
|
+
* @param args.force - 既存ファイルを強制的に上書きするかどうか
|
|
596
|
+
* @param args.verbose - 詳細なログ出力を行うかどうか
|
|
597
|
+
* @returns 成功時: void、失敗時: CliError
|
|
598
|
+
*/
|
|
599
|
+
const runInitCommand = async (args) => {
|
|
600
|
+
const formatter = new OutputFormatter(args.verbose);
|
|
601
|
+
formatter.info("Initializing enbu project...");
|
|
602
|
+
const setupResult = await setupAbflowDirectory(args.force, formatter);
|
|
603
|
+
if (setupResult.isErr()) return setupResult;
|
|
604
|
+
await promptGitignoreUpdate(formatter);
|
|
605
|
+
formatter.newline();
|
|
606
|
+
formatter.info("Initialization complete!");
|
|
607
|
+
formatter.info(`Try: npx enbu ${ABFLOW_DIR}/example.enbu.yaml`);
|
|
608
|
+
return ok(void 0);
|
|
609
|
+
};
|
|
610
|
+
/**
|
|
611
|
+
* .abflow/ ディレクトリとサンプルファイルを作成
|
|
612
|
+
*
|
|
613
|
+
* .abflow/ ディレクトリを作成し、その中にサンプルフローファイルを生成する。
|
|
614
|
+
* forceフラグがfalseで既存ファイルが存在する場合はスキップする。
|
|
615
|
+
*
|
|
616
|
+
* @param force - 既存ファイルを強制的に上書きするかどうか
|
|
617
|
+
* @param formatter - 出力フォーマッター
|
|
618
|
+
* @returns 成功時: void、失敗時: CliError
|
|
619
|
+
*/
|
|
620
|
+
const setupAbflowDirectory = async (force, formatter) => {
|
|
621
|
+
const abflowPath = resolve(process.cwd(), ABFLOW_DIR);
|
|
622
|
+
if (await fileExists(abflowPath) && !force) formatter.success(`Directory already exists: ${ABFLOW_DIR}`);
|
|
623
|
+
else {
|
|
624
|
+
const createDirResult = await createDirectory(abflowPath);
|
|
625
|
+
if (createDirResult.isErr()) return createDirResult;
|
|
626
|
+
formatter.success(`Created ${ABFLOW_DIR}/ directory`);
|
|
627
|
+
}
|
|
628
|
+
const exampleFlowPath = resolve(abflowPath, "example.enbu.yaml");
|
|
629
|
+
if (await fileExists(exampleFlowPath) && !force) formatter.success(`File already exists: ${ABFLOW_DIR}/example.enbu.yaml`);
|
|
630
|
+
else {
|
|
631
|
+
const writeResult = await writeFileContent(exampleFlowPath, SAMPLE_FLOW_YAML);
|
|
632
|
+
if (writeResult.isErr()) return writeResult;
|
|
633
|
+
formatter.success(`Created ${ABFLOW_DIR}/example.enbu.yaml`);
|
|
634
|
+
}
|
|
635
|
+
return ok(void 0);
|
|
636
|
+
};
|
|
637
|
+
/**
|
|
638
|
+
* .gitignore への追記を対話的に提案
|
|
639
|
+
*
|
|
640
|
+
* ユーザーに .gitignore への追記を提案し、了承された場合に .abflow/ を追記する。
|
|
641
|
+
* .gitignore の更新に失敗した場合は、エラーメッセージと手動での追記方法を表示する。
|
|
642
|
+
*
|
|
643
|
+
* @param formatter - 出力フォーマッター
|
|
644
|
+
*/
|
|
645
|
+
const promptGitignoreUpdate = async (formatter) => {
|
|
646
|
+
formatter.newline();
|
|
647
|
+
if (await askYesNo("Would you like to add .abflow/ to .gitignore? (y/N): ")) (await updateGitignore(resolve(process.cwd(), ".gitignore"))).match(() => formatter.success("Updated .gitignore"), (error) => {
|
|
648
|
+
formatter.error(`Failed to update .gitignore: ${error.message}`);
|
|
649
|
+
formatter.indent("You can manually add \".abflow/\" to your .gitignore file", 1);
|
|
650
|
+
});
|
|
651
|
+
};
|
|
652
|
+
/**
|
|
653
|
+
* Yes/No 質問を対話的に行う
|
|
654
|
+
*
|
|
655
|
+
* ユーザーに質問を表示し、標準入力から回答を受け取る。
|
|
656
|
+
* 'y' または 'yes'(大文字小文字を区別しない)が入力された場合にtrueを返す。
|
|
657
|
+
* それ以外の入力(空文字列を含む)の場合はfalseを返す。
|
|
658
|
+
*
|
|
659
|
+
* readlineのcreateInterfaceを使用して対話的な入力を実現している。
|
|
660
|
+
* 入力完了後は必ずrl.close()を呼び出してリソースを解放する。
|
|
661
|
+
*
|
|
662
|
+
* @param question - ユーザーに表示する質問文
|
|
663
|
+
* @returns ユーザーが 'y' または 'yes' を入力した場合はtrue、それ以外はfalse
|
|
664
|
+
*/
|
|
665
|
+
const askYesNo = (question) => {
|
|
666
|
+
return new Promise((resolve$1) => {
|
|
667
|
+
const rl = createInterface({
|
|
668
|
+
input: process.stdin,
|
|
669
|
+
output: process.stdout
|
|
670
|
+
});
|
|
671
|
+
rl.question(question, (answer) => {
|
|
672
|
+
rl.close();
|
|
673
|
+
const normalized = answer.trim().toLowerCase();
|
|
674
|
+
resolve$1(normalized === "y" || normalized === "yes");
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
};
|
|
678
|
+
/**
|
|
679
|
+
* .gitignore に .abflow/ を追記
|
|
680
|
+
*
|
|
681
|
+
* .gitignoreファイルに .abflow/ エントリを追加する。
|
|
682
|
+
* 以下の条件に応じて処理が分岐する:
|
|
683
|
+
*
|
|
684
|
+
* 1. .gitignoreが存在しない場合:
|
|
685
|
+
* - 新規に.gitignoreファイルを作成し、.abflow/を記述する
|
|
686
|
+
*
|
|
687
|
+
* 2. .gitignoreが存在し、既に.abflow/が含まれている場合:
|
|
688
|
+
* - 何もせずに成功を返す(重複追記を防ぐ)
|
|
689
|
+
*
|
|
690
|
+
* 3. .gitignoreが存在し、.abflow/が含まれていない場合:
|
|
691
|
+
* - ファイル末尾に.abflow/を追記する
|
|
692
|
+
* - 元のファイルが改行で終わっていない場合は、改行を追加してから追記する
|
|
693
|
+
*
|
|
694
|
+
* @param path - .gitignoreファイルのパス
|
|
695
|
+
* @returns 成功時: void、失敗時: CliError(type: 'execution_error')
|
|
696
|
+
*/
|
|
697
|
+
const updateGitignore = async (path) => {
|
|
698
|
+
const exists = await fileExists(path);
|
|
699
|
+
const entry = ".abflow/";
|
|
700
|
+
if (!exists) return writeFileContent(path, `${entry}\n`);
|
|
701
|
+
const readResult = await readFileContent(path);
|
|
702
|
+
if (readResult.isErr()) return readResult.andThen(() => ok(void 0));
|
|
703
|
+
const content = readResult.value;
|
|
704
|
+
if (content.includes(entry)) return ok(void 0);
|
|
705
|
+
return writeFileContent(path, content.endsWith("\n") ? `${content}${entry}\n` : `${content}\n${entry}\n`);
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
//#endregion
|
|
709
|
+
//#region src/commands/run.ts
|
|
710
|
+
/**
|
|
711
|
+
* runコマンドの実装
|
|
712
|
+
*
|
|
713
|
+
* フローファイルを読み込み、agent-browserで実行する。
|
|
714
|
+
* 実行結果を表示し、終了コードを返す。
|
|
715
|
+
*/
|
|
716
|
+
/**
|
|
717
|
+
* agent-browserのインストール状態を確認する
|
|
718
|
+
*
|
|
719
|
+
* @param formatter - 出力フォーマッター
|
|
720
|
+
* @returns 成功時: void、失敗時: CliError
|
|
721
|
+
*/
|
|
722
|
+
const checkAgentBrowserInstallation = async (formatter) => {
|
|
723
|
+
formatter.info("Checking agent-browser...");
|
|
724
|
+
formatter.debug("Checking agent-browser installation...");
|
|
725
|
+
return (await checkAgentBrowser()).match(() => {
|
|
726
|
+
formatter.success("agent-browser is installed");
|
|
727
|
+
formatter.newline();
|
|
728
|
+
return ok(void 0);
|
|
729
|
+
}, (error) => {
|
|
730
|
+
formatter.failure("agent-browser is not installed");
|
|
731
|
+
formatter.newline();
|
|
732
|
+
formatter.error("Error: agent-browser is not installed");
|
|
733
|
+
formatter.error("Please install it with: npm install -g agent-browser");
|
|
734
|
+
return err({
|
|
735
|
+
type: "execution_error",
|
|
736
|
+
message: error.type === "not_installed" ? error.message : `${error.type}: ${error.type === "command_failed" ? error.errorMessage ?? error.stderr : ""}`
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
};
|
|
740
|
+
/**
|
|
741
|
+
* フローファイルパスの配列を解決する
|
|
742
|
+
*
|
|
743
|
+
* ファイルが指定されていない場合、.abflow/ ディレクトリから検索する。
|
|
744
|
+
*
|
|
745
|
+
* @param files - 指定されたファイルパスの配列
|
|
746
|
+
* @returns 成功時: 解決されたファイルパスの配列、失敗時: CliError
|
|
747
|
+
*/
|
|
748
|
+
const resolveFlowFiles = async (files) => {
|
|
749
|
+
if (files.length > 0) return ok(files.map((f) => resolve(process.cwd(), f)));
|
|
750
|
+
return fromPromise(glob(resolve(process.cwd(), ".abflow", "*.enbu.yaml")), (error) => ({
|
|
751
|
+
type: "execution_error",
|
|
752
|
+
message: "Failed to search for flow files",
|
|
753
|
+
cause: error
|
|
754
|
+
}));
|
|
755
|
+
};
|
|
756
|
+
/**
|
|
757
|
+
* ファイルパスからフローを読み込んでパースする
|
|
758
|
+
*
|
|
759
|
+
* @param filePath - フローファイルのパス
|
|
760
|
+
* @returns 成功時: Flowオブジェクト、失敗時: CliError
|
|
761
|
+
*/
|
|
762
|
+
const loadFlowFromFile = async (filePath) => {
|
|
763
|
+
const readResult = await fromPromise(readFile(filePath, "utf-8"), (error) => ({
|
|
764
|
+
type: "execution_error",
|
|
765
|
+
message: `Failed to read file: ${filePath}`,
|
|
766
|
+
cause: error
|
|
767
|
+
}));
|
|
768
|
+
if (readResult.isErr()) return err(readResult.error);
|
|
769
|
+
const yamlContent = readResult.value;
|
|
770
|
+
return parseFlowYaml(yamlContent, filePath.split("/").pop() ?? "unknown.enbu.yaml").mapErr((parseError) => ({
|
|
771
|
+
type: "execution_error",
|
|
772
|
+
message: `Failed to parse flow file: ${parseError.message}`,
|
|
773
|
+
cause: parseError
|
|
774
|
+
}));
|
|
775
|
+
};
|
|
776
|
+
/**
|
|
777
|
+
* フローファイルを読み込む
|
|
778
|
+
*
|
|
779
|
+
* @param flowFiles - フローファイルパスの配列
|
|
780
|
+
* @param formatter - 出力フォーマッター
|
|
781
|
+
* @returns 成功時: Flowオブジェクトの配列、失敗時: CliError
|
|
782
|
+
*/
|
|
783
|
+
const loadFlows = async (flowFiles, formatter) => {
|
|
784
|
+
formatter.info("Loading flows...");
|
|
785
|
+
formatter.debug(`Loading flows from: ${flowFiles.join(", ")}`);
|
|
786
|
+
const flows = [];
|
|
787
|
+
for (const filePath of flowFiles) {
|
|
788
|
+
const loadResult = await loadFlowFromFile(filePath);
|
|
789
|
+
if (loadResult.isErr()) {
|
|
790
|
+
formatter.failure(`Failed to load flows: ${loadResult.error.message}`);
|
|
791
|
+
return err(loadResult.error);
|
|
792
|
+
}
|
|
793
|
+
flows.push(loadResult.value);
|
|
794
|
+
}
|
|
795
|
+
formatter.success(`Loaded ${flows.length} flow(s)`);
|
|
796
|
+
formatter.newline();
|
|
797
|
+
return ok(flows);
|
|
798
|
+
};
|
|
799
|
+
/**
|
|
800
|
+
* コマンド説明のフォーマッター関数マップ
|
|
801
|
+
*/
|
|
802
|
+
const commandFormatters = {
|
|
803
|
+
open: (cmd) => `open ${"url" in cmd && cmd.url || ""}`,
|
|
804
|
+
click: (cmd) => "selector" in cmd ? `click "${cmd.selector}"${"index" in cmd && cmd.index !== void 0 ? ` [${cmd.index}]` : ""}` : "click",
|
|
805
|
+
type: (cmd) => "selector" in cmd && "value" in cmd ? `type "${cmd.selector}" = "${cmd.value}"${"clear" in cmd && cmd.clear ? " (clear)" : ""}` : "type",
|
|
806
|
+
fill: (cmd) => "selector" in cmd && "value" in cmd ? `fill "${cmd.selector}" = "${cmd.value}"` : "fill",
|
|
807
|
+
press: (cmd) => `press ${"key" in cmd ? cmd.key : ""}`,
|
|
808
|
+
hover: (cmd) => "selector" in cmd ? `hover "${cmd.selector}"` : "hover",
|
|
809
|
+
select: (cmd) => "selector" in cmd && "value" in cmd ? `select "${cmd.selector}" = "${cmd.value}"` : "select",
|
|
810
|
+
scroll: (cmd) => "direction" in cmd && "amount" in cmd ? `scroll ${cmd.direction} ${cmd.amount}px` : "scroll",
|
|
811
|
+
scrollIntoView: (cmd) => "selector" in cmd ? `scrollIntoView "${cmd.selector}"` : "scrollIntoView",
|
|
812
|
+
wait: (cmd) => "ms" in cmd ? `wait ${cmd.ms}ms` : "target" in cmd ? `wait "${cmd.target}"` : "wait",
|
|
813
|
+
screenshot: (cmd) => "path" in cmd ? `screenshot ${cmd.path}${"fullPage" in cmd && cmd.fullPage ? " (full page)" : ""}` : "screenshot",
|
|
814
|
+
snapshot: () => "snapshot",
|
|
815
|
+
eval: (cmd) => "script" in cmd ? `eval "${cmd.script.substring(0, 50)}${cmd.script.length > 50 ? "..." : ""}"` : "eval",
|
|
816
|
+
assertVisible: (cmd) => "selector" in cmd ? `assertVisible "${cmd.selector}"` : "assertVisible",
|
|
817
|
+
assertEnabled: (cmd) => "selector" in cmd ? `assertEnabled "${cmd.selector}"` : "assertEnabled",
|
|
818
|
+
assertChecked: (cmd) => "selector" in cmd ? `assertChecked "${cmd.selector}"${"checked" in cmd && cmd.checked === false ? " (unchecked)" : ""}` : "assertChecked"
|
|
819
|
+
};
|
|
820
|
+
/**
|
|
821
|
+
* コマンドの説明を生成する
|
|
822
|
+
*
|
|
823
|
+
* @param command - コマンド
|
|
824
|
+
* @returns コマンドの説明文字列
|
|
825
|
+
*/
|
|
826
|
+
const formatCommandDescription = (command) => {
|
|
827
|
+
const formatter = commandFormatters[command.command];
|
|
828
|
+
return formatter ? formatter(command) : "unknown command";
|
|
829
|
+
};
|
|
830
|
+
/**
|
|
831
|
+
* フローを実行しながら進捗を表示する
|
|
832
|
+
*
|
|
833
|
+
* @param flow - 実行するフロー
|
|
834
|
+
* @param args - 実行オプション
|
|
835
|
+
* @param sessionName - セッション名
|
|
836
|
+
* @returns 成功時: FlowResult、失敗時: CliError
|
|
837
|
+
*/
|
|
838
|
+
const executeFlowWithProgress = async (flow, args, sessionName) => {
|
|
839
|
+
return (await executeFlow(flow, {
|
|
840
|
+
sessionName,
|
|
841
|
+
headed: args.headed,
|
|
842
|
+
env: args.env,
|
|
843
|
+
commandTimeoutMs: args.timeout,
|
|
844
|
+
screenshot: args.screenshot,
|
|
845
|
+
bail: args.bail
|
|
846
|
+
})).mapErr((agentError) => {
|
|
847
|
+
return {
|
|
848
|
+
type: "execution_error",
|
|
849
|
+
message: agentError.type === "not_installed" ? agentError.message : agentError.type === "parse_error" ? agentError.message : agentError.type === "timeout" ? `Timeout: ${agentError.command} (${agentError.timeoutMs}ms)` : agentError.errorMessage ?? agentError.stderr,
|
|
850
|
+
cause: agentError
|
|
851
|
+
};
|
|
852
|
+
});
|
|
853
|
+
};
|
|
854
|
+
/**
|
|
855
|
+
* 各ステップの結果を表示する(verboseモードのみ)
|
|
856
|
+
*/
|
|
857
|
+
const displayStepResults = (steps, formatter) => {
|
|
858
|
+
formatter.newline();
|
|
859
|
+
formatter.indent("Steps:", 1);
|
|
860
|
+
for (const step of steps) {
|
|
861
|
+
const stepDesc = formatCommandDescription(step.command);
|
|
862
|
+
const statusIcon = step.status === "passed" ? "✓" : "✗";
|
|
863
|
+
formatter.indent(`${statusIcon} Step ${step.index + 1}: ${stepDesc} (${step.duration}ms)`, 2);
|
|
864
|
+
if (step.error) formatter.indent(`Error: ${step.error.message}`, 3);
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
/**
|
|
868
|
+
* フロー実行結果を表示する
|
|
869
|
+
*
|
|
870
|
+
* @param flow - 実行したフロー
|
|
871
|
+
* @param result - フロー実行結果
|
|
872
|
+
* @param formatter - 出力フォーマッター
|
|
873
|
+
* @param verbose - verboseモードフラグ
|
|
874
|
+
*/
|
|
875
|
+
const displayFlowResult = (flow, result, formatter, verbose) => {
|
|
876
|
+
const duration = result.duration;
|
|
877
|
+
formatter.newline();
|
|
878
|
+
if (result.status === "passed") formatter.success(`PASSED: ${flow.name}.enbu.yaml`, duration);
|
|
879
|
+
else {
|
|
880
|
+
formatter.failure(`FAILED: ${flow.name}.enbu.yaml`, duration);
|
|
881
|
+
if (result.error) {
|
|
882
|
+
formatter.indent(`Step ${result.error.stepIndex + 1} failed: ${result.error.message}`, 1);
|
|
883
|
+
if (result.error.screenshot) formatter.indent(`Screenshot: ${result.error.screenshot}`, 1);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (verbose) displayStepResults(result.steps, formatter);
|
|
887
|
+
formatter.newline();
|
|
888
|
+
};
|
|
889
|
+
/**
|
|
890
|
+
* 全てのフローを実行する
|
|
891
|
+
*
|
|
892
|
+
* @param flows - 実行するフロー配列
|
|
893
|
+
* @param args - 実行オプション
|
|
894
|
+
* @param formatter - 出力フォーマッター
|
|
895
|
+
* @returns フロー実行結果のサマリー
|
|
896
|
+
*/
|
|
897
|
+
const executeAllFlows = async (flows, args, formatter) => {
|
|
898
|
+
let passed = 0;
|
|
899
|
+
let failed = 0;
|
|
900
|
+
const startTime = Date.now();
|
|
901
|
+
for (const flow of flows) {
|
|
902
|
+
formatter.info(`Running: ${flow.name}.enbu.yaml`);
|
|
903
|
+
formatter.debug(`Executing flow: ${flow.name} (${flow.steps.length} steps)`);
|
|
904
|
+
const flowStartTime = Date.now();
|
|
905
|
+
const sessionName = `abf-${args.session || flow.name}-${Date.now()}`;
|
|
906
|
+
await (await executeFlowWithProgress(flow, args, sessionName)).match(async (result) => {
|
|
907
|
+
displayFlowResult(flow, result, formatter, args.verbose);
|
|
908
|
+
if (result.status === "passed") {
|
|
909
|
+
passed++;
|
|
910
|
+
(await closeSession(sessionName)).mapErr((error) => {
|
|
911
|
+
formatter.debug(`Failed to close session: ${error.type}`);
|
|
912
|
+
});
|
|
913
|
+
} else {
|
|
914
|
+
failed++;
|
|
915
|
+
formatter.info("💡 Debug: To inspect the browser state, run:");
|
|
916
|
+
formatter.indent(`npx agent-browser snapshot --session ${sessionName}`, 1);
|
|
917
|
+
}
|
|
918
|
+
}, async (error) => {
|
|
919
|
+
failed++;
|
|
920
|
+
const duration = Date.now() - flowStartTime;
|
|
921
|
+
formatter.newline();
|
|
922
|
+
formatter.failure(`FAILED: ${flow.name}.enbu.yaml`, duration);
|
|
923
|
+
formatter.indent(error.message, 1);
|
|
924
|
+
formatter.newline();
|
|
925
|
+
});
|
|
926
|
+
if (args.bail && failed > 0) {
|
|
927
|
+
formatter.error("Stopping due to --bail flag");
|
|
928
|
+
formatter.newline();
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
formatter.separator();
|
|
933
|
+
const totalDuration = Date.now() - startTime;
|
|
934
|
+
const total = passed + failed;
|
|
935
|
+
formatter.info(`Summary: ${passed}/${total} flows passed (${(totalDuration / 1e3).toFixed(1)}s)`);
|
|
936
|
+
if (failed > 0) {
|
|
937
|
+
formatter.newline();
|
|
938
|
+
formatter.error("Exit code: 1");
|
|
939
|
+
}
|
|
940
|
+
return {
|
|
941
|
+
passed,
|
|
942
|
+
failed,
|
|
943
|
+
total
|
|
944
|
+
};
|
|
945
|
+
};
|
|
946
|
+
/**
|
|
947
|
+
* runコマンドを実行する
|
|
948
|
+
*
|
|
949
|
+
* @param args - runコマンドの引数
|
|
950
|
+
* @param formatter - 出力フォーマッター
|
|
951
|
+
* @returns 成功時: 実行結果、失敗時: CliError
|
|
952
|
+
*/
|
|
953
|
+
const runFlowCommand = async (args, formatter) => {
|
|
954
|
+
formatter.debug(`Args: ${JSON.stringify(args)}`);
|
|
955
|
+
const checkResult = await checkAgentBrowserInstallation(formatter);
|
|
956
|
+
if (checkResult.isErr()) return err(checkResult.error);
|
|
957
|
+
const flowFilesResult = await resolveFlowFiles(args.files);
|
|
958
|
+
if (flowFilesResult.isErr()) return err(flowFilesResult.error);
|
|
959
|
+
const flowFiles = flowFilesResult.value;
|
|
960
|
+
if (flowFiles.length === 0) {
|
|
961
|
+
formatter.error("Error: No flow files found");
|
|
962
|
+
formatter.error("Try: npx enbu init");
|
|
963
|
+
return err({
|
|
964
|
+
type: "execution_error",
|
|
965
|
+
message: "No flow files found"
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
const loadResult = await loadFlows(flowFiles, formatter);
|
|
969
|
+
if (loadResult.isErr()) return err(loadResult.error);
|
|
970
|
+
const flows = loadResult.value;
|
|
971
|
+
return ok(await executeAllFlows(flows, args, formatter));
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
//#endregion
|
|
975
|
+
//#region src/output/exit-code.ts
|
|
976
|
+
/**
|
|
977
|
+
* 終了コード定義
|
|
978
|
+
*/
|
|
979
|
+
const EXIT_CODE = {
|
|
980
|
+
SUCCESS: 0,
|
|
981
|
+
FLOW_FAILED: 1,
|
|
982
|
+
EXECUTION_ERROR: 2
|
|
983
|
+
};
|
|
984
|
+
/**
|
|
985
|
+
* 終了コードで終了する
|
|
986
|
+
*
|
|
987
|
+
* @param code - 終了コード
|
|
988
|
+
*/
|
|
989
|
+
const exitWithCode = (code) => {
|
|
990
|
+
process.exit(code);
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
//#endregion
|
|
994
|
+
//#region src/main.ts
|
|
995
|
+
/**
|
|
996
|
+
* CLIエントリポイント
|
|
997
|
+
*
|
|
998
|
+
* コマンドライン引数をパースし、適切なコマンドを実行する。
|
|
999
|
+
* 実行結果に基づいて終了コードを設定する。
|
|
1000
|
+
*/
|
|
1001
|
+
const main = async () => {
|
|
1002
|
+
await parseArgs(process.argv.slice(2)).match(async (args) => {
|
|
1003
|
+
if (args.version) {
|
|
1004
|
+
showVersion();
|
|
1005
|
+
exitWithCode(EXIT_CODE.SUCCESS);
|
|
1006
|
+
}
|
|
1007
|
+
if (args.help) {
|
|
1008
|
+
showHelp();
|
|
1009
|
+
exitWithCode(EXIT_CODE.SUCCESS);
|
|
1010
|
+
}
|
|
1011
|
+
if (args.command === "init") (await runInitCommand({
|
|
1012
|
+
force: args.force,
|
|
1013
|
+
verbose: args.verbose
|
|
1014
|
+
})).match(() => exitWithCode(EXIT_CODE.SUCCESS), (error) => {
|
|
1015
|
+
process.stderr.write(`Error: ${error.message}\n`);
|
|
1016
|
+
exitWithCode(EXIT_CODE.EXECUTION_ERROR);
|
|
1017
|
+
});
|
|
1018
|
+
else {
|
|
1019
|
+
const formatter = new OutputFormatter(args.verbose);
|
|
1020
|
+
(await runFlowCommand({
|
|
1021
|
+
files: args.files,
|
|
1022
|
+
headed: args.headed,
|
|
1023
|
+
env: args.env,
|
|
1024
|
+
timeout: args.timeout,
|
|
1025
|
+
screenshot: args.screenshot,
|
|
1026
|
+
bail: args.bail,
|
|
1027
|
+
session: args.session,
|
|
1028
|
+
verbose: args.verbose
|
|
1029
|
+
}, formatter)).match((executionResult) => {
|
|
1030
|
+
exitWithCode(executionResult.failed > 0 ? EXIT_CODE.FLOW_FAILED : EXIT_CODE.SUCCESS);
|
|
1031
|
+
}, (error) => {
|
|
1032
|
+
process.stderr.write(`Error: ${error.message}\n`);
|
|
1033
|
+
exitWithCode(EXIT_CODE.EXECUTION_ERROR);
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
}, (error) => {
|
|
1037
|
+
process.stderr.write(`Error: ${error.message}\n`);
|
|
1038
|
+
process.stderr.write("Try: npx enbu --help\n");
|
|
1039
|
+
exitWithCode(EXIT_CODE.EXECUTION_ERROR);
|
|
1040
|
+
});
|
|
1041
|
+
};
|
|
1042
|
+
main();
|
|
1043
|
+
|
|
1044
|
+
//#endregion
|
|
1045
|
+
export { };
|
|
1046
|
+
//# sourceMappingURL=main.mjs.map
|