total-recall-brain 3.31.2 → 3.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/total-recall.mjs +20 -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/api.mjs +133 -0
- package/src/cli/app/index.mjs +181 -1
- package/src/cli/brief.mjs +195 -0
- package/src/cli/command.mjs +269 -5
- package/src/cli/config.mjs +25 -7
- package/src/cli/connect.mjs +1 -1
- package/src/cli/daemon.mjs +3 -3
- package/src/cli/group.mjs +151 -0
- package/src/cli/init.mjs +54 -0
- package/src/cli/plugin/create.mjs +20 -10
- package/src/cli/project-id.mjs +43 -0
- package/src/cli/repo-expert-generate.mjs +37 -3
- package/src/cli/secret.mjs +36 -3
- package/src/cli/skill-config.mjs +211 -0
- package/src/cli/skill.mjs +37 -2
- package/src/core/api-routes.mjs +99 -0
- 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-id.mjs +72 -0
- package/src/core/provider-catalog.mjs +1 -1
- package/src/core/schema.mjs +72 -13
- package/src/core/secrets-env-export.mjs +3 -4
- package/src/core/secrets-store.mjs +30 -29
- package/src/core/skill-config.mjs +376 -0
- package/src/core/skills-registry.mjs +147 -31
- package/src/core/source-adapters.mjs +2 -2
- package/src/core/surface.mjs +109 -16
- package/src/server/routes/help.mjs +1 -1
- 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
|
@@ -9,7 +9,11 @@ import { agentDir, brainDir } from './config.mjs';
|
|
|
9
9
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
10
|
const ROOT = path.join(__dirname, '..', '..');
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// The daemon's own lockfile (daemon-loop acquirePidLock). start/stop/status read the
|
|
13
|
+
// same file: a separate logs/daemon.pid let `daemon stop` miss the live daemon and
|
|
14
|
+
// leave it running old code alongside a newly started one.
|
|
15
|
+
export const DAEMON_PID_FILE = path.join(brainDir, 'daemon.pid');
|
|
16
|
+
const PID_FILE = DAEMON_PID_FILE;
|
|
13
17
|
const LOG_FILE = path.join(brainDir, 'logs', 'daemon.log');
|
|
14
18
|
|
|
15
19
|
/**
|
|
@@ -104,8 +108,8 @@ export function startDaemon() {
|
|
|
104
108
|
|
|
105
109
|
child.unref();
|
|
106
110
|
|
|
107
|
-
//
|
|
108
|
-
|
|
111
|
+
// The daemon writes DAEMON_PID_FILE itself when it acquires its lock;
|
|
112
|
+
// pre-writing the child's pid here made the child see a live lock and exit.
|
|
109
113
|
fs.closeSync(logFd);
|
|
110
114
|
|
|
111
115
|
logger.info('daemon-control', `Active Intelligence Daemon started detached (PID ${child.pid}).`);
|
|
@@ -113,10 +117,13 @@ export function startDaemon() {
|
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
/**
|
|
116
|
-
* Stops the running background daemon.
|
|
117
|
-
*
|
|
120
|
+
* Stops the running background daemon and waits for the process to exit.
|
|
121
|
+
* The daemon owns its lockfile and removes it on exit, so a start issued right
|
|
122
|
+
* after stop can never run alongside a daemon that is still finishing work.
|
|
123
|
+
* @param {{ graceMs?: number, pollMs?: number }} [opts]
|
|
124
|
+
* @returns {Promise<boolean>} True if a daemon was running and has exited.
|
|
118
125
|
*/
|
|
119
|
-
export function stopDaemon() {
|
|
126
|
+
export async function stopDaemon({ graceMs = 35_000, pollMs = 250 } = {}) {
|
|
120
127
|
const pid = readPid();
|
|
121
128
|
if (!pid) {
|
|
122
129
|
// Clear PID file in case it was a stale/dead record
|
|
@@ -126,19 +133,31 @@ export function stopDaemon() {
|
|
|
126
133
|
return false;
|
|
127
134
|
}
|
|
128
135
|
|
|
136
|
+
const alive = () => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
129
137
|
try {
|
|
130
138
|
process.kill(pid, 'SIGTERM');
|
|
131
139
|
logger.info('daemon-control', `Sent SIGTERM to daemon PID ${pid}.`);
|
|
132
|
-
|
|
133
|
-
// Wait briefly and verify cleanup
|
|
134
|
-
try {
|
|
135
|
-
if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
|
|
136
|
-
} catch {}
|
|
137
|
-
return true;
|
|
138
140
|
} catch (err) {
|
|
139
141
|
logger.error('daemon-control', `Failed to terminate daemon PID ${pid}: ${err.message}`);
|
|
140
142
|
return false;
|
|
141
143
|
}
|
|
144
|
+
|
|
145
|
+
const deadline = Date.now() + graceMs;
|
|
146
|
+
while (alive() && Date.now() < deadline) {
|
|
147
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
148
|
+
}
|
|
149
|
+
if (alive()) {
|
|
150
|
+
logger.warn('daemon-control', `Daemon PID ${pid} still running after ${graceMs}ms — sending SIGKILL.`);
|
|
151
|
+
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// A SIGKILLed daemon cannot run its exit handler; drop its lock if still ours.
|
|
155
|
+
try {
|
|
156
|
+
if (fs.existsSync(PID_FILE) && parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10) === pid) {
|
|
157
|
+
fs.unlinkSync(PID_FILE);
|
|
158
|
+
}
|
|
159
|
+
} catch {}
|
|
160
|
+
return true;
|
|
142
161
|
}
|
|
143
162
|
|
|
144
163
|
/**
|
package/src/core/daemon-loop.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import path from 'path';
|
|
15
|
+
import { DAEMON_PID_FILE } from './daemon-control.mjs';
|
|
15
16
|
import fs from 'fs';
|
|
16
17
|
import { fileURLToPath } from 'node:url';
|
|
17
18
|
import { readProcessCommand, entryPathHint, shouldHonorPidLock } from './pid-lock.mjs';
|
|
@@ -102,7 +103,7 @@ export function writeInterrupt(message) {
|
|
|
102
103
|
|
|
103
104
|
// ─── PID Lockfile ───────────────────────────────────────────────────────────────
|
|
104
105
|
|
|
105
|
-
const PID_FILE =
|
|
106
|
+
const PID_FILE = DAEMON_PID_FILE;
|
|
106
107
|
|
|
107
108
|
/**
|
|
108
109
|
* Attempt to acquire the PID lockfile. If another daemon is alive, exit.
|
|
@@ -112,7 +113,7 @@ export function acquirePidLock() {
|
|
|
112
113
|
try {
|
|
113
114
|
if (fs.existsSync(PID_FILE)) {
|
|
114
115
|
const existingPid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
|
|
115
|
-
if (existingPid && !isNaN(existingPid)) {
|
|
116
|
+
if (existingPid && !isNaN(existingPid) && existingPid !== process.pid) {
|
|
116
117
|
// Liveness alone cannot tell our daemon from whatever else inherited
|
|
117
118
|
// the number after a reboot — observed here holding a PID that had
|
|
118
119
|
// since been handed to an unrelated desktop app, which would refuse
|
|
@@ -218,14 +219,20 @@ function shutdown(signal) {
|
|
|
218
219
|
});
|
|
219
220
|
})
|
|
220
221
|
.finally(() => {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
// Lock already gone — nothing to release.
|
|
225
|
-
}
|
|
222
|
+
// Keep the PID lock until the process really exits (see the 'exit'
|
|
223
|
+
// handler): releasing it here let a new daemon start while this one was
|
|
224
|
+
// still finishing its current task.
|
|
226
225
|
});
|
|
227
226
|
}
|
|
228
227
|
|
|
228
|
+
process.on('exit', () => {
|
|
229
|
+
try {
|
|
230
|
+
releasePidLock();
|
|
231
|
+
} catch {
|
|
232
|
+
// Lock already gone — nothing to release.
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
229
236
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
230
237
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
231
238
|
|
|
@@ -2,6 +2,8 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { validateAppCliSpec } from './app-deploy/runtime-cli.mjs';
|
|
6
|
+
import { validateUiSpec } from './app-deploy/ui-elements.mjs';
|
|
5
7
|
|
|
6
8
|
const ID_PATTERN = /^[a-z][a-z0-9-]{1,63}$/;
|
|
7
9
|
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
@@ -226,27 +228,25 @@ export function validatePluginManifest(manifest) {
|
|
|
226
228
|
errors.push("Each entry in 'commands' must be an object");
|
|
227
229
|
continue;
|
|
228
230
|
}
|
|
229
|
-
if (
|
|
230
|
-
errors.push("Each command entry must have a
|
|
231
|
+
if (typeof c.name !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(c.name)) {
|
|
232
|
+
errors.push("Each command entry must have a safe lowercase kebab-case 'name'");
|
|
231
233
|
}
|
|
232
234
|
if (!c.handler || !isSafeRelativePath(c.handler)) {
|
|
233
235
|
errors.push(`Command '${c.name || 'unknown'}' has invalid or unsafe 'handler': must be safe relative path`);
|
|
234
236
|
}
|
|
237
|
+
if (c.background !== undefined && typeof c.background !== 'boolean') {
|
|
238
|
+
errors.push(`Command '${c.name || 'unknown'}' background must be boolean`);
|
|
239
|
+
}
|
|
235
240
|
}
|
|
236
241
|
}
|
|
237
242
|
}
|
|
238
243
|
|
|
239
244
|
if (manifest.ui !== undefined) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
if (manifest.ui.elements !== undefined && !Array.isArray(manifest.ui.elements)) {
|
|
247
|
-
errors.push("'ui.elements' must be an array");
|
|
248
|
-
}
|
|
249
|
-
}
|
|
245
|
+
errors.push(...validateUiSpec(manifest.ui));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (manifest.app_cli !== undefined) {
|
|
249
|
+
errors.push(...validateAppCliSpec(manifest.app_cli));
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
if (manifest.openwiki_hubs !== undefined) {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable per-project identity: a random UUID kept in the project brain's
|
|
3
|
+
* config/brain.json (`project_id`) and mirrored into the project registry.
|
|
4
|
+
* Never derived from names or paths.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
function brainJsonPath(brainDir) {
|
|
11
|
+
return path.join(brainDir, 'config', 'brain.json');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readJson(file, fallback) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
17
|
+
} catch {
|
|
18
|
+
return fallback;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeJsonAtomic(file, data) {
|
|
23
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
24
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
25
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
26
|
+
fs.renameSync(tmp, file);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The project id stored for a project brain, or null. */
|
|
30
|
+
export function readProjectId(brainDir) {
|
|
31
|
+
return readJson(brainJsonPath(brainDir), {}).project_id || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Return the project's id, creating a random UUID on first use.
|
|
36
|
+
* @returns {{ project_id: string, created: boolean }}
|
|
37
|
+
*/
|
|
38
|
+
export function ensureProjectId(brainDir) {
|
|
39
|
+
const file = brainJsonPath(brainDir);
|
|
40
|
+
const cfg = readJson(file, {});
|
|
41
|
+
if (cfg.project_id) return { project_id: cfg.project_id, created: false };
|
|
42
|
+
cfg.project_id = randomUUID();
|
|
43
|
+
writeJsonAtomic(file, cfg);
|
|
44
|
+
return { project_id: cfg.project_id, created: true };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Give every registered project an id and make the registry agree with each
|
|
49
|
+
* project's brain.json (the brain.json id wins).
|
|
50
|
+
* @returns {{ total: number, created: number, synced: number, missing_brain: number }}
|
|
51
|
+
*/
|
|
52
|
+
export function repairProjectIds(globalBrainDir) {
|
|
53
|
+
const registryPath = path.join(globalBrainDir, 'config', 'project-registry.json');
|
|
54
|
+
const list = readJson(registryPath, []);
|
|
55
|
+
const out = { total: 0, created: 0, synced: 0, missing_brain: 0 };
|
|
56
|
+
if (!Array.isArray(list)) return out;
|
|
57
|
+
for (const row of list) {
|
|
58
|
+
out.total++;
|
|
59
|
+
if (!row?.brainDir || !fs.existsSync(row.brainDir)) {
|
|
60
|
+
out.missing_brain++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const { project_id, created } = ensureProjectId(row.brainDir);
|
|
64
|
+
if (created) out.created++;
|
|
65
|
+
if (row.project_id !== project_id) {
|
|
66
|
+
row.project_id = project_id;
|
|
67
|
+
out.synced++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (out.synced) writeJsonAtomic(registryPath, list);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
@@ -464,7 +464,7 @@ function matchesOnTokenBoundary(k, stem) {
|
|
|
464
464
|
export function providerForKeyName(keyName) {
|
|
465
465
|
// Strip packaging prefixes so DEVELOPER_BRAVE_SEARCH_API_KEY → BRAVE_SEARCH_API_KEY
|
|
466
466
|
let k = String(keyName || '').toUpperCase();
|
|
467
|
-
k = k.replace(/^(DEVELOPER_|VITE_|NEXT_PUBLIC_|PUBLIC_|NUXT_PUBLIC_
|
|
467
|
+
k = k.replace(/^(DEVELOPER_|VITE_|NEXT_PUBLIC_|PUBLIC_|NUXT_PUBLIC_)+/g, '');
|
|
468
468
|
|
|
469
469
|
// Tier 1 — exact pattern match.
|
|
470
470
|
for (const p of PROVIDER_CATALOG) {
|
package/src/core/schema.mjs
CHANGED
|
@@ -231,6 +231,63 @@ export const ConversationSchema = z.object({
|
|
|
231
231
|
created_at: ssssDatetime().optional(),
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
+
// SSSS 0.10 core primitives (spec §5.4). `role` and `thread` are the canonical
|
|
235
|
+
// forms of `security_role` and `conversation`, which stay for existing vaults.
|
|
236
|
+
export const AssetSchema = z.object({
|
|
237
|
+
type: z.literal('asset'),
|
|
238
|
+
name: z.string(),
|
|
239
|
+
mime_type: z.string(),
|
|
240
|
+
encoding: z.enum(['base64', 'utf-8', 'none']),
|
|
241
|
+
slug: z.string().optional(),
|
|
242
|
+
hash: z.string().optional(),
|
|
243
|
+
size_bytes: z.number().int().nonnegative().optional(),
|
|
244
|
+
description: z.string().optional(),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
export const ResourceSchema = z.object({
|
|
248
|
+
type: z.literal('resource'),
|
|
249
|
+
name: z.string(),
|
|
250
|
+
kind: z.string(),
|
|
251
|
+
status: z.enum(['bound', 'unbound', 'pending', 'error']),
|
|
252
|
+
slug: z.string().optional(),
|
|
253
|
+
description: z.string().optional(),
|
|
254
|
+
binds: z.unknown().optional(),
|
|
255
|
+
provisioning: z.unknown().optional(),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
export const RoleSchema = z.object({
|
|
259
|
+
type: z.literal('role'),
|
|
260
|
+
name: z.string(),
|
|
261
|
+
permissions: z.array(z.string()),
|
|
262
|
+
description: z.string().optional(),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
export const TriggerSchema = z.object({
|
|
266
|
+
type: z.literal('trigger'),
|
|
267
|
+
name: z.string(),
|
|
268
|
+
source: z.enum(['cron', 'interval', 'webhook', 'event', 'file_change', 'condition', 'manual']),
|
|
269
|
+
status: z.enum(['active', 'paused', 'disabled']),
|
|
270
|
+
slug: z.string().optional(),
|
|
271
|
+
description: z.string().optional(),
|
|
272
|
+
cron: z.string().optional(),
|
|
273
|
+
interval: z.union([z.string(), z.number()]).optional(),
|
|
274
|
+
timezone: z.string().optional(),
|
|
275
|
+
target_workflow: z.string().optional(),
|
|
276
|
+
target_params: z.record(z.unknown()).optional(),
|
|
277
|
+
concurrency: z.enum(['allow_parallel', 'skip_if_running', 'enqueue', 'replace']).optional(),
|
|
278
|
+
misfire_policy: z.enum(['skip', 'run_once', 'catch_up']).optional(),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
export const ThreadSchema = z.object({
|
|
282
|
+
type: z.literal('thread'),
|
|
283
|
+
thread_id: z.string(),
|
|
284
|
+
workspace_id: z.string().optional(),
|
|
285
|
+
user_id: z.string().optional(),
|
|
286
|
+
status: z.enum(['active', 'archived', 'closed']).optional(),
|
|
287
|
+
turn_count: z.number().int().optional(),
|
|
288
|
+
created_at: ssssDatetime().optional(),
|
|
289
|
+
});
|
|
290
|
+
|
|
234
291
|
export const RunSchema = z.object({
|
|
235
292
|
type: z.literal('run'),
|
|
236
293
|
run_id: z.string(),
|
|
@@ -1162,25 +1219,22 @@ export const AccessGrantSchema = z.object({
|
|
|
1162
1219
|
}).passthrough();
|
|
1163
1220
|
|
|
1164
1221
|
/**
|
|
1165
|
-
* Canonical repo-layer skill
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1222
|
+
* Canonical repo-layer configuration for a layered skill, stored in the repo's
|
|
1223
|
+
* project vault at `system/skills/<skill_id>.md` and written only through the
|
|
1224
|
+
* operation service (`src/core/skill-config.mjs`). The shape of `config` is the
|
|
1225
|
+
* skill core's contract (`core/config.schema.json`), not this host type, so no
|
|
1226
|
+
* skill-specific fields belong here. `config.json` beside the skill is a
|
|
1227
|
+
* projection of `config`, rebuilt after every write.
|
|
1169
1228
|
*/
|
|
1170
1229
|
export const SkillConfigSchema = z.object({
|
|
1171
1230
|
type: z.literal('skill_config'),
|
|
1172
1231
|
title: z.string().min(1),
|
|
1173
1232
|
description: z.string().min(1),
|
|
1174
1233
|
timestamp: ssssDatetime(),
|
|
1175
|
-
skill_id: z.string().regex(/^[a-z][a-z0-9-]{
|
|
1176
|
-
|
|
1177
|
-
config
|
|
1178
|
-
|
|
1179
|
-
gates: z.array(z.object({
|
|
1180
|
-
name: z.string(),
|
|
1181
|
-
command: z.string(),
|
|
1182
|
-
tier: z.string().optional()
|
|
1183
|
-
})).optional().default([]),
|
|
1234
|
+
skill_id: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/),
|
|
1235
|
+
config: z.record(z.string(), z.any()),
|
|
1236
|
+
/** SHA-256 of the core/config.schema.json the config was validated against. */
|
|
1237
|
+
schema_sha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
1184
1238
|
created_at: ssssDatetime(),
|
|
1185
1239
|
updated_at: ssssDatetime().optional()
|
|
1186
1240
|
}).passthrough();
|
|
@@ -1221,6 +1275,11 @@ export const SSSS_SCHEMAS = {
|
|
|
1221
1275
|
security_role: SecurityRoleSchema,
|
|
1222
1276
|
model: ModelSchema,
|
|
1223
1277
|
conversation: ConversationSchema,
|
|
1278
|
+
asset: AssetSchema,
|
|
1279
|
+
resource: ResourceSchema,
|
|
1280
|
+
role: RoleSchema,
|
|
1281
|
+
trigger: TriggerSchema,
|
|
1282
|
+
thread: ThreadSchema,
|
|
1224
1283
|
run: RunSchema,
|
|
1225
1284
|
primitive: PrimitiveSchema,
|
|
1226
1285
|
proposal: ProposalSchema,
|
|
@@ -67,7 +67,8 @@ export function mergeEnvManagedBlock(existing, managedBody) {
|
|
|
67
67
|
export function projectSlugFromPath(projectPath) {
|
|
68
68
|
if (!projectPath) return null;
|
|
69
69
|
const abs = path.resolve(projectPath);
|
|
70
|
-
|
|
70
|
+
// Bindings are stored trimmed, so a directory name with stray whitespace must match.
|
|
71
|
+
return path.basename(abs).trim();
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
/**
|
|
@@ -107,9 +108,7 @@ export function secretMatchesTarget(metaRow, target = {}) {
|
|
|
107
108
|
// Explicit key list mode
|
|
108
109
|
if (target.keys?.length) return true;
|
|
109
110
|
|
|
110
|
-
// Bound to this repo by slug or path (
|
|
111
|
-
// Multi-repo keys are invalid — do not project them until fixed
|
|
112
|
-
if (repos.length > 1) return false;
|
|
111
|
+
// Bound to this repo by slug or path (a key may be bound to several repos)
|
|
113
112
|
if (slug && repos.some((r) => r === slug || r === '*' || r.endsWith(`/${slug}`))) {
|
|
114
113
|
return true;
|
|
115
114
|
}
|
|
@@ -319,15 +319,14 @@ function appendAudit(brainDir, event) {
|
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
/**
|
|
322
|
-
* Parse repos field.
|
|
323
|
-
*
|
|
322
|
+
* Parse repos field. A credential may be bound to zero (developer/tooling),
|
|
323
|
+
* one, or several repos; uniqueness per repo is not required.
|
|
324
324
|
*
|
|
325
325
|
* @param {string|string[]|null|undefined} v
|
|
326
|
-
* @param {
|
|
326
|
+
* @param {object} [_opts] - accepted for compatibility; there is no limit
|
|
327
327
|
* @returns {string[]}
|
|
328
328
|
*/
|
|
329
|
-
export function normalizeReposBinding(v,
|
|
330
|
-
const strict = opts.strict !== false;
|
|
329
|
+
export function normalizeReposBinding(v, _opts = {}) {
|
|
331
330
|
let list = [];
|
|
332
331
|
if (Array.isArray(v)) {
|
|
333
332
|
list = [...new Set(v.map(String).map((s) => s.trim()).filter(Boolean))];
|
|
@@ -341,18 +340,21 @@ export function normalizeReposBinding(v, opts = {}) {
|
|
|
341
340
|
),
|
|
342
341
|
];
|
|
343
342
|
}
|
|
344
|
-
if (strict && list.length > 1) {
|
|
345
|
-
throw new Error(`A credential can be bound to at most ONE repo. Got ${list.length}: ${list.join(', ')}`);
|
|
346
|
-
}
|
|
347
343
|
return list;
|
|
348
344
|
}
|
|
349
345
|
|
|
350
346
|
/**
|
|
351
|
-
*
|
|
347
|
+
* Multi-repo binding is allowed, so this is never a violation. Kept because
|
|
348
|
+
* callers still read the field.
|
|
352
349
|
*/
|
|
353
|
-
export function isMultiRepoViolation(
|
|
354
|
-
|
|
355
|
-
|
|
350
|
+
export function isMultiRepoViolation(_meta = {}) {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Lowercase, de-duplicated tag list from an array or comma/space separated string. */
|
|
355
|
+
export function normalizeTags(v) {
|
|
356
|
+
const raw = Array.isArray(v) ? v : String(v ?? '').split(/[,;\s]+/);
|
|
357
|
+
return [...new Set(raw.map((t) => String(t).trim().toLowerCase()).filter(Boolean))];
|
|
356
358
|
}
|
|
357
359
|
|
|
358
360
|
/**
|
|
@@ -370,8 +372,9 @@ export function mergeSecretMeta(prev = {}, patch = {}) {
|
|
|
370
372
|
assign('scope', (v) => (v === 'project' ? 'project' : 'global'));
|
|
371
373
|
assign('provider', (v) => (v ? String(v).toLowerCase() : null));
|
|
372
374
|
assign('label', (v) => (v == null ? null : String(v)));
|
|
373
|
-
//
|
|
374
|
-
assign('repos', (v) => normalizeReposBinding(v
|
|
375
|
+
// empty = Developer secrets
|
|
376
|
+
assign('repos', (v) => normalizeReposBinding(v));
|
|
377
|
+
assign('tags', normalizeTags);
|
|
375
378
|
assign('subscription_tier', (v) => (v == null || v === '' ? null : String(v)));
|
|
376
379
|
assign('monthly_cost_usd', (v) => (v == null || v === '' ? null : Number(v)));
|
|
377
380
|
assign('monthly_cap_usd', (v) => (v == null || v === '' ? null : Number(v)));
|
|
@@ -478,19 +481,13 @@ export function buildSharedValueIndex(rows = []) {
|
|
|
478
481
|
const multiKey = keys.length > 1;
|
|
479
482
|
// Error unless every member is operator-waived
|
|
480
483
|
const allOk = members.every((m) => m.shared_value_ok === true);
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
error = `SHARED CREDENTIAL across apps/repos [${apps.join(', ')}] as keys [${keys.join(', ')}]. Issue a unique API key per app and re-bind.`;
|
|
489
|
-
} else {
|
|
490
|
-
// Same app/developer but duplicated under multiple secret names — still force unique
|
|
491
|
-
severity = 'error';
|
|
492
|
-
error = `SHARED CREDENTIAL value stored under ${keys.length} secret names [${keys.join(', ')}] (app=${apps[0]}). Use one key name or issue distinct values per purpose.`;
|
|
493
|
-
}
|
|
484
|
+
// Sharing a value across keys or repos is allowed; it is reported for
|
|
485
|
+
// information only and never counts as unhealthy.
|
|
486
|
+
void allOk;
|
|
487
|
+
const severity = 'info';
|
|
488
|
+
const error = multiApp
|
|
489
|
+
? `Shared value across [${apps.join(', ')}] as keys [${keys.join(', ')}] (allowed).`
|
|
490
|
+
: `Same value stored under ${keys.length} secret names [${keys.join(', ')}] (allowed).`;
|
|
494
491
|
|
|
495
492
|
const group = {
|
|
496
493
|
fingerprint: fp.slice(0, 12),
|
|
@@ -582,8 +579,9 @@ export async function listSecretsMeta(brainDir) {
|
|
|
582
579
|
label: m.label || null,
|
|
583
580
|
repos,
|
|
584
581
|
repo: repos.length === 1 ? repos[0] : null,
|
|
585
|
-
multi_repo_error:
|
|
586
|
-
binding_error:
|
|
582
|
+
multi_repo_error: false,
|
|
583
|
+
binding_error: null,
|
|
584
|
+
tags: Array.isArray(m.tags) ? m.tags : [],
|
|
587
585
|
project_path: m.project_path || null,
|
|
588
586
|
subscription_tier: m.subscription_tier || null,
|
|
589
587
|
monthly_cost_usd: m.monthly_cost_usd ?? null,
|
|
@@ -838,6 +836,7 @@ export async function setSecret(brainDir, key, value, opts = {}) {
|
|
|
838
836
|
scope: opts.scope || prev.scope || 'global',
|
|
839
837
|
provider: opts.provider || prev.provider || null,
|
|
840
838
|
repos: opts.repos !== undefined ? opts.repos : prev.repos,
|
|
839
|
+
tags: opts.tags !== undefined ? opts.tags : prev.tags,
|
|
841
840
|
subscription_tier: opts.subscription_tier !== undefined ? opts.subscription_tier : prev.subscription_tier,
|
|
842
841
|
monthly_cost_usd: opts.monthly_cost_usd !== undefined ? opts.monthly_cost_usd : prev.monthly_cost_usd,
|
|
843
842
|
monthly_cap_usd: opts.monthly_cap_usd !== undefined ? opts.monthly_cap_usd : prev.monthly_cap_usd,
|
|
@@ -860,6 +859,7 @@ export async function setSecret(brainDir, key, value, opts = {}) {
|
|
|
860
859
|
scope: secrets[META_KEY].keys[key].scope,
|
|
861
860
|
provider: secrets[META_KEY].keys[key].provider,
|
|
862
861
|
actor: opts.actor || 'cli',
|
|
862
|
+
project_id: opts.project_id || null,
|
|
863
863
|
});
|
|
864
864
|
|
|
865
865
|
// First-time set only: optionally queue product-level API research (not raw key-name scraping).
|
|
@@ -907,6 +907,7 @@ export async function getSecret(brainDir, key, opts = {}) {
|
|
|
907
907
|
action: opts.action || 'get',
|
|
908
908
|
key,
|
|
909
909
|
actor: opts.actor || 'cli',
|
|
910
|
+
project_id: opts.project_id || null,
|
|
910
911
|
});
|
|
911
912
|
return { found: true, key, value: secrets[key] };
|
|
912
913
|
}
|