@laivc/laicode 0.1.0 → 0.2.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,666 @@
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
+ const TOOL_PACKAGES = {
12
+ claude: {
13
+ packageName: "@anthropic-ai/claude-code",
14
+ binary: "claude",
15
+ name: "Claude Code",
16
+ installNotes: ["官方支持 native installer、Homebrew、WinGet、npm;Laicode 默认使用 npm 全局安装。"],
17
+ },
18
+ codex: {
19
+ packageName: "@openai/codex",
20
+ binary: "codex",
21
+ name: "Codex CLI",
22
+ installNotes: ["官方支持 standalone、npm、Homebrew;Laicode 默认使用 npm 全局安装,便于卸载和重装。"],
23
+ },
24
+ opencode: {
25
+ packageName: "opencode-ai",
26
+ binary: "opencode",
27
+ name: "OpenCode",
28
+ installNotes: ["OpenCode 官方支持 install script、npm、Bun、pnpm、Yarn、Homebrew、Chocolatey、Scoop;Laicode 默认使用 npm 全局安装。"],
29
+ },
30
+ continue: {
31
+ packageName: "@continuedev/cli",
32
+ binary: "cn",
33
+ name: "Continue CLI",
34
+ installNotes: ["Continue CLI 安装后提供 `cn` 命令;IDE 扩展仍需要在对应编辑器内安装。"],
35
+ },
36
+ }
37
+
38
+ function homeDir(env = process.env) {
39
+ return env.HOME || env.USERPROFILE || os.homedir()
40
+ }
41
+
42
+ function expandHome(value, env = process.env) {
43
+ if (!value) return value
44
+ if (value === "~") return homeDir(env)
45
+ if (value.startsWith("~/")) return path.join(homeDir(env), value.slice(2))
46
+ return value
47
+ }
48
+
49
+ function exists(file) {
50
+ try {
51
+ fs.accessSync(file)
52
+ return true
53
+ } catch {
54
+ return false
55
+ }
56
+ }
57
+
58
+ function commandExists(cmd, env = process.env) {
59
+ const pathEnv = String(env.PATH || "")
60
+ const dirs = pathEnv.split(path.delimiter).filter(Boolean)
61
+ const exts =
62
+ process.platform === "win32"
63
+ ? String(env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
64
+ : [""]
65
+ for (const dir of dirs) {
66
+ for (const ext of exts) {
67
+ if (exists(path.join(dir, cmd + ext))) return true
68
+ }
69
+ }
70
+ return false
71
+ }
72
+
73
+ function codexHome(env = process.env) {
74
+ return env.CODEX_HOME ? expandHome(env.CODEX_HOME, env) : path.join(homeDir(env), ".codex")
75
+ }
76
+
77
+ function continueHome(env = process.env) {
78
+ return path.join(homeDir(env), ".continue")
79
+ }
80
+
81
+ function claudeHome(env = process.env) {
82
+ return path.join(homeDir(env), ".claude")
83
+ }
84
+
85
+ function opencodeHome(env = process.env) {
86
+ return path.join(env.XDG_CONFIG_HOME || path.join(homeDir(env), ".config"), "opencode")
87
+ }
88
+
89
+ function platformConfigDir(appName, env = process.env) {
90
+ if (process.platform === "darwin") return path.join(homeDir(env), "Library", "Application Support", appName)
91
+ if (process.platform === "win32") return path.join(env.APPDATA || path.join(homeDir(env), "AppData", "Roaming"), appName)
92
+ return path.join(env.XDG_CONFIG_HOME || path.join(homeDir(env), ".config"), appName)
93
+ }
94
+
95
+ function detectTools(opts = {}) {
96
+ const env = opts.env || process.env
97
+ const codexDir = codexHome(env)
98
+ const continueDir = continueHome(env)
99
+ const claudeDir = claudeHome(env)
100
+ const opencodeDir = opencodeHome(env)
101
+ const cursorDir = platformConfigDir("Cursor", env)
102
+ const codeDir = platformConfigDir("Code", env)
103
+ const vscodeExtensions = path.join(homeDir(env), ".vscode", "extensions")
104
+
105
+ const continueYaml = path.join(continueDir, "config.yaml")
106
+ const continueJson = path.join(continueDir, "config.json")
107
+ const clineDetected =
108
+ exists(path.join(codeDir, "User", "globalStorage", "saoudrizwan.claude-dev")) ||
109
+ exists(path.join(codeDir, "User", "globalStorage", "cline.cline")) ||
110
+ (exists(vscodeExtensions) &&
111
+ fs.readdirSync(vscodeExtensions).some((name) => /(^|\.)(cline|claude-dev)/i.test(name)))
112
+
113
+ return [
114
+ {
115
+ id: "claude",
116
+ name: "Claude Code",
117
+ detected: commandExists("claude", env) || commandExists("ant", env) || exists(claudeDir) || exists(path.join(homeDir(env), ".claude.json")),
118
+ support: "blocked",
119
+ status: commandExists("claude", env) ? "已安装,接入待 Anthropic 网关" : "可安装,接入待 Anthropic 网关",
120
+ configPath: path.join(claudeDir, "settings.json"),
121
+ next: "laicode init --tool claude",
122
+ },
123
+ {
124
+ id: "codex",
125
+ name: "Codex CLI",
126
+ detected: commandExists("codex", env) || exists(codexDir),
127
+ support: "auto",
128
+ status: exists(path.join(codexDir, "laicode.config.toml")) ? "已接入 profile" : "可一键接入",
129
+ configPath: path.join(codexDir, "laicode.config.toml"),
130
+ next: "laicode init --tool codex --apply",
131
+ },
132
+ {
133
+ id: "vscode",
134
+ name: "VS Code",
135
+ detected: commandExists("code", env) || exists(codeDir),
136
+ support: "guide",
137
+ status: commandExists("code", env) || exists(codeDir) ? "编辑器已发现,请选择具体 AI 扩展接入" : "未检测到",
138
+ configPath: path.join(codeDir, "User"),
139
+ next: "laicode init --tool vscode",
140
+ },
141
+ {
142
+ id: "cursor",
143
+ name: "Cursor",
144
+ detected: exists(cursorDir) || commandExists("cursor", env),
145
+ support: "guide",
146
+ status: exists(cursorDir) || commandExists("cursor", env) ? "密钥在安全存储,暂不脚本写入" : "未检测到",
147
+ configPath: path.join(cursorDir, "User"),
148
+ next: "laicode init --tool cursor",
149
+ },
150
+ {
151
+ id: "opencode",
152
+ name: "OpenCode",
153
+ detected: commandExists("opencode", env) || exists(opencodeDir),
154
+ support: "auto",
155
+ status: exists(path.join(opencodeDir, "opencode.json")) ? "可合并 Lai.vc provider" : "可创建 Lai.vc provider",
156
+ configPath: path.join(opencodeDir, "opencode.json"),
157
+ next: "laicode init --tool opencode --apply",
158
+ },
159
+ {
160
+ id: "continue",
161
+ name: "Continue",
162
+ detected: exists(continueDir),
163
+ support: exists(continueYaml) || exists(continueJson) ? "manual-merge" : "auto-new",
164
+ status: exists(continueYaml) || exists(continueJson) ? "检测到现有配置,先预览手动合并" : "可创建新配置",
165
+ configPath: exists(continueYaml) ? continueYaml : exists(continueJson) ? continueJson : continueYaml,
166
+ next: "laicode init --tool continue --apply",
167
+ },
168
+ {
169
+ id: "cline",
170
+ name: "Cline",
171
+ detected: clineDetected,
172
+ support: "guide",
173
+ status: clineDetected ? "需在扩展 UI 中选择 OpenAI Compatible" : "未检测到",
174
+ configPath: path.join(codeDir, "User"),
175
+ next: "laicode init --tool cline",
176
+ },
177
+ ]
178
+ }
179
+
180
+ function normalizeToolId(tool) {
181
+ const value = String(tool || "codex").toLowerCase()
182
+ if (value === "claude-code" || value === "claudecli") return "claude"
183
+ if (value === "continue-cli") return "continue"
184
+ if (value === "open-code" || value === "opencode-ai") return "opencode"
185
+ if (value === "code" || value === "visual-studio-code") return "vscode"
186
+ return value
187
+ }
188
+
189
+ function tomlString(value) {
190
+ return JSON.stringify(String(value))
191
+ }
192
+
193
+ function yamlString(value) {
194
+ return JSON.stringify(String(value))
195
+ }
196
+
197
+ function plainObject(value) {
198
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {}
199
+ }
200
+
201
+ function codexProfileContent(ctx) {
202
+ const model = ctx.model || DEFAULT_MODEL
203
+ const apiBaseUrl = ctx.apiBaseUrl || DEFAULT_API_BASE
204
+ return [
205
+ "# Created by Laicode. Remove this file or run `laicode rollback` to undo.",
206
+ `model = ${tomlString(model)}`,
207
+ 'model_provider = "laivc"',
208
+ "",
209
+ "[model_providers.laivc]",
210
+ 'name = "Lai.vc"',
211
+ `base_url = ${tomlString(apiBaseUrl)}`,
212
+ "",
213
+ "[model_providers.laivc.auth]",
214
+ `command = ${tomlString(ctx.commandName || "laicode")}`,
215
+ 'args = ["credential", "api-key"]',
216
+ "timeout_ms = 5000",
217
+ "refresh_interval_ms = 300000",
218
+ "",
219
+ ].join("\n")
220
+ }
221
+
222
+ function continueConfigContent(ctx) {
223
+ const model = ctx.model || DEFAULT_MODEL
224
+ const apiBaseUrl = ctx.apiBaseUrl || DEFAULT_API_BASE
225
+ const apiKey = ctx.apiKey || "sk-..."
226
+ return [
227
+ "name: LaiCode",
228
+ "version: 0.0.1",
229
+ "schema: v1",
230
+ "models:",
231
+ ` - name: ${yamlString(`Lai.vc ${model}`)}`,
232
+ " provider: openai",
233
+ ` model: ${yamlString(model)}`,
234
+ ` apiBase: ${yamlString(apiBaseUrl)}`,
235
+ ` apiKey: ${yamlString(apiKey)}`,
236
+ " useResponsesApi: false",
237
+ "",
238
+ ].join("\n")
239
+ }
240
+
241
+ function opencodeConfigContent(ctx, existing = {}) {
242
+ const model = ctx.model || DEFAULT_MODEL
243
+ const apiBaseUrl = ctx.apiBaseUrl || DEFAULT_API_BASE
244
+ const apiKey = ctx.apiKey || "sk-..."
245
+ const existingProviders = plainObject(existing.provider)
246
+ const existingLaivc = plainObject(existingProviders.laivc)
247
+ const existingOptions = plainObject(existingLaivc.options)
248
+ const existingModels = plainObject(existingLaivc.models)
249
+ const existingModel = plainObject(existingModels[model])
250
+ const next = {
251
+ "$schema": "https://opencode.ai/config.json",
252
+ ...existing,
253
+ provider: {
254
+ ...existingProviders,
255
+ laivc: {
256
+ ...existingLaivc,
257
+ npm: "@ai-sdk/openai-compatible",
258
+ name: "Lai.vc",
259
+ options: {
260
+ ...existingOptions,
261
+ baseURL: apiBaseUrl,
262
+ apiKey,
263
+ },
264
+ models: {
265
+ ...existingModels,
266
+ [model]: {
267
+ ...existingModel,
268
+ name: model,
269
+ },
270
+ },
271
+ },
272
+ },
273
+ model: `laivc/${model}`,
274
+ }
275
+ return JSON.stringify(next, null, 2) + "\n"
276
+ }
277
+
278
+ function manualPlan(tool, ctx, reason, instructions) {
279
+ return {
280
+ tool,
281
+ ok: false,
282
+ mode: "manual",
283
+ reason,
284
+ instructions,
285
+ operations: [],
286
+ apiBaseUrl: ctx.apiBaseUrl || DEFAULT_API_BASE,
287
+ model: ctx.model || DEFAULT_MODEL,
288
+ }
289
+ }
290
+
291
+ function buildInitPlan(tool, ctx = {}) {
292
+ const env = ctx.env || process.env
293
+ const normalized = normalizeToolId(tool)
294
+ const apiBaseUrl = ctx.apiBaseUrl || DEFAULT_API_BASE
295
+ const model = ctx.model || DEFAULT_MODEL
296
+
297
+ if (normalized === "codex") {
298
+ const target = path.join(codexHome(env), "laicode.config.toml")
299
+ return {
300
+ tool: "codex",
301
+ ok: true,
302
+ mode: "profile",
303
+ summary: "创建 Codex 独立 profile,不改主配置和官方登录态。",
304
+ apiBaseUrl,
305
+ model,
306
+ runCommand: "codex --profile laicode",
307
+ operations: [
308
+ {
309
+ action: "write",
310
+ path: target,
311
+ mode: 0o600,
312
+ description: "Codex Lai.vc profile",
313
+ content: codexProfileContent({ ...ctx, apiBaseUrl, model }),
314
+ },
315
+ ],
316
+ }
317
+ }
318
+
319
+ if (normalized === "continue") {
320
+ const target = path.join(continueHome(env), "config.yaml")
321
+ const existingYaml = exists(target)
322
+ const existingJson = exists(path.join(continueHome(env), "config.json"))
323
+ if ((existingYaml || existingJson) && !ctx.force) {
324
+ return manualPlan("continue", { ...ctx, apiBaseUrl, model }, "检测到 Continue 现有配置,当前版本不自动合并 YAML/JSON。", [
325
+ "打开 Continue 配置文件。",
326
+ `新增 provider=openai, apiBase=${apiBaseUrl}, model=${model}, useResponsesApi=false。`,
327
+ "后续版本会在引入结构化 YAML 合并后支持自动 patch。",
328
+ ])
329
+ }
330
+ return {
331
+ tool: "continue",
332
+ ok: true,
333
+ mode: "new-config",
334
+ summary: "创建 Continue config.yaml,使用 OpenAI compatible provider。",
335
+ apiBaseUrl,
336
+ model,
337
+ operations: [
338
+ {
339
+ action: "write",
340
+ path: target,
341
+ mode: 0o600,
342
+ description: "Continue Lai.vc config",
343
+ content: continueConfigContent({ ...ctx, apiBaseUrl, model }),
344
+ },
345
+ ],
346
+ }
347
+ }
348
+
349
+ if (normalized === "opencode") {
350
+ const target = path.join(opencodeHome(env), "opencode.json")
351
+ let existing = {}
352
+ if (exists(target)) {
353
+ try {
354
+ existing = JSON.parse(fs.readFileSync(target, "utf8"))
355
+ } catch {
356
+ return manualPlan("opencode", { ...ctx, apiBaseUrl, model }, "检测到 OpenCode 配置,但不是有效 JSON,当前版本不自动覆盖。", [
357
+ `打开 ${target}。`,
358
+ "按 OpenCode provider 文档新增 `laivc` provider。",
359
+ `baseURL 填 ${apiBaseUrl},npm adapter 使用 @ai-sdk/openai-compatible。`,
360
+ ])
361
+ }
362
+ }
363
+ return {
364
+ tool: "opencode",
365
+ ok: true,
366
+ mode: exists(target) ? "json-merge" : "new-config",
367
+ summary: "创建或合并 OpenCode Lai.vc provider。",
368
+ apiBaseUrl,
369
+ model,
370
+ runCommand: "opencode",
371
+ operations: [
372
+ {
373
+ action: "write",
374
+ path: target,
375
+ mode: 0o600,
376
+ description: "OpenCode Lai.vc provider",
377
+ content: opencodeConfigContent({ ...ctx, apiBaseUrl, model }, existing),
378
+ },
379
+ ],
380
+ }
381
+ }
382
+
383
+ if (normalized === "claude") {
384
+ return manualPlan("claude", { ...ctx, apiBaseUrl, model }, "Claude Code 使用 Anthropic Messages API;当前 Lai.vc 只提供 OpenAI compatible /v1,不能直接接入。", [
385
+ "等待 Lai.vc 主站提供 Anthropic `/v1/messages` 兼容端点或官方代理决策。",
386
+ "届时可配置 `ANTHROPIC_BASE_URL` 指向 Lai.vc 的 Anthropic-compatible 网关。",
387
+ "认证可使用 `ANTHROPIC_AUTH_TOKEN` 或 `ANTHROPIC_API_KEY`,具体以主站契约为准。",
388
+ "当前请使用 Codex、Continue、Cursor 或 Cline 的 OpenAI compatible 接入路径测试 Lai.vc。",
389
+ ])
390
+ }
391
+
392
+ if (normalized === "vscode") {
393
+ return manualPlan("vscode", { ...ctx, apiBaseUrl, model }, "VS Code 是编辑器宿主,本身没有统一的 OpenAI-compatible 网关配置;请选择具体扩展接入。", [
394
+ "优先使用 Codex CLI/扩展时,运行 `laicode init --tool codex` 创建独立 Codex profile。",
395
+ "使用 Continue 扩展时,运行 `laicode init --tool continue` 预览 Continue 配置。",
396
+ "使用 Cline 扩展时,在 Cline 设置中选择 OpenAI Compatible,并填入 Lai.vc base URL 和 API Key。",
397
+ "Laicode 后续会按扩展稳定配置契约逐个自动化,不直接改 VS Code 安全存储。",
398
+ ])
399
+ }
400
+
401
+ if (normalized === "cline") {
402
+ return manualPlan("cline", { ...ctx, apiBaseUrl, model }, "Cline 设置目前由 VS Code 扩展 UI 管理,先提供可靠指引。", [
403
+ "打开 Cline 设置。",
404
+ "API Provider 选择 OpenAI Compatible。",
405
+ `Base URL 填 ${apiBaseUrl}。`,
406
+ "API Key 使用 `laicode keys --show` 显式查看后粘贴。",
407
+ `Model ID 填 ${model}。`,
408
+ ])
409
+ }
410
+
411
+ if (normalized === "cursor") {
412
+ return manualPlan("cursor", { ...ctx, apiBaseUrl, model }, "Cursor API Key 存在安全存储中,当前不支持可靠脚本写入。", [
413
+ "打开 Cursor Settings -> Models。",
414
+ "添加 OpenAI API Key。",
415
+ `启用 Override OpenAI Base URL 并填写 ${apiBaseUrl}。`,
416
+ `添加并启用自定义模型 ${model}。`,
417
+ ])
418
+ }
419
+
420
+ return manualPlan(normalized, { ...ctx, apiBaseUrl, model }, `暂不支持工具: ${normalized}`, ["运行 `laicode tools` 查看当前支持矩阵。"])
421
+ }
422
+
423
+ function commandPlan(command, args) {
424
+ return {
425
+ command,
426
+ args,
427
+ display: [command, ...args].join(" "),
428
+ }
429
+ }
430
+
431
+ function buildToolActionPlan(tool, action, ctx = {}) {
432
+ const normalized = normalizeToolId(tool)
433
+ const normalizedAction = String(action || "scan").toLowerCase()
434
+ const meta = TOOL_PACKAGES[normalized]
435
+ const env = ctx.env || process.env
436
+
437
+ if (normalizedAction === "configure") {
438
+ return buildInitPlan(normalized, ctx)
439
+ }
440
+
441
+ if (!["install", "uninstall", "reinstall"].includes(normalizedAction)) {
442
+ return manualPlan(normalized, ctx, `暂不支持动作: ${normalizedAction}`, [
443
+ "支持动作: install、uninstall、reinstall、configure。",
444
+ "运行 `laicode tools` 查看工具状态。",
445
+ ])
446
+ }
447
+
448
+ if (!meta) {
449
+ return manualPlan(normalized, ctx, "当前工具没有安全的自动安装/卸载策略。", [
450
+ "请按工具官方文档安装。",
451
+ "安装后运行 `laicode tools` 重新检测。",
452
+ ])
453
+ }
454
+
455
+ const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"
456
+ const hasNpm = commandExists(npmCommand, env) || commandExists("npm", env)
457
+ const commands = []
458
+ if (normalizedAction === "install") commands.push(commandPlan(npmCommand, ["install", "-g", meta.packageName]))
459
+ if (normalizedAction === "uninstall") commands.push(commandPlan(npmCommand, ["uninstall", "-g", meta.packageName]))
460
+ if (normalizedAction === "reinstall") {
461
+ commands.push(commandPlan(npmCommand, ["uninstall", "-g", meta.packageName]))
462
+ commands.push(commandPlan(npmCommand, ["install", "-g", meta.packageName]))
463
+ }
464
+
465
+ const postSteps = []
466
+ if (normalized === "codex") {
467
+ postSteps.push("运行 `laicode init --tool codex` 预览接入 profile。")
468
+ postSteps.push("应用后使用 `codex --profile laicode` 测试 Lai.vc。")
469
+ } else if (normalized === "continue") {
470
+ postSteps.push("运行 `laicode init --tool continue` 预览 Continue 配置。")
471
+ postSteps.push("IDE 扩展仍需在 Cursor/VS Code 扩展市场安装。")
472
+ } else if (normalized === "claude") {
473
+ postSteps.push("当前只能安装 Claude Code;接入 Lai.vc 需等待 Anthropic Messages 兼容端点。")
474
+ } else if (normalized === "opencode") {
475
+ postSteps.push("运行 `laicode init --tool opencode` 预览 OpenCode provider。")
476
+ postSteps.push("应用后使用 `opencode` 测试 Lai.vc。")
477
+ }
478
+
479
+ return {
480
+ tool: normalized,
481
+ action: normalizedAction,
482
+ ok: hasNpm,
483
+ mode: "command",
484
+ summary: `${meta.name} ${normalizedAction} 计划`,
485
+ reason: hasNpm ? "" : "未找到 npm,无法自动执行安装/卸载命令。",
486
+ commands,
487
+ warnings: [
488
+ "默认只预览命令;传 `--apply` 才会执行。",
489
+ "卸载/重装只覆盖 npm 全局安装的包;通过 Homebrew、WinGet 或官方安装器安装的版本需用对应工具管理。",
490
+ ...meta.installNotes,
491
+ ],
492
+ postSteps,
493
+ }
494
+ }
495
+
496
+ function runCommand(command, args, opts = {}) {
497
+ const { spawnSync } = require("child_process")
498
+ const result = spawnSync(command, args, {
499
+ encoding: "utf8",
500
+ stdio: opts.stdio || "pipe",
501
+ env: opts.env || process.env,
502
+ })
503
+ return {
504
+ command,
505
+ args,
506
+ status: result.status,
507
+ ok: result.status === 0,
508
+ stdout: result.stdout || "",
509
+ stderr: result.stderr || "",
510
+ error: result.error ? result.error.message : null,
511
+ }
512
+ }
513
+
514
+ async function applyToolActionPlan(plan, opts = {}) {
515
+ if (!plan.ok || !plan.commands?.length) {
516
+ throw new Error(plan.reason || "No commands to apply")
517
+ }
518
+ const results = []
519
+ for (const command of plan.commands) {
520
+ const result = runCommand(command.command, command.args, {
521
+ stdio: opts.stdio || "inherit",
522
+ env: opts.env || process.env,
523
+ })
524
+ results.push(result)
525
+ if (!result.ok) {
526
+ const err = new Error(`Command failed: ${command.display}`)
527
+ err.result = result
528
+ err.results = results
529
+ throw err
530
+ }
531
+ }
532
+ return {
533
+ tool: plan.tool,
534
+ action: plan.action,
535
+ appliedAt: new Date().toISOString(),
536
+ commands: plan.commands,
537
+ results,
538
+ postSteps: plan.postSteps || [],
539
+ }
540
+ }
541
+
542
+ function redactText(value, secrets = []) {
543
+ let out = String(value || "")
544
+ for (const secret of secrets.filter(Boolean)) {
545
+ out = out.split(secret).join(maskSecret(secret))
546
+ }
547
+ return out
548
+ }
549
+
550
+ function maskSecret(value) {
551
+ if (!value || value.length <= 16) return "***"
552
+ return `${value.slice(0, 8)}...${value.slice(-4)}`
553
+ }
554
+
555
+ function safePlan(plan, secrets = []) {
556
+ return {
557
+ ...plan,
558
+ operations: (plan.operations || []).map((op) => ({
559
+ action: op.action,
560
+ path: op.path,
561
+ mode: op.mode,
562
+ description: op.description,
563
+ preview: redactText(op.content, secrets),
564
+ exists: exists(op.path),
565
+ })),
566
+ }
567
+ }
568
+
569
+ function backupName(file) {
570
+ return Buffer.from(file).toString("base64url")
571
+ }
572
+
573
+ async function writeFileAtomic(file, content, mode = 0o600) {
574
+ await fsp.mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
575
+ const tmp = `${file}.${process.pid}.tmp`
576
+ await fsp.writeFile(tmp, content, { mode })
577
+ try {
578
+ await fsp.chmod(tmp, mode)
579
+ } catch {
580
+ // Best effort for non-POSIX filesystems.
581
+ }
582
+ await fsp.rename(tmp, file)
583
+ }
584
+
585
+ async function applyInitPlan(plan, opts = {}) {
586
+ if (!plan.ok || !plan.operations?.length) {
587
+ throw new Error(plan.reason || "No operations to apply")
588
+ }
589
+ const stateDir = opts.stateDir || path.join(homeDir(), ".laicode")
590
+ const id = new Date().toISOString().replace(/[:.]/g, "-")
591
+ const backupDir = path.join(stateDir, "backups", id)
592
+ const applied = []
593
+
594
+ await fsp.mkdir(backupDir, { recursive: true, mode: 0o700 })
595
+ for (const op of plan.operations) {
596
+ if (op.action !== "write") throw new Error(`Unsupported operation: ${op.action}`)
597
+ const existed = exists(op.path)
598
+ const backupPath = path.join(backupDir, backupName(op.path))
599
+ if (existed) {
600
+ await fsp.mkdir(path.dirname(backupPath), { recursive: true })
601
+ await fsp.copyFile(op.path, backupPath)
602
+ }
603
+ await writeFileAtomic(op.path, op.content, op.mode || 0o600)
604
+ applied.push({
605
+ action: op.action,
606
+ path: op.path,
607
+ description: op.description,
608
+ existed,
609
+ backupPath: existed ? backupPath : null,
610
+ mode: op.mode || 0o600,
611
+ })
612
+ }
613
+
614
+ const manifest = {
615
+ id,
616
+ createdAt: new Date().toISOString(),
617
+ tool: plan.tool,
618
+ mode: plan.mode,
619
+ operations: applied,
620
+ }
621
+ await writeFileAtomic(path.join(backupDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", 0o600)
622
+ return manifest
623
+ }
624
+
625
+ async function rollbackLatest(opts = {}) {
626
+ const stateDir = opts.stateDir || path.join(homeDir(), ".laicode")
627
+ const backupsDir = path.join(stateDir, "backups")
628
+ let entries
629
+ try {
630
+ entries = await fsp.readdir(backupsDir, { withFileTypes: true })
631
+ } catch (err) {
632
+ if (err.code === "ENOENT") return null
633
+ throw err
634
+ }
635
+ const ids = entries
636
+ .filter((entry) => entry.isDirectory() && !entry.name.endsWith(".rolled-back"))
637
+ .map((entry) => entry.name)
638
+ .sort()
639
+ const id = ids[ids.length - 1]
640
+ if (!id) return null
641
+
642
+ const backupDir = path.join(backupsDir, id)
643
+ const manifest = JSON.parse(await fsp.readFile(path.join(backupDir, "manifest.json"), "utf8"))
644
+ for (const op of [...manifest.operations].reverse()) {
645
+ if (op.existed && op.backupPath) {
646
+ await fsp.mkdir(path.dirname(op.path), { recursive: true, mode: 0o700 })
647
+ await fsp.copyFile(op.backupPath, op.path)
648
+ } else if (!op.existed) {
649
+ await fsp.rm(op.path, { force: true })
650
+ }
651
+ }
652
+ await fsp.rename(backupDir, `${backupDir}.rolled-back`)
653
+ return manifest
654
+ }
655
+
656
+ module.exports = {
657
+ DEFAULT_API_BASE,
658
+ DEFAULT_MODEL,
659
+ applyInitPlan,
660
+ applyToolActionPlan,
661
+ buildInitPlan,
662
+ buildToolActionPlan,
663
+ detectTools,
664
+ rollbackLatest,
665
+ safePlan,
666
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laivc/laicode",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Official Lai.vc CLI for configuring developer tools.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://lai.vc",
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "files": [
29
29
  "bin",
30
+ "lib",
30
31
  "README.md",
31
32
  "scripts"
32
33
  ],