@moloodev/cli 0.5.0 → 0.7.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 CHANGED
@@ -1,3 +1,11 @@
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`. Use `moloo editor dev --view-port 4451` to select another port. Click **로컬 뷰 연결** in the app and allow browser local network access when prompted. If a connection fails, the app displays the browser error or manifest HTTP status; check the CLI and the app's browser site permissions.
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. Click **로컬 뷰 연결** in the app and allow Chrome local network access when prompted. 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
+
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.
8
+
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`.
10
+
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.
@@ -0,0 +1,24 @@
1
+ import { lstatSync, readFileSync, realpathSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ export const LOCAL_CLI_ENV = "MOLOO_LOCAL_CLI";
5
+
6
+ /** Resolve from the invoking directory, including pnpm's symlinked packages. */
7
+ export function findLocalCli(cwd, currentBin, env = process.env) {
8
+ if (env[LOCAL_CLI_ENV]) return null;
9
+ for (let directory = resolve(cwd); ; directory = dirname(directory)) {
10
+ const packageRoot = join(directory, "node_modules/@moloodev/cli");
11
+ try {
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");
15
+ const local = realpathSync(resolve(packageRoot, bin));
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
+ }
21
+ }
22
+ if (dirname(directory) === directory) return null;
23
+ }
24
+ }
package/bin/moloo.mjs CHANGED
@@ -1,11 +1,26 @@
1
1
  #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
4
+ import { findLocalCli, LOCAL_CLI_ENV } from "./local-cli.mjs";
5
+
6
+ const local = findLocalCli(process.cwd(), fileURLToPath(import.meta.url));
7
+ if (local) {
8
+ const child = spawnSync(process.execPath, [local, ...process.argv.slice(2)], {
9
+ stdio: "inherit",
10
+ env: { ...process.env, [LOCAL_CLI_ENV]: "1" },
11
+ });
12
+ if (child.error) throw child.error;
13
+ if (child.signal) process.kill(process.pid, child.signal);
14
+ else process.exit(child.status ?? 1);
15
+ }
16
+
2
17
  await import("tsx/esm");
3
18
  const { runCli } = await import("../src/cli.ts");
4
19
 
5
20
  runCli(process.argv.slice(2)).then(
6
21
  (code) => process.exit(code),
7
22
  (err) => {
8
- console.error(err);
23
+ console.error(err instanceof Error && process.env.MOLOO_DEBUG !== "1" ? `Error: ${err.message}` : err);
9
24
  process.exit(1);
10
25
  },
11
26
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moloodev/cli",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "moloo": "./bin/moloo.mjs"
@@ -19,18 +19,20 @@
19
19
  "open": "11.0.2",
20
20
  "shadcn": "^4.21.0",
21
21
  "tailwindcss": "^4.3.3",
22
- "tw-animate-css": "^1.4.0",
22
+ "tar": "7.5.22",
23
23
  "tsx": "^4.23.13",
24
+ "tw-animate-css": "^1.4.0",
24
25
  "vite": "^8.2.2",
25
- "@moloodev/editor-sdk": "0.5.0",
26
- "@moloodev/editor-store": "0.5.0",
27
- "@moloodev/platform": "0.5.0",
28
- "@moloodev/ui": "0.5.0"
26
+ "zod": "^4.5.4",
27
+ "@moloodev/editor-sdk": "0.7.0",
28
+ "@moloodev/platform": "0.7.0",
29
+ "@moloodev/editor-store": "0.7.0",
30
+ "@moloodev/ui": "0.7.0"
29
31
  },
30
32
  "devDependencies": {
31
33
  "@types/node": "^24.13.3",
32
34
  "typescript": "^7.0.2",
33
- "@moloodev/typescript-config": "0.5.0"
35
+ "@moloodev/typescript-config": "0.7.0"
34
36
  },
35
37
  "publishConfig": {
36
38
  "access": "public"
@@ -38,13 +40,15 @@
38
40
  "files": [
39
41
  "bin",
40
42
  "src",
41
- "!src/**/*.test.ts"
43
+ "!src/**/*.test.ts",
44
+ "!src/fixtures",
45
+ "!bin/**/*.test.mjs"
42
46
  ],
43
47
  "engines": {
44
48
  "node": ">=24"
45
49
  },
46
50
  "scripts": {
47
51
  "typecheck": "tsc --noEmit",
48
- "test": "node --import tsx --test 'src/**/*.test.ts'"
52
+ "test": "node --import tsx --test 'src/**/*.test.ts' 'bin/**/*.test.mjs'"
49
53
  }
50
54
  }
