@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.
Files changed (56) hide show
  1. package/LICENSE +177 -0
  2. package/README.md +66 -2
  3. package/bin/substrio.mjs +91 -0
  4. package/package.json +44 -4
  5. package/src/api.mjs +107 -0
  6. package/src/apply.mjs +204 -0
  7. package/src/blobs.mjs +102 -0
  8. package/src/cli.mjs +405 -0
  9. package/src/collections.mjs +701 -0
  10. package/src/command-table.mjs +436 -0
  11. package/src/commands/bundle.mjs +369 -0
  12. package/src/commands/count.mjs +39 -0
  13. package/src/commands/deploy.mjs +488 -0
  14. package/src/commands/dev.mjs +246 -0
  15. package/src/commands/env.mjs +115 -0
  16. package/src/commands/files.mjs +172 -0
  17. package/src/commands/git.mjs +195 -0
  18. package/src/commands/groups-runtime.mjs +227 -0
  19. package/src/commands/groups.mjs +417 -0
  20. package/src/commands/history.mjs +374 -0
  21. package/src/commands/import.mjs +506 -0
  22. package/src/commands/init.mjs +175 -0
  23. package/src/commands/key.mjs +172 -0
  24. package/src/commands/login.mjs +167 -0
  25. package/src/commands/logs.mjs +285 -0
  26. package/src/commands/mcp-config.mjs +102 -0
  27. package/src/commands/op.mjs +265 -0
  28. package/src/commands/open.mjs +35 -0
  29. package/src/commands/push.mjs +199 -0
  30. package/src/commands/signup.mjs +164 -0
  31. package/src/commands/site.mjs +129 -0
  32. package/src/commands/skills.mjs +177 -0
  33. package/src/commands/status.mjs +78 -0
  34. package/src/commands/tenants.mjs +172 -0
  35. package/src/commands/test.mjs +167 -0
  36. package/src/commands/tools.mjs +63 -0
  37. package/src/commands/versions.mjs +293 -0
  38. package/src/config.mjs +569 -0
  39. package/src/documents.mjs +139 -0
  40. package/src/function-build.mjs +273 -0
  41. package/src/identity.mjs +248 -0
  42. package/src/jsonfile.mjs +22 -0
  43. package/src/live-move.mjs +267 -0
  44. package/src/local/args.mjs +58 -0
  45. package/src/local/reload.mjs +408 -0
  46. package/src/local/server.mjs +291 -0
  47. package/src/local/watch.mjs +175 -0
  48. package/src/manifest.mjs +591 -0
  49. package/src/ops.mjs +265 -0
  50. package/src/readers.mjs +416 -0
  51. package/src/sdk.mjs +26 -0
  52. package/src/settings.mjs +117 -0
  53. package/src/testing/app.mjs +507 -0
  54. package/src/testing/env.mjs +35 -0
  55. package/src/testing/index.mjs +11 -0
  56. 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
+ }