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 +3 -2
- package/dist/dev/dev-command.js +63 -27
- package/dist/dev/dev-servers.js +2 -2
- package/dist/dev/dev-state.js +147 -46
- package/dist/dev/dev-stop.js +107 -23
- package/dist/help.js +6 -4
- package/dist/init/templates.js +4 -3
- package/package.json +3 -3
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
|
|
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
|
|
package/dist/dev/dev-command.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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),
|
package/dist/dev/dev-servers.js
CHANGED
|
@@ -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
|
|
123
|
-
//
|
|
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
|
}
|
package/dist/dev/dev-state.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// ~/.driggsby/dev
|
|
3
|
-
// run ends. `dev --stop` reads
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// stale and treated as absent.
|
|
7
|
-
|
|
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
|
|
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),
|
|
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,
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return
|
|
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
|
|
135
|
-
//
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
162
|
-
if (!info.isFile()
|
|
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
|
-
|
|
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
|
|
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));
|
package/dist/dev/dev-stop.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
// driggsby dev --stop: end
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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,
|
|
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
|
|
30
|
-
if (
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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"
|
|
78
|
+
if (code === "ESRCH") {
|
|
54
79
|
await removeDevState(homeDirectory, state.pid);
|
|
55
|
-
io.out(
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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.
|
package/dist/init/templates.js
CHANGED
|
@@ -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
|
|
100
|
-
// (history_type is required:
|
|
101
|
-
// "
|
|
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.
|
|
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.
|
|
32
|
-
"@driggsby/sdk": "0.
|
|
31
|
+
"@driggsby/deploy": "0.5.1",
|
|
32
|
+
"@driggsby/sdk": "0.5.1"
|
|
33
33
|
}
|
|
34
34
|
}
|