@yijiancaotang/cli 1.1.0 → 1.2.1

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,370 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { lstat, realpath } from "node:fs/promises";
3
+ import { isAbsolute, join, parse, relative, resolve, sep } from "node:path";
4
+ import { asRecord, CliError } from "./errors.js";
5
+ import { OwnedTree } from "./installation.js";
6
+ import { acquireLocalLock } from "./local-lock.js";
7
+ const serviceBase = "/api/v1/agent";
8
+ const maxMapBytes = 256 * 1024;
9
+ const maxEntries = 256;
10
+ const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
11
+ const uuid = (value) => typeof value === "string" && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value);
12
+ const ownerID = (value) => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
13
+ const hex = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
14
+ const keys = (value, expected) => Object.keys(value).sort().join(",") === [...expected].sort().join(",");
15
+ const safePath = (value) => typeof value === "string" && value.length > 0 && value.length <= 4096 && isAbsolute(value) && resolve(value) === value && !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value);
16
+ const within = (parent, child) => { const part = relative(parent, child); return part !== ".." && !part.startsWith(`..${sep}`) && !isAbsolute(part); };
17
+ const sameIdentity = (a, b) => a.dev === b.dev && a.ino === b.ino && a.uid === b.uid;
18
+ const sameFacts = (a, b) => a.canonical_path === b.canonical_path && sameIdentity(a, b) && a.mode === b.mode;
19
+ class StateError extends Error {
20
+ reason;
21
+ constructor(reason) {
22
+ super("Local project binding metadata is unavailable or unsafe");
23
+ this.reason = reason;
24
+ }
25
+ }
26
+ function result(operation, status, reason) { return { schema_version: 1, operation, status, ...(reason ? { reason } : {}) }; }
27
+ function changedPreview() { throw new CliError("project_binding_preview_changed", "本机项目关联的确认范围已变化,未写入。", "重新查看当前目录、账号、服务和项目的预览;摘要不能替代本人明确同意。", 2); }
28
+ function projectOptions(options) {
29
+ if (!options || !uuid(options.projectId) || options.includeSubdirs !== undefined && typeof options.includeSubdirs !== "boolean")
30
+ throw new CliError("invalid_arguments", "项目关联参数无效。", "指定真实项目ID;子目录范围须单独预览确认,不扫描目录或猜测同名项目。", 2);
31
+ return { projectId: options.projectId.toLowerCase(), includeSubdirs: options.includeSubdirs === true };
32
+ }
33
+ function identity(ctx) {
34
+ if (!ownerID(ctx.verifiedOwnerId) || ctx.serviceBase !== serviceBase || typeof ctx.apiBaseUrl !== "string" || ctx.apiBaseUrl.trim() !== ctx.apiBaseUrl)
35
+ return;
36
+ try {
37
+ const url = new URL(ctx.apiBaseUrl);
38
+ if (url.username || url.password || url.search || url.hash || url.pathname !== "/" || (url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))))
39
+ return;
40
+ return { origin: url.origin, owner: ctx.verifiedOwnerId };
41
+ }
42
+ catch {
43
+ return;
44
+ }
45
+ }
46
+ async function directoryFacts(path) {
47
+ if (!safePath(path) || typeof process.getuid !== "function")
48
+ throw new StateError("directory_unsafe");
49
+ const uid = process.getuid(), root = parse(path).root, ancestors = [];
50
+ let current = root;
51
+ for (const part of [null, ...relative(root, path).split(sep).filter(Boolean)]) {
52
+ if (part !== null)
53
+ current = join(current, part);
54
+ const info = await lstat(current, { bigint: true }), mode = Number(info.mode & 4095n), owner = Number(info.uid), final = current === path;
55
+ const stickySystemAncestor = !final && owner === 0 && (mode & 0o1000) !== 0;
56
+ if (!info.isDirectory() || (final ? owner !== uid : owner !== uid && owner !== 0) || (mode & 0o022) !== 0 && !stickySystemAncestor)
57
+ throw new StateError("directory_unsafe");
58
+ ancestors.push({ canonical_path: current, dev: info.dev.toString(), ino: info.ino.toString(), uid: owner, mode });
59
+ }
60
+ if (await realpath(path) !== path)
61
+ throw new StateError("directory_unsafe");
62
+ return { facts: ancestors.at(-1), ancestors };
63
+ }
64
+ async function base(ctx, operation, requireLocal) {
65
+ if (requireLocal && ctx.localScopeConfirmed !== true)
66
+ return result(operation, "local_scope_required", "independent_local_workspace_not_confirmed");
67
+ const who = identity(ctx);
68
+ if (!who || typeof ctx.readProject !== "function")
69
+ return result(operation, "unknown", "identity_or_service_unverified");
70
+ try {
71
+ const home = await directoryFacts(ctx.homeDir), directory = await directoryFacts(ctx.cwd), tree = new OwnedTree(ctx.homeDir);
72
+ await tree.directory(ctx.homeDir, false, async () => true);
73
+ return { tree, home: home.facts, directory: directory.facts, ancestors: directory.ancestors, uid: process.getuid(), ...who, root: join(ctx.homeDir, ".caotang", "project-bindings") };
74
+ }
75
+ catch {
76
+ return result(operation, "invalid", "directory_unsafe_or_changed");
77
+ }
78
+ }
79
+ function isBase(value) { return "tree" in value; }
80
+ function metadata(b) { return { service: { origin: b.origin, service_base: serviceBase }, owner_id: b.owner }; }
81
+ async function verifyBase(ctx, b) {
82
+ const who = identity(ctx);
83
+ if (ctx.localScopeConfirmed !== true || !who || who.origin !== b.origin || who.owner !== b.owner
84
+ || !sameFacts((await directoryFacts(ctx.homeDir)).facts, b.home) || !sameFacts((await directoryFacts(ctx.cwd)).facts, b.directory))
85
+ throw new StateError("scope_changed");
86
+ }
87
+ async function checkedProject(ctx, id, operation) {
88
+ let value;
89
+ try {
90
+ value = await ctx.readProject(id);
91
+ }
92
+ catch {
93
+ return result(operation, "unknown", "project_validation_unavailable");
94
+ }
95
+ if (!value)
96
+ return result(operation, "invalid", "project_not_accessible");
97
+ if (["hidden", "archived", "deleted"].includes(value.status))
98
+ return result(operation, "invalid", "project_not_eligible");
99
+ if (!uuid(value.id) || value.id.toLowerCase() !== id || !["active", "paused", "draft"].includes(value.status)
100
+ || typeof value.title !== "string" || !value.title.trim() || [...value.title].length > 80 || /[\p{Cc}\uD800-\uDFFF]/u.test(value.title))
101
+ return result(operation, "unknown", "project_metadata_unverified");
102
+ return { id, title: value.title, status: value.status };
103
+ }
104
+ function isProject(value) { return "id" in value; }
105
+ function bindDigest(b, project, includeSubdirs) {
106
+ return hash(JSON.stringify({ schema_version: 1, operation: "bind", home: b.home, directory: b.directory, uid: b.uid, origin: b.origin, service_base: serviceBase, owner_id: b.owner, project, include_subdirs: includeSubdirs, scope: "independent_local_only" }));
107
+ }
108
+ async function bindingPreview(ctx, options) {
109
+ const wanted = projectOptions(options), operation = "project_bind_preview", b = await base(ctx, operation, false);
110
+ if (!isBase(b))
111
+ return b;
112
+ const project = await checkedProject(ctx, wanted.projectId, operation);
113
+ if (!isProject(project))
114
+ return project;
115
+ try {
116
+ if (!sameFacts((await directoryFacts(ctx.cwd)).facts, b.directory) || !sameFacts((await directoryFacts(ctx.homeDir)).facts, b.home))
117
+ return result(operation, "invalid", "directory_changed_during_preview");
118
+ }
119
+ catch {
120
+ return result(operation, "invalid", "directory_changed_during_preview");
121
+ }
122
+ return { b, project, includeSubdirs: wanted.includeSubdirs, preview: {
123
+ ...result(operation, "confirmation_required"), ...metadata(b), directory: { canonical_path: b.directory.canonical_path, include_subdirs: wanted.includeSubdirs }, project,
124
+ preview_digest: bindDigest(b, project, wanted.includeSubdirs), requires_local_scope_confirmation: true, local_write_only: true,
125
+ } };
126
+ }
127
+ export async function previewProjectBinding(ctx, options) {
128
+ const outcome = await bindingPreview(ctx, options);
129
+ return "preview" in outcome ? outcome.preview : outcome;
130
+ }
131
+ function storedFacts(value) {
132
+ const v = asRecord(value);
133
+ if (!v || !keys(v, ["canonical_path", "dev", "ino", "uid", "mode"]) || !safePath(v.canonical_path)
134
+ || typeof v.dev !== "string" || !/^\d{1,40}$/.test(v.dev) || typeof v.ino !== "string" || !/^\d{1,40}$/.test(v.ino)
135
+ || typeof v.uid !== "number" || !Number.isSafeInteger(v.uid) || v.uid < 0 || typeof v.mode !== "number" || !Number.isInteger(v.mode) || v.mode < 0 || v.mode > 0o7777)
136
+ throw new StateError("local_metadata_malformed");
137
+ return { canonical_path: v.canonical_path, dev: v.dev, ino: v.ino, uid: v.uid, mode: v.mode };
138
+ }
139
+ function json(bytes) {
140
+ try {
141
+ const raw = bytes.toString("utf8");
142
+ if (!Buffer.from(raw, "utf8").equals(bytes))
143
+ throw Error();
144
+ const value = asRecord(JSON.parse(raw));
145
+ if (!value)
146
+ throw Error();
147
+ return value;
148
+ }
149
+ catch {
150
+ throw new StateError("local_metadata_malformed");
151
+ }
152
+ }
153
+ async function localState(b, create) {
154
+ const path = join(b.root, "local-state.json");
155
+ let bytes = await b.tree.read(path, 16384, 0o600);
156
+ if (!bytes && create) {
157
+ const fresh = { schema_version: 1, scope: "user_confirmed_independent_local", local_state_id: randomBytes(32).toString("hex"), home: b.home };
158
+ try {
159
+ await b.tree.writeNew(path, Buffer.from(JSON.stringify(fresh)), 0o600);
160
+ }
161
+ catch (error) {
162
+ if (!(error instanceof CliError) || error.code !== "installation_conflict")
163
+ throw error;
164
+ }
165
+ bytes = await b.tree.read(path, 16384, 0o600);
166
+ if (!bytes)
167
+ throw new StateError("local_state_unconfirmed");
168
+ }
169
+ if (!bytes)
170
+ return null;
171
+ const value = json(bytes);
172
+ if (!keys(value, ["schema_version", "scope", "local_state_id", "home"]) || value.schema_version !== 1 || value.scope !== "user_confirmed_independent_local" || !hex(value.local_state_id))
173
+ throw new StateError("local_metadata_malformed");
174
+ const home = storedFacts(value.home);
175
+ if (!sameFacts(home, b.home))
176
+ throw new StateError("local_home_identity_changed");
177
+ return { schema_version: 1, scope: "user_confirmed_independent_local", local_state_id: value.local_state_id, home };
178
+ }
179
+ function namespace(b, state) {
180
+ return hash(JSON.stringify({ schema_version: 1, local_state_id: state.local_state_id, home: state.home, uid: b.uid, origin: b.origin, service_base: serviceBase, owner_id: b.owner }));
181
+ }
182
+ async function storedMap(b, state) {
183
+ const ns = namespace(b, state), directory = join(b.root, "namespaces", ns), path = join(directory, "bindings.json"), bytes = await b.tree.read(path, maxMapBytes, 0o600);
184
+ if (!bytes)
185
+ return { namespace: ns, path, directory, bytes: null, value: { schema_version: 1, namespace: ns, entries: [] } };
186
+ const value = json(bytes);
187
+ if (!keys(value, ["schema_version", "namespace", "entries"]) || value.schema_version !== 1 || value.namespace !== ns || !Array.isArray(value.entries) || value.entries.length > maxEntries)
188
+ throw new StateError("local_metadata_malformed");
189
+ const entries = value.entries.map((raw) => {
190
+ const item = asRecord(raw);
191
+ if (!item || !keys(item, ["directory", "project_id", "include_subdirs"]) || !uuid(item.project_id) || typeof item.include_subdirs !== "boolean")
192
+ throw new StateError("local_metadata_malformed");
193
+ const directory = storedFacts(item.directory);
194
+ if (directory.uid !== b.uid)
195
+ throw new StateError("local_metadata_malformed");
196
+ return { directory, project_id: item.project_id.toLowerCase(), include_subdirs: item.include_subdirs };
197
+ });
198
+ if (new Set(entries.map(entry => entry.directory.canonical_path)).size !== entries.length)
199
+ throw new StateError("local_metadata_malformed");
200
+ return { namespace: ns, path, directory, bytes, value: { schema_version: 1, namespace: ns, entries } };
201
+ }
202
+ function selection(b, entries) {
203
+ const candidates = entries.filter(entry => entry.directory.canonical_path === b.directory.canonical_path || entry.include_subdirs && within(entry.directory.canonical_path, b.directory.canonical_path))
204
+ .sort((a, z) => z.directory.canonical_path.length - a.directory.canonical_path.length);
205
+ if (candidates.length)
206
+ return { entry: candidates[0], inherited: candidates[0].directory.canonical_path !== b.directory.canonical_path };
207
+ const moved = entries.some(entry => sameIdentity(entry.directory, b.directory) || entry.include_subdirs && b.ancestors.some(ancestor => sameIdentity(entry.directory, ancestor)));
208
+ return { moved };
209
+ }
210
+ function stateFailure(operation, error) {
211
+ if (error instanceof StateError)
212
+ return result(operation, "invalid", error.reason);
213
+ if (error instanceof CliError && ["project_binding_busy", "project_binding_update_unknown"].includes(error.code))
214
+ return result(operation, "unknown", error.code);
215
+ return result(operation, "invalid", "local_state_unsafe_or_unavailable");
216
+ }
217
+ async function changeMap(b, map, entries) {
218
+ if (entries.length > maxEntries)
219
+ throw new StateError("local_binding_capacity_exceeded");
220
+ const value = { schema_version: 1, namespace: map.namespace, entries: [...entries].sort((a, z) => a.directory.canonical_path.localeCompare(z.directory.canonical_path)) };
221
+ const after = Buffer.from(JSON.stringify(value));
222
+ if (after.length > maxMapBytes)
223
+ throw new StateError("local_binding_capacity_exceeded");
224
+ if (map.bytes?.equals(after))
225
+ return false;
226
+ let writeAttempted = false;
227
+ try {
228
+ if (!map.bytes) {
229
+ writeAttempted = true;
230
+ await b.tree.writeNew(map.path, after, 0o600);
231
+ }
232
+ else {
233
+ const journal = Buffer.from(JSON.stringify({ schema_version: 1, before_sha256: hash(map.bytes), after_sha256: hash(after), before_base64: map.bytes.toString("base64"), after_base64: after.toString("base64") }));
234
+ await b.tree.writeNew(join(map.directory, "history", `${hash(map.bytes)}.${hash(after)}.json`), journal, 0o600);
235
+ const entry = { path: map.path, before: map.bytes, after, mode: 0o600, label: "project_bindings" };
236
+ writeAttempted = true;
237
+ try {
238
+ await b.tree.replaceManaged(entry);
239
+ }
240
+ catch {
241
+ try {
242
+ await b.tree.replaceManaged(entry, true);
243
+ }
244
+ catch { /* Keep recovery bytes; never reset an unverified inode. */ }
245
+ throw new StateError("local_update_unconfirmed");
246
+ }
247
+ }
248
+ if (!(await b.tree.read(map.path, maxMapBytes, 0o600))?.equals(after))
249
+ throw new StateError("local_update_unconfirmed");
250
+ }
251
+ catch (error) {
252
+ if (!writeAttempted)
253
+ throw error;
254
+ throw new CliError("project_binding_update_unknown", "本机关联更新未完成最终核对。", "保留现有元数据和恢复记录,由本人核对;不要自动重写或影响草堂项目。", 6);
255
+ }
256
+ return true;
257
+ }
258
+ async function locked(b, state, action) {
259
+ const ns = namespace(b, state), directory = join(b.root, "namespaces", ns), lockPath = join(directory, ".binding.lock");
260
+ const release = await b.tree.directory(directory, true, async (parents) => acquireLocalLock(lockPath, parents.at(-1), {
261
+ unsafe: () => new StateError("local_lock_unsafe"),
262
+ busy: () => new CliError("project_binding_busy", "另一项本机项目关联操作未完成。", "先等待本机操作结束;状态未知时不要删锁或强制覆盖。", 6),
263
+ }));
264
+ if (!release)
265
+ throw new StateError("local_lock_unavailable");
266
+ try {
267
+ return await action(await storedMap(b, state));
268
+ }
269
+ finally {
270
+ release();
271
+ }
272
+ }
273
+ export async function confirmProjectBinding(ctx, options) {
274
+ const operation = "project_bind";
275
+ if (ctx.localScopeConfirmed !== true || options.localScopeConfirmed !== true)
276
+ return result(operation, "local_scope_required", "independent_local_workspace_not_confirmed");
277
+ const outcome = await bindingPreview(ctx, options);
278
+ if (!("preview" in outcome))
279
+ return { ...outcome, operation };
280
+ if (!hex(options.previewDigest) || options.previewDigest !== outcome.preview.preview_digest)
281
+ changedPreview();
282
+ const { b, project, includeSubdirs } = outcome;
283
+ try {
284
+ await verifyBase(ctx, b);
285
+ const state = await localState(b, true);
286
+ if (!state)
287
+ throw new StateError("local_state_unconfirmed");
288
+ const changed = await locked(b, state, async (map) => {
289
+ await verifyBase(ctx, b);
290
+ const entries = map.value.entries.filter(entry => entry.directory.canonical_path !== b.directory.canonical_path);
291
+ entries.push({ directory: b.directory, project_id: project.id, include_subdirs: includeSubdirs });
292
+ return changeMap(b, map, entries);
293
+ });
294
+ return { ...result(operation, "bound"), ...metadata(b), directory: { canonical_path: b.directory.canonical_path, include_subdirs: includeSubdirs }, project, inherited: false, changed, local_write_only: true };
295
+ }
296
+ catch (error) {
297
+ return stateFailure(operation, error);
298
+ }
299
+ }
300
+ export async function readProjectBinding(ctx) {
301
+ const operation = "project_binding", b = await base(ctx, operation, true);
302
+ if (!isBase(b))
303
+ return b;
304
+ try {
305
+ const state = await localState(b, false);
306
+ if (!state)
307
+ return result(operation, "not_bound", "no_local_state_in_current_scope");
308
+ const map = await storedMap(b, state), chosen = selection(b, map.value.entries);
309
+ if (!chosen.entry)
310
+ return result(operation, chosen.moved ? "invalid" : "not_bound", chosen.moved ? "directory_moved" : "no_binding_in_current_scope");
311
+ const entry = chosen.entry;
312
+ if (!sameFacts((await directoryFacts(entry.directory.canonical_path)).facts, entry.directory))
313
+ return result(operation, "invalid", "directory_identity_changed");
314
+ const project = await checkedProject(ctx, entry.project_id, operation);
315
+ if (!isProject(project))
316
+ return project;
317
+ await verifyBase(ctx, b);
318
+ // A different local operation may have changed the map while the project
319
+ // validation was awaiting the server. Never label that old selection current.
320
+ const currentState = await localState(b, false);
321
+ if (!currentState || currentState.local_state_id !== state.local_state_id || !map.bytes
322
+ || !(await b.tree.read(map.path, maxMapBytes, 0o600))?.equals(map.bytes))
323
+ return result(operation, "unknown", "binding_changed_during_validation");
324
+ return { ...result(operation, "bound"), ...metadata(b), directory: { canonical_path: entry.directory.canonical_path, include_subdirs: entry.include_subdirs }, project, inherited: chosen.inherited === true };
325
+ }
326
+ catch (error) {
327
+ return stateFailure(operation, error);
328
+ }
329
+ }
330
+ function unbindDigest(b, map, entry) {
331
+ return hash(JSON.stringify({ schema_version: 1, operation: "unbind_exact", home: b.home, directory: b.directory, origin: b.origin, service_base: serviceBase, owner_id: b.owner, namespace: map.namespace, map_sha256: hash(map.bytes), entry }));
332
+ }
333
+ export async function unbindProjectBinding(ctx, options = {}) {
334
+ const operation = options.previewDigest === undefined ? "project_unbind_preview" : "project_unbind", b = await base(ctx, operation, true);
335
+ if (!isBase(b))
336
+ return b;
337
+ if (options.previewDigest !== undefined && options.localScopeConfirmed !== true)
338
+ return result(operation, "local_scope_required", "independent_local_workspace_not_confirmed");
339
+ try {
340
+ const state = await localState(b, false);
341
+ if (!state)
342
+ return result(operation, "not_bound", "no_local_state_in_current_scope");
343
+ const map = await storedMap(b, state), exact = map.value.entries.find(entry => entry.directory.canonical_path === b.directory.canonical_path);
344
+ if (!exact) {
345
+ const inherited = selection(b, map.value.entries).entry;
346
+ return result(operation, inherited ? "inherited_binding_requires_root" : "not_bound", inherited ? "confirm_at_exact_binding_directory" : "no_binding_in_current_scope");
347
+ }
348
+ if (!map.bytes)
349
+ throw new StateError("local_metadata_malformed");
350
+ const digest = unbindDigest(b, map, exact);
351
+ if (options.previewDigest === undefined)
352
+ return { ...result(operation, "confirmation_required"), ...metadata(b), directory: { canonical_path: b.directory.canonical_path, include_subdirs: exact.include_subdirs }, preview_digest: digest, requires_local_scope_confirmation: true, local_write_only: true };
353
+ if (!hex(options.previewDigest) || options.previewDigest !== digest)
354
+ changedPreview();
355
+ await verifyBase(ctx, b);
356
+ const changed = await locked(b, state, async (current) => {
357
+ const row = current.value.entries.find(entry => entry.directory.canonical_path === b.directory.canonical_path);
358
+ if (!row || !current.bytes || unbindDigest(b, current, row) !== options.previewDigest)
359
+ changedPreview();
360
+ await verifyBase(ctx, b);
361
+ return changeMap(b, current, current.value.entries.filter(entry => entry.directory.canonical_path !== b.directory.canonical_path));
362
+ });
363
+ return { ...result(operation, "unbound"), ...metadata(b), directory: { canonical_path: b.directory.canonical_path, include_subdirs: exact.include_subdirs }, changed, local_write_only: true };
364
+ }
365
+ catch (error) {
366
+ if (error instanceof CliError && error.code === "project_binding_preview_changed")
367
+ throw error;
368
+ return stateFailure(operation, error);
369
+ }
370
+ }