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/formats.d.ts +30 -0
- package/dist/cli/formats.d.ts.map +1 -0
- package/dist/cli/formats.js +145 -0
- package/dist/cli/formats.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +572 -40
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/path-template/index.d.ts +12 -0
- package/dist/path-template/index.d.ts.map +1 -1
- package/dist/path-template/index.js +50 -0
- package/dist/path-template/index.js.map +1 -1
- package/dist/push-daemon.d.ts +22 -0
- package/dist/push-daemon.d.ts.map +1 -1
- package/dist/push-daemon.js +102 -3
- package/dist/push-daemon.js.map +1 -1
- package/dist/repository.d.ts +9 -0
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +19 -4
- package/dist/repository.js.map +1 -1
- package/dist/sheet.d.ts +109 -3
- package/dist/sheet.d.ts.map +1 -1
- package/dist/sheet.js +391 -22
- package/dist/sheet.js.map +1 -1
- package/dist/transaction.d.ts +7 -3
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +6 -3
- package/dist/transaction.js.map +1 -1
- package/package.json +4 -1
package/dist/cli/index.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// CLI entry. See specs/api/cli.md.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import {
|
|
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(
|
|
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,
|
|
63
|
+
return readFile(input, encoding);
|
|
62
64
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
|
107
|
-
const
|
|
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
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
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' })
|
|
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 })
|
|
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);
|