@chenglu.she/sandy 1.0.4 → 1.0.5

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 CHANGED
@@ -31,7 +31,13 @@ mkdir -p ~/treedome && cd ~/treedome
31
31
  sandy init
32
32
  ```
33
33
 
34
- 会交互式询问飞书 / Cursor / Agent 等项,并写入当前目录的 `config.yaml`。人设模板见 [templates/sandy.mdc](./templates/sandy.mdc),可复制到 `.cursor/rules/`。
34
+ 会交互式询问飞书 / Cursor / Agent 等项,写入 `config.yaml`;在 macOS 上接着触发磁盘授权弹窗(桌面 / 文稿 / 下载等)。人设模板见 [templates/sandy.mdc](./templates/sandy.mdc),可复制到 `.cursor/rules/`。
35
+
36
+ 新机器务必在**电脑屏幕前**跑 init(不要 SSH):弹出的「node 想访问某某文件夹」全部点「允许」,并把提示里的 Node 路径加到「完全磁盘访问权限」。以后单独补授权:
37
+
38
+ ```bash
39
+ sandy authorize # 别名:sandy diskauth
40
+ ```
35
41
 
36
42
  必填项:
37
43
 
@@ -86,7 +92,7 @@ cd ~/treedome
86
92
  sandy
87
93
  ```
88
94
 
89
- 看到 `ws client ready` 后,飞书里私聊机器人即可。
95
+ 看到 `ws client ready` 后,飞书里私聊机器人即可。远程任务卡住、回家才看到 node 访问目录的授权框,再跑一次 `sandy authorize`。
90
96
 
91
97
  #### macOS 常驻(可选)
