@super-coding/cli 0.2.2 → 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.
@@ -1,10 +1,15 @@
1
- // 探测设备上是否装了 `pc`(PingCode Open API CLI)。
1
+ // 定位 `pc`(PingCode Open API CLI)二进制。
2
+ //
3
+ // 优先级:SC_PC_BINARY(显式指定)> 包内自带的预编译二进制 > PATH。
4
+ // 包内优先于 PATH 是刻意的:内嵌那份的版本与 skill 文档里写的命令一一对应,
5
+ // 用户机器上装的旧版 `pc` 反而会让参考文档和实际行为对不上。
2
6
  //
3
7
  // 找不到就安静降级:集成是「有则增强」,不是 daemon 的启动前提。
4
8
  // 版本探针刻意剥掉 PC_* 环境变量——即便 `--version` 不该读凭据,也不给它接触凭据的机会。
5
9
  import { execFile } from "node:child_process";
6
- import { accessSync, constants } from "node:fs";
10
+ import { accessSync, chmodSync, constants } from "node:fs";
7
11
  import { delimiter, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
8
13
  const BIN_NAME = "pc";
9
14
  /** Windows 上 `pc` 可能是 exe/cmd/bat;POSIX 只有同名可执行文件 */
10
15
  function candidateNames() {
@@ -21,11 +26,51 @@ function isExecutable(path) {
21
26
  return false;
22
27
  }
23
28
  }
24
- /** SC_PC_BINARY 可显式指定(自定义安装路径 / 测试注入);否则按 PATH 顺序找第一个。 */
25
- export function findPingcodeBinary(env = process.env) {
26
- const override = env["SC_PC_BINARY"]?.trim();
27
- if (override)
28
- return isExecutable(override) ? override : null;
29
+ /**
30
+ * npm 打包/解包会丢掉执行位,这里兜底补回来(只对自己包内的文件动手)。
31
+ * 补不上就当不可用,交给下一级回退,不要在这里抛。
32
+ */
33
+ function ensureExecutable(path) {
34
+ if (isExecutable(path))
35
+ return true;
36
+ if (process.platform === "win32")
37
+ return false;
38
+ try {
39
+ chmodSync(path, 0o755);
40
+ return isExecutable(path);
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ /** 上游 release 的资产命名:darwin-arm64 / linux-x64 / win32-x64 */
47
+ export function bundledBinaryName(platform = process.platform, arch = process.arch) {
48
+ const ext = platform === "win32" ? ".exe" : "";
49
+ return `pc-${platform}-${arch}${ext}`;
50
+ }
51
+ /**
52
+ * 包内二进制的候选路径。
53
+ * - 装好后跑:dist/pingcode/detect.js → dist/bin/
54
+ * - 用 tsx 跑源码:src/pingcode/detect.ts → 需要多上两级到 dist/bin/
55
+ * `extraDirs` 供测试注入,避免让用例依赖构建产物是否存在。
56
+ */
57
+ function bundledCandidates(name, extraDirs = []) {
58
+ const moduleDir = fileURLToPath(new URL(".", import.meta.url));
59
+ return [
60
+ ...extraDirs.map((dir) => join(dir, name)),
61
+ join(moduleDir, "..", "bin", name),
62
+ join(moduleDir, "..", "..", "dist", "bin", name),
63
+ ];
64
+ }
65
+ export function bundledPingcodeBinary(platform = process.platform, arch = process.arch, extraDirs = []) {
66
+ const name = bundledBinaryName(platform, arch);
67
+ for (const candidate of bundledCandidates(name, extraDirs)) {
68
+ if (ensureExecutable(candidate))
69
+ return candidate;
70
+ }
71
+ return null;
72
+ }
73
+ function findOnPath(env) {
29
74
  for (const dir of (env["PATH"] ?? "").split(delimiter)) {
30
75
  if (!dir)
31
76
  continue;
@@ -37,6 +82,21 @@ export function findPingcodeBinary(env = process.env) {
37
82
  }
38
83
  return null;
39
84
  }
85
+ /** SC_PC_BINARY 可显式指定(自定义安装路径 / 测试注入)> 包内 > PATH */
86
+ export function locatePingcodeBinary(env = process.env, platform = process.platform, arch = process.arch, extraBundledDirs = []) {
87
+ const override = env["SC_PC_BINARY"]?.trim();
88
+ if (override)
89
+ return isExecutable(override) ? { path: override, source: "override" } : null;
90
+ const bundled = bundledPingcodeBinary(platform, arch, extraBundledDirs);
91
+ if (bundled)
92
+ return { path: bundled, source: "bundled" };
93
+ const onPath = findOnPath(env);
94
+ return onPath ? { path: onPath, source: "system" } : null;
95
+ }
96
+ /** 只要路径的老接口(测试与外部调用用) */
97
+ export function findPingcodeBinary(env = process.env) {
98
+ return locatePingcodeBinary(env)?.path ?? null;
99
+ }
40
100
  /** 探针环境:剥掉 PingCode 凭据,避免任何输出把密钥带出来 */
41
101
  function probeEnv(env) {
42
102
  const next = { ...env };
@@ -58,12 +118,12 @@ export function probeVersion(path, env = process.env) {
58
118
  /** 探测并缓存结果(daemon 生命周期内二进制位置不会变) */
59
119
  let cached;
60
120
  export async function detectPingcodeBinary(env = process.env) {
61
- const path = findPingcodeBinary(env);
62
- const key = `${path ?? "(none)"}:${env["SC_PC_BINARY"] ?? ""}`;
121
+ const found = locatePingcodeBinary(env);
122
+ const key = `${found?.path ?? "(none)"}:${found?.source ?? ""}:${env["SC_PC_BINARY"] ?? ""}`;
63
123
  if (cached?.key === key)
64
124
  return cached.value;
65
- const version = path ? await probeVersion(path, env) : null;
66
- const value = path && version ? { path, version } : null;
125
+ const version = found ? await probeVersion(found.path, env) : null;
126
+ const value = found && version ? { path: found.path, version, source: found.source } : null;
67
127
  cached = { key, value };
68
128
  return value;
69
129
  }
@@ -1,8 +1,9 @@
1
1
  // PingCode(pc)集成的对外入口。
2
2
  export { PingcodeBridge, createPingcodeBridge } from "./runtime.js";
3
3
  export { loadPingcodeSkill, PINGCODE_SKILL_NAME } from "./skill.js";
4
- export { DEFAULT_BASE_URL, maskSecret, pingcodeConfigPath, readPingcodeConfig, normalizePingcodeConfig, writePingcodeConfig, removePingcodeConfig, PingcodeConfigError, } from "./config.js";
4
+ export { DEFAULT_BASE_URL, inspectPingcodeConfig, maskSecret, pingcodeConfigPath, readPingcodeConfig, normalizePingcodeConfig, writePingcodeConfig, removePingcodeConfig, PingcodeConfigError, } from "./config.js";
5
5
  export { connectPingcode, disconnectPingcode, printPingcodeStatus } from "./cli.js";
6
6
  export { findPingcodeBinary, detectPingcodeBinary } from "./detect.js";
7
7
  export { resolveToken, resetTokenCache } from "./token.js";
8
8
  export { writePingcodeLauncher, clearPingcodeToken, pingcodeTokenFile } from "./launcher.js";
9
+ export { applyRemotePingcodeConfig, isAcceptableRemoteBaseUrl } from "./remote-config.js";
@@ -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
+ }