create-ngis-plugin 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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * **Archetype: connector** (Stage 15 Z8, F15.13).
3
+ *
4
+ * No panel, no rail entry, no UI at all — only `capabilities.commands`. A
5
+ * connector exists to be **driven from outside the browser**: a scene script
6
+ * calls `scene.plugin("org.example.connector").invoke("list-resources", {...})`
7
+ * and the invocation travels the codeenv mailbox (Stage 11 E7) to whichever
8
+ * browser tab has this scene open and this plugin active. That lane and its
9
+ * failure modes are documented at `/market/devkit/docs/agent-operation`.
10
+ *
11
+ * ## What a connector must get right
12
+ *
13
+ * - **Every value it returns crosses a JSON boundary.** The mailbox stores
14
+ * `result_json` and the sandbox reads it back over HTTP, so a returned DOM
15
+ * node, class instance or function is not "unsupported" — it is silently
16
+ * lost. F15.2(1) is the same rule for host-crossing surfaces generally; here
17
+ * it also binds the return value.
18
+ * - **The result has a size cap.** `codeenv.plugin-invoke.max-result-bytes`
19
+ * defaults to **65536**, and `max-args-bytes` likewise. A connector that
20
+ * returns a whole attribute table gets a `400`, so page and cap deliberately.
21
+ * - **A caller waits, and a caller gives up.** The mailbox TTL defaults to
22
+ * **60 s** (`codeenv.plugin-invoke.ttl-seconds`); an invocation no tab claims
23
+ * expires and the sandbox raises "expired before a browser tab claimed it".
24
+ * Do slow work in a task, return its id, and let the caller poll.
25
+ * - **The host is `ctx.host`, never a stored reference** (F15.2(2)).
26
+ * - **The command id must be prefixed with the *whole* plugin id.**
27
+ * `commandPluginId` splits on the **last** dot, so a command called
28
+ * `connector.list` would resolve to a plugin named `connector` and arrive with
29
+ * no `ctx.host` at all.
30
+ */
31
+
32
+ import { defineNgisPlugin } from "@ngis/plugin-sdk";
33
+ import type {
34
+ CommandInvokeContext,
35
+ NgisPlugin,
36
+ NgisResourceType,
37
+ } from "@ngis/plugin-sdk";
38
+
39
+ const EXT_ID = "org.example.connector";
40
+ const LIST_COMMAND_ID = `${EXT_ID}.list-resources`;
41
+ const DESCRIBE_COMMAND_ID = `${EXT_ID}.describe-scene`;
42
+
43
+ /** Well under `max-result-bytes` even with long names. Page, do not truncate
44
+ * silently: the caller is told how many rows exist. */
45
+ const PAGE_SIZE = 100;
46
+
47
+ function requireHost(ctx: CommandInvokeContext, commandId: string) {
48
+ const host = ctx.host;
49
+ if (!host) throw new Error(`${commandId} was invoked without a host facade`);
50
+ return host;
51
+ }
52
+
53
+ interface ListArgs {
54
+ readonly types?: readonly string[];
55
+ readonly offset?: number;
56
+ }
57
+
58
+ /** The closed set `resources.list` accepts. A caller's JSON is filtered against
59
+ * it rather than cast: an unknown type is dropped, never forwarded. */
60
+ const RESOURCE_TYPES: readonly NgisResourceType[] = ["vector", "raster", "table", "file"];
61
+
62
+ function readResourceTypes(types: readonly string[] | undefined): readonly NgisResourceType[] {
63
+ if (!types) return [];
64
+ return RESOURCE_TYPES.filter((known) => types.includes(known));
65
+ }
66
+
67
+ const plugin: NgisPlugin = defineNgisPlugin({
68
+ manifest: {
69
+ id: EXT_ID,
70
+ name: "Connector",
71
+ version: "0.1.0",
72
+ minNgisVersion: "0.4.0",
73
+ capabilities: {
74
+ commands: [
75
+ {
76
+ id: LIST_COMMAND_ID,
77
+ title: "List scene resources",
78
+ paramsSchema: {
79
+ type: "object",
80
+ properties: {
81
+ types: { type: "array", items: { type: "string" } },
82
+ offset: { type: "integer", minimum: 0 },
83
+ },
84
+ },
85
+ invoke: async (args: unknown, ctx: CommandInvokeContext) => {
86
+ const host = requireHost(ctx, LIST_COMMAND_ID);
87
+ const { types, offset = 0 } =
88
+ (typeof args === "object" && args !== null ? args : {}) as ListArgs;
89
+
90
+ const wanted = readResourceTypes(types);
91
+ const all = await host.resources.list(
92
+ wanted.length > 0 ? { types: wanted } : undefined,
93
+ );
94
+ const page = all.slice(offset, offset + PAGE_SIZE);
95
+
96
+ return {
97
+ total: all.length,
98
+ offset,
99
+ // `nextOffset` is `null` at the end rather than absent: a caller
100
+ // must be able to tell "no more" from "the field was dropped".
101
+ nextOffset: offset + PAGE_SIZE < all.length ? offset + PAGE_SIZE : null,
102
+ resources: page.map((resource) => ({
103
+ id: resource.id,
104
+ name: resource.name,
105
+ type: resource.type,
106
+ // The id every dataset API takes, and the one a recipe's input
107
+ // slot is filled with — a scene-resource id is not a file id.
108
+ fileId: resource.fileId,
109
+ })),
110
+ };
111
+ },
112
+ },
113
+ {
114
+ id: DESCRIBE_COMMAND_ID,
115
+ title: "Describe the current scene",
116
+ paramsSchema: { type: "object", properties: {} },
117
+ invoke: async (_args: unknown, ctx: CommandInvokeContext) => {
118
+ const host = requireHost(ctx, DESCRIBE_COMMAND_ID);
119
+ const scene = await host.scene.get();
120
+ if (!scene) return { scene: null };
121
+ return {
122
+ scene: { id: scene.id, name: scene.name, type: scene.type },
123
+ };
124
+ },
125
+ },
126
+ ],
127
+ },
128
+ },
129
+ });
130
+
131
+ export default plugin;
132
+ export { EXT_ID, LIST_COMMAND_ID, DESCRIBE_COMMAND_ID, PAGE_SIZE };
@@ -0,0 +1,22 @@
1
+ {
2
+ "id": "org.example.tool-frontend",
3
+ "kind": "ui-plugin",
4
+ "name": "Tool front end",
5
+ "version": "0.1.0",
6
+ "minNgisVersion": "0.4.0",
7
+ "entry": "bundle.mjs",
8
+ "capabilities": {
9
+ "panels": [
10
+ { "id": "org.example.tool-frontend.main", "slot": "right" }
11
+ ],
12
+ "railTools": [
13
+ { "id": "org.example.tool-frontend.open" }
14
+ ],
15
+ "commands": [
16
+ { "id": "org.example.tool-frontend.run" }
17
+ ]
18
+ },
19
+ "description": "A rail entry and side panel for one analysis tool, with the run itself exposed as a command.",
20
+ "category": "analysis",
21
+ "tags": ["archetype", "tool-frontend"]
22
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * **Archetype: tool front end** (Stage 15 Z8, F15.13).
3
+ *
4
+ * The shape most first plugins want: one rail entry, one side panel, and a
5
+ * single analysis tool run. What makes it an archetype rather than a demo is
6
+ * where the host lives.
7
+ *
8
+ * ## The rule this file exists to demonstrate (F15.2)
9
+ *
10
+ * **The host is reached as `ctx.host`, inside the invocation, and is never
11
+ * stored.** A host handle does not outlive the call that produced it — Stage 13
12
+ * `V-142`, elevated to contract by F15.2(2) — so a `let host` at module scope is
13
+ * a shape this template must warn against rather than show, even though three
14
+ * Stage 13 dogfood plugins still carry one.
15
+ *
16
+ * **A declarative panel has no channel through which a host arrives.** The
17
+ * runtime registers `capabilities.panels` before `activate(host)` runs and
18
+ * `PanelRenderContext` carries only `close` (`V-339`, F15.2(3)). That is why the
19
+ * panel below renders and the *command* runs: the run path is a command, and a
20
+ * command's `ctx.host` is the command owner's own scope-enforced 0.4 facade.
21
+ *
22
+ * The same command is the plugin's whole external surface. A scene script calls
23
+ * it with `scene.plugin("org.example.tool-frontend").invoke("run", {...})` and a
24
+ * remote agent reaches it the same way through the codeenv mailbox — see the
25
+ * agent-operation guide at `/market/devkit/docs/agent-operation`.
26
+ */
27
+
28
+ import { defineNgisPlugin } from "@ngis/plugin-sdk";
29
+ import type {
30
+ CommandInvokeContext,
31
+ GisRailInvokeContext,
32
+ NgisPlugin,
33
+ } from "@ngis/plugin-sdk";
34
+
35
+ const EXT_ID = "org.example.tool-frontend";
36
+ const PANEL_ID = `${EXT_ID}.main`;
37
+ const OPEN_TOOL_ID = `${EXT_ID}.open`;
38
+ const RUN_COMMAND_ID = `${EXT_ID}.run`;
39
+
40
+ /**
41
+ * The analysis tool this front end drives. Replace it with a real tool id from
42
+ * `GET /api/analysis/tools/page`.
43
+ *
44
+ * Two catalog facts worth knowing before you pick one. `/ui-schema` answers
45
+ * `200` for **883** tools whose detail endpoint answers `404` because they are
46
+ * disabled (`V-371`), so read the detail endpoint before trusting a schema. And
47
+ * the nine PostGIS tools are invisible to `ngis-geoanalysis`'s binding and
48
+ * execution validation (`V-47`), so a wrong binding on one of those is not
49
+ * refused — it just runs wrong.
50
+ */
51
+ const TOOL_ID = "00000000-0000-4000-8000-000000000000";
52
+
53
+ interface RunArgs {
54
+ readonly params?: Record<string, string>;
55
+ readonly sceneId?: string;
56
+ }
57
+
58
+ function readRunArgs(args: unknown): RunArgs {
59
+ if (typeof args !== "object" || args === null) return {};
60
+ return args as RunArgs;
61
+ }
62
+
63
+ const plugin: NgisPlugin = defineNgisPlugin({
64
+ manifest: {
65
+ id: EXT_ID,
66
+ name: "Tool front end",
67
+ version: "0.1.0",
68
+ minNgisVersion: "0.4.0",
69
+ capabilities: {
70
+ panels: [
71
+ {
72
+ id: PANEL_ID,
73
+ slot: "right",
74
+ label: "Tool front end",
75
+ render: (container) => {
76
+ container.replaceChildren();
77
+ const root = document.createElement("div");
78
+ root.style.padding = "16px";
79
+ root.style.display = "grid";
80
+ root.style.gap = "8px";
81
+
82
+ const heading = document.createElement("p");
83
+ heading.textContent = "Tool front end";
84
+ const body = document.createElement("p");
85
+ body.textContent =
86
+ `Run this plugin's tool by invoking the command ${RUN_COMMAND_ID}. ` +
87
+ "The panel is presentation only: a declarative panel is registered " +
88
+ "before activate(host) runs, so no host reaches it.";
89
+
90
+ root.append(heading, body);
91
+ container.appendChild(root);
92
+ return () => container.replaceChildren();
93
+ },
94
+ },
95
+ ],
96
+ railTools: [
97
+ {
98
+ id: OPEN_TOOL_ID,
99
+ // A literal string, not an i18n key — a third-party plugin has no
100
+ // message catalog for the host to resolve against.
101
+ label: "Open tool front end",
102
+ kind: "action",
103
+ invoke: (ctx: GisRailInvokeContext) => {
104
+ ctx.api.panels.open("right", PANEL_ID);
105
+ },
106
+ },
107
+ ],
108
+ commands: [
109
+ {
110
+ id: RUN_COMMAND_ID,
111
+ title: "Run the tool",
112
+ paramsSchema: {
113
+ type: "object",
114
+ properties: {
115
+ params: {
116
+ type: "object",
117
+ description: "Tool parameters, every value a string.",
118
+ additionalProperties: { type: "string" },
119
+ },
120
+ sceneId: { type: "string" },
121
+ },
122
+ },
123
+ invoke: async (args: unknown, ctx: CommandInvokeContext) => {
124
+ // Guarded, always: an id whose namespace names no plugin the host
125
+ // knows yields a ctx with no `host` key at all, because the factory
126
+ // mints rather than validates.
127
+ const host = ctx.host;
128
+ if (!host) {
129
+ throw new Error(`${RUN_COMMAND_ID} was invoked without a host facade`);
130
+ }
131
+
132
+ const { params = {}, sceneId } = readRunArgs(args);
133
+ const handle = await host.tasks.run({
134
+ toolId: TOOL_ID,
135
+ params,
136
+ ...(sceneId ? { sceneId } : {}),
137
+ });
138
+
139
+ await host.ui.toast({
140
+ message: "Run submitted",
141
+ kind: "info",
142
+ description: handle.id,
143
+ });
144
+
145
+ // JSON, because this value crosses the codeenv mailbox back to the
146
+ // caller: every host-crossing value is JSON-serializable (F15.2).
147
+ return { taskId: handle.id, kind: handle.kind };
148
+ },
149
+ },
150
+ ],
151
+ },
152
+ },
153
+ });
154
+
155
+ // The bundle contract (ED16c) requires a default export — the loader's
156
+ // `import()` reads `module.default` as the `NgisPlugin`.
157
+ export default plugin;
158
+ export { EXT_ID, PANEL_ID, OPEN_TOOL_ID, RUN_COMMAND_ID, TOOL_ID };
@@ -0,0 +1,20 @@
1
+ {
2
+ "id": "org.example.workflow-app",
3
+ "kind": "ui-plugin",
4
+ "name": "Workflow app",
5
+ "version": "0.1.0",
6
+ "minNgisVersion": "0.4.0",
7
+ "entry": "bundle.mjs",
8
+ "capabilities": {
9
+ "panels": [
10
+ { "id": "org.example.workflow-app.main", "slot": "right" }
11
+ ],
12
+ "commands": [
13
+ { "id": "org.example.workflow-app.run" },
14
+ { "id": "org.example.workflow-app.status" }
15
+ ]
16
+ },
17
+ "description": "A front end for one published recipe: run it by slot key, then read the instance back.",
18
+ "category": "workflow",
19
+ "tags": ["archetype", "workflow-app", "recipe"]
20
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * **Archetype: workflow app** (Stage 15 Z8, F15.13).
3
+ *
4
+ * A front end for one **published recipe** — a saved flow graph published as a
5
+ * workflow tool and listed as a `tool`-kind extension. This is the archetype the
6
+ * describe→recipe scaffolder at `/market/author/ai` produces work for: the
7
+ * scaffolder emits the recipe (data), and a package like this one gives it a
8
+ * button and an external command.
9
+ *
10
+ * ## Three facts about a published recipe that change how you write this
11
+ *
12
+ * 1. **Its whole invocable surface is `slotKey → fileId`.**
13
+ * `compileToolSchema` emits `x-role` of `"input"` and `"output"` and never
14
+ * `"option"`, so a published workflow tool has **no knobs**. Every option the
15
+ * recipe's author chose is already baked into the published graph's node
16
+ * `params`. Passing anything but file ids here is passing nothing.
17
+ * 2. **You do not branch on it.** `host.tasks.run` forks on the registry row's
18
+ * `provider` inside the facade — `"workflow"` invokes the tool and polls an
19
+ * instance, anything else submits an analysis task. The plugin sees one API
20
+ * and must not branch itself (F13.2).
21
+ * 3. **A workflow run has no log stream.** There is no workflow SSE endpoint and
22
+ * the facade does not fabricate one: `NgisTaskState.logs` is permanently `[]`
23
+ * for `kind: "workflow"`. A UI that shows a log pane for a recipe shows an
24
+ * empty box forever.
25
+ *
26
+ * The host is reached as `ctx.host` inside each command and never stored
27
+ * (F15.2); the panel is presentation only, because a declarative panel is
28
+ * registered before `activate(host)` runs and `PanelRenderContext` carries only
29
+ * `close` (`V-339`).
30
+ */
31
+
32
+ import { defineNgisPlugin } from "@ngis/plugin-sdk";
33
+ import type { CommandInvokeContext, NgisPlugin } from "@ngis/plugin-sdk";
34
+
35
+ const EXT_ID = "org.example.workflow-app";
36
+ const PANEL_ID = `${EXT_ID}.main`;
37
+ const RUN_COMMAND_ID = `${EXT_ID}.run`;
38
+ const STATUS_COMMAND_ID = `${EXT_ID}.status`;
39
+
40
+ /**
41
+ * The published recipe this app drives — a **bare uuid in the shared
42
+ * `ngis_model_tool` id space** with `provider = "workflow"`, which is why
43
+ * `GetToolSchema(toolId)` and the ui-schema endpoint resolve it exactly like a
44
+ * builtin. Replace it with your own published recipe's tool id.
45
+ */
46
+ const RECIPE_TOOL_ID = "00000000-0000-4000-8000-000000000000";
47
+
48
+ interface RunArgs {
49
+ /** `slotKey → fileId`. Nothing else is accepted by the server. */
50
+ readonly inputs?: Record<string, string>;
51
+ readonly sceneId?: string;
52
+ }
53
+
54
+ function readArgs<T>(args: unknown): Partial<T> {
55
+ if (typeof args !== "object" || args === null) return {};
56
+ return args as Partial<T>;
57
+ }
58
+
59
+ function requireHost(ctx: CommandInvokeContext, commandId: string) {
60
+ const host = ctx.host;
61
+ if (!host) throw new Error(`${commandId} was invoked without a host facade`);
62
+ return host;
63
+ }
64
+
65
+ const plugin: NgisPlugin = defineNgisPlugin({
66
+ manifest: {
67
+ id: EXT_ID,
68
+ name: "Workflow app",
69
+ version: "0.1.0",
70
+ minNgisVersion: "0.4.0",
71
+ capabilities: {
72
+ panels: [
73
+ {
74
+ id: PANEL_ID,
75
+ slot: "right",
76
+ label: "Workflow app",
77
+ render: (container) => {
78
+ container.replaceChildren();
79
+ const root = document.createElement("div");
80
+ root.style.padding = "16px";
81
+ root.style.display = "grid";
82
+ root.style.gap = "8px";
83
+
84
+ const heading = document.createElement("p");
85
+ heading.textContent = "Workflow app";
86
+ const body = document.createElement("p");
87
+ body.textContent =
88
+ `Invoke ${RUN_COMMAND_ID} with one file id per input slot, then ` +
89
+ `${STATUS_COMMAND_ID} with the returned id. A recipe run is a ` +
90
+ "workflow instance and has no log stream.";
91
+
92
+ root.append(heading, body);
93
+ container.appendChild(root);
94
+ return () => container.replaceChildren();
95
+ },
96
+ },
97
+ ],
98
+ commands: [
99
+ {
100
+ id: RUN_COMMAND_ID,
101
+ title: "Run the recipe",
102
+ paramsSchema: {
103
+ type: "object",
104
+ properties: {
105
+ inputs: {
106
+ type: "object",
107
+ description: "slotKey -> fileId. A recipe takes no options.",
108
+ additionalProperties: { type: "string" },
109
+ },
110
+ sceneId: { type: "string" },
111
+ },
112
+ },
113
+ invoke: async (args: unknown, ctx: CommandInvokeContext) => {
114
+ const host = requireHost(ctx, RUN_COMMAND_ID);
115
+ const { inputs = {}, sceneId } = readArgs<RunArgs>(args);
116
+ const handle = await host.tasks.run({
117
+ toolId: RECIPE_TOOL_ID,
118
+ // `params` is the facade's one channel; for a workflow tool it is
119
+ // forwarded verbatim as `InvokeToolRequest.inputs`.
120
+ params: inputs,
121
+ ...(sceneId ? { sceneId } : {}),
122
+ });
123
+ return { id: handle.id, kind: handle.kind };
124
+ },
125
+ },
126
+ {
127
+ id: STATUS_COMMAND_ID,
128
+ title: "Read a run back",
129
+ paramsSchema: {
130
+ type: "object",
131
+ properties: { id: { type: "string" } },
132
+ required: ["id"],
133
+ },
134
+ invoke: async (args: unknown, ctx: CommandInvokeContext) => {
135
+ const host = requireHost(ctx, STATUS_COMMAND_ID);
136
+ const { id } = readArgs<{ id: string }>(args);
137
+ if (!id) throw new Error(`${STATUS_COMMAND_ID} requires an id`);
138
+
139
+ const state = await host.tasks.get(id);
140
+ if (!state) return { id, status: null, outputs: [] };
141
+ return {
142
+ id: state.id,
143
+ kind: state.kind,
144
+ status: state.status,
145
+ stage: state.stage,
146
+ error: state.error,
147
+ outputs: state.outputs.map((output) => ({
148
+ name: output.name,
149
+ fileId: output.fileId,
150
+ primary: output.primary,
151
+ })),
152
+ };
153
+ },
154
+ },
155
+ ],
156
+ },
157
+ },
158
+ });
159
+
160
+ export default plugin;
161
+ export { EXT_ID, PANEL_ID, RUN_COMMAND_ID, STATUS_COMMAND_ID, RECIPE_TOOL_ID };
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Bundles `src/plugin.ts` -> `dist/bundle.mjs` per the frozen bundle contract
4
+ * (ED16c, `docs/design-extension-platform.md` §5): self-contained ESM, one
5
+ * file, externals `react`/`react-dom`/`react/jsx-runtime`/`@ngis/plugin-sdk`
6
+ * (the host's import map resolves those bare specifiers at load time — never
7
+ * bundle them).
8
+ *
9
+ * Matches the shape `ngis-extension`'s own seed-bundle spike used to
10
+ * repackage `ngis.sample-coordinate-pin` (esbuild, the same externals) —
11
+ * see `ngis-backend/stages/11-extension-platform/session-2026-07-23-e1-extension-service.md`.
12
+ *
13
+ * **`react/jsx-runtime` + `jsx: "automatic"` (Stage 13 X6).** X4b added
14
+ * `react/jsx-runtime` to the loader's externals table and shipped the shim
15
+ * under `public/plugin-externals/`; this is the authoring half. The two lines
16
+ * below land together on purpose: esbuild's default JSX mode is the *classic*
17
+ * runtime, which emits `React.createElement` and never references
18
+ * `react/jsx-runtime` at all — so adding the external without the mode leaves
19
+ * a dead entry, and adding the mode without the external makes esbuild
20
+ * **resolve and inline** the host's React copy, which breaks hooks the moment
21
+ * two React instances share a page.
22
+ */
23
+
24
+ import esbuild from "esbuild";
25
+ import { existsSync, mkdirSync, statSync } from "node:fs";
26
+ import { dirname, join } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
30
+ const outfile = join(root, "dist", "bundle.mjs");
31
+
32
+ if (!existsSync(join(root, "dist"))) mkdirSync(join(root, "dist"));
33
+
34
+ await esbuild.build({
35
+ entryPoints: [join(root, "src", "plugin.ts")],
36
+ outfile,
37
+ bundle: true,
38
+ format: "esm",
39
+ platform: "browser",
40
+ target: "es2020",
41
+ jsx: "automatic",
42
+ // All five entries in the host's import map (`src/sdk/plugin-loader.ts`'s
43
+ // `PLUGIN_EXTERNAL_SHIM_URLS`). `@ngis/plugin-ui` is the fifth, added in Stage 14: its
44
+ // components' runtime lives in the host bundle, so bundling it gives you a second copy of the
45
+ // host's own components rendering against a different React — the same silent breakage as
46
+ // bundling React itself, and just as invisible at build time.
47
+ external: [
48
+ "react",
49
+ "react-dom",
50
+ "react/jsx-runtime",
51
+ "@ngis/plugin-sdk",
52
+ "@ngis/plugin-ui",
53
+ ],
54
+ sourcemap: false,
55
+ minify: false,
56
+ });
57
+
58
+ const size = statSync(outfile).size;
59
+ process.stdout.write(`[plugin-starter build] wrote ${outfile} (${size} bytes)\n`);
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Final step of `npm run package`: copies the validated `manifest.json`
4
+ * alongside the built `dist/bundle.mjs` and prints a summary — the pair is
5
+ * then ready for `POST /api/extensions/{extId}/versions` (multipart
6
+ * `manifest` + `bundle`).
7
+ */
8
+
9
+ import { copyFileSync, readFileSync, statSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
14
+ const manifestSrc = join(root, "manifest.json");
15
+ const manifestDist = join(root, "dist", "manifest.json");
16
+ const bundlePath = join(root, "dist", "bundle.mjs");
17
+
18
+ copyFileSync(manifestSrc, manifestDist);
19
+
20
+ const manifest = JSON.parse(readFileSync(manifestSrc, "utf8"));
21
+ const bundleSize = statSync(bundlePath).size;
22
+ const manifestSize = statSync(manifestDist).size;
23
+
24
+ process.stdout.write("\n[package] Ready for upload:\n");
25
+ process.stdout.write(` dist/manifest.json (${manifestSize} bytes) id=${manifest.id} version=${manifest.version}\n`);
26
+ process.stdout.write(` dist/bundle.mjs (${bundleSize} bytes)\n`);
27
+ process.stdout.write(
28
+ "\n Upload with: curl -X POST <gateway>/api/extensions/{extId}/versions \\\n" +
29
+ ' -H "Authorization: Bearer <token>" \\\n' +
30
+ ' -F "manifest=@dist/manifest.json;type=application/json" \\\n' +
31
+ ' -F "bundle=@dist/bundle.mjs;type=text/javascript"\n' +
32
+ " (creates a DRAFT — see ../../docs/plugin-authoring-guide.md for the full dev loop.)\n",
33
+ );
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Client-side sanity check for `manifest.json`, mirroring
4
+ * `ngis-extension`'s `PackageManifestValidator` (ED16a/b) — catches the same
5
+ * mistakes locally instead of on a failed upload. Not a substitute for the
6
+ * server's validation (which is the actual authority); this just saves a
7
+ * round trip.
8
+ */
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
15
+ const manifestPath = join(root, "manifest.json");
16
+
17
+ const EXT_ID_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
18
+ const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/;
19
+ const KNOWN_CAPABILITY_KEYS = new Set(["panels", "railTools", "layerTypes", "commands"]);
20
+
21
+ const errors = [];
22
+ function require_(condition, message) {
23
+ if (!condition) errors.push(message);
24
+ }
25
+
26
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
27
+
28
+ require_(typeof manifest.id === "string" && manifest.id.length > 0, "manifest.id is required");
29
+ require_(typeof manifest.name === "string" && manifest.name.length > 0, "manifest.name is required");
30
+ require_(typeof manifest.version === "string" && manifest.version.length > 0, "manifest.version is required");
31
+ require_(
32
+ typeof manifest.minNgisVersion === "string" && manifest.minNgisVersion.length > 0,
33
+ "manifest.minNgisVersion is required",
34
+ );
35
+ require_(typeof manifest.entry === "string" && manifest.entry.length > 0, "manifest.entry is required");
36
+
37
+ if (typeof manifest.id === "string") {
38
+ require_(
39
+ EXT_ID_PATTERN.test(manifest.id),
40
+ `manifest.id must match ${EXT_ID_PATTERN} (namespaced, e.g. "org.example.my-plugin"): ${manifest.id}`,
41
+ );
42
+ require_(!manifest.id.startsWith("ngis."), `manifest.id "ngis.*" is reserved for first-party plugins`);
43
+ }
44
+
45
+ if (manifest.kind !== undefined) {
46
+ require_(manifest.kind === "ui-plugin", `manifest.kind must be "ui-plugin" in this stage: ${manifest.kind}`);
47
+ }
48
+
49
+ if (typeof manifest.version === "string") {
50
+ require_(SEMVER_PATTERN.test(manifest.version), `manifest.version must be strict x.y.z: ${manifest.version}`);
51
+ }
52
+ if (typeof manifest.minNgisVersion === "string") {
53
+ require_(
54
+ SEMVER_PATTERN.test(manifest.minNgisVersion),
55
+ `manifest.minNgisVersion must be strict x.y.z: ${manifest.minNgisVersion}`,
56
+ );
57
+ }
58
+
59
+ require_(manifest.entry === "bundle.mjs", `manifest.entry is fixed to "bundle.mjs" in v1: ${manifest.entry}`);
60
+
61
+ if (manifest.capabilities === undefined || typeof manifest.capabilities !== "object" || manifest.capabilities === null) {
62
+ errors.push("manifest.capabilities must be an object");
63
+ } else {
64
+ for (const key of Object.keys(manifest.capabilities)) {
65
+ require_(KNOWN_CAPABILITY_KEYS.has(key), `Unknown capability-summary key: "${key}"`);
66
+ }
67
+ }
68
+
69
+ if (errors.length > 0) {
70
+ process.stderr.write("[validate-manifest] manifest.json is invalid:\n");
71
+ for (const error of errors) process.stderr.write(` - ${error}\n`);
72
+ process.exit(1);
73
+ }
74
+
75
+ process.stdout.write(
76
+ `[validate-manifest] manifest.json OK — id=${manifest.id} version=${manifest.version} minNgisVersion=${manifest.minNgisVersion}\n`,
77
+ );