@substrio/cli 0.0.0-stage → 0.1.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 +177 -0
- package/README.md +66 -2
- package/bin/substrio.mjs +91 -0
- package/package.json +44 -4
- package/src/api.mjs +107 -0
- package/src/apply.mjs +204 -0
- package/src/blobs.mjs +102 -0
- package/src/cli.mjs +405 -0
- package/src/collections.mjs +701 -0
- package/src/command-table.mjs +436 -0
- package/src/commands/bundle.mjs +369 -0
- package/src/commands/count.mjs +39 -0
- package/src/commands/deploy.mjs +488 -0
- package/src/commands/dev.mjs +246 -0
- package/src/commands/env.mjs +115 -0
- package/src/commands/files.mjs +172 -0
- package/src/commands/git.mjs +195 -0
- package/src/commands/groups-runtime.mjs +227 -0
- package/src/commands/groups.mjs +417 -0
- package/src/commands/history.mjs +374 -0
- package/src/commands/import.mjs +506 -0
- package/src/commands/init.mjs +175 -0
- package/src/commands/key.mjs +172 -0
- package/src/commands/login.mjs +167 -0
- package/src/commands/logs.mjs +285 -0
- package/src/commands/mcp-config.mjs +102 -0
- package/src/commands/op.mjs +265 -0
- package/src/commands/open.mjs +35 -0
- package/src/commands/push.mjs +199 -0
- package/src/commands/signup.mjs +164 -0
- package/src/commands/site.mjs +129 -0
- package/src/commands/skills.mjs +177 -0
- package/src/commands/status.mjs +78 -0
- package/src/commands/tenants.mjs +172 -0
- package/src/commands/test.mjs +167 -0
- package/src/commands/tools.mjs +63 -0
- package/src/commands/versions.mjs +293 -0
- package/src/config.mjs +569 -0
- package/src/documents.mjs +139 -0
- package/src/function-build.mjs +273 -0
- package/src/identity.mjs +248 -0
- package/src/jsonfile.mjs +22 -0
- package/src/live-move.mjs +267 -0
- package/src/local/args.mjs +58 -0
- package/src/local/reload.mjs +408 -0
- package/src/local/server.mjs +291 -0
- package/src/local/watch.mjs +175 -0
- package/src/manifest.mjs +591 -0
- package/src/ops.mjs +265 -0
- package/src/readers.mjs +416 -0
- package/src/sdk.mjs +26 -0
- package/src/settings.mjs +117 -0
- package/src/testing/app.mjs +507 -0
- package/src/testing/env.mjs +35 -0
- package/src/testing/index.mjs +11 -0
- package/src/testing/types.mjs +55 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: the local substrio_server `substrio dev` and `substrio test` run on, or
|
|
3
|
+
* attach to.
|
|
4
|
+
*
|
|
5
|
+
* SPAWN starts a substrio_server of its own on an OS-picked port with a throwaway
|
|
6
|
+
* data directory (removed on stop, unless the caller keeps it) or one the
|
|
7
|
+
* caller names (made, owner-only, when it is missing; its database and keys
|
|
8
|
+
* are reused when they are there), a random master key in a 0600 file, and
|
|
9
|
+
* learns the port from the server itself: the
|
|
10
|
+
* server writes it as one integer and a newline to the descriptor named by
|
|
11
|
+
* `--ready-fd` once every listener is up. The CLI reads that pipe; nothing is
|
|
12
|
+
* polled. ATTACH uses a server somebody else runs: nothing is started, kept
|
|
13
|
+
* or removed.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import { randomBytes } from 'node:crypto';
|
|
18
|
+
import { accessSync, constants, createWriteStream, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { delimiter, join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { SubstrioAPI } from '../api.mjs';
|
|
23
|
+
import { serverInvoker } from '../apply.mjs';
|
|
24
|
+
import { CliError, ErrorCode } from '../cli.mjs';
|
|
25
|
+
import { writePrivateFile } from '../config.mjs';
|
|
26
|
+
import { loadOperations } from '../commands/op.mjs';
|
|
27
|
+
|
|
28
|
+
/** How a local server comes to be. */
|
|
29
|
+
export const ServerTarget = Object.freeze({
|
|
30
|
+
SPAWN: 'spawn',
|
|
31
|
+
ATTACH: 'attach',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** The descriptor of the child the server writes its ready port to (`--ready-fd`). */
|
|
35
|
+
const READY_FD = 3;
|
|
36
|
+
/** The longest a stopping server may take before it is killed. */
|
|
37
|
+
const STOP_DEADLINE_MS = 10_000;
|
|
38
|
+
/** The file name of the server's binary. */
|
|
39
|
+
const BINARY_NAME = 'substrio_server';
|
|
40
|
+
|
|
41
|
+
/** `build/bin/substrio_server` of the checkout this CLI sits in, the last place looked. */
|
|
42
|
+
const CHECKOUT_BINARY = new URL(`../../../../build/bin/${BINARY_NAME}`, import.meta.url).pathname;
|
|
43
|
+
|
|
44
|
+
// The key a 0600 file holds, or null when there is no such file or it is empty.
|
|
45
|
+
function readKeyFile(path) {
|
|
46
|
+
try {
|
|
47
|
+
return readFileSync(path, 'utf8').trim() || null;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (err.code === 'ENOENT') return null;
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const isExecutable = (path) => {
|
|
55
|
+
try {
|
|
56
|
+
accessSync(path, constants.X_OK);
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The substrio_server to run: `explicit` (--server), else SUBSTRIO_SERVER_BIN, else
|
|
65
|
+
* one on PATH, else the checkout's build. Throws a typed CliError naming every
|
|
66
|
+
* place looked when there is none.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveServerBinary(explicit, env = process.env) {
|
|
69
|
+
const named = explicit ?? env.SUBSTRIO_SERVER_BIN;
|
|
70
|
+
if (named) {
|
|
71
|
+
if (!isExecutable(named)) {
|
|
72
|
+
throw new CliError(`The substrio_server ${named} does not exist or is not executable`, { code: ErrorCode.FILE_NOT_FOUND });
|
|
73
|
+
}
|
|
74
|
+
return named;
|
|
75
|
+
}
|
|
76
|
+
const onPath = (env.PATH ?? '').split(delimiter).filter(Boolean).map((dir) => join(dir, BINARY_NAME));
|
|
77
|
+
for (const candidate of [...onPath, CHECKOUT_BINARY]) {
|
|
78
|
+
if (isExecutable(candidate)) return candidate;
|
|
79
|
+
}
|
|
80
|
+
throw new CliError(`No substrio_server found: it is not on PATH and not at ${CHECKOUT_BINARY}`, {
|
|
81
|
+
code: ErrorCode.FILE_NOT_FOUND,
|
|
82
|
+
hint: 'Build it (cmake --build build --target substrio_server), or give --server PATH or set SUBSTRIO_SERVER_BIN',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The port a server wrote to its ready descriptor: one whole number, once, or a CliError. */
|
|
87
|
+
export function parseReadyPort(text) {
|
|
88
|
+
// Digits only: Number() would also take a sign, spaces, a hex or an exponent.
|
|
89
|
+
const digits = text.length > 0 && [...text].every((c) => c >= '0' && c <= '9');
|
|
90
|
+
const port = digits ? Number(text) : Number.NaN;
|
|
91
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
92
|
+
throw new CliError(`The server's ready line was not a port: ${JSON.stringify(text)}`, { code: ErrorCode.UNKNOWN });
|
|
93
|
+
}
|
|
94
|
+
return port;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A running local server: `url`, the master key (`masterKey`, never printed) and
|
|
99
|
+
* `stop()`.
|
|
100
|
+
*/
|
|
101
|
+
export class LocalServer {
|
|
102
|
+
#child = null;
|
|
103
|
+
#dataDir = null;
|
|
104
|
+
#removeDataDir = false;
|
|
105
|
+
#stopping = null;
|
|
106
|
+
#exited;
|
|
107
|
+
#startupLog = '';
|
|
108
|
+
#deadlineMs;
|
|
109
|
+
|
|
110
|
+
constructor({ target, url, masterKey, child = null, dataDir = null, removeDataDir = false, stopDeadlineMs = STOP_DEADLINE_MS }) {
|
|
111
|
+
this.#deadlineMs = stopDeadlineMs;
|
|
112
|
+
this.target = target;
|
|
113
|
+
this.url = url;
|
|
114
|
+
this.masterKey = masterKey;
|
|
115
|
+
this.#child = child;
|
|
116
|
+
this.#dataDir = dataDir;
|
|
117
|
+
this.#removeDataDir = removeDataDir;
|
|
118
|
+
this.#exited = child === null ? Promise.resolve(null) : new Promise((resolve) => {
|
|
119
|
+
child.once('exit', (code, signal) => resolve({ code, signal }));
|
|
120
|
+
// A binary that could not be started never exits: its 'error' is its end.
|
|
121
|
+
child.once('error', () => { if (child.pid === undefined) resolve(null); });
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
get dataDir() { return this.#dataDir; }
|
|
126
|
+
get pid() { return this.#child?.pid ?? null; }
|
|
127
|
+
/** Resolves with {code, signal} when the server process ends (null at once for an attached server). */
|
|
128
|
+
get exited() { return this.#exited; }
|
|
129
|
+
get port() { return Number(new URL(this.url).port); }
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Starts a substrio_server: `binary` (resolveServerBinary), on `port` (0: the OS
|
|
133
|
+
* picks), with data in `dataDir` (made, and removed on stop, when none is
|
|
134
|
+
* given, and removed on stop unless `keep`; a given one is made (0700) when it is missing, and left as it is on
|
|
135
|
+
* stop: its master.key, when there, is the server's key again), and its own output appended to `server.log` in it.
|
|
136
|
+
*/
|
|
137
|
+
static async spawn({ binary, port = 0, dataDir = null, keep = false, extraArgs = [], env = process.env, stopDeadlineMs = STOP_DEADLINE_MS }) {
|
|
138
|
+
const owned = dataDir === null;
|
|
139
|
+
const dir = owned ? mkdtempSync(join(tmpdir(), 'substrio-dev-')) : dataDir;
|
|
140
|
+
if (!owned) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
141
|
+
const keyFile = join(dir, 'master.key');
|
|
142
|
+
const masterKey = readKeyFile(keyFile) ?? randomBytes(24).toString('base64url');
|
|
143
|
+
writePrivateFile(keyFile, `${masterKey}\n`);
|
|
144
|
+
const logFile = join(dir, 'server.log');
|
|
145
|
+
// Opened now, not by the stream later: a server stopped at once removes the directory before a lazy open would run.
|
|
146
|
+
const log = createWriteStream(logFile, { fd: openSync(logFile, 'a') });
|
|
147
|
+
log.on('error', () => {});
|
|
148
|
+
|
|
149
|
+
const child = spawn(binary, [
|
|
150
|
+
'--data', dir, '--port', String(port), '--bind', '127.0.0.1', '--no-metrics',
|
|
151
|
+
'--master-key-file', keyFile, '--ready-fd', String(READY_FD), '--exit-on-stdin-close', ...extraArgs,
|
|
152
|
+
], { stdio: ['pipe', 'pipe', 'pipe', 'pipe'], env });
|
|
153
|
+
const server = new LocalServer({
|
|
154
|
+
target: ServerTarget.SPAWN, url: null, masterKey, child, dataDir: dir, removeDataDir: owned && !keep, stopDeadlineMs,
|
|
155
|
+
});
|
|
156
|
+
// Last resort when this process dies without stop(): the server must not outlive it.
|
|
157
|
+
const onExit = () => {
|
|
158
|
+
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
|
159
|
+
if (owned && !keep) rmSync(dir, { recursive: true, force: true });
|
|
160
|
+
};
|
|
161
|
+
process.once('exit', onExit);
|
|
162
|
+
child.once('exit', () => process.off('exit', onExit));
|
|
163
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
164
|
+
stream.on('data', (chunk) => {
|
|
165
|
+
log.write(chunk);
|
|
166
|
+
if (server.url === null) server.#startupLog += chunk;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
child.once('exit', () => log.end());
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const bound = await new Promise((resolve, reject) => {
|
|
173
|
+
let line = '';
|
|
174
|
+
child.once('error', (err) => reject(new CliError(`Could not start ${binary}: ${err.message}`, { code: ErrorCode.UNKNOWN })));
|
|
175
|
+
child.stdio[READY_FD].setEncoding('utf8');
|
|
176
|
+
child.stdio[READY_FD].on('error', () => {});
|
|
177
|
+
child.stdio[READY_FD].on('data', (chunk) => {
|
|
178
|
+
line += chunk;
|
|
179
|
+
if (!line.endsWith('\n')) return;
|
|
180
|
+
try {
|
|
181
|
+
resolve(parseReadyPort(line.slice(0, -1)));
|
|
182
|
+
} catch (err) {
|
|
183
|
+
reject(err);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
// The pipe ends without a port: the server died, or failed, before it was ready.
|
|
187
|
+
child.stdio[READY_FD].once('end', () => reject(new CliError('The server ended before it was ready', { code: ErrorCode.UNKNOWN })));
|
|
188
|
+
});
|
|
189
|
+
server.url = `http://127.0.0.1:${bound}`;
|
|
190
|
+
server.#startupLog = '';
|
|
191
|
+
return server;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
const output = server.#startupLog.trim();
|
|
194
|
+
await server.stop();
|
|
195
|
+
if (output) err.hint = `Server output:\n${output}`;
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** An attached server: somebody else's, never started, kept or removed here. */
|
|
201
|
+
static attach({ url, masterKey }) {
|
|
202
|
+
let base = url;
|
|
203
|
+
while (base.endsWith('/')) base = base.slice(0, -1);
|
|
204
|
+
return new LocalServer({ target: ServerTarget.ATTACH, url: base, masterKey });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Stops a spawned server (SIGTERM, then SIGKILL if it has not ended by the
|
|
209
|
+
* deadline) and removes the data directory it owns. Safe to call again.
|
|
210
|
+
* Resolves with how the process ended ({code, signal}), null when attached.
|
|
211
|
+
*/
|
|
212
|
+
stop() {
|
|
213
|
+
this.#stopping ??= this.#stop();
|
|
214
|
+
return this.#stopping;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async #stop() {
|
|
218
|
+
const child = this.#child;
|
|
219
|
+
let ended = null;
|
|
220
|
+
if (child !== null) {
|
|
221
|
+
if (child.pid === undefined) {
|
|
222
|
+
ended = await this.#exited;
|
|
223
|
+
} else if (child.exitCode === null && child.signalCode === null) {
|
|
224
|
+
const deadline = setTimeout(() => child.kill('SIGKILL'), this.#deadlineMs);
|
|
225
|
+
child.kill('SIGTERM');
|
|
226
|
+
ended = await this.#exited;
|
|
227
|
+
clearTimeout(deadline);
|
|
228
|
+
} else {
|
|
229
|
+
ended = await this.#exited;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (this.#removeDataDir && this.#dataDir !== null && existsSync(this.#dataDir)) {
|
|
233
|
+
rmSync(this.#dataDir, { recursive: true, force: true });
|
|
234
|
+
}
|
|
235
|
+
return ended;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** An API client on this server as `tenant`, with that tenant's key (or the master key). */
|
|
239
|
+
api(tenant, key = this.masterKey) {
|
|
240
|
+
return new SubstrioAPI({ url: this.url, tenant, master_key: key });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The master-key API, with no tenant: what makes and removes tenants. */
|
|
244
|
+
admin() {
|
|
245
|
+
return this.api(null, this.masterKey);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Makes tenant `slug` (a test tenant when `test`) with the master key and
|
|
250
|
+
* returns {slug, key, api, ops, invoke}: the tenant's own client, the
|
|
251
|
+
* server's operation catalog and a throwing invoke(name, input).
|
|
252
|
+
*/
|
|
253
|
+
async createTenant(slug, { test = false } = {}) {
|
|
254
|
+
const admin = this.admin();
|
|
255
|
+
const adminOps = await loadOperations(admin);
|
|
256
|
+
const made = await serverInvoker(admin, adminOps)('tenant.create', { slug, ...(test ? { test: true } : {}) });
|
|
257
|
+
return this.tenant(slug, made.key);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The tenant `slug` of a data directory that outlives the server: its key is read from `keyFile` (0600) when
|
|
262
|
+
* the file is there and the server still takes it, else the tenant is made and its key saved there.
|
|
263
|
+
* A tenant that exists whose key is gone is the server's own 409.
|
|
264
|
+
*/
|
|
265
|
+
async ensureTenant(slug, keyFile) {
|
|
266
|
+
const saved = readKeyFile(keyFile);
|
|
267
|
+
if (saved !== null) {
|
|
268
|
+
try {
|
|
269
|
+
return await this.tenant(slug, saved);
|
|
270
|
+
} catch (err) {
|
|
271
|
+
if (!(err instanceof CliError) || ![401, 403, 404].includes(err.status)) throw err;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const tenant = await this.createTenant(slug);
|
|
275
|
+
writePrivateFile(keyFile, `${tenant.key}\n`);
|
|
276
|
+
return tenant;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The handle of an existing tenant, as the tenant's own key sees it. */
|
|
280
|
+
async tenant(slug, key) {
|
|
281
|
+
const api = this.api(slug, key);
|
|
282
|
+
const ops = await loadOperations(api);
|
|
283
|
+
return { slug, key, api, ops, invoke: serverInvoker(api, ops) };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Deletes tenant `slug` with all its data (master key). */
|
|
287
|
+
async deleteTenant(slug) {
|
|
288
|
+
const admin = this.admin();
|
|
289
|
+
await serverInvoker(admin, await loadOperations(admin))('tenant.delete', { slug });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: watching the app for `substrio dev`. The OS tells us what changed
|
|
3
|
+
* (fs.watch, recursive: FSEvents on macOS, inotify on Linux), so nothing is
|
|
4
|
+
* polled. Each event is classified once, by comparing its path with the
|
|
5
|
+
* app's own paths (WatchPlan), into a typed Change.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { watch } from 'node:fs';
|
|
9
|
+
import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { collectionsDirOf } from '../collections.mjs';
|
|
12
|
+
|
|
13
|
+
/** What a changed path is to the app. */
|
|
14
|
+
export const ChangeKind = Object.freeze({
|
|
15
|
+
FUNCTION: 'function', // a .js file of functions_dir
|
|
16
|
+
COLLECTION: 'collection', // a .json file of collections_dir
|
|
17
|
+
CONFIG: 'config', // substrio.toml
|
|
18
|
+
RULES: 'rules', // rules_file
|
|
19
|
+
MCP: 'mcp', // mcp_file
|
|
20
|
+
PLANS: 'plans', // plans_file
|
|
21
|
+
SITE: 'site', // a file under a [[site.parts]] from
|
|
22
|
+
BUILD_INPUT: 'build-input', // a file under a build step's dir (not its outputs)
|
|
23
|
+
RESYNC: 'resync', // the OS could not say which file: look at everything
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One classified event: its kind, absolute path, and for FUNCTION the function name (`name`, the
|
|
28
|
+
* first one; `names`, every function this path is an input of: usually one, more when a shared
|
|
29
|
+
* file is bundled into several; `viaInput` when the path is one of those bundled inputs rather than
|
|
30
|
+
* the function's own entry file, which the fingerprint the Reloader keeps does not see), for
|
|
31
|
+
* BUILD_INPUT the step index.
|
|
32
|
+
*/
|
|
33
|
+
export class Change {
|
|
34
|
+
constructor(kind, path, { name = null, names = null, step = null, viaInput = false } = {}) {
|
|
35
|
+
this.kind = kind;
|
|
36
|
+
this.path = path;
|
|
37
|
+
this.names = names ?? (name !== null ? [name] : null);
|
|
38
|
+
this.name = this.names?.[0] ?? null;
|
|
39
|
+
this.step = step;
|
|
40
|
+
this.viaInput = viaInput;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Directory names never watched for changes: what tools write, not what the app is. */
|
|
45
|
+
const IGNORED_DIRECTORIES = new Set(['node_modules', '.git']);
|
|
46
|
+
/** Suffixes and prefixes of the files editors write beside the one saved. */
|
|
47
|
+
const EDITOR_SUFFIXES = ['~', '.swp', '.swx', '.tmp'];
|
|
48
|
+
const EDITOR_PREFIXES = ['.#', '#'];
|
|
49
|
+
const EDITOR_FILES = new Set(['4913', '.DS_Store']);
|
|
50
|
+
|
|
51
|
+
/** Whether `name` is an editor's scratch file. */
|
|
52
|
+
export function isEditorTemp(name) {
|
|
53
|
+
return EDITOR_FILES.has(name) || EDITOR_SUFFIXES.some((s) => name.endsWith(s)) || EDITOR_PREFIXES.some((p) => name.startsWith(p));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The path segments of `to` below `from`, or null when it is not below it. */
|
|
57
|
+
function below(from, to) {
|
|
58
|
+
const rel = relative(from, to);
|
|
59
|
+
if (rel === '' || isAbsolute(rel)) return null;
|
|
60
|
+
const segments = rel.split(sep);
|
|
61
|
+
return segments[0] === '..' ? null : segments;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* What the app's paths are, from its config and manifest: classifies a path
|
|
66
|
+
* once into a Change, or null when it is not the app's.
|
|
67
|
+
*/
|
|
68
|
+
export class WatchPlan {
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} root the app's directory
|
|
71
|
+
* @param {object} config loadConfig's result
|
|
72
|
+
* @param {object} manifest manifestOf's result
|
|
73
|
+
* @param {string[]} ignoredDirs absolute directories to leave out (a data directory inside the app)
|
|
74
|
+
*/
|
|
75
|
+
constructor(root, config, manifest, ignoredDirs = []) {
|
|
76
|
+
this.root = root;
|
|
77
|
+
this.config = resolve(root, 'substrio.toml');
|
|
78
|
+
this.functionsDir = resolve(root, config.functions_dir || 'functions');
|
|
79
|
+
this.collectionsDir = resolve(root, collectionsDirOf(config));
|
|
80
|
+
this.single = new Map();
|
|
81
|
+
if (config.rules_file) this.single.set(resolve(root, config.rules_file), ChangeKind.RULES);
|
|
82
|
+
if (config.mcp_file) this.single.set(resolve(root, config.mcp_file), ChangeKind.MCP);
|
|
83
|
+
if (config.plans_file) this.single.set(resolve(root, config.plans_file), ChangeKind.PLANS);
|
|
84
|
+
this.siteDirs = (manifest.site?.parts ?? []).map((part) => resolve(root, part.from));
|
|
85
|
+
this.buildDirs = manifest.build.map((step, index) => ({ index, dir: resolve(root, step.dir) }));
|
|
86
|
+
this.ignoredDirs = ignoredDirs.map((d) => resolve(d));
|
|
87
|
+
this.inputsByFunction = new Map(); // function name -> Set<absolute path>, its bundle's non-entry inputs
|
|
88
|
+
this.functionsByInput = new Map(); // absolute path -> Set<function name>, the reverse of the above
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What `name`'s bundle reads besides its entry file (buildFunction's `inputs`, absolute): a
|
|
93
|
+
* save to any of them now reclassifies as that function changing, same as saving the entry
|
|
94
|
+
* file itself. Replaces whatever was recorded for `name` before.
|
|
95
|
+
*/
|
|
96
|
+
setFunctionInputs(name, absPaths) {
|
|
97
|
+
const before = this.inputsByFunction.get(name);
|
|
98
|
+
if (before) {
|
|
99
|
+
for (const path of before) {
|
|
100
|
+
const names = this.functionsByInput.get(path);
|
|
101
|
+
if (names) { names.delete(name); if (names.size === 0) this.functionsByInput.delete(path); }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const after = new Set(absPaths.filter((p) => p !== resolve(this.functionsDir, `${name}.js`)));
|
|
105
|
+
this.inputsByFunction.set(name, after);
|
|
106
|
+
for (const path of after) {
|
|
107
|
+
let names = this.functionsByInput.get(path);
|
|
108
|
+
if (!names) this.functionsByInput.set(path, (names = new Set()));
|
|
109
|
+
names.add(name);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The Change an absolute `path` is, or null. */
|
|
114
|
+
classify(path) {
|
|
115
|
+
const segments = below(this.root, path);
|
|
116
|
+
if (segments === null) return null;
|
|
117
|
+
// A bundle's own inputs, node_modules included, are watched before the usual tool-directory
|
|
118
|
+
// exclusion: that is exactly where a real npm dependency lives.
|
|
119
|
+
const inputOf = this.functionsByInput.get(path);
|
|
120
|
+
if (inputOf !== undefined) return new Change(ChangeKind.FUNCTION, path, { names: [...inputOf], viaInput: true });
|
|
121
|
+
if (segments.some((s) => IGNORED_DIRECTORIES.has(s))) return null;
|
|
122
|
+
if (this.ignoredDirs.some((dir) => below(dir, path) !== null)) return null;
|
|
123
|
+
if (isEditorTemp(basename(path))) return null;
|
|
124
|
+
if (path === this.config) return new Change(ChangeKind.CONFIG, path);
|
|
125
|
+
const single = this.single.get(path);
|
|
126
|
+
if (single !== undefined) return new Change(single, path);
|
|
127
|
+
// The app's outputs first: a build step's dir often holds them (functions/dist inside functions/).
|
|
128
|
+
if (dirname(path) === this.functionsDir && path.endsWith('.js')) {
|
|
129
|
+
return new Change(ChangeKind.FUNCTION, path, { name: basename(path).slice(0, -'.js'.length) });
|
|
130
|
+
}
|
|
131
|
+
if (dirname(path) === this.collectionsDir && extname(path) === '.json') {
|
|
132
|
+
return new Change(ChangeKind.COLLECTION, path, { name: basename(path, '.json') });
|
|
133
|
+
}
|
|
134
|
+
if (this.siteDirs.some((dir) => below(dir, path) !== null)) return new Change(ChangeKind.SITE, path);
|
|
135
|
+
if (below(this.functionsDir, path) !== null) return null;
|
|
136
|
+
const step = this.buildDirs.find(({ dir }) => below(dir, path) !== null);
|
|
137
|
+
return step === undefined ? null : new Change(ChangeKind.BUILD_INPUT, path, { step: step.index });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* fs.watch over the app's directory: every event is classified and handed to
|
|
143
|
+
* `onChange(change)`. close() ends it for good: a start() after it (a session
|
|
144
|
+
* stopped while it was still starting) watches nothing, so no watcher is left
|
|
145
|
+
* holding the process open.
|
|
146
|
+
*/
|
|
147
|
+
export class AppWatcher {
|
|
148
|
+
#watcher = null;
|
|
149
|
+
#closed = false;
|
|
150
|
+
|
|
151
|
+
constructor(plan, onChange) {
|
|
152
|
+
this.plan = plan;
|
|
153
|
+
this.onChange = onChange;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
start() {
|
|
157
|
+
if (this.#closed) return this;
|
|
158
|
+
this.#watcher = watch(this.plan.root, { recursive: true, persistent: true }, (eventType, filename) => {
|
|
159
|
+
// An event with no file name: the OS could not say which, so everything is looked at.
|
|
160
|
+
if (filename === null || filename === undefined) {
|
|
161
|
+
this.onChange(new Change(ChangeKind.RESYNC, this.plan.root));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const change = this.plan.classify(resolve(this.plan.root, String(filename)));
|
|
165
|
+
if (change !== null) this.onChange(change);
|
|
166
|
+
});
|
|
167
|
+
return this;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
close() {
|
|
171
|
+
this.#closed = true;
|
|
172
|
+
this.#watcher?.close();
|
|
173
|
+
this.#watcher = null;
|
|
174
|
+
}
|
|
175
|
+
}
|