gitsheets 1.0.3 → 1.1.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/dist/cli/index.js CHANGED
@@ -1,12 +1,14 @@
1
1
  // CLI entry. See specs/api/cli.md.
2
- // v1.0 substrate ships: upsert, query, read, normalize.
3
- // infer / migrate-config / edit are tracked as follow-ups against #130, #139.
4
- import { readFile } from 'node:fs/promises';
2
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, isAbsolute, join } from 'node:path';
5
5
  import process from 'node:process';
6
6
  import yargs from 'yargs';
7
7
  import { hideBin } from 'yargs/helpers';
8
8
  import { ConfigError, GitsheetsError, IndexError, NotFoundError, RefError, TransactionError, ValidationError, } from '../errors.js';
9
9
  import { openRepo } from '../repository.js';
10
+ import { parseToml, stringifyRecord } from '../toml.js';
11
+ import { csvHeader, inferInputFormat, parseRecords, stringifyRecord_text, validateInputFormat, validateOutputFormat, } from './formats.js';
10
12
  // Exit codes per specs/api/cli.md
11
13
  function exitCodeForError(err) {
12
14
  if (err instanceof ValidationError)
@@ -46,43 +48,46 @@ function reportError(err) {
46
48
  }
47
49
  out.write(`gitsheets: ${String(err)}\n`);
48
50
  }
