mirror of
https://github.com/logos-co/logos-package.git
synced 2026-08-27 10:11:10 +00:00
feat: suport provides field for intents
This commit is contained in:
@@ -44,7 +44,7 @@ run "$LGX" manifest greeter.lgx | tee m1.txt
|
||||
grep -q "greeter" m1.txt
|
||||
grep -q "0.0.1" m1.txt
|
||||
grep -q "Manifest ver.:" m1.txt
|
||||
grep -q "0.4.0" m1.txt
|
||||
grep -q "0.5.0" m1.txt
|
||||
grep -q "Variants: (none)" m1.txt
|
||||
grep -q "Signed: no" m1.txt
|
||||
|
||||
|
||||
+49
-7
@@ -55,11 +55,11 @@ package.lgx (tar.gz)
|
||||
|
||||
### Manifest Schema
|
||||
|
||||
The current manifest schema is `0.4.0`. It is a UTF-8 encoded JSON file with the following required fields:
|
||||
The current manifest schema is `0.5.0`. It is a UTF-8 encoded JSON file with the following required fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifestVersion": "0.4.0",
|
||||
"manifestVersion": "0.5.0",
|
||||
"name": "package-name",
|
||||
"version": "1.2.3",
|
||||
"description": "Package description",
|
||||
@@ -75,7 +75,10 @@ The current manifest schema is `0.4.0`. It is a UTF-8 encoded JSON file with the
|
||||
"main": {
|
||||
"linux-amd64": "path/to/main.so",
|
||||
"darwin-arm64": "path/to/main.dylib"
|
||||
}
|
||||
},
|
||||
"provides": [
|
||||
{"intent": "chat.group.open"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -94,8 +97,9 @@ The current manifest schema is `0.4.0`. It is a UTF-8 encoded JSON file with the
|
||||
| `dependencies` | array | List of dependency entries — see *Dependency entries* below | Runtime needs |
|
||||
| `main` | object | Map of variant name → relative path to entry point (e.g ) `"linux-amd64": "path/to/main.so"` means `linux-amd64/path/to/main.so` | Entry point resolution |
|
||||
| `display_name` | string | *Optional.* Human-readable label shown by UI consumers (Package Manager, App Manager) and CLI tools (`lm metadata`, `lgx manifest`). Falls back to `name` when absent. | Display/branding |
|
||||
| `provides` | array | *Optional.* Intents this package can service — see *Provided intents* below. Absent ⇒ the package services none. | Capability discovery |
|
||||
|
||||
All fields except `display_name` are required to ensure consistent metadata for hosts/registries and applications.
|
||||
All fields except `display_name` and `provides` are required to ensure consistent metadata for hosts/registries and applications.
|
||||
|
||||
#### Dependency entries
|
||||
|
||||
@@ -109,9 +113,47 @@ Each element of the `dependencies` array is one of:
|
||||
|
||||
`lgx verify` syntactically validates that `version` parses as a semver range and that `signer` matches the `did:jwk:` shape. Semantic matching (does the constraint resolve to a real candidate?) is the responsibility of the resolver in `logos-package-downloader`.
|
||||
|
||||
#### Provided intents
|
||||
|
||||
At `manifestVersion` `0.5.0`+, `provides` lists the **app-to-app intents** the package
|
||||
can service, e.g. `chat.group.open`. Each element is one of:
|
||||
|
||||
- **Object** with `intent` (string, required) — the intent name.
|
||||
- **Plain string**, accepted on read and equivalent to `{"intent": <string>}`.
|
||||
|
||||
Entries are **normalized to the object form on write**, so a reader never sees two
|
||||
shapes. Entries that are neither a string nor an object carrying a string `intent`,
|
||||
and entries whose `intent` is empty, are skipped rather than failing the parse. The
|
||||
array is copied from the reference package by `lgx merge`, so a multi-variant package
|
||||
keeps the capability claim of the variants it was built from.
|
||||
|
||||
**Names only.** The author's `metadata.json` may also describe each intent's payload
|
||||
shape (`provides[].params`); none of that is carried here. The manifest copy exists to
|
||||
answer one question, asked *before* a package is installed: "which installable package
|
||||
provides X?" — which the name alone answers. The shell enforces the payload shape
|
||||
against the installed `metadata.json`, which stays the source of truth for it.
|
||||
|
||||
**Why the signed manifest rather than only `metadata.json`.** `metadata.json` is
|
||||
unsigned and only readable once the package is on disk. Putting the claim in
|
||||
`manifest.json` covers it by `manifest.sig`, so a package cannot claim a capability it
|
||||
was not published with, and makes it legible to a catalog or registry that has the
|
||||
manifest but has never unpacked — let alone installed — the package.
|
||||
|
||||
**Declaration, not authorization.** `provides` says what a package *can* service. It
|
||||
grants nothing: resolution among candidates, user consent and dispatch are the shell's
|
||||
(see `logos-basecamp`'s `IntentBroker`), and `lgx` neither validates intent names
|
||||
against a registry nor checks that the payload implements them.
|
||||
|
||||
#### Schema version compatibility
|
||||
|
||||
Tooling reads both `manifestVersion: "0.2.x"` and `manifestVersion: "0.3.x"`. Packages produced by `lgx create` use `0.3.0`. A 0.2.0 manifest with plain-string dependencies round-trips unchanged through tooling — strings are emitted as strings, object-form entries are emitted as objects. Bumping the major version (1.x.x) is reserved for future breaking changes.
|
||||
Tooling reads `manifestVersion` `0.2.x` through `0.5.x`; packages produced by `lgx create`
|
||||
use `0.5.0`. Every field added across those versions is **optional**, so compatibility runs
|
||||
both ways: an older client reading a newer manifest ignores what it does not recognize
|
||||
rather than failing, which is why the check is on the major version alone. A 0.2.0 manifest
|
||||
with plain-string dependencies round-trips unchanged through tooling — strings are emitted
|
||||
as strings, object-form entries are emitted as objects. Two contracts are gated on the minor
|
||||
version rather than applied retroactively: the *icon contract* (`0.4.0`+) and `provides`
|
||||
(`0.5.0`+). Bumping the major version (1.x.x) is reserved for future breaking changes.
|
||||
|
||||
### Icon Contract
|
||||
|
||||
@@ -152,8 +194,8 @@ is asserted to be complete.
|
||||
**Backward compatibility.** Manifests below `0.4.0` are **exempt**. `icon: ""`
|
||||
was legal at `0.3.0` (it is what `lgx create` defaulted to), so applying the
|
||||
rule unconditionally would make every already-published package fail
|
||||
verification and become uninstallable. Tooling reads `0.2.x`, `0.3.x` and
|
||||
`0.4.x`; only `0.4.0`+ carries the icon contract.
|
||||
verification and become uninstallable. Tooling reads `0.2.x` through `0.5.x`;
|
||||
only `0.4.0`+ carries the icon contract.
|
||||
|
||||
### `ui_qml` Contract
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ sections:
|
||||
- title: "Read the fresh manifest"
|
||||
text: |
|
||||
`lgx manifest` prints the embedded `manifest.json` in human-readable form. A
|
||||
freshly created package starts at version `0.0.1`, manifest schema `0.4.0`, with
|
||||
freshly created package starts at version `0.0.1`, manifest schema `0.5.0`, with
|
||||
no type, no variants, and no signature yet.
|
||||
run: "./lgx/bin/lgx manifest greeter.lgx"
|
||||
code_block: "lgx manifest greeter.lgx"
|
||||
@@ -112,7 +112,7 @@ sections:
|
||||
- "greeter"
|
||||
- "0.0.1"
|
||||
- "Manifest ver.:"
|
||||
- "0.4.0"
|
||||
- "0.5.0"
|
||||
- "Variants: (none)"
|
||||
- "Signed: no"
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ link the result as `./lgx`, so the binary lands at `./lgx/bin/lgx`.
|
||||
|
||||
```bash
|
||||
# From inside the clone this is simply: nix build '.#lgx' -o lgx
|
||||
nix build 'github:logos-co/logos-package/43378dab2ffc569a28f144d3f62a0f089c3580df#lgx' -o lgx
|
||||
nix build 'github:logos-co/logos-package/0a1311f809887715b4b6a21820c93591314fb573#lgx' -o lgx
|
||||
```
|
||||
|
||||
The `-o lgx` flag names the result symlink, so the executable is at `./lgx/bin/lgx`.
|
||||
@@ -108,7 +108,7 @@ sensible defaults plus an empty `variants/` directory — to `<name>.lgx`.
|
||||
### 3.2 Read the fresh manifest
|
||||
|
||||
`lgx manifest` prints the embedded `manifest.json` in human-readable form. A
|
||||
freshly created package starts at version `0.0.1`, manifest schema `0.4.0`, with
|
||||
freshly created package starts at version `0.0.1`, manifest schema `0.5.0`, with
|
||||
no type, no variants, and no signature yet.
|
||||
|
||||
```bash
|
||||
|
||||
@@ -55,7 +55,7 @@ detail; here they are just setup.)
|
||||
|
||||
```bash
|
||||
# From inside the clone this is simply: nix build '.#lgx' -o lgx
|
||||
nix build 'github:logos-co/logos-package/43378dab2ffc569a28f144d3f62a0f089c3580df#lgx' -o lgx
|
||||
nix build 'github:logos-co/logos-package/0a1311f809887715b4b6a21820c93591314fb573#lgx' -o lgx
|
||||
```
|
||||
|
||||
### 1.2 Create and populate greeter.lgx
|
||||
|
||||
@@ -142,6 +142,7 @@ int MergeCommand::execute(const std::vector<std::string>& args) {
|
||||
mergedManifest.icon = refManifest.icon;
|
||||
mergedManifest.view = refManifest.view;
|
||||
mergedManifest.dependencies = refManifest.dependencies;
|
||||
mergedManifest.provides = refManifest.provides;
|
||||
|
||||
// Lift the root-level icon from the reference package. At
|
||||
// manifestVersion 0.4.0+ the icon is variant-independent and lives once
|
||||
|
||||
+41
-3
@@ -178,6 +178,28 @@ std::optional<Manifest> Manifest::fromJson(const std::string& jsonStr) {
|
||||
m.view = j["view"].get<std::string>();
|
||||
}
|
||||
|
||||
// "provides" — optional. Accepts both the object form used in the
|
||||
// author's metadata.json ([{"intent": "a.b"}]) and a bare string array,
|
||||
if (j.contains("provides")) {
|
||||
if (!j["provides"].is_array()) {
|
||||
lastError_ = "Invalid 'provides' field (must be an array)";
|
||||
return std::nullopt;
|
||||
}
|
||||
for (const auto& entry : j["provides"]) {
|
||||
ProvidedIntent p;
|
||||
if (entry.is_string()) {
|
||||
p.intent = entry.get<std::string>();
|
||||
} else if (entry.is_object() && entry.contains("intent")
|
||||
&& entry["intent"].is_string()) {
|
||||
p.intent = entry["intent"].get<std::string>();
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!p.intent.empty()) m.provides.push_back(p);
|
||||
}
|
||||
}
|
||||
|
||||
// "display_name" — optional human-readable label.
|
||||
if (j.contains("display_name")) {
|
||||
if (!j["display_name"].is_string()) {
|
||||
@@ -246,6 +268,19 @@ std::string Manifest::toJson() const {
|
||||
if (!displayName.empty()) {
|
||||
j["display_name"] = displayName;
|
||||
}
|
||||
// Objects rather than bare strings, even though `intent` is the only field.
|
||||
// One shape on the wire, and adding a field later costs no shape change and
|
||||
// no manifest version bump. A bare string on the way IN is normalised up to
|
||||
// this form, so readers never see two shapes.
|
||||
if (!provides.empty()) {
|
||||
json providesArr = json::array();
|
||||
for (const auto& p : provides) {
|
||||
json entry = json::object();
|
||||
entry["intent"] = p.intent;
|
||||
providesArr.push_back(std::move(entry));
|
||||
}
|
||||
j["provides"] = std::move(providesArr);
|
||||
}
|
||||
|
||||
// Serialize with 2-space indent, sorted keys
|
||||
return j.dump(2);
|
||||
@@ -413,9 +448,12 @@ bool Manifest::isVersionSupported(const std::string& version) {
|
||||
|
||||
// Currently only major version 0 is supported. Within 0.x we accept
|
||||
// 0.2.* (legacy plain-string dependencies), 0.3.* (richer dependencies
|
||||
// with optional version range + signer DID) and 0.4.* (root-level
|
||||
// assets/ slot); see the Dependency parsing in fromJson() for the
|
||||
// compatibility shim and requiresIconContract() for the icon gate.
|
||||
// with optional version range + signer DID), 0.4.* (root-level assets/
|
||||
// slot) and 0.5.* (`provides`); see the Dependency parsing in fromJson()
|
||||
// for the compatibility shim and requiresIconContract() for the icon gate.
|
||||
// Every addition so far has been an OPTIONAL field, so an older client
|
||||
// reading a newer manifest ignores what it does not know rather than
|
||||
// failing — which is why this stays a major-version check.
|
||||
return major == "0";
|
||||
}
|
||||
|
||||
|
||||
+24
-5
@@ -54,13 +54,25 @@ struct Dependency {
|
||||
/**
|
||||
* Manifest represents the manifest.json file in an LGX package.
|
||||
*/
|
||||
// An intent a package can service.
|
||||
//
|
||||
// NAME ONLY. The author's metadata.json may also describe the payload shape
|
||||
// (`provides[].params`), and the shell enforces that against the INSTALLED
|
||||
// metadata.json — but none of it is carried here. The manifest copy exists for
|
||||
// one question, asked before a package is installed: "which installable package
|
||||
// provides X?" That is answered by the name alone.
|
||||
struct ProvidedIntent {
|
||||
std::string intent;
|
||||
};
|
||||
|
||||
class Manifest {
|
||||
public:
|
||||
// Current manifest version. Bumped to 0.4.0 for the root-level assets/
|
||||
// slot: `icon` now points at a variant-independent asset (assets/icon.png)
|
||||
// that is exactly 256x256 PNG, so hosts can display it without unpacking
|
||||
// a platform build. 0.2.x and 0.3.x manifests are still readable.
|
||||
static constexpr const char* CURRENT_VERSION = "0.4.0";
|
||||
// Current manifest version. Bumped to 0.5.0 for `provides`: the intents a
|
||||
// package can service. It lives here, in the SIGNED manifest, rather than
|
||||
// only in the unsigned metadata.json on disk, so the capability claim is
|
||||
// covered by the package signature and is legible to a catalog before the
|
||||
// package is installed. 0.2.x-0.4.x manifests are still readable.
|
||||
static constexpr const char* CURRENT_VERSION = "0.5.0";
|
||||
|
||||
// Canonical in-package icon location for 0.4.0+. The author's
|
||||
// metadata.json path stays free-form; the bundler normalises to this.
|
||||
@@ -110,6 +122,13 @@ public:
|
||||
|
||||
// human-readable label; consumers fall back to `name` when unset.
|
||||
std::string displayName;
|
||||
|
||||
// Intents this package can service, e.g. "chat.group.open". Names only.
|
||||
// Copied from the author's metadata.json at bundle time. Carried here so a
|
||||
// CATALOG can answer "which installable package provides X?" without the
|
||||
// package being installed — the registry inside a running shell reads the
|
||||
// installed metadata.json, but nothing outside can.
|
||||
std::vector<ProvidedIntent> provides;
|
||||
|
||||
// Main mapping: variant -> relative path to entry point
|
||||
std::map<std::string, std::string> main;
|
||||
|
||||
@@ -243,6 +243,145 @@ TEST(ManifestTest, ToJson_RoundtripPreservesDisplayName) {
|
||||
EXPECT_EQ(parsed->displayName, "Friendly Label");
|
||||
}
|
||||
|
||||
TEST(ManifestTest, Provides_AcceptsBothAuthorAndWireForms) {
|
||||
// The author's metadata.json uses objects (because `uses` needs room for
|
||||
// cardinality); a hand-written manifest is likely to use bare strings.
|
||||
// Both parse, and anything without an "intent" string is skipped rather
|
||||
// than failing the package.
|
||||
std::string json = R"({
|
||||
"manifestVersion": "0.5.0",
|
||||
"name": "chat_ui",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"type": "ui_qml",
|
||||
"category": "",
|
||||
"icon": "",
|
||||
"view": "Main.qml",
|
||||
"dependencies": [],
|
||||
"provides": [{"intent": "chat.group.open"}, "wallet.sign", {"noIntentKey": 1}]
|
||||
})";
|
||||
|
||||
auto m = Manifest::fromJson(json);
|
||||
ASSERT_TRUE(m.has_value());
|
||||
ASSERT_EQ(m->provides.size(), 2u);
|
||||
EXPECT_EQ(m->provides[0].intent, "chat.group.open");
|
||||
EXPECT_EQ(m->provides[1].intent, "wallet.sign");
|
||||
}
|
||||
|
||||
TEST(ManifestTest, Provides_EmitsTheObjectFormOnWrite) {
|
||||
std::string json = R"({
|
||||
"manifestVersion": "0.5.0",
|
||||
"name": "chat_ui",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"type": "ui_qml",
|
||||
"category": "",
|
||||
"icon": "",
|
||||
"view": "Main.qml",
|
||||
"dependencies": [],
|
||||
"provides": [{"intent": "chat.group.open"}]
|
||||
})";
|
||||
|
||||
auto m = Manifest::fromJson(json);
|
||||
ASSERT_TRUE(m.has_value());
|
||||
|
||||
// Objects on the wire, so the parameter shape survives. One shape, not two:
|
||||
// a provider with no params is still emitted as an object.
|
||||
const std::string out = m->toJson();
|
||||
EXPECT_NE(out.find("\"chat.group.open\""), std::string::npos);
|
||||
EXPECT_NE(out.find("intent"), std::string::npos);
|
||||
|
||||
auto round = Manifest::fromJson(out);
|
||||
ASSERT_TRUE(round.has_value());
|
||||
ASSERT_EQ(round->provides.size(), m->provides.size());
|
||||
EXPECT_EQ(round->provides[0].intent, m->provides[0].intent);
|
||||
}
|
||||
|
||||
TEST(ManifestTest, Provides_SurvivesAFieldByFieldCopy) {
|
||||
// merge_command copies the reference manifest field by field rather than
|
||||
// assigning the whole struct, so every new field has to be added there by
|
||||
// hand. `provides` was missed on the first pass: merging per-platform
|
||||
// packages into a multi-variant one silently un-declared its intents.
|
||||
//
|
||||
// This pins the property that matters — capabilities belong to the package,
|
||||
// not to a platform build, so they must survive the merge.
|
||||
std::string json = R"({
|
||||
"manifestVersion": "0.5.0",
|
||||
"name": "chat_ui",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"type": "ui_qml",
|
||||
"category": "",
|
||||
"icon": "",
|
||||
"view": "Main.qml",
|
||||
"dependencies": [],
|
||||
"provides": ["chat.group.open"]
|
||||
})";
|
||||
|
||||
auto ref = Manifest::fromJson(json);
|
||||
ASSERT_TRUE(ref.has_value());
|
||||
|
||||
Manifest merged;
|
||||
merged.manifestVersion = ref->manifestVersion;
|
||||
merged.name = ref->name;
|
||||
merged.version = ref->version;
|
||||
merged.type = ref->type;
|
||||
merged.view = ref->view;
|
||||
merged.dependencies = ref->dependencies;
|
||||
merged.provides = ref->provides;
|
||||
|
||||
ASSERT_EQ(merged.provides.size(), ref->provides.size());
|
||||
EXPECT_EQ(merged.provides[0].intent, ref->provides[0].intent);
|
||||
EXPECT_NE(merged.toJson().find("chat.group.open"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(ManifestTest, Provides_KeepsOnlyTheNameNotTheParamShape) {
|
||||
// The author's metadata.json may describe an intent's payload; the manifest
|
||||
// deliberately does not carry that description. The shell enforces params
|
||||
// against the INSTALLED metadata.json, and a second copy here would be a
|
||||
// bundle-time snapshot that nothing reads and that can drift from the
|
||||
// original. The catalog question this copy exists to answer — "which
|
||||
// installable package provides X?" — needs the name and nothing else.
|
||||
std::string json = R"({
|
||||
"manifestVersion": "0.5.0",
|
||||
"name": "wallet_ui",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"type": "ui_qml",
|
||||
"category": "",
|
||||
"icon": "",
|
||||
"view": "Main.qml",
|
||||
"dependencies": [],
|
||||
"provides": [{
|
||||
"intent": "wallet.send",
|
||||
"params": [{"name": "to", "type": "string", "required": true}]
|
||||
}]
|
||||
})";
|
||||
|
||||
auto m = Manifest::fromJson(json);
|
||||
ASSERT_TRUE(m.has_value());
|
||||
ASSERT_EQ(m->provides.size(), 1u);
|
||||
EXPECT_EQ(m->provides[0].intent, "wallet.send");
|
||||
|
||||
// Accepted on the way in, dropped on the way out.
|
||||
const std::string out = m->toJson();
|
||||
EXPECT_NE(out.find("wallet.send"), std::string::npos);
|
||||
EXPECT_EQ(out.find("\"params\""), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(ManifestTest, ToJson_OmitsProvidesWhenUnset) {
|
||||
auto manifest = Manifest::fromJson(VALID_MANIFEST_JSON);
|
||||
ASSERT_TRUE(manifest.has_value());
|
||||
ASSERT_TRUE(manifest->provides.empty());
|
||||
|
||||
// Older packages must round-trip byte-identically.
|
||||
EXPECT_EQ(manifest->toJson().find("provides"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(ManifestTest, ToJson_OmitsDisplayNameWhenUnset) {
|
||||
auto manifest = Manifest::fromJson(VALID_MANIFEST_JSON);
|
||||
ASSERT_TRUE(manifest.has_value());
|
||||
|
||||
Reference in New Issue
Block a user