@gregiteen/ssss-cli 0.7.1 → 0.8.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/bundle.mjs CHANGED
@@ -16,9 +16,16 @@
16
16
  import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import crypto from 'node:crypto';
19
- import { loadRegistries, resolvePortability } from './registry.mjs';
19
+ import {
20
+ loadRegistries,
21
+ resolvePortability,
22
+ validateDataConstraints,
23
+ validateReferenceConstraints,
24
+ } from './registry.mjs';
20
25
  import { parseDocument, serializeDocument } from './frontmatter.mjs';
21
26
 
27
+ export const DEFAULT_EXPORTER = '@gregiteen/ssss-cli';
28
+
22
29
  const PROFILE_INCLUDES = {
23
30
  backup: new Set(['structural', 'tenant_private', 'resource_bound']),
24
31
  template: new Set(['structural', 'resource_bound']),
@@ -133,7 +140,7 @@ function walkVault(vaultRoot) {
133
140
  */
134
141
  export function exportBundle(vaultRoot, opts = {}) {
135
142
  const { profile = 'backup', registryDir, name = 'untitled', description = '', version = '0.1.0',
136
- exporter = '@ssss/cli', requiredExtensions = [], parameters = [], provisioning = [],
143
+ exporter = DEFAULT_EXPORTER, requiredExtensions = [], parameters = [], provisioning = [],
137
144
  sourceWorkspaceId, exportedAt = '1970-01-01T00:00:00.000Z' } = opts;
138
145
  const includes = PROFILE_INCLUDES[profile];
139
146
  if (!includes) throw new Error(`Unknown export profile '${profile}'. Use backup | template | sale.`);
@@ -145,8 +152,35 @@ export function exportBundle(vaultRoot, opts = {}) {
145
152
  const manifestParameters = [...parameters];
146
153
  const manifestProvisioning = [...provisioning];
147
154
  const manifestDraft = { parameters: manifestParameters, provisioning: manifestProvisioning };
155
+ const vaultFiles = walkVault(vaultRoot);
156
+ const vaultDocuments = new Map(vaultFiles.map((file) => {
157
+ const parsed = parseDocument(file.content);
158
+ return [file.path, { ...file, ...parsed }];
159
+ }));
148
160
 
149
- for (const file of walkVault(vaultRoot)) {
161
+ for (const document of vaultDocuments.values()) {
162
+ const base = path.basename(document.path);
163
+ if (base === 'index.md' || base === 'log.md') continue;
164
+ const definition = types.get(document.data.type);
165
+ if (!definition) throw new Error(`Cannot export '${document.path}': unknown type '${document.data.type || '(missing)'}'.`);
166
+ const issues = [
167
+ ...validateDataConstraints(definition, document.data, core.universal_frontmatter?.required || ['type']),
168
+ ...validateReferenceConstraints(
169
+ definition,
170
+ document.data,
171
+ (referencePath) => vaultDocuments.get(referencePath) || null,
172
+ types
173
+ ),
174
+ ];
175
+ if (issues.length) {
176
+ throw new Error(
177
+ `Cannot export '${document.path}': ` +
178
+ issues.map((issue) => `${issue.field}: ${issue.issue}`).join('; ')
179
+ );
180
+ }
181
+ }
182
+
183
+ for (const file of vaultFiles) {
150
184
  const { data } = parseDocument(file.content);
151
185
  const type = data.type;
152
186
  const typeDef = types.get(type);
@@ -188,14 +222,29 @@ export function validateBundle(bundle, opts = {}) {
188
222
  const errors = [];
189
223
  const { types, core, extensions } = loadRegistries(opts.registryDir);
190
224
  const m = bundle && bundle.manifest;
191
- if (!m) return { valid: false, errors: ['bundle has no manifest'] };
225
+ if (!m || typeof m !== 'object' || Array.isArray(m)) {
226
+ return { valid: false, errors: ['bundle has no object manifest'] };
227
+ }
192
228
  const files = Array.isArray(bundle.files) ? bundle.files : [];
229
+ const requiredExtensions = Array.isArray(m.required_extensions) ? m.required_extensions : [];
230
+ const provisioning = Array.isArray(m.provisioning) ? m.provisioning : [];
231
+ const parameters = Array.isArray(m.parameters) ? m.parameters : [];
193
232
 
194
233
  for (const f of core.bundle.manifest_required_fields) {
195
234
  if (m[f] === undefined || m[f] === null) errors.push(`manifest missing required field '${f}'`);
196
235
  }
197
236
  if (!Array.isArray(bundle.files)) errors.push('bundle.files must be an array');
198
- for (const ext of m.required_extensions || []) {
237
+ if (!Array.isArray(m.required_extensions)) errors.push('manifest.required_extensions must be an array');
238
+ if (!Array.isArray(m.provisioning)) errors.push('manifest.provisioning must be an array');
239
+ if (!Array.isArray(m.parameters)) errors.push('manifest.parameters must be an array');
240
+ if (!m.primitive_inventory || typeof m.primitive_inventory !== 'object' || Array.isArray(m.primitive_inventory)) {
241
+ errors.push('manifest.primitive_inventory must be an object');
242
+ }
243
+ for (const ext of requiredExtensions) {
244
+ if (typeof ext !== 'string' || !ext) {
245
+ errors.push('required extension ids must be non-empty strings');
246
+ continue;
247
+ }
199
248
  if (!extensions.has(ext)) errors.push(`required extension '${ext}' is not loaded`);
200
249
  }
201
250
  if (!PROFILE_INCLUDES[m.export_profile]) errors.push(`invalid export_profile '${m.export_profile}'`);
@@ -206,71 +255,110 @@ export function validateBundle(bundle, opts = {}) {
206
255
  errors.push(`file_count ${m.file_count} ≠ actual ${files.length}`);
207
256
  }
208
257
  const seenPaths = new Set();
258
+ const bundleDocuments = new Map();
259
+ let fileShapeValid = true;
209
260
  for (const file of files) {
261
+ if (!file || typeof file !== 'object' || Array.isArray(file)) {
262
+ errors.push('bundle file entries must be objects');
263
+ fileShapeValid = false;
264
+ continue;
265
+ }
210
266
  if (!isSafeBundlePath(file.path)) {
211
267
  errors.push(`file '${file.path || '(missing)'}' has an unsafe bundle path`);
268
+ fileShapeValid = false;
269
+ continue;
270
+ }
271
+ if (typeof file.content !== 'string') {
272
+ errors.push(`file '${file.path}' content must be a string`);
273
+ fileShapeValid = false;
212
274
  continue;
213
275
  }
214
276
  if (seenPaths.has(file.path)) errors.push(`duplicate file path '${file.path}'`);
215
277
  seenPaths.add(file.path);
278
+ try { bundleDocuments.set(file.path, { ...file, ...parseDocument(file.content) }); }
279
+ catch (error) {
280
+ errors.push(`file '${file.path}' could not be parsed: ${error.message}`);
281
+ fileShapeValid = false;
282
+ }
216
283
  }
217
284
  // Provenance: recompute the content hash and reject on mismatch (§16.3).
218
285
  if (m.provenance) {
219
- for (const f of core.bundle.provenance_required_fields) {
220
- if (m.provenance[f] === undefined) errors.push(`provenance missing '${f}'`);
221
- }
222
- const recomputed = contentHash(files);
223
- if (m.provenance.content_hash && m.provenance.content_hash !== recomputed) {
224
- errors.push(`content_hash mismatch: manifest ${m.provenance.content_hash} ≠ recomputed ${recomputed}`);
286
+ if (typeof m.provenance !== 'object' || Array.isArray(m.provenance)) {
287
+ errors.push('manifest.provenance must be an object');
288
+ } else {
289
+ for (const f of core.bundle.provenance_required_fields) {
290
+ if (m.provenance[f] === undefined) errors.push(`provenance missing '${f}'`);
291
+ }
292
+ if (fileShapeValid) {
293
+ const recomputed = contentHash(files);
294
+ if (m.provenance.content_hash && m.provenance.content_hash !== recomputed) {
295
+ errors.push(`content_hash mismatch: manifest ${m.provenance.content_hash} ≠ recomputed ${recomputed}`);
296
+ }
297
+ }
225
298
  }
226
299
  }
227
300
  // Portability conformance: no file may exceed what the profile permits (§5.5).
228
301
  const includes = PROFILE_INCLUDES[m.export_profile];
229
302
  if (includes) {
230
- for (const file of files) {
231
- if (!isSafeBundlePath(file.path)) continue;
232
- const base = path.basename(file.path || '');
303
+ for (const document of bundleDocuments.values()) {
304
+ const base = path.basename(document.path);
233
305
  if (base === 'index.md' || base === 'log.md') continue;
234
- const { data } = parseDocument(file.content);
306
+ const { data } = document;
235
307
  const def = types.get(data.type);
236
308
  if (!def) {
237
- errors.push(`file '${file.path}' declares unknown type '${data.type || '(missing)'}'`);
309
+ errors.push(`file '${document.path}' declares unknown type '${data.type || '(missing)'}'`);
238
310
  continue;
239
311
  }
240
- const requiredFields = [
241
- ...new Set([
242
- ...(core.universal_frontmatter?.required || ['type']),
243
- ...(def.required_fields || []),
244
- ]),
245
- ];
246
- for (const f of requiredFields) {
247
- if (data[f] === undefined || data[f] === null || data[f] === '' || (Array.isArray(data[f]) && data[f].length === 0)) {
248
- errors.push(`file '${file.path}' missing required frontmatter field '${f}'`);
249
- }
312
+ for (const issue of validateDataConstraints(
313
+ def,
314
+ data,
315
+ core.universal_frontmatter?.required || ['type']
316
+ )) {
317
+ errors.push(`file '${document.path}' ${issue.field}: ${issue.issue}`);
250
318
  }
251
319
  const klass = resolvePortability(types.get(data.type), data);
252
320
  if (!includes.has(klass)) {
253
- errors.push(`file '${file.path}' is ${klass}, excluded by profile '${m.export_profile}'`);
321
+ errors.push(`file '${document.path}' is ${klass}, excluded by profile '${m.export_profile}'`);
254
322
  }
255
323
  if ((m.export_profile === 'sale' || m.export_profile === 'template') && klass === 'resource_bound') {
256
324
  const binds = def.resource?.binds || [];
257
325
  if (!binds.length) {
258
- errors.push(`file '${file.path}' is resource_bound but registry type '${data.type}' declares no resource.binds`);
326
+ errors.push(`file '${document.path}' is resource_bound but registry type '${data.type}' declares no resource.binds`);
259
327
  }
260
328
  for (const field of binds) {
261
329
  if (data[field] !== 'REQUIREMENT') {
262
- errors.push(`file '${file.path}' leaks resource_bound field '${field}' in ${m.export_profile} bundle`);
330
+ errors.push(`file '${document.path}' leaks resource_bound field '${field}' in ${m.export_profile} bundle`);
263
331
  }
264
332
  }
265
333
  }
266
334
  }
267
335
  }
336
+ for (const document of bundleDocuments.values()) {
337
+ const definition = types.get(document.data.type);
338
+ const issues = validateReferenceConstraints(
339
+ definition,
340
+ document.data,
341
+ (referencePath) => bundleDocuments.get(referencePath) || null,
342
+ types
343
+ );
344
+ for (const issue of issues) {
345
+ errors.push(`file '${document.path}' ${issue.field}: ${issue.issue}`);
346
+ }
347
+ }
268
348
  // Provisioning step + parameter vocabularies (§17.2 / §16.5).
269
- for (const s of m.provisioning || []) {
349
+ for (const s of provisioning) {
350
+ if (!s || typeof s !== 'object' || Array.isArray(s)) {
351
+ errors.push('provisioning entries must be objects');
352
+ continue;
353
+ }
270
354
  if (!STEP_SYSTEMS.has(s.system)) errors.push(`provisioning step '${s.id}' has invalid system '${s.system}'`);
271
355
  if (!STEP_MODES.has(s.mode)) errors.push(`provisioning step '${s.id}' has invalid mode '${s.mode}'`);
272
356
  }
273
- for (const p of m.parameters || []) {
357
+ for (const p of parameters) {
358
+ if (!p || typeof p !== 'object' || Array.isArray(p)) {
359
+ errors.push('parameter entries must be objects');
360
+ continue;
361
+ }
274
362
  for (const f of core.bundle.parameter_fields.required) {
275
363
  if (p[f] === undefined) errors.push(`parameter '${p.key || '?'}' missing '${f}'`);
276
364
  }
@@ -342,9 +430,57 @@ export function provisionBundle(bundle, opts = {}) {
342
430
  * (§6.3). Idempotent: each envelope's idempotency_key makes a re-run a no-op.
343
431
  */
344
432
  export function importBundle(plan, vaultRoot, engine) {
433
+ const dryRunFlags = new Set(plan.map((envelope) => !!envelope.dry_run));
434
+ if (dryRunFlags.size > 1) {
435
+ return {
436
+ ok: false,
437
+ committed: 0,
438
+ wouldCommit: 0,
439
+ results: [{
440
+ success: false,
441
+ validation: { valid: false, errors: ['Import plan may not mix dry_run and commit envelopes.'] },
442
+ }],
443
+ };
444
+ }
445
+ const plannedDocuments = new Map();
446
+ for (const envelope of plan) {
447
+ if (envelope.type !== 'operation' || typeof envelope.content !== 'string') continue;
448
+ plannedDocuments.set(envelope.path, {
449
+ path: envelope.path,
450
+ content: envelope.content,
451
+ ...parseDocument(envelope.content),
452
+ });
453
+ }
454
+ const operationOptions = {
455
+ referenceResolver: (referencePath) => plannedDocuments.get(referencePath),
456
+ };
457
+ // Two-phase import: validate the complete plan with in-plan references first.
458
+ // A single invalid document prevents every commit, eliminating partial bundle
459
+ // installation and allowing dry-run to validate cross-file constraints.
460
+ const preflight = plan.map((envelope) => {
461
+ const preflightKey = 'preflight-' + crypto.createHash('sha256')
462
+ .update(`${envelope.workspace_id}:${envelope.idempotency_key}:${envelope.path}`)
463
+ .digest('hex')
464
+ .slice(0, 32);
465
+ return engine.processOperation(
466
+ { ...envelope, idempotency_key: preflightKey, dry_run: true },
467
+ vaultRoot,
468
+ operationOptions
469
+ );
470
+ });
471
+ if (!preflight.every((result) => result.success)) {
472
+ return { ok: false, committed: 0, wouldCommit: 0, results: preflight };
473
+ }
474
+ if (dryRunFlags.has(true)) {
475
+ return {
476
+ ok: true,
477
+ committed: 0,
478
+ wouldCommit: preflight.filter((result) => !result.replay).length,
479
+ results: preflight,
480
+ };
481
+ }
345
482
  const results = [];
346
- for (const env of plan) results.push(engine.processOperation(env, vaultRoot));
483
+ for (const env of plan) results.push(engine.processOperation(env, vaultRoot, operationOptions));
347
484
  const committed = results.filter((r) => r.success && !r.replay && !r.dry_run).length;
348
- const wouldCommit = results.filter((r) => r.success && !r.replay && r.dry_run).length;
349
- return { ok: results.every((r) => r.success), committed, wouldCommit, results };
485
+ return { ok: results.every((r) => r.success), committed, wouldCommit: 0, results };
350
486
  }
package/src/engine.mjs CHANGED
@@ -15,7 +15,11 @@ import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import crypto from 'node:crypto';
17
17
  import { parseDocument, serializeDocument } from './frontmatter.mjs';
18
- import { loadRegistries, isAppendType } from './registry.mjs';
18
+ import {
19
+ loadRegistries,
20
+ isAppendType,
21
+ validateReferenceConstraints,
22
+ } from './registry.mjs';
19
23
 
20
24
  const ENVELOPE_TYPES = ['operation', 'patch', 'event', 'delete'];
21
25
  const ENVELOPE_REQUIRED = {
@@ -150,7 +154,7 @@ export function createEngine({ registryDir, leaseStore } = {}) {
150
154
  return { field, value };
151
155
  }
152
156
 
153
- function validateContent(resolvedType, data, filePath) {
157
+ function validateContent(resolvedType, data, filePath, vaultRoot, referenceResolver) {
154
158
  const def = types.get(resolvedType);
155
159
  const errors = [];
156
160
  const repair = [];
@@ -193,6 +197,43 @@ export function createEngine({ registryDir, leaseStore } = {}) {
193
197
  }
194
198
  }
195
199
  }
200
+ // patterns: portable string constraints declared by the registry. These
201
+ // are anchored by each pattern author and compiled during registry load.
202
+ for (const [field, pattern] of Object.entries(def.patterns || {})) {
203
+ if (isEmpty(data[field])) continue;
204
+ const values = Array.isArray(data[field]) ? data[field] : [data[field]];
205
+ const expression = new RegExp(pattern, 'u');
206
+ for (const value of values) {
207
+ if (typeof value !== 'string' || !expression.test(value)) {
208
+ errors.push(`Invalid format for field '${field}' on type '${resolvedType}'.`);
209
+ repair.push({ field, issue: `Must match registry pattern '${pattern}'.` });
210
+ }
211
+ }
212
+ }
213
+ if (vaultRoot && def.references) {
214
+ const referenceIssues = validateReferenceConstraints(
215
+ def,
216
+ data,
217
+ (referencePath) => {
218
+ if (referenceResolver) {
219
+ const planned = referenceResolver(referencePath);
220
+ if (planned !== undefined && planned !== null) return planned;
221
+ }
222
+ const target = resolveContainedPath(vaultRoot, referencePath);
223
+ if (!fs.existsSync(target)) return null;
224
+ const stat = fs.lstatSync(target);
225
+ if (stat.isSymbolicLink()) throw new Error('symlinked references are not allowed');
226
+ if (!stat.isFile()) throw new Error('reference target is not a file');
227
+ const content = fs.readFileSync(target, 'utf8');
228
+ return { path: referencePath, content, data: parseDocument(content).data };
229
+ },
230
+ types
231
+ );
232
+ for (const issue of referenceIssues) {
233
+ errors.push(`${issue.field}: ${issue.issue}`);
234
+ repair.push(issue);
235
+ }
236
+ }
196
237
  return { errors, repair };
197
238
  }
198
239
 
@@ -297,7 +338,13 @@ export function createEngine({ registryDir, leaseStore } = {}) {
297
338
  const { data } = parseDocument(envelope.content);
298
339
  resolvedType = data.type || null;
299
340
  if (!resolvedType) { errors.push(`Missing required frontmatter field 'type' in ${envelope.path}.`); repair.push({ field: 'type', issue: 'Frontmatter must declare a type.' }); }
300
- else ({ errors, repair } = validateContent(resolvedType, data, envelope.path));
341
+ else ({ errors, repair } = validateContent(
342
+ resolvedType,
343
+ data,
344
+ envelope.path,
345
+ vaultRoot,
346
+ options.referenceResolver
347
+ ));
301
348
  }
302
349
  if (!errors.length && fs.existsSync(abs) && !envelope.path.endsWith('/index.md')) {
303
350
  const { data: existingData } = parseDocument(fs.readFileSync(abs, 'utf8'));
@@ -305,6 +352,13 @@ export function createEngine({ registryDir, leaseStore } = {}) {
305
352
  errors.push(`Type rewrite refused for ${envelope.path}: existing type '${existingData.type}' cannot be replaced by '${resolvedType}'.`);
306
353
  repair.push({ field: 'type', issue: 'Use a migration controlled by a privileged host; operation writes may not change an existing file type.' });
307
354
  }
355
+ const { data: nextData } = parseDocument(envelope.content);
356
+ for (const field of types.get(resolvedType)?.immutable_fields || []) {
357
+ if (stableStringify(existingData[field]) !== stableStringify(nextData[field])) {
358
+ errors.push(`Operation may not change immutable field '${field}' for ${envelope.path}.`);
359
+ repair.push({ field, issue: 'Use an explicit migration rather than replacing an immutable identity field.' });
360
+ }
361
+ }
308
362
  }
309
363
  // append-type rewrite guard
310
364
  if (!errors.length && isAppendType(types.get(resolvedType)) && fs.existsSync(abs)) {
@@ -326,13 +380,33 @@ export function createEngine({ registryDir, leaseStore } = {}) {
326
380
  const merged = { ...data };
327
381
  for (const [k, v] of Object.entries(envelope.patches)) if (k !== '__body__') merged[k] = v;
328
382
  resolvedType = merged.type || null;
383
+ for (const field of types.get(resolvedType)?.immutable_fields || []) {
384
+ if (Object.prototype.hasOwnProperty.call(envelope.patches, field) &&
385
+ stableStringify(data[field]) !== stableStringify(merged[field])) {
386
+ errors.push(`Patch may not change immutable field '${field}' for ${envelope.path}.`);
387
+ repair.push({ field, issue: 'Use an explicit migration rather than patching an immutable identity field.' });
388
+ }
389
+ }
329
390
  const append = isAppendType(types.get(resolvedType));
330
391
  let newBody = body;
331
392
  if (envelope.patches.__body__ !== undefined) {
332
393
  if (append) newBody = body.replace(/\s+$/, '') + '\n' + envelope.patches.__body__;
333
394
  else newBody = envelope.patches.__body__;
334
395
  }
335
- ({ errors, repair } = resolvedType ? validateContent(resolvedType, merged, envelope.path) : { errors: ["Missing required field 'type'."], repair: [{ field: 'type', issue: 'No type after merge.' }] });
396
+ if (resolvedType) {
397
+ const contentValidation = validateContent(
398
+ resolvedType,
399
+ merged,
400
+ envelope.path,
401
+ vaultRoot,
402
+ options.referenceResolver
403
+ );
404
+ errors.push(...contentValidation.errors);
405
+ repair.push(...contentValidation.repair);
406
+ } else {
407
+ errors.push("Missing required field 'type'.");
408
+ repair.push({ field: 'type', issue: 'No type after merge.' });
409
+ }
336
410
  mergedForCommit = { data: merged, body: newBody };
337
411
  }
338
412
  } else if (envelope.type === 'event') {