dreamteamer 0.9.1 → 0.12.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/README.md +24 -0
- package/collections/collections.collection.yaml +18 -0
- package/collections/repos.collection.yaml +3 -0
- package/package.json +3 -2
- package/skills/building-dreamteamer/SKILL.md +6 -6
- package/skills/building-dreamteamer/references/collections.md +9 -9
- package/skills/building-dreamteamer/references/commands.md +2 -2
- package/skills/building-dreamteamer/references/ui-views.md +1 -1
- package/skills/using-dreamteamer/SKILL.md +30 -2
- package/skills/using-dreamteamer/references/records.md +7 -7
- package/src/cli.js +219 -55
- package/src/collections-cli.js +54 -16
- package/src/compile.js +44 -7
- package/src/env-vars.js +40 -0
- package/src/fractional-index.js +44 -0
- package/src/harnesses.js +167 -7
- package/src/presentation.js +5 -0
- package/src/ref.js +13 -0
- package/src/runtime.js +1 -1
- package/src/server.js +28 -0
package/src/cli.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
// dreamteamer CLI —
|
|
2
|
-
//
|
|
1
|
+
// dreamteamer CLI — VERB-FIRST: `dt <verb> [<target>]`, over the same primitives every surface uses.
|
|
2
|
+
//
|
|
3
|
+
// The verb set is CLOSED. `run()` switches on it and anything unrecognised is an error, because the
|
|
4
|
+
// predecessor grammar (`dt <collection> <verb>`) made the FALLBACK the collection path: a typo
|
|
5
|
+
// dispatched to a collection lookup and answered "unknown collection", which is a true sentence
|
|
6
|
+
// about the wrong thing. It also meant no verb could ever be named without a noun in front of it
|
|
7
|
+
// — `dt resolve '<string>'` had nowhere to live — and a namespaced reference had to be typed as two
|
|
8
|
+
// arguments that only the caller knew belonged together.
|
|
9
|
+
//
|
|
10
|
+
// This file TRANSLATES; it does not implement. Every record and schema verb lands on
|
|
11
|
+
// `collectionCommand(ws, collection, verb, args)`, whose signature is unchanged.
|
|
3
12
|
import fs from 'node:fs';
|
|
4
13
|
import path from 'node:path';
|
|
5
14
|
import { execFileSync } from 'node:child_process';
|
|
@@ -11,6 +20,8 @@ import { init, install, installClone, update, listRepos } from './init.js';
|
|
|
11
20
|
import { deriveEvents } from './events.js';
|
|
12
21
|
import { commitPending } from './commit.js';
|
|
13
22
|
import { Store } from './store.js';
|
|
23
|
+
import { splitRef } from './ref.js';
|
|
24
|
+
import { parseEnvValues, renderTemplate } from './env-vars.js';
|
|
14
25
|
|
|
15
26
|
// git calls whose failure we CATCH must not print git's own error: execFileSync forwards the
|
|
16
27
|
// child's stderr to ours unless told otherwise, so a handled "not a git repository" still
|
|
@@ -18,9 +29,64 @@ import { Store } from './store.js';
|
|
|
18
29
|
const QUIET = ['ignore', 'pipe', 'ignore'];
|
|
19
30
|
|
|
20
31
|
|
|
21
|
-
const USAGE = `usage: dreamteamer <
|
|
32
|
+
const USAGE = `usage: dreamteamer <verb> [<target>] [flags]
|
|
22
33
|
|
|
23
|
-
|
|
34
|
+
record verbs (hard validation — invalid writes are rejected before disk).
|
|
35
|
+
A <target> is either a collection name or a <collection>/<id> reference; the reference splits at
|
|
36
|
+
the longest DECLARED collection prefix, so finance/transactions/2026/03/coffee is ONE argument:
|
|
37
|
+
list <collection> [--filter k=v] [--where <json>] [--sort [-]<field>] [--json]
|
|
38
|
+
(--where takes the studio's operator set, e.g.
|
|
39
|
+
'{"starts":{"_gte":"2026-07-01"}}'; date-times
|
|
40
|
+
sort and compare as instants, across offsets)
|
|
41
|
+
get <collection>/<id> [--json]
|
|
42
|
+
add <collection> --<field> <value> … [--id <explicit-id>]
|
|
43
|
+
set <collection>/<id> <field>=<value> …
|
|
44
|
+
rm <collection>/<id> [--force]
|
|
45
|
+
rename <collection>/<id> <new-id> (rewrites all inbound refs, ONE commit)
|
|
46
|
+
move <collection>/<id> --after|--before <id> | --top | --bottom
|
|
47
|
+
move <collection> --init (place every record that has no sort value yet)
|
|
48
|
+
values <collection> <field> [--limit n] (the vocabulary a field actually uses —
|
|
49
|
+
what a filter/validator offers as choices)
|
|
50
|
+
history <collection>/<id> [--json] (git revisions of this record, newest first)
|
|
51
|
+
diff <collection>/<id> [--hash <sha>] (the patch one revision applied; defaults to HEAD)
|
|
52
|
+
revert <collection>/<id> --hash <sha> (restore the content at <sha>, as a NEW commit)
|
|
53
|
+
commands <collection>[/<id>] [--ids <id>,…] (bound commands + per-record state:
|
|
54
|
+
available / done / not-applicable)
|
|
55
|
+
ensure <repos-id> | --all [--json] (materialize an attached repo's working tree ON
|
|
56
|
+
DEMAND — never at install; --all is the explicit
|
|
57
|
+
opt-in, e.g. before going offline)
|
|
58
|
+
resolve '<string>' | <collection>/<id> <field>
|
|
59
|
+
(render \${env:NAME} · \${workspaceFolder} ·
|
|
60
|
+
\${userHome} — the ONLY substitution point; a
|
|
61
|
+
record keeps the template verbatim. An array
|
|
62
|
+
field prints one item per line)
|
|
63
|
+
|
|
64
|
+
schema verbs (write SOURCES through a compile gate, never the runtime — a different act, so a
|
|
65
|
+
different word in front of it):
|
|
66
|
+
schema add-collection --name <name> [--namespace <ns>] [--template docs|entity]
|
|
67
|
+
(--namespace health --name doctors === --name health/doctors; the
|
|
68
|
+
namespace must already be declared in dreamteamer.namespaces, and
|
|
69
|
+
records land in data/<ns>/<name>/)
|
|
70
|
+
schema rm-collection <name> [--force] (--force required if it still has records)
|
|
71
|
+
schema rename-collection <old> <new> (or <old> --namespace <ns> to move it into one)
|
|
72
|
+
moves the descriptor AND the records, re-suffixes files when the
|
|
73
|
+
suffix was derived, rewrites every inbound reference, ONE commit
|
|
74
|
+
schema add-field <collection> --name <field> --type <type> [--options a,b] [--default-value v]
|
|
75
|
+
[--required true] [--description "what this field means"]
|
|
76
|
+
types: string text markdown boolean number integer date datetime
|
|
77
|
+
enum tags <collection> — a date-time may be written as
|
|
78
|
+
"2026-07-28 12:00" or "2026-07-28T12:00"; the local offset is
|
|
79
|
+
stamped on for you (2026-07-28T12:00:00+03:00)
|
|
80
|
+
schema update-field <collection> --name <field> --type <type> [--options a,b] [--default-value v]
|
|
81
|
+
[--required true|false] [--description "…"]
|
|
82
|
+
(an existing description survives a retype)
|
|
83
|
+
schema remove-field <collection> --name <field>
|
|
84
|
+
schema add-view --path </route> --target list --collection collections/<c> --layout <id>
|
|
85
|
+
[--id <id>] [k.v=…]
|
|
86
|
+
schema set-view <id> <key>=<value> … (dotted keys: options.sort=-date, nav.label=Recent)
|
|
87
|
+
schema rm-view <id>
|
|
88
|
+
|
|
89
|
+
workspace verbs:
|
|
24
90
|
init write the workspace skeleton into the current directory (never compiles)
|
|
25
91
|
--version print the engine version (works anywhere)
|
|
26
92
|
install restore git_modules/ from the lockfile map; --clone <url> [name] adds one
|
|
@@ -35,51 +101,30 @@ commands:
|
|
|
35
101
|
commit publish records already written to disk: samples git status over every
|
|
36
102
|
collection's record dirs, one commit PER REPO, subject composed from the
|
|
37
103
|
status letters. [<collection> …] to scope, [-m <subject>], [--dry-run]
|
|
104
|
+
help this text
|
|
105
|
+
`;
|
|
38
106
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
<collection> get <id> [--json]
|
|
45
|
-
<collection> add --<field> <value> … [--id <explicit-id>]
|
|
46
|
-
<collection> set <id> <field>=<value> …
|
|
47
|
-
<collection> rm <id> [--force]
|
|
48
|
-
<collection> rename <old-id> <new-id> (rewrites all inbound refs, ONE commit)
|
|
49
|
-
<collection> history <id> [--json] (git revisions of this record, newest first)
|
|
50
|
-
<collection> diff <id> [--hash <sha>] (the patch one revision applied; defaults to HEAD)
|
|
51
|
-
<collection> revert <id> --hash <sha> (restore the content at <sha>, as a NEW commit)
|
|
107
|
+
// Record verbs, split by what their <target> means. `move` and `commands` are in NEITHER set: both
|
|
108
|
+
// accept either shape, and which one it is has to be decided against the declared collections.
|
|
109
|
+
const REF_VERBS = new Set(['get', 'set', 'rm', 'rename', 'history', 'diff', 'revert']);
|
|
110
|
+
const COLLECTION_VERBS = new Set(['list', 'add', 'values']);
|
|
111
|
+
const EITHER_VERBS = new Set(['move', 'commands']);
|
|
52
112
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
types: string text markdown boolean number integer date datetime
|
|
69
|
-
enum tags <collection> — a date-time may be written as
|
|
70
|
-
"2026-07-28 12:00" or "2026-07-28T12:00"; the local offset is
|
|
71
|
-
stamped on for you (2026-07-28T12:00:00+03:00)
|
|
72
|
-
<collection> update-field --name <field> --type <type> [--options a,b] [--default-value v] [--required true|false]
|
|
73
|
-
[--description "…"] (an existing description survives a retype)
|
|
74
|
-
<collection> remove-field --name <field>
|
|
75
|
-
ui-views add --path </route> --target list --collection collections/<c> --layout <id> [--id <id>] [k.v=…]
|
|
76
|
-
ui-views set <id> <key>=<value> … (dotted keys: options.sort=-date, nav.label=Recent)
|
|
77
|
-
ui-views rm <id>
|
|
78
|
-
commands for <collection>[/<id>] [--ids <id>,…] (bound commands + per-record state:
|
|
79
|
-
available / done / not-applicable)
|
|
80
|
-
<collection> values <field> [--limit n] (the vocabulary a field actually uses —
|
|
81
|
-
what a filter/validator offers as choices)
|
|
82
|
-
`;
|
|
113
|
+
// `schema <op>` → the (collection, verb) pair the implementation layer already answers to. The
|
|
114
|
+
// collections are literals: `collections` and `ui-views` are SYSTEM-stored, which is precisely what
|
|
115
|
+
// makes these a separate group in the grammar rather than records like any other.
|
|
116
|
+
const SCHEMA_OPS = {
|
|
117
|
+
'add-collection': ['collections', 'add'],
|
|
118
|
+
'rm-collection': ['collections', 'rm'],
|
|
119
|
+
'rename-collection': ['collections', 'rename'],
|
|
120
|
+
'add-view': ['ui-views', 'add'],
|
|
121
|
+
'set-view': ['ui-views', 'set'],
|
|
122
|
+
'rm-view': ['ui-views', 'rm'],
|
|
123
|
+
};
|
|
124
|
+
// These three name their collection POSITIONALLY (`schema add-field contacts --name phone`) and keep
|
|
125
|
+
// their existing verb spelling on it — the schema group is a prefix here, not a rename.
|
|
126
|
+
const SCHEMA_FIELD_OPS = new Set(['add-field', 'update-field', 'remove-field']);
|
|
127
|
+
const SCHEMA_OP_LIST = [...Object.keys(SCHEMA_OPS), ...SCHEMA_FIELD_OPS].join(' | ');
|
|
83
128
|
|
|
84
129
|
export function run(argv) {
|
|
85
130
|
const [cmd, ...rest] = argv;
|
|
@@ -96,6 +141,10 @@ export function run(argv) {
|
|
|
96
141
|
for (let i = 0; i < rest.length; i++) if (rest[i].startsWith('--')) flags[rest[i].slice(2)] = rest[i + 1];
|
|
97
142
|
process.exit(init({ flags }));
|
|
98
143
|
}
|
|
144
|
+
if (!cmd) {
|
|
145
|
+
console.log(USAGE);
|
|
146
|
+
process.exit(0);
|
|
147
|
+
}
|
|
99
148
|
const ws = findWorkspace();
|
|
100
149
|
switch (cmd) {
|
|
101
150
|
case 'install': {
|
|
@@ -202,7 +251,7 @@ export function run(argv) {
|
|
|
202
251
|
if (repos.length) {
|
|
203
252
|
const here = repos.filter((r) => r.present).length;
|
|
204
253
|
console.log(`repos: ${here}/${repos.length} materialized`);
|
|
205
|
-
for (const r of repos) if (!r.present) console.log(` absent: ${r.id} → ${r.path} (dreamteamer
|
|
254
|
+
for (const r of repos) if (!r.present) console.log(` absent: ${r.id} → ${r.path} (dreamteamer ensure ${r.id})`);
|
|
206
255
|
}
|
|
207
256
|
} catch { /* no repos descriptor compiled — nothing to report */ }
|
|
208
257
|
// Uncommitted records are invisible to `dt changes` (it diffs commits), so the
|
|
@@ -226,14 +275,25 @@ export function run(argv) {
|
|
|
226
275
|
case 'help':
|
|
227
276
|
console.log(USAGE);
|
|
228
277
|
process.exit(0);
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
process.exit(cmd ? 1 : 0);
|
|
233
|
-
}
|
|
278
|
+
case 'list': case 'add': case 'values':
|
|
279
|
+
case 'get': case 'set': case 'rm': case 'rename': case 'history': case 'diff': case 'revert':
|
|
280
|
+
case 'move': case 'commands':
|
|
234
281
|
warnIfStale(ws.root);
|
|
235
|
-
process.exit(
|
|
236
|
-
|
|
282
|
+
process.exit(dispatchRecordVerb(ws, cmd, rest));
|
|
283
|
+
// `repos ensure` lost its noun: the repos collection is still where the declaration lives,
|
|
284
|
+
// but materializing one is a verb the operator types, not a record write.
|
|
285
|
+
case 'ensure':
|
|
286
|
+
warnIfStale(ws.root);
|
|
287
|
+
process.exit(collectionCommand(ws, 'repos', 'ensure', rest));
|
|
288
|
+
case 'schema':
|
|
289
|
+
warnIfStale(ws.root);
|
|
290
|
+
process.exit(dispatchSchemaVerb(ws, rest));
|
|
291
|
+
case 'resolve':
|
|
292
|
+
process.exit(resolveVariables(ws, rest));
|
|
293
|
+
default:
|
|
294
|
+
console.error(`✖ unknown verb "${cmd}" — dreamteamer is verb-first since 0.12.0: dt <verb> [<target>]`);
|
|
295
|
+
console.error(USAGE);
|
|
296
|
+
process.exit(1);
|
|
237
297
|
}
|
|
238
298
|
} catch (e) {
|
|
239
299
|
console.error(`✖ ${e.message}`);
|
|
@@ -241,6 +301,110 @@ export function run(argv) {
|
|
|
241
301
|
}
|
|
242
302
|
}
|
|
243
303
|
|
|
304
|
+
/** Translate `dt <verb> <target> …` into the noun-verb call the implementation layer takes. */
|
|
305
|
+
function dispatchRecordVerb(ws, verb, args) {
|
|
306
|
+
const [target, ...rest] = args;
|
|
307
|
+
if (!target) throw new Error(`dt ${verb} needs a target — see \`dreamteamer help\``);
|
|
308
|
+
// A flag in the target slot is a word-order mistake, not a collection: without this,
|
|
309
|
+
// `dt list --json contacts` reported `unknown collection "--json"` and dumped every name.
|
|
310
|
+
if (target.startsWith('--')) throw new Error(`dt ${verb} takes its target BEFORE the flags: dreamteamer ${verb} <target> ${target} …`);
|
|
311
|
+
if (COLLECTION_VERBS.has(verb)) return collectionCommand(ws, target, verb, rest);
|
|
312
|
+
if (REF_VERBS.has(verb)) {
|
|
313
|
+
const { collection, id } = splitRef(new Store(ws).descriptors, target);
|
|
314
|
+
return collectionCommand(ws, collection, verb, [id, ...rest]);
|
|
315
|
+
}
|
|
316
|
+
// EITHER_VERBS from here: a bare collection is legal for both — `move <collection> --init`,
|
|
317
|
+
// `commands <collection>`.
|
|
318
|
+
const { descriptors } = new Store(ws);
|
|
319
|
+
if (descriptors.has(target)) {
|
|
320
|
+
return verb === 'move'
|
|
321
|
+
? collectionCommand(ws, target, 'move', rest)
|
|
322
|
+
: collectionCommand(ws, 'commands', 'for', [target, ...rest]);
|
|
323
|
+
}
|
|
324
|
+
const { collection, id } = splitRef(descriptors, target);
|
|
325
|
+
if (verb === 'move') return collectionCommand(ws, collection, 'move', [id, ...rest]);
|
|
326
|
+
// `commands for <c>/<id>` split its own target at the FIRST slash, which cannot name a
|
|
327
|
+
// namespaced collection. splitRef can, so the id is handed over as `--ids` — the same
|
|
328
|
+
// `commandsFor(store, collection, ids)` call, reached without re-encoding the reference.
|
|
329
|
+
// Ours goes FIRST so an explicit `--ids` from the caller still wins (last flag parsed wins).
|
|
330
|
+
return collectionCommand(ws, 'commands', 'for', [collection, '--ids', id, ...rest]);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* `dt resolve '<string>'` | `dt resolve <collection>/<id> <field>` — the ONLY place a `${env:…}`
|
|
335
|
+
* template becomes a value. Records hold the template verbatim; no read path substitutes anything,
|
|
336
|
+
* so a reference means the same thing on every machine and the file says which is which.
|
|
337
|
+
*
|
|
338
|
+
* THE HEURISTIC: the first argument is a REFERENCE iff it contains no `${` AND splits against a
|
|
339
|
+
* declared collection. Both halves are needed. `${` first, because `docs/${env:X}` is ref-SHAPED
|
|
340
|
+
* and must not be split as one — a reference can never contain a template, so the marker decides it
|
|
341
|
+
* outright. Then the split, because a plain path (`/tmp/x`, `nope/q3`) must render to itself rather
|
|
342
|
+
* than report an unknown collection it was never naming. Two accepted consequences: a bare
|
|
343
|
+
* collection name resolves to itself (splitRef refuses it, so it is "just a string"), and in an
|
|
344
|
+
* UNCOMPILED workspace every argument is a string, because there are no descriptors to split
|
|
345
|
+
* against — which keeps `dt resolve '${env:K}'` working before the first compile.
|
|
346
|
+
*/
|
|
347
|
+
function resolveVariables(ws, args) {
|
|
348
|
+
const [target, field] = args;
|
|
349
|
+
if (!target) throw new Error("dt resolve takes a string template or a <collection>/<id> and a field: dreamteamer resolve '${env:FILES_FOLDER}/x'");
|
|
350
|
+
// resolve has no flags, so a flag-shaped target is a mistake — and the one that costs is
|
|
351
|
+
// `dt resolve --help`, which would otherwise print `--help` back and exit 0.
|
|
352
|
+
if (target.startsWith('--')) throw new Error(`dt resolve takes a string or a <collection>/<id>, not a flag ("${target}") — see \`dreamteamer help\``);
|
|
353
|
+
const declared = ws.pkg.dreamteamer?.vars ?? [];
|
|
354
|
+
const envFile = path.join(ws.root, '.env');
|
|
355
|
+
const env = parseEnvValues(fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf8') : '');
|
|
356
|
+
const ctx = { env, workspaceFolder: ws.root, declared };
|
|
357
|
+
|
|
358
|
+
let ref = null;
|
|
359
|
+
let store = null;
|
|
360
|
+
if (!target.includes('${')) {
|
|
361
|
+
try {
|
|
362
|
+
store = new Store(ws);
|
|
363
|
+
ref = splitRef(store.descriptors, target);
|
|
364
|
+
} catch { ref = null; }
|
|
365
|
+
}
|
|
366
|
+
// An argument nobody reads is a silent wrong answer: `dt resolve docs/q3 source_file garbage`
|
|
367
|
+
// exited 0 on the field it happened to recognise.
|
|
368
|
+
const takes = ref ? 2 : 1;
|
|
369
|
+
if (args.length > takes) {
|
|
370
|
+
throw new Error(`dt resolve takes ${takes === 1 ? 'one string template' : 'a reference and ONE field'} — ${args.length - takes} extra argument(s): ${args.slice(takes).join(' ')}`);
|
|
371
|
+
}
|
|
372
|
+
if (!ref) {
|
|
373
|
+
console.log(renderTemplate(target, ctx));
|
|
374
|
+
return 0;
|
|
375
|
+
}
|
|
376
|
+
if (!field) throw new Error(`dt resolve ${target} needs a field name: dreamteamer resolve ${target} <field>`);
|
|
377
|
+
const { fields } = store.read(ref.collection, ref.id);
|
|
378
|
+
const value = fields[field];
|
|
379
|
+
if (value === undefined) throw new Error(`${target} has no field "${field}"`);
|
|
380
|
+
// RENDER EVERY ITEM BEFORE PRINTING ANY. An array prints one item per line so the output pipes
|
|
381
|
+
// into a shell loop unchanged — and a loop that does not check $? cannot tell a truncated list
|
|
382
|
+
// from a short one, so a failure on item n must not leave items 1..n-1 on stdout. Anything that
|
|
383
|
+
// is not text is refused rather than stringified: a number cannot hold a template.
|
|
384
|
+
const items = Array.isArray(value) ? value : [value];
|
|
385
|
+
const bad = items.findIndex((v) => typeof v !== 'string'); // index, not the value: an item may be null
|
|
386
|
+
if (bad > -1) throw new Error(`${target} field "${field}" holds a ${typeof items[bad]} — resolve renders text (a string, or a list of them)`);
|
|
387
|
+
const rendered = items.map((v) => renderTemplate(v, ctx));
|
|
388
|
+
if (rendered.length) console.log(rendered.join('\n'));
|
|
389
|
+
return 0;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Translate `dt schema <op> …` onto the same meta verbs `collectionCommand` already routes. */
|
|
393
|
+
function dispatchSchemaVerb(ws, args) {
|
|
394
|
+
const [op, ...rest] = args;
|
|
395
|
+
if (!op) throw new Error(`dt schema needs an operation — use ${SCHEMA_OP_LIST}`);
|
|
396
|
+
if (SCHEMA_FIELD_OPS.has(op)) {
|
|
397
|
+
const [collection, ...flags] = rest;
|
|
398
|
+
if (!collection || collection.startsWith('--')) {
|
|
399
|
+
throw new Error(`dt schema ${op} needs a collection: dreamteamer schema ${op} <collection> --name <field> …`);
|
|
400
|
+
}
|
|
401
|
+
return collectionCommand(ws, collection, op, flags);
|
|
402
|
+
}
|
|
403
|
+
const pair = SCHEMA_OPS[op];
|
|
404
|
+
if (!pair) throw new Error(`unknown schema operation "${op}" — use ${SCHEMA_OP_LIST}`);
|
|
405
|
+
return collectionCommand(ws, pair[0], pair[1], rest);
|
|
406
|
+
}
|
|
407
|
+
|
|
244
408
|
function tryGit(cwd, args) {
|
|
245
409
|
try { return execFileSync('git', args, { cwd, stdio: QUIET }).toString().trim() || null; } catch { return null; }
|
|
246
410
|
}
|
package/src/collections-cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The IMPLEMENTATION layer behind the verb-first CLI: `collectionCommand(ws, collection, verb, args)`
|
|
2
|
+
// is (collection, verb) shaped and stays that way — `cli.js` translates `dt <verb> <target>` onto it.
|
|
3
|
+
// So a spelling here (`collections add`, `<collection> add-field`, `commands for`, `repos ensure`) is
|
|
4
|
+
// an INTERNAL pair, not what the operator types; `dt schema add-collection` is what they type.
|
|
4
5
|
import { execFileSync } from 'node:child_process';
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import path from 'node:path';
|
|
@@ -19,6 +20,7 @@ import { distinctValues } from './field-values.js';
|
|
|
19
20
|
import { matchesFilter } from './filter.js';
|
|
20
21
|
import { baseNameOf, normalizeNamespaces } from './namespace.js';
|
|
21
22
|
import { sortRows } from './temporal.js';
|
|
23
|
+
import { keyBetween, placementKey } from './fractional-index.js';
|
|
22
24
|
import { ensureRepo, ensureAllRepos } from './init.js';
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -26,7 +28,7 @@ import { ensureRepo, ensureAllRepos } from './init.js';
|
|
|
26
28
|
*
|
|
27
29
|
* `console.log` to a pipe is asynchronous, and every CLI path ends in `process.exit()`, which
|
|
28
30
|
* discards whatever is still buffered. A shell pipeline hides the bug completely — the reader
|
|
29
|
-
* drains concurrently, so `dreamteamer contacts
|
|
31
|
+
* drains concurrently, so `dreamteamer list contacts --json | wc -c` reports all 32381 bytes — but
|
|
30
32
|
* the way a script or a coding agent actually calls this is execFileSync/spawnSync, and there the
|
|
31
33
|
* child exits with the pipe still full. Measured before this fix: the same command captured with
|
|
32
34
|
* execFileSync returned exactly **8190 bytes** (one pipe buffer) of that 32381-byte document, i.e.
|
|
@@ -133,6 +135,40 @@ export function collectionCommand(ws, collection, verb, args) {
|
|
|
133
135
|
flags.json ? emit(JSON.stringify({ id })) : console.log('✔ updated');
|
|
134
136
|
return 0;
|
|
135
137
|
}
|
|
138
|
+
// Manual ordering. ONE record is written per move — that is the entire feature; a dense
|
|
139
|
+
// integer would renumber everything below the insertion point and bury the change. The field is
|
|
140
|
+
// named by the descriptor (`sort_field`), never here, so a workspace may call it anything.
|
|
141
|
+
case 'move': {
|
|
142
|
+
const field = d.sort_field;
|
|
143
|
+
if (!field) throw new Error(`collection "${collection}" declares no sort_field — add one to its descriptor before ordering it by hand.`);
|
|
144
|
+
|
|
145
|
+
// Blanks sort FIRST (compareValues, via `?? ''`), so unplaced records surface at the top
|
|
146
|
+
// rather than hiding at the bottom, and ties fall back to id order: `walk` reads name-sorted
|
|
147
|
+
// and Array.sort is stable. That is the tiebreak two agents landing on the same key rely on.
|
|
148
|
+
const rows = [];
|
|
149
|
+
for (const { id: rid, fields } of store.readAll(collection)) rows.push({ id: rid, key: fields[field] ?? '' });
|
|
150
|
+
sortRows(rows, 'key');
|
|
151
|
+
|
|
152
|
+
if (flags.init) {
|
|
153
|
+
// Idempotent: a record that already carries a key keeps it, so --init can be re-run after
|
|
154
|
+
// adding records without disturbing an order the operator set by hand.
|
|
155
|
+
let prev = rows.filter((r) => r.key).pop()?.key ?? null;
|
|
156
|
+
let written = 0;
|
|
157
|
+
for (const r of rows) {
|
|
158
|
+
if (r.key) continue;
|
|
159
|
+
prev = keyBetween(prev, null);
|
|
160
|
+
store.set(collection, r.id, { [field]: prev });
|
|
161
|
+
written++;
|
|
162
|
+
}
|
|
163
|
+
flags.json ? emit(JSON.stringify({ placed: written })) : console.log(written ? `✔ placed ${written} record(s) in ${field}` : '✔ nothing to place');
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const id = need(pos, 0, 'id');
|
|
168
|
+
store.set(collection, id, { [field]: placementKey(rows, id, flags, collection) });
|
|
169
|
+
flags.json ? emit(JSON.stringify({ id })) : console.log('✔ moved');
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
136
172
|
case 'rm': {
|
|
137
173
|
const id = need(pos, 0, 'id');
|
|
138
174
|
const { inboundIgnored } = store.rm(collection, id, { force: !!flags.force });
|
|
@@ -146,7 +182,7 @@ export function collectionCommand(ws, collection, verb, args) {
|
|
|
146
182
|
if (out.touched) console.log(`✔ rewrote ${out.rewrites} inbound reference(s) across ${out.touched} file(s)`);
|
|
147
183
|
return 0;
|
|
148
184
|
}
|
|
149
|
-
// `dreamteamer meetings
|
|
185
|
+
// `dreamteamer values meetings status` — the vocabulary a field ACTUALLY uses, so a filter
|
|
150
186
|
// or a command-binding validator can offer a dropdown for a plain `type: string` field that
|
|
151
187
|
// no enum describes (operator: "still no dropdown for many things, visibility, status").
|
|
152
188
|
case 'values': {
|
|
@@ -181,7 +217,7 @@ export function collectionCommand(ws, collection, verb, args) {
|
|
|
181
217
|
// the hash is REQUIRED and has no default: "revert" with an implied target is how you
|
|
182
218
|
// destroy the wrong record. `<c> history <id>` is where you get one.
|
|
183
219
|
const hash = typeof flags.hash === 'string' ? flags.hash : pos[1];
|
|
184
|
-
if (!hash) throw new Error(`missing --hash <commit> — run \`dreamteamer ${collection}
|
|
220
|
+
if (!hash) throw new Error(`missing --hash <commit> — run \`dreamteamer history ${collection}/${id}\` to pick one`);
|
|
185
221
|
const out = store.revert(collection, id, hash);
|
|
186
222
|
flags.json ? emit(JSON.stringify(out)) : console.log(out.reverted ? `✔ reverted ${collection}/${id} to ${String(hash).slice(0, 7)}` : `= already identical to ${String(hash).slice(0, 7)} — nothing changed`);
|
|
187
223
|
return 0;
|
|
@@ -193,16 +229,18 @@ export function collectionCommand(ws, collection, verb, args) {
|
|
|
193
229
|
|
|
194
230
|
|
|
195
231
|
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
232
|
+
// Which bound commands apply, in which state (available / done / not-applicable). THE engine
|
|
233
|
+
// surface behind the studio's Commands tab (engine/UI parity: the verb lands first, the button
|
|
234
|
+
// second).
|
|
235
|
+
//
|
|
236
|
+
// ⚠ The COLLECTION arrives already resolved, and the ids only ever through `--ids`. This used to
|
|
237
|
+
// split its own target at the first slash — which cannot name a namespaced collection, so
|
|
238
|
+
// `commands finance/transactions` asked for a collection called "finance" and every namespaced
|
|
239
|
+
// target failed. `cli.js` owns reference resolution now (splitRef, longest declared prefix) and is
|
|
240
|
+
// the only caller, so a second, weaker splitter here could only ever disagree with it.
|
|
199
241
|
function metaCommandsFor(ws, store, flags, pos) {
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
const collection = slash > 0 ? target.slice(0, slash) : target;
|
|
203
|
-
const ids = slash > 0
|
|
204
|
-
? [target.slice(slash + 1)]
|
|
205
|
-
: typeof flags.ids === 'string' ? flags.ids.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
242
|
+
const collection = need(pos, 0, 'collection');
|
|
243
|
+
const ids = typeof flags.ids === 'string' ? flags.ids.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
206
244
|
const out = commandsFor(store, collection, ids);
|
|
207
245
|
if (flags.json) { emit(JSON.stringify(out, null, 2)); return 0; }
|
|
208
246
|
if (!out.commands.length) { console.log(`(no commands bound to ${collection})`); return 0; }
|
|
@@ -241,7 +279,7 @@ function metaCollectionsAdd(ws, store, flags) {
|
|
|
241
279
|
// migration whose last step (rewriting references) dangles everything when forgotten.
|
|
242
280
|
function metaCollectionsRename(ws, store, flags, pos) {
|
|
243
281
|
const [oldName, explicitNew] = pos;
|
|
244
|
-
if (!oldName) throw new Error('usage:
|
|
282
|
+
if (!oldName) throw new Error('usage: dreamteamer schema rename-collection <old> <new> | <old> --namespace <ns>');
|
|
245
283
|
// `--namespace health` on its own moves the collection INTO that namespace keeping its bare name,
|
|
246
284
|
// which is the common case and saves retyping it.
|
|
247
285
|
const newName = explicitNew
|
package/src/compile.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
// call at run time, same pattern as store.js ↔ compile.js.
|
|
18
18
|
import { runHarnessAdapters } from './harnesses.js';
|
|
19
19
|
import { satisfies } from './semver.js';
|
|
20
|
+
import { parseEnvValues } from './env-vars.js';
|
|
20
21
|
import { DERIVED_KINDS, readManifest, runtimeDir } from './runtime.js';
|
|
21
22
|
|
|
22
23
|
// re-exported, not moved: `readManifest` is in the VS Code extension's hand-maintained engine
|
|
@@ -295,21 +296,37 @@ export function compile({ root, pkg }) {
|
|
|
295
296
|
declaredEnv.get(k).push(source.name);
|
|
296
297
|
}
|
|
297
298
|
}
|
|
298
|
-
|
|
299
|
+
// `dreamteamer.vars` is the WORKSPACE's own declaration (root package.json, not a module's): the
|
|
300
|
+
// keys a `${env:NAME}` template is allowed to name. Same missing-key question as
|
|
301
|
+
// `dreamteamer.env`, one .env parse, two warnings — a module needs its key to RUN, a var is
|
|
302
|
+
// needed the moment someone calls `dt resolve`, and only the workspace can declare one.
|
|
303
|
+
if (config.vars !== undefined && (!Array.isArray(config.vars) || config.vars.some((v) => typeof v !== 'string'))) {
|
|
304
|
+
fail(`dreamteamer.vars must be a list of env key names (got ${JSON.stringify(config.vars)})`);
|
|
305
|
+
}
|
|
306
|
+
const declaredVars = config.vars ?? [];
|
|
307
|
+
if (declaredEnv.size || declaredVars.length) {
|
|
299
308
|
// .env is parsed for KEY names ONLY — values never reach any output or the manifest
|
|
300
309
|
const envPath = path.join(root, '.env');
|
|
301
310
|
if (!fs.existsSync(envPath)) {
|
|
302
|
-
console.warn(`⚠ no .env — modules declare env keys: ${[...declaredEnv.keys()].join(', ')} (see .env.example)`);
|
|
311
|
+
if (declaredEnv.size) console.warn(`⚠ no .env — modules declare env keys: ${[...declaredEnv.keys()].join(', ')} (see .env.example)`);
|
|
312
|
+
if (declaredVars.length) console.warn(`⚠ no .env — dreamteamer.vars declares ${declaredVars.join(', ')}, so no \${env:…} template can render here (see .env.example)`);
|
|
303
313
|
} else {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
314
|
+
// ⚠ THE ONE PARSER, not a key regex of our own. This used to hand-roll
|
|
315
|
+
// `/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/`, which accepted two lines
|
|
316
|
+
// `parseEnvValues` deliberately drops (`KEY =value`, and an indented key) and scanned a
|
|
317
|
+
// quoted value's continuation lines for keys. Either disagreement produces the worst
|
|
318
|
+
// pairing there is: compile says nothing and then `dt resolve` answers "no value in
|
|
319
|
+
// .env" about a line the operator is looking straight at. Values are read here and
|
|
320
|
+
// never printed — the warnings below name keys only.
|
|
321
|
+
const present = new Set(parseEnvValues(fs.readFileSync(envPath, 'utf8')).keys());
|
|
309
322
|
for (const [k, mods] of declaredEnv) {
|
|
310
323
|
if (present.has(k)) continue;
|
|
311
324
|
for (const mod of mods) console.warn(`⚠ module ${mod} declares env key ${k} — missing from .env (see .env.example)`);
|
|
312
325
|
}
|
|
326
|
+
for (const k of declaredVars) {
|
|
327
|
+
if (present.has(k)) continue;
|
|
328
|
+
console.warn(`⚠ dreamteamer.vars declares ${k} — missing from .env, so \${env:${k}} cannot render on this machine`);
|
|
329
|
+
}
|
|
313
330
|
}
|
|
314
331
|
}
|
|
315
332
|
|
|
@@ -630,6 +647,15 @@ export function compile({ root, pkg }) {
|
|
|
630
647
|
fail(`collection "${name}": id.pattern is not a valid regular expression — ${e.message} (${group.map((g) => g.src.path).join(', ')})`);
|
|
631
648
|
}
|
|
632
649
|
}
|
|
650
|
+
// `sort_field` names a field of this collection's OWN schema. Without this gate a typo
|
|
651
|
+
// surfaces as "dragging does nothing" while the drag handle is still offered — a silent lie,
|
|
652
|
+
// and the ordering it writes would land in a field no reader sorts by.
|
|
653
|
+
if (merged.sort_field !== undefined) {
|
|
654
|
+
if (typeof merged.sort_field !== 'string') fail(`collection "${name}": sort_field must be a string (got ${JSON.stringify(merged.sort_field)})`);
|
|
655
|
+
if (!(merged.schema?.properties ?? {})[merged.sort_field]) {
|
|
656
|
+
fail(`collection "${name}": sort_field "${merged.sort_field}" is not a field of its schema — declare it, or point sort_field at one that exists (${group.map((g) => g.src.path).join(', ')}).`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
633
659
|
// ---- the reference contract: every target is owned, depended on, or declared a peer ----
|
|
634
660
|
// Attribution is unioned across the whole group rather than taken from the base, because the
|
|
635
661
|
// merge keeps no per-field provenance — an overlay that adds a ref field would otherwise be
|
|
@@ -696,6 +722,17 @@ export function compile({ root, pkg }) {
|
|
|
696
722
|
}
|
|
697
723
|
const rt = path.join('collections', `${name}.collection.yaml`);
|
|
698
724
|
entries.set(rt, { sources: [...group.map((g) => g.src), ...templateSources], bytes: Buffer.from(dump(merged)) });
|
|
725
|
+
// A descriptor with no `description:` renders in the orientation block as a bare NAME — an
|
|
726
|
+
// agent learns the noun exists and nothing about when it is the right one. Derived pressure
|
|
727
|
+
// rather than a heroic backfill pass, and the same shape as the per-missing-env-key warning:
|
|
728
|
+
// non-blocking, named per offender, so the gap converges instead of being rediscovered.
|
|
729
|
+
//
|
|
730
|
+
// ⚠ Deliberately NO equivalent warning for `use_when`. That field is optional and correct to
|
|
731
|
+
// omit on most collections — warning on it would invert its authoring test and manufacture a
|
|
732
|
+
// restatement of the description on every collection that does not need one.
|
|
733
|
+
if (merged.storage.base !== 'runtime' && !String(merged.description ?? '').trim()) {
|
|
734
|
+
console.warn(`⚠ collection ${name} has no description — it renders as a bare name in the orientation block every session loads`);
|
|
735
|
+
}
|
|
699
736
|
counts.collections++;
|
|
700
737
|
if (extenders.length) mergedCount++;
|
|
701
738
|
}
|
package/src/env-vars.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// ${env:KEY} / ${workspaceFolder} / ${userHome} — VS Code's variable grammar, three variables.
|
|
2
|
+
// Values render ONLY on explicit request (dt resolve); records are never auto-substituted.
|
|
3
|
+
// parseEnvValues reads .env TEXT only (never shell-evaluates). A QUOTED value (single or double)
|
|
4
|
+
// may span multiple lines — the closing quote just has to end some later line, not the one it
|
|
5
|
+
// opened on — because `[^"\\]`/`[^']` match newlines in JS regex same as any other character.
|
|
6
|
+
// An UNQUOTED value is line-bound: it runs to end of line, so it can't span lines.
|
|
7
|
+
// Two accepted non-goals, unchanged on purpose: `KEY =value` (space before `=`) doesn't match the
|
|
8
|
+
// key pattern and is silently dropped, no diagnostic; an unquoted value keeps a trailing inline
|
|
9
|
+
// `# comment` as part of its text (quote it to strip one).
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
|
|
12
|
+
export function parseEnvValues(text) {
|
|
13
|
+
const out = new Map();
|
|
14
|
+
// KEY=value with optional `export `, optional quotes; value is whatever follows on the SAME line
|
|
15
|
+
const re = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=("(?:[^"\\]|\\.)*"|'[^']*'|[^\n]*)$/gm;
|
|
16
|
+
let m;
|
|
17
|
+
while ((m = re.exec(text))) {
|
|
18
|
+
let v = m[2].trim();
|
|
19
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
|
20
|
+
out.set(m[1], v);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SUPPORTED = 'supported: ${env:NAME}, ${workspaceFolder}, ${userHome}';
|
|
26
|
+
|
|
27
|
+
export function renderTemplate(str, { env, workspaceFolder, declared }) {
|
|
28
|
+
return str.replace(/\$\{([A-Za-z]+)(?::([A-Za-z0-9_]*))?\}/g, (whole, ns, arg) => {
|
|
29
|
+
if (arg === undefined) {
|
|
30
|
+
if (ns === 'workspaceFolder') return workspaceFolder;
|
|
31
|
+
if (ns === 'userHome') return os.homedir();
|
|
32
|
+
return whole; // un-namespaced ${VAR}: not ours, inert — prose mentions ${…} freely
|
|
33
|
+
}
|
|
34
|
+
if (ns !== 'env') throw new Error(`\${${ns}:${arg}} is not a dreamteamer variable — ${SUPPORTED}`);
|
|
35
|
+
if (!arg) throw new Error(`\${env:} needs a key name — ${SUPPORTED}`);
|
|
36
|
+
if (!declared.includes(arg)) throw new Error(`\${env:${arg}}: "${arg}" is not declared in dreamteamer.vars (workspace package.json) — declared: ${declared.join(', ') || '(none)'}`);
|
|
37
|
+
if (!env.has(arg)) throw new Error(`\${env:${arg}} is declared but has no value in .env on this machine`);
|
|
38
|
+
return env.get(arg);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Manual ordering keys. Named for the ALGORITHM: the FIELD is named per collection via
|
|
2
|
+
// `sort_field`, and nothing here may assume what it is called.
|
|
3
|
+
//
|
|
4
|
+
// ⚠ THE ALPHABET IS LOAD-BEARING. `compareValues` (temporal.js) ends in `localeCompare`, which is
|
|
5
|
+
// locale-aware, so the library's DEFAULT base-62 keys mis-sort here: prepending three times gives
|
|
6
|
+
// `Zy Zz a0`, and `sortRows` returns `a0 Zy Zz`. Same trap that breaks fractional indexing on
|
|
7
|
+
// Postgres under `en_US.utf8` instead of `C`. a-z is the intersection where three things hold at
|
|
8
|
+
// once — locale order agrees with codepoint order, and no key can parse as a number (the numeric
|
|
9
|
+
// branch of `compareValues` would sort "9" after "10") or as a temporal.
|
|
10
|
+
//
|
|
11
|
+
// test/unit/fractional-index.test.js asserts the base-62 failure directly, so this argument cannot
|
|
12
|
+
// be deleted as noise.
|
|
13
|
+
//
|
|
14
|
+
// Why a dependency rather than 75 lines of our own: the naive midpoint (halve toward the open end)
|
|
15
|
+
// degenerates on APPEND, which is the commonest write — measured at 200-character keys after 1000
|
|
16
|
+
// appends. The library's integer-part-with-magnitude-head keeps that at 4.
|
|
17
|
+
import { generateKeyBetween } from 'fractional-indexing';
|
|
18
|
+
|
|
19
|
+
const DIGITS = 'abcdefghijklmnopqrstuvwxyz';
|
|
20
|
+
|
|
21
|
+
/** A key strictly between `a` and `b`. Either may be null for an open end. */
|
|
22
|
+
export const keyBetween = (a, b) => generateKeyBetween(a ?? null, b ?? null, DIGITS);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The key that puts `id` where `dest` asks, given the collection's records in current sort order
|
|
26
|
+
* (`[{ id, key }]`, blanks first). PURE — the caller does the reading and the writing, which is what
|
|
27
|
+
* lets the CLI and the HTTP surface share one placement rule instead of two that drift.
|
|
28
|
+
*
|
|
29
|
+
* Fails closed on a destination that is not placed yet: with no key on the target there is nothing to
|
|
30
|
+
* compute against, and guessing would silently put the record somewhere the operator did not ask for.
|
|
31
|
+
*/
|
|
32
|
+
export function placementKey(rows, id, dest, collection = '<collection>') {
|
|
33
|
+
const placed = rows.filter((r) => r.key && r.id !== id);
|
|
34
|
+
const at = (t) => {
|
|
35
|
+
const i = placed.findIndex((r) => r.id === t);
|
|
36
|
+
if (i < 0) throw new Error(`"${t}" has no sort value yet — run \`dreamteamer move ${collection} --init\` first. nothing was written.`);
|
|
37
|
+
return i;
|
|
38
|
+
};
|
|
39
|
+
if (dest.top) return keyBetween(null, placed[0]?.key ?? null);
|
|
40
|
+
if (dest.bottom) return keyBetween(placed[placed.length - 1]?.key ?? null, null);
|
|
41
|
+
if (typeof dest.after === 'string') { const i = at(dest.after); return keyBetween(placed[i].key, placed[i + 1]?.key ?? null); }
|
|
42
|
+
if (typeof dest.before === 'string') { const i = at(dest.before); return keyBetween(placed[i - 1]?.key ?? null, placed[i].key); }
|
|
43
|
+
throw new Error('say where to put it — --after <id>, --before <id>, --top or --bottom. nothing was written.');
|
|
44
|
+
}
|