@terminus-ai/cli 0.0.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.
- package/LICENSE +21 -0
- package/README.md +1055 -0
- package/bin/agent-discovery.mjs +71 -0
- package/bin/agent-icon.mjs +77 -0
- package/bin/agent-models.mjs +77 -0
- package/bin/agent-type.mjs +51 -0
- package/bin/agentdev.mjs +657 -0
- package/bin/app-route-script.mjs +59 -0
- package/bin/app-runtime-contract.mjs +2 -0
- package/bin/appdev-remote.mjs +346 -0
- package/bin/appdev.mjs +4446 -0
- package/bin/apps.mjs +5512 -0
- package/bin/capability-calls.mjs +437 -0
- package/bin/capsule-data.mjs +260 -0
- package/bin/client.mjs +189 -0
- package/bin/commands.mjs +1194 -0
- package/bin/dev-capsules.mjs +1599 -0
- package/bin/dev-contract.mjs +262 -0
- package/bin/dev-data.mjs +287 -0
- package/bin/dev-members.mjs +18 -0
- package/bin/dev-net.mjs +316 -0
- package/bin/dev-notification-popup.mjs +628 -0
- package/bin/dev-ports.mjs +567 -0
- package/bin/dev-server-binding.mjs +35 -0
- package/bin/dev-server-ops.mjs +1086 -0
- package/bin/dev-ui/IoskeleyMono-400.woff2 +0 -0
- package/bin/dev-ui/IoskeleyMono-600.woff2 +0 -0
- package/bin/dev-ui/OFL.txt +92 -0
- package/bin/dev-ui/agent-robot.webp +0 -0
- package/bin/dev-ui/app.js +5217 -0
- package/bin/dev-ui/highlight.js +195 -0
- package/bin/dev-ui/index.html +34 -0
- package/bin/dev-ui/style.css +3640 -0
- package/bin/devlint.mjs +112 -0
- package/bin/devserver.mjs +2127 -0
- package/bin/devtriggers.mjs +367 -0
- package/bin/endpoints.mjs +156 -0
- package/bin/errors.mjs +61 -0
- package/bin/files.mjs +169 -0
- package/bin/horizontal-capabilities/v1/contract.json +280 -0
- package/bin/http.mjs +500 -0
- package/bin/lint-manifests/justbash-commands.json +88 -0
- package/bin/lint-manifests/python-stdlib.json +295 -0
- package/bin/login-page.mjs +488 -0
- package/bin/schedules.mjs +664 -0
- package/bin/server-sandbox.mjs +204 -0
- package/bin/servicedev.mjs +425 -0
- package/bin/sync.mjs +357 -0
- package/bin/terminus.js +3666 -0
- package/bin/toolchain.mjs +125 -0
- package/bin/vendor/app-runtime-v1/app-host.json +124 -0
- package/bin/vendor/app-runtime-v1/capability-calls.json +412 -0
- package/bin/vendor/app-runtime-v1/doors.json +2867 -0
- package/bin/vendor/appd/node-harness.mjs +209 -0
- package/bin/vendor/appd/python-harness.py +12 -0
- package/bin/vendor/appd/server-protocol.json +84 -0
- package/bin/vendor/where.mjs +541 -0
- package/bin/versioning.mjs +72 -0
- package/bin/write-rules.mjs +398 -0
- package/package.json +41 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app-runtime wire contract as the local hosts read it: doors.json (every
|
|
3
|
+
* `/_terminus` door, and which of them `terminus dev` serves) and
|
|
4
|
+
* app-host.json (the app host's routing and header allowlists), vendored
|
|
5
|
+
* byte-for-byte from the SDK's conformance bundle under
|
|
6
|
+
* bin/vendor/app-runtime-v1/ (test/conformance.test.mjs pins the copy).
|
|
7
|
+
*
|
|
8
|
+
* The local harness routes by this table: a door the contract marks
|
|
9
|
+
* `"dev": "unsupported"` answers `501 unsupported_in_dev`, a known path with
|
|
10
|
+
* another method `405 method_not_allowed`, and anything else `404
|
|
11
|
+
* not_found` — every answer in the one error envelope, with the code and
|
|
12
|
+
* status the catalogue gives it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
|
|
17
|
+
import { CliError } from "./client.mjs";
|
|
18
|
+
|
|
19
|
+
function vendored(name) {
|
|
20
|
+
return JSON.parse(readFileSync(new URL(`./vendor/app-runtime-v1/${name}`, import.meta.url), "utf8"));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DOORS = vendored("doors.json");
|
|
24
|
+
export const APP_HOST = vendored("app-host.json");
|
|
25
|
+
export const LIMITS = DOORS.limits;
|
|
26
|
+
|
|
27
|
+
const STATUS_BY_CODE = new Map(DOORS.errors.map((entry) => [entry.code, entry.status]));
|
|
28
|
+
/** The code a refusal that names only its status answers with: the
|
|
29
|
+
* catalogue's general code for that status (403 is forbidden, never
|
|
30
|
+
* grant_required; 503 is service_unavailable, never server_disabled). */
|
|
31
|
+
const CODE_BY_STATUS = new Map([
|
|
32
|
+
[400, "bad_request"],
|
|
33
|
+
[401, "unauthorized"],
|
|
34
|
+
[402, "quota_exceeded"],
|
|
35
|
+
[403, "forbidden"],
|
|
36
|
+
[404, "not_found"],
|
|
37
|
+
[405, "method_not_allowed"],
|
|
38
|
+
[409, "conflict"],
|
|
39
|
+
[413, "payload_too_large"],
|
|
40
|
+
[422, "unprocessable"],
|
|
41
|
+
[429, "rate_limited"],
|
|
42
|
+
[500, "internal_error"],
|
|
43
|
+
[501, "not_implemented"],
|
|
44
|
+
[502, "upstream_failed"],
|
|
45
|
+
[503, "service_unavailable"],
|
|
46
|
+
[504, "deadline_exceeded"],
|
|
47
|
+
]);
|
|
48
|
+
for (const [status, code] of CODE_BY_STATUS) {
|
|
49
|
+
if (STATUS_BY_CODE.get(code) !== status) throw new Error(`the contract has no ${status} ${code}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A refusal with a catalogue code; its status is the catalogue's. */
|
|
53
|
+
export function runtimeError(code, message, details = undefined) {
|
|
54
|
+
const status = STATUS_BY_CODE.get(code);
|
|
55
|
+
if (!status) throw new Error(`no error code '${code}' in the contract`);
|
|
56
|
+
return Object.assign(new CliError(message), {
|
|
57
|
+
status,
|
|
58
|
+
apiCode: code,
|
|
59
|
+
...(details === undefined ? {} : { details }),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The envelope an error answers with. A CliError without a status is a
|
|
64
|
+
* refused request (400 bad_request); anything that is not a CliError is the
|
|
65
|
+
* host's own failure (500 internal_error), as the platform answers one. */
|
|
66
|
+
export function errorEnvelope(error) {
|
|
67
|
+
let status = Number(error?.status) || 0;
|
|
68
|
+
if (!status) status = error instanceof CliError ? 400 : 500;
|
|
69
|
+
const code = typeof error?.apiCode === "string" && STATUS_BY_CODE.has(error.apiCode)
|
|
70
|
+
? error.apiCode
|
|
71
|
+
: CODE_BY_STATUS.get(status) ?? (status >= 500 ? "internal_error" : "bad_request");
|
|
72
|
+
const message = status === 500 && !(error instanceof CliError)
|
|
73
|
+
? `internal error: ${String(error?.message ?? error)}`
|
|
74
|
+
: String(error?.message ?? error);
|
|
75
|
+
return {
|
|
76
|
+
status: STATUS_BY_CODE.get(code) ?? status,
|
|
77
|
+
body: {
|
|
78
|
+
error: {
|
|
79
|
+
code,
|
|
80
|
+
message,
|
|
81
|
+
...(error?.details === undefined ? {} : { details: error.details }),
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Answer `error` in the envelope. */
|
|
88
|
+
export function sendRuntimeError(response, error) {
|
|
89
|
+
const { status, body } = errorEnvelope(error);
|
|
90
|
+
response.writeHead(status, { "content-type": "application/json" });
|
|
91
|
+
response.end(JSON.stringify(body));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ── door routing ──────────────────────────────────────────────────────── */
|
|
95
|
+
|
|
96
|
+
function compileTemplate(template) {
|
|
97
|
+
const segments = template.split("/").filter(Boolean).map((segment) => {
|
|
98
|
+
const wildcard = /^\{\*([a-z_]+)\}$/u.exec(segment);
|
|
99
|
+
if (wildcard) return { wildcard: wildcard[1] };
|
|
100
|
+
const param = /^\{([a-z_]+)\}$/u.exec(segment);
|
|
101
|
+
return param ? { param: param[1] } : { literal: segment };
|
|
102
|
+
});
|
|
103
|
+
return { template, segments };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function matchTemplate(compiled, parts) {
|
|
107
|
+
const params = {};
|
|
108
|
+
let literals = 0;
|
|
109
|
+
for (const [index, segment] of compiled.segments.entries()) {
|
|
110
|
+
if (segment.wildcard) {
|
|
111
|
+
if (index >= parts.length) return null;
|
|
112
|
+
params[segment.wildcard] = parts.slice(index).join("/");
|
|
113
|
+
return { params, literals, wildcard: true };
|
|
114
|
+
}
|
|
115
|
+
const part = parts[index];
|
|
116
|
+
if (part === undefined) return null;
|
|
117
|
+
if (segment.literal !== undefined) {
|
|
118
|
+
if (segment.literal !== part) return null;
|
|
119
|
+
literals += 1;
|
|
120
|
+
} else {
|
|
121
|
+
params[segment.param] = part;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (compiled.segments.length !== parts.length) return null;
|
|
125
|
+
return { params, literals, wildcard: false };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Every door an app can reach, by path template (a template may carry
|
|
129
|
+
* several methods). */
|
|
130
|
+
const TEMPLATES = (() => {
|
|
131
|
+
const byTemplate = new Map();
|
|
132
|
+
for (const door of DOORS.doors) {
|
|
133
|
+
if (!door.path) continue;
|
|
134
|
+
if (!byTemplate.has(door.path)) byTemplate.set(door.path, { ...compileTemplate(door.path), doors: [] });
|
|
135
|
+
byTemplate.get(door.path).doors.push(door);
|
|
136
|
+
}
|
|
137
|
+
return [...byTemplate.values()];
|
|
138
|
+
})();
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The door `method` `pathname` (app-relative, e.g. `/spaces/s-1/members`)
|
|
142
|
+
* names. Literal segments outrank parameters position by position, so
|
|
143
|
+
* `/collections/{collection}/changes` is never read as a record id — the way
|
|
144
|
+
* the platform's router resolves them. Answers `{ door, params }`,
|
|
145
|
+
* `{ methods }` when the path is a door's but the method is not, or null.
|
|
146
|
+
* A GET door also answers HEAD.
|
|
147
|
+
*/
|
|
148
|
+
export function matchDoor(method, pathname) {
|
|
149
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
150
|
+
let best = null;
|
|
151
|
+
for (const template of TEMPLATES) {
|
|
152
|
+
const match = matchTemplate(template, parts);
|
|
153
|
+
if (!match) continue;
|
|
154
|
+
const rank = [match.wildcard ? 0 : 1, ...template.segments.map((segment, index) => (
|
|
155
|
+
segment.literal !== undefined && parts[index] === segment.literal ? 1 : 0
|
|
156
|
+
))];
|
|
157
|
+
if (!best || compareRank(rank, best.rank) > 0) best = { template, match, rank };
|
|
158
|
+
}
|
|
159
|
+
if (!best) return null;
|
|
160
|
+
const verb = method === "HEAD" ? "GET" : method;
|
|
161
|
+
const door = best.template.doors.find((candidate) => candidate.method === verb);
|
|
162
|
+
if (!door) {
|
|
163
|
+
const methods = [...new Set(best.template.doors.flatMap((candidate) => (
|
|
164
|
+
candidate.method === "GET" ? ["GET", "HEAD"] : [candidate.method]
|
|
165
|
+
)))];
|
|
166
|
+
return { methods };
|
|
167
|
+
}
|
|
168
|
+
return { door, params: decodeParams(best.match.params) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function compareRank(left, right) {
|
|
172
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
173
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
174
|
+
if (difference) return difference;
|
|
175
|
+
}
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function decodeParams(params) {
|
|
180
|
+
const decoded = {};
|
|
181
|
+
for (const [name, value] of Object.entries(params)) {
|
|
182
|
+
try {
|
|
183
|
+
decoded[name] = value.split("/").map((segment) => decodeURIComponent(segment)).join("/");
|
|
184
|
+
} catch {
|
|
185
|
+
throw runtimeError("bad_request", `invalid path segment for ${name}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return decoded;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The doors `terminus dev` cannot serve (the contract's `"dev":
|
|
192
|
+
* "unsupported"`). */
|
|
193
|
+
export const DEV_UNSUPPORTED_DOORS = DOORS.doors.filter((door) => door.hosts.dev === "unsupported");
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Where sign-in brings a person back (`host.signin`'s `return_to`): a path on
|
|
197
|
+
* the app's own origin, and anything else is `/` — the app host's rule,
|
|
198
|
+
* written as a relative Location, which keeps the origin a request came in
|
|
199
|
+
* on (the harness's port, or the Vite dev server proxying to it).
|
|
200
|
+
*
|
|
201
|
+
* The ways a place could lead somewhere else, each answered `/`:
|
|
202
|
+
* - nothing, or not a path at all ("https://evil.example", "evil.example");
|
|
203
|
+
* - protocol-relative ("//evil.example"), another host on this scheme;
|
|
204
|
+
* - a backslash anywhere ("/\evil.example"), which browsers read as "/";
|
|
205
|
+
* - control characters: CR or LF would split the Location header, and URL
|
|
206
|
+
* parsers drop tabs and newlines, so "/\n/evil.example" becomes
|
|
207
|
+
* "//evil.example" after the check above had passed it;
|
|
208
|
+
* - dot segments that collapse to protocol-relative ("/..//evil.example").
|
|
209
|
+
* And one that would fail the answer instead: a character outside Latin-1
|
|
210
|
+
* makes Node refuse the header (a 500), so the place is written the way a URL
|
|
211
|
+
* spells it — percent-encoded, query and fragment kept.
|
|
212
|
+
*/
|
|
213
|
+
export function returnToPath(raw) {
|
|
214
|
+
const value = typeof raw === "string" ? raw : "";
|
|
215
|
+
if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\") || /\p{Cc}/u.test(value)) {
|
|
216
|
+
return "/";
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
const url = new URL(value, "http://localhost");
|
|
220
|
+
const place = `${url.pathname}${url.search}${url.hash}`;
|
|
221
|
+
return place.startsWith("//") ? "/" : place;
|
|
222
|
+
} catch {
|
|
223
|
+
return "/";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The request fields a JSON door accepts, or null when the door takes a
|
|
228
|
+
* whole body (`$body`) or none. */
|
|
229
|
+
export function requestFields(door) {
|
|
230
|
+
const fields = Object.keys(door.request ?? {});
|
|
231
|
+
if (!fields.length || fields.includes("$body")) return null;
|
|
232
|
+
return new Set(fields.map((field) => field.replace(/\?$/u, "")));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Refuse a JSON body's unknown fields, as the platform's strict grammar
|
|
236
|
+
* does ("an unknown field" is bad_request). */
|
|
237
|
+
export function refuseUnknownFields(door, body) {
|
|
238
|
+
const allowed = requestFields(door);
|
|
239
|
+
if (!allowed || !body || typeof body !== "object" || Array.isArray(body)) return;
|
|
240
|
+
const unknown = Object.keys(body).find((field) => !allowed.has(field));
|
|
241
|
+
if (unknown) throw runtimeError("bad_request", `unknown field '${unknown}'`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const IDEMPOTENCY_KEY = new RegExp(`^[^\\s\\p{Cc}]{1,${LIMITS.idempotency_key_chars}}$`, "u");
|
|
245
|
+
|
|
246
|
+
/** The optional or required Idempotency-Key: 1-`idempotency_key_chars`
|
|
247
|
+
* visible characters. */
|
|
248
|
+
export function idempotencyKey(request, { required = false, door = "this door" } = {}) {
|
|
249
|
+
const raw = request.headers["idempotency-key"];
|
|
250
|
+
if (raw === undefined || raw === "") {
|
|
251
|
+
if (required) throw runtimeError("bad_request", `${door} requires an Idempotency-Key`);
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
const value = Array.isArray(raw) ? raw[0] : String(raw);
|
|
255
|
+
if (!IDEMPOTENCY_KEY.test(value)) {
|
|
256
|
+
throw runtimeError(
|
|
257
|
+
"bad_request",
|
|
258
|
+
`Idempotency-Key must be 1-${LIMITS.idempotency_key_chars} visible characters`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return value;
|
|
262
|
+
}
|
package/bin/dev-data.mjs
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user file plane (`/_terminus/data*`) under `terminus dev`.
|
|
3
|
+
*
|
|
4
|
+
* Zones are the platform's two: `private` (the default — the app's own area)
|
|
5
|
+
* and `output` (the person's deliverables). A `space_id` addresses the
|
|
6
|
+
* caller's own member shard in that space, private zone only; nobody reads
|
|
7
|
+
* another member's shard through this door. Bytes stay plain files under
|
|
8
|
+
* .terminus/dev/ where a developer can look at them; what a file cannot say
|
|
9
|
+
* about itself — its media type, its version, its schema — sits beside it in
|
|
10
|
+
* the system database, and every answer is the contract's FileEntry /
|
|
11
|
+
* WriteReceipt: `{path, size_bytes, sha256, media_type, version, updated_at}`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
import { LIMITS, runtimeError } from "./dev-contract.mjs";
|
|
18
|
+
import { sha256, walkTree } from "./files.mjs";
|
|
19
|
+
import { compareStrings } from "./vendor/where.mjs";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_MEDIA_TYPE = "application/octet-stream";
|
|
22
|
+
const DEFAULT_PAGE = 100;
|
|
23
|
+
|
|
24
|
+
/** A relative path with no empty, `.` or `..` segment, or null. */
|
|
25
|
+
export function safeRelativePath(raw) {
|
|
26
|
+
if (typeof raw !== "string") return null;
|
|
27
|
+
const candidate = raw.replace(/^\/+/u, "");
|
|
28
|
+
if (!candidate || candidate.length > 512 || candidate.includes("\\") || candidate.includes("\0")) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const segments = candidate.split("/");
|
|
32
|
+
if (segments.some((segment) => !segment || segment === "." || segment === "..")) return null;
|
|
33
|
+
return candidate;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** List prefixes may name a directory with a trailing slash ("db/notes/"). */
|
|
37
|
+
export function safeListPrefix(raw) {
|
|
38
|
+
if (raw === undefined || raw === null || raw === "") return "";
|
|
39
|
+
if (typeof raw !== "string") return null;
|
|
40
|
+
const trimmed = raw.replace(/\/+$/u, "");
|
|
41
|
+
const safe = safeRelativePath(trimmed);
|
|
42
|
+
if (safe === null) return null;
|
|
43
|
+
return raw.endsWith("/") ? `${safe}/` : safe;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `limit`: an integer, clamped to 1..page_size; `fallback` when absent. */
|
|
47
|
+
export function pageLimit(raw, fallback = DEFAULT_PAGE) {
|
|
48
|
+
if (raw === null || raw === undefined || raw === "") return fallback;
|
|
49
|
+
if (!/^-?[0-9]+$/u.test(String(raw))) throw runtimeError("bad_request", "limit must be an integer");
|
|
50
|
+
return Math.max(1, Math.min(LIMITS.page_size, Number(raw)));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The media type a request says its body is, as stored. */
|
|
54
|
+
export function requestMediaType(request) {
|
|
55
|
+
const raw = String(request.headers["content-type"] ?? "").trim();
|
|
56
|
+
return raw && raw.length <= 255 ? raw : DEFAULT_MEDIA_TYPE;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class DevDataPlane {
|
|
60
|
+
/** `db` is the dev system database; `rootDir` the project's .terminus/dev. */
|
|
61
|
+
constructor({ rootDir, db, appId }) {
|
|
62
|
+
this.rootDir = rootDir;
|
|
63
|
+
this.db = db;
|
|
64
|
+
this.appId = appId;
|
|
65
|
+
this.locks = new Map();
|
|
66
|
+
db.exec(`
|
|
67
|
+
CREATE TABLE IF NOT EXISTS dev_data_files (
|
|
68
|
+
app_id TEXT NOT NULL,
|
|
69
|
+
root TEXT NOT NULL,
|
|
70
|
+
path TEXT NOT NULL,
|
|
71
|
+
sha256 TEXT NOT NULL,
|
|
72
|
+
media_type TEXT NOT NULL,
|
|
73
|
+
version INTEGER NOT NULL CHECK (version > 0),
|
|
74
|
+
schema_id TEXT,
|
|
75
|
+
schema_version INTEGER,
|
|
76
|
+
updated_at TEXT NOT NULL,
|
|
77
|
+
PRIMARY KEY (app_id, root, path)
|
|
78
|
+
);
|
|
79
|
+
`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Where `member`'s `zone` lives, with `spaceId` naming their shard. */
|
|
83
|
+
root(member, { zone = "private", spaceId = null } = {}) {
|
|
84
|
+
if (zone !== "private" && zone !== "output") {
|
|
85
|
+
throw runtimeError("bad_request", "data zone must be private or output");
|
|
86
|
+
}
|
|
87
|
+
if (zone === "output") {
|
|
88
|
+
if (spaceId) {
|
|
89
|
+
throw runtimeError("bad_request", "a space addresses the caller's member shard, in the private zone only");
|
|
90
|
+
}
|
|
91
|
+
return { key: `output:${member}`, dir: path.join(this.rootDir, "output", member), zone };
|
|
92
|
+
}
|
|
93
|
+
if (spaceId) {
|
|
94
|
+
return {
|
|
95
|
+
key: `shard:${spaceId}:${member}`,
|
|
96
|
+
dir: path.join(this.rootDir, "spaces", spaceId, "shards", member),
|
|
97
|
+
zone,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return { key: `private:${member}`, dir: path.join(this.rootDir, "personal", member), zone };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A file path, refused when unsafe or when it is a bucket's. */
|
|
104
|
+
filePath(root, raw) {
|
|
105
|
+
const relative = safeRelativePath(raw);
|
|
106
|
+
if (!relative) throw runtimeError("bad_request", "invalid app data path");
|
|
107
|
+
if (root.zone === "private" && relative.split("/")[0] === "buckets") {
|
|
108
|
+
throw runtimeError("bad_request", "paths under buckets/ belong to storage buckets");
|
|
109
|
+
}
|
|
110
|
+
return relative;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
row(root, relative) {
|
|
114
|
+
return this.db.prepare(`
|
|
115
|
+
SELECT sha256, media_type, version, schema_id, schema_version, updated_at
|
|
116
|
+
FROM dev_data_files WHERE app_id = ? AND root = ? AND path = ?
|
|
117
|
+
`).get(this.appId, root.key, relative) ?? null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The FileEntry of bytes on disk: what they are, and what was said of them
|
|
121
|
+
* when they were written (a file changed behind the plane's back keeps its
|
|
122
|
+
* version and reads its own time). */
|
|
123
|
+
async entry(root, relative, bytes = null) {
|
|
124
|
+
const file = path.join(root.dir, relative);
|
|
125
|
+
const content = bytes ?? await readFile(file);
|
|
126
|
+
const digest = sha256(content);
|
|
127
|
+
const row = this.row(root, relative);
|
|
128
|
+
const current = row && String(row.sha256) === digest;
|
|
129
|
+
return {
|
|
130
|
+
path: relative,
|
|
131
|
+
size_bytes: content.length,
|
|
132
|
+
sha256: digest,
|
|
133
|
+
media_type: row ? String(row.media_type) : DEFAULT_MEDIA_TYPE,
|
|
134
|
+
version: row ? Number(row.version) : 1,
|
|
135
|
+
updated_at: current ? String(row.updated_at) : (await stat(file)).mtime.toISOString(),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The schema a write declared for the bytes that are there now. */
|
|
140
|
+
schema(root, relative, digest) {
|
|
141
|
+
const row = this.row(root, relative);
|
|
142
|
+
if (!row || String(row.sha256) !== digest) return { id: null, version: null };
|
|
143
|
+
return {
|
|
144
|
+
id: row.schema_id === null ? null : String(row.schema_id),
|
|
145
|
+
version: row.schema_version === null ? null : Number(row.schema_version),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** `{files, next_cursor}`: files under `prefix` in path order, a page after
|
|
150
|
+
* `after` (the previous page's cursor). */
|
|
151
|
+
async list(root, { prefix = "", after = null, limit = DEFAULT_PAGE } = {}) {
|
|
152
|
+
const safePrefix = safeListPrefix(prefix);
|
|
153
|
+
if (safePrefix === null) throw runtimeError("bad_request", "invalid app data prefix");
|
|
154
|
+
let paths;
|
|
155
|
+
try {
|
|
156
|
+
paths = (await walkTree(root.dir)).map((file) => file.path);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error?.code !== "ENOENT") throw error;
|
|
159
|
+
paths = [];
|
|
160
|
+
}
|
|
161
|
+
const wanted = paths
|
|
162
|
+
.filter((file) => file.startsWith(safePrefix))
|
|
163
|
+
.filter((file) => !(root.zone === "private" && file.split("/")[0] === "buckets"))
|
|
164
|
+
.sort(compareStrings)
|
|
165
|
+
.filter((file) => after === null || after === "" || compareStrings(file, after) > 0);
|
|
166
|
+
const page = wanted.slice(0, limit);
|
|
167
|
+
const files = [];
|
|
168
|
+
for (const relative of page) files.push(await this.entry(root, relative));
|
|
169
|
+
return {
|
|
170
|
+
files,
|
|
171
|
+
next_cursor: wanted.length > limit && page.length ? page.at(-1) : null,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** One file's bytes and FileEntry; 404 when there is none. */
|
|
176
|
+
async read(root, raw) {
|
|
177
|
+
const relative = this.filePath(root, raw);
|
|
178
|
+
let bytes;
|
|
179
|
+
try {
|
|
180
|
+
bytes = await readFile(path.join(root.dir, relative));
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error?.code === "ENOENT" || error?.code === "EISDIR") {
|
|
183
|
+
throw runtimeError("not_found", "data object not found");
|
|
184
|
+
}
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
const entry = await this.entry(root, relative, bytes);
|
|
188
|
+
return { bytes, entry, schema: this.schema(root, relative, entry.sha256) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async locked(file, task) {
|
|
192
|
+
const previous = this.locks.get(file) ?? Promise.resolve();
|
|
193
|
+
let release;
|
|
194
|
+
const next = new Promise((resolve) => { release = resolve; });
|
|
195
|
+
this.locks.set(file, next);
|
|
196
|
+
await previous.catch(() => {});
|
|
197
|
+
try {
|
|
198
|
+
return await task();
|
|
199
|
+
} finally {
|
|
200
|
+
release();
|
|
201
|
+
if (this.locks.get(file) === next) this.locks.delete(file);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Compare-and-set: `missing` means the path must not exist yet. */
|
|
206
|
+
async precondition(file, expected) {
|
|
207
|
+
if (expected === null || expected === undefined) return;
|
|
208
|
+
const current = await readFile(file).catch((error) => {
|
|
209
|
+
if (error?.code === "ENOENT") return null;
|
|
210
|
+
throw error;
|
|
211
|
+
});
|
|
212
|
+
const actual = current === null ? "missing" : sha256(current);
|
|
213
|
+
if (actual !== expected) {
|
|
214
|
+
throw runtimeError(
|
|
215
|
+
"version_conflict",
|
|
216
|
+
`'${path.basename(file)}' changed: expected ${expected}, found ${actual}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Write one file → its WriteReceipt. */
|
|
222
|
+
async write(root, raw, bytes, { mediaType = DEFAULT_MEDIA_TYPE, expectedSha256 = null, schemaId = null, schemaVersion = null } = {}) {
|
|
223
|
+
const relative = this.filePath(root, raw);
|
|
224
|
+
if (bytes.length > LIMITS.data_object_bytes) {
|
|
225
|
+
throw runtimeError("payload_too_large", "a data object is at most 12 MiB");
|
|
226
|
+
}
|
|
227
|
+
const file = path.join(root.dir, relative);
|
|
228
|
+
return this.locked(file, async () => {
|
|
229
|
+
await this.precondition(file, expectedSha256);
|
|
230
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
231
|
+
await writeFile(file, bytes);
|
|
232
|
+
const prior = this.row(root, relative);
|
|
233
|
+
const updatedAt = new Date().toISOString();
|
|
234
|
+
const receipt = {
|
|
235
|
+
path: relative,
|
|
236
|
+
size_bytes: bytes.length,
|
|
237
|
+
sha256: sha256(bytes),
|
|
238
|
+
media_type: mediaType,
|
|
239
|
+
version: (prior ? Number(prior.version) : 0) + 1,
|
|
240
|
+
updated_at: updatedAt,
|
|
241
|
+
};
|
|
242
|
+
this.db.prepare(`
|
|
243
|
+
INSERT INTO dev_data_files
|
|
244
|
+
(app_id, root, path, sha256, media_type, version, schema_id, schema_version, updated_at)
|
|
245
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
246
|
+
ON CONFLICT (app_id, root, path) DO UPDATE SET
|
|
247
|
+
sha256 = excluded.sha256,
|
|
248
|
+
media_type = excluded.media_type,
|
|
249
|
+
version = excluded.version,
|
|
250
|
+
schema_id = excluded.schema_id,
|
|
251
|
+
schema_version = excluded.schema_version,
|
|
252
|
+
updated_at = excluded.updated_at
|
|
253
|
+
`).run(
|
|
254
|
+
this.appId,
|
|
255
|
+
root.key,
|
|
256
|
+
relative,
|
|
257
|
+
receipt.sha256,
|
|
258
|
+
receipt.media_type,
|
|
259
|
+
receipt.version,
|
|
260
|
+
schemaId,
|
|
261
|
+
schemaVersion,
|
|
262
|
+
updatedAt,
|
|
263
|
+
);
|
|
264
|
+
return receipt;
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Remove one file (a path already gone is removed all the same). */
|
|
269
|
+
async remove(root, raw, { expectedSha256 = null } = {}) {
|
|
270
|
+
const relative = this.filePath(root, raw);
|
|
271
|
+
const file = path.join(root.dir, relative);
|
|
272
|
+
return this.locked(file, async () => {
|
|
273
|
+
await this.precondition(file, expectedSha256);
|
|
274
|
+
await rm(file, { force: true });
|
|
275
|
+
this.db.prepare("DELETE FROM dev_data_files WHERE app_id = ? AND root = ? AND path = ?")
|
|
276
|
+
.run(this.appId, root.key, relative);
|
|
277
|
+
return { ok: true };
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Every member shard of a deleted space goes with it. */
|
|
282
|
+
async removeSpace(spaceId) {
|
|
283
|
+
await rm(path.join(this.rootDir, "spaces", spaceId), { recursive: true, force: true });
|
|
284
|
+
this.db.prepare("DELETE FROM dev_data_files WHERE app_id = ? AND root LIKE ? ESCAPE '\\'")
|
|
285
|
+
.run(this.appId, `shard:${spaceId.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}:%`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who the local test members are. `--members 3` is Alan, Bob and Carol; any
|
|
3
|
+
* count from 1 through 26 draws from this one stable Alan-to-Zoe directory,
|
|
4
|
+
* and the desk's Test windows name their people from the same list, so a
|
|
5
|
+
* maker meets the same Alan and Bob whichever way they test.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_DEV_MEMBERS = [
|
|
8
|
+
"Alan", "Bob", "Carol", "Dave", "Eve", "Frank", "Grace", "Harry", "Isabella",
|
|
9
|
+
"James", "Kate", "Lily", "Mary", "Nash", "Olivia", "Peter", "Quinn", "Rose",
|
|
10
|
+
"Sam", "Tina", "Uma", "Victor", "Wendy", "Xavier", "Yara", "Zoe",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Who plain `terminus dev` runs as: the first of them, Alan — one person
|
|
15
|
+
* testing alone is still somebody from the directory, not a member called
|
|
16
|
+
* "dev". Also the member the `data` commands read and write by default.
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_DEV_MEMBER = DEFAULT_DEV_MEMBERS[0].toLowerCase();
|