@ccmsg/cli 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/LICENSE +21 -0
- package/README.md +23 -0
- package/package.json +32 -0
- package/src/cli.ts +1074 -0
- package/src/daemon/control.ts +88 -0
- package/src/daemon/index.ts +6 -0
- package/src/daemon/link.ts +93 -0
- package/src/daemon/log.ts +116 -0
- package/src/daemon/registry.ts +285 -0
- package/src/daemon/snapshot.ts +115 -0
- package/src/daemon/supervise.ts +446 -0
- package/src/dispatch/caller.ts +47 -0
- package/src/dispatch/dispatch.ts +128 -0
- package/src/dispatch/handler.ts +55 -0
- package/src/dispatch/identity.ts +22 -0
- package/src/dispatch/index.ts +5 -0
- package/src/dispatch/result.ts +58 -0
- package/src/files/containment.ts +263 -0
- package/src/files/files.ts +421 -0
- package/src/files/index.ts +14 -0
- package/src/files/sandbox.ts +0 -0
- package/src/greeting/hook.ts +48 -0
- package/src/greeting/index.ts +2 -0
- package/src/greeting/meta.ts +66 -0
- package/src/instance/config.ts +424 -0
- package/src/instance/handlers.ts +28 -0
- package/src/instance/identity.ts +44 -0
- package/src/instance/index.ts +8 -0
- package/src/instance/instance.ts +911 -0
- package/src/instance/lock.ts +108 -0
- package/src/instance/log.ts +30 -0
- package/src/instance/paths.ts +200 -0
- package/src/instance/socket.ts +62 -0
- package/src/kv/index.ts +2 -0
- package/src/kv/merge.ts +66 -0
- package/src/kv/store.ts +195 -0
- package/src/launcher/index.ts +4 -0
- package/src/launcher/launcher.ts +190 -0
- package/src/launcher/roots.ts +32 -0
- package/src/launcher/spawn.ts +81 -0
- package/src/launcher/tree.ts +80 -0
- package/src/mesh/index.ts +5 -0
- package/src/mesh/keys.ts +158 -0
- package/src/mesh/mesh.ts +1169 -0
- package/src/mesh/probe.ts +100 -0
- package/src/mesh/relay.ts +147 -0
- package/src/mesh/wire.ts +96 -0
- package/src/messaging/delivery.ts +375 -0
- package/src/messaging/direct.ts +433 -0
- package/src/messaging/handlers.ts +14 -0
- package/src/messaging/inbox.ts +191 -0
- package/src/messaging/index.ts +5 -0
- package/src/messaging/notify.ts +117 -0
- package/src/plugin/claude.ts +148 -0
- package/src/plugin/index.ts +13 -0
- package/src/plugin/install.ts +416 -0
- package/src/service/index.ts +1 -0
- package/src/service/service.ts +359 -0
- package/src/sessions/classify.ts +66 -0
- package/src/sessions/dump.ts +105 -0
- package/src/sessions/fork.ts +127 -0
- package/src/sessions/handlers.ts +158 -0
- package/src/sessions/harness.ts +167 -0
- package/src/sessions/index.ts +26 -0
- package/src/sessions/last-live.ts +111 -0
- package/src/sessions/processes.ts +413 -0
- package/src/sessions/registry.ts +785 -0
- package/src/sessions/search.ts +278 -0
- package/src/sessions/status.ts +209 -0
- package/src/sessions/terminals.ts +72 -0
- package/src/sessions/workspace.ts +140 -0
- package/src/topics/handlers.ts +42 -0
- package/src/topics/index.ts +2 -0
- package/src/topics/topics.ts +290 -0
- package/src/transcript/files.ts +201 -0
- package/src/transcript/fold.ts +833 -0
- package/src/transcript/index.ts +16 -0
- package/src/transcript/read.ts +82 -0
- package/src/transcript/tail.ts +195 -0
- package/src/transcript/transcripts.ts +162 -0
- package/src/translate/helper.ts +87 -0
- package/src/translate/index.ts +2 -0
- package/src/translate/translate.ts +127 -0
- package/src/transport/conn.ts +129 -0
- package/src/transport/dial.ts +65 -0
- package/src/transport/driver.ts +102 -0
- package/src/transport/entry.ts +39 -0
- package/src/transport/framing.ts +131 -0
- package/src/transport/index.ts +8 -0
- package/src/transport/listener.ts +39 -0
- package/src/transport/uds.ts +88 -0
- package/src/transport/ws.ts +170 -0
- package/src/upstream/events.ts +125 -0
- package/src/upstream/gateway.ts +275 -0
- package/src/upstream/index.ts +8 -0
- package/src/upstream/json.ts +81 -0
- package/src/upstream/requests.ts +234 -0
- package/src/upstream/stats.ts +99 -0
- package/src/upstream/status.ts +281 -0
- package/src/upstream/usage.ts +208 -0
- package/src/upstream/webhook.ts +141 -0
- package/src/version.ts +8 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
openSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
statSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import type { Dirent } from "node:fs";
|
|
15
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
16
|
+
import type {
|
|
17
|
+
DirEntry,
|
|
18
|
+
DirListArgs,
|
|
19
|
+
DirListResult,
|
|
20
|
+
FileCreateArgs,
|
|
21
|
+
FileCreateResult,
|
|
22
|
+
FileDeleteArgs,
|
|
23
|
+
FileDeleteResult,
|
|
24
|
+
FileEditArgs,
|
|
25
|
+
FileEditResult,
|
|
26
|
+
FileFindArgs,
|
|
27
|
+
FileFindHit,
|
|
28
|
+
FileFindResult,
|
|
29
|
+
FileReadArgs,
|
|
30
|
+
FileReadResult,
|
|
31
|
+
FileStatBatchArgs,
|
|
32
|
+
FileStatBatchResult,
|
|
33
|
+
FileStatEntry,
|
|
34
|
+
FileWriteArgs,
|
|
35
|
+
FileWriteResult,
|
|
36
|
+
Timestamp,
|
|
37
|
+
} from "@ccmsg/protocol";
|
|
38
|
+
import { type HandlerInput, OpError } from "../dispatch/index.ts";
|
|
39
|
+
import type { Containment, Located, Viewer } from "./containment.ts";
|
|
40
|
+
|
|
41
|
+
/** How much of a file `file_read` carries. Larger files are answered with their
|
|
42
|
+
* head and `truncated`, so one file can never cost the connection more than
|
|
43
|
+
* this however big it grew (DR-0008 §5). */
|
|
44
|
+
const READ_LIMIT = 512 * 1024;
|
|
45
|
+
|
|
46
|
+
/** How much of a file decides whether it is text. A NUL in the head is what
|
|
47
|
+
* separates a document from a binary here: enough to keep an image out of a
|
|
48
|
+
* text pane, and deliberately not a full content-type guess. */
|
|
49
|
+
const SNIFF = 8 * 1024;
|
|
50
|
+
|
|
51
|
+
/** What one walk may answer with, and how far it may look to find it. The
|
|
52
|
+
* second is what bounds a walk over a tree whose matches are few — without it a
|
|
53
|
+
* query that matches nothing is a walk of everything. */
|
|
54
|
+
const FIND_HITS = 200;
|
|
55
|
+
const FIND_VISITS = 20_000;
|
|
56
|
+
|
|
57
|
+
/** The eight ops that read and write files.
|
|
58
|
+
*
|
|
59
|
+
* None of them decides what may be reached: each turns its arguments into a
|
|
60
|
+
* located path, does the one thing it names, and states the result in the shape
|
|
61
|
+
* the kind implies. The decision is `Containment`'s, and what it needs of the
|
|
62
|
+
* caller — the role dispatch states for a `scope: "role"` op, and the session
|
|
63
|
+
* the connection speaks for — is handed over unread. */
|
|
64
|
+
export function fileHandlers(paths: Containment) {
|
|
65
|
+
const viewer = (input: HandlerInput): Viewer => ({ role: input.role, sid: input.identity?.sid });
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
dir_list: (input: HandlerInput): DirListResult => {
|
|
69
|
+
const args = input.args as unknown as DirListArgs;
|
|
70
|
+
const at = paths.root(args, viewer(input));
|
|
71
|
+
const stat = existing(at);
|
|
72
|
+
if (!stat.isDirectory()) throw new OpError("not_found", `${args.path ?? ""} is not a folder`);
|
|
73
|
+
return { sid: args.sid, path: at.path, entries: entriesOf(at.real) };
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
file_read: (input: HandlerInput): FileReadResult => {
|
|
77
|
+
const args = input.args as unknown as FileReadArgs;
|
|
78
|
+
const at = paths.locate(args, viewer(input));
|
|
79
|
+
const stat = existing(at);
|
|
80
|
+
if (!stat.isFile()) throw new OpError("not_found", `${args.path} is not a file`);
|
|
81
|
+
// As much as the answer may carry and no more: a file larger than the
|
|
82
|
+
// limit is answered from its head, so reading it whole would cost the
|
|
83
|
+
// instance the whole of a file whose size is what the limit exists to
|
|
84
|
+
// refuse.
|
|
85
|
+
const head = bytesOf(at.real, READ_LIMIT);
|
|
86
|
+
const binary = isBinary(head);
|
|
87
|
+
return {
|
|
88
|
+
sid: args.sid,
|
|
89
|
+
path: at.path,
|
|
90
|
+
size: stat.size,
|
|
91
|
+
truncated: stat.size > head.byteLength,
|
|
92
|
+
binary,
|
|
93
|
+
content: binary ? "" : head.toString("utf8"),
|
|
94
|
+
mtime_at: mtimeOf(stat),
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
file_write: (input: HandlerInput): FileWriteResult => {
|
|
99
|
+
const args = input.args as unknown as FileWriteArgs;
|
|
100
|
+
const at = paths.inbox(args.sid, args.path, viewer(input));
|
|
101
|
+
// The inbox takes new notes, so an existing name is refused rather than
|
|
102
|
+
// replaced; the folder itself is made, since a repository that has never
|
|
103
|
+
// had one is exactly where the first note goes (DR-0019 §2.1).
|
|
104
|
+
mkdirSync(dirname(at.real), { recursive: true });
|
|
105
|
+
create(at.real, args.content);
|
|
106
|
+
return { sid: args.sid, path: at.path };
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
file_create: (input: HandlerInput): FileCreateResult => {
|
|
110
|
+
const args = input.args as unknown as FileCreateArgs;
|
|
111
|
+
const at = paths.locate(args, viewer(input));
|
|
112
|
+
const parent = dirname(at.real);
|
|
113
|
+
if (!isDirectory(parent)) {
|
|
114
|
+
throw new OpError("not_found", `${args.path} has no folder to be created in`);
|
|
115
|
+
}
|
|
116
|
+
create(at.real, args.content);
|
|
117
|
+
return { sid: args.sid, path: at.path };
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
file_edit: (input: HandlerInput): FileEditResult => {
|
|
121
|
+
const args = input.args as unknown as FileEditArgs;
|
|
122
|
+
const at = paths.locate(args, viewer(input));
|
|
123
|
+
const stat = existing(at);
|
|
124
|
+
if (!stat.isFile()) throw new OpError("not_found", `${args.path} is not a file`);
|
|
125
|
+
if (isBinary(bytesOf(at.real, SNIFF))) {
|
|
126
|
+
throw new OpError("not_a_text_file", `${args.path} holds binary content`);
|
|
127
|
+
}
|
|
128
|
+
if (mtimeOf(stat) !== args.expected_mtime_at || stat.size !== args.expected_size) {
|
|
129
|
+
throw new OpError("file_conflict", `${args.path} changed since it was read`);
|
|
130
|
+
}
|
|
131
|
+
replace(at.real, args.content);
|
|
132
|
+
const after = statSync(at.real);
|
|
133
|
+
return { sid: args.sid, path: at.path, size: after.size, mtime_at: mtimeOf(after) };
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
file_delete: (input: HandlerInput): FileDeleteResult => {
|
|
137
|
+
const args = input.args as unknown as FileDeleteArgs;
|
|
138
|
+
const at = paths.locate(args, viewer(input));
|
|
139
|
+
// What is unlinked is what is named, so this reads the name itself rather
|
|
140
|
+
// than what it resolves to: a symlink is refused as the wrong kind of
|
|
141
|
+
// thing instead of taking its target's answer. The resolved path is the
|
|
142
|
+
// one containment admitted and would answer for the target, which is the
|
|
143
|
+
// file a link inside the root could otherwise be pointed at.
|
|
144
|
+
const stat = lstatOf(at.named);
|
|
145
|
+
if (stat === undefined) throw new OpError("not_found", `${args.path} is not there`);
|
|
146
|
+
if (!stat.isFile()) {
|
|
147
|
+
throw new OpError("path_forbidden", `${args.path} is not a plain file`);
|
|
148
|
+
}
|
|
149
|
+
unlinkSync(at.named);
|
|
150
|
+
return { sid: args.sid, path: at.path };
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
file_find: (input: HandlerInput): FileFindResult => {
|
|
154
|
+
const args = input.args as unknown as FileFindArgs;
|
|
155
|
+
const at = paths.root(
|
|
156
|
+
{ sid: args.sid, kind: args.kind, ...(args.root === undefined ? {} : { path: args.root }) },
|
|
157
|
+
viewer(input),
|
|
158
|
+
);
|
|
159
|
+
const terms = parseQuery(args.query);
|
|
160
|
+
// A query with nothing to include matches nothing rather than the whole
|
|
161
|
+
// tree, so a cleared search box costs no walk at all.
|
|
162
|
+
if (terms.include.length === 0) return { sid: args.sid, hits: [], truncated: false };
|
|
163
|
+
const walk = find(at, terms, args.respect_gitignore ?? true);
|
|
164
|
+
return { sid: args.sid, hits: walk.hits, truncated: walk.truncated };
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
file_stat_batch: (input: HandlerInput): FileStatBatchResult => {
|
|
168
|
+
const args = input.args as unknown as FileStatBatchArgs;
|
|
169
|
+
const results = args.paths.map((path): FileStatEntry | null => {
|
|
170
|
+
const at = paths.identify(args.sid, path, viewer(input));
|
|
171
|
+
if (at === undefined || !isFile(at.real)) return null;
|
|
172
|
+
return { kind: at.kind, path: at.path };
|
|
173
|
+
});
|
|
174
|
+
return { results };
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The file a located path names, or the contract's word for "not there". */
|
|
180
|
+
function existing(at: Located) {
|
|
181
|
+
try {
|
|
182
|
+
return statSync(at.real);
|
|
183
|
+
} catch {
|
|
184
|
+
throw new OpError("not_found", `${at.path} is not there`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function lstatOf(path: string) {
|
|
189
|
+
try {
|
|
190
|
+
return lstatSync(path);
|
|
191
|
+
} catch {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isFile(path: string): boolean {
|
|
197
|
+
try {
|
|
198
|
+
return statSync(path).isFile();
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isDirectory(path: string): boolean {
|
|
205
|
+
try {
|
|
206
|
+
return statSync(path).isDirectory();
|
|
207
|
+
} catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Whole milliseconds, which is what the contract's timestamps are and what an
|
|
213
|
+
* edit compares its token against. */
|
|
214
|
+
function mtimeOf(stat: { mtimeMs: number }): Timestamp {
|
|
215
|
+
return Math.floor(stat.mtimeMs);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A file's leading bytes, at most `limit` of them. */
|
|
219
|
+
function bytesOf(path: string, limit: number): Buffer {
|
|
220
|
+
const fd = openSync(path, "r");
|
|
221
|
+
try {
|
|
222
|
+
const buffer = Buffer.alloc(limit);
|
|
223
|
+
const read = readSync(fd, buffer, 0, limit, 0);
|
|
224
|
+
return buffer.subarray(0, read);
|
|
225
|
+
} finally {
|
|
226
|
+
closeSync(fd);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isBinary(bytes: Buffer): boolean {
|
|
231
|
+
return bytes.subarray(0, SNIFF).includes(0);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Write a file that must not be there yet. The exclusive open is what decides
|
|
235
|
+
* it: a check followed by a write would answer about the moment before. */
|
|
236
|
+
function create(path: string, content: string): void {
|
|
237
|
+
try {
|
|
238
|
+
writeFileSync(path, content, { flag: "wx" });
|
|
239
|
+
} catch (cause) {
|
|
240
|
+
if ((cause as NodeJS.ErrnoException).code === "EEXIST") {
|
|
241
|
+
throw new OpError("file_exists", `${basename(path)} is already there`);
|
|
242
|
+
}
|
|
243
|
+
throw cause;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Replace a file's content whole. The write lands beside it and is renamed
|
|
248
|
+
* over it, so a reader sees either the old file or the new one and never a
|
|
249
|
+
* half-written one. */
|
|
250
|
+
function replace(path: string, content: string): void {
|
|
251
|
+
const temporary = `${path}.ccmsg-${process.pid}-${Date.now()}`;
|
|
252
|
+
writeFileSync(temporary, content);
|
|
253
|
+
try {
|
|
254
|
+
renameSync(temporary, path);
|
|
255
|
+
} catch (cause) {
|
|
256
|
+
unlinkSync(temporary);
|
|
257
|
+
throw cause;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function entriesOf(dir: string): DirEntry[] {
|
|
262
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
263
|
+
.map((entry): DirEntry => {
|
|
264
|
+
const type = entry.isSymbolicLink()
|
|
265
|
+
? "symlink"
|
|
266
|
+
: entry.isDirectory()
|
|
267
|
+
? "dir"
|
|
268
|
+
: entry.isFile()
|
|
269
|
+
? "file"
|
|
270
|
+
: "other";
|
|
271
|
+
// A symlink is reported as itself, so what is stated about it is the link
|
|
272
|
+
// and never what it points at — including one pointing out of the root,
|
|
273
|
+
// which is listed here and refuses to resolve everywhere else.
|
|
274
|
+
const stat = type === "symlink" ? undefined : lstatOf(join(dir, entry.name));
|
|
275
|
+
return {
|
|
276
|
+
name: entry.name,
|
|
277
|
+
type,
|
|
278
|
+
...(stat?.isFile() === true ? { size: stat.size } : {}),
|
|
279
|
+
...(stat === undefined ? {} : { mtime_at: mtimeOf(stat) }),
|
|
280
|
+
};
|
|
281
|
+
})
|
|
282
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface Terms {
|
|
286
|
+
readonly include: readonly string[];
|
|
287
|
+
readonly exclude: readonly string[];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function parseQuery(query: string): Terms {
|
|
291
|
+
const include: string[] = [];
|
|
292
|
+
const exclude: string[] = [];
|
|
293
|
+
for (const word of query.split(/\s+/).filter((each) => each !== "")) {
|
|
294
|
+
if (word.startsWith("-")) {
|
|
295
|
+
const rest = word.slice(1);
|
|
296
|
+
if (rest !== "") exclude.push(rest.toLowerCase());
|
|
297
|
+
} else include.push(word.toLowerCase());
|
|
298
|
+
}
|
|
299
|
+
return { include, exclude };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function matches(path: string, terms: Terms): boolean {
|
|
303
|
+
const haystack = path.toLowerCase();
|
|
304
|
+
return (
|
|
305
|
+
terms.include.every((word) => haystack.includes(word)) &&
|
|
306
|
+
!terms.exclude.some((word) => haystack.includes(word))
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Walk one subtree, answering the paths whose own spelling matches.
|
|
311
|
+
*
|
|
312
|
+
* Both caps are reported the same way: the hits are the ones found, and
|
|
313
|
+
* `truncated` says they are not the whole match set. Saying so is better than
|
|
314
|
+
* implying these are all. */
|
|
315
|
+
function find(at: Located, terms: Terms, respectGitignore: boolean) {
|
|
316
|
+
const hits: FileFindHit[] = [];
|
|
317
|
+
let visits = 0;
|
|
318
|
+
let truncated = false;
|
|
319
|
+
|
|
320
|
+
const walk = (dir: string, ignored: Ignores): void => {
|
|
321
|
+
if (truncated) return;
|
|
322
|
+
const here = respectGitignore ? ignored.descend(dir) : ignored;
|
|
323
|
+
let entries: Dirent[];
|
|
324
|
+
try {
|
|
325
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
326
|
+
} catch {
|
|
327
|
+
// A folder that cannot be read contributes nothing, and a walk that
|
|
328
|
+
// stopped at one would answer less than it can.
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
for (const entry of entries) {
|
|
332
|
+
if (++visits > FIND_VISITS) {
|
|
333
|
+
truncated = true;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const full = join(dir, entry.name);
|
|
337
|
+
const isDir = entry.isDirectory();
|
|
338
|
+
if (respectGitignore && here.hides(entry.name, isDir)) continue;
|
|
339
|
+
const type = entry.isSymbolicLink() ? "symlink" : isDir ? "dir" : "file";
|
|
340
|
+
if (type !== "file" && type !== "dir" && type !== "symlink") continue;
|
|
341
|
+
const shown = at.kind === "contained" ? relative(at.real, full) : full;
|
|
342
|
+
if (matches(shown, terms)) {
|
|
343
|
+
hits.push({ path: shown, type });
|
|
344
|
+
if (hits.length >= FIND_HITS) {
|
|
345
|
+
truncated = true;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Only real directories are descended: a symlink is answered as itself,
|
|
350
|
+
// and following one would walk out of the root the walk is bounded by.
|
|
351
|
+
if (isDir && !entry.isSymbolicLink()) {
|
|
352
|
+
walk(full, here);
|
|
353
|
+
if (truncated) return;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
walk(at.real, EMPTY_IGNORES);
|
|
359
|
+
return { hits, truncated };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** What the repository's ignore rules hide, as far as a name-matching walk can
|
|
363
|
+
* read them.
|
|
364
|
+
*
|
|
365
|
+
* Design rationale: this reads `.gitignore` as literal names and simple globs
|
|
366
|
+
* and stops there — no negation, no anchored paths, no parent `.gitignore`
|
|
367
|
+
* outside the walked subtree. The rules exist here to keep vendored trees from
|
|
368
|
+
* pushing the real answer out of a capped reply, and a `node_modules/` line is
|
|
369
|
+
* what does that; implementing the whole format would buy accuracy on patterns
|
|
370
|
+
* that do not change which answer a person is looking for. Anything the rules
|
|
371
|
+
* do not hide is answered, so an unimplemented pattern shows a file rather than
|
|
372
|
+
* hiding one. */
|
|
373
|
+
interface Ignores {
|
|
374
|
+
hides(name: string, isDir: boolean): boolean;
|
|
375
|
+
descend(dir: string): Ignores;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const ALWAYS_HIDDEN = new Set([".git"]);
|
|
379
|
+
|
|
380
|
+
const EMPTY_IGNORES: Ignores = makeIgnores([]);
|
|
381
|
+
|
|
382
|
+
function makeIgnores(patterns: readonly RegExp[]): Ignores {
|
|
383
|
+
return {
|
|
384
|
+
hides(name, _isDir) {
|
|
385
|
+
if (ALWAYS_HIDDEN.has(name)) return true;
|
|
386
|
+
return patterns.some((pattern) => pattern.test(name));
|
|
387
|
+
},
|
|
388
|
+
descend(dir) {
|
|
389
|
+
const own = readIgnoreFile(join(dir, ".gitignore"));
|
|
390
|
+
return own.length === 0 ? makeIgnores(patterns) : makeIgnores([...patterns, ...own]);
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function readIgnoreFile(file: string): RegExp[] {
|
|
396
|
+
let text: string;
|
|
397
|
+
try {
|
|
398
|
+
text = readFileSync(file, "utf8");
|
|
399
|
+
} catch {
|
|
400
|
+
return [];
|
|
401
|
+
}
|
|
402
|
+
return text
|
|
403
|
+
.split("\n")
|
|
404
|
+
.map((line) => line.trim())
|
|
405
|
+
.filter((line) => line !== "" && !line.startsWith("#") && !line.startsWith("!"))
|
|
406
|
+
.map((line) => line.replace(/\/+$/, ""))
|
|
407
|
+
.filter((line) => !line.includes("/"))
|
|
408
|
+
.map(globToRegExp);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function globToRegExp(pattern: string): RegExp {
|
|
412
|
+
const body = pattern
|
|
413
|
+
.split("")
|
|
414
|
+
.map((char) => (char === "*" ? "[^/]*" : char === "?" ? "[^/]" : escapeRegExp(char)))
|
|
415
|
+
.join("");
|
|
416
|
+
return new RegExp(`^${body}$`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function escapeRegExp(char: string): string {
|
|
420
|
+
return /[\\^$.|?*+()[\]{}]/.test(char) ? `\\${char}` : char;
|
|
421
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export {
|
|
2
|
+
canonical,
|
|
3
|
+
Containment,
|
|
4
|
+
type DirArgs,
|
|
5
|
+
type Located,
|
|
6
|
+
type PathArgs,
|
|
7
|
+
type RootsSource,
|
|
8
|
+
sees,
|
|
9
|
+
type SessionRoots,
|
|
10
|
+
type Viewer,
|
|
11
|
+
within,
|
|
12
|
+
} from "./containment.ts";
|
|
13
|
+
export { fileHandlers } from "./files.ts";
|
|
14
|
+
export { isSandboxOrigin, sandboxCapabilities, SandboxGrants, sandboxHandlers } from "./sandbox.ts";
|
|
Binary file
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** What a harness lifecycle event tells the command it fires.
|
|
2
|
+
*
|
|
3
|
+
* The harness writes one JSON object to the command's standard input, and only
|
|
4
|
+
* the fields a greeting or a departure is made of are taken from it. The names
|
|
5
|
+
* are the harness's own (`session_id`, `cwd`, `transcript_path`, `reason`);
|
|
6
|
+
* they are read here and nowhere else, so the rest of the CLI speaks the
|
|
7
|
+
* contract's names. */
|
|
8
|
+
export interface HookEvent {
|
|
9
|
+
readonly sid?: string;
|
|
10
|
+
readonly cwd?: string;
|
|
11
|
+
readonly transcript_path?: string;
|
|
12
|
+
readonly reason?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Read one such event, or nothing to go on.
|
|
16
|
+
*
|
|
17
|
+
* A hook runs beside a session rather than for a person, so nothing it is
|
|
18
|
+
* handed is an error: input that is absent, truncated or not an object leaves
|
|
19
|
+
* the command with whatever its own options gave it. What the command does
|
|
20
|
+
* with nothing is the command's decision, and for every command that reads one
|
|
21
|
+
* that decision is to stay quiet. */
|
|
22
|
+
export async function hookEvent(
|
|
23
|
+
read: () => Promise<string> = () => Bun.stdin.text(),
|
|
24
|
+
): Promise<HookEvent> {
|
|
25
|
+
let parsed: unknown;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(await read());
|
|
28
|
+
} catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
if (typeof parsed !== "object" || parsed === null) return {};
|
|
32
|
+
const event = parsed as Record<string, unknown>;
|
|
33
|
+
return {
|
|
34
|
+
...text(event, "session_id", "sid"),
|
|
35
|
+
...text(event, "cwd", "cwd"),
|
|
36
|
+
...text(event, "transcript_path", "transcript_path"),
|
|
37
|
+
...text(event, "reason", "reason"),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function text(
|
|
42
|
+
event: Record<string, unknown>,
|
|
43
|
+
from: string,
|
|
44
|
+
as: keyof HookEvent,
|
|
45
|
+
): Partial<HookEvent> {
|
|
46
|
+
const value = event[from];
|
|
47
|
+
return typeof value === "string" && value !== "" ? { [as]: value } : {};
|
|
48
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { basename, dirname } from "node:path";
|
|
2
|
+
import type { HelloArgs } from "@ccmsg/protocol";
|
|
3
|
+
|
|
4
|
+
/** What a session can say about itself when it greets: the contract's shared
|
|
5
|
+
* fields, each of them optional because a greeting states what it knows and
|
|
6
|
+
* the instance leaves the rest unknown (contract, `SessionMetaFields`). */
|
|
7
|
+
export type StatedMeta = Partial<
|
|
8
|
+
Pick<HelloArgs, "repo" | "ws" | "cwd" | "repo_root" | "branch" | "transcript_path" | "title">
|
|
9
|
+
>;
|
|
10
|
+
|
|
11
|
+
/** How a question is put to the version control the session works under. The
|
|
12
|
+
* answer is its output, or nothing when there is none to give. Named so a test
|
|
13
|
+
* can answer for a tree it never has to create. */
|
|
14
|
+
export type Ask = (args: readonly string[], cwd: string) => string | undefined;
|
|
15
|
+
|
|
16
|
+
const askGit: Ask = (args, cwd) => {
|
|
17
|
+
try {
|
|
18
|
+
const done = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
19
|
+
return done.exitCode === 0 ? done.stdout.toString() : undefined;
|
|
20
|
+
} catch {
|
|
21
|
+
// No git on this host, or a working directory that is not there to run it
|
|
22
|
+
// in. Both are "nothing can be said about a repository here", which is a
|
|
23
|
+
// thing a session is allowed to be.
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** What `git rev-parse` is asked, in one exchange: where the worktree begins,
|
|
29
|
+
* and what is checked out in it. The two answers arrive in this order, one per
|
|
30
|
+
* line. */
|
|
31
|
+
const WHERE = ["rev-parse", "--show-toplevel", "--abbrev-ref", "HEAD"] as const;
|
|
32
|
+
|
|
33
|
+
/** The name `--abbrev-ref` gives a head that is on no branch. Read as "not on
|
|
34
|
+
* one" rather than as a branch called HEAD. */
|
|
35
|
+
const DETACHED = "HEAD";
|
|
36
|
+
|
|
37
|
+
/** Where this process is working, as a session would state it.
|
|
38
|
+
*
|
|
39
|
+
* `repo` and `ws` are display names for a layout, and the layout read here is
|
|
40
|
+
* the one the contract describes: a repository is a container and a workspace
|
|
41
|
+
* is a checkout inside it, which is what makes `repo_root` the place sibling
|
|
42
|
+
* workspaces are reachable from (contract, `SessionMetaFields.repo_root`). So
|
|
43
|
+
* the worktree git reports is the workspace, its parent is the repository, and
|
|
44
|
+
* a tree that is not one workspace among siblings simply reads as a repository
|
|
45
|
+
* with a single one.
|
|
46
|
+
*
|
|
47
|
+
* Outside a repository only the working directory is stated. Nothing is
|
|
48
|
+
* guessed from a path that git does not stand behind — a session that names
|
|
49
|
+
* neither is shown by its sid, which is a thing the instance already does.
|
|
50
|
+
*
|
|
51
|
+
* `transcript_path` and `title` are never derived: one is a file this process
|
|
52
|
+
* has no way to find and the other is the session's own name for itself. Both
|
|
53
|
+
* reach a greeting only by being handed in. */
|
|
54
|
+
export function statedMeta(cwd: string = process.cwd(), ask: Ask = askGit): StatedMeta {
|
|
55
|
+
const answer = ask(WHERE, cwd);
|
|
56
|
+
const [top, branch] = (answer ?? "").split("\n").map((line) => line.trim());
|
|
57
|
+
if (top === undefined || top === "") return { cwd };
|
|
58
|
+
const root = dirname(top);
|
|
59
|
+
return {
|
|
60
|
+
repo: basename(root),
|
|
61
|
+
ws: basename(top),
|
|
62
|
+
cwd,
|
|
63
|
+
repo_root: root,
|
|
64
|
+
...(branch === undefined || branch === "" || branch === DETACHED ? {} : { branch }),
|
|
65
|
+
};
|
|
66
|
+
}
|