@roubo/shared 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config-schema.ts +432 -0
- package/deep-merge.ts +38 -0
- package/gate-overrides-contract.ts +120 -0
- package/integration-types.ts +124 -0
- package/package.json +39 -0
- package/plugin-consent-schema.ts +80 -0
- package/plugin-enable-state-schema.ts +30 -0
- package/plugin-manifest-schema.ts +179 -0
- package/plugin-manifest.ts +55 -0
- package/plugin-runtime-types.ts +50 -0
- package/provision-descriptor-schema.ts +103 -0
- package/testbench-canonicalize.ts +129 -0
- package/testbench-contracts.ts +271 -0
- package/testbench-domain-types.ts +128 -0
- package/testbench-domain.ts +232 -0
- package/testbench-targeting-schema.ts +132 -0
- package/types.ts +1975 -0
- package/work-units-contract.ts +155 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative source-picker payloads returned by an integration plugin's
|
|
3
|
+
* `listSourceCandidates` RPC. Roubo's host renders the UI; plugins ship no
|
|
4
|
+
* React. See `.specifications/integration-plugins/architecture.md` (FR-019).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type SourceCandidateIcon = "repo" | "project" | "board" | "epic" | "filter";
|
|
8
|
+
|
|
9
|
+
export interface SourceCandidateItem {
|
|
10
|
+
externalId: string;
|
|
11
|
+
label: string;
|
|
12
|
+
sublabel?: string;
|
|
13
|
+
icon?: SourceCandidateIcon;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SourceCandidateCategory {
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
items: SourceCandidateItem[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type SourceCandidatesShape =
|
|
23
|
+
| "multi-list"
|
|
24
|
+
| "categorized-multi-list"
|
|
25
|
+
| "searchable-categorized";
|
|
26
|
+
|
|
27
|
+
// One selectable mode within a synthetic category (e.g. "assigned to me":
|
|
28
|
+
// in-project vs anywhere). Distinct from a SourceCandidateItem in that it has
|
|
29
|
+
// no externalId and is not fetched via search; the host renders it inline.
|
|
30
|
+
export interface SourceCategoryOption {
|
|
31
|
+
id: string;
|
|
32
|
+
label: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A category declared by the "searchable-categorized" shape. The plugin ships
|
|
36
|
+
// no items here; it only declares which categories exist, their icon, and
|
|
37
|
+
// whether each is gated behind a parent selection. Items arrive later via the
|
|
38
|
+
// host's source-options search RPC.
|
|
39
|
+
export interface SearchableSourceCategory {
|
|
40
|
+
id: "project" | "board" | "filter" | "epic" | "mine";
|
|
41
|
+
label: string;
|
|
42
|
+
icon?: SourceCandidateIcon;
|
|
43
|
+
// Gate: the category is disabled until the named parent selection exists.
|
|
44
|
+
scopedBy?: "project";
|
|
45
|
+
// Inline modes for synthetic categories like "mine".
|
|
46
|
+
options?: SourceCategoryOption[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SourceCandidatesResponse {
|
|
50
|
+
shape: SourceCandidatesShape;
|
|
51
|
+
// Present iff shape === "multi-list".
|
|
52
|
+
items?: SourceCandidateItem[];
|
|
53
|
+
// Present iff shape === "categorized-multi-list".
|
|
54
|
+
categories?: SourceCandidateCategory[];
|
|
55
|
+
// Present iff shape === "searchable-categorized".
|
|
56
|
+
searchableCategories?: SearchableSourceCategory[];
|
|
57
|
+
// Reserved for future pagination; v1 plugins return undefined.
|
|
58
|
+
nextCursor?: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Params for the scoped, paginated source-option search (`getSourceOptions`).
|
|
63
|
+
* Generalizes facet-option search with a parent `scope` (the Jira project keys a
|
|
64
|
+
* board/filter/epic search is confined to) and an opaque `cursor`. `search` is
|
|
65
|
+
* the optional user-typed term (debounced client-side). Scoped categories with
|
|
66
|
+
* no `scope.project` return an empty page.
|
|
67
|
+
*/
|
|
68
|
+
export interface GetSourceOptionsParams {
|
|
69
|
+
category: "project" | "board" | "filter" | "epic";
|
|
70
|
+
scope?: { project?: string[] };
|
|
71
|
+
search?: string;
|
|
72
|
+
cursor?: string | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One page of source options returned by `getSourceOptions`. `nextCursor` is an
|
|
77
|
+
* opaque token the host passes back verbatim to fetch the next page; `null`
|
|
78
|
+
* means exhausted (NFR-004: every item reachable, no page dropped or duplicated).
|
|
79
|
+
*/
|
|
80
|
+
export interface SourceOptionsResult {
|
|
81
|
+
items: SourceCandidateItem[];
|
|
82
|
+
nextCursor: string | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One persisted source entry. The primitive `string` form is the externalId;
|
|
87
|
+
* the object form carries per-source toggles (currently only the GitHub-family
|
|
88
|
+
* Code Scanning / Secret Scanning / Dependabot booleans, ignored by other
|
|
89
|
+
* plugins). The two forms are interchangeable on disk: a writer collapses to
|
|
90
|
+
* the primitive form when no toggles are set, and expands to the object form
|
|
91
|
+
* the moment any toggle turns on.
|
|
92
|
+
*/
|
|
93
|
+
export type SourceSelectionEntry =
|
|
94
|
+
| string
|
|
95
|
+
| {
|
|
96
|
+
externalId: string;
|
|
97
|
+
// Human-readable display name and secondary line for the source, captured
|
|
98
|
+
// when the user picks it so chips and the Settings tile can show the name
|
|
99
|
+
// instead of the raw id on reload (no re-fetch). Display only: the plugin
|
|
100
|
+
// and `translateSources` ignore them.
|
|
101
|
+
label?: string;
|
|
102
|
+
sublabel?: string;
|
|
103
|
+
// Jira project key the source is scoped to (project-first model). Set on
|
|
104
|
+
// board / filter / epic entries and on the synthetic `mine` source when
|
|
105
|
+
// scoped in-project, so removing a project can prune its scoped sources.
|
|
106
|
+
project?: string;
|
|
107
|
+
// Board sources: active sprint only vs the whole board's backing filter.
|
|
108
|
+
boardMode?: "active-sprint" | "whole-board";
|
|
109
|
+
// "Assigned to me" synthetic source: scoped to a project or instance-wide.
|
|
110
|
+
mineScope?: "in-project" | "anywhere";
|
|
111
|
+
// GitHub-family toggles (ignored by other plugins).
|
|
112
|
+
includeCodeQLAlerts?: boolean;
|
|
113
|
+
includeSecretScanningAlerts?: boolean;
|
|
114
|
+
includeDependabotAlerts?: boolean;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Persisted selection for a project. Keys are plugin-defined category ids
|
|
119
|
+
* (or the literal `"items"` for the multi-list shape). Values are arrays of
|
|
120
|
+
* source entries (string externalIds or objects with per-source toggles).
|
|
121
|
+
* Stored verbatim in `integration.sources` of the per-user override and
|
|
122
|
+
* treated as opaque-to-Roubo elsewhere.
|
|
123
|
+
*/
|
|
124
|
+
export type SourceSelection = Record<string, SourceSelectionEntry[]>;
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@roubo/shared",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared TypeScript types for Roubo client + server.",
|
|
5
|
+
"author": "David Poxon",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"homepage": "https://github.com/davidpoxon/roubo#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/davidpoxon/roubo.git",
|
|
11
|
+
"directory": "shared"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/davidpoxon/roubo/issues"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "./types.ts",
|
|
18
|
+
"types": "./types.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./types.ts",
|
|
21
|
+
"./provision-descriptor-schema": "./provision-descriptor-schema.ts",
|
|
22
|
+
"./testbench-contracts": "./testbench-contracts.ts",
|
|
23
|
+
"./testbench-canonicalize": "./testbench-canonicalize.ts",
|
|
24
|
+
"./testbench-domain": "./testbench-domain.ts",
|
|
25
|
+
"./work-units-contract": "./work-units-contract.ts",
|
|
26
|
+
"./gate-overrides-contract": "./gate-overrides-contract.ts"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"*.ts",
|
|
30
|
+
"!*.test.ts"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"yaml": "2.9.0",
|
|
37
|
+
"zod": "4.4.3"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { PluginPermissions } from "./plugin-manifest-schema.js";
|
|
3
|
+
|
|
4
|
+
// Issue #615 / CP-FR-011, CP-FR-012, CP-NFR-001. Per-plugin consent record.
|
|
5
|
+
// See:
|
|
6
|
+
// .specifications/component-plugins/prd.md (CP-FR-011, CP-FR-012, CP-NFR-001)
|
|
7
|
+
// .specifications/component-plugins/architecture.md ('Data model', lines 61, 108-109)
|
|
8
|
+
//
|
|
9
|
+
// v1 ships the declare-then-enforce trust model's declaration half: the consumer
|
|
10
|
+
// is shown every declared permission category and must acknowledge them before a
|
|
11
|
+
// component plugin runs. A ConsentRecord captures that acknowledgement so the
|
|
12
|
+
// server can refuse to start a component whose plugin has none. v2 adds runtime
|
|
13
|
+
// enforcement and sandboxing; this record is advisory in v1.
|
|
14
|
+
|
|
15
|
+
export const PLUGIN_CONSENT_STATE_SCHEMA_VERSION = 1 as const;
|
|
16
|
+
|
|
17
|
+
// The advisory permission categories a component plugin can declare. Kept in one
|
|
18
|
+
// place so the consent store, the route, and the UI agree on the canonical set.
|
|
19
|
+
export const PERMISSION_CATEGORIES = [
|
|
20
|
+
"network",
|
|
21
|
+
"credentials",
|
|
22
|
+
"filesystem",
|
|
23
|
+
"processes",
|
|
24
|
+
"ports",
|
|
25
|
+
"docker",
|
|
26
|
+
] as const;
|
|
27
|
+
export type PermissionCategory = (typeof PERMISSION_CATEGORIES)[number];
|
|
28
|
+
|
|
29
|
+
export const ConsentRecordSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
pluginId: z.string().min(1),
|
|
32
|
+
acknowledgedCategories: z.array(z.string().min(1)),
|
|
33
|
+
consentedAt: z.string().min(1),
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
export type ConsentRecord = z.infer<typeof ConsentRecordSchema>;
|
|
37
|
+
|
|
38
|
+
export const PluginConsentStateSchema = z
|
|
39
|
+
.object({
|
|
40
|
+
schemaVersion: z.literal(PLUGIN_CONSENT_STATE_SCHEMA_VERSION),
|
|
41
|
+
plugins: z.record(z.string().min(1), ConsentRecordSchema),
|
|
42
|
+
})
|
|
43
|
+
.strict();
|
|
44
|
+
export type PluginConsentState = z.infer<typeof PluginConsentStateSchema>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Enumerates the permission categories a manifest actually declares, i.e. the
|
|
48
|
+
* categories the consumer must acknowledge before the plugin may run. A category
|
|
49
|
+
* is "declared" only when it requests something: an empty `network.hosts`,
|
|
50
|
+
* `credentials.slots`, or `filesystem.paths`, a `false` `processes` / `ports` /
|
|
51
|
+
* `docker`, or an absent optional category all count as not-declared and so do
|
|
52
|
+
* not require acknowledgement.
|
|
53
|
+
*
|
|
54
|
+
* Order follows PERMISSION_CATEGORIES so the UI and the route render a stable
|
|
55
|
+
* sequence.
|
|
56
|
+
*/
|
|
57
|
+
export function declaredCategories(permissions: PluginPermissions): PermissionCategory[] {
|
|
58
|
+
const declared: PermissionCategory[] = [];
|
|
59
|
+
if (permissions.network.hosts.length > 0) declared.push("network");
|
|
60
|
+
if (permissions.credentials.slots.length > 0) declared.push("credentials");
|
|
61
|
+
if (permissions.filesystem.paths.length > 0) declared.push("filesystem");
|
|
62
|
+
if (permissions.processes !== false) declared.push("processes");
|
|
63
|
+
if (permissions.ports !== undefined && permissions.ports !== false) declared.push("ports");
|
|
64
|
+
if (permissions.docker !== undefined && permissions.docker !== false) declared.push("docker");
|
|
65
|
+
return declared;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* True when every category the manifest declares appears in `acknowledged`.
|
|
70
|
+
* Extra acknowledged categories are tolerated (forward-compatible); the gate is
|
|
71
|
+
* only that no declared category is missing. Used by the POST /consent route to
|
|
72
|
+
* reject a body that omits any declared category (400).
|
|
73
|
+
*/
|
|
74
|
+
export function isFullyAcknowledged(
|
|
75
|
+
permissions: PluginPermissions,
|
|
76
|
+
acknowledged: readonly string[],
|
|
77
|
+
): boolean {
|
|
78
|
+
const ack = new Set(acknowledged);
|
|
79
|
+
return declaredCategories(permissions).every((cat) => ack.has(cat));
|
|
80
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// WU-046 / issue #137: persistent per-plugin enable state. See:
|
|
4
|
+
// .specifications/integration-plugins/prd.md (FR-059, FR-060, NFR-019, US-016)
|
|
5
|
+
// .specifications/integration-plugins/architecture.md (lines 1064-1097)
|
|
6
|
+
|
|
7
|
+
export const PLUGIN_ENABLE_STATE_SCHEMA_VERSION = 1 as const;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Manifest ids of the bundled plugins shipped in `<repo>/plugins/`. The
|
|
11
|
+
* greenfield migration path seeds `plugins-state.json` with one `"disabled"`
|
|
12
|
+
* entry per id so a fresh install opts in to each integration explicitly.
|
|
13
|
+
*/
|
|
14
|
+
export const BUNDLED_PLUGIN_IDS = ["github-com", "ghe", "jira-self-hosted"] as const;
|
|
15
|
+
export type BundledPluginId = (typeof BUNDLED_PLUGIN_IDS)[number];
|
|
16
|
+
|
|
17
|
+
export const PluginEnableStateValueSchema = z.enum(["enabled", "disabled"]);
|
|
18
|
+
export type PluginEnableStateValue = z.infer<typeof PluginEnableStateValueSchema>;
|
|
19
|
+
|
|
20
|
+
export const PluginEnableStateSchema = z
|
|
21
|
+
.object({
|
|
22
|
+
schemaVersion: z.literal(PLUGIN_ENABLE_STATE_SCHEMA_VERSION),
|
|
23
|
+
plugins: z.record(z.string().min(1), PluginEnableStateValueSchema),
|
|
24
|
+
// Sentinel that this install has been through the greenfield seeding pass.
|
|
25
|
+
// Prevents re-seeding a fresh-cloned alpha install that happens to look
|
|
26
|
+
// greenfield. Set to true at the same atomic write that seeds `plugins`.
|
|
27
|
+
installInitialized: z.boolean(),
|
|
28
|
+
})
|
|
29
|
+
.strict();
|
|
30
|
+
export type PluginEnableState = z.infer<typeof PluginEnableStateSchema>;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// ── Sub-schemas ──
|
|
4
|
+
|
|
5
|
+
export const CredentialSlotSchema = z
|
|
6
|
+
.object({
|
|
7
|
+
slot: z.string().min(1, "Required"),
|
|
8
|
+
scope: z.enum(["read", "read-write"]),
|
|
9
|
+
description: z.string().min(1, "Required"),
|
|
10
|
+
})
|
|
11
|
+
.strict();
|
|
12
|
+
export type CredentialSlot = z.infer<typeof CredentialSlotSchema>;
|
|
13
|
+
|
|
14
|
+
export const NetworkPermissionsSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
hosts: z.array(z.string()),
|
|
17
|
+
})
|
|
18
|
+
.strict();
|
|
19
|
+
export type NetworkPermissions = z.infer<typeof NetworkPermissionsSchema>;
|
|
20
|
+
|
|
21
|
+
export const CredentialsPermissionsSchema = z
|
|
22
|
+
.object({
|
|
23
|
+
slots: z.array(CredentialSlotSchema),
|
|
24
|
+
})
|
|
25
|
+
.strict();
|
|
26
|
+
export type CredentialsPermissions = z.infer<typeof CredentialsPermissionsSchema>;
|
|
27
|
+
|
|
28
|
+
export const FilesystemPermissionsSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
paths: z.array(z.string()),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
export type FilesystemPermissions = z.infer<typeof FilesystemPermissionsSchema>;
|
|
34
|
+
|
|
35
|
+
export const ProcessesPermissionSchema = z.union([
|
|
36
|
+
z.literal(false),
|
|
37
|
+
z.object({ executables: z.array(z.string()) }).strict(),
|
|
38
|
+
]);
|
|
39
|
+
export type ProcessesPermission = z.infer<typeof ProcessesPermissionSchema>;
|
|
40
|
+
|
|
41
|
+
// `ports` lets a component plugin declare the bench ports it needs allocated.
|
|
42
|
+
// Either false (no port allocation) or an object naming the port keys the host
|
|
43
|
+
// resolves into BenchContext.ports (architecture.md, FR-001/FR-011).
|
|
44
|
+
export const PortsPermissionSchema = z.union([
|
|
45
|
+
z.literal(false),
|
|
46
|
+
z.object({ names: z.array(z.string()) }).strict(),
|
|
47
|
+
]);
|
|
48
|
+
export type PortsPermission = z.infer<typeof PortsPermissionSchema>;
|
|
49
|
+
|
|
50
|
+
// `docker` gates a component plugin's access to the host docker broker
|
|
51
|
+
// (composeUp / waitForHealthy / assignContainer, etc.). Either false (no docker
|
|
52
|
+
// access) or an object (reserved for future scoping fields).
|
|
53
|
+
export const DockerPermissionSchema = z.union([z.literal(false), z.object({}).strict()]);
|
|
54
|
+
export type DockerPermission = z.infer<typeof DockerPermissionSchema>;
|
|
55
|
+
|
|
56
|
+
// `permissions` is intentionally `.passthrough()` (not `.strict()`) so future
|
|
57
|
+
// permission categories can be added in a 1.x minor without breaking older
|
|
58
|
+
// hosts. See decisions-log.md AF-002. `ports` and `docker` are the component
|
|
59
|
+
// categories (optional, so existing integration manifests validate unchanged).
|
|
60
|
+
export const PluginPermissionsSchema = z
|
|
61
|
+
.object({
|
|
62
|
+
network: NetworkPermissionsSchema,
|
|
63
|
+
credentials: CredentialsPermissionsSchema,
|
|
64
|
+
filesystem: FilesystemPermissionsSchema,
|
|
65
|
+
processes: ProcessesPermissionSchema,
|
|
66
|
+
ports: PortsPermissionSchema.optional(),
|
|
67
|
+
docker: DockerPermissionSchema.optional(),
|
|
68
|
+
})
|
|
69
|
+
.passthrough();
|
|
70
|
+
export type PluginPermissions = z.infer<typeof PluginPermissionsSchema>;
|
|
71
|
+
|
|
72
|
+
// Per-capability flags a tracker plugin declares for the privileged tracker-action
|
|
73
|
+
// ops the TrackerActionGateway gates (verify-gate FR-011, NFR-005; spike #704).
|
|
74
|
+
// A flag is true only when the plugin implements the op for the connected
|
|
75
|
+
// instance. The gateway reads these up front and degrades with a legible error
|
|
76
|
+
// (never a silent no-op) when a flag is absent or false. close-gate is not a new
|
|
77
|
+
// flag: it reuses the existing applyTransition capability (architecture.md:133-134).
|
|
78
|
+
export const PluginCapabilitiesSchema = z
|
|
79
|
+
.object({
|
|
80
|
+
/** The plugin implements `createIssue` for the connected instance (FR-011). */
|
|
81
|
+
supportsCreateIssue: z.boolean().optional(),
|
|
82
|
+
/**
|
|
83
|
+
* The plugin implements `addBlockedBy` AND the connected instance exposes the
|
|
84
|
+
* blocking-link write (the GitHub GA / GHE version / Jira link-type condition
|
|
85
|
+
* from spike #704). May be resolved at runtime, not just statically.
|
|
86
|
+
*/
|
|
87
|
+
supportsBlockingLinks: z.boolean().optional(),
|
|
88
|
+
})
|
|
89
|
+
.strict();
|
|
90
|
+
export type PluginCapabilities = z.infer<typeof PluginCapabilitiesSchema>;
|
|
91
|
+
|
|
92
|
+
// Plugin-global defaults seeded into the three-layer effective-config merge
|
|
93
|
+
// (FR-064). Per-project and per-source layers override these. The host reads
|
|
94
|
+
// this section at manifest parse time; plugins do not see it via host-RPC.
|
|
95
|
+
export const PluginDefaultIntegrationConfigSchema = z
|
|
96
|
+
.object({
|
|
97
|
+
excludedStatuses: z.array(z.string().min(1)).optional(),
|
|
98
|
+
// Plugin-global default for the category-first status exclusion (FR-010).
|
|
99
|
+
// Seeded by the plugin manifest (e.g. jira-self-hosted ships ["Done"]).
|
|
100
|
+
excludedStatusCategories: z.array(z.string().min(1)).optional(),
|
|
101
|
+
})
|
|
102
|
+
.strict();
|
|
103
|
+
export type PluginDefaultIntegrationConfig = z.infer<typeof PluginDefaultIntegrationConfigSchema>;
|
|
104
|
+
|
|
105
|
+
// Per FR-057 / mockups §22, plugins may ship an icon rendered in the
|
|
106
|
+
// Plugins-page tile header (32×32) and the Configure modal header (24×24).
|
|
107
|
+
// Accepted forms:
|
|
108
|
+
// - `data:image/svg+xml;...` or `data:image/png;base64,...` data URI
|
|
109
|
+
// - relative POSIX path inside the plugin directory (e.g. `assets/icon.svg`)
|
|
110
|
+
// Loose validation here: the client renders this as an <img src>; manifest
|
|
111
|
+
// authors are trusted to ship something sensible. 16 KB ceiling guards
|
|
112
|
+
// against accidentally shipping a megabyte of base64.
|
|
113
|
+
export const PluginIconSchema = z
|
|
114
|
+
.string()
|
|
115
|
+
.min(1, "Required")
|
|
116
|
+
.max(16 * 1024, "Icon must be at most 16 KB");
|
|
117
|
+
export type PluginIcon = z.infer<typeof PluginIconSchema>;
|
|
118
|
+
|
|
119
|
+
// The set of plugin kinds the host understands. `component` lands with the
|
|
120
|
+
// component-plugin work (FR-001); `integration` is the original kind. The
|
|
121
|
+
// discriminator widens here without breaking existing integration manifests.
|
|
122
|
+
export const PluginKindSchema = z.enum(["integration", "component"]);
|
|
123
|
+
export type PluginKind = z.infer<typeof PluginKindSchema>;
|
|
124
|
+
|
|
125
|
+
// A node-semver-compatible range, used to validate the manifest `roubo` field
|
|
126
|
+
// at schema time so a malformed range is rejected with a clear error (FR-001),
|
|
127
|
+
// rather than only at host-compatibility time. Kept dependency-free (the
|
|
128
|
+
// `shared` workspace depends only on `yaml` + `zod`): this validates each
|
|
129
|
+
// space- or `||`-separated comparator against the comparator grammar
|
|
130
|
+
// node-semver accepts (operators, hyphen ranges, x-ranges, caret/tilde,
|
|
131
|
+
// wildcards). It is intentionally permissive on the comparator side and strict
|
|
132
|
+
// only about rejecting obvious garbage (the host re-checks with node-semver).
|
|
133
|
+
const SEMVER_COMPARATOR =
|
|
134
|
+
/^(?:[<>]=?|=|\^|~)?\s*v?(?:\d+|[xX*])(?:\.(?:\d+|[xX*]))?(?:\.(?:\d+|[xX*]))?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
135
|
+
|
|
136
|
+
export function isValidRouboRange(range: string): boolean {
|
|
137
|
+
const trimmed = range.trim();
|
|
138
|
+
if (trimmed.length === 0) return false;
|
|
139
|
+
// `*` / `x` on their own is the match-anything range.
|
|
140
|
+
if (/^[*xX]$/.test(trimmed)) return true;
|
|
141
|
+
const orClauses = trimmed.split("||");
|
|
142
|
+
return orClauses.every((clause) => {
|
|
143
|
+
const comparators = clause.trim().split(/\s+/).filter(Boolean);
|
|
144
|
+
if (comparators.length === 0) return false;
|
|
145
|
+
// A hyphen range ("1.2.3 - 2.3.4") parses as [lo, "-", hi].
|
|
146
|
+
if (comparators.length === 3 && comparators[1] === "-") {
|
|
147
|
+
return SEMVER_COMPARATOR.test(comparators[0]) && SEMVER_COMPARATOR.test(comparators[2]);
|
|
148
|
+
}
|
|
149
|
+
return comparators.every((c) => SEMVER_COMPARATOR.test(c));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Root manifest ──
|
|
154
|
+
|
|
155
|
+
export const PluginManifestSchema = z
|
|
156
|
+
.object({
|
|
157
|
+
id: z
|
|
158
|
+
.string()
|
|
159
|
+
.regex(/^[a-z][a-z0-9-]*$/, "Must be kebab-case (lowercase letters, digits, hyphens)"),
|
|
160
|
+
name: z.string().min(1, "Required"),
|
|
161
|
+
version: z.string().min(1, "Required"),
|
|
162
|
+
description: z.string().min(1, "Required"),
|
|
163
|
+
kind: PluginKindSchema,
|
|
164
|
+
roubo: z.string().min(1, "Required").refine(isValidRouboRange, "Must be a valid semver range"),
|
|
165
|
+
entry: z.string().min(1, "Required"),
|
|
166
|
+
icon: PluginIconSchema.optional(),
|
|
167
|
+
configSchema: z.record(z.string(), z.unknown()).optional(),
|
|
168
|
+
capabilities: PluginCapabilitiesSchema.optional(),
|
|
169
|
+
defaultIntegrationConfig: PluginDefaultIntegrationConfigSchema.optional(),
|
|
170
|
+
permissions: PluginPermissionsSchema,
|
|
171
|
+
// Component plugins declare the SDK component-contract version they target
|
|
172
|
+
// (FR-001/FR-002); the host validates the registered-method set against it.
|
|
173
|
+
contractVersion: z.number().int().positive().optional(),
|
|
174
|
+
// Optional ProvisionDescriptor schema version a declarative component plugin
|
|
175
|
+
// emits, so the host can reject a descriptor-schema mismatch (FR-017).
|
|
176
|
+
descriptorSchemaVersion: z.number().int().positive().optional(),
|
|
177
|
+
})
|
|
178
|
+
.strict();
|
|
179
|
+
export type PluginManifest = z.infer<typeof PluginManifestSchema>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { parse as parseYaml, YAMLParseError } from "yaml";
|
|
2
|
+
import { PluginManifestSchema, type PluginManifest } from "./plugin-manifest-schema.js";
|
|
3
|
+
|
|
4
|
+
export type ParseManifestResult =
|
|
5
|
+
| { ok: true; manifest: PluginManifest }
|
|
6
|
+
| {
|
|
7
|
+
ok: false;
|
|
8
|
+
error: {
|
|
9
|
+
code: "invalid-yaml" | "schema";
|
|
10
|
+
message: string;
|
|
11
|
+
path?: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function parseManifest(yamlText: string, sourcePath: string): ParseManifestResult {
|
|
16
|
+
let raw: unknown;
|
|
17
|
+
try {
|
|
18
|
+
raw = parseYaml(yamlText);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
const message = err instanceof YAMLParseError ? err.message : (err as Error).message;
|
|
21
|
+
return {
|
|
22
|
+
ok: false,
|
|
23
|
+
error: {
|
|
24
|
+
code: "invalid-yaml",
|
|
25
|
+
message: `Failed to parse ${sourcePath}: ${message}`,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (raw === null || raw === undefined) {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
error: {
|
|
34
|
+
code: "invalid-yaml",
|
|
35
|
+
message: `${sourcePath} is empty`,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const parsed = PluginManifestSchema.safeParse(raw);
|
|
41
|
+
if (!parsed.success) {
|
|
42
|
+
const issue = parsed.error.issues[0];
|
|
43
|
+
const path = issue.path.length > 0 ? issue.path.join(".") : undefined;
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
error: {
|
|
47
|
+
code: "schema",
|
|
48
|
+
message: path ? `${path}: ${issue.message}` : issue.message,
|
|
49
|
+
path,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { ok: true, manifest: parsed.data };
|
|
55
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { PluginManifest } from "./plugin-manifest-schema.js";
|
|
2
|
+
|
|
3
|
+
export type PluginStatus = "enabled" | "disabled" | "errored" | "incompatible" | "invalid";
|
|
4
|
+
|
|
5
|
+
export type PluginSource = "bundled" | "user";
|
|
6
|
+
|
|
7
|
+
export interface RestartEvent {
|
|
8
|
+
at: string;
|
|
9
|
+
reason: "unexpected-exit" | "spawn-failed" | "sandbox-fallback";
|
|
10
|
+
exitCode: number | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PluginError {
|
|
14
|
+
code: string;
|
|
15
|
+
message: string;
|
|
16
|
+
methodName?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LogLine {
|
|
20
|
+
ts: string;
|
|
21
|
+
source: "stdout" | "stderr" | "host";
|
|
22
|
+
level?: "info" | "warn" | "error";
|
|
23
|
+
text: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A structured, user-visible notice that the docker isolation tier could not
|
|
28
|
+
* engage for the plugin's directory. Surfaced on PluginRecord so callers of
|
|
29
|
+
* listInstalled() can present an actionable remediation rather than relying
|
|
30
|
+
* only on the log line (#743).
|
|
31
|
+
*/
|
|
32
|
+
export interface IsolationNotice {
|
|
33
|
+
kind: "docker-mount-unshared";
|
|
34
|
+
pluginDir: string;
|
|
35
|
+
message: string;
|
|
36
|
+
at: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PluginRecord {
|
|
40
|
+
id: string;
|
|
41
|
+
manifest: PluginManifest | null;
|
|
42
|
+
manifestPath: string;
|
|
43
|
+
pluginDir: string;
|
|
44
|
+
source: PluginSource;
|
|
45
|
+
status: PluginStatus;
|
|
46
|
+
lastError: PluginError | null;
|
|
47
|
+
restartHistory: RestartEvent[];
|
|
48
|
+
pid: number | null;
|
|
49
|
+
isolationNotices?: IsolationNotice[];
|
|
50
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// Issue #603 / T1.2: the typed ProvisionDescriptor discriminated union that a
|
|
4
|
+
// component plugin emits and the host LifecycleEngine executes. See:
|
|
5
|
+
// .specifications/component-plugins/prd.md (FR-002, FR-022, US-005, US-012)
|
|
6
|
+
// .specifications/component-plugins/architecture.md ('Data model', line 54)
|
|
7
|
+
//
|
|
8
|
+
// The shape is frozen in architecture.md. It lives in shared/ so both the host
|
|
9
|
+
// LifecycleEngine and the plugin SDK import one contract without a circular
|
|
10
|
+
// dependency. Every variant carries a top-level schemaVersion so the host can
|
|
11
|
+
// validate a descriptor and reject a mismatched version (the z.literal gate
|
|
12
|
+
// below fails validation when the version does not match).
|
|
13
|
+
|
|
14
|
+
export const SUPPORTED_PROVISION_SCHEMA_VERSION = 1 as const;
|
|
15
|
+
|
|
16
|
+
// ── docker ──
|
|
17
|
+
// A compose-backed component: the host brings up `service` in `composeFile`,
|
|
18
|
+
// optionally running `initService` first, optionally running a `migration`
|
|
19
|
+
// command once the service is healthy, and optionally exposing a connection
|
|
20
|
+
// string via `connection.template`.
|
|
21
|
+
|
|
22
|
+
const DockerMigrationSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
command: z.string().min(1),
|
|
25
|
+
args: z.array(z.string()).optional(),
|
|
26
|
+
})
|
|
27
|
+
.strict();
|
|
28
|
+
|
|
29
|
+
const DockerConnectionSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
template: z.string().min(1),
|
|
32
|
+
})
|
|
33
|
+
.strict();
|
|
34
|
+
|
|
35
|
+
export const DockerProvisionDescriptorSchema = z
|
|
36
|
+
.object({
|
|
37
|
+
schemaVersion: z.literal(SUPPORTED_PROVISION_SCHEMA_VERSION),
|
|
38
|
+
kind: z.literal("docker"),
|
|
39
|
+
composeFile: z.string().min(1),
|
|
40
|
+
service: z.string().min(1),
|
|
41
|
+
initService: z.string().min(1).optional(),
|
|
42
|
+
portEnvVar: z.string().min(1).optional(),
|
|
43
|
+
migration: DockerMigrationSchema.optional(),
|
|
44
|
+
connection: DockerConnectionSchema.optional(),
|
|
45
|
+
assignedContainerId: z.string().min(1).optional(),
|
|
46
|
+
// Component-level env injected into the compose interpolation environment
|
|
47
|
+
// (and the migration process env), merged alongside the allocated port. This
|
|
48
|
+
// mirrors the built-in database path, which folds `componentConfig.env` into
|
|
49
|
+
// the compose `portOverrides` (see bench-manager `startDockerComponent`), so a
|
|
50
|
+
// plugin-backed database reaches env parity (CP-FR-004, CP-FR-007).
|
|
51
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
52
|
+
healthcheck: z.boolean().optional(),
|
|
53
|
+
})
|
|
54
|
+
.strict();
|
|
55
|
+
export type DockerProvisionDescriptor = z.infer<typeof DockerProvisionDescriptorSchema>;
|
|
56
|
+
|
|
57
|
+
// ── process ──
|
|
58
|
+
// A long-running process the host owns: `command` is started in `cwd` with the
|
|
59
|
+
// merged `env` / `envFile`, optionally after running a one-time `setup`, once
|
|
60
|
+
// the named `dependsOn` components are up.
|
|
61
|
+
|
|
62
|
+
export const ProcessProvisionDescriptorSchema = z
|
|
63
|
+
.object({
|
|
64
|
+
schemaVersion: z.literal(SUPPORTED_PROVISION_SCHEMA_VERSION),
|
|
65
|
+
kind: z.literal("process"),
|
|
66
|
+
command: z.string().min(1),
|
|
67
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
68
|
+
envFile: z.string().min(1).optional(),
|
|
69
|
+
cwd: z.string().min(1).optional(),
|
|
70
|
+
setup: z.string().min(1).optional(),
|
|
71
|
+
dependsOn: z.array(z.string()).optional(),
|
|
72
|
+
})
|
|
73
|
+
.strict();
|
|
74
|
+
export type ProcessProvisionDescriptor = z.infer<typeof ProcessProvisionDescriptorSchema>;
|
|
75
|
+
|
|
76
|
+
// ── oneshot ──
|
|
77
|
+
// A run-to-completion command (the FR-022 deploy stress-test shape): like a
|
|
78
|
+
// process but expected to exit, with an optional `timeoutMs` ceiling.
|
|
79
|
+
|
|
80
|
+
export const OneshotProvisionDescriptorSchema = z
|
|
81
|
+
.object({
|
|
82
|
+
schemaVersion: z.literal(SUPPORTED_PROVISION_SCHEMA_VERSION),
|
|
83
|
+
kind: z.literal("oneshot"),
|
|
84
|
+
command: z.string().min(1),
|
|
85
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
86
|
+
envFile: z.string().min(1).optional(),
|
|
87
|
+
cwd: z.string().min(1).optional(),
|
|
88
|
+
dependsOn: z.array(z.string()).optional(),
|
|
89
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
90
|
+
})
|
|
91
|
+
.strict();
|
|
92
|
+
export type OneshotProvisionDescriptor = z.infer<typeof OneshotProvisionDescriptorSchema>;
|
|
93
|
+
|
|
94
|
+
// ── union ──
|
|
95
|
+
// Discriminated on `kind`; schemaVersion is a literal field on each member, so
|
|
96
|
+
// a mismatched version fails validation without any z.intersection wrapping.
|
|
97
|
+
|
|
98
|
+
export const ProvisionDescriptorSchema = z.discriminatedUnion("kind", [
|
|
99
|
+
DockerProvisionDescriptorSchema,
|
|
100
|
+
ProcessProvisionDescriptorSchema,
|
|
101
|
+
OneshotProvisionDescriptorSchema,
|
|
102
|
+
]);
|
|
103
|
+
export type ProvisionDescriptor = z.infer<typeof ProvisionDescriptorSchema>;
|