@gregiteen/ssss-cli 0.7.0 → 0.7.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.
- package/CHANGELOG.md +21 -0
- package/README.md +10 -10
- package/VERSION +1 -1
- package/conformance/README.md +8 -6
- package/conformance/reference-bundle.ucw.json +19 -2
- package/docs/help/import.md +4 -2
- package/docs/help/leases.md +31 -0
- package/docs/help/overview.md +1 -1
- package/docs/help/portability.md +11 -3
- package/docs/help/runtime.md +3 -3
- package/docs/help/scaffold.md +2 -2
- package/package.json +1 -1
- package/scripts/build-reference-bundle.mjs +1 -1
- package/scripts/cmd-import.mjs +5 -1
- package/scripts/cmd-new.mjs +11 -11
- package/scripts/conformance.mjs +201 -1
- package/scripts/ssss.mjs +2 -2
- package/src/bundle.mjs +103 -14
- package/src/frontmatter.mjs +141 -38
- package/src/registry.mjs +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ All notable changes to the SSSS (Structured Semantic Syntax System) spec will be
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.7.1] - 2026-07-08
|
|
9
|
+
### Fixed
|
|
10
|
+
- Reduced `resource_bound` primitives to requirement declarations in `template`
|
|
11
|
+
and `sale` exports, preventing seller-bound domain, phone, or integration
|
|
12
|
+
values from leaking into bundles.
|
|
13
|
+
- Rejected malformed bundles with missing `files` arrays and unknown
|
|
14
|
+
`required_extensions`.
|
|
15
|
+
- Preserved nested frontmatter maps and arrays during patch serialization.
|
|
16
|
+
- Corrected `ssss import --dry-run` accounting to report `would commit` counts
|
|
17
|
+
while leaving target vaults empty.
|
|
18
|
+
- Generated starter and reference task files now use the valid `pending` task
|
|
19
|
+
status.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- Conformance coverage for resource-bound export reduction, strict bundle
|
|
23
|
+
validation, lease conflict/expiry/unreadable-state behavior, nested
|
|
24
|
+
frontmatter patching, `ssss new` scaffold validation, and dry-run import
|
|
25
|
+
behavior.
|
|
26
|
+
- `docs/help/leases.md` and completed `IMPROVEMENTS` project documentation.
|
|
27
|
+
- Full package content for the `push` skill so skill conformance passes.
|
|
28
|
+
|
|
8
29
|
## [0.7.0] - 2026-07-02
|
|
9
30
|
### Changed
|
|
10
31
|
- **BREAKING: universal frontmatter enforcement** — every SSSS document primitive
|
package/README.md
CHANGED
|
@@ -22,15 +22,15 @@ SSSS is the vendor-neutral standard and reference implementation shared by
|
|
|
22
22
|
- **The `.ucw` bundle format** (§16) and **provisioning contract** (§17) — package a
|
|
23
23
|
vault, then `export → provision → import` it into a fresh tenant, deterministically.
|
|
24
24
|
|
|
25
|
-
This package, `@ssss
|
|
25
|
+
This package, `@gregiteen/ssss-cli`, is **dependency-free**: a host needs neither Zod nor a YAML
|
|
26
26
|
library to be conformant. The reference engine *is* the validator.
|
|
27
27
|
|
|
28
28
|
## Install
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
|
-
npm install -g @ssss
|
|
31
|
+
npm install -g @gregiteen/ssss-cli # the `ssss` command
|
|
32
32
|
# or use it as a library:
|
|
33
|
-
npm install @ssss
|
|
33
|
+
npm install @gregiteen/ssss-cli
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
Requires Node 18+. No runtime dependencies.
|
|
@@ -79,16 +79,16 @@ ssss import festival.ucw.json --vault ./new-tenant \
|
|
|
79
79
|
| `ssss import <bundle>` | Replay a bundle/plan into a vault via the engine (idempotent). |
|
|
80
80
|
| `ssss autolink [dir]` | Generate OKF wiki-links across a vault. |
|
|
81
81
|
| `ssss conformance` | Run the canonical conformance suite (§12). |
|
|
82
|
-
| `ssss help [topic]` | Local docs: `runtime`, `portability`, `bundle`, `provisioning`, … |
|
|
82
|
+
| `ssss help [topic]` | Local docs: `runtime`, `portability`, `bundle`, `provisioning`, `leases`, … |
|
|
83
83
|
|
|
84
84
|
Run `ssss <command> --help` for flags, or `ssss help <topic>` for concepts.
|
|
85
85
|
|
|
86
86
|
## Use as a library
|
|
87
87
|
|
|
88
88
|
```js
|
|
89
|
-
import { createEngine } from '@ssss
|
|
90
|
-
import { exportBundle, validateBundle, provisionBundle, importBundle } from '@ssss
|
|
91
|
-
import { planWorkflowTrigger } from '@ssss
|
|
89
|
+
import { createEngine } from '@gregiteen/ssss-cli/engine';
|
|
90
|
+
import { exportBundle, validateBundle, provisionBundle, importBundle } from '@gregiteen/ssss-cli/bundle';
|
|
91
|
+
import { planWorkflowTrigger } from '@gregiteen/ssss-cli/runtime';
|
|
92
92
|
|
|
93
93
|
const bundle = exportBundle('./my-vault', { profile: 'sale', name: 'Festival in a Box' });
|
|
94
94
|
const { valid, errors } = validateBundle(bundle);
|
|
@@ -115,9 +115,9 @@ const runtimePlan = planWorkflowTrigger({
|
|
|
115
115
|
});
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
-
Exports: `@ssss
|
|
119
|
-
`@ssss
|
|
120
|
-
`@ssss
|
|
118
|
+
Exports: `@gregiteen/ssss-cli` / `@gregiteen/ssss-cli/engine` (Operation Contract engine),
|
|
119
|
+
`@gregiteen/ssss-cli/bundle` (export/provision/import), `@gregiteen/ssss-cli/registry`,
|
|
120
|
+
`@gregiteen/ssss-cli/runtime`, `@gregiteen/ssss-cli/frontmatter`.
|
|
121
121
|
|
|
122
122
|
## Portability — why a vault is safe to sell
|
|
123
123
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.7.
|
|
1
|
+
0.7.1
|
package/conformance/README.md
CHANGED
|
@@ -31,12 +31,14 @@ npx ssss conformance --endpoint <url> --token <pat> # run fixtures against a l
|
|
|
31
31
|
|
|
32
32
|
The runner validates that the fixture file is well-formed and that every
|
|
33
33
|
`expected_response` is internally consistent with the envelope rules. With
|
|
34
|
-
`--engine`, it replays every fixture through the reference engine (`src/engine.mjs`)
|
|
35
|
-
checks workflow runtime planning (`src/runtime.mjs`, spec §11.8),
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
`--engine`, it replays every fixture through the reference engine (`src/engine.mjs`),
|
|
35
|
+
checks workflow runtime planning (`src/runtime.mjs`, spec §11.8), runs regression
|
|
36
|
+
coverage for patch serialization and leases (§6/§7), and round-trips the
|
|
37
|
+
reference bundle (validate → provision → import → re-import, spec §16/§17)
|
|
38
|
+
against a temp vault — proving idempotency, strict bundle shape validation, and
|
|
39
|
+
that no `tenant_private` or seller-bound `resource_bound` value ever lands. With
|
|
40
|
+
`--endpoint`, it POSTs each fixture's `request` to a host's Operation Contract
|
|
41
|
+
endpoint and diffs the response.
|
|
40
42
|
|
|
41
43
|
## Core vs. extension registry
|
|
42
44
|
|
|
@@ -39,6 +39,14 @@
|
|
|
39
39
|
"system": "email",
|
|
40
40
|
"mode": "provision",
|
|
41
41
|
"required": false
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"id": "bind-domain",
|
|
45
|
+
"label": "Bind domain",
|
|
46
|
+
"system": "domains",
|
|
47
|
+
"mode": "provision",
|
|
48
|
+
"required": true,
|
|
49
|
+
"notes": "owns_domain"
|
|
42
50
|
}
|
|
43
51
|
],
|
|
44
52
|
"parameters": [
|
|
@@ -66,11 +74,20 @@
|
|
|
66
74
|
"scope": "workspace",
|
|
67
75
|
"source": "user",
|
|
68
76
|
"required": false
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"key": "domain_registrar",
|
|
80
|
+
"label": "domain registrar",
|
|
81
|
+
"type": "string",
|
|
82
|
+
"scope": "workspace",
|
|
83
|
+
"source": "user",
|
|
84
|
+
"required": false,
|
|
85
|
+
"description": "Satisfies the domain resource requirement field 'registrar'."
|
|
69
86
|
}
|
|
70
87
|
],
|
|
71
88
|
"file_count": 14,
|
|
72
89
|
"provenance": {
|
|
73
|
-
"content_hash": "sha256:
|
|
90
|
+
"content_hash": "sha256:7febd673938a3dc04c068e0bf08097a9783c09e612606d2df9a4db8dcb8907f5",
|
|
74
91
|
"exporter": "@ssss/cli@0.7.0"
|
|
75
92
|
}
|
|
76
93
|
},
|
|
@@ -85,7 +102,7 @@
|
|
|
85
102
|
},
|
|
86
103
|
{
|
|
87
104
|
"path": "domains/festival.md",
|
|
88
|
-
"content": "---\ntype: domain\ntitle: Festival Domain Requirement\ndescription: Domain resource requirement that the buyer binds during provisioning.\ntimestamp: 2026-07-02T00:00:00Z\ndomain_name: REQUIREMENT\nregistrar: REQUIREMENT\nstatus: required\npayment_status: unbound\nx_portability: resource_bound\n---\nThis festival needs a domain. The buyer binds their own at provision (owns_domain)."
|
|
105
|
+
"content": "---\ntype: domain\ntitle: Festival Domain Requirement\ndescription: Domain resource requirement that the buyer binds during provisioning.\ntimestamp: \"2026-07-02T00:00:00Z\"\ndomain_name: REQUIREMENT\nregistrar: REQUIREMENT\nstatus: required\npayment_status: unbound\nx_portability: resource_bound\n---\nThis festival needs a domain. The buyer binds their own at provision (owns_domain)."
|
|
89
106
|
},
|
|
90
107
|
{
|
|
91
108
|
"path": "domains/index.md",
|
package/docs/help/import.md
CHANGED
|
@@ -12,8 +12,10 @@ ssss import --plan plan.json --vault <dir> # replay a plan from `ssss prov
|
|
|
12
12
|
- `--vault <dir>` is required and is created if missing.
|
|
13
13
|
- Without `--plan`, import provisions inline first (resolving `--param` flags); a
|
|
14
14
|
failed provision (unresolved params / dangling links) aborts before any write.
|
|
15
|
-
- `--dry-run` validates every envelope without committing
|
|
16
|
-
|
|
15
|
+
- `--dry-run` validates every envelope without committing and reports
|
|
16
|
+
`would commit` instead of `committed`.
|
|
17
|
+
- The normal summary reports `committed`, `unchanged (idempotent)`, and `failed`
|
|
18
|
+
counts.
|
|
17
19
|
|
|
18
20
|
Run `ssss import --help` for the full flag list.
|
|
19
21
|
See also: `ssss help provisioning`, `ssss provision`.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Leases (spec §7)
|
|
2
|
+
|
|
3
|
+
Leases are file-level write locks checked by the Operation Contract before a
|
|
4
|
+
write commits. They protect paths that are being edited by another actor or host
|
|
5
|
+
process.
|
|
6
|
+
|
|
7
|
+
The reference engine expects lease state in a host-provided `leaseStore`:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
<leaseStore>/<workspace_id>/<sha256(path)>.lease.json
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Each lease file contains:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"lease_id": "lease-123",
|
|
18
|
+
"expires_at": "2026-07-08T23:00:00.000Z"
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Behavior:
|
|
23
|
+
|
|
24
|
+
- If no lease exists, the write can continue.
|
|
25
|
+
- If a non-expired lease exists, the envelope must provide the matching
|
|
26
|
+
`lease_id`.
|
|
27
|
+
- A mismatched or missing `lease_id` fails validation before commit.
|
|
28
|
+
- Expired leases are removed and do not block the write.
|
|
29
|
+
- Unreadable lease state fails closed; the engine refuses to guess.
|
|
30
|
+
|
|
31
|
+
See also: `ssss help roles`, `ssss help conformance`.
|
package/docs/help/overview.md
CHANGED
|
@@ -4,7 +4,7 @@ The **Structured Semantic Syntax System** is a database-free, Markdown-first sch
|
|
|
4
4
|
and mutation contract for AI-agent state. A vault is a directory of Markdown files
|
|
5
5
|
with YAML frontmatter; every file declares a `type` defined in the registry.
|
|
6
6
|
|
|
7
|
-
`@ssss
|
|
7
|
+
`@gregiteen/ssss-cli` is the reference implementation: a dependency-free Operation Contract
|
|
8
8
|
engine, a registry-driven validator, and the `.ucw` bundle/provisioning tooling.
|
|
9
9
|
|
|
10
10
|
## The pieces
|
package/docs/help/portability.md
CHANGED
|
@@ -20,8 +20,16 @@ restrictive of (type default, override) wins.
|
|
|
20
20
|
| `template` | structural + resource_bound | A reusable template, private data stripped. |
|
|
21
21
|
| `sale` | structural + resource_bound | A tradeable business, private data stripped. |
|
|
22
22
|
|
|
23
|
-
> `template` and `sale` exports MUST drop every `tenant_private` primitive
|
|
24
|
-
>
|
|
25
|
-
>
|
|
23
|
+
> `template` and `sale` exports MUST drop every `tenant_private` primitive and
|
|
24
|
+
> reduce every `resource_bound` primitive to a requirement declaration. The
|
|
25
|
+
> seller's actual domain, phone number, provider, or connection value must not
|
|
26
|
+
> appear in the bundle.
|
|
27
|
+
|
|
28
|
+
For resource-bound files, the reference exporter uses the extension registry's
|
|
29
|
+
`resource.binds` metadata to replace bound fields with `REQUIREMENT` and add
|
|
30
|
+
matching provisioning requirements. The reference bundle
|
|
31
|
+
(`conformance/reference-bundle.ucw.json`) proves both guarantees: its `task`
|
|
32
|
+
ticket is dropped from the `sale` export, and its `domain` file carries only
|
|
33
|
+
requirement placeholders.
|
|
26
34
|
|
|
27
35
|
See also: `ssss help bundle`, `ssss help export`.
|
package/docs/help/runtime.md
CHANGED
|
@@ -54,11 +54,11 @@ database canonical.
|
|
|
54
54
|
|
|
55
55
|
## Reference helpers
|
|
56
56
|
|
|
57
|
-
Use `@ssss
|
|
57
|
+
Use `@gregiteen/ssss-cli/runtime` when building a host daemon:
|
|
58
58
|
|
|
59
59
|
```js
|
|
60
|
-
import { createEngine } from '@ssss
|
|
61
|
-
import { planWorkflowTrigger } from '@ssss
|
|
60
|
+
import { createEngine } from '@gregiteen/ssss-cli/engine';
|
|
61
|
+
import { planWorkflowTrigger } from '@gregiteen/ssss-cli/runtime';
|
|
62
62
|
|
|
63
63
|
const plan = planWorkflowTrigger({
|
|
64
64
|
workflowPath: 'workflows/daily-digest/WORKFLOW.md',
|
package/docs/help/scaffold.md
CHANGED
|
@@ -13,7 +13,7 @@ ssss new my-app --with-total-recall --install
|
|
|
13
13
|
|
|
14
14
|
```
|
|
15
15
|
my-app/
|
|
16
|
-
package.json @ssss
|
|
16
|
+
package.json @gregiteen/ssss-cli pinned to this CLI's version tag; npm test = node --test
|
|
17
17
|
vault/ Starter SSSS documents:
|
|
18
18
|
rules/welcome.md (structural)
|
|
19
19
|
workflows/onboarding.md (structural, links [[welcome]])
|
|
@@ -38,7 +38,7 @@ provisions, and re-imports idempotently as a `sale` bundle (§16/§17) — with
|
|
|
38
38
|
| `--with-total-recall` | Add `total-recall-brain` + memory wiring to CLAUDE.md/README. |
|
|
39
39
|
| `--install` | Run `npm install`, Total Recall `init` (if requested), then `npm test`. |
|
|
40
40
|
| `--no-git` | Skip `git init`. |
|
|
41
|
-
| `--ref <git-ref>` | Pin `@ssss
|
|
41
|
+
| `--ref <git-ref>` | Pin `@gregiteen/ssss-cli` to a specific tag/branch/SHA (default: this CLI's version tag). |
|
|
42
42
|
| `--force` | Scaffold into a non-empty directory. |
|
|
43
43
|
|
|
44
44
|
Shelling out (git/npm/npx) uses `execFileSync` with fixed argument vectors — no shell,
|
package/package.json
CHANGED
|
@@ -104,7 +104,7 @@ description: Private support task for an attendee seat-change request.
|
|
|
104
104
|
timestamp: 2026-07-02T00:00:00Z
|
|
105
105
|
priority: high
|
|
106
106
|
category: support
|
|
107
|
-
status:
|
|
107
|
+
status: pending
|
|
108
108
|
---
|
|
109
109
|
Attendee jane@example.com requested a seat change.`,
|
|
110
110
|
};
|
package/scripts/cmd-import.mjs
CHANGED
|
@@ -63,7 +63,11 @@ export async function run(argv) {
|
|
|
63
63
|
const engine = createEngine({ registryDir: flags.registry });
|
|
64
64
|
const result = importBundle(envelopes, vault, engine);
|
|
65
65
|
const failed = result.results.filter((r) => !r.success);
|
|
66
|
-
|
|
66
|
+
if (flags['dry-run']) {
|
|
67
|
+
console.log(`${result.ok ? '✓' : '✗'} import dry-run — ${result.wouldCommit || 0} would commit, ${envelopes.length - (result.wouldCommit || 0)} unchanged (idempotent), ${failed.length} failed → ${vault}`);
|
|
68
|
+
} else {
|
|
69
|
+
console.log(`${result.ok ? '✓' : '✗'} import — ${result.committed} committed, ${envelopes.length - result.committed} unchanged (idempotent), ${failed.length} failed → ${vault}`);
|
|
70
|
+
}
|
|
67
71
|
if (failed.length) {
|
|
68
72
|
for (const r of failed) console.error(` ✗ ${r.path}: ${(r.validation?.errors || []).join('; ')}`);
|
|
69
73
|
process.exit(1);
|
package/scripts/cmd-new.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `ssss new` — scaffold a fresh repo wired for SSSS (and, optionally, Total Recall).
|
|
3
3
|
*
|
|
4
4
|
* Produces a ready-to-run project: a starter `vault/` of core primitives, the
|
|
5
|
-
* `@ssss
|
|
5
|
+
* `@gregiteen/ssss-cli` dependency pinned to this CLI's own version tag, a dependency-free
|
|
6
6
|
* conformance test (`node --test`) that replays the canonical fixtures through the
|
|
7
7
|
* engine AND round-trips the vault as a `sale` bundle, plus a CLAUDE.md describing
|
|
8
8
|
* the conventions. With `--with-total-recall` it also wires the Total Recall
|
|
@@ -31,7 +31,7 @@ const HELP = usage({
|
|
|
31
31
|
['--with-total-recall', 'Also wire the Total Recall memory OS (total-recall-brain).'],
|
|
32
32
|
['--install', 'Run `npm install` after scaffolding (and Total Recall init if requested).'],
|
|
33
33
|
['--no-git', 'Do not run `git init`.'],
|
|
34
|
-
['--ref <git-ref>', `Pin @ssss
|
|
34
|
+
['--ref <git-ref>', `Pin @gregiteen/ssss-cli to this ref (default: v${VERSION}).`],
|
|
35
35
|
['--force', 'Allow scaffolding into a non-empty directory.'],
|
|
36
36
|
],
|
|
37
37
|
examples: [
|
|
@@ -81,7 +81,7 @@ export async function run(argv) {
|
|
|
81
81
|
export: 'ssss export vault --profile sale --out dist/bundle.ucw.json',
|
|
82
82
|
validate: 'ssss conformance --engine',
|
|
83
83
|
},
|
|
84
|
-
dependencies: { '@ssss
|
|
84
|
+
dependencies: { '@gregiteen/ssss-cli': `github:gregiteen/ssss#${ref}` },
|
|
85
85
|
...(withTR ? { devDependencies: { 'total-recall-brain': 'latest' } } : {}),
|
|
86
86
|
};
|
|
87
87
|
W(root, 'package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
@@ -94,7 +94,7 @@ export async function run(argv) {
|
|
|
94
94
|
W(root, 'vault/assistants/concierge.md',
|
|
95
95
|
'---\ntype: assistant\ntitle: Concierge\ndescription: Starter assistant definition for the project.\ntimestamp: 2026-07-02T00:00:00Z\nname: Concierge\n---\n\n# Concierge\n\nA starter assistant definition.\n');
|
|
96
96
|
W(root, 'vault/tasks/first-task.md',
|
|
97
|
-
'---\ntype: task\ntitle: First Task\ndescription: Starter private setup task that demonstrates tenant_private filtering.\ntimestamp: 2026-07-02T00:00:00Z\npriority: normal\ncategory: setup\nstatus:
|
|
97
|
+
'---\ntype: task\ntitle: First Task\ndescription: Starter private setup task that demonstrates tenant_private filtering.\ntimestamp: 2026-07-02T00:00:00Z\npriority: normal\ncategory: setup\nstatus: pending\n---\n\n# First task\n\nThis is `tenant_private` (§5.5) — it is **dropped** from `template`/`sale` exports.\n');
|
|
98
98
|
|
|
99
99
|
// ── conformance test (zero-dependency: node --test) ─────────────────────
|
|
100
100
|
W(root, 'test/ssss-conformance.test.mjs', CONFORMANCE_TEST);
|
|
@@ -107,7 +107,7 @@ export async function run(argv) {
|
|
|
107
107
|
// ── git + install (best-effort, flag-gated, no shell) ───────────────────
|
|
108
108
|
const log = (m) => console.error(m);
|
|
109
109
|
log(`Scaffolded SSSS project → ${root}`);
|
|
110
|
-
log(` @ssss
|
|
110
|
+
log(` @gregiteen/ssss-cli pinned to github:gregiteen/ssss#${ref}`);
|
|
111
111
|
if (withTR) log(' Total Recall wired (total-recall-brain).');
|
|
112
112
|
|
|
113
113
|
if (!flags['no-git'] && !fs.existsSync(path.join(root, '.git'))) {
|
|
@@ -153,11 +153,11 @@ import fs from 'node:fs';
|
|
|
153
153
|
import os from 'node:os';
|
|
154
154
|
import path from 'node:path';
|
|
155
155
|
import { fileURLToPath } from 'node:url';
|
|
156
|
-
import { createEngine } from '@ssss
|
|
157
|
-
import { exportBundle, validateBundle, provisionBundle, importBundle } from '@ssss
|
|
156
|
+
import { createEngine } from '@gregiteen/ssss-cli/engine';
|
|
157
|
+
import { exportBundle, validateBundle, provisionBundle, importBundle } from '@gregiteen/ssss-cli/bundle';
|
|
158
158
|
|
|
159
159
|
const require = createRequire(import.meta.url);
|
|
160
|
-
const { fixtures } = require('@ssss
|
|
160
|
+
const { fixtures } = require('@gregiteen/ssss-cli/conformance/fixtures.json');
|
|
161
161
|
const VAULT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'vault');
|
|
162
162
|
|
|
163
163
|
test('engine implements the canonical Operation Contract (spec §6)', () => {
|
|
@@ -199,7 +199,7 @@ function readme(name, withTR) {
|
|
|
199
199
|
return `# ${name}
|
|
200
200
|
|
|
201
201
|
An [SSSS](https://github.com/gregiteen/ssss)-native project: a Markdown **vault** is
|
|
202
|
-
the source of truth, validated and packaged by the dependency-free \`@ssss
|
|
202
|
+
the source of truth, validated and packaged by the dependency-free \`@gregiteen/ssss-cli\`.
|
|
203
203
|
|
|
204
204
|
## Layout
|
|
205
205
|
|
|
@@ -240,9 +240,9 @@ This repo uses **SSSS** (Structured Semantic Syntax System) as its state contrac
|
|
|
240
240
|
|
|
241
241
|
## Source of truth
|
|
242
242
|
- \`vault/\` is the only source of truth — typed Markdown documents with YAML
|
|
243
|
-
frontmatter. Every file declares a \`type\` defined in the \`@ssss
|
|
243
|
+
frontmatter. Every file declares a \`type\` defined in the \`@gregiteen/ssss-cli\` core registry.
|
|
244
244
|
- Mutate the vault through the standard, not by hand-writing files in bulk:
|
|
245
|
-
\`npx ssss\` for bundle/tenant tooling, or the \`@ssss
|
|
245
|
+
\`npx ssss\` for bundle/tenant tooling, or the \`@gregiteen/ssss-cli/engine\` Operation Contract
|
|
246
246
|
in code.
|
|
247
247
|
|
|
248
248
|
## Portability (§5.5) — the rule that matters
|
package/scripts/conformance.mjs
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import fs from 'node:fs';
|
|
15
15
|
import os from 'node:os';
|
|
16
16
|
import path from 'node:path';
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
17
19
|
import { fileURLToPath } from 'node:url';
|
|
18
20
|
import { createEngine } from '../src/engine.mjs';
|
|
19
21
|
import { validateBundle, provisionBundle, importBundle } from '../src/bundle.mjs';
|
|
@@ -194,6 +196,26 @@ function runBundleConformance() {
|
|
|
194
196
|
check('reference bundle is schema- and hash-valid', valid, errors.join('; '));
|
|
195
197
|
check('sale profile carries no tenant_private file',
|
|
196
198
|
bundle.files.every((f) => parseDocument(f.content).data.type !== 'task' && parseDocument(f.content).data.type !== 'conversation'));
|
|
199
|
+
check('sale profile reduces resource_bound fields to requirement declarations',
|
|
200
|
+
bundle.files.every((f) => {
|
|
201
|
+
const data = parseDocument(f.content).data;
|
|
202
|
+
if (data.type !== 'domain') return true;
|
|
203
|
+
return data.domain_name === 'REQUIREMENT' && data.registrar === 'REQUIREMENT';
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
const missingFilesBundle = JSON.parse(JSON.stringify(bundle));
|
|
207
|
+
delete missingFilesBundle.files;
|
|
208
|
+
const missingFilesValidation = validateBundle(missingFilesBundle, { registryDir: REGISTRY_DIR });
|
|
209
|
+
check('bundle validation rejects missing files array',
|
|
210
|
+
!missingFilesValidation.valid && missingFilesValidation.errors.some((e) => e.includes('bundle.files must be an array')),
|
|
211
|
+
missingFilesValidation.errors.join('; '));
|
|
212
|
+
|
|
213
|
+
const unknownExtensionBundle = JSON.parse(JSON.stringify(bundle));
|
|
214
|
+
unknownExtensionBundle.manifest.required_extensions = ['missing-extension'];
|
|
215
|
+
const unknownExtensionValidation = validateBundle(unknownExtensionBundle, { registryDir: REGISTRY_DIR });
|
|
216
|
+
check('bundle validation rejects unknown required_extensions entries',
|
|
217
|
+
!unknownExtensionValidation.valid && unknownExtensionValidation.errors.some((e) => e.includes("required extension 'missing-extension'")),
|
|
218
|
+
unknownExtensionValidation.errors.join('; '));
|
|
197
219
|
|
|
198
220
|
const prov = provisionBundle(bundle, { workspaceId: 'ws-conf', parameters: { business_name: 'Demo', domain: 'demo.example' } });
|
|
199
221
|
check('provision resolves params + link integrity (no dangling [[links]])', prov.ok,
|
|
@@ -225,6 +247,180 @@ function runBundleConformance() {
|
|
|
225
247
|
return pass === checks.length;
|
|
226
248
|
}
|
|
227
249
|
|
|
250
|
+
function runOperationContractRegressionConformance() {
|
|
251
|
+
const checks = [];
|
|
252
|
+
const check = (name, cond, detail = '') => { checks.push({ name, cond, detail }); };
|
|
253
|
+
|
|
254
|
+
const vault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-op-reg-'));
|
|
255
|
+
const leaseStore = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-lease-'));
|
|
256
|
+
try {
|
|
257
|
+
const engine = createEngine({ registryDir: REGISTRY_DIR, leaseStore });
|
|
258
|
+
const workflowPath = 'workflows/nested.md';
|
|
259
|
+
const createRes = engine.processOperation({
|
|
260
|
+
type: 'operation',
|
|
261
|
+
idempotency_key: 'nested-create',
|
|
262
|
+
workspace_id: 'ws-reg',
|
|
263
|
+
path: workflowPath,
|
|
264
|
+
actor: { role: 'system' },
|
|
265
|
+
content: [
|
|
266
|
+
'---',
|
|
267
|
+
'type: workflow',
|
|
268
|
+
'title: Nested Workflow',
|
|
269
|
+
'description: Exercises nested frontmatter patch serialization.',
|
|
270
|
+
'timestamp: 2026-07-02T00:00:00Z',
|
|
271
|
+
'name: Nested Workflow',
|
|
272
|
+
'triggers:',
|
|
273
|
+
' - type: cron',
|
|
274
|
+
' id: morning',
|
|
275
|
+
' cron: "0 8 * * *"',
|
|
276
|
+
' timezone: "America/Denver"',
|
|
277
|
+
'---',
|
|
278
|
+
'',
|
|
279
|
+
'Body.',
|
|
280
|
+
'',
|
|
281
|
+
].join('\n'),
|
|
282
|
+
}, vault);
|
|
283
|
+
check('nested frontmatter fixture commits before patch', createRes.success === true, JSON.stringify(createRes.validation?.errors || []));
|
|
284
|
+
|
|
285
|
+
const patchRes = engine.processOperation({
|
|
286
|
+
type: 'patch',
|
|
287
|
+
idempotency_key: 'nested-patch',
|
|
288
|
+
workspace_id: 'ws-reg',
|
|
289
|
+
path: workflowPath,
|
|
290
|
+
actor: { role: 'system' },
|
|
291
|
+
patches: { description: 'Patched without dropping triggers.' },
|
|
292
|
+
}, vault);
|
|
293
|
+
const patched = fs.readFileSync(path.join(vault, workflowPath), 'utf8');
|
|
294
|
+
const patchedData = parseDocument(patched).data;
|
|
295
|
+
check('patch preserves nested frontmatter arrays of maps',
|
|
296
|
+
patchRes.success === true && Array.isArray(patchedData.triggers) && patchedData.triggers[0]?.cron === '0 8 * * *',
|
|
297
|
+
patched);
|
|
298
|
+
|
|
299
|
+
const leaseTarget = 'rules/leased.md';
|
|
300
|
+
const leasePathKey = crypto.createHash('sha256').update(leaseTarget).digest('hex');
|
|
301
|
+
const leaseDir = path.join(leaseStore, 'ws-reg');
|
|
302
|
+
fs.mkdirSync(leaseDir, { recursive: true });
|
|
303
|
+
fs.writeFileSync(path.join(leaseDir, `${leasePathKey}.lease.json`), JSON.stringify({
|
|
304
|
+
lease_id: 'lease-ok',
|
|
305
|
+
expires_at: '2999-01-01T00:00:00.000Z',
|
|
306
|
+
}));
|
|
307
|
+
const leasedEnvelope = {
|
|
308
|
+
type: 'operation',
|
|
309
|
+
idempotency_key: 'lease-missing',
|
|
310
|
+
workspace_id: 'ws-reg',
|
|
311
|
+
path: leaseTarget,
|
|
312
|
+
actor: { role: 'system' },
|
|
313
|
+
content: '---\ntype: rule\ntitle: Leased\ndescription: Lease test.\ntimestamp: 2026-07-02T00:00:00Z\nname: Leased\n---\nBody.\n',
|
|
314
|
+
};
|
|
315
|
+
const missingLease = engine.processOperation(leasedEnvelope, vault);
|
|
316
|
+
check('lease conflict rejects missing lease_id', missingLease.success === false && missingLease.validation.errors.some((e) => e.includes('leased')));
|
|
317
|
+
|
|
318
|
+
const mismatchLease = engine.processOperation({ ...leasedEnvelope, idempotency_key: 'lease-mismatch', lease_id: 'wrong' }, vault);
|
|
319
|
+
check('lease conflict rejects mismatched lease_id', mismatchLease.success === false && mismatchLease.validation.errors.some((e) => e.includes('Lease mismatch')));
|
|
320
|
+
|
|
321
|
+
const matchedLease = engine.processOperation({ ...leasedEnvelope, idempotency_key: 'lease-match', lease_id: 'lease-ok' }, vault);
|
|
322
|
+
check('lease allows matching lease_id', matchedLease.success === true, JSON.stringify(matchedLease.validation?.errors || []));
|
|
323
|
+
|
|
324
|
+
const expiredTarget = 'rules/expired.md';
|
|
325
|
+
const expiredPathKey = crypto.createHash('sha256').update(expiredTarget).digest('hex');
|
|
326
|
+
fs.writeFileSync(path.join(leaseDir, `${expiredPathKey}.lease.json`), JSON.stringify({
|
|
327
|
+
lease_id: 'expired',
|
|
328
|
+
expires_at: '2000-01-01T00:00:00.000Z',
|
|
329
|
+
}));
|
|
330
|
+
const expiredLease = engine.processOperation({ ...leasedEnvelope, idempotency_key: 'lease-expired', path: expiredTarget }, vault);
|
|
331
|
+
check('expired lease does not block writes', expiredLease.success === true, JSON.stringify(expiredLease.validation?.errors || []));
|
|
332
|
+
|
|
333
|
+
const unreadableTarget = 'rules/unreadable.md';
|
|
334
|
+
const unreadablePathKey = crypto.createHash('sha256').update(unreadableTarget).digest('hex');
|
|
335
|
+
fs.writeFileSync(path.join(leaseDir, `${unreadablePathKey}.lease.json`), '{not json');
|
|
336
|
+
const unreadableLease = engine.processOperation({ ...leasedEnvelope, idempotency_key: 'lease-unreadable', path: unreadableTarget }, vault);
|
|
337
|
+
check('unreadable lease state fails closed', unreadableLease.success === false && unreadableLease.validation.errors.some((e) => e.includes('unreadable')));
|
|
338
|
+
} finally {
|
|
339
|
+
fs.rmSync(vault, { recursive: true, force: true });
|
|
340
|
+
fs.rmSync(leaseStore, { recursive: true, force: true });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let pass = 0;
|
|
344
|
+
for (const c of checks) {
|
|
345
|
+
if (c.cond) { pass++; console.log(` ✅ ${c.name}`); }
|
|
346
|
+
else console.log(` ❌ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`);
|
|
347
|
+
}
|
|
348
|
+
console.log(`\n ${pass}/${checks.length} operation regression checks passed (§6/§7)`);
|
|
349
|
+
return pass === checks.length;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function runCliSmokeConformance() {
|
|
353
|
+
const checks = [];
|
|
354
|
+
const check = (name, cond, detail = '') => { checks.push({ name, cond, detail }); };
|
|
355
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-cli-smoke-'));
|
|
356
|
+
try {
|
|
357
|
+
const starter = path.join(tmp, 'starter');
|
|
358
|
+
execFileSync('node', [path.join(__dirname, 'ssss.mjs'), 'new', starter, '--no-git'], { stdio: 'ignore' });
|
|
359
|
+
const vault = path.join(starter, 'vault');
|
|
360
|
+
const files = [];
|
|
361
|
+
(function walk(dir) {
|
|
362
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
363
|
+
const abs = path.join(dir, entry.name);
|
|
364
|
+
if (entry.isDirectory()) walk(abs);
|
|
365
|
+
else if (entry.name.endsWith('.md')) files.push(abs);
|
|
366
|
+
}
|
|
367
|
+
})(vault);
|
|
368
|
+
const engine = createEngine({ registryDir: REGISTRY_DIR });
|
|
369
|
+
const failures = [];
|
|
370
|
+
for (const abs of files) {
|
|
371
|
+
const rel = path.relative(vault, abs);
|
|
372
|
+
const res = engine.processOperation({
|
|
373
|
+
type: 'operation',
|
|
374
|
+
idempotency_key: `scaffold-${rel.replace(/[^A-Za-z0-9._-]/g, '_')}`,
|
|
375
|
+
workspace_id: 'ws-scaffold',
|
|
376
|
+
path: rel,
|
|
377
|
+
content: fs.readFileSync(abs, 'utf8'),
|
|
378
|
+
dry_run: true,
|
|
379
|
+
actor: { role: 'system' },
|
|
380
|
+
}, vault);
|
|
381
|
+
if (!res.success) failures.push({ rel, errors: res.validation?.errors || [] });
|
|
382
|
+
}
|
|
383
|
+
check('ssss new starter vault files validate against the registry', files.length > 0 && failures.length === 0, JSON.stringify(failures));
|
|
384
|
+
|
|
385
|
+
const dryRunVault = path.join(tmp, 'dry-run-vault');
|
|
386
|
+
const dryRunOut = execFileSync('node', [
|
|
387
|
+
path.join(__dirname, 'ssss.mjs'),
|
|
388
|
+
'import',
|
|
389
|
+
REFERENCE_BUNDLE,
|
|
390
|
+
'--vault',
|
|
391
|
+
dryRunVault,
|
|
392
|
+
'--param',
|
|
393
|
+
'business_name=Demo',
|
|
394
|
+
'--param',
|
|
395
|
+
'domain=demo.example',
|
|
396
|
+
'--dry-run',
|
|
397
|
+
], { encoding: 'utf8' });
|
|
398
|
+
const dryRunFiles = [];
|
|
399
|
+
if (fs.existsSync(dryRunVault)) {
|
|
400
|
+
(function walkDryRun(dir) {
|
|
401
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
402
|
+
const abs = path.join(dir, entry.name);
|
|
403
|
+
if (entry.isDirectory()) walkDryRun(abs);
|
|
404
|
+
else if (entry.isFile()) dryRunFiles.push(abs);
|
|
405
|
+
}
|
|
406
|
+
})(dryRunVault);
|
|
407
|
+
}
|
|
408
|
+
check('ssss import --dry-run reports would-commit counts and leaves target empty',
|
|
409
|
+
dryRunOut.includes('would commit') && dryRunFiles.length === 0,
|
|
410
|
+
dryRunOut.trim());
|
|
411
|
+
} finally {
|
|
412
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
let pass = 0;
|
|
416
|
+
for (const c of checks) {
|
|
417
|
+
if (c.cond) { pass++; console.log(` ✅ ${c.name}`); }
|
|
418
|
+
else console.log(` ❌ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`);
|
|
419
|
+
}
|
|
420
|
+
console.log(`\n ${pass}/${checks.length} CLI smoke checks passed`);
|
|
421
|
+
return pass === checks.length;
|
|
422
|
+
}
|
|
423
|
+
|
|
228
424
|
/**
|
|
229
425
|
* Runtime conformance (spec §11.8). Proves that a conformant daemon can treat
|
|
230
426
|
* workflow frontmatter as the canonical schedule source, then derive trigger,
|
|
@@ -395,9 +591,13 @@ async function main() {
|
|
|
395
591
|
const engineOk = runAgainstEngine(doc);
|
|
396
592
|
console.log('\nRunning workflow runtime conformance (src/runtime.mjs, §11.8) ...');
|
|
397
593
|
const runtimeOk = runRuntimeConformance();
|
|
594
|
+
console.log('\nRunning operation regression conformance (src/engine.mjs, §6/§7) ...');
|
|
595
|
+
const operationRegressionOk = runOperationContractRegressionConformance();
|
|
398
596
|
console.log('\nRunning bundle/provisioning conformance (src/bundle.mjs, §16/§17) ...');
|
|
399
597
|
const bundleOk = runBundleConformance();
|
|
400
|
-
|
|
598
|
+
console.log('\nRunning CLI smoke conformance (scripts/ssss.mjs) ...');
|
|
599
|
+
const cliSmokeOk = runCliSmokeConformance();
|
|
600
|
+
process.exit(engineOk && runtimeOk && operationRegressionOk && bundleOk && cliSmokeOk ? 0 : 1);
|
|
401
601
|
} else {
|
|
402
602
|
console.log('\nℹ No --endpoint/--engine given; ran structural + registry validation only.');
|
|
403
603
|
console.log(' Reference engine: ssss conformance --engine');
|
package/scripts/ssss.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* @ssss
|
|
3
|
+
* @gregiteen/ssss-cli dispatcher — the `ssss` command.
|
|
4
4
|
*
|
|
5
5
|
* Routes the first positional argument to a subcommand. The bundle/provisioning
|
|
6
6
|
* commands (export/validate/inspect/provision/import/help) export a `run(argv)`
|
|
@@ -21,7 +21,7 @@ const VERSION = (() => {
|
|
|
21
21
|
catch { return '0.0.0'; }
|
|
22
22
|
})();
|
|
23
23
|
|
|
24
|
-
const TOP_LEVEL_HELP = `@ssss
|
|
24
|
+
const TOP_LEVEL_HELP = `@gregiteen/ssss-cli v${VERSION} — Structured Semantic Syntax System
|
|
25
25
|
|
|
26
26
|
Usage: ssss <command> [options]
|
|
27
27
|
|
package/src/bundle.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import fs from 'node:fs';
|
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import crypto from 'node:crypto';
|
|
19
19
|
import { loadRegistries, resolvePortability } from './registry.mjs';
|
|
20
|
-
import { parseDocument } from './frontmatter.mjs';
|
|
20
|
+
import { parseDocument, serializeDocument } from './frontmatter.mjs';
|
|
21
21
|
|
|
22
22
|
const PROFILE_INCLUDES = {
|
|
23
23
|
backup: new Set(['structural', 'tenant_private', 'resource_bound']),
|
|
@@ -27,6 +27,13 @@ const PROFILE_INCLUDES = {
|
|
|
27
27
|
const STEP_SYSTEMS = new Set(['workspace', 'branding', 'email', 'phone', 'domains', 'accounts', 'deployments', 'marketplace', 'tabs', 'automation']);
|
|
28
28
|
const STEP_MODES = new Set(['existing', 'provision', 'install', 'generate', 'configure']);
|
|
29
29
|
const PARAM_SOURCES = new Set(['user', 'llm', 'graph', 'system']);
|
|
30
|
+
const RESOURCE_SYSTEM_BY_KIND = {
|
|
31
|
+
api_connection: 'automation',
|
|
32
|
+
domain: 'domains',
|
|
33
|
+
email: 'email',
|
|
34
|
+
mailbox: 'email',
|
|
35
|
+
phone: 'phone',
|
|
36
|
+
};
|
|
30
37
|
|
|
31
38
|
function isSafeBundlePath(p) {
|
|
32
39
|
if (typeof p !== 'string' || !p || p.includes('\0')) return false;
|
|
@@ -41,6 +48,62 @@ function canonicalFiles(files) {
|
|
|
41
48
|
return sorted.map((f) => ({ path: f.path, content: f.content }));
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
function requirementParameterKey(type, field) {
|
|
52
|
+
return `${type}_${field}`.replace(/[^A-Za-z0-9_]+/g, '_');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resourceFieldParameterAliases(type, field) {
|
|
56
|
+
const aliases = [requirementParameterKey(type, field), field];
|
|
57
|
+
if (type === 'domain' && field === 'domain_name') aliases.push('domain');
|
|
58
|
+
if (type === 'phone_number' && field === 'phone_number') aliases.push('phone');
|
|
59
|
+
return aliases;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ensureRequirementDeclarations(manifest, typeDef, resourceFields) {
|
|
63
|
+
const type = typeDef.type;
|
|
64
|
+
const resource = typeDef.resource || {};
|
|
65
|
+
const existingParams = new Set((manifest.parameters || []).map((p) => p.key));
|
|
66
|
+
for (const field of resourceFields) {
|
|
67
|
+
if (resourceFieldParameterAliases(type, field).some((key) => existingParams.has(key))) continue;
|
|
68
|
+
const key = requirementParameterKey(type, field);
|
|
69
|
+
if (!existingParams.has(key)) {
|
|
70
|
+
manifest.parameters.push({
|
|
71
|
+
key,
|
|
72
|
+
label: `${resource.kind || type} ${field}`.replace(/_/g, ' '),
|
|
73
|
+
type: 'string',
|
|
74
|
+
scope: 'workspace',
|
|
75
|
+
source: 'user',
|
|
76
|
+
required: false,
|
|
77
|
+
description: `Satisfies the ${type} resource requirement field '${field}'.`,
|
|
78
|
+
});
|
|
79
|
+
existingParams.add(key);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const stepId = `bind-${type}`.replace(/[^A-Za-z0-9_-]+/g, '-');
|
|
84
|
+
if (!(manifest.provisioning || []).some((s) => s.id === stepId)) {
|
|
85
|
+
manifest.provisioning.push({
|
|
86
|
+
id: stepId,
|
|
87
|
+
label: `Bind ${resource.kind || type}`,
|
|
88
|
+
system: RESOURCE_SYSTEM_BY_KIND[resource.kind] || 'accounts',
|
|
89
|
+
mode: 'provision',
|
|
90
|
+
required: true,
|
|
91
|
+
notes: resource.provision_relation || 'resource_bound requirement',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function reduceResourceBoundFile(file, typeDef, profile) {
|
|
97
|
+
const binds = typeDef?.resource?.binds;
|
|
98
|
+
if (!Array.isArray(binds) || binds.length === 0) {
|
|
99
|
+
throw new Error(`Cannot export resource_bound '${file.path}' in ${profile}: registry type '${typeDef?.type || '(unknown)'}' does not declare resource.binds.`);
|
|
100
|
+
}
|
|
101
|
+
const { data, body } = parseDocument(file.content);
|
|
102
|
+
const reduced = { ...data, x_portability: 'resource_bound' };
|
|
103
|
+
for (const field of binds) reduced[field] = 'REQUIREMENT';
|
|
104
|
+
return { path: file.path, content: serializeDocument(reduced, body), resourceFields: binds };
|
|
105
|
+
}
|
|
106
|
+
|
|
44
107
|
/** sha256 over the canonical file serialization (§16.3). */
|
|
45
108
|
export function contentHash(files) {
|
|
46
109
|
const pre = JSON.stringify(canonicalFiles(files));
|
|
@@ -75,10 +138,13 @@ export function exportBundle(vaultRoot, opts = {}) {
|
|
|
75
138
|
const includes = PROFILE_INCLUDES[profile];
|
|
76
139
|
if (!includes) throw new Error(`Unknown export profile '${profile}'. Use backup | template | sale.`);
|
|
77
140
|
|
|
78
|
-
const { types } = loadRegistries(registryDir);
|
|
141
|
+
const { types, core } = loadRegistries(registryDir);
|
|
79
142
|
const dropped = [];
|
|
80
143
|
const kept = [];
|
|
81
144
|
const inventory = {};
|
|
145
|
+
const manifestParameters = [...parameters];
|
|
146
|
+
const manifestProvisioning = [...provisioning];
|
|
147
|
+
const manifestDraft = { parameters: manifestParameters, provisioning: manifestProvisioning };
|
|
82
148
|
|
|
83
149
|
for (const file of walkVault(vaultRoot)) {
|
|
84
150
|
const { data } = parseDocument(file.content);
|
|
@@ -86,7 +152,13 @@ export function exportBundle(vaultRoot, opts = {}) {
|
|
|
86
152
|
const typeDef = types.get(type);
|
|
87
153
|
const klass = resolvePortability(typeDef, data);
|
|
88
154
|
if (!includes.has(klass)) { dropped.push({ path: file.path, type, portability: klass }); continue; }
|
|
89
|
-
|
|
155
|
+
if ((profile === 'sale' || profile === 'template') && klass === 'resource_bound') {
|
|
156
|
+
const reduced = reduceResourceBoundFile(file, typeDef, profile);
|
|
157
|
+
kept.push({ path: reduced.path, content: reduced.content });
|
|
158
|
+
ensureRequirementDeclarations(manifestDraft, typeDef, reduced.resourceFields);
|
|
159
|
+
} else {
|
|
160
|
+
kept.push(file);
|
|
161
|
+
}
|
|
90
162
|
if (type) inventory[type] = (inventory[type] || 0) + 1;
|
|
91
163
|
}
|
|
92
164
|
|
|
@@ -94,12 +166,12 @@ export function exportBundle(vaultRoot, opts = {}) {
|
|
|
94
166
|
const manifest = {
|
|
95
167
|
name, description, version,
|
|
96
168
|
exported_at: exportedAt,
|
|
97
|
-
ssss_core_version:
|
|
169
|
+
ssss_core_version: core.spec_version,
|
|
98
170
|
required_extensions: requiredExtensions,
|
|
99
171
|
export_profile: profile,
|
|
100
172
|
primitive_inventory: inventory,
|
|
101
|
-
provisioning,
|
|
102
|
-
parameters,
|
|
173
|
+
provisioning: manifestProvisioning,
|
|
174
|
+
parameters: manifestParameters,
|
|
103
175
|
file_count: files.length,
|
|
104
176
|
provenance: { content_hash: contentHash(files), exporter },
|
|
105
177
|
};
|
|
@@ -114,22 +186,27 @@ export function exportBundle(vaultRoot, opts = {}) {
|
|
|
114
186
|
*/
|
|
115
187
|
export function validateBundle(bundle, opts = {}) {
|
|
116
188
|
const errors = [];
|
|
117
|
-
const { types, core } = loadRegistries(opts.registryDir);
|
|
189
|
+
const { types, core, extensions } = loadRegistries(opts.registryDir);
|
|
118
190
|
const m = bundle && bundle.manifest;
|
|
119
191
|
if (!m) return { valid: false, errors: ['bundle has no manifest'] };
|
|
192
|
+
const files = Array.isArray(bundle.files) ? bundle.files : [];
|
|
120
193
|
|
|
121
194
|
for (const f of core.bundle.manifest_required_fields) {
|
|
122
195
|
if (m[f] === undefined || m[f] === null) errors.push(`manifest missing required field '${f}'`);
|
|
123
196
|
}
|
|
197
|
+
if (!Array.isArray(bundle.files)) errors.push('bundle.files must be an array');
|
|
198
|
+
for (const ext of m.required_extensions || []) {
|
|
199
|
+
if (!extensions.has(ext)) errors.push(`required extension '${ext}' is not loaded`);
|
|
200
|
+
}
|
|
124
201
|
if (!PROFILE_INCLUDES[m.export_profile]) errors.push(`invalid export_profile '${m.export_profile}'`);
|
|
125
202
|
if (m.ssss_core_version && m.ssss_core_version !== core.spec_version) {
|
|
126
203
|
errors.push(`bundle targets ssss_core_version ${m.ssss_core_version}; host is ${core.spec_version}`);
|
|
127
204
|
}
|
|
128
|
-
if (
|
|
129
|
-
errors.push(`file_count ${m.file_count} ≠ actual ${
|
|
205
|
+
if (m.file_count !== files.length) {
|
|
206
|
+
errors.push(`file_count ${m.file_count} ≠ actual ${files.length}`);
|
|
130
207
|
}
|
|
131
208
|
const seenPaths = new Set();
|
|
132
|
-
for (const file of
|
|
209
|
+
for (const file of files) {
|
|
133
210
|
if (!isSafeBundlePath(file.path)) {
|
|
134
211
|
errors.push(`file '${file.path || '(missing)'}' has an unsafe bundle path`);
|
|
135
212
|
continue;
|
|
@@ -142,7 +219,7 @@ export function validateBundle(bundle, opts = {}) {
|
|
|
142
219
|
for (const f of core.bundle.provenance_required_fields) {
|
|
143
220
|
if (m.provenance[f] === undefined) errors.push(`provenance missing '${f}'`);
|
|
144
221
|
}
|
|
145
|
-
const recomputed = contentHash(
|
|
222
|
+
const recomputed = contentHash(files);
|
|
146
223
|
if (m.provenance.content_hash && m.provenance.content_hash !== recomputed) {
|
|
147
224
|
errors.push(`content_hash mismatch: manifest ${m.provenance.content_hash} ≠ recomputed ${recomputed}`);
|
|
148
225
|
}
|
|
@@ -150,7 +227,7 @@ export function validateBundle(bundle, opts = {}) {
|
|
|
150
227
|
// Portability conformance: no file may exceed what the profile permits (§5.5).
|
|
151
228
|
const includes = PROFILE_INCLUDES[m.export_profile];
|
|
152
229
|
if (includes) {
|
|
153
|
-
for (const file of
|
|
230
|
+
for (const file of files) {
|
|
154
231
|
if (!isSafeBundlePath(file.path)) continue;
|
|
155
232
|
const base = path.basename(file.path || '');
|
|
156
233
|
if (base === 'index.md' || base === 'log.md') continue;
|
|
@@ -175,6 +252,17 @@ export function validateBundle(bundle, opts = {}) {
|
|
|
175
252
|
if (!includes.has(klass)) {
|
|
176
253
|
errors.push(`file '${file.path}' is ${klass}, excluded by profile '${m.export_profile}'`);
|
|
177
254
|
}
|
|
255
|
+
if ((m.export_profile === 'sale' || m.export_profile === 'template') && klass === 'resource_bound') {
|
|
256
|
+
const binds = def.resource?.binds || [];
|
|
257
|
+
if (!binds.length) {
|
|
258
|
+
errors.push(`file '${file.path}' is resource_bound but registry type '${data.type}' declares no resource.binds`);
|
|
259
|
+
}
|
|
260
|
+
for (const field of binds) {
|
|
261
|
+
if (data[field] !== 'REQUIREMENT') {
|
|
262
|
+
errors.push(`file '${file.path}' leaks resource_bound field '${field}' in ${m.export_profile} bundle`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
178
266
|
}
|
|
179
267
|
}
|
|
180
268
|
// Provisioning step + parameter vocabularies (§17.2 / §16.5).
|
|
@@ -256,6 +344,7 @@ export function provisionBundle(bundle, opts = {}) {
|
|
|
256
344
|
export function importBundle(plan, vaultRoot, engine) {
|
|
257
345
|
const results = [];
|
|
258
346
|
for (const env of plan) results.push(engine.processOperation(env, vaultRoot));
|
|
259
|
-
const committed = results.filter((r) => r.success && !r.replay).length;
|
|
260
|
-
|
|
347
|
+
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 };
|
|
261
350
|
}
|
package/src/frontmatter.mjs
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* SSSS files are Markdown with a leading `---`-fenced YAML frontmatter block
|
|
5
5
|
* (spec §4.1). The canonical engine avoids a YAML dependency: it parses the
|
|
6
|
-
* subset SSSS actually uses —
|
|
7
|
-
*
|
|
8
|
-
* `type
|
|
6
|
+
* subset SSSS actually uses — scalars, inline `[a, b]` arrays, block `- item`
|
|
7
|
+
* arrays, nested maps, and arrays of shallow maps — which is sufficient to
|
|
8
|
+
* resolve a file's `type`, validate registry fields (§5, §9), and round-trip
|
|
9
|
+
* operation patches without dropping nested frontmatter.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
/** Split a document into its raw frontmatter block and body. */
|
|
@@ -42,43 +43,107 @@ function coerce(raw) {
|
|
|
42
43
|
return v;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
function countIndent(line) {
|
|
47
|
+
const m = line.match(/^ */);
|
|
48
|
+
return m ? m[0].length : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function splitKeyValue(text) {
|
|
52
|
+
const m = text.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
|
|
53
|
+
return m ? { key: m[1], rest: m[2] } : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function nextSignificant(lines, start) {
|
|
57
|
+
for (let i = start; i < lines.length; i++) {
|
|
58
|
+
if (lines[i].trim() && !lines[i].trim().startsWith('#')) return i;
|
|
59
|
+
}
|
|
60
|
+
return -1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseMap(lines, start, indent) {
|
|
51
64
|
const data = {};
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
for (let i = 0; i < lines.length; i++) {
|
|
65
|
+
let i = start;
|
|
66
|
+
while (i < lines.length) {
|
|
55
67
|
const line = lines[i];
|
|
56
|
-
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
if (rest
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
if (!line.trim() || line.trim().startsWith('#')) { i++; continue; }
|
|
69
|
+
const currentIndent = countIndent(line);
|
|
70
|
+
if (currentIndent < indent) break;
|
|
71
|
+
if (currentIndent > indent) { i++; continue; }
|
|
72
|
+
const kv = splitKeyValue(line.slice(indent));
|
|
73
|
+
if (!kv) { i++; continue; }
|
|
74
|
+
if (kv.rest !== '') {
|
|
75
|
+
data[kv.key] = coerce(kv.rest);
|
|
76
|
+
i++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const next = nextSignificant(lines, i + 1);
|
|
81
|
+
if (next < 0 || countIndent(lines[next]) <= indent) {
|
|
82
|
+
data[kv.key] = {};
|
|
83
|
+
i++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const childIndent = countIndent(lines[next]);
|
|
87
|
+
if (lines[next].slice(childIndent).startsWith('- ')) {
|
|
88
|
+
const parsed = parseList(lines, next, childIndent);
|
|
89
|
+
data[kv.key] = parsed.value;
|
|
90
|
+
i = parsed.next;
|
|
91
|
+
} else {
|
|
92
|
+
const parsed = parseMap(lines, next, childIndent);
|
|
93
|
+
data[kv.key] = parsed.value;
|
|
94
|
+
i = parsed.next;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { value: data, next: i };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseList(lines, start, indent) {
|
|
101
|
+
const items = [];
|
|
102
|
+
let i = start;
|
|
103
|
+
while (i < lines.length) {
|
|
104
|
+
const line = lines[i];
|
|
105
|
+
if (!line.trim() || line.trim().startsWith('#')) { i++; continue; }
|
|
106
|
+
const currentIndent = countIndent(line);
|
|
107
|
+
if (currentIndent < indent) break;
|
|
108
|
+
if (currentIndent > indent) { i++; continue; }
|
|
109
|
+
const item = line.slice(indent).match(/^-\s*(.*)$/);
|
|
110
|
+
if (!item) break;
|
|
111
|
+
const raw = item[1];
|
|
112
|
+
const kv = splitKeyValue(raw);
|
|
113
|
+
if (kv) {
|
|
114
|
+
const obj = { [kv.key]: kv.rest === '' ? {} : coerce(kv.rest) };
|
|
115
|
+
i++;
|
|
116
|
+
const next = nextSignificant(lines, i);
|
|
117
|
+
if (next >= 0 && countIndent(lines[next]) > indent) {
|
|
118
|
+
const parsed = parseMap(lines, next, countIndent(lines[next]));
|
|
119
|
+
Object.assign(obj, parsed.value);
|
|
120
|
+
i = parsed.next;
|
|
121
|
+
}
|
|
122
|
+
items.push(obj);
|
|
123
|
+
} else if (raw === '') {
|
|
124
|
+
const next = nextSignificant(lines, i + 1);
|
|
125
|
+
if (next >= 0 && countIndent(lines[next]) > indent) {
|
|
126
|
+
const parsed = parseMap(lines, next, countIndent(lines[next]));
|
|
127
|
+
items.push(parsed.value);
|
|
128
|
+
i = parsed.next;
|
|
129
|
+
} else {
|
|
130
|
+
items.push('');
|
|
131
|
+
i++;
|
|
74
132
|
}
|
|
75
|
-
data[key] = isList ? items : {};
|
|
76
|
-
i = isList ? j - 1 : i;
|
|
77
133
|
} else {
|
|
78
|
-
|
|
134
|
+
items.push(coerce(raw));
|
|
135
|
+
i++;
|
|
79
136
|
}
|
|
80
137
|
}
|
|
81
|
-
return
|
|
138
|
+
return { value: items, next: i };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Parse a frontmatter block (the inner YAML, no fences) into an object.
|
|
143
|
+
*/
|
|
144
|
+
export function parseFrontmatter(fm) {
|
|
145
|
+
if (!fm) return {};
|
|
146
|
+
return parseMap(fm.split('\n'), 0, 0).value;
|
|
82
147
|
}
|
|
83
148
|
|
|
84
149
|
/** Parse a full document (with fences) into { data, body }. */
|
|
@@ -96,13 +161,51 @@ function serializeValue(v) {
|
|
|
96
161
|
return s;
|
|
97
162
|
}
|
|
98
163
|
|
|
164
|
+
function serializeEntry(lines, key, value, indent = 0) {
|
|
165
|
+
const pad = ' '.repeat(indent);
|
|
166
|
+
if (Array.isArray(value)) {
|
|
167
|
+
if (!value.length) {
|
|
168
|
+
lines.push(`${pad}${key}: []`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
lines.push(`${pad}${key}:`);
|
|
172
|
+
for (const item of value) {
|
|
173
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
174
|
+
const entries = Object.entries(item);
|
|
175
|
+
if (!entries.length) {
|
|
176
|
+
lines.push(`${pad} - {}`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const [first, ...rest] = entries;
|
|
180
|
+
if (first[1] && typeof first[1] === 'object' && !Array.isArray(first[1])) {
|
|
181
|
+
lines.push(`${pad} - ${first[0]}:`);
|
|
182
|
+
serializeObject(lines, first[1], indent + 6);
|
|
183
|
+
} else {
|
|
184
|
+
lines.push(`${pad} - ${first[0]}: ${serializeValue(first[1])}`);
|
|
185
|
+
}
|
|
186
|
+
for (const [childKey, childValue] of rest) serializeEntry(lines, childKey, childValue, indent + 4);
|
|
187
|
+
} else {
|
|
188
|
+
lines.push(`${pad} - ${serializeValue(item)}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (value && typeof value === 'object') {
|
|
194
|
+
lines.push(`${pad}${key}:`);
|
|
195
|
+
serializeObject(lines, value, indent + 2);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
lines.push(`${pad}${key}: ${serializeValue(value)}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function serializeObject(lines, obj, indent) {
|
|
202
|
+
for (const [k, v] of Object.entries(obj)) serializeEntry(lines, k, v, indent);
|
|
203
|
+
}
|
|
204
|
+
|
|
99
205
|
/** Serialize a frontmatter object + body back into an SSSS document. */
|
|
100
206
|
export function serializeDocument(data, body) {
|
|
101
207
|
const lines = ['---'];
|
|
102
|
-
|
|
103
|
-
if (v && typeof v === 'object' && !Array.isArray(v)) continue; // skip nested maps we can't round-trip
|
|
104
|
-
lines.push(`${k}: ${serializeValue(v)}`);
|
|
105
|
-
}
|
|
208
|
+
serializeObject(lines, data, 0);
|
|
106
209
|
lines.push('---');
|
|
107
210
|
const b = body == null ? '' : (body.startsWith('\n') ? body : `\n${body}`);
|
|
108
211
|
return lines.join('\n') + b;
|
package/src/registry.mjs
CHANGED
|
@@ -20,11 +20,12 @@ const APPEND_TYPES = new Set(['conversation', 'run']);
|
|
|
20
20
|
/**
|
|
21
21
|
* Load the core registry and merge extension registries.
|
|
22
22
|
* @param {string} [registryDir] - directory containing core.json + extensions/
|
|
23
|
-
* @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object }}
|
|
23
|
+
* @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object, extensions: Set<string> }}
|
|
24
24
|
*/
|
|
25
25
|
export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
|
|
26
26
|
const corePath = path.join(registryDir, 'core.json');
|
|
27
27
|
const core = JSON.parse(fs.readFileSync(corePath, 'utf8'));
|
|
28
|
+
const extensions = new Set();
|
|
28
29
|
|
|
29
30
|
const types = new Map();
|
|
30
31
|
for (const [name, def] of Object.entries(core.document_primitives || {})) {
|
|
@@ -35,6 +36,7 @@ export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
|
|
|
35
36
|
if (fs.existsSync(extDir)) {
|
|
36
37
|
for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json'))) {
|
|
37
38
|
const ext = JSON.parse(fs.readFileSync(path.join(extDir, file), 'utf8'));
|
|
39
|
+
if (ext.registry) extensions.add(ext.registry);
|
|
38
40
|
for (const [name, def] of Object.entries(ext.document_primitives || {})) {
|
|
39
41
|
if (types.has(name) && types.get(name).registry === 'core') continue; // core wins; extensions MUST NOT redefine
|
|
40
42
|
types.set(name, { ...def, type: name, registry: ext.registry || file });
|
|
@@ -44,7 +46,7 @@ export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
|
|
|
44
46
|
|
|
45
47
|
const contractTypes = new Set(Object.keys(core.contract_primitives || {}));
|
|
46
48
|
const portabilityClasses = Object.keys(core.portability?.classes || {});
|
|
47
|
-
return { types, contractTypes, portabilityClasses, core };
|
|
49
|
+
return { types, contractTypes, portabilityClasses, core, extensions };
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
/** Is this document type append-only (events appended, never rewritten)? */
|