webim-plugin 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Orhan POLAT
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # webim-plugin
2
+
3
+ CLI for Web-IM builder plugins: scaffold a project, bundle any TypeScript/JSX
4
+ source tree into the single-module standard the dashboard accepts, validate it
5
+ with the same rules the plugin manager enforces, and push it straight to a
6
+ tenant.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install -g webim-plugin # global install
12
+ npx webim-plugin init my-widget # or one-off via npx
13
+ npm create webim-plugin my-widget # or the create flow
14
+ ```
15
+
16
+ Requires Node 18+.
17
+
18
+ ## Usage
19
+
20
+ ```bash
21
+ webim-plugin init my-widget # scaffold: webim-plugin.json, src/, README
22
+ cd my-widget
23
+ webim-plugin build # esbuild -> dist/plugin.js + validation
24
+ webim-plugin push # build, sign in, merge into builder_plugins
25
+ webim-plugin dev # watch src/, rebuild + push on every change
26
+ ```
27
+
28
+ ## Config — `webim-plugin.json`
29
+
30
+ ```json
31
+ {
32
+ "entry": "src/index.js",
33
+ "out": "dist/plugin.js",
34
+ "api": "https://your-webim/api",
35
+ "tenant": "your-tenant-slug"
36
+ }
37
+ ```
38
+
39
+ ## Auth for push/dev
40
+
41
+ Environment variables, never stored in the project:
42
+
43
+ - `WEBIM_TOKEN` — a Bearer access token (required for accounts with 2FA), or
44
+ - `WEBIM_EMAIL` + `WEBIM_PASSWORD` — a normal dashboard sign-in.
45
+
46
+ The account needs the `settings.tenant` edit grant (owner/admin pass).
47
+
48
+ ## The plugin standard
49
+
50
+ One ES module, default-exporting:
51
+
52
+ ```js
53
+ export default {
54
+ id: 'kebab-slug', // stable — saved pages reference it
55
+ name: 'Display Name',
56
+ version: '1.0.0',
57
+ blocks: [{
58
+ id: 'kebab-slug', // stable per plugin
59
+ label: 'Toolbox label',
60
+ props: { /* defaults */ },
61
+ settings: [{ name, label, type }], // text|number|switch|select|color|image
62
+ mount(el, props, ctx) {}, // ctx = { tenant, apiUrl, locale, settings }
63
+ update(el, props, ctx) {}, // optional — omitted = unmount + mount
64
+ unmount(el) {} // optional
65
+ }]
66
+ };
67
+ ```
68
+
69
+ Source can span as many files as you like — `build` flattens them. Keep DOM
70
+ work inside `mount`/`update`: the built module is imported in Node during
71
+ validation, so module-level `document` access fails the build (on purpose).
72
+
73
+ Pushing merges by plugin id over the tenant's existing catalog — one plugin
74
+ per project, other installed plugins untouched.
75
+
76
+ ## License
77
+
78
+ [MIT](./LICENSE)
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console */
3
+ // webim-plugin — the Web-IM builder plugin CLI.
4
+ //
5
+ // webim-plugin init [dir] scaffold a plugin project
6
+ // webim-plugin build bundle src/ into dist/plugin.js and validate it
7
+ // webim-plugin push build + upload to the tenant's builder_plugins
8
+ // webim-plugin dev watch src/, rebuild and push on every change
9
+ //
10
+ // The build target is the SINGLE-MODULE STANDARD the dashboard's plugin
11
+ // manager accepts (see ui components/builder/plugins/runtime.js): one ES
12
+ // module whose default export is { id, name, version, blocks: [{ id, label,
13
+ // props, settings, mount(el, props, ctx), update?, unmount? }] }. Write
14
+ // TypeScript/JSX across as many files as you like — esbuild flattens them.
15
+ //
16
+ // Auth for push: WEBIM_TOKEN (a Bearer access token) or WEBIM_EMAIL +
17
+ // WEBIM_PASSWORD (a normal dashboard sign-in; accounts with 2FA must use
18
+ // WEBIM_TOKEN). The account needs the settings.tenant edit grant — owner or
19
+ // admin roles pass automatically.
20
+
21
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
22
+ import { existsSync } from 'node:fs';
23
+ import path from 'node:path';
24
+ import { pathToFileURL } from 'node:url';
25
+
26
+ const CONFIG_FILE = 'webim-plugin.json';
27
+
28
+ const HELP = `webim-plugin — Web-IM builder plugin CLI
29
+
30
+ Commands:
31
+ init [dir] scaffold a plugin project (webim-plugin.json, src/, README)
32
+ build bundle the entry into dist/plugin.js and validate it
33
+ push build, then upload to the tenant's builder_plugins setting
34
+ dev watch the source; rebuild and push on every change
35
+
36
+ Config (webim-plugin.json):
37
+ { "entry": "src/index.js", "out": "dist/plugin.js",
38
+ "api": "http://localhost:4100/api", "tenant": "root" }
39
+
40
+ Auth (push/dev): WEBIM_TOKEN, or WEBIM_EMAIL + WEBIM_PASSWORD.`;
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // scaffolding
44
+
45
+ const TEMPLATE_CONFIG = (name) => `${JSON.stringify(
46
+ { entry: 'src/index.js', out: 'dist/plugin.js', api: 'http://localhost:4100/api', tenant: 'root' },
47
+ null,
48
+ 2
49
+ )}\n`;
50
+
51
+ const TEMPLATE_PLUGIN = (id, name) => `// ${name} — a Web-IM builder plugin.
52
+ //
53
+ // One module is the whole plugin. Split code into as many files as you like
54
+ // ("webim-plugin build" bundles them), use TypeScript or JSX if you prefer —
55
+ // only this default export shape is the contract.
56
+
57
+ export default {
58
+ id: '${id}',
59
+ name: '${name}',
60
+ version: '1.0.0',
61
+ blocks: [
62
+ {
63
+ id: 'card',
64
+ label: '${name}',
65
+ // Default prop values — what a freshly dropped block starts with.
66
+ props: { title: 'Hello from ${name}', color: '#6366f1' },
67
+ // Typed fields the builder's Inspector renders for this block.
68
+ // Types: text | number | switch | select | color | image
69
+ settings: [
70
+ { name: 'title', label: 'Title', type: 'text' },
71
+ { name: 'color', label: 'Color', type: 'color' }
72
+ ],
73
+ /**
74
+ * Renders the block. \`el\` is yours until unmount; \`ctx\` is
75
+ * { tenant, apiUrl, locale, settings }.
76
+ */
77
+ mount(el, props, ctx) {
78
+ el.innerHTML = '';
79
+
80
+ const card = document.createElement('div');
81
+
82
+ card.style.cssText = 'padding:18px 22px;border-radius:10px;color:#fff;font-weight:600;';
83
+ card.style.background = props.color || '#6366f1';
84
+ card.textContent = props.title || '${name}';
85
+ el.appendChild(card);
86
+ },
87
+ /** Called when props change in the editor; omit to remount instead. */
88
+ update(el, props, ctx) {
89
+ const card = el.firstChild;
90
+
91
+ if (!card) return this.mount(el, props, ctx);
92
+ card.style.background = props.color || '#6366f1';
93
+ card.textContent = props.title || '${name}';
94
+ },
95
+ unmount(el) {}
96
+ }
97
+ ]
98
+ };
99
+ `;
100
+
101
+ const TEMPLATE_README = (id, name) => `# ${name}
102
+
103
+ A Web-IM builder plugin. Develop:
104
+
105
+ \`\`\`bash
106
+ npm install
107
+ npx webim-plugin build # bundle + validate -> dist/plugin.js
108
+ npx webim-plugin push # upload to the tenant in webim-plugin.json
109
+ npx webim-plugin dev # watch + rebuild + push
110
+ \`\`\`
111
+
112
+ Set credentials for push: \`WEBIM_TOKEN\`, or \`WEBIM_EMAIL\` + \`WEBIM_PASSWORD\`.
113
+ `;
114
+
115
+ const TEMPLATE_PACKAGE = (id) => `${JSON.stringify(
116
+ {
117
+ name: id,
118
+ private: true,
119
+ version: '1.0.0',
120
+ type: 'module',
121
+ scripts: { build: 'webim-plugin build', push: 'webim-plugin push', dev: 'webim-plugin dev' },
122
+ devDependencies: { 'webim-plugin': '^0.1.0' }
123
+ },
124
+ null,
125
+ 2
126
+ )}\n`;
127
+
128
+ async function init(dirArg) {
129
+ const dir = path.resolve(dirArg ?? '.');
130
+ const id = path
131
+ .basename(dir)
132
+ .toLowerCase()
133
+ .replace(/[^a-z0-9]+/g, '-')
134
+ .replace(/(^-|-$)/g, '') || 'my-plugin';
135
+ const name = id
136
+ .split('-')
137
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
138
+ .join(' ');
139
+
140
+ if (existsSync(path.join(dir, CONFIG_FILE))) {
141
+ throw new Error(`${CONFIG_FILE} already exists in ${dir}`);
142
+ }
143
+
144
+ await mkdir(path.join(dir, 'src'), { recursive: true });
145
+ await writeFile(path.join(dir, CONFIG_FILE), TEMPLATE_CONFIG(name));
146
+ await writeFile(path.join(dir, 'src', 'index.js'), TEMPLATE_PLUGIN(id, name));
147
+ await writeFile(path.join(dir, 'README.md'), TEMPLATE_README(id, name));
148
+
149
+ if (!existsSync(path.join(dir, 'package.json'))) {
150
+ await writeFile(path.join(dir, 'package.json'), TEMPLATE_PACKAGE(id));
151
+ }
152
+
153
+ console.log(`Scaffolded "${id}" in ${dir}`);
154
+ console.log('Next: edit webim-plugin.json (api/tenant), then `webim-plugin build`.');
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // build + validate
159
+
160
+ async function loadConfig() {
161
+ const file = path.resolve(CONFIG_FILE);
162
+
163
+ if (!existsSync(file)) throw new Error(`No ${CONFIG_FILE} here — run \`webim-plugin init\` first`);
164
+
165
+ const config = JSON.parse(await readFile(file, 'utf8'));
166
+
167
+ return {
168
+ entry: config.entry ?? 'src/index.js',
169
+ out: config.out ?? 'dist/plugin.js',
170
+ api: config.api ?? 'http://localhost:4100/api',
171
+ tenant: config.tenant ?? ''
172
+ };
173
+ }
174
+
175
+ async function bundle(config) {
176
+ const esbuild = await import('esbuild');
177
+ const result = await esbuild.build({
178
+ entryPoints: [config.entry],
179
+ outfile: config.out,
180
+ bundle: true,
181
+ format: 'esm',
182
+ platform: 'browser',
183
+ target: 'es2020',
184
+ minify: false,
185
+ logLevel: 'silent'
186
+ });
187
+
188
+ if (result.errors.length) throw new Error(result.errors.map((e) => e.text).join('\n'));
189
+
190
+ return config.out;
191
+ }
192
+
193
+ /**
194
+ * The same rules the dashboard's packPlugin enforces (keep in sync with
195
+ * ui components/builder/plugins/runtime.js) — failing here beats failing in
196
+ * the manager after an upload.
197
+ */
198
+ function validatePlugin(plugin) {
199
+ if (!plugin || typeof plugin !== 'object') throw new Error('The module must default-export the plugin object');
200
+ if (typeof plugin.id !== 'string' || !/^[a-z0-9-]+$/.test(plugin.id)) {
201
+ throw new Error('`id` must be a kebab-case slug');
202
+ }
203
+ if (typeof plugin.name !== 'string' || !plugin.name.trim()) throw new Error('`name` is required');
204
+ if (!Array.isArray(plugin.blocks) || !plugin.blocks.length) throw new Error('`blocks` must be a non-empty array');
205
+
206
+ for (const block of plugin.blocks) {
207
+ if (!block || typeof block !== 'object') throw new Error('Every block must be an object');
208
+ if (typeof block.id !== 'string' || !/^[a-z0-9-]+$/.test(block.id)) {
209
+ throw new Error('Every block needs a kebab-case `id`');
210
+ }
211
+ if (typeof block.label !== 'string' || !block.label.trim()) throw new Error(`Block "${block.id}" needs a label`);
212
+ if (typeof block.mount !== 'function') throw new Error(`Block "${block.id}" needs a mount(el, props, ctx) function`);
213
+ }
214
+
215
+ return {
216
+ id: plugin.id,
217
+ name: plugin.name,
218
+ version: typeof plugin.version === 'string' ? plugin.version : '1.0.0',
219
+ blocks: plugin.blocks.map((block) => ({
220
+ id: block.id,
221
+ label: block.label,
222
+ props: block.props && typeof block.props === 'object' ? block.props : {},
223
+ settings: Array.isArray(block.settings)
224
+ ? block.settings.filter((field) => field && typeof field.name === 'string')
225
+ : []
226
+ }))
227
+ };
228
+ }
229
+
230
+ async function build() {
231
+ const config = await loadConfig();
232
+ const out = await bundle(config);
233
+ // Import the BUILT file: what ships is what gets validated. Module-level
234
+ // DOM access would fail right here — keep DOM work inside mount().
235
+ const mod = await import(`${pathToFileURL(path.resolve(out)).href}?v=${Date.now()}`);
236
+ const manifest = validatePlugin(mod?.default);
237
+
238
+ console.log(`Built ${out}`);
239
+ console.log(
240
+ ` ${manifest.name} v${manifest.version} (${manifest.id}) — blocks: ${manifest.blocks
241
+ .map((block) => block.id)
242
+ .join(', ')}`
243
+ );
244
+
245
+ return { config, out, manifest };
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // push
250
+
251
+ async function api(config, pathname, { method = 'GET', token, body } = {}) {
252
+ const response = await fetch(`${config.api}${pathname}`, {
253
+ method,
254
+ headers: {
255
+ 'content-type': 'application/json',
256
+ ...(config.tenant ? { 'x-tenant': config.tenant } : {}),
257
+ ...(token ? { authorization: `Bearer ${token}` } : {})
258
+ },
259
+ body: body === undefined ? undefined : JSON.stringify(body)
260
+ });
261
+ const data = await response.json().catch(() => null);
262
+
263
+ if (!response.ok || data?.success === false) {
264
+ const error = data?.error ?? {};
265
+
266
+ throw new Error(`${method} ${pathname} failed: ${error.code ?? response.status} ${error.message ?? ''}`.trim());
267
+ }
268
+
269
+ return data?.payload;
270
+ }
271
+
272
+ async function resolveToken(config) {
273
+ if (process.env.WEBIM_TOKEN) return process.env.WEBIM_TOKEN;
274
+
275
+ const email = process.env.WEBIM_EMAIL;
276
+ const password = process.env.WEBIM_PASSWORD;
277
+
278
+ if (!email || !password) {
279
+ throw new Error('Set WEBIM_TOKEN, or WEBIM_EMAIL and WEBIM_PASSWORD, to push');
280
+ }
281
+
282
+ const session = await api(config, '/auth/login', { method: 'POST', body: { email, password } });
283
+ const token = session?.session?.access?.token;
284
+
285
+ if (!token) throw new Error('Login succeeded but no access token came back');
286
+
287
+ return token;
288
+ }
289
+
290
+ async function push() {
291
+ const { config, out, manifest } = await build();
292
+ const token = await resolveToken(config);
293
+ const code = await readFile(path.resolve(out), 'utf8');
294
+
295
+ // A personal access token is TENANT-BOUND: authed routes write to the
296
+ // token's tenant and ignore X-Tenant. Ask /me who we really are and refuse
297
+ // a mismatch — a silent cross-tenant push is how catalogs get polluted.
298
+ const me = await api(config, '/me', { token });
299
+ const actualTenant = me?.tenant?.slug;
300
+
301
+ if (config.tenant && actualTenant && (actualTenant !== config.tenant)) {
302
+ throw new Error(
303
+ `This credential acts on tenant "${actualTenant}" but webim-plugin.json says "${config.tenant}" — ` +
304
+ 'use a token minted in the right tenant, or fix the config'
305
+ );
306
+ }
307
+
308
+ // Merge over the tenant's existing catalog — pushing one plugin must not
309
+ // wipe the others.
310
+ const rows = await api(config, '/settings/tenant', { token });
311
+ const current = Array.isArray(rows) ? rows.find((row) => row?.key === 'builder_plugins')?.value : null;
312
+ const plugins = current && typeof current.plugins === 'object' ? { ...current.plugins } : {};
313
+
314
+ plugins[manifest.id] = { code, manifest };
315
+
316
+ await api(config, '/settings/builder_plugins', {
317
+ method: 'PUT',
318
+ token,
319
+ body: { value: { plugins }, is_public: true }
320
+ });
321
+
322
+ console.log(`Pushed ${manifest.id} v${manifest.version} to tenant "${actualTenant ?? config.tenant}" (${config.api})`);
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // dev (watch)
327
+
328
+ async function dev() {
329
+ const config = await loadConfig();
330
+ const esbuild = await import('esbuild');
331
+ const context = await esbuild.context({
332
+ entryPoints: [config.entry],
333
+ outfile: config.out,
334
+ bundle: true,
335
+ format: 'esm',
336
+ platform: 'browser',
337
+ target: 'es2020',
338
+ logLevel: 'silent',
339
+ plugins: [
340
+ {
341
+ name: 'webim-push',
342
+ setup(buildHook) {
343
+ buildHook.onEnd(async (result) => {
344
+ if (result.errors.length) {
345
+ console.error(result.errors.map((e) => e.text).join('\n'));
346
+
347
+ return;
348
+ }
349
+
350
+ try {
351
+ await push();
352
+ } catch (err) {
353
+ console.error(String(err?.message ?? err));
354
+ }
355
+ });
356
+ }
357
+ }
358
+ ]
359
+ });
360
+
361
+ await context.watch();
362
+ console.log(`Watching ${config.entry} — every change rebuilds and pushes to "${config.tenant}". Ctrl-C to stop.`);
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+
367
+ const [command, argument] = process.argv.slice(2);
368
+
369
+ try {
370
+ if ((command === '--version') || (command === '-v')) {
371
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
372
+
373
+ console.log(pkg.version);
374
+ } else if (command === 'init') await init(argument);
375
+ else if (command === 'build') await build();
376
+ else if (command === 'push') await push();
377
+ else if (command === 'dev') await dev();
378
+ else console.log(HELP);
379
+ } catch (err) {
380
+ console.error(`Error: ${err?.message ?? err}`);
381
+ process.exit(1);
382
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "webim-plugin",
3
+ "version": "1.0.0",
4
+ "description": "CLI for Web-IM builder plugins — scaffold, bundle TS/JSX into the single-module standard, validate, and push to a tenant",
5
+ "license": "MIT",
6
+ "author": "Orhan POLAT",
7
+ "type": "module",
8
+ "bin": {
9
+ "webim-plugin": "./bin/webim-plugin.js"
10
+ },
11
+ "files": [
12
+ "bin"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/morsaken/webim-plugin.git"
20
+ },
21
+ "homepage": "https://github.com/morsaken/webim-plugin#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/morsaken/webim-plugin/issues"
24
+ },
25
+ "keywords": [
26
+ "webim",
27
+ "plugin",
28
+ "cli",
29
+ "builder",
30
+ "cms",
31
+ "esbuild"
32
+ ],
33
+ "dependencies": {
34
+ "esbuild": "^0.25.0"
35
+ }
36
+ }