driggsby 0.3.2 → 0.4.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.
- package/README.md +15 -1
- package/dist/args-delete.js +38 -0
- package/dist/args-rules.js +182 -0
- package/dist/args-shared.js +1 -0
- package/dist/args.js +25 -11
- package/dist/deploy/delete-command.js +125 -0
- package/dist/deploy/versions-command.js +10 -5
- package/dist/help.js +58 -3
- package/dist/login/claim-client.js +2 -1
- package/dist/main.js +6 -0
- package/dist/query/query-command.js +2 -2
- package/dist/rules/rule-actions.js +14 -0
- package/dist/rules/rules-command.js +96 -0
- package/dist/rules/rules-rpc.js +132 -0
- package/dist/terminal-text.js +11 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -86,7 +86,9 @@ Every deploy after that uploads only the files that changed. Deploy with
|
|
|
86
86
|
`--preview` to upload a version without
|
|
87
87
|
changing what visitors see, `npx driggsby@latest versions` to list every
|
|
88
88
|
kept version, and `npx driggsby@latest rollback` to switch which one is
|
|
89
|
-
live — switching re-uploads nothing. `npx driggsby@latest
|
|
89
|
+
live — switching re-uploads nothing. `npx driggsby@latest delete` removes
|
|
90
|
+
a deployed app forever, after asking you to type its address name back
|
|
91
|
+
(`--yes` for scripts). `npx driggsby@latest query <tool>`
|
|
90
92
|
runs one read-only data tool and prints its result as JSON, the same
|
|
91
93
|
shape a `driggsby.watch` callback receives, so you can see the data
|
|
92
94
|
before writing any render code.
|
|
@@ -98,6 +100,18 @@ with Node.js 18 or newer.
|
|
|
98
100
|
See the [GitHub repo](https://github.com/driggsby/driggsby) for
|
|
99
101
|
tools, examples, and full documentation.
|
|
100
102
|
|
|
103
|
+
## Transaction rules
|
|
104
|
+
|
|
105
|
+
`npx driggsby@latest rules` reads and changes your transaction rules — your
|
|
106
|
+
standing corrections to how Driggsby reads your transactions: a category, a
|
|
107
|
+
counterparty's name, or tags on a set of transactions. `rules describe`
|
|
108
|
+
prints the rule format and the process. `rules preview` shows exactly what a
|
|
109
|
+
new or changed rule would do and returns the confirm token `rules save`
|
|
110
|
+
needs to apply it; renaming, pausing, or resuming a rule needs its
|
|
111
|
+
`rule_ref` and no preview. `rules delete` needs `--yes`. Every action prints JSON. If you
|
|
112
|
+
signed in with an earlier version of the CLI, run
|
|
113
|
+
`npx driggsby@latest login` once more to use rules.
|
|
114
|
+
|
|
101
115
|
## License
|
|
102
116
|
|
|
103
117
|
Apache-2.0
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Argv parsing for driggsby delete: one optional positional (the app's
|
|
2
|
+
// address name), --yes for scripts and agents, help, and the shared
|
|
3
|
+
// usage-error shape — usage errors exit 2, echoed argv is sanitized by the
|
|
4
|
+
// shared builders.
|
|
5
|
+
import { helpCommand, unexpectedArgument, unexpectedFlagValue, } from "./args-shared.js";
|
|
6
|
+
import { DELETE_HELP } from "./help.js";
|
|
7
|
+
const DELETE_USAGE = "Usage: npx driggsby@latest delete [APP-ADDRESS-NAME] [--yes]";
|
|
8
|
+
export function parseDelete(argv) {
|
|
9
|
+
let slug = null;
|
|
10
|
+
let yes = false;
|
|
11
|
+
let optionsEnded = false;
|
|
12
|
+
for (const token of argv) {
|
|
13
|
+
if (!optionsEnded && token === "--") {
|
|
14
|
+
optionsEnded = true;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (!optionsEnded && (token === "-h" || token === "--help")) {
|
|
18
|
+
return helpCommand(DELETE_HELP);
|
|
19
|
+
}
|
|
20
|
+
if (!optionsEnded && token === "--yes") {
|
|
21
|
+
yes = true;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (!optionsEnded && token.startsWith("--yes=")) {
|
|
25
|
+
throw unexpectedFlagValue("--yes", token.slice("--yes=".length), DELETE_USAGE);
|
|
26
|
+
}
|
|
27
|
+
// Any other flag-looking token is a mistake, not a name; after "--"
|
|
28
|
+
// everything is positional and the server refuses unknown names.
|
|
29
|
+
if (!optionsEnded && token.startsWith("-") && token !== "-") {
|
|
30
|
+
throw unexpectedArgument(token, DELETE_USAGE);
|
|
31
|
+
}
|
|
32
|
+
if (slug !== null) {
|
|
33
|
+
throw unexpectedArgument(token, DELETE_USAGE);
|
|
34
|
+
}
|
|
35
|
+
slug = token;
|
|
36
|
+
}
|
|
37
|
+
return { kind: "delete", slug, yes };
|
|
38
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Argv parsing for `driggsby rules <ACTION> [--params <JSON> | --params-file
|
|
2
|
+
// <PATH>] [--yes]`. --params is the rule tool's arguments object, verbatim;
|
|
3
|
+
// --params-file reads the same object from a file, for rules too big or
|
|
4
|
+
// too quote-heavy for a shell line. Usage errors exit 2; echoed argv is
|
|
5
|
+
// sanitized by the shared builders. Help, the action, and the flag rules
|
|
6
|
+
// are all checked before any params are parsed or any file is read.
|
|
7
|
+
import { closeSync, constants, fstatSync, openSync, readSync, statSync } from "node:fs";
|
|
8
|
+
import { helpCommand, unexpectedArgument, unexpectedFlagValue } from "./args-shared.js";
|
|
9
|
+
import { didYouMean } from "./clap-suggestions.js";
|
|
10
|
+
import { CliError } from "./cli-error.js";
|
|
11
|
+
import { RULES_HELP } from "./help.js";
|
|
12
|
+
import { isRuleAction, RULE_ACTIONS } from "./rules/rule-actions.js";
|
|
13
|
+
import { quotedForTerminal } from "./terminal-text.js";
|
|
14
|
+
const RULES_USAGE = "Usage: npx driggsby@latest rules <ACTION> [--params <JSON>] [--yes]";
|
|
15
|
+
// A saved rule with learned patterns and its answers is a few KB; this
|
|
16
|
+
// bounds a mistaken path to something like a log file.
|
|
17
|
+
export const MAX_PARAMS_FILE_BYTES = 1_048_576;
|
|
18
|
+
const MAX_PARAMS_FILE_MB = String(MAX_PARAMS_FILE_BYTES / 1_048_576);
|
|
19
|
+
export function parseRules(argv) {
|
|
20
|
+
let actionToken = null;
|
|
21
|
+
let source = null;
|
|
22
|
+
let yes = false;
|
|
23
|
+
let optionsEnded = false;
|
|
24
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
25
|
+
const token = argv[index] ?? "";
|
|
26
|
+
if (!optionsEnded && token === "--") {
|
|
27
|
+
optionsEnded = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!optionsEnded && (token === "-h" || token === "--help")) {
|
|
31
|
+
return helpCommand(RULES_HELP);
|
|
32
|
+
}
|
|
33
|
+
if (!optionsEnded && token === "--yes") {
|
|
34
|
+
yes = true;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!optionsEnded && token.startsWith("--yes=")) {
|
|
38
|
+
throw unexpectedFlagValue("--yes", token.slice("--yes=".length), RULES_USAGE);
|
|
39
|
+
}
|
|
40
|
+
const flag = !optionsEnded ? paramsFlag(token) : null;
|
|
41
|
+
if (flag !== null) {
|
|
42
|
+
if (source !== null) {
|
|
43
|
+
throw usageError("error: give the params once, with either --params or --params-file");
|
|
44
|
+
}
|
|
45
|
+
const attached = token.length > flag.length;
|
|
46
|
+
const value = attached ? token.slice(flag.length + 1) : argv[index + 1];
|
|
47
|
+
if (value === undefined || value === "" || (!attached && value.length > 1 && value.startsWith("-"))) {
|
|
48
|
+
throw usageError(`error: a value is required for '${flag}' but none was supplied`);
|
|
49
|
+
}
|
|
50
|
+
source = flag === "--params" ? { flag, json: value } : { flag, path: value };
|
|
51
|
+
index += attached ? 0 : 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (actionToken === null && (optionsEnded || !token.startsWith("-"))) {
|
|
55
|
+
actionToken = token;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
throw unexpectedArgument(token, RULES_USAGE);
|
|
59
|
+
}
|
|
60
|
+
if (actionToken === null) {
|
|
61
|
+
throw usageError("error: the following required arguments were not provided:\n <ACTION>");
|
|
62
|
+
}
|
|
63
|
+
if (!isRuleAction(actionToken)) {
|
|
64
|
+
throw unknownAction(actionToken);
|
|
65
|
+
}
|
|
66
|
+
const action = actionToken;
|
|
67
|
+
if (action === "describe" && source !== null) {
|
|
68
|
+
throw usageError("error: rules describe takes no params");
|
|
69
|
+
}
|
|
70
|
+
if (action === "delete" && !yes) {
|
|
71
|
+
throw usageError("error: deleting a rule can't be undone — its categories and tags come off every\n" +
|
|
72
|
+
"transaction it covers. To delete it, run the same command again with --yes.");
|
|
73
|
+
}
|
|
74
|
+
if (action !== "delete" && yes) {
|
|
75
|
+
throw usageError("error: '--yes' only applies to rules delete");
|
|
76
|
+
}
|
|
77
|
+
return { kind: "rules", action, params: source === null ? {} : resolveParams(source), yes };
|
|
78
|
+
}
|
|
79
|
+
// "--params", "--params=…", "--params-file", "--params-file=…" → the flag.
|
|
80
|
+
function paramsFlag(token) {
|
|
81
|
+
for (const flag of ["--params-file", "--params"]) {
|
|
82
|
+
if (token === flag || token.startsWith(`${flag}=`)) {
|
|
83
|
+
return flag;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
function resolveParams(source) {
|
|
89
|
+
if (source.flag === "--params") {
|
|
90
|
+
return parseParamsObject(source.json, "--params", "\nFor JSON that's hard to quote in this shell, save it to a file and pass\n--params-file <PATH> instead.");
|
|
91
|
+
}
|
|
92
|
+
return parseParamsObject(readParamsFile(source.path), "--params-file", "");
|
|
93
|
+
}
|
|
94
|
+
function parseParamsObject(rawValue, flag, hint) {
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(rawValue);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
parsed = null;
|
|
101
|
+
}
|
|
102
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
103
|
+
throw usageError(`error: invalid value for '${flag}':\n'${flag}' must be a JSON object, like '{"rule_ref":"rule_..."}'${hint}`);
|
|
104
|
+
}
|
|
105
|
+
return parsed;
|
|
106
|
+
}
|
|
107
|
+
// The path is the caller's own; neither it nor the contents are ever echoed
|
|
108
|
+
// back. The read is bounded by bytes actually read, not by the size stat
|
|
109
|
+
// reports (pseudo-files report 0, and a file can grow between stat and
|
|
110
|
+
// read). Special files are refused before they are opened, since opening a
|
|
111
|
+
// FIFO blocks; on POSIX the open is also non-blocking, so a FIFO swapped in
|
|
112
|
+
// after the check is refused by the fstat instead of hanging.
|
|
113
|
+
function readParamsFile(path) {
|
|
114
|
+
let bytes;
|
|
115
|
+
try {
|
|
116
|
+
if (!statSync(path).isFile()) {
|
|
117
|
+
throw new ParamsFileProblem("the path isn't a regular file");
|
|
118
|
+
}
|
|
119
|
+
bytes = readAtMost(path, MAX_PARAMS_FILE_BYTES + 1);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
throw usageError(`error: couldn't read the --params-file: ${paramsFileProblem(error)}`);
|
|
123
|
+
}
|
|
124
|
+
if (bytes.length > MAX_PARAMS_FILE_BYTES) {
|
|
125
|
+
throw usageError(`error: couldn't read the --params-file: it's over ${MAX_PARAMS_FILE_MB} MB`);
|
|
126
|
+
}
|
|
127
|
+
return decodeParamsFile(bytes);
|
|
128
|
+
}
|
|
129
|
+
class ParamsFileProblem extends Error {
|
|
130
|
+
}
|
|
131
|
+
function readAtMost(path, limit) {
|
|
132
|
+
const descriptor = openSync(path, process.platform === "win32" ? "r" : constants.O_RDONLY | constants.O_NONBLOCK);
|
|
133
|
+
try {
|
|
134
|
+
if (!fstatSync(descriptor).isFile()) {
|
|
135
|
+
throw new ParamsFileProblem("the path isn't a regular file");
|
|
136
|
+
}
|
|
137
|
+
const buffer = Buffer.alloc(limit);
|
|
138
|
+
let length = 0;
|
|
139
|
+
while (length < limit) {
|
|
140
|
+
const read = readSync(descriptor, buffer, length, limit - length, null);
|
|
141
|
+
if (read === 0) {
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
length += read;
|
|
145
|
+
}
|
|
146
|
+
return buffer.subarray(0, length);
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
closeSync(descriptor);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function paramsFileProblem(error) {
|
|
153
|
+
if (error instanceof ParamsFileProblem) {
|
|
154
|
+
return error.message;
|
|
155
|
+
}
|
|
156
|
+
const code = error instanceof Error && "code" in error ? error.code : undefined;
|
|
157
|
+
if (code === "ENOENT") {
|
|
158
|
+
return "there's no file at that path";
|
|
159
|
+
}
|
|
160
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
161
|
+
return "this account isn't allowed to read it";
|
|
162
|
+
}
|
|
163
|
+
return `it must be a readable JSON file under ${MAX_PARAMS_FILE_MB} MB`;
|
|
164
|
+
}
|
|
165
|
+
// Editors and shells on Windows often save "UTF-8" with a byte-order mark,
|
|
166
|
+
// and Windows PowerShell 5.1's > writes UTF-16LE; both read as the JSON
|
|
167
|
+
// they hold.
|
|
168
|
+
function decodeParamsFile(bytes) {
|
|
169
|
+
if (bytes[0] === 0xff && bytes[1] === 0xfe) {
|
|
170
|
+
return bytes.subarray(2).toString("utf16le");
|
|
171
|
+
}
|
|
172
|
+
const text = bytes.toString("utf8");
|
|
173
|
+
return text.startsWith("\uFEFF") ? text.slice(1) : text;
|
|
174
|
+
}
|
|
175
|
+
function unknownAction(token) {
|
|
176
|
+
const similar = didYouMean(token, RULE_ACTIONS);
|
|
177
|
+
const tip = similar === undefined ? "" : `\n\n tip: a similar action exists: '${similar}'`;
|
|
178
|
+
return usageError(`error: ${quotedForTerminal(token, 60)} isn't a rules action. Actions: ${RULE_ACTIONS.join(", ")}.${tip}`);
|
|
179
|
+
}
|
|
180
|
+
function usageError(firstLines) {
|
|
181
|
+
return new CliError(`${firstLines}\n\n${RULES_USAGE}\n\nFor more information, try '--help'.`, 2);
|
|
182
|
+
}
|
package/dist/args-shared.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// terminal through an echoed argument, tip, or usage line.
|
|
5
5
|
import { CliError } from "./cli-error.js";
|
|
6
6
|
import {} from "./mcp-setup/known-client.js";
|
|
7
|
+
import {} from "./rules/rule-actions.js";
|
|
7
8
|
import { sanitizeForTerminal } from "./terminal-text.js";
|
|
8
9
|
export function helpCommand(text) {
|
|
9
10
|
return { kind: "print-help", text, stream: "stdout", exitCode: 0 };
|
package/dist/args.js
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
// you mean" tips (via the ported Jaro similarity in clap-suggestions.ts),
|
|
5
5
|
// "--" end-of-options, and short-flag cluster dispatch. Newer commands
|
|
6
6
|
// (login, logout) keep the same behavior and exit codes without chasing
|
|
7
|
-
// clap's byte-level quirks.
|
|
7
|
+
// clap's byte-level quirks. One deliberate difference: a misplaced flag's
|
|
8
|
+
// cross-level tip names only the subcommand actually typed.
|
|
8
9
|
import { parseDeploy, parseRollback, parseVersions } from "./args-deploy.js";
|
|
9
10
|
import { parseDev } from "./args-dev.js";
|
|
11
|
+
import { parseDelete } from "./args-delete.js";
|
|
10
12
|
import { parseInit } from "./args-init.js";
|
|
11
13
|
import { parseMcpSetup } from "./args-mcp-setup.js";
|
|
12
14
|
import { parseQuery } from "./args-query.js";
|
|
15
|
+
import { parseRules } from "./args-rules.js";
|
|
13
16
|
import { removeDashesTip, similarArgumentTip, unexpectedArgument, unexpectedFlagValue, } from "./args-shared.js";
|
|
14
17
|
import { didYouMean } from "./clap-suggestions.js";
|
|
15
18
|
import { CliError } from "./cli-error.js";
|
|
@@ -21,7 +24,7 @@ const MCP_USAGE = "Usage: npx driggsby@latest mcp <COMMAND>";
|
|
|
21
24
|
const ROOT_LEVEL = {
|
|
22
25
|
helpText: ROOT_HELP,
|
|
23
26
|
usage: ROOT_USAGE,
|
|
24
|
-
subcommands: ["mcp", "login", "logout", "init", "dev", "deploy", "rollback", "versions", "query"],
|
|
27
|
+
subcommands: ["mcp", "login", "logout", "init", "dev", "deploy", "rollback", "versions", "delete", "query", "rules"],
|
|
25
28
|
longFlags: ["help", "version"],
|
|
26
29
|
subcommandFlags: [
|
|
27
30
|
{ name: "mcp", longFlags: ["help"] },
|
|
@@ -32,7 +35,9 @@ const ROOT_LEVEL = {
|
|
|
32
35
|
{ name: "deploy", longFlags: ["preview", "help"] },
|
|
33
36
|
{ name: "rollback", longFlags: ["to", "help"] },
|
|
34
37
|
{ name: "versions", longFlags: ["help"] },
|
|
38
|
+
{ name: "delete", longFlags: ["yes", "help"] },
|
|
35
39
|
{ name: "query", longFlags: ["sql", "params", "help"] },
|
|
40
|
+
{ name: "rules", longFlags: ["params", "params-file", "yes", "help"] },
|
|
36
41
|
],
|
|
37
42
|
usagePrefix: "npx driggsby@latest",
|
|
38
43
|
hasVersion: true,
|
|
@@ -96,10 +101,14 @@ function dispatchSubcommand(name, rest) {
|
|
|
96
101
|
return parseDeploy(rest);
|
|
97
102
|
case "rollback":
|
|
98
103
|
return parseRollback(rest);
|
|
104
|
+
case "delete":
|
|
105
|
+
return parseDelete(rest);
|
|
99
106
|
case "versions":
|
|
100
107
|
return parseVersions(rest);
|
|
101
108
|
case "query":
|
|
102
109
|
return parseQuery(rest);
|
|
110
|
+
case "rules":
|
|
111
|
+
return parseRules(rest);
|
|
103
112
|
case "setup":
|
|
104
113
|
return parseMcpSetup(rest);
|
|
105
114
|
default:
|
|
@@ -163,18 +172,23 @@ function unknownLongFlagAtLevel(level, token, remainingArgs) {
|
|
|
163
172
|
}
|
|
164
173
|
// clap's cross-level fallback: when a LATER argv token names one of this
|
|
165
174
|
// level's subcommands and that subcommand has a similar flag, point the
|
|
166
|
-
// user at the flag's real home ("tip: 'setup --print' exists").
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
+
// user at the flag's real home ("tip: 'setup --print' exists"). The home
|
|
176
|
+
// is only ever the first subcommand actually typed (a flag's value never
|
|
177
|
+
// counts), so `--yes rules delete` is never pointed at the app-deleting
|
|
178
|
+
// `delete --yes`.
|
|
179
|
+
const typed = firstTypedSubcommand(level, remainingArgs);
|
|
180
|
+
const subcommand = level.subcommandFlags.find((entry) => entry.name === typed);
|
|
181
|
+
const subcommandSimilar = subcommand === undefined ? undefined : didYouMean(flagName, subcommand.longFlags);
|
|
182
|
+
if (subcommand !== undefined && subcommandSimilar !== undefined) {
|
|
183
|
+
return unexpectedArgument(shown, level.usage, ` tip: '${subcommand.name} --${subcommandSimilar}' exists`);
|
|
175
184
|
}
|
|
176
185
|
return unexpectedArgument(shown, level.usage);
|
|
177
186
|
}
|
|
187
|
+
// Flags anywhere in the tree whose next token is their value, not a word.
|
|
188
|
+
const VALUE_FLAGS = new Set(["--params", "--params-file", "--sql", "--to", "-s", "--scope"]);
|
|
189
|
+
function firstTypedSubcommand(level, args) {
|
|
190
|
+
return args.find((argument, index) => level.subcommands.includes(argument) && !VALUE_FLAGS.has(args[index - 1] ?? ""));
|
|
191
|
+
}
|
|
178
192
|
function unrecognizedSubcommand(level, subcommand) {
|
|
179
193
|
const similar = didYouMean(subcommand, level.subcommands);
|
|
180
194
|
const tip = similar === undefined ? "" : ` tip: a similar subcommand exists: '${similar}'\n\n`;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// driggsby delete: permanently remove a deployed app — the app itself and
|
|
2
|
+
// every version Driggsby kept for it. There is no undo, so this command
|
|
3
|
+
// owns the "are you sure": interactively the person types the app's
|
|
4
|
+
// address name back; in a script or agent run, --yes is required.
|
|
5
|
+
import { access } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { deleteApp, readProjectConfig, slugProblem } from "@driggsby/deploy";
|
|
8
|
+
import { apiBaseUrl } from "../api/base-url.js";
|
|
9
|
+
import { CliError } from "../cli-error.js";
|
|
10
|
+
import { defaultCredentialEnvironment, } from "../credentials/store.js";
|
|
11
|
+
import { capForTerminal, quotedForTerminal, wrapProse } from "../terminal-text.js";
|
|
12
|
+
import { MAX_SERVER_TEXT_CHARS, MAX_SERVER_URL_CHARS, deployFailure, requireDeploySession, } from "./api-session.js";
|
|
13
|
+
import { fetchVersionList } from "./versions-command.js";
|
|
14
|
+
const DELETE_RETRY_COMMAND = "npx driggsby@latest delete";
|
|
15
|
+
// The success line is `✓ Deleted ` (12 columns) plus the quoted name;
|
|
16
|
+
// 12 + 2 quotes + 64 = 78 keeps it inside 80 whatever the server sends.
|
|
17
|
+
const DELETED_LINE_NAME_CHARS = 64;
|
|
18
|
+
// Once the name is known (and validated), the retry names it too, so a
|
|
19
|
+
// retry from the wrong folder can't quietly target a different app.
|
|
20
|
+
function retryCommandFor(slug) {
|
|
21
|
+
return slug === null ? DELETE_RETRY_COMMAND : `${DELETE_RETRY_COMMAND} ${slug}`;
|
|
22
|
+
}
|
|
23
|
+
function defaultDeleteIo() {
|
|
24
|
+
return {
|
|
25
|
+
out: (text) => {
|
|
26
|
+
process.stdout.write(text);
|
|
27
|
+
},
|
|
28
|
+
ask: async (question) => {
|
|
29
|
+
const { createInterface } = await import("node:readline/promises");
|
|
30
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
31
|
+
try {
|
|
32
|
+
return await readline.question(question);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
readline.close();
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
interactive: process.stdin.isTTY && process.stdout.isTTY,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export async function runDelete(options, environment = defaultCredentialEnvironment(), io = defaultDeleteIo()) {
|
|
42
|
+
const projectDirectory = options.projectDirectory ?? process.cwd();
|
|
43
|
+
const baseUrl = apiBaseUrl(environment.env);
|
|
44
|
+
let resolvedSlug = null;
|
|
45
|
+
try {
|
|
46
|
+
const slug = options.slug === null
|
|
47
|
+
? await slugFromProjectFolder(projectDirectory)
|
|
48
|
+
: validatedArgvSlug(options.slug);
|
|
49
|
+
resolvedSlug = slug;
|
|
50
|
+
const session = await requireDeploySession(environment);
|
|
51
|
+
const api = { baseUrl: session.baseUrl, token: session.token };
|
|
52
|
+
// The look-before-you-leap read: it shows exactly what is about to
|
|
53
|
+
// go, and an unknown name stops here — with delete's own not-found,
|
|
54
|
+
// never the version list's "deploy it first" suggestion — before the
|
|
55
|
+
// DELETE is ever sent.
|
|
56
|
+
const list = await fetchVersionList(api, slug, appAlreadyGone);
|
|
57
|
+
const versionCount = list.versions.length;
|
|
58
|
+
const versionsLine = versionCount === 1 ? "1 deployed version" : `${versionCount} deployed versions`;
|
|
59
|
+
// The slug passed the strict pattern above (argv) or came from the
|
|
60
|
+
// validated project config, so it prints bare here.
|
|
61
|
+
io.out(`${wrapProse(`Deleting ${slug} removes it forever: ${versionsLine}, its page in Driggsby, and its address:`)}\n` +
|
|
62
|
+
` ${capForTerminal(list.url, MAX_SERVER_URL_CHARS)}\n\n`);
|
|
63
|
+
if (!options.yes) {
|
|
64
|
+
await confirmOrStop(slug, io);
|
|
65
|
+
}
|
|
66
|
+
const deleted = await deleteApp(api, slug);
|
|
67
|
+
// The name is server text: quoted so it reads as a name, never as the
|
|
68
|
+
// CLI's own sentence, and capped tighter than usual (64) so the line
|
|
69
|
+
// stays inside 80 columns whatever the server sends. The preflight
|
|
70
|
+
// above already named the address, so the slug isn't repeated here.
|
|
71
|
+
io.out(`✓ Deleted ${quotedForTerminal(deleted.appName, DELETED_LINE_NAME_CHARS)}\n\n`);
|
|
72
|
+
io.out(`${wrapProse("The app and its versions are gone, and its address now shows nothing. Deploying the app's folder again would create a new app at a new address.")}\n` +
|
|
73
|
+
"\nNext:\n" +
|
|
74
|
+
" Start a new app with npx driggsby@latest init\n");
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
throw deployFailure(error, baseUrl, retryCommandFor(resolvedSlug));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// A name typed on the command line is shape-gated before it goes anywhere:
|
|
82
|
+
// a typo gets the naming rules instead of a network round trip, nothing
|
|
83
|
+
// unvalidated is ever echoed, and the empty string can't slip through to
|
|
84
|
+
// match an empty confirmation answer.
|
|
85
|
+
function validatedArgvSlug(slug) {
|
|
86
|
+
const problem = slugProblem(slug);
|
|
87
|
+
if (problem === null)
|
|
88
|
+
return slug;
|
|
89
|
+
const flatProblem = problem.replaceAll("\n", " ");
|
|
90
|
+
throw new CliError(wrapProse(`${quotedForTerminal(slug, MAX_SERVER_TEXT_CHARS)} doesn't look like an app's address name: ${flatProblem}. Copy the name from the app's driggsby.json ("slug"), or from npx driggsby@latest versions run in its folder.`), 2);
|
|
91
|
+
}
|
|
92
|
+
// The app already isn't there — for a delete that is closer to done than
|
|
93
|
+
// to broken, and the one wrong suggestion would be "deploy it first".
|
|
94
|
+
function appAlreadyGone(slug) {
|
|
95
|
+
return new CliError(wrapProse(`There's no app named ${quotedForTerminal(slug, MAX_SERVER_TEXT_CHARS)} on your Driggsby account — maybe it was already deleted. Nothing was changed.`), 1);
|
|
96
|
+
}
|
|
97
|
+
// With no name given, the app is the one this folder deploys. Only a
|
|
98
|
+
// genuinely missing driggsby.json gets the "name it directly" guidance;
|
|
99
|
+
// a present-but-broken config surfaces its own precise error, the same
|
|
100
|
+
// one deploy and versions would show.
|
|
101
|
+
async function slugFromProjectFolder(projectDirectory) {
|
|
102
|
+
try {
|
|
103
|
+
await access(join(projectDirectory, "driggsby.json"));
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
throw new CliError(`${wrapProse("This folder doesn't have a driggsby.json, so there's no app here to delete. Name the app directly — the \"slug\" in its driggsby.json:")}\n` +
|
|
107
|
+
" npx driggsby@latest delete <APP-ADDRESS-NAME>", 1);
|
|
108
|
+
}
|
|
109
|
+
const config = await readProjectConfig(projectDirectory);
|
|
110
|
+
return config.slug;
|
|
111
|
+
}
|
|
112
|
+
// The person types the address name back. Anything else — including an
|
|
113
|
+
// empty answer — deletes nothing and says so. Scripts and agents have no
|
|
114
|
+
// one to ask, so they must say --yes explicitly.
|
|
115
|
+
async function confirmOrStop(slug, io) {
|
|
116
|
+
if (!io.interactive) {
|
|
117
|
+
// Already shape-validated, so echoing it into a command line is safe.
|
|
118
|
+
throw new CliError(`${wrapProse("Deleting can't be undone, and this terminal can't ask you to confirm. Say it explicitly:")}\n` +
|
|
119
|
+
` npx driggsby@latest delete ${slug} --yes`, 2);
|
|
120
|
+
}
|
|
121
|
+
const answer = (await io.ask("Type the app's address name to confirm: ")).trim();
|
|
122
|
+
if (answer !== slug) {
|
|
123
|
+
throw new CliError("That didn't match, so nothing was deleted.", 1);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -42,20 +42,25 @@ export async function runVersions(options, environment = defaultCredentialEnviro
|
|
|
42
42
|
throw deployFailure(error, baseUrl, "npx driggsby@latest versions");
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
// A 404 here means the app
|
|
46
|
-
// the fix is a deploy
|
|
47
|
-
|
|
45
|
+
// A 404 here means the app isn't on this account. For versions and
|
|
46
|
+
// rollback the fix is a deploy; a caller whose right next step differs
|
|
47
|
+
// (delete) passes its own notFound shape. The slug is quoted and capped
|
|
48
|
+
// either way — delete hands this an argv string, not only config values.
|
|
49
|
+
export async function fetchVersionList(api, slug, notFound = firstDeployCreatesIt) {
|
|
48
50
|
try {
|
|
49
51
|
return await listVersions(api, slug);
|
|
50
52
|
}
|
|
51
53
|
catch (error) {
|
|
52
54
|
if (error instanceof DeployApiError && error.status === 404) {
|
|
53
|
-
throw
|
|
54
|
-
" npx driggsby@latest deploy", 1);
|
|
55
|
+
throw notFound(slug);
|
|
55
56
|
}
|
|
56
57
|
throw error;
|
|
57
58
|
}
|
|
58
59
|
}
|
|
60
|
+
function firstDeployCreatesIt(slug) {
|
|
61
|
+
return new CliError(`${wrapProse(`There's no app named ${quotedForTerminal(slug, MAX_SERVER_TEXT_CHARS)} on your Driggsby account yet. Its first deploy creates it:`)}\n` +
|
|
62
|
+
" npx driggsby@latest deploy", 1);
|
|
63
|
+
}
|
|
59
64
|
export function renderVersionRows(list) {
|
|
60
65
|
const rows = list.versions.map((version) => ({
|
|
61
66
|
label: `v${version.number}`,
|
package/dist/help.js
CHANGED
|
@@ -19,7 +19,9 @@ Commands:
|
|
|
19
19
|
deploy Deploy the app in this directory to Driggsby.
|
|
20
20
|
rollback Switch which deployed version of your app is live.
|
|
21
21
|
versions List your deployed app's versions.
|
|
22
|
+
delete Permanently remove a deployed app.
|
|
22
23
|
query Run one Driggsby data tool and print its result as JSON.
|
|
24
|
+
rules Create, change, and delete your transaction rules.
|
|
23
25
|
|
|
24
26
|
Options:
|
|
25
27
|
-h, --help Print help
|
|
@@ -35,6 +37,7 @@ Examples:
|
|
|
35
37
|
npx driggsby@latest dev
|
|
36
38
|
npx driggsby@latest deploy
|
|
37
39
|
npx driggsby@latest query get_overview
|
|
40
|
+
npx driggsby@latest rules describe
|
|
38
41
|
`;
|
|
39
42
|
export const INIT_HELP = `Create a new Driggsby app in a new folder.
|
|
40
43
|
|
|
@@ -57,9 +60,9 @@ Options:
|
|
|
57
60
|
export const LOGIN_HELP = `Sign in to Driggsby and save an app token on this machine.
|
|
58
61
|
|
|
59
62
|
This opens a Driggsby approval page in your browser. Approving it issues an
|
|
60
|
-
app token that lets this machine read your Driggsby data
|
|
61
|
-
apps. The token is saved locally for
|
|
62
|
-
printed.
|
|
63
|
+
app token that lets this machine read your Driggsby data, deploy Driggsby
|
|
64
|
+
apps, and manage your transaction rules. The token is saved locally for
|
|
65
|
+
other driggsby commands and is never printed.
|
|
63
66
|
|
|
64
67
|
Usage: npx driggsby@latest login
|
|
65
68
|
|
|
@@ -128,6 +131,19 @@ Options:
|
|
|
128
131
|
npx driggsby@latest versions.
|
|
129
132
|
-h, --help Print help
|
|
130
133
|
`;
|
|
134
|
+
export const DELETE_HELP = `Permanently remove a deployed app.
|
|
135
|
+
|
|
136
|
+
This deletes the app and every version Driggsby kept for it. There is no
|
|
137
|
+
undo: the app's address shows nothing from that moment on. With no name,
|
|
138
|
+
the app in this folder's driggsby.json is the one deleted. You'll be asked
|
|
139
|
+
to type the name back to confirm; scripts and agents pass --yes instead.
|
|
140
|
+
|
|
141
|
+
Usage: npx driggsby@latest delete [APP-ADDRESS-NAME] [--yes]
|
|
142
|
+
|
|
143
|
+
Options:
|
|
144
|
+
--yes Skip the confirmation question. For scripts and agents.
|
|
145
|
+
-h, --help Print help
|
|
146
|
+
`;
|
|
131
147
|
export const VERSIONS_HELP = `List your deployed app's versions.
|
|
132
148
|
|
|
133
149
|
Shows every version Driggsby has kept for the app in this directory, which
|
|
@@ -168,6 +184,45 @@ Examples:
|
|
|
168
184
|
npx driggsby@latest query list_recurring_transactions
|
|
169
185
|
npx driggsby@latest query query_cash_sql --sql "SELECT 1"
|
|
170
186
|
`;
|
|
187
|
+
export const RULES_HELP = `Create, change, and delete your transaction rules.
|
|
188
|
+
|
|
189
|
+
A transaction rule is your standing correction to how Driggsby reads your
|
|
190
|
+
transactions: a category, a counterparty's name, or tags on a set of
|
|
191
|
+
transactions. Each action runs one Driggsby rule tool and prints its result
|
|
192
|
+
as JSON; the JSON goes to stdout and nothing else does. Start with describe:
|
|
193
|
+
it prints the rule format, the process, and worked examples.
|
|
194
|
+
|
|
195
|
+
Sign in first with npx driggsby@latest login.
|
|
196
|
+
|
|
197
|
+
Usage: npx driggsby@latest rules <ACTION> [--params <JSON>] [--yes]
|
|
198
|
+
|
|
199
|
+
Actions:
|
|
200
|
+
describe Print the rule format, the process, and examples.
|
|
201
|
+
list List your rules. With {"rule_ref":"..."}, one rule in full.
|
|
202
|
+
tags List your transaction tags.
|
|
203
|
+
preview Show exactly what a rule would change. Saves nothing; returns
|
|
204
|
+
the confirm_token that save needs.
|
|
205
|
+
save Create or change a rule with a preview's confirm_token, or
|
|
206
|
+
rename, pause, or resume one by rule_ref.
|
|
207
|
+
delete Delete a rule by rule_ref. There is no undo; needs --yes.
|
|
208
|
+
|
|
209
|
+
Options:
|
|
210
|
+
--params <JSON> The action's params as a JSON object, like
|
|
211
|
+
'{"rule_ref":"rule_..."}'.
|
|
212
|
+
--params-file <PATH> Read the params object from a JSON file instead.
|
|
213
|
+
--yes Confirm a delete.
|
|
214
|
+
-h, --help Print help
|
|
215
|
+
|
|
216
|
+
If Driggsby refuses a change with details to act on (a question to answer,
|
|
217
|
+
an overlapping rule), stdout carries those details as JSON. Any failure puts
|
|
218
|
+
its message on stderr and exits 1.
|
|
219
|
+
|
|
220
|
+
Examples:
|
|
221
|
+
npx driggsby@latest rules describe
|
|
222
|
+
npx driggsby@latest rules list
|
|
223
|
+
npx driggsby@latest rules tags
|
|
224
|
+
npx driggsby@latest rules save --params-file answers.json
|
|
225
|
+
`;
|
|
171
226
|
export const MCP_HELP = `Set up Driggsby MCP clients.
|
|
172
227
|
|
|
173
228
|
Usage: npx driggsby@latest mcp <COMMAND>
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// an approval.
|
|
6
6
|
import { CliError } from "../cli-error.js";
|
|
7
7
|
import { sanitizeForTerminal } from "../terminal-text.js";
|
|
8
|
-
|
|
8
|
+
// The CLI's own sign-in: read, deploy, and the transaction-rule tools.
|
|
9
|
+
const CLAIM_SCOPE = "driggsby.cli";
|
|
9
10
|
const CLAIM_APP_NAME = "Driggsby CLI";
|
|
10
11
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
11
12
|
// The server names its own claim lifetime; this cap keeps a buggy or hostile
|
package/dist/main.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { parseArgv } from "./args.js";
|
|
5
5
|
import { CliError } from "./cli-error.js";
|
|
6
|
+
import { runDelete } from "./deploy/delete-command.js";
|
|
6
7
|
import { runDeploy } from "./deploy/deploy-command.js";
|
|
7
8
|
import { runDev } from "./dev/dev-command.js";
|
|
8
9
|
import { runDevStop } from "./dev/dev-stop.js";
|
|
@@ -10,6 +11,7 @@ import { runInit } from "./init/init-command.js";
|
|
|
10
11
|
import { runRollback } from "./deploy/rollback-command.js";
|
|
11
12
|
import { runVersions } from "./deploy/versions-command.js";
|
|
12
13
|
import { runQuery } from "./query/query-command.js";
|
|
14
|
+
import { runRules } from "./rules/rules-command.js";
|
|
13
15
|
import { runLogin } from "./login/login.js";
|
|
14
16
|
import { runLogout } from "./login/logout.js";
|
|
15
17
|
import { runMcpSetup } from "./mcp-setup/setup.js";
|
|
@@ -39,12 +41,16 @@ async function run(argv) {
|
|
|
39
41
|
return command.stop ? await runDevStop(homedir()) : await runDev();
|
|
40
42
|
case "deploy":
|
|
41
43
|
return await runDeploy({ preview: command.preview });
|
|
44
|
+
case "delete":
|
|
45
|
+
return await runDelete({ slug: command.slug, yes: command.yes });
|
|
42
46
|
case "rollback":
|
|
43
47
|
return await runRollback({ toVersion: command.toVersion });
|
|
44
48
|
case "versions":
|
|
45
49
|
return await runVersions({});
|
|
46
50
|
case "query":
|
|
47
51
|
return await runQuery({ tool: command.tool, params: command.params });
|
|
52
|
+
case "rules":
|
|
53
|
+
return await runRules({ action: command.action, params: command.params, yes: command.yes });
|
|
48
54
|
}
|
|
49
55
|
}
|
|
50
56
|
try {
|
|
@@ -10,7 +10,7 @@ import { McpBroker } from "../dev/mcp-broker.js";
|
|
|
10
10
|
import { SQL_TOOLS } from "../args-query.js";
|
|
11
11
|
import { APP_TOOL_ALLOWLIST } from "../dev/tool-allowlist.js";
|
|
12
12
|
import { deployFailure, requireDeploySession, SIGN_IN_AGAIN_MESSAGE } from "../deploy/api-session.js";
|
|
13
|
-
import { quotedForTerminal, wrapProse } from "../terminal-text.js";
|
|
13
|
+
import { quotedForTerminal, terminalSafeJson, wrapProse } from "../terminal-text.js";
|
|
14
14
|
// The Driggsby endpoint requires a short reason on most read tools; when
|
|
15
15
|
// the caller gives none, this names the CLI rather than the dev preview.
|
|
16
16
|
const QUERY_REASON = "Checking this tool's result shape from the driggsby CLI.";
|
|
@@ -52,7 +52,7 @@ export async function runQuery(options, environment = defaultCredentialEnvironme
|
|
|
52
52
|
: wrapProse(outcome.error.message);
|
|
53
53
|
throw new CliError(message, 1);
|
|
54
54
|
}
|
|
55
|
-
io.out(`${
|
|
55
|
+
io.out(`${terminalSafeJson(outcome.result)}\n`);
|
|
56
56
|
return 0;
|
|
57
57
|
}
|
|
58
58
|
catch (error) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The rules command's actions. Each maps to exactly one Driggsby
|
|
2
|
+
// transaction-rule tool (the same tools the Driggsby MCP server offers);
|
|
3
|
+
// describe reads those tools' own descriptions instead of calling one.
|
|
4
|
+
export const RULE_ACTION_TOOLS = {
|
|
5
|
+
list: "list_transaction_rules",
|
|
6
|
+
tags: "list_transaction_tags",
|
|
7
|
+
preview: "preview_transaction_rule",
|
|
8
|
+
save: "save_transaction_rule",
|
|
9
|
+
delete: "delete_transaction_rule",
|
|
10
|
+
};
|
|
11
|
+
export const RULE_ACTIONS = ["describe", "list", "tags", "preview", "save", "delete"];
|
|
12
|
+
export function isRuleAction(value) {
|
|
13
|
+
return RULE_ACTIONS.includes(value);
|
|
14
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// driggsby rules: read and change the signed-in person's transaction
|
|
2
|
+
// rules. Each action is one call to a Driggsby transaction-rule tool (the
|
|
3
|
+
// same tools the Driggsby MCP server offers); describe prints those tools'
|
|
4
|
+
// own descriptions, so the rule format is never copied into this CLI.
|
|
5
|
+
// stdout carries only JSON (the result, or a refusal's own details) so an
|
|
6
|
+
// agent can pipe or save it; every message is a CliError on stderr.
|
|
7
|
+
import { apiBaseUrl } from "../api/base-url.js";
|
|
8
|
+
import { CliError } from "../cli-error.js";
|
|
9
|
+
import { defaultCredentialEnvironment } from "../credentials/store.js";
|
|
10
|
+
import { deployFailure, requireDeploySession } from "../deploy/api-session.js";
|
|
11
|
+
import { terminalSafeJson } from "../terminal-text.js";
|
|
12
|
+
import { RULE_ACTION_TOOLS, RULE_ACTIONS } from "./rule-actions.js";
|
|
13
|
+
import { callRuleTool, listRuleTools } from "./rules-rpc.js";
|
|
14
|
+
// Most rule tools require a short reason; when the caller gives none, this
|
|
15
|
+
// names the CLI.
|
|
16
|
+
export const RULES_REASON = "Managing transaction rules from the driggsby CLI.";
|
|
17
|
+
function defaultRulesIo() {
|
|
18
|
+
return {
|
|
19
|
+
out: (text) => {
|
|
20
|
+
process.stdout.write(text);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export async function runRules(options, environment = defaultCredentialEnvironment(), io = defaultRulesIo()) {
|
|
25
|
+
const { action, params } = options;
|
|
26
|
+
if (action === "delete" && options.yes !== true) {
|
|
27
|
+
throw new CliError("Deleting a rule can't be undone, so it needs --yes.", 2);
|
|
28
|
+
}
|
|
29
|
+
const baseUrl = apiBaseUrl(environment.env);
|
|
30
|
+
const retryCommand = retryCommandFor(options);
|
|
31
|
+
try {
|
|
32
|
+
const session = await requireDeploySession(environment);
|
|
33
|
+
if (action === "describe") {
|
|
34
|
+
const tools = await listRuleTools(session);
|
|
35
|
+
io.out(`${terminalSafeJson(describePayload(tools))}\n`);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
const outcome = await callRuleTool(session, RULE_ACTION_TOOLS[action], withReason(params));
|
|
39
|
+
if (outcome.kind === "ok") {
|
|
40
|
+
io.out(`${terminalSafeJson(outcome.result)}\n`);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
if (outcome.kind === "refused") {
|
|
44
|
+
io.out(`${terminalSafeJson(outcome.details)}\n`);
|
|
45
|
+
}
|
|
46
|
+
throw new CliError(outcome.message, 1);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
throw deployFailure(error, baseUrl, retryCommand);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Each action's flags, keyed by action so a new action can't be left out.
|
|
53
|
+
const ACTION_FLAGS = {
|
|
54
|
+
list: " [--params <JSON>]",
|
|
55
|
+
tags: "",
|
|
56
|
+
preview: " --params <JSON>",
|
|
57
|
+
save: " --params <JSON>",
|
|
58
|
+
delete: " --params <JSON> --yes",
|
|
59
|
+
};
|
|
60
|
+
// What describe prints: the tools' own descriptions, plus the CLI command
|
|
61
|
+
// that runs each tool the descriptions name.
|
|
62
|
+
export function describePayload(tools) {
|
|
63
|
+
const howToRun = {};
|
|
64
|
+
for (const action of RULE_ACTIONS) {
|
|
65
|
+
if (action !== "describe") {
|
|
66
|
+
howToRun[RULE_ACTION_TOOLS[action]] = `npx driggsby@latest rules ${action}${ACTION_FLAGS[action]}`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// The two read tools the rule descriptions send an agent to first.
|
|
70
|
+
howToRun.search_cash_transactions = "npx driggsby@latest query search_cash_transactions --params <JSON>";
|
|
71
|
+
howToRun.query_cash_sql = "npx driggsby@latest query query_cash_sql --sql <SQL>";
|
|
72
|
+
return {
|
|
73
|
+
how_to_run: howToRun,
|
|
74
|
+
notes: [
|
|
75
|
+
"--params is the tool's arguments object as JSON; --params-file <PATH> reads the same object from a file.",
|
|
76
|
+
"The CLI fills in reason when you leave it out.",
|
|
77
|
+
],
|
|
78
|
+
tools,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function withReason(params) {
|
|
82
|
+
const reason = params.reason;
|
|
83
|
+
if (typeof reason === "string" && reason.trim() !== "") {
|
|
84
|
+
return params;
|
|
85
|
+
}
|
|
86
|
+
return { ...params, reason: RULES_REASON };
|
|
87
|
+
}
|
|
88
|
+
// The command to run again after a network blip. Params are never rebuilt
|
|
89
|
+
// into a shell line (no quoting is right for every shell), so they are named
|
|
90
|
+
// in a plain sentence under the command instead. A retried save is safe: a
|
|
91
|
+
// confirm token redeems once, so a save that already landed is refused, not
|
|
92
|
+
// applied twice.
|
|
93
|
+
function retryCommandFor(options) {
|
|
94
|
+
const command = `npx driggsby@latest rules ${options.action}${options.action === "delete" ? " --yes" : ""}`;
|
|
95
|
+
return Object.keys(options.params).length === 0 ? command : `${command}\n with the same params as before`;
|
|
96
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The rules command's channel to Driggsby: one JSON-RPC POST to /mcp with
|
|
2
|
+
// the saved sign-in. Unlike the dev preview's broker, a tool refusal keeps
|
|
3
|
+
// its structured details (a blocking suggestion and its options, the
|
|
4
|
+
// overlapping rule), because an agent needs them to answer and save again.
|
|
5
|
+
import { CliError } from "../cli-error.js";
|
|
6
|
+
import { SIGN_IN_AGAIN_MESSAGE } from "../deploy/api-session.js";
|
|
7
|
+
import { GENERIC_TOOL_TROUBLE } from "../dev/dev-servers.js";
|
|
8
|
+
import { capForTerminal, sanitizeForTerminal } from "../terminal-text.js";
|
|
9
|
+
import { RULE_ACTION_TOOLS } from "./rule-actions.js";
|
|
10
|
+
// A preview replays the person's posted history; give it longer than a
|
|
11
|
+
// dashboard read gets.
|
|
12
|
+
export const RULES_CALL_TIMEOUT_MS = 60_000;
|
|
13
|
+
// Server messages are hostile text: sanitized and bounded before they reach
|
|
14
|
+
// the terminal. Generous, because a rule refusal explains itself.
|
|
15
|
+
const MAX_SERVER_MESSAGE_CHARS = 1_000;
|
|
16
|
+
export const RULES_NOT_AVAILABLE_MESSAGE = "This sign-in can't use transaction rules. Sign in again and approve the request:\n" +
|
|
17
|
+
" npx driggsby@latest login\n" +
|
|
18
|
+
"If rules still aren't available after that, they aren't open to your account yet.";
|
|
19
|
+
export async function callRuleTool(session, tool, toolArguments) {
|
|
20
|
+
const payload = await postMcp(session, {
|
|
21
|
+
jsonrpc: "2.0",
|
|
22
|
+
id: 1,
|
|
23
|
+
method: "tools/call",
|
|
24
|
+
params: { name: tool, arguments: toolArguments },
|
|
25
|
+
});
|
|
26
|
+
return interpretToolCall(payload);
|
|
27
|
+
}
|
|
28
|
+
// The rule tools' own descriptions and input schemas, from the tools/list
|
|
29
|
+
// this sign-in sees. None listed means the sign-in predates rules or rules
|
|
30
|
+
// aren't open to the account; either way the next step is the same.
|
|
31
|
+
export async function listRuleTools(session) {
|
|
32
|
+
const payload = await postMcp(session, { jsonrpc: "2.0", id: 1, method: "tools/list" });
|
|
33
|
+
const tools = asRecord(asRecord(payload)?.result)?.tools;
|
|
34
|
+
if (!Array.isArray(tools)) {
|
|
35
|
+
throw new CliError(GENERIC_TOOL_TROUBLE, 1);
|
|
36
|
+
}
|
|
37
|
+
const listed = tools;
|
|
38
|
+
const wanted = new Set(Object.values(RULE_ACTION_TOOLS));
|
|
39
|
+
const found = listed.flatMap((tool) => {
|
|
40
|
+
const record = asRecord(tool);
|
|
41
|
+
if (record === null ||
|
|
42
|
+
typeof record.name !== "string" ||
|
|
43
|
+
!wanted.has(record.name) ||
|
|
44
|
+
typeof record.description !== "string") {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
return [
|
|
48
|
+
{
|
|
49
|
+
name: record.name,
|
|
50
|
+
title: typeof record.title === "string" ? record.title : null,
|
|
51
|
+
description: record.description,
|
|
52
|
+
inputSchema: record.inputSchema ?? null,
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
});
|
|
56
|
+
if (found.length === 0) {
|
|
57
|
+
throw new CliError(RULES_NOT_AVAILABLE_MESSAGE, 1);
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
// Three shapes: a protocol error ({ error: { message } }), a tool refusal
|
|
62
|
+
// (result.isError, details in structuredContent), and success.
|
|
63
|
+
export function interpretToolCall(payload) {
|
|
64
|
+
const envelope = asRecord(payload);
|
|
65
|
+
if (envelope === null) {
|
|
66
|
+
return { kind: "error", message: GENERIC_TOOL_TROUBLE };
|
|
67
|
+
}
|
|
68
|
+
const error = asRecord(envelope.error);
|
|
69
|
+
if (error !== null) {
|
|
70
|
+
return { kind: "error", message: serverMessage(error.message) };
|
|
71
|
+
}
|
|
72
|
+
const result = asRecord(envelope.result);
|
|
73
|
+
if (result === null) {
|
|
74
|
+
return { kind: "error", message: GENERIC_TOOL_TROUBLE };
|
|
75
|
+
}
|
|
76
|
+
const structured = result.structuredContent ?? null;
|
|
77
|
+
if (result.isError === true) {
|
|
78
|
+
const message = asRecord(structured)?.error ?? firstText(result.content);
|
|
79
|
+
return { kind: "refused", message: serverMessage(message), details: structured };
|
|
80
|
+
}
|
|
81
|
+
return { kind: "ok", result: structured };
|
|
82
|
+
}
|
|
83
|
+
// A refusal's message lives in structuredContent.error; the MCP text
|
|
84
|
+
// content is the fallback, the same one the dev preview's broker reads.
|
|
85
|
+
function firstText(content) {
|
|
86
|
+
return Array.isArray(content) ? asRecord(content[0])?.text : undefined;
|
|
87
|
+
}
|
|
88
|
+
async function postMcp(session, body) {
|
|
89
|
+
// Bare headers on purpose: the Driggsby endpoint refuses anything that
|
|
90
|
+
// looks like a browser call (an Origin or Sec-Fetch-Site header), and
|
|
91
|
+
// Node's fetch sends neither on its own.
|
|
92
|
+
const response = await fetch(new URL("/mcp", session.baseUrl), {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${session.token}` },
|
|
95
|
+
body: JSON.stringify(body),
|
|
96
|
+
// The bearer token must never travel to a redirect target.
|
|
97
|
+
redirect: "error",
|
|
98
|
+
signal: AbortSignal.timeout(RULES_CALL_TIMEOUT_MS),
|
|
99
|
+
});
|
|
100
|
+
if (response.status === 401) {
|
|
101
|
+
throw new CliError(SIGN_IN_AGAIN_MESSAGE, 1);
|
|
102
|
+
}
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
throw new CliError(GENERIC_TOOL_TROUBLE, 1);
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const parsed = await response.json();
|
|
108
|
+
return parsed;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
throw new CliError(GENERIC_TOOL_TROUBLE, 1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// capForTerminal strips control and bidi bytes, then bounds the length.
|
|
115
|
+
// Deliberately unwrapped: a server message can end in a command
|
|
116
|
+
// (npx driggsby@latest login), and a hard wrap could split it; the terminal
|
|
117
|
+
// soft-wraps a long line and copy-paste stays intact.
|
|
118
|
+
function serverMessage(value) {
|
|
119
|
+
if (typeof value !== "string") {
|
|
120
|
+
return GENERIC_TOOL_TROUBLE;
|
|
121
|
+
}
|
|
122
|
+
const clean = sanitizeForTerminal(value).trim();
|
|
123
|
+
if (clean === "") {
|
|
124
|
+
return GENERIC_TOOL_TROUBLE;
|
|
125
|
+
}
|
|
126
|
+
return clean.length > MAX_SERVER_MESSAGE_CHARS ? `${capForTerminal(clean, MAX_SERVER_MESSAGE_CHARS - 1)}…` : clean;
|
|
127
|
+
}
|
|
128
|
+
function asRecord(value) {
|
|
129
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
130
|
+
? value
|
|
131
|
+
: null;
|
|
132
|
+
}
|
package/dist/terminal-text.js
CHANGED
|
@@ -32,3 +32,14 @@ export function wrapNames(names, indent) {
|
|
|
32
32
|
lines.push(line);
|
|
33
33
|
return lines.join("\n");
|
|
34
34
|
}
|
|
35
|
+
// JSON.stringify escapes C0 controls but passes C1 controls, bidi
|
|
36
|
+
// overrides, and invisible code points through raw; server data is hostile
|
|
37
|
+
// (a counterparty's name comes from a bank), so escape those too, the same
|
|
38
|
+
// class sanitizeForTerminal strips from prose. They can only sit inside JSON
|
|
39
|
+
// strings, so the output stays valid JSON that parses to the same value.
|
|
40
|
+
// Each UTF-16 unit is escaped on its own, so an astral code point (a tag
|
|
41
|
+
// character) becomes a valid surrogate-pair escape.
|
|
42
|
+
const TERMINAL_UNSAFE_IN_JSON = /[\u007f-\u009f\u2028\u2029\ufff9-\ufffb]|\p{Default_Ignorable_Code_Point}/gu;
|
|
43
|
+
export function terminalSafeJson(value) {
|
|
44
|
+
return JSON.stringify(value ?? null, null, 2).replace(TERMINAL_UNSAFE_IN_JSON, (match) => Array.from({ length: match.length }, (_, index) => `\\u${match.charCodeAt(index).toString(16).padStart(4, "0")}`).join(""));
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "driggsby",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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.4.0",
|
|
32
|
+
"@driggsby/sdk": "0.4.0"
|
|
33
33
|
}
|
|
34
34
|
}
|