@@ -0,0 +1,23 @@
1
+ import { publishErrorBody } from "@moloodev/platform";
2
+ import { z } from "zod";
3
+
4
+ const ErrorBodySchema = z.object({
5
+ error: z.string(),
6
+ message: z.string().optional(),
7
+ details: z.array(z.object({ code: z.string().optional(), detail: z.string() })).catch([]),
8
+ });
9
+
10
+ export function readApiBody(body: string): Record<string, unknown> | undefined {
11
+ try {
12
+ return z.record(z.string(), z.unknown()).safeParse(JSON.parse(body)).data;
13
+ } catch { /* Non-JSON errors still get an HTTP status below. */ }
14
+ return undefined;
15
+ }
16
+
17
+ export function apiErrorMessage(status: number, body: string): string {
18
+ const result = ErrorBodySchema.safeParse(readApiBody(body)).data;
19
+ const code = result?.error ?? body;
20
+ const message = result?.message ?? publishErrorBody(code).message ?? `Request failed (${status}): ${code}`;
21
+ const details = result?.details.map((finding) => ` ${finding.code ? finding.code + ": " : ""}${finding.detail}`) ?? [];
22
+ return [message, ...details].join("\n");
23
+ }
package/src/cli.ts CHANGED
@@ -2,17 +2,29 @@ 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 { publishEditorPackage } from "./publish";
5
+ import { isEditorTrack, type EditorTrack } from "@moloodev/platform";
6
+ import { promoteEditorPackage, publishEditorPackage } from "./publish";
7
+ import { printEditorStatus } from "./editor-status";
8
+ import { createEditor, EDITOR_CREATE_USAGE } from "./editor-create";
9
+ import { apiErrorMessage, readApiBody } from "./api-result";
6
10
  import { listWorkspaces, useWorkspace, type WorkspaceOptions } from "./workspace";
7
11
 
8
12
  const EDITOR_USAGE =
9
- "Usage: moloo editor <dev|build|publish> [--dir <path>] [--out-dir <path>] [--view-port <port>] [--workspace <slug>]\nDev view port: 4310 (default)";
13
+ `${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)`;
10
14
  const WORKSPACE_USAGE = "Usage: moloo workspace list | use <slug>";
11
- const USAGE = `Usage: moloo <command>\n\nCommands:\n login [--device]\n logout\n whoami\n workspace list | use <slug>\n editor dev | build | publish\n\n${WORKSPACE_USAGE}\n${EDITOR_USAGE}`;
15
+ 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 | promote | status\n\n${WORKSPACE_USAGE}\n${EDITOR_USAGE}`;
12
16
 
13
17
  type Command = (args: readonly string[], options: WorkspaceOptions) => Promise<number>;
14
18
 
15
19
  const editorCommands: Record<string, Command> = {
20
+ async create(args) {
21
+ const ids = args.filter((arg) => arg !== "--here");
22
+ if (ids.length !== 1 || !ids[0] || ids[0].startsWith("-") || args.length > 2) {
23
+ throw new Error(EDITOR_CREATE_USAGE);
24
+ }
25
+ await createEditor(ids[0], { here: args.includes("--here") });
26
+ return 0;
27
+ },
16
28
  async dev(args, options) {
17
29
  for (let index = 0; index < args.length; index += 2) {
18
30
  if (
@@ -41,22 +53,45 @@ const editorCommands: Record<string, Command> = {
41
53
  return 0;
42
54
  },
43
55
  async publish(args, options) {
44
- for (let index = 0; index < args.length; index += 2) {
45
- if (!["--dir", "--workspace"].includes(args[index] ?? "") ||
46
- !args[index + 1] || args[index + 1]?.startsWith("--")) {
47
- throw new Error(EDITOR_USAGE);
48
- }
49
- }
56
+ validateWorkspaceFlags(args, ["--track"]);
57
+ const track = readTrackFlag(args);
50
58
  const result = await publishEditorPackage(packageRootFlag(args), {
51
- ...options, workspace: readFlag(args, "--workspace"),
59
+ ...options, workspace: readFlag(args, "--workspace"), track,
52
60
  });
53
61
  if (result.status === 202) {
54
- const receipt = JSON.parse(result.body) as { data: { id: string } };
55
- console.log(`${result.id} 제출됨·심사 필요 ${receipt.data.id}`);
62
+ const receipt = readApiBody(result.body)?.data as { id?: string } | undefined;
63
+ if (typeof receipt?.id !== "string") throw new Error("Invalid submission receipt");
64
+ const next = track === "stable" ? "pending platform review" : "beta after checks pass";
65
+ console.log(`${result.id} submitted — ${next}. Revision: ${receipt.id}`);
56
66
  return 0;
57
67
  }
68
+ if (result.status !== 200 && result.status !== 201) {
69
+ throw new Error(apiErrorMessage(result.status, result.body));
70
+ }
58
71
  console.log(`${result.id} ${String(result.status)} ${result.body}`);
59
- return result.status === 200 || result.status === 201 ? 0 : 1;
72
+ return 0;
73
+ },
74
+ async promote(args, options) {
75
+ const [from, to, ...flags] = args;
76
+ if (from !== "beta" || to !== "stable") throw new Error(EDITOR_USAGE);
77
+ validateWorkspaceFlags(flags);
78
+ const result = await promoteEditorPackage(packageRootFlag(flags), {
79
+ ...options, workspace: readFlag(flags, "--workspace"),
80
+ });
81
+ if (![200, 201, 202].includes(result.status)) {
82
+ throw new Error(apiErrorMessage(result.status, result.body));
83
+ }
84
+ const data = readApiBody(result.body)?.data as { id?: string; revision?: number } | undefined;
85
+ if (typeof data?.id !== "string" || typeof data.revision !== "number") throw new Error("Invalid promotion receipt");
86
+ const outcome = result.status === 201
87
+ ? "promoted to stable"
88
+ : result.status === 200 ? "stable already points at beta" : "submitted for stable — pending platform review";
89
+ console.log(`${result.id} #${String(data.revision)} ${outcome}. Revision: ${data.id}`);
90
+ return 0;
91
+ },
92
+ async status(args, options) {
93
+ validateWorkspaceFlags(args);
94
+ return printEditorStatus(packageRootFlag(args), { ...options, workspace: readFlag(args, "--workspace") });
60
95
  },
