@softov/ahpc 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 +296 -0
- package/dist/src/ahp/channels.d.ts +146 -0
- package/dist/src/ahp/channels.js +423 -0
- package/dist/src/ahp/connection.d.ts +656 -0
- package/dist/src/ahp/connection.js +1 -0
- package/dist/src/ahp/fake.d.ts +69 -0
- package/dist/src/ahp/fake.js +1996 -0
- package/dist/src/ahp/live.d.ts +160 -0
- package/dist/src/ahp/live.js +2641 -0
- package/dist/src/ahp/operate.d.ts +36 -0
- package/dist/src/ahp/operate.js +56 -0
- package/dist/src/ahp/publish.d.ts +39 -0
- package/dist/src/ahp/publish.js +370 -0
- package/dist/src/ahp/status.d.ts +23 -0
- package/dist/src/ahp/status.js +23 -0
- package/dist/src/ahp/types.d.ts +745 -0
- package/dist/src/ahp/types.js +27 -0
- package/dist/src/app.d.ts +54 -0
- package/dist/src/app.js +369 -0
- package/dist/src/blocks.d.ts +88 -0
- package/dist/src/blocks.js +102 -0
- package/dist/src/cli/main.d.ts +13 -0
- package/dist/src/cli/main.js +1537 -0
- package/dist/src/cli/render.d.ts +43 -0
- package/dist/src/cli/render.js +91 -0
- package/dist/src/config.d.ts +63 -0
- package/dist/src/config.js +45 -0
- package/dist/src/connect.d.ts +53 -0
- package/dist/src/connect.js +69 -0
- package/dist/src/control.d.ts +189 -0
- package/dist/src/control.js +2241 -0
- package/dist/src/diff.d.ts +44 -0
- package/dist/src/diff.js +111 -0
- package/dist/src/flags.d.ts +28 -0
- package/dist/src/flags.js +76 -0
- package/dist/src/main.d.ts +10 -0
- package/dist/src/main.js +54 -0
- package/dist/src/schedule.d.ts +46 -0
- package/dist/src/schedule.js +140 -0
- package/dist/src/screens.d.ts +79 -0
- package/dist/src/screens.js +1322 -0
- package/dist/src/state.d.ts +355 -0
- package/dist/src/state.js +531 -0
- package/dist/src/terminal.d.ts +44 -0
- package/dist/src/terminal.js +101 -0
- package/dist/src/tui.d.ts +92 -0
- package/dist/src/tui.js +372 -0
- package/dist/src/view/automations.d.ts +31 -0
- package/dist/src/view/automations.js +76 -0
- package/dist/src/view/bood/art.d.ts +90 -0
- package/dist/src/view/bood/art.js +152 -0
- package/dist/src/view/bood/beetle.d.ts +7 -0
- package/dist/src/view/bood/beetle.js +123 -0
- package/dist/src/view/bood/bunny.d.ts +8 -0
- package/dist/src/view/bood/bunny.js +228 -0
- package/dist/src/view/bood/cat.d.ts +9 -0
- package/dist/src/view/bood/cat.js +358 -0
- package/dist/src/view/bood/crab.d.ts +7 -0
- package/dist/src/view/bood/crab.js +121 -0
- package/dist/src/view/bood/index.d.ts +29 -0
- package/dist/src/view/bood/index.js +25 -0
- package/dist/src/view/bood/motion.d.ts +178 -0
- package/dist/src/view/bood/motion.js +298 -0
- package/dist/src/view/bood/owl.d.ts +9 -0
- package/dist/src/view/bood/owl.js +285 -0
- package/dist/src/view/bood/registry.d.ts +36 -0
- package/dist/src/view/bood/registry.js +218 -0
- package/dist/src/view/bood/render.d.ts +31 -0
- package/dist/src/view/bood/render.js +196 -0
- package/dist/src/view/bood/sprite.d.ts +18 -0
- package/dist/src/view/bood/sprite.js +130 -0
- package/dist/src/view/bood/sprout.d.ts +8 -0
- package/dist/src/view/bood/sprout.js +121 -0
- package/dist/src/view/bood/types.d.ts +158 -0
- package/dist/src/view/bood/types.js +57 -0
- package/dist/src/view/bubble.d.ts +75 -0
- package/dist/src/view/bubble.js +86 -0
- package/dist/src/view/changes.d.ts +35 -0
- package/dist/src/view/changes.js +22 -0
- package/dist/src/view/composer.d.ts +64 -0
- package/dist/src/view/composer.js +192 -0
- package/dist/src/view/controls.d.ts +44 -0
- package/dist/src/view/controls.js +49 -0
- package/dist/src/view/creature.d.ts +69 -0
- package/dist/src/view/creature.js +83 -0
- package/dist/src/view/customizations.d.ts +28 -0
- package/dist/src/view/customizations.js +53 -0
- package/dist/src/view/details.d.ts +65 -0
- package/dist/src/view/details.js +65 -0
- package/dist/src/view/filediff.d.ts +29 -0
- package/dist/src/view/filediff.js +24 -0
- package/dist/src/view/files.d.ts +31 -0
- package/dist/src/view/files.js +39 -0
- package/dist/src/view/hitl.d.ts +43 -0
- package/dist/src/view/hitl.js +171 -0
- package/dist/src/view/icons.d.ts +13 -0
- package/dist/src/view/icons.js +71 -0
- package/dist/src/view/picker.d.ts +42 -0
- package/dist/src/view/picker.js +71 -0
- package/dist/src/view/sessionhead.d.ts +41 -0
- package/dist/src/view/sessionhead.js +60 -0
- package/dist/src/view/sessions.d.ts +34 -0
- package/dist/src/view/sessions.js +61 -0
- package/dist/src/view/terminal.d.ts +30 -0
- package/dist/src/view/terminal.js +32 -0
- package/dist/src/view/toolcall.d.ts +27 -0
- package/dist/src/view/toolcall.js +48 -0
- package/dist/src/view/transcript.d.ts +50 -0
- package/dist/src/view/transcript.js +60 -0
- package/package.json +72 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Running a changeset operation, including the part where the host says no. */
|
|
2
|
+
import type { HostConnection } from './connection.js';
|
|
3
|
+
import type { ChangesetOperationTarget } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Run one of a changeset's operations, asking for write access if it is refused.
|
|
6
|
+
*
|
|
7
|
+
* Above the seam on purpose. Both host implementations refuse the same way, so
|
|
8
|
+
* doing this once here is what makes the scripted host and a live daemon
|
|
9
|
+
* behave identically from a screen's point of view - and it is a decision
|
|
10
|
+
* rather than plumbing: `ask` is what turns a silent retry into a question,
|
|
11
|
+
* and a shell and a full-screen client want different answers to that.
|
|
12
|
+
*
|
|
13
|
+
* The request is sent back **verbatim**. The host named a resource - the file
|
|
14
|
+
* for an operation on one, the project for an operation on the whole changeset
|
|
15
|
+
* - and asking about a different one is asking the wrong question.
|
|
16
|
+
*
|
|
17
|
+
* Exactly one retry. A second refusal after a grant means the host meant it,
|
|
18
|
+
* and looping on denials is how a button becomes a hang.
|
|
19
|
+
*/
|
|
20
|
+
export declare function operate(host: HostConnection, changeset: string, operationId: string, options?: {
|
|
21
|
+
target?: ChangesetOperationTarget;
|
|
22
|
+
/**
|
|
23
|
+
* Whether to go ahead and ask for the access the refusal named.
|
|
24
|
+
*
|
|
25
|
+
* Answering `false` leaves the original refusal to be thrown, which is a
|
|
26
|
+
* real outcome: somebody was asked whether to let a host write to their
|
|
27
|
+
* repository and said no.
|
|
28
|
+
*/
|
|
29
|
+
ask?(request: {
|
|
30
|
+
uri: string;
|
|
31
|
+
write?: boolean;
|
|
32
|
+
read?: boolean;
|
|
33
|
+
}): Promise<boolean> | boolean;
|
|
34
|
+
}): Promise<{
|
|
35
|
+
message?: string;
|
|
36
|
+
}>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Running a changeset operation, including the part where the host says no. */
|
|
2
|
+
/**
|
|
3
|
+
* A refusal that names the way out of itself.
|
|
4
|
+
*
|
|
5
|
+
* `-32009` with a `resourceRequest` payload in `data.request`: the host is
|
|
6
|
+
* saying not "no" but "not until you ask", and the payload is the asking. A
|
|
7
|
+
* client that only knew how to press the button would draw one that fails and
|
|
8
|
+
* cannot explain itself.
|
|
9
|
+
*/
|
|
10
|
+
const unlockable = (error) => {
|
|
11
|
+
const held = error;
|
|
12
|
+
if (held.code !== -32009)
|
|
13
|
+
return undefined;
|
|
14
|
+
const request = held.data?.request;
|
|
15
|
+
if (typeof request !== 'object' || request === null)
|
|
16
|
+
return undefined;
|
|
17
|
+
const asked = request;
|
|
18
|
+
return typeof asked.uri === 'string' ? asked : undefined;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Run one of a changeset's operations, asking for write access if it is refused.
|
|
22
|
+
*
|
|
23
|
+
* Above the seam on purpose. Both host implementations refuse the same way, so
|
|
24
|
+
* doing this once here is what makes the scripted host and a live daemon
|
|
25
|
+
* behave identically from a screen's point of view - and it is a decision
|
|
26
|
+
* rather than plumbing: `ask` is what turns a silent retry into a question,
|
|
27
|
+
* and a shell and a full-screen client want different answers to that.
|
|
28
|
+
*
|
|
29
|
+
* The request is sent back **verbatim**. The host named a resource - the file
|
|
30
|
+
* for an operation on one, the project for an operation on the whole changeset
|
|
31
|
+
* - and asking about a different one is asking the wrong question.
|
|
32
|
+
*
|
|
33
|
+
* Exactly one retry. A second refusal after a grant means the host meant it,
|
|
34
|
+
* and looping on denials is how a button becomes a hang.
|
|
35
|
+
*/
|
|
36
|
+
export async function operate(host, changeset, operationId, options = {}) {
|
|
37
|
+
if (!host.invoke)
|
|
38
|
+
throw new Error('This host connection cannot run changeset operations.');
|
|
39
|
+
const run = () => host.invoke(changeset, operationId, options.target);
|
|
40
|
+
try {
|
|
41
|
+
return await run();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
const asked = unlockable(error);
|
|
45
|
+
if (!asked || !host.requestResource)
|
|
46
|
+
throw error;
|
|
47
|
+
const wanted = asked;
|
|
48
|
+
if (options.ask && !(await options.ask(wanted)))
|
|
49
|
+
throw error;
|
|
50
|
+
await host.requestResource(wanted.uri, {
|
|
51
|
+
...(wanted.read === true ? { read: true } : {}),
|
|
52
|
+
...(wanted.write === true ? { write: true } : {}),
|
|
53
|
+
});
|
|
54
|
+
return await run();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The authority a client publishes under is its own `clientId`.
|
|
3
|
+
*
|
|
4
|
+
* `<scheme>://<clientId>/…` is how the reference host addresses a
|
|
5
|
+
* client-served resource, and a host routes by reading the authority and
|
|
6
|
+
* matching it against the connection that sent it - so a fixed authority
|
|
7
|
+
* reaches nothing. The scheme is `virtual:`, which is what the specification's
|
|
8
|
+
* examples and both conformance suites use.
|
|
9
|
+
*/
|
|
10
|
+
export declare function publishedUnder(clientId: string): string;
|
|
11
|
+
export interface Published {
|
|
12
|
+
/** The directory served, or null when nothing was published. */
|
|
13
|
+
readonly root: string | null;
|
|
14
|
+
/** Whether a host may write into it. Off unless asked for. */
|
|
15
|
+
readonly writable: boolean;
|
|
16
|
+
/** The prefix this is addressed under, once a client id is known. */
|
|
17
|
+
readonly prefix: string;
|
|
18
|
+
/** Say which connection this is, so the URIs a host routes on are right. */
|
|
19
|
+
as(clientId: string): Published;
|
|
20
|
+
/** Per-method handlers, in the shape the protocol client composes. */
|
|
21
|
+
handlers(): Record<string, (params: unknown) => Promise<unknown>>;
|
|
22
|
+
}
|
|
23
|
+
/** A refusal carrying the code the protocol declares for it. */
|
|
24
|
+
export declare class PublishRefusal extends Error {
|
|
25
|
+
readonly code: number;
|
|
26
|
+
constructor(code: number, message: string);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A published directory, or one that refuses everything.
|
|
30
|
+
*
|
|
31
|
+
* `root` null is the default and the safe one: every method answers `-32009`
|
|
32
|
+
* naming what would be needed, which is a refusal rather than an absence - the
|
|
33
|
+
* host asked something this client understands and declined.
|
|
34
|
+
*/
|
|
35
|
+
export declare function publish(options?: {
|
|
36
|
+
root?: string;
|
|
37
|
+
writable?: boolean;
|
|
38
|
+
clientId?: string;
|
|
39
|
+
}): Published;
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this client serves back to a host.
|
|
3
|
+
*
|
|
4
|
+
* AHP is symmetrical: `subscriptions.md` says the same nine `resource*`
|
|
5
|
+
* methods plus `createResourceWatch` may be initiated by the server, and the
|
|
6
|
+
* registry's example of what they are for is fetching a client-published URI -
|
|
7
|
+
* `virtual://my-client/...`. The .NET conformance test publishes
|
|
8
|
+
* `virtual://native-aot/resource` and the TypeScript one `virtual://client/thing`,
|
|
9
|
+
* so the scheme shape is the specification's rather than one chosen here.
|
|
10
|
+
*
|
|
11
|
+
* Nothing is served unless a directory was named on the command line. A client
|
|
12
|
+
* that offered its filesystem to whatever host it happened to connect to would
|
|
13
|
+
* be a mistake rather than a feature, and `-32009 PermissionDenied` is the
|
|
14
|
+
* declared refusal - the receiver enforcing access, which the specification
|
|
15
|
+
* says is the receiver's job whichever peer initiated.
|
|
16
|
+
*/
|
|
17
|
+
import { lstat, mkdir, open, readdir, realpath, rename, rm, stat } from 'node:fs/promises';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { constants } from 'node:fs';
|
|
20
|
+
/**
|
|
21
|
+
* The authority a client publishes under is its own `clientId`.
|
|
22
|
+
*
|
|
23
|
+
* `<scheme>://<clientId>/…` is how the reference host addresses a
|
|
24
|
+
* client-served resource, and a host routes by reading the authority and
|
|
25
|
+
* matching it against the connection that sent it - so a fixed authority
|
|
26
|
+
* reaches nothing. The scheme is `virtual:`, which is what the specification's
|
|
27
|
+
* examples and both conformance suites use.
|
|
28
|
+
*/
|
|
29
|
+
export function publishedUnder(clientId) {
|
|
30
|
+
return `virtual://${clientId}/`;
|
|
31
|
+
}
|
|
32
|
+
/** JSON-RPC codes this answers with, as `commands.ts` declares them. */
|
|
33
|
+
const NOT_FOUND = -32008;
|
|
34
|
+
const PERMISSION_DENIED = -32009;
|
|
35
|
+
const ALREADY_EXISTS = -32010;
|
|
36
|
+
const CONFLICT = -32011;
|
|
37
|
+
const tagOf = (size, mtimeMs) => `W/"${size.toString(16)}-${Math.trunc(mtimeMs).toString(16)}"`;
|
|
38
|
+
/** A refusal carrying the code the protocol declares for it. */
|
|
39
|
+
export class PublishRefusal extends Error {
|
|
40
|
+
code;
|
|
41
|
+
constructor(code, message) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.name = 'PublishRefusal';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A published directory, or one that refuses everything.
|
|
49
|
+
*
|
|
50
|
+
* `root` null is the default and the safe one: every method answers `-32009`
|
|
51
|
+
* naming what would be needed, which is a refusal rather than an absence - the
|
|
52
|
+
* host asked something this client understands and declined.
|
|
53
|
+
*/
|
|
54
|
+
export function publish(options = {}) {
|
|
55
|
+
const root = options.root === undefined ? null : path.resolve(options.root);
|
|
56
|
+
const writable = options.writable === true;
|
|
57
|
+
/*
|
|
58
|
+
* Until a connection exists there is no id to publish under.
|
|
59
|
+
*
|
|
60
|
+
* `ahpc` is the placeholder and is deliberately one a host will not route:
|
|
61
|
+
* `as()` is called with the real `clientId` before anything can be asked
|
|
62
|
+
* for, and a prefix that happened to work without it would hide the day it
|
|
63
|
+
* was not called.
|
|
64
|
+
*/
|
|
65
|
+
const PREFIX = publishedUnder(options.clientId ?? 'ahpc');
|
|
66
|
+
const writes = new Map();
|
|
67
|
+
/**
|
|
68
|
+
* The file a `virtual://<clientId>/...` names, or a refusal.
|
|
69
|
+
*
|
|
70
|
+
* Resolved and then checked to be inside the published directory, because
|
|
71
|
+
* `..` in a URI is a host asking for the rest of the disk. The check is on
|
|
72
|
+
* the resolved path rather than on the text: `a/../../etc` is only visibly
|
|
73
|
+
* outside once it has been resolved.
|
|
74
|
+
*/
|
|
75
|
+
const where = async (uri, linkItself = false) => {
|
|
76
|
+
if (root === null) {
|
|
77
|
+
throw new PublishRefusal(PERMISSION_DENIED, 'This client publishes nothing. Start it with --publish.');
|
|
78
|
+
}
|
|
79
|
+
/*
|
|
80
|
+
* The publication's own root, with or without the trailing slash.
|
|
81
|
+
*
|
|
82
|
+
* RFC 3986 6.2.3: where an authority is present, an empty path is
|
|
83
|
+
* equivalent to `/`. `virtual://<clientId>` and `virtual://<clientId>/`
|
|
84
|
+
* are one resource, so refusing the first is this publisher being wrong
|
|
85
|
+
* about a URI rather than the caller being sloppy - and a caller cannot
|
|
86
|
+
* fix it, because anything composing the root out of a client id read off
|
|
87
|
+
* `initialize` produces the bare form.
|
|
88
|
+
*/
|
|
89
|
+
const bare = PREFIX.slice(0, -1);
|
|
90
|
+
const rest = typeof uri !== 'string' ? null
|
|
91
|
+
: uri === bare ? ''
|
|
92
|
+
: uri.startsWith(PREFIX) ? uri.slice(PREFIX.length)
|
|
93
|
+
: null;
|
|
94
|
+
if (rest === null) {
|
|
95
|
+
throw new PublishRefusal(PERMISSION_DENIED, `This client serves only ${PREFIX}`);
|
|
96
|
+
}
|
|
97
|
+
/*
|
|
98
|
+
* Resolved against the root, and a leading slash is why that is not
|
|
99
|
+
* enough on its own: `virtual://<id>//etc/passwd` leaves `/etc/passwd`,
|
|
100
|
+
* which `path.resolve` treats as absolute and returns unchanged. The
|
|
101
|
+
* containment check below is what refuses it, so it is a guard rather
|
|
102
|
+
* than a tidy-up.
|
|
103
|
+
*/
|
|
104
|
+
const inside = path.resolve(root, decodeURIComponent(rest));
|
|
105
|
+
if (inside !== root && !inside.startsWith(`${root}${path.sep}`)) {
|
|
106
|
+
throw new PublishRefusal(PERMISSION_DENIED, 'That is outside what this client published.');
|
|
107
|
+
}
|
|
108
|
+
const boundary = await realpath(root);
|
|
109
|
+
let ancestor = inside;
|
|
110
|
+
const missing = [];
|
|
111
|
+
for (;;) {
|
|
112
|
+
try {
|
|
113
|
+
const resolved = await realpath(ancestor);
|
|
114
|
+
if (resolved !== boundary && !resolved.startsWith(`${boundary}${path.sep}`)) {
|
|
115
|
+
throw new PublishRefusal(PERMISSION_DENIED, 'That is outside what this client published.');
|
|
116
|
+
}
|
|
117
|
+
if (linkItself && missing.length === 0) {
|
|
118
|
+
return path.join(await realpath(path.dirname(inside)), path.basename(inside));
|
|
119
|
+
}
|
|
120
|
+
return path.join(resolved, ...missing.reverse());
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error instanceof PublishRefusal)
|
|
124
|
+
throw error;
|
|
125
|
+
// A dangling link has a destination too. Never treat it as a new file.
|
|
126
|
+
if (await lstat(ancestor).then((entry) => entry.isSymbolicLink(), () => false)) {
|
|
127
|
+
throw new PublishRefusal(PERMISSION_DENIED, 'That is a dangling symbolic link.');
|
|
128
|
+
}
|
|
129
|
+
if (error.code !== 'ENOENT')
|
|
130
|
+
throw error;
|
|
131
|
+
const parent = path.dirname(ancestor);
|
|
132
|
+
if (parent === ancestor)
|
|
133
|
+
throw error;
|
|
134
|
+
missing.push(path.basename(ancestor));
|
|
135
|
+
ancestor = parent;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const mutable = () => {
|
|
140
|
+
if (!writable)
|
|
141
|
+
throw new PublishRefusal(PERMISSION_DENIED, 'What this client published is read-only.');
|
|
142
|
+
};
|
|
143
|
+
const gone = (uri) => {
|
|
144
|
+
throw new PublishRefusal(NOT_FOUND, `${String(uri)} is not there.`);
|
|
145
|
+
};
|
|
146
|
+
/*
|
|
147
|
+
* `ahpd` carries a second copy of this, in its `resources.ts`.
|
|
148
|
+
*
|
|
149
|
+
* `resourceWrite` is symmetrical - a host asks a client for one exactly as a
|
|
150
|
+
* client asks a host - so both ends need the same flags, the same order of
|
|
151
|
+
* preconditions and the same append and insert arithmetic. This client does
|
|
152
|
+
* not depend on that package and is not going to, so the copy is deliberate.
|
|
153
|
+
* What is not deliberate is fixing one and not the other: everything here
|
|
154
|
+
* was wrong in both at once, and was corrected in both at once.
|
|
155
|
+
*/
|
|
156
|
+
const write = async (at, uri, params) => {
|
|
157
|
+
const before = writes.get(at) ?? Promise.resolve();
|
|
158
|
+
const operation = before.catch(() => { }).then(async () => {
|
|
159
|
+
const mode = params.mode === 'append' || params.mode === 'insert' ? params.mode : 'truncate';
|
|
160
|
+
const position = typeof params.position === 'number' ? params.position : 0;
|
|
161
|
+
const createOnly = params.createOnly === true;
|
|
162
|
+
const ifMatch = typeof params.ifMatch === 'string' ? params.ifMatch : undefined;
|
|
163
|
+
const incoming = params.encoding === 'base64'
|
|
164
|
+
? Buffer.from(String(params.data), 'base64')
|
|
165
|
+
: Buffer.from(String(params.data), 'utf8');
|
|
166
|
+
let flags = (mode === 'truncate' && position === 0 ? constants.O_WRONLY : constants.O_RDWR) | constants.O_NOFOLLOW;
|
|
167
|
+
if (ifMatch === undefined)
|
|
168
|
+
flags |= constants.O_CREAT;
|
|
169
|
+
if (createOnly && ifMatch === undefined)
|
|
170
|
+
flags |= constants.O_EXCL;
|
|
171
|
+
const file = await open(at, flags).catch((error) => {
|
|
172
|
+
if (createOnly && error.code === 'EEXIST') {
|
|
173
|
+
throw new PublishRefusal(ALREADY_EXISTS, `${String(uri)} already exists.`);
|
|
174
|
+
}
|
|
175
|
+
if (ifMatch !== undefined && error.code === 'ENOENT') {
|
|
176
|
+
throw new PublishRefusal(CONFLICT, `${String(uri)} has changed since ${ifMatch}.`);
|
|
177
|
+
}
|
|
178
|
+
if (error.code === 'ENOENT')
|
|
179
|
+
return gone(uri);
|
|
180
|
+
// The two refusals the flags produce, said in this client's own words:
|
|
181
|
+
// `O_NOFOLLOW` answers a final link with `ELOOP`, and a directory
|
|
182
|
+
// opened for writing answers `EISDIR`. Both are a refusal to write
|
|
183
|
+
// what was asked for, and neither is useful to a host as an errno.
|
|
184
|
+
if (error.code === 'ELOOP') {
|
|
185
|
+
throw new PublishRefusal(PERMISSION_DENIED, `${String(uri)} is a symbolic link.`);
|
|
186
|
+
}
|
|
187
|
+
if (error.code === 'EISDIR') {
|
|
188
|
+
throw new PublishRefusal(PERMISSION_DENIED, `${String(uri)} is a directory.`);
|
|
189
|
+
}
|
|
190
|
+
throw new PublishRefusal(PERMISSION_DENIED, `Could not write ${String(uri)}: ${error.message}`);
|
|
191
|
+
});
|
|
192
|
+
try {
|
|
193
|
+
if (createOnly && ifMatch !== undefined) {
|
|
194
|
+
throw new PublishRefusal(ALREADY_EXISTS, `${String(uri)} already exists.`);
|
|
195
|
+
}
|
|
196
|
+
if (ifMatch !== undefined) {
|
|
197
|
+
// Off the open descriptor, so what is compared is the file about to
|
|
198
|
+
// be written rather than whatever the name pointed at a moment ago.
|
|
199
|
+
const found = await file.stat();
|
|
200
|
+
if (tagOf(found.size, found.mtimeMs) !== ifMatch) {
|
|
201
|
+
throw new PublishRefusal(CONFLICT, `${String(uri)} has changed since ${ifMatch}.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const held = mode === 'truncate' && position === 0 ? Buffer.alloc(0) : await file.readFile();
|
|
205
|
+
let output;
|
|
206
|
+
if (mode === 'append') {
|
|
207
|
+
const cut = Math.max(0, held.length - Math.max(0, position));
|
|
208
|
+
output = Buffer.concat([held.subarray(0, cut), incoming, held.subarray(cut)]);
|
|
209
|
+
}
|
|
210
|
+
else if (mode === 'insert') {
|
|
211
|
+
const cut = Math.min(Math.max(0, position), held.length);
|
|
212
|
+
output = Buffer.concat([held.subarray(0, cut), incoming, held.subarray(cut)]);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const cut = Math.min(Math.max(0, position), held.length);
|
|
216
|
+
output = Buffer.concat([held.subarray(0, cut), incoming]);
|
|
217
|
+
}
|
|
218
|
+
let offset = 0;
|
|
219
|
+
while (offset < output.length) {
|
|
220
|
+
const { bytesWritten } = await file.write(output, offset, output.length - offset, offset);
|
|
221
|
+
if (bytesWritten === 0) {
|
|
222
|
+
throw new PublishRefusal(PERMISSION_DENIED, `Could not finish writing ${String(uri)}.`);
|
|
223
|
+
}
|
|
224
|
+
offset += bytesWritten;
|
|
225
|
+
}
|
|
226
|
+
await file.truncate(output.length);
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
await file.close();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
writes.set(at, operation);
|
|
233
|
+
try {
|
|
234
|
+
await operation;
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
if (writes.get(at) === operation)
|
|
238
|
+
writes.delete(at);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
return {
|
|
242
|
+
root,
|
|
243
|
+
writable,
|
|
244
|
+
prefix: PREFIX,
|
|
245
|
+
as: (clientId) => publish({
|
|
246
|
+
...(root === null ? {} : { root }),
|
|
247
|
+
...(writable ? { writable } : {}),
|
|
248
|
+
clientId,
|
|
249
|
+
}),
|
|
250
|
+
handlers: () => ({
|
|
251
|
+
resourceRead: async (params) => {
|
|
252
|
+
const uri = params.uri;
|
|
253
|
+
const at = await where(uri);
|
|
254
|
+
let body;
|
|
255
|
+
try {
|
|
256
|
+
const file = await open(at, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
257
|
+
try {
|
|
258
|
+
body = await file.readFile();
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
await file.close();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return gone(uri);
|
|
266
|
+
}
|
|
267
|
+
// Binary MUST be base64 and text MAY be utf-8. A NUL byte is the cheap
|
|
268
|
+
// test, and a host handed a PNG as utf-8 receives something that is
|
|
269
|
+
// not the file.
|
|
270
|
+
return body.includes(0)
|
|
271
|
+
? { data: body.toString('base64'), encoding: 'base64' }
|
|
272
|
+
: { data: body.toString('utf8'), encoding: 'utf-8' };
|
|
273
|
+
},
|
|
274
|
+
resourceList: async (params) => {
|
|
275
|
+
const uri = params.uri;
|
|
276
|
+
const at = await where(uri);
|
|
277
|
+
try {
|
|
278
|
+
const found = await readdir(at, { withFileTypes: true });
|
|
279
|
+
const boundary = await realpath(root);
|
|
280
|
+
return {
|
|
281
|
+
entries: found.map((entry) => ({
|
|
282
|
+
uri: `${PREFIX}${path.relative(boundary, path.join(at, entry.name))}`,
|
|
283
|
+
name: entry.name,
|
|
284
|
+
type: entry.isDirectory() ? 'directory' : 'file',
|
|
285
|
+
})),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return gone(uri);
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
resourceResolve: async (params) => {
|
|
293
|
+
const uri = params.uri;
|
|
294
|
+
const at = await where(uri);
|
|
295
|
+
try {
|
|
296
|
+
const found = await stat(at);
|
|
297
|
+
return {
|
|
298
|
+
uri: String(uri),
|
|
299
|
+
type: found.isDirectory() ? 'directory' : 'file',
|
|
300
|
+
size: found.size,
|
|
301
|
+
mtime: new Date(found.mtimeMs).toISOString(),
|
|
302
|
+
...(found.isDirectory() ? {} : { etag: tagOf(found.size, found.mtimeMs) }),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return gone(uri);
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
/*
|
|
310
|
+
* The one method that answers without touching the disk.
|
|
311
|
+
*
|
|
312
|
+
* `resourceRequest` is how a peer asks for access it does not have. This
|
|
313
|
+
* client has no way to put the question to whoever started it - it may
|
|
314
|
+
* be a pipe in a script - so the honest answer is that the grant is a
|
|
315
|
+
* flag rather than a conversation.
|
|
316
|
+
*/
|
|
317
|
+
resourceRequest: async () => ({
|
|
318
|
+
granted: false,
|
|
319
|
+
reason: writable
|
|
320
|
+
? 'This client grants only what --publish named.'
|
|
321
|
+
: 'This client published its directory read-only. Restart it with --publish-writable.',
|
|
322
|
+
}),
|
|
323
|
+
resourceWrite: async (params) => {
|
|
324
|
+
const input = params;
|
|
325
|
+
mutable();
|
|
326
|
+
await write(await where(input.uri), input.uri, input);
|
|
327
|
+
return {};
|
|
328
|
+
},
|
|
329
|
+
resourceDelete: async (params) => {
|
|
330
|
+
const { uri, recursive } = params;
|
|
331
|
+
mutable();
|
|
332
|
+
await rm(await where(uri, true), { recursive: recursive === true });
|
|
333
|
+
return {};
|
|
334
|
+
},
|
|
335
|
+
resourceMkdir: async (params) => {
|
|
336
|
+
mutable();
|
|
337
|
+
await mkdir(await where(params.uri), { recursive: true });
|
|
338
|
+
return {};
|
|
339
|
+
},
|
|
340
|
+
resourceMove: async (params) => {
|
|
341
|
+
const { source, destination } = params;
|
|
342
|
+
mutable();
|
|
343
|
+
await rename(await where(source, true), await where(destination, true));
|
|
344
|
+
return {};
|
|
345
|
+
},
|
|
346
|
+
resourceCopy: async (params) => {
|
|
347
|
+
const { source, destination } = params;
|
|
348
|
+
mutable();
|
|
349
|
+
const from = await where(source);
|
|
350
|
+
const to = await where(destination);
|
|
351
|
+
const input = await open(from, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
352
|
+
try {
|
|
353
|
+
const output = await open(to, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW);
|
|
354
|
+
try {
|
|
355
|
+
const bytes = await input.readFile();
|
|
356
|
+
await output.truncate(0);
|
|
357
|
+
await output.writeFile(bytes);
|
|
358
|
+
}
|
|
359
|
+
finally {
|
|
360
|
+
await output.close();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
await input.close();
|
|
365
|
+
}
|
|
366
|
+
return {};
|
|
367
|
+
},
|
|
368
|
+
}),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SessionSummary } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The status bitset, decoded once.
|
|
4
|
+
*
|
|
5
|
+
* `InputNeeded` is 24 and carries `InProgress` (8), so testing `InProgress`
|
|
6
|
+
* first swallows it: a session blocked on a confirmation reads as merely
|
|
7
|
+
* running, and the one state that wants a person is the one nobody sees.
|
|
8
|
+
* Order matters here and nowhere else, which is why it is one function.
|
|
9
|
+
*/
|
|
10
|
+
export type Activity = 'input' | 'running' | 'error' | 'idle';
|
|
11
|
+
export interface Status {
|
|
12
|
+
activity: Activity;
|
|
13
|
+
archived: boolean;
|
|
14
|
+
read: boolean;
|
|
15
|
+
/** The word. Never the only carrier of the meaning. */
|
|
16
|
+
label: string;
|
|
17
|
+
/** The colour. Also never the only carrier. */
|
|
18
|
+
tone: 'warning' | 'accent' | 'danger' | 'muted';
|
|
19
|
+
/** The shape a 16-colour session, a piped log and a colourblind reader keep. */
|
|
20
|
+
glyph: 'bulletHalf' | 'bulletFilled' | 'cross' | 'bulletHollow';
|
|
21
|
+
}
|
|
22
|
+
export declare function decodeStatus(status: number): Status;
|
|
23
|
+
export declare function byUrgency(a: SessionSummary, b: SessionSummary): number;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { SessionFlag } from './types.js';
|
|
2
|
+
export function decodeStatus(status) {
|
|
3
|
+
const archived = (status & SessionFlag.IsArchived) !== 0;
|
|
4
|
+
const read = (status & SessionFlag.IsRead) !== 0;
|
|
5
|
+
// Most specific first.
|
|
6
|
+
const activity = (status & SessionFlag.InputNeeded) === SessionFlag.InputNeeded ? 'input'
|
|
7
|
+
: (status & SessionFlag.InProgress) !== 0 ? 'running'
|
|
8
|
+
: (status & SessionFlag.Error) !== 0 ? 'error'
|
|
9
|
+
: 'idle';
|
|
10
|
+
const shown = {
|
|
11
|
+
input: { label: 'waiting on you', tone: 'warning', glyph: 'bulletHalf' },
|
|
12
|
+
running: { label: 'running', tone: 'accent', glyph: 'bulletFilled' },
|
|
13
|
+
error: { label: 'error', tone: 'danger', glyph: 'cross' },
|
|
14
|
+
idle: { label: 'idle', tone: 'muted', glyph: 'bulletHollow' },
|
|
15
|
+
};
|
|
16
|
+
return { activity, archived, read, ...shown[activity] };
|
|
17
|
+
}
|
|
18
|
+
/** Sessions worth answering first, then worth watching, then the rest. */
|
|
19
|
+
const RANK = { input: 0, running: 1, error: 2, idle: 3 };
|
|
20
|
+
export function byUrgency(a, b) {
|
|
21
|
+
const rank = RANK[decodeStatus(a.status).activity] - RANK[decodeStatus(b.status).activity];
|
|
22
|
+
return rank !== 0 ? rank : b.modifiedAt.localeCompare(a.modifiedAt);
|
|
23
|
+
}
|