49
- async function readInput(input) {
51
+ async function readInput(input, encoding) {
50
52
  if (input === undefined || input === '-') {
51
53
  const chunks = [];
52
54
  for await (const chunk of process.stdin) {
53
55
  chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
54
56
  }
55
- return Buffer.concat(chunks).toString('utf8');
57
+ return Buffer.concat(chunks).toString(encoding);
56
58
  }
57
59
  // Treat anything starting with `{` or `[` as inline JSON, otherwise a path.
58
60
  const trimmed = input.trimStart();
59
61
  if (trimmed.startsWith('{') || trimmed.startsWith('['))
60
62
  return input;
61
- return readFile(input, 'utf8');
63
+ return readFile(input, encoding);
62
64
  }
63
- function parseJsonRecords(text) {
64
- const trimmed = text.trim();
65
- if (!trimmed)
66
- return [];
67
- // JSON array → many records; JSON object → single record; one-record-per-line JSONL → many.
68
- if (trimmed.startsWith('[')) {
69
- const arr = JSON.parse(trimmed);
70
- if (!Array.isArray(arr))
71
- throw new Error('expected JSON array of records');
72
- return arr;
73
- }
74
- if (trimmed.startsWith('{')) {
75
- return [JSON.parse(trimmed)];
76
- }
77
- // JSONL fallback
78
- return trimmed
79
- .split('\n')
80
- .filter((line) => line.trim().length > 0)
81
- .map((line) => JSON.parse(line));
65
+ const VALID_ENCODINGS = new Set([
66
+ 'utf8',
67
+ 'utf-8',
68
+ 'utf16le',
69
+ 'utf-16le',
70
+ 'ascii',
71
+ 'latin1',
72
+ 'binary',
73
+ 'base64',
74
+ 'hex',
75
+ ]);
76
+ function resolveEncoding(raw) {
77
+ const enc = (raw ?? 'utf8').toLowerCase();
78
+ if (!VALID_ENCODINGS.has(enc)) {
79
+ throw new Error(`--encoding "${raw}" is not a recognized encoding`);
80
+ }
81
+ return enc;
82
82
  }
83
83
  async function loadRepoAndSheet(argv) {
84
84
  const repo = await openRepo(argv.gitDir ? { gitDir: argv.gitDir } : {});
85
- const sheet = await repo.openSheet(argv.sheet, argv.root ? { root: argv.root } : {});
85
+ const sheetOpts = {};
86
+ if (argv.root)
87
+ sheetOpts.root = argv.root;
88
+ if (argv.prefix)
89
+ sheetOpts.prefix = argv.prefix;
90
+ const sheet = await repo.openSheet(argv.sheet, sheetOpts);
86
91
  return { repo, sheet };
87
92
  }
88
93
  function buildTxOpts(argv, defaultMessage) {
@@ -103,36 +108,191 @@ function buildTxOpts(argv, defaultMessage) {
103
108
  async function runUpsert(argv) {
104
109
  const { repo, sheet } = await loadRepoAndSheet(argv);
105
110
  void sheet; // loadRepoAndSheet validates the config exists
106
- const text = await readInput(argv.input);
107
- const records = parseJsonRecords(text);
111
+ const encoding = resolveEncoding(argv.encoding);
112
+ const explicitFormat = validateInputFormat(argv.format);
113
+ const format = explicitFormat ?? inferInputFormat(argv.input);
114
+ const text = await readInput(argv.input, encoding);
115
+ const records = parseRecords(text, format);
108
116
  if (records.length === 0)
109
117
  return;
110
- const txOpts = buildTxOpts(argv, `${argv.sheet} upsert (${records.length})`);
118
+ const attachmentMap = argv.attachment ?? {};
119
+ const attachmentNames = Object.keys(attachmentMap);
120
+ if (attachmentNames.length > 0 && records.length !== 1) {
121
+ throw new Error(`--attachment requires a single-record input (got ${records.length} records)`);
122
+ }
123
+ // Resolve attachment source paths up front so a missing file fails before
124
+ // the transaction opens. We hand them to hologit's writeBlobFromFile so
125
+ // binary content is hashed correctly (git hash-object -w). For `-` (stdin),
126
+ // we buffer it to a tmp file first since stdin may already be consumed by
127
+ // the record input.
128
+ const attachmentSources = {}; // name → absolute path
129
+ const tmpDirs = []; // cleanup at end
130
+ let stdinConsumed = argv.input === '-' || argv.input === undefined;
131
+ const inputDir = argv.input && argv.input !== '-' ? dirname(argv.input) : process.cwd();
132
+ for (const name of attachmentNames) {
133
+ const source = attachmentMap[name];
134
+ if (source === '-') {
135
+ if (stdinConsumed) {
136
+ throw new Error(`--attachment ${name}=-: stdin is already consumed; only one '-' source per command`);
137
+ }
138
+ stdinConsumed = true;
139
+ const chunks = [];
140
+ for await (const chunk of process.stdin) {
141
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
142
+ }
143
+ const dir = await mkdtemp(join(tmpdir(), 'gitsheets-attach-'));
144
+ tmpDirs.push(dir);
145
+ const tmpPath = join(dir, 'data');
146
+ await writeFile(tmpPath, Buffer.concat(chunks));
147
+ attachmentSources[name] = tmpPath;
148
+ }
149
+ else {
150
+ const resolved = isAbsolute(source) ? source : join(inputDir, source);
151
+ attachmentSources[name] = resolved;
152
+ }
153
+ }
154
+ if (argv.patch && argv.deleteMissing) {
155
+ throw new Error('--patch and --delete-missing cannot be combined');
156
+ }
157
+ if (argv.patch && attachmentNames.length > 0) {
158
+ throw new Error('--patch and --attachment cannot be combined');
159
+ }
160
+ // For --patch: pre-load the sheet's path-template so we know which input
161
+ // fields form the query (record-identifier) and which are the patch payload.
162
+ let templateKeyFields;
163
+ if (argv.patch) {
164
+ const config = await sheet.readConfig();
165
+ const tpl = (await import('../path-template/index.js')).Template.fromString(config.path);
166
+ templateKeyFields = new Set(tpl.getFieldNames());
167
+ if (templateKeyFields.size === 0) {
168
+ throw new Error('--patch: cannot auto-derive a query — sheet path template has no extractable field names');
169
+ }
170
+ }
171
+ const messageDefault = argv.patch
172
+ ? `${argv.sheet} patch (${records.length})`
173
+ : argv.deleteMissing
174
+ ? `${argv.sheet} full-replace (${records.length})`
175
+ : `${argv.sheet} upsert (${records.length})`;
176
+ const txOpts = buildTxOpts(argv, messageDefault);
177
+ const txSheetOpts = argv.prefix ? { prefix: argv.prefix } : {};
178
+ // For --delete-missing, capture the existing record paths BEFORE the
179
+ // transaction opens, so we can compute the "missing" set after all upserts.
180
+ // Reading from the same Sheet handle the loadRepoAndSheet returned gives us
181
+ // a HEAD snapshot, not the in-flight tx state — exactly what we want.
182
+ const existingPaths = new Set();
183
+ if (argv.deleteMissing) {
184
+ for await (const r of sheet.query()) {
185
+ const p = r[Symbol.for('gitsheets-path')];
186
+ if (typeof p === 'string')
187
+ existingPaths.add(p);
188
+ }
189
+ }
111
190
  await repo.transact(txOpts, async (tx) => {
112
- const target = tx.sheet(argv.sheet);
191
+ const target = tx.sheet(argv.sheet, txSheetOpts);
192
+ const upsertedPaths = new Set();
193
+ let lastResult;
113
194
  for (const record of records) {
195
+ if (argv.patch && templateKeyFields) {
196
+ // Split the input into (query, partial) using the template's field
197
+ // names: keys present in the template AND in the record form the
198
+ // query; the rest is the JSON Merge Patch payload.
199
+ const query = {};
200
+ const partial = {};
201
+ for (const [k, v] of Object.entries(record)) {
202
+ if (templateKeyFields.has(k)) {
203
+ query[k] = v;
204
+ }
205
+ else {
206
+ partial[k] = v;
207
+ }
208
+ }
209
+ if (Object.keys(query).length === 0) {
210
+ throw new Error(`--patch: input record does not include any of the path-template fields (${[...templateKeyFields].join(', ')})`);
211
+ }
212
+ const result = await target.patch(query, partial);
213
+ upsertedPaths.add(result.path);
214
+ process.stdout.write(`${result.blob.hash} ${result.path}\n`);
215
+ lastResult = result;
216
+ continue;
217
+ }
114
218
  const result = await target.upsert(record);
219
+ upsertedPaths.add(result.path);
115
220
  process.stdout.write(`${result.blob.hash} ${result.path}\n`);
221
+ lastResult = result;
222
+ }
223
+ // Attach files alongside the (single) record. Already guarded above to
224
+ // run only when records.length === 1. Each source goes through
225
+ // hologit's writeBlobFromFile (git hash-object -w <path>) so binary
226
+ // content is hashed verbatim.
227
+ if (attachmentNames.length > 0 && lastResult) {
228
+ const blobMap = {};
229
+ for (const [name, sourcePath] of Object.entries(attachmentSources)) {
230
+ blobMap[name] = await repo.hologitRepo.writeBlobFromFile(sourcePath);
231
+ }
232
+ await target.setAttachments(lastResult.path, blobMap);
233
+ for (const name of attachmentNames) {
234
+ process.stdout.write(`+ ${lastResult.path}/${name}\n`);
235
+ }
236
+ }
237
+ if (argv.deleteMissing) {
238
+ // Anything in the existing set that's not in the upserted set must die.
239
+ for (const p of existingPaths) {
240
+ if (!upsertedPaths.has(p)) {
241
+ await target.delete(p);
242
+ process.stdout.write(`- ${p}\n`);
243
+ }
244
+ }
116
245
  }
117
246
  });
247
+ // Clean up tmp dirs used to materialize stdin-sourced attachments.
248
+ for (const dir of tmpDirs) {
249
+ await rm(dir, { recursive: true, force: true });
250
+ }
118
251
  }
119
252
  async function runQuery(argv) {
120
253
  const { sheet } = await loadRepoAndSheet(argv);
121
254
  const filter = argv.filter ?? {};
255
+ const format = validateOutputFormat(argv.format) ?? 'json';
256
+ const headers = argv.headers ?? true;
257
+ const fields = argv.fields;
122
258
  let yielded = 0;
259
+ let headerWritten = false;
260
+ const allRecords = []; // only used for TOML output
123
261
  for await (const record of sheet.query(filter)) {
124
- const out = argv.fields
125
- ? Object.fromEntries(argv.fields.map((f) => [f, record[f]]))
126
- : record;
127
- // Strip well-known symbols before serializing
128
- process.stdout.write(`${JSON.stringify(out)}\n`);
262
+ if (format === 'csv' || format === 'tsv') {
263
+ if (!headerWritten) {
264
+ // Header columns come from --fields, otherwise from the first record.
265
+ const cols = fields ?? Object.keys(record).filter((k) => !k.startsWith('__'));
266
+ if (headers)
267
+ process.stdout.write(csvHeader(cols, format));
268
+ headerWritten = true;
269
+ }
270
+ process.stdout.write(stringifyRecord_text(record, format, fields));
271
+ }
272
+ else if (format === 'toml') {
273
+ // TOML needs all records to assemble [[records]] — buffer.
274
+ allRecords.push(record);
275
+ }
276
+ else {
277
+ // json (default) — stream NDJSON
278
+ process.stdout.write(stringifyRecord_text(record, 'json', fields));
279
+ }
129
280
  yielded++;
130
281
  if (argv.limit !== undefined && yielded >= argv.limit)
131
282
  break;
132
283
  }
284
+ if (format === 'toml') {
285
+ // Emit a single TOML document with a [[records]] array. The wrapper keeps
286
+ // the output round-trippable through `parseRecords(text, 'toml')`.
287
+ for (const r of allRecords) {
288
+ process.stdout.write('[[records]]\n');
289
+ process.stdout.write(stringifyRecord_text(r, 'toml', fields));
290
+ }
291
+ }
133
292
  }
134
293
  async function runRead(argv) {
135
294
  const { sheet } = await loadRepoAndSheet(argv);
295
+ const format = validateOutputFormat(argv.format) ?? 'json';
136
296
  // The path is treated as the record's full slug-rendered key plus optional
137
297
  // .toml extension. For the simple `${{ slug }}` case this is just the slug.
138
298
  const target = argv.path.endsWith('.toml') ? argv.path.slice(0, -5) : argv.path;
@@ -149,7 +309,303 @@ async function runRead(argv) {
149
309
  if (!found) {
150
310
  throw new NotFoundError('record_not_found', `${argv.sheet}: no record at ${target}`);
151
311
  }
152
- process.stdout.write(`${JSON.stringify(found, null, 2)}\n`);
312
+ if (format === 'json') {
313
+ // Pretty-print for human reads — JSON.stringify(_, null, 2) matches v1.0 behavior
314
+ const cleaned = { ...found };
315
+ delete cleaned[Symbol.for('gitsheets-path')];
316
+ delete cleaned[Symbol.for('gitsheets-sheet')];
317
+ process.stdout.write(`${JSON.stringify(cleaned, null, 2)}\n`);
318
+ return;
319
+ }
320
+ process.stdout.write(stringifyRecord_text(found, format));
321
+ }
322
+ /**
323
+ * Read the raw TOML bytes of a sheet config at HEAD (or whichever ref the
324
+ * caller's `--ref` points to). Returns the file's text content.
325
+ *
326
+ * Uses `git cat-file blob` rather than the parsed Sheet config so the same
327
+ * helper supports both v1.0-shaped and pre-v1.0 (`[gitsheet.fields]`) configs
328
+ * — `migrate-config` operates on the latter.
329
+ */
330
+ async function readSheetConfigText(gitDir, ref, configPath) {
331
+ const { execFile } = await import('node:child_process');
332
+ const { promisify } = await import('node:util');
333
+ const ex = promisify(execFile);
334
+ const { stdout } = await ex('git', ['cat-file', 'blob', `${ref}:${configPath}`], {
335
+ cwd: gitDir,
336
+ maxBuffer: 16 * 1024 * 1024,
337
+ });
338
+ return stdout;
339
+ }
340
+ /** Update one of a record-like field's observed type info during inference. */
341
+ function observeField(acc, key, value) {
342
+ if (!acc[key])
343
+ acc[key] = { types: new Set() };
344
+ const slot = acc[key];
345
+ if (value === null) {
346
+ slot.types.add('null');
347
+ }
348
+ else if (Array.isArray(value)) {
349
+ slot.types.add('array');
350
+ if (!slot.items)
351
+ slot.items = new Set();
352
+ for (const el of value)
353
+ slot.items.add(jsonTypeOf(el));
354
+ }
355
+ else if (value instanceof Date) {
356
+ slot.types.add('string'); // TOML datetimes stringify as ISO-8601 in JSON Schema
357
+ }
358
+ else if (typeof value === 'object') {
359
+ slot.types.add('object');
360
+ }
361
+ else if (typeof value === 'number') {
362
+ slot.types.add(Number.isInteger(value) ? 'integer' : 'number');
363
+ if (slot.min === undefined || value < slot.min)
364
+ slot.min = value;
365
+ if (slot.max === undefined || value > slot.max)
366
+ slot.max = value;
367
+ }
368
+ else if (typeof value === 'boolean') {
369
+ slot.types.add('boolean');
370
+ }
371
+ else if (typeof value === 'string') {
372
+ slot.types.add('string');
373
+ }
374
+ }
375
+ function jsonTypeOf(value) {
376
+ if (value === null)
377
+ return 'null';
378
+ if (Array.isArray(value))
379
+ return 'array';
380
+ if (value instanceof Date)
381
+ return 'string';
382
+ if (typeof value === 'object')
383
+ return 'object';
384
+ if (typeof value === 'number')
385
+ return Number.isInteger(value) ? 'integer' : 'number';
386
+ return typeof value;
387
+ }
388
+ async function runInfer(argv) {
389
+ const { repo, sheet } = await loadRepoAndSheet(argv);
390
+ const configPath = `${argv.root && argv.root !== '/' && argv.root !== '.' ? argv.root + '/' : ''}.gitsheets/${argv.sheet}.toml`;
391
+ const observed = {};
392
+ const presence = new Map();
393
+ let recordCount = 0;
394
+ for await (const record of sheet.query()) {
395
+ recordCount++;
396
+ for (const [k, v] of Object.entries(record)) {
397
+ presence.set(k, (presence.get(k) ?? 0) + 1);
398
+ observeField(observed, k, v);
399
+ }
400
+ }
401
+ if (recordCount === 0) {
402
+ process.stderr.write('gitsheets: no records to infer from\n');
403
+ return;
404
+ }
405
+ const properties = {};
406
+ for (const [field, info] of Object.entries(observed)) {
407
+ const types = [...info.types].sort();
408
+ const prop = {};
409
+ prop['type'] = types.length === 1 ? types[0] : types;
410
+ if (info.items && info.items.size > 0) {
411
+ const itemTypes = [...info.items].sort();
412
+ prop['items'] = { type: itemTypes.length === 1 ? itemTypes[0] : itemTypes };
413
+ }
414
+ if (info.min !== undefined)
415
+ prop['minimum'] = info.min;
416
+ if (info.max !== undefined)
417
+ prop['maximum'] = info.max;
418
+ properties[field] = prop;
419
+ }
420
+ const required = [...presence.entries()]
421
+ .filter(([, count]) => count === recordCount)
422
+ .map(([n]) => n)
423
+ .sort();
424
+ // Read existing config and merge schema into it (preserving root / path / fields).
425
+ const ref = argv.ref ?? 'HEAD';
426
+ const configText = await readSheetConfigText(repo.gitDir, ref, configPath);
427
+ const parsed = parseToml(configText);
428
+ const gitsheet = (parsed['gitsheet'] ?? {});
429
+ const newSchema = { type: 'object', properties };
430
+ if (required.length > 0)
431
+ newSchema['required'] = required;
432
+ gitsheet['schema'] = newSchema;
433
+ parsed['gitsheet'] = gitsheet;
434
+ const newText = stringifyRecord(parsed);
435
+ const txOpts = buildTxOpts(argv, `${argv.sheet} infer schema (${Object.keys(properties).length} fields)`);
436
+ await repo.transact(txOpts, async (tx) => {
437
+ await tx.tree.writeChild(configPath, newText);
438
+ tx.markMutated();
439
+ });
440
+ process.stdout.write(`inferred schema for .gitsheets/${argv.sheet}.toml — ${Object.keys(properties).length} properties, ${required.length} required\n`);
441
+ }
442
+ async function runInit(argv) {
443
+ const repo = await openRepo(argv.gitDir ? { gitDir: argv.gitDir } : {});
444
+ const configPath = `${argv.root && argv.root !== '/' && argv.root !== '.' ? argv.root + '/' : ''}.gitsheets/${argv.sheet}.toml`;
445
+ // Refuse to overwrite an existing config (unless --force).
446
+ const ref = argv.ref ?? 'HEAD';
447
+ if (!argv.force) {
448
+ try {
449
+ await readSheetConfigText(repo.gitDir, ref, configPath);
450
+ throw new Error(`.gitsheets/${argv.sheet}.toml already exists at ${ref} — use --force to overwrite`);
451
+ }
452
+ catch (err) {
453
+ // err is "already exists" → rethrow; otherwise it doesn't exist → proceed.
454
+ if (err instanceof Error &&
455
+ err.message.startsWith(`.gitsheets/${argv.sheet}.toml already exists`)) {
456
+ throw err;
457
+ }
458
+ }
459
+ }
460
+ const config = {
461
+ gitsheet: {
462
+ root: argv.sheet,
463
+ path: argv.path ?? '${{ id }}',
464
+ },
465
+ };
466
+ if (argv.schema) {
467
+ const schemaText = await readFile(argv.schema, 'utf8');
468
+ const schemaParsed = JSON.parse(schemaText);
469
+ if (typeof schemaParsed !== 'object' || schemaParsed === null) {
470
+ throw new Error(`--schema: ${argv.schema} did not parse as a JSON object`);
471
+ }
472
+ config['gitsheet']['schema'] = schemaParsed;
473
+ }
474
+ const newText = stringifyRecord(config);
475
+ // Validate the config parses through the standard SheetConfig loader so we
476
+ // don't commit something we'll then fail to open.
477
+ try {
478
+ const { parseConfigToml } = await import('../toml.js');
479
+ parseConfigToml(newText, configPath);
480
+ }
481
+ catch (err) {
482
+ throw new Error(`init produced an invalid config: ${err instanceof Error ? err.message : String(err)}`);
483
+ }
484
+ const txOpts = buildTxOpts(argv, `${argv.sheet} init sheet config`);
485
+ await repo.transact(txOpts, async (tx) => {
486
+ await tx.tree.writeChild(configPath, newText);
487
+ tx.markMutated();
488
+ });
489
+ process.stdout.write(`created .gitsheets/${argv.sheet}.toml\n`);
490
+ }
491
+ async function runMigrateConfig(argv) {
492
+ const repo = await openRepo(argv.gitDir ? { gitDir: argv.gitDir } : {});
493
+ const configPath = `${argv.root && argv.root !== '/' && argv.root !== '.' ? argv.root + '/' : ''}.gitsheets/${argv.sheet}.toml`;
494
+ const ref = argv.ref ?? 'HEAD';
495
+ const configText = await readSheetConfigText(repo.gitDir, ref, configPath);
496
+ const parsed = parseToml(configText);
497
+ const gitsheet = (parsed['gitsheet'] ?? {});
498
+ const fields = gitsheet['fields'];
499
+ if (!fields || typeof fields !== 'object') {
500
+ process.stderr.write('gitsheets: no [gitsheet.fields] block to migrate\n');
501
+ return;
502
+ }
503
+ const properties = {};
504
+ const remainingFields = {};
505
+ let warnings = 0;
506
+ for (const [name, cfg] of Object.entries(fields)) {
507
+ if (typeof cfg !== 'object' || cfg === null)
508
+ continue;
509
+ const schemaProp = {};
510
+ const remainingField = {};
511
+ if (cfg['type'] !== undefined)
512
+ schemaProp['type'] = cfg['type'];
513
+ if (cfg['enum'] !== undefined)
514
+ schemaProp['enum'] = cfg['enum'];
515
+ if (cfg['default'] !== undefined)
516
+ schemaProp['default'] = cfg['default'];
517
+ if (cfg['sort'] !== undefined)
518
+ remainingField['sort'] = cfg['sort'];
519
+ if (cfg['trueValues'] !== undefined || cfg['falseValues'] !== undefined) {
520
+ process.stderr.write(`gitsheets: warning — ${name}.trueValues/falseValues moved out of validation (use a CSV-ingest helper)\n`);
521
+ warnings++;
522
+ }
523
+ if (Object.keys(schemaProp).length > 0)
524
+ properties[name] = schemaProp;
525
+ if (Object.keys(remainingField).length > 0)
526
+ remainingFields[name] = remainingField;
527
+ }
528
+ // Rebuild gitsheet block: drop the old fields, keep root/path, add schema.
529
+ const newGitsheet = {};
530
+ for (const [k, v] of Object.entries(gitsheet)) {
531
+ if (k === 'fields' || k === 'schema')
532
+ continue;
533
+ newGitsheet[k] = v;
534
+ }
535
+ if (Object.keys(remainingFields).length > 0)
536
+ newGitsheet['fields'] = remainingFields;
537
+ if (Object.keys(properties).length > 0) {
538
+ newGitsheet['schema'] = { type: 'object', properties };
539
+ }
540
+ parsed['gitsheet'] = newGitsheet;
541
+ const newText = stringifyRecord(parsed);
542
+ const txOpts = buildTxOpts(argv, `${argv.sheet} migrate-config`);
543
+ await repo.transact(txOpts, async (tx) => {
544
+ await tx.tree.writeChild(configPath, newText);
545
+ tx.markMutated();
546
+ });
547
+ process.stdout.write(`migrated .gitsheets/${argv.sheet}.toml — ${Object.keys(properties).length} property migrations${warnings ? `, ${warnings} warning(s)` : ''}\n`);
548
+ }
549
+ async function runEdit(argv) {
550
+ const { repo, sheet } = await loadRepoAndSheet(argv);
551
+ const target = argv.path.endsWith('.toml') ? argv.path.slice(0, -5) : argv.path;
552
+ // Resolve the record by walking query() and matching the rendered path.
553
+ let found;
554
+ for await (const record of sheet.query()) {
555
+ const pathSym = record[Symbol.for('gitsheets-path')];
556
+ if (pathSym === target) {
557
+ found = record;
558
+ break;
559
+ }
560
+ }
561
+ if (!found) {
562
+ throw new NotFoundError('record_not_found', `${argv.sheet}: no record at ${target}`);
563
+ }
564
+ // Drop symbols before serializing; they're not part of the record's data.
565
+ const cleaned = { ...found };
566
+ delete cleaned[Symbol.for('gitsheets-path')];
567
+ delete cleaned[Symbol.for('gitsheets-sheet')];
568
+ const originalToml = stringifyRecord(cleaned);
569
+ const tmpDir = await mkdtemp(join(tmpdir(), 'gitsheets-edit-'));
570
+ const tmpFile = join(tmpDir, `${argv.sheet}-${target.replace(/\//g, '-')}.toml`);
571
+ try {
572
+ await writeFile(tmpFile, originalToml, 'utf8');
573
+ const editor = process.env['VISUAL'] || process.env['EDITOR'] || 'vi';
574
+ const shell = process.platform === 'win32' ? 'cmd' : 'sh';
575
+ const shellArgs = process.platform === 'win32'
576
+ ? ['/c', `${editor} "${tmpFile}"`]
577
+ : ['-c', `${editor} "${tmpFile}"`];
578
+ // Spawn with inherited stdio so the editor takes the terminal.
579
+ const { spawn } = await import('node:child_process');
580
+ const exit = await new Promise((resolve, reject) => {
581
+ const child = spawn(shell, shellArgs, { stdio: 'inherit' });
582
+ child.on('error', reject);
583
+ child.on('exit', (code) => resolve(code));
584
+ });
585
+ if (exit !== 0) {
586
+ throw new Error(`editor exited with code ${exit ?? 'null'} — aborting`);
587
+ }
588
+ const editedToml = await readFile(tmpFile, 'utf8');
589
+ if (editedToml === originalToml) {
590
+ // No-op; don't commit. Matches the "no commit on no change" idiom.
591
+ process.stderr.write('gitsheets: no changes — nothing to commit\n');
592
+ return;
593
+ }
594
+ const edited = parseToml(editedToml);
595
+ const txOpts = buildTxOpts(argv, `${argv.sheet} edit ${target}`);
596
+ const txSheetOpts = argv.prefix ? { prefix: argv.prefix } : {};
597
+ await repo.transact(txOpts, async (tx) => {
598
+ const sheetTx = tx.sheet(argv.sheet, txSheetOpts);
599
+ // Carry the original path annotation so upsert detects renames if the
600
+ // user changed a path-template field.
601
+ edited[Symbol.for('gitsheets-path')] = target;
602
+ const result = await sheetTx.upsert(edited);
603
+ process.stdout.write(`${result.blob.hash} ${result.path}\n`);
604
+ });
605
+ }
606
+ finally {
607
+ await rm(tmpDir, { recursive: true, force: true });
608
+ }
153
609
  }
154
610
  async function runNormalize(argv) {
155
611
  const { repo, sheet } = await loadRepoAndSheet(argv);
@@ -160,8 +616,9 @@ async function runNormalize(argv) {
160
616
  if (records.length === 0)
161
617
  return;
162
618
  const txOpts = buildTxOpts(argv, `${argv.sheet} normalize`);
619
+ const txSheetOpts = argv.prefix ? { prefix: argv.prefix } : {};
163
620
  await repo.transact(txOpts, async (tx) => {
164
- const target = tx.sheet(argv.sheet);
621
+ const target = tx.sheet(argv.sheet, txSheetOpts);
165
622
  for (const r of records) {
166
623
  const result = await target.upsert(r);
167
624
  process.stdout.write(`${result.blob.hash} ${result.path}\n`);
@@ -181,6 +638,10 @@ export async function main(args = hideBin(process.argv)) {
181
638
  default: process.env['GIT_DIR'],
182
639
  })
183
640
  .option('root', { type: 'string', describe: 'Sub-directory under the data tree; default "/"' })
641
+ .option('prefix', {
642
+ type: 'string',
643
+ describe: 'Sub-prefix under each sheet\'s config root — scopes records to a sub-tree (multi-tenant)',
644
+ })
184
645
  .option('ref', { type: 'string', describe: "Parent ref/commit; default HEAD's branch" })
185
646
  .option('commit-to', { type: 'string', describe: 'Branch to update on commit' })
186
647
  .option('message', { type: 'string', describe: 'Commit message (mutating commands)' })
@@ -208,8 +669,44 @@ export async function main(args = hideBin(process.argv)) {
208
669
  .positional('input', {
209
670
  type: 'string',
210
671
  describe: "Inline JSON, a file path, or '-' for stdin",
672
+ })
673
+ .option('format', {
674
+ type: 'string',
675
+ choices: ['json', 'toml', 'csv'],
676
+ describe: 'Input format; default: inferred from extension, falls back to json',
677
+ })
678
+ .option('encoding', {
679
+ type: 'string',
680
+ describe: 'Encoding for file/stdin input (default: utf8)',
681
+ })
682
+ .option('delete-missing', {
683
+ type: 'boolean',
684
+ default: false,
685
+ describe: 'DESTRUCTIVE: delete every record not present in the input set, in the same transaction',
686
+ })
687
+ .option('patch', {
688
+ type: 'boolean',
689
+ default: false,
690
+ describe: 'Treat each input record as an RFC 7396 merge-patch: fields matching the sheet path template become the query; the rest are merged into the matched record. Cannot be combined with --delete-missing or --attachment.',
691
+ })
692
+ .option('attachment', {
693
+ type: 'string',
694
+ array: true,
695
+ describe: "Attach a file alongside the record: --attachment <name>=<source>. <source> is a file path (relative to the input file's dir, else cwd) or '-' for stdin. Repeatable. Requires a single-record input.",
696
+ coerce: (raw) => {
697
+ const items = Array.isArray(raw) ? raw : [raw];
698
+ const out = {};
699
+ for (const item of items) {
700
+ const eq = item.indexOf('=');
701
+ if (eq === -1) {
702
+ throw new Error(`--attachment expects <name>=<source>, got ${item}`);
703
+ }
704
+ out[item.slice(0, eq)] = item.slice(eq + 1);
705
+ }
706
+ return out;
707
+ },
211
708
  }), runUpsert)
212
- .command('query <sheet>', 'Read records as newline-delimited JSON', (y) => y
709
+ .command('query <sheet>', 'Read records (output: JSON by default; --format=csv|tsv|toml supported)', (y) => y
213
710
  .positional('sheet', { type: 'string', demandOption: true })
214
711
  .option('filter', {
215
712
  type: 'string',
@@ -228,11 +725,46 @@ export async function main(args = hideBin(process.argv)) {
228
725
  },
229
726
  })
230
727
  .option('fields', { type: 'string', array: true })
231
- .option('limit', { type: 'number' }), runQuery)
728
+ .option('limit', { type: 'number' })
729
+ .option('format', {
730
+ type: 'string',
731
+ choices: ['json', 'toml', 'csv', 'tsv'],
732
+ describe: 'Output format (default: json)',
733
+ })
734
+ .option('headers', {
735
+ type: 'boolean',
736
+ default: true,
737
+ describe: 'Emit a header row for CSV/TSV output (default: true)',
738
+ }), runQuery)
232
739
  .command('read <sheet> <path>', 'Read a single record by its rendered path', (y) => y
233
740
  .positional('sheet', { type: 'string', demandOption: true })
234
- .positional('path', { type: 'string', demandOption: true }), runRead)
741
+ .positional('path', { type: 'string', demandOption: true })
742
+ .option('format', {
743
+ type: 'string',
744
+ choices: ['json', 'toml', 'csv', 'tsv'],
745
+ describe: 'Output format (default: pretty json)',
746
+ }), runRead)
747
+ .command('edit <sheet> <path>', "Open a record in $EDITOR (TOML form); on save, validate and upsert in a transaction", (y) => y
748
+ .positional('sheet', { type: 'string', demandOption: true })
749
+ .positional('path', { type: 'string', demandOption: true }), runEdit)
235
750
  .command('normalize <sheet>', 'Re-write every record through the canonical-normalization pipeline', (y) => y.positional('sheet', { type: 'string', demandOption: true }), runNormalize)
751
+ .command('init <sheet>', "Scaffold .gitsheets/<sheet>.toml with sensible defaults", (y) => y
752
+ .positional('sheet', { type: 'string', demandOption: true })
753
+ .option('path', {
754
+ type: 'string',
755
+ describe: "Path template (default: '${{ id }}')",
756
+ })
757
+ .option('schema', {
758
+ type: 'string',
759
+ describe: 'Path to a JSON Schema file to embed under [gitsheet.schema]',
760
+ })
761
+ .option('force', {
762
+ type: 'boolean',
763
+ default: false,
764
+ describe: 'Overwrite an existing .gitsheets/<sheet>.toml',
765
+ }), runInit)
766
+ .command('infer <sheet>', "Scan every record and write a starter [gitsheet.schema] block", (y) => y.positional('sheet', { type: 'string', demandOption: true }), runInfer)
767
+ .command('migrate-config <sheet>', "Convert a pre-v1.0 [gitsheet.fields] config to a v1.0 [gitsheet.schema] config", (y) => y.positional('sheet', { type: 'string', demandOption: true }), runMigrateConfig)
236
768
  .fail((msg, err) => {
237
769
  if (err) {
238
770
  reportError(err);