total-recall-brain 3.13.0 → 3.13.2
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 +91 -108
- package/bin/antigravity.mjs +81 -52
- package/bin/total-recall.mjs +32 -57
- package/extension/background.js +1 -1
- package/extension/options/options.js +9 -3
- package/extension/sidepanel/sidepanel.js +0 -2
- package/frontend/index.html +4 -3
- package/frontend/package.json +1 -0
- package/frontend/src/App.tsx +109 -152
- package/frontend/src/api.ts +605 -23
- package/frontend/src/components/BrainSelector.tsx +211 -199
- package/frontend/src/components/DocumentEditorModal.tsx +57 -0
- package/frontend/src/components/DocumentTable.tsx +47 -0
- package/frontend/src/components/Graph3D.tsx +120 -71
- package/frontend/src/components/UsageChart.tsx +3 -3
- package/frontend/src/components/brand/BrandMark.tsx +95 -0
- package/frontend/src/index.css +286 -86
- package/frontend/src/pages/ApiKeysPage.tsx +1878 -215
- package/frontend/src/pages/ChatPage.tsx +103 -45
- package/frontend/src/pages/DesignDocsPage.tsx +5 -5
- package/frontend/src/pages/FilesPage.tsx +10 -6
- package/frontend/src/pages/GraphPage.tsx +5 -3
- package/frontend/src/pages/HelpPage.tsx +2 -2
- package/frontend/src/pages/InboxPage.tsx +82 -0
- package/frontend/src/pages/InstructionsPage.tsx +1 -1
- package/frontend/src/pages/IntegrationsPage.tsx +21 -10
- package/frontend/src/pages/LoginPage.tsx +95 -77
- package/frontend/src/pages/MemoryPage.tsx +31 -8
- package/frontend/src/pages/OnboardingPage.tsx +690 -0
- package/frontend/src/pages/OpenWikiPage.tsx +412 -323
- package/frontend/src/pages/SettingsPage.tsx +825 -922
- package/frontend/src/pages/SkillsPage.tsx +272 -147
- package/frontend/src/pages/UsagePage.tsx +23 -31
- package/frontend/src/pages/VaultPage.tsx +223 -0
- package/frontend/src/types.ts +13 -0
- package/package.json +4 -4
- package/scaffold/.agent/skills/total-recall/SKILL.md +43 -4
- package/scaffold/.agent/skills/total-recall/modules/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/modules/agents/README.md +5 -0
- package/scaffold/.agent/skills/total-recall/{skills/tr-cli-agents → modules/agents}/agents.yml +8 -0
- package/scaffold/.agent/skills/total-recall/modules/skill-deploy/README.md +9 -0
- package/scaffold/.agent/skills/total-recall/openwiki/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/openwiki/architecture.md +5 -0
- package/scaffold/.agent/skills/total-recall/openwiki/ide.md +10 -0
- package/scaffold/.agent/skills/total-recall/openwiki/memory.md +11 -0
- package/scaffold/.agent/skills/total-recall/openwiki/secrets.md +7 -0
- package/scaffold/.agent/skills/total-recall/openwiki/skills.md +7 -0
- package/src/cli/brain.mjs +63 -5
- package/src/cli/connect.mjs +11 -19
- package/src/cli/daemon.mjs +5 -4
- package/src/cli/deploy-ui.mjs +8 -3
- package/src/cli/deploy.mjs +84 -27
- package/src/cli/ingest/index.mjs +1 -1
- package/src/cli/init.mjs +63 -34
- package/src/cli/remember.mjs +3 -3
- package/src/cli/secret.mjs +812 -0
- package/src/cli/setup.mjs +34 -7
- package/src/cli/share.mjs +2 -2
- package/src/cli/skill.mjs +396 -35
- package/src/cli/sync.mjs +4 -4
- package/src/cli/task.mjs +287 -0
- package/src/core/clarity-rewriter.mjs +5 -5
- package/src/core/config.mjs +44 -2
- package/src/core/conflict-detector.mjs +5 -5
- package/src/core/crons.mjs +54 -0
- package/src/core/daemon-loop.mjs +87 -235
- package/src/core/dream.mjs +170 -10
- package/src/core/env-import.mjs +456 -0
- package/src/core/fact-seeker.mjs +14 -14
- package/src/core/import-rules.mjs +20 -7
- package/src/core/inference-engine.mjs +3 -3
- package/src/core/migrate.mjs +14 -8
- package/src/core/okf-adapter.mjs +2 -2
- package/src/core/operation-validator.mjs +39 -331
- package/src/core/optimizer.mjs +7 -0
- package/src/core/post-mortem.mjs +2 -2
- package/src/core/project-brain.mjs +349 -0
- package/src/core/provider-catalog.mjs +296 -0
- package/src/core/quick-capture.mjs +1 -1
- package/src/core/remote-vault-sync.mjs +194 -0
- package/src/core/research-queue.mjs +7 -11
- package/src/core/runtime.mjs +33 -9
- package/src/core/scheduler.mjs +59 -5
- package/src/core/schema.mjs +14 -0
- package/src/core/search.mjs +1 -1
- package/src/core/secrets-env-export.mjs +323 -0
- package/src/core/secrets-rotate.mjs +245 -0
- package/src/core/secrets-store.mjs +645 -0
- package/src/core/semantic-index.mjs +3 -3
- package/src/core/skills-registry.mjs +1062 -0
- package/src/core/source-adapters.mjs +4 -1
- package/src/core/ssss-host-extension.mjs +94 -0
- package/src/core/ssss-kernel-bridge.mjs +530 -0
- package/src/core/surface.mjs +14 -0
- package/src/core/task-envelope.mjs +298 -0
- package/src/core/task-executors.mjs +600 -0
- package/src/core/validated-write.mjs +124 -56
- package/src/core/vault.mjs +21 -34
- package/src/core/webauthn-store.mjs +272 -0
- package/src/server/api.mjs +26 -16
- package/src/server/auth.mjs +51 -0
- package/src/server/index.mjs +3 -3
- package/src/server/keys.mjs +30 -4
- package/src/server/rest.mjs +198 -25
- package/src/server/route-manifest.json +156 -0
- package/src/server/routes/_shared.mjs +40 -1
- package/src/server/routes/docs.mjs +260 -0
- package/src/server/routes/memory.mjs +145 -32
- package/src/server/routes/research.mjs +30 -0
- package/src/server/routes/secrets.mjs +470 -0
- package/src/server/routes/share.mjs +2 -2
- package/src/server/routes/skills.mjs +452 -54
- package/src/server/routes/sync.mjs +48 -0
- package/src/server/routes/webauthn.mjs +184 -0
- package/src/server/update-route-manifest.mjs +39 -0
- package/templates/onboarding-interview.md +32 -22
- package/templates/openwiki/README.md +10 -0
- package/templates/openwiki/architecture.md +5 -0
- package/templates/openwiki/ide.md +10 -0
- package/templates/openwiki/memory.md +11 -0
- package/templates/openwiki/secrets.md +7 -0
- package/templates/openwiki/skills.md +7 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/SKILL.md +0 -121
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/antigravity.txt +0 -20
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/claude.txt +0 -58
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/codex.txt +0 -81
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/gemini.txt +0 -44
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/scripts/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/subagents/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-research/SKILL.md +0 -338
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/SKILL.md +0 -282
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/assets/skill-template/SKILL.md +0 -20
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/evals/evals.json +0 -20
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/hooks/on-skill-add.sh +0 -14
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/hooks/on-skill-delete.sh +0 -14
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/learnings/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/references/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/create-skill.sh +0 -65
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/enforce-skill-optimization.mjs +0 -135
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/skill-watcher.mjs +0 -64
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/subagents/audit-all-skills.md +0 -62
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/SKILL.md +0 -298
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/evals/evals.json +0 -17
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/references/CHANGELOG.md +0 -124
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/references/ssss-spec.md +0 -1325
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/scripts/validate-schema.mjs +0 -232
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/subagents/audit-ssss-node.md +0 -9
- /package/scaffold/.agent/skills/total-recall/{skills/tr-cli-agents/evals → modules/skill-deploy/scripts}/.gitkeep +0 -0
- /package/scaffold/.agent/skills/total-recall/{skills/tr-skill → modules/skill-deploy}/scripts/find-skills.mjs +0 -0
- /package/scaffold/.agent/skills/total-recall/{skills/tr-skill → modules/skill-deploy}/scripts/install-skill.mjs +0 -0
- /package/scaffold/.agent/skills/total-recall/{skills/tr-skill → modules/skill-deploy}/scripts/scan-skill.mjs +0 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Materialize .env files FROM the Total Recall secrets store.
|
|
3
|
+
*
|
|
4
|
+
* Model: TR secrets.enc is the SSOT for all user API keys.
|
|
5
|
+
* Repo / deploy .env files are projections — never the other way around.
|
|
6
|
+
*
|
|
7
|
+
* Never write secret values into vault markdown, openwiki, or surfaces.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import { loadSecrets, listSecretsMeta, resolveSecretsPath } from './secrets-store.mjs';
|
|
14
|
+
|
|
15
|
+
const META_KEY = '__tr_secrets_meta';
|
|
16
|
+
|
|
17
|
+
const BEGIN_MARK = '# BEGIN TOTAL-RECALL-SECRETS';
|
|
18
|
+
const END_MARK = '# END TOTAL-RECALL-SECRETS';
|
|
19
|
+
|
|
20
|
+
function envHeader() {
|
|
21
|
+
return `${BEGIN_MARK}
|
|
22
|
+
# Generated by Total Recall — do not hand-edit secret values here.
|
|
23
|
+
# SSOT: secrets store (npx total-recall secret …)
|
|
24
|
+
# Re-export: npx total-recall secret export-env --path <repo>
|
|
25
|
+
# ${new Date().toISOString()}
|
|
26
|
+
`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Merge a managed TR secrets block into an existing .env without wiping
|
|
31
|
+
* non-secret / hand-managed keys outside the BEGIN/END markers.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} existing - current file contents (or '')
|
|
34
|
+
* @param {string} managedBody - full block including BEGIN/END marks
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function mergeEnvManagedBlock(existing, managedBody) {
|
|
38
|
+
const managed = String(managedBody || '').trimEnd() + '\n';
|
|
39
|
+
const text = existing == null ? '' : String(existing);
|
|
40
|
+
|
|
41
|
+
if (!text.trim()) return managed;
|
|
42
|
+
|
|
43
|
+
const beginIdx = text.indexOf(BEGIN_MARK);
|
|
44
|
+
const endIdx = text.indexOf(END_MARK);
|
|
45
|
+
|
|
46
|
+
if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
|
|
47
|
+
const before = text.slice(0, beginIdx).replace(/\s+$/, '');
|
|
48
|
+
const after = text.slice(endIdx + END_MARK.length).replace(/^\r?\n?/, '');
|
|
49
|
+
const parts = [];
|
|
50
|
+
if (before) parts.push(before.endsWith('\n') ? before : `${before}\n`);
|
|
51
|
+
parts.push(managed.endsWith('\n') ? managed : `${managed}\n`);
|
|
52
|
+
if (after.trim()) {
|
|
53
|
+
parts.push(after.startsWith('\n') ? after : `\n${after}`);
|
|
54
|
+
}
|
|
55
|
+
return parts.join('').replace(/\n{3,}/g, '\n\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// No prior TR block — append managed section after existing content
|
|
59
|
+
const base = text.endsWith('\n') ? text : `${text}\n`;
|
|
60
|
+
return `${base}\n${managed}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve a project slug from a filesystem path (last segment).
|
|
65
|
+
* @param {string} projectPath
|
|
66
|
+
*/
|
|
67
|
+
export function projectSlugFromPath(projectPath) {
|
|
68
|
+
if (!projectPath) return null;
|
|
69
|
+
const abs = path.resolve(projectPath);
|
|
70
|
+
return path.basename(abs);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Load project-registry entries (if any).
|
|
75
|
+
*/
|
|
76
|
+
export function loadProjectRegistry() {
|
|
77
|
+
const registryPath = path.join(
|
|
78
|
+
os.homedir(),
|
|
79
|
+
'.agent',
|
|
80
|
+
'skills',
|
|
81
|
+
'total-recall',
|
|
82
|
+
'config',
|
|
83
|
+
'project-registry.json',
|
|
84
|
+
);
|
|
85
|
+
if (!fs.existsSync(registryPath)) return [];
|
|
86
|
+
try {
|
|
87
|
+
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
88
|
+
return Array.isArray(data) ? data : [];
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Decide whether a secret meta row applies to a target project.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} metaRow - from listSecretsMeta
|
|
98
|
+
* @param {{ projectPath?: string, projectSlug?: string, includeGlobal?: boolean, keys?: string[] }} target
|
|
99
|
+
*/
|
|
100
|
+
export function secretMatchesTarget(metaRow, target = {}) {
|
|
101
|
+
if (target.keys?.length && !target.keys.includes(metaRow.key)) return false;
|
|
102
|
+
|
|
103
|
+
const slug = target.projectSlug || projectSlugFromPath(target.projectPath);
|
|
104
|
+
const repos = Array.isArray(metaRow.repos) ? metaRow.repos : [];
|
|
105
|
+
const projectPath = metaRow.project_path || '';
|
|
106
|
+
|
|
107
|
+
// Explicit key list mode
|
|
108
|
+
if (target.keys?.length) return true;
|
|
109
|
+
|
|
110
|
+
// Bound to this repo by slug or path (policy: at most one product repo per key)
|
|
111
|
+
// Multi-repo keys are invalid — do not project them until fixed
|
|
112
|
+
if (repos.length > 1) return false;
|
|
113
|
+
if (slug && repos.some((r) => r === slug || r === '*' || r.endsWith(`/${slug}`))) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
if (target.projectPath && projectPath) {
|
|
117
|
+
const a = path.resolve(target.projectPath);
|
|
118
|
+
const b = path.resolve(projectPath);
|
|
119
|
+
if (a === b || a.startsWith(b + path.sep) || b.startsWith(a + path.sep)) return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Unbound secrets: include when includeGlobal (default true for full SSOT export)
|
|
123
|
+
if (target.includeGlobal !== false && repos.length === 0 && !projectPath) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Also include when tagged global scope with empty repos
|
|
128
|
+
if (target.includeGlobal !== false && metaRow.scope === 'global' && repos.length === 0) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Build dotenv body (KEY=value lines) for matching secrets.
|
|
137
|
+
* @returns {{ body: string, keys: string[], exampleBody: string, count: number }}
|
|
138
|
+
*/
|
|
139
|
+
export async function buildEnvProjection(brainDir, target = {}) {
|
|
140
|
+
const secrets = await loadSecrets(brainDir);
|
|
141
|
+
const metaRows = await listSecretsMeta(brainDir);
|
|
142
|
+
const byKey = new Map(metaRows.map((m) => [m.key, m]));
|
|
143
|
+
|
|
144
|
+
const selected = [];
|
|
145
|
+
for (const [key, value] of Object.entries(secrets)) {
|
|
146
|
+
if (key === META_KEY) continue;
|
|
147
|
+
if (value == null || value === '') continue;
|
|
148
|
+
const meta = byKey.get(key) || { key, repos: [], scope: 'global' };
|
|
149
|
+
if (!secretMatchesTarget(meta, target)) continue;
|
|
150
|
+
selected.push({ key, value: String(value), meta });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
selected.sort((a, b) => a.key.localeCompare(b.key));
|
|
154
|
+
|
|
155
|
+
const lines = selected.map(({ key, value }) => {
|
|
156
|
+
// Quote if needed
|
|
157
|
+
const needsQuote = /[\s#"'$\\]/.test(value);
|
|
158
|
+
const v = needsQuote ? JSON.stringify(value) : value;
|
|
159
|
+
return `${key}=${v}`;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const exampleLines = selected.map(({ key }) => `${key}=`);
|
|
163
|
+
|
|
164
|
+
const body = `${envHeader()}${lines.join('\n')}${lines.length ? '\n' : ''}${END_MARK}\n`;
|
|
165
|
+
const exampleBody = `# Example env — names only. Values live in Total Recall secrets store.
|
|
166
|
+
# Generate real .env: npx total-recall secret export-env --path .
|
|
167
|
+
${exampleLines.join('\n')}${exampleLines.length ? '\n' : ''}`;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
body,
|
|
171
|
+
exampleBody,
|
|
172
|
+
keys: selected.map((s) => s.key),
|
|
173
|
+
count: selected.length,
|
|
174
|
+
store: resolveSecretsPath(brainDir),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Write .env (and optional .env.example) into a project directory.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} brainDir
|
|
182
|
+
* @param {string} projectPath - repo root
|
|
183
|
+
* @param {{
|
|
184
|
+
* filename?: string,
|
|
185
|
+
* example?: boolean,
|
|
186
|
+
* dryRun?: boolean,
|
|
187
|
+
* includeGlobal?: boolean,
|
|
188
|
+
* keys?: string[],
|
|
189
|
+
* mode?: number,
|
|
190
|
+
* }} opts
|
|
191
|
+
*/
|
|
192
|
+
export async function exportEnvToProject(brainDir, projectPath, opts = {}) {
|
|
193
|
+
const abs = path.resolve(projectPath);
|
|
194
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
|
|
195
|
+
throw new Error(`Project path is not a directory: ${abs}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const filename = opts.filename || '.env';
|
|
199
|
+
const target = {
|
|
200
|
+
projectPath: abs,
|
|
201
|
+
projectSlug: projectSlugFromPath(abs),
|
|
202
|
+
includeGlobal: opts.includeGlobal !== false,
|
|
203
|
+
keys: opts.keys,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const projection = await buildEnvProjection(brainDir, target);
|
|
207
|
+
const envPath = path.join(abs, filename);
|
|
208
|
+
const examplePath = path.join(abs, `${filename}.example`);
|
|
209
|
+
|
|
210
|
+
if (opts.dryRun) {
|
|
211
|
+
return {
|
|
212
|
+
dryRun: true,
|
|
213
|
+
envPath,
|
|
214
|
+
examplePath: opts.example !== false ? examplePath : null,
|
|
215
|
+
keys: projection.keys,
|
|
216
|
+
count: projection.count,
|
|
217
|
+
store: projection.store,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const mode = opts.mode ?? 0o600;
|
|
222
|
+
// Merge by default so non-secret APP_NAME=… etc. outside the TR block survive
|
|
223
|
+
const replaceAll = opts.replaceAll === true;
|
|
224
|
+
let finalBody = projection.body;
|
|
225
|
+
let merged = false;
|
|
226
|
+
if (!replaceAll && fs.existsSync(envPath)) {
|
|
227
|
+
try {
|
|
228
|
+
const existing = fs.readFileSync(envPath, 'utf8');
|
|
229
|
+
finalBody = mergeEnvManagedBlock(existing, projection.body);
|
|
230
|
+
merged = true;
|
|
231
|
+
} catch {
|
|
232
|
+
finalBody = projection.body;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
fs.writeFileSync(envPath, finalBody, { encoding: 'utf8', mode });
|
|
236
|
+
try {
|
|
237
|
+
fs.chmodSync(envPath, mode);
|
|
238
|
+
} catch {
|
|
239
|
+
/* windows / fs without chmod */
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let wroteExample = false;
|
|
243
|
+
if (opts.example !== false) {
|
|
244
|
+
fs.writeFileSync(examplePath, projection.exampleBody, { encoding: 'utf8', mode: 0o644 });
|
|
245
|
+
wroteExample = true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Ensure .env is gitignored
|
|
249
|
+
ensureGitignoreHasEnv(abs);
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
dryRun: false,
|
|
253
|
+
envPath,
|
|
254
|
+
examplePath: wroteExample ? examplePath : null,
|
|
255
|
+
keys: projection.keys,
|
|
256
|
+
count: projection.count,
|
|
257
|
+
store: projection.store,
|
|
258
|
+
projectSlug: target.projectSlug,
|
|
259
|
+
merged,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Export to multiple registered projects.
|
|
265
|
+
*/
|
|
266
|
+
export async function exportEnvToRegistry(brainDir, opts = {}) {
|
|
267
|
+
const registry = loadProjectRegistry();
|
|
268
|
+
const results = [];
|
|
269
|
+
for (const entry of registry) {
|
|
270
|
+
const projectPath = entry.path || entry.project_root;
|
|
271
|
+
if (!projectPath || !fs.existsSync(projectPath)) {
|
|
272
|
+
results.push({
|
|
273
|
+
name: entry.name,
|
|
274
|
+
path: projectPath,
|
|
275
|
+
ok: false,
|
|
276
|
+
error: 'path missing',
|
|
277
|
+
});
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
const r = await exportEnvToProject(brainDir, projectPath, opts);
|
|
282
|
+
results.push({ name: entry.name, ...r, ok: true });
|
|
283
|
+
} catch (err) {
|
|
284
|
+
results.push({ name: entry.name, path: projectPath, ok: false, error: err.message });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return results;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Build a deploy payload: dotenv text + optional JSON map (no disk write).
|
|
292
|
+
*/
|
|
293
|
+
export async function buildDeploySecretsPayload(brainDir, target = {}) {
|
|
294
|
+
const projection = await buildEnvProjection(brainDir, {
|
|
295
|
+
includeGlobal: true,
|
|
296
|
+
...target,
|
|
297
|
+
});
|
|
298
|
+
const secrets = await loadSecrets(brainDir);
|
|
299
|
+
const map = {};
|
|
300
|
+
for (const key of projection.keys) {
|
|
301
|
+
if (secrets[key] != null) map[key] = String(secrets[key]);
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
dotenv: projection.body,
|
|
305
|
+
map,
|
|
306
|
+
keys: projection.keys,
|
|
307
|
+
count: projection.count,
|
|
308
|
+
store: projection.store,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function ensureGitignoreHasEnv(projectPath) {
|
|
313
|
+
const gi = path.join(projectPath, '.gitignore');
|
|
314
|
+
const linesNeeded = ['.env', '.env.local', '.env.*.local'];
|
|
315
|
+
let content = '';
|
|
316
|
+
if (fs.existsSync(gi)) {
|
|
317
|
+
content = fs.readFileSync(gi, 'utf8');
|
|
318
|
+
}
|
|
319
|
+
const missing = linesNeeded.filter((l) => !content.split(/\r?\n/).some((line) => line.trim() === l));
|
|
320
|
+
if (!missing.length) return;
|
|
321
|
+
const block = `\n# Total Recall secrets projections\n${missing.join('\n')}\n`;
|
|
322
|
+
fs.appendFileSync(gi, content.endsWith('\n') || !content ? block : `\n${block}`);
|
|
323
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets rotation orchestration.
|
|
3
|
+
*
|
|
4
|
+
* - Detect overdue keys (schedule metadata)
|
|
5
|
+
* - Enqueue assisted rotation tasks (optional browser-use)
|
|
6
|
+
* - After rotate: re-export .env to bound repos
|
|
7
|
+
* - TR PATs can be auto-rotated when explicitly requested
|
|
8
|
+
*
|
|
9
|
+
* Never log secret values. Never write values into vault/openwiki.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import {
|
|
15
|
+
listRotationDue,
|
|
16
|
+
listSecretsMeta,
|
|
17
|
+
rotateSecret,
|
|
18
|
+
getSecret,
|
|
19
|
+
updateSecretMeta,
|
|
20
|
+
} from './secrets-store.mjs';
|
|
21
|
+
import { exportEnvToProject, exportEnvToRegistry, loadProjectRegistry, projectSlugFromPath } from './secrets-env-export.mjs';
|
|
22
|
+
import { getProvider, providerForKeyName } from './provider-catalog.mjs';
|
|
23
|
+
import { atomicWrite, safeStringify } from './vault.mjs';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build a supervised browser-use prompt for rotating one key.
|
|
27
|
+
* Does not perform automation itself.
|
|
28
|
+
*/
|
|
29
|
+
export function buildBrowserRotatePrompt(secretMeta) {
|
|
30
|
+
const catalog = getProvider(secretMeta.provider) || providerForKeyName(secretMeta.key);
|
|
31
|
+
const consoleUrl = secretMeta.console_url || catalog?.console_url || '(provider console)';
|
|
32
|
+
const docsUrl = secretMeta.api_docs_url || catalog?.docs_url || '';
|
|
33
|
+
|
|
34
|
+
return [
|
|
35
|
+
`Rotate API credential for secret key: ${secretMeta.key}`,
|
|
36
|
+
`Provider: ${secretMeta.provider || catalog?.id || 'unknown'}`,
|
|
37
|
+
'',
|
|
38
|
+
'SUPERVISED browser workflow (do NOT auto-revoke until TR confirms store+export):',
|
|
39
|
+
`1. Open: ${consoleUrl}`,
|
|
40
|
+
'2. Complete login / 2FA with the human if needed.',
|
|
41
|
+
'3. Create a NEW API key (do not delete the old one yet).',
|
|
42
|
+
'4. Copy the new key value ONCE — never paste into chat logs or vault markdown.',
|
|
43
|
+
'5. Tell the human to run (or run via tool if value is only in a secure channel):',
|
|
44
|
+
` npx total-recall secret rotate ${secretMeta.key} "<NEW_VALUE>" --export-env`,
|
|
45
|
+
'6. After TR confirms export succeeded, revoke/disable the OLD key in the console.',
|
|
46
|
+
'',
|
|
47
|
+
docsUrl ? `API docs: ${docsUrl}` : '',
|
|
48
|
+
secretMeta.repos?.length ? `Bound repos: ${secretMeta.repos.join(', ')}` : 'Repos: (unbound / global projection)',
|
|
49
|
+
secretMeta.next_rotate_due ? `Was due: ${secretMeta.next_rotate_due}` : '',
|
|
50
|
+
'',
|
|
51
|
+
'Safety: never screenshot the full key; never write the value into remember/openwiki.',
|
|
52
|
+
]
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Enqueue one queue task per overdue secret (or refresh existing pending).
|
|
59
|
+
* @param {string} brainDir
|
|
60
|
+
* @param {{ autoOnly?: boolean, queueDir?: string }} opts
|
|
61
|
+
*/
|
|
62
|
+
export async function enqueueRotationDueTasks(brainDir, opts = {}) {
|
|
63
|
+
const due = await listRotationDue(brainDir, { autoOnly: !!opts.autoOnly });
|
|
64
|
+
const queueDir =
|
|
65
|
+
opts.queueDir || path.join(brainDir, 'scheduler', 'queue');
|
|
66
|
+
if (!fs.existsSync(queueDir)) fs.mkdirSync(queueDir, { recursive: true });
|
|
67
|
+
|
|
68
|
+
const created = [];
|
|
69
|
+
const now = new Date().toISOString();
|
|
70
|
+
|
|
71
|
+
for (const secret of due) {
|
|
72
|
+
const slug = `secret-rotate-${secret.key}`
|
|
73
|
+
.toLowerCase()
|
|
74
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
75
|
+
.slice(0, 80);
|
|
76
|
+
const filePath = path.join(queueDir, `${slug}.md`);
|
|
77
|
+
if (fs.existsSync(filePath)) {
|
|
78
|
+
// leave existing pending task
|
|
79
|
+
created.push({ key: secret.key, slug, status: 'exists' });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const prompt = buildBrowserRotatePrompt(secret);
|
|
84
|
+
const front = {
|
|
85
|
+
type: 'task',
|
|
86
|
+
slug,
|
|
87
|
+
category: 'secrets-rotation',
|
|
88
|
+
title: `Rotate secret: ${secret.key}`,
|
|
89
|
+
status: 'pending',
|
|
90
|
+
priority: secret.auto_rotate ? 1 : 2,
|
|
91
|
+
executor: 'secrets-rotation-assist',
|
|
92
|
+
capabilities: ['secrets:rotate', 'notify'],
|
|
93
|
+
intent: `Rotate overdue secret ${secret.key} (supervised browser or provider API)`,
|
|
94
|
+
payload: {
|
|
95
|
+
secret_key: secret.key,
|
|
96
|
+
provider: secret.provider,
|
|
97
|
+
auto_rotate: !!secret.auto_rotate,
|
|
98
|
+
console_url: secret.console_url,
|
|
99
|
+
browser_prompt: true,
|
|
100
|
+
},
|
|
101
|
+
created: now,
|
|
102
|
+
updated: now,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const body = [
|
|
106
|
+
`## Objective`,
|
|
107
|
+
``,
|
|
108
|
+
`Rotate \`${secret.key}\` — schedule overdue.`,
|
|
109
|
+
``,
|
|
110
|
+
`## Browser-use instructions`,
|
|
111
|
+
``,
|
|
112
|
+
'```',
|
|
113
|
+
prompt,
|
|
114
|
+
'```',
|
|
115
|
+
``,
|
|
116
|
+
`## After new value is obtained`,
|
|
117
|
+
``,
|
|
118
|
+
'```bash',
|
|
119
|
+
`npx total-recall secret rotate ${secret.key} "<NEW_VALUE>" --export-env`,
|
|
120
|
+
'```',
|
|
121
|
+
].join('\n');
|
|
122
|
+
|
|
123
|
+
atomicWrite(filePath, safeStringify(body, front));
|
|
124
|
+
created.push({ key: secret.key, slug, status: 'created', path: filePath });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { due: due.length, tasks: created };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Rotate value then re-export .env to bound repos (and optionally all registry).
|
|
132
|
+
*/
|
|
133
|
+
export async function rotateSecretAndExport(brainDir, key, newValue, opts = {}) {
|
|
134
|
+
const result = await rotateSecret(brainDir, key, newValue, {
|
|
135
|
+
actor: opts.actor || 'cli',
|
|
136
|
+
provider: opts.provider,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const metaRows = await listSecretsMeta(brainDir);
|
|
140
|
+
const row = metaRows.find((r) => r.key === key);
|
|
141
|
+
const exports = [];
|
|
142
|
+
|
|
143
|
+
if (opts.exportEnv !== false) {
|
|
144
|
+
const registry = loadProjectRegistry();
|
|
145
|
+
const repos = row?.repos || [];
|
|
146
|
+
const boundPaths = new Set();
|
|
147
|
+
|
|
148
|
+
for (const r of repos) {
|
|
149
|
+
// match registry by name/slug
|
|
150
|
+
for (const entry of registry) {
|
|
151
|
+
if (entry.name === r || projectSlugFromPath(entry.path) === r) {
|
|
152
|
+
if (entry.path && fs.existsSync(entry.path)) boundPaths.add(path.resolve(entry.path));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// also allow absolute path in repos
|
|
156
|
+
if (r.startsWith('/') && fs.existsSync(r)) boundPaths.add(path.resolve(r));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (row?.project_path && fs.existsSync(row.project_path)) {
|
|
160
|
+
boundPaths.add(path.resolve(row.project_path));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (opts.exportAllProjects) {
|
|
164
|
+
const all = await exportEnvToRegistry(brainDir, {
|
|
165
|
+
includeGlobal: true,
|
|
166
|
+
example: true,
|
|
167
|
+
});
|
|
168
|
+
for (const r of all) exports.push(r);
|
|
169
|
+
} else if (boundPaths.size) {
|
|
170
|
+
for (const p of boundPaths) {
|
|
171
|
+
try {
|
|
172
|
+
const r = await exportEnvToProject(brainDir, p, {
|
|
173
|
+
includeGlobal: opts.includeGlobal !== false,
|
|
174
|
+
example: true,
|
|
175
|
+
});
|
|
176
|
+
exports.push({ ok: true, ...r });
|
|
177
|
+
} catch (err) {
|
|
178
|
+
exports.push({ ok: false, path: p, error: err.message });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} else if (opts.exportCwd) {
|
|
182
|
+
const r = await exportEnvToProject(brainDir, process.cwd(), {
|
|
183
|
+
includeGlobal: true,
|
|
184
|
+
example: true,
|
|
185
|
+
});
|
|
186
|
+
exports.push({ ok: true, ...r });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
...result,
|
|
192
|
+
secret: row,
|
|
193
|
+
exports,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Daemon executor: scan due secrets and enqueue assist tasks.
|
|
199
|
+
*/
|
|
200
|
+
export async function runSecretsRotationCheck(ctx) {
|
|
201
|
+
const result = await enqueueRotationDueTasks(ctx.brainDir, {
|
|
202
|
+
autoOnly: false,
|
|
203
|
+
queueDir: ctx.queueDir,
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
success: true,
|
|
207
|
+
output: `Rotation check: ${result.due} due, ${result.tasks.filter((t) => t.status === 'created').length} tasks created`,
|
|
208
|
+
executor: 'secrets-rotation-check',
|
|
209
|
+
...result,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Daemon executor: re-export all project envs from SSOT.
|
|
215
|
+
*/
|
|
216
|
+
export async function runSecretsExportAll(ctx) {
|
|
217
|
+
const results = await exportEnvToRegistry(ctx.brainDir, {
|
|
218
|
+
includeGlobal: true,
|
|
219
|
+
example: true,
|
|
220
|
+
});
|
|
221
|
+
const ok = results.filter((r) => r.ok).length;
|
|
222
|
+
return {
|
|
223
|
+
success: true,
|
|
224
|
+
output: `export-env: ${ok}/${results.length} projects updated`,
|
|
225
|
+
executor: 'secrets-export-env',
|
|
226
|
+
results,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Print browser prompt for a key (CLI helper).
|
|
232
|
+
*/
|
|
233
|
+
export async function getBrowserRotateAssist(brainDir, key) {
|
|
234
|
+
const rows = await listSecretsMeta(brainDir);
|
|
235
|
+
const row = rows.find((r) => r.key === key);
|
|
236
|
+
if (!row) throw new Error(`Secret not found: ${key}`);
|
|
237
|
+
return {
|
|
238
|
+
key,
|
|
239
|
+
prompt: buildBrowserRotatePrompt(row),
|
|
240
|
+
console_url: row.console_url,
|
|
241
|
+
docs_url: row.api_docs_url,
|
|
242
|
+
overdue: row.rotation_overdue,
|
|
243
|
+
next_rotate_due: row.next_rotate_due,
|
|
244
|
+
};
|
|
245
|
+
}
|