61
96
  };
62
97
 
@@ -108,8 +143,7 @@ const commands: Record<string, {
108
143
  ? editorCommands[name]
109
144
  : undefined;
110
145
  if (!command) {
111
- console.error(EDITOR_USAGE);
112
- return 1;
146
+ throw new Error(EDITOR_USAGE);
113
147
  }
114
148
  return command(args, options);
115
149
  },
@@ -126,8 +160,7 @@ export async function runCli(argv: readonly string[], options: WorkspaceOptions
126
160
  return 0;
127
161
  }
128
162
  if (!command) {
129
- console.error(USAGE);
130
- return 1;
163
+ throw new Error(USAGE);
131
164
  }
132
165
  return command.run(args, options);
133
166
  }
@@ -136,12 +169,25 @@ function packageRootFlag(args: readonly string[]): string {
136
169
  return resolve(readFlag(args, "--dir") ?? process.cwd());
137
170
  }
138
171
 
172
+ function validateWorkspaceFlags(args: readonly string[], extra: readonly string[] = []): void {
173
+ for (let index = 0; index < args.length; index += 2) {
174
+ if (!["--dir", "--workspace", ...extra].includes(args[index] ?? "") ||
175
+ !args[index + 1] || args[index + 1]?.startsWith("--")) throw new Error(EDITOR_USAGE);
176
+ }
177
+ }
178
+
139
179
  function readFlag(args: readonly string[], flag: string): string | undefined {
140
180
  const index = args.indexOf(flag);
141
181
  if (index === -1) return undefined;
142
182
  return args[index + 1];
143
183
  }
144
184
 
