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/server.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// dreamteamer server — the CLEAN REST contract over the same validating store the
|
|
2
|
+
// CLI uses (decision #16: nothing Directus-flavored lives here; the studio's api
|
|
3
|
+
// client carries the one transitional adapter). serves the built studio at /admin.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import express from 'express';
|
|
8
|
+
import { Store, bodyField } from './store.js';
|
|
9
|
+
import { readManifest, staleness, discoverModules, CompileError } from './compile.js';
|
|
10
|
+
import { presentation } from './presentation.js';
|
|
11
|
+
import { createCollection, removeCollection, addField, updateField, removeField, fieldDef, saveUiView, removeUiView } from './schema-ops.js';
|
|
12
|
+
import { history, historyDiff } from './history.js';
|
|
13
|
+
import { matchesFilter } from './filter.js';
|
|
14
|
+
import { sortRows } from './temporal.js';
|
|
15
|
+
import { commandsFor, recordResolver } from './record-commands.js';
|
|
16
|
+
import { distinctValues } from './field-values.js';
|
|
17
|
+
import { slugOrHash } from './template.js';
|
|
18
|
+
|
|
19
|
+
export function startServer(ws, { port = 8080, host = '127.0.0.1' } = {}) {
|
|
20
|
+
const app = express();
|
|
21
|
+
app.use(express.json({ limit: '10mb' }));
|
|
22
|
+
|
|
23
|
+
// a fresh Store per mutating request would be wasteful; per-process with manual
|
|
24
|
+
// reload on demand is enough for a local single-operator server (RAD).
|
|
25
|
+
let store = new Store(ws);
|
|
26
|
+
const reload = () => { store = new Store(ws); };
|
|
27
|
+
|
|
28
|
+
// review finding 9: descriptors loaded once per process made `compile` invisible to a
|
|
29
|
+
// running server. cheap fix: stat the manifest per request, rebuild the Store when it moved.
|
|
30
|
+
const manifestPath = path.join(ws.root, '.dreamteamer', 'manifest.yaml');
|
|
31
|
+
let manifestMtime = fs.existsSync(manifestPath) ? fs.statSync(manifestPath).mtimeMs : 0;
|
|
32
|
+
const freshStore = () => {
|
|
33
|
+
const m = fs.existsSync(manifestPath) ? fs.statSync(manifestPath).mtimeMs : 0;
|
|
34
|
+
if (m !== manifestMtime) { manifestMtime = m; reload(); }
|
|
35
|
+
return store;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const api = express.Router();
|
|
39
|
+
api.use((req, res, next) => { freshStore(); next(); });
|
|
40
|
+
|
|
41
|
+
// current operator id — same rule init seeds users with (slugOrHash of git user.name),
|
|
42
|
+
// so `@me` filters in ui-views resolve to the seeded user record.
|
|
43
|
+
let operatorId = null;
|
|
44
|
+
try {
|
|
45
|
+
operatorId = slugOrHash(execFileSync('git', ['config', 'user.name'], { cwd: ws.root }).toString().trim());
|
|
46
|
+
} catch { /* no git identity — @me filters simply won't narrow */ }
|
|
47
|
+
|
|
48
|
+
api.get('/info', (req, res) => {
|
|
49
|
+
const manifest = readManifest(ws.root);
|
|
50
|
+
const stale = staleness(ws.root);
|
|
51
|
+
res.json({
|
|
52
|
+
name: ws.pkg.name,
|
|
53
|
+
host: manifest?.host,
|
|
54
|
+
compiled: manifest?.compiled,
|
|
55
|
+
modules: manifest?.modules ?? [],
|
|
56
|
+
ui: manifest?.ui ?? [], // module UI bundles staged at /ui/<name>/app.js
|
|
57
|
+
user: operatorId,
|
|
58
|
+
stale: stale.stale?.length ?? 0,
|
|
59
|
+
collections: [...store.descriptors.keys()],
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
api.get('/schema', (req, res) => {
|
|
64
|
+
res.json([...store.descriptors.values()].sort((a, b) => (a.order ?? 999) - (b.order ?? 999)));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
api.get('/collections/:name/records', (req, res) => {
|
|
68
|
+
const d = store.descriptor(req.params.name);
|
|
69
|
+
const bf = bodyField(d);
|
|
70
|
+
const { limit = 200, offset = 0, sort, q, filter, ...rest } = req.query;
|
|
71
|
+
const modified = gitModifiedMap(ws.root, store.dir(d));
|
|
72
|
+
let rows = [];
|
|
73
|
+
for (const { id, file, fields } of store.readAll(req.params.name)) {
|
|
74
|
+
rows.push({ ...fields, id, 'last-modified': modified.get(path.relative(ws.root, file)) ?? null }); // record id WINS over any schema field named "id"
|
|
75
|
+
}
|
|
76
|
+
// rich filter: ?filter=<json> — Directus-style operators (_eq/_contains/_and/...)
|
|
77
|
+
// + one-hop relational conditions (tier 1) via the memoized resolver
|
|
78
|
+
if (filter) {
|
|
79
|
+
const f = typeof filter === 'string' ? JSON.parse(filter) : filter;
|
|
80
|
+
const resolve = recordResolver(store);
|
|
81
|
+
rows = rows.filter((r) => matchesFilter(r, f, resolve));
|
|
82
|
+
}
|
|
83
|
+
// simple equality: filter[field]=value or bare field=value
|
|
84
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
85
|
+
const key = k.startsWith('filter[') ? k.slice(7, -1) : k;
|
|
86
|
+
rows = rows.filter((r) => String(r[key] ?? '') === String(v));
|
|
87
|
+
}
|
|
88
|
+
if (q) {
|
|
89
|
+
const needle = String(q).toLowerCase();
|
|
90
|
+
rows = rows.filter((r) => JSON.stringify(r).toLowerCase().includes(needle));
|
|
91
|
+
}
|
|
92
|
+
sortRows(rows, sort);
|
|
93
|
+
const total = rows.length;
|
|
94
|
+
rows = rows.slice(Number(offset), Number(offset) + Number(limit));
|
|
95
|
+
const wantBody = req.query['with-body'] === 'true';
|
|
96
|
+
if (bf && !wantBody) for (const r of rows) delete r[bf];
|
|
97
|
+
res.json({ records: rows, total });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
api.get('/collections/:name/records/*id', (req, res) => {
|
|
101
|
+
const { fields, file } = store.read(req.params.name, idParam(req));
|
|
102
|
+
const commit = gitLastCommit(ws.root, file);
|
|
103
|
+
res.json({
|
|
104
|
+
id: idParam(req),
|
|
105
|
+
fields: {
|
|
106
|
+
...fields,
|
|
107
|
+
'last-modified': commit?.date ?? null,
|
|
108
|
+
'$last-modified-by': commit?.author ?? null,
|
|
109
|
+
'$last-commit-message': commit?.message ?? null,
|
|
110
|
+
},
|
|
111
|
+
path: path.relative(ws.root, file),
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
api.post('/collections/:name/records', (req, res) => {
|
|
116
|
+
const { id: explicitId, ...fields } = req.body ?? {};
|
|
117
|
+
const { id, file } = store.add(req.params.name, fields, { id: explicitId });
|
|
118
|
+
res.status(201).json({ id, path: path.relative(ws.root, file) });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
api.patch('/collections/:name/records/*id', (req, res) => {
|
|
122
|
+
// clients may echo synthetic response keys back on save (id/path/last-modified/the two
|
|
123
|
+
// $-prefixed commit-info fields) — never persist any of them.
|
|
124
|
+
const { id: _id, path: _path, 'last-modified': _lm, '$last-modified-by': _lmb, '$last-commit-message': _lcm, ...changes } = req.body ?? {};
|
|
125
|
+
store.set(req.params.name, idParam(req), changes);
|
|
126
|
+
res.json({ id: idParam(req) });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
api.delete('/collections/:name/records/*id', (req, res) => {
|
|
130
|
+
store.rm(req.params.name, idParam(req), { force: req.query.force === 'true' });
|
|
131
|
+
res.json({ id: idParam(req) });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
api.post('/collections/:name/rename', (req, res) => {
|
|
135
|
+
const { old: oldId, new: newId } = req.body ?? {};
|
|
136
|
+
const out = store.rename(req.params.name, oldId, newId);
|
|
137
|
+
res.json(out);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
api.get('/history/:name/*id', (req, res) => {
|
|
141
|
+
res.json(history(store, req.params.name, idParam(req)));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// presentation projection (adapter inversion, M3): how to RENDER each field/collection.
|
|
145
|
+
api.get('/presentation', (req, res) => {
|
|
146
|
+
res.json(presentation(store.descriptors));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// bound commands + per-record state for the Commands tab — ?ids=<id>[,<id>…]
|
|
150
|
+
// (same op as `dreamteamer commands for`; the UI only renders what this returns)
|
|
151
|
+
api.get('/commands/:name', (req, res) => {
|
|
152
|
+
const ids = typeof req.query.ids === 'string' ? req.query.ids.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
153
|
+
res.json(commandsFor(store, req.params.name, ids));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// the vocabulary a field actually uses — what the filter panel offers as choices for a plain
|
|
157
|
+
// `type: string` field no enum describes (same op as `dreamteamer <c> values <field>`).
|
|
158
|
+
api.get('/collections/:name/values/:field', (req, res) => {
|
|
159
|
+
const limit = req.query.limit === undefined ? undefined : Number(req.query.limit);
|
|
160
|
+
res.json(distinctValues(store, req.params.name, req.params.field, { limit }));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ---- schema writes (M3): source-writing ops behind the compile dry-run gate ------
|
|
164
|
+
// every op writes the workspace descriptor source, proves it with a real compile
|
|
165
|
+
// (CompileError → 400, source restored), commits, and reloads the Store.
|
|
166
|
+
const schemaOp = (fn) => (req, res, next) => {
|
|
167
|
+
try {
|
|
168
|
+
const out = fn(req);
|
|
169
|
+
reload();
|
|
170
|
+
res.json(out);
|
|
171
|
+
} catch (e) { next(e); }
|
|
172
|
+
};
|
|
173
|
+
api.post('/schema/collections', schemaOp((req) =>
|
|
174
|
+
createCollection(ws, store, { name: req.body?.name, template: req.body?.template })));
|
|
175
|
+
api.delete('/schema/collections/:name', schemaOp((req) =>
|
|
176
|
+
removeCollection(ws, store, req.params.name, { force: req.query.force === 'true' })));
|
|
177
|
+
api.post('/schema/collections/:name/fields', schemaOp((req) => {
|
|
178
|
+
const b = req.body ?? {};
|
|
179
|
+
const prop = b.prop ?? fieldDef(store, b);
|
|
180
|
+
return addField(ws, store, req.params.name, { name: b.name, prop, required: b.required === true });
|
|
181
|
+
}));
|
|
182
|
+
api.patch('/schema/collections/:name/fields/:field', schemaOp((req) => {
|
|
183
|
+
const b = req.body ?? {};
|
|
184
|
+
const prop = b.prop ?? fieldDef(store, b);
|
|
185
|
+
return updateField(ws, store, req.params.name, req.params.field, { prop, required: b.required });
|
|
186
|
+
}));
|
|
187
|
+
api.delete('/schema/collections/:name/fields/:field', schemaOp((req) =>
|
|
188
|
+
removeField(ws, store, req.params.name, req.params.field)));
|
|
189
|
+
|
|
190
|
+
// saved views: a studio view IS a ui-view record (source-written, compile-gated)
|
|
191
|
+
api.post('/schema/ui-views', schemaOp((req) => saveUiView(ws, store, { id: req.body?.id, view: req.body?.view })));
|
|
192
|
+
api.delete('/schema/ui-views/:id', schemaOp((req) => removeUiView(ws, store, req.params.id)));
|
|
193
|
+
|
|
194
|
+
// per-record revision diff + revert (M3: git already has the data; this exposes it)
|
|
195
|
+
api.get('/history-diff/:name/*id', (req, res) => {
|
|
196
|
+
res.json(historyDiff(store, req.params.name, idParam(req), String(req.query.hash ?? 'HEAD')));
|
|
197
|
+
});
|
|
198
|
+
api.post('/collections/:name/records-revert/*id', (req, res) => {
|
|
199
|
+
res.json(store.revert(req.params.name, idParam(req), String(req.body?.hash ?? '')));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
api.post('/reload', (req, res) => { reload(); res.json({ ok: true }); });
|
|
204
|
+
|
|
205
|
+
app.use('/api', api);
|
|
206
|
+
|
|
207
|
+
// module UI bundles staged by compile (.dreamteamer/ui/<module>/app.js)
|
|
208
|
+
app.use('/ui', express.static(path.join(ws.root, '.dreamteamer', 'ui')));
|
|
209
|
+
|
|
210
|
+
// error contract: store errors are 400 (validation) / 404 (missing) / 409 (referenced)
|
|
211
|
+
app.use((err, req, res, next) => {
|
|
212
|
+
const msg = err.message ?? String(err);
|
|
213
|
+
const code = err instanceof CompileError ? 400
|
|
214
|
+
: /no such record/.test(msg) ? 404
|
|
215
|
+
: /referenced by|already exists/.test(msg) ? 409 : 400;
|
|
216
|
+
res.status(code).json({ error: msg });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// studio: explicit config (pkg.dreamteamer.studio, a path TO a dist dir) wins;
|
|
220
|
+
// else the first discovered module (channel precedence order) shipping a built
|
|
221
|
+
// studio — studio/dist for the inline engine, dist/ for a dedicated studio package.
|
|
222
|
+
let studioDist = null;
|
|
223
|
+
if (typeof ws.pkg.dreamteamer?.studio === 'string') {
|
|
224
|
+
studioDist = path.join(ws.root, ws.pkg.dreamteamer.studio);
|
|
225
|
+
} else {
|
|
226
|
+
outer: for (const m of discoverModules(ws.root, ws.pkg).modules) {
|
|
227
|
+
for (const cand of [path.join(m.root, 'studio', 'dist'), path.join(m.root, 'dist')]) {
|
|
228
|
+
if (fs.existsSync(path.join(cand, 'index.html'))) { studioDist = cand; break outer; }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (studioDist && fs.existsSync(path.join(studioDist, 'index.html'))) {
|
|
233
|
+
app.use('/admin', express.static(studioDist));
|
|
234
|
+
app.get('/admin/*rest', (req, res) => res.sendFile(path.join(studioDist, 'index.html')));
|
|
235
|
+
} else {
|
|
236
|
+
app.get('/admin', (req, res) => res.status(503).send('studio not built — run: npm run build:studio, or install @dreamteamer/studio'));
|
|
237
|
+
}
|
|
238
|
+
const hasStudio = !!(studioDist && fs.existsSync(path.join(studioDist, 'index.html')));
|
|
239
|
+
app.get('/', (req, res) => res.redirect(hasStudio ? '/admin' : '/api'));
|
|
240
|
+
|
|
241
|
+
return new Promise((resolve) => {
|
|
242
|
+
const server = app.listen(port, host, () => {
|
|
243
|
+
// Only advertise /admin when a studio is actually installed. No studio ships with the
|
|
244
|
+
// engine, so naming it unconditionally sent every new user to a 503.
|
|
245
|
+
const where = hasStudio ? `/admin (api: /api)` : `/api`;
|
|
246
|
+
console.log(`✔ dreamteamer server at http://${host}:${port}${where}`);
|
|
247
|
+
resolve(server);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function idParam(req) {
|
|
253
|
+
const id = req.params.id;
|
|
254
|
+
return Array.isArray(id) ? id.join('/') : id;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// list-level "last modified" (operator ask 2026-07-27): ONE `git log` per collection listing —
|
|
258
|
+
// newest-first, so the first sighting of a path is its most recent touching commit — rather than
|
|
259
|
+
// one process spawn per record. `dir` outside the repo (or the repo having no history for it,
|
|
260
|
+
// e.g. a brand-new untracked file, or a runtime-based collection whose records live in the
|
|
261
|
+
// gitignored `.dreamteamer/` runtime) degrades to an empty map, i.e. every row gets `null`.
|
|
262
|
+
function gitModifiedMap(root, dir) {
|
|
263
|
+
const map = new Map();
|
|
264
|
+
const rel = path.relative(root, dir);
|
|
265
|
+
if (rel.startsWith('..')) return map;
|
|
266
|
+
let out;
|
|
267
|
+
try {
|
|
268
|
+
out = execFileSync('git', ['log', '--format=%x01%aI', '--name-only', '--', rel], { cwd: root }).toString();
|
|
269
|
+
} catch { return map; }
|
|
270
|
+
let date = null;
|
|
271
|
+
for (const line of out.split('\n')) {
|
|
272
|
+
if (line.startsWith('\x01')) { date = line.slice(1); continue; }
|
|
273
|
+
if (!line) continue;
|
|
274
|
+
if (!map.has(line)) map.set(line, date);
|
|
275
|
+
}
|
|
276
|
+
return map;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// single-record commit info (detail page header, operator ask 2026-07-27: author + message
|
|
280
|
+
// alongside the date, GitHub-file-header style) — one cheap `git log -1` on just that file.
|
|
281
|
+
// Author name only (%an) — no Co-Authored-By trailer parsing, by design (scope call, not a gap).
|
|
282
|
+
function gitLastCommit(root, file) {
|
|
283
|
+
const rel = path.relative(root, file);
|
|
284
|
+
if (rel.startsWith('..')) return null;
|
|
285
|
+
try {
|
|
286
|
+
const out = execFileSync('git', ['log', '-1', '--format=%aI%x00%an%x00%s', '--', rel], { cwd: root }).toString().trim();
|
|
287
|
+
if (!out) return null;
|
|
288
|
+
const [date, author, message] = out.split('\0');
|
|
289
|
+
return { date, author, message };
|
|
290
|
+
} catch { return null; }
|
|
291
|
+
}
|