@remkoj/optimizely-graph-cli 1.0.3

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/README.md ADDED
@@ -0,0 +1,2 @@
1
+ # Optimizely SaaS CMS CLI
2
+ Command line utitilities to work with the Optimizely CMS - SaaS Core and Content Graph
package/bin/index.js ADDED
@@ -0,0 +1,416 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Frontend Utility providing helpers for common tasks when building a Vercel
4
+ * hosted, Next.JS based website that uses Optimizely Content-Graph as content
5
+ * repository
6
+ *
7
+ * License: Apache 2
8
+ * Copyright (c) 2023 - Remko Jantzen
9
+ */
10
+
11
+ import * as dotenv from 'dotenv';
12
+ import * as dotenvExpand from 'dotenv-expand';
13
+ import * as path from 'node:path';
14
+ import path__default from 'node:path';
15
+ import yargs from 'yargs';
16
+ import { readEnvironmentVariables, applyConfigDefaults, validateConfig } from '@remkoj/optimizely-graph-client/config';
17
+ import createAdminApi, { isApiError } from '@remkoj/optimizely-graph-client/admin';
18
+ import chalk from 'chalk';
19
+ import figures from 'figures';
20
+ import Table from 'cli-table3';
21
+ import { createHmacFetch } from '@remkoj/optimizely-graph-client/client';
22
+ import fs from 'node:fs';
23
+
24
+ function processEnvFile(suffix = "") {
25
+ const envVars = dotenv.config({
26
+ path: path.resolve(process.cwd(), `.env${suffix}`)
27
+ });
28
+ dotenvExpand.expand(envVars);
29
+ }
30
+ const envName = process.env.OPTI_BUILD_ENV ?? process.env.NODE_ENV ?? 'development';
31
+ processEnvFile(`.${envName}.local`);
32
+ processEnvFile(`.${envName}`);
33
+ processEnvFile('.local');
34
+ processEnvFile();
35
+
36
+ function isDemanded(value) {
37
+ if (value == undefined || value == null)
38
+ return true;
39
+ switch (typeof (value)) {
40
+ case 'string':
41
+ return value == "";
42
+ }
43
+ return false;
44
+ }
45
+
46
+ function createCliApp(scriptName, version, epilogue) {
47
+ const config = readEnvironmentVariables();
48
+ return yargs(process.argv)
49
+ .scriptName(scriptName)
50
+ .version(version ?? "development")
51
+ .usage('$0 <cmd> [args]')
52
+ .option("dxp_url", { alias: "du", description: "Optimizely CMS URL", string: true, type: "string", demandOption: isDemanded(config.dxp_url), default: config.dxp_url })
53
+ .option("deploy_domain", { alias: "dd", description: "Frontend domain", string: true, type: "string", demandOption: isDemanded(config.deploy_domain), default: config.deploy_domain })
54
+ .option('app_key', { alias: "ak", description: "Content Graph App Key", string: true, type: "string", demandOption: isDemanded(config.app_key), default: config.app_key })
55
+ .option('secret', { alias: "s", description: "Content Graph Secret", string: true, type: "string", demandOption: isDemanded(config.secret), default: config.secret })
56
+ .option('single_key', { alias: "sk", description: "Content Graph Single Key", string: true, type: "string", demandOption: isDemanded(config.single_key), default: config.single_key })
57
+ .option('gateway', { alias: "g", description: "Content Graph Gateway", string: true, type: "string", demandOption: isDemanded(config.gateway), default: config.gateway })
58
+ .option('verbose', { description: "Enable query logging", boolean: true, type: 'boolean', demandOption: false, default: config.query_log })
59
+ .demandCommand(1, 1)
60
+ .showHelpOnFail(true)
61
+ .epilogue(epilogue ?? `Copyright Remko Jantzen - 2023-${(new Date(Date.now())).getFullYear()}`)
62
+ .help();
63
+ }
64
+ function getArgsConfig(args) {
65
+ const config = applyConfigDefaults({
66
+ dxp_url: args.dxp_url,
67
+ deploy_domain: args.deploy_domain,
68
+ app_key: args.app_key,
69
+ secret: args.secret,
70
+ single_key: args.single_key,
71
+ gateway: args.gateway,
72
+ query_log: args.verbose
73
+ });
74
+ if (!validateConfig(config, false))
75
+ throw new Error("Invalid Content-Graph connection details provided");
76
+ return config;
77
+ }
78
+ function getFrontendURL(config) {
79
+ const host = config.deploy_domain ?? 'http://localhost:3000';
80
+ const hostname = host.split(":")[0];
81
+ const scheme = hostname == 'localhost' || hostname.endsWith(".local") ? 'http:' : 'https:';
82
+ return new URL(`${scheme}//${host}/`);
83
+ }
84
+
85
+ const publishToVercelModule$2 = {
86
+ command: ['register [path] [verb]'],
87
+ handler: async (args) => {
88
+ const cgConfig = getArgsConfig(args);
89
+ const frontendUrl = getFrontendURL(cgConfig);
90
+ const hookPath = args.path ?? '/';
91
+ const verb = args.verb ?? 'POST';
92
+ const token = args.publish_token;
93
+ const webhookTarget = new URL(hookPath, frontendUrl);
94
+ if (token)
95
+ webhookTarget.searchParams.set('token', token);
96
+ process.stdout.write(`${chalk.yellow(chalk.bold(figures.arrowRight))} Registering webhook target: ${chalk.yellow(webhookTarget.href)}\n`);
97
+ if (webhookTarget.hostname == 'localhost') {
98
+ process.stderr.write(chalk.redBright(chalk.bold(figures.cross) + " Cannot register a localhost Site URL with Content Graph") + "\n");
99
+ process.exitCode = 1;
100
+ return;
101
+ }
102
+ if (!cgConfig.app_key || !cgConfig.secret)
103
+ throw new Error("Make sure both the Optimizely Graph App Key & Secret have been defined");
104
+ const adminApi = createAdminApi(cgConfig);
105
+ try {
106
+ const currentHooks = await adminApi.webhooks.listWebhookHandler();
107
+ if (currentHooks.some(x => x.request.url == webhookTarget.href)) {
108
+ process.stdout.write("\n" + chalk.greenBright(chalk.bold(figures.tick) + " Webhook already registered, no action needed") + "\n");
109
+ process.exitCode = 0;
110
+ return;
111
+ }
112
+ function urlWithoutSearch(url) {
113
+ const newURL = new URL(url.href);
114
+ for (const key in newURL.searchParams.keys)
115
+ newURL.searchParams.delete(key);
116
+ return newURL;
117
+ }
118
+ const targetWithoutQuery = urlWithoutSearch(webhookTarget);
119
+ await Promise.allSettled(currentHooks.map(async (hook) => {
120
+ const hookUrl = urlWithoutSearch(new URL(hook.request.url));
121
+ if (hookUrl.href == targetWithoutQuery.href) {
122
+ process.stdout.write(`${chalk.yellow(chalk.bold(figures.arrowRight))} Removing webhook with incorrect query parameters: ${chalk.yellow(hookUrl.href)}`);
123
+ try {
124
+ await adminApi.webhooks.deleteWebhookHandler(hook.id);
125
+ return true;
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ }
131
+ return true;
132
+ }));
133
+ await adminApi.webhooks.createWebhookHandler({
134
+ request: {
135
+ url: webhookTarget.href,
136
+ method: verb
137
+ }
138
+ });
139
+ process.stdout.write("\n" + chalk.greenBright(`${chalk.bold(figures.tick)} ${webhookTarget.href} has been added as Webhook recipient to Optimizely Graph`) + "\n");
140
+ }
141
+ catch (e) {
142
+ if (isApiError(e)) {
143
+ process.stderr.write(chalk.redBright(`${chalk.bold(figures.cross)} Optimizely Graph returned an error: HTTP ${e.status}: ${e.statusText}`) + "\n");
144
+ if (args.verbose)
145
+ console.error(chalk.redBright(JSON.stringify(e.body, undefined, 4)));
146
+ }
147
+ else {
148
+ process.stderr.write(chalk.redBright(`${chalk.bold(figures.cross)} Optimizely Graph returned an error`) + "\n");
149
+ }
150
+ process.exitCode = 1;
151
+ return;
152
+ }
153
+ },
154
+ aliases: [],
155
+ describe: "Adds a webhook to Optimizely Graph that invokes /api/content/publish on every publish in Optimizely Graph",
156
+ builder: (args) => {
157
+ const defaultToken = readEnvironmentVariables().publish;
158
+ const hasDefaultToken = typeof (defaultToken) == 'string' && defaultToken.length > 0;
159
+ args.positional('path', { type: "string", describe: "The frontend route to invoke to publish", default: "/api/content/publish", demandOption: false });
160
+ args.positional('verb', { type: "string", describe: "The HTTP verb to be used when sending the webhook", default: "POST", demandOption: false });
161
+ args.option("publish_token", { alias: "pt", description: "Publishing token", string: true, type: "string", demandOption: !hasDefaultToken, default: defaultToken });
162
+ return args;
163
+ }
164
+ };
165
+
166
+ const publishToVercelModule$1 = {
167
+ command: ['unregister [path]'],
168
+ handler: async (args) => {
169
+ const cgConfig = getArgsConfig(args);
170
+ const hookPath = args.path ?? '/';
171
+ const token = args.publish_token;
172
+ const token_id = args.token_id;
173
+ if (!cgConfig.app_key || !cgConfig.secret)
174
+ throw new Error("Make sure both the Optimizely Graph App Key & Secret have been defined");
175
+ const adminApi = createAdminApi(cgConfig);
176
+ if (typeof (token_id) == 'string' && token_id.length > 24) {
177
+ process.stdout.write(`Removing Webhook with ID: ${token_id}\n`);
178
+ try {
179
+ await adminApi.webhooks.deleteWebhookHandler(token_id);
180
+ }
181
+ catch (e) {
182
+ if (isApiError(e)) {
183
+ process.stderr.write(`!! Optimizely Graph returned an error: HTTP ${e.status}: ${e.statusText}\n`);
184
+ if (args.verbose)
185
+ console.error(e.body);
186
+ process.exitCode = 1;
187
+ return;
188
+ }
189
+ else {
190
+ process.stderr.write(`!! Optimizely Graph returned an error\n`);
191
+ }
192
+ }
193
+ process.stdout.write(`Removed WebHook with ID ${token_id} from ContentGraph\n`);
194
+ return;
195
+ }
196
+ const frontendUrl = getFrontendURL(cgConfig);
197
+ const webhookTarget = new URL(hookPath, frontendUrl);
198
+ if (token)
199
+ webhookTarget.searchParams.set('token', token);
200
+ process.stdout.write(`Removing webhook target: ${webhookTarget.href}\n`);
201
+ if (webhookTarget.hostname == 'localhost') {
202
+ process.stderr.write("!! Cannot register a localhost Site URL with Content Graph\n");
203
+ process.exitCode = 1;
204
+ return;
205
+ }
206
+ try {
207
+ const currentHooks = await adminApi.webhooks.listWebhookHandler();
208
+ const hooks = currentHooks.filter(x => x.request.url == webhookTarget.href);
209
+ if (!hooks || hooks.length == 0) {
210
+ process.stdout.write("Webhook not found, not removing anything\n");
211
+ process.exitCode = 0;
212
+ return;
213
+ }
214
+ await Promise.all(hooks.map(async (hook) => {
215
+ const hookId = hook.id;
216
+ process.stdout.write(`Removing Webhook with ID: ${hookId}\n`);
217
+ await adminApi.webhooks.deleteWebhookHandler(hookId).then(() => process.stdout.write(`Removed WebHook with ID ${hookId} from ContentGraph\n`));
218
+ return true;
219
+ }));
220
+ }
221
+ catch (e) {
222
+ if (isApiError(e)) {
223
+ process.stderr.write(`!! Optimizely Graph returned an error: HTTP ${e.status}: ${e.statusText}\n`);
224
+ if (args.verbose)
225
+ console.error(e.body);
226
+ process.exitCode = 1;
227
+ return;
228
+ }
229
+ else {
230
+ process.stderr.write(`!! Optimizely Graph returned an error\n`);
231
+ }
232
+ }
233
+ process.stdout.write("Done\n");
234
+ },
235
+ aliases: [],
236
+ describe: "Removes a webhook from ContentGraph that invokes /api/content/publish on every publish in ContentGraph",
237
+ builder: (args) => {
238
+ const defaultToken = process.env.FRONTEND_PUBLISH_TOKEN || undefined;
239
+ const hasDefaultToken = typeof (defaultToken) == 'string' && defaultToken.length > 0;
240
+ args.positional('path', { type: "string", describe: "The frontend route to invoke to publish", default: "/api/content/publish", demandOption: false });
241
+ args.option("publish_token", { alias: "pt", description: "Publishing token", string: true, type: "string", demandOption: !hasDefaultToken, default: defaultToken });
242
+ args.option("token_id", { alias: "ti", description: "If set, removes this webhook only", string: true, type: "string", demandOption: false, default: undefined });
243
+ return args;
244
+ }
245
+ };
246
+
247
+ const publishToVercelModule = {
248
+ command: ['list'],
249
+ handler: async (args) => {
250
+ const cgConfig = getArgsConfig(args);
251
+ if (!cgConfig.app_key || !cgConfig.secret)
252
+ throw new Error("Make sure both the Optimizely Graph App Key & Secret have been defined");
253
+ const adminApi = createAdminApi(cgConfig);
254
+ try {
255
+ const currentHooks = await adminApi.webhooks.listWebhookHandler();
256
+ const hooks = new Table({
257
+ head: [chalk.yellow(chalk.bold("ID")), chalk.yellow(chalk.bold("Method")), chalk.yellow(chalk.bold("Url"))],
258
+ colWidths: [38, 8, 100]
259
+ });
260
+ currentHooks.forEach(x => {
261
+ hooks.push([x.id, x.request.method, x.request.url]);
262
+ });
263
+ process.stdout.write(hooks.toString() + "\n");
264
+ process.stdout.write(chalk.green(chalk.bold(figures.tick + " Done")) + "\n");
265
+ }
266
+ catch (e) {
267
+ if (isApiError(e)) {
268
+ process.stderr.write(chalk.redBright(`${chalk.bold(figures.cross)} Optimizely Graph returned an error: HTTP ${e.status}: ${e.statusText}`) + "\n");
269
+ if (args.verbose)
270
+ console.error(chalk.redBright(JSON.stringify(e.body, undefined, 4)));
271
+ }
272
+ else {
273
+ process.stderr.write(chalk.redBright(`${chalk.bold(figures.cross)} Optimizely Graph returned an unknown error`) + "\n");
274
+ if (args.verbose)
275
+ console.error(chalk.redBright(e));
276
+ }
277
+ process.exitCode = 1;
278
+ return;
279
+ }
280
+ },
281
+ aliases: [],
282
+ describe: "List all webhooks in ContentGraph",
283
+ };
284
+
285
+ const DEFAULT_CONFIG_FILE = "src/site-config.ts";
286
+ const createSiteConfigModule = {
287
+ command: ['site-config [file_path]'],
288
+ handler: async (args) => {
289
+ const cgConfig = getArgsConfig(args);
290
+ if (!cgConfig.app_key || !cgConfig.secret)
291
+ throw new Error("Make sure both the Optimizely Graph App Key & Secret have been defined");
292
+ const cgFetch = createHmacFetch(cgConfig.app_key, cgConfig.secret);
293
+ const siteHost = getFrontendURL(cgConfig).host;
294
+ const targetFile = args.file_path ?? DEFAULT_CONFIG_FILE;
295
+ process.stdout.write(`${chalk.yellow(chalk.bold(figures.arrowRight))} Generating configuration file for website with domain: ${chalk.yellow(siteHost)}\n`);
296
+ let response = await cgFetch(new URL('/content/v2', cgConfig.gateway), { method: "POST", body: JSON.stringify(siteQuery(siteHost)) });
297
+ if (!response.ok) {
298
+ process.stdout.write(chalk.redBright(chalk.bold(figures.cross) + " Failed loading website data"));
299
+ process.exitCode = 1;
300
+ return;
301
+ }
302
+ let responseBody = await response.json();
303
+ if ((responseBody.data?.SiteDefinition?.items?.length ?? 0) < 1) {
304
+ process.stdout.write(`${chalk.red(chalk.bold(figures.bullet))} Domain not found, falling back to match-all domain: ${chalk.yellow("*")}\n`);
305
+ response = await cgFetch(new URL('/content/v2', cgConfig.gateway), { method: "POST", body: JSON.stringify(siteQuery("*")) });
306
+ if (!response.ok) {
307
+ process.stdout.write(chalk.redBright(chalk.bold(figures.cross) + " Failed loading website data"));
308
+ process.exitCode = 1;
309
+ return;
310
+ }
311
+ responseBody = await response.json();
312
+ }
313
+ if ((responseBody.data?.SiteDefinition?.items?.length ?? 0) < 1) {
314
+ process.stdout.write(chalk.redBright(`${chalk.bold(figures.cross)} No website defintion found for host ${siteHost}`) + "\n");
315
+ process.exitCode = 1;
316
+ return;
317
+ }
318
+ if ((responseBody.data?.SiteDefinition?.items?.length ?? 0) > 1) {
319
+ process.stdout.write(chalk.redBright(`${chalk.bold(figures.cross)} Multiple website defintion found for host ${siteHost}, please correct your CMS configuration`) + "\n");
320
+ process.exitCode = 1;
321
+ return;
322
+ }
323
+ process.stdout.write(`${chalk.yellow(chalk.bold(figures.arrowRight))} Loaded website data, generating TypeScript code.\n`);
324
+ const siteDefinition = responseBody.data.SiteDefinition.items[0];
325
+ siteDefinition.domains = (siteDefinition.domains ?? []).map((d) => {
326
+ const def = {
327
+ name: d.name,
328
+ isPrimary: d.type == "Primary",
329
+ isEdit: d.type == "Edit"
330
+ };
331
+ if (d.forLocale?.code)
332
+ def.forLocale = d.forLocale?.code;
333
+ return def;
334
+ });
335
+ siteDefinition.locales = (siteDefinition.locales ?? []).map((c) => {
336
+ const loc = {
337
+ code: c.code,
338
+ slug: c.slug?.toLowerCase(),
339
+ graphLocale: c.code.replace("-", "_"),
340
+ isDefault: c.isDefault == true
341
+ };
342
+ return loc;
343
+ });
344
+ const code = [
345
+ '/**',
346
+ ' * This file has been automatically generated, do not update manually',
347
+ ' *',
348
+ ' * Use yarn frontend-cli site-config to re-generate this file',
349
+ ' */',
350
+ 'import { ChannelDefinition, type ChannelDefinitionData } from "@remkoj/optimizely-graph-client"',
351
+ '',
352
+ `const generated_data : ChannelDefinitionData = ${JSON.stringify(siteDefinition)};`,
353
+ '',
354
+ `export const SiteConfig = new ChannelDefinition(generated_data, "${cgConfig.dxp_url}")`,
355
+ 'export default SiteConfig'
356
+ ];
357
+ const filePath = path__default.join(process.cwd(), targetFile);
358
+ process.stdout.write(`${chalk.yellow(chalk.bold(figures.arrowRight))} Writing code to: ${chalk.yellow(filePath)}\n`);
359
+ fs.writeFileSync(filePath, code.join("\n"));
360
+ process.stdout.write(chalk.greenBright(`${chalk.bold(figures.tick)} Done`) + "\n");
361
+ },
362
+ builder: (args) => {
363
+ args.positional("file_path", { description: "The target file path", string: true, type: "string", demandOption: false, default: DEFAULT_CONFIG_FILE });
364
+ return args;
365
+ },
366
+ aliases: [],
367
+ describe: "Generate a static site configuration file",
368
+ };
369
+ function siteQuery(site_url) {
370
+ return {
371
+ query: `query GetSiteConfig($domain: String!) {
372
+ SiteDefinition (
373
+ where: {
374
+ _or: [
375
+ { Hosts: { Name: { eq: $domain }} }
376
+ ]
377
+ }
378
+ ) {
379
+ items {
380
+ id: Id
381
+ name: Name,
382
+ domains: Hosts {
383
+ name: Name
384
+ type: Type
385
+ forLocale: Language {
386
+ code: Name
387
+ }
388
+ }
389
+ locales:Languages {
390
+ code:Name
391
+ slug:UrlSegment
392
+ isDefault:IsMasterLanguage
393
+ }
394
+ content: ContentRoots {
395
+ startPage: StartPage {
396
+ id:Id,
397
+ guidValue:GuidValue
398
+ }
399
+ }
400
+ }
401
+ }
402
+ }`,
403
+ variables: JSON.stringify({ domain: site_url })
404
+ };
405
+ }
406
+
407
+ var commands = [publishToVercelModule$2, publishToVercelModule$1, publishToVercelModule, createSiteConfigModule];
408
+
409
+ var APP;
410
+ (function (APP) {
411
+ APP["Script"] = "opti-graph";
412
+ APP["Version"] = "1.0.3";
413
+ })(APP || (APP = {}));
414
+ const app = createCliApp(APP.Script, APP.Version);
415
+ app.command(commands);
416
+ app.parse(process.argv.slice(2));
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ import initEnvironment from './context/env.js';
2
+ import createCliApp from './app.js';
3
+ import commands from './commands/index.js';
4
+ var APP;
5
+ (function (APP) {
6
+ APP["Script"] = "opti-graph";
7
+ APP["Version"] = "1.0.3";
8
+ })(APP || (APP = {}));
9
+ initEnvironment();
10
+ const app = createCliApp(APP.Script, APP.Version);
11
+ app.command(commands);
12
+ app.parse(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@remkoj/optimizely-graph-cli",
3
+ "repository": "https://github.com/remkoj/optimizely-dxp-clients.git",
4
+ "author": "Remko Jantzen <693172+remkoj@users.noreply.github.com>",
5
+ "homepage": "https://github.com/remkoj/optimizely-dxp-clients",
6
+ "version": "1.0.3",
7
+ "license": "Apache-2.0",
8
+ "packageManager": "yarn@4.1.1",
9
+ "type": "module",
10
+ "description": "CLI Utilities for Optimizely DXP",
11
+ "main": "dist/index.js",
12
+ "files": [
13
+ "./bin"
14
+ ],
15
+ "bin": {
16
+ "opti-graph": "bin/index.js"
17
+ },
18
+ "scripts": {
19
+ "watch": "yarn tsc --watch",
20
+ "clean": "yarn tsc --build --clean",
21
+ "compile": "yarn tsc --build",
22
+ "recompile": "yarn tsc --build --clean && yarn tsc --build && yarn rollup --config rollup.config.js",
23
+ "run-compiled": "yarn node dist/index.js",
24
+ "prepare": "yarn tsc --build --force && yarn rollup --config rollup.config.js",
25
+ "rebuild": "yarn tsc --build --clean && yarn tsc --build && yarn rollup --config rollup.config.js",
26
+ "run": "yarn node bin/index.js",
27
+ "bundle": "yarn rollup --config rollup.config.js"
28
+ },
29
+ "devDependencies": {
30
+ "@remkoj/optimizely-graph-client": "1.0.3",
31
+ "@rollup/plugin-commonjs": "^25.0.7",
32
+ "@rollup/plugin-json": "^6.1.0",
33
+ "@rollup/pluginutils": "^5.1.0",
34
+ "@types/crypto-js": "^4.2.2",
35
+ "@types/glob": "^8.1.0",
36
+ "@types/node": "^20.12.4",
37
+ "@types/source-map-support": "^0.5.10",
38
+ "@types/yargs": "^17.0.32",
39
+ "rollup": "^4.14.0",
40
+ "source-map-support": "^0.5.21",
41
+ "tslib": "^2.6.2",
42
+ "typescript": "^5.4.3"
43
+ },
44
+ "dependencies": {
45
+ "chalk": "^5.3.0",
46
+ "cli-table3": "^0.6.4",
47
+ "dotenv": "^16.4.5",
48
+ "dotenv-expand": "^11.0.6",
49
+ "figures": "^6.1.0",
50
+ "yargs": "^17.7.2"
51
+ },
52
+ "peerDependencies": {
53
+ "@remkoj/optimizely-graph-client": "1.0.3"
54
+ }
55
+ }