185
+ function readTrackFlag(args: readonly string[]): EditorTrack {
186
+ const raw = readFlag(args, "--track") ?? "stable";
187
+ if (!isEditorTrack(raw)) throw new Error(`Invalid --track: use stable or beta`);
188
+ return raw;
189
+ }
190
+
145
191
  function readPortFlag(args: readonly string[], flag: string): number | undefined {
146
192
  const raw = readFlag(args, flag);
147
193
  if (raw === undefined) return undefined;
@@ -0,0 +1,114 @@
1
+ import { constants } from "node:fs";
2
+ import { copyFile, cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { extract, list } from "tar";
6
+ import { parseEditorId } from "@moloodev/editor-sdk/protocol";
7
+
8
+ export const EDITOR_TEMPLATE_TAG = "v1";
9
+ export const EDITOR_TEMPLATE_URL = `https://codeload.github.com/moloodev/editor-template/tar.gz/refs/tags/${EDITOR_TEMPLATE_TAG}`;
10
+ export const EDITOR_CREATE_USAGE = "Usage: moloo editor create <publisher>.<name> [--here]";
11
+
12
+ export async function createEditor(
13
+ id: string,
14
+ { cwd = process.cwd(), here = false, fetch: download = globalThis.fetch }: {
15
+ cwd?: string;
16
+ here?: boolean;
17
+ fetch?: typeof fetch;
18
+ } = {},
19
+ ): Promise<string> {
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).");
23
+ }
24
+ const destination = here ? resolve(cwd) : resolve(cwd, `${editor.editor}-editor`);
25
+ async function checkDestination() {
26
+ if (here) {
27
+ if ((await readdir(destination)).length) throw new Error("--here requires an empty current directory (including hidden files).");
28
+ } else {
29
+ const existing = await lstat(destination).catch((error: NodeJS.ErrnoException) => {
30
+ if (error.code !== "ENOENT") throw error;
31
+ return null;
32
+ });
33
+ if (existing) throw new Error(`Destination already exists: ${destination}`);
34
+ }
35
+ }
36
+ await checkDestination();
37
+ const staging = await mkdtemp(join(tmpdir(), "moloo-create-"));
38
+ try {
39
+ const response = await download(EDITOR_TEMPLATE_URL, { signal: AbortSignal.timeout(30_000) });
40
+ if (!response.ok) throw new Error(`Template download failed (${response.status}): ${EDITOR_TEMPLATE_URL}`);
41
+ const archive = join(staging, "template.tar.gz");
42
+ await writeFile(archive, new Uint8Array(await response.arrayBuffer()));
43
+ const source = join(staging, "source");
44
+ await mkdir(source);
45
+ let archiveRoot: string | undefined;
46
+ list({
47
+ file: archive, sync: true, strict: true,
48
+ onReadEntry(entry) {
49
+ const path = entry.path;
50
+ const parts = path.replace(/\/$/, "").split("/");
51
+ archiveRoot ??= parts[0];
52
+ if (parts[0] !== archiveRoot || parts.some((part) => !part || part === "." || part === ".." || part === ".git") ||
53
+ path.includes("\\") || !["File", "Directory"].includes(entry.type)) {
54
+ throw new Error(`Unsupported template archive entry: ${path}`);
55
+ }
56
+ },
57
+ });
58
+ await extract({ file: archive, cwd: source, strip: 1, strict: true });
59
+ const packageFile = join(source, "package.json");
60
+ const pkg = JSON.parse(await readFile(packageFile, "utf8"));
61
+ if (pkg.name !== "@moloodev/example-editor") throw new Error("Invalid editor template package placeholder");
62
+ pkg.name = `@${editor.publisher}/${editor.editor}-editor`;
63
+ await writeFile(packageFile, JSON.stringify(pkg, null, 2) + "\n");
64
+ const manifestFile = join(source, "src/manifest.ts");
65
+ let manifest = await readFile(manifestFile, "utf8");
66
+ for (const [token, value] of [
67
+ ["example.editor", id],
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`],
72
+ ] as const) {
73
+ if (!manifest.includes(token)) throw new Error(`Missing editor template placeholder: ${token}`);
74
+ manifest = manifest.replaceAll(token, value);
75
+ }
76
+ await writeFile(manifestFile, manifest);
77
+ const readmeFile = join(source, "README.md");
78
+ const readme = await readFile(readmeFile, "utf8");
79
+ if (!readme.startsWith("# Example editor\n")) throw new Error("Missing editor template README title");
80
+ await writeFile(readmeFile, readme.replace("# Example editor\n", `# ${editor.editor} editor\n`));
81
+
82
+ // Recheck after download; mkdir and exclusive copies also protect against races.
83
+ await checkDestination();
84
+ if (!here) await mkdir(destination);
85
+ const created: string[] = [];
86
+ try {
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
+ }
102
+ }
103
+ } catch (error) {
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 });
107
+ throw error;
108
+ }
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>`);
110
+ return destination;
111
+ } finally {
112
+ await rm(staging, { recursive: true, force: true });
113
+ }
114
+ }
@@ -0,0 +1,32 @@
1
+ import { editorStatusResponseSchema, getApiOrigin } from "@moloodev/platform";
2
+ import { publishToken } from "./auth";
3
+ import { apiErrorMessage, readApiBody } from "./api-result";
4
+ import { readPackageEditorView } from "./editor-views";
5
+ import { resolveEditorTarget } from "./publish";
6
+ import type { WorkspaceOptions } from "./workspace";
7
+
8
+ export async function printEditorStatus(packageRoot: string, options: WorkspaceOptions & { workspace?: string }): Promise<number> {
9
+ const { id } = readPackageEditorView(packageRoot);
10
+ const target = await resolveEditorTarget(id, options);
11
+ const token = await publishToken(options.store);
12
+ const query = new URLSearchParams({ workspace: target.workspaceSlug, editor: id });
13
+ const response = await fetch(`${getApiOrigin("production")}/v1/revisions/status?${query}`, {
14
+ redirect: "error", headers: { authorization: `Bearer ${token}` },
15
+ });
16
+ const body = await response.text();
17
+ if (response.status !== 200) {
18
+ throw new Error(apiErrorMessage(response.status, body));
19
+ }
20
+ const parsed = editorStatusResponseSchema.safeParse(readApiBody(body));
21
+ if (!parsed.success) throw new Error("Invalid editor status response");
22
+ const { data } = parsed.data;
23
+ console.log(`${id} — latest ${data.length} revisions (${target.workspaceSlug})`);
24
+ if (!data.length) console.log("No revisions submitted.");
25
+ for (const row of data) {
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"}`);
28
+ if (row.rejectionReason) console.log(` Rejected: ${row.rejectionReason}`);
29
+ for (const finding of row.checkFindings) console.log(` ${finding.code}: ${finding.detail}`);
30
+ }
31
+ return 0;
32
+ }
@@ -6,8 +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";
10
+ import { parsePublishName, PUBLISH_ERROR_MESSAGES } from "@moloodev/platform";
11
11
  import { parseNetwork } from "@moloodev/platform/network";
