dreamteamer 0.16.0 → 0.17.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/collections/collections.collection.yaml +10 -0
- package/collections/modules.collection.yaml +8 -0
- package/collections/repos.collection.yaml +5 -1
- package/package.json +1 -1
- package/skills/using-dreamteamer/SKILL.md +21 -5
- package/skills/using-dreamteamer/references/collections.md +14 -3
- package/skills/using-dreamteamer/references/data-modeling.md +8 -2
- package/skills/using-dreamteamer/references/getting-started.md +113 -0
- package/skills/using-dreamteamer/references/records.md +29 -11
- package/skills/using-dreamteamer/references/ui-views.md +11 -4
- package/src/check.js +15 -9
- package/src/cli.js +22 -12
- package/src/collections-cli.js +158 -26
- package/src/commit.js +9 -1
- package/src/compile.js +95 -14
- package/src/env-vars.js +15 -0
- package/src/init.js +27 -8
- package/src/namespace.js +15 -0
- package/src/ref.js +19 -0
- package/src/schema-ops.js +115 -43
- package/src/store.js +273 -52
package/src/collections-cli.js
CHANGED
|
@@ -86,15 +86,26 @@ export function collectionCommand(ws, collection, verb, args) {
|
|
|
86
86
|
|
|
87
87
|
switch (verb) {
|
|
88
88
|
case 'list': {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
// EVERY condition, ANDed — a repeated flag composes rather than replacing. `--filter a=1
|
|
90
|
+
// --filter b=2` used to keep only `b=2` and return rows the caller had excluded, which is
|
|
91
|
+
// the one failure a narrowing verb must not have: a listing that answers a question nobody
|
|
92
|
+
// asked, at exit 0. Bare-field flags (`--status todo`) already composed this way; the
|
|
93
|
+
// meta flag now reads the same, and `[].concat` treats one and many alike.
|
|
94
|
+
const filters = Object.entries(flags)
|
|
95
|
+
.filter(([k]) => !LIST_META_FLAGS.has(k))
|
|
96
|
+
.flatMap(([k, v]) => [].concat(v).map((one) => [k, one]));
|
|
97
|
+
for (const cond of [].concat(flags.filter ?? [])) {
|
|
98
|
+
const eq = String(cond).indexOf('=');
|
|
99
|
+
// `--filter status` is not a condition. It used to slice into `statu = status`, which
|
|
100
|
+
// matches nothing and reads as a fact about the collection.
|
|
101
|
+
if (eq < 1) throw new Error(`--filter takes <field>=<value> — got "${cond}"`);
|
|
102
|
+
filters.push([String(cond).slice(0, eq), String(cond).slice(eq + 1)]);
|
|
93
103
|
}
|
|
94
104
|
// `--where` is the SAME operator set the studio's filter panel emits and saved views
|
|
95
105
|
// store — one `matchesFilter`, so `--where '{"starts":{"_gte":"2026-07-01"}}'` and the
|
|
96
106
|
// panel that produced that JSON cannot disagree about which records match.
|
|
97
|
-
const
|
|
107
|
+
const whereJson = oneValue(flags, 'where');
|
|
108
|
+
const where = whereJson ? load(whereJson) : null;
|
|
98
109
|
const resolve = where ? recordResolver(store) : null;
|
|
99
110
|
const bf = bodyField(d);
|
|
100
111
|
const rows = [];
|
|
@@ -107,7 +118,8 @@ export function collectionCommand(ws, collection, verb, args) {
|
|
|
107
118
|
// sorting was studio-only until now: the browse table ordered records and no CLI
|
|
108
119
|
// invocation could. Same `sortRows` the server and api.ts call, so `--sort -starts`
|
|
109
120
|
// orders date-times by INSTANT across mixed offsets rather than by string.
|
|
110
|
-
|
|
121
|
+
const sort = oneValue(flags, 'sort');
|
|
122
|
+
if (sort) sortRows(rows, sort);
|
|
111
123
|
if (flags.json) { emit(JSON.stringify(rows, null, 2)); return 0; }
|
|
112
124
|
const cols = ['id', ...(d.list_fields ?? []).filter((c) => c !== 'id')];
|
|
113
125
|
for (const r of rows) console.log(cols.map((c) => fmtCell(r[c])).join(' '));
|
|
@@ -138,9 +150,7 @@ export function collectionCommand(ws, collection, verb, args) {
|
|
|
138
150
|
}
|
|
139
151
|
case 'set': {
|
|
140
152
|
const id = need(pos, 0, 'id');
|
|
141
|
-
const changes = coerceArrays(d,
|
|
142
|
-
pos.slice(1).filter((p) => p.includes('=')).map((p) => [p.slice(0, p.indexOf('=')), p.slice(p.indexOf('=') + 1)])
|
|
143
|
-
));
|
|
153
|
+
const changes = coerceArrays(d, pairs(pos.slice(1)));
|
|
144
154
|
Object.assign(changes, coerceArrays(d, stripMeta(flags)));
|
|
145
155
|
if (!Object.keys(changes).length) throw new Error('nothing to set — pass key=value pairs or --key value flags');
|
|
146
156
|
store.set(collection, id, changes);
|
|
@@ -219,7 +229,7 @@ export function collectionCommand(ws, collection, verb, args) {
|
|
|
219
229
|
}
|
|
220
230
|
case 'diff': {
|
|
221
231
|
const id = need(pos, 0, 'id');
|
|
222
|
-
const out = historyDiff(store, collection, id,
|
|
232
|
+
const out = historyDiff(store, collection, id, oneValue(flags, 'hash') ?? 'HEAD');
|
|
223
233
|
if (flags.json) { emit(JSON.stringify(out, null, 2)); return 0; }
|
|
224
234
|
console.log(out.diff.trimEnd() || `(no change to ${out.path} in ${out.hash})`);
|
|
225
235
|
return 0;
|
|
@@ -228,7 +238,7 @@ export function collectionCommand(ws, collection, verb, args) {
|
|
|
228
238
|
const id = need(pos, 0, 'id');
|
|
229
239
|
// the hash is REQUIRED and has no default: "revert" with an implied target is how you
|
|
230
240
|
// destroy the wrong record. `<c> history <id>` is where you get one.
|
|
231
|
-
const hash =
|
|
241
|
+
const hash = oneValue(flags, 'hash') ?? pos[1];
|
|
232
242
|
if (!hash) throw new Error(`missing --hash <commit> — run \`dreamteamer history ${collection}/${id}\` to pick one`);
|
|
233
243
|
const out = store.revert(collection, id, hash);
|
|
234
244
|
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`);
|
|
@@ -252,7 +262,7 @@ export function collectionCommand(ws, collection, verb, args) {
|
|
|
252
262
|
// the only caller, so a second, weaker splitter here could only ever disagree with it.
|
|
253
263
|
function metaCommandsFor(ws, store, flags, pos) {
|
|
254
264
|
const collection = need(pos, 0, 'collection');
|
|
255
|
-
const ids =
|
|
265
|
+
const ids = (oneValue(flags, 'ids') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
256
266
|
const out = commandsFor(store, collection, ids);
|
|
257
267
|
if (flags.json) { emit(JSON.stringify(out, null, 2)); return 0; }
|
|
258
268
|
if (!out.commands.length) { console.log(`(no commands bound to ${collection})`); return 0; }
|
|
@@ -280,6 +290,7 @@ function metaReposEnsure(ws, flags, pos) {
|
|
|
280
290
|
|
|
281
291
|
// `dreamteamer collections add --name research-docs --template docs`
|
|
282
292
|
function metaCollectionsAdd(ws, store, flags) {
|
|
293
|
+
refuseRepeats(flags);
|
|
283
294
|
const { file } = createCollection(ws, store, { name: flags.name, template: flags.template, namespace: flags.namespace });
|
|
284
295
|
console.log(`✔ ${rel(ws.root, file)}`);
|
|
285
296
|
console.log('✔ compiled — the collection is live (schema ops prove sources with a real compile)');
|
|
@@ -290,6 +301,7 @@ function metaCollectionsAdd(ws, store, flags) {
|
|
|
290
301
|
// The whole point is that namespacing EXISTING data is one command instead of a six-step hand
|
|
291
302
|
// migration whose last step (rewriting references) dangles everything when forgotten.
|
|
292
303
|
function metaCollectionsRename(ws, store, flags, pos) {
|
|
304
|
+
refuseRepeats(flags);
|
|
293
305
|
const [oldName, explicitNew] = pos;
|
|
294
306
|
if (!oldName) throw new Error('usage: dreamteamer schema rename-collection <old> <new> | <old> --namespace <ns>');
|
|
295
307
|
// `--namespace health` on its own moves the collection INTO that namespace keeping its bare name,
|
|
@@ -313,6 +325,7 @@ function metaCollectionsRename(ws, store, flags, pos) {
|
|
|
313
325
|
// `dreamteamer collections rm widgets [--force]` — --force is required to drop a collection
|
|
314
326
|
// that still has records (removeCollection refuses otherwise, and says so).
|
|
315
327
|
function metaCollectionsRm(ws, store, flags, pos) {
|
|
328
|
+
refuseRepeats(flags);
|
|
316
329
|
const name = need(pos, 0, 'collection name');
|
|
317
330
|
const out = removeCollection(ws, store, name, { force: !!flags.force });
|
|
318
331
|
flags.json ? emit(JSON.stringify(out)) : console.log(`✔ removed collection ${out.removed}`);
|
|
@@ -322,6 +335,7 @@ function metaCollectionsRm(ws, store, flags, pos) {
|
|
|
322
335
|
|
|
323
336
|
// `dreamteamer tasks add-field --name urgent --type boolean --default-value false`
|
|
324
337
|
function metaAddField(ws, store, collection, flags) {
|
|
338
|
+
refuseRepeats(flags);
|
|
325
339
|
const prop = fieldDef(store, flags, collection);
|
|
326
340
|
// fieldDef DEFERS every relation flag it has no reference to attach to, because on update-field
|
|
327
341
|
// the target is carried in afterwards. add-field has nothing to carry, so a relation flag that
|
|
@@ -372,6 +386,7 @@ function reportMirror(store, collection, fieldName, prop) {
|
|
|
372
386
|
// Same flag vocabulary as add-field (one `fieldDef`), so the two read as one operation with two
|
|
373
387
|
// preconditions rather than two dialects.
|
|
374
388
|
function metaUpdateField(ws, store, collection, flags) {
|
|
389
|
+
refuseRepeats(flags);
|
|
375
390
|
if (!flags.name) throw new Error('missing --name <field>');
|
|
376
391
|
const prop = fieldDef(store, flags, collection);
|
|
377
392
|
// tri-state: omitting --required leaves requiredness ALONE, rather than silently clearing it
|
|
@@ -392,6 +407,7 @@ function metaUpdateField(ws, store, collection, flags) {
|
|
|
392
407
|
|
|
393
408
|
// `dreamteamer tasks remove-field --name urgent`
|
|
394
409
|
function metaRemoveField(ws, store, collection, flags) {
|
|
410
|
+
refuseRepeats(flags);
|
|
395
411
|
const name = flags.name ?? flags.field;
|
|
396
412
|
if (!name) throw new Error('missing --name <field>');
|
|
397
413
|
const out = removeField(ws, store, collection, name);
|
|
@@ -402,6 +418,10 @@ function metaRemoveField(ws, store, collection, flags) {
|
|
|
402
418
|
// half of a destructive verb, and a silent deletion is a different act from a reported one.
|
|
403
419
|
if (out.cleared) console.log(` cleared its values from ${out.cleared} ${collection} record${out.cleared === 1 ? '' : 's'} (git holds them: git show HEAD~1)`);
|
|
404
420
|
reportDropped(out.dropped);
|
|
421
|
+
// This descriptor's own `list_fields`/`sort_field` were pruned with the field; a ui-view is a
|
|
422
|
+
// source this verb does not own, so it is NAMED rather than edited — and naming it is the whole
|
|
423
|
+
// point, since a column of a field that no longer exists renders as an empty one.
|
|
424
|
+
if (out.staleViews?.length) console.warn(`⚠ still listing ${collection}.${name} as a column: ${out.staleViews.join(', ')} — edit with \`dreamteamer schema set-view <id> options.columns=…\``);
|
|
405
425
|
}
|
|
406
426
|
return 0;
|
|
407
427
|
}
|
|
@@ -411,8 +431,26 @@ function metaRemoveField(ws, store, collection, flags) {
|
|
|
411
431
|
// through saveUiView's compile gate rather than the record store. Without these verbs everything
|
|
412
432
|
// the Layout options panel does — columns, order, sort, layout, filter, nav — was click-only.
|
|
413
433
|
|
|
414
|
-
/**
|
|
415
|
-
|
|
434
|
+
/**
|
|
435
|
+
* Layout options whose value is a LIST, so `options.columns=title,status` means what it says.
|
|
436
|
+
*
|
|
437
|
+
* ⚠ NAMED, not inferred, and that is the whole design: `options` is an open bag with no schema —
|
|
438
|
+
* each layout declares its own keys and unknown ones ride through untouched — so nothing in the
|
|
439
|
+
* runtime can tell a list key from a scalar one. Without this set the comma spelling every other
|
|
440
|
+
* verb accepts for an array (`--tags a,b`) stored the literal string `"title,status"`, which the
|
|
441
|
+
* surface reads with `Array.isArray` and therefore ignores: a view that looked configured, drew
|
|
442
|
+
* the descriptor's `list_fields` instead, and reported nothing anywhere.
|
|
443
|
+
*
|
|
444
|
+
* `columns` is honoured by every layout; `ref_fields` and `value_fields` are the diagram's
|
|
445
|
+
* link-by pickers. `arrangement` is deliberately ABSENT — its elements are objects, so the JSON
|
|
446
|
+
* form is its only honest spelling, and a comma split would quietly produce garbage.
|
|
447
|
+
*/
|
|
448
|
+
const VIEW_LIST_OPTIONS = new Set(['options.columns', 'options.ref_fields', 'options.value_fields']);
|
|
449
|
+
|
|
450
|
+
/** `--options '{"sort":"-date"}'` style flags, plus dotted `options.sort=-date` positionals. The
|
|
451
|
+
* KEY is passed because the value's shape depends on it: only the key says whether a comma is a
|
|
452
|
+
* separator or a character. */
|
|
453
|
+
function parseViewValue(raw, key) {
|
|
416
454
|
if (typeof raw !== 'string') return raw;
|
|
417
455
|
const t = raw.trim();
|
|
418
456
|
if (t === 'true') return true;
|
|
@@ -421,6 +459,22 @@ function parseViewValue(raw) {
|
|
|
421
459
|
if (t.startsWith('{') || t.startsWith('[')) {
|
|
422
460
|
try { return JSON.parse(t); } catch { throw new Error(`not valid JSON: ${t}`); }
|
|
423
461
|
}
|
|
462
|
+
// ⚠ A QUOTED value is a LITERAL string, and `options.sort='""'` is the whole reason the branch
|
|
463
|
+
// exists: `sort: ''` is what the surface requires to round-trip "unsorted" (omit it and the
|
|
464
|
+
// ordering silently reverts to a fallback on the next load), and the dotted grammar could not
|
|
465
|
+
// express it — an empty value UNSETS, by the same convention `dt set` has for a record field.
|
|
466
|
+
// So the quoting is the operator saying "the empty string IS the value"; `assignViewValue`
|
|
467
|
+
// carries that through to `assignPath`.
|
|
468
|
+
if (t.startsWith('"')) {
|
|
469
|
+
try {
|
|
470
|
+
const s = JSON.parse(t);
|
|
471
|
+
if (typeof s === 'string') return s;
|
|
472
|
+
} catch { /* one message for every malformed quoting, below */ }
|
|
473
|
+
throw new Error(`not a quoted string: ${t} — a literal is written like '"-date"' (and '""' for the empty one)`);
|
|
474
|
+
}
|
|
475
|
+
// An empty value falls through to assignPath, which UNSETS the key — a `columns=` that wrote
|
|
476
|
+
// `columns: []` would be a configured-to-show-nothing view, not a removed setting.
|
|
477
|
+
if (t !== '' && VIEW_LIST_OPTIONS.has(key)) return t.split(',').map((s) => s.trim()).filter(Boolean);
|
|
424
478
|
return raw;
|
|
425
479
|
}
|
|
426
480
|
|
|
@@ -435,11 +489,15 @@ function parseViewValue(raw) {
|
|
|
435
489
|
*
|
|
436
490
|
* Intermediate objects are not created on the way to a delete: unsetting `a.b` on a record with no
|
|
437
491
|
* `a` should leave the record alone, not grow an empty `a: {}`.
|
|
492
|
+
*
|
|
493
|
+
* `explicit` is the escape hatch, and it has exactly one caller: a value the operator QUOTED
|
|
494
|
+
* (`options.sort='""'`) is a value, empty or not. Without it the convention above has no exception
|
|
495
|
+
* and `sort: ''` — which the surface needs to mean "unsorted" — is unwritable from the CLI.
|
|
438
496
|
*/
|
|
439
|
-
function assignPath(target, dotted, value) {
|
|
497
|
+
function assignPath(target, dotted, value, explicit = false) {
|
|
440
498
|
const keys = dotted.split('.');
|
|
441
499
|
const leaf = keys[keys.length - 1];
|
|
442
|
-
const unset = value === null || value === '';
|
|
500
|
+
const unset = !explicit && (value === null || value === '');
|
|
443
501
|
let node = target;
|
|
444
502
|
for (const k of keys.slice(0, -1)) {
|
|
445
503
|
if (node[k] == null || typeof node[k] !== 'object' || Array.isArray(node[k])) {
|
|
@@ -452,6 +510,12 @@ function assignPath(target, dotted, value) {
|
|
|
452
510
|
else node[leaf] = value;
|
|
453
511
|
}
|
|
454
512
|
|
|
513
|
+
/** One dotted `key=value` write into a view: the value grammar, plus the one signal `assignPath`
|
|
514
|
+
* cannot recover from the parsed value — whether the operator quoted it. */
|
|
515
|
+
function assignViewValue(view, key, raw) {
|
|
516
|
+
assignPath(view, key, parseViewValue(raw, key), typeof raw === 'string' && raw.trim().startsWith('"'));
|
|
517
|
+
}
|
|
518
|
+
|
|
455
519
|
const VIEW_META_FLAGS = new Set(['id', 'json', 'force']);
|
|
456
520
|
|
|
457
521
|
function metaUiView(ws, store, verb, flags, pos) {
|
|
@@ -466,7 +530,7 @@ function metaUiView(ws, store, verb, flags, pos) {
|
|
|
466
530
|
// store means `set` works on a MODULE-shipped view too — the edit lands as a workspace source
|
|
467
531
|
// that shadows it, which is the same thing saving one in the UI does.
|
|
468
532
|
let view = {};
|
|
469
|
-
let id =
|
|
533
|
+
let id = oneValue(flags, 'id');
|
|
470
534
|
if (verb === 'set') {
|
|
471
535
|
id ??= need(pos, 0, 'ui-view id');
|
|
472
536
|
const { fields } = store.read('ui-views', id);
|
|
@@ -476,11 +540,16 @@ function metaUiView(ws, store, verb, flags, pos) {
|
|
|
476
540
|
|
|
477
541
|
for (const p of pos.slice(verb === 'set' ? 1 : 0)) {
|
|
478
542
|
if (!p.includes('=')) continue;
|
|
479
|
-
|
|
543
|
+
assignViewValue(view, p.slice(0, p.indexOf('=')), p.slice(p.indexOf('=') + 1));
|
|
480
544
|
}
|
|
481
545
|
for (const [k, v] of Object.entries(flags)) {
|
|
482
546
|
if (VIEW_META_FLAGS.has(k)) continue;
|
|
483
|
-
|
|
547
|
+
// A view key holds ONE value, and a list IS one value — so a repeated flag is a mistake with
|
|
548
|
+
// two spellings to correct it, rather than an array nobody asked for.
|
|
549
|
+
if (Array.isArray(v)) {
|
|
550
|
+
throw new Error(`--${k} was given ${v.length} times — a view key takes one value, and a list is written as one: --${k} ${v.join(',')} (or the JSON form '${JSON.stringify(v)}')`);
|
|
551
|
+
}
|
|
552
|
+
assignViewValue(view, k, v);
|
|
484
553
|
}
|
|
485
554
|
|
|
486
555
|
if (!view.path) throw new Error('missing --path </route> — a view is addressed by its route');
|
|
@@ -621,21 +690,75 @@ function* parseEach(store, collection, onError) {
|
|
|
621
690
|
}
|
|
622
691
|
}
|
|
623
692
|
|
|
624
|
-
|
|
693
|
+
/**
|
|
694
|
+
* `--flag value`, `--flag=value`, a bare `--flag` (true), and positionals.
|
|
695
|
+
*
|
|
696
|
+
* ⚠ A REPEATED flag PROMOTES TO AN ARRAY; it does not overwrite. `dt add c --tags a --tags b`
|
|
697
|
+
* assigned twice and kept `b`, so the first value never reached disk and nothing said so — the
|
|
698
|
+
* shape of every silent-wrong-answer bug this CLI has had. The promotion is the only honest parse,
|
|
699
|
+
* because "the operator typed it twice" is a fact the parser cannot interpret on its own.
|
|
700
|
+
*
|
|
701
|
+
* Every consumer of a flag therefore owes an answer to "what does a repeat MEAN here", and there
|
|
702
|
+
* are exactly three: it is one ELEMENT of an array field (`coerceArrays`), it is one more
|
|
703
|
+
* conjunctive CONDITION (`--filter`, in list), or the flag holds one value and a repeat is a
|
|
704
|
+
* MISTAKE (`oneValue`, and the view writer). Silence is not one of them.
|
|
705
|
+
*/
|
|
706
|
+
export function parseArgs(args) {
|
|
625
707
|
const flags = {};
|
|
626
708
|
const pos = [];
|
|
709
|
+
const put = (k, v) => { flags[k] = k in flags ? [].concat(flags[k], v) : v; };
|
|
627
710
|
for (let i = 0; i < args.length; i++) {
|
|
628
711
|
const a = args[i];
|
|
629
712
|
if (a.startsWith('--')) {
|
|
630
713
|
const eq = a.indexOf('=');
|
|
631
|
-
if (eq > -1)
|
|
632
|
-
else if (i + 1 < args.length && !args[i + 1].startsWith('--'))
|
|
633
|
-
else
|
|
714
|
+
if (eq > -1) put(a.slice(2, eq), a.slice(eq + 1));
|
|
715
|
+
else if (i + 1 < args.length && !args[i + 1].startsWith('--')) put(a.slice(2), args[++i]);
|
|
716
|
+
else put(a.slice(2), true);
|
|
634
717
|
} else pos.push(a);
|
|
635
718
|
}
|
|
636
719
|
return { flags, pos };
|
|
637
720
|
}
|
|
638
721
|
|
|
722
|
+
/** Read a flag that takes exactly ONE value. A repeat arrives as an array, and dropping it on the
|
|
723
|
+
* floor (`typeof v === 'string'` reads an array as absent) would answer the wrong question at exit
|
|
724
|
+
* 0 — so it is named instead, with the line that was typed. */
|
|
725
|
+
function oneValue(flags, key) {
|
|
726
|
+
const v = flags[key];
|
|
727
|
+
if (Array.isArray(v)) {
|
|
728
|
+
throw new Error(`--${key} was given ${v.length} times and takes ONE value: ${v.map((x) => `--${key} ${x}`).join(' ')}`);
|
|
729
|
+
}
|
|
730
|
+
return typeof v === 'string' ? v : undefined;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* A schema verb takes ONE value per flag, and a repeat is refused before anything is written.
|
|
735
|
+
*
|
|
736
|
+
* None of them means a list by repetition — `--options a,b` is one value, and `--name` names the
|
|
737
|
+
* single thing being written — so every repeat is a mistake. It has to be caught here because the
|
|
738
|
+
* ones that matter are IDENTITY: `--name x --name y` wrote a field called `y` before the promotion
|
|
739
|
+
* and one called `x,y` after it, and both of those are a source file the operator then has to find
|
|
740
|
+
* and edit by hand.
|
|
741
|
+
*/
|
|
742
|
+
function refuseRepeats(flags) {
|
|
743
|
+
const dup = Object.entries(flags).find(([, v]) => Array.isArray(v));
|
|
744
|
+
if (!dup) return;
|
|
745
|
+
const [k, v] = dup;
|
|
746
|
+
throw new Error(`--${k} was given ${v.length} times, and a schema verb takes ONE value per flag: ${v.map((x) => `--${k} ${x}`).join(' ')}`);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** `field=value` positionals, with the same promote-on-repeat rule the flags have. It was
|
|
750
|
+
* `Object.fromEntries`, which keeps the LAST pair — `dt set c/id tags=a tags=b` wrote `[b]`. */
|
|
751
|
+
function pairs(list) {
|
|
752
|
+
const out = {};
|
|
753
|
+
for (const p of list) {
|
|
754
|
+
if (!p.includes('=')) continue;
|
|
755
|
+
const k = p.slice(0, p.indexOf('='));
|
|
756
|
+
const v = p.slice(p.indexOf('=') + 1);
|
|
757
|
+
out[k] = k in out ? [].concat(out[k], v) : v;
|
|
758
|
+
}
|
|
759
|
+
return out;
|
|
760
|
+
}
|
|
761
|
+
|
|
639
762
|
const META_FLAGS = new Set(['id', 'json', 'force', 'filter']);
|
|
640
763
|
const stripMeta = (flags) => Object.fromEntries(Object.entries(flags).filter(([k]) => !META_FLAGS.has(k)));
|
|
641
764
|
|
|
@@ -643,9 +766,18 @@ const stripMeta = (flags) => Object.fromEntries(Object.entries(flags).filter(([k
|
|
|
643
766
|
function coerceArrays(d, fields) {
|
|
644
767
|
const out = {};
|
|
645
768
|
for (const [k, v] of Object.entries(fields)) {
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
769
|
+
const isList = d.schema.properties?.[k]?.type === 'array';
|
|
770
|
+
// An array here means the operator SPELLED the key twice (`--tags a --tags b`, or
|
|
771
|
+
// `tags=a tags=b`). On a list field each sighting is one element — deliberately NOT
|
|
772
|
+
// comma-split again, so a value that contains a comma can be written by repeating the flag.
|
|
773
|
+
if (Array.isArray(v)) {
|
|
774
|
+
if (!isList) {
|
|
775
|
+
throw new Error(`${k} was given ${v.length} times (--${k} <value> / ${k}=<value>) and ${k} is not an array field — pass it once`);
|
|
776
|
+
}
|
|
777
|
+
out[k] = v;
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
out[k] = isList && typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : v;
|
|
649
781
|
}
|
|
650
782
|
return out;
|
|
651
783
|
}
|
package/src/commit.js
CHANGED
|
@@ -377,7 +377,14 @@ function sample(root, repo, dirs, descriptors) {
|
|
|
377
377
|
const fromRel = (status === 'R' || status === 'C') ? chunks[++i] : null;
|
|
378
378
|
const rec = pathToRecord(descriptors, prefix + repoRel);
|
|
379
379
|
if (!rec) continue;
|
|
380
|
-
|
|
380
|
+
// ⚠ `M` MEANS `set` FOR EVERY CODEC BUT ONE. `set` is the single verb the store refuses on a
|
|
381
|
+
// `codec: file` record — its bytes ARE the record, and the refusal's own message says to use
|
|
382
|
+
// `add --from … --force` instead — so a forced replacement was published under the name of
|
|
383
|
+
// the one thing that cannot be done to it. The status letter still decides; the descriptor
|
|
384
|
+
// only says what the letter is called here.
|
|
385
|
+
let verb = VERB[status] ?? 'set';
|
|
386
|
+
if (verb === 'set' && (descriptors.get(rec.collection)?.storage?.codec ?? 'md') === 'file') verb = 'replace';
|
|
387
|
+
rows.push({ repoRel, fromRel, ...rec, verb });
|
|
381
388
|
}
|
|
382
389
|
return { cwd, rows };
|
|
383
390
|
}
|
|
@@ -485,6 +492,7 @@ export function commitPending(store, { only = [], message, dryRun = false } = {}
|
|
|
485
492
|
// store.js has always used.
|
|
486
493
|
execFileSync('git', ['add', '--all', '--', ...paths], { cwd });
|
|
487
494
|
execFileSync('git', ['commit', '--quiet', '-m', subject, '--', ...paths, ...alreadyStaged], { cwd });
|
|
495
|
+
store.headMoved(); // the verb that exists to move HEAD — see store.gitHead
|
|
488
496
|
}
|
|
489
497
|
const sha = dryRun ? null : execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd }).toString().trim();
|
|
490
498
|
results.push({ repo, rows, subject, sha, warning, leftPending });
|
package/src/compile.js
CHANGED
|
@@ -12,7 +12,7 @@ import { walk, patternRe } from './records.js';
|
|
|
12
12
|
import { unknownOperators } from './filter.js';
|
|
13
13
|
import {
|
|
14
14
|
normalizeNamespaces, namespaceProblems, unqualifiedProblems, defaultStoragePath, storageOverlaps,
|
|
15
|
-
baseNameOf,
|
|
15
|
+
baseNameOf, singular,
|
|
16
16
|
} from './namespace.js';
|
|
17
17
|
// circular on paper in earlier versions — safe: both sides only
|
|
18
18
|
// call at run time, same pattern as store.js ↔ compile.js.
|
|
@@ -104,7 +104,7 @@ function normalizeRelationKeywords(schema, name, prefix = '') {
|
|
|
104
104
|
// every per-relation keyword, not just `x-inverse` — `x-unique` decides the mirror's
|
|
105
105
|
// cardinality, `x-on-delete` its removal rule and `x-inverse-of` names the owner from the
|
|
106
106
|
// other spelling, so all four have to reach the same node the relation is read from.
|
|
107
|
-
for (const kw of ['x-inverse', 'x-title-template', 'x-unique', 'x-on-delete', 'x-inverse-of']) {
|
|
107
|
+
for (const kw of ['x-inverse', 'x-title-template', 'x-unique', 'x-on-delete', 'x-inverse-of', 'x-reference-soft']) {
|
|
108
108
|
if (!(kw in prop)) continue;
|
|
109
109
|
if (kw in prop.items && prop.items[kw] !== prop[kw]) {
|
|
110
110
|
fail(`collection "${name}": field "${at}" declares conflicting ${kw} on the property and its items — keep one.`);
|
|
@@ -423,14 +423,29 @@ const NON_SOURCE_DIRS = new Set([
|
|
|
423
423
|
]);
|
|
424
424
|
|
|
425
425
|
/** Unrecognised source-root folders in a module, or [] for the workspace root (a vault legitimately
|
|
426
|
-
* holds arbitrary directories — this gate is about PACKAGES, whose folders all mean something).
|
|
426
|
+
* holds arbitrary directories — this gate is about PACKAGES, whose folders all mean something).
|
|
427
|
+
*
|
|
428
|
+
* ⚠ `system/` is enumerated TOO, under the same rule. It is in NON_SOURCE_DIRS so the root pass
|
|
429
|
+
* waves it through, and that waved through everything BELOW it — so `system/gizmos/probe.gizmo.yaml`
|
|
430
|
+
* compiled ✔ in silence while the identical folder at the module root hard-errored. That is exactly
|
|
431
|
+
* the decision-156 shape the root gate exists to prevent, surviving one level down: `kindDir` still
|
|
432
|
+
* reads `system/<kind>` as the pre-flatten fallback, so a kind the engine stopped knowing is just as
|
|
433
|
+
* invisible there as it ever was at the root, and the half-migrated module is the likeliest place
|
|
434
|
+
* for one to be. Known kinds under `system/` keep compiling — the fallback is deliberate and stays
|
|
435
|
+
* (CLAUDE.md); only UNKNOWN folders become errors, and `dreamteamer.ignore` excuses them in both
|
|
436
|
+
* places with one entry. */
|
|
427
437
|
function strayKindDirs(source, wsRoot, declaredIgnore) {
|
|
428
438
|
if (path.resolve(source.root) === path.resolve(wsRoot)) return [];
|
|
429
439
|
const allow = new Set([...KINDS, ...NON_SOURCE_DIRS, ...declaredIgnore]);
|
|
430
|
-
|
|
440
|
+
const dirsIn = (dir, prefix = '') => (fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }) : [])
|
|
431
441
|
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !allow.has(e.name))
|
|
432
|
-
.map((e) => e.name)
|
|
433
|
-
|
|
442
|
+
.map((e) => `${prefix}${e.name}`);
|
|
443
|
+
return [
|
|
444
|
+
...dirsIn(source.root),
|
|
445
|
+
// The SAME allow set one level down — a generic package folder reads the same wherever the
|
|
446
|
+
// module put it, and one `ignore` entry covers both spellings rather than two.
|
|
447
|
+
...dirsIn(path.join(source.root, 'system'), 'system/'),
|
|
448
|
+
].sort();
|
|
434
449
|
}
|
|
435
450
|
|
|
436
451
|
const sha256 = (buf) => 'sha256:' + createHash('sha256').update(buf).digest('hex');
|
|
@@ -527,6 +542,30 @@ export function compile({ root, pkg }) {
|
|
|
527
542
|
const harnesses = config.harnesses ?? ['claude-code'];
|
|
528
543
|
const rel = (p) => path.relative(root, p);
|
|
529
544
|
|
|
545
|
+
/** ⚠ YAML, WITH THE FILE IN THE MESSAGE. js-yaml reports `line:column` and a snippet and never a
|
|
546
|
+
* path, so one stray tab in a workspace of a hundred sources was an mtime bisect. Every parse of
|
|
547
|
+
* a MODULE SOURCE goes through here; the projected records compile dumps itself do not need it. */
|
|
548
|
+
const loadSource = (text, srcPath) => {
|
|
549
|
+
try {
|
|
550
|
+
return load(text);
|
|
551
|
+
} catch (e) {
|
|
552
|
+
return fail(`${srcPath}: ${e.message}`);
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
/** An EMPTY source is never intentional, and every reader indexes straight into what it parses.
|
|
557
|
+
* The collection case died with a bare "Cannot read properties of undefined" naming no file; a
|
|
558
|
+
* 0-byte ui-view was worse — compile said ✔ and `check` caught it one gate later, naming the
|
|
559
|
+
* COMPILED artifact under `.dreamteamer/`, which nobody can edit and the next compile overwrites.
|
|
560
|
+
* Refuse here, the last layer that still knows which module file the bytes came from.
|
|
561
|
+
* ⚠ ENTITY FILES ONLY. A skill is a FOLDER: its `SKILL.md` is the entity and is covered, while a
|
|
562
|
+
* placeholder under `references/` is the author's business, not the compiler's. */
|
|
563
|
+
const refuseEmptySource = (srcPath) => {
|
|
564
|
+
if (fs.readFileSync(srcPath, 'utf8').trim() === '') {
|
|
565
|
+
fail(`${rel(srcPath)}: source is empty — there is nothing to compile. Delete the file, or write one.`);
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
|
|
530
569
|
// ---- discover sources: channel modules then the workspace's own -----------------
|
|
531
570
|
const { modules: discovered, shadows } = discoverModules(root, pkg);
|
|
532
571
|
for (const s of shadows) console.warn(shadowWarning(s));
|
|
@@ -688,9 +727,13 @@ export function compile({ root, pkg }) {
|
|
|
688
727
|
// both of which used to compile ✔ and contribute nothing (see NON_SOURCE_DIRS)
|
|
689
728
|
const strays = strayKindDirs(source, root, moduleIgnores.get(source.name) ?? []);
|
|
690
729
|
if (strays.length) {
|
|
730
|
+
// `ignore` matches a FOLDER NAME, at the root and under `system/` alike, so the remedy has
|
|
731
|
+
// to quote the bare name — suggesting "system/gizmos" would print an entry that matches
|
|
732
|
+
// nothing and send the reader back for a second compile to find that out.
|
|
733
|
+
const ignorable = [...new Set(strays.map((s) => s.replace(/^system\//, '')))];
|
|
691
734
|
fail(`module "${source.name}" (${rel(source.root)}) has folder(s) that are not a known kind: ${strays.join(', ')}
|
|
692
735
|
known kinds: ${KINDS.join(', ')}
|
|
693
|
-
if these are not sources, declare them: "dreamteamer": { "ignore": [${
|
|
736
|
+
if these are not sources, declare them: "dreamteamer": { "ignore": [${ignorable.map((s) => `"${s}"`).join(', ')}] } in ${rel(path.join(source.root, 'package.json'))}`);
|
|
694
737
|
}
|
|
695
738
|
for (const kind of KINDS) {
|
|
696
739
|
// a half-moved module compiles its flat half and drops the rest — say so rather than
|
|
@@ -721,7 +764,16 @@ export function compile({ root, pkg }) {
|
|
|
721
764
|
if (kind === 'collections' && !isDir) {
|
|
722
765
|
// descriptors merge via 'extends' — collect per collection name
|
|
723
766
|
const bytes = fs.readFileSync(srcPath);
|
|
724
|
-
|
|
767
|
+
// A `touch`ed file on the way to writing one is the ordinary way to arrive here. The
|
|
768
|
+
// descriptor keeps its own wording — it is the one kind that can say what is missing.
|
|
769
|
+
if (bytes.toString('utf8').trim() === '') {
|
|
770
|
+
fail(`${rel(srcPath)}: collection source is empty — a descriptor needs at least 'name' and 'schema' (or 'extends'). Delete the file, or write one.`);
|
|
771
|
+
}
|
|
772
|
+
const doc = loadSource(bytes.toString('utf8'), rel(srcPath));
|
|
773
|
+
// a scalar or a list parses fine and then reads as undefined on every key below
|
|
774
|
+
if (doc == null || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
775
|
+
fail(`${rel(srcPath)}: collection source is not a mapping — a descriptor needs at least 'name' and 'schema' (or 'extends').`);
|
|
776
|
+
}
|
|
725
777
|
// `codec: file` records are opaque bytes: there are no fields, so there is no schema to
|
|
726
778
|
// require and none to honour. Every other codec parses text into fields and must declare
|
|
727
779
|
// what they are.
|
|
@@ -733,18 +785,23 @@ export function compile({ root, pkg }) {
|
|
|
733
785
|
descriptorGroups.get(doc.name).push({ src: { path: rel(srcPath), hash: sha256(bytes) }, doc, moduleName: source.name });
|
|
734
786
|
contributed.add(source.name);
|
|
735
787
|
} else if (FOLDER_KINDS.has(kind) && isDir) {
|
|
788
|
+
// the folder's ENTITY file, not its payload — see refuseEmptySource
|
|
789
|
+
const entityFile = path.join(srcPath, 'SKILL.md');
|
|
790
|
+
if (fs.existsSync(entityFile)) refuseEmptySource(entityFile);
|
|
736
791
|
for (const file of walk(srcPath)) {
|
|
737
792
|
addEntry(path.join(kind, name, path.relative(srcPath, file)), file);
|
|
738
793
|
contributed.add(source.name);
|
|
739
794
|
}
|
|
740
795
|
counts[kind]++;
|
|
741
796
|
} else if (!isDir) {
|
|
797
|
+
refuseEmptySource(srcPath);
|
|
742
798
|
addEntry(path.join(kind, name), srcPath);
|
|
743
799
|
contributed.add(source.name);
|
|
744
800
|
counts[kind]++;
|
|
745
801
|
} else {
|
|
746
802
|
// nested dirs for file-shape kinds (e.g. date-partitioned) — recurse
|
|
747
803
|
for (const file of walk(srcPath)) {
|
|
804
|
+
refuseEmptySource(file);
|
|
748
805
|
addEntry(path.join(kind, path.relative(srcDir, file)), file);
|
|
749
806
|
contributed.add(source.name);
|
|
750
807
|
counts[kind]++;
|
|
@@ -805,8 +862,8 @@ export function compile({ root, pkg }) {
|
|
|
805
862
|
for (const [rt, entry] of entries) {
|
|
806
863
|
const m = /^collection-templates\/(.+)\.collection-template\.yaml$/.exec(rt);
|
|
807
864
|
if (!m) continue;
|
|
808
|
-
const doc =
|
|
809
|
-
templateDocs.set(m[1], { template: doc
|
|
865
|
+
const doc = loadSource(entry.bytes.toString('utf8'), entry.sources[0].path);
|
|
866
|
+
templateDocs.set(m[1], { template: doc?.template ?? {}, src: entry.sources[0] });
|
|
810
867
|
}
|
|
811
868
|
|
|
812
869
|
// ---- namespaces: the declared list, validated against what actually compiled ----------
|
|
@@ -916,6 +973,21 @@ export function compile({ root, pkg }) {
|
|
|
916
973
|
// An authored `storage.path` still wins — registering an existing folder is a first-class case
|
|
917
974
|
// (skills/using-dreamteamer/references/collections.md).
|
|
918
975
|
merged.storage.path ??= defaultStoragePath(name, namespaces, config['data-path'] ?? 'data');
|
|
976
|
+
// The middle segment of every record filename, `<id>.<suffix>.<ext>`. DERIVED here, exactly as
|
|
977
|
+
// `path` is above, so the runtime always carries an explicit one.
|
|
978
|
+
//
|
|
979
|
+
// ⚠ Absent, it was interpolated raw and every record landed as `<id>.undefined.md` — silent at
|
|
980
|
+
// compile, at `add` and at `check`, because `idFromRecordPath` reads the same undefined back
|
|
981
|
+
// and the round trip agrees with itself. On a `codec: file` collection it is not even silent
|
|
982
|
+
// for long: the next verb dies inside that function on `undefined.replace`.
|
|
983
|
+
//
|
|
984
|
+
// The rule is the singular of the BARE name (`health/doctors` records are `<id>.doctor.md`,
|
|
985
|
+
// not `<id>.health/doctor.md`) — the same rule `collections add` writes into a new descriptor,
|
|
986
|
+
// and the one `rename-collection` already ASSUMES when it tests `oldSuffix === singular(oldBase)`
|
|
987
|
+
// to decide whether a suffix may be re-derived. Deriving here makes that assumption true rather
|
|
988
|
+
// than coincidental. Kinder than refusing, and no existing workspace changes: a descriptor that
|
|
989
|
+
// authors a suffix keeps it, and one that does not was writing `.undefined.` files.
|
|
990
|
+
merged.storage.suffix ??= singular(baseNameOf(name, namespaces));
|
|
919
991
|
const storagePath = String(merged.storage.path ?? '');
|
|
920
992
|
const systemKinds = [...KINDS, ...DERIVED_KINDS];
|
|
921
993
|
const isSystem = systemKinds.includes(storagePath) || systemKinds.includes(storagePath.replace(/^system\//, ''));
|
|
@@ -1001,6 +1073,8 @@ export function compile({ root, pkg }) {
|
|
|
1001
1073
|
const declaredDeps = new Set(groupModules.flatMap((m) => moduleDeps.get(m) ?? []));
|
|
1002
1074
|
const declaredPeers = new Set(groupModules.flatMap((m) => modulePeers.get(m) ?? []));
|
|
1003
1075
|
const owns = (t) => groupModules.includes(collOwner.get(t));
|
|
1076
|
+
// what THIS collection actually points at — see `unresolved_peers` below
|
|
1077
|
+
const referenced = new Set();
|
|
1004
1078
|
normalizeRelationKeywords(merged.schema, name);
|
|
1005
1079
|
for (const [at, raw] of refTargets(merged.schema)) {
|
|
1006
1080
|
if (raw === '*') {
|
|
@@ -1021,6 +1095,7 @@ export function compile({ root, pkg }) {
|
|
|
1021
1095
|
fail(`collection "${name}": field "${at}" has an invalid x-reference ${JSON.stringify(raw)} — expected a collection name, a non-empty list of collection names, or '*'.`);
|
|
1022
1096
|
}
|
|
1023
1097
|
for (const target of targets) {
|
|
1098
|
+
referenced.add(target);
|
|
1024
1099
|
if (CORE_COLLECTIONS.has(target) || owns(target)) continue;
|
|
1025
1100
|
const owner = collOwner.get(target);
|
|
1026
1101
|
if (owner && declaredDeps.has(owner)) continue;
|
|
@@ -1034,7 +1109,13 @@ export function compile({ root, pkg }) {
|
|
|
1034
1109
|
// Declared peers that nothing provides, stated as DATA on the descriptor so `check` can
|
|
1035
1110
|
// excuse their references without learning what a module is (the `storage.base` precedent —
|
|
1036
1111
|
// check.js is in the record layer and must not know modules exist).
|
|
1037
|
-
|
|
1112
|
+
//
|
|
1113
|
+
// ⚠ Only the peers THIS collection references. It used to be every peer the module declared,
|
|
1114
|
+
// stamped onto every collection in it — which made the field a module-level fact wearing a
|
|
1115
|
+
// collection-level key, and handed a collection that references nothing an excuse it can
|
|
1116
|
+
// never legitimately use. A peer declared for one collection would then have silently excused
|
|
1117
|
+
// a typo'd reference in a sibling.
|
|
1118
|
+
const unresolved = [...declaredPeers].filter((p) => referenced.has(p) && !collOwner.has(p)).sort();
|
|
1038
1119
|
if (unresolved.length) merged.unresolved_peers = unresolved;
|
|
1039
1120
|
|
|
1040
1121
|
// ONE BODY PER COLLECTION. A record's prose is the text after the frontmatter — there is only
|
|
@@ -1187,7 +1268,7 @@ export function compile({ root, pkg }) {
|
|
|
1187
1268
|
for (const [rt, e] of entries) {
|
|
1188
1269
|
if (rt.startsWith('agents/')) {
|
|
1189
1270
|
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(e.bytes.toString('utf8'));
|
|
1190
|
-
const doc = fm ?
|
|
1271
|
+
const doc = fm ? loadSource(fm[1], e.sources[0].path) : {};
|
|
1191
1272
|
for (const sk of doc.skills ?? []) {
|
|
1192
1273
|
if (!skillIds.has(String(sk).replace(/^skills\//, ''))) fail(`${rt}: references unknown skill "${sk}"`);
|
|
1193
1274
|
}
|
|
@@ -1221,7 +1302,7 @@ export function compile({ root, pkg }) {
|
|
|
1221
1302
|
const ownFields = load(entries.get(path.join('collections', 'ui-views.collection.yaml'))?.bytes?.toString('utf8') ?? '')?.schema?.properties ?? {};
|
|
1222
1303
|
for (const [rt, e] of entries) {
|
|
1223
1304
|
if (!rt.startsWith('ui-views/')) continue;
|
|
1224
|
-
const view =
|
|
1305
|
+
const view = loadSource(e.bytes.toString('utf8'), e.sources[0].path);
|
|
1225
1306
|
// filters are load-bearing (they narrow what the operator SEES) — typo'd operators
|
|
1226
1307
|
// fail at compile, not silently at render (review finding 5)
|
|
1227
1308
|
const badOps = view?.filter ? [...unknownOperators(view.filter)] : [];
|
|
@@ -1258,7 +1339,7 @@ export function compile({ root, pkg }) {
|
|
|
1258
1339
|
const commandIds = new Set([...entries.keys()].filter((k) => k.startsWith('commands/')).map((k) => path.basename(k).replace(/\.command\.md$/, '')));
|
|
1259
1340
|
for (const [rt, e] of entries) {
|
|
1260
1341
|
if (!rt.startsWith('command-bindings/')) continue;
|
|
1261
|
-
const b =
|
|
1342
|
+
const b = loadSource(e.bytes.toString('utf8'), e.sources[0].path);
|
|
1262
1343
|
const cmd = String(b?.command ?? '').replace(/^commands\//, '');
|
|
1263
1344
|
if (!cmd || !commandIds.has(cmd)) fail(`${rt}: references unknown command "${b?.command ?? ''}"`);
|
|
1264
1345
|
const coll = String(b?.collection ?? '').replace(/^collections\//, '');
|
package/src/env-vars.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
// Two accepted non-goals, unchanged on purpose: `KEY =value` (space before `=`) doesn't match the
|
|
8
8
|
// key pattern and is silently dropped, no diagnostic; an unquoted value keeps a trailing inline
|
|
9
9
|
// `# comment` as part of its text (quote it to strip one).
|
|
10
|
+
import fs from 'node:fs';
|
|
10
11
|
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
11
13
|
|
|
12
14
|
export function parseEnvValues(text) {
|
|
13
15
|
const out = new Map();
|
|
@@ -22,6 +24,19 @@ export function parseEnvValues(text) {
|
|
|
22
24
|
return out;
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/** What `renderTemplate` renders AGAINST, for one workspace: the names `dreamteamer.vars` declares,
|
|
28
|
+
* the values this machine's `.env` carries, and the root. One builder rather than three lines at
|
|
29
|
+
* each call site — `dt resolve` and `repos ensure` disagreeing about which vars are declared would
|
|
30
|
+
* be a path that renders in one verb and refuses in the other. */
|
|
31
|
+
export function envContext(ws) {
|
|
32
|
+
const envFile = path.join(ws.root, '.env');
|
|
33
|
+
return {
|
|
34
|
+
env: parseEnvValues(fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf8') : ''),
|
|
35
|
+
workspaceFolder: ws.root,
|
|
36
|
+
declared: ws.pkg.dreamteamer?.vars ?? [],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
const SUPPORTED = 'supported: ${env:NAME}, ${workspaceFolder}, ${userHome}';
|
|
26
41
|
|
|
27
42
|
export function renderTemplate(str, { env, workspaceFolder, declared }) {
|