@4yi-dev/cli 0.1.14 → 0.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4yi-dev/cli",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "4YI command-line launcher for OAuth login and OpenCode runtime",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
1
3
  import path from "node:path";
2
4
  import process from "node:process";
3
5
  import { spawn as nodeSpawn } from "node:child_process";
@@ -6,6 +8,8 @@ import { requestJson } from "./http.mjs";
6
8
 
7
9
  const DEFAULT_LIMIT = 10;
8
10
  const REQUEST_TIMEOUT_MS = 15_000;
11
+ const SESSION_SCAN_LIMIT = 500;
12
+ const SESSION_PREFIX_BYTES = 256 * 1024;
9
13
 
10
14
  function executableName(command, platform = process.platform) {
11
15
  return platform === "win32" ? `${command}.cmd` : command;
@@ -34,6 +38,104 @@ function relativeTime(timestamp, now = Date.now()) {
34
38
  return new Date(Number(timestamp) * 1000).toLocaleDateString();
35
39
  }
36
40
 
41
+ function codexHomeForEnv(env = process.env) {
42
+ if (env.CODEX_HOME) return env.CODEX_HOME;
43
+ const home = env.HOME || env.USERPROFILE || os.homedir();
44
+ return path.join(home, ".codex");
45
+ }
46
+
47
+ function collectSessionFiles(root) {
48
+ const files = [];
49
+ const pending = [root];
50
+ while (pending.length > 0) {
51
+ const directory = pending.pop();
52
+ let entries;
53
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { continue; }
54
+ for (const entry of entries) {
55
+ const file = path.join(directory, entry.name);
56
+ if (entry.isDirectory()) pending.push(file);
57
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
58
+ try { files.push({ file, mtimeMs: fs.statSync(file).mtimeMs }); } catch { /* Ignore a file removed during scanning. */ }
59
+ }
60
+ }
61
+ }
62
+ return files.sort((left, right) => right.mtimeMs - left.mtimeMs).slice(0, SESSION_SCAN_LIMIT);
63
+ }
64
+
65
+ function readFilePrefix(file) {
66
+ const descriptor = fs.openSync(file, "r");
67
+ try {
68
+ const buffer = Buffer.alloc(SESSION_PREFIX_BYTES);
69
+ const bytes = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
70
+ return buffer.toString("utf8", 0, bytes);
71
+ } finally {
72
+ fs.closeSync(descriptor);
73
+ }
74
+ }
75
+
76
+ function messageText(payload) {
77
+ if (payload?.type === "message" && payload.role === "user") {
78
+ if (typeof payload.content === "string") return payload.content;
79
+ if (Array.isArray(payload.content)) {
80
+ return payload.content
81
+ .map((item) => item?.text || item?.input_text || "")
82
+ .filter(Boolean)
83
+ .join(" ");
84
+ }
85
+ }
86
+ if (payload?.type === "user_message") return payload.message || payload.text || "";
87
+ return "";
88
+ }
89
+
90
+ function sessionThreadFromFile({ file, mtimeMs }) {
91
+ let text;
92
+ try { text = readFilePrefix(file); } catch { return null; }
93
+ let metadata;
94
+ let preview = "";
95
+ for (const line of text.split(/\r?\n/)) {
96
+ if (!line.trim()) continue;
97
+ let record;
98
+ try { record = JSON.parse(line); } catch { continue; }
99
+ if (record.type === "session_meta") metadata = record.payload;
100
+ if (!preview && (record.type === "response_item" || record.type === "event_msg")) {
101
+ preview = messageText(record.payload);
102
+ }
103
+ if (metadata && preview) break;
104
+ }
105
+ const id = metadata?.id || metadata?.session_id;
106
+ if (!id) return null;
107
+ const created = Date.parse(metadata.timestamp || "");
108
+ return {
109
+ id,
110
+ modelProvider: metadata.model_provider || metadata.modelProvider || "openai",
111
+ cwd: metadata.cwd || "",
112
+ preview,
113
+ createdAt: Number.isFinite(created) ? Math.floor(created / 1000) : Math.floor(mtimeMs / 1000),
114
+ updatedAt: Math.floor(mtimeMs / 1000),
115
+ recencyAt: Math.floor(mtimeMs / 1000),
116
+ ephemeral: false,
117
+ };
118
+ }
119
+
120
+ export function scanCodexSessionThreads({ codexHome = codexHomeForEnv() } = {}) {
121
+ const sessions = path.join(codexHome, "sessions");
122
+ return collectSessionFiles(sessions).map(sessionThreadFromFile).filter(Boolean);
123
+ }
124
+
125
+ function mergeCandidateThreads(serverThreads, sessionThreads, limit) {
126
+ const threads = new Map();
127
+ for (const thread of sessionThreads) if (thread?.id) threads.set(thread.id, thread);
128
+ for (const thread of serverThreads) if (thread?.id) threads.set(thread.id, { ...threads.get(thread.id), ...thread });
129
+ return [...threads.values()]
130
+ .filter((thread) => thread.modelProvider !== "4yi" && !thread.ephemeral)
131
+ .sort((left, right) => {
132
+ const leftTime = left.recencyAt || left.updatedAt || left.createdAt || 0;
133
+ const rightTime = right.recencyAt || right.updatedAt || right.createdAt || 0;
134
+ return rightTime - leftTime;
135
+ })
136
+ .slice(0, limit);
137
+ }
138
+
37
139
  export function describeCodexThread(thread, { now = Date.now() } = {}) {
38
140
  const title = oneLine(thread.name || thread.preview, "未命名任务");
39
141
  const project = path.basename(String(thread.cwd || "")) || String(thread.cwd || "未知项目");
@@ -201,6 +303,7 @@ export async function migrateCodexTask({
201
303
  client,
202
304
  clientOptions = {},
203
305
  selectThread = selectCodexThread,
306
+ scanThreads = scanCodexSessionThreads,
204
307
  } = {}) {
205
308
  if (!session?.token) throw new Error("Not signed in. Run `4yi login`.");
206
309
  const selectedModel = model || await loadDefaultCodexModel(session);
@@ -215,13 +318,11 @@ export async function migrateCodexTask({
215
318
  limit: Math.max(limit * 3, 30),
216
319
  sortKey: "updated_at",
217
320
  sortDirection: "desc",
218
- useStateDbOnly: true,
219
321
  });
220
- const candidates = (response?.data || [])
221
- .filter((thread) => thread?.id && thread.modelProvider !== "4yi" && !thread.ephemeral)
222
- .slice(0, limit);
322
+ const sessionThreads = scanThreads({ codexHome: codexHomeForEnv(clientOptions.env || process.env) });
323
+ const candidates = mergeCandidateThreads(response?.data || [], sessionThreads, limit);
223
324
  if (candidates.length === 0) {
224
- throw new Error("没有找到可迁移的旧 Codex 任务。新任务已经会默认使用 4YI");
325
+ throw new Error("没有自动找到可迁移的旧 Codex 任务。新任务已经会默认使用 4YI;如已知任务 ID,可运行 `4yi migrate codex --thread <任务ID>`。");
225
326
  }
226
327
  selected = await selectThread(candidates, { input, output });
227
328
  if (!selected) {
@@ -230,7 +331,7 @@ export async function migrateCodexTask({
230
331
  }
231
332
  }
232
333
 
233
- stdout(`正在迁移到 ${selectedModel}…`);
334
+ stdout(`正在创建新的 4YI 任务(${selectedModel})…`);
234
335
  const result = await appServer.request("thread/fork", {
235
336
  threadId: selected.id,
236
337
  model: selectedModel,
@@ -251,6 +352,10 @@ export async function migrateCodexTask({
251
352
 
252
353
  export const __testing = {
253
354
  appServerLaunch,
355
+ codexHomeForEnv,
254
356
  loadDefaultCodexModel,
357
+ mergeCandidateThreads,
255
358
  relativeTime,
359
+ scanCodexSessionThreads,
360
+ sessionThreadFromFile,
256
361
  };
package/src/connect.mjs CHANGED
@@ -97,10 +97,21 @@ function executableName(command, platform = process.platform) {
97
97
  return platform === "win32" ? `${command}.cmd` : command;
98
98
  }
99
99
 
100
- function commandAvailable(command, { platform = process.platform, spawn = spawnSync } = {}) {
101
- const result = spawn(executableName(command, platform), ["--version"], {
100
+ function commandLaunch(command, args, { platform = process.platform, env = process.env } = {}) {
101
+ if (platform !== "win32") return { command, args, shell: false };
102
+ // Avoid Node 24 DEP0190 by invoking cmd.exe explicitly. Every token passed
103
+ // here is owned by the CLI (tool names, fixed flags, and npm package names),
104
+ // never user input.
105
+ const shell = env.ComSpec || env.COMSPEC || "cmd.exe";
106
+ return { command: shell, args: ["/d", "/s", "/c", `${command} ${args.join(" ")}`], shell: false };
107
+ }
108
+
109
+ function commandAvailable(command, { platform = process.platform, env = process.env, spawn = spawnSync } = {}) {
110
+ const launch = commandLaunch(executableName(command, platform), ["--version"], { platform, env });
111
+ const result = spawn(launch.command, launch.args, {
102
112
  encoding: "utf8",
103
- shell: platform === "win32",
113
+ env,
114
+ shell: launch.shell,
104
115
  stdio: "ignore",
105
116
  });
106
117
  return !result.error && result.status === 0;
@@ -123,29 +134,32 @@ async function ensureToolCli(target, {
123
134
  input = process.stdin,
124
135
  output = process.stdout,
125
136
  platform = process.platform,
137
+ env = process.env,
126
138
  spawn = spawnSync,
127
139
  stdout = console.log,
128
140
  } = {}) {
129
141
  const meta = TOOL_METADATA[target];
130
- if (commandAvailable(meta.command, { platform, spawn })) return { installed: false };
142
+ if (commandAvailable(meta.command, { platform, env, spawn })) return { installed: false };
131
143
 
132
144
  const approved = autoInstall || await confirmInstall(meta, { input, output });
133
145
  if (!approved) {
134
146
  throw new Error(`${meta.label} CLI is required. Install it with \`npm install -g ${meta.npmPackage}\`, then run this command again.`);
135
147
  }
136
- if (!commandAvailable("npm", { platform, spawn })) {
148
+ if (!commandAvailable("npm", { platform, env, spawn })) {
137
149
  throw new Error(`npm is required to install ${meta.label} CLI automatically. Install Node.js/npm, then run this command again.`);
138
150
  }
139
151
 
140
152
  stdout(`Installing ${meta.label} CLI (${meta.npmPackage})...`);
141
- const result = spawn(executableName("npm", platform), ["install", "-g", meta.npmPackage], {
142
- shell: platform === "win32",
153
+ const launch = commandLaunch(executableName("npm", platform), ["install", "-g", meta.npmPackage], { platform, env });
154
+ const result = spawn(launch.command, launch.args, {
155
+ env,
156
+ shell: launch.shell,
143
157
  stdio: "inherit",
144
158
  });
145
159
  if (result.error || result.status !== 0) {
146
160
  throw new Error(`Could not install ${meta.label} CLI automatically. Run \`npm install -g ${meta.npmPackage}\` and try again.`);
147
161
  }
148
- if (!commandAvailable(meta.command, { platform, spawn })) {
162
+ if (!commandAvailable(meta.command, { platform, env, spawn })) {
149
163
  throw new Error(`${meta.label} CLI was installed, but \`${meta.command}\` is not available in this terminal. Open a new terminal and run this command again.`);
150
164
  }
151
165
  stdout(`${meta.label} CLI installed.`);
@@ -448,9 +462,10 @@ function escapeRegExp(value) {
448
462
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
449
463
  }
450
464
 
451
- function parseBundledCatalog({ platform = process.platform, spawn = spawnSync } = {}) {
465
+ function parseBundledCatalog({ platform = process.platform, env = process.env, spawn = spawnSync } = {}) {
452
466
  const command = executableName("codex", platform);
453
- const result = spawn(command, ["debug", "models", "--bundled"], { encoding: "utf8", shell: platform === "win32" });
467
+ const launch = commandLaunch(command, ["debug", "models", "--bundled"], { platform, env });
468
+ const result = spawn(launch.command, launch.args, { encoding: "utf8", env, shell: launch.shell });
454
469
  if (result.status !== 0 || !result.stdout) throw new Error("Codex CLI is required to build its 4YI model catalog. Install Codex, then run this command again.");
455
470
  const catalog = JSON.parse(result.stdout);
456
471
  const template = catalog.models?.find((model) => model.slug === "gpt-5.5") || catalog.models?.[0];
@@ -693,6 +708,7 @@ export const __testing = {
693
708
  buildCodexCatalog,
694
709
  checkCodex,
695
710
  commandAvailable,
711
+ commandLaunch,
696
712
  desktopAppCandidates,
697
713
  ensureToolCli,
698
714
  executableName,