driggsby 0.4.1 → 0.5.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/README.md CHANGED
@@ -60,8 +60,9 @@ This runs the app locally, embedded exactly the way Driggsby embeds it
60
60
  after a deploy, and reloads the page when you save a file. While dev runs,
61
61
  the preview serves your financial data to this machine — other websites
62
62
  can't reach it, but anything running locally can, so stop it when you're
63
- done: press Ctrl+C, or from any terminal run `npx driggsby@latest dev --stop`.
64
- It also stops on its own after 30 minutes with no page open.
63
+ done: press Ctrl+C, or run `npx driggsby@latest dev --stop` in the app's
64
+ folder. It also stops on its own after 30 minutes with no page open.
65
+ Previews for several apps can run at once; each prints its own addresses.
65
66
 
66
67
  ## Deploying an app
67
68
 
@@ -15,12 +15,15 @@ import { tryOpenUrl } from "../login/open-url.js";
15
15
  import { wrapProse } from "../terminal-text.js";
16
16
  import { DEV_IDLE_MINUTES, DEV_START_COMMAND, DEV_STOP_COMMAND } from "./dev-commands.js";
17
17
  import { startDevServers } from "./dev-servers.js";
18
- import { readLiveDevState, removeDevState, writeDevState } from "./dev-state.js";
19
- import { displayFolder } from "./display-folder.js";
18
+ import { pruneDevStatesForPorts, readDevStates, removeDevState, writeDevState } from "./dev-state.js";
20
19
  import { McpBroker } from "./mcp-broker.js";
21
20
  import { watchDirectory } from "./watcher.js";
22
21
  export const DEV_HOST_PORT = 4111;
23
22
  export const DEV_APP_PORT = 4112;
23
+ // Previews for other folders can already hold the default pair (a person
24
+ // often runs several agents at once), so dev moves up two ports at a time,
25
+ // 4111/4112, then 4113/4114, and so on, through this many pairs.
26
+ export const DEV_PORT_PAIRS = 10;
24
27
  const DEV_IDLE_TIMEOUT_MS = DEV_IDLE_MINUTES * 60 * 1000;
25
28
  const DEV_IDLE_CHECK_MS = 15 * 1000;
26
29
  function defaultDevIo() {
@@ -57,22 +60,36 @@ export async function runDev(options = {}, environment = defaultCredentialEnviro
57
60
  " npx driggsby@latest deploy", 1);
58
61
  }
59
62
  const session = await requireDeploySession(environment);
63
+ await refuseSecondPreview(environment.homeDirectory, projectDirectory);
60
64
  const sdkBundle = await loadSdkBundle();
61
65
  const broker = new McpBroker({ baseUrl: session.baseUrl, token: session.token });