92
98
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chenglu.she/sandy",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Feishu long-connection bot that drives Cursor agents via @cursor/sdk",
@@ -0,0 +1,185 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ configFilePath,
7
+ expandPath,
8
+ readConfigFile,
9
+ workDir,
10
+ } from "./config-io.js";
11
+
12
+ export type AuthorizeOptions = {
13
+ extraDirs?: string[];
14
+ /** Open System Settings → Full Disk Access after probing. Default true. */
15
+ openSettings?: boolean;
16
+ };
17
+
18
+ type ProbeResult = {
19
+ dir: string;
20
+ status: "ok" | "missing" | "denied";
21
+ detail?: string;
22
+ };
23
+
24
+ function nodeBinary(): string {
25
+ try {
26
+ return fs.realpathSync(process.execPath);
27
+ } catch {
28
+ return process.execPath;
29
+ }
30
+ }
31
+
32
+ function isRemoteSession(): boolean {
33
+ return Boolean(process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY);
34
+ }
35
+
36
+ function extraDirsFromConfig(): string[] {
37
+ try {
38
+ const filePath = configFilePath();
39
+ if (!fs.existsSync(filePath)) return [workDir];
40
+ const file = readConfigFile(filePath);
41
+ const cwdRaw = file.agent?.cwd?.trim() || workDir;
42
+ const dirs = Array.isArray(file.agent?.dirs) ? file.agent.dirs : [];
43
+ return [
44
+ expandPath(String(cwdRaw), workDir),
45
+ ...dirs.map((d) => expandPath(String(d), workDir)),
46
+ ];
47
+ } catch {
48
+ return [workDir];
49
+ }
50
+ }
51
+
52
+ function listSubdirs(root: string): string[] {
53
+ try {
54
+ return fs
55
+ .readdirSync(root, { withFileTypes: true })
56
+ .filter((d) => d.isDirectory() || d.isSymbolicLink())
57
+ .map((d) => path.resolve(root, d.name));
58
+ } catch {
59
+ return [];
60
+ }
61
+ }
62
+
63
+ function standardTargets(home: string): string[] {
64
+ return [
65
+ home,
66
+ path.join(home, "Desktop"),
67
+ path.join(home, "Documents"),
68
+ path.join(home, "Downloads"),
69
+ path.join(home, "Pictures"),
70
+ path.join(home, "Movies"),
71
+ path.join(home, "Music"),
72
+ path.join(home, "Library"),
73
+ path.join(home, "Library", "CloudStorage"),
74
+ path.join(home, "Library", "Mobile Documents"),
75
+ path.join(home, "Library", "Mobile Documents", "com~apple~CloudDocs"),
76
+ ];
77
+ }
78
+
79
+ function uniqueExistingOrder(dirs: string[]): string[] {
80
+ const seen = new Set<string>();
81
+ const out: string[] = [];
82
+ for (const raw of dirs) {
83
+ const resolved = path.resolve(raw);
84
+ if (seen.has(resolved)) continue;
85
+ seen.add(resolved);
86
+ out.push(resolved);
87
+ }
88
+ return out;
89
+ }
90
+
91
+ function probe(dir: string): ProbeResult {
92
+ try {
93
+ fs.accessSync(dir, fs.constants.R_OK);
94
+ const st = fs.statSync(dir);
95
+ if (st.isDirectory()) {
96
+ const dh = fs.opendirSync(dir);
97
+ dh.closeSync();
98
+ }
99
+ return { dir, status: "ok" };
100
+ } catch (e) {
101
+ const err = e as NodeJS.ErrnoException;
102
+ if (err.code === "ENOENT") return { dir, status: "missing" };
103
+ return { dir, status: "denied", detail: err.code || err.message };
104
+ }
105
+ }
106
+
107
+ function displayPath(dir: string, home: string): string {
108
+ if (dir === home) return "~";
109
+ if (dir.startsWith(home + path.sep)) return "~" + dir.slice(home.length);
110
+ return dir;
111
+ }
112
+
113
+ function openFullDiskAccessSettings(): void {
114
+ spawn(
115
+ "open",
116
+ [
117
+ "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles",
118
+ ],
119
+ { detached: true, stdio: "ignore" },
120
+ ).unref();
121
+ }
122
+
123
+ /**
124
+ * Touch macOS TCC-protected folders so this Node binary gets Files and Folders
125
+ * prompts. Must be run at the Mac (GUI session); SSH will hang on unread dialogs.
126
+ */
127
+ export function runAuthorize(options: AuthorizeOptions = {}): void {
128
+ if (process.platform !== "darwin") {
129
+ console.log("非 macOS,无需磁盘授权。");
130
+ return;
131
+ }
132
+
133
+ const home = os.homedir();
134
+ const nodePath = nodeBinary();
135
+ const extra = options.extraDirs?.length ? options.extraDirs : extraDirsFromConfig();
136
+ const cloudRoot = path.resolve(path.join(home, "Library", "CloudStorage"));
137
+ const targets = uniqueExistingOrder([...standardTargets(home), ...extra]);
138
+ const openSettings = options.openSettings !== false;
139
+
140
+ console.log("macOS 磁盘授权");
141
+ console.log("会逐个访问受保护目录;弹出「node 想访问…」请全部点「允许」。");
142
+ console.log(`当前 Node: ${nodePath}`);
143
+ if (isRemoteSession()) {
144
+ console.log("检测到 SSH:弹窗会出现在这台 Mac 的屏幕上,没人点就会卡住。请在电脑前执行。");
145
+ }
146
+ console.log("");
147
+
148
+ const results: ProbeResult[] = [];
149
+ const seen = new Set(targets);
150
+ for (let i = 0; i < targets.length; i++) {
151
+ const dir = targets[i]!;
152
+ const label = displayPath(dir, home);
153
+ process.stdout.write(` ${label} … `);
154
+ const result = probe(dir);
155
+ results.push(result);
156
+ if (result.status === "ok") console.log("ok");
157
+ else if (result.status === "missing") console.log("(目录不存在,跳过)");
158
+ else console.log(`拒绝 (${result.detail})`);
159
+
160
+ if (result.status === "ok" && dir === cloudRoot) {
161
+ for (const child of listSubdirs(dir)) {
162
+ if (seen.has(child)) continue;
163
+ seen.add(child);
164
+ targets.push(child);
165
+ }
166
+ }
167
+ }
168
+
169
+ const denied = results.filter((r) => r.status === "denied");
170
+ const ok = results.filter((r) => r.status === "ok");
171
+ console.log("");
172
+ console.log(`完成:允许 ${ok.length},拒绝 ${denied.length},其余目录不存在。`);
173
+ if (denied.length) {
174
+ console.log("被拒绝的目录远程访问仍会卡住。再跑一次 sandy authorize,或到「文件和文件夹」里打开开关。");
175
+ }
176
+
177
+ console.log("");
178
+ console.log("建议再把这份 Node 加到「完全磁盘访问权限」(升级 Node 后要重新加):");
179
+ console.log(` ${nodePath}`);
180
+
181
+ if (openSettings) {
182
+ openFullDiskAccessSettings();
183
+ console.log("已打开系统设置 → 隐私与安全性 → 完全磁盘访问权限。");
184
+ }
185
+ }
package/src/cli.ts CHANGED
@@ -3,10 +3,21 @@ const subcommand = process.argv[2];
3
3
  if (subcommand === "init") {
4
4
  const { runInit } = await import("./init.js");
5
5
  await runInit(process.argv.slice(3));
6
+ } else if (subcommand === "authorize" || subcommand === "diskauth") {
7
+ const { runAuthorize } = await import("./authorize.js");
8
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
9
+ console.log(`Usage: sandy authorize
10
+
11
+ Touch macOS-protected folders so this Node binary gets Files and Folders prompts.
12
+ Run at the Mac (not over SSH). Alias: sandy diskauth`);
13
+ } else {
14
+ runAuthorize({ openSettings: true });
15
+ }
6
16
  } else if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
