@moloodev/cli 0.2.2 → 0.4.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/README.md +3 -0
- package/package.json +14 -15
- package/src/auth.ts +158 -0
- package/src/cli.ts +88 -34
- package/src/config.ts +36 -0
- package/src/credentials.ts +54 -0
- package/src/dev-server.ts +40 -141
- package/src/editor-dev.ts +38 -0
- package/src/editor-views.ts +23 -43
- package/src/index.ts +0 -2
- package/src/oauth.ts +190 -0
- package/src/publish.ts +6 -5
- package/src/workspace.ts +68 -0
- package/src/dev-host-app.tsx +0 -688
- package/src/dev-host-client.ts +0 -940
- package/src/dev-host-entry.tsx +0 -12
- package/src/dev-host.css +0 -19
- package/src/view-page.ts +0 -57
package/src/dev-server.ts
CHANGED
|
@@ -1,82 +1,27 @@
|
|
|
1
|
+
import { getWorkspaceHostUrl } from "@moloodev/platform";
|
|
1
2
|
import { existsSync, watch, type FSWatcher } from "node:fs";
|
|
2
3
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
3
|
-
import {
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import esbuild from "esbuild";
|
|
4
|
+
import { join } from "node:path";
|
|
6
5
|
import {
|
|
7
|
-
allowedFrameAncestor,
|
|
8
6
|
editorOriginHeaders,
|
|
9
7
|
type EditorOriginHeaderPolicy,
|
|
10
8
|
} from "@moloodev/editor-sdk/origin-headers";
|
|
11
|
-
import { buildEditorScript, readPackageEditorView
|
|
9
|
+
import { buildEditorScript, readPackageEditorView } from "./editor-views";
|
|
12
10
|
|
|
13
11
|
export type StartEditorDevServerOptions = {
|
|
14
12
|
readonly packageRoot: string;
|
|
15
|
-
readonly hostPort?: number;
|
|
16
13
|
readonly viewPort?: number;
|
|
17
14
|
readonly watch?: boolean;
|
|
18
15
|
};
|
|
19
16
|
|
|
20
17
|
export type EditorDevServer = {
|
|
21
18
|
readonly editorId: string;
|
|
22
|
-
readonly hostOrigin: string;
|
|
23
19
|
readonly viewOrigin: string;
|
|
24
|
-
readonly hostPort: number;
|
|
25
20
|
readonly viewPort: number;
|
|
26
21
|
rebuild(): Promise<{ build: number }>;
|
|
27
22
|
close(): Promise<void>;
|
|
28
23
|
};
|
|
29
24
|
|
|
30
|
-
let cachedHostScript: string | null = null;
|
|
31
|
-
|
|
32
|
-
function packageSourceDir(specifier: string): string {
|
|
33
|
-
let dir = dirname(fileURLToPath(import.meta.resolve(specifier)));
|
|
34
|
-
while (!existsSync(join(dir, "package.json"))) {
|
|
35
|
-
const parent = dirname(dir);
|
|
36
|
-
if (parent === dir) throw new Error(`Cannot locate package root for ${specifier}`);
|
|
37
|
-
dir = parent;
|
|
38
|
-
}
|
|
39
|
-
return join(dir, "src");
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function buildHostClientScript(): Promise<string> {
|
|
43
|
-
if (cachedHostScript !== null) return cachedHostScript;
|
|
44
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
45
|
-
const css = new Set<string>();
|
|
46
|
-
const built = await esbuild.build({
|
|
47
|
-
entryPoints: [join(here, "dev-host-entry.tsx")],
|
|
48
|
-
bundle: true,
|
|
49
|
-
format: "iife",
|
|
50
|
-
platform: "browser",
|
|
51
|
-
jsx: "automatic",
|
|
52
|
-
write: false,
|
|
53
|
-
logLevel: "error",
|
|
54
|
-
legalComments: "none",
|
|
55
|
-
define: { "process.env.NODE_ENV": '"development"' },
|
|
56
|
-
alias: { "lib0/logging": join(here, "yjs-log.ts") },
|
|
57
|
-
plugins: [
|
|
58
|
-
{
|
|
59
|
-
name: "collect-host-css",
|
|
60
|
-
setup(builder) {
|
|
61
|
-
builder.onLoad({ filter: /\.css$/ }, (args) => {
|
|
62
|
-
css.add(args.path);
|
|
63
|
-
return { contents: "", loader: "js" };
|
|
64
|
-
});
|
|
65
|
-
},
|
|
66
|
-
},
|
|
67
|
-
],
|
|
68
|
-
});
|
|
69
|
-
const file = built.outputFiles?.[0];
|
|
70
|
-
if (!file) throw new Error("Failed to bundle dev-host-entry.tsx");
|
|
71
|
-
const header = await styleHeader(join(here, "dev-host.css"), [...css], [
|
|
72
|
-
here,
|
|
73
|
-
packageSourceDir("@moloodev/ui"),
|
|
74
|
-
packageSourceDir("@moloodev/editor-sdk/layout"),
|
|
75
|
-
]);
|
|
76
|
-
cachedHostScript = header + file.text;
|
|
77
|
-
return cachedHostScript;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
25
|
function viewDocumentCsp(network: readonly string[], ancestor: string | null): string {
|
|
81
26
|
const connect = ["'self'", ...network];
|
|
82
27
|
const frameAncestors = ancestor ? [ancestor] : ["'none'"];
|
|
@@ -136,8 +81,8 @@ function closeServer(server: Server): Promise<void> {
|
|
|
136
81
|
export async function startEditorDevServer(
|
|
137
82
|
options: StartEditorDevServerOptions,
|
|
138
83
|
): Promise<EditorDevServer> {
|
|
84
|
+
const appOrigin = getWorkspaceHostUrl("production");
|
|
139
85
|
const initialView = readPackageEditorView(options.packageRoot);
|
|
140
|
-
const hostScript = await buildHostClientScript();
|
|
141
86
|
let currentView = initialView;
|
|
142
87
|
let currentEditorScript = await buildEditorScript(initialView);
|
|
143
88
|
let buildCount = 1;
|
|
@@ -146,11 +91,32 @@ export async function startEditorDevServer(
|
|
|
146
91
|
let watcher: FSWatcher | null = null;
|
|
147
92
|
let watchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
148
93
|
|
|
149
|
-
let hostOrigin = "";
|
|
150
94
|
let viewOrigin = "";
|
|
151
95
|
|
|
152
96
|
const viewServer = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
97
|
+
// Check the literal authority before handling requests to prevent DNS rebinding.
|
|
98
|
+
if (![`127.0.0.1:${boundViewPort}`, `localhost:${boundViewPort}`, `[::1]:${boundViewPort}`].includes(req.headers.host ?? "") ||
|
|
99
|
+
(req.headers.origin && req.headers.origin !== appOrigin)) {
|
|
100
|
+
res.writeHead(403);
|
|
101
|
+
res.end("forbidden");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (req.headers.origin === appOrigin) {
|
|
105
|
+
res.setHeader("access-control-allow-origin", appOrigin);
|
|
106
|
+
res.setHeader("vary", "Origin");
|
|
107
|
+
}
|
|
153
108
|
const method = req.method ?? "GET";
|
|
109
|
+
if (method === "OPTIONS" && req.headers.origin === appOrigin) {
|
|
110
|
+
res.setHeader("access-control-allow-methods", "GET, HEAD");
|
|
111
|
+
// Manifest fetch has no custom headers; EventSource may resume with Last-Event-ID.
|
|
112
|
+
res.setHeader("access-control-allow-headers", "last-event-id");
|
|
113
|
+
if (req.headers["access-control-request-private-network"] === "true") {
|
|
114
|
+
res.setHeader("access-control-allow-private-network", "true");
|
|
115
|
+
}
|
|
116
|
+
res.writeHead(204, { "cache-control": "no-store" });
|
|
117
|
+
res.end();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
154
120
|
const url = new URL(req.url ?? "/", viewOrigin || "http://127.0.0.1");
|
|
155
121
|
const refused = editorOriginHeaders({ parent: null, verifiedOrigins: [] });
|
|
156
122
|
|
|
@@ -166,10 +132,10 @@ export async function startEditorDevServer(
|
|
|
166
132
|
|
|
167
133
|
if (url.pathname === "/") {
|
|
168
134
|
const parent = singleQueryParam(url, "parent");
|
|
169
|
-
const verifiedOrigins = [
|
|
170
|
-
const ancestor =
|
|
135
|
+
const verifiedOrigins: readonly string[] = [appOrigin];
|
|
136
|
+
const ancestor = parent === appOrigin ? appOrigin : null;
|
|
171
137
|
const policy: EditorOriginHeaderPolicy = {
|
|
172
|
-
...editorOriginHeaders({ parent, verifiedOrigins }),
|
|
138
|
+
...editorOriginHeaders({ parent: ancestor, verifiedOrigins }),
|
|
173
139
|
"content-security-policy": viewDocumentCsp(currentView.network, ancestor),
|
|
174
140
|
};
|
|
175
141
|
applyOriginPolicy(res, policy);
|
|
@@ -203,62 +169,8 @@ export async function startEditorDevServer(
|
|
|
203
169
|
return;
|
|
204
170
|
}
|
|
205
171
|
|
|
206
|
-
applyOriginPolicy(res, refused);
|
|
207
|
-
res.writeHead(404, {
|
|
208
|
-
"content-type": "text/plain; charset=utf-8",
|
|
209
|
-
"cache-control": "no-store",
|
|
210
|
-
});
|
|
211
|
-
res.end(method === "HEAD" ? undefined : "not found");
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
const hostServer = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
215
|
-
const method = req.method ?? "GET";
|
|
216
|
-
const url = new URL(req.url ?? "/", hostOrigin || "http://127.0.0.1");
|
|
217
|
-
|
|
218
|
-
if (method !== "GET" && method !== "HEAD") {
|
|
219
|
-
res.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
|
|
220
|
-
res.end("method not allowed");
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (url.pathname === "/") {
|
|
225
|
-
const bootstrap = {
|
|
226
|
-
editorId: currentView.id,
|
|
227
|
-
hostOrigin,
|
|
228
|
-
viewOrigin,
|
|
229
|
-
manifest: currentView.manifest,
|
|
230
|
-
};
|
|
231
|
-
const html = [
|
|
232
|
-
"<!doctype html>",
|
|
233
|
-
'<html lang="ko" class="dark">',
|
|
234
|
-
"<head>",
|
|
235
|
-
'<meta charset="utf-8">',
|
|
236
|
-
`<title>${String(currentView.manifest.title ?? currentView.id)} — moloo dev</title>`,
|
|
237
|
-
"</head>",
|
|
238
|
-
"<body>",
|
|
239
|
-
`<script>window.__MOLOO_DEV_HOST__ = ${JSON.stringify(bootstrap).replace(/</g, "\\u003c")};</script>`,
|
|
240
|
-
'<script src="/host.js"></script>',
|
|
241
|
-
"</body>",
|
|
242
|
-
"</html>",
|
|
243
|
-
].join("\n");
|
|
244
|
-
res.writeHead(200, {
|
|
245
|
-
"content-type": "text/html; charset=utf-8",
|
|
246
|
-
"cache-control": "no-store",
|
|
247
|
-
});
|
|
248
|
-
res.end(method === "HEAD" ? undefined : html);
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
if (url.pathname === "/host.js") {
|
|
253
|
-
res.writeHead(200, {
|
|
254
|
-
"content-type": "text/javascript; charset=utf-8",
|
|
255
|
-
"cache-control": "no-store",
|
|
256
|
-
});
|
|
257
|
-
res.end(method === "HEAD" ? undefined : hostScript);
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
172
|
if (url.pathname === "/manifest.json") {
|
|
173
|
+
applyOriginPolicy(res, refused);
|
|
262
174
|
res.writeHead(200, {
|
|
263
175
|
"content-type": "application/json; charset=utf-8",
|
|
264
176
|
"cache-control": "no-store",
|
|
@@ -268,11 +180,16 @@ export async function startEditorDevServer(
|
|
|
268
180
|
}
|
|
269
181
|
|
|
270
182
|
if (url.pathname === "/events") {
|
|
183
|
+
applyOriginPolicy(res, refused);
|
|
271
184
|
res.writeHead(200, {
|
|
272
185
|
"content-type": "text/event-stream; charset=utf-8",
|
|
273
186
|
"cache-control": "no-store",
|
|
274
187
|
connection: "keep-alive",
|
|
275
188
|
});
|
|
189
|
+
if (method === "HEAD") {
|
|
190
|
+
res.end();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
276
193
|
res.write(`event: connected\ndata: ${JSON.stringify({ build: buildCount })}\n\n`);
|
|
277
194
|
sseClients.add(res);
|
|
278
195
|
req.on("close", () => {
|
|
@@ -281,27 +198,12 @@ export async function startEditorDevServer(
|
|
|
281
198
|
return;
|
|
282
199
|
}
|
|
283
200
|
|
|
201
|
+
applyOriginPolicy(res, refused);
|
|
284
202
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
285
|
-
res.end("not found");
|
|
203
|
+
res.end(method === "HEAD" ? undefined : "not found");
|
|
286
204
|
});
|
|
287
205
|
|
|
288
|
-
const
|
|
289
|
-
const boundHostPort = await listenOnLoopback(hostServer, requestedHostPort);
|
|
290
|
-
const requestedViewPort =
|
|
291
|
-
options.viewPort ?? (requestedHostPort === 0 ? 0 : boundHostPort + 1);
|
|
292
|
-
let boundViewPort: number;
|
|
293
|
-
try {
|
|
294
|
-
boundViewPort = await listenOnLoopback(viewServer, requestedViewPort);
|
|
295
|
-
} catch (err) {
|
|
296
|
-
if (options.viewPort === undefined) {
|
|
297
|
-
boundViewPort = await listenOnLoopback(viewServer, 0);
|
|
298
|
-
} else {
|
|
299
|
-
await closeServer(hostServer);
|
|
300
|
-
throw err;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
hostOrigin = `http://127.0.0.1:${String(boundHostPort)}`;
|
|
206
|
+
const boundViewPort = await listenOnLoopback(viewServer, options.viewPort ?? 4310);
|
|
305
207
|
viewOrigin = `http://127.0.0.1:${String(boundViewPort)}`;
|
|
306
208
|
|
|
307
209
|
function broadcastSse(event: string, payload: unknown): void {
|
|
@@ -329,8 +231,7 @@ export async function startEditorDevServer(
|
|
|
329
231
|
|
|
330
232
|
const srcDir = join(options.packageRoot, "src");
|
|
331
233
|
if (options.watch !== false && existsSync(srcDir)) {
|
|
332
|
-
watcher = watch(srcDir, { recursive: true }, (
|
|
333
|
-
if (filename && String(filename).includes(".view-css-")) return;
|
|
234
|
+
watcher = watch(srcDir, { recursive: true }, () => {
|
|
334
235
|
if (watchTimer !== null) clearTimeout(watchTimer);
|
|
335
236
|
watchTimer = setTimeout(() => {
|
|
336
237
|
watchTimer = null;
|
|
@@ -341,9 +242,7 @@ export async function startEditorDevServer(
|
|
|
341
242
|
|
|
342
243
|
return {
|
|
343
244
|
editorId: currentView.id,
|
|
344
|
-
hostOrigin,
|
|
345
245
|
viewOrigin,
|
|
346
|
-
hostPort: boundHostPort,
|
|
347
246
|
viewPort: boundViewPort,
|
|
348
247
|
rebuild,
|
|
349
248
|
async close() {
|
|
@@ -359,7 +258,7 @@ export async function startEditorDevServer(
|
|
|
359
258
|
client.end();
|
|
360
259
|
}
|
|
361
260
|
sseClients.clear();
|
|
362
|
-
await
|
|
261
|
+
await closeServer(viewServer);
|
|
363
262
|
},
|
|
364
263
|
};
|
|
365
264
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { getWorkspaceHostUrl } from "@moloodev/platform";
|
|
2
|
+
import { startEditorDevServer, type StartEditorDevServerOptions } from "./dev-server";
|
|
3
|
+
import { resolveWorkspace, type WorkspaceOptions } from "./workspace";
|
|
4
|
+
|
|
5
|
+
async function openBrowser(url: string) {
|
|
6
|
+
const { default: open } = await import("open");
|
|
7
|
+
await open(url, { wait: false });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function waitForStop() {
|
|
11
|
+
return new Promise<void>((resolve) => {
|
|
12
|
+
const stop = () => {
|
|
13
|
+
process.off("SIGINT", stop);
|
|
14
|
+
process.off("SIGTERM", stop);
|
|
15
|
+
resolve();
|
|
16
|
+
};
|
|
17
|
+
process.on("SIGINT", stop);
|
|
18
|
+
process.on("SIGTERM", stop);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runEditorDev(
|
|
23
|
+
options: StartEditorDevServerOptions & WorkspaceOptions & { workspace?: string },
|
|
24
|
+
deps = { resolve: resolveWorkspace, start: startEditorDevServer, open: openBrowser, stopped: waitForStop },
|
|
25
|
+
): Promise<number> {
|
|
26
|
+
const slug = await deps.resolve({ ...options, flag: options.workspace });
|
|
27
|
+
const server = await deps.start(options);
|
|
28
|
+
try {
|
|
29
|
+
const destination = new URL(`/w/${encodeURIComponent(slug)}/dev-editor`, getWorkspaceHostUrl("production"));
|
|
30
|
+
destination.searchParams.set("view", server.viewOrigin);
|
|
31
|
+
console.log(`${server.editorId} view origin -> ${server.viewOrigin}`);
|
|
32
|
+
await deps.open(destination.href);
|
|
33
|
+
await deps.stopped();
|
|
34
|
+
} finally {
|
|
35
|
+
await server.close();
|
|
36
|
+
}
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
package/src/editor-views.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, mkdtempSync,
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
+
import { findPackageJSON } from "node:module";
|
|
3
4
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import esbuild from "esbuild";
|
|
@@ -13,13 +14,10 @@ import { readEditorManifest } from "./read-editor-manifest";
|
|
|
13
14
|
|
|
14
15
|
export type EditorView = {
|
|
15
16
|
id: string;
|
|
16
|
-
editor: string;
|
|
17
17
|
packageRoot: string;
|
|
18
18
|
network: readonly string[];
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
const defaultFallback = join(dirname(fileURLToPath(import.meta.url)), "view-page.ts");
|
|
22
|
-
|
|
23
21
|
export function readPackageEditorView(packageRoot: string): EditorView & { manifest: Record<string, unknown> } {
|
|
24
22
|
const manifestPath = join(packageRoot, "src/manifest.ts");
|
|
25
23
|
if (!existsSync(manifestPath)) {
|
|
@@ -35,41 +33,16 @@ export function readPackageEditorView(packageRoot: string): EditorView & { manif
|
|
|
35
33
|
throw new Error(`Invalid editor id ${manifest.id}`);
|
|
36
34
|
}
|
|
37
35
|
const network = parseNetwork("network" in manifest ? manifest.network : undefined);
|
|
38
|
-
return { id: manifest.id,
|
|
36
|
+
return { id: manifest.id, packageRoot, network, manifest };
|
|
39
37
|
}
|
|
40
38
|
|
|
41
|
-
export function
|
|
42
|
-
const singleManifest = join(repo, "src/manifest.ts");
|
|
43
|
-
if (existsSync(singleManifest)) {
|
|
44
|
-
const view = readPackageEditorView(repo);
|
|
45
|
-
return [{ id: view.id, editor: view.editor, packageRoot: view.packageRoot, network: view.network }];
|
|
46
|
-
}
|
|
47
|
-
const dir = join(repo, "editors");
|
|
48
|
-
if (!existsSync(dir)) return [];
|
|
49
|
-
const found: EditorView[] = [];
|
|
50
|
-
for (const name of readdirSync(dir)) {
|
|
51
|
-
const packageRoot = join(dir, name);
|
|
52
|
-
const manifestPath = join(packageRoot, "src/manifest.ts");
|
|
53
|
-
if (!existsSync(manifestPath)) continue;
|
|
54
|
-
const manifest: unknown = readEditorManifest(manifestPath);
|
|
55
|
-
if (!isRecord(manifest) || typeof manifest.id !== "string") continue;
|
|
56
|
-
const parsed = parseEditorId(manifest.id, true);
|
|
57
|
-
if (parsed === undefined || parsed.publisher !== "moloo") continue;
|
|
58
|
-
const network = parseNetwork("network" in manifest ? manifest.network : undefined);
|
|
59
|
-
if (found.some((entry) => entry.id === manifest.id)) throw new Error(`Duplicate editor ${manifest.id}`);
|
|
60
|
-
found.push({ id: manifest.id, editor: parsed.editor, packageRoot, network });
|
|
61
|
-
}
|
|
62
|
-
return found.sort((left, right) => left.editor.localeCompare(right.editor));
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function resolveViewEntry(packageRoot: string, fallback: string = defaultFallback): string {
|
|
39
|
+
export function resolveViewEntry(packageRoot: string): string {
|
|
66
40
|
const manifest: unknown = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
67
41
|
if (!isRecord(manifest)) throw new Error("Invalid editor package.json");
|
|
68
42
|
const exports = manifest.exports;
|
|
69
|
-
if (exports
|
|
70
|
-
if (!isRecord(exports)) throw new Error("Invalid editor exports");
|
|
43
|
+
if (!isRecord(exports)) throw new Error("Editor package must export ./view");
|
|
71
44
|
const target = exports["./view"];
|
|
72
|
-
if (target === undefined)
|
|
45
|
+
if (target === undefined) throw new Error("Editor package must export ./view");
|
|
73
46
|
if (
|
|
74
47
|
typeof target !== "string" ||
|
|
75
48
|
!target.startsWith("./") ||
|
|
@@ -122,14 +95,14 @@ export async function buildEditorView(entry: string, output: string, metafile: s
|
|
|
122
95
|
writeFileSync(metafile, JSON.stringify(view.metafile));
|
|
123
96
|
}
|
|
124
97
|
|
|
125
|
-
export async function buildEditorScript(editor: EditorView
|
|
98
|
+
export async function buildEditorScript(editor: EditorView): Promise<string> {
|
|
126
99
|
const directory = mkdtempSync(join(tmpdir(), "moloo-editor-"));
|
|
127
100
|
try {
|
|
128
101
|
const output = join(directory, "editor.js");
|
|
129
102
|
const packageStyles = join(editor.packageRoot, "src/styles.css");
|
|
130
103
|
const styles = existsSync(packageStyles) ? packageStyles : undefined;
|
|
131
104
|
await buildEditorView(
|
|
132
|
-
resolveViewEntry(editor.packageRoot
|
|
105
|
+
resolveViewEntry(editor.packageRoot),
|
|
133
106
|
output,
|
|
134
107
|
join(directory, "meta.json"),
|
|
135
108
|
styles,
|
|
@@ -151,7 +124,7 @@ export async function buildEditorPackage(
|
|
|
151
124
|
const metafilePath = join(outDir, "meta.json");
|
|
152
125
|
const styles = join(packageRoot, "src/styles.css");
|
|
153
126
|
await buildEditorView(
|
|
154
|
-
resolveViewEntry(packageRoot
|
|
127
|
+
resolveViewEntry(packageRoot),
|
|
155
128
|
scriptPath,
|
|
156
129
|
metafilePath,
|
|
157
130
|
existsSync(styles) ? styles : undefined,
|
|
@@ -161,20 +134,27 @@ export async function buildEditorPackage(
|
|
|
161
134
|
return { id: view.id, scriptPath, manifestPath, metafilePath, script, manifest: view.manifest };
|
|
162
135
|
}
|
|
163
136
|
|
|
164
|
-
|
|
165
|
-
const
|
|
137
|
+
async function styleHeader(styles: string, collected: string[]): Promise<string> {
|
|
138
|
+
const animationPackage = findPackageJSON("tw-animate-css", import.meta.url);
|
|
139
|
+
if (!animationPackage) throw new Error("Missing CLI dependency: tw-animate-css");
|
|
140
|
+
const directory = mkdtempSync(join(tmpdir(), "moloo-view-css-"));
|
|
166
141
|
try {
|
|
167
142
|
const entry = join(directory, "input.css");
|
|
168
143
|
writeFileSync(
|
|
169
144
|
entry,
|
|
170
|
-
[
|
|
171
|
-
...[styles, ...collected].map((file) => `@import ${JSON.stringify(file)};`),
|
|
172
|
-
...sources.map((dir) => `@source ${JSON.stringify(dir)};`),
|
|
173
|
-
].join("\n"),
|
|
145
|
+
[styles, ...collected].map((file) => `@import ${JSON.stringify(file)};`).join("\n"),
|
|
174
146
|
);
|
|
175
147
|
const compiled = await build({
|
|
176
148
|
configFile: false,
|
|
177
|
-
root:
|
|
149
|
+
root: dirname(styles),
|
|
150
|
+
cacheDir: join(directory, ".vite"),
|
|
151
|
+
resolve: {
|
|
152
|
+
alias: {
|
|
153
|
+
tailwindcss: dirname(fileURLToPath(import.meta.resolve("tailwindcss/package.json"))),
|
|
154
|
+
"tw-animate-css": dirname(animationPackage),
|
|
155
|
+
"shadcn/tailwind.css": fileURLToPath(import.meta.resolve("shadcn/tailwind.css")),
|
|
156
|
+
},
|
|
157
|
+
},
|
|
178
158
|
plugins: [tailwind()],
|
|
179
159
|
logLevel: "error",
|
|
180
160
|
build: { write: false, cssMinify: false, rollupOptions: { input: entry } },
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,6 @@ export {
|
|
|
2
2
|
buildEditorPackage,
|
|
3
3
|
buildEditorScript,
|
|
4
4
|
buildEditorView,
|
|
5
|
-
findEditorViews,
|
|
6
5
|
readPackageEditorView,
|
|
7
6
|
resolveViewEntry,
|
|
8
7
|
type EditorView,
|
|
@@ -21,5 +20,4 @@ export {
|
|
|
21
20
|
uploadEndpoint,
|
|
22
21
|
} from "./publish";
|
|
23
22
|
export { readEditorManifest } from "./read-editor-manifest";
|
|
24
|
-
export { rewriteThreeLoaders } from "./three-local-loaders";
|
|
25
23
|
export { runCli } from "./cli";
|
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import type { Tokens } from "./credentials";
|
|
5
|
+
|
|
6
|
+
export class BrowserUnavailable extends Error {}
|
|
7
|
+
|
|
8
|
+
export const CLIENT_ID = "moloo-cli";
|
|
9
|
+
export const SCOPE = "profile email offline_access workspace:read";
|
|
10
|
+
export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
11
|
+
|
|
12
|
+
export async function oauthPost(issuer: string, path: string, body: Record<string, string>) {
|
|
13
|
+
return fetch(`${issuer}${path}`, {
|
|
14
|
+
method: "POST",
|
|
15
|
+
redirect: "error",
|
|
16
|
+
signal: AbortSignal.timeout(30_000),
|
|
17
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
18
|
+
body: new URLSearchParams({ client_id: CLIENT_ID, ...body }),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function readTokens(response: Response, refreshToken?: string): Promise<Tokens> {
|
|
23
|
+
const value = (await response.json()) as Record<string, unknown>;
|
|
24
|
+
if (
|
|
25
|
+
!response.ok ||
|
|
26
|
+
typeof value.access_token !== "string" ||
|
|
27
|
+
typeof (value.refresh_token ?? refreshToken) !== "string" ||
|
|
28
|
+
typeof value.expires_in !== "number" ||
|
|
29
|
+
!Number.isFinite(value.expires_in) ||
|
|
30
|
+
value.expires_in <= 0 ||
|
|
31
|
+
typeof value.token_type !== "string" ||
|
|
32
|
+
value.token_type.toLowerCase() !== "bearer" ||
|
|
33
|
+
typeof value.scope !== "string"
|
|
34
|
+
) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`OAuth token exchange failed (${String(value.error ?? response.status)}). Run moloo login.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
accessToken: value.access_token,
|
|
41
|
+
refreshToken: (value.refresh_token ?? refreshToken) as string,
|
|
42
|
+
expiresAt: Date.now() + value.expires_in * 1000,
|
|
43
|
+
scopes: value.scope.split(" ").filter(Boolean),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Bind before opening the browser; tolerate unrelated/forged requests without consuming the callback. */
|
|
48
|
+
export async function loopbackCallback(state: string, timeout = 120_000) {
|
|
49
|
+
let accept!: (code: string) => void;
|
|
50
|
+
let fail!: (error: Error) => void;
|
|
51
|
+
const code = new Promise<string>((resolve, reject) => {
|
|
52
|
+
accept = resolve;
|
|
53
|
+
fail = reject;
|
|
54
|
+
});
|
|
55
|
+
// Opening a browser can fail before the caller starts awaiting the code.
|
|
56
|
+
void code.catch(() => {});
|
|
57
|
+
const server = createServer((req, res) => {
|
|
58
|
+
res.setHeader("Cache-Control", "no-store");
|
|
59
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
60
|
+
const received = Buffer.from(url.searchParams.get("state") ?? "");
|
|
61
|
+
const expected = Buffer.from(state);
|
|
62
|
+
if (
|
|
63
|
+
req.method !== "GET" ||
|
|
64
|
+
url.pathname !== "/callback" ||
|
|
65
|
+
received.length !== expected.length ||
|
|
66
|
+
!timingSafeEqual(received, expected)
|
|
67
|
+
) {
|
|
68
|
+
res.writeHead(400).end("Invalid callback");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const error = url.searchParams.get("error");
|
|
72
|
+
const value = url.searchParams.get("code");
|
|
73
|
+
if (error) {
|
|
74
|
+
res.end("Login declined. Return to the terminal.");
|
|
75
|
+
fail(new Error(`OAuth authorization failed: ${error}`));
|
|
76
|
+
} else if (value) {
|
|
77
|
+
res.end("Authorization received. Return to the terminal.");
|
|
78
|
+
accept(value);
|
|
79
|
+
} else {
|
|
80
|
+
res.writeHead(400).end("Missing authorization code");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
await new Promise<void>((resolve, reject) => {
|
|
84
|
+
server.once("error", reject);
|
|
85
|
+
server.listen(0, "127.0.0.1", () => {
|
|
86
|
+
server.off("error", reject);
|
|
87
|
+
resolve();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
const address = server.address();
|
|
91
|
+
if (!address || typeof address === "string") throw new Error("Cannot bind login callback");
|
|
92
|
+
const timer = setTimeout(
|
|
93
|
+
() => fail(new Error("Login timed out. Run moloo login again.")),
|
|
94
|
+
timeout,
|
|
95
|
+
);
|
|
96
|
+
return {
|
|
97
|
+
redirectUri: `http://127.0.0.1:${address.port}/callback`,
|
|
98
|
+
code,
|
|
99
|
+
close() {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
server.close();
|
|
102
|
+
server.closeAllConnections();
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function browserLogin(
|
|
108
|
+
issuer: string,
|
|
109
|
+
openBrowser: (url: string) => Promise<unknown>,
|
|
110
|
+
scope = SCOPE,
|
|
111
|
+
) {
|
|
112
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
113
|
+
const state = randomBytes(32).toString("base64url");
|
|
114
|
+
const callback = await loopbackCallback(state);
|
|
115
|
+
try {
|
|
116
|
+
const url = new URL(`${issuer}/oauth2/authorize`);
|
|
117
|
+
url.search = new URLSearchParams({
|
|
118
|
+
client_id: CLIENT_ID,
|
|
119
|
+
response_type: "code",
|
|
120
|
+
scope,
|
|
121
|
+
redirect_uri: callback.redirectUri,
|
|
122
|
+
state,
|
|
123
|
+
code_challenge_method: "S256",
|
|
124
|
+
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
125
|
+
}).toString();
|
|
126
|
+
const opened = openBrowser(url.href).catch(() => {
|
|
127
|
+
throw new BrowserUnavailable("Cannot open browser. Use device login.");
|
|
128
|
+
});
|
|
129
|
+
// Some desktop launchers stay alive for the browser's lifetime.
|
|
130
|
+
await Promise.race([opened, callback.code.then(() => undefined)]);
|
|
131
|
+
return await readTokens(
|
|
132
|
+
await oauthPost(issuer, "/oauth2/token", {
|
|
133
|
+
grant_type: "authorization_code",
|
|
134
|
+
code: await callback.code,
|
|
135
|
+
code_verifier: verifier,
|
|
136
|
+
redirect_uri: callback.redirectUri,
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
} finally {
|
|
140
|
+
callback.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
type DeviceCode = {
|
|
145
|
+
device_code: string;
|
|
146
|
+
user_code: string;
|
|
147
|
+
verification_uri: string;
|
|
148
|
+
expires_in: number;
|
|
149
|
+
interval?: number;
|
|
150
|
+
};
|
|
151
|
+
export async function pollDevice(
|
|
152
|
+
issuer: string,
|
|
153
|
+
device: DeviceCode,
|
|
154
|
+
wait: (ms: number) => Promise<void> = delay,
|
|
155
|
+
now = Date.now,
|
|
156
|
+
) {
|
|
157
|
+
const deadline = now() + device.expires_in * 1000;
|
|
158
|
+
let interval = Math.max(1, device.interval ?? 5) * 1000;
|
|
159
|
+
while (now() + interval < deadline) {
|
|
160
|
+
await wait(interval);
|
|
161
|
+
const response = await oauthPost(issuer, "/oauth2/token", {
|
|
162
|
+
grant_type: DEVICE_GRANT,
|
|
163
|
+
device_code: device.device_code,
|
|
164
|
+
});
|
|
165
|
+
if (response.ok) return readTokens(response);
|
|
166
|
+
const value = (await response.json()) as { error?: string };
|
|
167
|
+
if (value.error === "slow_down") interval += 5000;
|
|
168
|
+
else if (value.error !== "authorization_pending")
|
|
169
|
+
throw new Error(`Device login failed: ${value.error ?? response.status}`);
|
|
170
|
+
}
|
|
171
|
+
throw new Error("Device code expired. Run moloo login again.");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function deviceLogin(issuer: string, scope = SCOPE) {
|
|
175
|
+
const response = await oauthPost(issuer, "/device/code", { scope });
|
|
176
|
+
const device = (await response.json()) as DeviceCode;
|
|
177
|
+
if (
|
|
178
|
+
!response.ok ||
|
|
179
|
+
!device.device_code ||
|
|
180
|
+
!device.user_code ||
|
|
181
|
+
!device.verification_uri ||
|
|
182
|
+
!Number.isFinite(device.expires_in) ||
|
|
183
|
+
device.expires_in <= 0 ||
|
|
184
|
+
(device.interval !== undefined && (!Number.isFinite(device.interval) || device.interval <= 0))
|
|
185
|
+
) {
|
|
186
|
+
throw new Error("Cannot request a device code");
|
|
187
|
+
}
|
|
188
|
+
console.log(`Open ${device.verification_uri}\nEnter code: ${device.user_code}`);
|
|
189
|
+
return pollDevice(issuer, device);
|
|
190
|
+
}
|