@maker-or/opencms 0.1.6 → 0.1.8

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 (3) hide show
  1. package/README.md +10 -1
  2. package/dist/index.js +144 -56
  3. package/package.json +3 -1
package/README.md CHANGED
@@ -23,6 +23,15 @@ npx @maker-or/opencms deploy
23
23
 
24
24
  The generated `cms/schema.json` file defines the project's content types and allowed blocks. The CLI syncs it to the development environment when `dev` or `deploy` runs.
25
25
 
26
+ `opencms deploy` promotes the development schema and only published development pages as one production snapshot. Draft pages stay out of production and production pages no longer present in the published development snapshot are removed. When `VERCEL_TOKEN` is set, the command also deploys the application with its project ID, API origin, and production CMS environment supplied to Vercel at build time and runtime.
27
+
26
28
  The CLI stores its local login configuration in `~/.config/opencms/config.json` (or `$XDG_CONFIG_HOME/opencms/config.json` when configured).
27
29
 
28
- The CLI defaults to the hosted OpenCMS dashboard and API. Use `OPENCMS_DASHBOARD_URL` and `OPENCMS_API_URL` to target a local or self-hosted instance.
30
+ The CLI uses the OpenCMS control-plane origin from `OPENCMS_URL`. Set it to the dashboard/API origin for your hosted, local, or self-hosted instance. `OPENCMS_API_URL` and `OPENCMS_DASHBOARD_URL` remain supported as separate legacy overrides, but there is no baked-in deployment URL.
31
+
32
+ For example:
33
+
34
+ ```bash
35
+ export OPENCMS_URL=https://your-opencms-domain.example
36
+ npx @maker-or/opencms login
37
+ ```
package/dist/index.js CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { createServer } from "node:http";
5
+ import { randomUUID } from "node:crypto";
5
6
  import { homedir } from "node:os";
6
7
  import { join, resolve } from "node:path";