7
17
  console.log(`Usage:
8
- sandy Start the Feishu bot (reads ./config.yaml)
9
- sandy init Interactive setup — writes config.yaml`);
18
+ sandy Start the Feishu bot (reads ./config.yaml)
19
+ sandy init Interactive setup — writes config.yaml, then macOS disk auth
20
+ sandy authorize Trigger macOS folder-access prompts (alias: diskauth)`);
10
21
  } else {
11
22
  await import("./index.js");
12
23
  }
@@ -91,14 +91,6 @@ export const INIT_FIELD_GUIDES = {
91
91
  ],
92
92
  } satisfies FieldGuide,
93
93
 
94
- agentDirLinks: {
95
- title: "工作目录下的 symlink(可选)",
96
- lines: [
97
- "若 agent.cwd 里有 symlink(如 api → ~/code/api),填链接名以便一并放行。",
98
- "多个名称用英文逗号分隔;留空可跳过。",
99
- ],
100
- } satisfies FieldGuide,
101
-
102
94
  agentSandbox: {
103
95
  title: "Cursor 本地沙箱",
104
96
  lines: [
@@ -121,17 +113,5 @@ export const INIT_FIELD_GUIDES = {
121
113
  links: [{ label: "Cursor 模型与 SDK", url: "https://cursor.com/docs/sdk/typescript" }],
122
114
  } satisfies FieldGuide,
123
115
 
124
- feishuDocsFolder: {
125
- title: "飞书文档默认文件夹(可选)",
126
- lines: [
127
- "feishu_doc_create 创建文档时默认放到哪个云空间文件夹。",
128
- "在飞书云文档打开目标文件夹,从 URL 或文件夹属性里复制 folder_token。",
129
- "不需要自动归档文档可留空。",
130
- ],
131
- links: [
132
- { label: "创建文档 API", url: "https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document/create" },
133
- ],
134
- } satisfies FieldGuide,
135
-
136
116
  feishuAppHome: FEISHU_APP_HOME,
137
117
  } as const;
package/src/init.ts CHANGED
@@ -100,13 +100,6 @@ export async function runInit(argv: string[] = []): Promise<void> {
100
100
  );
101
101
  cfg.agent!.dirs = dirsRaw ? splitList(dirsRaw) : [];
102
102
 
103
- const linksRaw = await prompt.askWithGuide(
104
- INIT_FIELD_GUIDES.agentDirLinks,
105
- "symlink 名(逗号分隔,可留空)",
106
- { defaultValue: "" },
107
- );
108
- cfg.agent!.dirLinks = linksRaw ? splitList(linksRaw) : [];
109
-
110
103
  cfg.agent!.sandbox = await prompt.askYesNoWithGuide(
111
104
  INIT_FIELD_GUIDES.agentSandbox,
112
105
  "开启本地沙箱?",
@@ -117,15 +110,23 @@ export async function runInit(argv: string[] = []): Promise<void> {
117
110
  "模型 id",
118
111
  { defaultValue: "auto" },
119
112
  );
120
- cfg.feishuDocsFolder = await prompt.askWithGuide(
121
- INIT_FIELD_GUIDES.feishuDocsFolder,
122
- "folder_token(可留空)",
123
- { defaultValue: "" },
124
- );
125
113
 
126
114
  writeConfigFile(outPath, cfg);
127
115
 
128
116
  console.log("\n✓ 已写入 " + outPath);
117
+
118
+ if (process.platform === "darwin") {
119
+ const { runAuthorize } = await import("./authorize.js");
120
+ prompt.section("四、macOS 磁盘授权");
121
+ console.log(" 弹出「node 想访问…」请全部点「允许」。远程 SSH 时弹窗在本机屏幕上。\n");
122
+ const extraDirs = [
123
+ expandPath(cfg.agent!.cwd || targetDir, targetDir),
124
+ ...(cfg.agent!.dirs ?? []).map((d) => expandPath(d, targetDir)),
125
+ targetDir,
126
+ ];
127
+ runAuthorize({ extraDirs, openSettings: true });
128
+ }
129
+
129
130
  console.log("\n下一步:");
130
131
  console.log(` cd ${targetDir}`);
131
132
  console.log(" sandy");