driggsby 0.2.3 → 0.3.0

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.
@@ -0,0 +1,196 @@
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";
8
+ import { Agent, request as httpRequest } from "node:http";
9
+ import { join } from "node:path";
10
+ import { driggsbyDirectory, writeOwnerOnlyFile } from "../owner-only-file.js";
11
+ const STATE_FILE_NAME = "dev.json";
12
+ // The host origin answers this with the pid of the dev serving it, so a
13
+ // record can be checked against the process actually holding the port.
14
+ export const DEV_IDENTITY_PATH = "/-/dev-identity";
15
+ const IDENTITY_TIMEOUT_MS = 1_000;
16
+ // Whole-probe deadline: the socket timeout above resets on every byte, so a
17
+ // port that dribbles bytes would otherwise hold the probe open for ever;
18
+ // the abort surfaces as a request error, which reads as no answer.
19
+ const IDENTITY_DEADLINE_MS = 2_000;
20
+ const IDENTITY_ATTEMPTS = 2;
21
+ const IDENTITY_RETRY_PAUSE_MS = 100;
22
+ const IDENTITY_MAX_BYTES = 4_096;
23
+ // The record is a few short fields; anything larger is not ours.
24
+ const STATE_MAX_BYTES = 4_096;
25
+ // Node routes fetch AND the default http agent through a proxy named in the
26
+ // environment (NODE_USE_ENV_PROXY with HTTP_PROXY); an agent built here
27
+ // carries no such configuration, so the identity probe below can never
28
+ // leave the loopback interface.
29
+ const LOOPBACK_AGENT = new Agent({ keepAlive: false });
30
+ export function devStatePath(homeDirectory) {
31
+ return join(driggsbyDirectory(homeDirectory), STATE_FILE_NAME);
32
+ }
33
+ export async function writeDevState(homeDirectory, state) {
34
+ await writeOwnerOnlyFile(homeDirectory, STATE_FILE_NAME, `${JSON.stringify(state, null, 2)}\n`);
35
+ }
36
+ export function defaultDevProbes() {
37
+ return { isAlive: processIsAlive, pidServing: devPidServing };
38
+ }
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
+ }
48
+ if (!probes.isAlive(state.pid)) {
49
+ await removeDevState(homeDirectory, state.pid);
50
+ return { status: "none", state: null };
51
+ }
52
+ return (await probes.pidServing(state.hostPort)) === state.pid
53
+ ? { status: "live", state }
54
+ : { status: "unconfirmed", state };
55
+ }
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;
60
+ }
61
+ // Asks the host origin on `hostPort` which pid serves it; null when nothing
62
+ // answers, or whatever answers is not a driggsby dev. A short pause and one
63
+ // retry cover a transient socket failure.
64
+ export async function devPidServing(hostPort) {
65
+ for (let attempt = 0; attempt < IDENTITY_ATTEMPTS; attempt += 1) {
66
+ if (attempt > 0) {
67
+ await new Promise((resolve) => {
68
+ setTimeout(resolve, IDENTITY_RETRY_PAUSE_MS);
69
+ });
70
+ }
71
+ const pid = await askIdentity(hostPort);
72
+ if (pid !== null) {
73
+ return pid;
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+ // Loopback only, on LOOPBACK_AGENT. No redirect is followed (anything but
79
+ // 200 is no answer), and the body is read bounded, because whatever holds
80
+ // that port is untrusted.
81
+ function askIdentity(hostPort) {
82
+ return new Promise((settle) => {
83
+ const request = httpRequest({
84
+ host: "127.0.0.1",
85
+ port: hostPort,
86
+ path: DEV_IDENTITY_PATH,
87
+ method: "GET",
88
+ timeout: IDENTITY_TIMEOUT_MS,
89
+ signal: AbortSignal.timeout(IDENTITY_DEADLINE_MS),
90
+ agent: LOOPBACK_AGENT,
91
+ }, (response) => {
92
+ if (response.statusCode !== 200) {
93
+ response.resume();
94
+ settle(null);
95
+ return;
96
+ }
97
+ const chunks = [];
98
+ let total = 0;
99
+ response.on("data", (chunk) => {
100
+ total += chunk.byteLength;
101
+ if (total > IDENTITY_MAX_BYTES) {
102
+ request.destroy();
103
+ settle(null);
104
+ return;
105
+ }
106
+ chunks.push(chunk);
107
+ });
108
+ response.on("end", () => {
109
+ settle(pidFromIdentityBody(Buffer.concat(chunks).toString("utf8")));
110
+ });
111
+ response.on("error", () => {
112
+ settle(null);
113
+ });
114
+ });
115
+ request.on("timeout", () => {
116
+ request.destroy();
117
+ });
118
+ request.on("error", () => {
119
+ settle(null);
120
+ });
121
+ request.end();
122
+ });
123
+ }
124
+ function pidFromIdentityBody(body) {
125
+ try {
126
+ const payload = JSON.parse(body);
127
+ const pid = typeof payload === "object" && payload !== null ? payload.pid : null;
128
+ return isPid(pid) ? pid : null;
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
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.
136
+ 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 });
143
+ }
144
+ }
145
+ export function processIsAlive(pid) {
146
+ try {
147
+ process.kill(pid, 0);
148
+ return true;
149
+ }
150
+ catch (error) {
151
+ // EPERM means the process exists but belongs to someone else.
152
+ return error.code === "EPERM";
153
+ }
154
+ }
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);
159
+ let raw;
160
+ try {
161
+ const info = await lstat(path);
162
+ if (!info.isFile() || info.size > STATE_MAX_BYTES) {
163
+ return "malformed";
164
+ }
165
+ raw = await readFile(path, "utf8");
166
+ }
167
+ catch {
168
+ return null;
169
+ }
170
+ try {
171
+ const parsed = JSON.parse(raw);
172
+ return isDevState(parsed) ? parsed : "malformed";
173
+ }
174
+ catch {
175
+ return "malformed";
176
+ }
177
+ }
178
+ function isDevState(value) {
179
+ if (typeof value !== "object" || value === null) {
180
+ return false;
181
+ }
182
+ const record = value;
183
+ return (isPid(record.pid) &&
184
+ typeof record.folder === "string" &&
185
+ typeof record.startedAt === "string" &&
186
+ isPort(record.hostPort) &&
187
+ isPort(record.appPort));
188
+ }
189
+ // Only a real, positive pid: 0 and negatives are process groups to kill(2),
190
+ // and a record must never turn into a broadcast signal.
191
+ function isPid(value) {
192
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
193
+ }
194
+ function isPort(value) {
195
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535;
196
+ }
@@ -0,0 +1,76 @@
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).
6
+ import { CliError } from "../cli-error.js";
7
+ import { DEV_START_COMMAND, DEV_STOP_COMMAND } from "./dev-commands.js";
8
+ import { defaultDevProbes, readDevState, removeDevState } from "./dev-state.js";
9
+ import { displayFolder } from "./display-folder.js";
10
+ // How many waits of `waitMs` a signalled dev gets to go before we report it.
11
+ const STOP_ATTEMPTS = 20;
12
+ function defaultDevStopDeps() {
13
+ return {
14
+ ...defaultDevProbes(),
15
+ terminate: (pid) => {
16
+ process.kill(pid, "SIGTERM");
17
+ },
18
+ waitMs: 250,
19
+ };
20
+ }
21
+ function defaultDevStopIo() {
22
+ return {
23
+ out: (text) => {
24
+ process.stdout.write(text);
25
+ },
26
+ };
27
+ }
28
+ export async function runDevStop(homeDirectory, io = defaultDevStopIo(), deps = defaultDevStopDeps()) {
29
+ const read = await readDevState(homeDirectory, deps);
30
+ if (read.status === "none") {
31
+ io.out(nothingRunning());
32
+ return 0;
33
+ }
34
+ const state = read.state;
35
+ 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);
44
+ }
45
+ try {
46
+ deps.terminate(state.pid);
47
+ }
48
+ 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.
52
+ const code = error.code;
53
+ if (code === "ESRCH" || code === "EPERM") {
54
+ await removeDevState(homeDirectory, state.pid);
55
+ io.out(nothingRunning());
56
+ return 0;
57
+ }
58
+ throw error;
59
+ }
60
+ for (let attempt = 0; attempt < STOP_ATTEMPTS; attempt += 1) {
61
+ if (!deps.isAlive(state.pid)) {
62
+ 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`);
64
+ return 0;
65
+ }
66
+ await new Promise((resolve) => {
67
+ setTimeout(resolve, deps.waitMs);
68
+ });
69
+ }
70
+ throw new CliError(`driggsby dev was asked to stop but is still running for the app in:\n ${folder}\n\n` +
71
+ "Press Ctrl+C in the terminal where it runs, then check again:\n" +
72
+ ` ${DEV_STOP_COMMAND}`, 1);
73
+ }
74
+ function nothingRunning() {
75
+ return `No driggsby dev is running on this machine.\n\nStart one in an app's folder with:\n ${DEV_START_COMMAND}\n`;
76
+ }
@@ -0,0 +1,23 @@
1
+ // A project folder as printed in `dev` messages: the person's home shortens
2
+ // to "~" so the common case fits a terminal line, and the result goes
3
+ // through quotedForTerminal because a path is text this CLI did not author.
4
+ import { homedir } from "node:os";
5
+ import { isAbsolute, relative, sep } from "node:path";
6
+ import { quotedForTerminal } from "../terminal-text.js";
7
+ const MAX_FOLDER_CHARS = 200;
8
+ export function displayFolder(folder, home = homedir()) {
9
+ return quotedForTerminal(homeRelative(folder, home), MAX_FOLDER_CHARS);
10
+ }
11
+ function homeRelative(folder, home) {
12
+ if (home === "" || !isAbsolute(folder)) {
13
+ return folder;
14
+ }
15
+ const inside = relative(home, folder);
16
+ if (inside === "") {
17
+ return "~";
18
+ }
19
+ if (inside.startsWith("..") || isAbsolute(inside)) {
20
+ return folder;
21
+ }
22
+ return `~${sep}${inside}`;
23
+ }
@@ -82,12 +82,56 @@ export const HOST_PAGE_JS = [
82
82
  "",
83
83
  "let appRoute = sanitizeAppRoute(window.location.hash);",
84
84
  "",
85
- "// URL writes are bounded (leading + one trailing per window) because",
86
- "// the frame controls their rate, and each write is guarded: Safari",
87
- "// hard-errors on rapid replaceState bursts.",
88
- "const ROUTE_WRITE_WINDOW_MS = 1000;",
85
+ "// URL writes are bounded because the frame controls their rate, but",
86
+ "// ordinary clicking should not feel the bound — a token bucket does",
87
+ "// both, matching the production host: a flurry of up to 15",
88
+ "// navigations writes instantly, sustained clicking faster than one",
89
+ "// per 600ms then paces at 600ms, and a frame spamming routes drains",
90
+ "// the bucket and degrades to paced trailing writes that still land",
91
+ "// the final state. Burst (15) plus refills (one per 600ms = 50 per",
92
+ "// 30s) tops out at 65 writes per 30s, leaving at least ~35 of",
93
+ "// Safari's ~100-per-30s per-document replaceState budget for the",
94
+ "// host page's own writes. Keep these two constants identical to the",
95
+ "// production host's; nothing pins them across the two codebases.",
96
+ "const ROUTE_WRITE_BUCKET_CAPACITY = 15;",
97
+ "const ROUTE_WRITE_REFILL_MS = 600;",
98
+ "let routeTokens = ROUTE_WRITE_BUCKET_CAPACITY;",
99
+ "let routeLastRefillMs = Date.now();",
89
100
  "let routeWriteTimer = null;",
90
101
  "",
102
+ "function refillRouteTokens() {",
103
+ " const now = Date.now();",
104
+ " // A backward clock step would otherwise strand the refill mark in",
105
+ " // the future and stall every write until the clock caught back up.",
106
+ " if (now < routeLastRefillMs) {",
107
+ " routeLastRefillMs = now;",
108
+ " }",
109
+ " const earned = Math.floor((now - routeLastRefillMs) / ROUTE_WRITE_REFILL_MS);",
110
+ " if (earned < 1) {",
111
+ " return;",
112
+ " }",
113
+ " routeTokens = Math.min(ROUTE_WRITE_BUCKET_CAPACITY, routeTokens + earned);",
114
+ " routeLastRefillMs += earned * ROUTE_WRITE_REFILL_MS;",
115
+ "}",
116
+ "",
117
+ "function takeRouteToken() {",
118
+ " refillRouteTokens();",
119
+ " if (routeTokens < 1) {",
120
+ " return false;",
121
+ " }",
122
+ " routeTokens -= 1;",
123
+ " return true;",
124
+ "}",
125
+ "",
126
+ "function routeRetryDelayMs() {",
127
+ " refillRouteTokens();",
128
+ " if (routeTokens >= 1) {",
129
+ " return 1;",
130
+ " }",
131
+ " const sinceRefill = Date.now() - routeLastRefillMs;",
132
+ " return Math.min(ROUTE_WRITE_REFILL_MS, Math.max(1, ROUTE_WRITE_REFILL_MS - sinceRefill));",
133
+ "}",
134
+ "",
91
135
  "function writeRouteToUrl() {",
92
136
  " if (window.location.hash === appRoute) {",
93
137
  " return;",
@@ -100,16 +144,33 @@ export const HOST_PAGE_JS = [
100
144
  " }",
101
145
  "}",
102
146
  "",
147
+ "function scheduleTrailingRouteWrite() {",
148
+ " routeWriteTimer = setTimeout(() => {",
149
+ " routeWriteTimer = null;",
150
+ " if (takeRouteToken()) {",
151
+ " writeRouteToUrl();",
152
+ " } else {",
153
+ " // The clock can land a hair before the token accrues; wait out",
154
+ " // the remainder rather than writing over budget.",
155
+ " scheduleTrailingRouteWrite();",
156
+ " }",
157
+ " }, routeRetryDelayMs());",
158
+ "}",
159
+ "",
103
160
  "function applyRoute(hash) {",
104
161
  " appRoute = hash;",
105
162
  " if (routeWriteTimer !== null) {",
163
+ " return; // the trailing write reads appRoute",
164
+ " }",
165
+ " // Already mirrored: nothing to write, no token spent on nothing.",
166
+ " if (window.location.hash === hash) {",
106
167
  " return;",
107
168
  " }",
108
- " writeRouteToUrl();",
109
- " routeWriteTimer = setTimeout(() => {",
110
- " routeWriteTimer = null;",
169
+ " if (takeRouteToken()) {",
111
170
  " writeRouteToUrl();",
112
- " }, ROUTE_WRITE_WINDOW_MS);",
171
+ " return;",
172
+ " }",
173
+ " scheduleTrailingRouteWrite();",
113
174
  "}",
114
175
  "",
115
176
  "function postToApp(message) {",
@@ -4,6 +4,7 @@
4
4
  // { ok, result | error } envelope. Bounds mirror the production host: a few
5
5
  // calls in flight, a bounded queue, and a hard per-call timeout, so a
6
6
  // buggy app can hammer its own laptop but never Driggsby.
7
+ import { capForTerminal, sanitizeForTerminal } from "../terminal-text.js";
7
8
  import { GENERIC_TOOL_TROUBLE } from "./dev-servers.js";
8
9
  export const MAX_IN_FLIGHT_CALLS = 4;
9
10
  export const MAX_QUEUED_CALLS = 64;
@@ -16,8 +17,9 @@ export const SIGN_IN_AGAIN_MESSAGE = "Your sign-in on this machine isn't valid a
16
17
  // endpoint requires a short reason on most read tools; a locally previewed
17
18
  // dashboard has exactly one.
18
19
  const DEFAULT_REASON = "Rendering this data in the developer's own Driggsby app preview.";
19
- // One broker per `driggsby dev` run. runToolCall never rejects — every
20
- // failure becomes an { ok: false } envelope with a person-readable message.
20
+ // One broker per `driggsby dev` run (or per `driggsby query`). With the
21
+ // default transportErrors, runToolCall never rejects — every failure
22
+ // becomes an { ok: false } envelope with a person-readable message.
21
23
  export class McpBroker {
22
24
  options;
23
25
  queue = [];
@@ -30,8 +32,8 @@ export class McpBroker {
30
32
  if (this.queue.length >= MAX_QUEUED_CALLS) {
31
33
  return { ok: false, error: { message: GENERIC_TOOL_TROUBLE } };
32
34
  }
33
- return await new Promise((resolve) => {
34
- this.queue.push({ tool, argumentsObject, resolve });
35
+ return await new Promise((resolve, reject) => {
36
+ this.queue.push({ tool, argumentsObject, resolve, reject });
35
37
  this.pump();
36
38
  });
37
39
  }
@@ -53,7 +55,11 @@ export class McpBroker {
53
55
  try {
54
56
  result = await this.callMcp(call.tool, call.argumentsObject);
55
57
  }
56
- catch {
58
+ catch (error) {
59
+ if (this.options.transportErrors === "throw") {
60
+ call.reject(error);
61
+ return;
62
+ }
57
63
  result = { ok: false, error: { message: GENERIC_TOOL_TROUBLE } };
58
64
  }
59
65
  call.resolve(result);
@@ -61,7 +67,7 @@ export class McpBroker {
61
67
  async callMcp(tool, argumentsObject) {
62
68
  const toolArguments = { ...argumentsObject };
63
69
  if (typeof toolArguments.reason !== "string" || toolArguments.reason.trim() === "") {
64
- toolArguments.reason = DEFAULT_REASON;
70
+ toolArguments.reason = this.options.defaultReason ?? DEFAULT_REASON;
65
71
  }
66
72
  const requestId = this.nextRequestId;
67
73
  this.nextRequestId += 1;
@@ -87,7 +93,7 @@ export class McpBroker {
87
93
  signal: AbortSignal.timeout(this.options.timeoutMs ?? TOOL_CALL_TIMEOUT_MS),
88
94
  });
89
95
  if (response.status === 401) {
90
- return { ok: false, error: { message: SIGN_IN_AGAIN_MESSAGE } };
96
+ return { ok: false, error: { message: this.options.signInAgainMessage ?? SIGN_IN_AGAIN_MESSAGE } };
91
97
  }
92
98
  if (!response.ok) {
93
99
  return { ok: false, error: { message: GENERIC_TOOL_TROUBLE } };
@@ -142,8 +148,15 @@ function toolRefusalMessage(resultRecord) {
142
148
  }
143
149
  return GENERIC_TOOL_TROUBLE;
144
150
  }
151
+ // The server's own error text is hostile input on every path that shows it
152
+ // (a terminal for `query`, a page for `dev`): strip control and bidi bytes
153
+ // and cap the length in one place, so no caller has to remember to.
145
154
  function capMessage(message) {
146
- return message.length > MAX_ERROR_MESSAGE_CHARS
147
- ? `${message.slice(0, MAX_ERROR_MESSAGE_CHARS)}…`
148
- : message;
155
+ const clean = sanitizeForTerminal(message);
156
+ // Text that was nothing but invisible code points sanitizes to nothing;
157
+ // a blank error helps nobody.
158
+ if (clean.trim() === "") {
159
+ return GENERIC_TOOL_TROUBLE;
160
+ }
161
+ return clean.length > MAX_ERROR_MESSAGE_CHARS ? `${capForTerminal(clean, MAX_ERROR_MESSAGE_CHARS)}…` : clean;
149
162
  }
package/dist/help.js CHANGED
@@ -5,6 +5,9 @@
5
5
  // the long setup help, and the distinct short/long setup variants). The root
6
6
  // help grew past the Rust CLI when login/logout landed; its fixture pins the
7
7
  // current text.
8
+ import { DEV_IDLE_MINUTES } from "./dev/dev-commands.js";
9
+ import { APP_TOOL_ALLOWLIST } from "./dev/tool-allowlist.js";
10
+ import { wrapNames } from "./terminal-text.js";
8
11
  export const ROOT_HELP = `Usage: npx driggsby@latest <COMMAND>
9
12
 
10
13
  Commands:
@@ -16,6 +19,7 @@ Commands:
16
19
  deploy Deploy the app in this directory to Driggsby.
17
20
  rollback Switch which deployed version of your app is live.
18
21
  versions List your deployed app's versions.
22
+ query Run one Driggsby data tool and print its result as JSON.
19
23
 
20
24
  Options:
21
25
  -h, --help Print help
@@ -30,6 +34,7 @@ Examples:
30
34
  npx driggsby@latest init money-dash
31
35
  npx driggsby@latest dev
32
36
  npx driggsby@latest deploy
37
+ npx driggsby@latest query get_overview
33
38
  `;
34
39
  export const INIT_HELP = `Create a new Driggsby app in a new folder.
35
40
 
@@ -80,22 +85,27 @@ files reload the page on save.
80
85
 
81
86
  While dev runs, the preview serves your financial data to this machine —
82
87
  other websites can't reach it, but anything running locally can, so stop
83
- it when you're done.
88
+ it when you're done: press Ctrl+C, or from any terminal run
89
+ npx driggsby@latest dev --stop. It also stops on its own after
90
+ ${String(DEV_IDLE_MINUTES)} minutes with no page open.
84
91
 
85
92
  Sign in first with npx driggsby@latest login.
86
93
 
87
- Usage: npx driggsby@latest dev
94
+ Usage: npx driggsby@latest dev [--stop]
88
95
 
89
96
  Options:
97
+ --stop Stop the driggsby dev running on this machine.
90
98
  -h, --help Print help
91
99
  `;
92
100
  export const DEPLOY_HELP = `Deploy the app in this directory to Driggsby.
93
101
 
94
102
  This reads driggsby.json next to your app files, uploads only what changed,
95
- and makes the new version live at your app's driggsby.dev address. The
96
- first deploy creates the app: Driggsby assigns its address (your name plus
97
- a unique ending) and saves it back into driggsby.json. With --preview, the
98
- version is uploaded and kept ready without changing what's live.
103
+ and makes the new version live. It prints the app's page in Driggsby, where
104
+ it runs with your data, then the app's own driggsby.dev address, which
105
+ shows no Driggsby data when opened directly. The first deploy creates the
106
+ app: Driggsby assigns its address (your name plus a unique ending) and
107
+ saves it back into driggsby.json. With --preview, the version is uploaded
108
+ and kept ready without changing what's live.
99
109
 
100
110
  Sign in first with npx driggsby@latest login.
101
111
 
@@ -128,6 +138,36 @@ Usage: npx driggsby@latest versions
128
138
  Options:
129
139
  -h, --help Print help
130
140
  `;
141
+ // The tool list is the allowlist itself, so the help can never name a tool
142
+ // a Driggsby app cannot watch.
143
+ const QUERY_TOOL_LINES = wrapNames([...APP_TOOL_ALLOWLIST], " ");
144
+ export const QUERY_HELP = `Run one Driggsby data tool and print its result as JSON.
145
+
146
+ The output is exactly what driggsby.watch hands an app's callback for the
147
+ same tool and params, so you can read a result's shape before writing any
148
+ render code. Only the read-only tools an app can watch are accepted. The
149
+ JSON goes to stdout and nothing else does; messages go to stderr.
150
+
151
+ Sign in first with npx driggsby@latest login.
152
+
153
+ Usage: npx driggsby@latest query <TOOL> [--sql <SQL>] [--params <JSON>]
154
+
155
+ Arguments:
156
+ <TOOL> One of:
157
+ ${QUERY_TOOL_LINES}
158
+
159
+ Options:
160
+ --sql <SQL> The SQL for query_cash_sql or query_investment_sql.
161
+ Overrides a sql key given in --params.
162
+ --params <JSON> Other params as a JSON object, for example
163
+ '{"history_type":"liabilities"}'.
164
+ -h, --help Print help
165
+
166
+ Examples:
167
+ npx driggsby@latest query get_overview
168
+ npx driggsby@latest query list_recurring_transactions
169
+ npx driggsby@latest query query_cash_sql --sql "SELECT 1"
170
+ `;
131
171
  export const MCP_HELP = `Set up Driggsby MCP clients.
132
172
 
133
173
  Usage: npx driggsby@latest mcp <COMMAND>
@@ -21,8 +21,6 @@ export function indexHtml(slug) {
21
21
  <main>
22
22
  <header>
23
23
  <h1>${slug}</h1>
24
- <p class="sample-note" id="sample-note" hidden>Sample data — your real
25
- accounts appear when this app runs inside Driggsby.</p>
26
24
  </header>
27
25
  <!-- The bars below are skeleton placeholders. app.js replaces them
28
26
  with data on its first render; edit the render functions there,
@@ -112,6 +110,11 @@ export const APP_JS = `// Your app's code. Edit anything — driggsby dev reload
112
110
  // Params mirror Driggsby's public MCP tools of the same names, with one
113
111
  // simplification: those tools require a "reason" string, but a watch can
114
112
  // leave it out — Driggsby fills one in naming the dashboard.
113
+ //
114
+ // To see a tool's exact result shape before writing render code, run it
115
+ // once from the terminal — the JSON it prints is what the callback gets:
116
+ //
117
+ // npx driggsby@latest query <tool>
115
118
 
116
119
  // ---------------------------------------------------------------------------
117
120
  // Sample data. Every name and number here is invented. It paints only
@@ -232,25 +235,18 @@ function renderAccounts(result) {
232
235
  // Live data. window.driggsby exists when the Driggsby SDK loaded — inside
233
236
  // Driggsby, or under driggsby dev. Whether anything embeds this page is
234
237
  // knowable synchronously: opened directly in a tab, no host will ever
235
- // answer, so the sample data replaces the skeleton right away and the note
236
- // explaining it is revealed (it ships hidden in the HTML, so an embedded
237
- // page never shows it, not even for the moment before this script runs).
238
- // Embedded, sample numbers never paint at all — the shipped skeleton holds
239
- // the layout until the first real results replace it and fade in.
238
+ // answer, so the sample data replaces the skeleton right away, and the SDK
239
+ // shows its own notice explaining that the page runs with real data only
240
+ // inside Driggsby. Embedded, sample numbers never paint at all — the
241
+ // shipped skeleton holds the layout until the first real results replace
242
+ // it and fade in.
240
243
  // ---------------------------------------------------------------------------
241
244
 
242
245
  const standalone = window.parent === window;
243
- const note = document.getElementById("sample-note");
244
246
 
245
247
  if (standalone) {
246
- // The note and the sample values paint together (this whole branch is
247
- // one synchronous task). The reveal is kept first so an edit that ever
248
- // makes rendering async can't paint values before their label.
249
- if (note) note.hidden = false;
250
248
  renderOverview(SAMPLE_OVERVIEW);
251
249
  renderAccounts(SAMPLE_ACCOUNTS);
252
- } else if (note) {
253
- note.remove();
254
250
  }
255
251
 
256
252
  if (window.driggsby) {
@@ -267,8 +263,6 @@ export const STYLES_CSS = `:root {
267
263
  --text: #1f2430;
268
264
  --text-muted: #6d7280;
269
265
  --hairline: #e6e8ec;
270
- --note-background: #fdf6e3;
271
- --note-text: #7a5b1e;
272
266
  }
273
267
 
274
268
  * {
@@ -302,21 +296,6 @@ h2 {
302
296
  font-weight: 600;
303
297
  }
304
298
 
305
- .sample-note {
306
- display: inline-block;
307
- margin: 12px 0 0;
308
- padding: 4px 10px;
309
- border-radius: 6px;
310
- background: var(--note-background);
311
- color: var(--note-text);
312
- font-size: 13px;
313
- }
314
-
315
- /* The display rule above would defeat the hidden attribute without this. */
316
- .sample-note[hidden] {
317
- display: none;
318
- }
319
-
320
299
  /* The skeleton ships in the HTML so the first paint is the final layout as
321
300
  muted bars; the first render replaces it and fades in (see app.js). The
322
301
  bar margins make each skeleton block exactly the height of the text it