@gregiteen/ssss-cli 0.11.0 → 0.11.1

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.
@@ -10,8 +10,20 @@
10
10
  * operation patches without dropping nested frontmatter.
11
11
  */
12
12
 
13
+ /**
14
+ * Repair a document a broken writer flattened onto one line with literal `\n`
15
+ * text in place of newlines (`---\ntype: "memory"\n...`). Real documents with
16
+ * any actual line break are returned unchanged.
17
+ */
18
+ export function repairEscapedNewlines(content) {
19
+ if (typeof content !== 'string' || !content.startsWith('---\\n') || content.trim().includes('\n')) return content;
20
+ const fixed = content.replace(/\\n/g, '\n');
21
+ return fixed.endsWith('\n') ? fixed : `${fixed}\n`;
22
+ }
23
+
13
24
  /** Split a document into its raw frontmatter block and body. */
14
25
  export function splitFrontmatter(content) {
26
+ content = repairEscapedNewlines(content);
15
27
  if (typeof content !== 'string' || !content.startsWith('---')) {
16
28
  return { fm: '', body: content || '', hasFm: false };
17
29
  }
@@ -25,36 +37,190 @@ export function splitFrontmatter(content) {
25
37
  return { fm: inner, body: content.slice(sliceEnd), hasFm: true };
26
38
  }
27
39
 
28
- function coerce(raw) {
29
- const v = raw.trim();
30
- if (v === '') return '';
31
- if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
40
+ function countIndent(line) {
41
+ const m = line.match(/^ */);
42
+ return m ? m[0].length : 0;
43
+ }
44
+
45
+ /** Index of the quote that closes the quoted scalar starting at `v[0]`, or -1. */
46
+ function findClosingQuote(v) {
47
+ const q = v[0];
48
+ for (let i = 1; i < v.length; i++) {
49
+ if (q === '"') {
50
+ if (v[i] === '\\') i++;
51
+ else if (v[i] === '"') return i;
52
+ } else if (v[i] === "'") {
53
+ if (v[i + 1] === "'") i++;
54
+ else return i;
55
+ }
56
+ }
57
+ return -1;
58
+ }
59
+
60
+ const YAML_ESCAPES = {
61
+ 0: '\0', a: '\x07', b: '\b', t: '\t', '\t': '\t', n: '\n', v: '\v', f: '\f', r: '\r', e: '\x1b',
62
+ ' ': ' ', '"': '"', '/': '/', '\\': '\\', N: '\x85', _: '\xa0', L: '\u2028', P: '\u2029'
63
+ };
64
+
65
+ /** Decode a YAML double-quoted scalar body (a superset of JSON string escapes). */
66
+ function unescapeDouble(body) {
67
+ return body.replace(/\\(?:x([0-9a-fA-F]{2})|u([0-9a-fA-F]{4})|U([0-9a-fA-F]{8})|([\s\S]))/g, (m, x, u, U, c) => {
68
+ const hex = x ?? u ?? U;
69
+ if (hex) {
70
+ const cp = parseInt(hex, 16);
71
+ return cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
72
+ }
73
+ return Object.hasOwn(YAML_ESCAPES, c) ? YAML_ESCAPES[c] : m;
74
+ });
75
+ }
76
+
77
+ function unquote(tok) {
78
+ if (tok[0] === '"') {
32
79
  // Double-quoted scalars carry escapes; the serializer writes them with JSON.stringify.
33
- try { return JSON.parse(v); } catch { return v.slice(1, -1); }
80
+ try { return JSON.parse(tok); } catch { return unescapeDouble(tok.slice(1, -1)); }
34
81
  }
35
- if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1).replace(/''/g, "'");
36
- if (v === 'true') return true;
37
- if (v === 'false') return false;
38
- if (v === 'null' || v === '~') return null;
82
+ return tok.slice(1, -1).replace(/''/g, "'");
83
+ }
84
+
85
+ /** Drop a trailing ` # comment` from an unquoted value. */
86
+ function stripComment(v) {
87
+ return v.replace(/(^|\s)#.*$/, '').trimEnd();
88
+ }
89
+
90
+ const INT_RE = /^[-+]?(?:0|[1-9]\d*)$/;
91
+ const FLOAT_RE = /^[-+]?(?:(?:0|[1-9]\d*)?\.\d+(?:[eE][-+]?\d+)?|(?:0|[1-9]\d*)(?:\.\d*)?[eE][-+]?\d+)$/;
92
+
93
+ function coercePlain(v) {
94
+ if (v === 'true' || v === 'True' || v === 'TRUE') return true;
95
+ if (v === 'false' || v === 'False' || v === 'FALSE') return false;
96
+ if (v === 'null' || v === 'Null' || v === 'NULL' || v === '~') return null;
39
97
  // A leading zero (zip 07003, license 00123) marks a code, not a number.
40
- if (/^-?(0|[1-9]\d*)$/.test(v)) return parseInt(v, 10);
41
- if (/^-?\d*\.\d+$/.test(v)) return parseFloat(v);
42
- if (v.startsWith('[') && v.endsWith(']')) {
43
- const inner = v.slice(1, -1).trim();
44
- if (!inner) return [];
45
- return inner.split(',').map((s) => coerce(s));
46
- }
98
+ if (INT_RE.test(v)) return parseInt(v, 10);
99
+ if (/^0x[0-9a-fA-F]+$/.test(v)) return parseInt(v.slice(2), 16);
100
+ if (/^0o[0-7]+$/.test(v)) return parseInt(v.slice(2), 8);
101
+ if (FLOAT_RE.test(v)) return parseFloat(v);
47
102
  return v;
48
103
  }
49
104
 
50
- function countIndent(line) {
51
- const m = line.match(/^ */);
52
- return m ? m[0].length : 0;
105
+ /**
106
+ * Parse an inline `[...]` / `{...}` value (JSON and YAML flow style). Quotes,
107
+ * nested brackets and commas inside quoted strings are respected. Throws on
108
+ * malformed input so the caller can fall back to the raw text.
109
+ */
110
+ function parseFlow(src) {
111
+ let pos = 0;
112
+ const ws = () => { while (pos < src.length && /\s/.test(src[pos])) pos++; };
113
+ const quoted = () => {
114
+ const end = findClosingQuote(src.slice(pos));
115
+ if (end < 0) throw new Error('unterminated quote');
116
+ const tok = src.slice(pos, pos + end + 1);
117
+ pos += end + 1;
118
+ return unquote(tok);
119
+ };
120
+ const plain = (inKey) => {
121
+ const start = pos;
122
+ while (pos < src.length) {
123
+ const ch = src[pos];
124
+ if (ch === ',' || ch === ']' || ch === '}') break;
125
+ if (inKey && ch === ':' && (pos + 1 >= src.length || /[\s,\]}]/.test(src[pos + 1]))) break;
126
+ pos++;
127
+ }
128
+ return src.slice(start, pos).trim();
129
+ };
130
+ const node = () => {
131
+ ws();
132
+ const ch = src[pos];
133
+ if (ch === '[') return seq();
134
+ if (ch === '{') return map();
135
+ if (ch === '"' || ch === "'") return quoted();
136
+ const p = plain(false);
137
+ return p === '' ? null : coercePlain(p);
138
+ };
139
+ const seq = () => {
140
+ pos++;
141
+ const out = [];
142
+ for (;;) {
143
+ ws();
144
+ if (pos >= src.length) throw new Error('unterminated sequence');
145
+ if (src[pos] === ']') { pos++; return out; }
146
+ out.push(node());
147
+ ws();
148
+ if (src[pos] === ',') pos++;
149
+ else if (src[pos] !== ']') throw new Error('bad sequence');
150
+ }
151
+ };
152
+ const map = () => {
153
+ pos++;
154
+ const out = {};
155
+ for (;;) {
156
+ ws();
157
+ if (pos >= src.length) throw new Error('unterminated map');
158
+ if (src[pos] === '}') { pos++; return out; }
159
+ const key = src[pos] === '"' || src[pos] === "'" ? quoted() : plain(true);
160
+ ws();
161
+ if (src[pos] === ':') { pos++; out[key] = node(); } else out[key] = null;
162
+ ws();
163
+ if (src[pos] === ',') pos++;
164
+ else if (src[pos] !== '}') throw new Error('bad map');
165
+ }
166
+ };
167
+ const value = node();
168
+ ws();
169
+ if (pos < src.length && !src.slice(pos).startsWith('#')) throw new Error('trailing text');
170
+ return value;
171
+ }
172
+
173
+ function coerce(raw) {
174
+ const v = raw.trim();
175
+ if (v === '') return '';
176
+ if (v[0] === '"' || v[0] === "'") {
177
+ const end = findClosingQuote(v);
178
+ if (end > 0 && /^\s*(#.*)?$/.test(v.slice(end + 1))) return unquote(v.slice(0, end + 1));
179
+ return v;
180
+ }
181
+ if (v[0] === '[' || v[0] === '{') {
182
+ try { return parseFlow(v); } catch { return v; }
183
+ }
184
+ return coercePlain(stripComment(v));
53
185
  }
54
186
 
187
+ // A key is a bare word (no whitespace, `:` or `#`; inner single spaces allowed)
188
+ // or a quoted string, followed by `:` and then whitespace or end of line. The
189
+ // whitespace requirement keeps tags like `context:universal` and URLs as values.
190
+ const KEY_RE =
191
+ /^(?:([A-Za-z0-9_][^\s:#]*(?: [^\s:#]+)*)|("(?:[^"\\]|\\.)*")|('(?:[^']|'')*')):(?:[ \t]+(.*))?$/;
192
+
55
193
  function splitKeyValue(text) {
56
- const m = text.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
57
- return m ? { key: m[1], rest: m[2] } : null;
194
+ const m = text.trimEnd().match(KEY_RE);
195
+ if (!m) return null;
196
+ const key = m[1] ?? unquote(m[2] ?? m[3]);
197
+ const rest = (m[4] ?? '').trim();
198
+ return { key, rest: rest.startsWith('#') ? '' : rest };
199
+ }
200
+
201
+ /**
202
+ * Read a scalar or flow value that starts on `lines[i]` (already split into
203
+ * `rest`) and may wrap onto deeper-indented continuation lines. Plain and
204
+ * quoted lines fold with a space; a blank line is one newline.
205
+ */
206
+ function readScalar(lines, i, keyIndent, rest) {
207
+ const inline = /^["'[{]/.test(rest);
208
+ let text = rest;
209
+ let j = i + 1;
210
+ let blanks = 0;
211
+ let last = i;
212
+ while (j < lines.length) {
213
+ const line = lines[j];
214
+ if (!line.trim()) { blanks++; j++; continue; }
215
+ if (countIndent(line) <= keyIndent) break;
216
+ if (!inline && line.trim().startsWith('#')) break;
217
+ const piece = inline ? line.trim() : stripComment(line.trim());
218
+ text += blanks ? '\n'.repeat(blanks) + piece : ` ${piece}`;
219
+ blanks = 0;
220
+ last = j;
221
+ j++;
222
+ }
223
+ return { value: coerce(text), next: last + 1 };
58
224
  }
59
225
 
60
226
  function nextSignificant(lines, start) {
@@ -64,7 +230,7 @@ function nextSignificant(lines, start) {
64
230
  return -1;
65
231
  }
66
232
 
67
- const BLOCK_SCALAR_RE = /^([|>])([+-]?)$/;
233
+ const BLOCK_SCALAR_RE = /^([|>])([+-]?)(?:\s+#.*)?$/;
68
234
 
69
235
  /** Read a `|` (literal) or `>` (folded) block scalar with clip/strip/keep chomping. */
70
236
  function parseBlockScalar(lines, start, parentIndent, style, chomp) {
@@ -84,23 +250,26 @@ function parseBlockScalar(lines, start, parentIndent, style, chomp) {
84
250
  while (end > 0 && raw[end - 1] === '') end--;
85
251
  const content = raw.slice(0, end);
86
252
  let text = content[0] ?? '';
253
+ let prevText = 0; // index of the last non-blank line already emitted
87
254
  for (let k = 1; k < content.length; k++) {
88
255
  const line = content[k];
89
- const previous = content[k - 1];
90
- if (style === '|') text += `\n${line}`;
91
- // Folding: each blank line is one newline, and the break before it is
92
- // consumed; more-indented lines keep their breaks; others join with a space.
93
- else if (line === '') text += '\n';
94
- else if (previous === '') text += line;
95
- else if (/^\s/.test(line) || /^\s/.test(previous)) text += `\n${line}`;
96
- else text += ` ${line}`;
256
+ if (line === '') continue;
257
+ const blanks = k - prevText - 1;
258
+ if (style === '|') text += `${'\n'.repeat(blanks + 1)}${line}`;
259
+ // Folding: a break between two ordinary lines becomes a space, and blank
260
+ // lines each add one newline; a more-indented line on either side keeps its break.
261
+ else if (/^\s/.test(line) || /^\s/.test(content[prevText])) text += `${'\n'.repeat(blanks + 1)}${line}`;
262
+ else text += blanks ? `${'\n'.repeat(blanks)}${line}` : ` ${line}`;
263
+ prevText = k;
97
264
  }
98
- if (!content.length) text = '';
265
+ if (!content.length) text = chomp === '+' ? '\n'.repeat(raw.length) : '';
99
266
  else if (chomp === '+') text += '\n'.repeat(1 + raw.length - end);
100
267
  else if (chomp !== '-') text += '\n';
101
268
  return { value: text, next: i };
102
269
  }
103
270
 
271
+ const isDash = (text) => text === '-' || text.startsWith('- ');
272
+
104
273
  function parseMap(lines, start, indent) {
105
274
  const data = {};
106
275
  let i = start;
@@ -120,19 +289,22 @@ function parseMap(lines, start, indent) {
120
289
  continue;
121
290
  }
122
291
  if (kv.rest !== '') {
123
- data[kv.key] = coerce(kv.rest);
124
- i++;
292
+ const scalar = readScalar(lines, i, indent, kv.rest);
293
+ data[kv.key] = scalar.value;
294
+ i = scalar.next;
125
295
  continue;
126
296
  }
127
297
 
128
298
  const next = nextSignificant(lines, i + 1);
129
- if (next < 0 || countIndent(lines[next]) <= indent) {
299
+ // YAML allows a block list at the same indent as its key.
300
+ const sameIndentList = next >= 0 && countIndent(lines[next]) === indent && isDash(lines[next].slice(indent));
301
+ if (next < 0 || (countIndent(lines[next]) <= indent && !sameIndentList)) {
130
302
  data[kv.key] = {};
131
303
  i++;
132
304
  continue;
133
305
  }
134
306
  const childIndent = countIndent(lines[next]);
135
- if (lines[next].slice(childIndent).startsWith('- ')) {
307
+ if (isDash(lines[next].slice(childIndent))) {
136
308
  const parsed = parseList(lines, next, childIndent);
137
309
  data[kv.key] = parsed.value;
138
310
  i = parsed.next;
@@ -154,21 +326,25 @@ function parseList(lines, start, indent) {
154
326
  const currentIndent = countIndent(line);
155
327
  if (currentIndent < indent) break;
156
328
  if (currentIndent > indent) { i++; continue; }
157
- const item = line.slice(indent).match(/^-\s*(.*)$/);
329
+ const item = line.slice(indent).match(/^-(?:(\s+)(.*))?$/);
158
330
  if (!item) break;
159
- const raw = item[1];
331
+ const raw = (item[2] ?? '').trim();
332
+ const keyIndent = indent + 1 + (item[1] ?? '').length;
160
333
  const kv = splitKeyValue(raw);
161
334
  if (kv) {
162
- const obj = { [kv.key]: kv.rest === '' ? {} : coerce(kv.rest) };
163
- i++;
164
- const next = nextSignificant(lines, i);
165
- if (next >= 0 && countIndent(lines[next]) > indent) {
166
- const parsed = parseMap(lines, next, countIndent(lines[next]));
167
- Object.assign(obj, parsed.value);
168
- i = parsed.next;
169
- }
170
- items.push(obj);
171
- } else if (raw === '') {
335
+ // Re-read the item as a map whose first key sits where the dash was, so
336
+ // nested children, block scalars and wrapped values of every key parse alike.
337
+ lines[i] = ' '.repeat(keyIndent) + raw;
338
+ const parsed = parseMap(lines, i, keyIndent);
339
+ items.push(parsed.value);
340
+ i = parsed.next;
341
+ } else if (isDash(raw)) {
342
+ // Nested sequence (`- - a`): re-read the inner dash as its own list item.
343
+ lines[i] = ' '.repeat(keyIndent) + raw;
344
+ const parsed = parseList(lines, i, keyIndent);
345
+ items.push(parsed.value);
346
+ i = parsed.next;
347
+ } else if (raw === '' || raw.startsWith('#')) {
172
348
  const next = nextSignificant(lines, i + 1);
173
349
  if (next >= 0 && countIndent(lines[next]) > indent) {
174
350
  const parsed = parseMap(lines, next, countIndent(lines[next]));
@@ -178,9 +354,15 @@ function parseList(lines, start, indent) {
178
354
  items.push('');
179
355
  i++;
180
356
  }
357
+ } else if (BLOCK_SCALAR_RE.test(raw)) {
358
+ const block = raw.match(BLOCK_SCALAR_RE);
359
+ const parsed = parseBlockScalar(lines, i + 1, indent, block[1], block[2]);
360
+ items.push(parsed.value);
361
+ i = parsed.next;
181
362
  } else {
182
- items.push(coerce(raw));
183
- i++;
363
+ const scalar = readScalar(lines, i, indent, raw);
364
+ items.push(scalar.value);
365
+ i = scalar.next;
184
366
  }
185
367
  }
186
368
  return { value: items, next: i };
@@ -208,15 +390,22 @@ function serializeValue(v) {
208
390
  if (v === null) return 'null';
209
391
  if (typeof v === 'boolean' || typeof v === 'number') return String(v);
210
392
  if (Array.isArray(v)) return `[${v.map((x) => serializeValue(x)).join(', ')}]`;
393
+ // Nested objects inside an inline array go out as JSON, which the flow parser reads back.
394
+ if (typeof v === 'object') return JSON.stringify(v);
211
395
  const s = String(v);
212
- if (s === '' || /[:#\[\]{}"'\\\n\r\t|>]/.test(s) || /^\s|\s$/.test(s)) return JSON.stringify(s);
396
+ if (s === '' || /[:#,\[\]{}"'\\\n\r\t|>]/.test(s) || /^\s|\s$/.test(s) || /^[-?!&*%@`]/.test(s)) return JSON.stringify(s);
213
397
  // A string that would read back as a number, boolean or null stays quoted,
214
398
  // so "07003" and "true" survive a round trip (here and in YAML 1.1 readers).
215
399
  if (LOOKS_TYPED.test(s)) return JSON.stringify(s);
216
400
  return s;
217
401
  }
218
402
 
219
- function serializeEntry(lines, key, value, indent = 0) {
403
+ function serializeKey(key) {
404
+ return /^[A-Za-z0-9_][A-Za-z0-9_.\-/]*$/.test(key) ? key : JSON.stringify(key);
405
+ }
406
+
407
+ function serializeEntry(lines, rawKey, value, indent = 0) {
408
+ const key = serializeKey(rawKey);
220
409
  const pad = ' '.repeat(indent);
221
410
  if (Array.isArray(value)) {
222
411
  if (!value.length) {
@@ -233,10 +422,10 @@ function serializeEntry(lines, key, value, indent = 0) {
233
422
  }
234
423
  const [first, ...rest] = entries;
235
424
  if (first[1] && typeof first[1] === 'object' && !Array.isArray(first[1])) {
236
- lines.push(`${pad} - ${first[0]}:`);
425
+ lines.push(`${pad} - ${serializeKey(first[0])}:`);
237
426
  serializeObject(lines, first[1], indent + 6);
238
427
  } else {
239
- lines.push(`${pad} - ${first[0]}: ${serializeValue(first[1])}`);
428
+ lines.push(`${pad} - ${serializeKey(first[0])}: ${serializeValue(first[1])}`);
240
429
  }
241
430
  for (const [childKey, childValue] of rest) serializeEntry(lines, childKey, childValue, indent + 4);
242
431
  } else {
package/src/kernel.mjs CHANGED
@@ -184,6 +184,28 @@ export function createKernel(options = {}) {
184
184
  if (currentData?.type && currentData.type !== nextData.type) {
185
185
  return failure('validation_failed', envelope, operationId, ['Operation may not change an existing document type.'], [{ field: 'type', issue: 'Use an explicit migration.' }]);
186
186
  }
187
+ // Replaying an identical full operation is a no-op, not a replace. This must
188
+ // be checked before the append-only guard so that re-importing a bundle that
189
+ // is already present reports `unchanged` instead of failing (spec §17.1).
190
+ if (current && current.bytes.toString('utf8') === nextContent) {
191
+ const noopAuth = await authorize({
192
+ principal, workspaceId: envelope.workspace_id, definition,
193
+ action: envelope.type === 'operation' ? 'replace' : actionFor(envelope, true, isAppendType(definition)),
194
+ context,
195
+ });
196
+ if (!noopAuth.allowed) {
197
+ const code = validateVerifiedPrincipal(principal).valid ? 'forbidden' : 'unauthorized';
198
+ return failure(code, envelope, operationId, [`Access denied: ${noopAuth.reason}`], [{ field: 'principal', issue: noopAuth.reason }]);
199
+ }
200
+ const response = {
201
+ success: true, type: envelope.type, operation_id: operationId, path: envelope.path,
202
+ committed_at: current.version?.committed_at || null, dry_run: false,
203
+ unchanged: true,
204
+ validation: { valid: true, type: definition.qualified_type, errors: [], warnings: [] },
205
+ };
206
+ try { await idempotencyStore.put(envelope.workspace_id, envelope.idempotency_key, { request_hash: requestHash, response }); } catch {}
207
+ return response;
208
+ }
187
209
  // A full replace would rewrite recorded history (spec §5.3).
188
210
  if (current && isAppendType(definition)) {
189
211
  return failure('validation_failed', envelope, operationId, [`Append-only primitive '${declaredType}' cannot be replaced.`], [{ field: 'content', issue: 'Append with a patch carrying __body__.' }]);
package/src/registry.mjs CHANGED
@@ -297,26 +297,68 @@ export function resolvePrimitiveDefinition(registrySet, type) {
297
297
  return local ? registrySet.types.get(local) || null : null;
298
298
  }
299
299
 
300
+ /**
301
+ * Names of the extensions loaded when a caller does not scope the load.
302
+ *
303
+ * A registry directory accumulates one extension per product. Two unrelated
304
+ * products may each legitimately define a shared primitive name (e.g. both a
305
+ * CRM and a ticketing product needing `integration_connection`), and §5.6 makes
306
+ * that a load-time collision. `extensions/defaults.json` names the set that is
307
+ * safe to compose together; every other extension MUST be selected explicitly by
308
+ * the caller that owns it. Without the manifest, every extension is loaded and
309
+ * any cross-product collision stays a hard error rather than a silent preference.
310
+ */
311
+ function loadExtensionDefaults(registryDir) {
312
+ const manifest = path.join(registryDir, 'extensions', 'defaults.json');
313
+ if (!fs.existsSync(manifest)) return null;
314
+ const parsed = JSON.parse(fs.readFileSync(manifest, 'utf8'));
315
+ const names = parsed?.default_extensions;
316
+ if (!Array.isArray(names) || names.some((n) => typeof n !== 'string')) {
317
+ throw new Error('extensions/defaults.json: default_extensions must be an array of registry names.');
318
+ }
319
+ return names;
320
+ }
321
+
300
322
  /**
301
323
  * Load the core registry and merge extension registries.
324
+ *
325
+ * `only` scopes the load to a set of registry names (e.g. `['dabber']`). A
326
+ * registry directory may hold extensions for unrelated products that legitimately
327
+ * declare the same shared primitive name; loading them together is a load-time
328
+ * collision (§5.6), so a caller that knows which extension a vault belongs to
329
+ * MUST be able to load just that one. When `only` is omitted, the set named by
330
+ * `extensions/defaults.json` is loaded; failing that, every extension is loaded
331
+ * and any duplicate name remains a hard error rather than a silent preference.
332
+ *
302
333
  * @param {string} [registryDir] - directory containing core.json + extensions/
334
+ * @param {{ only?: string[]|Set<string> }} [options]
303
335
  * @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object, extensions: Set<string> }}
304
336
  */
305
- export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
337
+ export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR, options = {}) {
306
338
  const corePath = path.join(registryDir, 'core.json');
307
339
  const core = JSON.parse(fs.readFileSync(corePath, 'utf8'));
340
+ const requested = options.only ? new Set(options.only) : null;
341
+ const defaults = requested ? null : loadExtensionDefaults(registryDir);
342
+ const only = requested || (defaults ? new Set(defaults) : null);
308
343
  const extensionInputs = [];
309
344
  const extDir = path.join(registryDir, 'extensions');
310
345
  if (fs.existsSync(extDir)) {
311
- for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json')).sort()) {
346
+ for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json') && f !== 'defaults.json').sort()) {
312
347
  const extPath = path.join(extDir, file);
313
348
  if (fs.lstatSync(extPath).isSymbolicLink()) {
314
349
  throw new Error(`extensions/${file}: symlinked registries are not allowed.`);
315
350
  }
316
- extensionInputs.push({
317
- source: `extensions/${file}`,
318
- registry: JSON.parse(fs.readFileSync(extPath, 'utf8')),
319
- });
351
+ const registry = JSON.parse(fs.readFileSync(extPath, 'utf8'));
352
+ if (only && !only.has(registry.registry) && !only.has(file.replace(/\.json$/, ''))) continue;
353
+ extensionInputs.push({ source: `extensions/${file}`, registry });
354
+ }
355
+ }
356
+ if (only) {
357
+ const loaded = new Set(extensionInputs.map((e) => e.registry.registry));
358
+ for (const name of only) {
359
+ if (!loaded.has(name) && !extensionInputs.some((e) => e.source === `extensions/${name}.json`)) {
360
+ throw new Error(`registry '${name}' is not present in ${path.join(registryDir, 'extensions')}.`);
361
+ }
320
362
  }
321
363
  }
322
364
  return composeRegistries({ core, extensions: extensionInputs });