@gregiteen/ssss-cli 0.7.1 → 0.9.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/CHANGELOG.md +42 -4
- package/README.md +19 -4
- package/VERSION +1 -1
- package/conformance/README.md +11 -7
- package/conformance/fixtures.json +24 -2
- package/conformance/reference-bundle.ucw.json +2 -2
- package/docs/help/bundle.md +6 -2
- package/docs/help/import.md +2 -0
- package/docs/help/overview.md +8 -4
- package/docs/help/semantic.md +38 -0
- package/docs/ssss-spec.md +67 -7
- package/package.json +27 -3
- package/registry/core.json +36 -3
- package/scripts/audit-registry-field-usage.mjs +10 -1
- package/scripts/build-reference-bundle.mjs +4 -1
- package/scripts/cmd-adapter.mjs +29 -0
- package/scripts/cmd-export.mjs +3 -3
- package/scripts/cmd-import.mjs +1 -1
- package/scripts/cmd-migrate.mjs +26 -0
- package/scripts/cmd-new.mjs +5 -5
- package/scripts/cmd-primitive.mjs +48 -0
- package/scripts/cmd-registry.mjs +25 -0
- package/scripts/cmd-semantic.mjs +86 -0
- package/scripts/conformance-09.mjs +199 -0
- package/scripts/conformance.mjs +320 -28
- package/scripts/ssss.mjs +10 -0
- package/skills/ssss/SKILL.md +221 -0
- package/skills/ssss/evals/evals.json +37 -0
- package/skills/ssss/references/contract-reference.md +95 -0
- package/skills/ssss/scripts/validate-schema.mjs +215 -0
- package/skills/ssss/subagents/audit-ssss-contract.md +22 -0
- package/src/authorization.mjs +61 -0
- package/src/bundle.mjs +171 -35
- package/src/engine.mjs +101 -429
- package/src/events.mjs +90 -0
- package/src/guard.mjs +32 -0
- package/src/http.mjs +26 -0
- package/src/idempotency.mjs +55 -0
- package/src/kernel.mjs +260 -0
- package/src/leases.mjs +125 -0
- package/src/primitive.mjs +227 -0
- package/src/projections.mjs +62 -0
- package/src/registry.mjs +408 -17
- package/src/semantic.mjs +210 -0
- package/src/ui.mjs +100 -0
- package/src/validator.mjs +87 -0
- package/src/vfs.mjs +203 -0
- package/types/kernel.d.ts +48 -0
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 {
|
|
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 =
|
|
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
|
+
}));
|
|
160
|
+
|
|
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
|
+
}
|
|
148
182
|
|
|
149
|
-
for (const file of
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
231
|
-
|
|
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 } =
|
|
306
|
+
const { data } = document;
|
|
235
307
|
const def = types.get(data.type);
|
|
236
308
|
if (!def) {
|
|
237
|
-
errors.push(`file '${
|
|
309
|
+
errors.push(`file '${document.path}' declares unknown type '${data.type || '(missing)'}'`);
|
|
238
310
|
continue;
|
|
239
311
|
}
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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 '${
|
|
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 '${
|
|
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 '${
|
|
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
|
|
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
|
|
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
|
}
|
|
@@ -341,10 +429,58 @@ export function provisionBundle(bundle, opts = {}) {
|
|
|
341
429
|
* import(plan, target) → replay (§17.1). Runs each envelope through the engine
|
|
342
430
|
* (§6.3). Idempotent: each envelope's idempotency_key makes a re-run a no-op.
|
|
343
431
|
*/
|
|
344
|
-
export function importBundle(plan, vaultRoot, engine) {
|
|
432
|
+
export async 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 = await Promise.all(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(await engine.processOperation(env, vaultRoot, operationOptions));
|
|
347
484
|
const committed = results.filter((r) => r.success && !r.replay && !r.dry_run).length;
|
|
348
|
-
|
|
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
|
}
|