@laivc/laicode 0.0.1 → 0.2.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 +143 -5
- package/bin/laicode.js +2439 -25
- package/lib/adapters.js +379 -0
- package/package.json +13 -3
- package/scripts/release-check.js +20 -0
- package/scripts/smoke-test.js +153 -0
package/lib/adapters.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const fs = require("fs")
|
|
4
|
+
const fsp = require("fs/promises")
|
|
5
|
+
const os = require("os")
|
|
6
|
+
const path = require("path")
|
|
7
|
+
|
|
8
|
+
const DEFAULT_API_BASE = "https://api.lai.vc/v1"
|
|
9
|
+
const DEFAULT_MODEL = "gpt-5.5"
|
|
10
|
+
|
|
11
|
+
function homeDir(env = process.env) {
|
|
12
|
+
return env.HOME || env.USERPROFILE || os.homedir()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function expandHome(value, env = process.env) {
|
|
16
|
+
if (!value) return value
|
|
17
|
+
if (value === "~") return homeDir(env)
|
|
18
|
+
if (value.startsWith("~/")) return path.join(homeDir(env), value.slice(2))
|
|
19
|
+
return value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function exists(file) {
|
|
23
|
+
try {
|
|
24
|
+
fs.accessSync(file)
|
|
25
|
+
return true
|
|
26
|
+
} catch {
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function commandExists(cmd, env = process.env) {
|
|
32
|
+
const pathEnv = String(env.PATH || "")
|
|
33
|
+
const dirs = pathEnv.split(path.delimiter).filter(Boolean)
|
|
34
|
+
const exts =
|
|
35
|
+
process.platform === "win32"
|
|
36
|
+
? String(env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
|
|
37
|
+
: [""]
|
|
38
|
+
for (const dir of dirs) {
|
|
39
|
+
for (const ext of exts) {
|
|
40
|
+
if (exists(path.join(dir, cmd + ext))) return true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function codexHome(env = process.env) {
|
|
47
|
+
return env.CODEX_HOME ? expandHome(env.CODEX_HOME, env) : path.join(homeDir(env), ".codex")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function continueHome(env = process.env) {
|
|
51
|
+
return path.join(homeDir(env), ".continue")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function platformConfigDir(appName, env = process.env) {
|
|
55
|
+
if (process.platform === "darwin") return path.join(homeDir(env), "Library", "Application Support", appName)
|
|
56
|
+
if (process.platform === "win32") return path.join(env.APPDATA || path.join(homeDir(env), "AppData", "Roaming"), appName)
|
|
57
|
+
return path.join(env.XDG_CONFIG_HOME || path.join(homeDir(env), ".config"), appName)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function detectTools(opts = {}) {
|
|
61
|
+
const env = opts.env || process.env
|
|
62
|
+
const codexDir = codexHome(env)
|
|
63
|
+
const continueDir = continueHome(env)
|
|
64
|
+
const cursorDir = platformConfigDir("Cursor", env)
|
|
65
|
+
const codeDir = platformConfigDir("Code", env)
|
|
66
|
+
const vscodeExtensions = path.join(homeDir(env), ".vscode", "extensions")
|
|
67
|
+
|
|
68
|
+
const continueYaml = path.join(continueDir, "config.yaml")
|
|
69
|
+
const continueJson = path.join(continueDir, "config.json")
|
|
70
|
+
const clineDetected =
|
|
71
|
+
exists(path.join(codeDir, "User", "globalStorage", "saoudrizwan.claude-dev")) ||
|
|
72
|
+
exists(path.join(codeDir, "User", "globalStorage", "cline.cline")) ||
|
|
73
|
+
(exists(vscodeExtensions) &&
|
|
74
|
+
fs.readdirSync(vscodeExtensions).some((name) => /(^|\.)(cline|claude-dev)/i.test(name)))
|
|
75
|
+
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
id: "codex",
|
|
79
|
+
name: "Codex CLI",
|
|
80
|
+
detected: commandExists("codex", env) || exists(codexDir),
|
|
81
|
+
support: "auto",
|
|
82
|
+
status: exists(path.join(codexDir, "laicode.config.toml")) ? "已接入 profile" : "可一键接入",
|
|
83
|
+
configPath: path.join(codexDir, "laicode.config.toml"),
|
|
84
|
+
next: "laicode init --tool codex --apply",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "continue",
|
|
88
|
+
name: "Continue",
|
|
89
|
+
detected: exists(continueDir),
|
|
90
|
+
support: exists(continueYaml) || exists(continueJson) ? "manual-merge" : "auto-new",
|
|
91
|
+
status: exists(continueYaml) || exists(continueJson) ? "检测到现有配置,先预览手动合并" : "可创建新配置",
|
|
92
|
+
configPath: exists(continueYaml) ? continueYaml : exists(continueJson) ? continueJson : continueYaml,
|
|
93
|
+
next: "laicode init --tool continue --apply",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "cline",
|
|
97
|
+
name: "Cline",
|
|
98
|
+
detected: clineDetected,
|
|
99
|
+
support: "guide",
|
|
100
|
+
status: clineDetected ? "需在扩展 UI 中选择 OpenAI Compatible" : "未检测到",
|
|
101
|
+
configPath: path.join(codeDir, "User"),
|
|
102
|
+
next: "laicode init --tool cline",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "cursor",
|
|
106
|
+
name: "Cursor",
|
|
107
|
+
detected: exists(cursorDir) || commandExists("cursor", env),
|
|
108
|
+
support: "guide",
|
|
109
|
+
status: exists(cursorDir) || commandExists("cursor", env) ? "密钥在安全存储,暂不脚本写入" : "未检测到",
|
|
110
|
+
configPath: path.join(cursorDir, "User"),
|
|
111
|
+
next: "laicode init --tool cursor",
|
|
112
|
+
},
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function tomlString(value) {
|
|
117
|
+
return JSON.stringify(String(value))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function yamlString(value) {
|
|
121
|
+
return JSON.stringify(String(value))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function codexProfileContent(ctx) {
|
|
125
|
+
const model = ctx.model || DEFAULT_MODEL
|
|
126
|
+
const apiBaseUrl = ctx.apiBaseUrl || DEFAULT_API_BASE
|
|
127
|
+
return [
|
|
128
|
+
"# Created by Laicode. Remove this file or run `laicode rollback` to undo.",
|
|
129
|
+
`model = ${tomlString(model)}`,
|
|
130
|
+
'model_provider = "laivc"',
|
|
131
|
+
"",
|
|
132
|
+
"[model_providers.laivc]",
|
|
133
|
+
'name = "Lai.vc"',
|
|
134
|
+
`base_url = ${tomlString(apiBaseUrl)}`,
|
|
135
|
+
"",
|
|
136
|
+
"[model_providers.laivc.auth]",
|
|
137
|
+
`command = ${tomlString(ctx.commandName || "laicode")}`,
|
|
138
|
+
'args = ["credential", "api-key"]',
|
|
139
|
+
"timeout_ms = 5000",
|
|
140
|
+
"refresh_interval_ms = 300000",
|
|
141
|
+
"",
|
|
142
|
+
].join("\n")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function continueConfigContent(ctx) {
|
|
146
|
+
const model = ctx.model || DEFAULT_MODEL
|
|
147
|
+
const apiBaseUrl = ctx.apiBaseUrl || DEFAULT_API_BASE
|
|
148
|
+
const apiKey = ctx.apiKey || "sk-..."
|
|
149
|
+
return [
|
|
150
|
+
"name: LaiCode",
|
|
151
|
+
"version: 0.0.1",
|
|
152
|
+
"schema: v1",
|
|
153
|
+
"models:",
|
|
154
|
+
` - name: ${yamlString(`Lai.vc ${model}`)}`,
|
|
155
|
+
" provider: openai",
|
|
156
|
+
` model: ${yamlString(model)}`,
|
|
157
|
+
` apiBase: ${yamlString(apiBaseUrl)}`,
|
|
158
|
+
` apiKey: ${yamlString(apiKey)}`,
|
|
159
|
+
" useResponsesApi: false",
|
|
160
|
+
"",
|
|
161
|
+
].join("\n")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function manualPlan(tool, ctx, reason, instructions) {
|
|
165
|
+
return {
|
|
166
|
+
tool,
|
|
167
|
+
ok: false,
|
|
168
|
+
mode: "manual",
|
|
169
|
+
reason,
|
|
170
|
+
instructions,
|
|
171
|
+
operations: [],
|
|
172
|
+
apiBaseUrl: ctx.apiBaseUrl || DEFAULT_API_BASE,
|
|
173
|
+
model: ctx.model || DEFAULT_MODEL,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function buildInitPlan(tool, ctx = {}) {
|
|
178
|
+
const env = ctx.env || process.env
|
|
179
|
+
const normalized = String(tool || "codex").toLowerCase()
|
|
180
|
+
const apiBaseUrl = ctx.apiBaseUrl || DEFAULT_API_BASE
|
|
181
|
+
const model = ctx.model || DEFAULT_MODEL
|
|
182
|
+
|
|
183
|
+
if (normalized === "codex") {
|
|
184
|
+
const target = path.join(codexHome(env), "laicode.config.toml")
|
|
185
|
+
return {
|
|
186
|
+
tool: "codex",
|
|
187
|
+
ok: true,
|
|
188
|
+
mode: "profile",
|
|
189
|
+
summary: "创建 Codex 独立 profile,不改主配置和官方登录态。",
|
|
190
|
+
apiBaseUrl,
|
|
191
|
+
model,
|
|
192
|
+
runCommand: "codex --profile laicode",
|
|
193
|
+
operations: [
|
|
194
|
+
{
|
|
195
|
+
action: "write",
|
|
196
|
+
path: target,
|
|
197
|
+
mode: 0o600,
|
|
198
|
+
description: "Codex Lai.vc profile",
|
|
199
|
+
content: codexProfileContent({ ...ctx, apiBaseUrl, model }),
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (normalized === "continue") {
|
|
206
|
+
const target = path.join(continueHome(env), "config.yaml")
|
|
207
|
+
const existingYaml = exists(target)
|
|
208
|
+
const existingJson = exists(path.join(continueHome(env), "config.json"))
|
|
209
|
+
if ((existingYaml || existingJson) && !ctx.force) {
|
|
210
|
+
return manualPlan("continue", { ...ctx, apiBaseUrl, model }, "检测到 Continue 现有配置,当前版本不自动合并 YAML/JSON。", [
|
|
211
|
+
"打开 Continue 配置文件。",
|
|
212
|
+
`新增 provider=openai, apiBase=${apiBaseUrl}, model=${model}, useResponsesApi=false。`,
|
|
213
|
+
"后续版本会在引入结构化 YAML 合并后支持自动 patch。",
|
|
214
|
+
])
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
tool: "continue",
|
|
218
|
+
ok: true,
|
|
219
|
+
mode: "new-config",
|
|
220
|
+
summary: "创建 Continue config.yaml,使用 OpenAI compatible provider。",
|
|
221
|
+
apiBaseUrl,
|
|
222
|
+
model,
|
|
223
|
+
operations: [
|
|
224
|
+
{
|
|
225
|
+
action: "write",
|
|
226
|
+
path: target,
|
|
227
|
+
mode: 0o600,
|
|
228
|
+
description: "Continue Lai.vc config",
|
|
229
|
+
content: continueConfigContent({ ...ctx, apiBaseUrl, model }),
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (normalized === "cline") {
|
|
236
|
+
return manualPlan("cline", { ...ctx, apiBaseUrl, model }, "Cline 设置目前由 VS Code 扩展 UI 管理,先提供可靠指引。", [
|
|
237
|
+
"打开 Cline 设置。",
|
|
238
|
+
"API Provider 选择 OpenAI Compatible。",
|
|
239
|
+
`Base URL 填 ${apiBaseUrl}。`,
|
|
240
|
+
"API Key 使用 `laicode keys --show` 显式查看后粘贴。",
|
|
241
|
+
`Model ID 填 ${model}。`,
|
|
242
|
+
])
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (normalized === "cursor") {
|
|
246
|
+
return manualPlan("cursor", { ...ctx, apiBaseUrl, model }, "Cursor API Key 存在安全存储中,当前不支持可靠脚本写入。", [
|
|
247
|
+
"打开 Cursor Settings -> Models。",
|
|
248
|
+
"添加 OpenAI API Key。",
|
|
249
|
+
`启用 Override OpenAI Base URL 并填写 ${apiBaseUrl}。`,
|
|
250
|
+
`添加并启用自定义模型 ${model}。`,
|
|
251
|
+
])
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return manualPlan(normalized, { ...ctx, apiBaseUrl, model }, `暂不支持工具: ${normalized}`, ["运行 `laicode tools` 查看当前支持矩阵。"])
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function redactText(value, secrets = []) {
|
|
258
|
+
let out = String(value || "")
|
|
259
|
+
for (const secret of secrets.filter(Boolean)) {
|
|
260
|
+
out = out.split(secret).join(maskSecret(secret))
|
|
261
|
+
}
|
|
262
|
+
return out
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function maskSecret(value) {
|
|
266
|
+
if (!value || value.length <= 16) return "***"
|
|
267
|
+
return `${value.slice(0, 8)}...${value.slice(-4)}`
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function safePlan(plan, secrets = []) {
|
|
271
|
+
return {
|
|
272
|
+
...plan,
|
|
273
|
+
operations: (plan.operations || []).map((op) => ({
|
|
274
|
+
action: op.action,
|
|
275
|
+
path: op.path,
|
|
276
|
+
mode: op.mode,
|
|
277
|
+
description: op.description,
|
|
278
|
+
preview: redactText(op.content, secrets),
|
|
279
|
+
exists: exists(op.path),
|
|
280
|
+
})),
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function backupName(file) {
|
|
285
|
+
return Buffer.from(file).toString("base64url")
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function writeFileAtomic(file, content, mode = 0o600) {
|
|
289
|
+
await fsp.mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
290
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
291
|
+
await fsp.writeFile(tmp, content, { mode })
|
|
292
|
+
try {
|
|
293
|
+
await fsp.chmod(tmp, mode)
|
|
294
|
+
} catch {
|
|
295
|
+
// Best effort for non-POSIX filesystems.
|
|
296
|
+
}
|
|
297
|
+
await fsp.rename(tmp, file)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function applyInitPlan(plan, opts = {}) {
|
|
301
|
+
if (!plan.ok || !plan.operations?.length) {
|
|
302
|
+
throw new Error(plan.reason || "No operations to apply")
|
|
303
|
+
}
|
|
304
|
+
const stateDir = opts.stateDir || path.join(homeDir(), ".laicode")
|
|
305
|
+
const id = new Date().toISOString().replace(/[:.]/g, "-")
|
|
306
|
+
const backupDir = path.join(stateDir, "backups", id)
|
|
307
|
+
const applied = []
|
|
308
|
+
|
|
309
|
+
await fsp.mkdir(backupDir, { recursive: true, mode: 0o700 })
|
|
310
|
+
for (const op of plan.operations) {
|
|
311
|
+
if (op.action !== "write") throw new Error(`Unsupported operation: ${op.action}`)
|
|
312
|
+
const existed = exists(op.path)
|
|
313
|
+
const backupPath = path.join(backupDir, backupName(op.path))
|
|
314
|
+
if (existed) {
|
|
315
|
+
await fsp.mkdir(path.dirname(backupPath), { recursive: true })
|
|
316
|
+
await fsp.copyFile(op.path, backupPath)
|
|
317
|
+
}
|
|
318
|
+
await writeFileAtomic(op.path, op.content, op.mode || 0o600)
|
|
319
|
+
applied.push({
|
|
320
|
+
action: op.action,
|
|
321
|
+
path: op.path,
|
|
322
|
+
description: op.description,
|
|
323
|
+
existed,
|
|
324
|
+
backupPath: existed ? backupPath : null,
|
|
325
|
+
mode: op.mode || 0o600,
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const manifest = {
|
|
330
|
+
id,
|
|
331
|
+
createdAt: new Date().toISOString(),
|
|
332
|
+
tool: plan.tool,
|
|
333
|
+
mode: plan.mode,
|
|
334
|
+
operations: applied,
|
|
335
|
+
}
|
|
336
|
+
await writeFileAtomic(path.join(backupDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", 0o600)
|
|
337
|
+
return manifest
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function rollbackLatest(opts = {}) {
|
|
341
|
+
const stateDir = opts.stateDir || path.join(homeDir(), ".laicode")
|
|
342
|
+
const backupsDir = path.join(stateDir, "backups")
|
|
343
|
+
let entries
|
|
344
|
+
try {
|
|
345
|
+
entries = await fsp.readdir(backupsDir, { withFileTypes: true })
|
|
346
|
+
} catch (err) {
|
|
347
|
+
if (err.code === "ENOENT") return null
|
|
348
|
+
throw err
|
|
349
|
+
}
|
|
350
|
+
const ids = entries
|
|
351
|
+
.filter((entry) => entry.isDirectory() && !entry.name.endsWith(".rolled-back"))
|
|
352
|
+
.map((entry) => entry.name)
|
|
353
|
+
.sort()
|
|
354
|
+
const id = ids[ids.length - 1]
|
|
355
|
+
if (!id) return null
|
|
356
|
+
|
|
357
|
+
const backupDir = path.join(backupsDir, id)
|
|
358
|
+
const manifest = JSON.parse(await fsp.readFile(path.join(backupDir, "manifest.json"), "utf8"))
|
|
359
|
+
for (const op of [...manifest.operations].reverse()) {
|
|
360
|
+
if (op.existed && op.backupPath) {
|
|
361
|
+
await fsp.mkdir(path.dirname(op.path), { recursive: true, mode: 0o700 })
|
|
362
|
+
await fsp.copyFile(op.backupPath, op.path)
|
|
363
|
+
} else if (!op.existed) {
|
|
364
|
+
await fsp.rm(op.path, { force: true })
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
await fsp.rename(backupDir, `${backupDir}.rolled-back`)
|
|
368
|
+
return manifest
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
module.exports = {
|
|
372
|
+
DEFAULT_API_BASE,
|
|
373
|
+
DEFAULT_MODEL,
|
|
374
|
+
applyInitPlan,
|
|
375
|
+
buildInitPlan,
|
|
376
|
+
detectTools,
|
|
377
|
+
rollbackLatest,
|
|
378
|
+
safePlan,
|
|
379
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@laivc/laicode",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Official Lai.vc CLI for configuring developer tools.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://lai.vc",
|
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/laivc/laicode.git"
|
|
10
10
|
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/laivc/laicode/issues"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
11
17
|
"keywords": [
|
|
12
18
|
"lai",
|
|
13
19
|
"laivc",
|
|
@@ -21,10 +27,14 @@
|
|
|
21
27
|
},
|
|
22
28
|
"files": [
|
|
23
29
|
"bin",
|
|
24
|
-
"
|
|
30
|
+
"lib",
|
|
31
|
+
"README.md",
|
|
32
|
+
"scripts"
|
|
25
33
|
],
|
|
26
34
|
"scripts": {
|
|
27
|
-
"prepublishOnly": "node bin/laicode.js --version"
|
|
35
|
+
"prepublishOnly": "npm test && node bin/laicode.js --version",
|
|
36
|
+
"release:check": "node scripts/release-check.js",
|
|
37
|
+
"test": "node scripts/smoke-test.js"
|
|
28
38
|
},
|
|
29
39
|
"engines": {
|
|
30
40
|
"node": ">=18"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require("child_process")
|
|
4
|
+
|
|
5
|
+
const steps = [
|
|
6
|
+
["node", ["--check", "bin/laicode.js"]],
|
|
7
|
+
["node", ["--check", "scripts/smoke-test.js"]],
|
|
8
|
+
["npm", ["test"]],
|
|
9
|
+
["npm", ["publish", "--dry-run", "--access", "public"]],
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
for (const [cmd, args] of steps) {
|
|
13
|
+
console.log(`\n$ ${cmd} ${args.join(" ")}`)
|
|
14
|
+
const result = spawnSync(cmd, args, { stdio: "inherit", shell: process.platform === "win32" })
|
|
15
|
+
if (result.status !== 0) {
|
|
16
|
+
process.exit(result.status || 1)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
console.log("\nLaicode release check passed.")
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require("child_process")
|
|
4
|
+
const fs = require("fs")
|
|
5
|
+
const os = require("os")
|
|
6
|
+
const path = require("path")
|
|
7
|
+
|
|
8
|
+
const home = path.join(os.tmpdir(), `laicode-npm-smoke-test-${process.pid}`)
|
|
9
|
+
const codexHome = path.join(home, "codex")
|
|
10
|
+
fs.rmSync(home, { recursive: true, force: true })
|
|
11
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 })
|
|
12
|
+
fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 })
|
|
13
|
+
fs.writeFileSync(
|
|
14
|
+
path.join(home, "config.json"),
|
|
15
|
+
JSON.stringify(
|
|
16
|
+
{
|
|
17
|
+
apiBaseUrl: "https://api.lai.vc/v1",
|
|
18
|
+
apiKey: "sk-smoke-test-secret-value",
|
|
19
|
+
defaultModel: "gpt-5.5",
|
|
20
|
+
dashboardCache: {
|
|
21
|
+
onlineModels: 8,
|
|
22
|
+
balance: 1,
|
|
23
|
+
requests: 0,
|
|
24
|
+
version: "npm 0.0.1,待发布",
|
|
25
|
+
checkedAt: new Date().toISOString(),
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
null,
|
|
29
|
+
2
|
|
30
|
+
) + "\n",
|
|
31
|
+
{ mode: 0o600 }
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const env = { ...process.env, CODEX_HOME: codexHome, HOME: home, LAICODE_HOME: home, LAICODE_PLAIN: "1" }
|
|
35
|
+
|
|
36
|
+
function run(args) {
|
|
37
|
+
const result = spawnSync(process.execPath, args, { encoding: "utf8", env })
|
|
38
|
+
if (result.status !== 0) {
|
|
39
|
+
process.stdout.write(result.stdout || "")
|
|
40
|
+
process.stderr.write(result.stderr || "")
|
|
41
|
+
process.exit(result.status || 1)
|
|
42
|
+
}
|
|
43
|
+
return result.stdout
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function assertIncludes(value, needle, label) {
|
|
47
|
+
if (!value.includes(needle)) {
|
|
48
|
+
console.error(`Smoke assertion failed: ${label}`)
|
|
49
|
+
console.error(`Expected to include: ${needle}`)
|
|
50
|
+
console.error(value)
|
|
51
|
+
process.exit(1)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function assertExcludes(value, needle, label) {
|
|
56
|
+
if (value.includes(needle)) {
|
|
57
|
+
console.error(`Smoke assertion failed: ${label}`)
|
|
58
|
+
console.error(`Expected to exclude: ${needle}`)
|
|
59
|
+
console.error(value)
|
|
60
|
+
process.exit(1)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseJson(value, label) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(value)
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error(`Smoke assertion failed: ${label}`)
|
|
69
|
+
console.error(err.message)
|
|
70
|
+
console.error(value)
|
|
71
|
+
process.exit(1)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const help = run(["bin/laicode.js", "--help"])
|
|
76
|
+
assertIncludes(help, "laicode status [--refresh] [--json]", "help lists status")
|
|
77
|
+
assertIncludes(help, "laicode commands [--json]", "help lists commands")
|
|
78
|
+
assertIncludes(help, "laicode completion [bash|zsh|fish|powershell]", "help lists completion")
|
|
79
|
+
assertIncludes(help, "laicode tools [--json]", "help lists tools")
|
|
80
|
+
assertIncludes(help, "laicode init [--tool codex] [--apply] [--json]", "help lists init")
|
|
81
|
+
assertIncludes(help, "laicode doctor [--model gpt-5.5] [--json]", "help lists doctor json")
|
|
82
|
+
assertIncludes(help, "laicode bench [--model gpt-5.5] [--count 3] [--json]", "help lists bench json")
|
|
83
|
+
assertIncludes(help, "LAICODE_NO_SPINNER", "help lists spinner toggle")
|
|
84
|
+
assertIncludes(run(["bin/laicode.js", "--version"]), "0.2.0", "long version")
|
|
85
|
+
assertIncludes(run(["bin/laicode.js", "-v"]), "0.2.0", "short version")
|
|
86
|
+
assertIncludes(run(["bin/laicode.js", "--plain", "brand"]), "Lai.vc 终端字标", "plain brand command")
|
|
87
|
+
assertIncludes(run(["bin/laicode.js", "--color", "brand"]), "品牌色板", "color brand command")
|
|
88
|
+
assertIncludes(run(["bin/laicode.js", "status"]), "快照 缓存", "status uses local dashboard cache")
|
|
89
|
+
const statusJson = run(["bin/laicode.js", "status", "--json"])
|
|
90
|
+
assertIncludes(statusJson, '"cached": true', "status json uses local dashboard cache")
|
|
91
|
+
assertExcludes(statusJson, "sk-", "status json hides api keys")
|
|
92
|
+
assertExcludes(statusJson, "lc_at_", "status json hides access tokens")
|
|
93
|
+
const status = parseJson(statusJson, "status json parses")
|
|
94
|
+
if (!Array.isArray(status.nextActions) || !status.nextActions.find((action) => action.command === "laicode login")) {
|
|
95
|
+
console.error("Smoke assertion failed: status json includes first-run next action")
|
|
96
|
+
process.exit(1)
|
|
97
|
+
}
|
|
98
|
+
assertIncludes(run(["bin/laicode.js", "commands", "--json"]), '"name": "commands"', "commands json")
|
|
99
|
+
assertIncludes(run(["bin/laicode.js", "completion", "bash"]), "complete -F _laicode_completion laicode", "bash completion")
|
|
100
|
+
assertIncludes(run(["bin/laicode.js", "completion", "fish"]), "__fish_seen_subcommand_from bench", "fish completion")
|
|
101
|
+
const toolsJson = run(["bin/laicode.js", "tools", "--json"])
|
|
102
|
+
const tools = parseJson(toolsJson, "tools json parses")
|
|
103
|
+
if (!tools.tools.find((tool) => tool.id === "codex")) {
|
|
104
|
+
console.error("Smoke assertion failed: tools json includes codex")
|
|
105
|
+
process.exit(1)
|
|
106
|
+
}
|
|
107
|
+
const initPreviewJson = run(["bin/laicode.js", "init", "--tool", "codex", "--json"])
|
|
108
|
+
assertExcludes(initPreviewJson, "sk-smoke-test-secret-value", "init preview redacts api key")
|
|
109
|
+
const initPreview = parseJson(initPreviewJson, "init preview json parses")
|
|
110
|
+
if (initPreview.apply !== false || initPreview.plan.tool !== "codex") {
|
|
111
|
+
console.error("Smoke assertion failed: init preview describes codex plan")
|
|
112
|
+
process.exit(1)
|
|
113
|
+
}
|
|
114
|
+
const initApplyJson = run(["bin/laicode.js", "init", "--tool", "codex", "--apply", "--json"])
|
|
115
|
+
const initApply = parseJson(initApplyJson, "init apply json parses")
|
|
116
|
+
if (initApply.apply !== true || initApply.plan.tool !== "codex") {
|
|
117
|
+
console.error("Smoke assertion failed: init apply json describes codex plan")
|
|
118
|
+
process.exit(1)
|
|
119
|
+
}
|
|
120
|
+
const profilePath = path.join(codexHome, "laicode.config.toml")
|
|
121
|
+
if (!fs.existsSync(profilePath)) {
|
|
122
|
+
console.error("Smoke assertion failed: init apply writes codex profile")
|
|
123
|
+
process.exit(1)
|
|
124
|
+
}
|
|
125
|
+
assertIncludes(fs.readFileSync(profilePath, "utf8"), "model_provider = \"laivc\"", "codex profile provider")
|
|
126
|
+
const rollbackJson = run(["bin/laicode.js", "rollback", "--json"])
|
|
127
|
+
const rollback = parseJson(rollbackJson, "rollback json parses")
|
|
128
|
+
if (!rollback.ok || fs.existsSync(profilePath)) {
|
|
129
|
+
console.error("Smoke assertion failed: rollback removes created codex profile")
|
|
130
|
+
process.exit(1)
|
|
131
|
+
}
|
|
132
|
+
const configJson = run(["bin/laicode.js", "config", "--json"])
|
|
133
|
+
assertIncludes(configJson, '"defaultModel": "gpt-5.5"', "config json")
|
|
134
|
+
assertExcludes(configJson, "sk-", "config json hides api keys")
|
|
135
|
+
assertExcludes(configJson, "lc_at_", "config json hides access tokens")
|
|
136
|
+
const configSetJson = run(["bin/laicode.js", "config", "set", "model", "gpt-5.4-mini", "--json"])
|
|
137
|
+
const configSet = parseJson(configSetJson, "config set json parses")
|
|
138
|
+
if (configSet.config.defaultModel !== "gpt-5.4-mini") {
|
|
139
|
+
console.error("Smoke assertion failed: config set json updates model")
|
|
140
|
+
process.exit(1)
|
|
141
|
+
}
|
|
142
|
+
const configGetSecretJson = run(["bin/laicode.js", "config", "get", "api-key", "--json"])
|
|
143
|
+
const configGetSecret = parseJson(configGetSecretJson, "config get secret json parses")
|
|
144
|
+
if (!configGetSecret.secret || configGetSecret.value !== undefined) {
|
|
145
|
+
console.error("Smoke assertion failed: config get secret json hides value")
|
|
146
|
+
process.exit(1)
|
|
147
|
+
}
|
|
148
|
+
const configUnsetJson = run(["bin/laicode.js", "config", "unset", "model", "--json"])
|
|
149
|
+
const configUnset = parseJson(configUnsetJson, "config unset json parses")
|
|
150
|
+
if (configUnset.config.defaultModel !== undefined) {
|
|
151
|
+
console.error("Smoke assertion failed: config unset json removes model")
|
|
152
|
+
process.exit(1)
|
|
153
|
+
}
|