@super-coding/cli 0.2.1 → 0.2.3

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,69 @@
1
+ // 平台下发的 PingCode 凭据落地:remote 经 webhook 取回 manifest environment 配置,
2
+ // 在 hello 之后推给 daemon,这里决定「写不写本机文件」。
3
+ //
4
+ // 核心策略:**只在本机完全没有配置时写入**(missing → 写;ok/invalid → 不碰)。
5
+ // 理由:本机文件可能承载用户手动配的个人令牌或私有部署地址,静默覆盖等于销毁用户配置;
6
+ // 而「平台配置统一凭据」的场景只需要覆盖新机器。轮换 secret 时的逃生路径是
7
+ // `scode pingcode disconnect`(删文件)后重连,而不是让握手路径隐式覆盖。
8
+ //
9
+ // 安全:base_url 在本通道单独做 https 强制(localhost 例外)。config.ts 的 normalize 允许
10
+ // http(本地 pc 调试合理),但 WS 下发的值来自网络,必须更严——被篡改成 http 就等于把令牌
11
+ // 明文送出去。
12
+ import { inspectPingcodeConfig, pingcodeConfigPath, writePingcodeConfig, } from "./config.js";
13
+ const clean = (value) => (typeof value === "string" ? value.trim() : "");
14
+ /**
15
+ * 只接受 https;http 仅放行本机回环(私有部署的本地调试、e2e stub)。
16
+ * 与 config.ts 的宽松校验分开是刻意的:用户手输可以 http,网络下发不行。
17
+ */
18
+ export function isAcceptableRemoteBaseUrl(raw) {
19
+ let url;
20
+ try {
21
+ url = new URL(raw);
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ if (url.protocol === "https:")
27
+ return true;
28
+ if (url.protocol !== "http:")
29
+ return false;
30
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
31
+ }
32
+ /**
33
+ * 应用平台下发的凭据。绝不抛错:daemon 的握手路径不能因为一个坏字段崩掉。
34
+ * 返回值供调用方决定日志级别(written 打一次、exists 静默)。
35
+ */
36
+ export function applyRemotePingcodeConfig(input, env = process.env) {
37
+ // 总开关:SC_PINGCODE=off 关掉整条集成;SC_PINGCODE_REMOTE_CONFIG=off 只关远端写入
38
+ if (env["SC_PINGCODE"] === "off" || env["SC_PINGCODE_REMOTE_CONFIG"] === "off") {
39
+ return { applied: "skipped_disabled" };
40
+ }
41
+ const baseUrl = clean(input?.base_url);
42
+ const clientId = clean(input?.client_id);
43
+ const clientSecret = clean(input?.client_secret);
44
+ if (!clientId || !clientSecret) {
45
+ if (!clientId && !clientSecret)
46
+ return { applied: "skipped_empty" };
47
+ return { applied: "skipped_incomplete", missing: clientId ? "client_secret" : "client_id" };
48
+ }
49
+ if (baseUrl && !isAcceptableRemoteBaseUrl(baseUrl)) {
50
+ return { applied: "skipped_bad_base_url", base_url: baseUrl };
51
+ }
52
+ const state = inspectPingcodeConfig();
53
+ if (state.state === "ok")
54
+ return { applied: "exists", path: state.path };
55
+ // 坏文件不覆盖:可能只是手写少了个逗号,覆盖会让用户失去自己配置的线索
56
+ if (state.state === "invalid") {
57
+ return { applied: "skipped_invalid", path: state.path, error: state.error };
58
+ }
59
+ try {
60
+ // base_url 缺省时交给 normalize 回落 DEFAULT_BASE_URL,不在调用方硬编码
61
+ writePingcodeConfig(baseUrl
62
+ ? { base_url: baseUrl, client_id: clientId, client_secret: clientSecret }
63
+ : { client_id: clientId, client_secret: clientSecret });
64
+ return { applied: "written", path: pingcodeConfigPath() };
65
+ }
66
+ catch (err) {
67
+ return { applied: "failed", error: err.message };
68
+ }
69
+ }
@@ -3,10 +3,25 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  /** dist 与 src 同构:builtin/ 与 skill.js 同目录(构建期由 copy-assets 拷贝) */
6
- const SKILL_PATH = fileURLToPath(new URL("./builtin/SKILL.md", import.meta.url));
7
- const REFERENCE_PATH = fileURLToPath(new URL("./builtin/reference.md", import.meta.url));
6
+ const BUILTIN_DIR = "./builtin/";
7
+ const SKILL_PATH = fileURLToPath(new URL(`${BUILTIN_DIR}SKILL.md`, import.meta.url));
8
+ /**
9
+ * 附属参考文件。`pc` 有 18 个模块、约 400 条命令,全部塞进 SKILL.md 会把每次加载的
10
+ * 上下文撑爆,因此按模块拆开:SKILL.md 只留入口与铁律,细节按需读取。
11
+ * 顺序即写入会话目录的顺序,第一项为主参考。
12
+ */
13
+ export const REFERENCE_FILES = [
14
+ "reference.md",
15
+ "reference-ship.md",
16
+ "reference-testhub.md",
17
+ "reference-org-wiki.md",
18
+ "reference-cross.md",
19
+ ];
20
+ const REFERENCE_PATHS = REFERENCE_FILES.map((name) => fileURLToPath(new URL(`${BUILTIN_DIR}${name}`, import.meta.url)));
8
21
  /** 正文过短视为打包缺失/截断,宁可不注入 */
9
22
  const MIN_SKILL_CHARS = 200;
23
+ /** 单个参考文件的下限:最薄的一份也有 40+ 条命令,低于这个值基本是拷贝失败 */
24
+ const MIN_REFERENCE_CHARS = 1_500;
10
25
  export const PINGCODE_SKILL_NAME = "use-pingcode-cli";
11
26
  const SKILL_ID = "builtin:use-pingcode-cli";
12
27
  let cached;
@@ -23,19 +38,23 @@ export function loadPingcodeSkill() {
23
38
  return cached;
24
39
  try {
25
40
  const content = stripBom(readFileSync(SKILL_PATH, "utf8"));
26
- const reference = stripBom(readFileSync(REFERENCE_PATH, "utf8"));
27
41
  if (!/^---\r?\n/.test(content))
28
42
  throw new Error("SKILL.md 缺少 frontmatter");
29
43
  if (content.trim().length < MIN_SKILL_CHARS)
30
44
  throw new Error(`SKILL.md 正文过短(<${MIN_SKILL_CHARS} 字符),疑似打包缺失`);
31
- if (reference.trim().length < MIN_SKILL_CHARS)
32
- throw new Error("reference.md 内容过短,疑似打包缺失");
45
+ const files = REFERENCE_FILES.map((name, i) => {
46
+ const reference = stripBom(readFileSync(REFERENCE_PATHS[i], "utf8"));
47
+ if (reference.trim().length < MIN_REFERENCE_CHARS) {
48
+ throw new Error(`${name} 内容过短(<${MIN_REFERENCE_CHARS} 字符),疑似打包缺失`);
49
+ }
50
+ return { path: name, content: reference };
51
+ });
33
52
  cached = {
34
53
  id: SKILL_ID,
35
54
  name: PINGCODE_SKILL_NAME,
36
55
  ...(frontmatterDescription(content) ? { description: frontmatterDescription(content) } : {}),
37
56
  content,
38
- files: [{ path: "reference.md", content: reference }],
57
+ files,
39
58
  };
40
59
  }
41
60
  catch (err) {
@@ -48,4 +67,8 @@ export function loadPingcodeSkill() {
48
67
  export function resetPingcodeSkillCache() {
49
68
  cached = undefined;
50
69
  }
51
- export const pingcodeSkillPaths = { SKILL_PATH, REFERENCE_PATH };
70
+ export const pingcodeSkillPaths = {
71
+ SKILL_PATH,
72
+ REFERENCE_PATH: REFERENCE_PATHS[0],
73
+ REFERENCE_PATHS,
74
+ };
@@ -0,0 +1,329 @@
1
+ // `scode setup`:一条命令把本机初始化到「能被平台派活」的状态。
2
+ //
3
+ // 交互形态是向导:先给总览(会做几件事),再一步一步来;每一步先说明「这步干什么」,
4
+ // 需要用户输入时把问题、说明、默认值分行摆开,输入位置用 `›` 指出来;最后给结果汇总。
5
+ // 非交互(--yes 或无 TTY)只用参数/环境变量,缺什么跳过什么。
6
+ //
7
+ // 三个步骤:
8
+ // 1. 设备绑定:未绑定时先在前台配对,配对码直接打在用户眼前。
9
+ // 2. 守护进程:绑定后再启动 daemon —— 未绑定就启动,daemon 会自己发一份配对码,
10
+ // 与这里的配对流程抢同一个设备,所以顺序是「先绑定、后启动」。
11
+ // 3. PingCode 凭据:依次询问 PC_OPEN_API_BASE_URL / PC_CLIENT_ID / PC_CLIENT_SECRET,
12
+ // 落盘与校验复用 `scode pingcode connect`(~/.super-coding/pingcode.json,0600)。
13
+ import { loadDevice, restoreDeviceBackup } from "../daemon/device-store.js";
14
+ import { startDaemon, stopDaemon } from "../daemon/manage.js";
15
+ import { isAlive, readPid } from "../daemon/paths.js";
16
+ import { runPairing } from "../daemon/pairing.js";
17
+ import { serviceUrls } from "../daemon/urls.js";
18
+ import { connectPingcode, DEFAULT_BASE_URL, inspectPingcodeConfig, } from "../pingcode/index.js";
19
+ import { askConfirm, createPrompter, noPrompt } from "./prompt.js";
20
+ import { GUTTER, INDENT, paint, pad, symbol } from "./ui.js";
21
+ /** 与 PingCode 应用里「Open API」三项一一对应,同时也是可用的环境变量名 */
22
+ export const ENV_BASE_URL = "PC_OPEN_API_BASE_URL";
23
+ export const ENV_CLIENT_ID = "PC_CLIENT_ID";
24
+ export const ENV_CLIENT_SECRET = "PC_CLIENT_SECRET";
25
+ const TOTAL_STEPS = 3;
26
+ /** 汇总里的标签宽度(「守护进程」= 8 列,留一点余量) */
27
+ const LABEL_WIDTH = 12;
28
+ /** 复核区的标签宽度:要放下最长的 `Client Secret`(13 列) */
29
+ const REVIEW_LABEL_WIDTH = 16;
30
+ /** 分隔线宽度:固定值,避免窄终端折行折出难看的样子 */
31
+ const RULE_WIDTH = 72;
32
+ // ---------- 输出 ----------
33
+ function stepLine(index, title) {
34
+ console.log("");
35
+ console.log(`${paint.cyan(symbol.step)} ${paint.bold(`${index}/${TOTAL_STEPS}`)} ${paint.bold(title)}`);
36
+ }
37
+ /** 步骤内的一行(挂在竖线上);不传内容就是一条竖线,用来分隔块 */
38
+ function row(text = "") {
39
+ console.log(text ? `${GUTTER}${text}` : GUTTER.trimEnd());
40
+ }
41
+ function note(text) {
42
+ row(paint.dim(text));
43
+ }
44
+ function ok(text, detail) {
45
+ row(`${paint.green(symbol.done)} ${text}${detail ? `${INDENT}${paint.dim(detail)}` : ""}`);
46
+ }
47
+ function skip(text) {
48
+ row(paint.dim(`${symbol.skip} ${text}`));
49
+ }
50
+ function fail(text) {
51
+ row(paint.red(`${symbol.fail} ${text}`));
52
+ }
53
+ /** 汇总里的「标签 + 值」行,按显示宽度对齐(中文占 2 列,不能用 length) */
54
+ function field(label, value) {
55
+ console.log(` ${paint.dim(pad(label, LABEL_WIDTH))}${value}`);
56
+ }
57
+ /** 步骤内的「标签 + 值」行:跟着竖线导轨走,标签列更宽(要放下 Client Secret) */
58
+ function rowField(label, value) {
59
+ row(`${INDENT}${paint.dim(pad(label, REVIEW_LABEL_WIDTH))}${value}`);
60
+ }
61
+ /** 密钥只显示固定 8 个点:既不暴露长度,也不像 `***cret` 那样露出尾部 */
62
+ const DOT_MASK = "••••••••";
63
+ // ---------- 小工具 ----------
64
+ /** 环境变量 → 去空串,避免把「配了个空值」当成已配置 */
65
+ function fromEnv(env, name) {
66
+ const raw = env[name];
67
+ return raw && raw.trim() ? raw.trim() : undefined;
68
+ }
69
+ function defaultRunningPid() {
70
+ const state = readPid();
71
+ return state && isAlive(state.pid) ? state.pid : null;
72
+ }
73
+ function currentPingcode() {
74
+ const inspected = inspectPingcodeConfig();
75
+ return inspected.state === "ok" ? inspected.config : null;
76
+ }
77
+ function shortId(id) {
78
+ return id.length > 8 ? `${id.slice(0, 8)}…` : id;
79
+ }
80
+ /** Open API 地址必须能当 base URL 用;写错要等校验时才报错就太晚了 */
81
+ function validateBaseUrl(value) {
82
+ if (!/^https?:\/\//i.test(value)) {
83
+ return "要以 http:// 或 https:// 开头,例如 https://open.pingcode.com";
84
+ }
85
+ if (/\s/.test(value))
86
+ return "地址里不能有空格";
87
+ return undefined;
88
+ }
89
+ // ---------- PingCode:非交互 ----------
90
+ /** 参数 > 环境变量 > 已有配置 > 默认地址;缺凭据就跳过 */
91
+ async function pingcodeNonInteractive(opts, env, existing, connect) {
92
+ if (existing && !opts.force && !(opts.clientId || opts.clientSecret)) {
93
+ ok("沿用现有配置", `Client ID ${existing.client_id ?? "无"}`);
94
+ return false;
95
+ }
96
+ const baseUrl = (opts.baseUrl ??
97
+ fromEnv(env, ENV_BASE_URL) ??
98
+ existing?.base_url ??
99
+ DEFAULT_BASE_URL).trim();
100
+ const clientId = opts.clientId ?? fromEnv(env, ENV_CLIENT_ID) ?? existing?.client_id;
101
+ const clientSecret = opts.clientSecret ?? fromEnv(env, ENV_CLIENT_SECRET) ?? existing?.client_secret;
102
+ if (!clientId || !clientSecret) {
103
+ skip("未提供凭据(非交互模式不提问)");
104
+ note("补齐:scode setup --client-id <ID> --client-secret <SECRET> [--base-url <URL>]");
105
+ note(`或设置环境变量 ${ENV_CLIENT_ID} / ${ENV_CLIENT_SECRET}`);
106
+ return false;
107
+ }
108
+ await connect({ baseUrl, clientId, clientSecret });
109
+ return true;
110
+ }
111
+ // ---------- PingCode:交互 ----------
112
+ async function pingcodeInteractive(opts, env, existing, ask, connect) {
113
+ const explicit = !!(opts.clientId || opts.clientSecret);
114
+ if (existing && !opts.force && !explicit) {
115
+ ok("当前已配置", `${existing.base_url} · Client ID ${existing.client_id ?? "(无)"}`);
116
+ const again = await askConfirm(ask, "要修改这些配置吗?", false, "不修改就直接回车");
117
+ if (!again) {
118
+ skip("保留现有配置");
119
+ return false;
120
+ }
121
+ }
122
+ else if (!explicit) {
123
+ const wanted = await askConfirm(ask, "现在配置 PingCode 凭据吗?", true, "稍后也可以用 scode pingcode connect 单独配置");
124
+ if (!wanted) {
125
+ skip("已跳过 PingCode 配置");
126
+ return false;
127
+ }
128
+ }
129
+ return await collectPingcode(opts, env, existing, ask, connect);
130
+ }
131
+ /** 逐项问 → 复核 → 保存 */
132
+ async function collectPingcode(opts, env, existing, ask, connect) {
133
+ const baseUrlDefault = (opts.baseUrl ??
134
+ fromEnv(env, ENV_BASE_URL) ??
135
+ existing?.base_url ??
136
+ DEFAULT_BASE_URL).trim();
137
+ const clientIdDefault = opts.clientId ?? fromEnv(env, ENV_CLIENT_ID) ?? existing?.client_id;
138
+ const clientSecretDefault = opts.clientSecret ?? fromEnv(env, ENV_CLIENT_SECRET) ?? existing?.client_secret;
139
+ row();
140
+ note("三项都在 PingCode「应用 → Open API」页面里,可以直接复制");
141
+ row();
142
+ const baseUrl = await ask("Open API 地址", {
143
+ defaultValue: baseUrlDefault,
144
+ hint: ["公有云 https://open.pingcode.com", "私有部署 https://<你的域名>/open"],
145
+ validate: validateBaseUrl,
146
+ });
147
+ row();
148
+ const clientId = await ask("Client ID", {
149
+ defaultValue: clientIdDefault,
150
+ allowEmpty: !clientIdDefault,
151
+ hint: "在应用凭据里,不是应用 ID,也不是工作项编号;留空则跳过",
152
+ });
153
+ row();
154
+ const clientSecret = await ask("Client Secret", {
155
+ defaultValue: clientSecretDefault,
156
+ defaultLabel: "回车沿用已保存的值",
157
+ allowEmpty: !clientSecretDefault,
158
+ hidden: true,
159
+ hint: clientSecretDefault
160
+ ? "输入时不回显;直接回车则沿用已保存的值"
161
+ : "输入时不回显;粘贴后直接回车(留空则跳过)",
162
+ });
163
+ if (!clientId || !clientSecret) {
164
+ skip("Client ID / Client Secret 为空,已跳过(稍后可用 scode pingcode connect 补)");
165
+ return false;
166
+ }
167
+ row();
168
+ row(paint.bold("即将保存"));
169
+ rowField("地址", baseUrl);
170
+ rowField("Client ID", clientId);
171
+ rowField("Client Secret", paint.dim(DOT_MASK));
172
+ const confirmed = await askConfirm(ask, "确认保存并校验吗?", true, "校验会真的向 PingCode 换一次令牌");
173
+ if (!confirmed) {
174
+ skip("已取消,未写入任何内容");
175
+ return false;
176
+ }
177
+ row(paint.dim("… 正在校验"));
178
+ await connect({ baseUrl, clientId, clientSecret });
179
+ ok("凭据已保存并通过校验");
180
+ return true;
181
+ }
182
+ // ---------- 主流程 ----------
183
+ export async function runSetup(opts = {}, deps = {}) {
184
+ const env = deps.env ?? process.env;
185
+ const interactive = deps.interactive ?? (!!process.stdin.isTTY && !opts.yes);
186
+ // Ctrl+C:默认行为要么打一堆栈,要么被 readline 吃掉继续跑;统一成一句人话 + 明确的退出码
187
+ const onSigint = () => {
188
+ console.log(`\n${GUTTER}${paint.yellow(`${symbol.warn} 已中断(已完成的步骤不会回滚)`)}`);
189
+ process.exit(130);
190
+ };
191
+ // 注入了 ask(测试)时不再创建真实 readline,避免测试进程挂上 stdin 监听
192
+ const ownPrompter = interactive && !deps.ask ? createPrompter(process.stdin, process.stdout, { onInterrupt: onSigint }) : null;
193
+ const ask = deps.ask ?? ownPrompter?.ask ?? noPrompt;
194
+ const pairing = deps.pairing ?? ((httpUrl, log) => runPairing(httpUrl, { log }));
195
+ const start = deps.startDaemon ?? startDaemon;
196
+ const stop = deps.stopDaemon ?? stopDaemon;
197
+ const connect = deps.connectPingcode ?? connectPingcode;
198
+ const load = deps.loadDevice ?? (() => loadDevice() ?? restoreDeviceBackup());
199
+ const runningPid = deps.runningPid ?? defaultRunningPid;
200
+ const { http } = serviceUrls(env);
201
+ let pingcodeError = null;
202
+ let changed = 0;
203
+ if (interactive)
204
+ process.once("SIGINT", onSigint);
205
+ try {
206
+ console.log("");
207
+ console.log(paint.bold("Super Coding 初始化"));
208
+ console.log(`${INDENT}${paint.dim("依次完成 3 件事:绑定设备 → 启动守护进程 → 配置 PingCode 凭据")}`);
209
+ console.log(`${INDENT}${paint.dim(interactive
210
+ ? "回车采用方括号里的默认值,Ctrl+C 随时中断"
211
+ : "非交互模式:只用命令行参数与环境变量,缺什么跳过什么")}`);
212
+ console.log(paint.dim("─".repeat(RULE_WIDTH)));
213
+ // ---------- 1/3 设备绑定 ----------
214
+ stepLine(1, "设备绑定");
215
+ note("绑定后,平台才能把任务派到这台机器执行。");
216
+ let device = load();
217
+ if (device) {
218
+ ok("已绑定", `${device.machine_name} · ${shortId(device.id)}`);
219
+ }
220
+ else if (!interactive) {
221
+ // 配对要等你看到终端里的码、再去应用里输入,--yes 场景下没人能做这件事;
222
+ // 直接进配对会静默轮询 10 分钟然后超时退出,比跳过更糟
223
+ skip("未绑定设备(非交互模式不配对)");
224
+ note("请在终端里运行 scode setup 完成配对");
225
+ }
226
+ else if (!(await askConfirm(ask, "设备尚未绑定,现在开始配对吗?", true))) {
227
+ skip("未绑定(之后可再运行 scode setup)");
228
+ }
229
+ else {
230
+ if (runningPid()) {
231
+ row();
232
+ note("先停掉正在运行的守护进程,否则它会自己发一份配对码,和这里抢同一个设备");
233
+ await stop();
234
+ ok("已停止守护进程");
235
+ }
236
+ row();
237
+ note("请在 Super Coding 应用「设备」页添加设备,把终端里的配对码填进去");
238
+ device = await pairing(http, (msg) => row(paint.dim(msg)));
239
+ row();
240
+ ok("配对完成", `${device.machine_name} · ${shortId(device.id)}`);
241
+ changed += 1;
242
+ }
243
+ // ---------- 2/3 守护进程 ----------
244
+ stepLine(2, "守护进程");
245
+ note("守护进程常驻本机,负责连接平台并执行任务。");
246
+ const pid = runningPid();
247
+ if (pid) {
248
+ ok("已在运行", `pid ${pid}`);
249
+ }
250
+ else if (!device) {
251
+ skip("设备未绑定,暂不启动;绑定后运行 scode daemon start");
252
+ }
253
+ else if (interactive && !(await askConfirm(ask, "现在启动守护进程吗?", true))) {
254
+ skip("已跳过;稍后运行 scode daemon start");
255
+ }
256
+ else {
257
+ await start();
258
+ ok("已启动", "后台常驻,关掉终端也不影响");
259
+ changed += 1;
260
+ }
261
+ // ---------- 3/3 PingCode 凭据 ----------
262
+ stepLine(3, "PingCode 凭据");
263
+ note("配好之后,agent 才能在会话里读取工作项上下文。");
264
+ if (opts.skipPingcode) {
265
+ skip("已跳过(--skip-pingcode)");
266
+ }
267
+ else {
268
+ const existing = currentPingcode();
269
+ try {
270
+ const done = interactive
271
+ ? await pingcodeInteractive(opts, env, existing, ask, connect)
272
+ : await pingcodeNonInteractive(opts, env, existing, connect);
273
+ if (done)
274
+ changed += 1;
275
+ }
276
+ catch (err) {
277
+ pingcodeError = err instanceof Error ? err : new Error(String(err));
278
+ fail(`配置失败:${pingcodeError.message}`);
279
+ note("修好后重试:scode pingcode connect");
280
+ }
281
+ }
282
+ // ---------- 汇总 ----------
283
+ console.log(paint.dim("─".repeat(RULE_WIDTH)));
284
+ const finalDevice = load();
285
+ const finalPid = runningPid();
286
+ const finalPingcode = currentPingcode();
287
+ const allReady = !!finalDevice && !!finalPid && (!!finalPingcode || !!opts.skipPingcode);
288
+ if (pingcodeError) {
289
+ console.log(`${paint.red(symbol.fail)} ${paint.bold("初始化未全部完成")}`);
290
+ }
291
+ else if (!allReady) {
292
+ // 有步骤被跳过或取消:不能说「已就绪」,那会让人以为配完了
293
+ console.log(`${paint.yellow(symbol.warn)} ${paint.bold("初始化尚未完成")}`);
294
+ }
295
+ else if (changed === 0) {
296
+ console.log(`${paint.green(symbol.done)} ${paint.bold("已就绪,本次没有改动")}`);
297
+ }
298
+ else {
299
+ console.log(`${paint.green(symbol.done)} ${paint.bold("初始化完成")}`);
300
+ }
301
+ console.log("");
302
+ field("设备", finalDevice
303
+ ? `${finalDevice.machine_name}${paint.dim(` · ${shortId(finalDevice.id)}`)}`
304
+ : paint.yellow("未绑定"));
305
+ field("守护进程", finalPid ? paint.green(`运行中 · pid ${finalPid}`) : paint.yellow("未运行"));
306
+ field("PingCode", finalPingcode
307
+ ? paint.green(`已配置 ${paint.dim(`· ${finalPingcode.base_url}`)}`)
308
+ : paint.dim("未配置"));
309
+ console.log("");
310
+ console.log(`${INDENT}${paint.dim("下一步")}`);
311
+ const next = [
312
+ ["scode daemon logs -f", "查看实时日志"],
313
+ ["scode daemon status", "查看运行状态"],
314
+ ];
315
+ if (!finalPingcode)
316
+ next.push(["scode setup", "补配 PingCode 凭据"]);
317
+ for (const [cmd, desc] of next) {
318
+ console.log(`${INDENT} ${paint.cyan(pad(cmd, 22))}${paint.dim(desc)}`);
319
+ }
320
+ console.log("");
321
+ if (pingcodeError)
322
+ process.exitCode = 1;
323
+ }
324
+ finally {
325
+ if (interactive)
326
+ process.removeListener("SIGINT", onSigint);
327
+ ownPrompter?.close();
328
+ }
329
+ }
@@ -0,0 +1,129 @@
1
+ // 交互式问答:只做 setup 需要的最小能力——带默认值的单行输入 + 密钥不回显 + 输入校验循环。
2
+ //
3
+ // 不引入第三方依赖(仓库只依赖 commander/ws)。密钥遮蔽用 readline 的 _writeToOutput
4
+ // 钩子:它在写提示符与回显输入时被调用,静音期间一律吞掉,提示符由我们自己先打印。
5
+ //
6
+ // 排版目标:每个问题都「先说清楚是什么、默认值是什么」,输入位置用 `› ` 明确指出来,
7
+ // 而不是把所有信息揉在一行里。
8
+ import { createInterface } from "node:readline";
9
+ import { GUTTER, INDENT, paint, symbol } from "./ui.js";
10
+ export function createPrompter(input = process.stdin, output = process.stdout, opts = {}) {
11
+ const rl = createInterface({ input, output });
12
+ let muted = false;
13
+ // readline 关闭后 question() 会直接抛 "readline was closed";记下来,后续提问降级为默认值
14
+ let closed = false;
15
+ rl.once("close", () => {
16
+ closed = true;
17
+ });
18
+ if (opts.onInterrupt)
19
+ rl.on("SIGINT", () => opts.onInterrupt?.());
20
+ // 只接管「输出」,不接管行编辑;静音时连同回显一起吞掉
21
+ rl._writeToOutput = (text) => {
22
+ if (!muted)
23
+ output.write(text);
24
+ };
25
+ const resolveEmpty = (question, defaultValue, allowEmpty) => {
26
+ if (defaultValue !== undefined)
27
+ return defaultValue;
28
+ if (allowEmpty)
29
+ return "";
30
+ throw new Error(`${question} 不能为空`);
31
+ };
32
+ const read = async (defaultValue) => {
33
+ let eof = false;
34
+ try {
35
+ return await new Promise((resolve) => {
36
+ const onClose = () => {
37
+ eof = true;
38
+ resolve(defaultValue ?? "");
39
+ };
40
+ rl.once("close", onClose);
41
+ rl.question("", (value) => {
42
+ rl.removeListener("close", onClose);
43
+ resolve(value);
44
+ });
45
+ });
46
+ }
47
+ finally {
48
+ if (eof)
49
+ output.write("\n");
50
+ }
51
+ };
52
+ const ask = async (question, opts = {}) => {
53
+ const { defaultValue, defaultLabel, hidden = false, allowEmpty = false, hint, suffix, validate, gutter = GUTTER, } = opts;
54
+ const tail = suffix ??
55
+ (defaultValue !== undefined
56
+ ? ` [${(defaultLabel ?? defaultValue) || "空"}]`
57
+ : allowEmpty
58
+ ? ""
59
+ : " [必填]");
60
+ const hints = hint === undefined ? [] : Array.isArray(hint) ? hint : [hint];
61
+ for (let attempt = 0;; attempt += 1) {
62
+ output.write(`${gutter}${paint.cyan("?")} ${paint.bold(question)}${paint.dim(tail)}\n`);
63
+ // 说明只在第一次展示:校验失败重问时再重复一遍会让终端刷屏
64
+ if (attempt === 0) {
65
+ for (const line of hints)
66
+ output.write(`${gutter}${INDENT}${paint.dim(line)}\n`);
67
+ }
68
+ output.write(`${gutter}${INDENT}${paint.dim(`${symbol.arrow} `)}`);
69
+ // 输入流已结束(Ctrl+D 或管道提前 EOF):不再提问,直接按默认值/空值收场
70
+ if (closed) {
71
+ output.write("\n");
72
+ return resolveEmpty(question, defaultValue, allowEmpty);
73
+ }
74
+ muted = hidden;
75
+ let answer;
76
+ try {
77
+ answer = await read(defaultValue);
78
+ }
79
+ finally {
80
+ muted = false;
81
+ if (hidden)
82
+ output.write("\n");
83
+ }
84
+ // 不回显的输入完全没有视觉反馈,容易让人以为没读到;这里只报位数,不泄露内容
85
+ if (hidden && answer.trim()) {
86
+ output.write(`${gutter}${INDENT}${paint.dim(`${symbol.skip} 已记录 ${answer.trim().length} 个字符(不回显)`)}\n`);
87
+ }
88
+ const trimmed = answer.trim();
89
+ const value = trimmed ? trimmed : defaultValue !== undefined ? defaultValue : allowEmpty ? "" : null;
90
+ if (value === null) {
91
+ output.write(`${gutter}${INDENT}${paint.yellow(`${symbol.warn} ${question} 不能为空`)}\n`);
92
+ continue;
93
+ }
94
+ const error = validate?.(value);
95
+ if (error) {
96
+ output.write(`${gutter}${INDENT}${paint.yellow(`${symbol.warn} ${error}`)}\n`);
97
+ continue;
98
+ }
99
+ return value;
100
+ }
101
+ };
102
+ return { ask, close: () => rl.close() };
103
+ }
104
+ /**
105
+ * 是非题:回车走默认值,只有明确的 y/n 才改判;看不懂的输入重问一次,
106
+ * 而不是默默当成默认值(那会让人以为自己答了别的意思)。
107
+ */
108
+ export async function askConfirm(ask, question, defaultYes = true, hint) {
109
+ for (;;) {
110
+ const answer = (await ask(question, {
111
+ allowEmpty: true,
112
+ defaultValue: "",
113
+ suffix: defaultYes ? " (Y/n)" : " (y/N)",
114
+ hint,
115
+ }))
116
+ .trim()
117
+ .toLowerCase();
118
+ if (!answer)
119
+ return defaultYes;
120
+ if (/^(y|yes|是|确认|好)$/.test(answer))
121
+ return true;
122
+ if (/^(n|no|否|不|取消)$/.test(answer))
123
+ return false;
124
+ }
125
+ }
126
+ /** 非交互模式:参数/环境变量已经决定一切,问题不应被提出 */
127
+ export const noPrompt = (question) => {
128
+ throw new Error(`需要交互输入(${question}),但当前不是可交互终端;请用命令行参数或环境变量提供`);
129
+ };
@@ -0,0 +1,78 @@
1
+ // setup 的输出层:颜色、图标、对齐。
2
+ //
3
+ // 只解决「看不出这一步在干什么 / 输出像流水账」的问题,不掺业务判断。
4
+ //
5
+ // 颜色规则:FORCE_COLOR 强制开;NO_COLOR、TERM=dumb、非 TTY 一律关 —— 重定向到文件或
6
+ // CI 里不应该出现一堆 ESC 序列。ANSI 用自写的包装函数,不引第三方依赖(仓库只有 commander/ws)。
7
+ const env = process.env;
8
+ const forceColor = env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== "0";
9
+ const colorEnabled = forceColor || (!!process.stdout.isTTY && env.NO_COLOR === undefined && env.TERM !== "dumb");
10
+ function style(open, close) {
11
+ return colorEnabled ? (text) => `\x1b[${open}m${text}\x1b[${close}m` : (text) => text;
12
+ }
13
+ export const paint = {
14
+ bold: style("1", "22"),
15
+ dim: style("2", "22"),
16
+ red: style("31", "39"),
17
+ green: style("32", "39"),
18
+ yellow: style("33", "39"),
19
+ cyan: style("36", "39"),
20
+ };
21
+ export const symbol = {
22
+ step: "◆",
23
+ done: "✓",
24
+ skip: "·",
25
+ warn: "!",
26
+ fail: "✗",
27
+ arrow: "›",
28
+ bullet: "•",
29
+ };
30
+ /** 左侧导轨:把每一步的内容都挂在一条竖线上,视觉上归拢到当前步骤 */
31
+ export const GUTTER = " │ ";
32
+ /** 步骤内容里的二级缩进(提示、输入游标用) */
33
+ export const INDENT = " ";
34
+ const WIDE_RANGES = [
35
+ [0x1100, 0x115f], // 谚文字母
36
+ [0x2e80, 0x303e], // 部首、康熙部首、CJK 标点
37
+ [0x3041, 0x33ff], // 假名、注音、CJK 兼容
38
+ [0x3400, 0x4dbf], // CJK 扩展 A
39
+ [0x4e00, 0x9fff], // CJK 基本区
40
+ [0xa000, 0xa4cf], // 彝文
41
+ [0xac00, 0xd7a3], // 谚文音节
42
+ [0xf900, 0xfaff], // CJK 兼容表意
43
+ [0xfe30, 0xfe6f], // CJK 兼容形式
44
+ [0xff00, 0xff60], // 全角 ASCII
45
+ [0xffe0, 0xffe6], // 全角符号
46
+ [0x1f300, 0x1f64f], // emoji
47
+ [0x20000, 0x3fffd], // CJK 扩展 B 及以上
48
+ ];
49
+ function isWide(code) {
50
+ return WIDE_RANGES.some(([lo, hi]) => code >= lo && code <= hi);
51
+ }
52
+ /**
53
+ * 显示宽度:中文/全角占 2 列。
54
+ * 直接用 `String.length` 对齐会让中英混排的列歪掉(「设备」是 2 个字符但占 4 列)。
55
+ */
56
+ export function displayWidth(text) {
57
+ let width = 0;
58
+ for (const ch of text) {
59
+ const code = ch.codePointAt(0) ?? 0;
60
+ width += isWide(code) ? 2 : 1;
61
+ }
62
+ return width;
63
+ }
64
+ /** 按显示宽度右补空格,把 `text` 撑到 `width` 列 */
65
+ export function pad(text, width) {
66
+ const gap = width - displayWidth(text);
67
+ return gap > 0 ? text + " ".repeat(gap) : text;
68
+ }
69
+ /** 终端宽度(取不到或不合理时退回 72),用于画分隔线 */
70
+ export function terminalWidth() {
71
+ const columns = process.stdout.columns;
72
+ if (!columns || columns < 40)
73
+ return 72;
74
+ return Math.min(columns, 100) - 2;
75
+ }
76
+ export function rule() {
77
+ return paint.dim("─".repeat(terminalWidth()));
78
+ }