total-recall-brain 3.31.0 → 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/frontend/dist/assets/index-BUEZPLim.js +5742 -0
- package/frontend/dist/assets/index-FOJG4jQh.js +5742 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/package.json +1 -1
- package/package.json +2 -2
- package/scaffold/.agent/skills/plugins/SKILL.md +5 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +33 -10
- package/src/cli/app/index.mjs +181 -1
- package/src/cli/command.mjs +269 -5
- package/src/cli/config.mjs +2 -1
- package/src/cli/connect.mjs +1 -1
- package/src/cli/daemon.mjs +3 -3
- package/src/cli/deploy-ui.mjs +5 -4
- package/src/cli/deploy.mjs +3 -2
- package/src/cli/init.mjs +61 -6
- package/src/cli/plugin/create.mjs +20 -10
- package/src/cli/repo-expert-generate.mjs +37 -3
- package/src/cli/reset-password.mjs +2 -1
- package/src/cli/skill-config.mjs +211 -0
- package/src/cli/skill.mjs +37 -2
- package/src/cli/uninstall.mjs +2 -1
- 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/project-brain.mjs +2 -1
- package/src/core/schema.mjs +72 -13
- package/src/core/secrets-store.mjs +5 -4
- package/src/core/secure-file.mjs +71 -0
- 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/core/webauthn-store.mjs +2 -1
- package/src/server/auth.mjs +3 -2
- package/src/server/index.mjs +2 -1
- package/src/server/routes/collab.mjs +7 -3
- package/src/server/routes/config.mjs +4 -3
- 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
|
@@ -2,15 +2,33 @@
|
|
|
2
2
|
* Total Recall — Design Token Parser & Validator
|
|
3
3
|
*
|
|
4
4
|
* Implements Phase 2B of CAPABILITY_DEPLOYMENT_PLUGINS:
|
|
5
|
-
* - Parses `DESIGN.md` YAML frontmatter
|
|
6
|
-
* - Resolves token references and compiles
|
|
7
|
-
* - Validates plugin UI
|
|
5
|
+
* - Parses `DESIGN.md` YAML frontmatter (google-labs-code/design.md format) into tokens
|
|
6
|
+
* - Resolves `{group.token}` references and compiles CSS custom properties (`:root { ... }`)
|
|
7
|
+
* - Validates plugin UI source: no hardcoded colors, and only declared token variables
|
|
8
|
+
*
|
|
9
|
+
* Variable names: colors → `--color-<name>`, spacing → `--spacing-<name>`,
|
|
10
|
+
* rounded → `--radius-<name>`, typography style props → `--typography-<style>-<prop>`,
|
|
11
|
+
* a `typography.fontFamily` map of families → `--font-family-<name>`, and primitive
|
|
12
|
+
* component props → `--component-<name>-<prop>`. Names are kebab-cased.
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
15
|
import fs from 'node:fs';
|
|
11
|
-
import
|
|
16
|
+
import { parse as parseYaml } from 'yaml';
|
|
17
|
+
|
|
18
|
+
export class DesignTokenError extends Error {
|
|
19
|
+
constructor(errors) {
|
|
20
|
+
super(`Invalid design tokens:\n- ${errors.join('\n- ')}`);
|
|
21
|
+
this.name = 'DesignTokenError';
|
|
22
|
+
this.errors = errors;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const TOKEN_KEY = /^[A-Za-z0-9_-]+$/;
|
|
27
|
+
// A value lands inside a generated stylesheet: nothing that can close the
|
|
28
|
+
// declaration, the rule, or an enclosing <style> element.
|
|
29
|
+
const UNSAFE_VALUE = /[;{}<>\\\r\n]/;
|
|
30
|
+
const TYPOGRAPHY_PROPS = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'fontFeature', 'fontVariation'];
|
|
12
31
|
|
|
13
|
-
// Matches kebab-case transformation
|
|
14
32
|
function toKebabCase(str) {
|
|
15
33
|
return str
|
|
16
34
|
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
@@ -18,55 +36,28 @@ function toKebabCase(str) {
|
|
|
18
36
|
.toLowerCase();
|
|
19
37
|
}
|
|
20
38
|
|
|
39
|
+
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
40
|
+
|
|
21
41
|
/**
|
|
22
42
|
* Extracts YAML frontmatter from a markdown string.
|
|
23
43
|
* @param {string} content
|
|
24
44
|
* @returns {object}
|
|
25
45
|
*/
|
|
26
46
|
export function extractFrontmatter(content) {
|
|
27
|
-
const match = content.match(
|
|
47
|
+
const match = String(content).match(/^?---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
|
|
28
48
|
if (!match) return {};
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const stack = [{ obj: result, indent: -1 }];
|
|
35
|
-
|
|
36
|
-
for (const line of lines) {
|
|
37
|
-
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
38
|
-
const indent = line.search(/\S/);
|
|
39
|
-
const trimmed = line.trim();
|
|
40
|
-
const colonIdx = trimmed.indexOf(':');
|
|
41
|
-
if (colonIdx === -1) continue;
|
|
42
|
-
|
|
43
|
-
const key = trimmed.slice(0, colonIdx).trim();
|
|
44
|
-
let val = trimmed.slice(colonIdx + 1).trim();
|
|
45
|
-
|
|
46
|
-
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
|
47
|
-
stack.pop();
|
|
48
|
-
}
|
|
49
|
-
const current = stack[stack.length - 1].obj;
|
|
50
|
-
|
|
51
|
-
if (val === '') {
|
|
52
|
-
current[key] = {};
|
|
53
|
-
stack.push({ obj: current[key], indent });
|
|
54
|
-
} else {
|
|
55
|
-
if (
|
|
56
|
-
(val.startsWith('"') && val.endsWith('"')) ||
|
|
57
|
-
(val.startsWith("'") && val.endsWith("'"))
|
|
58
|
-
) {
|
|
59
|
-
val = val.slice(1, -1);
|
|
60
|
-
}
|
|
61
|
-
current[key] = val;
|
|
62
|
-
}
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = parseYaml(match[1]);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
throw new DesignTokenError([`frontmatter is not valid YAML: ${err.message}`]);
|
|
63
54
|
}
|
|
64
|
-
|
|
65
|
-
return result;
|
|
55
|
+
return isPlainObject(parsed) ? parsed : {};
|
|
66
56
|
}
|
|
67
57
|
|
|
68
58
|
/**
|
|
69
59
|
* Resolves token references like "{colors.primary}" within values.
|
|
60
|
+
* An unresolvable or circular reference is left as written.
|
|
70
61
|
* @param {object} tokens
|
|
71
62
|
* @returns {object}
|
|
72
63
|
*/
|
|
@@ -80,25 +71,28 @@ export function resolveTokenReferences(tokens) {
|
|
|
80
71
|
return curr;
|
|
81
72
|
}
|
|
82
73
|
|
|
83
|
-
function resolveVal(val) {
|
|
74
|
+
function resolveVal(val, seen) {
|
|
84
75
|
if (typeof val !== 'string') return val;
|
|
76
|
+
const whole = val.match(/^\{([a-zA-Z0-9_.-]+)\}$/);
|
|
77
|
+
if (whole && !seen.has(whole[1])) {
|
|
78
|
+
const target = getNested(tokens, whole[1].split('.'));
|
|
79
|
+
// A whole-value reference may point at a composite (e.g. a typography style).
|
|
80
|
+
if (isPlainObject(target)) return walk(target, new Set([...seen, whole[1]]));
|
|
81
|
+
}
|
|
85
82
|
return val.replace(/\{([a-zA-Z0-9_.-]+)\}/g, (match, tokenPath) => {
|
|
86
|
-
|
|
87
|
-
const resolved = getNested(tokens,
|
|
88
|
-
|
|
83
|
+
if (seen.has(tokenPath)) return match;
|
|
84
|
+
const resolved = getNested(tokens, tokenPath.split('.'));
|
|
85
|
+
if (typeof resolved === 'string' || typeof resolved === 'number') {
|
|
86
|
+
return String(resolveVal(resolved, new Set([...seen, tokenPath])));
|
|
87
|
+
}
|
|
88
|
+
return match;
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
function walk(node) {
|
|
93
|
-
if (
|
|
92
|
+
function walk(node, seen = new Set()) {
|
|
93
|
+
if (!isPlainObject(node)) return resolveVal(node, seen);
|
|
94
94
|
const out = {};
|
|
95
|
-
for (const [k, v] of Object.entries(node))
|
|
96
|
-
if (typeof v === 'object' && v !== null) {
|
|
97
|
-
out[k] = walk(v);
|
|
98
|
-
} else {
|
|
99
|
-
out[k] = resolveVal(v);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
95
|
+
for (const [k, v] of Object.entries(node)) out[k] = walk(v, seen);
|
|
102
96
|
return out;
|
|
103
97
|
}
|
|
104
98
|
|
|
@@ -106,112 +100,158 @@ export function resolveTokenReferences(tokens) {
|
|
|
106
100
|
}
|
|
107
101
|
|
|
108
102
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* @
|
|
112
|
-
* @
|
|
103
|
+
* Lists the CSS custom properties a token set produces, in document order.
|
|
104
|
+
* @param {object} tokens - Parsed (resolved or not) design tokens
|
|
105
|
+
* @returns {Array<[string, string]>} [name without the leading `--`, value]
|
|
106
|
+
* @throws {DesignTokenError} on unsafe names or values, or unresolved references
|
|
113
107
|
*/
|
|
114
|
-
export function
|
|
108
|
+
export function collectCssVariables(tokens) {
|
|
109
|
+
const resolved = resolveTokenReferences(tokens || {});
|
|
115
110
|
const vars = [];
|
|
116
|
-
const
|
|
111
|
+
const errors = [];
|
|
112
|
+
const seen = new Map();
|
|
117
113
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
114
|
+
const add = (name, value, where) => {
|
|
115
|
+
if (typeof value !== 'string' && typeof value !== 'number') return;
|
|
116
|
+
const text = String(value).trim();
|
|
117
|
+
if (!text) return;
|
|
118
|
+
if (/\{[a-zA-Z0-9_.-]+\}/.test(text)) {
|
|
119
|
+
errors.push(`${where}: unresolved reference '${text}'`);
|
|
120
|
+
return;
|
|
124
121
|
}
|
|
125
|
-
|
|
122
|
+
if (UNSAFE_VALUE.test(text)) {
|
|
123
|
+
errors.push(`${where}: value contains a character not allowed in a CSS value`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (seen.has(name)) {
|
|
127
|
+
errors.push(`${where}: produces --${name}, already produced by ${seen.get(name)}`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
seen.set(name, where);
|
|
131
|
+
vars.push([name, text]);
|
|
132
|
+
};
|
|
133
|
+
const keysOf = (group, where) => Object.entries(group).filter(([key]) => {
|
|
134
|
+
if (TOKEN_KEY.test(key)) return true;
|
|
135
|
+
errors.push(`${where}.${key}: token names may use letters, digits, '-' and '_' only`);
|
|
136
|
+
return false;
|
|
137
|
+
});
|
|
126
138
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
139
|
+
if (isPlainObject(resolved.colors)) {
|
|
140
|
+
for (const [name, val] of keysOf(resolved.colors, 'colors')) add(`color-${toKebabCase(name)}`, val, `colors.${name}`);
|
|
141
|
+
}
|
|
142
|
+
if (isPlainObject(resolved.typography)) {
|
|
143
|
+
for (const [style, def] of keysOf(resolved.typography, 'typography')) {
|
|
144
|
+
if (!isPlainObject(def)) continue;
|
|
145
|
+
if (style === 'fontFamily') {
|
|
146
|
+
// A map of font families by role (heading, body, …), as some DESIGN.md files use.
|
|
147
|
+
for (const [name, val] of keysOf(def, 'typography.fontFamily')) {
|
|
148
|
+
add(`font-family-${toKebabCase(name)}`, val, `typography.fontFamily.${name}`);
|
|
133
149
|
}
|
|
150
|
+
continue;
|
|
134
151
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
// 3. Spacing
|
|
139
|
-
if (resolved.spacing) {
|
|
140
|
-
for (const [name, val] of Object.entries(resolved.spacing)) {
|
|
141
|
-
if (typeof val === 'string') {
|
|
142
|
-
vars.push(` --spacing-${toKebabCase(name)}: ${val};`);
|
|
152
|
+
for (const prop of TYPOGRAPHY_PROPS) {
|
|
153
|
+
add(`typography-${toKebabCase(style)}-${toKebabCase(prop)}`, def[prop], `typography.${style}.${prop}`);
|
|
143
154
|
}
|
|
144
155
|
}
|
|
145
156
|
}
|
|
146
|
-
|
|
147
|
-
|
|
157
|
+
if (isPlainObject(resolved.spacing)) {
|
|
158
|
+
for (const [name, val] of keysOf(resolved.spacing, 'spacing')) add(`spacing-${toKebabCase(name)}`, val, `spacing.${name}`);
|
|
159
|
+
}
|
|
148
160
|
const rounded = resolved.rounded || resolved.borderRadius;
|
|
149
|
-
if (rounded) {
|
|
150
|
-
for (const [name, val] of
|
|
151
|
-
|
|
152
|
-
|
|
161
|
+
if (isPlainObject(rounded)) {
|
|
162
|
+
for (const [name, val] of keysOf(rounded, 'rounded')) add(`radius-${toKebabCase(name)}`, val, `rounded.${name}`);
|
|
163
|
+
}
|
|
164
|
+
if (isPlainObject(resolved.components)) {
|
|
165
|
+
for (const [component, def] of keysOf(resolved.components, 'components')) {
|
|
166
|
+
if (!isPlainObject(def)) continue;
|
|
167
|
+
for (const [prop, val] of keysOf(def, `components.${component}`)) {
|
|
168
|
+
add(`component-${toKebabCase(component)}-${toKebabCase(prop)}`, val, `components.${component}.${prop}`);
|
|
153
169
|
}
|
|
154
170
|
}
|
|
155
171
|
}
|
|
156
172
|
|
|
173
|
+
if (errors.length) throw new DesignTokenError(errors);
|
|
174
|
+
return vars;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Compiles parsed tokens into standard CSS custom properties.
|
|
179
|
+
*
|
|
180
|
+
* @param {object} tokens - Parsed design tokens
|
|
181
|
+
* @returns {string} Compiled CSS block
|
|
182
|
+
*/
|
|
183
|
+
export function generateCssVariables(tokens) {
|
|
184
|
+
const vars = collectCssVariables(tokens).map(([name, value]) => ` --${name}: ${value};`);
|
|
157
185
|
return `:root {\n${vars.join('\n')}\n}\n`;
|
|
158
186
|
}
|
|
159
187
|
|
|
160
188
|
/**
|
|
161
189
|
* Parses DESIGN.md from a path or string and compiles to CSS variables.
|
|
162
190
|
* @param {string} source - Path to DESIGN.md or raw content
|
|
163
|
-
* @returns {{ tokens: object, css: string }}
|
|
191
|
+
* @returns {{ tokens: object, css: string, variables: string[] }}
|
|
164
192
|
*/
|
|
165
193
|
export function parseDesignTokens(source) {
|
|
166
194
|
let content = source;
|
|
167
|
-
if (fs.existsSync(source)) {
|
|
195
|
+
if (!/[\r\n]/.test(source) && fs.existsSync(source)) {
|
|
168
196
|
content = fs.readFileSync(source, 'utf8');
|
|
169
197
|
}
|
|
170
198
|
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
const css =
|
|
199
|
+
const resolved = resolveTokenReferences(extractFrontmatter(content));
|
|
200
|
+
const entries = collectCssVariables(resolved);
|
|
201
|
+
const css = `:root {\n${entries.map(([name, value]) => ` --${name}: ${value};`).join('\n')}\n}\n`;
|
|
174
202
|
|
|
175
203
|
return {
|
|
176
204
|
tokens: resolved,
|
|
177
|
-
css
|
|
205
|
+
css,
|
|
206
|
+
variables: entries.map(([name]) => name)
|
|
178
207
|
};
|
|
179
208
|
}
|
|
180
209
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
*/
|
|
185
|
-
const HARDCODED_COLOR_REGEX = /(?:color|background|border|fill|stroke)\s*:\s*(#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\()/i;
|
|
210
|
+
// A color-bearing declaration and the literal color forms that may not appear in its value.
|
|
211
|
+
const COLOR_DECLARATION = /\b(color|background(?:-color)?|border(?:-(?:top|right|bottom|left|block|inline))?(?:-color)?|outline(?:-color)?|fill|stroke|box-shadow|text-shadow|caret-color|accent-color|text-decoration(?:-color)?)\s*:\s*([^;}\n]*)/gi;
|
|
212
|
+
const LITERAL_COLOR = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(/;
|
|
186
213
|
|
|
187
214
|
/**
|
|
188
215
|
* Validates plugin UI source code against design token compliance.
|
|
189
|
-
* Rejects hardcoded
|
|
216
|
+
* Rejects hardcoded colors in color-bearing declarations (including var()
|
|
217
|
+
* fallbacks), and, when `allowedTokens` is given, any `var(--name)` whose name is
|
|
218
|
+
* not declared. Custom properties starting with `--_` are element-private.
|
|
190
219
|
*
|
|
191
220
|
* @param {string} uiSource - Source code of UI component (HTML, JSX, CSS, Web Component)
|
|
192
|
-
* @param {
|
|
193
|
-
* @returns {{ valid: boolean, violations: string[] }}
|
|
221
|
+
* @param {{ allowedTokens?: Iterable<string> }} [options]
|
|
222
|
+
* @returns {{ valid: boolean, violations: string[], usedTokens: string[] }}
|
|
194
223
|
*/
|
|
195
224
|
export function validatePluginUiAgainstTokens(uiSource, options = {}) {
|
|
196
225
|
const violations = [];
|
|
197
|
-
const
|
|
226
|
+
const used = new Set();
|
|
227
|
+
const allowed = options.allowedTokens ? new Set(options.allowedTokens) : null;
|
|
228
|
+
const lines = String(uiSource).split('\n');
|
|
198
229
|
|
|
199
230
|
for (let i = 0; i < lines.length; i++) {
|
|
200
231
|
const line = lines[i];
|
|
201
232
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// Allow var(--...) even if it has fallback or comments
|
|
205
|
-
if (!line.includes('var(--')) {
|
|
233
|
+
for (const match of line.matchAll(COLOR_DECLARATION)) {
|
|
234
|
+
if (LITERAL_COLOR.test(match[2])) {
|
|
206
235
|
violations.push(
|
|
207
236
|
`Line ${i + 1}: Hardcoded color value detected ('${line.trim()}'). Use design token CSS variable var(--color-...) instead.`
|
|
208
237
|
);
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const match of line.matchAll(/var\(\s*--([A-Za-z0-9_-]+)/g)) {
|
|
243
|
+
const name = match[1];
|
|
244
|
+
if (name.startsWith('_')) continue;
|
|
245
|
+
used.add(name);
|
|
246
|
+
if (allowed && !allowed.has(name)) {
|
|
247
|
+
violations.push(`Line ${i + 1}: var(--${name}) is not a declared design token.`);
|
|
209
248
|
}
|
|
210
249
|
}
|
|
211
250
|
}
|
|
212
251
|
|
|
213
252
|
return {
|
|
214
253
|
valid: violations.length === 0,
|
|
215
|
-
violations
|
|
254
|
+
violations,
|
|
255
|
+
usedTokens: [...used]
|
|
216
256
|
};
|
|
217
257
|
}
|
|
@@ -14,7 +14,7 @@ import fs from 'node:fs';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import crypto from 'node:crypto';
|
|
16
16
|
import { resolveCapabilitySource } from './source.mjs';
|
|
17
|
-
import { resolveCapabilityGraph } from './resolve.mjs';
|
|
17
|
+
import { resolveCapabilityGraph, detectSsssVersion } from './resolve.mjs';
|
|
18
18
|
|
|
19
19
|
export const PROTECTED_APP_FILES = new Set([
|
|
20
20
|
'package.json',
|
|
@@ -187,7 +187,7 @@ export async function createDeploymentPlan(source, options = {}) {
|
|
|
187
187
|
targetApp: {
|
|
188
188
|
dir: targetDir,
|
|
189
189
|
framework: adapter,
|
|
190
|
-
ssssVersion:
|
|
190
|
+
ssssVersion: detectSsssVersion(targetDir)
|
|
191
191
|
},
|
|
192
192
|
pluginResolver: options.pluginResolver
|
|
193
193
|
});
|
|
@@ -10,7 +10,30 @@
|
|
|
10
10
|
* - Access grant and resource requirement aggregation
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import fs from 'node:fs';
|
|
13
14
|
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const SSSS_PACKAGES = ['@gregiteen/ssss-cli', '@ssss/cli'];
|
|
18
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
19
|
+
|
|
20
|
+
function installedSsssVersion(root) {
|
|
21
|
+
for (const name of SSSS_PACKAGES) {
|
|
22
|
+
try {
|
|
23
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'node_modules', name, 'package.json'), 'utf8'));
|
|
24
|
+
if (pkg.version) return pkg.version;
|
|
25
|
+
} catch { /* not installed here */ }
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The SSSS version a target app runs: its installed SSSS CLI package, else the
|
|
32
|
+
* one bundled with Total Recall (what `ssss new` would scaffold today).
|
|
33
|
+
*/
|
|
34
|
+
export function detectSsssVersion(targetDir) {
|
|
35
|
+
return (targetDir && installedSsssVersion(targetDir)) || installedSsssVersion(PACKAGE_ROOT);
|
|
36
|
+
}
|
|
14
37
|
|
|
15
38
|
/**
|
|
16
39
|
* Custom error types for capability resolution
|
|
@@ -110,7 +133,12 @@ export function checkSsssVersionCompatibility(manifest, targetSsssVersion) {
|
|
|
110
133
|
const req = manifest.deploy?.required_ssss_version;
|
|
111
134
|
if (!req) return;
|
|
112
135
|
|
|
113
|
-
const currentVersion = targetSsssVersion ||
|
|
136
|
+
const currentVersion = targetSsssVersion || detectSsssVersion();
|
|
137
|
+
if (!currentVersion) {
|
|
138
|
+
throw new IncompatibleSsssVersionError(
|
|
139
|
+
`Plugin '${manifest.id}' requires SSSS version ${req}, but no SSSS CLI package is installed in the target app or Total Recall`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
114
142
|
if (!satisfiesVersionConstraint(currentVersion, req)) {
|
|
115
143
|
throw new IncompatibleSsssVersionError(
|
|
116
144
|
`Plugin '${manifest.id}' requires SSSS version ${req}, but target app environment provides ${currentVersion}`
|
|
@@ -138,7 +166,7 @@ export function checkSsssVersionCompatibility(manifest, targetSsssVersion) {
|
|
|
138
166
|
*/
|
|
139
167
|
export async function resolveCapabilityGraph(rootDescriptor, options = {}) {
|
|
140
168
|
const adapter = options.adapter || options.targetApp?.framework || 'ssss-app';
|
|
141
|
-
const targetSsssVersion = options.targetApp?.ssssVersion ||
|
|
169
|
+
const targetSsssVersion = options.targetApp?.ssssVersion || detectSsssVersion(options.targetApp?.dir);
|
|
142
170
|
const pluginResolver = options.pluginResolver || (async () => null);
|
|
143
171
|
|
|
144
172
|
const graph = new Map(); // id -> descriptor
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// __HEADER__
|
|
3
|
+
//
|
|
4
|
+
// Exit codes: 0 ok · 1 the app answered with an error · 2 usage · 3 the app is unreachable.
|
|
5
|
+
|
|
6
|
+
const SPEC = __SPEC__;
|
|
7
|
+
|
|
8
|
+
const EXIT = { OK: 0, HTTP: 1, USAGE: 2, UNREACHABLE: 3 };
|
|
9
|
+
const flagOf = (name) => `--${name.replace(/_/g, '-')}`;
|
|
10
|
+
|
|
11
|
+
class UsageError extends Error {}
|
|
12
|
+
|
|
13
|
+
function usage(command) {
|
|
14
|
+
if (!command) {
|
|
15
|
+
const lines = [`Usage: ${SPEC.name} <command> [options]`, ''];
|
|
16
|
+
if (SPEC.description) lines.push(SPEC.description, '');
|
|
17
|
+
lines.push('Commands:');
|
|
18
|
+
for (const c of SPEC.commands) lines.push(` ${c.name.padEnd(20)} ${c.description || ''}`);
|
|
19
|
+
lines.push(
|
|
20
|
+
'',
|
|
21
|
+
'Global options:',
|
|
22
|
+
' --json Print a JSON envelope: { ok, status, data | error }',
|
|
23
|
+
` --base-url <url> App API base URL (default: $${SPEC.api.base_url_env} or ${SPEC.api.default_base_url})`,
|
|
24
|
+
' -h, --help Show help; with a command, that command\'s options',
|
|
25
|
+
);
|
|
26
|
+
return lines.join('\n');
|
|
27
|
+
}
|
|
28
|
+
const positionals = command.args.filter((a) => a.positional).map((a) => `<${a.name}>`);
|
|
29
|
+
const lines = [`Usage: ${SPEC.name} ${[command.name, ...positionals].join(' ')} [options]`, ''];
|
|
30
|
+
if (command.description) lines.push(command.description, '');
|
|
31
|
+
for (const a of command.args) {
|
|
32
|
+
const label = a.positional ? `<${a.name}>` : a.type === 'boolean' ? flagOf(a.name) : `${flagOf(a.name)} <${a.type}>`;
|
|
33
|
+
const notes = [
|
|
34
|
+
a.required && !a.positional ? 'required' : '',
|
|
35
|
+
a.choices ? `one of: ${a.choices.join(', ')}` : '',
|
|
36
|
+
a.default !== undefined ? `default: ${a.default}` : '',
|
|
37
|
+
].filter(Boolean).join('; ');
|
|
38
|
+
lines.push(` ${label.padEnd(26)} ${a.description || ''}${notes ? ` (${notes})` : ''}`);
|
|
39
|
+
}
|
|
40
|
+
return lines.join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function coerce(arg, raw) {
|
|
44
|
+
let value = raw;
|
|
45
|
+
if (arg.type === 'integer') {
|
|
46
|
+
if (!/^[-+]?\d+$/.test(raw.trim())) throw new UsageError(`${arg.name} must be an integer, got '${raw}'`);
|
|
47
|
+
value = Number(raw);
|
|
48
|
+
} else if (arg.type === 'number') {
|
|
49
|
+
value = Number(raw);
|
|
50
|
+
if (raw.trim() === '' || !Number.isFinite(value)) throw new UsageError(`${arg.name} must be a number, got '${raw}'`);
|
|
51
|
+
}
|
|
52
|
+
if (arg.choices && !arg.choices.includes(value)) {
|
|
53
|
+
throw new UsageError(`${arg.name} must be one of: ${arg.choices.join(', ')}`);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parse(argv) {
|
|
59
|
+
const global = { json: false, baseUrl: null, help: false };
|
|
60
|
+
const rest = [];
|
|
61
|
+
for (let i = 0; i < argv.length; i++) {
|
|
62
|
+
const token = argv[i];
|
|
63
|
+
if (token === '--json') global.json = true;
|
|
64
|
+
else if (token === '-h' || token === '--help') global.help = true;
|
|
65
|
+
else if (token.startsWith('--base-url=')) global.baseUrl = token.slice('--base-url='.length);
|
|
66
|
+
else if (token === '--base-url') {
|
|
67
|
+
if (argv[i + 1] === undefined) throw new UsageError('--base-url needs a value');
|
|
68
|
+
global.baseUrl = argv[++i];
|
|
69
|
+
} else rest.push(token);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const [name, ...tokens] = rest;
|
|
73
|
+
if (!name) return { global, command: null, values: {} };
|
|
74
|
+
const command = SPEC.commands.find((c) => c.name === name);
|
|
75
|
+
if (!command) throw new UsageError(`unknown command '${name}'`);
|
|
76
|
+
if (global.help) return { global, command, values: {} };
|
|
77
|
+
|
|
78
|
+
const values = {};
|
|
79
|
+
const positionals = command.args.filter((a) => a.positional);
|
|
80
|
+
let next = 0;
|
|
81
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
82
|
+
const token = tokens[i];
|
|
83
|
+
if (token.startsWith('--')) {
|
|
84
|
+
const eq = token.indexOf('=');
|
|
85
|
+
const flag = eq === -1 ? token : token.slice(0, eq);
|
|
86
|
+
const inline = eq === -1 ? undefined : token.slice(eq + 1);
|
|
87
|
+
const arg = command.args.find((a) => !a.positional && flagOf(a.name) === flag);
|
|
88
|
+
if (!arg) throw new UsageError(`unknown option ${flag} for '${name}'`);
|
|
89
|
+
if (arg.type === 'boolean') {
|
|
90
|
+
if (inline !== undefined) throw new UsageError(`${flag} takes no value`);
|
|
91
|
+
values[arg.name] = true;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const raw = inline !== undefined ? inline : tokens[++i];
|
|
95
|
+
if (raw === undefined) throw new UsageError(`${flag} needs a value`);
|
|
96
|
+
values[arg.name] = coerce(arg, raw);
|
|
97
|
+
} else {
|
|
98
|
+
const arg = positionals[next++];
|
|
99
|
+
if (!arg) throw new UsageError(`unexpected argument '${token}'`);
|
|
100
|
+
values[arg.name] = coerce(arg, token);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const arg of command.args) {
|
|
104
|
+
if (values[arg.name] === undefined && arg.default !== undefined) values[arg.name] = arg.default;
|
|
105
|
+
if (values[arg.name] === undefined && (arg.required || arg.positional)) {
|
|
106
|
+
throw new UsageError(`missing ${arg.positional ? `<${arg.name}>` : flagOf(arg.name)}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { global, command, values };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildRequest(command, values, baseUrl) {
|
|
113
|
+
let base;
|
|
114
|
+
try {
|
|
115
|
+
base = new URL(baseUrl);
|
|
116
|
+
} catch {
|
|
117
|
+
throw new UsageError(`invalid base URL '${baseUrl}'`);
|
|
118
|
+
}
|
|
119
|
+
if (base.protocol !== 'http:' && base.protocol !== 'https:') throw new UsageError(`base URL must be http(s): '${baseUrl}'`);
|
|
120
|
+
|
|
121
|
+
const used = new Set();
|
|
122
|
+
const pathname = command.path.replace(/\{([a-z][a-z0-9_]*)\}/g, (_, key) => {
|
|
123
|
+
used.add(key);
|
|
124
|
+
return encodeURIComponent(String(values[key]));
|
|
125
|
+
});
|
|
126
|
+
const url = new URL(base.href.replace(/\/+$/, '') + pathname);
|
|
127
|
+
const rest = Object.entries(values).filter(([key]) => !used.has(key));
|
|
128
|
+
const init = { method: command.method, headers: { accept: 'application/json', 'user-agent': `${SPEC.name}-cli` } };
|
|
129
|
+
if (command.method === 'GET' || command.method === 'DELETE') {
|
|
130
|
+
for (const [key, value] of rest) url.searchParams.append(key, String(value));
|
|
131
|
+
} else if (rest.length) {
|
|
132
|
+
init.headers['content-type'] = 'application/json';
|
|
133
|
+
init.body = JSON.stringify(Object.fromEntries(rest));
|
|
134
|
+
}
|
|
135
|
+
const token = SPEC.auth ? process.env[SPEC.auth.token_env] : undefined;
|
|
136
|
+
if (token) init.headers.authorization = `Bearer ${token}`;
|
|
137
|
+
return { url, init };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function errorText(data, response) {
|
|
141
|
+
if (data && typeof data === 'object') {
|
|
142
|
+
const detail = data.error ?? data.message;
|
|
143
|
+
if (detail !== undefined) return typeof detail === 'string' ? detail : JSON.stringify(detail);
|
|
144
|
+
}
|
|
145
|
+
if (typeof data === 'string' && data.trim()) return data.trim().slice(0, 500);
|
|
146
|
+
return response.statusText || `HTTP ${response.status}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function fail(json, status, error) {
|
|
150
|
+
if (json) console.log(JSON.stringify({ ok: false, status, error }));
|
|
151
|
+
else console.error(`${SPEC.name}: ${error}${status ? ` (HTTP ${status})` : ''}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function main() {
|
|
155
|
+
let parsed;
|
|
156
|
+
try {
|
|
157
|
+
parsed = parse(process.argv.slice(2));
|
|
158
|
+
} catch (err) {
|
|
159
|
+
if (!(err instanceof UsageError)) throw err;
|
|
160
|
+
console.error(`${SPEC.name}: ${err.message}\nRun '${SPEC.name} --help' for usage.`);
|
|
161
|
+
return EXIT.USAGE;
|
|
162
|
+
}
|
|
163
|
+
const { global, command, values } = parsed;
|
|
164
|
+
if (!command) {
|
|
165
|
+
console.log(usage());
|
|
166
|
+
return global.help ? EXIT.OK : EXIT.USAGE;
|
|
167
|
+
}
|
|
168
|
+
if (global.help) {
|
|
169
|
+
console.log(usage(command));
|
|
170
|
+
return EXIT.OK;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const baseUrl = global.baseUrl || process.env[SPEC.api.base_url_env] || SPEC.api.default_base_url;
|
|
174
|
+
let request;
|
|
175
|
+
try {
|
|
176
|
+
request = buildRequest(command, values, baseUrl);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (!(err instanceof UsageError)) throw err;
|
|
179
|
+
console.error(`${SPEC.name}: ${err.message}`);
|
|
180
|
+
return EXIT.USAGE;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let response;
|
|
184
|
+
try {
|
|
185
|
+
response = await fetch(request.url, { ...request.init, signal: AbortSignal.timeout(SPEC.api.timeout_seconds * 1000) });
|
|
186
|
+
} catch (err) {
|
|
187
|
+
const reason = err?.name === 'TimeoutError' ? `no answer within ${SPEC.api.timeout_seconds}s` : (err?.cause?.code || err?.message || String(err));
|
|
188
|
+
fail(global.json, null, `cannot reach ${request.url.origin}: ${reason}`);
|
|
189
|
+
return EXIT.UNREACHABLE;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const text = await response.text();
|
|
193
|
+
let data = text;
|
|
194
|
+
try {
|
|
195
|
+
data = text ? JSON.parse(text) : null;
|
|
196
|
+
} catch { /* not JSON: keep the text */ }
|
|
197
|
+
|
|
198
|
+
if (!response.ok) {
|
|
199
|
+
fail(global.json, response.status, errorText(data, response));
|
|
200
|
+
return EXIT.HTTP;
|
|
201
|
+
}
|
|
202
|
+
if (global.json) console.log(JSON.stringify({ ok: true, status: response.status, data }));
|
|
203
|
+
else if (typeof data === 'string') console.log(data);
|
|
204
|
+
else if (data !== null) console.log(JSON.stringify(data, null, 2));
|
|
205
|
+
return EXIT.OK;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
main().then(
|
|
209
|
+
(code) => { process.exitCode = code; },
|
|
210
|
+
(err) => { console.error(err?.stack || String(err)); process.exitCode = 1; },
|
|
211
|
+
);
|