u1s1-cli 0.9.3 → 0.10.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/dist/deploy.js +235 -0
- package/dist/index.js +8 -1
- package/package.json +8 -1
package/dist/deploy.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { VERSION, u1s1Dir } from "./config.js";
|
|
5
|
+
/**
|
|
6
|
+
* u1s1 deploy:把静态网页一键发布到 <name>.u1s1.app。
|
|
7
|
+
* 检测项目里的静态站点根目录 → 首次询问子域名(记在 ~/.u1s1/deploys.json)
|
|
8
|
+
* → 并发上传 → 网关原子切换生效,输出可分享的网址。
|
|
9
|
+
*/
|
|
10
|
+
const deploysFile = join(u1s1Dir, "deploys.json");
|
|
11
|
+
/** 构建产物目录优先:Vite/Next 等项目根的 index.html 是源码,不是能直接上线的产物。 */
|
|
12
|
+
const BUILD_DIRS = ["dist", "build", "out", "_site", "public"];
|
|
13
|
+
const SKIP_DIRS = new Set(["node_modules", "__pycache__"]);
|
|
14
|
+
function authHeaders(apiKey) {
|
|
15
|
+
return { authorization: `Bearer ${apiKey}`, "x-u1s1-version": VERSION };
|
|
16
|
+
}
|
|
17
|
+
function readDeploys() {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(readFileSync(deploysFile, "utf8"));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function rememberSite(dir, site) {
|
|
26
|
+
const all = readDeploys();
|
|
27
|
+
all[dir] = site;
|
|
28
|
+
writeFileSync(deploysFile, JSON.stringify(all, null, 2) + "\n");
|
|
29
|
+
}
|
|
30
|
+
/** 找要部署的目录:显式参数 > 含 index.html 的构建产物目录 > 当前目录本身。 */
|
|
31
|
+
function resolveSiteDir(explicit) {
|
|
32
|
+
if (explicit) {
|
|
33
|
+
const dir = resolve(explicit);
|
|
34
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
|
35
|
+
throw new Error(`目录不存在:${dir}`);
|
|
36
|
+
}
|
|
37
|
+
if (!existsSync(join(dir, "index.html"))) {
|
|
38
|
+
throw new Error(`${dir} 里没有 index.html,网站需要一个首页`);
|
|
39
|
+
}
|
|
40
|
+
return dir;
|
|
41
|
+
}
|
|
42
|
+
const cwd = process.cwd();
|
|
43
|
+
for (const sub of BUILD_DIRS) {
|
|
44
|
+
if (existsSync(join(cwd, sub, "index.html")))
|
|
45
|
+
return join(cwd, sub);
|
|
46
|
+
}
|
|
47
|
+
if (existsSync(join(cwd, "index.html")))
|
|
48
|
+
return cwd;
|
|
49
|
+
throw new Error("这里找不到能发布的网页(index.html)。\n" +
|
|
50
|
+
" 在网站目录里运行 u1s1 deploy,或指定目录:u1s1 deploy <目录>\n" +
|
|
51
|
+
" 如果项目需要构建(如 Vite/Next),先跑构建再部署 dist/ 等产物目录");
|
|
52
|
+
}
|
|
53
|
+
function collectFiles(root) {
|
|
54
|
+
const files = [];
|
|
55
|
+
const walk = (dir) => {
|
|
56
|
+
for (const name of readdirSync(dir)) {
|
|
57
|
+
if (name.startsWith(".") || SKIP_DIRS.has(name))
|
|
58
|
+
continue;
|
|
59
|
+
const abs = join(dir, name);
|
|
60
|
+
const st = statSync(abs);
|
|
61
|
+
if (st.isDirectory())
|
|
62
|
+
walk(abs);
|
|
63
|
+
else if (st.isFile()) {
|
|
64
|
+
files.push({ path: relative(root, abs).split(sep).join("/"), abs, bytes: st.size });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
walk(root);
|
|
69
|
+
return files;
|
|
70
|
+
}
|
|
71
|
+
/** 从目录名生成默认子域名。 */
|
|
72
|
+
function slugify(name) {
|
|
73
|
+
const slug = name
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
76
|
+
.replace(/-{2,}/g, "-")
|
|
77
|
+
.replace(/^-+|-+$/g, "")
|
|
78
|
+
.slice(0, 30)
|
|
79
|
+
.replace(/^-+|-+$/g, "");
|
|
80
|
+
return slug.length >= 3 ? slug : `site-${Math.random().toString(36).slice(2, 6)}`;
|
|
81
|
+
}
|
|
82
|
+
function fmtBytes(n) {
|
|
83
|
+
if (n >= 1024 * 1024)
|
|
84
|
+
return `${(n / 1024 / 1024).toFixed(1)}MB`;
|
|
85
|
+
if (n >= 1024)
|
|
86
|
+
return `${Math.round(n / 1024)}KB`;
|
|
87
|
+
return `${n}B`;
|
|
88
|
+
}
|
|
89
|
+
async function api(cfg, method, path, body) {
|
|
90
|
+
let resp;
|
|
91
|
+
try {
|
|
92
|
+
resp = await fetch(`${cfg.baseUrl}${path}`, {
|
|
93
|
+
method,
|
|
94
|
+
headers: { ...authHeaders(cfg.apiKey), "content-type": "application/json" },
|
|
95
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`);
|
|
100
|
+
}
|
|
101
|
+
const data = (await resp.json().catch(() => null));
|
|
102
|
+
if (!resp.ok) {
|
|
103
|
+
const e = new Error(data?.error?.message ?? `服务端返回 ${resp.status},稍后再试`);
|
|
104
|
+
e.code = data?.error?.code;
|
|
105
|
+
throw e;
|
|
106
|
+
}
|
|
107
|
+
return data;
|
|
108
|
+
}
|
|
109
|
+
/** 逐个上传,失败重试一次;并发数保守取 6。 */
|
|
110
|
+
async function uploadAll(cfg, start, files) {
|
|
111
|
+
let done = 0;
|
|
112
|
+
const queue = [...files];
|
|
113
|
+
const uploadOne = async (f) => {
|
|
114
|
+
const qs = new URLSearchParams({ site: start.site, deploy_id: start.deploy_id, path: f.path });
|
|
115
|
+
const put = async () => fetch(`${cfg.baseUrl}/deploy/file?${qs}`, {
|
|
116
|
+
method: "PUT",
|
|
117
|
+
headers: { ...authHeaders(cfg.apiKey), "content-type": "application/octet-stream" },
|
|
118
|
+
body: readFileSync(f.abs),
|
|
119
|
+
});
|
|
120
|
+
let resp = await put().catch(() => null);
|
|
121
|
+
if (!resp?.ok)
|
|
122
|
+
resp = await put().catch(() => null);
|
|
123
|
+
if (!resp?.ok) {
|
|
124
|
+
const body = resp ? (await resp.json().catch(() => null)) : null;
|
|
125
|
+
throw new Error(`上传 ${f.path} 失败:${body?.error?.message ?? "网络错误"}`);
|
|
126
|
+
}
|
|
127
|
+
done++;
|
|
128
|
+
process.stdout.write(`\r 上传中 ${done}/${files.length} ${f.path.slice(0, 48).padEnd(48)}`);
|
|
129
|
+
};
|
|
130
|
+
const workers = Array.from({ length: Math.min(6, queue.length) }, async () => {
|
|
131
|
+
for (let f = queue.shift(); f; f = queue.shift())
|
|
132
|
+
await uploadOne(f);
|
|
133
|
+
});
|
|
134
|
+
await Promise.all(workers);
|
|
135
|
+
process.stdout.write("\r" + " ".repeat(70) + "\r");
|
|
136
|
+
}
|
|
137
|
+
async function promptSiteName(def) {
|
|
138
|
+
if (!process.stdin.isTTY)
|
|
139
|
+
return def;
|
|
140
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
141
|
+
try {
|
|
142
|
+
const answer = (await rl.question(` 站点子域名(回车用 ${def}):`)).trim().toLowerCase();
|
|
143
|
+
return answer || def;
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
rl.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export async function deployCommand(cfg, args) {
|
|
150
|
+
// 参数:[dir] [--name xxx];u1s1 deploy list 列出已有站点
|
|
151
|
+
if (args[0] === "list") {
|
|
152
|
+
const { sites } = await api(cfg, "GET", "/deploy/sites");
|
|
153
|
+
if (!sites.length) {
|
|
154
|
+
console.log(" 还没有部署过站点。在网页目录里跑 u1s1 deploy 试试。");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
console.log("");
|
|
158
|
+
for (const s of sites) {
|
|
159
|
+
console.log(` ${s.deployed ? "●" : "○"} ${s.url} ${fmtBytes(s.total_bytes)} · ${s.updated_at} UTC`);
|
|
160
|
+
}
|
|
161
|
+
console.log("");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
let name;
|
|
165
|
+
let dirArg;
|
|
166
|
+
for (let i = 0; i < args.length; i++) {
|
|
167
|
+
const a = args[i];
|
|
168
|
+
if (a === "--name" || a === "-n")
|
|
169
|
+
name = args[++i]?.toLowerCase();
|
|
170
|
+
else if (a.startsWith("--name="))
|
|
171
|
+
name = a.slice(7).toLowerCase();
|
|
172
|
+
else if (!a.startsWith("-"))
|
|
173
|
+
dirArg = a;
|
|
174
|
+
}
|
|
175
|
+
const dir = resolveSiteDir(dirArg);
|
|
176
|
+
const files = collectFiles(dir);
|
|
177
|
+
if (!files.some((f) => f.path === "index.html")) {
|
|
178
|
+
throw new Error(`${dir} 里没有 index.html,网站需要一个首页`);
|
|
179
|
+
}
|
|
180
|
+
const totalBytes = files.reduce((s, f) => s + f.bytes, 0);
|
|
181
|
+
console.log("");
|
|
182
|
+
console.log(` 部署目录 ${dir}`);
|
|
183
|
+
console.log(` 文件 ${files.length} 个,共 ${fmtBytes(totalBytes)}`);
|
|
184
|
+
// 站点名:--name > 上次用过的 > 交互询问(默认目录名;dist 等产物目录用项目名)
|
|
185
|
+
const remembered = readDeploys()[dir];
|
|
186
|
+
if (!name && remembered)
|
|
187
|
+
name = remembered;
|
|
188
|
+
if (!name) {
|
|
189
|
+
const projectName = BUILD_DIRS.includes(basename(dir)) ? basename(resolve(dir, "..")) : basename(dir);
|
|
190
|
+
name = await promptSiteName(slugify(projectName));
|
|
191
|
+
}
|
|
192
|
+
let start;
|
|
193
|
+
for (let attempt = 0; !start; attempt++) {
|
|
194
|
+
try {
|
|
195
|
+
start = await api(cfg, "POST", "/deploy/start", { site: name });
|
|
196
|
+
}
|
|
197
|
+
catch (e) {
|
|
198
|
+
const code = e.code;
|
|
199
|
+
const retriable = code === "site_name_taken" || code === "invalid_site_name";
|
|
200
|
+
if (!retriable || attempt >= 3)
|
|
201
|
+
throw e;
|
|
202
|
+
console.log(` ${e.message}`);
|
|
203
|
+
if (!process.stdin.isTTY) {
|
|
204
|
+
// 非交互环境自动加后缀重试一次
|
|
205
|
+
if (attempt > 0)
|
|
206
|
+
throw e;
|
|
207
|
+
name = `${name.slice(0, 25)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
name = await promptSiteName(`${name.slice(0, 25)}-${Math.random().toString(36).slice(2, 6)}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const tooBig = files.filter((f) => f.bytes > start.limits.max_file_bytes);
|
|
215
|
+
if (tooBig.length) {
|
|
216
|
+
throw new Error(`这些文件超过单文件上限 ${fmtBytes(start.limits.max_file_bytes)}:\n` +
|
|
217
|
+
tooBig.map((f) => ` ${f.path}(${fmtBytes(f.bytes)})`).join("\n"));
|
|
218
|
+
}
|
|
219
|
+
if (files.length > start.limits.max_files || totalBytes > start.limits.max_total_bytes) {
|
|
220
|
+
throw new Error(`超出配额:最多 ${start.limits.max_files} 个文件 / ${fmtBytes(start.limits.max_total_bytes)}。` +
|
|
221
|
+
`当前 ${files.length} 个 / ${fmtBytes(totalBytes)}`);
|
|
222
|
+
}
|
|
223
|
+
await uploadAll(cfg, start, files);
|
|
224
|
+
const fin = await api(cfg, "POST", "/deploy/finish", {
|
|
225
|
+
site: start.site,
|
|
226
|
+
deploy_id: start.deploy_id,
|
|
227
|
+
});
|
|
228
|
+
rememberSite(dir, start.site);
|
|
229
|
+
console.log(` ✅ 部署完成,${fin.file_count} 个文件已上线`);
|
|
230
|
+
console.log("");
|
|
231
|
+
console.log(` 🌐 ${fin.url}`);
|
|
232
|
+
console.log("");
|
|
233
|
+
console.log(" 把网址发给朋友就能看。改完代码再跑一次 u1s1 deploy 即可更新。");
|
|
234
|
+
console.log("");
|
|
235
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -241,7 +241,7 @@ async function run() {
|
|
|
241
241
|
}
|
|
242
242
|
if (cmd === "--help" || cmd === "-h") {
|
|
243
243
|
printConsoleBanner(VERSION);
|
|
244
|
-
console.log(" u1s1 命令:web(浏览器网页版)· web shortcut(桌面图标)· login / logout · model · usage · update · import");
|
|
244
|
+
console.log(" u1s1 命令:web(浏览器网页版)· web shortcut(桌面图标)· deploy(发布网页)· login / logout · model · usage · update · import");
|
|
245
245
|
console.log("");
|
|
246
246
|
}
|
|
247
247
|
if (cmd === "web") {
|
|
@@ -257,6 +257,13 @@ async function run() {
|
|
|
257
257
|
await webCommand(cfg, args.slice(1));
|
|
258
258
|
return;
|
|
259
259
|
}
|
|
260
|
+
if (cmd === "deploy") {
|
|
261
|
+
const { ensureAuth } = await import("./login.js");
|
|
262
|
+
const cfg = await ensureAuth();
|
|
263
|
+
const { deployCommand } = await import("./deploy.js");
|
|
264
|
+
await deployCommand(cfg, args.slice(1));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
260
267
|
if (cmd === "login") {
|
|
261
268
|
const { login } = await import("./login.js");
|
|
262
269
|
await login(args[1]);
|
package/package.json
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "u1s1-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"u1s1": "dist/index.js"
|
|
8
8
|
},
|
|
9
|
+
"exports": {
|
|
10
|
+
"./embed": {
|
|
11
|
+
"types": "./dist/embed.d.ts",
|
|
12
|
+
"default": "./dist/embed.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
9
16
|
"files": [
|
|
10
17
|
"dist"
|
|
11
18
|
],
|