codex-work-receipt 0.7.4 → 0.8.1

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.
@@ -0,0 +1,364 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ import { dateKey } from "../lib/time.mjs";
8
+ import { writeJsonAtomicSync } from "../lib/files.mjs";
9
+
10
+ const require = createRequire(import.meta.url);
11
+
12
+ export const AUTO_HOOK_MARKER = "codex-work-receipt-auto-hook-v1";
13
+ export const AUTO_CONFIG_VERSION = 1;
14
+
15
+ function isObject(value) {
16
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
17
+ }
18
+
19
+ function timestampSlug(now = new Date()) {
20
+ return now.toISOString().replace(/[:.]/g, "-");
21
+ }
22
+
23
+ export function getWorkReceiptHome({ homeDir = os.homedir(), dataDir = null } = {}) {
24
+ return path.resolve(
25
+ dataDir || process.env.CODEX_WORK_RECEIPT_HOME || path.join(homeDir, ".codex-work-receipt"),
26
+ );
27
+ }
28
+
29
+ export function getAutoPaths({ workReceiptHome }) {
30
+ const root = path.resolve(workReceiptHome);
31
+ return {
32
+ root,
33
+ configPath: path.join(root, "config.json"),
34
+ statePath: path.join(root, "auto-state.json"),
35
+ runtimeDir: path.join(root, "runtime"),
36
+ autoOutputDir: path.join(root, "auto"),
37
+ logDir: path.join(root, "logs"),
38
+ };
39
+ }
40
+
41
+ export function readAutoConfig({ workReceiptHome }) {
42
+ const { configPath } = getAutoPaths({ workReceiptHome });
43
+ if (!fs.existsSync(configPath)) return null;
44
+ try {
45
+ const value = JSON.parse(fs.readFileSync(configPath, "utf8"));
46
+ return isObject(value) ? value : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ export function readAutoState({ workReceiptHome }) {
53
+ const { statePath } = getAutoPaths({ workReceiptHome });
54
+ if (!fs.existsSync(statePath)) return null;
55
+ try {
56
+ const value = JSON.parse(fs.readFileSync(statePath, "utf8"));
57
+ return isObject(value) ? value : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ export function writeAutoState({ workReceiptHome }, value) {
64
+ const { statePath } = getAutoPaths({ workReceiptHome });
65
+ writeJsonAtomicSync(statePath, value);
66
+ }
67
+
68
+ function packageVersion(projectDir) {
69
+ try {
70
+ return JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf8")).version || "unknown";
71
+ } catch {
72
+ return "unknown";
73
+ }
74
+ }
75
+
76
+ function installRuntime({ projectDir, workReceiptHome, nodePath }) {
77
+ const { runtimeDir } = getAutoPaths({ workReceiptHome });
78
+ const stagingDir = path.join(
79
+ path.dirname(runtimeDir),
80
+ `.runtime.install-${process.pid}-${Date.now()}`,
81
+ );
82
+ const domToImageSource = require.resolve("dom-to-image-more");
83
+
84
+ fs.mkdirSync(stagingDir, { recursive: true });
85
+ try {
86
+ fs.cpSync(path.join(projectDir, "src"), path.join(stagingDir, "src"), { recursive: true });
87
+ fs.mkdirSync(path.join(stagingDir, "assets"), { recursive: true });
88
+ fs.copyFileSync(
89
+ path.join(projectDir, "assets", "miniprogram-code.png"),
90
+ path.join(stagingDir, "assets", "miniprogram-code.png"),
91
+ );
92
+ fs.copyFileSync(path.join(projectDir, "package.json"), path.join(stagingDir, "package.json"));
93
+ fs.mkdirSync(path.join(stagingDir, "vendor"), { recursive: true });
94
+ fs.copyFileSync(domToImageSource, path.join(stagingDir, "vendor", "dom-to-image-more.min.js"));
95
+ writeJsonAtomicSync(path.join(stagingDir, "runtime.json"), {
96
+ version: 1,
97
+ package_version: packageVersion(projectDir),
98
+ node_path: nodePath,
99
+ installed_at: new Date().toISOString(),
100
+ });
101
+
102
+ fs.rmSync(runtimeDir, { recursive: true, force: true });
103
+ fs.renameSync(stagingDir, runtimeDir);
104
+ } catch (error) {
105
+ fs.rmSync(stagingDir, { recursive: true, force: true });
106
+ throw error;
107
+ }
108
+
109
+ return {
110
+ runtimeDir,
111
+ runtimeVersion: packageVersion(projectDir),
112
+ hookEntry: path.join(runtimeDir, "src", "auto-hook.mjs"),
113
+ runnerEntry: path.join(runtimeDir, "src", "auto-runner.mjs"),
114
+ };
115
+ }
116
+
117
+ function posixQuote(value) {
118
+ return `'${String(value).replaceAll("'", `'\\''`)}'`;
119
+ }
120
+
121
+ function windowsQuote(value) {
122
+ return `"${String(value).replaceAll('"', '""')}"`;
123
+ }
124
+
125
+ function hookHandler({ nodePath, hookEntry, workReceiptHome }) {
126
+ const args = [hookEntry, "--work-receipt-home", workReceiptHome, "--marker", AUTO_HOOK_MARKER];
127
+ return {
128
+ type: "command",
129
+ command: [nodePath, ...args].map(posixQuote).join(" "),
130
+ commandWindows: [nodePath, ...args].map(windowsQuote).join(" "),
131
+ timeout: 5,
132
+ };
133
+ }
134
+
135
+ function isOwnHook(handler) {
136
+ return isObject(handler) && (
137
+ String(handler.command || "").includes(AUTO_HOOK_MARKER) ||
138
+ String(handler.commandWindows || handler.command_windows || "").includes(AUTO_HOOK_MARKER)
139
+ );
140
+ }
141
+
142
+ function removeOwnHooks(document) {
143
+ if (!isObject(document?.hooks) || !Array.isArray(document.hooks.Stop)) return false;
144
+ let changed = false;
145
+ const nextGroups = [];
146
+ for (const group of document.hooks.Stop) {
147
+ if (!isObject(group) || !Array.isArray(group.hooks)) {
148
+ nextGroups.push(group);
149
+ continue;
150
+ }
151
+ const nextHandlers = group.hooks.filter((handler) => {
152
+ const own = isOwnHook(handler);
153
+ if (own) changed = true;
154
+ return !own;
155
+ });
156
+ if (nextHandlers.length || !group.hooks.some(isOwnHook)) {
157
+ nextGroups.push({ ...group, hooks: nextHandlers });
158
+ }
159
+ }
160
+ if (changed) document.hooks.Stop = nextGroups;
161
+ return changed;
162
+ }
163
+
164
+ function readHooksDocument(hooksPath) {
165
+ if (!fs.existsSync(hooksPath)) return {};
166
+ let document;
167
+ try {
168
+ document = JSON.parse(fs.readFileSync(hooksPath, "utf8"));
169
+ } catch (error) {
170
+ throw new Error(`无法解析现有 Codex Hooks 配置:${hooksPath}:${error.message}`);
171
+ }
172
+ if (!isObject(document)) throw new Error(`Codex Hooks 配置必须是 JSON 对象:${hooksPath}`);
173
+ return document;
174
+ }
175
+
176
+ function persistHooksDocument(hooksPath, document, { createBackup = true } = {}) {
177
+ let backupPath = null;
178
+ if (createBackup && fs.existsSync(hooksPath)) {
179
+ backupPath = `${hooksPath}.codex-work-receipt-${timestampSlug()}.bak`;
180
+ fs.copyFileSync(hooksPath, backupPath);
181
+ }
182
+ writeJsonAtomicSync(hooksPath, document);
183
+ return backupPath;
184
+ }
185
+
186
+ function installHook({ hooksPath, handler }) {
187
+ const document = readHooksDocument(hooksPath);
188
+ removeOwnHooks(document);
189
+ if (document.hooks !== undefined && !isObject(document.hooks)) {
190
+ throw new Error(`Codex Hooks 配置中的 hooks 必须是 JSON 对象:${hooksPath}`);
191
+ }
192
+ if (!document.hooks) document.hooks = {};
193
+ if (document.hooks.Stop !== undefined && !Array.isArray(document.hooks.Stop)) {
194
+ throw new Error(`Codex Hooks 配置中的 Stop 必须是数组:${hooksPath}`);
195
+ }
196
+ if (!document.hooks.Stop) document.hooks.Stop = [];
197
+ document.hooks.Stop.push({ hooks: [handler] });
198
+ const backupPath = persistHooksDocument(hooksPath, document, {
199
+ createBackup: fs.existsSync(hooksPath),
200
+ });
201
+ return { hooksPath, backupPath };
202
+ }
203
+
204
+ export function removeAutomaticHook({ hooksPath }) {
205
+ if (!hooksPath || !fs.existsSync(hooksPath)) return { hooksPath, removed: false, backupPath: null };
206
+ const document = readHooksDocument(hooksPath);
207
+ const removed = removeOwnHooks(document);
208
+ if (!removed) return { hooksPath, removed: false, backupPath: null };
209
+ const backupPath = persistHooksDocument(hooksPath, document);
210
+ return { hooksPath, removed: true, backupPath };
211
+ }
212
+
213
+ function hookIsInstalled(hooksPath) {
214
+ if (!hooksPath || !fs.existsSync(hooksPath)) return false;
215
+ try {
216
+ const document = readHooksDocument(hooksPath);
217
+ return Array.isArray(document.hooks?.Stop) && document.hooks.Stop.some(
218
+ (group) => Array.isArray(group?.hooks) && group.hooks.some(isOwnHook),
219
+ );
220
+ } catch {
221
+ return false;
222
+ }
223
+ }
224
+
225
+ function codexHomePath({ homeDir, codexHome }) {
226
+ return path.resolve(codexHome || process.env.CODEX_HOME || path.join(homeDir, ".codex"));
227
+ }
228
+
229
+ export function enableAutomaticMode({
230
+ projectDir,
231
+ homeDir = os.homedir(),
232
+ codexHome = null,
233
+ dataDir = null,
234
+ locale = "zh-CN",
235
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Shanghai",
236
+ theme = "classic",
237
+ nodePath = process.execPath,
238
+ } = {}) {
239
+ if (!projectDir) throw new Error("启用自动保存时缺少项目目录");
240
+ const workReceiptHome = getWorkReceiptHome({ homeDir, dataDir });
241
+ const paths = getAutoPaths({ workReceiptHome });
242
+ const previous = readAutoConfig({ workReceiptHome });
243
+ const resolvedCodexHome = codexHomePath({ homeDir, codexHome });
244
+ const hooksPath = path.join(resolvedCodexHome, "hooks.json");
245
+
246
+ if (previous?.hook?.hooks_path && path.resolve(previous.hook.hooks_path) !== hooksPath) {
247
+ removeAutomaticHook({ hooksPath: previous.hook.hooks_path });
248
+ }
249
+
250
+ const runtime = installRuntime({ projectDir, workReceiptHome, nodePath });
251
+ const installedHook = installHook({
252
+ hooksPath,
253
+ handler: hookHandler({
254
+ nodePath,
255
+ hookEntry: runtime.hookEntry,
256
+ workReceiptHome,
257
+ }),
258
+ });
259
+ const config = {
260
+ config_version: AUTO_CONFIG_VERSION,
261
+ mode: "automatic",
262
+ preferences: { locale, timezone, theme },
263
+ work_receipt_home: workReceiptHome,
264
+ runtime: {
265
+ package_version: runtime.runtimeVersion,
266
+ node_path: nodePath,
267
+ runner_entry: runtime.runnerEntry,
268
+ },
269
+ hook: {
270
+ marker: AUTO_HOOK_MARKER,
271
+ codex_home: resolvedCodexHome,
272
+ hooks_path: hooksPath,
273
+ },
274
+ updated_at: new Date().toISOString(),
275
+ };
276
+ writeJsonAtomicSync(paths.configPath, config);
277
+ return { config, paths, installedHook };
278
+ }
279
+
280
+ export function configureManualMode({
281
+ homeDir = os.homedir(),
282
+ codexHome = null,
283
+ dataDir = null,
284
+ locale = "zh-CN",
285
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Shanghai",
286
+ theme = "classic",
287
+ } = {}) {
288
+ const workReceiptHome = getWorkReceiptHome({ homeDir, dataDir });
289
+ const paths = getAutoPaths({ workReceiptHome });
290
+ const previous = readAutoConfig({ workReceiptHome });
291
+ const currentHooksPath = path.join(codexHomePath({ homeDir, codexHome }), "hooks.json");
292
+ const hookTargets = new Set([previous?.hook?.hooks_path, currentHooksPath].filter(Boolean));
293
+ const removedHooks = [...hookTargets].map((hooksPath) => removeAutomaticHook({ hooksPath }));
294
+ const removedHook = removedHooks.find((item) => item.removed) || removedHooks[0] || {
295
+ hooksPath: currentHooksPath,
296
+ removed: false,
297
+ backupPath: null,
298
+ };
299
+ const config = {
300
+ config_version: AUTO_CONFIG_VERSION,
301
+ mode: "manual",
302
+ preferences: { locale, timezone, theme },
303
+ work_receipt_home: workReceiptHome,
304
+ runtime: previous?.runtime || null,
305
+ hook: null,
306
+ updated_at: new Date().toISOString(),
307
+ };
308
+ writeJsonAtomicSync(paths.configPath, config);
309
+ return { config, paths, removedHook };
310
+ }
311
+
312
+ export function automaticOutputPath(config, now = new Date()) {
313
+ const workReceiptHome = config.work_receipt_home;
314
+ const timezone = config.preferences?.timezone || "Asia/Shanghai";
315
+ const day = dateKey(now, timezone);
316
+ return path.join(
317
+ getAutoPaths({ workReceiptHome }).autoOutputDir,
318
+ day,
319
+ `codex-receipt-today-${day}.html`,
320
+ );
321
+ }
322
+
323
+ export function startAutomaticRun(config) {
324
+ const nodePath = config?.runtime?.node_path;
325
+ const runnerEntry = config?.runtime?.runner_entry;
326
+ if (!nodePath || !runnerEntry || !fs.existsSync(runnerEntry)) return false;
327
+ const child = spawn(nodePath, [
328
+ runnerEntry,
329
+ "--work-receipt-home",
330
+ config.work_receipt_home,
331
+ "--triggered-at",
332
+ new Date().toISOString(),
333
+ ], {
334
+ detached: true,
335
+ stdio: "ignore",
336
+ });
337
+ child.unref();
338
+ return true;
339
+ }
340
+
341
+ export function getAutomaticStatus({
342
+ homeDir = os.homedir(),
343
+ codexHome = null,
344
+ dataDir = null,
345
+ } = {}) {
346
+ const workReceiptHome = getWorkReceiptHome({ homeDir, dataDir });
347
+ const config = readAutoConfig({ workReceiptHome });
348
+ const state = readAutoState({ workReceiptHome });
349
+ const hooksPath = config?.hook?.hooks_path || path.join(codexHomePath({ homeDir, codexHome }), "hooks.json");
350
+ return {
351
+ workReceiptHome,
352
+ config,
353
+ state,
354
+ mode: config?.mode || "unconfigured",
355
+ hookInstalled: hookIsInstalled(hooksPath),
356
+ runtimeInstalled: Boolean(
357
+ config?.runtime?.runner_entry &&
358
+ config?.runtime?.node_path &&
359
+ fs.existsSync(config.runtime.runner_entry) &&
360
+ fs.existsSync(config.runtime.node_path)
361
+ ),
362
+ outputFile: config?.mode === "automatic" ? automaticOutputPath(config) : state?.output_file || null,
363
+ };
364
+ }
@@ -0,0 +1,74 @@
1
+ import { canonicalStringify, sha256Hex } from "./canonical.mjs";
2
+ import { compactReceipt } from "./transfer-record.mjs";
3
+
4
+ export const RECEIPT_FILE_FORMAT = "codex-work-receipt";
5
+ export const RECEIPT_FILE_VERSION = 1;
6
+ export const RECEIPT_FILE_MIME_TYPE = "application/vnd.codex-work-receipt+json";
7
+
8
+ function isObject(value) {
9
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
10
+ }
11
+
12
+ function payloadSchemaFor(record) {
13
+ return record.schema_version === 2 ? "cwr2" : "cwr1";
14
+ }
15
+
16
+ export function buildReceiptFileEnvelope(record) {
17
+ const payload = compactReceipt(record);
18
+ return {
19
+ format: RECEIPT_FILE_FORMAT,
20
+ file_version: RECEIPT_FILE_VERSION,
21
+ payload_schema: payloadSchemaFor(record),
22
+ payload,
23
+ integrity: {
24
+ algorithm: "sha256",
25
+ digest: sha256Hex(canonicalStringify(payload)),
26
+ },
27
+ };
28
+ }
29
+
30
+ export function serializeReceiptFileEnvelope(envelope) {
31
+ return `${JSON.stringify(envelope, null, 2)}\n`;
32
+ }
33
+
34
+ export function createReceiptFile(record) {
35
+ const envelope = buildReceiptFileEnvelope(record);
36
+ return {
37
+ envelope,
38
+ content: serializeReceiptFileEnvelope(envelope),
39
+ mimeType: RECEIPT_FILE_MIME_TYPE,
40
+ };
41
+ }
42
+
43
+ export function decodeReceiptFile(value) {
44
+ let envelope;
45
+ try {
46
+ envelope = typeof value === "string" ? JSON.parse(value) : value;
47
+ } catch {
48
+ throw new Error("微信导入文件不是有效的 JSON");
49
+ }
50
+ if (!isObject(envelope) || envelope.format !== RECEIPT_FILE_FORMAT) {
51
+ throw new Error("不是有效的 AI 打工小票导入文件");
52
+ }
53
+ if (envelope.file_version !== RECEIPT_FILE_VERSION) {
54
+ throw new Error(`不支持的导入文件版本:${envelope.file_version}`);
55
+ }
56
+ if (envelope.payload_schema !== "cwr1" && envelope.payload_schema !== "cwr2") {
57
+ throw new Error(`不支持的小票数据协议:${envelope.payload_schema}`);
58
+ }
59
+ if (!isObject(envelope.payload) || envelope.payload.v !== Number(envelope.payload_schema.slice(3))) {
60
+ throw new Error("导入文件的数据协议与内容不一致");
61
+ }
62
+ if (
63
+ !isObject(envelope.integrity) ||
64
+ envelope.integrity.algorithm !== "sha256" ||
65
+ !/^[a-f0-9]{64}$/.test(String(envelope.integrity.digest || ""))
66
+ ) {
67
+ throw new Error("导入文件缺少有效的完整性校验");
68
+ }
69
+ const actualDigest = sha256Hex(canonicalStringify(envelope.payload));
70
+ if (actualDigest !== envelope.integrity.digest) {
71
+ throw new Error("微信导入文件完整性校验失败");
72
+ }
73
+ return envelope.payload;
74
+ }
@@ -0,0 +1,98 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { buildCanonicalFacts } from "./fact-buckets.mjs";
5
+ import { createReceiptFile } from "./file-payload.mjs";
6
+ import { collectMetrics } from "./metrics.mjs";
7
+ import { outputSlugForRange, resolveRange } from "./range.mjs";
8
+ import {
9
+ buildReceiptRecord,
10
+ persistReceiptRecord,
11
+ transferFilePathForOutput,
12
+ } from "./receipt-record.mjs";
13
+ import { loadCodexSessions } from "../parsers/codex.mjs";
14
+ import { renderHtml } from "../renderers/html.mjs";
15
+ import { writeFileAtomicSync } from "../lib/files.mjs";
16
+
17
+ function mimeTypeForImage(filePath) {
18
+ const extension = path.extname(filePath).toLowerCase();
19
+ if (extension === ".png") return "image/png";
20
+ if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
21
+ if (extension === ".webp") return "image/webp";
22
+ if (extension === ".svg") return "image/svg+xml";
23
+ throw new Error(`不支持的小程序码图片格式:${extension || "未知"}`);
24
+ }
25
+
26
+ function imageAsDataUrl(filePath) {
27
+ if (!filePath || !fs.existsSync(filePath)) return null;
28
+ return `data:${mimeTypeForImage(filePath)};base64,${fs.readFileSync(filePath).toString("base64")}`;
29
+ }
30
+
31
+ export async function generateReceipt(
32
+ options,
33
+ {
34
+ projectDir,
35
+ now = new Date(),
36
+ createDataQr = null,
37
+ codexHome = null,
38
+ } = {},
39
+ ) {
40
+ if (!projectDir) throw new Error("生成小票时缺少项目目录");
41
+
42
+ const range = resolveRange(options.mode, options.timezone, now, options.sessionId, options.hours);
43
+ const sessions = loadCodexSessions(range, { codexHome });
44
+ const metrics = collectMetrics(sessions, range);
45
+ const observedAt = now.toISOString();
46
+ const canonical = range.scope === "last-hours"
47
+ ? {}
48
+ : buildCanonicalFacts(sessions, range, { observedAt });
49
+ const record = buildReceiptRecord(metrics, options.theme, options.locale, canonical);
50
+
51
+ const defaultOutputDir = path.join(process.cwd(), "codex-work-receipt-output");
52
+ const requestedOutput = options.output || path.join(
53
+ defaultOutputDir,
54
+ `codex-receipt-${outputSlugForRange(range, record.id)}.html`,
55
+ );
56
+ const outputFile = path.resolve(/\.html?$/i.test(requestedOutput) ? requestedOutput : `${requestedOutput}.html`);
57
+ fs.mkdirSync(path.dirname(outputFile), { recursive: true });
58
+
59
+ const transferFile = createReceiptFile(record);
60
+ const transferPath = transferFilePathForOutput(outputFile);
61
+ let dataQrDataUrl = null;
62
+ let dataQrVersion = null;
63
+ if (typeof createDataQr === "function") {
64
+ try {
65
+ const dataQr = await createDataQr(record);
66
+ dataQrDataUrl = dataQr?.dataUrl || null;
67
+ dataQrVersion = dataQr?.version || null;
68
+ } catch {
69
+ dataQrDataUrl = null;
70
+ dataQrVersion = null;
71
+ }
72
+ }
73
+
74
+ const miniProgramCodeDataUrl = imageAsDataUrl(path.join(projectDir, "assets", "miniprogram-code.png"));
75
+ writeFileAtomicSync(
76
+ outputFile,
77
+ renderHtml({
78
+ record,
79
+ dataQrDataUrl,
80
+ miniProgramCodeDataUrl,
81
+ transferFile: {
82
+ ...transferFile,
83
+ filename: path.basename(transferPath),
84
+ },
85
+ }),
86
+ );
87
+ const persisted = persistReceiptRecord(record, outputFile, options.dataDir, transferFile);
88
+
89
+ return {
90
+ range,
91
+ record,
92
+ outputFile,
93
+ persisted,
94
+ dataQrDataUrl,
95
+ dataQrVersion,
96
+ miniProgramCodeDataUrl,
97
+ };
98
+ }
@@ -0,0 +1,31 @@
1
+ import { createInterface } from "node:readline/promises";
2
+
3
+ export async function promptForGenerationMode({ locale = "zh-CN" } = {}) {
4
+ const isEnglish = locale === "en";
5
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
6
+ try {
7
+ console.log(isEnglish ? "\nHow should AI Work Receipt save receipts?\n" : "\n你希望怎样保存 AI 打工小票?\n");
8
+ console.log(isEnglish
9
+ ? "1. Automatic saving (recommended)"
10
+ : "1. 自动保存(推荐)");
11
+ console.log(isEnglish
12
+ ? " Quietly refresh today's receipt and WeChat import file whenever a Codex turn stops."
13
+ : " Codex 每完成一轮工作,就静默刷新今天的小票和微信导入文件。");
14
+ console.log(isEnglish
15
+ ? "2. Manual only"
16
+ : "2. 仅手动");
17
+ console.log(isEnglish
18
+ ? " Generate only when you run the command or ask Ticket Buddy."
19
+ : " 只有执行命令或告诉票仔时才生成。");
20
+
21
+ while (true) {
22
+ const answer = (await readline.question(
23
+ isEnglish ? "\nEnter 1–2 (default 1): " : "\n请输入 1–2(默认 1):",
24
+ )).trim();
25
+ if (!answer || answer === "1") return "automatic";
26
+ if (answer === "2") return "manual";
27
+ }
28
+ } finally {
29
+ readline.close();
30
+ }
31
+ }