@schnebel-crm/cli 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.
Files changed (2) hide show
  1. package/dist/cli.mjs +614 -0
  2. package/package.json +34 -0
package/dist/cli.mjs ADDED
@@ -0,0 +1,614 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import prompts from "prompts";
4
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { join, resolve } from "node:path";
6
+ import pc from "picocolors";
7
+ import { build, context } from "esbuild";
8
+ import { pathToFileURL } from "node:url";
9
+ import { homedir } from "node:os";
10
+
11
+ //#region src/utils/log.ts
12
+ const log = {
13
+ success(msg) {
14
+ console.log(`${pc.green("✓")} ${msg}`);
15
+ },
16
+ error(msg) {
17
+ console.error(`${pc.red("✗")} ${msg}`);
18
+ },
19
+ info(msg) {
20
+ console.log(`${pc.blue("ℹ")} ${msg}`);
21
+ },
22
+ warn(msg) {
23
+ console.log(`${pc.yellow("⚠")} ${msg}`);
24
+ },
25
+ step(msg) {
26
+ console.log(`${pc.cyan("→")} ${msg}`);
27
+ }
28
+ };
29
+
30
+ //#endregion
31
+ //#region src/commands/init.ts
32
+ const initCommand = new Command("init").description("Create a new Schnebel CRM integration").argument("[name]", "Integration name").action(async (name) => {
33
+ const answers = await prompts([
34
+ {
35
+ type: name ? null : "text",
36
+ name: "name",
37
+ message: "Integration name:",
38
+ initial: name
39
+ },
40
+ {
41
+ type: "text",
42
+ name: "slug",
43
+ message: "Integration slug (kebab-case):",
44
+ initial: (prev) => (name ?? prev).toLowerCase().replace(/\s+/g, "-")
45
+ },
46
+ {
47
+ type: "text",
48
+ name: "description",
49
+ message: "Description:"
50
+ },
51
+ {
52
+ type: "select",
53
+ name: "category",
54
+ message: "Category:",
55
+ choices: [
56
+ {
57
+ title: "Email",
58
+ value: "email"
59
+ },
60
+ {
61
+ title: "Calendar",
62
+ value: "calendar"
63
+ },
64
+ {
65
+ title: "Communication",
66
+ value: "communication"
67
+ },
68
+ {
69
+ title: "Analytics",
70
+ value: "analytics"
71
+ },
72
+ {
73
+ title: "Storage",
74
+ value: "storage"
75
+ },
76
+ {
77
+ title: "Payment",
78
+ value: "payment"
79
+ },
80
+ {
81
+ title: "Marketing",
82
+ value: "marketing"
83
+ },
84
+ {
85
+ title: "Automation",
86
+ value: "automation"
87
+ },
88
+ {
89
+ title: "Other",
90
+ value: "other"
91
+ }
92
+ ]
93
+ },
94
+ {
95
+ type: "select",
96
+ name: "authType",
97
+ message: "Authentication type:",
98
+ choices: [
99
+ {
100
+ title: "API Key",
101
+ value: "api_key"
102
+ },
103
+ {
104
+ title: "OAuth 2.0",
105
+ value: "oauth2"
106
+ },
107
+ {
108
+ title: "Basic Auth",
109
+ value: "basic"
110
+ },
111
+ {
112
+ title: "None",
113
+ value: "none"
114
+ },
115
+ {
116
+ title: "Custom",
117
+ value: "custom"
118
+ }
119
+ ]
120
+ }
121
+ ]);
122
+ if (!answers.slug) {
123
+ log.error("Aborted.");
124
+ return;
125
+ }
126
+ const integrationName = name ?? answers.name ?? answers.slug;
127
+ const dir = resolve(answers.slug);
128
+ await mkdir(join(dir, "src"), { recursive: true });
129
+ await writeFile(join(dir, "package.json"), JSON.stringify({
130
+ name: answers.slug,
131
+ version: "1.0.0",
132
+ type: "module",
133
+ private: true,
134
+ scripts: {
135
+ build: "schnebel build",
136
+ dev: "schnebel dev",
137
+ deploy: "schnebel deploy"
138
+ },
139
+ dependencies: { "@schnebel-crm/integration-sdk": "^0.1.0" },
140
+ devDependencies: {
141
+ "@schnebel-crm/cli": "^0.1.0",
142
+ typescript: "^5"
143
+ }
144
+ }, null, 2));
145
+ await writeFile(join(dir, "tsconfig.json"), JSON.stringify({
146
+ compilerOptions: {
147
+ target: "ESNext",
148
+ module: "ESNext",
149
+ moduleResolution: "bundler",
150
+ strict: true,
151
+ skipLibCheck: true,
152
+ esModuleInterop: true,
153
+ verbatimModuleSyntax: true,
154
+ outDir: "./dist",
155
+ declaration: true
156
+ },
157
+ include: ["src"]
158
+ }, null, 2));
159
+ await writeFile(join(dir, "schnebel.config.ts"), `import { defineConfig } from "@schnebel-crm/integration-sdk/config";
160
+
161
+ export default defineConfig({
162
+ entry: "./src/index.ts",
163
+ // github: "https://github.com/your-org/${answers.slug}",
164
+ });
165
+ `);
166
+ await writeFile(join(dir, "src", "definition.ts"), `import { defineIntegration } from "@schnebel-crm/integration-sdk";
167
+
168
+ export const definition = defineIntegration({
169
+ id: "${answers.slug}",
170
+ name: "${integrationName}",
171
+ description: "${answers.description ?? ""}",
172
+ icon: "IconPlug",
173
+ category: "${answers.category}",
174
+ authType: "${answers.authType}",
175
+ version: "1.0.0",
176
+ webhookSupport: false,
177
+ capabilities: [],
178
+
179
+ configFields: [
180
+ // {
181
+ // key: "example",
182
+ // label: "Example Field",
183
+ // type: "text",
184
+ // required: true,
185
+ // },
186
+ ],
187
+
188
+ credentialFields: [${answers.authType === "api_key" ? `
189
+ {
190
+ key: "api_key",
191
+ label: "API Key",
192
+ type: "secret",
193
+ required: true,
194
+ placeholder: "Your API key",
195
+ },` : ""}
196
+ ],
197
+ });
198
+ `);
199
+ await writeFile(join(dir, "src", "handler.ts"), `import { defineHandler, createAction } from "@schnebel-crm/integration-sdk";
200
+
201
+ export const handler = defineHandler({
202
+ async testConnection(ctx) {
203
+ // TODO: Validate credentials and return connection status
204
+ try {
205
+ // Example: await fetch("https://api.example.com/health", {
206
+ // headers: { Authorization: \`Bearer \${ctx.credentials.api_key}\` },
207
+ // });
208
+ return { success: true };
209
+ } catch (err) {
210
+ return {
211
+ success: false,
212
+ error: err instanceof Error ? err.message : "Connection failed",
213
+ };
214
+ }
215
+ },
216
+
217
+ actions: [
218
+ // createAction({
219
+ // id: "example_action",
220
+ // name: "Example Action",
221
+ // description: "An example action.",
222
+ // async execute(ctx, input) {
223
+ // return { result: "done" };
224
+ // },
225
+ // }),
226
+ ],
227
+ });
228
+ `);
229
+ await writeFile(join(dir, "src", "index.ts"), `export { definition } from "./definition.js";
230
+ export { handler } from "./handler.js";
231
+ `);
232
+ await writeFile(join(dir, ".gitignore"), `node_modules/
233
+ dist/
234
+ .schnebel/
235
+ `);
236
+ console.log("");
237
+ log.success(`Created ${pc.bold(answers.slug)} integration`);
238
+ console.log("");
239
+ console.log(` ${pc.dim("$")} cd ${answers.slug}`);
240
+ console.log(` ${pc.dim("$")} npm install`);
241
+ console.log(` ${pc.dim("$")} npm run build`);
242
+ console.log("");
243
+ });
244
+
245
+ //#endregion
246
+ //#region src/utils/config.ts
247
+ const DEFAULTS = {
248
+ entry: "./src/index.ts",
249
+ outDir: "./dist",
250
+ github: ""
251
+ };
252
+ async function loadConfig(cwd) {
253
+ const configPath = resolve(cwd, "schnebel.config.ts");
254
+ try {
255
+ const mod = await import(pathToFileURL(configPath).href);
256
+ const userConfig = mod.default ?? mod;
257
+ return {
258
+ ...DEFAULTS,
259
+ ...userConfig
260
+ };
261
+ } catch {
262
+ return DEFAULTS;
263
+ }
264
+ }
265
+
266
+ //#endregion
267
+ //#region src/commands/build.ts
268
+ const buildCommand = new Command("build").description("Build the integration into a deployable bundle").action(async () => {
269
+ const cwd = process.cwd();
270
+ const config = await loadConfig(cwd);
271
+ const entryPoint = resolve(cwd, config.entry);
272
+ const outDir = resolve(cwd, config.outDir);
273
+ const outFile = join(outDir, "index.mjs");
274
+ log.step("Building integration...");
275
+ try {
276
+ await build({
277
+ entryPoints: [entryPoint],
278
+ bundle: true,
279
+ format: "esm",
280
+ platform: "node",
281
+ target: "node20",
282
+ outfile: outFile,
283
+ external: ["@schnebel-crm/integration-sdk"],
284
+ minify: false,
285
+ sourcemap: false
286
+ });
287
+ log.step("Generating manifest...");
288
+ const mod = await import(pathToFileURL(outFile).href);
289
+ if (!mod.definition) {
290
+ log.error("Bundle must export a 'definition' named export.");
291
+ process.exit(1);
292
+ }
293
+ if (!mod.handler) {
294
+ log.error("Bundle must export a 'handler' named export.");
295
+ process.exit(1);
296
+ }
297
+ const def = mod.definition;
298
+ const manifest = {
299
+ id: def.id,
300
+ name: def.name,
301
+ version: def.version,
302
+ description: def.description,
303
+ icon: def.icon,
304
+ category: def.category,
305
+ authType: def.authType,
306
+ capabilities: def.capabilities,
307
+ webhookSupport: def.webhookSupport,
308
+ multiInstance: def.multiInstance ?? false,
309
+ configFields: def.configFields,
310
+ credentialFields: def.credentialFields,
311
+ documentationUrl: def.documentationUrl,
312
+ tags: def.tags,
313
+ sdkVersion: "0.1.0"
314
+ };
315
+ await writeFile(join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
316
+ log.success(`Built ${def.name}@${def.version}`);
317
+ log.info(` Bundle: ${outFile}`);
318
+ log.info(` Manifest: ${join(outDir, "manifest.json")}`);
319
+ } catch (err) {
320
+ log.error(`Build failed: ${err instanceof Error ? err.message : String(err)}`);
321
+ process.exit(1);
322
+ }
323
+ });
324
+
325
+ //#endregion
326
+ //#region src/commands/validate.ts
327
+ const validateCommand = new Command("validate").description("Validate the built integration bundle").action(async () => {
328
+ const cwd = process.cwd();
329
+ const outDir = resolve(cwd, (await loadConfig(cwd)).outDir);
330
+ const bundlePath = join(outDir, "index.mjs");
331
+ const manifestPath = join(outDir, "manifest.json");
332
+ let hasErrors = false;
333
+ try {
334
+ await access(bundlePath);
335
+ } catch {
336
+ log.error("Bundle not found. Run 'schnebel build' first.");
337
+ process.exit(1);
338
+ }
339
+ try {
340
+ await access(manifestPath);
341
+ } catch {
342
+ log.error("Manifest not found. Run 'schnebel build' first.");
343
+ process.exit(1);
344
+ }
345
+ log.step("Validating bundle exports...");
346
+ try {
347
+ const mod = await import(pathToFileURL(bundlePath).href);
348
+ if (!mod.definition) {
349
+ log.error("Missing 'definition' export.");
350
+ hasErrors = true;
351
+ } else {
352
+ const def = mod.definition;
353
+ if (!def.id) {
354
+ log.error("definition.id is required");
355
+ hasErrors = true;
356
+ }
357
+ if (!def.name) {
358
+ log.error("definition.name is required");
359
+ hasErrors = true;
360
+ }
361
+ if (!def.version) {
362
+ log.error("definition.version is required");
363
+ hasErrors = true;
364
+ }
365
+ if (!def.category) {
366
+ log.error("definition.category is required");
367
+ hasErrors = true;
368
+ }
369
+ if (!/^[a-z0-9-]+$/.test(def.id)) {
370
+ log.error("definition.id must be lowercase alphanumeric with hyphens only");
371
+ hasErrors = true;
372
+ }
373
+ if (!hasErrors) log.success(`Definition: ${def.name} (${def.id}@${def.version})`);
374
+ }
375
+ if (!mod.handler) {
376
+ log.error("Missing 'handler' export.");
377
+ hasErrors = true;
378
+ } else {
379
+ if (typeof mod.handler.testConnection !== "function") {
380
+ log.error("handler.testConnection must be a function");
381
+ hasErrors = true;
382
+ }
383
+ if (!Array.isArray(mod.handler.actions)) {
384
+ log.error("handler.actions must be an array");
385
+ hasErrors = true;
386
+ }
387
+ if (!hasErrors) log.success(`Handler: testConnection + ${mod.handler.actions.length} action(s)`);
388
+ }
389
+ } catch (err) {
390
+ log.error(`Failed to load bundle: ${err instanceof Error ? err.message : String(err)}`);
391
+ hasErrors = true;
392
+ }
393
+ if (hasErrors) {
394
+ log.error("Validation failed.");
395
+ process.exit(1);
396
+ }
397
+ log.success("Validation passed.");
398
+ });
399
+
400
+ //#endregion
401
+ //#region src/utils/auth.ts
402
+ const CONFIG_DIR = join(homedir(), ".schnebel");
403
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
404
+ async function saveAuth(config) {
405
+ await mkdir(CONFIG_DIR, { recursive: true });
406
+ await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
407
+ }
408
+ async function loadAuth() {
409
+ try {
410
+ const data = await readFile(CONFIG_FILE, "utf-8");
411
+ return JSON.parse(data);
412
+ } catch {
413
+ return null;
414
+ }
415
+ }
416
+
417
+ //#endregion
418
+ //#region src/commands/deploy.ts
419
+ const deployCommand = new Command("deploy").description("Deploy the integration to your Schnebel CRM workspace").action(async () => {
420
+ const auth = await loadAuth();
421
+ if (!auth) {
422
+ log.error("Not authenticated. Run 'schnebel login' first.");
423
+ process.exit(1);
424
+ }
425
+ const cwd = process.cwd();
426
+ const outDir = resolve(cwd, (await loadConfig(cwd)).outDir);
427
+ log.step("Reading build artifacts...");
428
+ let bundle;
429
+ let manifest;
430
+ try {
431
+ bundle = await readFile(join(outDir, "index.mjs"));
432
+ manifest = await readFile(join(outDir, "manifest.json"), "utf-8");
433
+ } catch {
434
+ log.error("Build artifacts not found. Run 'schnebel build' first.");
435
+ process.exit(1);
436
+ }
437
+ const manifestData = JSON.parse(manifest);
438
+ log.step(`Deploying ${manifestData.name}@${manifestData.version}...`);
439
+ try {
440
+ const formData = new FormData();
441
+ formData.append("bundle", new Blob([bundle]), "index.mjs");
442
+ formData.append("manifest", manifest);
443
+ const res = await fetch(`${auth.url}/api/integrations/custom/upload`, {
444
+ method: "POST",
445
+ headers: { "x-api-key": auth.apiKey },
446
+ body: formData
447
+ });
448
+ if (!res.ok) {
449
+ const body = await res.text();
450
+ log.error(`Deploy failed (${res.status}): ${body}`);
451
+ process.exit(1);
452
+ }
453
+ const result = await res.json();
454
+ log.success(`Deployed ${manifestData.name}@${manifestData.version} to your workspace`);
455
+ log.info(`Slug: ${result.slug}`);
456
+ } catch (err) {
457
+ log.error(`Deploy failed: ${err instanceof Error ? err.message : String(err)}`);
458
+ process.exit(1);
459
+ }
460
+ });
461
+
462
+ //#endregion
463
+ //#region src/commands/publish.ts
464
+ const publishCommand = new Command("publish").description("Submit the integration for App Store review").action(async () => {
465
+ const auth = await loadAuth();
466
+ if (!auth) {
467
+ log.error("Not authenticated. Run 'schnebel login' first.");
468
+ process.exit(1);
469
+ }
470
+ const cwd = process.cwd();
471
+ const config = await loadConfig(cwd);
472
+ if (!config.github) {
473
+ log.error("A GitHub repository URL is required for public integrations.");
474
+ log.info("Add 'github' to your schnebel.config.ts:");
475
+ log.info(" github: \"https://github.com/your-org/your-integration\"");
476
+ process.exit(1);
477
+ }
478
+ try {
479
+ const repoUrl = config.github.replace("https://github.com/", "https://api.github.com/repos/");
480
+ const res = await fetch(repoUrl);
481
+ if (!res.ok) {
482
+ log.error("GitHub repository not found or not accessible.");
483
+ process.exit(1);
484
+ }
485
+ if ((await res.json()).private) {
486
+ log.error("GitHub repository must be public for App Store integrations.");
487
+ process.exit(1);
488
+ }
489
+ } catch {
490
+ log.warn("Could not verify GitHub repository. Proceeding anyway.");
491
+ }
492
+ const outDir = resolve(cwd, config.outDir);
493
+ let manifest;
494
+ try {
495
+ manifest = await readFile(join(outDir, "manifest.json"), "utf-8");
496
+ } catch {
497
+ log.error("Manifest not found. Run 'schnebel build' first.");
498
+ process.exit(1);
499
+ }
500
+ const manifestData = JSON.parse(manifest);
501
+ log.step(`Submitting ${manifestData.name}@${manifestData.version} for review...`);
502
+ try {
503
+ const res = await fetch(`${auth.url}/api/integrations/custom/${manifestData.id}/submit`, {
504
+ method: "POST",
505
+ headers: {
506
+ "Content-Type": "application/json",
507
+ "x-api-key": auth.apiKey
508
+ },
509
+ body: JSON.stringify({ githubUrl: config.github })
510
+ });
511
+ if (!res.ok) {
512
+ const body = await res.text();
513
+ log.error(`Submit failed (${res.status}): ${body}`);
514
+ process.exit(1);
515
+ }
516
+ log.success("Submitted for review.");
517
+ log.info("We'll notify you when the review is complete.");
518
+ } catch (err) {
519
+ log.error(`Submit failed: ${err instanceof Error ? err.message : String(err)}`);
520
+ process.exit(1);
521
+ }
522
+ });
523
+
524
+ //#endregion
525
+ //#region src/commands/login.ts
526
+ const loginCommand = new Command("login").description("Authenticate with your Schnebel CRM instance").action(async () => {
527
+ const answers = await prompts([{
528
+ type: "text",
529
+ name: "url",
530
+ message: "Schnebel CRM URL:",
531
+ initial: "https://api.schnebel-crm.de"
532
+ }, {
533
+ type: "password",
534
+ name: "apiKey",
535
+ message: "API Key:"
536
+ }]);
537
+ if (!answers.url || !answers.apiKey) {
538
+ log.error("Aborted.");
539
+ return;
540
+ }
541
+ log.step("Verifying connection...");
542
+ try {
543
+ const res = await fetch(`${answers.url}/v1/health`, { headers: { "x-api-key": answers.apiKey } });
544
+ if (!res.ok) {
545
+ log.error(`Connection failed (${res.status}). Check your URL and API key.`);
546
+ return;
547
+ }
548
+ } catch (err) {
549
+ log.error(`Connection failed: ${err instanceof Error ? err.message : String(err)}`);
550
+ return;
551
+ }
552
+ await saveAuth({
553
+ url: answers.url,
554
+ apiKey: answers.apiKey
555
+ });
556
+ log.success("Authenticated successfully.");
557
+ log.info(`Config saved to ~/.schnebel/config.json`);
558
+ });
559
+
560
+ //#endregion
561
+ //#region src/commands/whoami.ts
562
+ const whoamiCommand = new Command("whoami").description("Show current authentication status").action(async () => {
563
+ const auth = await loadAuth();
564
+ if (!auth) {
565
+ log.error("Not authenticated. Run 'schnebel login' first.");
566
+ return;
567
+ }
568
+ log.success("Authenticated");
569
+ log.info(`URL: ${auth.url}`);
570
+ log.info(`API Key: ${auth.apiKey.slice(0, 8)}...`);
571
+ });
572
+
573
+ //#endregion
574
+ //#region src/commands/dev.ts
575
+ const devCommand = new Command("dev").description("Watch and validate the integration in development mode").action(async () => {
576
+ const cwd = process.cwd();
577
+ const config = await loadConfig(cwd);
578
+ const entryPoint = resolve(cwd, config.entry);
579
+ const outDir = resolve(cwd, config.outDir);
580
+ log.info("Starting dev mode... (press Ctrl+C to stop)");
581
+ try {
582
+ await (await context({
583
+ entryPoints: [entryPoint],
584
+ bundle: true,
585
+ format: "esm",
586
+ platform: "node",
587
+ target: "node20",
588
+ outfile: `${outDir}/index.mjs`,
589
+ external: ["@schnebel-crm/integration-sdk"],
590
+ logLevel: "info"
591
+ })).watch();
592
+ log.success("Watching for changes...");
593
+ } catch (err) {
594
+ log.error(`Dev mode failed: ${err instanceof Error ? err.message : String(err)}`);
595
+ process.exit(1);
596
+ }
597
+ });
598
+
599
+ //#endregion
600
+ //#region src/cli.ts
601
+ const program = new Command();
602
+ program.name("schnebel").description("CLI for building and deploying Schnebel CRM integrations").version("0.1.0");
603
+ program.addCommand(initCommand);
604
+ program.addCommand(devCommand);
605
+ program.addCommand(buildCommand);
606
+ program.addCommand(validateCommand);
607
+ program.addCommand(deployCommand);
608
+ program.addCommand(publishCommand);
609
+ program.addCommand(loginCommand);
610
+ program.addCommand(whoamiCommand);
611
+ program.parse();
612
+
613
+ //#endregion
614
+ export { };
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@schnebel-crm/cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "CLI for building and deploying Schnebel CRM integrations",
6
+ "main": "./dist/cli.mjs",
7
+ "bin": {
8
+ "schnebel": "./dist/cli.mjs"
9
+ },
10
+ "files": ["dist"],
11
+ "scripts": {
12
+ "build": "tsdown src/cli.ts --format esm --clean",
13
+ "check-types": "tsc --noEmit",
14
+ "prepublishOnly": "pnpm run build"
15
+ },
16
+ "keywords": ["schnebel", "crm", "integration", "cli"],
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/schnebel-it/schnebel-crm"
21
+ },
22
+ "dependencies": {
23
+ "@schnebel-crm/integration-sdk": "workspace:^",
24
+ "commander": "^13.0.0",
25
+ "esbuild": "^0.25.0",
26
+ "picocolors": "^1.1.0",
27
+ "prompts": "^2.4.2"
28
+ },
29
+ "devDependencies": {
30
+ "@types/prompts": "^2.4.9",
31
+ "tsdown": "^0.16.5",
32
+ "typescript": "^5"
33
+ }
34
+ }