driggsby 0.2.4 → 0.3.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,7 +60,8 @@ 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.
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.
64
65
 
65
66
  ## Deploying an app
66
67
 
@@ -75,6 +76,9 @@ The first deploy creates the app: Driggsby assigns its address — the name
75
76
  you picked plus a unique ending, like
76
77
  `your-app-name-x7k2qf.driggsby.dev` — and saves the assigned name back
77
78
  into `driggsby.json`, so any readable name works and names never collide.
79
+ `deploy` prints the Driggsby page for the app first; that is where it runs
80
+ with your data. The `driggsby.dev` address is the app on its own, which
81
+ shows no Driggsby data when opened directly.
78
82
  If the project lives in git, commit the updated `driggsby.json` — it is the
79
83
  only record of your app's address, and a fresh checkout without it would
80
84
  create a second app.
@@ -82,7 +86,10 @@ Every deploy after that uploads only the files that changed. Deploy with
82
86
  `--preview` to upload a version without
83
87
  changing what visitors see, `npx driggsby@latest versions` to list every
84
88
  kept version, and `npx driggsby@latest rollback` to switch which one is
85
- live — switching re-uploads nothing.
89
+ live — switching re-uploads nothing. `npx driggsby@latest query <tool>`
90
+ runs one read-only data tool and prints its result as JSON, the same
91
+ shape a `driggsby.watch` callback receives, so you can see the data
92
+ before writing any render code.
86
93
 
87
94
  The CLI is pure TypeScript and installs from the npm registry alone — no
88
95
  binary downloads, no install scripts — and runs on macOS, Linux, and Windows
@@ -1,7 +1,7 @@
1
1
  // Argv parsing for the deploy command family: deploy, rollback, versions.
2
2
  // Same behavior contract as the rest of the tree — help on -h/--help, usage
3
3
  // errors exit 2, echoed argv is sanitized — without chasing clap byte quirks.
4
- import { unexpectedArgument, unexpectedFlagValue, } from "./args-shared.js";
4
+ import { helpCommand, unexpectedArgument, unexpectedFlagValue, } from "./args-shared.js";
5
5
  import { CliError } from "./cli-error.js";
6
6
  import { DEPLOY_HELP, ROLLBACK_HELP, VERSIONS_HELP } from "./help.js";
7
7
  import { sanitizeForTerminal } from "./terminal-text.js";
@@ -73,9 +73,6 @@ export function parseVersions(argv) {
73
73
  }
74
74
  return { kind: "versions" };
75
75
  }
76
- function helpCommand(text) {
77
- return { kind: "print-help", text, stream: "stdout", exitCode: 0 };
78
- }
79
76
  // Version numbers are the small positive integers shown by `versions` (v1,
80
77
  // v2, ...). Anything else is a usage mistake, reported like other bad argv —
81
78
  // including a digit string too large for an exact integer, which would
