@moloodev/cli 0.6.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
@@ -2,7 +2,9 @@
2
2
 
3
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
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
 
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
- } catch (error) {
15
- if (error.code !== "ENOENT") throw error;
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
@@ -20,7 +20,7 @@ const { runCli } = await import("../src/cli.ts");
20
20
  runCli(process.argv.slice(2)).then(
21
21
  (code) => process.exit(code),
22
22
  (err) => {
23
- console.error(err);
23
+ console.error(err instanceof Error && process.env.MOLOO_DEBUG !== "1" ? `Error: ${err.message}` : err);
24
24
  process.exit(1);
25
25
  },
26
26
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moloodev/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.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.6.0",
28
- "@moloodev/editor-store": "0.6.0",
29
- "@moloodev/platform": "0.6.0",
30
- "@moloodev/ui": "0.6.0"
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"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^24.13.3",
34
34
  "typescript": "^7.0.2",
35
- "@moloodev/typescript-config": "0.6.0"
35
+ "@moloodev/typescript-config": "0.7.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() })).optional(),
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?.map((finding) => ` ${finding.code ? finding.code + ": " : ""}${finding.detail}`) ?? [];
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,17 @@ 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";
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";
9
10
  import { listWorkspaces, useWorkspace, type WorkspaceOptions } from "./workspace";
10
11
 
11
12
  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)`;
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)`;
13
14
  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}`;
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}`;
15
16
 
16
17
  type Command = (args: readonly string[], options: WorkspaceOptions) => Promise<number>;
17
18
 
@@ -52,23 +53,42 @@ const editorCommands: Record<string, Command> = {
52
53
  return 0;
53
54
  },
54
55
  async publish(args, options) {
55
- validateWorkspaceFlags(args);
56
+ validateWorkspaceFlags(args, ["--track"]);
57
+ const track = readTrackFlag(args);
56
58
  const result = await publishEditorPackage(packageRootFlag(args), {
57
- ...options, workspace: readFlag(args, "--workspace"),
59
+ ...options, workspace: readFlag(args, "--workspace"), track,
58
60
  });
59
61
  if (result.status === 202) {
60
62
  const receipt = readApiBody(result.body)?.data as { id?: string } | undefined;
61
63
  if (typeof receipt?.id !== "string") throw new Error("Invalid submission receipt");
62
- console.log(`${result.id} submitted — pending platform review. Revision: ${receipt.id}`);
64
+ const next = track === "stable" ? "pending platform review" : "beta after checks pass";
65
+ console.log(`${result.id} submitted — ${next}. Revision: ${receipt.id}`);
63
66
  return 0;
64
67
  }
65
68
  if (result.status !== 200 && result.status !== 201) {
66
- console.error(apiErrorMessage(result.status, result.body));
67
- return 1;
69
+ throw new Error(apiErrorMessage(result.status, result.body));
68
70
  }
69
71
  console.log(`${result.id} ${String(result.status)} ${result.body}`);
70
72
  return 0;
71
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
+ },
72
92
  async status(args, options) {
73
93
  validateWorkspaceFlags(args);
74
94
  return printEditorStatus(packageRootFlag(args), { ...options, workspace: readFlag(args, "--workspace") });
@@ -123,8 +143,7 @@ const commands: Record<string, {
123
143
  ? editorCommands[name]
124
144
  : undefined;
125
145
  if (!command) {
126
- console.error(EDITOR_USAGE);
127
- return 1;
146
+ throw new Error(EDITOR_USAGE);
128
147
  }
129
148
  return command(args, options);
130
149
  },
@@ -141,8 +160,7 @@ export async function runCli(argv: readonly string[], options: WorkspaceOptions
141
160
  return 0;
142
161
  }
143
162
  if (!command) {
144
- console.error(USAGE);
145
- return 1;
163
+ throw new Error(USAGE);
146
164
  }
147
165
  return command.run(args, options);
148
166
  }
@@ -151,9 +169,9 @@ function packageRootFlag(args: readonly string[]): string {
151
169
  return resolve(readFlag(args, "--dir") ?? process.cwd());
152
170
  }
153
171
 
154
- function validateWorkspaceFlags(args: readonly string[]): void {
172
+ function validateWorkspaceFlags(args: readonly string[], extra: readonly string[] = []): void {
155
173
  for (let index = 0; index < args.length; index += 2) {
156
- if (!["--dir", "--workspace"].includes(args[index] ?? "") ||
174
+ if (!["--dir", "--workspace", ...extra].includes(args[index] ?? "") ||
157
175
  !args[index + 1] || args[index + 1]?.startsWith("--")) throw new Error(EDITOR_USAGE);
158
176
  }
159
177
  }
@@ -164,6 +182,12 @@ function readFlag(args: readonly string[], flag: string): string | undefined {
164
182
  return args[index + 1];
165
183
  }
166
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
+
167
191
  function readPortFlag(args: readonly string[], flag: string): number | undefined {
168
192
  const raw = readFlag(args, flag);
169
193
  if (raw === undefined) return undefined;
@@ -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 qualified = parseQualifiedName(id);
22
- if (!qualified || `${qualified.publisher}.${qualified.name}` !== id || !parseEditorId(id, true)) {
23
- throw new Error("Invalid editor id: use publisher.name with a valid publisher handle and lowercase editor DNS label (1–63 characters). Reserved publishers other than moloo are unavailable.");
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 { publisher, name } = qualified;
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}/${name}-editor`;
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/${name}`],
71
- [".example.json", `.${name}.json`],
72
- ["https://example.com/schemas/example/v1.json", `https://example.com/schemas/${name}/v1.json`],
73
- ["Example editor", `${name} 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", `# ${name} 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
- await cp(join(source, entry), join(destination, entry), {
90
- recursive: true, force: false, errorOnExist: true, mode: constants.COPYFILE_EXCL,
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 (!here) await rm(destination, { recursive: true, force: true });
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 ${name}-editor\n`} pnpm install\n pnpm exec moloo login\n pnpm dev\n\nPublish: pnpm publish:editor --workspace <slug>`);
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 });
@@ -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
- console.error(apiErrorMessage(response.status, body));
26
- return 1;
18
+ throw new Error(apiErrorMessage(response.status, body));
27
19
  }
28
- const parsed = StatusBodySchema.safeParse(readApiBody(body));
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
- console.log(`#${row.revision} ${row.status} (${row.id})\n Submitted: ${row.submittedAt}\n Reviewed: ${row.reviewedAt ?? "pending"}`);
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
  }
@@ -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 { isPublisherHandle, isReservedPublisherHandle, PUBLISH_ERROR_MESSAGES } from "@moloodev/platform";
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 = parseEditorId(manifest.id, true);
38
- if (parsed === undefined) {
39
- 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]);
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 },