@moloodev/cli 0.6.0 → 0.8.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 +6 -2
- package/bin/local-cli.mjs +8 -9
- package/bin/moloo.mjs +3 -2
- package/package.json +6 -6
- package/src/api-result.ts +2 -2
- package/src/cli.ts +46 -14
- package/src/editor-create.ts +31 -19
- package/src/editor-dev.ts +1 -0
- package/src/editor-status.ts +5 -12
- package/src/editor-views.ts +5 -11
- package/src/publish.ts +26 -2
- package/src/upgrade.ts +104 -0
package/README.md
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
`moloo editor dev` does not support the local app host because the parent app origin is fixed to production (`https://app.moloo.dev`).
|
|
2
2
|
|
|
3
|
-
The view server defaults to `http://127.0.0.1:4310` and opens `https://app.moloo.dev/w/<slug>/dev-editor`. Use `moloo editor dev --view-port 4451` to select another port.
|
|
3
|
+
The view server defaults to `http://127.0.0.1:4310` and opens `https://app.moloo.dev/w/<slug>/dev-editor`. Use `moloo editor dev --view-port 4451` to select another port. The first time, click **로컬 뷰 연결** in the app and allow Chrome local network access; once granted, the app connects automatically. If a connection fails, the app displays the browser error or manifest HTTP status; check the CLI and the app's browser site permissions.
|
|
4
4
|
|
|
5
|
-
`moloo editor publish` always targets a workspace you administer. For `moloo.*` manifests it defaults to workspace `moloo`; `--workspace` overrides that default. Other publishers use the explicit flag, saved workspace or membership selection. Publisher ownership is verified on the server. Builtin means publisher `moloo`, managed by the admin-only `moloo` workspace; these submissions publish immediately after checks pass (422 with finding details on failure). Other publishers are claimed by the submitting workspace on first submission and receive 202 pending review in `https://admin.moloo.dev`. Run `moloo editor status` to inspect revision status, check findings, and rejection reasons.
|
|
5
|
+
`moloo editor publish` always targets a workspace you administer. For `moloo.*` manifests it defaults to workspace `moloo`; `--workspace` overrides that default. Other publishers use the explicit flag, saved workspace or membership selection. Publisher ownership is verified on the server. Builtin means publisher `moloo`, managed by the admin-only `moloo` workspace; these submissions publish immediately after checks pass (422 with finding details on failure). Other publishers are claimed by the submitting workspace on first submission and receive 202 pending review in `https://admin.moloo.dev`. Run `moloo editor status` to inspect revision status, which tracks point at each revision, check findings, and rejection reasons.
|
|
6
|
+
|
|
7
|
+
Editors have two release tracks, `stable` (the default) and `beta`. Each track points at one revision. `moloo editor publish --track beta` releases to beta as soon as checks pass, with no platform review; workspaces that opt into beta in their editor settings get it, and all others stay on stable. `moloo editor promote beta stable` moves stable to the current beta revision: immediately for `moloo.*` editors and for revisions already reviewed, otherwise after platform review. A stable release also moves beta when beta is older, so beta never trails stable.
|
|
6
8
|
|
|
7
9
|
Create a standalone editor with `moloo editor create <publisher>.<name>` (for example, `moloo editor create acme.notes`), or use `moloo editor create acme.notes --here` in an empty directory. The CLI downloads template tag `v1` from `moloodev/editor-template`, then rewrites its package, manifest, and README title. It never overwrites an existing destination. In the generated directory, run `pnpm install`, `moloo login`, `moloo workspace use <slug>`, then `moloo editor dev`. When ready, run `moloo editor build`, `moloo editor publish`, and `moloo editor status`.
|
|
8
10
|
|
|
9
11
|
The `moloo` bin delegates to a project's installed `@moloodev/cli`, searching from the current directory upward. This keeps global invocations on the project's CLI version.
|
|
12
|
+
|
|
13
|
+
`moloo upgrade` upgrades the globally installed CLI to the npm `latest` version and is never delegated to a project's CLI. It detects npm, pnpm, yarn or bun from the install path and runs that manager's global install; if the version is already latest it only says so. When the running CLI is a project dependency, it prints how to upgrade it with the project's package manager and changes nothing.
|
package/bin/local-cli.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, realpathSync } from "node:fs";
|
|
1
|
+
import { lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
export const LOCAL_CLI_ENV = "MOLOO_LOCAL_CLI";
|
|
@@ -8,17 +8,16 @@ export function findLocalCli(cwd, currentBin, env = process.env) {
|
|
|
8
8
|
if (env[LOCAL_CLI_ENV]) return null;
|
|
9
9
|
for (let directory = resolve(cwd); ; directory = dirname(directory)) {
|
|
10
10
|
const packageRoot = join(directory, "node_modules/@moloodev/cli");
|
|
11
|
-
let pkg;
|
|
12
11
|
try {
|
|
13
|
-
pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
14
|
-
|
|
15
|
-
if (
|
|
16
|
-
}
|
|
17
|
-
if (pkg) {
|
|
18
|
-
const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.moloo;
|
|
19
|
-
if (typeof bin !== "string") throw new Error("Local @moloodev/cli has no moloo bin");
|
|
12
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
13
|
+
const bin = typeof pkg?.bin === "string" ? pkg.bin : pkg?.bin?.moloo;
|
|
14
|
+
if (typeof bin !== "string" || !bin) throw new Error("Local @moloodev/cli has no moloo bin");
|
|
20
15
|
const local = realpathSync(resolve(packageRoot, bin));
|
|
21
16
|
return local === realpathSync(currentBin) ? null : local;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error.code !== "ENOENT" || lstatSync(packageRoot, { throwIfNoEntry: false })) {
|
|
19
|
+
throw new Error(`Broken local @moloodev/cli at ${packageRoot}: ${error.message}. ${LOCAL_CLI_ENV}=1 skips the handoff.`, { cause: error });
|
|
20
|
+
}
|
|
22
21
|
}
|
|
23
22
|
if (dirname(directory) === directory) return null;
|
|
24
23
|
}
|
package/bin/moloo.mjs
CHANGED
|
@@ -3,7 +3,8 @@ import { spawnSync } from "node:child_process";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { findLocalCli, LOCAL_CLI_ENV } from "./local-cli.mjs";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// `upgrade` targets the globally installed CLI itself, so it never hands off.
|
|
7
|
+
const local = process.argv[2] === "upgrade" ? null : findLocalCli(process.cwd(), fileURLToPath(import.meta.url));
|
|
7
8
|
if (local) {
|
|
8
9
|
const child = spawnSync(process.execPath, [local, ...process.argv.slice(2)], {
|
|
9
10
|
stdio: "inherit",
|
|
@@ -20,7 +21,7 @@ const { runCli } = await import("../src/cli.ts");
|
|
|
20
21
|
runCli(process.argv.slice(2)).then(
|
|
21
22
|
(code) => process.exit(code),
|
|
22
23
|
(err) => {
|
|
23
|
-
console.error(err);
|
|
24
|
+
console.error(err instanceof Error && process.env.MOLOO_DEBUG !== "1" ? `Error: ${err.message}` : err);
|
|
24
25
|
process.exit(1);
|
|
25
26
|
},
|
|
26
27
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moloodev/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"moloo": "./bin/moloo.mjs"
|
|
@@ -24,15 +24,15 @@
|
|
|
24
24
|
"tw-animate-css": "^1.4.0",
|
|
25
25
|
"vite": "^8.2.2",
|
|
26
26
|
"zod": "^4.5.4",
|
|
27
|
-
"@moloodev/editor-sdk": "0.
|
|
28
|
-
"@moloodev/editor-store": "0.
|
|
29
|
-
"@moloodev/platform": "0.
|
|
30
|
-
"@moloodev/ui": "0.
|
|
27
|
+
"@moloodev/editor-sdk": "0.8.0",
|
|
28
|
+
"@moloodev/editor-store": "0.8.0",
|
|
29
|
+
"@moloodev/platform": "0.8.0",
|
|
30
|
+
"@moloodev/ui": "0.8.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/node": "^24.13.3",
|
|
34
34
|
"typescript": "^7.0.2",
|
|
35
|
-
"@moloodev/typescript-config": "0.
|
|
35
|
+
"@moloodev/typescript-config": "0.8.0"
|
|
36
36
|
},
|
|
37
37
|
"publishConfig": {
|
|
38
38
|
"access": "public"
|
package/src/api-result.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
const ErrorBodySchema = z.object({
|
|
5
5
|
error: z.string(),
|
|
6
6
|
message: z.string().optional(),
|
|
7
|
-
details: z.array(z.object({ code: z.string().optional(), detail: z.string() })).
|
|
7
|
+
details: z.array(z.object({ code: z.string().optional(), detail: z.string() })).catch([]),
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
export function readApiBody(body: string): Record<string, unknown> | undefined {
|
|
@@ -18,6 +18,6 @@ export function apiErrorMessage(status: number, body: string): string {
|
|
|
18
18
|
const result = ErrorBodySchema.safeParse(readApiBody(body)).data;
|
|
19
19
|
const code = result?.error ?? body;
|
|
20
20
|
const message = result?.message ?? publishErrorBody(code).message ?? `Request failed (${status}): ${code}`;
|
|
21
|
-
const details = result?.details
|
|
21
|
+
const details = result?.details.map((finding) => ` ${finding.code ? finding.code + ": " : ""}${finding.detail}`) ?? [];
|
|
22
22
|
return [message, ...details].join("\n");
|
|
23
23
|
}
|
package/src/cli.ts
CHANGED
|
@@ -2,16 +2,18 @@ import { login, logout, whoami } from "./auth";
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { runEditorDev } from "./editor-dev";
|
|
4
4
|
import { buildEditorPackage } from "./editor-views";
|
|
5
|
-
import {
|
|
5
|
+
import { isEditorTrack, type EditorTrack } from "@moloodev/platform";
|
|
6
|
+
import { promoteEditorPackage, publishEditorPackage } from "./publish";
|
|
6
7
|
import { printEditorStatus } from "./editor-status";
|
|
7
8
|
import { createEditor, EDITOR_CREATE_USAGE } from "./editor-create";
|
|
8
9
|
import { apiErrorMessage, readApiBody } from "./api-result";
|
|
10
|
+
import { upgradeCli, UPGRADE_USAGE } from "./upgrade";
|
|
9
11
|
import { listWorkspaces, useWorkspace, type WorkspaceOptions } from "./workspace";
|
|
10
12
|
|
|
11
13
|
const EDITOR_USAGE =
|
|
12
|
-
`${EDITOR_CREATE_USAGE}\nUsage: moloo editor <dev|build|publish|status> [--dir <path>] [--out-dir <path>] [--view-port <port>] [--workspace <slug>]\nDev view port: 4310 (default)`;
|
|
14
|
+
`${EDITOR_CREATE_USAGE}\nUsage: moloo editor <dev|build|publish|status> [--dir <path>] [--out-dir <path>] [--view-port <port>] [--workspace <slug>]\nUsage: moloo editor publish [--track stable|beta] [--dir <path>] [--workspace <slug>]\nUsage: moloo editor promote beta stable [--dir <path>] [--workspace <slug>]\nDev view port: 4310 (default)`;
|
|
13
15
|
const WORKSPACE_USAGE = "Usage: moloo workspace list | use <slug>";
|
|
14
|
-
const USAGE = `Usage: moloo <command>\n\nCommands:\n login [--device]\n logout\n whoami\n workspace list | use <slug>\n editor create | dev | build | publish | status\n\n${WORKSPACE_USAGE}\n${EDITOR_USAGE}`;
|
|
16
|
+
const USAGE = `Usage: moloo <command>\n\nCommands:\n login [--device]\n logout\n whoami\n upgrade\n workspace list | use <slug>\n editor create | dev | build | publish | promote | status\n\n${WORKSPACE_USAGE}\n${EDITOR_USAGE}`;
|
|
15
17
|
|
|
16
18
|
type Command = (args: readonly string[], options: WorkspaceOptions) => Promise<number>;
|
|
17
19
|
|
|
@@ -52,23 +54,42 @@ const editorCommands: Record<string, Command> = {
|
|
|
52
54
|
return 0;
|
|
53
55
|
},
|
|
54
56
|
async publish(args, options) {
|
|
55
|
-
validateWorkspaceFlags(args);
|
|
57
|
+
validateWorkspaceFlags(args, ["--track"]);
|
|
58
|
+
const track = readTrackFlag(args);
|
|
56
59
|
const result = await publishEditorPackage(packageRootFlag(args), {
|
|
57
|
-
...options, workspace: readFlag(args, "--workspace"),
|
|
60
|
+
...options, workspace: readFlag(args, "--workspace"), track,
|
|
58
61
|
});
|
|
59
62
|
if (result.status === 202) {
|
|
60
63
|
const receipt = readApiBody(result.body)?.data as { id?: string } | undefined;
|
|
61
64
|
if (typeof receipt?.id !== "string") throw new Error("Invalid submission receipt");
|
|
62
|
-
|
|
65
|
+
const next = track === "stable" ? "pending platform review" : "beta after checks pass";
|
|
66
|
+
console.log(`${result.id} submitted — ${next}. Revision: ${receipt.id}`);
|
|
63
67
|
return 0;
|
|
64
68
|
}
|
|
65
69
|
if (result.status !== 200 && result.status !== 201) {
|
|
66
|
-
|
|
67
|
-
return 1;
|
|
70
|
+
throw new Error(apiErrorMessage(result.status, result.body));
|
|
68
71
|
}
|
|
69
72
|
console.log(`${result.id} ${String(result.status)} ${result.body}`);
|
|
70
73
|
return 0;
|
|
71
74
|
},
|
|
75
|
+
async promote(args, options) {
|
|
76
|
+
const [from, to, ...flags] = args;
|
|
77
|
+
if (from !== "beta" || to !== "stable") throw new Error(EDITOR_USAGE);
|
|
78
|
+
validateWorkspaceFlags(flags);
|
|
79
|
+
const result = await promoteEditorPackage(packageRootFlag(flags), {
|
|
80
|
+
...options, workspace: readFlag(flags, "--workspace"),
|
|
81
|
+
});
|
|
82
|
+
if (![200, 201, 202].includes(result.status)) {
|
|
83
|
+
throw new Error(apiErrorMessage(result.status, result.body));
|
|
84
|
+
}
|
|
85
|
+
const data = readApiBody(result.body)?.data as { id?: string; revision?: number } | undefined;
|
|
86
|
+
if (typeof data?.id !== "string" || typeof data.revision !== "number") throw new Error("Invalid promotion receipt");
|
|
87
|
+
const outcome = result.status === 201
|
|
88
|
+
? "promoted to stable"
|
|
89
|
+
: result.status === 200 ? "stable already points at beta" : "submitted for stable — pending platform review";
|
|
90
|
+
console.log(`${result.id} #${String(data.revision)} ${outcome}. Revision: ${data.id}`);
|
|
91
|
+
return 0;
|
|
92
|
+
},
|
|
72
93
|
async status(args, options) {
|
|
73
94
|
validateWorkspaceFlags(args);
|
|
74
95
|
return printEditorStatus(packageRootFlag(args), { ...options, workspace: readFlag(args, "--workspace") });
|
|
@@ -103,6 +124,13 @@ const commands: Record<string, {
|
|
|
103
124
|
return 0;
|
|
104
125
|
},
|
|
105
126
|
},
|
|
127
|
+
upgrade: {
|
|
128
|
+
usage: UPGRADE_USAGE,
|
|
129
|
+
async run(args) {
|
|
130
|
+
if (args.length) throw new Error(UPGRADE_USAGE);
|
|
131
|
+
return upgradeCli();
|
|
132
|
+
},
|
|
133
|
+
},
|
|
106
134
|
workspace: {
|
|
107
135
|
usage: WORKSPACE_USAGE,
|
|
108
136
|
async run([name, ...args], options) {
|
|
@@ -123,8 +151,7 @@ const commands: Record<string, {
|
|
|
123
151
|
? editorCommands[name]
|
|
124
152
|
: undefined;
|
|
125
153
|
if (!command) {
|
|
126
|
-
|
|
127
|
-
return 1;
|
|
154
|
+
throw new Error(EDITOR_USAGE);
|
|
128
155
|
}
|
|
129
156
|
return command(args, options);
|
|
130
157
|
},
|
|
@@ -141,8 +168,7 @@ export async function runCli(argv: readonly string[], options: WorkspaceOptions
|
|
|
141
168
|
return 0;
|
|
142
169
|
}
|
|
143
170
|
if (!command) {
|
|
144
|
-
|
|
145
|
-
return 1;
|
|
171
|
+
throw new Error(USAGE);
|
|
146
172
|
}
|
|
147
173
|
return command.run(args, options);
|
|
148
174
|
}
|
|
@@ -151,9 +177,9 @@ function packageRootFlag(args: readonly string[]): string {
|
|
|
151
177
|
return resolve(readFlag(args, "--dir") ?? process.cwd());
|
|
152
178
|
}
|
|
153
179
|
|
|
154
|
-
function validateWorkspaceFlags(args: readonly string[]): void {
|
|
180
|
+
function validateWorkspaceFlags(args: readonly string[], extra: readonly string[] = []): void {
|
|
155
181
|
for (let index = 0; index < args.length; index += 2) {
|
|
156
|
-
if (!["--dir", "--workspace"].includes(args[index] ?? "") ||
|
|
182
|
+
if (!["--dir", "--workspace", ...extra].includes(args[index] ?? "") ||
|
|
157
183
|
!args[index + 1] || args[index + 1]?.startsWith("--")) throw new Error(EDITOR_USAGE);
|
|
158
184
|
}
|
|
159
185
|
}
|
|
@@ -164,6 +190,12 @@ function readFlag(args: readonly string[], flag: string): string | undefined {
|
|
|
164
190
|
return args[index + 1];
|
|
165
191
|
}
|
|
166
192
|
|
|
193
|
+
function readTrackFlag(args: readonly string[]): EditorTrack {
|
|
194
|
+
const raw = readFlag(args, "--track") ?? "stable";
|
|
195
|
+
if (!isEditorTrack(raw)) throw new Error(`Invalid --track: use stable or beta`);
|
|
196
|
+
return raw;
|
|
197
|
+
}
|
|
198
|
+
|
|
167
199
|
function readPortFlag(args: readonly string[], flag: string): number | undefined {
|
|
168
200
|
const raw = readFlag(args, flag);
|
|
169
201
|
if (raw === undefined) return undefined;
|
package/src/editor-create.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
|
-
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { copyFile, cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { extract, list } from "tar";
|
|
6
|
-
import { parseQualifiedName } from "@moloodev/platform";
|
|
7
6
|
import { parseEditorId } from "@moloodev/editor-sdk/protocol";
|
|
8
7
|
|
|
9
8
|
export const EDITOR_TEMPLATE_TAG = "v1";
|
|
@@ -18,12 +17,11 @@ export async function createEditor(
|
|
|
18
17
|
fetch?: typeof fetch;
|
|
19
18
|
} = {},
|
|
20
19
|
): Promise<string> {
|
|
21
|
-
const
|
|
22
|
-
if (!
|
|
23
|
-
throw new Error("Invalid editor id: use publisher.name with a valid publisher handle and lowercase editor DNS label (1–63 characters).
|
|
20
|
+
const editor = parseEditorId(id);
|
|
21
|
+
if (!editor) {
|
|
22
|
+
throw new Error("Invalid editor id: use publisher.name with a valid publisher handle and lowercase editor DNS label (1–63 characters).");
|
|
24
23
|
}
|
|
25
|
-
const
|
|
26
|
-
const destination = here ? resolve(cwd) : resolve(cwd, `${name}-editor`);
|
|
24
|
+
const destination = here ? resolve(cwd) : resolve(cwd, `${editor.editor}-editor`);
|
|
27
25
|
async function checkDestination() {
|
|
28
26
|
if (here) {
|
|
29
27
|
if ((await readdir(destination)).length) throw new Error("--here requires an empty current directory (including hidden files).");
|
|
@@ -61,16 +59,16 @@ export async function createEditor(
|
|
|
61
59
|
const packageFile = join(source, "package.json");
|
|
62
60
|
const pkg = JSON.parse(await readFile(packageFile, "utf8"));
|
|
63
61
|
if (pkg.name !== "@moloodev/example-editor") throw new Error("Invalid editor template package placeholder");
|
|
64
|
-
pkg.name = `@${publisher}/${
|
|
62
|
+
pkg.name = `@${editor.publisher}/${editor.editor}-editor`;
|
|
65
63
|
await writeFile(packageFile, JSON.stringify(pkg, null, 2) + "\n");
|
|
66
64
|
const manifestFile = join(source, "src/manifest.ts");
|
|
67
65
|
let manifest = await readFile(manifestFile, "utf8");
|
|
68
66
|
for (const [token, value] of [
|
|
69
67
|
["example.editor", id],
|
|
70
|
-
["/editor/example", `/editor/${
|
|
71
|
-
[".example.json", `.${
|
|
72
|
-
["https://example.com/schemas/example/v1.json", `https://example.com/schemas/${
|
|
73
|
-
["Example editor", `${
|
|
68
|
+
["/editor/example", `/editor/${editor.editor}`],
|
|
69
|
+
[".example.json", `.${editor.editor}.json`],
|
|
70
|
+
["https://example.com/schemas/example/v1.json", `https://example.com/schemas/${editor.editor}/v1.json`],
|
|
71
|
+
["Example editor", `${editor.editor} editor`],
|
|
74
72
|
] as const) {
|
|
75
73
|
if (!manifest.includes(token)) throw new Error(`Missing editor template placeholder: ${token}`);
|
|
76
74
|
manifest = manifest.replaceAll(token, value);
|
|
@@ -79,22 +77,36 @@ export async function createEditor(
|
|
|
79
77
|
const readmeFile = join(source, "README.md");
|
|
80
78
|
const readme = await readFile(readmeFile, "utf8");
|
|
81
79
|
if (!readme.startsWith("# Example editor\n")) throw new Error("Missing editor template README title");
|
|
82
|
-
await writeFile(readmeFile, readme.replace("# Example editor\n", `# ${
|
|
80
|
+
await writeFile(readmeFile, readme.replace("# Example editor\n", `# ${editor.editor} editor\n`));
|
|
83
81
|
|
|
84
82
|
// Recheck after download; mkdir and exclusive copies also protect against races.
|
|
85
83
|
await checkDestination();
|
|
86
84
|
if (!here) await mkdir(destination);
|
|
85
|
+
const created: string[] = [];
|
|
87
86
|
try {
|
|
88
|
-
for (const entry of await readdir(source)) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
for (const entry of await readdir(source, { withFileTypes: true })) {
|
|
88
|
+
const target = join(destination, entry.name);
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
// Claim the directory before copying so partial copies can be removed.
|
|
91
|
+
await mkdir(target);
|
|
92
|
+
created.push(target);
|
|
93
|
+
for (const child of await readdir(join(source, entry.name))) {
|
|
94
|
+
await cp(join(source, entry.name, child), join(target, child), {
|
|
95
|
+
recursive: true, force: false, errorOnExist: true, mode: constants.COPYFILE_EXCL,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
await copyFile(join(source, entry.name), target, constants.COPYFILE_EXCL);
|
|
100
|
+
created.push(target);
|
|
101
|
+
}
|
|
92
102
|
}
|
|
93
103
|
} catch (error) {
|
|
94
|
-
if (
|
|
104
|
+
if (here) {
|
|
105
|
+
for (const target of created) await rm(target, { recursive: true, force: true });
|
|
106
|
+
} else await rm(destination, { recursive: true, force: true });
|
|
95
107
|
throw error;
|
|
96
108
|
}
|
|
97
|
-
console.log(`Created ${id} in ${destination}\n\nNext steps:\n${here ? "" : ` cd ${
|
|
109
|
+
console.log(`Created ${id} in ${destination}\n\nNext steps:\n${here ? "" : ` cd ${editor.editor}-editor\n`} pnpm install\n pnpm exec moloo login\n pnpm dev\n\nPublish: pnpm publish:editor --workspace <slug>`);
|
|
98
110
|
return destination;
|
|
99
111
|
} finally {
|
|
100
112
|
await rm(staging, { recursive: true, force: true });
|
package/src/editor-dev.ts
CHANGED
|
@@ -28,6 +28,7 @@ export async function runEditorDev(
|
|
|
28
28
|
try {
|
|
29
29
|
const destination = new URL(`/w/${encodeURIComponent(slug)}/dev-editor`, getWorkspaceHostUrl("production"));
|
|
30
30
|
destination.searchParams.set("view", server.viewOrigin);
|
|
31
|
+
console.log(`${server.editorId} browser -> ${destination.href}`);
|
|
31
32
|
console.log(`${server.editorId} view origin -> ${server.viewOrigin}`);
|
|
32
33
|
await deps.open(destination.href);
|
|
33
34
|
await deps.stopped();
|
package/src/editor-status.ts
CHANGED
|
@@ -1,17 +1,10 @@
|
|
|
1
|
-
import { getApiOrigin } from "@moloodev/platform";
|
|
2
|
-
import { z } from "zod";
|
|
1
|
+
import { editorStatusResponseSchema, getApiOrigin } from "@moloodev/platform";
|
|
3
2
|
import { publishToken } from "./auth";
|
|
4
3
|
import { apiErrorMessage, readApiBody } from "./api-result";
|
|
5
4
|
import { readPackageEditorView } from "./editor-views";
|
|
6
5
|
import { resolveEditorTarget } from "./publish";
|
|
7
6
|
import type { WorkspaceOptions } from "./workspace";
|
|
8
7
|
|
|
9
|
-
const StatusBodySchema = z.object({ data: z.array(z.object({
|
|
10
|
-
id: z.string(), revision: z.number().int(), status: z.string(),
|
|
11
|
-
submittedAt: z.string(), reviewedAt: z.string().nullable(), rejectionReason: z.string().nullable(),
|
|
12
|
-
checkFindings: z.array(z.object({ code: z.string(), detail: z.string() })),
|
|
13
|
-
})) });
|
|
14
|
-
|
|
15
8
|
export async function printEditorStatus(packageRoot: string, options: WorkspaceOptions & { workspace?: string }): Promise<number> {
|
|
16
9
|
const { id } = readPackageEditorView(packageRoot);
|
|
17
10
|
const target = await resolveEditorTarget(id, options);
|
|
@@ -22,16 +15,16 @@ export async function printEditorStatus(packageRoot: string, options: WorkspaceO
|
|
|
22
15
|
});
|
|
23
16
|
const body = await response.text();
|
|
24
17
|
if (response.status !== 200) {
|
|
25
|
-
|
|
26
|
-
return 1;
|
|
18
|
+
throw new Error(apiErrorMessage(response.status, body));
|
|
27
19
|
}
|
|
28
|
-
const parsed =
|
|
20
|
+
const parsed = editorStatusResponseSchema.safeParse(readApiBody(body));
|
|
29
21
|
if (!parsed.success) throw new Error("Invalid editor status response");
|
|
30
22
|
const { data } = parsed.data;
|
|
31
23
|
console.log(`${id} — latest ${data.length} revisions (${target.workspaceSlug})`);
|
|
32
24
|
if (!data.length) console.log("No revisions submitted.");
|
|
33
25
|
for (const row of data) {
|
|
34
|
-
|
|
26
|
+
const tracks = row.tracks.length ? ` [${row.tracks.join(", ")}]` : "";
|
|
27
|
+
console.log(`#${row.revision} ${row.status}${tracks} (${row.id})\n Submitted: ${row.submittedAt}\n Reviewed: ${row.reviewedAt ?? "pending"}`);
|
|
35
28
|
if (row.rejectionReason) console.log(` Rejected: ${row.rejectionReason}`);
|
|
36
29
|
for (const finding of row.checkFindings) console.log(` ${finding.code}: ${finding.detail}`);
|
|
37
30
|
}
|
package/src/editor-views.ts
CHANGED
|
@@ -6,9 +6,8 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import esbuild from "esbuild";
|
|
7
7
|
import { build, type Rollup } from "vite";
|
|
8
8
|
import tailwind from "@tailwindcss/vite";
|
|
9
|
-
import { parseEditorId } from "@moloodev/editor-sdk/protocol";
|
|
10
9
|
import { validateEditorManifest } from "@moloodev/editor-sdk/commands/editor-document";
|
|
11
|
-
import {
|
|
10
|
+
import { parsePublishName, PUBLISH_ERROR_MESSAGES } from "@moloodev/platform";
|
|
12
11
|
import { parseNetwork } from "@moloodev/platform/network";
|
|
13
12
|
import { publishBundlePlugin } from "./publish-bundle";
|
|
14
13
|
import { readEditorManifest } from "./read-editor-manifest";
|
|
@@ -25,19 +24,14 @@ export function readPackageEditorView(packageRoot: string): EditorView & { manif
|
|
|
25
24
|
throw new Error(`Missing editor manifest at ${manifestPath}`);
|
|
26
25
|
}
|
|
27
26
|
const manifest = readEditorManifest(manifestPath);
|
|
28
|
-
if (typeof manifest.id === "string" && manifest.id.includes(".")) {
|
|
29
|
-
const publisher = manifest.id.split(".")[0]!;
|
|
30
|
-
if (!isPublisherHandle(publisher)) throw new Error(PUBLISH_ERROR_MESSAGES.invalid_publisher_handle);
|
|
31
|
-
if (publisher !== "moloo" && isReservedPublisherHandle(publisher)) throw new Error(PUBLISH_ERROR_MESSAGES.publisher_reserved);
|
|
32
|
-
}
|
|
33
|
-
validateEditorManifest(manifest);
|
|
34
27
|
if (typeof manifest.id !== "string") {
|
|
35
28
|
throw new Error(`Invalid editor manifest id in ${manifestPath}`);
|
|
36
29
|
}
|
|
37
|
-
const parsed =
|
|
38
|
-
if (parsed
|
|
39
|
-
throw new Error(
|
|
30
|
+
const parsed = parsePublishName(manifest.id);
|
|
31
|
+
if (!parsed.ok) {
|
|
32
|
+
throw new Error(PUBLISH_ERROR_MESSAGES[parsed.error]);
|
|
40
33
|
}
|
|
34
|
+
validateEditorManifest(manifest);
|
|
41
35
|
const network = parseNetwork("network" in manifest ? manifest.network : undefined);
|
|
42
36
|
return { id: manifest.id, packageRoot, network, manifest };
|
|
43
37
|
}
|
package/src/publish.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { publishToken } from "./auth";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { putScriptUpload } from "@moloodev/editor-store/upload";
|
|
4
|
-
import { getApiOrigin, parseQualifiedName, type PublishTarget } from "@moloodev/platform";
|
|
4
|
+
import { getApiOrigin, parseQualifiedName, type EditorTrack, type PublishTarget } from "@moloodev/platform";
|
|
5
5
|
import { buildEditorPackage, readPackageEditorView } from "./editor-views";
|
|
6
6
|
import { resolveWorkspace, type WorkspaceOptions } from "./workspace";
|
|
7
7
|
|
|
@@ -9,6 +9,10 @@ export function publishEndpoint(): string {
|
|
|
9
9
|
return `${getApiOrigin("production")}/v1/revisions/publish`;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export function promoteEndpoint(): string {
|
|
13
|
+
return `${getApiOrigin("production")}/v1/revisions/promote`;
|
|
14
|
+
}
|
|
15
|
+
|
|
12
16
|
export function uploadEndpoint(): string {
|
|
13
17
|
return `${getApiOrigin("production")}/v1/revisions/uploads`;
|
|
14
18
|
}
|
|
@@ -18,6 +22,7 @@ export async function publishEditor(input: {
|
|
|
18
22
|
script: string;
|
|
19
23
|
token: string;
|
|
20
24
|
target: PublishTarget;
|
|
25
|
+
track: EditorTrack;
|
|
21
26
|
}): Promise<{ status: number; body: string }> {
|
|
22
27
|
const uploadUrl = uploadEndpoint();
|
|
23
28
|
const publishUrl = publishEndpoint();
|
|
@@ -48,6 +53,7 @@ export async function publishEditor(input: {
|
|
|
48
53
|
},
|
|
49
54
|
body: JSON.stringify({
|
|
50
55
|
target: input.target,
|
|
56
|
+
track: input.track,
|
|
51
57
|
manifest: input.manifest,
|
|
52
58
|
upload: {
|
|
53
59
|
pathname: upload.pathname,
|
|
@@ -60,7 +66,7 @@ export async function publishEditor(input: {
|
|
|
60
66
|
|
|
61
67
|
export async function publishEditorPackage(
|
|
62
68
|
packageRoot: string,
|
|
63
|
-
options: Omit<WorkspaceOptions, "apiOrigin"> & { workspace?: string } = {},
|
|
69
|
+
options: Omit<WorkspaceOptions, "apiOrigin"> & { workspace?: string; track?: EditorTrack } = {},
|
|
64
70
|
): Promise<{ id: string; status: number; body: string }> {
|
|
65
71
|
const view = readPackageEditorView(packageRoot);
|
|
66
72
|
const target = await resolveEditorTarget(view.id, options);
|
|
@@ -71,10 +77,28 @@ export async function publishEditorPackage(
|
|
|
71
77
|
script: built.script,
|
|
72
78
|
token,
|
|
73
79
|
target,
|
|
80
|
+
track: options.track ?? "stable",
|
|
74
81
|
});
|
|
75
82
|
return { id: built.id, ...result };
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
/** Move the editor's `stable` track to its current `beta` revision. */
|
|
86
|
+
export async function promoteEditorPackage(
|
|
87
|
+
packageRoot: string,
|
|
88
|
+
options: Omit<WorkspaceOptions, "apiOrigin"> & { workspace?: string } = {},
|
|
89
|
+
): Promise<{ id: string; status: number; body: string }> {
|
|
90
|
+
const { id } = readPackageEditorView(packageRoot);
|
|
91
|
+
const target = await resolveEditorTarget(id, options);
|
|
92
|
+
const token = await publishToken(options.store);
|
|
93
|
+
const response = await fetch(promoteEndpoint(), {
|
|
94
|
+
method: "POST",
|
|
95
|
+
redirect: "error",
|
|
96
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
97
|
+
body: JSON.stringify({ target, editor: id, from: "beta", to: "stable" }),
|
|
98
|
+
});
|
|
99
|
+
return { id, status: response.status, body: await response.text() };
|
|
100
|
+
}
|
|
101
|
+
|
|
78
102
|
export async function resolveEditorTarget(
|
|
79
103
|
id: string,
|
|
80
104
|
options: WorkspaceOptions & { workspace?: string },
|
package/src/upgrade.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { dirname, join, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
export const UPGRADE_USAGE = "Usage: moloo upgrade";
|
|
7
|
+
|
|
8
|
+
const PACKAGE = "@moloodev/cli";
|
|
9
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE.replace("/", "%2F")}/latest`;
|
|
10
|
+
|
|
11
|
+
export type GlobalManager = "npm" | "pnpm" | "yarn" | "bun";
|
|
12
|
+
export type Install = { kind: "global"; manager: GlobalManager } | { kind: "local" };
|
|
13
|
+
|
|
14
|
+
const INSTALL_COMMANDS: Record<GlobalManager, readonly [string, ...string[]]> = {
|
|
15
|
+
npm: ["npm", "install", "--global", `${PACKAGE}@latest`],
|
|
16
|
+
pnpm: ["pnpm", "add", "--global", `${PACKAGE}@latest`],
|
|
17
|
+
yarn: ["yarn", "global", "add", `${PACKAGE}@latest`],
|
|
18
|
+
bun: ["bun", "add", "--global", `${PACKAGE}@latest`],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Classify the installed package directory (realpath) by each manager's global store layout. */
|
|
22
|
+
export function detectInstall(packageRoot: string): Install {
|
|
23
|
+
const path = packageRoot.split(sep).join("/");
|
|
24
|
+
if (/\/pnpm\/global\/|\/\.pnpm-global\//.test(path)) return { kind: "global", manager: "pnpm" };
|
|
25
|
+
if (/\/\.bun\/install\/global\//.test(path)) return { kind: "global", manager: "bun" };
|
|
26
|
+
if (/\/yarn\/global\/|\/\.yarn-global\//.test(path)) return { kind: "global", manager: "yarn" };
|
|
27
|
+
if (/\/lib\/node_modules\/@moloodev\/cli$|\/npm\/node_modules\/@moloodev\/cli$/.test(path)) {
|
|
28
|
+
return { kind: "global", manager: "npm" };
|
|
29
|
+
}
|
|
30
|
+
return { kind: "local" };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type Runner = (command: string, args: readonly string[]) => number;
|
|
34
|
+
|
|
35
|
+
export interface UpgradeDeps {
|
|
36
|
+
packageRoot?: string;
|
|
37
|
+
currentVersion?: string;
|
|
38
|
+
fetchLatest?: () => Promise<string>;
|
|
39
|
+
run?: Runner;
|
|
40
|
+
log?: (line: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function upgradeCli(deps: UpgradeDeps = {}): Promise<number> {
|
|
44
|
+
const packageRoot = deps.packageRoot ?? realpathSync(join(dirname(fileURLToPath(import.meta.url)), ".."));
|
|
45
|
+
const current = deps.currentVersion ?? readVersion(packageRoot);
|
|
46
|
+
const log = deps.log ?? console.log;
|
|
47
|
+
const install = detectInstall(packageRoot);
|
|
48
|
+
if (install.kind === "local") {
|
|
49
|
+
log(`${PACKAGE} ${current} is a project dependency (${packageRoot}).`);
|
|
50
|
+
log(`Upgrade it with the project's package manager, e.g. pnpm add -D ${PACKAGE}@latest`);
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
const latest = await (deps.fetchLatest ?? fetchLatestVersion)();
|
|
54
|
+
if (latest === current) {
|
|
55
|
+
log(`${PACKAGE} ${current} is already the latest.`);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
if (compareVersions(current, latest) > 0) {
|
|
59
|
+
log(`${PACKAGE} ${current} is newer than latest ${latest}; not downgrading.`);
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
const [command, ...args] = INSTALL_COMMANDS[install.manager];
|
|
63
|
+
log(`Upgrading ${PACKAGE} ${current} -> ${latest}: ${[command, ...args].join(" ")}`);
|
|
64
|
+
const status = (deps.run ?? spawnRunner)(command, args);
|
|
65
|
+
if (status !== 0) throw new Error(`${command} exited with ${String(status)}`);
|
|
66
|
+
log(`${PACKAGE} upgraded to ${latest}.`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Minimal semver order: numeric core, then a release outranks its prereleases. */
|
|
71
|
+
export function compareVersions(a: string, b: string): number {
|
|
72
|
+
const [coreA = "", preA] = a.split("-", 2);
|
|
73
|
+
const [coreB = "", preB] = b.split("-", 2);
|
|
74
|
+
const numsA = coreA.split(".").map(Number);
|
|
75
|
+
const numsB = coreB.split(".").map(Number);
|
|
76
|
+
for (let index = 0; index < 3; index += 1) {
|
|
77
|
+
const diff = (numsA[index] ?? 0) - (numsB[index] ?? 0);
|
|
78
|
+
if (diff) return Math.sign(diff);
|
|
79
|
+
}
|
|
80
|
+
if (preA === preB) return 0;
|
|
81
|
+
if (preA === undefined) return 1;
|
|
82
|
+
if (preB === undefined) return -1;
|
|
83
|
+
return preA.localeCompare(preB, "en", { numeric: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readVersion(packageRoot: string): string {
|
|
87
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as { version?: unknown };
|
|
88
|
+
if (typeof pkg.version !== "string") throw new Error(`No version in ${packageRoot}/package.json`);
|
|
89
|
+
return pkg.version;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function fetchLatestVersion(): Promise<string> {
|
|
93
|
+
const response = await fetch(REGISTRY_URL);
|
|
94
|
+
if (!response.ok) throw new Error(`npm registry returned ${String(response.status)} for ${PACKAGE}`);
|
|
95
|
+
const body = (await response.json()) as { version?: unknown };
|
|
96
|
+
if (typeof body.version !== "string") throw new Error(`npm registry has no latest version for ${PACKAGE}`);
|
|
97
|
+
return body.version;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const spawnRunner: Runner = (command, args) => {
|
|
101
|
+
const child = spawnSync(command, args, { stdio: "inherit", shell: process.platform === "win32" });
|
|
102
|
+
if (child.error) throw child.error;
|
|
103
|
+
return child.status ?? 1;
|
|
104
|
+
};
|