@@ -0,0 +1,27 @@
1
+ // Argv parsing for `driggsby dev [--stop]`. Same contract as the rest of the
2
+ // tree: help on -h/--help, usage errors exit 2, echoed argv is sanitized.
3
+ import { helpCommand, unexpectedArgument, unexpectedFlagValue } from "./args-shared.js";
4
+ import { DEV_HELP } from "./help.js";
5
+ export const DEV_USAGE = "Usage: npx driggsby@latest dev [--stop]";
6
+ export function parseDev(argv) {
7
+ let stop = false;
8
+ let optionsEnded = false;
9
+ for (const token of argv) {
10
+ if (!optionsEnded && token === "--") {
11
+ optionsEnded = true;
12
+ continue;
13
+ }
14
+ if (!optionsEnded && (token === "-h" || token === "--help")) {
15
+ return helpCommand(DEV_HELP);
16
+ }
17
+ if (!optionsEnded && token === "--stop") {
18
+ stop = true;
19
+ continue;
20
+ }
21
+ if (!optionsEnded && token.startsWith("--stop=")) {
22
+ throw unexpectedFlagValue("--stop", token.slice("--stop=".length), DEV_USAGE);
23
+ }
24
+ throw unexpectedArgument(token, DEV_USAGE);
25
+ }
26
+ return { kind: "dev", stop };
27
+ }
package/dist/args-init.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Argv parsing for driggsby init: one optional positional (the app's name),
2
2
  // help, and the shared usage-error shape — usage errors exit 2, echoed argv
3
3
  // is sanitized by the shared builders.
4
- import { unexpectedArgument } from "./args-shared.js";
4
+ import { helpCommand, unexpectedArgument } from "./args-shared.js";
5
5
  import { INIT_HELP } from "./help.js";
6
6
  const INIT_USAGE = "Usage: npx driggsby@latest init [NAME]";
7
7
  export function parseInit(argv) {
@@ -13,7 +13,7 @@ export function parseInit(argv) {
13
13
  continue;
14
14
  }
15
15
  if (!optionsEnded && (token === "-h" || token === "--help")) {
16
- return { kind: "print-help", text: INIT_HELP, stream: "stdout", exitCode: 0 };
16
+ return helpCommand(INIT_HELP);
17
17
  }
18
18
  // Any other flag-looking token is a mistake, not a name; after "--"
19
19
  // everything is positional and slug validation refuses bad names.
@@ -0,0 +1,120 @@
1
+ // Argv parsing for `driggsby query <TOOL> [--sql <SQL>] [--params <JSON>]`.
2
+ // Same behavior contract as the rest of the tree: help on -h/--help, usage
3
+ // errors exit 2, echoed argv is sanitized. The tool is checked against the
4
+ // allowlist here so a typo is a usage error naming the twelve, not a
5
+ // server round trip.
6
+ import { helpCommand, unexpectedArgument } from "./args-shared.js";
7
+ import { didYouMean } from "./clap-suggestions.js";
8
+ import { CliError } from "./cli-error.js";
9
+ import { APP_TOOL_ALLOWLIST } from "./dev/tool-allowlist.js";
10
+ import { QUERY_HELP } from "./help.js";
11
+ import { quotedForTerminal, wrapNames } from "./terminal-text.js";
12
+ // The two tools that take SQL: --sql applies only to these, and a retry
13
+ // line names --sql only for them.
14
+ export const SQL_TOOLS = new Set(["query_cash_sql", "query_investment_sql"]);
15
+ const QUERY_USAGE = "Usage: npx driggsby@latest query <TOOL> [--sql <SQL>] [--params <JSON>]";
16
+ export function parseQuery(argv) {
17
+ let tool = null;
18
+ let sql = null;
19
+ let params = null;
20
+ let optionsEnded = false;
21
+ for (let index = 0; index < argv.length; index += 1) {
22
+ const token = argv[index] ?? "";
23
+ if (!optionsEnded && token === "--") {
24
+ optionsEnded = true;
25
+ continue;
26
+ }
27
+ if (!optionsEnded && (token === "-h" || token === "--help")) {
28
+ return helpCommand(QUERY_HELP);
29
+ }
30
+ if (!optionsEnded && token === "--sql") {
31
+ sql = requireFlagValue(argv, index, "--sql");
32
+ index += 1;
33
+ continue;
34
+ }
35
+ if (!optionsEnded && token.startsWith("--sql=")) {
36
+ sql = token.slice("--sql=".length);
37
+ if (sql.trim() === "") {
38
+ throw missingFlagValue("--sql");
39
+ }
40
+ continue;
41
+ }
42
+ if (!optionsEnded && token === "--params") {
43
+ params = parseParams(requireFlagValue(argv, index, "--params", { allowBlank: true }));
44
+ index += 1;
45
+ continue;
46
+ }
47
+ if (!optionsEnded && token.startsWith("--params=")) {
48
+ params = parseParams(token.slice("--params=".length));
49
+ continue;
50
+ }
51
+ if (tool === null && (optionsEnded || !token.startsWith("-"))) {
52
+ tool = token;
53
+ continue;
54
+ }
55
+ throw unexpectedArgument(token, QUERY_USAGE);
56
+ }
57
+ if (tool === null) {
58
+ throw missingTool();
59
+ }
60
+ if (!APP_TOOL_ALLOWLIST.has(tool)) {
61
+ throw unknownTool(tool);
62
+ }
63
+ if (sql !== null && !SQL_TOOLS.has(tool)) {
64
+ throw sqlForNonSqlTool(tool);
65
+ }
66
+ const merged = { ...(params ?? {}) };
67
+ if (sql !== null) {
68
+ merged.sql = sql;
69
+ }
70
+ return { kind: "query", tool, params: merged };
71
+ }
72
+ // A following flag is never a value: `--sql --params '{}'` is a missing SQL,
73
+ // not SQL that reads "--params" (SQL that must start with "-" can use the
74
+ // --sql= spelling).
75
+ function requireFlagValue(argv, index, flag, options = {}) {
76
+ const value = argv[index + 1];
77
+ const looksLikeFlag = value !== undefined && value.length > 1 && value.startsWith("-");
78
+ const blank = value === undefined || looksLikeFlag || (value.trim() === "" && options.allowBlank !== true);
79
+ if (blank) {
80
+ throw missingFlagValue(flag);
81
+ }
82
+ return value;
83
+ }
84
+ function missingFlagValue(flag) {
85
+ return new CliError(`error: a value is required for '${flag}' but none was supplied\n\n${QUERY_USAGE}\n\nFor more information, try '--help'.`, 2);
86
+ }
87
+ // --params is the tool's params object, verbatim; anything that is not a
88
+ // JSON object (an array, a string, unparseable text) is a usage mistake.
89
+ function parseParams(rawValue) {
90
+ let parsed;
91
+ try {
92
+ parsed = JSON.parse(rawValue);
93
+ }
94
+ catch {
95
+ throw invalidParams();
96
+ }
97
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
98
+ throw invalidParams();
99
+ }
100
+ return parsed;
101
+ }
102
+ function invalidParams() {
103
+ return new CliError(`error: invalid value for '--params':\n'--params' must be a JSON object, like '{"history_type":"liabilities"}'\n\n` +
104
+ `${QUERY_USAGE}\n\nFor more information, try '--help'.`, 2);
105
+ }
106
+ function missingTool() {
107
+ return new CliError(`error: the following required arguments were not provided:\n <TOOL>\n\n${QUERY_USAGE}\n\nFor more information, try '--help'.`, 2);
108
+ }
109
+ function unknownTool(tool) {
110
+ const similar = didYouMean(tool, [...APP_TOOL_ALLOWLIST]);
111
+ const tip = similar === undefined ? "" : ` tip: a similar tool exists: '${similar}'\n\n`;
112
+ return new CliError(`error: ${quotedForTerminal(tool, 60)} isn't a tool a Driggsby app can call.\n` +
113
+ `Apps can watch these read-only tools:\n` +
114
+ `${wrapNames([...APP_TOOL_ALLOWLIST], " ")}\n\n${tip}${QUERY_USAGE}\n\nFor more information, try '--help'.`, 2);
115
+ }
116
+ function sqlForNonSqlTool(tool) {
117
+ return new CliError(`error: '--sql' only applies to query_cash_sql or query_investment_sql;\n` +
118
+ `'${tool}' takes no SQL.\n\n` +
119
+ `${QUERY_USAGE}\n\nFor more information, try '--help'.`, 2);
120
+ }
@@ -5,6 +5,9 @@
5
5
  import { CliError } from "./cli-error.js";
6
6
  import {} from "./mcp-setup/known-client.js";
7
7
  import { sanitizeForTerminal } from "./terminal-text.js";
8
+ export function helpCommand(text) {
9
+ return { kind: "print-help", text, stream: "stdout", exitCode: 0 };
10
+ }
8
11
  // "--print=true" / "--help=x" / "--version=x": these flags take no value.
9
12
  export function unexpectedFlagValue(flag, rawValue, usage) {
10
13
  const value = sanitizeForTerminal(rawValue);
package/dist/args.js CHANGED
@@ -6,12 +6,14 @@
6
6
  // (login, logout) keep the same behavior and exit codes without chasing
7
7
  // clap's byte-level quirks.
8
8
  import { parseDeploy, parseRollback, parseVersions } from "./args-deploy.js";
9
+ import { parseDev } from "./args-dev.js";
9
10
  import { parseInit } from "./args-init.js";
10
11
  import { parseMcpSetup } from "./args-mcp-setup.js";
12
+ import { parseQuery } from "./args-query.js";
11
13
  import { removeDashesTip, similarArgumentTip, unexpectedArgument, unexpectedFlagValue, } from "./args-shared.js";
12
14
  import { didYouMean } from "./clap-suggestions.js";
13
15
  import { CliError } from "./cli-error.js";
14
- import { DEV_HELP, LOGIN_HELP, LOGOUT_HELP, MCP_HELP, ROOT_HELP } from "./help.js";
16
+ import { LOGIN_HELP, LOGOUT_HELP, MCP_HELP, ROOT_HELP } from "./help.js";
15
17
  import { sanitizeForTerminal } from "./terminal-text.js";
16
18
  export {} from "./args-shared.js";
17
19
  const ROOT_USAGE = "Usage: npx driggsby@latest <COMMAND>";
@@ -19,17 +21,18 @@ const MCP_USAGE = "Usage: npx driggsby@latest mcp <COMMAND>";
19
21
  const ROOT_LEVEL = {
20
22
  helpText: ROOT_HELP,
21
23
  usage: ROOT_USAGE,
22
- subcommands: ["mcp", "login", "logout", "init", "dev", "deploy", "rollback", "versions"],
24
+ subcommands: ["mcp", "login", "logout", "init", "dev", "deploy", "rollback", "versions", "query"],
23
25
  longFlags: ["help", "version"],
24
26
  subcommandFlags: [
25
27
  { name: "mcp", longFlags: ["help"] },
26
28
  { name: "login", longFlags: ["help"] },
27
29
  { name: "logout", longFlags: ["help"] },
28
30
  { name: "init", longFlags: ["help"] },
29
- { name: "dev", longFlags: ["help"] },
31
+ { name: "dev", longFlags: ["stop", "help"] },
30
32
  { name: "deploy", longFlags: ["preview", "help"] },
31
33
  { name: "rollback", longFlags: ["to", "help"] },
32
34
  { name: "versions", longFlags: ["help"] },
35
+ { name: "query", longFlags: ["sql", "params", "help"] },
33
36
  ],
34
37
  usagePrefix: "npx driggsby@latest",
35
38
  hasVersion: true,
@@ -88,13 +91,15 @@ function dispatchSubcommand(name, rest) {
88
91
  case "init":
89
92
  return parseInit(rest);
90
93
  case "dev":
91
- return parseBareCommand({ kind: "dev" }, DEV_HELP, "Usage: npx driggsby@latest dev", rest);
94
+ return parseDev(rest);
92
95
  case "deploy":
93
96
  return parseDeploy(rest);
94
97
  case "rollback":
95
98
  return parseRollback(rest);
96
99
  case "versions":
97
100
  return parseVersions(rest);
101
+ case "query":
102
+ return parseQuery(rest);
98
103
  case "setup":
99
104
  return parseMcpSetup(rest);
100
105
  default:
@@ -2,12 +2,13 @@
2
2
  // atomically with owner-only permissions. This is the only backend on
3
3
  // Windows (no readable credential CLI exists there; the profile directory's
4
4
  // default user-only ACL is the boundary) and the fallback everywhere else.
5
- import { randomUUID } from "node:crypto";
6
- import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
5
+ import { readdir, readFile, rm } from "node:fs/promises";
7
6
  import { join } from "node:path";
7
+ import { driggsbyDirectory, writeOwnerOnlyFile } from "../owner-only-file.js";
8
8
  import {} from "./clear-outcome.js";
9
+ const CREDENTIALS_FILE_NAME = "credentials.json";
9
10
  export function credentialsFilePath(homeDirectory) {
10
- return join(homeDirectory, ".driggsby", "credentials.json");
11
+ return join(driggsbyDirectory(homeDirectory), CREDENTIALS_FILE_NAME);
11
12
  }
12
13
  export async function readFileToken(homeDirectory) {
13
14
  let raw;
@@ -30,21 +31,7 @@ export async function readFileToken(homeDirectory) {
30
31
  }
31
32
  }
32
33
  export async function writeFileToken(homeDirectory, token) {
33
- const directory = join(homeDirectory, ".driggsby");
34
- // mode applies only when the directory is created; an existing ~/.driggsby
35
- // keeps whatever permissions the user already gave it.
36
- await mkdir(directory, { recursive: true, mode: 0o700 });
37
- const finalPath = credentialsFilePath(homeDirectory);
38
- const temporaryPath = join(directory, `credentials.json.${randomUUID()}.tmp`);
39
- const body = `${JSON.stringify({ app_token: token }, null, 2)}\n`;
40
- try {
41
- await writeFile(temporaryPath, body, { mode: 0o600 });
42
- await rename(temporaryPath, finalPath);
43
- }
44
- catch (error) {
45
- await rm(temporaryPath, { force: true });
46
- throw error;
47
- }
34
+ await writeOwnerOnlyFile(homeDirectory, CREDENTIALS_FILE_NAME, `${JSON.stringify({ app_token: token }, null, 2)}\n`);
48
35
  }
49
36
  export async function clearFileToken(homeDirectory) {
50
37
  let outcome;
@@ -59,10 +46,10 @@ export async function clearFileToken(homeDirectory) {
59
46
  // readable token in a `credentials.json.<uuid>.tmp` file; removal must not
60
47
  // leave one behind while reporting the token gone.
61
48
  try {
62
- for (const entry of await readdir(join(homeDirectory, ".driggsby"))) {
49
+ for (const entry of await readdir(driggsbyDirectory(homeDirectory))) {
63
50
  if (entry.startsWith("credentials.json.") && entry.endsWith(".tmp")) {
64
51
  try {
65
- await rm(join(homeDirectory, ".driggsby", entry), { force: true });
52
+ await rm(join(driggsbyDirectory(homeDirectory), entry), { force: true });
66
53
  }
67
54
  catch {
68
55
  outcome = "failed";
@@ -9,6 +9,8 @@ import { CliError } from "../cli-error.js";
9
9
  import { readStoredToken, } from "../credentials/store.js";
10
10
  import { capForTerminal, wrapProse } from "../terminal-text.js";
11
11
  const LOGIN_COMMAND = "npx driggsby@latest login";
12
+ // The one wording for a saved sign-in the server no longer accepts.
13
+ export const SIGN_IN_AGAIN_MESSAGE = "Your sign-in on this machine isn't valid anymore. Sign in again:\n" + ` ${LOGIN_COMMAND}`;
12
14
  const MAX_ERROR_DESCRIPTION_CHARS = 300;
13
15
  // Server-supplied display strings go through capForTerminal (sanitize plus
14
16
  // a hard length bound) with bounds sized to each string's real contract so
@@ -67,8 +69,7 @@ function apiFailure(error, retryCommand) {
67
69
  return new CliError(`Driggsby is briefly unavailable just now. Please try again:\n ${retryCommand}`, 1);
68
70
  }
69
71
  if (error.status === 401) {
70
- return new CliError("Your sign-in on this machine isn't valid anymore. Sign in again:\n" +
71
- ` ${LOGIN_COMMAND}`, 1);
72
+ return new CliError(SIGN_IN_AGAIN_MESSAGE, 1);
72
73
  }
73
74
  if (error.code === "deploy_scope_required") {
74
75
  return new CliError("Your saved sign-in can read your Driggsby data but wasn't approved for\n" +
@@ -1,11 +1,11 @@
1
1
  // driggsby deploy: read driggsby.json, hash the serve folder, upload what
2
2
  // Driggsby doesn't have, and make the new version live (or hold it as a
3
3
  // ready-but-not-live version with --preview).
4
- import { collectDeployFiles, deployProjectFiles, formatBytes, readProjectConfig, } from "@driggsby/deploy";
4
+ import { collectDeployFiles, deployProjectFiles, formatBytes, hasLiveAddress, liveAddresses, liveAddressLines, readProjectConfig, } from "@driggsby/deploy";
5
5
  import { apiBaseUrl } from "../api/base-url.js";
6
6
  import { defaultCredentialEnvironment, } from "../credentials/store.js";
7
- import { capForTerminal, quotedForTerminal, wrapProse } from "../terminal-text.js";
8
- import { deployFailure, MAX_SERVER_TEXT_CHARS, MAX_SERVER_URL_CHARS, requireDeploySession, } from "./api-session.js";
7
+ import { quotedForTerminal, wrapProse } from "../terminal-text.js";
8
+ import { deployFailure, MAX_SERVER_TEXT_CHARS, requireDeploySession, } from "./api-session.js";
9
9
  const DEPLOY_RETRY_COMMAND = "npx driggsby@latest deploy";
10
10
  function defaultDeployIo() {
11
11
  return {
@@ -48,9 +48,12 @@ export async function runDeploy(options, environment = defaultCredentialEnvironm
48
48
  const seconds = (io.now() - startedAt) / 1_000;
49
49
  io.out(`${changedNote(outcome)}\n`);
50
50
  io.out(`${uploadedLine(outcome, seconds)}\n`);
51
- io.out(`${resultLine(outcome)}\n`);
52
- if (outcome.url !== null) {
53
- io.out(`\n ${capForTerminal(outcome.url, MAX_SERVER_URL_CHARS)}\n`);
51
+ const addresses = liveAddresses(outcome.url, outcome.consoleUrl, session.baseUrl);
52
+ io.out(`${resultLine(outcome, addresses)}\n`);
53
+ // A preview has no address to open: the Driggsby page shows the live
54
+ // version, which a preview leaves unchanged.
55
+ if (outcome.live) {
56
+ io.out(liveAddressLines(addresses));
54
57
  }
55
58
  if (collected.skippedNodeModules.length > 0) {
56
59
  io.out(`\n${wrapProse("Note: node_modules doesn't deploy — Driggsby serves your app as static files, so it isn't needed.")}\n`);
@@ -89,12 +92,12 @@ function uploadedLine(outcome, seconds) {
89
92
  }
90
93
  return `✓ Uploaded ${formatBytes(outcome.uploadedBytes)} in ${formatSeconds(seconds)}`;
91
94
  }
92
- function resultLine(outcome) {
95
+ function resultLine(outcome, addresses) {
93
96
  if (outcome.live) {
94
- // "at:" only when the URL that line promises actually follows.
95
- return outcome.url === null
96
- ? `✓ Live v${outcome.versionNumber}`
97
- : `✓ Live v${outcome.versionNumber}, at:`;
97
+ // "at:" only when an address that line promises actually follows.
98
+ return hasLiveAddress(addresses)
99
+ ? `✓ Live v${outcome.versionNumber}, at:`
100
+ : `✓ Live v${outcome.versionNumber}`;
98
101
  }
99
102
  return `✓ Ready v${outcome.versionNumber} is uploaded but not live — what visitors see is unchanged`;
100
103
  }
@@ -2,12 +2,12 @@
2
2
  // re-uploads — the switch is a pointer change on Driggsby's side. With --to N
3
3
  // it acts directly; without it, it shows the choices and asks (or, when not
4
4
  // attached to a person, explains how to re-run with --to).
5
- import { setLiveVersion, readProjectConfig } from "@driggsby/deploy";
5
+ import { hasLiveAddress, liveAddresses, liveAddressLines, readProjectConfig, setLiveVersion, } from "@driggsby/deploy";
6
6
  import { apiBaseUrl } from "../api/base-url.js";
7
7
  import { CliError } from "../cli-error.js";
8
8
  import { defaultCredentialEnvironment, } from "../credentials/store.js";
9
- import { capForTerminal, wrapProse } from "../terminal-text.js";
10
- import { deployFailure, MAX_SERVER_URL_CHARS, requireDeploySession, } from "./api-session.js";
9
+ import { wrapProse } from "../terminal-text.js";
10
+ import { deployFailure, requireDeploySession, } from "./api-session.js";
11
11
  import { fetchVersionList, renderVersionRows } from "./versions-command.js";
12
12
  const ROLLBACK_RETRY_COMMAND = "npx driggsby@latest rollback";
13
13
  function defaultRollbackIo() {
@@ -56,13 +56,11 @@ export async function runRollback(options, environment = defaultCredentialEnviro
56
56
  const previousLive = list.liveVersionNumber;
57
57
  const result = await setLiveVersion(api, config.slug, target);
58
58
  const was = previousLive === null ? "" : ` (was v${previousLive})`;
59
- // "at:" only when the URL that line promises actually follows; the URL
60
- // is a server-supplied string, sanitized before printing.
61
- const atSuffix = result.url === null ? "" : ", at:";
59
+ // "at:" only when an address that line promises actually follows.
60
+ const addresses = liveAddresses(result.url, result.consoleUrl, session.baseUrl);
61
+ const atSuffix = hasLiveAddress(addresses) ? ", at:" : "";
62
62
  io.out(`✓ Live v${result.versionNumber} is what visitors see now${was}${atSuffix}\n`);
63
- if (result.url !== null) {
64
- io.out(`\n ${capForTerminal(result.url, MAX_SERVER_URL_CHARS)}\n`);
65
- }
63
+ io.out(liveAddressLines(addresses));
66
64
  io.out(`\n${wrapProse(`Nothing re-uploaded — Driggsby already had v${result.versionNumber} in full.`)}\n` +
67
65
  "\nNext:\n See every version with npx driggsby@latest versions\n");
68
66
  return 0;
@@ -13,12 +13,16 @@ import { defaultCredentialEnvironment, } from "../credentials/store.js";
13
13
  import { requireDeploySession } from "../deploy/api-session.js";
14
14
  import { tryOpenUrl } from "../login/open-url.js";
15
15
  import { wrapProse } from "../terminal-text.js";
16
+ import { DEV_IDLE_MINUTES, DEV_START_COMMAND, DEV_STOP_COMMAND } from "./dev-commands.js";
16
17
  import { startDevServers } from "./dev-servers.js";
18
+ import { readLiveDevState, removeDevState, writeDevState } from "./dev-state.js";
19
+ import { displayFolder } from "./display-folder.js";
17
20
  import { McpBroker } from "./mcp-broker.js";
18
21
  import { watchDirectory } from "./watcher.js";
19
22
  export const DEV_HOST_PORT = 4111;
20
23
  export const DEV_APP_PORT = 4112;
21
- const DEV_RETRY_COMMAND = "npx driggsby@latest dev";
24
+ const DEV_IDLE_TIMEOUT_MS = DEV_IDLE_MINUTES * 60 * 1000;
25
+ const DEV_IDLE_CHECK_MS = 15 * 1000;
22
26
  function defaultDevIo() {
23
27
  return {
24
28
  out: (text) => {
@@ -33,6 +37,13 @@ function defaultDevIo() {
33
37
  process.once("SIGTERM", () => {
34
38
  resolve();
35
39
  });
40
+ // A closed terminal window: still a clean stop, so the record and
41
+ // the ports are released like any other exit. (On Windows, a signal
42
+ // from `dev --stop` ends the process outright instead; `dev --stop`
43
+ // then removes the record itself once the pid is gone.)
44
+ process.once("SIGHUP", () => {
45
+ resolve();
46
+ });
36
47
  });
37
48
  },
38
49
  };
@@ -61,25 +72,100 @@ export async function runDev(options = {}, environment = defaultCredentialEnviro
61
72
  });
62
73
  }
63
74
  catch (error) {
64
- throw devStartFailure(error, baseUrl);
75
+ throw await devStartFailure(error, baseUrl, environment.homeDirectory);
65
76
  }
66
77
  const stopWatching = watchDirectory(config.serveDirectory, () => {
67
78
  servers.notifyChange();
68
79
  });
80
+ const idleTimeoutMs = options.idleTimeoutMs ?? DEV_IDLE_TIMEOUT_MS;
81
+ const idle = idleWatch(servers, idleTimeoutMs, options.idleCheckMs ?? DEV_IDLE_CHECK_MS);
69
82
  try {
83
+ // The record is what `dev --stop` finds; a home that can't be written
84
+ // costs that convenience, not the preview.
85
+ const recorded = await tryWriteDevState(environment.homeDirectory, projectDirectory, servers);
70
86
  const opened = await io.openUrl(servers.hostOrigin);
71
- io.out(`✓ Ready ${config.slug} is running with your live Driggsby data${opened ? ", at:" : ":"}\n\n` +
72
- ` ${servers.hostOrigin}\n\n` +
73
- `${wrapProse("Edit the app's files and the page reloads on save. Press Ctrl+C to stop.")}\n` +
74
- "\nNext:\n Put it live on driggsby.dev with npx driggsby@latest deploy\n");
75
- await io.waitForShutdown();
87
+ io.out(readyText(config.slug, servers, opened, recorded, idleTimeoutMs));
88
+ const ending = await Promise.race([io.waitForShutdown().then(() => "shutdown"), idle.expired]);
89
+ if (ending === "idle") {
90
+ io.out(`✓ Stopped No page was open for ${idleWindowWords(idleTimeoutMs)}, so ` +
91
+ `driggsby dev stopped itself.\n\nStart it again with:\n ${DEV_START_COMMAND}\n`);
92
+ }
76
93
  return 0;
77
94
  }
78
95
  finally {
96
+ idle.cancel();
79
97
  stopWatching();
98
+ // The record goes before the ports are freed, so a dev starting the
99
+ // instant these close never finds a record this one is about to remove.
100
+ // (A `dev --stop` waiting on this exit polls the pid for five seconds,
101
+ // and closing takes well under one.) The record is a convenience: a
102
+ // failure to remove it must never keep the ports open.
103
+ try {
104
+ await removeDevState(environment.homeDirectory, process.pid);
105
+ }
106
+ catch {
107
+ // Left behind; the next read finds a dead pid and removes it.
108
+ }
80
109
  await servers.close();
81
110
  }
82
111
  }
112
+ async function tryWriteDevState(homeDirectory, folder, servers) {
113
+ try {
114
+ await writeDevState(homeDirectory, {
115
+ pid: process.pid,
116
+ folder,
117
+ startedAt: new Date().toISOString(),
118
+ hostPort: servers.hostPort,
119
+ appPort: servers.appPort,
120
+ });
121
+ return true;
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ }
127
+ function readyText(slug, servers, opened, recorded, idleTimeoutMs) {
128
+ 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`
130
+ : `${wrapProse("Edit the app's files and the page reloads on save. Press Ctrl+C to stop.")}\n`;
131
+ return (`✓ Ready ${slug} is running with your live Driggsby data${opened ? ", at:" : ":"}\n\n` +
132
+ ` ${servers.hostOrigin}\n\n` +
133
+ `${wrapProse(`That page embeds the app from ${servers.appOrigin}. Opened on its own, the app gets no Driggsby data, so use the address above.`)}\n\n` +
134
+ `${stopLine}\n` +
135
+ `${wrapProse(`It stops on its own after ${idleWindowWords(idleTimeoutMs)} with no page open.`)}\n` +
136
+ "\nNext:\n Put it live on driggsby.dev with npx driggsby@latest deploy\n");
137
+ }
138
+ export function idleWindowWords(timeoutMs) {
139
+ const minutes = Math.round(timeoutMs / 60_000);
140
+ if (minutes < 1) {
141
+ return "a moment";
142
+ }
143
+ return minutes === 1 ? "1 minute" : `${String(minutes)} minutes`;
144
+ }
145
+ function idleWatch(servers, timeoutMs, checkMs) {
146
+ let resolveExpired = () => undefined;
147
+ const expired = new Promise((resolve) => {
148
+ resolveExpired = resolve;
149
+ });
150
+ let idleSince = Date.now();
151
+ const timer = setInterval(() => {
152
+ if (servers.clientCount() > 0) {
153
+ idleSince = Date.now();
154
+ return;
155
+ }
156
+ if (Date.now() - idleSince >= timeoutMs) {
157
+ resolveExpired("idle");
158
+ }
159
+ }, checkMs);
160
+ // A pending check must never hold the process open once the run ends.
161
+ timer.unref();
162
+ return {
163
+ expired,
164
+ cancel: () => {
165
+ clearInterval(timer);
166
+ },
167
+ };
168
+ }
83
169
  // The Driggsby SDK bundle ships inside @driggsby/sdk; dev serves it on the
84
170
  // app origin at the same path production serves it, so the scaffold's
85
171
  // script tag works identically in both places.
@@ -88,20 +174,26 @@ async function loadSdkBundle() {
88
174
  const bundlePath = require.resolve("@driggsby/sdk/driggsby-sdk.js");
89
175
  return await readFile(bundlePath, "utf8");
90
176
  }
91
- function devStartFailure(error, baseUrl) {
177
+ async function devStartFailure(error, baseUrl, homeDirectory) {
92
178
  if (error instanceof CliError) {
93
179
  return error;
94
180
  }
95
181
  if (error.code === "EADDRINUSE") {
96
- 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 (often another driggsby dev) and try again:`)}\n` +
97
- ` ${DEV_RETRY_COMMAND}`, 1);
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);
98
190
  }
99
191
  const blocked = blockedNetworkError(error, {
100
192
  host: apiHost(baseUrl),
101
- retryCommand: DEV_RETRY_COMMAND,
193
+ retryCommand: DEV_START_COMMAND,
102
194
  });
103
195
  if (blocked !== null) {
104
196
  return blocked;
105
197
  }
106
- return new CliError(`We weren't able to start the preview just now. Please try again:\n ${DEV_RETRY_COMMAND}`, 1);
198
+ return new CliError(`We weren't able to start the preview just now. Please try again:\n ${DEV_START_COMMAND}`, 1);
107
199
  }
@@ -0,0 +1,10 @@
1
+ // The commands `driggsby dev` names in its own output and help, and the
2
+ // idle window it enforces, in one place so no two surfaces can disagree.
3
+ export const DEV_START_COMMAND = "npx driggsby@latest dev";
4
+ export const DEV_STOP_COMMAND = "npx driggsby@latest dev --stop";
5
+ // A preview nobody has open for this long stops itself, so a dev started in
6
+ // the background (often by an agent) does not keep serving live financial
7
+ // data on this machine indefinitely.
8
+ // Spelled out as prose in README.md and packages/driggsby/README.md too;
9
+ // change those with this.
10
+ export const DEV_IDLE_MINUTES = 30;
@@ -5,6 +5,7 @@
5
5
  // only. The app token never appears here — the broker callback holds it in
6
6
  // the CLI process.
7
7
  import { createServer } from "node:http";
8
+ import { DEV_IDENTITY_PATH } from "./dev-state.js";
8
9
  import { hostPageHtml, HOST_PAGE_JS } from "./host-page.js";
9
10
  import { serveStaticFile } from "./static-files.js";
10
11
  import { APP_TOOL_ALLOWLIST } from "./tool-allowlist.js";
@@ -55,6 +56,7 @@ export async function startDevServers(options) {
55
56
  client.write("event: change\ndata: files-changed\n\n");
56
57
  }
57
58
  },
59
+ clientCount: () => sseClients.size,
58
60
  close: async () => {
59
61
  for (const client of sseClients) {
60
62
  client.end();
@@ -116,6 +118,12 @@ async function handleHostRequest(options, hostPort, appOrigin, sseClients, reque
116
118
  return;
117
119
  }
118
120
  const path = requestPath(request);
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.
124
+ sendJson(response, 200, { pid: process.pid });
125
+ return;
126
+ }
119
127
  if (request.method === "GET" && path === "/") {
120
128
  sendHostPage(response, hostPageHtml(options.slug, appOrigin, options.background), appOrigin);
121
129
  return;