dreamteamer 0.6.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/LICENSE +202 -0
- package/NOTICE +16 -0
- package/README.md +83 -0
- package/agents/dreamteamer.agent.md +7 -0
- package/bin/dreamteamer.js +65 -0
- package/collection-templates/docs.collection-template.yaml +14 -0
- package/collection-templates/entity.collection-template.yaml +16 -0
- package/collections/agents.collection.yaml +33 -0
- package/collections/collection-templates.collection.yaml +20 -0
- package/collections/collections.collection.yaml +78 -0
- package/collections/command-bindings.collection.yaml +46 -0
- package/collections/commands.collection.yaml +36 -0
- package/collections/repos.collection.yaml +42 -0
- package/collections/skills.collection.yaml +22 -0
- package/collections/ui-views.collection.yaml +48 -0
- package/collections/users.collection.yaml +21 -0
- package/package.json +58 -0
- package/skills/building-dreamteamer/SKILL.md +117 -0
- package/skills/building-dreamteamer/references/agents.md +44 -0
- package/skills/building-dreamteamer/references/before-you-build.md +42 -0
- package/skills/building-dreamteamer/references/collections.md +120 -0
- package/skills/building-dreamteamer/references/commands.md +69 -0
- package/skills/building-dreamteamer/references/skills.md +73 -0
- package/skills/building-dreamteamer/references/ui-components.md +78 -0
- package/skills/building-dreamteamer/references/ui-views.md +59 -0
- package/skills/using-dreamteamer/SKILL.md +100 -0
- package/skills/using-dreamteamer/references/git-events.md +64 -0
- package/skills/using-dreamteamer/references/records.md +102 -0
- package/src/check.js +193 -0
- package/src/cli.js +250 -0
- package/src/collections-cli.js +389 -0
- package/src/commit.js +117 -0
- package/src/compile.js +747 -0
- package/src/events.js +124 -0
- package/src/field-values.js +69 -0
- package/src/filter.js +107 -0
- package/src/harnesses.js +233 -0
- package/src/history.js +64 -0
- package/src/init.js +307 -0
- package/src/presentation.js +190 -0
- package/src/record-commands.js +84 -0
- package/src/records.js +73 -0
- package/src/runtime.js +96 -0
- package/src/schema-ops.js +263 -0
- package/src/semver.js +32 -0
- package/src/server.js +291 -0
- package/src/store.js +450 -0
- package/src/template.js +98 -0
- package/src/temporal.js +149 -0
- package/src/workspace.js +51 -0
- package/src/yaml.js +6 -0
package/src/init.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// dreamteamer init — write the workspace skeleton into the current directory.
|
|
2
|
+
// non-interactive: flags override sensible defaults (RAD phase; prompts later).
|
|
3
|
+
// never compiles — compile is always explicit.
|
|
4
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { dump } from './yaml.js';
|
|
8
|
+
import { slugOrHash } from './template.js';
|
|
9
|
+
import { discoverModules, KINDS } from './compile.js';
|
|
10
|
+
import { Store } from './store.js';
|
|
11
|
+
|
|
12
|
+
const SKELETON_KINDS = ['collections', 'skills', 'agents', 'commands', 'ui-views'];
|
|
13
|
+
|
|
14
|
+
const GITIGNORE = `node_modules/
|
|
15
|
+
git_modules/
|
|
16
|
+
.dreamteamer/
|
|
17
|
+
.claude/
|
|
18
|
+
.agents/
|
|
19
|
+
.cursor/
|
|
20
|
+
.env
|
|
21
|
+
media/
|
|
22
|
+
.screenshots/
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
// A brand-new workspace with no collections gives a user nothing to run, and makes `compile` warn
|
|
26
|
+
// that the module `init` just created "contributed no recognised sources" — a warning about its own
|
|
27
|
+
// output, which reads as a broken install. One starter collection answers both. `notes` is
|
|
28
|
+
// deliberately the most generic thing a workspace can hold.
|
|
29
|
+
const STARTER_COLLECTION = `name: notes
|
|
30
|
+
storage:
|
|
31
|
+
path: data/notes
|
|
32
|
+
codec: md
|
|
33
|
+
shape: file
|
|
34
|
+
suffix: note
|
|
35
|
+
id:
|
|
36
|
+
generate: '{{ created | date }}--{{ title | slug }}'
|
|
37
|
+
pattern: ^\\d{4}-\\d{2}-\\d{2}--[a-z0-9-]+$
|
|
38
|
+
schema:
|
|
39
|
+
type: object
|
|
40
|
+
required:
|
|
41
|
+
- title
|
|
42
|
+
properties:
|
|
43
|
+
title:
|
|
44
|
+
type: string
|
|
45
|
+
description: What this note is called.
|
|
46
|
+
body:
|
|
47
|
+
type: string
|
|
48
|
+
format: markdown
|
|
49
|
+
x-body: true
|
|
50
|
+
description: The note itself.
|
|
51
|
+
icon: sticky_note_2
|
|
52
|
+
title: Notes
|
|
53
|
+
title_template: '{{ title }}'
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const ENV_EXAMPLE = `# secrets for skills and modules go here (copy to .env; .env is never committed).
|
|
57
|
+
# modules declare the env keys they require in their package.json dreamteamer.env list.
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
export function init({ flags = {} } = {}) {
|
|
61
|
+
const root = process.cwd();
|
|
62
|
+
const name = flags.name ?? path.basename(root);
|
|
63
|
+
const dataPath = flags['data-path'] ?? 'data';
|
|
64
|
+
const harnesses = typeof flags.harnesses === 'string' ? flags.harnesses.split(',').map((s) => s.trim()) : ['claude-code'];
|
|
65
|
+
|
|
66
|
+
// package.json: create or update — the single manifest
|
|
67
|
+
const pkgPath = path.join(root, 'package.json');
|
|
68
|
+
const pkg = fs.existsSync(pkgPath) ? JSON.parse(fs.readFileSync(pkgPath, 'utf8')) : { name, private: true, version: '0.0.1' };
|
|
69
|
+
pkg.dreamteamer = {
|
|
70
|
+
'data-path': dataPath,
|
|
71
|
+
harnesses,
|
|
72
|
+
'gitignore-runtime-folder': true,
|
|
73
|
+
'workspace-module': name, // workspace-owned system sources live in modules/<name>/ — data and logic stay separated
|
|
74
|
+
'git-modules': {},
|
|
75
|
+
disable: [],
|
|
76
|
+
...pkg.dreamteamer,
|
|
77
|
+
};
|
|
78
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, '\t') + '\n');
|
|
79
|
+
|
|
80
|
+
// folder skeleton — the workspace's own sources are an inline module, kinds FLAT at its root
|
|
81
|
+
const wm = pkg.dreamteamer['workspace-module'];
|
|
82
|
+
const systemRoot = wm ? path.join(root, 'modules', wm) : root;
|
|
83
|
+
for (const kind of SKELETON_KINDS) fs.mkdirSync(path.join(systemRoot, kind), { recursive: true });
|
|
84
|
+
if (wm) {
|
|
85
|
+
const modulePkg = path.join(systemRoot, 'package.json');
|
|
86
|
+
if (!fs.existsSync(modulePkg)) {
|
|
87
|
+
// `files` is the npm publish surface: every kind a module can ship, since a new one here
|
|
88
|
+
// is an engine change that would otherwise silently stop being packaged.
|
|
89
|
+
fs.writeFileSync(modulePkg, JSON.stringify({ name: wm, private: true, version: '0.0.1', files: [...KINDS], dreamteamer: {} }, null, '\t') + '\n');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// one starter collection, so `compile` has something to report instead of warning about the
|
|
93
|
+
// empty module it just made, and so a fresh workspace has something to run.
|
|
94
|
+
const starter = path.join(systemRoot, 'collections', 'notes.collection.yaml');
|
|
95
|
+
if (!fs.existsSync(starter)) fs.writeFileSync(starter, STARTER_COLLECTION);
|
|
96
|
+
fs.mkdirSync(path.join(root, dataPath), { recursive: true });
|
|
97
|
+
fs.mkdirSync(path.join(root, 'state'), { recursive: true });
|
|
98
|
+
|
|
99
|
+
// seed the operator's user record from the git identity.
|
|
100
|
+
//
|
|
101
|
+
// The id MUST be slugOrHash(git user.name) — that is exactly how `@me` resolves in filters and
|
|
102
|
+
// ui-views (server.js / fsdata.ts), so seeding it from the same source is what makes the core
|
|
103
|
+
// /inbox view work by construction. A workspace where a user record is authored by hand under a
|
|
104
|
+
// different id gets an EMPTY inbox with no error (decision 99b) — hence the setup-script step in
|
|
105
|
+
// any workspace with a second person.
|
|
106
|
+
//
|
|
107
|
+
// No `everyone` team is seeded: `teams` was removed from core 2026-07-31. It was a one-record
|
|
108
|
+
// abstraction with no reader — nothing in the engine, in `check`, or in any view resolved a team.
|
|
109
|
+
const gitName = tryGit(root, ['config', 'user.name']) ?? 'operator';
|
|
110
|
+
const gitEmail = tryGit(root, ['config', 'user.email']);
|
|
111
|
+
const userId = slugOrHash(gitName);
|
|
112
|
+
const usersDir = path.join(root, dataPath, 'users');
|
|
113
|
+
fs.mkdirSync(usersDir, { recursive: true });
|
|
114
|
+
const userFile = path.join(usersDir, `${userId}.user.yaml`);
|
|
115
|
+
if (!fs.existsSync(userFile)) fs.writeFileSync(userFile, dump({ name: gitName, ...(gitEmail ? { email: gitEmail } : {}) }));
|
|
116
|
+
|
|
117
|
+
// .gitignore + .env.example (append-if-missing, never clobber)
|
|
118
|
+
appendMissing(path.join(root, '.gitignore'), GITIGNORE);
|
|
119
|
+
if (!fs.existsSync(path.join(root, '.env.example'))) fs.writeFileSync(path.join(root, '.env.example'), ENV_EXAMPLE);
|
|
120
|
+
|
|
121
|
+
// one init commit (if we're in a git repo)
|
|
122
|
+
try {
|
|
123
|
+
// stdio ignored on purpose: this whole block is best-effort, and execFileSync forwards the
|
|
124
|
+
// child's stderr to ours by default — so a plain `dreamteamer init` in a non-git folder
|
|
125
|
+
// printed git's raw "fatal: not a git repository" above our own handled warning.
|
|
126
|
+
execFileSync('git', ['add', '--all'], { cwd: root, stdio: 'ignore' });
|
|
127
|
+
execFileSync('git', ['commit', '--quiet', '-m', `dreamteamer: init workspace ${name}`], { cwd: root, stdio: 'ignore' });
|
|
128
|
+
} catch { console.warn('⚠ not a git repo (or nothing to commit) — init files written, no commit'); }
|
|
129
|
+
|
|
130
|
+
console.log(`✔ workspace ${name} initialized — run \`dreamteamer compile\` to materialize the runtime`);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// dreamteamer install — restore git_modules/ working clones from the committed lockfile map
|
|
135
|
+
export function install({ root, pkg }) {
|
|
136
|
+
const map = pkg.dreamteamer?.['git-modules'] ?? {};
|
|
137
|
+
const names = Object.keys(map);
|
|
138
|
+
if (!names.length) { console.log('✔ no git-modules declared — nothing to restore'); return 0; }
|
|
139
|
+
fs.mkdirSync(path.join(root, 'git_modules'), { recursive: true });
|
|
140
|
+
for (const name of names) {
|
|
141
|
+
const { url, ref = 'main' } = map[name];
|
|
142
|
+
const dest = path.join(root, 'git_modules', name);
|
|
143
|
+
if (fs.existsSync(dest)) {
|
|
144
|
+
const head = tryGit(dest, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
145
|
+
const dirty = tryGit(dest, ['status', '--porcelain']);
|
|
146
|
+
if (head !== ref) console.warn(`⚠ git_modules/${name}: HEAD is ${head}, lockfile says ${ref} — not touching it${dirty ? ' (dirty)' : ''}`);
|
|
147
|
+
else console.log(`✔ git_modules/${name} present (${ref})`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
console.log(`… cloning ${url} → git_modules/${name} (${ref})`);
|
|
151
|
+
execFileSync('git', ['clone', '--branch', ref, url, dest], { stdio: 'inherit' });
|
|
152
|
+
buildClone(dest, name);
|
|
153
|
+
}
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// dreamteamer update [<name>] — pull each lockfile-declared git_modules clone forward
|
|
158
|
+
// (ff-only on its recorded ref) and rebuild it. dirty clones are skipped, never touched.
|
|
159
|
+
// the caller (cli) runs compile afterwards — a pulled module may change sources.
|
|
160
|
+
export function update({ root, pkg }, only) {
|
|
161
|
+
const map = pkg.dreamteamer?.['git-modules'] ?? {};
|
|
162
|
+
if (only && !map[only]) throw new Error(`"${only}" is not in dreamteamer.git-modules (known: ${Object.keys(map).join(', ') || 'none'})`);
|
|
163
|
+
const names = only ? [only] : Object.keys(map);
|
|
164
|
+
if (!names.length) { console.log('✔ no git-modules declared — nothing to update'); return 0; }
|
|
165
|
+
for (const name of names) {
|
|
166
|
+
const { ref = 'main' } = map[name];
|
|
167
|
+
const dest = path.join(root, 'git_modules', name);
|
|
168
|
+
if (!fs.existsSync(dest)) { console.warn(`⚠ git_modules/${name} missing — run \`dreamteamer install\` first; skipped`); continue; }
|
|
169
|
+
if (tryGit(dest, ['status', '--porcelain'])) { console.warn(`⚠ git_modules/${name} is dirty — skipped (commit or stash there, then re-run)`); continue; }
|
|
170
|
+
const before = tryGit(dest, ['rev-parse', '--short', 'HEAD']);
|
|
171
|
+
try {
|
|
172
|
+
execFileSync('git', ['pull', '--ff-only', 'origin', ref], { cwd: dest, stdio: 'inherit' });
|
|
173
|
+
} catch {
|
|
174
|
+
console.warn(`⚠ git_modules/${name}: ff-only pull of origin/${ref} failed (diverged?) — left at ${before}`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const after = tryGit(dest, ['rev-parse', '--short', 'HEAD']);
|
|
178
|
+
if (before === after) { console.log(`✔ git_modules/${name} already up to date (${after})`); continue; }
|
|
179
|
+
buildClone(dest, name); // deps/dist may have moved with the pull
|
|
180
|
+
console.log(`✔ git_modules/${name} ${before} → ${after}`);
|
|
181
|
+
}
|
|
182
|
+
// dev-clone semantics: these clones SHADOW any node_modules copy of the same module —
|
|
183
|
+
// what just updated is what runs. npm-channel modules are updated via npm, not here.
|
|
184
|
+
console.log('… git_modules clones shadow node_modules copies — the updated clones win');
|
|
185
|
+
for (const m of discoverModules(root, pkg).modules) {
|
|
186
|
+
if (m.channel === 'npm') console.log(`… npm-channel module ${m.name} is not managed here — \`npm update ${m.name}\` to pull it forward`);
|
|
187
|
+
}
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// a fresh clone with deps or a prepare/build script needs `npm install` to be usable
|
|
192
|
+
// (deps land; `prepare` runs on install and builds dist). failure warns, never crashes.
|
|
193
|
+
function buildClone(dest, name) {
|
|
194
|
+
let cp;
|
|
195
|
+
try { cp = JSON.parse(fs.readFileSync(path.join(dest, 'package.json'), 'utf8')); } catch { return; }
|
|
196
|
+
if (!cp.dependencies && !cp.scripts?.prepare && !cp.scripts?.build) return;
|
|
197
|
+
console.log(`… npm install in git_modules/${name}`);
|
|
198
|
+
const r = spawnSync('npm', ['install', '--no-fund', '--no-audit'], { cwd: dest, stdio: 'inherit' });
|
|
199
|
+
if (r.status !== 0) console.warn(`⚠ npm install failed in git_modules/${name} (exit ${r.status ?? r.error?.message}) — clone may be unbuilt; fix and re-run npm install there`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function tryGit(cwd, args) {
|
|
203
|
+
try { return execFileSync('git', args, { cwd }).toString().trim() || null; } catch { return null; }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function appendMissing(file, block) {
|
|
207
|
+
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
208
|
+
const missing = block.split('\n').filter((l) => l && !existing.split('\n').includes(l));
|
|
209
|
+
if (missing.length) fs.writeFileSync(file, (existing ? existing.trimEnd() + '\n' : '') + missing.join('\n') + '\n');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// dreamteamer install --clone <url> [name] — clone a module for development AND
|
|
213
|
+
// record it in the committed lockfile map (story 5.3)
|
|
214
|
+
export function installClone(ws, url, name) {
|
|
215
|
+
if (!url) throw new Error('usage: dreamteamer install --clone <git-url> [name]');
|
|
216
|
+
name = name && !name.startsWith('--') ? name : path.basename(url, '.git');
|
|
217
|
+
const dest = path.join(ws.root, 'git_modules', name);
|
|
218
|
+
if (fs.existsSync(dest)) throw new Error(`git_modules/${name} already exists`);
|
|
219
|
+
fs.mkdirSync(path.join(ws.root, 'git_modules'), { recursive: true });
|
|
220
|
+
execFileSync('git', ['clone', url, dest], { stdio: 'inherit' });
|
|
221
|
+
buildClone(dest, name);
|
|
222
|
+
const ref = tryGit(dest, ['rev-parse', '--abbrev-ref', 'HEAD']) ?? 'main';
|
|
223
|
+
const pkgPath = path.join(ws.root, 'package.json');
|
|
224
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
225
|
+
pkg.dreamteamer['git-modules'] = { ...pkg.dreamteamer['git-modules'], [name]: { url, ref } };
|
|
226
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, '\t') + '\n');
|
|
227
|
+
execFileSync('git', ['add', 'package.json'], { cwd: ws.root });
|
|
228
|
+
execFileSync('git', ['commit', '--quiet', '-m', `dreamteamer: install ${name} (git module)`, '--', 'package.json'], { cwd: ws.root });
|
|
229
|
+
console.log(`✔ git_modules/${name} (ref ${ref})`);
|
|
230
|
+
console.log('✔ package.json dreamteamer.git-modules updated');
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---- attached repos: `repos` records, materialized ON DEMAND ------------------------------
|
|
235
|
+
// A `repos` record declares a related git repo and NOTHING about the schema — the other half of
|
|
236
|
+
// what git_modules fuses together. Modules stay in package.json because compile can't read records
|
|
237
|
+
// until they're restored (no .dreamteamer → no schemas → no readable records); attached repos have
|
|
238
|
+
// no such constraint, so they get to be data.
|
|
239
|
+
|
|
240
|
+
const relPath = (root, p) => path.relative(root, p) || '.';
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Where a declared repo's working tree lives. Pure — never touches disk, so callers can resolve a
|
|
244
|
+
* path without materializing (that is how `status` reports presence).
|
|
245
|
+
*
|
|
246
|
+
* The engine deliberately does NOT know what `identity` means: a workspace may use it to select a
|
|
247
|
+
* `~/.gitconfig` includeIf folder so the clone commits as the right git user, but that resolution
|
|
248
|
+
* happens entirely outside the engine. Here it is just a path segment.
|
|
249
|
+
*/
|
|
250
|
+
export function repoPath(ws, fields) {
|
|
251
|
+
if (fields.path) return path.join(ws.root, fields.path);
|
|
252
|
+
const base = ws.pkg.dreamteamer?.['repos-path'] ?? 'projects';
|
|
253
|
+
return fields.identity
|
|
254
|
+
? path.join(ws.root, base, fields.identity, fields.name)
|
|
255
|
+
: path.join(ws.root, base, fields.name);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* dreamteamer repos ensure <id> — materialize ONE declared repo, idempotently.
|
|
260
|
+
* Cheap as a stat when already present, so callers never branch on presence themselves.
|
|
261
|
+
*/
|
|
262
|
+
export function ensureRepo(ws, id) {
|
|
263
|
+
const { fields } = new Store(ws).read('repos', id);
|
|
264
|
+
return materializeRepo(ws, id, fields);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** dreamteamer repos ensure --all — explicit opt-in (going offline, grepping across all of them). */
|
|
268
|
+
export function ensureAllRepos(ws) {
|
|
269
|
+
const out = [];
|
|
270
|
+
for (const { id, fields } of new Store(ws).readAll('repos')) out.push(materializeRepo(ws, id, fields));
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Declared repos and whether each is on disk. Presence is OBSERVED, never stored on the record —
|
|
276
|
+
* with lazy materialization "is it here?" is the first question anyone asks, and a stored answer
|
|
277
|
+
* would be wrong the moment someone deletes a folder.
|
|
278
|
+
*/
|
|
279
|
+
export function listRepos(ws) {
|
|
280
|
+
const out = [];
|
|
281
|
+
for (const { id, fields } of new Store(ws).readAll('repos')) {
|
|
282
|
+
const dest = repoPath(ws, fields);
|
|
283
|
+
out.push({ id, path: relPath(ws.root, dest), present: fs.existsSync(dest) });
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function materializeRepo(ws, id, fields) {
|
|
289
|
+
const dest = repoPath(ws, fields);
|
|
290
|
+
const ref = fields.ref ?? 'main';
|
|
291
|
+
const rp = relPath(ws.root, dest);
|
|
292
|
+
if (fs.existsSync(dest)) {
|
|
293
|
+
// same contract as install(): warn on drift, never force-sync, never touch a dirty tree
|
|
294
|
+
const head = tryGit(dest, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
295
|
+
const dirty = tryGit(dest, ['status', '--porcelain']);
|
|
296
|
+
if (head && head !== ref) {
|
|
297
|
+
console.warn(`⚠ ${rp}: HEAD is ${head}, record says ${ref} — not touching it${dirty ? ' (dirty)' : ''}`);
|
|
298
|
+
}
|
|
299
|
+
return { id, path: rp, present: true, cloned: false, ref: head ?? ref };
|
|
300
|
+
}
|
|
301
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
302
|
+
console.log(`… cloning ${fields.url} → ${rp} (${ref})`);
|
|
303
|
+
execFileSync('git', ['clone', '--branch', ref, fields.url, dest], { stdio: 'inherit' });
|
|
304
|
+
// NO buildClone() here, unlike install(): a prototype or app repo is not an npm module and
|
|
305
|
+
// must never have `npm install` run in it as a side effect of being materialized.
|
|
306
|
+
return { id, path: rp, present: true, cloned: true, ref };
|
|
307
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// presentation projection — descriptor → "how to render each field" rows, served at
|
|
2
|
+
// GET /api/presentation. this is the ADAPTER INVERSION (review, M3): the mapping the
|
|
3
|
+
// studio's transitional adapter synthesized client-side is genuinely useful, so it moves
|
|
4
|
+
// into the clean contract as an explicit projection; the studio consumes ONE contract and
|
|
5
|
+
// the client adapter shrinks to a thin path translator. shapes deliberately match what
|
|
6
|
+
// the studio components already speak (field {field,type,meta,schema}).
|
|
7
|
+
|
|
8
|
+
/** the projection for every collection: rows keyed by collection name + collection meta. */
|
|
9
|
+
export function presentation(descriptors) {
|
|
10
|
+
const collections = [];
|
|
11
|
+
const fields = {};
|
|
12
|
+
const relations = [];
|
|
13
|
+
for (const d of [...descriptors.values()].sort((a, b) => (a.order ?? 999) - (b.order ?? 999))) {
|
|
14
|
+
collections.push(collectionRow(d));
|
|
15
|
+
const rows = [];
|
|
16
|
+
rows.push({
|
|
17
|
+
field: 'id',
|
|
18
|
+
type: 'string',
|
|
19
|
+
meta: { collection: d.name, field: 'id', hidden: true, readonly: true, edit: 'input' },
|
|
20
|
+
schema: { name: 'id', is_primary_key: true, is_nullable: false },
|
|
21
|
+
});
|
|
22
|
+
// Synthesized like `id` above — never a schema property, so it's never written to disk
|
|
23
|
+
// (unknownFields would reject it) and never touches a collection's real frontmatter
|
|
24
|
+
// contract. `readonly: true` (not `hidden`) so it's a browse column and sortable; `formHidden`
|
|
25
|
+
// (studio-only convention, not a Directus concept — ContentDetail filters on it before handing
|
|
26
|
+
// fields to ItemForm) keeps it OUT of the record form now that PageHeader shows the richer
|
|
27
|
+
// author/message/date line instead (operator ask 2026-07-27). Computed server-side per
|
|
28
|
+
// request (server.js) from `git log` on the record's file — null for anything the compiled
|
|
29
|
+
// `.dreamteamer/` runtime backs (skills/agents/commands/…, gitignored) since there's no
|
|
30
|
+
// meaningful history to read there.
|
|
31
|
+
rows.push({
|
|
32
|
+
field: 'last-modified',
|
|
33
|
+
type: 'timestamp',
|
|
34
|
+
meta: { collection: d.name, field: 'last-modified', readonly: true, formHidden: true, view: 'date', view_options: { relative: true } },
|
|
35
|
+
schema: { name: 'last-modified', is_primary_key: false, is_nullable: true, default_value: null },
|
|
36
|
+
});
|
|
37
|
+
for (const [name, prop] of Object.entries(d.schema?.properties ?? {})) {
|
|
38
|
+
if (name === 'id') continue;
|
|
39
|
+
rows.push(fieldRow(d, name, prop, new Set(d.schema?.required ?? []).has(name), descriptors));
|
|
40
|
+
const target = referenceTargetOf(prop);
|
|
41
|
+
if (target) {
|
|
42
|
+
relations.push({ collection: d.name, field: name, related_collection: target, list: prop.type === 'array' });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
fields[d.name] = rows;
|
|
46
|
+
}
|
|
47
|
+
return { collections, fields, relations };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function collectionRow(d) {
|
|
51
|
+
const props = d.schema?.properties ?? {};
|
|
52
|
+
const titleField = ['title', 'name', 'subject'].find((f) => f in props);
|
|
53
|
+
const meta = { collection: d.name, record_type: d.storage?.suffix ?? d.name };
|
|
54
|
+
// resolved by compile — a surface renders this and never title-cases an id itself
|
|
55
|
+
if (typeof d.title === 'string' && d.title.length > 0) meta.title = d.title;
|
|
56
|
+
if (typeof d.title_template === 'string' && d.title_template.length > 0) meta.title_template = d.title_template;
|
|
57
|
+
if (titleField) meta.title_field = titleField;
|
|
58
|
+
if (typeof d.order === 'number') meta.order = d.order;
|
|
59
|
+
if (Array.isArray(d.list_fields)) meta.list_fields = d.list_fields;
|
|
60
|
+
if (typeof d.icon === 'string') meta.icon = d.icon;
|
|
61
|
+
if (typeof d.group === 'string') meta.group = d.group;
|
|
62
|
+
if (typeof d.description === 'string' && d.description.length > 0) meta.description = d.description;
|
|
63
|
+
return { collection: d.name, meta, system: d.storage?.base === 'runtime' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function referenceTargetOf(prop) {
|
|
67
|
+
const ref = prop.type === 'array' ? prop.items?.['x-reference'] : prop['x-reference'];
|
|
68
|
+
if (typeof ref !== 'string' || ref === '' || ref === '*') return null;
|
|
69
|
+
return ref;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The template that renders a VALUE of this field as a human label.
|
|
74
|
+
*
|
|
75
|
+
* Authored per-field with `x-title-template`, else INHERITED from the target collection's
|
|
76
|
+
* `title_template` — because "a company is labelled by its name" is a fact about companies, not
|
|
77
|
+
* about each of the eleven fields that point at one. Before this, that fact was hand-copied onto
|
|
78
|
+
* every referencing field as `x-display: '{{ name }}'`; 51 of the 54 sites in this workspace were
|
|
79
|
+
* exactly what the target already implies.
|
|
80
|
+
*/
|
|
81
|
+
function titleTemplateOf(prop, descriptors) {
|
|
82
|
+
const own = prop.type === 'array' ? prop.items?.['x-title-template'] : prop['x-title-template'];
|
|
83
|
+
if (typeof own === 'string' && own.length > 0) return own;
|
|
84
|
+
const target = referenceTargetOf(prop);
|
|
85
|
+
const inherited = target ? descriptors.get(target)?.title_template : undefined;
|
|
86
|
+
return typeof inherited === 'string' && inherited.length > 0 ? inherited : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function fieldRow(d, name, prop, isRequired, descriptors) {
|
|
90
|
+
const meta = { collection: d.name, field: name };
|
|
91
|
+
if (isRequired) meta.required = true;
|
|
92
|
+
// JSON Schema's own `description` on the property — what the field MEANS, authored in the
|
|
93
|
+
// module source beside the field. Carried through verbatim so a surface can explain a field
|
|
94
|
+
// without a second vocabulary (the UI shows it as the property row's tooltip).
|
|
95
|
+
if (typeof prop.description === 'string' && prop.description.length > 0) meta.description = prop.description;
|
|
96
|
+
// resolved by compile (titleCase of the field name unless authored) — the label every surface
|
|
97
|
+
// shows, so no component title-cases a field name on its own.
|
|
98
|
+
if (typeof prop.title === 'string' && prop.title.length > 0) meta.title = prop.title;
|
|
99
|
+
|
|
100
|
+
let type = 'string';
|
|
101
|
+
const target = referenceTargetOf(prop);
|
|
102
|
+
|
|
103
|
+
if (prop['x-body'] === true) {
|
|
104
|
+
type = 'text';
|
|
105
|
+
meta.special = ['dt-body'];
|
|
106
|
+
meta.edit = 'input-rich-text-md';
|
|
107
|
+
} else if (target && prop.type !== 'array') {
|
|
108
|
+
meta.special = ['dt-relation-path'];
|
|
109
|
+
} else if (target && prop.type === 'array') {
|
|
110
|
+
type = 'json';
|
|
111
|
+
meta.special = ['dt-relation-path', 'dt-relation-list'];
|
|
112
|
+
} else if (prop.type === 'array' && prop.items?.type === 'object') {
|
|
113
|
+
type = 'json';
|
|
114
|
+
meta.edit = 'list';
|
|
115
|
+
meta.view = 'list';
|
|
116
|
+
const listOptions = { fields: optionFieldsOf(prop.items) };
|
|
117
|
+
if (typeof prop.items['x-title-template'] === 'string') listOptions.template = prop.items['x-title-template'];
|
|
118
|
+
meta.edit_options = listOptions;
|
|
119
|
+
meta.view_options = listOptions;
|
|
120
|
+
} else if (prop.type === 'array') {
|
|
121
|
+
type = 'json';
|
|
122
|
+
meta.edit = 'tags';
|
|
123
|
+
meta.view = 'tags';
|
|
124
|
+
} else if (prop.type === 'boolean') {
|
|
125
|
+
type = 'boolean';
|
|
126
|
+
} else if (prop.type === 'integer') {
|
|
127
|
+
type = 'integer';
|
|
128
|
+
} else if (prop.type === 'number') {
|
|
129
|
+
type = 'float';
|
|
130
|
+
} else if (prop.type === 'object') {
|
|
131
|
+
type = 'json';
|
|
132
|
+
if (prop.properties && Object.keys(prop.properties).length > 0) {
|
|
133
|
+
meta.edit = 'nested';
|
|
134
|
+
meta.view = 'nested';
|
|
135
|
+
const nestedOptions = { fields: optionFieldsOf(prop) };
|
|
136
|
+
meta.edit_options = nestedOptions;
|
|
137
|
+
meta.view_options = nestedOptions;
|
|
138
|
+
}
|
|
139
|
+
} else if (prop.format === 'date') {
|
|
140
|
+
type = 'date';
|
|
141
|
+
} else if (prop.format === 'date-time') {
|
|
142
|
+
type = 'timestamp';
|
|
143
|
+
} else if (prop.format === 'markdown') {
|
|
144
|
+
type = 'text';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (Array.isArray(prop.enum) && prop.enum.length > 0) {
|
|
148
|
+
meta.edit_options = { choices: prop.enum.map((v) => ({ text: String(v), value: v })) };
|
|
149
|
+
}
|
|
150
|
+
const tpl = titleTemplateOf(prop, descriptors);
|
|
151
|
+
if (typeof tpl === 'string' && tpl.length > 0) meta.view_options = { ...meta.view_options, template: tpl };
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
field: name,
|
|
155
|
+
type,
|
|
156
|
+
meta,
|
|
157
|
+
schema: { name, is_primary_key: false, is_nullable: !isRequired, default_value: prop.default ?? null },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// sub-form shape (EditList rows / EditNested objects) derived recursively
|
|
162
|
+
function optionFieldsOf(objSchema) {
|
|
163
|
+
return Object.entries(objSchema.properties ?? {}).map(([field, prop]) => {
|
|
164
|
+
const def = { field, name: field, type: 'string' };
|
|
165
|
+
if (Array.isArray(prop.enum) && prop.enum.length > 0) {
|
|
166
|
+
def.edit = 'select-dropdown';
|
|
167
|
+
def.edit_options = { choices: prop.enum.map((v) => ({ text: String(v), value: v })) };
|
|
168
|
+
} else if (prop.type === 'boolean') {
|
|
169
|
+
def.type = 'boolean';
|
|
170
|
+
} else if (prop.type === 'integer' || prop.type === 'number') {
|
|
171
|
+
def.type = prop.type === 'integer' ? 'integer' : 'float';
|
|
172
|
+
} else if (prop.type === 'array' && prop.items?.type === 'object') {
|
|
173
|
+
def.type = 'json';
|
|
174
|
+
def.edit = 'list';
|
|
175
|
+
def.edit_options = { fields: optionFieldsOf(prop.items) };
|
|
176
|
+
} else if (prop.type === 'array') {
|
|
177
|
+
def.type = 'json';
|
|
178
|
+
def.edit = 'tags';
|
|
179
|
+
} else if (prop.type === 'object' && prop.properties && Object.keys(prop.properties).length > 0) {
|
|
180
|
+
def.type = 'json';
|
|
181
|
+
def.edit = 'nested';
|
|
182
|
+
def.edit_options = { fields: optionFieldsOf(prop) };
|
|
183
|
+
} else if (prop.type === 'object') {
|
|
184
|
+
def.type = 'json';
|
|
185
|
+
} else if (prop.format === 'markdown') {
|
|
186
|
+
def.type = 'text';
|
|
187
|
+
}
|
|
188
|
+
return def;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// record⇄command evaluation — which bound commands apply to a record (or a list
|
|
2
|
+
// selection), and in which state. a binding is a `command-bindings` record: a m2m
|
|
3
|
+
// join (command × collection) carrying `can-enter` / `can-exit` filter predicates,
|
|
4
|
+
// evaluated with the SAME operator set as list-view filters plus one-hop outbound
|
|
5
|
+
// ref traversal (tier 1). serves `dreamteamer commands for`, GET /api/commands/:name,
|
|
6
|
+
// and (through the extension's api.ts port) the studio's Commands tab.
|
|
7
|
+
import { matchesFilter } from './filter.js';
|
|
8
|
+
|
|
9
|
+
// memoized `<collection>/<id>` → parsed fields (or null) for ONE evaluation pass:
|
|
10
|
+
// overlapping refs across a 50-record selection parse once, and filter.js stays
|
|
11
|
+
// pure — it never learns about the store, it just gets this callback.
|
|
12
|
+
export function recordResolver(store) {
|
|
13
|
+
const memo = new Map();
|
|
14
|
+
return (ref) => {
|
|
15
|
+
if (memo.has(ref)) return memo.get(ref);
|
|
16
|
+
let target = null;
|
|
17
|
+
const slash = typeof ref === 'string' ? ref.indexOf('/') : -1;
|
|
18
|
+
if (slash > 0) {
|
|
19
|
+
try {
|
|
20
|
+
const { fields } = store.read(ref.slice(0, slash), ref.slice(slash + 1));
|
|
21
|
+
target = { ...fields, id: ref.slice(slash + 1) };
|
|
22
|
+
} catch { /* dangling ref or unknown collection — narrows, never widens */ }
|
|
23
|
+
}
|
|
24
|
+
memo.set(ref, target);
|
|
25
|
+
return target;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* every command bound to `collection`, evaluated over `ids` (possibly empty).
|
|
31
|
+
* target=collection bindings need no record — always runnable, invocation carries the
|
|
32
|
+
* collection name (so a command bound to several collections knows where to write).
|
|
33
|
+
* target=record bindings get a per-id state:
|
|
34
|
+
* done can-exit passes (the command's post-condition already holds)
|
|
35
|
+
* available can-enter passes (or no can-enter) and can-exit doesn't
|
|
36
|
+
* not-applicable can-enter fails (or the record doesn't resolve)
|
|
37
|
+
* `invocation` covers the ELIGIBLE ids only, space-separated — a deliberate contract:
|
|
38
|
+
* the UI shows it verbatim in an editable textarea, so nothing is silently dropped.
|
|
39
|
+
*/
|
|
40
|
+
export function commandsFor(store, collection, ids = []) {
|
|
41
|
+
store.descriptor(collection); // unknown collection throws here, not deep inside a filter walk
|
|
42
|
+
const resolve = recordResolver(store);
|
|
43
|
+
const commands = new Map();
|
|
44
|
+
for (const { id, fields } of store.readAll('commands')) commands.set(`commands/${id}`, fields);
|
|
45
|
+
const rows = [];
|
|
46
|
+
for (const { id: bindingId, fields: b } of store.readAll('command-bindings')) {
|
|
47
|
+
if (b.collection !== `collections/${collection}`) continue;
|
|
48
|
+
const cmd = commands.get(b.command);
|
|
49
|
+
if (!cmd) continue; // compile rejects dangling binding refs; stay safe on a stale runtime
|
|
50
|
+
const row = {
|
|
51
|
+
binding: `command-bindings/${bindingId}`,
|
|
52
|
+
command: b.command,
|
|
53
|
+
name: cmd.name,
|
|
54
|
+
description: b.description ?? cmd.description ?? '',
|
|
55
|
+
'argument-hint': cmd['argument-hint'] ?? null,
|
|
56
|
+
target: b.target ?? 'record',
|
|
57
|
+
};
|
|
58
|
+
if (row.target === 'collection') {
|
|
59
|
+
rows.push({ ...row, invocation: `/${cmd.name} ${collection}` });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const states = {};
|
|
63
|
+
for (const id of ids) {
|
|
64
|
+
const record = resolve(`${collection}/${id}`);
|
|
65
|
+
let state = 'not-applicable';
|
|
66
|
+
if (record) {
|
|
67
|
+
if (b['can-exit'] && matchesFilter(record, b['can-exit'], resolve)) state = 'done';
|
|
68
|
+
else if (!b['can-enter'] || matchesFilter(record, b['can-enter'], resolve)) state = 'available';
|
|
69
|
+
}
|
|
70
|
+
states[id] = state;
|
|
71
|
+
}
|
|
72
|
+
const eligible = ids.filter((id) => states[id] === 'available');
|
|
73
|
+
rows.push({
|
|
74
|
+
...row,
|
|
75
|
+
states,
|
|
76
|
+
eligible,
|
|
77
|
+
done: ids.filter((id) => states[id] === 'done'),
|
|
78
|
+
invocation: eligible.length ? `/${cmd.name} ${eligible.map((id) => `${collection}/${id}`).join(' ')}` : null,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// record commands first (the headline on a record page), then collection commands; name-sorted within
|
|
82
|
+
rows.sort((a, b) => (a.target === b.target ? a.name.localeCompare(b.name) : a.target === 'collection' ? 1 : -1));
|
|
83
|
+
return { collection, ids, commands: rows };
|
|
84
|
+
}
|
package/src/records.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// shared record primitives — parsing, id patterns, error formatting — used by
|
|
2
|
+
// both the validating store (hard, write-time) and check (soft, report-only).
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { load } from './yaml.js';
|
|
6
|
+
|
|
7
|
+
export function parseRecord(file, d, bodyField) {
|
|
8
|
+
return parseRecordText(fs.readFileSync(file, 'utf8'), d, bodyField);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseRecordText(text, d, bodyField) {
|
|
12
|
+
const codec = d.storage.codec ?? 'md';
|
|
13
|
+
if (codec === 'yaml') return load(text) ?? {};
|
|
14
|
+
if (codec === 'json') return JSON.parse(text);
|
|
15
|
+
let fields = {};
|
|
16
|
+
let body = text;
|
|
17
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
|
|
18
|
+
if (m) {
|
|
19
|
+
fields = load(m[1]) ?? {};
|
|
20
|
+
body = text.slice(m[0].length);
|
|
21
|
+
}
|
|
22
|
+
if (bodyField && body.trim()) fields[bodyField] = body;
|
|
23
|
+
return fields;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// id patterns may use unicode property escapes — compile with the u flag
|
|
27
|
+
export function patternRe(pattern) {
|
|
28
|
+
try { return new RegExp(pattern, 'u'); } catch { return new RegExp(pattern); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ajv errors, humanized: echo the offending value (the one datum the reader needs)
|
|
32
|
+
export function fmtAjvError(e, fields) {
|
|
33
|
+
const fieldPath = e.instancePath.slice(1).replace(/\//g, '.');
|
|
34
|
+
const value = fieldPath ? fieldPath.split('.').reduce((v, k) => v?.[k], fields) : undefined;
|
|
35
|
+
if (e.keyword === 'enum') return `field ${fieldPath}: "${value}" not in enum [${e.params.allowedValues.join(', ')}]`;
|
|
36
|
+
return `field ${fieldPath || '(root)'}: ${JSON.stringify(value) ?? ''} ${e.message}`.trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// unknown keys relative to a schema that declares properties (typo detector)
|
|
40
|
+
export function unknownFields(schema, fields) {
|
|
41
|
+
const props = schema?.properties ?? {};
|
|
42
|
+
if (!Object.keys(props).length) return [];
|
|
43
|
+
return Object.keys(fields).filter((k) => !(k in props));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---- shared reader primitives (review finding 11: walk/EXT existed 2-4×, diverging) ----
|
|
47
|
+
|
|
48
|
+
export const EXT = { md: '.md', yaml: '.yaml', json: '.json' };
|
|
49
|
+
|
|
50
|
+
const JUNK_DIRS = new Set(['__pycache__', 'node_modules']);
|
|
51
|
+
const JUNK_FILE = /\.(pyc|pyo)$|^\.DS_Store$/;
|
|
52
|
+
|
|
53
|
+
// THE collection walk — junk-excluding everywhere (store/check used to see .pyc files
|
|
54
|
+
// compile deliberately skipped; one walk, one verdict).
|
|
55
|
+
export function* walk(dir) {
|
|
56
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
57
|
+
if (name.startsWith('.') || JUNK_DIRS.has(name)) continue;
|
|
58
|
+
if (JUNK_FILE.test(name)) continue;
|
|
59
|
+
const p = path.join(dir, name);
|
|
60
|
+
if (fs.statSync(p).isDirectory()) yield* walk(p);
|
|
61
|
+
else yield p;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ids are PATHS — but only downward ones. traversal segments, absolute paths and
|
|
66
|
+
// backslashes are rejected before any fs join (review finding 1: an escaping --id
|
|
67
|
+
// wrote a record outside the repo and orphaned others inside it).
|
|
68
|
+
export function assertSafeId(id) {
|
|
69
|
+
if (typeof id !== 'string' || id === '') throw new Error(`invalid id "${id}" — nothing was written.`);
|
|
70
|
+
if (id.startsWith('/') || id.includes('\\') || id.split('/').some((s) => s === '' || s === '.' || s === '..')) {
|
|
71
|
+
throw new Error(`invalid id "${id}" — ids are relative paths, no "."/".." segments, no leading slash. nothing was written.`);
|
|
72
|
+
}
|
|
73
|
+
}
|