Build composable Camino packages with explicit type and source dependencies

This commit is contained in:
Timothy J. Aveni
2026-09-22 13:51:32 -07:00
parent 85734e3003
commit 0da022e216
12 changed files with 508 additions and 78 deletions
+3
View File
@@ -13,6 +13,7 @@
service ? { },
nodeModules ? null,
components ? null,
group ? false,
}:
let
compiler = "${protocol}/libexec/quixos-protocol/execution-world.mjs";
@@ -26,6 +27,7 @@ let
sharedRuntime
nodeModules
service
group
;
components =
if components == null then
@@ -35,6 +37,7 @@ let
inherit (components) sdk runtime entries;
dependencies = components.sdk.dependencies;
bindingOutput = components.bindingOutput or "src/gen/client.ts";
contracts = components.contracts or [ ];
};
source = src;
portable =
+103
View File
@@ -0,0 +1,103 @@
import fs from "node:fs/promises";
import path from "node:path";
import { createHash } from "node:crypto";
import { pathToFileURL } from "node:url";
import { buildService } from "./build-service.mjs";
const config = JSON.parse(await fs.readFile(process.argv[2], "utf8"));
const compiler = await import(pathToFileURL(config.compiler));
const plan = JSON.parse(await fs.readFile(config.plan, "utf8"));
const pkg = plan.definition.packages.find((pkg) => pkg.revision === config.packageRevisionId);
if (!pkg || compiler.hexDigest(plan.definition) !== plan.digest) throw Error("Invalid package assembly context");
const output = process.env.out;
if (!output) throw Error("Package assembly requires an output directory");
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const generatorDigest = digest(await fs.readFile(config.compiler));
const implementations = [],
components = [],
files = new Map(),
ids = new Set();
const recordFile = async (name, mediaType) => {
const bytes = await fs.readFile(path.join(output, compiler.artifactPath(name)));
return { path: name, bytes: bytes.length, digest: digest(bytes), mediaType };
};
await fs.mkdir(path.join(output, "share/quixos/generated"), { recursive: true });
for (const root of config.groups) {
const manifest = compiler.parsePackageArtifacts(
JSON.parse(await fs.readFile(path.join(root, "share/quixos/package-artifacts.json"), "utf8")),
);
if (
manifest.packageRevisionId !== pkg.revision ||
manifest.bindingDigest !== pkg.bindingDigest ||
manifest.sourceDigest !== pkg.sourceDigest ||
JSON.stringify(manifest.versions) !== JSON.stringify(plan.definition.versions)
)
throw Error("Artifact group belongs to a different checked package");
const receipt = JSON.parse(await fs.readFile(path.join(root, "share/quixos/build-receipt.json"), "utf8"));
const expected = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest);
if (JSON.stringify(receipt) !== JSON.stringify(expected.receipt))
throw Error("Artifact group has an invalid build receipt");
const read = await compiler.artifactFileReader({ [pkg.revision]: root });
for (const artifact of [...manifest.implementations, ...(manifest.components ?? [])]) {
const key = `${artifact.compatibility === "org.quixos.react.esm/1" ? "module" : "implementation"}:${artifact.id}`;
if (ids.has(key)) throw Error("Duplicate artifact group export " + key);
ids.add(key);
for (const file of artifact.files) {
const bytes = await read(pkg.revision, file.path, file.bytes);
if (bytes.length !== file.bytes || digest(bytes) !== file.digest)
throw Error("Modified group asset " + file.path);
const previous = files.get(file.path);
if (previous && JSON.stringify(previous) !== JSON.stringify(file))
throw Error("Conflicting group asset " + file.path);
if (!previous) {
await fs.mkdir(path.dirname(path.join(output, file.path)), { recursive: true });
await fs.writeFile(path.join(output, file.path), bytes, { mode: file.path.startsWith("bin/") ? 0o755 : 0o644 });
files.set(file.path, file);
}
}
}
for (const [name, bytes] of expected.sources) {
const actual = await fs.readFile(path.join(root, name));
if (!Buffer.from(bytes).equals(actual)) throw Error("Modified group type witness " + name);
await fs.writeFile(path.join(output, name), bytes);
}
implementations.push(...manifest.implementations);
components.push(...(manifest.components ?? []));
}
if (implementations.filter((entry) => entry.compatibility === "quixos-service-v1").length > 1)
throw Error("A package can have only one process service owner");
if (!implementations.some((entry) => entry.compatibility === "quixos-service-v1"))
await buildService({
config: { ...config, service: {} },
compiler,
pkg,
output,
coreServer: path.join(config.sharedRuntime, "core.mjs"),
sharedFiles: [...files.values()].filter((file) => file.path.startsWith("share/quixos/shared/")),
implementations,
recordFile,
});
const manifest = compiler.parsePackageArtifacts({
schemaVersion: 1,
packageRevisionId: pkg.revision,
bindingDigest: pkg.bindingDigest,
sourceDigest: pkg.sourceDigest,
versions: plan.definition.versions,
implementations,
...(components.length ? { components } : {}),
});
const { receipt, sources } = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest);
for (const [name, bytes] of sources) {
const actual = await fs.readFile(path.join(output, name));
if (!Buffer.from(bytes).equals(actual)) throw Error("Assembly lacks checked generated source " + name);
}
await fs.writeFile(path.join(output, "share/quixos/package-artifacts.json"), JSON.stringify(manifest, null, 2));
await fs.writeFile(path.join(output, "share/quixos/build-receipt.json"), JSON.stringify(receipt, null, 2));
// Check package coverage with the same public validator used at installation.
const packagePlan = { definition: { ...plan.definition, packages: [pkg] }, digest: "" };
packagePlan.digest = compiler.hexDigest(packagePlan.definition);
await compiler.checkExecutionWorld(
packagePlan,
[manifest],
await compiler.artifactFileReader({ [pkg.revision]: output }),
);
+29
View File
@@ -0,0 +1,29 @@
{
pkgs,
context,
artifacts,
}:
let
groups = map (artifact: artifact.caminoGroup or artifact) artifacts;
config = pkgs.writeText "camino-package-assembly.json" (
builtins.toJSON {
inherit groups;
inherit (context) plan packageRevisionId sharedRuntime;
compiler = "${context.generator}/libexec/quixos-protocol/execution-world.mjs";
node = "${pkgs.nodejs_24}/bin/node";
esbuild = "${pkgs.esbuild}/bin/esbuild";
shell = "${pkgs.runtimeShell}";
serviceAdapter = ./portable-service.mjs;
}
);
in
pkgs.runCommand "camino-package"
{
passthru = {
caminoGroups = groups;
assemblyConfig = config;
};
}
''
${pkgs.nodejs_24}/bin/node ${./.}/assemble-package.mjs ${config}
''
+8 -73
View File
@@ -1,3 +1,4 @@
import { buildService } from "./build-service.mjs";
import { buildComponents } from "./build-components.mjs";
/** Trusted Nix build driver. No package-defined build hooks run between generation and witnessing. */
import fs from "node:fs/promises";
@@ -161,79 +162,13 @@ for (const { target, mode, settings } of builds) {
};
implementations.push({ ...body, contentDigest: compiler.implementationDigest(body) });
}
const components = await buildComponents({ config, compiler, plan, pkg, output, recordFile, generated });
if (pkg.exports.length || config.service.entry) {
await fs.mkdir(path.join(output, "bin"), { recursive: true });
await fs.mkdir(path.join(output, "share/quixos/service"), { recursive: true });
let serviceEntry;
if (config.service.entry) {
if (!/\.m?js$/.test(config.service.entry))
throw Error("Custom service entry must be JavaScript; use a separate typed/language build before packaging");
serviceEntry = path.join(config.source, relative(config.service.entry));
} else {
const modules = implementations.filter((implementation) => implementation.target === "server");
if (pkg.exports.some((entry) => !modules.some((module) => module.exports.includes(entry.id))))
throw Error("Generated service does not cover every package export; provide a custom service");
if (!modules.length) throw Error("Generated service requires a checked registry");
serviceEntry = path.resolve("service-entry.mjs");
const imports = modules
.map(
(module, index) =>
`import { ${module.registryExport} as registry${index} } from ${JSON.stringify(path.join(output, module.path))};`,
)
.join("\n");
await fs.writeFile(
serviceEntry,
imports +
`\nimport {servePortableRegistry} from ${JSON.stringify(config.serviceAdapter)};
const registries = [${modules.map((_, index) => "registry" + index).join(",")}];
const owners = new Map();
for (const registry of registries) for (const id of registry.exports) {
if (owners.has(id)) throw Error("Duplicate registry export " + id);
owners.set(id, registry);
}
servePortableRegistry({exports: [...owners.keys()], invoke(request, channel, context) {
const registry = owners.get(request.exportId);
if (!registry) throw Error("Unknown export");
return registry.invoke(request, channel, context);
}});\n`,
);
}
const serviceModule = "share/quixos/service/main.mjs";
execFileSync(
config.esbuild,
[
serviceEntry,
"--bundle",
"--platform=node",
"--format=esm",
"--minify-whitespace",
"--target=es2024",
`--external:${coreServer}`,
`--outfile=${path.join(output, serviceModule)}`,
],
{ stdio: "inherit" },
);
await fs.writeFile(
path.join(output, "bin/service"),
`#!${config.shell}\nservice_root=\${0%/*}/..\nexec ${config.node} "$service_root/${serviceModule}" "$@"\n`,
{ mode: 0o755 },
);
const serviceBody = {
id: "service",
target: "server",
compatibility: "quixos-service-v1",
path: "bin/service",
ports: "quixos-transaction-ports-v1",
exports: pkg.exports.map((e) => e.id),
files: [
await recordFile("bin/service", "text/x-shellscript"),
await recordFile(serviceModule, "text/javascript"),
...sharedFiles,
],
};
implementations.unshift({ ...serviceBody, contentDigest: compiler.implementationDigest(serviceBody) });
}
const components =
config.group && !config.components
? []
: await buildComponents({ config, compiler, plan, pkg, output, recordFile, generated });
if (!config.group || config.service.entry)
await buildService({ config, compiler, pkg, output, coreServer, sharedFiles, implementations, recordFile });
const manifest = compiler.parsePackageArtifacts({
schemaVersion: 1,
packageRevisionId: pkg.revision,
+56 -4
View File
@@ -9,7 +9,9 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec
if (pkg.components?.length) throw Error("Declared components require checked component build settings");
return [];
}
const declarations = pkg.components ?? [];
const declarations = config.group
? (pkg.components ?? []).filter((entry) => Object.hasOwn(settings.entries, entry.id))
: (pkg.components ?? []);
const entries = Object.entries(settings.entries);
if (
entries.length !== declarations.length ||
@@ -86,6 +88,46 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec
await fs.writeFile(path.join(output, "share/quixos/generated/component-assets.d.ts"), assets);
generated.set("component-client", { file: path.join(work, bindingPath), source: bindings });
generated.set("component-assets", { file: path.join(work, assetPath), source: assets });
// Public props are type-only inputs owned by imported interface resources.
// Augment the generated nominal registry without importing an implementation.
const propsImports = [],
propsMembers = [],
seenProps = new Set();
for (const root of settings.contracts ?? []) {
const companion = JSON.parse(await fs.readFile(path.join(root, "react-contracts.json"), "utf8"));
if (companion.schemaVersion !== 1) throw Error("Unsupported React props companion");
const contracts = plan.definition.workspace.interfaceImports.filter(
(iface) =>
iface.revisionId === companion.interfaceRevisionId ||
iface.application?.definitionId === companion.interfaceRevisionId,
);
if (!contracts.length) continue;
for (const [memberId, entry] of Object.entries(companion.members)) {
if (!/^[$A-Z_a-z][$\w]*$/.test(entry.export)) throw Error("Invalid props type export");
const alias = `Props${propsImports.length}`;
propsImports.push(
`import type {${entry.export} as ${alias}} from ${JSON.stringify(path.join(root, "types", compiler.artifactPath(entry.path)))};`,
);
for (const iface of contracts) {
if (
!(iface.template?.members ?? iface.members).some(
(member) => member.kind === "module" && member.id === memberId,
)
)
throw Error("Props companion names an unknown module member");
const key = JSON.stringify([iface.revisionId, memberId]);
if (seenProps.has(key)) throw Error("Duplicate React props companion for " + key);
seenProps.add(key);
propsMembers.push(`${JSON.stringify(key)}: ${alias};`);
}
}
}
const propsPath = "component-props.d.ts";
await fs.writeFile(
path.join(work, propsPath),
propsImports.join("\n") +
`\nexport {};\ndeclare module ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))} { interface ModuleProps {${propsMembers.join("\n")}} }\n`,
);
const components = [];
for (const [id, entry] of entries) {
const declaration = declarations.find((component) => component.id === id),
@@ -93,10 +135,14 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec
if (!/^[$A-Z_a-z][$\w]*$/.test(entry.export)) throw Error("Invalid component export name");
const key = digest(Buffer.from(id)).slice(0, 24),
directory = "share/quixos/components/" + key;
const witness = path.join(work, "component-check-" + key + ".ts");
const witness = path.join(work, "component-check-" + key + ".tsx");
const module = pkg.modules.find((module) => module.id === id);
await fs.writeFile(
witness,
`import type {Components} from ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))};import {${entry.export} as Component} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};const checked:Components[${JSON.stringify(declaration.displayName)}]=Component;export default checked;\n`,
`import type {Modules} from ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))};import {${entry.export} as Component} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};const checked:Modules[${JSON.stringify(declaration.displayName)}]=Component;export default checked;\n` +
(module.contract === "org.quixos.react.subject-only/1"
? `import type {ComponentProps} from "@quixos/camino-react";declare const subject:ComponentProps<${JSON.stringify(declaration.subject)}>['subject'];const mount = <Component subject={subject}/>;\n`
: ""),
);
execFileSync(
config.node,
@@ -118,15 +164,21 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec
"react-jsx",
path.basename(witness),
assetPath,
propsPath,
],
{ cwd: work, stdio: "inherit" },
);
const metadata = path.join(work, "component-meta-" + key + ".json");
const facade = "component-entry-" + key + ".ts";
await fs.writeFile(
path.join(work, facade),
`export {${entry.export}} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};\n`,
);
await fs.mkdir(path.join(output, directory), { recursive: true });
execFileSync(
config.esbuild,
[
source,
facade,
"--bundle",
"--platform=browser",
"--format=esm",
+90
View File
@@ -0,0 +1,90 @@
import fs from "node:fs/promises";
import path from "node:path";
import { execFileSync } from "node:child_process";
const config = JSON.parse(await fs.readFile(process.argv[2], "utf8"));
const resource = JSON.parse(await fs.readFile(config.interface, "utf8"));
if (resource.kind !== "interface") throw Error("React props belong to an interface resource");
const iface = resource.revision;
const output = process.env.out;
if (!output) throw Error("Missing Nix output");
const work = path.resolve("contracts-source");
const declarations = [];
await fs.cp(config.source, work, { recursive: true });
const writable = async (directory) => {
await fs.chmod(directory, 0o755);
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
if (entry.isSymbolicLink()) throw Error("Contract source cannot contain symlinks");
if (entry.isDirectory()) await writable(file);
else {
await fs.chmod(file, 0o644);
if (/\.d\.[cm]?ts$/.test(file)) declarations.push(path.relative(work, file));
}
}
};
await writable(work);
await fs.writeFile(path.join(work, "package.json"), JSON.stringify({ type: "module" }));
if (config.dependencies) await fs.symlink(config.dependencies, path.join(work, "node_modules"));
const members = Object.create(null);
let index = 0;
for (const [id, entry] of Object.entries(config.members)) {
const member = (iface.template?.members ?? iface.members).find((member) => member.id === id);
if (
member?.kind !== "module" ||
!["org.quixos.react.component/1", "org.quixos.react.subject-only/1"].includes(member.contract)
)
throw Error("Props entry is not a React module member: " + id);
if (
!/^[$A-Z_a-z][$\w]*$/.test(entry.export) ||
path.isAbsolute(entry.entry) ||
entry.entry.split("/").some((part) => part === ".." || !part)
)
throw Error("Invalid props type entry");
const filename = `props-${index++}.ts`;
const typeImport = JSON.stringify("./" + entry.entry.replace(/\.[cm]?tsx?$/, ".js"));
await fs.writeFile(
path.join(work, filename),
`import type {${entry.export} as Props} from ${typeImport};
type Assert<T extends true> = T;
type ObjectProps = Assert<Props extends object ? true : false>;
type ReservedSubject = Assert<"subject" extends keyof Props ? false : true>;
${member.contract === "org.quixos.react.subject-only/1" ? "type SubjectOnly = Assert<{} extends Props ? true : false>;" : ""}
export type {Props};\n`,
);
members[id] = { path: filename.replace(/\.ts$/, ".d.ts"), export: "Props" };
}
await fs.mkdir(path.join(output, "types"), { recursive: true });
if (!Object.keys(members).length) throw Error("React type companion declares no module members");
execFileSync(
config.node,
[
config.tsc,
"--declaration",
"--emitDeclarationOnly",
"--strict",
"--target",
"es2024",
"--module",
"nodenext",
"--moduleResolution",
"nodenext",
"--rootDir",
work,
"--outDir",
path.join(output, "types"),
...Object.values(members).map((entry) => path.join(work, entry.path.replace(/\.d\.ts$/, ".ts"))),
],
{ cwd: work, stdio: "inherit" },
);
// TypeScript emits declarations for implementation sources, but does not copy
// authored declarations which those emitted files may import.
for (const file of declarations) {
await fs.mkdir(path.dirname(path.join(output, "types", file)), { recursive: true });
await fs.copyFile(path.join(work, file), path.join(output, "types", file));
}
await fs.writeFile(path.join(output, "package.json"), JSON.stringify({ type: "module" }));
if (config.dependencies) await fs.symlink(config.dependencies, path.join(output, "node_modules"));
await fs.writeFile(
path.join(output, "react-contracts.json"),
JSON.stringify({ schemaVersion: 1, interfaceRevisionId: iface.revisionId, members }, null, 2),
);
+88
View File
@@ -0,0 +1,88 @@
import fs from "node:fs/promises";
import path from "node:path";
import { execFileSync } from "node:child_process";
export async function buildService({
config,
compiler,
pkg,
output,
coreServer,
sharedFiles,
implementations,
recordFile,
}) {
const relative = compiler.artifactPath;
if (pkg.exports.length || config.service.entry) {
await fs.mkdir(path.join(output, "bin"), { recursive: true });
await fs.mkdir(path.join(output, "share/quixos/service"), { recursive: true });
let serviceEntry;
if (config.service.entry) {
if (!/\.m?js$/.test(config.service.entry))
throw Error("Custom service entry must be JavaScript; use a separate typed/language build before packaging");
serviceEntry = path.join(config.source, relative(config.service.entry));
} else {
const modules = implementations.filter((implementation) => implementation.target === "server");
if (pkg.exports.some((entry) => !modules.some((module) => module.exports.includes(entry.id))))
throw Error("Generated service does not cover every package export; provide a custom service");
if (!modules.length) throw Error("Generated service requires a checked registry");
serviceEntry = path.resolve("service-entry.mjs");
const imports = modules
.map(
(module, index) =>
`import { ${module.registryExport} as registry${index} } from ${JSON.stringify(path.join(output, module.path))};`,
)
.join("\n");
await fs.writeFile(
serviceEntry,
imports +
`\nimport {servePortableRegistry} from ${JSON.stringify(config.serviceAdapter)};
const registries = [${modules.map((_, index) => "registry" + index).join(",")}];
const owners = new Map();
for (const registry of registries) for (const id of registry.exports) {
if (owners.has(id)) throw Error("Duplicate registry export " + id);
owners.set(id, registry);
}
servePortableRegistry({exports: [...owners.keys()], invoke(request, channel, context) {
const registry = owners.get(request.exportId);
if (!registry) throw Error("Unknown export");
return registry.invoke(request, channel, context);
}});\n`,
);
}
const serviceModule = "share/quixos/service/main.mjs";
execFileSync(
config.esbuild,
[
serviceEntry,
"--bundle",
"--platform=node",
"--format=esm",
"--minify-whitespace",
"--target=es2024",
`--external:${coreServer}`,
`--outfile=${path.join(output, serviceModule)}`,
],
{ stdio: "inherit" },
);
await fs.writeFile(
path.join(output, "bin/service"),
`#!${config.shell}\nservice_root=\${0%/*}/..\nexec ${config.node} "$service_root/${serviceModule}" "$@"\n`,
{ mode: 0o755 },
);
const serviceBody = {
id: "service",
target: "server",
compatibility: "quixos-service-v1",
path: "bin/service",
ports: "quixos-transaction-ports-v1",
exports: pkg.exports.map((e) => e.id),
files: [
await recordFile("bin/service", "text/x-shellscript"),
await recordFile(serviceModule, "text/javascript"),
...sharedFiles,
],
};
implementations.unshift({ ...serviceBody, contentDigest: compiler.implementationDigest(serviceBody) });
}
}
+5 -1
View File
@@ -846,7 +846,11 @@ let
);
in
{
mkCaminoArtifacts = import ./artifacts.nix;
mkCaminoReactPackage = import ./react-package.nix;
mkCaminoReactContracts = import ./react-contracts.nix;
mkCaminoTypeScriptPackage = import ./typescript-package.nix;
mkCaminoTypeScriptService = import ./typescript-service.nix;
assembleCaminoPackage = import ./assemble-package.nix;
inherit
mkQuixosPackageFlake
mkTsPackageServer
+21
View File
@@ -0,0 +1,21 @@
{
pkgs,
context,
src,
members,
dependencies ? null,
}:
let
config = pkgs.writeText "camino-react-contracts.json" (
builtins.toJSON {
source = src;
interface = context.interface;
inherit members dependencies;
node = "${pkgs.nodejs_24}/bin/node";
tsc = "${context.typescript}/node_modules/typescript/bin/tsc";
}
);
in
pkgs.runCommand "camino-react-contracts" { } ''
${pkgs.nodejs_24}/bin/node ${./.}/build-react-contracts.mjs ${config}
''
+34
View File
@@ -0,0 +1,34 @@
{
pkgs,
context,
src,
modules,
bindingOutput ? "src/gen/client.ts",
dependencies ? null,
}:
let
group = import ./artifacts.nix {
inherit pkgs src;
inherit (context)
plan
core
sharedRuntime
typescript
packageRevisionId
;
protocol = context.generator;
group = true;
nodeModules = dependencies;
components = {
inherit bindingOutput;
inherit (context.react) sdk runtime;
entries = modules;
contracts = context.react.contracts or [ ];
};
};
package = import ./assemble-package.nix {
inherit pkgs context;
artifacts = [ group ];
};
in
package // { caminoGroup = group; }
+41
View File
@@ -0,0 +1,41 @@
{
pkgs,
context,
src,
entry,
targets ? [
"browser"
"server"
],
entries ? { },
bindingOutput ? "src/gen/qx.ts",
dependencies ? null,
}:
let
group = import ./artifacts.nix {
inherit pkgs src;
inherit (context)
plan
core
sharedRuntime
typescript
packageRevisionId
;
protocol = context.generator;
group = true;
nodeModules = dependencies;
portable = {
inherit
entry
entries
targets
bindingOutput
;
};
};
package = import ./assemble-package.nix {
inherit pkgs context;
artifacts = [ group ];
};
in
package // { caminoGroup = group; }
+30
View File
@@ -0,0 +1,30 @@
{
pkgs,
context,
src,
entry,
bindingOutput ? "src/gen/remote.ts",
dependencies ? null,
service ? { },
}:
let
group = import ./artifacts.nix {
inherit pkgs src service;
inherit (context)
plan
core
sharedRuntime
typescript
packageRevisionId
;
protocol = context.generator;
group = true;
nodeModules = dependencies;
remote = { inherit entry bindingOutput; };
};
package = import ./assemble-package.nix {
inherit pkgs context;
artifacts = [ group ];
};
in
package // { caminoGroup = group; }