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
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// noun-verb collection commands: dreamteamer <collection> list|get|add|set|rm|rename|history|diff|revert
|
|
2
|
+
// + meta verbs: `collections add|rm`, `<collection> add-field|update-field|remove-field`,
|
|
3
|
+
// `ui-views add|set|rm`
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { Store, bodyField } from './store.js';
|
|
8
|
+
import { load, dump } from './yaml.js';
|
|
9
|
+
import { slug } from './template.js';
|
|
10
|
+
import {
|
|
11
|
+
createCollection, removeCollection, addField, updateField, removeField, fieldDef, saveUiView, removeUiView,
|
|
12
|
+
// was copy-pasted here, and the copy went stale the moment the source layout gained a second
|
|
13
|
+
// spelling — one implementation, two callers
|
|
14
|
+
workspaceSystemDir,
|
|
15
|
+
} from './schema-ops.js';
|
|
16
|
+
import { history, historyDiff } from './history.js';
|
|
17
|
+
import { commandsFor, recordResolver } from './record-commands.js';
|
|
18
|
+
import { distinctValues } from './field-values.js';
|
|
19
|
+
import { matchesFilter } from './filter.js';
|
|
20
|
+
import { sortRows } from './temporal.js';
|
|
21
|
+
import { ensureRepo, ensureAllRepos } from './init.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Emit MACHINE-READABLE output synchronously. Use this for every `--json` payload.
|
|
25
|
+
*
|
|
26
|
+
* `console.log` to a pipe is asynchronous, and every CLI path ends in `process.exit()`, which
|
|
27
|
+
* discards whatever is still buffered. A shell pipeline hides the bug completely — the reader
|
|
28
|
+
* drains concurrently, so `dreamteamer contacts list --json | wc -c` reports all 32381 bytes — but
|
|
29
|
+
* the way a script or a coding agent actually calls this is execFileSync/spawnSync, and there the
|
|
30
|
+
* child exits with the pipe still full. Measured before this fix: the same command captured with
|
|
31
|
+
* execFileSync returned exactly **8190 bytes** (one pipe buffer) of that 32381-byte document, i.e.
|
|
32
|
+
* silently invalid JSON, with a zero exit status.
|
|
33
|
+
*
|
|
34
|
+
* Found while writing a setup script for a second operator — the first consumer to read `--json`
|
|
35
|
+
* from a real program rather than a terminal.
|
|
36
|
+
*
|
|
37
|
+
* ⚠ 2026-07-30: a SINGLE `fs.writeSync` was NOT enough, and the previous version of this comment
|
|
38
|
+
* claimed it was. Writing to a pipe performs one `write(2)`, which returns a SHORT COUNT once the
|
|
39
|
+
* 64KB pipe buffer is full — it does not throw, it reports fewer bytes written and the caller
|
|
40
|
+
* ignores the number. So the same truncation reappeared at a larger size: measured at exactly
|
|
41
|
+
* **65126 bytes** of a ~100KB payload, again invalid JSON with a zero exit status. `meetings list
|
|
42
|
+
* --json` (141767 bytes) was affected. Found in a sibling script that reproduced it twice, once
|
|
43
|
+
* per fix.
|
|
44
|
+
*
|
|
45
|
+
* So: write in a LOOP until every byte lands, and retry EAGAIN — stdout can be a non-blocking pipe.
|
|
46
|
+
*/
|
|
47
|
+
export const emit = (s) => {
|
|
48
|
+
const buf = Buffer.from(s + '\n');
|
|
49
|
+
let off = 0;
|
|
50
|
+
while (off < buf.length) {
|
|
51
|
+
try {
|
|
52
|
+
off += fs.writeSync(1, buf, off, buf.length - off);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
if (e.code === 'EAGAIN') continue;
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** `list` flags that are options, not `field=value` shorthand filters. */
|
|
61
|
+
const LIST_META_FLAGS = new Set(['json', 'filter', 'where', 'sort']);
|
|
62
|
+
|
|
63
|
+
export function collectionCommand(ws, collection, verb, args) {
|
|
64
|
+
const store = new Store(ws);
|
|
65
|
+
const { flags, pos } = parseArgs(args);
|
|
66
|
+
|
|
67
|
+
// ---- meta verbs: schema operations write SOURCES, never the runtime ----------
|
|
68
|
+
// These MUST come before the generic switch: their collections are system-stored, so the
|
|
69
|
+
// ordinary record path refuses them ("… are system sources") and always would.
|
|
70
|
+
if (collection === 'collections' && verb === 'add') return metaCollectionsAdd(ws, store, flags);
|
|
71
|
+
if (collection === 'collections' && verb === 'rm') return metaCollectionsRm(ws, store, flags, pos);
|
|
72
|
+
if (collection === 'commands' && verb === 'for') return metaCommandsFor(ws, store, flags, pos);
|
|
73
|
+
if (collection === 'ui-views' && ['add', 'set', 'rm'].includes(verb)) return metaUiView(ws, store, verb, flags, pos);
|
|
74
|
+
if (collection === 'repos' && verb === 'ensure') return metaReposEnsure(ws, flags, pos);
|
|
75
|
+
if (verb === 'add-field') return metaAddField(ws, store, collection, flags);
|
|
76
|
+
if (verb === 'update-field') return metaUpdateField(ws, store, collection, flags);
|
|
77
|
+
if (verb === 'remove-field') return metaRemoveField(ws, store, collection, flags);
|
|
78
|
+
|
|
79
|
+
const d = store.descriptor(collection);
|
|
80
|
+
|
|
81
|
+
switch (verb) {
|
|
82
|
+
case 'list': {
|
|
83
|
+
const filters = Object.entries(flags).filter(([k]) => !LIST_META_FLAGS.has(k));
|
|
84
|
+
if (typeof flags.filter === 'string') {
|
|
85
|
+
const eq = flags.filter.indexOf('=');
|
|
86
|
+
filters.push([flags.filter.slice(0, eq), flags.filter.slice(eq + 1)]);
|
|
87
|
+
}
|
|
88
|
+
// `--where` is the SAME operator set the studio's filter panel emits and saved views
|
|
89
|
+
// store — one `matchesFilter`, so `--where '{"starts":{"_gte":"2026-07-01"}}'` and the
|
|
90
|
+
// panel that produced that JSON cannot disagree about which records match.
|
|
91
|
+
const where = typeof flags.where === 'string' ? load(flags.where) : null;
|
|
92
|
+
const resolve = where ? recordResolver(store) : null;
|
|
93
|
+
const bf = bodyField(d);
|
|
94
|
+
const rows = [];
|
|
95
|
+
for (const { id, fields } of store.readAll(collection)) { // ONE walk, not one per record
|
|
96
|
+
if (!filters.every(([k, v]) => String(fields[k] ?? '') === String(v))) continue;
|
|
97
|
+
if (where && !matchesFilter({ ...fields, id }, where, resolve)) continue;
|
|
98
|
+
if (bf) delete fields[bf]; // bodies don't belong in listings
|
|
99
|
+
rows.push({ ...fields, id }); // record id WINS over any schema field named "id"
|
|
100
|
+
}
|
|
101
|
+
// sorting was studio-only until now: the browse table ordered records and no CLI
|
|
102
|
+
// invocation could. Same `sortRows` the server and api.ts call, so `--sort -starts`
|
|
103
|
+
// orders date-times by INSTANT across mixed offsets rather than by string.
|
|
104
|
+
if (typeof flags.sort === 'string') sortRows(rows, flags.sort);
|
|
105
|
+
if (flags.json) { emit(JSON.stringify(rows, null, 2)); return 0; }
|
|
106
|
+
const cols = ['id', ...(d.list_fields ?? []).filter((c) => c !== 'id')];
|
|
107
|
+
for (const r of rows) console.log(cols.map((c) => fmtCell(r[c])).join(' '));
|
|
108
|
+
if (!rows.length) console.log(`(no ${collection}${filters.length || where ? ' matching' : ''})`);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
case 'get': {
|
|
112
|
+
const id = need(pos, 0, 'id');
|
|
113
|
+
const { fields } = store.read(collection, id);
|
|
114
|
+
flags.json ? emit(JSON.stringify({ ...fields, id }, null, 2)) : console.log(dump(fields).trimEnd());
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
case 'add': {
|
|
118
|
+
const fields = coerceArrays(d, stripMeta(flags));
|
|
119
|
+
const { id, file } = store.add(collection, fields, { id: flags.id });
|
|
120
|
+
flags.json ? emit(JSON.stringify({ id, path: rel(ws.root, file) })) : console.log(`✔ ${rel(ws.root, file)}`);
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
case 'set': {
|
|
124
|
+
const id = need(pos, 0, 'id');
|
|
125
|
+
const changes = coerceArrays(d, Object.fromEntries(
|
|
126
|
+
pos.slice(1).filter((p) => p.includes('=')).map((p) => [p.slice(0, p.indexOf('=')), p.slice(p.indexOf('=') + 1)])
|
|
127
|
+
));
|
|
128
|
+
Object.assign(changes, coerceArrays(d, stripMeta(flags)));
|
|
129
|
+
if (!Object.keys(changes).length) throw new Error('nothing to set — pass key=value pairs or --key value flags');
|
|
130
|
+
store.set(collection, id, changes);
|
|
131
|
+
flags.json ? emit(JSON.stringify({ id })) : console.log('✔ updated');
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
case 'rm': {
|
|
135
|
+
const id = need(pos, 0, 'id');
|
|
136
|
+
const { inboundIgnored } = store.rm(collection, id, { force: !!flags.force });
|
|
137
|
+
flags.json ? emit(JSON.stringify({ id, removed: true, inboundIgnored })) : console.log(`✔ removed${inboundIgnored ? ` (${inboundIgnored} inbound reference(s) left dangling — run \`dreamteamer check\`)` : ''}`);
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
case 'rename': {
|
|
141
|
+
const out = store.rename(collection, need(pos, 0, 'old id'), need(pos, 1, 'new id'));
|
|
142
|
+
if (flags.json) { emit(JSON.stringify(out)); return 0; }
|
|
143
|
+
console.log(`✔ renamed ${collection}/${need(pos, 0, 'old id')} → ${collection}/${out.id}`);
|
|
144
|
+
if (out.touched) console.log(`✔ rewrote ${out.rewrites} inbound reference(s) across ${out.touched} file(s)`);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
// `dreamteamer meetings values status` — the vocabulary a field ACTUALLY uses, so a filter
|
|
148
|
+
// or a command-binding validator can offer a dropdown for a plain `type: string` field that
|
|
149
|
+
// no enum describes (operator: "still no dropdown for many things, visibility, status").
|
|
150
|
+
case 'values': {
|
|
151
|
+
const field = need(pos, 0, 'field');
|
|
152
|
+
const out = distinctValues(store, collection, field, {
|
|
153
|
+
limit: flags.limit === undefined ? undefined : Number(flags.limit),
|
|
154
|
+
});
|
|
155
|
+
if (flags.json) { emit(JSON.stringify(out, null, 2)); return 0; }
|
|
156
|
+
if (out.skipped) { console.log(`(${collection}.${field} is a ${out.skipped} field — no value vocabulary)`); return 0; }
|
|
157
|
+
if (!out.values.length) { console.log(`(no values set on ${collection}.${field})`); return 0; }
|
|
158
|
+
for (const { value, count } of out.values) console.log(count == null ? String(value) : `${String(count).padStart(5)} ${value}`);
|
|
159
|
+
console.log(`— ${out.total} distinct${out.truncated ? ` (showing ${out.values.length})` : ''}, from ${out.source}`);
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
case 'history': {
|
|
163
|
+
const id = need(pos, 0, 'id');
|
|
164
|
+
const log = history(store, collection, id);
|
|
165
|
+
if (flags.json) { emit(JSON.stringify(log, null, 2)); return 0; }
|
|
166
|
+
if (!log.length) { console.log(`(no history for ${collection}/${id} — not committed yet)`); return 0; }
|
|
167
|
+
for (const c of log) console.log(`${c.hash.slice(0, 7)} ${c.date.slice(0, 10)} ${c.author} ${c.subject}`);
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
case 'diff': {
|
|
171
|
+
const id = need(pos, 0, 'id');
|
|
172
|
+
const out = historyDiff(store, collection, id, typeof flags.hash === 'string' ? flags.hash : 'HEAD');
|
|
173
|
+
if (flags.json) { emit(JSON.stringify(out, null, 2)); return 0; }
|
|
174
|
+
console.log(out.diff.trimEnd() || `(no change to ${out.path} in ${out.hash})`);
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
case 'revert': {
|
|
178
|
+
const id = need(pos, 0, 'id');
|
|
179
|
+
// the hash is REQUIRED and has no default: "revert" with an implied target is how you
|
|
180
|
+
// destroy the wrong record. `<c> history <id>` is where you get one.
|
|
181
|
+
const hash = typeof flags.hash === 'string' ? flags.hash : pos[1];
|
|
182
|
+
if (!hash) throw new Error(`missing --hash <commit> — run \`dreamteamer ${collection} history ${id}\` to pick one`);
|
|
183
|
+
const out = store.revert(collection, id, hash);
|
|
184
|
+
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`);
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
default:
|
|
188
|
+
throw new Error(`unknown verb "${verb}" — use list | get | add | set | rm | rename | values | history | diff | revert`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
// `dreamteamer commands for <collection>[/<id>] [--ids <id>[,…]] [--json]` — which bound
|
|
195
|
+
// commands apply, in which state (available / done / not-applicable). THE engine surface
|
|
196
|
+
// behind the studio's Commands tab (engine/UI parity: the verb lands first, the button second).
|
|
197
|
+
function metaCommandsFor(ws, store, flags, pos) {
|
|
198
|
+
const target = need(pos, 0, 'collection[/id]');
|
|
199
|
+
const slash = target.indexOf('/');
|
|
200
|
+
const collection = slash > 0 ? target.slice(0, slash) : target;
|
|
201
|
+
const ids = slash > 0
|
|
202
|
+
? [target.slice(slash + 1)]
|
|
203
|
+
: typeof flags.ids === 'string' ? flags.ids.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
204
|
+
const out = commandsFor(store, collection, ids);
|
|
205
|
+
if (flags.json) { emit(JSON.stringify(out, null, 2)); return 0; }
|
|
206
|
+
if (!out.commands.length) { console.log(`(no commands bound to ${collection})`); return 0; }
|
|
207
|
+
for (const c of out.commands) {
|
|
208
|
+
if (c.target === 'collection') { console.log(`${c.name} [collection] ${c.invocation}`); continue; }
|
|
209
|
+
const counts = ids.length ? ` ${c.eligible.length}/${ids.length} eligible${c.done.length ? `, ${c.done.length} done` : ''}` : ' (no ids given)';
|
|
210
|
+
console.log(`${c.name} [record]${counts}${c.invocation ? `\n ${c.invocation}` : ''}`);
|
|
211
|
+
}
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `repos ensure <id>` / `repos ensure --all` — materialize declared repos on demand.
|
|
217
|
+
* Lazy by design: `install` deliberately does NOT do this, so a fresh workspace clone is
|
|
218
|
+
* immediately workable without pulling every attached repo (and one unreachable remote can only
|
|
219
|
+
* fail the action you asked for, not the whole install).
|
|
220
|
+
*/
|
|
221
|
+
function metaReposEnsure(ws, flags, pos) {
|
|
222
|
+
const results = flags.all ? ensureAllRepos(ws) : [ensureRepo(ws, need(pos, 0, 'id'))];
|
|
223
|
+
if (flags.json) { emit(JSON.stringify(results, null, 2)); return 0; }
|
|
224
|
+
for (const r of results) console.log(r.cloned ? `✔ cloned ${r.path}` : `✔ ${r.path} (present)`);
|
|
225
|
+
if (!results.length) console.log('(no repos declared)');
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// `dreamteamer collections add --name research-docs --template docs`
|
|
230
|
+
function metaCollectionsAdd(ws, store, flags) {
|
|
231
|
+
const { file } = createCollection(ws, store, { name: flags.name, template: flags.template });
|
|
232
|
+
console.log(`✔ ${rel(ws.root, file)}`);
|
|
233
|
+
console.log('✔ compiled — the collection is live (schema ops prove sources with a real compile)');
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// `dreamteamer collections rm widgets [--force]` — --force is required to drop a collection
|
|
238
|
+
// that still has records (removeCollection refuses otherwise, and says so).
|
|
239
|
+
function metaCollectionsRm(ws, store, flags, pos) {
|
|
240
|
+
const name = need(pos, 0, 'collection name');
|
|
241
|
+
const out = removeCollection(ws, store, name, { force: !!flags.force });
|
|
242
|
+
flags.json ? emit(JSON.stringify(out)) : console.log(`✔ removed collection ${out.removed}`);
|
|
243
|
+
console.log('✔ compiled — the collection is gone');
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// `dreamteamer tasks add-field --name urgent --type boolean --default-value false`
|
|
248
|
+
function metaAddField(ws, store, collection, flags) {
|
|
249
|
+
const prop = fieldDef(store, flags);
|
|
250
|
+
const out = addField(ws, store, collection, { name: flags.name, prop, required: flags.required === 'true' });
|
|
251
|
+
console.log(`✔ ${rel(ws.root, out.file)}${out.extends ? ` (extends ${out.extends})` : ''}`);
|
|
252
|
+
console.log('✔ compiled — the field is live');
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// `dreamteamer tasks update-field --name urgent --type enum --options a,b --required false`
|
|
257
|
+
// Same flag vocabulary as add-field (one `fieldDef`), so the two read as one operation with two
|
|
258
|
+
// preconditions rather than two dialects.
|
|
259
|
+
function metaUpdateField(ws, store, collection, flags) {
|
|
260
|
+
if (!flags.name) throw new Error('missing --name <field>');
|
|
261
|
+
const prop = fieldDef(store, flags);
|
|
262
|
+
// tri-state: omitting --required leaves requiredness ALONE, rather than silently clearing it
|
|
263
|
+
const required = flags.required === undefined ? undefined : flags.required === 'true' || flags.required === true;
|
|
264
|
+
const out = updateField(ws, store, collection, flags.name, { prop, required });
|
|
265
|
+
console.log(`✔ ${rel(ws.root, out.file)}${out.extends ? ` (extends ${out.extends})` : ''}`);
|
|
266
|
+
console.log('✔ compiled — the field is updated');
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// `dreamteamer tasks remove-field --name urgent`
|
|
271
|
+
function metaRemoveField(ws, store, collection, flags) {
|
|
272
|
+
const name = flags.name ?? flags.field;
|
|
273
|
+
if (!name) throw new Error('missing --name <field>');
|
|
274
|
+
const out = removeField(ws, store, collection, name);
|
|
275
|
+
flags.json ? emit(JSON.stringify(out)) : console.log(`✔ removed field ${collection}.${out.removed}`);
|
|
276
|
+
console.log('✔ compiled — the field is gone');
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---- ui-views ---------------------------------------------------------------------------------
|
|
281
|
+
// A view is an ordinary record conceptually (decision 49) but a SYSTEM-stored one, so it goes
|
|
282
|
+
// through saveUiView's compile gate rather than the record store. Without these verbs everything
|
|
283
|
+
// the Layout options panel does — columns, order, sort, layout, filter, nav — was click-only.
|
|
284
|
+
|
|
285
|
+
/** `--options '{"sort":"-date"}'` style flags, plus dotted `options.sort=-date` positionals. */
|
|
286
|
+
function parseViewValue(raw) {
|
|
287
|
+
if (typeof raw !== 'string') return raw;
|
|
288
|
+
const t = raw.trim();
|
|
289
|
+
if (t === 'true') return true;
|
|
290
|
+
if (t === 'false') return false;
|
|
291
|
+
if (t !== '' && !Number.isNaN(Number(t))) return Number(t);
|
|
292
|
+
if (t.startsWith('{') || t.startsWith('[')) {
|
|
293
|
+
try { return JSON.parse(t); } catch { throw new Error(`not valid JSON: ${t}`); }
|
|
294
|
+
}
|
|
295
|
+
return raw;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Assign `a.b.c` into a nested object, creating plain objects on the way down. */
|
|
299
|
+
function assignPath(target, dotted, value) {
|
|
300
|
+
const keys = dotted.split('.');
|
|
301
|
+
let node = target;
|
|
302
|
+
for (const k of keys.slice(0, -1)) {
|
|
303
|
+
if (node[k] == null || typeof node[k] !== 'object' || Array.isArray(node[k])) node[k] = {};
|
|
304
|
+
node = node[k];
|
|
305
|
+
}
|
|
306
|
+
node[keys[keys.length - 1]] = value;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const VIEW_META_FLAGS = new Set(['id', 'json', 'force']);
|
|
310
|
+
|
|
311
|
+
function metaUiView(ws, store, verb, flags, pos) {
|
|
312
|
+
if (verb === 'rm') {
|
|
313
|
+
const out = removeUiView(ws, store, need(pos, 0, 'ui-view id'));
|
|
314
|
+
flags.json ? emit(JSON.stringify(out)) : console.log(`✔ removed ui-view ${out.removed}`);
|
|
315
|
+
console.log('✔ compiled — the route is gone');
|
|
316
|
+
return 0;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// `set` edits what the record already says; `add` starts from nothing. Reading through the
|
|
320
|
+
// store means `set` works on a MODULE-shipped view too — the edit lands as a workspace source
|
|
321
|
+
// that shadows it, which is the same thing saving one in the UI does.
|
|
322
|
+
let view = {};
|
|
323
|
+
let id = typeof flags.id === 'string' ? flags.id : undefined;
|
|
324
|
+
if (verb === 'set') {
|
|
325
|
+
id ??= need(pos, 0, 'ui-view id');
|
|
326
|
+
const { fields } = store.read('ui-views', id);
|
|
327
|
+
view = JSON.parse(JSON.stringify(fields));
|
|
328
|
+
delete view.id; // the id is the filename, never a body key
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for (const p of pos.slice(verb === 'set' ? 1 : 0)) {
|
|
332
|
+
if (!p.includes('=')) continue;
|
|
333
|
+
assignPath(view, p.slice(0, p.indexOf('=')), parseViewValue(p.slice(p.indexOf('=') + 1)));
|
|
334
|
+
}
|
|
335
|
+
for (const [k, v] of Object.entries(flags)) {
|
|
336
|
+
if (VIEW_META_FLAGS.has(k)) continue;
|
|
337
|
+
assignPath(view, k, parseViewValue(v));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (!view.path) throw new Error('missing --path </route> — a view is addressed by its route');
|
|
341
|
+
// same id rule the descriptor declares (`{{ path | slug }}`) and the UI derives, so a view
|
|
342
|
+
// saved from the CLI and one saved from the panel land on the SAME record.
|
|
343
|
+
id ??= slug(view.path);
|
|
344
|
+
|
|
345
|
+
const out = saveUiView(ws, store, { id, view });
|
|
346
|
+
flags.json ? emit(JSON.stringify(out)) : console.log(`✔ ${rel(ws.root, out.file)}`);
|
|
347
|
+
console.log(`✔ compiled — ${view.path} is live`);
|
|
348
|
+
return 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function parseArgs(args) {
|
|
352
|
+
const flags = {};
|
|
353
|
+
const pos = [];
|
|
354
|
+
for (let i = 0; i < args.length; i++) {
|
|
355
|
+
const a = args[i];
|
|
356
|
+
if (a.startsWith('--')) {
|
|
357
|
+
const eq = a.indexOf('=');
|
|
358
|
+
if (eq > -1) flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
359
|
+
else if (i + 1 < args.length && !args[i + 1].startsWith('--')) flags[a.slice(2)] = args[++i];
|
|
360
|
+
else flags[a.slice(2)] = true;
|
|
361
|
+
} else pos.push(a);
|
|
362
|
+
}
|
|
363
|
+
return { flags, pos };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const META_FLAGS = new Set(['id', 'json', 'force', 'filter']);
|
|
367
|
+
const stripMeta = (flags) => Object.fromEntries(Object.entries(flags).filter(([k]) => !META_FLAGS.has(k)));
|
|
368
|
+
|
|
369
|
+
// CLI values are strings; split comma-lists for array-typed fields (ajv coerces the rest)
|
|
370
|
+
function coerceArrays(d, fields) {
|
|
371
|
+
const out = {};
|
|
372
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
373
|
+
out[k] = d.schema.properties?.[k]?.type === 'array' && typeof v === 'string'
|
|
374
|
+
? v.split(',').map((s) => s.trim()).filter(Boolean)
|
|
375
|
+
: v;
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function need(pos, i, what) {
|
|
381
|
+
if (pos[i] === undefined) throw new Error(`missing <${what}>`);
|
|
382
|
+
return pos[i];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const fmtCell = (v) => (v === undefined ? '-' : Array.isArray(v) ? v.join(',') : String(v));
|
|
386
|
+
|
|
387
|
+
function rel(root, p) {
|
|
388
|
+
return p.startsWith(root) ? p.slice(root.length + 1) : p;
|
|
389
|
+
}
|
package/src/commit.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// dt commit — publish what is already on disk. Model: GIT IS THE JOURNAL. There is no pending
|
|
2
|
+
// file and no cursor; the set of things to commit is sampled from `git status` over the record
|
|
3
|
+
// directories of every collection, which means it cannot disagree with reality.
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { pathToRecord } from './events.js';
|
|
8
|
+
|
|
9
|
+
const VERB = { A: 'add', M: 'set', D: 'rm', R: 'rename', '?': 'add' };
|
|
10
|
+
|
|
11
|
+
/** Record directories to watch, grouped by owning repo. System-stored collections are excluded:
|
|
12
|
+
* they live in the gitignored runtime and their sources are module files — the same exclusion
|
|
13
|
+
* pathToRecord already applies. */
|
|
14
|
+
function scopeByRepo(descriptors, only) {
|
|
15
|
+
const byRepo = new Map();
|
|
16
|
+
for (const d of descriptors.values()) {
|
|
17
|
+
const p = d.storage?.path;
|
|
18
|
+
// `storage.base`, not a `system/` prefix: after the flatten a runtime collection's path is a
|
|
19
|
+
// bare kind name (`skills`), so the old test admitted all seven — and this list becomes a
|
|
20
|
+
// `git add` pathspec, which fails outright on a path the workspace root does not have.
|
|
21
|
+
if (!p || d.storage.base === 'runtime') continue;
|
|
22
|
+
if (only.length && !only.includes(d.name)) continue;
|
|
23
|
+
const repo = d.storage.repo ?? '.';
|
|
24
|
+
if (!byRepo.has(repo)) byRepo.set(repo, []);
|
|
25
|
+
byRepo.get(repo).push(p);
|
|
26
|
+
}
|
|
27
|
+
return byRepo;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** In-progress merge/rebase/cherry-pick makes a commit's meaning ambiguous. Ordinary dirtiness
|
|
31
|
+
* is NOT a refusal condition — committing dirty records is this verb's entire job. */
|
|
32
|
+
function inProgress(cwd) {
|
|
33
|
+
let gitDir;
|
|
34
|
+
try { gitDir = execFileSync('git', ['rev-parse', '--absolute-git-dir'], { cwd }).toString().trim(); }
|
|
35
|
+
catch { return 'not a git repository'; }
|
|
36
|
+
for (const [marker, label] of [['MERGE_HEAD', 'merge'], ['rebase-merge', 'rebase'], ['rebase-apply', 'rebase'], ['CHERRY_PICK_HEAD', 'cherry-pick'], ['REVERT_HEAD', 'revert']]) {
|
|
37
|
+
if (fs.existsSync(path.join(gitDir, marker))) return `a ${label} is in progress`;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A commit on a detached HEAD is reachable only by sha. Worth saying out loud before making one;
|
|
43
|
+
* not worth refusing over, since it is sometimes exactly what someone means to do. */
|
|
44
|
+
function detached(cwd) {
|
|
45
|
+
try { return execFileSync('git', ['symbolic-ref', '--quiet', 'HEAD'], { cwd }).toString().trim() === ''; }
|
|
46
|
+
catch { return true; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Sample one repo: porcelain status over its record dirs → [{repoRel, collection, id, verb}].
|
|
50
|
+
* `git status` run in a module repo returns REPO-relative paths; pathToRecord takes
|
|
51
|
+
* WORKSPACE-relative ones, so every path is re-prefixed before matching. Skip this and every
|
|
52
|
+
* module-owned path maps to null — dt commit would commit nothing and report success. */
|
|
53
|
+
function sample(root, repo, dirs, descriptors) {
|
|
54
|
+
const cwd = path.resolve(root, repo);
|
|
55
|
+
const prefix = repo === '.' ? '' : `${repo}/`;
|
|
56
|
+
const relDirs = dirs.map((d) => (prefix && d.startsWith(prefix) ? d.slice(prefix.length) : d));
|
|
57
|
+
// `-uall` is load-bearing: by default porcelain COLLAPSES an untracked directory to a single
|
|
58
|
+
// `?? data/notes/` entry, which maps to no record — so the first records of a brand-new
|
|
59
|
+
// collection would be invisible and dt commit would report success having committed nothing.
|
|
60
|
+
const out = execFileSync('git', ['status', '--porcelain', '-z', '-uall', '--', ...relDirs], { cwd }).toString();
|
|
61
|
+
const chunks = out.split('\0').filter((c) => c.length > 0);
|
|
62
|
+
const rows = [];
|
|
63
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
64
|
+
const status = chunks[i].slice(0, 2).trim()[0] ?? 'M';
|
|
65
|
+
const repoRel = chunks[i].slice(3);
|
|
66
|
+
// -z emits a rename/copy as TWO chunks: the new path carries the `XY ` prefix, the old
|
|
67
|
+
// path follows BARE. Consume it here or the next iteration reads a path as a status code
|
|
68
|
+
// — and worse, the old path never gets staged, so the commit keeps half a rename.
|
|
69
|
+
const fromRel = (status === 'R' || status === 'C') ? chunks[++i] : null;
|
|
70
|
+
const rec = pathToRecord(descriptors, prefix + repoRel);
|
|
71
|
+
if (!rec) continue;
|
|
72
|
+
rows.push({ repoRel, fromRel, ...rec, verb: VERB[status] ?? 'set' });
|
|
73
|
+
}
|
|
74
|
+
return { cwd, rows };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One subject for one repo's rows. The git status letter IS the verb, which is why a
|
|
78
|
+
* single-record commit keeps exactly the subject it had when writes committed themselves. */
|
|
79
|
+
export function composeSubject(rows) {
|
|
80
|
+
if (rows.length === 1) return `dreamteamer: ${rows[0].collection} ${rows[0].verb} ${rows[0].id}`;
|
|
81
|
+
const collections = [...new Set(rows.map((r) => r.collection))].sort();
|
|
82
|
+
if (collections.length === 1) {
|
|
83
|
+
const counts = {};
|
|
84
|
+
for (const r of rows) counts[r.verb] = (counts[r.verb] ?? 0) + 1;
|
|
85
|
+
const parts = Object.entries(counts).map(([v, n]) => `${n} ${v}`).join(', ');
|
|
86
|
+
return `dreamteamer: ${collections[0]} ${rows.length} changes (${parts})`;
|
|
87
|
+
}
|
|
88
|
+
return `dreamteamer: ${rows.length} changes across ${collections.join(', ')}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function commitPending(store, { only = [], message, dryRun = false } = {}) {
|
|
92
|
+
const byRepo = scopeByRepo(store.descriptors, only);
|
|
93
|
+
const results = [];
|
|
94
|
+
for (const [repo, dirs] of byRepo) {
|
|
95
|
+
const { cwd, rows } = sample(store.root, repo, dirs, store.descriptors);
|
|
96
|
+
if (!rows.length) continue;
|
|
97
|
+
const blocked = inProgress(cwd);
|
|
98
|
+
if (blocked) { results.push({ repo, rows, blocked }); continue; }
|
|
99
|
+
const warning = detached(cwd) ? 'HEAD is detached — this commit will be reachable only by sha' : null;
|
|
100
|
+
const subject = message ?? composeSubject(rows);
|
|
101
|
+
if (!dryRun) {
|
|
102
|
+
const paths = rows.map((r) => r.repoRel);
|
|
103
|
+
// A rename only reports as `R` once it is STAGED, so its old path is in neither the
|
|
104
|
+
// worktree nor the index and `git add` refuses it ("did not match any files"). It needs
|
|
105
|
+
// no adding — only naming, so the partial commit below carries the deletion half too.
|
|
106
|
+
const alreadyStaged = rows.filter((r) => r.fromRel).map((r) => r.fromRel);
|
|
107
|
+
// `--all` here is PATHSPEC-SCOPED — "including deletions of these named files", not
|
|
108
|
+
// "everything in the tree" (the unscoped form CLAUDE.md rule 6 forbids). Same shape
|
|
109
|
+
// store.js has always used.
|
|
110
|
+
execFileSync('git', ['add', '--all', '--', ...paths], { cwd });
|
|
111
|
+
execFileSync('git', ['commit', '--quiet', '-m', subject, '--', ...paths, ...alreadyStaged], { cwd });
|
|
112
|
+
}
|
|
113
|
+
const sha = dryRun ? null : execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd }).toString().trim();
|
|
114
|
+
results.push({ repo, rows, subject, sha, warning });
|
|
115
|
+
}
|
|
116
|
+
return results;
|
|
117
|
+
}
|