12
12
  import { publishBundlePlugin } from "./publish-bundle";
13
13
  import { readEditorManifest } from "./read-editor-manifest";
@@ -24,14 +24,14 @@ export function readPackageEditorView(packageRoot: string): EditorView & { manif
24
24
  throw new Error(`Missing editor manifest at ${manifestPath}`);
25
25
  }
26
26
  const manifest = readEditorManifest(manifestPath);
27
- validateEditorManifest(manifest);
28
27
  if (typeof manifest.id !== "string") {
29
28
  throw new Error(`Invalid editor manifest id in ${manifestPath}`);
30
29
  }
31
- const parsed = parseEditorId(manifest.id, true);
32
- if (parsed === undefined) {
33
- throw new Error(`Invalid editor id ${manifest.id}`);
30
+ const parsed = parsePublishName(manifest.id);
31
+ if (!parsed.ok) {
32
+ throw new Error(PUBLISH_ERROR_MESSAGES[parsed.error]);
34
33
  }
34
+ validateEditorManifest(manifest);
35
35
  const network = parseNetwork("network" in manifest ? manifest.network : undefined);
36
36
  return { id: manifest.id, packageRoot, network, manifest };
37
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,20 +66,10 @@ 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
- const name = parseQualifiedName(view.id);
67
- if (!name) throw new Error("Invalid editor manifest id");
68
- let target: PublishTarget;
69
- if (name.publisher === "moloo") {
70
- if (options.workspace !== undefined) throw new Error("Built-in editors do not accept --workspace.");
71
- target = { kind: "builtin" };
72
- } else {
73
- target = { kind: "workspace", workspaceSlug: await resolveWorkspace({
74
- flag: options.workspace, store: options.store, configPath: options.configPath,
75
- }) };
76
- }
72
+ const target = await resolveEditorTarget(view.id, options);
77
73
  const token = await publishToken(options.store);
78
74
  const built = await buildEditorPackage(packageRoot);
79
75
  const result = await publishEditor({
@@ -81,10 +77,40 @@ export async function publishEditorPackage(
81
77
  script: built.script,
82
78
  token,
83
79
  target,
80
+ track: options.track ?? "stable",
84
81
  });
85
82
  return { id: built.id, ...result };
86
83
  }
87
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
+
102
+ export async function resolveEditorTarget(
103
+ id: string,
104
+ options: WorkspaceOptions & { workspace?: string },
105
+ ): Promise<PublishTarget> {
106
+ const name = parseQualifiedName(id);
107
+ if (!name) throw new Error("Invalid editor manifest id");
108
+ return { kind: "workspace", workspaceSlug: await resolveWorkspace({
109
+ flag: options.workspace ?? (name.publisher === "moloo" ? "moloo" : undefined),
110
+ store: options.store, configPath: options.configPath, apiOrigin: options.apiOrigin,
111
+ }) };
112
+ }
113
+
88
114
  function readUpload(text: string): { pathname: string; token: string } | undefined {
89
115
  let value: unknown;
90
116
  try {