dreamteamer 0.6.4 → 0.7.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/src/schema-ops.js CHANGED
@@ -10,6 +10,12 @@ import { execFileSync } from 'node:child_process';
10
10
  import { load, dump } from './yaml.js';
11
11
  import { compile, kindDir, titleCase } from './compile.js';
12
12
  import { readManifest, runtimeKindDir } from './runtime.js';
13
+ import { normalizeNamespaces, namespaceOf, baseNameOf, qualify, defaultStoragePath } from './namespace.js';
14
+
15
+ // Same rule as store.js: a git failure we CATCH must not also print git's own error on top of the
16
+ // clean message we throw. stdout stays piped because some callers read it.
17
+ const GIT_QUIET = ['ignore', 'pipe', 'ignore'];
18
+ import { walk, EXT } from './records.js';
13
19
 
14
20
  // ---- the gate -------------------------------------------------------------------
15
21
 
@@ -39,10 +45,10 @@ function writeGated(ws, store, files, subject, mutate) {
39
45
  // to record directories, so a deferred source edit would be publishable by nothing.
40
46
  // Extending `dt commit` to module sources is the natural follow-on; it is not this wave.
41
47
  try {
42
- execFileSync('git', ['add', '--', ...rels], { cwd: ws.root });
43
- execFileSync('git', ['commit', '--quiet', '-m', subject, '--', ...rels], { cwd: ws.root });
48
+ execFileSync('git', ['add', '--', ...rels], { cwd: ws.root, stdio: GIT_QUIET });
49
+ execFileSync('git', ['commit', '--quiet', '-m', subject, '--', ...rels], { cwd: ws.root, stdio: GIT_QUIET });
44
50
  } catch (e) {
45
- try { execFileSync('git', ['reset', '--quiet', '--', ...rels], { cwd: ws.root }); } catch { /* nothing staged */ }
51
+ try { execFileSync('git', ['reset', '--quiet', '--', ...rels], { cwd: ws.root, stdio: GIT_QUIET }); } catch { /* nothing staged */ }
46
52
  restore();
47
53
  try { compile(ws); } catch { /* pre-op sources were compilable */ }
48
54
  throw new Error(`git commit failed — the schema change was rolled back, nothing was changed. (${e.message.split('\n')[0]})`);
@@ -60,29 +66,46 @@ export function workspaceSystemDir(ws, kind) {
60
66
 
61
67
  // ---- ops ------------------------------------------------------------------------
62
68
 
63
- export function createCollection(ws, store, { name, template }) {
69
+ export function createCollection(ws, store, { name, template, namespace }) {
64
70
  if (!name) throw new Error('missing collection name');
65
- if (store.descriptors.has(name)) throw new Error(`collection "${name}" already exists`);
66
- const dest = path.join(workspaceSystemDir(ws, 'collections'), `${name}.collection.yaml`);
71
+ // `--namespace health --name doctors` and `--name health/doctors` are the SAME collection, because
72
+ // the qualified name IS the identity everywhere else in the engine. Accepting both keeps the CLI
73
+ // honest about that rather than making the operator learn which spelling a verb wants.
74
+ const declared = normalizeNamespaces(ws.pkg.dreamteamer?.namespaces);
75
+ const qualified = namespace ? qualify(namespace, name) : name;
76
+ const ns = namespaceOf(qualified, declared);
77
+ if (qualified.includes('/') && !ns) {
78
+ throw new Error(`namespace "${qualified.slice(0, qualified.lastIndexOf('/'))}" is not declared — add it to dreamteamer.namespaces in package.json first, or the collection will not compile.`);
79
+ }
80
+ if (store.descriptors.has(qualified)) throw new Error(`collection "${qualified}" already exists`);
81
+ // NESTED, mirroring where compile puts it in the runtime: `collections/health/doctors.collection.yaml`.
82
+ // compile enumerates this kind recursively for exactly this reason — and `upsertField` derives the
83
+ // same path from the same name, which is what keeps a later `add-field` editing the base descriptor
84
+ // instead of quietly creating an overlay beside it.
85
+ const dest = path.join(workspaceSystemDir(ws, 'collections'), `${qualified}.collection.yaml`);
67
86
  if (fs.existsSync(dest)) throw new Error(`${path.relative(ws.root, dest)} already exists`);
68
87
 
69
- let descriptor = { name };
88
+ let descriptor = { name: qualified };
70
89
  if (template) {
71
90
  const tplFile = path.join(runtimeKindDir(ws.root, 'collection-templates'), `${template}.collection-template.yaml`);
72
91
  if (!fs.existsSync(tplFile)) throw new Error(`unknown collection-template "${template}"`);
73
- descriptor = { name, ...structuredClone(load(fs.readFileSync(tplFile, 'utf8')).template) };
92
+ descriptor = { name: qualified, ...structuredClone(load(fs.readFileSync(tplFile, 'utf8')).template) };
74
93
  } else {
75
94
  // templateless: MINIMAL but compilable — grow it with add-field
76
95
  descriptor.id = { generate: '{{ name | slug }}' };
77
96
  descriptor.schema = { type: 'object', required: ['name'], properties: { name: { type: 'string' } } };
78
97
  }
79
98
  descriptor.storage = {
80
- path: `${ws.pkg.dreamteamer?.['data-path'] ?? 'data'}/${name}`,
99
+ // AUTHORED even though compile would derive the same value, because a descriptor a human opens
100
+ // should say where its records live without them having to know the derivation rule.
101
+ path: defaultStoragePath(qualified, declared, ws.pkg.dreamteamer?.['data-path'] ?? 'data'),
81
102
  codec: 'md', shape: 'file',
82
103
  ...descriptor.storage,
83
- suffix: descriptor.storage?.suffix ?? singular(name),
104
+ // the SUFFIX comes off the bare name — `health/doctors` records are `<id>.doctor.md`, not
105
+ // `<id>.health/doctor.md`
106
+ suffix: descriptor.storage?.suffix ?? singular(baseNameOf(qualified, declared)),
84
107
  };
85
- writeGated(ws, store, [dest], `dreamteamer: collections add ${name}`, () => {
108
+ writeGated(ws, store, [dest], `dreamteamer: collections add ${qualified}`, () => {
86
109
  fs.mkdirSync(path.dirname(dest), { recursive: true });
87
110
  fs.writeFileSync(dest, dump(descriptor));
88
111
  });
@@ -100,6 +123,232 @@ export function removeCollection(ws, store, name, { force = false } = {}) {
100
123
  return { removed: name };
101
124
  }
102
125
 
126
+ /**
127
+ * Rename a collection — descriptor, records, and every inbound reference, in ONE commit.
128
+ *
129
+ * This exists because namespacing EXISTING data was otherwise a hand migration: `git mv` the
130
+ * descriptor, edit `name` and `storage.path`, `git mv` the record folder, re-suffix every file, then
131
+ * find and rewrite every reference — six steps with no gate, where forgetting the last one dangles
132
+ * every link silently. `dt collections rename doctors health/doctors` is the whole thing.
133
+ *
134
+ * DERIVED-VS-AUTHORED is the rule for both moving parts, the same rule `createCollection` uses:
135
+ * - `storage.path` moves only if it was DERIVED (equal to the default for the old name). An authored
136
+ * path is a deliberate choice about where records live and a rename must not overrule it.
137
+ * - `storage.suffix` is re-derived only if it was DERIVED (the singular of the old base name), because
138
+ * otherwise the filenames would start lying about what they hold. `doctors` → `health/doctors` keeps
139
+ * the base name, so nothing is re-suffixed — which is the common case and the cheap one.
140
+ *
141
+ * References are rewritten by asking the STORE to do it, once per record id, rather than by matching
142
+ * the collection prefix with a new regex. `store.rewriteRefs` already knows the boundary rules and
143
+ * already scopes prose to `[[wikilinks]]` (decision 7) — a fresh `oldName/` pattern would have to
144
+ * relearn both, and would corrupt `data/tasks/` in a path or a URL on its first outing. N passes over
145
+ * the record files is the price, and at human scale it is worth paying for reusing the correct code.
146
+ */
147
+ export function renameCollection(ws, store, oldName, newName) {
148
+ const d = store.descriptor(oldName); // throws with the known-collection list if absent
149
+ if (!newName) throw new Error('missing new collection name');
150
+ if (oldName === newName) return { renamed: false, name: newName };
151
+
152
+ const declared = normalizeNamespaces(ws.pkg.dreamteamer?.namespaces);
153
+ if (newName.includes('/') && !namespaceOf(newName, declared)) {
154
+ throw new Error(`namespace "${newName.slice(0, newName.lastIndexOf('/'))}" is not declared — add it to dreamteamer.namespaces in package.json first.`);
155
+ }
156
+ if (store.descriptors.has(newName)) throw new Error(`collection "${newName}" already exists`);
157
+ if (d.storage.base === 'runtime') throw new Error(`"${oldName}" is a compiled source, not a data collection — it cannot be renamed`);
158
+
159
+ const src = path.join(workspaceSystemDir(ws, 'collections'), `${oldName}.collection.yaml`);
160
+ const dest = path.join(workspaceSystemDir(ws, 'collections'), `${newName}.collection.yaml`);
161
+ if (!fs.existsSync(src)) {
162
+ throw new Error(`"${oldName}" is not workspace-owned — it ships with a module, so rename it there (or overlay it with \`extends\`)`);
163
+ }
164
+
165
+ const doc = load(fs.readFileSync(src, 'utf8'));
166
+ const dataPath = ws.pkg.dreamteamer?.['data-path'] ?? 'data';
167
+ // `d` is the COMPILED descriptor, so its storage.path already carries any module prefix; the
168
+ // authored source is what we compare against, and what we rewrite.
169
+ const authoredPath = String(doc.storage?.path ?? '');
170
+ const pathWasDerived = authoredPath === '' || authoredPath === defaultStoragePath(oldName, declared, dataPath);
171
+ const newPath = pathWasDerived ? defaultStoragePath(newName, declared, dataPath) : authoredPath;
172
+
173
+ const oldBase = baseNameOf(oldName, declared);
174
+ const newBase = baseNameOf(newName, declared);
175
+ const oldSuffix = d.storage.suffix;
176
+ const suffixWasDerived = oldSuffix === singular(oldBase);
177
+ const newSuffix = suffixWasDerived ? singular(newBase) : oldSuffix;
178
+
179
+ // Every id BEFORE anything moves — the store's index is keyed on the old collection.
180
+ const ids = [...store.ids(oldName).keys()];
181
+ const oldDir = store.dir(d);
182
+ const newDir = path.join(ws.root, newPath);
183
+ if (newDir !== oldDir && fs.existsSync(newDir)) {
184
+ throw new Error(`${newPath} already exists on disk — move or remove it first; nothing was renamed`);
185
+ }
186
+
187
+ // ---- rollback state, captured before the first mutation --------------------------------------
188
+ const srcBytes = fs.readFileSync(src);
189
+ let movedData = false;
190
+ let resuffixed = [];
191
+ const undo = () => {
192
+ for (const [from, to] of resuffixed) { if (fs.existsSync(to)) fs.renameSync(to, from); }
193
+ if (movedData && fs.existsSync(newDir)) {
194
+ fs.mkdirSync(path.dirname(oldDir), { recursive: true });
195
+ fs.renameSync(newDir, oldDir);
196
+ pruneEmpty(path.dirname(newDir), path.join(ws.root, dataPath));
197
+ }
198
+ fs.mkdirSync(path.dirname(src), { recursive: true });
199
+ fs.writeFileSync(src, srcBytes);
200
+ if (dest !== src) fs.rmSync(dest, { force: true });
201
+ };
202
+
203
+ return store.withWriteLock(() => {
204
+ // referencing files are snapshotted by the store's own helper via rewriteRefs' touched list, so
205
+ // they are captured here the same way `store.rename` does it: read before, restore on failure.
206
+ const refFiles = new Map();
207
+ const captureRefs = (ref) => {
208
+ for (const f of store.findInboundRefs(ref)) {
209
+ const abs = path.join(ws.root, f);
210
+ if (!refFiles.has(abs)) refFiles.set(abs, fs.readFileSync(abs));
211
+ }
212
+ };
213
+ for (const id of ids) captureRefs(`${oldName}/${id}`);
214
+ captureRefs(`collections/${oldName}`);
215
+ const restoreRefs = () => { for (const [f, bytes] of refFiles) fs.writeFileSync(f, bytes); };
216
+
217
+ const touched = new Set();
218
+ let rewrites = 0;
219
+ try {
220
+ // 1. the descriptor source, at its new path
221
+ doc.name = newName;
222
+ doc.storage = { ...doc.storage, path: newPath, suffix: newSuffix };
223
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
224
+ fs.writeFileSync(dest, dump(doc));
225
+ if (dest !== src) fs.rmSync(src);
226
+ touched.add(src);
227
+ touched.add(dest);
228
+
229
+ // 2. the record folder, then the per-file suffix if it was derived
230
+ if (newDir !== oldDir && fs.existsSync(oldDir)) {
231
+ fs.mkdirSync(path.dirname(newDir), { recursive: true });
232
+ fs.renameSync(oldDir, newDir);
233
+ movedData = true;
234
+ pruneEmpty(path.dirname(oldDir), path.join(ws.root, dataPath));
235
+ }
236
+ if (newSuffix !== oldSuffix && fs.existsSync(newDir)) {
237
+ const ext = EXT[d.storage.codec ?? 'md'];
238
+ for (const file of walk(newDir)) {
239
+ if (!file.endsWith(`.${oldSuffix}${ext}`)) continue;
240
+ const to = file.slice(0, -(oldSuffix.length + ext.length + 1)) + `.${newSuffix}${ext}`;
241
+ fs.renameSync(file, to);
242
+ resuffixed.push([file, to]);
243
+ }
244
+ }
245
+ if (movedData) { touched.add(oldDir); touched.add(newDir); }
246
+
247
+ // 3. inbound references: per record id, plus the collection's own id in `collections`
248
+ // (which is what ui-views and command-bindings point at).
249
+ for (const id of ids) {
250
+ const out = store.rewriteRefs(`${oldName}/${id}`, `${newName}/${id}`);
251
+ rewrites += out.rewrites;
252
+ for (const f of out.touched) touched.add(f);
253
+ }
254
+ const collOut = store.rewriteRefs(`collections/${oldName}`, `collections/${newName}`);
255
+ rewrites += collOut.rewrites;
256
+ for (const f of collOut.touched) touched.add(f);
257
+
258
+ // 4. bare `x-reference: <oldName>` in every descriptor SOURCE. Not a `<collection>/<id>`
259
+ // ref, so step 3 cannot see it — and leaving it makes compile fail on an unknown target.
260
+ for (const f of descriptorSources(ws, store)) {
261
+ const before = fs.readFileSync(f, 'utf8');
262
+ const doc2 = load(before);
263
+ if (!doc2 || !retargetRefs(doc2.schema, oldName, newName)) continue;
264
+ if (!refFiles.has(f)) refFiles.set(f, Buffer.from(before));
265
+ fs.writeFileSync(f, dump(doc2));
266
+ touched.add(f);
267
+ rewrites++;
268
+ }
269
+
270
+ compile(ws); // the gate: an uncompilable rename never reaches history
271
+ } catch (e) {
272
+ restoreRefs();
273
+ undo();
274
+ try { compile(ws); } catch { /* pre-rename sources were compilable */ }
275
+ throw e;
276
+ }
277
+
278
+ // `git add -- <path>` FAILS OUTRIGHT on a pathspec that is neither on disk nor in the index —
279
+ // which is exactly what the old descriptor becomes when it was never committed in the first
280
+ // place (a collection added but not yet published). One bad entry aborts the whole `add`, so
281
+ // the rename rolled back over a file git simply did not care about. Filter, don't assume.
282
+ const rels = [...touched]
283
+ .map((f) => path.relative(ws.root, f))
284
+ .filter((rel) => fs.existsSync(path.join(ws.root, rel)) || isTracked(ws.root, rel));
285
+ try {
286
+ execFileSync('git', ['add', '--all', '--', ...rels], { cwd: ws.root, stdio: GIT_QUIET });
287
+ execFileSync('git', ['commit', '--quiet', '-m', `dreamteamer: collections rename ${oldName} → ${newName}`, '--', ...rels], { cwd: ws.root, stdio: GIT_QUIET });
288
+ } catch (e) {
289
+ try { execFileSync('git', ['reset', '--quiet', '--', ...rels], { cwd: ws.root, stdio: GIT_QUIET }); } catch { /* nothing staged */ }
290
+ restoreRefs();
291
+ undo();
292
+ try { compile(ws); } catch { /* pre-rename sources were compilable */ }
293
+ throw new Error(`git commit failed — the rename was rolled back, nothing was changed. (${e.message.split('\n')[0]})`);
294
+ }
295
+
296
+ return {
297
+ renamed: true, name: newName, records: ids.length, rewrites,
298
+ from: path.relative(ws.root, oldDir), to: path.relative(ws.root, newDir),
299
+ suffix: newSuffix !== oldSuffix ? { from: oldSuffix, to: newSuffix } : null,
300
+ pathKept: pathWasDerived ? null : authoredPath,
301
+ };
302
+ });
303
+ }
304
+
305
+ /** Does git know this path? A deleted-and-never-committed file must be dropped from a pathspec. */
306
+ function isTracked(root, rel) {
307
+ try {
308
+ execFileSync('git', ['ls-files', '--error-unmatch', '--', rel], { cwd: root, stdio: ['ignore', 'ignore', 'ignore'] });
309
+ return true;
310
+ } catch { return false; }
311
+ }
312
+
313
+ /** Every workspace-owned descriptor source, recursively (namespaced descriptors are nested). */
314
+ function descriptorSources(ws, store) {
315
+ const out = [];
316
+ for (const root of store.sourceRoots()) {
317
+ const dir = kindDir(root, 'collections');
318
+ // ⚠ SPREAD FIRST. `walk` is a GENERATOR, and `Iterator.prototype.filter` is a Node 22 iterator
319
+ // helper — so `walk(dir).filter(...)` works on 22 and throws "filter is not a function" on 20,
320
+ // which package.json still supports (`"node": ">=20"`). Caught by the CI matrix, not by local runs.
321
+ if (fs.existsSync(dir)) out.push(...[...walk(dir)].filter((f) => f.endsWith('.collection.yaml')));
322
+ }
323
+ return out;
324
+ }
325
+
326
+ /** Rewrite `x-reference: old` → new anywhere in a schema. Returns true if anything changed. */
327
+ function retargetRefs(schema, oldName, newName) {
328
+ let changed = false;
329
+ for (const prop of Object.values(schema?.properties ?? {})) {
330
+ if (!prop || typeof prop !== 'object') continue;
331
+ for (const holder of [prop, prop.items]) {
332
+ if (holder && typeof holder === 'object' && holder['x-reference'] === oldName) {
333
+ holder['x-reference'] = newName;
334
+ changed = true;
335
+ }
336
+ }
337
+ if (prop.properties && retargetRefs(prop, oldName, newName)) changed = true;
338
+ if (prop.items?.properties && retargetRefs(prop.items, oldName, newName)) changed = true;
339
+ }
340
+ return changed;
341
+ }
342
+
343
+ /** Remove now-empty parents up to (not including) the data root — a moved collection leaves its
344
+ * namespace folder behind otherwise. */
345
+ function pruneEmpty(dir, stopAt) {
346
+ while (dir !== stopAt && dir.startsWith(stopAt) && fs.existsSync(dir) && fs.readdirSync(dir).length === 0) {
347
+ fs.rmdirSync(dir);
348
+ dir = path.dirname(dir);
349
+ }
350
+ }
351
+
103
352
  export function addField(ws, store, collection, { name: fieldName, prop, required }) {
104
353
  store.descriptor(collection); // must exist in the compiled runtime
105
354
  if (!fieldName) throw new Error('missing field name');
package/src/server.js CHANGED
@@ -70,6 +70,15 @@ export function startServer(ws, { port = 8080, host = '127.0.0.1' } = {}) {
70
70
  res.json([...store.descriptors.values()].sort((a, b) => (a.order ?? 999) - (b.order ?? 999)));
71
71
  });
72
72
 
73
+ // ⚠ A NAMESPACED COLLECTION NAME CONTAINS A SLASH (`health/doctors`), and `:name` is one path
74
+ // segment — so a client MUST percent-encode it: `/collections/health%2Fdoctors/records`. Express
75
+ // matches on the still-encoded path and decodes params afterwards, so `req.params.name` arrives as
76
+ // `health/doctors` and every route below works unchanged.
77
+ //
78
+ // The alternative was `*name`, and it is wrong: `/collections/*name/records/*id` puts a greedy
79
+ // wildcard, a literal and a second wildcard in one pattern, so `/collections/a/b/records/c` has
80
+ // several readings and the router picks one. Encoding keeps the boundary explicit at the caller,
81
+ // which is the same reason references declare their namespace instead of having it inferred.
73
82
  api.get('/collections/:name/records', (req, res) => {
74
83
  const d = store.descriptor(req.params.name);
75
84
  const bf = bodyField(d);
package/src/store.js CHANGED
@@ -11,7 +11,8 @@ import { dump } from './yaml.js';
11
11
  import { generateId } from './template.js';
12
12
  import { parseRecord, parseRecordText, patternRe, fmtAjvError, unknownFields, walk, EXT, assertSafeId } from './records.js';
13
13
  import { normalizeRecord } from './temporal.js';
14
- import { NO_RUNTIME, sourceHint, loadDescriptors, runtimeDir, sourceRoots as compiledSourceRoots } from './runtime.js';
14
+ import { NO_RUNTIME, sourceHint, loadDescriptors, runtimeDir, namespaces as compiledNamespaces, sourceRoots as compiledSourceRoots } from './runtime.js';
15
+ import { parseRef } from './namespace.js';
15
16
 
16
17
  // git calls whose failure we CATCH must not print git's own error: execFileSync forwards the
17
18
  // child's stderr to ours unless told otherwise, so a handled "not a git repository" still
@@ -33,6 +34,9 @@ export class Store {
33
34
  const descriptors = loadDescriptors(root);
34
35
  if (!descriptors) throw new Error(NO_RUNTIME);
35
36
  this.descriptors = descriptors;
37
+ // The closed set every reference is split against (see src/namespace.js). Read once per Store:
38
+ // it is compile output, and a Store is already rebuilt whenever the runtime changes.
39
+ this.namespaces = compiledNamespaces(root);
36
40
  }
37
41
 
38
42
  descriptor(collection) {
@@ -186,10 +190,12 @@ export class Store {
186
190
  if (raw == null) continue;
187
191
  for (const value of Array.isArray(raw) ? raw : [raw]) {
188
192
  if (typeof value !== 'string' || value.startsWith('@')) continue;
189
- const slash = value.indexOf('/');
190
- if (slash < 1) throw new Error(`${key}: reference "${value}" is not <collection>/<id> — nothing was written.`);
191
- const coll = value.slice(0, slash);
192
- const id = value.slice(slash + 1);
193
+ // ONE parser for the collection/id boundary, shared with `check` and the extension —
194
+ // a namespace that meant one thing on write and another on read would be worse than
195
+ // no namespaces at all.
196
+ const parsed = parseRef(value, this.namespaces);
197
+ if (!parsed) throw new Error(`${key}: reference "${value}" is not <collection>/<id> — nothing was written.`);
198
+ const { collection: coll, id } = parsed;
193
199
  if (target !== '*' && coll !== target) throw new Error(`${key}: reference "${value}" must target collection "${target}" — nothing was written.`);
194
200
  if (!this.descriptors.has(coll)) throw new Error(`${key}: reference "${value}" targets unknown collection "${coll}" — nothing was written.`);
195
201
  if (!this.ids(coll).has(id)) throw new Error(`${key}: dangling reference "${value}" — no such record. nothing was written.`);
@@ -243,9 +249,6 @@ export class Store {
243
249
  throw new Error(`${collection}/${id} is referenced by:\n${inbound.map((f) => ` ${f}`).join('\n')}\nfix the references or pass --force. nothing was removed.`);
244
250
  }
245
251
  const unit = this.recordRoot(d, id); // folder-shape: the whole folder goes, not just the entry file
246
- // Folder-shape records would need a recursive snapshot; the only folder-shape collection
247
- // is `skills`, which is system-stored and so never reaches rm (writableDescriptor refuses
248
- // first). Not built for a case that cannot occur.
249
252
  // snapshot BEFORE the delete, or there is nothing left to read
250
253
  const restore = snapshot([unit]);
251
254
  return this.withWriteLock(() => {
@@ -389,10 +392,14 @@ export class Store {
389
392
  const cwd = path.resolve(this.root, repo);
390
393
  const rel = files.map((f) => path.relative(cwd, f));
391
394
  try {
392
- execFileSync('git', ['add', '--all', '--', ...rel], { cwd });
393
- execFileSync('git', ['commit', '--quiet', '-m', subject, '--', ...rel], { cwd });
395
+ // QUIET, per the rule at the top of this file: a failure we CATCH must not also print git's
396
+ // own error. These three were the exception — a caught `git add` failure dumped git's raw
397
+ // multi-line advice ("Another git process seems to be running…") on top of the clean message
398
+ // this function throws, so the user read the scary one and not the accurate one.
399
+ execFileSync('git', ['add', '--all', '--', ...rel], { cwd, stdio: QUIET });
400
+ execFileSync('git', ['commit', '--quiet', '-m', subject, '--', ...rel], { cwd, stdio: QUIET });
394
401
  } catch (e) {
395
- try { execFileSync('git', ['reset', '--quiet', '--', ...rel], { cwd }); } catch { /* nothing staged */ }
402
+ try { execFileSync('git', ['reset', '--quiet', '--', ...rel], { cwd, stdio: QUIET }); } catch { /* nothing staged */ }
396
403
  if (undo) {
397
404
  try { undo(); } catch (u) {
398
405
  throw new Error(`git commit failed AND rollback failed (${u.message}) — inspect the working tree. original: ${e.message.split('\n')[0]}`);
@@ -442,21 +449,50 @@ function readPkg(root) {
442
449
  try { return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { return {}; }
443
450
  }
444
451
 
445
- /** Byte snapshot of a set of files, and a restore closure. The undo mechanism schema-ops has
446
- * used for source writes since it was written (schema-ops.js:20). Record writes used
452
+ /** Byte snapshot of a set of files OR DIRECTORIES, and a restore closure. The undo mechanism
453
+ * schema-ops has used for source writes since it was written (schema-ops.js:20). Record writes used
447
454
  * `git checkout HEAD -- <paths>` instead, which is only correct while HEAD is guaranteed to be
448
455
  * the last good state — it is not, once writes stop committing, and it silently discarded
449
- * uncommitted hand-edits even before that. */
456
+ * uncommitted hand-edits even before that.
457
+ *
458
+ * A DIRECTORY unit is snapshotted recursively. It used to be skipped with a comment arguing the case
459
+ * could not occur — the only folder-shape collection was `skills`, which is system-stored, so
460
+ * `writableDescriptor` refused before `rm` was reached. That reasoning was true and is the wrong kind
461
+ * of true: it depended on a fact about the CURRENT set of collections rather than on anything the code
462
+ * enforces, and `shape: folder` is an ordinary descriptor option any workspace can choose. The failure
463
+ * it left behind was silent and total — `rm` would delete the folder and the "restore" closure would
464
+ * do nothing, so a failed commit meant the record was simply gone. Twelve lines, no such hole. */
450
465
  function snapshot(units) {
451
- const snaps = units.map((u) => ({
452
- u,
453
- prev: fs.existsSync(u) && fs.statSync(u).isFile() ? fs.readFileSync(u) : null,
454
- existed: fs.existsSync(u),
455
- }));
466
+ const snaps = units.map((u) => {
467
+ const existed = fs.existsSync(u);
468
+ const isDir = existed && fs.statSync(u).isDirectory();
469
+ return {
470
+ u, existed, isDir,
471
+ prev: existed && !isDir ? fs.readFileSync(u) : null,
472
+ tree: isDir ? snapshotTree(u) : null,
473
+ };
474
+ });
456
475
  return () => {
457
- for (const { u, prev, existed } of snaps) {
476
+ for (const { u, prev, tree, existed, isDir } of snaps) {
458
477
  if (!existed) { fs.rmSync(u, { force: true, recursive: true }); continue; }
478
+ if (isDir) {
479
+ fs.rmSync(u, { force: true, recursive: true }); // partial state from a failed op
480
+ fs.mkdirSync(u, { recursive: true });
481
+ for (const [rel, bytes] of tree) {
482
+ const dest = path.join(u, rel);
483
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
484
+ fs.writeFileSync(dest, bytes);
485
+ }
486
+ continue;
487
+ }
459
488
  if (prev !== null) { fs.mkdirSync(path.dirname(u), { recursive: true }); fs.writeFileSync(u, prev); }
460
489
  }
461
490
  };
462
491
  }
492
+
493
+ /** Every file under `dir` as [relative path, bytes] — the whole of a folder-shape record. */
494
+ function snapshotTree(dir) {
495
+ const out = [];
496
+ for (const file of walk(dir)) out.push([path.relative(dir, file), fs.readFileSync(file)]);
497
+ return out;
498
+ }