@gogitcms/editor 0.23.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/app/index.html +38 -0
- package/app/src/App.tsx +2665 -0
- package/app/src/apollo.ts +119 -0
- package/app/src/auth.ts +248 -0
- package/app/src/config.ts +88 -0
- package/app/src/main.tsx +18 -0
- package/app/src/media.ts +151 -0
- package/app/src/navigation.tsx +321 -0
- package/app/src/plugins.ts +11 -0
- package/app/src/previewHost.tsx +263 -0
- package/app/src/previewToken.ts +68 -0
- package/app/src/queries.ts +591 -0
- package/app/src/virtual-cms-plugins.d.ts +6 -0
- package/app/vendor/analytics/src/__tests__/config.test.ts +91 -0
- package/app/vendor/analytics/src/config.ts +94 -0
- package/app/vendor/analytics/src/events.ts +56 -0
- package/app/vendor/analytics/src/index.ts +23 -0
- package/app/vendor/analytics/src/provider.tsx +196 -0
- package/app/vendor/design-system/src/ThemeProvider.tsx +86 -0
- package/app/vendor/design-system/src/__tests__/ApplyChangesModal.test.tsx +148 -0
- package/app/vendor/design-system/src/__tests__/BranchImport.test.tsx +46 -0
- package/app/vendor/design-system/src/__tests__/Button.test.tsx +45 -0
- package/app/vendor/design-system/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.media.test.tsx +140 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.search.test.tsx +135 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
- package/app/vendor/design-system/src/__tests__/MediaBrowser.test.tsx +353 -0
- package/app/vendor/design-system/src/__tests__/MediaField.test.tsx +185 -0
- package/app/vendor/design-system/src/__tests__/Notifications.test.tsx +69 -0
- package/app/vendor/design-system/src/__tests__/Onboarding.test.tsx +287 -0
- package/app/vendor/design-system/src/__tests__/cssTokens.test.ts +201 -0
- package/app/vendor/design-system/src/__tests__/fieldComponents.test.ts +43 -0
- package/app/vendor/design-system/src/__tests__/reorder.test.ts +58 -0
- package/app/vendor/design-system/src/components/ApplyChangesModal.tsx +348 -0
- package/app/vendor/design-system/src/components/BranchImport.tsx +157 -0
- package/app/vendor/design-system/src/components/BranchMenu.tsx +192 -0
- package/app/vendor/design-system/src/components/Button.tsx +131 -0
- package/app/vendor/design-system/src/components/ChangeDetail.tsx +472 -0
- package/app/vendor/design-system/src/components/ChangeRequestSummary.tsx +173 -0
- package/app/vendor/design-system/src/components/CollabField.tsx +388 -0
- package/app/vendor/design-system/src/components/ContentBrowser.tsx +5073 -0
- package/app/vendor/design-system/src/components/Icon.tsx +28 -0
- package/app/vendor/design-system/src/components/Icon.web.tsx +31 -0
- package/app/vendor/design-system/src/components/Input.tsx +106 -0
- package/app/vendor/design-system/src/components/MediaBrowser.tsx +766 -0
- package/app/vendor/design-system/src/components/MediaField.tsx +670 -0
- package/app/vendor/design-system/src/components/MediaPreview.tsx +91 -0
- package/app/vendor/design-system/src/components/MediaPreview.web.tsx +169 -0
- package/app/vendor/design-system/src/components/NavRow.tsx +105 -0
- package/app/vendor/design-system/src/components/Notifications.tsx +301 -0
- package/app/vendor/design-system/src/components/Onboarding.tsx +751 -0
- package/app/vendor/design-system/src/components/ProjectMenu.tsx +124 -0
- package/app/vendor/design-system/src/components/Segment.tsx +87 -0
- package/app/vendor/design-system/src/components/Skeleton.tsx +216 -0
- package/app/vendor/design-system/src/components/Spinner.tsx +44 -0
- package/app/vendor/design-system/src/components/Text.tsx +85 -0
- package/app/vendor/design-system/src/components/documentDrafts.ts +213 -0
- package/app/vendor/design-system/src/components/layout.tsx +284 -0
- package/app/vendor/design-system/src/components/primitives.tsx +143 -0
- package/app/vendor/design-system/src/components/reorder.ts +40 -0
- package/app/vendor/design-system/src/fieldComponents.ts +63 -0
- package/app/vendor/design-system/src/icons.ts +102 -0
- package/app/vendor/design-system/src/index.ts +198 -0
- package/app/vendor/design-system/src/media.ts +229 -0
- package/app/vendor/design-system/src/theme.ts +116 -0
- package/app/vendor/design-system/src/web/Button.tsx +110 -0
- package/app/vendor/design-system/src/web/Icon.tsx +52 -0
- package/app/vendor/design-system/src/web/Input.tsx +39 -0
- package/app/vendor/design-system/src/web/index.ts +43 -0
- package/app/vendor/design-system/src/web/primitives.tsx +119 -0
- package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +248 -0
- package/app/vendor/markdown-editor/src/Toolbar.tsx +157 -0
- package/app/vendor/markdown-editor/src/field.tsx +67 -0
- package/app/vendor/markdown-editor/src/flavors/commonmark/index.ts +27 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/index.ts +30 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/parser.ts +73 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/schema.ts +70 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/serializer.ts +82 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/taskList.ts +39 -0
- package/app/vendor/markdown-editor/src/flavors/registry.ts +13 -0
- package/app/vendor/markdown-editor/src/flavors/shared/inputrules.ts +79 -0
- package/app/vendor/markdown-editor/src/flavors/shared/keymap.ts +89 -0
- package/app/vendor/markdown-editor/src/flavors/shared/placeholder.ts +24 -0
- package/app/vendor/markdown-editor/src/flavors/shared/plugins.ts +58 -0
- package/app/vendor/markdown-editor/src/flavors/types.ts +22 -0
- package/app/vendor/markdown-editor/src/formats/registry.ts +45 -0
- package/app/vendor/markdown-editor/src/icons.tsx +120 -0
- package/app/vendor/markdown-editor/src/index.ts +23 -0
- package/app/vendor/markdown-editor/src/theme.ts +249 -0
- package/app/vendor/markdown-editor/src/types.ts +58 -0
- package/app/vendor/plugin-sdk/src/__tests__/documentActions.test.ts +108 -0
- package/app/vendor/plugin-sdk/src/__tests__/loader.test.ts +75 -0
- package/app/vendor/plugin-sdk/src/__tests__/registry.test.ts +148 -0
- package/app/vendor/plugin-sdk/src/index.ts +33 -0
- package/app/vendor/plugin-sdk/src/loader.ts +45 -0
- package/app/vendor/plugin-sdk/src/navkeys.ts +17 -0
- package/app/vendor/plugin-sdk/src/react.tsx +155 -0
- package/app/vendor/plugin-sdk/src/registry.ts +337 -0
- package/app/vendor/plugin-sdk/src/types.ts +230 -0
- package/app/vendor/realtime/src/__tests__/pure.test.ts +58 -0
- package/app/vendor/realtime/src/__tests__/seeded-room.base64 +1 -0
- package/app/vendor/realtime/src/__tests__/seeding.test.ts +70 -0
- package/app/vendor/realtime/src/collab.ts +308 -0
- package/app/vendor/realtime/src/hooks.ts +57 -0
- package/app/vendor/realtime/src/index.ts +12 -0
- package/app/vendor/realtime/src/pure.ts +43 -0
- package/app/vite.config.mjs +59 -0
- package/bin/gogitcms-editor.mjs +168 -0
- package/npm-shrinkwrap.json +5696 -0
- package/package.json +74 -0
- package/src/commands/build.mjs +100 -0
- package/src/commands/dev.mjs +186 -0
- package/src/commands/init.mjs +144 -0
- package/src/commands/login.mjs +116 -0
- package/src/commands/status.mjs +68 -0
- package/src/lib/api.mjs +122 -0
- package/src/lib/appRoot.mjs +51 -0
- package/src/lib/baked.json +3 -0
- package/src/lib/config.mjs +108 -0
- package/src/lib/credentials.mjs +91 -0
- package/src/lib/defaults.mjs +26 -0
- package/src/lib/graphql.mjs +28 -0
- package/src/lib/localServer.mjs +206 -0
- package/src/lib/open.mjs +23 -0
- package/src/lib/plugins.mjs +164 -0
- package/src/lib/tokenBroker.mjs +85 -0
- package/src/tui/prompts.mjs +92 -0
- package/src/tui/screen.mjs +282 -0
- package/src/tui/theme.mjs +40 -0
package/src/lib/api.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// REST + GraphQL client for the CLI.
|
|
2
|
+
//
|
|
3
|
+
// The CLI is a native client: it holds a refresh token itself (see
|
|
4
|
+
// credentials.mjs) rather than relying on the HttpOnly cookie the browser gets,
|
|
5
|
+
// so every token-issuing call passes `native: true` and reads the pair from the
|
|
6
|
+
// response body.
|
|
7
|
+
|
|
8
|
+
import * as credentials from "./credentials.mjs";
|
|
9
|
+
|
|
10
|
+
export class ApiError extends Error {
|
|
11
|
+
constructor(status, slug, message) {
|
|
12
|
+
super(message || slug || `request failed with ${status}`);
|
|
13
|
+
this.name = "ApiError";
|
|
14
|
+
this.status = status;
|
|
15
|
+
// slug is the machine-readable reason (e.g. "authorization_pending") the
|
|
16
|
+
// device flow branches on.
|
|
17
|
+
this.slug = slug;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function parseError(res) {
|
|
22
|
+
try {
|
|
23
|
+
const body = await res.json();
|
|
24
|
+
return body.error || body.message || "";
|
|
25
|
+
} catch {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function post(apiUrl, path, body, token) {
|
|
31
|
+
const res = await fetch(`${credentials.normalizeApiUrl(apiUrl)}/api/v1${path}`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: {
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
36
|
+
},
|
|
37
|
+
body: JSON.stringify(body ?? {}),
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) throw new ApiError(res.status, await parseError(res));
|
|
40
|
+
if (res.status === 204) return null;
|
|
41
|
+
return res.json();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// requestDeviceCode starts the device grant and returns the code pair plus where
|
|
45
|
+
// to send the user.
|
|
46
|
+
export function requestDeviceCode(apiUrl, clientName = "gogitcms-editor") {
|
|
47
|
+
return post(apiUrl, "/auth/device/code", { clientName });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// pollDeviceToken asks once for the session behind a device code. A pending or
|
|
51
|
+
// rejected authorization arrives as an ApiError whose slug says which.
|
|
52
|
+
export function pollDeviceToken(apiUrl, deviceCode) {
|
|
53
|
+
return post(apiUrl, "/auth/device/token", { deviceCode });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// refresh rotates the stored refresh token, returning a fresh pair.
|
|
57
|
+
export function refresh(apiUrl, refreshToken) {
|
|
58
|
+
return post(apiUrl, "/auth/refresh", { refreshToken, native: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function me(apiUrl, token) {
|
|
62
|
+
const res = await fetch(`${credentials.normalizeApiUrl(apiUrl)}/api/v1/me`, {
|
|
63
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
64
|
+
});
|
|
65
|
+
if (!res.ok) throw new ApiError(res.status, await parseError(res));
|
|
66
|
+
return res.json();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// accessToken returns a currently-valid access token for an API origin,
|
|
70
|
+
// refreshing and re-persisting the pair when the stored one has aged out. Throws
|
|
71
|
+
// NotLoggedInError when there is nothing to refresh from, so callers can point
|
|
72
|
+
// the user at `gogitcms-editor login`.
|
|
73
|
+
export class NotLoggedInError extends Error {
|
|
74
|
+
constructor(apiUrl) {
|
|
75
|
+
super(`Not signed in to ${apiUrl}. Run gogitcms-editor login first.`);
|
|
76
|
+
this.name = "NotLoggedInError";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function accessToken(apiUrl) {
|
|
81
|
+
const creds = await credentials.load(apiUrl);
|
|
82
|
+
if (!creds?.refreshToken) throw new NotLoggedInError(apiUrl);
|
|
83
|
+
if (credentials.isFresh(creds)) return creds.accessToken;
|
|
84
|
+
|
|
85
|
+
let pair;
|
|
86
|
+
try {
|
|
87
|
+
pair = await refresh(apiUrl, creds.refreshToken);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
// A rejected refresh token is a dead session, not a transient failure —
|
|
90
|
+
// drop it so the next command asks for a fresh sign-in instead of retrying.
|
|
91
|
+
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
|
|
92
|
+
await credentials.clear(apiUrl);
|
|
93
|
+
throw new NotLoggedInError(apiUrl);
|
|
94
|
+
}
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
const next = {
|
|
98
|
+
...creds,
|
|
99
|
+
accessToken: pair.accessToken,
|
|
100
|
+
// Refresh tokens rotate: keep the new one or the next refresh fails.
|
|
101
|
+
refreshToken: pair.refreshToken || creds.refreshToken,
|
|
102
|
+
expiresAt: credentials.expiresAtFrom(pair.accessToken),
|
|
103
|
+
};
|
|
104
|
+
await credentials.save(apiUrl, next);
|
|
105
|
+
return next.accessToken;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// graphql runs one operation against the server's /graphql endpoint with a fresh
|
|
109
|
+
// access token. GraphQL reports errors in a 200 body, so those are surfaced here
|
|
110
|
+
// rather than left for the caller to notice.
|
|
111
|
+
export async function graphql(apiUrl, query, variables = {}) {
|
|
112
|
+
const token = await accessToken(apiUrl);
|
|
113
|
+
const res = await fetch(`${credentials.normalizeApiUrl(apiUrl)}/graphql`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
116
|
+
body: JSON.stringify({ query, variables }),
|
|
117
|
+
});
|
|
118
|
+
if (!res.ok) throw new ApiError(res.status, await parseError(res));
|
|
119
|
+
const body = await res.json();
|
|
120
|
+
if (body.errors?.length) throw new Error(body.errors[0].message);
|
|
121
|
+
return body.data;
|
|
122
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Locating the SPA sources that `dev` and `build` run Vite over.
|
|
2
|
+
//
|
|
3
|
+
// Two layouts have to work:
|
|
4
|
+
// workspace — inside the monorepo the editor is the linked @gogitcms/frontend
|
|
5
|
+
// package, whose sources are the ones being edited.
|
|
6
|
+
// published — `scripts/prepack.mjs` stages a self-contained copy of the editor
|
|
7
|
+
// (plus the workspace @gogitcms/* packages it imports) into ./app, so the npm
|
|
8
|
+
// tarball carries everything and resolves nothing outside itself.
|
|
9
|
+
//
|
|
10
|
+
// The workspace copy wins when it resolves. @gogitcms/frontend is a devDependency, so
|
|
11
|
+
// it is never installed for a consumer of the published package — only a
|
|
12
|
+
// checkout can satisfy it, and there the live sources are always what the
|
|
13
|
+
// developer means. Preferring ./app instead made a leftover staging directory
|
|
14
|
+
// (one `make ide-build` is enough to create it) silently shadow the real
|
|
15
|
+
// sources, so edits to the editor appeared to do nothing.
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import { createRequire } from "node:module";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const packageRoot = path.resolve(here, "..", "..");
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
|
|
27
|
+
export function resolveAppRoot() {
|
|
28
|
+
try {
|
|
29
|
+
// @gogitcms/frontend declares no "exports", so its package.json is resolvable and
|
|
30
|
+
// gives us the package directory without depending on an entry point.
|
|
31
|
+
return path.dirname(require.resolve("@gogitcms/frontend/package.json"));
|
|
32
|
+
} catch {
|
|
33
|
+
// Not a checkout: fall through to the copy staged into the tarball.
|
|
34
|
+
}
|
|
35
|
+
const staged = path.join(packageRoot, "app");
|
|
36
|
+
if (fs.existsSync(path.join(staged, "index.html"))) return staged;
|
|
37
|
+
throw new Error(
|
|
38
|
+
"Could not locate the editor sources. This build of gogitcms-editor is incomplete — reinstall the package.",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// viteConfigFile returns the Vite config that belongs to the resolved app root.
|
|
43
|
+
// The staged copy ships its own (with aliases rewritten to the vendored
|
|
44
|
+
// packages); the workspace copy uses the editor's own vite.config.ts.
|
|
45
|
+
export function viteConfigFile(appRoot) {
|
|
46
|
+
for (const name of ["vite.config.mjs", "vite.config.ts"]) {
|
|
47
|
+
const file = path.join(appRoot, name);
|
|
48
|
+
if (fs.existsSync(file)) return file;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The project's cms.config.{js,mjs,cjs,ts}, read from the working directory.
|
|
2
|
+
//
|
|
3
|
+
// The SPA reads the same shape at build time via Vite `define` — see
|
|
4
|
+
// apps/frontend/src/config.ts, which resolves API_URL / WORKSPACE_ID /
|
|
5
|
+
// REPOSITORY_ID out of __CMS_CONFIG_BUILD__. `init` writes this file; `dev` and
|
|
6
|
+
// `build` read it back.
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
|
|
13
|
+
const NAMES = ["cms.config.js", "cms.config.mjs", "cms.config.cjs", "cms.config.ts"];
|
|
14
|
+
|
|
15
|
+
// Written as .mjs, not .js: the file is an ES module, and in a project whose
|
|
16
|
+
// package.json has no "type" field Node warns on every load about reparsing a
|
|
17
|
+
// .js file as ESM. The .mjs extension is unambiguous, and `find` still accepts a
|
|
18
|
+
// hand-written cms.config.js.
|
|
19
|
+
export const CONFIG_FILENAME = "cms.config.mjs";
|
|
20
|
+
|
|
21
|
+
// find returns the path of the project's config file, or null.
|
|
22
|
+
export function find(cwd = process.cwd()) {
|
|
23
|
+
return NAMES.map((n) => path.resolve(cwd, n)).find((p) => fs.existsSync(p)) ?? null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// load reads and evaluates the config file, returning its default export (or
|
|
27
|
+
// null when there is no config yet).
|
|
28
|
+
export async function load(cwd = process.cwd()) {
|
|
29
|
+
const file = find(cwd);
|
|
30
|
+
if (!file) return null;
|
|
31
|
+
if (file.endsWith(".ts")) {
|
|
32
|
+
// Transpile the TS config with esbuild (a Vite dependency) and import it.
|
|
33
|
+
const { transform } = await import("esbuild");
|
|
34
|
+
const src = await readFile(file, "utf8");
|
|
35
|
+
const { code } = await transform(src, { loader: "ts", format: "esm" });
|
|
36
|
+
const mod = await import("data:text/javascript," + encodeURIComponent(code));
|
|
37
|
+
return mod.default ?? mod.config ?? null;
|
|
38
|
+
}
|
|
39
|
+
const mod = await import(pathToFileURL(file).href);
|
|
40
|
+
return mod.default ?? mod.config ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// normalizeBasePath turns whatever a user writes — "cms", "/cms", "/cms/" — into
|
|
44
|
+
// the form Vite's `base` expects: leading and trailing slash, or "/" for a site
|
|
45
|
+
// served from the root. The SPA reads the same value back as
|
|
46
|
+
// import.meta.env.BASE_URL to build its own URLs (see apps/frontend/src/
|
|
47
|
+
// navigation.tsx), so this is the single place the mount path is decided.
|
|
48
|
+
export function normalizeBasePath(raw) {
|
|
49
|
+
const trimmed = String(raw ?? "").trim();
|
|
50
|
+
if (!trimmed || trimmed === "/") return "/";
|
|
51
|
+
return `/${trimmed.replace(/^\/+|\/+$/g, "")}/`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// render produces the file body `init` writes — a real ES module, formatted the
|
|
55
|
+
// way it is shown in the terminal.
|
|
56
|
+
export function render({ API_URL, WORKSPACE_ID, REPOSITORY_ID, BASE_PATH }) {
|
|
57
|
+
return [
|
|
58
|
+
"// Go·Git CMS editor configuration, written by `gogitcms-editor init`.",
|
|
59
|
+
"export default {",
|
|
60
|
+
` API_URL: ${JSON.stringify(API_URL)},`,
|
|
61
|
+
` WORKSPACE_ID: ${JSON.stringify(WORKSPACE_ID)},`,
|
|
62
|
+
` REPOSITORY_ID: ${JSON.stringify(REPOSITORY_ID)},`,
|
|
63
|
+
"",
|
|
64
|
+
" // Path this editor is served from. Use a subpath (e.g. \"/cms/\") when it",
|
|
65
|
+
" // is mounted under one — assets and in-app URLs are both built from it.",
|
|
66
|
+
` BASE_PATH: ${JSON.stringify(normalizeBasePath(BASE_PATH))},`,
|
|
67
|
+
"};",
|
|
68
|
+
"",
|
|
69
|
+
].join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function write(config, cwd = process.cwd()) {
|
|
73
|
+
const file = path.resolve(cwd, CONFIG_FILENAME);
|
|
74
|
+
await writeFile(file, render(config), "utf8");
|
|
75
|
+
return file;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The keys the SPA actually reads at runtime (apps/frontend/src/config.ts).
|
|
79
|
+
// This list is an allow-list, so a key missing from it is dropped in silence —
|
|
80
|
+
// PROJECT was, which meant a config pinning the editor to one project of a
|
|
81
|
+
// multi-project repository built an editor that was not pinned to anything.
|
|
82
|
+
// BASE_PATH is deliberately absent: it configures the bundler, and the SPA reads
|
|
83
|
+
// the result as import.meta.env.BASE_URL rather than from this object.
|
|
84
|
+
const RUNTIME_KEYS = [
|
|
85
|
+
"API_URL",
|
|
86
|
+
"WORKSPACE_ID",
|
|
87
|
+
"REPOSITORY_ID",
|
|
88
|
+
"PROJECT",
|
|
89
|
+
"TOKEN_ENDPOINT",
|
|
90
|
+
"COLLAB_URL",
|
|
91
|
+
"MODE",
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
// defineFor packages the config for Vite's `define`, matching the
|
|
95
|
+
// __CMS_CONFIG_BUILD__ constant the SPA declares. Returns {} when there is
|
|
96
|
+
// nothing to inject, so a plain `vite` run still works.
|
|
97
|
+
export function defineFor(config) {
|
|
98
|
+
const clean = Object.fromEntries(
|
|
99
|
+
RUNTIME_KEYS.map((k) => [k, config?.[k]]).filter(([, v]) => v != null && v !== ""),
|
|
100
|
+
);
|
|
101
|
+
return Object.keys(clean).length ? { __CMS_CONFIG_BUILD__: JSON.stringify(clean) } : {};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// basePathFor resolves the mount path for a command: an explicit --base flag
|
|
105
|
+
// wins over the config file, which wins over the root.
|
|
106
|
+
export function basePathFor(config, flag) {
|
|
107
|
+
return normalizeBasePath(flag || config?.BASE_PATH || "/");
|
|
108
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Credential storage for the CLI.
|
|
2
|
+
//
|
|
3
|
+
// Tokens live in ~/.gitcms/credentials, keyed by API origin so one machine can
|
|
4
|
+
// hold sessions for several servers (a local stack and a hosted one). The file
|
|
5
|
+
// holds a refresh token — a long-lived bearer credential — so the directory is
|
|
6
|
+
// created 0700 and the file written 0600, and it is never logged or echoed.
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
const DIR = path.join(os.homedir(), ".gitcms");
|
|
13
|
+
const FILE = path.join(DIR, "credentials");
|
|
14
|
+
|
|
15
|
+
export const credentialsPath = FILE;
|
|
16
|
+
|
|
17
|
+
// Access tokens are short-lived; refresh a little early so a long-running
|
|
18
|
+
// command never presents one that expires mid-flight.
|
|
19
|
+
const EXPIRY_SKEW_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
// normalizeApiUrl strips a trailing slash so the same server is one key however
|
|
22
|
+
// the user typed it.
|
|
23
|
+
export function normalizeApiUrl(apiUrl) {
|
|
24
|
+
return String(apiUrl || "").replace(/\/+$/, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function readAll() {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(await fs.readFile(FILE, "utf8"));
|
|
30
|
+
} catch (e) {
|
|
31
|
+
if (e.code === "ENOENT") return {};
|
|
32
|
+
// A corrupt file shouldn't wedge the CLI — `login` will overwrite it.
|
|
33
|
+
if (e instanceof SyntaxError) return {};
|
|
34
|
+
throw e;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function writeAll(all) {
|
|
39
|
+
await fs.mkdir(DIR, { recursive: true, mode: 0o700 });
|
|
40
|
+
await fs.writeFile(FILE, JSON.stringify(all, null, 2) + "\n", { mode: 0o600 });
|
|
41
|
+
// mkdir/writeFile only apply the mode when creating, so an existing file keeps
|
|
42
|
+
// whatever it had — re-assert both.
|
|
43
|
+
await fs.chmod(DIR, 0o700).catch(() => {});
|
|
44
|
+
await fs.chmod(FILE, 0o600);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// load returns the stored credentials for an API origin, or null.
|
|
48
|
+
export async function load(apiUrl) {
|
|
49
|
+
const all = await readAll();
|
|
50
|
+
return all[normalizeApiUrl(apiUrl)] ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// loadAny returns [apiUrl, credentials] for the single stored session, used when
|
|
54
|
+
// no API URL is known yet (e.g. `status` before `init`). Null when there are
|
|
55
|
+
// none, or when there are several and the caller must disambiguate.
|
|
56
|
+
export async function loadAny() {
|
|
57
|
+
const all = await readAll();
|
|
58
|
+
const keys = Object.keys(all);
|
|
59
|
+
if (keys.length !== 1) return null;
|
|
60
|
+
return [keys[0], all[keys[0]]];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function save(apiUrl, creds) {
|
|
64
|
+
const all = await readAll();
|
|
65
|
+
all[normalizeApiUrl(apiUrl)] = creds;
|
|
66
|
+
await writeAll(all);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function clear(apiUrl) {
|
|
70
|
+
const all = await readAll();
|
|
71
|
+
delete all[normalizeApiUrl(apiUrl)];
|
|
72
|
+
await writeAll(all);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// expiresAtFrom converts an access-token lifetime into an absolute timestamp.
|
|
76
|
+
export function expiresAtFrom(accessToken) {
|
|
77
|
+
// The access token is a JWT; read its exp rather than guessing a TTL the
|
|
78
|
+
// server owns. An unparseable token just means "refresh on first failure".
|
|
79
|
+
try {
|
|
80
|
+
const [, payload] = accessToken.split(".");
|
|
81
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
82
|
+
if (typeof claims.exp === "number") return claims.exp * 1000;
|
|
83
|
+
} catch {
|
|
84
|
+
// fall through
|
|
85
|
+
}
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isFresh(creds) {
|
|
90
|
+
return Boolean(creds?.accessToken) && creds.expiresAt > Date.now() + EXPIRY_SKEW_MS;
|
|
91
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Where the CLI points when nothing says otherwise.
|
|
2
|
+
//
|
|
3
|
+
// GITCMS_API_URL lets a self-hoster set their server once (in a shell profile or
|
|
4
|
+
// a project .envrc) instead of passing --api-url to every command; it always
|
|
5
|
+
// wins. Otherwise the published CLI points at the value baked in at publish time
|
|
6
|
+
// (see scripts/prepack.mjs — the hosted server when built on GitHub Actions),
|
|
7
|
+
// falling back to the local stack so a fresh monorepo checkout works with no
|
|
8
|
+
// configuration.
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
function bakedApiUrl() {
|
|
15
|
+
try {
|
|
16
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const { apiUrl } = JSON.parse(readFileSync(path.join(here, "baked.json"), "utf8"));
|
|
18
|
+
return apiUrl || "";
|
|
19
|
+
} catch {
|
|
20
|
+
// No baked.json in a local checkout — fall through to localhost.
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_API_URL =
|
|
26
|
+
process.env.GITCMS_API_URL || bakedApiUrl() || "http://localhost:8080";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The handful of GraphQL documents `init` needs, as plain strings — the CLI
|
|
2
|
+
// talks to /graphql with fetch and has no Apollo runtime. They mirror WORKSPACES
|
|
3
|
+
// and REPOSITORIES in apps/frontend/src/queries.ts; keep them in step if the
|
|
4
|
+
// schema's selection set changes.
|
|
5
|
+
|
|
6
|
+
export const WORKSPACES = /* GraphQL */ `
|
|
7
|
+
query Workspaces {
|
|
8
|
+
workspaces {
|
|
9
|
+
id
|
|
10
|
+
name
|
|
11
|
+
slug
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
export const REPOSITORIES = /* GraphQL */ `
|
|
17
|
+
query Repositories($workspaceId: ID) {
|
|
18
|
+
repositories(workspaceId: $workspaceId) {
|
|
19
|
+
id
|
|
20
|
+
owner
|
|
21
|
+
name
|
|
22
|
+
branches {
|
|
23
|
+
id
|
|
24
|
+
name
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
`;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// The local content server (gitcms-local), spawned by `gogitcms-editor dev --local`.
|
|
2
|
+
//
|
|
3
|
+
// In local mode there is no hosted API and no account: a Go binary indexes the
|
|
4
|
+
// working copy and serves the same GraphQL schema over loopback. This module
|
|
5
|
+
// finds that binary, starts it, and reads back the handshake the SPA needs.
|
|
6
|
+
//
|
|
7
|
+
// The binary is an optional dependency, so a remote-only install never downloads
|
|
8
|
+
// platform binaries it will not run — which also means "not installed" is a
|
|
9
|
+
// normal state to explain rather than a crash.
|
|
10
|
+
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
|
|
18
|
+
// The scopes a binary package can be published under, most likely first.
|
|
19
|
+
//
|
|
20
|
+
// @gogitcms is the npm org and the name this CLI carries in the tree. The
|
|
21
|
+
// GitHub Packages copy is renamed to @go-git-cms at publish time, because that
|
|
22
|
+
// registry requires the package scope to equal the repo owner and the GitHub
|
|
23
|
+
// org is spelled differently from the npm org (scripts/npm-scope.mjs). A CLI
|
|
24
|
+
// installed from GitHub Packages therefore has @go-git-cms binaries in its
|
|
25
|
+
// node_modules, so resolution has to try both — this file ships inside both
|
|
26
|
+
// tarballs and cannot know which one it is running from.
|
|
27
|
+
const SCOPES = ["@gogitcms", "@go-git-cms"];
|
|
28
|
+
|
|
29
|
+
// ownScope reads the scope off this CLI's own name, so resolution and the
|
|
30
|
+
// "not installed" hint both lead with the registry the user actually installed
|
|
31
|
+
// from rather than with a guess. Falls back to the npmjs scope if the manifest
|
|
32
|
+
// is unreadable, which is not a reason to fail — the other candidate is tried
|
|
33
|
+
// either way.
|
|
34
|
+
function ownScope() {
|
|
35
|
+
try {
|
|
36
|
+
const { name } = require("../../package.json");
|
|
37
|
+
const scope = typeof name === "string" ? name.split("/")[0] : null;
|
|
38
|
+
if (SCOPES.includes(scope)) return scope;
|
|
39
|
+
} catch {
|
|
40
|
+
// Fall through.
|
|
41
|
+
}
|
|
42
|
+
return SCOPES[0];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// PLATFORM_SUFFIXES maps Node's platform/arch onto the published binary
|
|
46
|
+
// packages. They are optional dependencies of this CLI, so npm installs exactly
|
|
47
|
+
// the one matching the machine (os/cpu) and skips the rest — the binaries are
|
|
48
|
+
// ~21 MB each, and a remote-only user should not pay for five of them.
|
|
49
|
+
// Mirrors the matrix in scripts/build-local-binaries.mjs.
|
|
50
|
+
const PLATFORM_SUFFIXES = {
|
|
51
|
+
"darwin-arm64": "gitcms-local-darwin-arm64",
|
|
52
|
+
"darwin-x64": "gitcms-local-darwin-x64",
|
|
53
|
+
"linux-x64": "gitcms-local-linux-x64",
|
|
54
|
+
"linux-arm64": "gitcms-local-linux-arm64",
|
|
55
|
+
"win32-x64": "gitcms-local-win32-x64",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// platformPackages returns the candidate package names for this machine, this
|
|
59
|
+
// CLI's own scope first, or an empty list on a platform we publish no binary
|
|
60
|
+
// for.
|
|
61
|
+
function platformPackages(key) {
|
|
62
|
+
const suffix = PLATFORM_SUFFIXES[key];
|
|
63
|
+
if (!suffix) return [];
|
|
64
|
+
const mine = ownScope();
|
|
65
|
+
const ordered = [mine, ...SCOPES.filter((s) => s !== mine)];
|
|
66
|
+
return ordered.map((scope) => `${scope}/${suffix}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class LocalServerError extends Error {}
|
|
70
|
+
|
|
71
|
+
// resolveBinary finds the gitcms-local executable.
|
|
72
|
+
//
|
|
73
|
+
// Order matters: an explicit override wins so a developer can point at a build
|
|
74
|
+
// from source, then the platform package, then a PATH lookup for someone who
|
|
75
|
+
// installed the Go binary directly.
|
|
76
|
+
export function resolveBinary() {
|
|
77
|
+
if (process.env.GITCMS_LOCAL_BIN) {
|
|
78
|
+
const override = process.env.GITCMS_LOCAL_BIN;
|
|
79
|
+
if (!fs.existsSync(override)) {
|
|
80
|
+
throw new LocalServerError(`GITCMS_LOCAL_BIN points at ${override}, which does not exist.`);
|
|
81
|
+
}
|
|
82
|
+
return override;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const key = `${process.platform}-${process.arch}`;
|
|
86
|
+
const candidates = platformPackages(key);
|
|
87
|
+
const exe = process.platform === "win32" ? "gitcms-local.exe" : "gitcms-local";
|
|
88
|
+
for (const pkg of candidates) {
|
|
89
|
+
try {
|
|
90
|
+
// Resolve the package's own manifest, then the binary beside it — resolving
|
|
91
|
+
// the binary path directly would depend on it being an export.
|
|
92
|
+
const manifest = require.resolve(`${pkg}/package.json`);
|
|
93
|
+
const candidate = path.join(path.dirname(manifest), "bin", exe);
|
|
94
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
95
|
+
} catch {
|
|
96
|
+
// Not installed under this scope — try the next, then PATH, then error.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const onPath = lookupOnPath();
|
|
101
|
+
if (onPath) return onPath;
|
|
102
|
+
|
|
103
|
+
// candidates[0] is this CLI's own scope (see platformPackages), so the
|
|
104
|
+
// install hint matches the registry the user actually got this copy from.
|
|
105
|
+
const preferred = candidates[0];
|
|
106
|
+
throw new LocalServerError(
|
|
107
|
+
preferred
|
|
108
|
+
? `Local mode needs the ${preferred} binary, which ships as an optional\n` +
|
|
109
|
+
` dependency of this CLI. If you installed with --no-optional or\n` +
|
|
110
|
+
` --ignore-optional, reinstall without it:\n\n` +
|
|
111
|
+
` npm install ${preferred}\n\n` +
|
|
112
|
+
` Or build from source (apps/local) and set GITCMS_LOCAL_BIN.`
|
|
113
|
+
: `Local mode has no prebuilt binary for ${key}.\n` +
|
|
114
|
+
` Build one from apps/local and point GITCMS_LOCAL_BIN at it.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function lookupOnPath() {
|
|
119
|
+
const exe = process.platform === "win32" ? "gitcms-local.exe" : "gitcms-local";
|
|
120
|
+
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
|
121
|
+
if (!dir) continue;
|
|
122
|
+
const candidate = path.join(dir, exe);
|
|
123
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// start spawns the server and resolves with its handshake once it is listening.
|
|
129
|
+
//
|
|
130
|
+
// The binary prints one line of JSON to stdout with --json and logs to stderr,
|
|
131
|
+
// so the handshake is read from the first stdout line rather than by polling a
|
|
132
|
+
// health endpoint — it is available the instant the port is bound, and it
|
|
133
|
+
// carries the session token the SPA needs anyway.
|
|
134
|
+
export async function start({ root, port, onLog }) {
|
|
135
|
+
const bin = resolveBinary();
|
|
136
|
+
const args = ["serve", "--root", root, "--json"];
|
|
137
|
+
if (port) args.push("--port", String(port));
|
|
138
|
+
|
|
139
|
+
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
140
|
+
|
|
141
|
+
let stdout = "";
|
|
142
|
+
let stderr = "";
|
|
143
|
+
const handshake = await new Promise((resolve, reject) => {
|
|
144
|
+
// A binary that starts but never prints (a hung filesystem, a huge cold
|
|
145
|
+
// index) must not hang the CLI forever with no explanation.
|
|
146
|
+
const timer = setTimeout(() => {
|
|
147
|
+
child.kill();
|
|
148
|
+
reject(new LocalServerError(`The local server did not start within 60s.${stderr ? `\n${stderr.trim()}` : ""}`));
|
|
149
|
+
}, 60_000);
|
|
150
|
+
|
|
151
|
+
child.stdout.setEncoding("utf8");
|
|
152
|
+
child.stdout.on("data", (chunk) => {
|
|
153
|
+
stdout += chunk;
|
|
154
|
+
const newline = stdout.indexOf("\n");
|
|
155
|
+
if (newline === -1) return;
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
try {
|
|
158
|
+
resolve(JSON.parse(stdout.slice(0, newline)));
|
|
159
|
+
} catch (e) {
|
|
160
|
+
reject(new LocalServerError(`Could not read the local server's handshake: ${e.message}`));
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
child.stderr.setEncoding("utf8");
|
|
165
|
+
child.stderr.on("data", (chunk) => {
|
|
166
|
+
stderr += chunk;
|
|
167
|
+
if (onLog) onLog(chunk.trimEnd());
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
child.on("error", (e) => {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
reject(new LocalServerError(`Could not start ${bin}: ${e.message}`));
|
|
173
|
+
});
|
|
174
|
+
child.on("exit", (code) => {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
reject(new LocalServerError(`The local server exited (${code}).${stderr ? `\n${stderr.trim()}` : ""}`));
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// The startup listeners above reject on exit; past the handshake an exit is
|
|
181
|
+
// just the process ending, so they are replaced.
|
|
182
|
+
child.removeAllListeners("exit");
|
|
183
|
+
child.removeAllListeners("error");
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
handshake,
|
|
187
|
+
stop: () =>
|
|
188
|
+
new Promise((resolve) => {
|
|
189
|
+
if (child.exitCode !== null) return resolve();
|
|
190
|
+
child.once("exit", () => resolve());
|
|
191
|
+
child.kill();
|
|
192
|
+
}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// capabilities fetches the server's capability flags, which tell the SPA which
|
|
197
|
+
// surfaces to hide. Fetched rather than assumed so the flags live in one place —
|
|
198
|
+
// the server — and a future capability needs no CLI change.
|
|
199
|
+
export async function capabilities(handshake) {
|
|
200
|
+
const res = await fetch(handshake.contextUrl, {
|
|
201
|
+
headers: { Accept: "application/json" },
|
|
202
|
+
});
|
|
203
|
+
if (!res.ok) throw new LocalServerError(`The local server rejected the context request (${res.status}).`);
|
|
204
|
+
const body = await res.json();
|
|
205
|
+
return body.capabilities ?? {};
|
|
206
|
+
}
|
package/src/lib/open.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Opening a URL in the user's browser, without a dependency.
|
|
2
|
+
//
|
|
3
|
+
// Best-effort by design: the CLI always prints the URL too, so a headless box or
|
|
4
|
+
// a locked-down desktop just means the user copies it by hand.
|
|
5
|
+
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
export function openUrl(url) {
|
|
9
|
+
const [cmd, args] =
|
|
10
|
+
process.platform === "darwin"
|
|
11
|
+
? ["open", [url]]
|
|
12
|
+
: process.platform === "win32"
|
|
13
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
14
|
+
: ["xdg-open", [url]];
|
|
15
|
+
try {
|
|
16
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
17
|
+
child.on("error", () => {});
|
|
18
|
+
child.unref();
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|