total-recall-brain 3.31.2 → 3.32.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/bin/total-recall.mjs +2 -2
- package/package.json +2 -2
- package/scaffold/.agent/skills/plugins/SKILL.md +5 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +33 -3
- package/src/cli/app/index.mjs +181 -1
- package/src/cli/command.mjs +269 -5
- package/src/cli/connect.mjs +1 -1
- package/src/cli/daemon.mjs +3 -3
- package/src/cli/init.mjs +54 -0
- package/src/cli/plugin/create.mjs +20 -10
- package/src/cli/repo-expert-generate.mjs +37 -3
- package/src/cli/skill-config.mjs +211 -0
- package/src/cli/skill.mjs +37 -2
- package/src/core/app-deploy/adapter-contract.mjs +176 -0
- package/src/core/app-deploy/design-tokens.mjs +148 -108
- package/src/core/app-deploy/plan.mjs +2 -2
- package/src/core/app-deploy/resolve.mjs +30 -2
- package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +211 -0
- package/src/core/app-deploy/runtime-cli/python.py.tmpl +178 -0
- package/src/core/app-deploy/runtime-cli.mjs +254 -0
- package/src/core/app-deploy/ui-elements.mjs +473 -0
- package/src/core/app-deploy/verify.mjs +18 -0
- package/src/core/command-surface.mjs +129 -0
- package/src/core/daemon-control.mjs +31 -12
- package/src/core/daemon-loop.mjs +14 -7
- package/src/core/plugin-loader.mjs +12 -12
- package/src/core/schema.mjs +72 -13
- package/src/core/skill-config.mjs +376 -0
- package/src/core/skills-registry.mjs +147 -31
- package/src/core/surface.mjs +52 -7
- package/src/server/routes/system.mjs +1 -1
- package/plugins/code-quality/README.md +0 -25
- package/plugins/code-quality/cli.mjs +0 -25
- package/plugins/code-quality/plugin.json +0 -17
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# __HEADER__
|
|
3
|
+
#
|
|
4
|
+
# Exit codes: 0 ok · 1 the app answered with an error · 2 usage · 3 the app is unreachable.
|
|
5
|
+
# Standard library only.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
|
|
19
|
+
SPEC = json.loads(__SPEC__)
|
|
20
|
+
|
|
21
|
+
EXIT_OK, EXIT_HTTP, EXIT_USAGE, EXIT_UNREACHABLE = 0, 1, 2, 3
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def flag_of(name: str) -> str:
|
|
25
|
+
return "--" + name.replace("_", "-")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def integer(raw: str) -> int:
|
|
29
|
+
if not re.fullmatch(r"[-+]?\d+", raw.strip()):
|
|
30
|
+
raise argparse.ArgumentTypeError(f"must be an integer, got '{raw}'")
|
|
31
|
+
return int(raw)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def number(raw: str) -> float:
|
|
35
|
+
try:
|
|
36
|
+
value = float(raw)
|
|
37
|
+
except ValueError:
|
|
38
|
+
raise argparse.ArgumentTypeError(f"must be a number, got '{raw}'") from None
|
|
39
|
+
if not math.isfinite(value):
|
|
40
|
+
raise argparse.ArgumentTypeError(f"must be a number, got '{raw}'")
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
TYPES = {"string": str, "integer": integer, "number": number}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def add_global_options(parser: argparse.ArgumentParser, top: bool) -> None:
|
|
48
|
+
# Accepted before or after the command. On a subcommand they only apply
|
|
49
|
+
# when given, so they never overwrite the top-level value.
|
|
50
|
+
extra = {} if top else {"default": argparse.SUPPRESS}
|
|
51
|
+
parser.add_argument("--json", action="store_true", help="Print a JSON envelope: { ok, status, data | error }", **extra)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--base-url",
|
|
54
|
+
help=f"App API base URL (default: ${SPEC['api']['base_url_env']} or {SPEC['api']['default_base_url']})",
|
|
55
|
+
**extra,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
60
|
+
parser = argparse.ArgumentParser(prog=SPEC["name"], description=SPEC.get("description"))
|
|
61
|
+
add_global_options(parser, top=True)
|
|
62
|
+
commands = parser.add_subparsers(dest="command", metavar="<command>")
|
|
63
|
+
for command in SPEC["commands"]:
|
|
64
|
+
sub = commands.add_parser(command["name"], help=command.get("description"), description=command.get("description"))
|
|
65
|
+
add_global_options(sub, top=False)
|
|
66
|
+
for arg in command["args"]:
|
|
67
|
+
options = {"help": arg.get("description")}
|
|
68
|
+
if arg["type"] == "boolean":
|
|
69
|
+
options.update(action="store_const", const=True, default=arg.get("default"))
|
|
70
|
+
else:
|
|
71
|
+
options["type"] = TYPES[arg["type"]]
|
|
72
|
+
if "choices" in arg:
|
|
73
|
+
options["choices"] = arg["choices"]
|
|
74
|
+
if arg.get("positional"):
|
|
75
|
+
sub.add_argument(arg["name"], **options)
|
|
76
|
+
else:
|
|
77
|
+
if arg["type"] != "boolean":
|
|
78
|
+
options.update(required=bool(arg.get("required")), default=arg.get("default"), metavar=f"<{arg['type']}>")
|
|
79
|
+
sub.add_argument(flag_of(arg["name"]), dest=arg["name"], **options)
|
|
80
|
+
return parser
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def query_value(value) -> str:
|
|
84
|
+
if isinstance(value, bool):
|
|
85
|
+
return "true" if value else "false"
|
|
86
|
+
if isinstance(value, float) and value.is_integer():
|
|
87
|
+
return str(int(value))
|
|
88
|
+
return str(value)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def error_text(data, status: int, reason: str) -> str:
|
|
92
|
+
if isinstance(data, dict):
|
|
93
|
+
detail = data.get("error", data.get("message"))
|
|
94
|
+
if detail is not None:
|
|
95
|
+
return detail if isinstance(detail, str) else json.dumps(detail)
|
|
96
|
+
if isinstance(data, str) and data.strip():
|
|
97
|
+
return data.strip()[:500]
|
|
98
|
+
return reason or f"HTTP {status}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def fail(as_json: bool, status, error: str) -> None:
|
|
102
|
+
if as_json:
|
|
103
|
+
print(json.dumps({"ok": False, "status": status, "error": error}))
|
|
104
|
+
else:
|
|
105
|
+
print(f"{SPEC['name']}: {error}" + (f" (HTTP {status})" if status else ""), file=sys.stderr)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main(argv: list[str] | None = None) -> int:
|
|
109
|
+
parser = build_parser()
|
|
110
|
+
args = parser.parse_args(argv)
|
|
111
|
+
if not args.command:
|
|
112
|
+
parser.print_help()
|
|
113
|
+
return EXIT_USAGE
|
|
114
|
+
|
|
115
|
+
command = next(c for c in SPEC["commands"] if c["name"] == args.command)
|
|
116
|
+
values = {a["name"]: getattr(args, a["name"]) for a in command["args"] if getattr(args, a["name"], None) is not None}
|
|
117
|
+
|
|
118
|
+
base_url = args.base_url or os.environ.get(SPEC["api"]["base_url_env"]) or SPEC["api"]["default_base_url"]
|
|
119
|
+
base = urllib.parse.urlsplit(base_url)
|
|
120
|
+
if base.scheme not in ("http", "https") or not base.netloc:
|
|
121
|
+
print(f"{SPEC['name']}: base URL must be http(s): '{base_url}'", file=sys.stderr)
|
|
122
|
+
return EXIT_USAGE
|
|
123
|
+
|
|
124
|
+
used = set()
|
|
125
|
+
|
|
126
|
+
def fill(match: re.Match) -> str:
|
|
127
|
+
used.add(match.group(1))
|
|
128
|
+
return urllib.parse.quote(str(values[match.group(1)]), safe="")
|
|
129
|
+
|
|
130
|
+
url = base_url.rstrip("/") + re.sub(r"\{([a-z][a-z0-9_]*)\}", fill, command["path"])
|
|
131
|
+
rest = {key: value for key, value in values.items() if key not in used}
|
|
132
|
+
headers = {"Accept": "application/json", "User-Agent": f"{SPEC['name']}-cli"}
|
|
133
|
+
body = None
|
|
134
|
+
if command["method"] in ("GET", "DELETE"):
|
|
135
|
+
if rest:
|
|
136
|
+
url += ("&" if "?" in url else "?") + urllib.parse.urlencode([(k, query_value(v)) for k, v in rest.items()])
|
|
137
|
+
elif rest:
|
|
138
|
+
headers["Content-Type"] = "application/json"
|
|
139
|
+
body = json.dumps(rest).encode("utf-8")
|
|
140
|
+
token = os.environ.get(SPEC["auth"]["token_env"]) if SPEC.get("auth") else None
|
|
141
|
+
if token:
|
|
142
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
143
|
+
|
|
144
|
+
request = urllib.request.Request(url, data=body, headers=headers, method=command["method"])
|
|
145
|
+
origin = f"{base.scheme}://{base.netloc}"
|
|
146
|
+
try:
|
|
147
|
+
with urllib.request.urlopen(request, timeout=SPEC["api"]["timeout_seconds"]) as response:
|
|
148
|
+
status, reason, ok = response.status, response.reason, True
|
|
149
|
+
text = response.read().decode("utf-8", "replace")
|
|
150
|
+
except urllib.error.HTTPError as err:
|
|
151
|
+
status, reason, ok = err.code, err.reason, False
|
|
152
|
+
text = err.read().decode("utf-8", "replace")
|
|
153
|
+
except TimeoutError:
|
|
154
|
+
fail(args.json, None, f"cannot reach {origin}: no answer within {SPEC['api']['timeout_seconds']}s")
|
|
155
|
+
return EXIT_UNREACHABLE
|
|
156
|
+
except (urllib.error.URLError, OSError) as err:
|
|
157
|
+
fail(args.json, None, f"cannot reach {origin}: {getattr(err, 'reason', err)}")
|
|
158
|
+
return EXIT_UNREACHABLE
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
data = json.loads(text) if text else None
|
|
162
|
+
except ValueError:
|
|
163
|
+
data = text
|
|
164
|
+
|
|
165
|
+
if not ok:
|
|
166
|
+
fail(args.json, status, error_text(data, status, reason))
|
|
167
|
+
return EXIT_HTTP
|
|
168
|
+
if args.json:
|
|
169
|
+
print(json.dumps({"ok": True, "status": status, "data": data}))
|
|
170
|
+
elif isinstance(data, str):
|
|
171
|
+
print(data)
|
|
172
|
+
elif data is not None:
|
|
173
|
+
print(json.dumps(data, indent=2))
|
|
174
|
+
return EXIT_OK
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
sys.exit(main())
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-runtime CLI generation.
|
|
3
|
+
*
|
|
4
|
+
* A capability plugin declares the command-line interface its app exposes as
|
|
5
|
+
* data (`app_cli` in plugin.json). This module validates that spec and renders
|
|
6
|
+
* one self-contained file per language: a Node ESM script or a Python argparse
|
|
7
|
+
* module, in the manner of a hand-written app CLI such as a Flask app's own
|
|
8
|
+
* `crm.py`. The generated file embeds the validated spec and a fixed runtime,
|
|
9
|
+
* talks only to the app's HTTP API, and imports nothing from Total Recall, so
|
|
10
|
+
* it keeps working where Total Recall is not installed.
|
|
11
|
+
*
|
|
12
|
+
* Generation is deterministic and never writes: callers decide where the file
|
|
13
|
+
* goes.
|
|
14
|
+
*/
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const TEMPLATE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'runtime-cli');
|
|
21
|
+
const TEMPLATES = { node: 'node.mjs.tmpl', python: 'python.py.tmpl' };
|
|
22
|
+
|
|
23
|
+
export const APP_CLI_LANGUAGES = Object.freeze(Object.keys(TEMPLATES));
|
|
24
|
+
export const ADAPTER_LANGUAGES = Object.freeze({ 'ssss-app': 'node', nextjs: 'node', react: 'node', flask: 'python' });
|
|
25
|
+
|
|
26
|
+
const NAME = /^[a-z][a-z0-9-]{0,63}$/;
|
|
27
|
+
const ARG_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
28
|
+
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
29
|
+
const PATH_CHARS = /^\/[A-Za-z0-9_\-./{}]*$/;
|
|
30
|
+
const PLACEHOLDER = /\{([^{}]*)\}/g;
|
|
31
|
+
const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
|
32
|
+
const TYPES = new Set(['string', 'integer', 'number', 'boolean']);
|
|
33
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
34
|
+
// Taken by the generated runtime's own options and parser.
|
|
35
|
+
const RESERVED_ARGS = new Set(['json', 'base_url', 'help', 'command']);
|
|
36
|
+
|
|
37
|
+
const SPEC_KEYS = new Set(['name', 'description', 'api', 'auth', 'commands']);
|
|
38
|
+
const API_KEYS = new Set(['base_url_env', 'default_base_url', 'timeout_seconds']);
|
|
39
|
+
const AUTH_KEYS = new Set(['type', 'token_env']);
|
|
40
|
+
const COMMAND_KEYS = new Set(['name', 'description', 'method', 'path', 'args']);
|
|
41
|
+
const ARG_KEYS = new Set(['name', 'type', 'description', 'required', 'positional', 'choices', 'default']);
|
|
42
|
+
|
|
43
|
+
export class AppCliSpecError extends Error {
|
|
44
|
+
constructor(errors) {
|
|
45
|
+
super(`Invalid app_cli spec:\n ${errors.join('\n ')}`);
|
|
46
|
+
this.name = 'AppCliSpecError';
|
|
47
|
+
this.errors = errors;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
52
|
+
|
|
53
|
+
function unknownKeys(value, allowed, where, errors) {
|
|
54
|
+
for (const key of Object.keys(value)) {
|
|
55
|
+
if (!allowed.has(key)) errors.push(`${where}: unknown field '${key}'`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function optionalText(value, where, errors) {
|
|
60
|
+
if (value !== undefined && (typeof value !== 'string' || !value.trim())) errors.push(`${where} must be a non-empty string`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function matchesType(type, value) {
|
|
64
|
+
if (type === 'string') return typeof value === 'string';
|
|
65
|
+
if (type === 'integer') return Number.isInteger(value);
|
|
66
|
+
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
|
|
67
|
+
return typeof value === 'boolean';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeArg(arg, where, errors) {
|
|
71
|
+
if (!isObject(arg)) {
|
|
72
|
+
errors.push(`${where} must be an object`);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
unknownKeys(arg, ARG_KEYS, where, errors);
|
|
76
|
+
if (typeof arg.name !== 'string' || !ARG_NAME.test(arg.name)) errors.push(`${where}.name must match ${ARG_NAME}`);
|
|
77
|
+
else if (RESERVED_ARGS.has(arg.name)) errors.push(`${where}.name '${arg.name}' is reserved by the generated CLI`);
|
|
78
|
+
if (!TYPES.has(arg.type)) errors.push(`${where}.type must be one of ${[...TYPES].join(', ')}`);
|
|
79
|
+
optionalText(arg.description, `${where}.description`, errors);
|
|
80
|
+
for (const flag of ['required', 'positional']) {
|
|
81
|
+
if (arg[flag] !== undefined && typeof arg[flag] !== 'boolean') errors.push(`${where}.${flag} must be a boolean`);
|
|
82
|
+
}
|
|
83
|
+
if (arg.type === 'boolean' && arg.positional) errors.push(`${where}: a boolean cannot be positional`);
|
|
84
|
+
if (arg.type === 'boolean' && arg.required) errors.push(`${where}: a boolean flag cannot be required`);
|
|
85
|
+
if (arg.choices !== undefined) {
|
|
86
|
+
if (arg.type === 'boolean') errors.push(`${where}: a boolean cannot have choices`);
|
|
87
|
+
else if (!Array.isArray(arg.choices) || !arg.choices.length || !arg.choices.every((c) => matchesType(arg.type, c))) {
|
|
88
|
+
errors.push(`${where}.choices must be a non-empty array of ${arg.type} values`);
|
|
89
|
+
} else if (new Set(arg.choices).size !== arg.choices.length) errors.push(`${where}.choices has duplicates`);
|
|
90
|
+
}
|
|
91
|
+
if (arg.default !== undefined) {
|
|
92
|
+
if (arg.positional) errors.push(`${where}: a positional argument cannot have a default`);
|
|
93
|
+
if (arg.required) errors.push(`${where}: a required argument cannot have a default`);
|
|
94
|
+
if (!matchesType(arg.type, arg.default)) errors.push(`${where}.default must be a ${arg.type}`);
|
|
95
|
+
else if (Array.isArray(arg.choices) && !arg.choices.includes(arg.default)) errors.push(`${where}.default must be one of its choices`);
|
|
96
|
+
}
|
|
97
|
+
const out = { name: arg.name, type: arg.type };
|
|
98
|
+
if (arg.description) out.description = arg.description;
|
|
99
|
+
if (arg.positional) out.positional = true;
|
|
100
|
+
if (arg.required || arg.positional) out.required = true;
|
|
101
|
+
if (arg.choices !== undefined) out.choices = arg.choices;
|
|
102
|
+
if (arg.default !== undefined) out.default = arg.default;
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeCommand(command, where, errors) {
|
|
107
|
+
if (!isObject(command)) {
|
|
108
|
+
errors.push(`${where} must be an object`);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
unknownKeys(command, COMMAND_KEYS, where, errors);
|
|
112
|
+
if (typeof command.name !== 'string' || !NAME.test(command.name)) errors.push(`${where}.name must match ${NAME}`);
|
|
113
|
+
optionalText(command.description, `${where}.description`, errors);
|
|
114
|
+
if (!METHODS.has(command.method)) errors.push(`${where}.method must be one of ${[...METHODS].join(', ')}`);
|
|
115
|
+
|
|
116
|
+
const rawArgs = command.args === undefined ? [] : command.args;
|
|
117
|
+
if (!Array.isArray(rawArgs)) errors.push(`${where}.args must be an array`);
|
|
118
|
+
const args = (Array.isArray(rawArgs) ? rawArgs : []).map((arg, i) => normalizeArg(arg, `${where}.args[${i}]`, errors)).filter(Boolean);
|
|
119
|
+
const names = args.map((a) => a.name);
|
|
120
|
+
if (new Set(names).size !== names.length) errors.push(`${where}.args has duplicate names`);
|
|
121
|
+
|
|
122
|
+
if (typeof command.path !== 'string' || !PATH_CHARS.test(command.path) || command.path.split('/').includes('..')) {
|
|
123
|
+
errors.push(`${where}.path must start with '/' and use only letters, digits, '-', '_', '.', '/' and {placeholders}, without '..'`);
|
|
124
|
+
} else {
|
|
125
|
+
const stripped = command.path.replace(PLACEHOLDER, '');
|
|
126
|
+
if (stripped.includes('{') || stripped.includes('}')) errors.push(`${where}.path has an unbalanced brace`);
|
|
127
|
+
for (const [, key] of command.path.matchAll(PLACEHOLDER)) {
|
|
128
|
+
const arg = args.find((a) => a.name === key);
|
|
129
|
+
if (!arg) errors.push(`${where}.path placeholder {${key}} has no matching arg`);
|
|
130
|
+
else if (!arg.required) errors.push(`${where}.path placeholder {${key}} must be a required or positional arg`);
|
|
131
|
+
else if (arg.type === 'boolean') errors.push(`${where}.path placeholder {${key}} cannot be a boolean`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const out = { name: command.name, method: command.method, path: command.path, args };
|
|
136
|
+
if (command.description) out.description = command.description;
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Validate an `app_cli` spec and return its normalized form: known fields
|
|
142
|
+
* only, defaults filled in. Throws AppCliSpecError listing every problem.
|
|
143
|
+
*/
|
|
144
|
+
export function normalizeAppCliSpec(spec) {
|
|
145
|
+
const errors = [];
|
|
146
|
+
if (!isObject(spec)) throw new AppCliSpecError(['app_cli must be an object']);
|
|
147
|
+
unknownKeys(spec, SPEC_KEYS, 'app_cli', errors);
|
|
148
|
+
if (typeof spec.name !== 'string' || !NAME.test(spec.name)) errors.push(`app_cli.name must match ${NAME}`);
|
|
149
|
+
optionalText(spec.description, 'app_cli.description', errors);
|
|
150
|
+
|
|
151
|
+
const api = { timeout_seconds: 30 };
|
|
152
|
+
if (!isObject(spec.api)) {
|
|
153
|
+
errors.push('app_cli.api must be an object with base_url_env and default_base_url');
|
|
154
|
+
} else {
|
|
155
|
+
unknownKeys(spec.api, API_KEYS, 'app_cli.api', errors);
|
|
156
|
+
if (typeof spec.api.base_url_env !== 'string' || !ENV_NAME.test(spec.api.base_url_env)) errors.push(`app_cli.api.base_url_env must match ${ENV_NAME}`);
|
|
157
|
+
else api.base_url_env = spec.api.base_url_env;
|
|
158
|
+
// The published default only ever points at the local app. Anything else is
|
|
159
|
+
// the operator's to configure through the environment or --base-url.
|
|
160
|
+
let base = null;
|
|
161
|
+
try {
|
|
162
|
+
base = new URL(spec.api.default_base_url);
|
|
163
|
+
} catch { /* reported below */ }
|
|
164
|
+
if (!base || !['http:', 'https:'].includes(base.protocol) || !LOOPBACK.has(base.hostname) || base.username || base.password || base.search || base.hash) {
|
|
165
|
+
errors.push('app_cli.api.default_base_url must be an http(s) loopback URL (127.0.0.1, localhost or [::1]) without credentials, query or fragment');
|
|
166
|
+
} else api.default_base_url = spec.api.default_base_url.replace(/\/+$/, '');
|
|
167
|
+
if (spec.api.timeout_seconds !== undefined) {
|
|
168
|
+
if (!Number.isInteger(spec.api.timeout_seconds) || spec.api.timeout_seconds < 1 || spec.api.timeout_seconds > 600) {
|
|
169
|
+
errors.push('app_cli.api.timeout_seconds must be an integer from 1 to 600');
|
|
170
|
+
} else api.timeout_seconds = spec.api.timeout_seconds;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let auth;
|
|
175
|
+
if (spec.auth !== undefined) {
|
|
176
|
+
if (!isObject(spec.auth)) errors.push('app_cli.auth must be an object');
|
|
177
|
+
else {
|
|
178
|
+
unknownKeys(spec.auth, AUTH_KEYS, 'app_cli.auth', errors);
|
|
179
|
+
if (spec.auth.type !== 'bearer') errors.push("app_cli.auth.type must be 'bearer'");
|
|
180
|
+
if (typeof spec.auth.token_env !== 'string' || !ENV_NAME.test(spec.auth.token_env)) errors.push(`app_cli.auth.token_env must match ${ENV_NAME}`);
|
|
181
|
+
auth = { type: 'bearer', token_env: spec.auth.token_env };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let commands = [];
|
|
186
|
+
if (!Array.isArray(spec.commands) || !spec.commands.length) errors.push('app_cli.commands must be a non-empty array');
|
|
187
|
+
else {
|
|
188
|
+
commands = spec.commands.map((c, i) => normalizeCommand(c, `app_cli.commands[${i}]`, errors)).filter(Boolean);
|
|
189
|
+
const names = commands.map((c) => c.name);
|
|
190
|
+
if (new Set(names).size !== names.length) errors.push('app_cli.commands has duplicate names');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (errors.length) throw new AppCliSpecError(errors);
|
|
194
|
+
const out = { name: spec.name, api };
|
|
195
|
+
if (spec.description) out.description = spec.description;
|
|
196
|
+
if (auth) out.auth = auth;
|
|
197
|
+
out.commands = commands;
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Errors for an `app_cli` spec, or [] when it is valid. Used by the manifest validator. */
|
|
202
|
+
export function validateAppCliSpec(spec) {
|
|
203
|
+
try {
|
|
204
|
+
normalizeAppCliSpec(spec);
|
|
205
|
+
return [];
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if (err instanceof AppCliSpecError) return err.errors;
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function languageForAdapter(adapter) {
|
|
213
|
+
const language = ADAPTER_LANGUAGES[adapter];
|
|
214
|
+
if (!language) {
|
|
215
|
+
throw new Error(`No app CLI language for adapter '${adapter}'. Known adapters: ${Object.keys(ADAPTER_LANGUAGES).join(', ')}`);
|
|
216
|
+
}
|
|
217
|
+
return language;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Where the generated file conventionally lives in the app: `bin/<name>.mjs` or `<name>.py`. */
|
|
221
|
+
export function appCliPath(name, language) {
|
|
222
|
+
return language === 'python' ? `${name.replace(/-/g, '_')}.py` : `bin/${name}.mjs`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Render the app CLI for one language.
|
|
227
|
+
* @param {object} spec The manifest's `app_cli`.
|
|
228
|
+
* @param {{ language: 'node'|'python', source?: { id: string, version: string } }} options
|
|
229
|
+
* @returns {{ language: string, path: string, content: string, mode: number, sha256: string, spec: object }}
|
|
230
|
+
*/
|
|
231
|
+
export function generateAppCli(spec, { language, source } = {}) {
|
|
232
|
+
if (!TEMPLATES[language]) throw new Error(`Unknown app CLI language '${language}'. Use one of: ${APP_CLI_LANGUAGES.join(', ')}`);
|
|
233
|
+
const normalized = normalizeAppCliSpec(spec);
|
|
234
|
+
const specJson = JSON.stringify(normalized);
|
|
235
|
+
const specHash = crypto.createHash('sha256').update(specJson).digest('hex');
|
|
236
|
+
const origin = source?.id ? ` from plugin ${source.id}${source.version ? `@${source.version}` : ''}` : '';
|
|
237
|
+
const header = `Generated by Total Recall${origin} (app_cli sha256 ${specHash.slice(0, 12)}). Regenerate instead of editing; it needs no Total Recall install.`;
|
|
238
|
+
// JSON text is a valid JS expression; for Python it is embedded as a JSON
|
|
239
|
+
// string literal (itself valid Python) and decoded with json.loads.
|
|
240
|
+
const specLiteral = language === 'python' ? JSON.stringify(specJson) : JSON.stringify(normalized, null, 2);
|
|
241
|
+
const template = fs.readFileSync(path.join(TEMPLATE_DIR, TEMPLATES[language]), 'utf8');
|
|
242
|
+
if (template.split('__SPEC__').length !== 2 || template.split('__HEADER__').length !== 2) {
|
|
243
|
+
throw new Error(`App CLI template ${TEMPLATES[language]} must contain __SPEC__ and __HEADER__ exactly once`);
|
|
244
|
+
}
|
|
245
|
+
const content = template.replace('__HEADER__', () => header).replace('__SPEC__', () => specLiteral);
|
|
246
|
+
return {
|
|
247
|
+
language,
|
|
248
|
+
path: appCliPath(normalized.name, language),
|
|
249
|
+
content,
|
|
250
|
+
mode: 0o755,
|
|
251
|
+
sha256: crypto.createHash('sha256').update(content).digest('hex'),
|
|
252
|
+
spec: normalized,
|
|
253
|
+
};
|
|
254
|
+
}
|