62
- let servers;
63
- try {
64
- servers = await startDevServers({
65
- slug: config.slug,
66
- background: config.background,
67
- serveDirectory: config.serveDirectory,
68
- sdkBundle,
69
- runToolCall: (tool, argumentsObject) => broker.runToolCall(tool, argumentsObject),
70
- hostPort: options.hostPort ?? DEV_HOST_PORT,
71
- appPort: options.appPort ?? DEV_APP_PORT,
72
- });
73
- }
74
- catch (error) {
75
- throw await devStartFailure(error, baseUrl, environment.homeDirectory);
66
+ const hostPort = options.hostPort ?? DEV_HOST_PORT;
67
+ const appPort = options.appPort ?? DEV_APP_PORT;
68
+ const pairs = options.portPairs ?? DEV_PORT_PAIRS;
69
+ // Ephemeral ports (0, in tests) are never in use, so they never move.
70
+ const fixedPorts = hostPort !== 0 && appPort !== 0;
71
+ let servers = null;
72
+ for (let pair = 0; servers === null; pair += 1) {
73
+ try {
74
+ servers = await startDevServers({
75
+ slug: config.slug,
76
+ background: config.background,
77
+ serveDirectory: config.serveDirectory,
78
+ sdkBundle,
79
+ runToolCall: (tool, argumentsObject) => broker.runToolCall(tool, argumentsObject),
80
+ hostPort: shiftedPort(hostPort, pair),
81
+ appPort: shiftedPort(appPort, pair),
82
+ });
83
+ }
84
+ catch (error) {
85
+ // A pair already in use (another folder's preview, most often) moves
86
+ // the run to the next pair; startDevServers has released the port it
87
+ // did bind.
88
+ if (portUnavailable(error) && pair + 1 < pairs && fixedPorts) {
89
+ continue;
90
+ }
91
+ throw devStartFailure(error, baseUrl, { hostPort, appPort, pairs });
92
+ }
76
93
  }
77
94
  const stopWatching = watchDirectory(config.serveDirectory, () => {
78
95
  servers.notifyChange();
@@ -111,6 +128,8 @@ export async function runDev(options = {}, environment = defaultCredentialEnviro
111
128
  }
112
129
  async function tryWriteDevState(homeDirectory, folder, servers) {
113
130
  try {
131
+ // Records a dev left behind on these ports can't be live: they're ours now.
132
+ await pruneDevStatesForPorts(homeDirectory, process.pid, servers.hostPort, servers.appPort);
114
133
  await writeDevState(homeDirectory, {
115
134
  pid: process.pid,
116
135
  folder,
@@ -126,7 +145,7 @@ async function tryWriteDevState(homeDirectory, folder, servers) {
126
145
  }
127
146
  function readyText(slug, servers, opened, recorded, idleTimeoutMs) {
128
147
  const stopLine = recorded
129
- ? `${wrapProse("Edit the app's files and the page reloads on save. Press Ctrl+C to stop, or from any terminal:")}\n ${DEV_STOP_COMMAND}\n`
148
+ ? `${wrapProse("Edit the app's files and the page reloads on save. Press Ctrl+C to stop, or run this in the app's folder:")}\n ${DEV_STOP_COMMAND}\n`
130
149
  : `${wrapProse("Edit the app's files and the page reloads on save. Press Ctrl+C to stop.")}\n`;
131
150
  return (`✓ Ready ${slug} is running with your live Driggsby data${opened ? ", at:" : ":"}\n\n` +
132
151
  ` ${servers.hostOrigin}\n\n` +
@@ -174,19 +193,36 @@ async function loadSdkBundle() {
174
193
  const bundlePath = require.resolve("@driggsby/sdk/driggsby-sdk.js");
175
194
  return await readFile(bundlePath, "utf8");
176
195
  }
177
- async function devStartFailure(error, baseUrl, homeDirectory) {
196
+ function shiftedPort(port, pair) {
197
+ return port === 0 ? 0 : port + pair * 2;
198
+ }
199
+ // In use, or reserved (Windows can exclude port ranges for Hyper-V and
200
+ // WinNAT, which reads as access denied).
201
+ function portUnavailable(error) {
202
+ const code = error?.code;
203
+ return code === "EADDRINUSE" || code === "EACCES";
204
+ }
205
+ // One preview per folder: a second would only compete with the first for
206
+ // the same files, so dev points at the one already running instead.
207
+ async function refuseSecondPreview(homeDirectory, projectDirectory) {
208
+ const reads = await readDevStates(homeDirectory, undefined, projectDirectory);
209
+ const running = reads.find((read) => read.status === "live")?.state;
210
+ if (running === undefined) {
211
+ return;
212
+ }
213
+ throw new CliError(`driggsby dev is already running for this app, at:\n http://127.0.0.1:${String(running.hostPort)}\n\n` +
214
+ `${wrapProse("Open that address, or stop it and start again:")}\n ${DEV_STOP_COMMAND}\n ${DEV_START_COMMAND}`, 1);
215
+ }
216
+ function devStartFailure(error, baseUrl, ports) {
178
217
  if (error instanceof CliError) {
179
218
  return error;
180
219
  }
181
- if (error.code === "EADDRINUSE") {
182
- const running = await readLiveDevState(homeDirectory);
183
- if (running !== null) {
184
- return new CliError(`driggsby dev is already running for the app in:\n ${displayFolder(running.folder)}\n\n` +
185
- `Only one can run at a time. Stop it first:\n ${DEV_STOP_COMMAND}\n\n` +
186
- `Then try again here:\n ${DEV_START_COMMAND}`, 1);
187
- }
188
- return new CliError(`${wrapProse(`Ports ${String(DEV_HOST_PORT)} and ${String(DEV_APP_PORT)} are how driggsby dev serves the preview, and something on this machine is already using one of them. Stop that program and try again:`)}\n` +
189
- ` ${DEV_START_COMMAND}`, 1);
220
+ if (portUnavailable(error)) {
221
+ const first = Math.min(ports.hostPort, ports.appPort);
222
+ const last = Math.max(shiftedPort(ports.hostPort, ports.pairs - 1), shiftedPort(ports.appPort, ports.pairs - 1));
223
+ return new CliError(`${wrapProse(`driggsby dev serves previews in pairs of ports from ${String(first)} to ${String(last)}, and none of those pairs is free on this machine. Stop a preview you're done with, in its app's folder:`)}\n` +
224
+ ` ${DEV_STOP_COMMAND}\n\n` +
225
+ `Then try again here:\n ${DEV_START_COMMAND}`, 1);
190
226
  }
191
227
  const blocked = blockedNetworkError(error, {
192
228
  host: apiHost(baseUrl),
@@ -119,8 +119,8 @@ async function handleHostRequest(options, hostPort, appOrigin, sseClients, reque
119
119
  }
120
120
  const path = requestPath(request);
121
121
  if (request.method === "GET" && path === DEV_IDENTITY_PATH) {
122
- // Lets `dev --stop` (and a colliding `dev`) confirm that the process in
123
- // ~/.driggsby/dev.json is the one actually serving this port.
122
+ // Lets `dev --stop` (and `dev`) confirm that a record in ~/.driggsby
123
+ // names the process actually serving this port.
124
124
  sendJson(response, 200, { pid: process.pid });
125
125
  return;
126
126
  }
@@ -1,14 +1,19 @@
1
- // The record of the one `driggsby dev` running on this machine:
2
- // ~/.driggsby/dev.json, written after both ports bind and removed when the
3
- // run ends. `dev --stop` reads it to find the process, and a second `dev`
4
- // reads it to say which folder already holds the ports. A file whose pid is
5
- // no longer alive (the machine rebooted, the process was killed hard) is
6
- // stale and treated as absent. It holds no credentials.
7
- import { lstat, readFile, rm } from "node:fs/promises";
1
+ // The records of the `driggsby dev` previews running on this machine, one
2
+ // per run: ~/.driggsby/dev-<pid>.json, written after both ports bind and
3
+ // removed when the run ends. `dev --stop` reads them to find the process
4
+ // for a folder, and `dev` reads them to say where a folder's preview is
5
+ // already running. A file whose pid is no longer alive (the machine
6
+ // rebooted, the process was killed hard) is stale and treated as absent.
7
+ // Earlier versions kept one machine-wide record in ~/.driggsby/dev.json;
8
+ // it is still read, so a preview started by one of them can be stopped.
9
+ // They hold no credentials.
10
+ import { constants } from "node:fs";
11
+ import { open, readdir, rm } from "node:fs/promises";
8
12
  import { Agent, request as httpRequest } from "node:http";
9
- import { join } from "node:path";
13
+ import { isAbsolute, join, resolve } from "node:path";
10
14
  import { driggsbyDirectory, writeOwnerOnlyFile } from "../owner-only-file.js";
11
- const STATE_FILE_NAME = "dev.json";
15
+ const STATE_FILE_PATTERN = /^dev-([1-9]\d{0,9})\.json$/;
16
+ const LEGACY_STATE_FILE_NAME = "dev.json";
12
17
  // The host origin answers this with the pid of the dev serving it, so a
13
18
  // record can be checked against the process actually holding the port.
14
19
  export const DEV_IDENTITY_PATH = "/-/dev-identity";
@@ -27,36 +32,109 @@ const STATE_MAX_BYTES = 4_096;
27
32
  // carries no such configuration, so the identity probe below can never
28
33
  // leave the loopback interface.
29
34
  const LOOPBACK_AGENT = new Agent({ keepAlive: false });
30
- export function devStatePath(homeDirectory) {
31
- return join(driggsbyDirectory(homeDirectory), STATE_FILE_NAME);
35
+ export function devStatePath(homeDirectory, pid) {
36
+ return join(driggsbyDirectory(homeDirectory), stateFileName(pid));
37
+ }
38
+ function stateFileName(pid) {
39
+ return `dev-${String(pid)}.json`;
32
40
  }
33
41
  export async function writeDevState(homeDirectory, state) {
34
- await writeOwnerOnlyFile(homeDirectory, STATE_FILE_NAME, `${JSON.stringify(state, null, 2)}\n`);
42
+ await writeOwnerOnlyFile(homeDirectory, stateFileName(state.pid), `${JSON.stringify(state, null, 2)}\n`);
35
43
  }
36
44
  export function defaultDevProbes() {
37
45
  return { isAlive: processIsAlive, pidServing: devPidServing };
38
46
  }
39
- export async function readDevState(homeDirectory, probes = defaultDevProbes()) {
40
- const state = await readDevStateFile(homeDirectory);
41
- if (state === null) {
42
- return { status: "none", state: null };
43
- }
44
- if (state === "malformed") {
45
- await rm(devStatePath(homeDirectory), { force: true });
46
- return { status: "none", state: null };
47
+ // Every record on this machine that may still be a running dev, oldest
48
+ // first; stale and malformed records are removed on the way. With
49
+ // `onlyFolder`, only that folder's records are read and probed.
50
+ export async function readDevStates(homeDirectory, probes = defaultDevProbes(), onlyFolder) {
51
+ const reads = [];
52
+ for (const record of await recordFiles(homeDirectory)) {
53
+ const state = await readDevStateFile(record.path);
54
+ if (state === null || state === "skip") {
55
+ continue;
56
+ }
57
+ // A record must name the pid its file is named for, so no one run's
58
+ // file can speak for another's.
59
+ if (state === "malformed" || (record.pid !== null && state.pid !== record.pid)) {
60
+ await removeQuietly(record.path);
61
+ continue;
62
+ }
63
+ if (onlyFolder !== undefined && !sameFolder(state.folder, onlyFolder)) {
64
+ continue;
65
+ }
66
+ if (!probes.isAlive(state.pid)) {
67
+ await removeQuietly(record.path);
68
+ continue;
69
+ }
70
+ const servingPid = await probes.pidServing(state.hostPort);
71
+ if (servingPid !== null && servingPid !== state.pid) {
72
+ await removeQuietly(record.path);
73
+ continue;
74
+ }
75
+ reads.push(servingPid === state.pid ? { status: "live", state } : { status: "unconfirmed", state });
47
76
  }
48
- if (!probes.isAlive(state.pid)) {
49
- await removeDevState(homeDirectory, state.pid);
50
- return { status: "none", state: null };
77
+ return reads.sort((a, b) => (a.state.startedAt < b.state.startedAt ? -1 : a.state.startedAt > b.state.startedAt ? 1 : 0));
78
+ }
79
+ // A dev that has just bound its ports clears every other record naming one
80
+ // of them: those ports are held exclusively on 127.0.0.1, so no dev such a
81
+ // record names can still be serving there.
82
+ export async function pruneDevStatesForPorts(homeDirectory, ownPid, hostPort, appPort) {
83
+ const ours = [hostPort, appPort];
84
+ for (const record of await recordFiles(homeDirectory)) {
85
+ const state = await readDevStateFile(record.path);
86
+ if (state === null || state === "skip" || state === "malformed" || state.pid === ownPid) {
87
+ continue;
88
+ }
89
+ if (ours.includes(state.hostPort) || ours.includes(state.appPort)) {
90
+ await removeQuietly(record.path);
91
+ }
51
92
  }
52
- return (await probes.pidServing(state.hostPort)) === state.pid
53
- ? { status: "live", state }
54
- : { status: "unconfirmed", state };
55
93
  }
56
- // The state of a dev that is confirmed running, or null.
57
- export async function readLiveDevState(homeDirectory, probes = defaultDevProbes()) {
58
- const read = await readDevState(homeDirectory, probes);
59
- return read.status === "live" ? read.state : null;
94
+ // Windows has neither flag; there a symlink is followed and still checked
95
+ // as a regular file through the one handle.
96
+ function readFlags() {
97
+ return process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
98
+ }
99
+ // Two records name the same folder when their resolved paths match; paths
100
+ // on Windows compare without regard to case, as its filesystems do.
101
+ export function sameFolder(a, b) {
102
+ const left = resolve(a);
103
+ const right = resolve(b);
104
+ return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
105
+ }
106
+ // Removal is best-effort: an entry that cannot be removed (a directory, a
107
+ // file another program holds open on Windows) must never stop a dev from
108
+ // starting or stopping.
109
+ async function removeQuietly(path) {
110
+ await rm(path, { force: true }).catch(() => undefined);
111
+ }
112
+ // The states of the devs confirmed running, oldest first.
113
+ export async function readLiveDevStates(homeDirectory, probes = defaultDevProbes()) {
114
+ const reads = await readDevStates(homeDirectory, probes);
115
+ return reads.filter((read) => read.status === "live").map((read) => read.state);
116
+ }
117
+ async function recordFiles(homeDirectory) {
118
+ const directory = driggsbyDirectory(homeDirectory);
119
+ let names;
120
+ try {
121
+ names = await readdir(directory);
122
+ }
123
+ catch {
124
+ return [];
125
+ }
126
+ const files = [];
127
+ for (const name of names) {
128
+ if (name === LEGACY_STATE_FILE_NAME) {
129
+ files.push({ path: join(directory, name), pid: null });
130
+ continue;
131
+ }
132
+ const match = STATE_FILE_PATTERN.exec(name);
133
+ if (match !== null) {
134
+ files.push({ path: join(directory, name), pid: Number(match[1]) });
135
+ }
136
+ }
137
+ return files;
60
138
  }
61
139
  // Asks the host origin on `hostPort` which pid serves it; null when nothing
62
140
  // answers, or whatever answers is not a driggsby dev. A short pause and one
@@ -131,15 +209,14 @@ function pidFromIdentityBody(body) {
131
209
  return null;
132
210
  }
133
211
  }
134
- // Removes the file only if it still names `pid`: a dev that ends must never
135
- // erase the record of a newer dev that took the ports after it.
212
+ // Removes the run's own record, and the legacy machine-wide record only if
213
+ // it still names `pid`: a dev that ends must never erase another's record.
136
214
  export async function removeDevState(homeDirectory, pid) {
137
- const state = await readDevStateFile(homeDirectory);
138
- if (state === null) {
139
- return;
140
- }
141
- if (state === "malformed" || state.pid === pid) {
142
- await rm(devStatePath(homeDirectory), { force: true });
215
+ await removeQuietly(devStatePath(homeDirectory, pid));
216
+ const legacyPath = join(driggsbyDirectory(homeDirectory), LEGACY_STATE_FILE_NAME);
217
+ const legacy = await readDevStateFile(legacyPath);
218
+ if (legacy !== null && legacy !== "skip" && legacy !== "malformed" && legacy.pid === pid) {
219
+ await removeQuietly(legacyPath);
143
220
  }
144
221
  }
145
222
  export function processIsAlive(pid) {
@@ -152,20 +229,41 @@ export function processIsAlive(pid) {
152
229
  return error.code === "EPERM";
153
230
  }
154
231
  }
155
- // Only a small regular file is read: a symlink, a device, or anything
156
- // oversized at that path is not our record.
157
- async function readDevStateFile(homeDirectory) {
158
- const path = devStatePath(homeDirectory);
232
+ // Only a small regular file is read. The file is opened without following
233
+ // a symlink (a symlink reads as malformed, and removing it unlinks only the
234
+ // link) and without blocking (a FIFO never hangs the read), then checked
235
+ // and read through that one handle, so nothing swapped in after a check is
236
+ // ever read. Anything else at the path (a directory, a device) is skipped.
237
+ async function readDevStateFile(path) {
238
+ let handle;
239
+ try {
240
+ handle = await open(path, readFlags());
241
+ }
242
+ catch (error) {
243
+ const code = error.code;
244
+ return code === "ELOOP" || code === "EMLINK" ? "malformed" : code === "ENOENT" ? null : "skip";
245
+ }
159
246
  let raw;
160
247
  try {
161
- const info = await lstat(path);
162
- if (!info.isFile() || info.size > STATE_MAX_BYTES) {
248
+ const info = await handle.stat();
249
+ if (!info.isFile()) {
250
+ return "skip";
251
+ }
252
+ if (info.size > STATE_MAX_BYTES) {
163
253
  return "malformed";
164
254
  }
165
- raw = await readFile(path, "utf8");
255
+ const buffer = Buffer.alloc(STATE_MAX_BYTES + 1);
256
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
257
+ if (bytesRead > STATE_MAX_BYTES) {
258
+ return "malformed";
259
+ }
260
+ raw = buffer.subarray(0, bytesRead).toString("utf8");
166
261
  }
167
262
  catch {
168
- return null;
263
+ return "skip";
264
+ }
265
+ finally {
266
+ await handle.close();
169
267
  }
170
268
  try {
171
269
  const parsed = JSON.parse(raw);
@@ -181,7 +279,10 @@ function isDevState(value) {
181
279
  }
182
280
  const record = value;
183
281
  return (isPid(record.pid) &&
282
+ // Always absolute as dev writes it; a relative one would match any
283
+ // folder it is read from.
184
284
  typeof record.folder === "string" &&
285
+ isAbsolute(record.folder) &&
185
286
  typeof record.startedAt === "string" &&
186
287
  isPort(record.hostPort) &&
187
288
  isPort(record.appPort));
@@ -1,11 +1,18 @@
1
- // driggsby dev --stop: end the `driggsby dev` running on this machine from
2
- // any terminal, so an agent that started one in the background can put the
3
- // ports (and the live-data preview) away without hunting for the process.
4
- // The record in ~/.driggsby/dev.json is trusted only after the recorded
5
- // port confirms it is served by the recorded pid (see readDevState).
1
+ // driggsby dev --stop: end a `driggsby dev` without its terminal, so an
2
+ // agent that started one in the background can put the ports (and the
3
+ // live-data preview) away without hunting for the process. Several previews
4
+ // can run at once, one per app folder, often each an agent's: run anywhere
5
+ // inside an app (its folder or a subfolder), it stops that app's preview
6
+ // and never another's; run outside any app, it stops the only preview
7
+ // running, and with several it names them rather than guess. A record is
8
+ // trusted only after its recorded port confirms it is served by the
9
+ // recorded pid (see readDevStates), and that is confirmed again just before
10
+ // the signal.
11
+ import { stat } from "node:fs/promises";
12
+ import { dirname, join, resolve as resolvePath } from "node:path";
6
13
  import { CliError } from "../cli-error.js";
7
14
  import { DEV_START_COMMAND, DEV_STOP_COMMAND } from "./dev-commands.js";
8
- import { defaultDevProbes, readDevState, removeDevState } from "./dev-state.js";
15
+ import { defaultDevProbes, readDevStates, removeDevState, sameFolder, } from "./dev-state.js";
9
16
  import { displayFolder } from "./display-folder.js";
10
17
  // How many waits of `waitMs` a signalled dev gets to go before we report it.
11
18
  const STOP_ATTEMPTS = 20;
@@ -25,42 +32,65 @@ function defaultDevStopIo() {
25
32
  },
26
33
  };
27
34
  }
28
- export async function runDevStop(homeDirectory, io = defaultDevStopIo(), deps = defaultDevStopDeps()) {
29
- const read = await readDevState(homeDirectory, deps);
30
- if (read.status === "none") {
35
+ export async function runDevStop(homeDirectory, io = defaultDevStopIo(), deps = defaultDevStopDeps(), currentFolder = process.cwd()) {
36
+ const reads = await readDevStates(homeDirectory, deps);
37
+ if (reads.length === 0) {
31
38
  io.out(nothingRunning());
32
39
  return 0;
33
40
  }
41
+ // The app this runs in, found the way `dev` records it: the nearest folder
42
+ // at or above this one with a driggsby.json. Its live preview comes first:
43
+ // a leftover record for the app must never hide the one running there.
44
+ const appFolder = await appFolderAround(currentFolder);
45
+ const here = reads.filter((each) => sameFolder(each.state.folder, appFolder ?? currentFolder));
46
+ let read = here.find(isLive) ?? here[0];
47
+ if (read === undefined) {
48
+ if (appFolder !== null) {
49
+ io.out(nothingRunningForThisApp(reads));
50
+ return 0;
51
+ }
52
+ const live = reads.filter(isLive);
53
+ read = live.length === 1 ? live[0] : live.length === 0 && folderCount(reads) === 1 ? reads[0] : undefined;
54
+ }
55
+ if (read === undefined) {
56
+ throw severalRunning(reads);
57
+ }
34
58
  const state = read.state;
35
59
  const folder = displayFolder(state.folder);
36
- if (read.status === "unconfirmed") {
37
- // The pid is alive but the port did not answer as that dev: it may be
38
- // busy, or the pid may have been recycled. Never signal a process the
39
- // port has not vouched for, and never claim nothing is running.
40
- throw new CliError(`driggsby dev is recorded for the app in:\n ${folder}\n\n` +
41
- "but didn't answer, so it wasn't stopped. If it's still running, press\n" +
42
- `Ctrl+C in the terminal where it runs, then try again:\n ${DEV_STOP_COMMAND}\n\n` +
43
- `If nothing is running there, the next driggsby dev replaces this record.`, 1);
60
+ // The pid is alive but the port did not answer as that dev: it may be
61
+ // busy, or the pid may have been recycled. Never signal a process the
62
+ // port has not vouched for, and never claim nothing is running. The port
63
+ // is asked again right before the signal, since reading every record
64
+ // can take a few seconds on a busy machine.
65
+ if (read.status === "unconfirmed" || (await deps.pidServing(state.hostPort)) !== state.pid) {
66
+ throw unconfirmed(folder);
44
67
  }
45
68
  try {
46
69
  deps.terminate(state.pid);
47
70
  }
48
71
  catch (error) {
49
- // Gone between the check and the signal, or a pid that is not ours to
50
- // signal (another user's process): either way there is no dev to stop.
51
- // Any other failure leaves the record, since the dev may well be running.
72
+ // Gone between the check and the signal: nothing left to stop. A dev
73
+ // the port vouched for that this process may not signal (a sandboxed
74
+ // agent, another user's process) is still running, so its record stays
75
+ // and the person is told where to stop it. Any other failure leaves the
76
+ // record too.
52
77
  const code = error.code;
53
- if (code === "ESRCH" || code === "EPERM") {
78
+ if (code === "ESRCH") {
54
79
  await removeDevState(homeDirectory, state.pid);
55
- io.out(nothingRunning());
80
+ io.out(`driggsby dev had already stopped for the app in:\n ${folder}\n`);
56
81
  return 0;
57
82
  }
83
+ if (code === "EPERM") {
84
+ throw new CliError(`driggsby dev is running for the app in:\n ${folder}\n\n` +
85
+ "but isn't allowed to be stopped from here. Press Ctrl+C in the terminal\n" +
86
+ "where it runs.", 1);
87
+ }
58
88
  throw error;
59
89
  }
60
90
  for (let attempt = 0; attempt < STOP_ATTEMPTS; attempt += 1) {
61
91
  if (!deps.isAlive(state.pid)) {
62
92
  await removeDevState(homeDirectory, state.pid);
63
- io.out(`✓ Stopped driggsby dev for the app in:\n ${folder}\n\nStart it again with:\n ${DEV_START_COMMAND}\n`);
93
+ io.out(`✓ Stopped driggsby dev for the app in:\n ${folder}\n\nStart it again in its folder with:\n ${DEV_START_COMMAND}\n`);
64
94
  return 0;
65
95
  }
66
96
  await new Promise((resolve) => {
@@ -71,6 +101,60 @@ export async function runDevStop(homeDirectory, io = defaultDevStopIo(), deps =
71
101
  "Press Ctrl+C in the terminal where it runs, then check again:\n" +
72
102
  ` ${DEV_STOP_COMMAND}`, 1);
73
103
  }
104
+ function isLive(read) {
105
+ return read.status === "live";
106
+ }
107
+ async function appFolderAround(folder) {
108
+ for (let current = resolvePath(folder);; current = dirname(current)) {
109
+ if (await hasAppConfig(current)) {
110
+ return current;
111
+ }
112
+ if (dirname(current) === current) {
113
+ return null;
114
+ }
115
+ }
116
+ }
117
+ async function hasAppConfig(folder) {
118
+ try {
119
+ return (await stat(join(folder, "driggsby.json"))).isFile();
120
+ }
121
+ catch {
122
+ return false;
123
+ }
124
+ }
125
+ function folderCount(reads) {
126
+ const folders = [];
127
+ for (const read of reads) {
128
+ if (!folders.some((folder) => sameFolder(folder, read.state.folder))) {
129
+ folders.push(read.state.folder);
130
+ }
131
+ }
132
+ return folders.length;
133
+ }
134
+ function unconfirmed(folder) {
135
+ return new CliError(`driggsby dev is recorded for the app in:\n ${folder}\n\n` +
136
+ "but didn't answer, so it wasn't stopped. If it's still running, press\n" +
137
+ `Ctrl+C in the terminal where it runs, then try again:\n ${DEV_STOP_COMMAND}\n\n` +
138
+ "If nothing is running there, the record is a leftover.", 1);
139
+ }
140
+ // One line per folder, the ones that did not answer marked as such.
141
+ function runningFolders(reads) {
142
+ const lines = reads.map((read) => ` ${displayFolder(read.state.folder)}${read.status === "live" ? "" : " (not answering)"}`);
143
+ return [...new Set(lines)].join("\n");
144
+ }
145
+ function nothingRunningForThisApp(reads) {
146
+ return ("No driggsby dev is running for this app.\n\n" +
147
+ `Previews running for other apps, each stopped from its own folder:\n${runningFolders(reads)}\n\n` +
148
+ `Start one in this app's folder with:\n ${DEV_START_COMMAND}\n`);
149
+ }
150
+ function severalRunning(reads) {
151
+ const apps = folderCount(reads) > 1 ? "more than one app" : "one app more than once";
152
+ const head = reads.some(isLive)
153
+ ? `driggsby dev is running for ${apps}, in:`
154
+ : `driggsby dev is recorded for ${apps}, and none answered:`;
155
+ return new CliError(`${head}\n${runningFolders(reads)}\n\n` +
156
+ `Nothing was stopped. Run this in the folder of the one to stop:\n ${DEV_STOP_COMMAND}`, 1);
157
+ }
74
158
  function nothingRunning() {
75
159
  return `No driggsby dev is running on this machine.\n\nStart one in an app's folder with:\n ${DEV_START_COMMAND}\n`;
76
160
  }
package/dist/help.js CHANGED
@@ -86,18 +86,20 @@ This runs the app locally, embedded the same way Driggsby embeds it after a
86
86
  deploy, so what you see here is what a deploy will show. Edits to the app's
87
87
  files reload the page on save.
88
88
 
89
+ Previews for several apps can run at once; each prints its own addresses.
90
+
89
91
  While dev runs, the preview serves your financial data to this machine —
90
92
  other websites can't reach it, but anything running locally can, so stop
91
- it when you're done: press Ctrl+C, or from any terminal run
92
- npx driggsby@latest dev --stop. It also stops on its own after
93
- ${String(DEV_IDLE_MINUTES)} minutes with no page open.
93
+ it when you're done: press Ctrl+C, or run npx driggsby@latest dev --stop
94
+ in the app's folder. It also stops on its own after ${String(DEV_IDLE_MINUTES)} minutes with no
95
+ page open.
94
96
 
95
97
  Sign in first with npx driggsby@latest login.
96
98
 
97
99
  Usage: npx driggsby@latest dev [--stop]
98
100
 
99
101
  Options:
100
- --stop Stop the driggsby dev running on this machine.
102
+ --stop Stop this app's driggsby dev, or the only one running.
101
103
  -h, --help Print help
102
104
  `;
103
105
  export const DEPLOY_HELP = `Deploy the app in this directory to Driggsby.
@@ -96,9 +96,10 @@ export const APP_JS = `// Your app's code. Edit anything — driggsby dev reload
96
96
  // These are the tools an app can watch — read-only, nothing else responds:
97
97
  //
98
98
  // get_overview money rollups + every linked account
99
- // get_history balances, holdings, or debts over time
100
- // (history_type is required: "account_balances",
101
- // "investment_holdings", or "liabilities")
99
+ // get_history balances, holdings, debts, or manual items
100
+ // over time (history_type is required:
101
+ // "account_balances", "investment_holdings",
102
+ // "liabilities", or "custom_items")
102
103
  // list_accounts current balances per linked account
103
104
  // list_assets_and_liabilities the full balance sheet, incl. manual items
104
105
  // list_findings issues Driggsby has flagged
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driggsby",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Set up and build on Driggsby, the MCP server for personal finance.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  "node": ">=18"
29
29
  },
30
30
  "dependencies": {
31
- "@driggsby/deploy": "0.4.1",
32
- "@driggsby/sdk": "0.4.1"
31
+ "@driggsby/deploy": "0.5.1",
32
+ "@driggsby/sdk": "0.5.1"
33
33
  }
34
34
  }