@ccmsg/cli 0.12.0 → 0.14.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/package.json +2 -2
- package/src/auth/admin.ts +5 -2
- package/src/auth/auth.ts +17 -17
- package/src/auth/records.ts +64 -42
- package/src/cli.ts +38 -5
- package/src/daemon/snapshot.ts +3 -3
- package/src/daemon/supervise.ts +15 -7
- package/src/dispatch/dispatch.ts +4 -4
- package/src/files/containment.ts +42 -19
- package/src/files/files.ts +123 -104
- package/src/files/sandbox.ts +0 -0
- package/src/instance/instance.ts +43 -16
- package/src/instance/log.ts +29 -13
- package/src/kv/store.ts +104 -56
- package/src/launcher/launcher.ts +8 -5
- package/src/launcher/roots.ts +10 -7
- package/src/launcher/tree.ts +15 -12
- package/src/mesh/instances.ts +5 -5
- package/src/mesh/mesh.ts +11 -11
- package/src/mesh/relay.ts +8 -8
- package/src/messaging/delivery.ts +126 -27
- package/src/messaging/direct.ts +105 -39
- package/src/messaging/inbox.ts +72 -18
- package/src/messaging/notify.ts +7 -2
- package/src/sessions/dump.ts +73 -11
- package/src/sessions/fork.ts +21 -14
- package/src/sessions/handlers.ts +9 -7
- package/src/sessions/items.ts +10 -8
- package/src/sessions/last-live.ts +28 -5
- package/src/sessions/registry.ts +43 -26
- package/src/sessions/search.ts +16 -7
- package/src/sessions/status.ts +3 -3
- package/src/topics/egress.ts +2 -2
- package/src/topics/topics.ts +0 -0
- package/src/transcript/files.ts +65 -23
- package/src/transcript/read.ts +10 -10
- package/src/transcript/scan.ts +49 -0
package/src/files/containment.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
2
3
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
4
|
import type { FileKind, Role, Sid } from "@ccmsg/protocol";
|
|
4
5
|
import { OpError } from "../dispatch/index.ts";
|
|
@@ -69,11 +70,11 @@ export class Containment {
|
|
|
69
70
|
constructor(private readonly source: RootsSource) {}
|
|
70
71
|
|
|
71
72
|
/** A path named by kind, as an op's arguments give it. */
|
|
72
|
-
locate(args: PathArgs, viewer: Viewer = {}): Located {
|
|
73
|
+
async locate(args: PathArgs, viewer: Viewer = {}): Promise<Located> {
|
|
73
74
|
const roots = this.rootsFor(args.sid, viewer);
|
|
74
|
-
const named = this.absolute(args, roots);
|
|
75
|
-
const real = canonical(named);
|
|
76
|
-
return { ...this.admit(args.kind, real, roots), named };
|
|
75
|
+
const named = await this.absolute(args, roots);
|
|
76
|
+
const real = await canonical(named);
|
|
77
|
+
return { ...(await this.admit(args.kind, real, roots)), named };
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/** An absolute path with no kind: which surface admits it, if any.
|
|
@@ -81,7 +82,7 @@ export class Containment {
|
|
|
81
82
|
* The surfaces are tried in the order the contract states, and the answer is
|
|
82
83
|
* one value for every refusal — outside the allowlists, or simply not there —
|
|
83
84
|
* so a caller cannot learn from it whether a path it may not read exists. */
|
|
84
|
-
identify(sid: Sid, path: string, viewer: Viewer = {}): Located | undefined {
|
|
85
|
+
async identify(sid: Sid, path: string, viewer: Viewer = {}): Promise<Located | undefined> {
|
|
85
86
|
let roots: SessionRoots;
|
|
86
87
|
try {
|
|
87
88
|
roots = this.rootsFor(sid, viewer);
|
|
@@ -90,10 +91,10 @@ export class Containment {
|
|
|
90
91
|
}
|
|
91
92
|
if (!isAbsolute(path)) return undefined;
|
|
92
93
|
const named = resolve(path);
|
|
93
|
-
const real = canonical(named);
|
|
94
|
+
const real = await canonical(named);
|
|
94
95
|
for (const kind of KINDS) {
|
|
95
96
|
try {
|
|
96
|
-
return { ...this.admit(kind, real, roots), named };
|
|
97
|
+
return { ...(await this.admit(kind, real, roots)), named };
|
|
97
98
|
} catch {
|
|
98
99
|
// The next surface may admit it; running out of surfaces is the miss.
|
|
99
100
|
}
|
|
@@ -106,15 +107,15 @@ export class Containment {
|
|
|
106
107
|
* fixed to (DR-0019). A name that leaves the inbox is refused as unwritable
|
|
107
108
|
* rather than as forbidden — the path is reachable, and only writing there
|
|
108
109
|
* is not. */
|
|
109
|
-
inbox(sid: Sid, path: string, viewer: Viewer = {}): Located {
|
|
110
|
+
async inbox(sid: Sid, path: string, viewer: Viewer = {}): Promise<Located> {
|
|
110
111
|
const roots = this.rootsFor(sid, viewer);
|
|
111
112
|
const cwd = roots.cwd;
|
|
112
113
|
if (cwd === undefined || !isAbsolute(cwd)) {
|
|
113
114
|
throw new OpError("path_forbidden", `${sid} states no working directory to write into`);
|
|
114
115
|
}
|
|
115
|
-
const base = canonical(cwd);
|
|
116
|
+
const base = await canonical(cwd);
|
|
116
117
|
const named = resolve(base, path);
|
|
117
|
-
const real = canonical(named);
|
|
118
|
+
const real = await canonical(named);
|
|
118
119
|
const inbox = join(base, INBOX);
|
|
119
120
|
if (!within(real, inbox)) {
|
|
120
121
|
throw new OpError("path_not_writable", `only ${INBOX}/ takes a written file`);
|
|
@@ -123,7 +124,7 @@ export class Containment {
|
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
/** The directory a listing or a walk starts from. */
|
|
126
|
-
root(args: DirArgs, viewer: Viewer = {}): Located {
|
|
127
|
+
root(args: DirArgs, viewer: Viewer = {}): Promise<Located> {
|
|
127
128
|
return this.locate({ sid: args.sid, kind: args.kind, path: args.path ?? "" }, viewer);
|
|
128
129
|
}
|
|
129
130
|
|
|
@@ -142,13 +143,13 @@ export class Containment {
|
|
|
142
143
|
}
|
|
143
144
|
|
|
144
145
|
/** Turn an op's `path` into an absolute one, in the shape its kind states. */
|
|
145
|
-
private absolute(args: PathArgs, roots: SessionRoots): string {
|
|
146
|
+
private async absolute(args: PathArgs, roots: SessionRoots): Promise<string> {
|
|
146
147
|
if (args.kind === "contained") {
|
|
147
148
|
const root = roots.root;
|
|
148
149
|
if (root === undefined || !isAbsolute(root)) {
|
|
149
150
|
throw new OpError("path_forbidden", "this session states no root to be contained by");
|
|
150
151
|
}
|
|
151
|
-
return resolve(canonical(root), `.${sep}${args.path}`);
|
|
152
|
+
return resolve(await canonical(root), `.${sep}${args.path}`);
|
|
152
153
|
}
|
|
153
154
|
if (!isAbsolute(args.path)) {
|
|
154
155
|
throw new OpError("path_forbidden", `a ${args.kind} path is absolute`);
|
|
@@ -159,17 +160,21 @@ export class Containment {
|
|
|
159
160
|
/** Whether a resolved path is inside the surface it claims. The check runs on
|
|
160
161
|
* what the filesystem resolved, so a symlink pointing out of a root is
|
|
161
162
|
* refused however it was spelled (DR-0008 §3). */
|
|
162
|
-
private admit(
|
|
163
|
+
private async admit(
|
|
164
|
+
kind: FileKind,
|
|
165
|
+
real: string,
|
|
166
|
+
roots: SessionRoots,
|
|
167
|
+
): Promise<Omit<Located, "named">> {
|
|
163
168
|
if (kind === "contained") {
|
|
164
|
-
const root = roots.root === undefined ? undefined : canonical(roots.root);
|
|
169
|
+
const root = roots.root === undefined ? undefined : await canonical(roots.root);
|
|
165
170
|
if (root === undefined || !within(real, root)) {
|
|
166
171
|
throw new OpError("path_forbidden", "the path is outside the session's root");
|
|
167
172
|
}
|
|
168
173
|
return { kind, real, path: relativeTo(root, real) };
|
|
169
174
|
}
|
|
170
175
|
if (kind === "workspace") {
|
|
171
|
-
const
|
|
172
|
-
if (folder
|
|
176
|
+
const folders = await Promise.all(roots.workspace_folders.map(canonical));
|
|
177
|
+
if (!folders.some((folder) => within(real, folder))) {
|
|
173
178
|
throw new OpError("path_forbidden", "the path is in no workspace folder of this session");
|
|
174
179
|
}
|
|
175
180
|
return { kind, real, path: real };
|
|
@@ -179,7 +184,9 @@ export class Containment {
|
|
|
179
184
|
// path spelled through a symlink and the same file spelled directly are
|
|
180
185
|
// one entry, and a path since replaced by a symlink resolves elsewhere and
|
|
181
186
|
// is no longer in the list.
|
|
182
|
-
const named = roots.external_files.
|
|
187
|
+
const named = (await Promise.all(roots.external_files.map(canonical))).some(
|
|
188
|
+
(each) => each === real,
|
|
189
|
+
);
|
|
183
190
|
if (!named) {
|
|
184
191
|
throw new OpError("path_forbidden", "the path is not one this session's transcript named");
|
|
185
192
|
}
|
|
@@ -234,7 +241,23 @@ const KINDS = ["contained", "workspace", "external"] as const;
|
|
|
234
241
|
* last segment back, so a file about to be created is decided by where it would
|
|
235
242
|
* land rather than being refused for not being there. A parent that does not
|
|
236
243
|
* resolve either leaves the path as written, which no surface admits. */
|
|
237
|
-
export function canonical(path: string): string {
|
|
244
|
+
export async function canonical(path: string): Promise<string> {
|
|
245
|
+
const absolute = resolve(path);
|
|
246
|
+
try {
|
|
247
|
+
return await realpath(absolute);
|
|
248
|
+
} catch {
|
|
249
|
+
const parent = dirname(absolute);
|
|
250
|
+
if (parent === absolute) return absolute;
|
|
251
|
+
try {
|
|
252
|
+
return join(await realpath(parent), basename(absolute));
|
|
253
|
+
} catch {
|
|
254
|
+
return absolute;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The synchronous form required while `session.status` states its value synchronously (DESIGN §6); an asynchronous topic value uses `canonical` as the single path. */
|
|
260
|
+
export function canonicalSync(path: string): string {
|
|
238
261
|
const absolute = resolve(path);
|
|
239
262
|
try {
|
|
240
263
|
return realpathSync(absolute);
|
package/src/files/files.ts
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
writeFileSync,
|
|
13
|
-
} from "node:fs";
|
|
2
|
+
lstat,
|
|
3
|
+
mkdir,
|
|
4
|
+
open,
|
|
5
|
+
readdir,
|
|
6
|
+
readFile,
|
|
7
|
+
rename,
|
|
8
|
+
stat,
|
|
9
|
+
unlink,
|
|
10
|
+
writeFile,
|
|
11
|
+
} from "node:fs/promises";
|
|
14
12
|
import type { Dirent } from "node:fs";
|
|
15
13
|
import { basename, dirname, join, relative } from "node:path";
|
|
16
14
|
import type {
|
|
@@ -62,27 +60,34 @@ const FIND_VISITS = 20_000;
|
|
|
62
60
|
* caller — the role dispatch states for a `scope: "role"` op, and the session
|
|
63
61
|
* the connection speaks for — is handed over unread. */
|
|
64
62
|
export function fileHandlers(paths: Containment) {
|
|
65
|
-
const viewer = (input: HandlerInput): Viewer => ({
|
|
63
|
+
const viewer = (input: HandlerInput): Viewer => ({
|
|
64
|
+
role: input.role,
|
|
65
|
+
sid: input.identity?.sid,
|
|
66
|
+
});
|
|
66
67
|
|
|
67
68
|
return {
|
|
68
|
-
"dir.list": (input: HandlerInput): DirListResult => {
|
|
69
|
+
"dir.list": async (input: HandlerInput): Promise<DirListResult> => {
|
|
69
70
|
const args = input.args as unknown as DirListArgs;
|
|
70
|
-
const at = paths.root(args, viewer(input));
|
|
71
|
-
const stat = existing(at);
|
|
71
|
+
const at = await paths.root(args, viewer(input));
|
|
72
|
+
const stat = await existing(at);
|
|
72
73
|
if (!stat.isDirectory()) throw new OpError("not_found", `${args.path ?? ""} is not a folder`);
|
|
73
|
-
return {
|
|
74
|
+
return {
|
|
75
|
+
sid: args.sid,
|
|
76
|
+
path: at.path,
|
|
77
|
+
entries: await entriesOf(at.real),
|
|
78
|
+
};
|
|
74
79
|
},
|
|
75
80
|
|
|
76
|
-
"file.read": (input: HandlerInput): FileReadResult => {
|
|
81
|
+
"file.read": async (input: HandlerInput): Promise<FileReadResult> => {
|
|
77
82
|
const args = input.args as unknown as FileReadArgs;
|
|
78
|
-
const at = paths.locate(args, viewer(input));
|
|
79
|
-
const stat = existing(at);
|
|
83
|
+
const at = await paths.locate(args, viewer(input));
|
|
84
|
+
const stat = await existing(at);
|
|
80
85
|
if (!stat.isFile()) throw new OpError("not_found", `${args.path} is not a file`);
|
|
81
86
|
// As much as the answer may carry and no more: a file larger than the
|
|
82
87
|
// limit is answered from its head, so reading it whole would cost the
|
|
83
88
|
// instance the whole of a file whose size is what the limit exists to
|
|
84
89
|
// refuse.
|
|
85
|
-
const head = bytesOf(at.real, READ_LIMIT);
|
|
90
|
+
const head = await bytesOf(at.real, READ_LIMIT);
|
|
86
91
|
const binary = isBinary(head);
|
|
87
92
|
return {
|
|
88
93
|
sid: args.sid,
|
|
@@ -95,115 +100,126 @@ export function fileHandlers(paths: Containment) {
|
|
|
95
100
|
};
|
|
96
101
|
},
|
|
97
102
|
|
|
98
|
-
"file.write": (input: HandlerInput): FileWriteResult => {
|
|
103
|
+
"file.write": async (input: HandlerInput): Promise<FileWriteResult> => {
|
|
99
104
|
const args = input.args as unknown as FileWriteArgs;
|
|
100
|
-
const at = paths.inbox(args.sid, args.path, viewer(input));
|
|
105
|
+
const at = await paths.inbox(args.sid, args.path, viewer(input));
|
|
101
106
|
// The inbox takes new notes, so an existing name is refused rather than
|
|
102
107
|
// replaced; the folder itself is made, since a repository that has never
|
|
103
108
|
// had one is exactly where the first note goes (DR-0019 §2.1).
|
|
104
|
-
|
|
105
|
-
create(at.real, args.content);
|
|
109
|
+
await mkdir(dirname(at.real), { recursive: true });
|
|
110
|
+
await create(at.real, args.content);
|
|
106
111
|
return { sid: args.sid, path: at.path };
|
|
107
112
|
},
|
|
108
113
|
|
|
109
|
-
"file.create": (input: HandlerInput): FileCreateResult => {
|
|
114
|
+
"file.create": async (input: HandlerInput): Promise<FileCreateResult> => {
|
|
110
115
|
const args = input.args as unknown as FileCreateArgs;
|
|
111
|
-
const at = paths.locate(args, viewer(input));
|
|
116
|
+
const at = await paths.locate(args, viewer(input));
|
|
112
117
|
const parent = dirname(at.real);
|
|
113
|
-
if (!isDirectory(parent)) {
|
|
118
|
+
if (!(await isDirectory(parent))) {
|
|
114
119
|
throw new OpError("not_found", `${args.path} has no folder to be created in`);
|
|
115
120
|
}
|
|
116
|
-
create(at.real, args.content);
|
|
121
|
+
await create(at.real, args.content);
|
|
117
122
|
return { sid: args.sid, path: at.path };
|
|
118
123
|
},
|
|
119
124
|
|
|
120
|
-
"file.edit": (input: HandlerInput): FileEditResult => {
|
|
125
|
+
"file.edit": async (input: HandlerInput): Promise<FileEditResult> => {
|
|
121
126
|
const args = input.args as unknown as FileEditArgs;
|
|
122
|
-
const at = paths.locate(args, viewer(input));
|
|
123
|
-
const
|
|
124
|
-
if (!
|
|
125
|
-
if (isBinary(bytesOf(at.real, SNIFF))) {
|
|
127
|
+
const at = await paths.locate(args, viewer(input));
|
|
128
|
+
const before = await existing(at);
|
|
129
|
+
if (!before.isFile()) throw new OpError("not_found", `${args.path} is not a file`);
|
|
130
|
+
if (isBinary(await bytesOf(at.real, SNIFF))) {
|
|
126
131
|
throw new OpError("not_a_text_file", `${args.path} holds binary content`);
|
|
127
132
|
}
|
|
128
|
-
if (mtimeOf(
|
|
133
|
+
if (mtimeOf(before) !== args.expected_mtime_at || before.size !== args.expected_size) {
|
|
129
134
|
throw new OpError("file_conflict", `${args.path} changed since it was read`);
|
|
130
135
|
}
|
|
131
|
-
replace(at.real, args.content);
|
|
132
|
-
const after =
|
|
133
|
-
return {
|
|
136
|
+
await replace(at.real, args.content);
|
|
137
|
+
const after = await stat(at.real);
|
|
138
|
+
return {
|
|
139
|
+
sid: args.sid,
|
|
140
|
+
path: at.path,
|
|
141
|
+
size: after.size,
|
|
142
|
+
mtime_at: mtimeOf(after),
|
|
143
|
+
};
|
|
134
144
|
},
|
|
135
145
|
|
|
136
|
-
"file.delete": (input: HandlerInput): FileDeleteResult => {
|
|
146
|
+
"file.delete": async (input: HandlerInput): Promise<FileDeleteResult> => {
|
|
137
147
|
const args = input.args as unknown as FileDeleteArgs;
|
|
138
|
-
const at = paths.locate(args, viewer(input));
|
|
148
|
+
const at = await paths.locate(args, viewer(input));
|
|
139
149
|
// What is unlinked is what is named, so this reads the name itself rather
|
|
140
150
|
// than what it resolves to: a symlink is refused as the wrong kind of
|
|
141
151
|
// thing instead of taking its target's answer. The resolved path is the
|
|
142
152
|
// one containment admitted and would answer for the target, which is the
|
|
143
153
|
// file a link inside the root could otherwise be pointed at.
|
|
144
|
-
const stat = lstatOf(at.named);
|
|
154
|
+
const stat = await lstatOf(at.named);
|
|
145
155
|
if (stat === undefined) throw new OpError("not_found", `${args.path} is not there`);
|
|
146
156
|
if (!stat.isFile()) {
|
|
147
157
|
throw new OpError("path_forbidden", `${args.path} is not a plain file`);
|
|
148
158
|
}
|
|
149
|
-
|
|
159
|
+
await unlink(at.named);
|
|
150
160
|
return { sid: args.sid, path: at.path };
|
|
151
161
|
},
|
|
152
162
|
|
|
153
|
-
"file.find": (input: HandlerInput): FileFindResult => {
|
|
163
|
+
"file.find": async (input: HandlerInput): Promise<FileFindResult> => {
|
|
154
164
|
const args = input.args as unknown as FileFindArgs;
|
|
155
|
-
const at = paths.root(
|
|
156
|
-
{
|
|
165
|
+
const at = await paths.root(
|
|
166
|
+
{
|
|
167
|
+
sid: args.sid,
|
|
168
|
+
kind: args.kind,
|
|
169
|
+
...(args.root === undefined ? {} : { path: args.root }),
|
|
170
|
+
},
|
|
157
171
|
viewer(input),
|
|
158
172
|
);
|
|
159
173
|
const terms = parseQuery(args.query);
|
|
160
174
|
// A query with nothing to include matches nothing rather than the whole
|
|
161
175
|
// tree, so a cleared search box costs no walk at all.
|
|
162
176
|
if (terms.include.length === 0) return { sid: args.sid, hits: [], truncated: false };
|
|
163
|
-
const walk = find(at, terms, args.respect_gitignore ?? true);
|
|
177
|
+
const walk = await find(at, terms, args.respect_gitignore ?? true);
|
|
164
178
|
return { sid: args.sid, hits: walk.hits, truncated: walk.truncated };
|
|
165
179
|
},
|
|
166
180
|
|
|
167
|
-
"file.stat": (input: HandlerInput): FileStatResult => {
|
|
181
|
+
"file.stat": async (input: HandlerInput): Promise<FileStatResult> => {
|
|
168
182
|
const args = input.args as unknown as FileStatArgs;
|
|
169
|
-
const results =
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
183
|
+
const results = await Promise.all(
|
|
184
|
+
args.paths.map(async (path): Promise<FileStatEntry | null> => {
|
|
185
|
+
const at = await paths.identify(args.sid, path, viewer(input));
|
|
186
|
+
if (at === undefined || !(await isFile(at.real))) return null;
|
|
187
|
+
return { kind: at.kind, path: at.path };
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
174
190
|
return { results };
|
|
175
191
|
},
|
|
176
192
|
};
|
|
177
193
|
}
|
|
178
194
|
|
|
179
195
|
/** The file a located path names, or the contract's word for "not there". */
|
|
180
|
-
function existing(at: Located) {
|
|
196
|
+
async function existing(at: Located) {
|
|
181
197
|
try {
|
|
182
|
-
return
|
|
198
|
+
return await stat(at.real);
|
|
183
199
|
} catch {
|
|
184
200
|
throw new OpError("not_found", `${at.path} is not there`);
|
|
185
201
|
}
|
|
186
202
|
}
|
|
187
203
|
|
|
188
|
-
function lstatOf(path: string) {
|
|
204
|
+
async function lstatOf(path: string) {
|
|
189
205
|
try {
|
|
190
|
-
return
|
|
206
|
+
return await lstat(path);
|
|
191
207
|
} catch {
|
|
192
208
|
return undefined;
|
|
193
209
|
}
|
|
194
210
|
}
|
|
195
211
|
|
|
196
|
-
function isFile(path: string): boolean {
|
|
212
|
+
async function isFile(path: string): Promise<boolean> {
|
|
197
213
|
try {
|
|
198
|
-
return
|
|
214
|
+
return (await stat(path)).isFile();
|
|
199
215
|
} catch {
|
|
200
216
|
return false;
|
|
201
217
|
}
|
|
202
218
|
}
|
|
203
219
|
|
|
204
|
-
function isDirectory(path: string): boolean {
|
|
220
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
205
221
|
try {
|
|
206
|
-
return
|
|
222
|
+
return (await stat(path)).isDirectory();
|
|
207
223
|
} catch {
|
|
208
224
|
return false;
|
|
209
225
|
}
|
|
@@ -216,14 +232,14 @@ function mtimeOf(stat: { mtimeMs: number }): Timestamp {
|
|
|
216
232
|
}
|
|
217
233
|
|
|
218
234
|
/** A file's leading bytes, at most `limit` of them. */
|
|
219
|
-
function bytesOf(path: string, limit: number): Buffer {
|
|
220
|
-
const
|
|
235
|
+
async function bytesOf(path: string, limit: number): Promise<Buffer> {
|
|
236
|
+
const handle = await open(path, "r");
|
|
221
237
|
try {
|
|
222
238
|
const buffer = Buffer.alloc(limit);
|
|
223
|
-
const
|
|
224
|
-
return buffer.subarray(0,
|
|
239
|
+
const { bytesRead } = await handle.read(buffer, 0, limit, 0);
|
|
240
|
+
return buffer.subarray(0, bytesRead);
|
|
225
241
|
} finally {
|
|
226
|
-
|
|
242
|
+
await handle.close();
|
|
227
243
|
}
|
|
228
244
|
}
|
|
229
245
|
|
|
@@ -233,9 +249,9 @@ function isBinary(bytes: Buffer): boolean {
|
|
|
233
249
|
|
|
234
250
|
/** Write a file that must not be there yet. The exclusive open is what decides
|
|
235
251
|
* it: a check followed by a write would answer about the moment before. */
|
|
236
|
-
function create(path: string, content: string): void {
|
|
252
|
+
async function create(path: string, content: string): Promise<void> {
|
|
237
253
|
try {
|
|
238
|
-
|
|
254
|
+
await writeFile(path, content, { flag: "wx" });
|
|
239
255
|
} catch (cause) {
|
|
240
256
|
if ((cause as NodeJS.ErrnoException).code === "EEXIST") {
|
|
241
257
|
throw new OpError("file_exists", `${basename(path)} is already there`);
|
|
@@ -247,39 +263,42 @@ function create(path: string, content: string): void {
|
|
|
247
263
|
/** Replace a file's content whole. The write lands beside it and is renamed
|
|
248
264
|
* over it, so a reader sees either the old file or the new one and never a
|
|
249
265
|
* half-written one. */
|
|
250
|
-
function replace(path: string, content: string): void {
|
|
266
|
+
async function replace(path: string, content: string): Promise<void> {
|
|
251
267
|
const temporary = `${path}.ccmsg-${process.pid}-${Date.now()}`;
|
|
252
|
-
|
|
268
|
+
await writeFile(temporary, content);
|
|
253
269
|
try {
|
|
254
|
-
|
|
270
|
+
await rename(temporary, path);
|
|
255
271
|
} catch (cause) {
|
|
256
|
-
|
|
272
|
+
await unlink(temporary);
|
|
257
273
|
throw cause;
|
|
258
274
|
}
|
|
259
275
|
}
|
|
260
276
|
|
|
261
|
-
function entriesOf(dir: string): DirEntry[] {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
? "
|
|
268
|
-
: entry.
|
|
269
|
-
? "
|
|
270
|
-
:
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
277
|
+
async function entriesOf(dir: string): Promise<DirEntry[]> {
|
|
278
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
279
|
+
return (
|
|
280
|
+
await Promise.all(
|
|
281
|
+
entries.map(async (entry): Promise<DirEntry> => {
|
|
282
|
+
const type = entry.isSymbolicLink()
|
|
283
|
+
? "symlink"
|
|
284
|
+
: entry.isDirectory()
|
|
285
|
+
? "dir"
|
|
286
|
+
: entry.isFile()
|
|
287
|
+
? "file"
|
|
288
|
+
: "other";
|
|
289
|
+
// A symlink is reported as itself, so what is stated about it is the link
|
|
290
|
+
// and never what it points at — including one pointing out of the root,
|
|
291
|
+
// which is listed here and refuses to resolve everywhere else.
|
|
292
|
+
const info = type === "symlink" ? undefined : await lstatOf(join(dir, entry.name));
|
|
293
|
+
return {
|
|
294
|
+
name: entry.name,
|
|
295
|
+
type,
|
|
296
|
+
...(info?.isFile() === true ? { size: info.size } : {}),
|
|
297
|
+
...(info === undefined ? {} : { mtime_at: mtimeOf(info) }),
|
|
298
|
+
};
|
|
299
|
+
}),
|
|
300
|
+
)
|
|
301
|
+
).sort((a, b) => a.name.localeCompare(b.name));
|
|
283
302
|
}
|
|
284
303
|
|
|
285
304
|
interface Terms {
|
|
@@ -312,17 +331,17 @@ function matches(path: string, terms: Terms): boolean {
|
|
|
312
331
|
* Both caps are reported the same way: the hits are the ones found, and
|
|
313
332
|
* `truncated` says they are not the whole match set. Saying so is better than
|
|
314
333
|
* implying these are all. */
|
|
315
|
-
function find(at: Located, terms: Terms, respectGitignore: boolean) {
|
|
334
|
+
async function find(at: Located, terms: Terms, respectGitignore: boolean) {
|
|
316
335
|
const hits: FileFindHit[] = [];
|
|
317
336
|
let visits = 0;
|
|
318
337
|
let truncated = false;
|
|
319
338
|
|
|
320
|
-
const walk = (dir: string, ignored: Ignores): void => {
|
|
339
|
+
const walk = async (dir: string, ignored: Ignores): Promise<void> => {
|
|
321
340
|
if (truncated) return;
|
|
322
|
-
const here = respectGitignore ? ignored.descend(dir) : ignored;
|
|
341
|
+
const here = respectGitignore ? await ignored.descend(dir) : ignored;
|
|
323
342
|
let entries: Dirent[];
|
|
324
343
|
try {
|
|
325
|
-
entries =
|
|
344
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
326
345
|
} catch {
|
|
327
346
|
// A folder that cannot be read contributes nothing, and a walk that
|
|
328
347
|
// stopped at one would answer less than it can.
|
|
@@ -349,13 +368,13 @@ function find(at: Located, terms: Terms, respectGitignore: boolean) {
|
|
|
349
368
|
// Only real directories are descended: a symlink is answered as itself,
|
|
350
369
|
// and following one would walk out of the root the walk is bounded by.
|
|
351
370
|
if (isDir && !entry.isSymbolicLink()) {
|
|
352
|
-
walk(full, here);
|
|
371
|
+
await walk(full, here);
|
|
353
372
|
if (truncated) return;
|
|
354
373
|
}
|
|
355
374
|
}
|
|
356
375
|
};
|
|
357
376
|
|
|
358
|
-
walk(at.real, EMPTY_IGNORES);
|
|
377
|
+
await walk(at.real, EMPTY_IGNORES);
|
|
359
378
|
return { hits, truncated };
|
|
360
379
|
}
|
|
361
380
|
|
|
@@ -372,7 +391,7 @@ function find(at: Located, terms: Terms, respectGitignore: boolean) {
|
|
|
372
391
|
* hiding one. */
|
|
373
392
|
interface Ignores {
|
|
374
393
|
hides(name: string, isDir: boolean): boolean;
|
|
375
|
-
descend(dir: string): Ignores
|
|
394
|
+
descend(dir: string): Promise<Ignores>;
|
|
376
395
|
}
|
|
377
396
|
|
|
378
397
|
const ALWAYS_HIDDEN = new Set([".git"]);
|
|
@@ -385,17 +404,17 @@ function makeIgnores(patterns: readonly RegExp[]): Ignores {
|
|
|
385
404
|
if (ALWAYS_HIDDEN.has(name)) return true;
|
|
386
405
|
return patterns.some((pattern) => pattern.test(name));
|
|
387
406
|
},
|
|
388
|
-
descend(dir) {
|
|
389
|
-
const own = readIgnoreFile(join(dir, ".gitignore"));
|
|
407
|
+
async descend(dir) {
|
|
408
|
+
const own = await readIgnoreFile(join(dir, ".gitignore"));
|
|
390
409
|
return own.length === 0 ? makeIgnores(patterns) : makeIgnores([...patterns, ...own]);
|
|
391
410
|
},
|
|
392
411
|
};
|
|
393
412
|
}
|
|
394
413
|
|
|
395
|
-
function readIgnoreFile(file: string): RegExp[] {
|
|
414
|
+
async function readIgnoreFile(file: string): Promise<RegExp[]> {
|
|
396
415
|
let text: string;
|
|
397
416
|
try {
|
|
398
|
-
text =
|
|
417
|
+
text = await readFile(file, "utf8");
|
|
399
418
|
} catch {
|
|
400
419
|
return [];
|
|
401
420
|
}
|
package/src/files/sandbox.ts
CHANGED
|
Binary file
|