7
- import { access, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
8
+ import { access, chmod, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
8
9
  import { constants } from "node:fs";
9
10
  import { spawn } from "node:child_process";
10
11
  import { createInterface } from "node:readline/promises";
@@ -67,10 +68,7 @@ class OpenCmsApiError extends Error {
67
68
  function createSdk(options = {}) {
68
69
  const defaultBaseUrl = typeof window === "undefined" ? undefined : window.location.origin;
69
70
  const configuredBaseUrl = options.baseUrl ?? defaultBaseUrl;
70
- if (!configuredBaseUrl) {
71
- throw new Error("baseUrl is required when using the OpenCMS SDK outside a browser.");
72
- }
73
- const baseUrl = configuredBaseUrl.replace(/\/$/, "");
71
+ const baseUrl = configuredBaseUrl?.replace(/\/$/, "") ?? "";
74
72
  const fetcher = options.fetch ?? globalThis.fetch;
75
73
  const projectId = options.projectId;
76
74
  const environment = options.environment ?? "development";
@@ -81,6 +79,9 @@ function createSdk(options = {}) {
81
79
  if (token) {
82
80
  headers.set("Authorization", `Bearer ${token}`);
83
81
  }
82
+ if (!baseUrl && typeof window === "undefined") {
83
+ throw new Error("baseUrl is required when making OpenCMS SDK requests outside a browser.");
84
+ }
84
85
  const response = await fetcher(`${baseUrl}${path}`, {
85
86
  ...init,
86
87
  headers
@@ -103,18 +104,21 @@ function createSdk(options = {}) {
103
104
  method: "POST",
104
105
  headers: { "Content-Type": "application/json" },
105
106
  body: JSON.stringify(input)
107
+ }),
108
+ delete: (targetProjectId) => request(`/api/projects/${targetProjectId}`, {
109
+ method: "DELETE"
106
110
  })
107
111
  },
108
112
  schema: {
109
113
  get: () => {
110
114
  if (!projectId)
111
115
  throw new Error("projectId is required to get the schema");
112
- return request(`/api/projects/${projectId}/schema`);
116
+ return request(`/api/projects/${projectId}/schema?environment=${environment}`);
113
117
  },
114
118
  update: (schema) => {
115
119
  if (!projectId)
116
120
  throw new Error("projectId is required to update the schema");
117
- return request(`/api/projects/${projectId}/schema`, {
121
+ return request(`/api/projects/${projectId}/schema?environment=${environment}`, {
118
122
  method: "PUT",
119
123
  headers: { "Content-Type": "application/json" },
120
124
  body: JSON.stringify(schema)
@@ -205,13 +209,30 @@ function createSdk(options = {}) {
205
209
  };
206
210
  }
207
211
 
212
+ // src/vercel.ts
213
+ function vercelDeploymentArgs({
214
+ apiUrl,
215
+ projectId,
216
+ token
217
+ }) {
218
+ const connectionVariables = [
219
+ `NEXT_PUBLIC_OPENCMS_PROJECT_ID=${projectId}`,
220
+ `OPENCMS_API_URL=${apiUrl}`,
221
+ "OPENCMS_ENVIRONMENT=production"
222
+ ];
223
+ return [
224
+ "vercel",
225
+ "--prod",
226
+ "--yes",
227
+ "--token",
228
+ token,
229
+ ...connectionVariables.flatMap((value) => ["--build-env", value, "--env", value])
230
+ ];
231
+ }
232
+
208
233
  // src/index.ts
209
- var hostedUrl = "https://web-eta-ten-16.vercel.app";
210
- var dashboardUrl = process.env.OPENCMS_DASHBOARD_URL ?? hostedUrl;
211
- var apiUrl = process.env.OPENCMS_API_URL ?? hostedUrl;
212
234
  var configRoot = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
213
235
  var configPath = join(configRoot, "opencms", "config.json");
214
- var legacyLocalApiUrl = "http://localhost:3000";
215
236
  async function readConfig() {
216
237
  if (!await fileExists(configPath))
217
238
  return {};
@@ -222,19 +243,30 @@ async function readConfig() {
222
243
  }
223
244
  }
224
245
  async function writeConfig(config) {
225
- await mkdir(join(configRoot, "opencms"), { recursive: true });
246
+ const directory = join(configRoot, "opencms");
247
+ await mkdir(directory, { recursive: true, mode: 448 });
226
248
  await writeFile(configPath, `${JSON.stringify(config, null, 2)}
227
- `, "utf8");
249
+ `, { encoding: "utf8", mode: 384 });
250
+ if (process.platform !== "win32") {
251
+ await chmod(directory, 448);
252
+ await chmod(configPath, 384);
253
+ }
228
254
  }
229
255
  function tokenFor(config) {
230
256
  return process.env.OPENCMS_CLERK_TOKEN ?? config.token ?? null;
231
257
  }
258
+ function requireEndpoint(endpoint) {
259
+ const normalized = endpoint?.trim().replace(/\/$/, "");
260
+ if (!normalized) {
261
+ throw new Error("OpenCMS endpoint is not configured. Set OPENCMS_URL to your OpenCMS dashboard/API origin and try again.");
262
+ }
263
+ return normalized;
264
+ }
232
265
  function apiUrlFor(config) {
233
- if (process.env.OPENCMS_API_URL)
234
- return process.env.OPENCMS_API_URL;
235
- if (config?.apiUrl && config.apiUrl !== legacyLocalApiUrl)
236
- return config.apiUrl;
237
- return apiUrl;
266
+ return requireEndpoint(process.env.OPENCMS_URL ?? process.env.OPENCMS_API_URL ?? config?.apiUrl ?? process.env.OPENCMS_DASHBOARD_URL);
267
+ }
268
+ function dashboardUrlFor(config) {
269
+ return requireEndpoint(process.env.OPENCMS_URL ?? process.env.OPENCMS_DASHBOARD_URL ?? process.env.OPENCMS_API_URL ?? config?.apiUrl);
238
270
  }
239
271
  function sdk(config, projectId) {
240
272
  return createSdk({
@@ -269,7 +301,8 @@ async function openBrowser(url) {
269
301
  const command = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
270
302
  await runCommand(command[0], command.slice(1));
271
303
  }
272
- async function browserLogin() {
304
+ async function browserLogin(config) {
305
+ const loginState = randomUUID();
273
306
  let resolveToken = () => {
274
307
  return;
275
308
  };
@@ -287,14 +320,24 @@ async function browserLogin() {
287
320
  response.end("Waiting for opencms login.");
288
321
  return;
289
322
  }
323
+ if (url.searchParams.get("state") !== loginState) {
324
+ response.writeHead(400, { "Content-Type": "text/plain", "Cache-Control": "no-store" });
325
+ response.end("Invalid login state.");
326
+ return;
327
+ }
290
328
  const token = url.searchParams.get("token");
291
329
  if (!token) {
292
- response.writeHead(400, { "Content-Type": "text/plain" });
330
+ response.writeHead(400, { "Content-Type": "text/plain", "Cache-Control": "no-store" });
293
331
  response.end("Missing login token.");
294
332
  return;
295
333
  }
296
334
  resolveToken(token);
297
- response.writeHead(200, { "Content-Type": "text/html" });
335
+ response.writeHead(200, {
336
+ "Content-Type": "text/html",
337
+ "Cache-Control": "no-store",
338
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
339
+ "X-Content-Type-Options": "nosniff"
340
+ });
298
341
  response.end("<h1>OpenCMS login complete</h1><p>You can close this window.</p>");
299
342
  });
300
343
  await new Promise((resolve2, reject) => {
@@ -305,7 +348,7 @@ async function browserLogin() {
305
348
  if (!address || typeof address === "string")
306
349
  throw new Error("Unable to start the login callback server.");
307
350
  const callback = `http://127.0.0.1:${address.port}/callback`;
308
- const loginUrl = `${dashboardUrl.replace(/\/$/, "")}/cli/login?redirect_uri=${encodeURIComponent(callback)}`;
351
+ const loginUrl = `${dashboardUrlFor(config)}/cli/login?redirect_uri=${encodeURIComponent(callback)}&state=${encodeURIComponent(loginState)}`;
309
352
  console.log(`Opening ${loginUrl}`);
310
353
  try {
311
354
  await openBrowser(loginUrl);
@@ -324,7 +367,7 @@ async function ensureToken(config) {
324
367
  const token = tokenFor(config);
325
368
  if (token)
326
369
  return token;
327
- const loggedInToken = await browserLogin();
370
+ const loggedInToken = await browserLogin(config);
328
371
  await writeConfig({ ...config, token: loggedInToken, apiUrl: apiUrlFor(config) });
329
372
  return loggedInToken;
330
373
  }
@@ -333,10 +376,22 @@ async function reauthenticate(config) {
333
376
  throw new Error("OPENCMS_CLERK_TOKEN was rejected or expired. Provide a fresh token or unset the variable to use browser login.");
334
377
  }
335
378
  console.log("Your OpenCMS session has expired. Opening browser login…");
336
- const loggedInToken = await browserLogin();
379
+ const loggedInToken = await browserLogin(config);
337
380
  await writeConfig({ ...config, token: loggedInToken, apiUrl: apiUrlFor(config) });
338
381
  return loggedInToken;
339
382
  }
383
+ async function withReauthentication(operation) {
384
+ let config = await readConfig();
385
+ try {
386
+ return await operation(config);
387
+ } catch (error) {
388
+ if (!(error instanceof OpenCmsApiError) || error.status !== 401)
389
+ throw error;
390
+ await reauthenticate(config);
391
+ config = await readConfig();
392
+ return operation(config);
393
+ }
394
+ }
340
395
  async function login() {
341
396
  const config = await readConfig();
342
397
  if (process.env.OPENCMS_CLERK_TOKEN) {
@@ -344,12 +399,24 @@ async function login() {
344
399
  console.log("Saved OPENCMS_CLERK_TOKEN for local CLI use.");
345
400
  return;
346
401
  }
347
- const loggedInToken = await browserLogin();
402
+ const loggedInToken = await browserLogin(config);
348
403
  await writeConfig({ ...config, token: loggedInToken, apiUrl: apiUrlFor(config) });
349
404
  console.log("Logged in to OpenCMS.");
350
405
  }
351
406
  async function logout() {
352
407
  const config = await readConfig();
408
+ if (config.token?.startsWith("ocms_")) {
409
+ try {
410
+ const response = await fetch(`${apiUrlFor(config)}/api/cli/tokens/current`, {
411
+ method: "DELETE",
412
+ headers: { Authorization: `Bearer ${config.token}` }
413
+ });
414
+ if (!response.ok)
415
+ console.warn("OpenCMS: The remote CLI session could not be revoked.");
416
+ } catch {
417
+ console.warn("OpenCMS: The remote CLI session could not be revoked.");
418
+ }
419
+ }
353
420
  const { token: _token, ...withoutToken } = config;
354
421
  await writeConfig(withoutToken);
355
422
  console.log("Logged out of OpenCMS.");
@@ -381,8 +448,8 @@ async function ensureCmsDirectory(destination, project, baseUrl) {
381
448
  const configFile = join(cmsDirectory, "opencms.ts");
382
449
  if (!await fileExists(configFile)) {
383
450
  await writeFile(configFile, `export const opencms = {
384
- projectId: process.env.NEXT_PUBLIC_OPENCMS_PROJECT_ID ?? "${project.id}",
385
- apiUrl: process.env.OPENCMS_API_URL ?? "${baseUrl}",
451
+ projectId: process.env.NEXT_PUBLIC_OPENCMS_PROJECT_ID ?? "",
452
+ apiUrl: process.env.OPENCMS_API_URL ?? "",
386
453
  environment: process.env.OPENCMS_ENVIRONMENT ?? "development",
387
454
  } as const;
388
455
  `, "utf8");
@@ -428,6 +495,9 @@ async function createProject() {
428
495
  const name = await ask("Project name: ");
429
496
  if (!name)
430
497
  throw new Error("A project name is required.");
498
+ const destination = resolve(process.cwd(), slugify(name));
499
+ if (await fileExists(destination))
500
+ throw new Error(`Destination already exists: ${destination}`);
431
501
  let currentConfig = await readConfig();
432
502
  let client = sdk(currentConfig);
433
503
  let project;
@@ -441,17 +511,26 @@ async function createProject() {
441
511
  client = sdk(currentConfig);
442
512
  project = await client.projects.create({ name });
443
513
  }
444
- const destination = resolve(process.cwd(), slugify(project.name));
445
- await pullTemplate(destination);
446
- const baseUrl = apiUrlFor(config);
447
- await writeProjectEnv(destination, project, baseUrl);
448
- await ensureCmsDirectory(destination, project, baseUrl);
449
- await installDependencies(destination);
514
+ const baseUrl = apiUrlFor(currentConfig);
515
+ try {
516
+ await pullTemplate(destination);
517
+ await writeProjectEnv(destination, project, baseUrl);
518
+ await ensureCmsDirectory(destination, project, baseUrl);
519
+ await installDependencies(destination);
520
+ } catch (error) {
521
+ await rm(destination, { recursive: true, force: true });
522
+ try {
523
+ await client.projects.delete(project.id);
524
+ } catch {
525
+ console.error(`OpenCMS: Local setup failed and cloud rollback also failed. Project ID: ${project.id}`);
526
+ }
527
+ throw error;
528
+ }
450
529
  await writeConfig({ ...await readConfig(), projectId: project.id, apiUrl: baseUrl });
451
530
  console.log(`
452
531
  Created ${project.name}.`);
453
532
  console.log(`Project ID: ${project.id}`);
454
- console.log(`Dashboard: ${dashboardUrl.replace(/\/$/, "")}/dashboard/${project.id}`);
533
+ console.log(`Dashboard: ${dashboardUrlFor(await readConfig())}/dashboard/${project.id}`);
455
534
  console.log(`
456
535
  Next steps:
457
536
  cd ${slugify(project.name)}
@@ -461,16 +540,11 @@ Next steps:
461
540
  async function runDev() {
462
541
  const config = await readConfig();
463
542
  await ensureToken(config);
464
- try {
465
- await syncLocalSchema(await projectIdFromEnv(), await readConfig());
466
- } catch (error) {
467
- if (!(error instanceof OpenCmsApiError) || error.status !== 401)
468
- throw error;
469
- await reauthenticate(await readConfig());
470
- await syncLocalSchema(await projectIdFromEnv(), await readConfig());
471
- }
543
+ const projectId = await projectIdFromEnv();
544
+ await withReauthentication((currentConfig) => syncLocalSchema(projectId, currentConfig));
472
545
  const manager = await packageManager(process.cwd());
473
- const command = manager[0] === "npm" ? ["npm", "run", "dev"] : manager[0] === "pnpm" ? ["pnpm", "dev"] : manager[0] === "yarn" ? ["yarn", "dev"] : ["bun", "run", "dev"];
546
+ const script = await nextDevScript(process.cwd());
547
+ const command = manager[0] === "npm" ? ["npm", "run", script] : manager[0] === "pnpm" ? ["pnpm", script] : manager[0] === "yarn" ? ["yarn", script] : ["bun", "run", script];
474
548
  process.exit(await runCommand(command[0], command.slice(1), {
475
549
  cwd: process.cwd(),
476
550
  env: {
@@ -481,6 +555,24 @@ async function runDev() {
481
555
  inherit: true
482
556
  }));
483
557
  }
558
+ async function nextDevScript(destination) {
559
+ const packagePath = join(destination, "package.json");
560
+ if (!await fileExists(packagePath))
561
+ return "dev";
562
+ try {
563
+ const packageJson = JSON.parse(await readFile(packagePath, "utf8"));
564
+ if (typeof packageJson.scripts?.["dev:next"] === "string")
565
+ return "dev:next";
566
+ if (typeof packageJson.scripts?.dev === "string" && /opencms(?:@[^\s]+)?\s+dev/.test(packageJson.scripts.dev)) {
567
+ throw new Error("This OpenCMS template is missing its dev:next script. Update the template before running opencms dev.");
568
+ }
569
+ } catch (error) {
570
+ if (error instanceof SyntaxError)
571
+ throw new Error("package.json is not valid JSON.");
572
+ throw error;
573
+ }
574
+ return "dev";
575
+ }
484
576
  async function syncLocalSchema(projectId, config) {
485
577
  if (!projectId)
486
578
  return;
@@ -511,24 +603,20 @@ async function deploy() {
511
603
  const projectId = await projectIdFromEnv() ?? config.projectId;
512
604
  if (!projectId)
513
605
  throw new Error("No OpenCMS project is configured in this directory.");
514
- let deployment;
515
- try {
516
- await syncLocalSchema(projectId, await readConfig());
517
- deployment = await sdk(await readConfig()).deploy(projectId);
518
- } catch (error) {
519
- if (!(error instanceof OpenCmsApiError) || error.status !== 401)
520
- throw error;
521
- await reauthenticate(await readConfig());
522
- await syncLocalSchema(projectId, await readConfig());
523
- deployment = await sdk(await readConfig()).deploy(projectId);
524
- }
525
- console.log(`Deployed ${deployment.sourceEnvironment} content to ${deployment.targetEnvironment}.`);
606
+ await withReauthentication((currentConfig) => syncLocalSchema(projectId, currentConfig));
526
607
  if (process.env.VERCEL_TOKEN) {
527
608
  console.log("Deploying the application to Vercel…");
528
- if (await runCommand("npx", ["vercel", "--prod", "--yes", "--token", process.env.VERCEL_TOKEN], { cwd: process.cwd(), inherit: true }) !== 0) {
609
+ const args = vercelDeploymentArgs({
610
+ apiUrl: apiUrlFor(await readConfig()),
611
+ projectId,
612
+ token: process.env.VERCEL_TOKEN
613
+ });
614
+ if (await runCommand("npx", args, { cwd: process.cwd(), inherit: true }) !== 0) {
529
615
  throw new Error("Vercel deployment failed.");
530
616
  }
531
617
  }
618
+ const deployment = await withReauthentication((currentConfig) => sdk(currentConfig).deploy(projectId));
619
+ console.log(`Deployed ${deployment.sourceEnvironment} content to ${deployment.targetEnvironment}.`);
532
620
  }
533
621
  function printHelp() {
534
622
  console.log(`OpenCMS CLI
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maker-or/opencms",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "The developer-first CLI for OpenCMS",
5
5
  "type": "module",
6
6
  "repository": {
@@ -28,6 +28,7 @@
28
28
  "build": "bun build src/index.ts --outfile dist/index.js --target=node --format=esm",
29
29
  "dev": "bun --watch src/index.ts",
30
30
  "start": "node dist/index.js",
31
+ "test": "bun run build && bun test",
31
32
  "typecheck": "tsc --noEmit",
32
33
  "prepack": "bun run build"
33
34
  },
@@ -35,6 +36,7 @@
35
36
  "node": ">=20"
36
37
  },
37
38
  "devDependencies": {
39
+ "@types/bun": "latest",
38
40
  "@types/node": "^22.13.10",
39
41
  "typescript": "^5.8.3"
40
42
  }