@vitex/nook 0.0.0-stage → 0.1.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 +248 -2
- package/bin/nook.js +13 -0
- package/dist/frontend/assets/index-BjWGt3e9.css +1 -0
- package/dist/frontend/assets/index-Cu74B6FQ.js +38 -0
- package/dist/frontend/icon.svg +8 -0
- package/dist/frontend/icons/apple-touch-icon.png +0 -0
- package/dist/frontend/icons/icon-192.png +0 -0
- package/dist/frontend/icons/icon-512.png +0 -0
- package/dist/frontend/icons/maskable-192.png +0 -0
- package/dist/frontend/icons/maskable-512.png +0 -0
- package/dist/frontend/index.html +24 -0
- package/dist/frontend/manifest.webmanifest +49 -0
- package/dist/frontend/sw.js +83 -0
- package/package.json +37 -4
- package/src/backend/agent/pool.js +229 -0
- package/src/backend/agent/runtime.js +17 -0
- package/src/backend/agent/serialize.js +180 -0
- package/src/backend/agent/tools.js +34 -0
- package/src/backend/cli.js +237 -0
- package/src/backend/config.js +69 -0
- package/src/backend/fs-browser.js +121 -0
- package/src/backend/index.js +3 -0
- package/src/backend/logging.js +9 -0
- package/src/backend/net.js +43 -0
- package/src/backend/package.json +15 -0
- package/src/backend/projects.js +89 -0
- package/src/backend/server.js +433 -0
- package/src/backend/service.js +337 -0
- package/src/backend/sessions.js +187 -0
- package/src/backend/text.js +4 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nook CLI
|
|
3
|
+
*
|
|
4
|
+
* nook start 前台启动 Web 服务
|
|
5
|
+
* nook status 查看运行状态
|
|
6
|
+
* nook service launchd 服务管理
|
|
7
|
+
*/
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_HOST,
|
|
12
|
+
DEFAULT_PORT,
|
|
13
|
+
ensureHome,
|
|
14
|
+
getHomeDir,
|
|
15
|
+
getHost,
|
|
16
|
+
getLogsDir,
|
|
17
|
+
getPort,
|
|
18
|
+
} from './config.js';
|
|
19
|
+
import { listProjects } from './projects.js';
|
|
20
|
+
import { accessUrls } from './net.js';
|
|
21
|
+
import { log, logErr } from './logging.js';
|
|
22
|
+
import { startServer } from './server.js';
|
|
23
|
+
import {
|
|
24
|
+
installService,
|
|
25
|
+
restartService,
|
|
26
|
+
serviceStatus,
|
|
27
|
+
startService,
|
|
28
|
+
stopService,
|
|
29
|
+
uninstallService,
|
|
30
|
+
} from './service.js';
|
|
31
|
+
|
|
32
|
+
const VERSION = '0.1.0';
|
|
33
|
+
|
|
34
|
+
const HELP = `Nook ${VERSION}
|
|
35
|
+
给 Pi 用的轻量 Web 界面
|
|
36
|
+
|
|
37
|
+
用法:
|
|
38
|
+
nook start [--host ${DEFAULT_HOST}] [--port ${DEFAULT_PORT}]
|
|
39
|
+
nook status
|
|
40
|
+
nook service install
|
|
41
|
+
nook service uninstall
|
|
42
|
+
nook service start
|
|
43
|
+
nook service stop
|
|
44
|
+
nook service restart
|
|
45
|
+
nook service status
|
|
46
|
+
nook help
|
|
47
|
+
|
|
48
|
+
说明:
|
|
49
|
+
只保存项目索引 ${path.join(getHomeDir(), 'projects.json')}
|
|
50
|
+
日志目录 ${getLogsDir()}
|
|
51
|
+
Pi 配置与会话沿用 Pi 自己的设置(~/.pi)
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
function parseArgs(argv) {
|
|
55
|
+
const flags = {};
|
|
56
|
+
const positional = [];
|
|
57
|
+
for (let i = 0; i < argv.length; i++) {
|
|
58
|
+
const arg = argv[i];
|
|
59
|
+
if (arg === '--') { positional.push(...argv.slice(i + 1)); break; }
|
|
60
|
+
if (arg.startsWith('--')) {
|
|
61
|
+
const eq = arg.indexOf('=');
|
|
62
|
+
if (eq !== -1) {
|
|
63
|
+
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
64
|
+
} else {
|
|
65
|
+
const key = arg.slice(2);
|
|
66
|
+
const next = argv[i + 1];
|
|
67
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
68
|
+
flags[key] = next;
|
|
69
|
+
i++;
|
|
70
|
+
} else {
|
|
71
|
+
flags[key] = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
positional.push(arg);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { flags, positional };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function applyFlags(flags) {
|
|
82
|
+
if (flags.host) process.env.NOOK_HOST = String(flags.host);
|
|
83
|
+
if (flags.port) process.env.NOOK_PORT = String(flags.port);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function printStartup({ port, host }) {
|
|
87
|
+
const urls = accessUrls(port, host);
|
|
88
|
+
const lines = [
|
|
89
|
+
`Nook ${VERSION}`,
|
|
90
|
+
` 监听 ${host}:${port}`,
|
|
91
|
+
` 本机 ${urls.local}`,
|
|
92
|
+
];
|
|
93
|
+
for (const url of urls.network) lines.push(` 网络 ${url}`);
|
|
94
|
+
console.log(lines.join('\n'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function startCmd() {
|
|
98
|
+
ensureHome();
|
|
99
|
+
const port = getPort();
|
|
100
|
+
const host = getHost();
|
|
101
|
+
let started;
|
|
102
|
+
try {
|
|
103
|
+
started = await startServer({ port, host });
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (e?.code === 'EADDRINUSE') {
|
|
106
|
+
logErr(`端口 ${port} 已被占用。可以换一个端口:nook start --port 4318`);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
if (e?.code === 'EADDRNOTAVAIL') {
|
|
110
|
+
logErr(`无法监听 ${host}:${port},请检查 --host 是否是本机网卡地址`);
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
throw e;
|
|
114
|
+
}
|
|
115
|
+
printStartup({ port: started.port, host: started.host });
|
|
116
|
+
log('服务已启动,等待访问…');
|
|
117
|
+
|
|
118
|
+
let closing = false;
|
|
119
|
+
const shutdown = async (signal) => {
|
|
120
|
+
if (closing) return;
|
|
121
|
+
closing = true;
|
|
122
|
+
log(`收到 ${signal},正在退出…`);
|
|
123
|
+
try { started.server.close(); } catch { }
|
|
124
|
+
setTimeout(() => process.exit(0), 100).unref();
|
|
125
|
+
};
|
|
126
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
127
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
128
|
+
await new Promise(() => { });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function statusCmd() {
|
|
132
|
+
const service = serviceStatus();
|
|
133
|
+
const port = service.running && service.port ? service.port : getPort();
|
|
134
|
+
const host = service.running && service.host ? service.host : getHost();
|
|
135
|
+
const urls = accessUrls(port, host);
|
|
136
|
+
const projects = listProjects();
|
|
137
|
+
const url = urls.network[0] || urls.local;
|
|
138
|
+
const logs = service.manager === 'systemd' ? `journalctl --user -u ${service.label}` : getLogsDir();
|
|
139
|
+
|
|
140
|
+
console.log(`Nook ${VERSION}`);
|
|
141
|
+
console.log(`地址 ${url}`);
|
|
142
|
+
console.log(`状态 ${service.installed ? (service.running ? 'running' : 'stopped') : 'not-installed'}`);
|
|
143
|
+
console.log(`项目 ${path.join(getHomeDir(), 'projects.json')}(${projects.length} 个)`);
|
|
144
|
+
console.log(`日志 ${logs}`);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function serviceCmd(sub) {
|
|
149
|
+
const port = getPort();
|
|
150
|
+
const host = getHost();
|
|
151
|
+
switch (sub || 'status') {
|
|
152
|
+
case 'install': {
|
|
153
|
+
installService({ port, host });
|
|
154
|
+
const urls = accessUrls(port, host);
|
|
155
|
+
log(` 监听 ${host}:${port}`);
|
|
156
|
+
log(` 本机 ${urls.local}`);
|
|
157
|
+
for (const url of urls.network) log(` 网络 ${url}`);
|
|
158
|
+
log(' 提示 修改代码后运行 nook service restart');
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
case 'uninstall':
|
|
162
|
+
uninstallService();
|
|
163
|
+
return 0;
|
|
164
|
+
case 'start':
|
|
165
|
+
startService();
|
|
166
|
+
return 0;
|
|
167
|
+
case 'stop':
|
|
168
|
+
stopService();
|
|
169
|
+
return 0;
|
|
170
|
+
case 'restart':
|
|
171
|
+
restartService();
|
|
172
|
+
return 0;
|
|
173
|
+
case 'status': {
|
|
174
|
+
const s = serviceStatus();
|
|
175
|
+
console.log(`服务 ${s.installed ? (s.running ? 'running' : 'stopped') : 'not-installed'}`);
|
|
176
|
+
if (s.host && s.port) console.log(`监听 ${s.host}:${s.port}`);
|
|
177
|
+
console.log(`管理器 ${s.manager}`);
|
|
178
|
+
console.log(`Label ${s.label}`);
|
|
179
|
+
console.log(`文件 ${s.file || '-'}`);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
default:
|
|
183
|
+
logErr('未知 service 子命令:' + sub);
|
|
184
|
+
console.log(`用法:
|
|
185
|
+
nook service install
|
|
186
|
+
nook service uninstall
|
|
187
|
+
nook service start
|
|
188
|
+
nook service stop
|
|
189
|
+
nook service restart
|
|
190
|
+
nook service status`);
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
196
|
+
const { flags, positional } = parseArgs(argv);
|
|
197
|
+
applyFlags(flags);
|
|
198
|
+
|
|
199
|
+
const cmd = flags.version ? 'version' : (positional[0] || 'help');
|
|
200
|
+
switch (cmd) {
|
|
201
|
+
case 'start':
|
|
202
|
+
case 'run':
|
|
203
|
+
return startCmd();
|
|
204
|
+
case 'status':
|
|
205
|
+
return statusCmd();
|
|
206
|
+
case 'service':
|
|
207
|
+
case 'daemon':
|
|
208
|
+
return serviceCmd(positional[1]);
|
|
209
|
+
case 'version':
|
|
210
|
+
case '-v':
|
|
211
|
+
case '--version':
|
|
212
|
+
console.log(VERSION);
|
|
213
|
+
return 0;
|
|
214
|
+
case 'help':
|
|
215
|
+
case '-h':
|
|
216
|
+
case '--help':
|
|
217
|
+
case undefined:
|
|
218
|
+
console.log(HELP);
|
|
219
|
+
return 0;
|
|
220
|
+
default:
|
|
221
|
+
logErr('未知命令:' + cmd);
|
|
222
|
+
console.log(HELP);
|
|
223
|
+
return 1;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const isDirectRun = process.argv[1]
|
|
228
|
+
&& path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
229
|
+
|
|
230
|
+
if (isDirectRun) {
|
|
231
|
+
main(process.argv.slice(2)).then((code) => {
|
|
232
|
+
process.exitCode = code ?? 0;
|
|
233
|
+
}).catch((err) => {
|
|
234
|
+
console.error(err?.stack || String(err));
|
|
235
|
+
process.exitCode = 1;
|
|
236
|
+
});
|
|
237
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nook 路径与运行参数。
|
|
3
|
+
*
|
|
4
|
+
* Nook 只保存自己管理的项目目录索引,不记录任何模型或服务配置。
|
|
5
|
+
* 所有运行期数据都放在 ~/.nook(可用 NOOK_HOME 覆盖):
|
|
6
|
+
* projects.json 项目索引
|
|
7
|
+
* logs/ macOS 服务日志
|
|
8
|
+
*
|
|
9
|
+
* Pi 配置与会话仍由 Pi 自己保存在 ~/.pi。
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
export const BACKEND_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
export const PROJECT_DIR = path.resolve(BACKEND_DIR, '..', '..');
|
|
18
|
+
export const DEFAULT_PORT = 4317;
|
|
19
|
+
export const DEFAULT_HOST = '127.0.0.1';
|
|
20
|
+
|
|
21
|
+
export function getHomeDir() {
|
|
22
|
+
return process.env.NOOK_HOME ? path.resolve(process.env.NOOK_HOME) : path.join(os.homedir(), '.nook');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getProjectsPath() {
|
|
26
|
+
return path.join(getHomeDir(), 'projects.json');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getLogsDir() {
|
|
30
|
+
return path.join(getHomeDir(), 'logs');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getWebDir() {
|
|
34
|
+
const candidates = [
|
|
35
|
+
process.env.NOOK_WEB_DIR,
|
|
36
|
+
path.join(PROJECT_DIR, 'dist', 'frontend'),
|
|
37
|
+
path.join(PROJECT_DIR, 'src', 'frontend', 'dist'),
|
|
38
|
+
].filter(Boolean);
|
|
39
|
+
for (const dir of candidates) {
|
|
40
|
+
try {
|
|
41
|
+
if (fs.statSync(path.join(dir, 'index.html')).isFile()) return dir;
|
|
42
|
+
} catch { }
|
|
43
|
+
}
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ensureHome() {
|
|
48
|
+
fs.mkdirSync(getHomeDir(), { recursive: true });
|
|
49
|
+
fs.mkdirSync(getLogsDir(), { recursive: true });
|
|
50
|
+
return getHomeDir();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ensureDir(dir) {
|
|
54
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
55
|
+
return dir;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getNodeBin() {
|
|
59
|
+
return process.execPath;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getPort() {
|
|
63
|
+
const n = Number(process.env.NOOK_PORT || process.env.PORT || DEFAULT_PORT);
|
|
64
|
+
return Number.isFinite(n) && n > 0 && n < 65536 ? n : DEFAULT_PORT;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getHost() {
|
|
68
|
+
return process.env.NOOK_HOST || DEFAULT_HOST;
|
|
69
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 网页端目录选择器用的只读目录 API。
|
|
3
|
+
*
|
|
4
|
+
* 全部使用异步 fs:macOS 在后台进程访问「桌面/文稿/下载」时可能弹出权限框,
|
|
5
|
+
* 同步 fs 会把整个 Node 事件循环卡死;异步 + 超时至少保证 Web 服务本身还能响应。
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const MAX_ENTRIES = 500;
|
|
12
|
+
const FS_TIMEOUT_MS = 2500;
|
|
13
|
+
|
|
14
|
+
function expandHome(input) {
|
|
15
|
+
const s = String(input || '').trim();
|
|
16
|
+
if (!s || s === '~') return os.homedir();
|
|
17
|
+
if (s.startsWith('~/')) return path.join(os.homedir(), s.slice(2));
|
|
18
|
+
return s;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function protectedRoots() {
|
|
22
|
+
const home = os.homedir();
|
|
23
|
+
return [path.join(home, 'Desktop'), path.join(home, 'Documents'), path.join(home, 'Downloads')];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isService() {
|
|
27
|
+
return process.env.NOOK_SERVICE === '1';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function assertAccessible(target) {
|
|
31
|
+
if (process.platform !== 'darwin') return;
|
|
32
|
+
if (!isService() || process.env.NOOK_ALLOW_PROTECTED === '1') return;
|
|
33
|
+
if (protectedRoots().some((root) => target === root || target.startsWith(root + path.sep))) {
|
|
34
|
+
throw new Error('后台服务访问不了桌面/文稿/下载(macOS 隐私保护)。可改用 nook start 前台运行,或授权后设置 NOOK_ALLOW_PROTECTED=1');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function withTimeout(promise, message = '读取超时(可能触发了 macOS 权限提示)') {
|
|
39
|
+
return Promise.race([
|
|
40
|
+
promise,
|
|
41
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(message)), FS_TIMEOUT_MS)),
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function listDirectories(inputPath) {
|
|
46
|
+
const target = path.resolve(expandHome(inputPath));
|
|
47
|
+
assertAccessible(target);
|
|
48
|
+
let stat;
|
|
49
|
+
try {
|
|
50
|
+
stat = await withTimeout(fs.stat(target));
|
|
51
|
+
} catch (e) {
|
|
52
|
+
if (e?.message?.includes('超时')) throw e;
|
|
53
|
+
throw new Error('目录不存在');
|
|
54
|
+
}
|
|
55
|
+
if (!stat.isDirectory()) throw new Error('不是目录');
|
|
56
|
+
|
|
57
|
+
let entries;
|
|
58
|
+
try {
|
|
59
|
+
entries = (await withTimeout(fs.readdir(target, { withFileTypes: true })))
|
|
60
|
+
.filter((d) => !d.name.startsWith('.') && d.isDirectory())
|
|
61
|
+
.map((d) => ({ name: d.name, path: path.join(target, d.name) }))
|
|
62
|
+
.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN', { numeric: true }));
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (e?.message?.includes('超时')) throw e;
|
|
65
|
+
throw new Error('没有权限读取这个目录');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let writable = false;
|
|
69
|
+
try {
|
|
70
|
+
await withTimeout(fs.access(target, fs.constants.W_OK));
|
|
71
|
+
writable = true;
|
|
72
|
+
} catch { }
|
|
73
|
+
|
|
74
|
+
const truncated = entries.length > MAX_ENTRIES;
|
|
75
|
+
if (truncated) entries = entries.slice(0, MAX_ENTRIES);
|
|
76
|
+
const parent = path.dirname(target);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
path: target,
|
|
80
|
+
parent: parent === target ? null : parent,
|
|
81
|
+
home: os.homedir(),
|
|
82
|
+
dirs: entries,
|
|
83
|
+
writable,
|
|
84
|
+
truncated,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function createDirectory(parentInput, name) {
|
|
89
|
+
const parent = path.resolve(expandHome(parentInput));
|
|
90
|
+
assertAccessible(parent);
|
|
91
|
+
const clean = String(name || '').trim();
|
|
92
|
+
if (!clean) throw new Error('请输入文件夹名称');
|
|
93
|
+
if (clean === '.' || clean === '..' || clean.startsWith('.')) throw new Error('名称不能以点开头');
|
|
94
|
+
if (/[\/\\\u0000]/.test(clean)) throw new Error('名称不能包含斜杠');
|
|
95
|
+
if (clean.length > 80) throw new Error('名称过长');
|
|
96
|
+
|
|
97
|
+
let stat;
|
|
98
|
+
try {
|
|
99
|
+
stat = await withTimeout(fs.stat(parent));
|
|
100
|
+
} catch {
|
|
101
|
+
throw new Error('上级目录不存在');
|
|
102
|
+
}
|
|
103
|
+
if (!stat.isDirectory()) throw new Error('上级路径不是目录');
|
|
104
|
+
|
|
105
|
+
const target = path.join(parent, clean);
|
|
106
|
+
try {
|
|
107
|
+
await withTimeout(fs.stat(target));
|
|
108
|
+
throw new Error('同名目录已存在');
|
|
109
|
+
} catch (e) {
|
|
110
|
+
if (e?.message === '同名目录已存在') throw e;
|
|
111
|
+
if (e?.message?.includes('超时')) throw e;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await withTimeout(fs.mkdir(target));
|
|
116
|
+
} catch (e) {
|
|
117
|
+
if (e?.message?.includes('超时')) throw e;
|
|
118
|
+
throw new Error('没有权限在这里新建目录');
|
|
119
|
+
}
|
|
120
|
+
return { path: target };
|
|
121
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const ts = () => new Date().toISOString();
|
|
2
|
+
|
|
3
|
+
export const log = (...args) => console.log(ts(), ...args);
|
|
4
|
+
export const logErr = (...args) => console.error(ts(), ...args);
|
|
5
|
+
|
|
6
|
+
export function shortId(id, length = 12) {
|
|
7
|
+
const s = String(id ?? '');
|
|
8
|
+
return s.length > length ? s.slice(0, length) + '…' : s;
|
|
9
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通用网络地址工具。
|
|
3
|
+
*
|
|
4
|
+
* Nook 不依赖任何特定网络服务;只根据传入的 host / port 监听,
|
|
5
|
+
* 并在 CLI 里展示本机和各网卡对应的访问地址。
|
|
6
|
+
*/
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
|
|
9
|
+
export function isLoopback(host) {
|
|
10
|
+
return !host || host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isWildcard(host) {
|
|
14
|
+
return !host || host === '0.0.0.0' || host === '::';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function localUrl(port) {
|
|
18
|
+
return `http://127.0.0.1:${port}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function networkAddresses() {
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const infos of Object.values(os.networkInterfaces())) {
|
|
24
|
+
for (const info of infos || []) {
|
|
25
|
+
if (info.family === 'IPv4' && !info.internal) out.push(info.address);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return [...new Set(out)];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 给 CLI / API 用的访问地址集合 */
|
|
32
|
+
export function accessUrls(port, host = '127.0.0.1') {
|
|
33
|
+
const local = localUrl(port);
|
|
34
|
+
const network = [];
|
|
35
|
+
|
|
36
|
+
if (isWildcard(host)) {
|
|
37
|
+
for (const ip of networkAddresses()) network.push(`http://${ip}:${port}`);
|
|
38
|
+
} else if (!isLoopback(host)) {
|
|
39
|
+
network.push(`http://${host}:${port}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { local, network };
|
|
43
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nook/backend",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"dev": "node cli.js start --host 127.0.0.1",
|
|
9
|
+
"start": "node cli.js start"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@earendil-works/pi-coding-agent": "0.86.1",
|
|
13
|
+
"typebox": "1.3.27"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 项目索引:每个项目就是一个 Pi 工作目录(cwd)。
|
|
3
|
+
* 项目只记录名字与路径,不复制、不移动用户目录里的文件。
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { ensureHome, getProjectsPath } from './config.js';
|
|
10
|
+
|
|
11
|
+
function load() {
|
|
12
|
+
ensureHome();
|
|
13
|
+
const file = getProjectsPath();
|
|
14
|
+
if (!fs.existsSync(file)) return { version: 1, projects: [] };
|
|
15
|
+
try {
|
|
16
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
17
|
+
return { version: 1, projects: Array.isArray(data.projects) ? data.projects : [] };
|
|
18
|
+
} catch {
|
|
19
|
+
return { version: 1, projects: [] };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function save(data) {
|
|
24
|
+
ensureHome();
|
|
25
|
+
const file = getProjectsPath();
|
|
26
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
27
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
28
|
+
fs.renameSync(tmp, file);
|
|
29
|
+
try { fs.chmodSync(file, 0o600); } catch { }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function expandHome(p) {
|
|
33
|
+
const s = String(p || '').trim();
|
|
34
|
+
if (s === '~') return os.homedir();
|
|
35
|
+
if (s.startsWith('~/')) return path.join(os.homedir(), s.slice(2));
|
|
36
|
+
return s;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function listProjects() {
|
|
40
|
+
return load().projects
|
|
41
|
+
.slice()
|
|
42
|
+
.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getProject(id) {
|
|
46
|
+
return load().projects.find((p) => p.id === id) || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createProject({ name, path: inputPath }) {
|
|
50
|
+
const label = String(name || '').trim();
|
|
51
|
+
if (!label) throw new Error('请输入项目名称');
|
|
52
|
+
if (label.length > 80) throw new Error('项目名称过长');
|
|
53
|
+
if (!inputPath || !String(inputPath).trim()) throw new Error('请选择项目目录');
|
|
54
|
+
|
|
55
|
+
const data = load();
|
|
56
|
+
const raw = expandHome(inputPath);
|
|
57
|
+
const dir = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(os.homedir(), raw);
|
|
58
|
+
|
|
59
|
+
if (fs.existsSync(dir) && !fs.statSync(dir).isDirectory()) {
|
|
60
|
+
throw new Error('这个路径不是目录');
|
|
61
|
+
}
|
|
62
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
63
|
+
|
|
64
|
+
const duplicate = data.projects.find((p) => p.path === dir);
|
|
65
|
+
if (duplicate) {
|
|
66
|
+
const err = new Error(`这个目录已经是项目「${duplicate.name}」`);
|
|
67
|
+
err.code = 'DUPLICATE';
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const project = {
|
|
72
|
+
id: crypto.randomUUID(),
|
|
73
|
+
name: label,
|
|
74
|
+
path: dir,
|
|
75
|
+
createdAt: new Date().toISOString(),
|
|
76
|
+
};
|
|
77
|
+
data.projects.push(project);
|
|
78
|
+
save(data);
|
|
79
|
+
return project;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function removeProject(id) {
|
|
83
|
+
const data = load();
|
|
84
|
+
const index = data.projects.findIndex((p) => p.id === id);
|
|
85
|
+
if (index === -1) return null;
|
|
86
|
+
const [removed] = data.projects.splice(index, 1);
|
|
87
|
+
save(data);
|
|
88
|
+
return removed;
|
|
89
|
+
}
|