@fluid-app/fluid-cli-droplets 0.1.122 → 0.1.124

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/dist/index.mjs CHANGED
@@ -17,7 +17,7 @@ function dropletInstallationsPath(dropletUuid) {
17
17
  function installationPath(installationUuid) {
18
18
  return `${INSTALLATIONS_PATH}/${encodeURIComponent(installationUuid)}`;
19
19
  }
20
- function apiStatus(error) {
20
+ function apiStatus$1(error) {
21
21
  const status = error.status;
22
22
  return typeof status === "number" ? status : void 0;
23
23
  }
@@ -34,7 +34,7 @@ async function installDroplet(client, dropletUuid, roleUuid) {
34
34
  installation: (await client.post(dropletInstallationsPath(dropletUuid), { installation })).droplet_installation
35
35
  };
36
36
  } catch (error) {
37
- if (apiStatus(error) === 409) return {
37
+ if (apiStatus$1(error) === 409) return {
38
38
  installed: true,
39
39
  alreadyInstalled: true,
40
40
  dropletUuid
@@ -77,7 +77,7 @@ async function resolveInstallation(client, uuid) {
77
77
  try {
78
78
  return (await findInstallation(client, uuid))?.uuid ?? null;
79
79
  } catch (error) {
80
- if (apiStatus(error) === 403) throw new DropletsCliError(`Finding the installation of ${uuid} needs the droplets.view permission. Pass the installation's uuid (dri_…) instead.`);
80
+ if (apiStatus$1(error) === 403) throw new DropletsCliError(`Finding the installation of ${uuid} needs the droplets.view permission. Pass the installation's uuid (dri_…) instead.`);
81
81
  throw error;
82
82
  }
83
83
  }
@@ -90,7 +90,7 @@ async function preflightInstallation(client, dropletUuid) {
90
90
  try {
91
91
  return await findInstallation(client, dropletUuid);
92
92
  } catch (error) {
93
- if (apiStatus(error) === 403) return void 0;
93
+ if (apiStatus$1(error) === 403) return void 0;
94
94
  throw error;
95
95
  }
96
96
  }
@@ -114,7 +114,7 @@ async function uninstallDroplet(client, installationUuid) {
114
114
  installation: (await client.delete(installationPath(installationUuid))).droplet_installation
115
115
  };
116
116
  } catch (error) {
117
- if (apiStatus(error) === 404) throw notInstalled(installationUuid);
117
+ if (apiStatus$1(error) === 404) throw notInstalled(installationUuid);
118
118
  throw error;
119
119
  }
120
120
  }
@@ -159,10 +159,10 @@ function fail(err) {
159
159
  }) + "\n");
160
160
  process.exitCode = 1;
161
161
  }
162
- function run(action) {
162
+ function run$1(action) {
163
163
  return action().catch(fail);
164
164
  }
165
- async function dropletsClient(ctx) {
165
+ async function dropletsClient$1(ctx) {
166
166
  return await ctx.getClient();
167
167
  }
168
168
  /**
@@ -170,8 +170,8 @@ async function dropletsClient(ctx) {
170
170
  * v2026-10 company API, next to the generated `installations` commands.
171
171
  */
172
172
  function registerInstallCommands(parent, ctx) {
173
- parent.command("install <droplet_uuid>").description("Install a droplet for this company; it receives the scopes it requests (changes live config)").option("--role <role_uuid>", "The role the droplet acts with, by its uuid; a new role is made when left out").option("--yes", "Confirm without a prompt", false).action((dropletUuid, opts) => run(async () => {
174
- const client = await dropletsClient(ctx);
173
+ parent.command("install <droplet_uuid>").description("Install a droplet for this company; it receives the scopes it requests (changes live config)").option("--role <role_uuid>", "The role the droplet acts with, by its uuid; a new role is made when left out").option("--yes", "Confirm without a prompt", false).action((dropletUuid, opts) => run$1(async () => {
174
+ const client = await dropletsClient$1(ctx);
175
175
  const existing = await preflightInstallation(client, dropletUuid);
176
176
  if (existing) {
177
177
  ctx.output({
@@ -184,16 +184,146 @@ function registerInstallCommands(parent, ctx) {
184
184
  await confirmChange(`Install droplet ${dropletUuid} for this company?`, opts.yes);
185
185
  ctx.output(await installDroplet(client, dropletUuid, opts.role));
186
186
  }));
187
- parent.command("uninstall <uuid>").description("Uninstall a droplet by its uuid or its installation's uuid (dri_…); its credentials are revoked and it is sent its uninstall webhook (changes live config)").option("--yes", "Confirm without a prompt", false).action((uuid, opts) => run(async () => {
188
- const client = await dropletsClient(ctx);
187
+ parent.command("uninstall <uuid>").description("Uninstall a droplet by its uuid or its installation's uuid (dri_…); its credentials are revoked and it is sent its uninstall webhook (changes live config)").option("--yes", "Confirm without a prompt", false).action((uuid, opts) => run$1(async () => {
188
+ const client = await dropletsClient$1(ctx);
189
189
  const installationUuid = await resolveInstallation(client, uuid);
190
190
  if (!installationUuid) throw notInstalled(uuid);
191
191
  await confirmChange(`Uninstall ${installationUuid} from this company?`, opts.yes);
192
192
  ctx.output(await uninstallDroplet(client, installationUuid));
193
193
  }));
194
- parent.command("installed").description("List the droplets this company has installed; --full for full installation rows, every page").option("--full", "Full rows (status, scopes, role), including installations being removed", false).option("--category <slug>", "With --full, only droplets in this category").action((opts) => run(async () => {
194
+ parent.command("installed").description("List the droplets this company has installed; --full for full installation rows, every page").option("--full", "Full rows (status, scopes, role), including installations being removed", false).option("--category <slug>", "With --full, only droplets in this category").action((opts) => run$1(async () => {
195
195
  if (opts.category && !opts.full) throw new DropletsCliError("--category needs --full.");
196
- ctx.output(await listInstallations(await dropletsClient(ctx), opts));
196
+ ctx.output(await listInstallations(await dropletsClient$1(ctx), opts));
197
+ }));
198
+ }
199
+ //#endregion
200
+ //#region src/publish/api.ts
201
+ /**
202
+ * Asking Fluid to publish a droplet, and a root admin's review of those
203
+ * requests, through the v2026-10 APIs
204
+ * (apps/rails/docs/openapi/v2026-10/company-droplets.yaml and the root-admin
205
+ * droplet spec).
206
+ */
207
+ const PUBLISH_REQUESTS_PATH = "/api/root_admin/v2026-10/droplet-publish-requests";
208
+ /** The publisher's request to publish lives under the droplet. */
209
+ function publishRequestPath(dropletUuid) {
210
+ return `/api/company/v2026-10/droplets/${encodeURIComponent(dropletUuid)}/publish-request`;
211
+ }
212
+ function reviewPath(requestId, decision) {
213
+ return `${PUBLISH_REQUESTS_PATH}/${encodeURIComponent(requestId)}/${decision}`;
214
+ }
215
+ function apiStatus(error) {
216
+ const status = error.status;
217
+ return typeof status === "number" ? status : void 0;
218
+ }
219
+ /** Publish review is released per publishing company; without it the
220
+ * request endpoints answer 404. */
221
+ function notAvailable(dropletUuid) {
222
+ return new DropletsCliError(`${dropletUuid} is not one of this company's droplets, or asking Fluid to publish is not turned on for this company.`);
223
+ }
224
+ /**
225
+ * Ask Fluid to publish a draft or internal droplet. Asking again while a
226
+ * request waits returns that request.
227
+ */
228
+ async function requestPublish(client, dropletUuid) {
229
+ try {
230
+ return {
231
+ requested: true,
232
+ publish_request: (await client.post(publishRequestPath(dropletUuid))).publish_request
233
+ };
234
+ } catch (error) {
235
+ if (apiStatus(error) === 404) throw notAvailable(dropletUuid);
236
+ throw error;
237
+ }
238
+ }
239
+ /** Withdraw the request to publish that is waiting. */
240
+ async function withdrawPublish(client, dropletUuid) {
241
+ try {
242
+ await client.delete(publishRequestPath(dropletUuid));
243
+ return {
244
+ withdrawn: true,
245
+ droplet_uuid: dropletUuid
246
+ };
247
+ } catch (error) {
248
+ if (apiStatus(error) === 404) throw new DropletsCliError(`${dropletUuid} has no request to publish waiting, or is not one of this company's droplets.`);
249
+ throw error;
250
+ }
251
+ }
252
+ /**
253
+ * Where a droplet stands: its status, whether this company can ask Fluid to
254
+ * publish it, and Fluid's latest decision (with the reason once rejected).
255
+ */
256
+ async function publishStatus(client, dropletUuid) {
257
+ const detail = await client.get(`/api/company/v2026-10/droplets/${encodeURIComponent(dropletUuid)}`);
258
+ return {
259
+ droplet_uuid: dropletUuid,
260
+ status: detail.droplet?.status,
261
+ publish_review_available: detail.droplet?.publish_review_available === true,
262
+ latest_publish_review: detail.droplet?.latest_publish_review ?? null
263
+ };
264
+ }
265
+ /** Requests to publish in a state (pending when left out), oldest first,
266
+ * every page. Root admin only. */
267
+ async function listPublishRequests(client, options = {}) {
268
+ const filter = {
269
+ state: options.state ?? "pending",
270
+ ...options.droplet ? { droplet_uuid: options.droplet } : {}
271
+ };
272
+ const rows = [];
273
+ let cursor;
274
+ for (let page = 0; page < 100; page += 1) {
275
+ const response = await client.get(PUBLISH_REQUESTS_PATH, {
276
+ filter,
277
+ ...cursor ? { page: { cursor } } : {}
278
+ });
279
+ rows.push(...response.publish_requests ?? []);
280
+ cursor = response.meta?.pagination?.next_cursor;
281
+ if (!cursor) return rows;
282
+ }
283
+ throw new DropletsCliError(`Stopped after 100 pages of publish requests.`);
284
+ }
285
+ /** Approve a request, which publishes the droplet, or reject it with a
286
+ * reason its publisher is told. Root admin only. */
287
+ async function decidePublishRequest(client, requestId, decision, reason) {
288
+ if (decision === "reject" && !reason?.trim()) throw new DropletsCliError("Say why with --reason.");
289
+ return (await client.post(reviewPath(requestId, decision), decision === "reject" ? { reason } : void 0)).publish_request;
290
+ }
291
+ //#endregion
292
+ //#region src/commands/publish.ts
293
+ function run(action) {
294
+ return action().catch(fail);
295
+ }
296
+ async function dropletsClient(ctx) {
297
+ return await ctx.getClient();
298
+ }
299
+ /**
300
+ * `fluid droplets request-publish|withdraw-publish|publish-status` for a
301
+ * droplet's publisher, and `fluid droplets publish-requests
302
+ * list|approve|reject` for a Fluid root admin.
303
+ */
304
+ function registerPublishCommands(parent, ctx) {
305
+ parent.command("request-publish <droplet_uuid>").description("Ask Fluid to publish a draft or internal droplet to the marketplace; Fluid reviews it first").option("--yes", "Confirm without a prompt", false).action((dropletUuid, opts) => run(async () => {
306
+ await confirmChange(`Ask Fluid to publish ${dropletUuid} to the marketplace?`, opts.yes);
307
+ ctx.output(await requestPublish(await dropletsClient(ctx), dropletUuid));
308
+ }));
309
+ parent.command("withdraw-publish <droplet_uuid>").description("Withdraw a droplet's request to be published").option("--yes", "Confirm without a prompt", false).action((dropletUuid, opts) => run(async () => {
310
+ await confirmChange(`Withdraw the request to publish ${dropletUuid}?`, opts.yes);
311
+ ctx.output(await withdrawPublish(await dropletsClient(ctx), dropletUuid));
312
+ }));
313
+ parent.command("publish-status <droplet_uuid>").description("Show a droplet's status and Fluid's latest publish decision, with the reason when rejected").action((dropletUuid) => run(async () => {
314
+ ctx.output(await publishStatus(await dropletsClient(ctx), dropletUuid));
315
+ }));
316
+ const review = parent.command("publish-requests").description("Review droplets asking to be published (Fluid root admins)");
317
+ review.command("list").description("List requests to publish, oldest first").option("--state <state>", "pending, approved, rejected, withdrawn or superseded", "pending").option("--droplet <droplet_uuid>", "Only this droplet's requests").action((opts) => run(async () => {
318
+ ctx.output(await listPublishRequests(await dropletsClient(ctx), opts));
319
+ }));
320
+ review.command("approve <request_id>").description("Approve a request, publishing the droplet (changes live config)").option("--yes", "Confirm without a prompt", false).action((requestId, opts) => run(async () => {
321
+ await confirmChange(`Approve ${requestId} and publish the droplet?`, opts.yes);
322
+ ctx.output(await decidePublishRequest(await dropletsClient(ctx), requestId, "approve"));
323
+ }));
324
+ review.command("reject <request_id>").description("Reject a request; the publisher is told the reason").requiredOption("--reason <text>", "Why it was rejected").option("--yes", "Confirm without a prompt", false).action((requestId, opts) => run(async () => {
325
+ await confirmChange(`Reject ${requestId}?`, opts.yes);
326
+ ctx.output(await decidePublishRequest(await dropletsClient(ctx), requestId, "reject", opts.reason));
197
327
  }));
198
328
  }
199
329
  //#endregion
@@ -332,6 +462,7 @@ const plugin = {
332
462
  ctx.program.addCommand(createDomainCommand("droplets", "Manage Fluid droplets and installations", (parent, cmdCtx) => {
333
463
  registerAllCommands(parent, cmdCtx);
334
464
  registerInstallCommands(parent, cmdCtx);
465
+ registerPublishCommands(parent, cmdCtx);
335
466
  }));
336
467
  }
337
468
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fluid-app/fluid-cli-droplets",
3
- "version": "0.1.122",
3
+ "version": "0.1.124",
4
4
  "description": "Fluid CLI commands for droplet management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -17,7 +17,7 @@
17
17
  "access": "public"
18
18
  },
19
19
  "dependencies": {
20
- "@fluid-app/fluid-cli": "0.1.121",
20
+ "@fluid-app/fluid-cli": "0.1.122",
21
21
  "commander": "^12.0.0"
22
22
  },
23
23
  "devDependencies": {
@@ -0,0 +1,222 @@
1
+ import { DropletsCliError, type DropletsClient } from "../install/api";
2
+ import {
3
+ decidePublishRequest,
4
+ listPublishRequests,
5
+ PUBLISH_REQUESTS_PATH,
6
+ publishRequestPath,
7
+ publishStatus,
8
+ requestPublish,
9
+ reviewPath,
10
+ withdrawPublish,
11
+ } from "../publish/api";
12
+
13
+ type Call = {
14
+ method: string;
15
+ endpoint: string;
16
+ body?: unknown;
17
+ params?: unknown;
18
+ };
19
+
20
+ /** Answers each request from a queue per `METHOD endpoint`; an Error is thrown. */
21
+ function fakeClient(responses: Record<string, unknown[]> = {}) {
22
+ const calls: Call[] = [];
23
+ const respond = (method: string, endpoint: string) => {
24
+ const queue = responses[`${method} ${endpoint}`];
25
+ const value = queue && queue.length > 0 ? queue.shift() : {};
26
+ if (value instanceof Error) throw value;
27
+ return value;
28
+ };
29
+ const client: DropletsClient = {
30
+ get: async <T>(endpoint: string, params?: Record<string, unknown>) => {
31
+ calls.push({ method: "GET", endpoint, params });
32
+ return respond("GET", endpoint) as T;
33
+ },
34
+ post: async <T>(endpoint: string, body?: unknown) => {
35
+ calls.push({ method: "POST", endpoint, body });
36
+ return respond("POST", endpoint) as T;
37
+ },
38
+ delete: async <T>(endpoint: string) => {
39
+ calls.push({ method: "DELETE", endpoint });
40
+ return respond("DELETE", endpoint) as T;
41
+ },
42
+ };
43
+ return { client, calls };
44
+ }
45
+
46
+ const apiError = (status: number) =>
47
+ Object.assign(new Error(`HTTP ${status}`), { status });
48
+
49
+ const review = {
50
+ id: "0199a1b2-0000-7000-8000-000000000001",
51
+ state: "pending",
52
+ status_before: "internal",
53
+ reason: null,
54
+ requested_at: "2026-10-22T10:00:00Z",
55
+ decided_at: null,
56
+ };
57
+
58
+ describe("requestPublish", () => {
59
+ it("asks under the droplet and returns the request", async () => {
60
+ const path = publishRequestPath("drp_1");
61
+ const { client, calls } = fakeClient({
62
+ [`POST ${path}`]: [{ publish_request: review }],
63
+ });
64
+
65
+ await expect(requestPublish(client, "drp_1")).resolves.toEqual({
66
+ requested: true,
67
+ publish_request: review,
68
+ });
69
+ expect(calls).toEqual([{ method: "POST", endpoint: path }]);
70
+ });
71
+
72
+ it("explains a 404: not the company's droplet, or not turned on", async () => {
73
+ const { client } = fakeClient({
74
+ [`POST ${publishRequestPath("drp_1")}`]: [apiError(404)],
75
+ });
76
+
77
+ await expect(requestPublish(client, "drp_1")).rejects.toBeInstanceOf(
78
+ DropletsCliError,
79
+ );
80
+ });
81
+
82
+ it("passes other failures on", async () => {
83
+ const { client } = fakeClient({
84
+ [`POST ${publishRequestPath("drp_1")}`]: [apiError(422)],
85
+ });
86
+
87
+ await expect(requestPublish(client, "drp_1")).rejects.toMatchObject({
88
+ status: 422,
89
+ });
90
+ });
91
+ });
92
+
93
+ describe("withdrawPublish", () => {
94
+ it("deletes the waiting request", async () => {
95
+ const path = publishRequestPath("drp_1");
96
+ const { client, calls } = fakeClient({ [`DELETE ${path}`]: [undefined] });
97
+
98
+ await expect(withdrawPublish(client, "drp_1")).resolves.toEqual({
99
+ withdrawn: true,
100
+ droplet_uuid: "drp_1",
101
+ });
102
+ expect(calls).toEqual([{ method: "DELETE", endpoint: path }]);
103
+ });
104
+
105
+ it("says when nothing is waiting", async () => {
106
+ const { client } = fakeClient({
107
+ [`DELETE ${publishRequestPath("drp_1")}`]: [apiError(404)],
108
+ });
109
+
110
+ await expect(withdrawPublish(client, "drp_1")).rejects.toThrow(
111
+ /no request to publish waiting/,
112
+ );
113
+ });
114
+ });
115
+
116
+ describe("publishStatus", () => {
117
+ it("reads the status and latest decision from the droplet", async () => {
118
+ const rejected = { ...review, state: "rejected", reason: "No logo" };
119
+ const { client } = fakeClient({
120
+ "GET /api/company/v2026-10/droplets/drp_1": [
121
+ {
122
+ droplet: {
123
+ uuid: "drp_1",
124
+ status: "internal",
125
+ publish_review_available: true,
126
+ latest_publish_review: rejected,
127
+ },
128
+ },
129
+ ],
130
+ });
131
+
132
+ await expect(publishStatus(client, "drp_1")).resolves.toEqual({
133
+ droplet_uuid: "drp_1",
134
+ status: "internal",
135
+ publish_review_available: true,
136
+ latest_publish_review: rejected,
137
+ });
138
+ });
139
+
140
+ it("reads review as unavailable when the fields are missing", async () => {
141
+ const { client } = fakeClient({
142
+ "GET /api/company/v2026-10/droplets/drp_1": [
143
+ { droplet: { uuid: "drp_1", status: "draft" } },
144
+ ],
145
+ });
146
+
147
+ await expect(publishStatus(client, "drp_1")).resolves.toMatchObject({
148
+ publish_review_available: false,
149
+ latest_publish_review: null,
150
+ });
151
+ });
152
+ });
153
+
154
+ describe("listPublishRequests", () => {
155
+ it("lists pending requests, following every page", async () => {
156
+ const second = { ...review, id: "0199a1b2-0000-7000-8000-000000000002" };
157
+ const { client, calls } = fakeClient({
158
+ [`GET ${PUBLISH_REQUESTS_PATH}`]: [
159
+ {
160
+ publish_requests: [review],
161
+ meta: { pagination: { next_cursor: "c2" } },
162
+ },
163
+ { publish_requests: [second], meta: { pagination: {} } },
164
+ ],
165
+ });
166
+
167
+ await expect(listPublishRequests(client)).resolves.toEqual([
168
+ review,
169
+ second,
170
+ ]);
171
+ expect(calls.map((call) => call.params)).toEqual([
172
+ { filter: { state: "pending" } },
173
+ { filter: { state: "pending" }, page: { cursor: "c2" } },
174
+ ]);
175
+ });
176
+
177
+ it("filters by state and droplet", async () => {
178
+ const { client, calls } = fakeClient();
179
+
180
+ await listPublishRequests(client, { state: "rejected", droplet: "drp_1" });
181
+
182
+ expect(calls[0]?.params).toEqual({
183
+ filter: { state: "rejected", droplet_uuid: "drp_1" },
184
+ });
185
+ });
186
+ });
187
+
188
+ describe("decidePublishRequest", () => {
189
+ it("approves without a body", async () => {
190
+ const path = reviewPath(review.id, "approve");
191
+ const { client, calls } = fakeClient({
192
+ [`POST ${path}`]: [{ publish_request: { ...review, state: "approved" } }],
193
+ });
194
+
195
+ await expect(
196
+ decidePublishRequest(client, review.id, "approve"),
197
+ ).resolves.toMatchObject({ state: "approved" });
198
+ expect(calls).toEqual([
199
+ { method: "POST", endpoint: path, body: undefined },
200
+ ]);
201
+ });
202
+
203
+ it("rejects with the reason", async () => {
204
+ const path = reviewPath(review.id, "reject");
205
+ const { client, calls } = fakeClient({
206
+ [`POST ${path}`]: [{ publish_request: { ...review, state: "rejected" } }],
207
+ });
208
+
209
+ await decidePublishRequest(client, review.id, "reject", "No logo");
210
+
211
+ expect(calls[0]?.body).toEqual({ reason: "No logo" });
212
+ });
213
+
214
+ it("refuses a rejection without a reason before calling the API", async () => {
215
+ const { client, calls } = fakeClient();
216
+
217
+ await expect(
218
+ decidePublishRequest(client, review.id, "reject", " "),
219
+ ).rejects.toThrow("Say why with --reason.");
220
+ expect(calls).toEqual([]);
221
+ });
222
+ });
@@ -0,0 +1,135 @@
1
+ import type { Command } from "commander";
2
+ import type { CommandContext } from "@fluid-app/fluid-cli";
3
+
4
+ import type { DropletsClient } from "../install/api.js";
5
+ import {
6
+ decidePublishRequest,
7
+ listPublishRequests,
8
+ publishStatus,
9
+ requestPublish,
10
+ withdrawPublish,
11
+ } from "../publish/api.js";
12
+ import { confirmChange, fail } from "./install.js";
13
+
14
+ function run(action: () => Promise<void>): Promise<void> {
15
+ return action().catch(fail);
16
+ }
17
+
18
+ async function dropletsClient(ctx: CommandContext): Promise<DropletsClient> {
19
+ return (await ctx.getClient()) as unknown as DropletsClient;
20
+ }
21
+
22
+ /**
23
+ * `fluid droplets request-publish|withdraw-publish|publish-status` for a
24
+ * droplet's publisher, and `fluid droplets publish-requests
25
+ * list|approve|reject` for a Fluid root admin.
26
+ */
27
+ export function registerPublishCommands(
28
+ parent: Command,
29
+ ctx: CommandContext,
30
+ ): void {
31
+ parent
32
+ .command("request-publish <droplet_uuid>")
33
+ .description(
34
+ "Ask Fluid to publish a draft or internal droplet to the marketplace; Fluid reviews it first",
35
+ )
36
+ .option("--yes", "Confirm without a prompt", false)
37
+ .action((dropletUuid: string, opts: { yes: boolean }) =>
38
+ run(async () => {
39
+ await confirmChange(
40
+ `Ask Fluid to publish ${dropletUuid} to the marketplace?`,
41
+ opts.yes,
42
+ );
43
+ ctx.output(
44
+ await requestPublish(await dropletsClient(ctx), dropletUuid),
45
+ );
46
+ }),
47
+ );
48
+
49
+ parent
50
+ .command("withdraw-publish <droplet_uuid>")
51
+ .description("Withdraw a droplet's request to be published")
52
+ .option("--yes", "Confirm without a prompt", false)
53
+ .action((dropletUuid: string, opts: { yes: boolean }) =>
54
+ run(async () => {
55
+ await confirmChange(
56
+ `Withdraw the request to publish ${dropletUuid}?`,
57
+ opts.yes,
58
+ );
59
+ ctx.output(
60
+ await withdrawPublish(await dropletsClient(ctx), dropletUuid),
61
+ );
62
+ }),
63
+ );
64
+
65
+ parent
66
+ .command("publish-status <droplet_uuid>")
67
+ .description(
68
+ "Show a droplet's status and Fluid's latest publish decision, with the reason when rejected",
69
+ )
70
+ .action((dropletUuid: string) =>
71
+ run(async () => {
72
+ ctx.output(await publishStatus(await dropletsClient(ctx), dropletUuid));
73
+ }),
74
+ );
75
+
76
+ const review = parent
77
+ .command("publish-requests")
78
+ .description("Review droplets asking to be published (Fluid root admins)");
79
+
80
+ review
81
+ .command("list")
82
+ .description("List requests to publish, oldest first")
83
+ .option(
84
+ "--state <state>",
85
+ "pending, approved, rejected, withdrawn or superseded",
86
+ "pending",
87
+ )
88
+ .option("--droplet <droplet_uuid>", "Only this droplet's requests")
89
+ .action((opts: { state: string; droplet?: string }) =>
90
+ run(async () => {
91
+ ctx.output(await listPublishRequests(await dropletsClient(ctx), opts));
92
+ }),
93
+ );
94
+
95
+ review
96
+ .command("approve <request_id>")
97
+ .description(
98
+ "Approve a request, publishing the droplet (changes live config)",
99
+ )
100
+ .option("--yes", "Confirm without a prompt", false)
101
+ .action((requestId: string, opts: { yes: boolean }) =>
102
+ run(async () => {
103
+ await confirmChange(
104
+ `Approve ${requestId} and publish the droplet?`,
105
+ opts.yes,
106
+ );
107
+ ctx.output(
108
+ await decidePublishRequest(
109
+ await dropletsClient(ctx),
110
+ requestId,
111
+ "approve",
112
+ ),
113
+ );
114
+ }),
115
+ );
116
+
117
+ review
118
+ .command("reject <request_id>")
119
+ .description("Reject a request; the publisher is told the reason")
120
+ .requiredOption("--reason <text>", "Why it was rejected")
121
+ .option("--yes", "Confirm without a prompt", false)
122
+ .action((requestId: string, opts: { reason: string; yes: boolean }) =>
123
+ run(async () => {
124
+ await confirmChange(`Reject ${requestId}?`, opts.yes);
125
+ ctx.output(
126
+ await decidePublishRequest(
127
+ await dropletsClient(ctx),
128
+ requestId,
129
+ "reject",
130
+ opts.reason,
131
+ ),
132
+ );
133
+ }),
134
+ );
135
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import type { FluidPlugin } from "@fluid-app/fluid-cli";
2
2
  import { createDomainCommand } from "@fluid-app/fluid-cli";
3
3
 
4
4
  import { registerInstallCommands } from "./commands/install.js";
5
+ import { registerPublishCommands } from "./commands/publish.js";
5
6
  import { registerAllCommands } from "./generated/index.js";
6
7
 
7
8
  const plugin: FluidPlugin = {
@@ -16,8 +17,10 @@ const plugin: FluidPlugin = {
16
17
  registerAllCommands(parent, cmdCtx);
17
18
  // Hand-written commands on top of the generated OpenAPI surface:
18
19
  // `install`, `uninstall` and `installed`, over the v2026-10
19
- // company API.
20
+ // company API, and asking Fluid to publish a droplet (with the
21
+ // root admin's review of those requests).
20
22
  registerInstallCommands(parent, cmdCtx);
23
+ registerPublishCommands(parent, cmdCtx);
21
24
  },
22
25
  ),
23
26
  );
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Asking Fluid to publish a droplet, and a root admin's review of those
3
+ * requests, through the v2026-10 APIs
4
+ * (apps/rails/docs/openapi/v2026-10/company-droplets.yaml and the root-admin
5
+ * droplet spec).
6
+ */
7
+
8
+ import { DropletsCliError, type DropletsClient } from "../install/api.js";
9
+
10
+ /** A publisher's view of a request to publish, on the droplet's detail. */
11
+ export interface PublishReview {
12
+ id: string;
13
+ state: string;
14
+ status_before: string;
15
+ reason: string | null;
16
+ requested_at: string;
17
+ decided_at: string | null;
18
+ }
19
+
20
+ /** A root admin's view of a request to publish. */
21
+ export interface PublishRequestRow extends PublishReview {
22
+ droplet?: { uuid: string; name: string | null; status: string };
23
+ company?: { uuid: string; name: string | null };
24
+ requested_by?: { uuid: string; name: string | null; email: string } | null;
25
+ closed_by?: { uuid: string; name: string | null; email: string } | null;
26
+ }
27
+
28
+ interface DropletDetail {
29
+ droplet?: {
30
+ uuid: string;
31
+ title?: string | null;
32
+ status?: string;
33
+ latest_publish_review?: PublishReview | null;
34
+ publish_review_available?: boolean;
35
+ };
36
+ }
37
+
38
+ interface PublishRequestsPage {
39
+ publish_requests?: PublishRequestRow[];
40
+ meta?: { pagination?: { next_cursor?: string | null } };
41
+ }
42
+
43
+ export const PUBLISH_REQUESTS_PATH =
44
+ "/api/root_admin/v2026-10/droplet-publish-requests";
45
+
46
+ /** The publisher's request to publish lives under the droplet. */
47
+ export function publishRequestPath(dropletUuid: string): string {
48
+ return `/api/company/v2026-10/droplets/${encodeURIComponent(dropletUuid)}/publish-request`;
49
+ }
50
+
51
+ export function reviewPath(
52
+ requestId: string,
53
+ decision: "approve" | "reject",
54
+ ): string {
55
+ return `${PUBLISH_REQUESTS_PATH}/${encodeURIComponent(requestId)}/${decision}`;
56
+ }
57
+
58
+ function apiStatus(error: unknown): number | undefined {
59
+ const status = (error as { status?: unknown }).status;
60
+ return typeof status === "number" ? status : undefined;
61
+ }
62
+
63
+ /** Publish review is released per publishing company; without it the
64
+ * request endpoints answer 404. */
65
+ function notAvailable(dropletUuid: string): DropletsCliError {
66
+ return new DropletsCliError(
67
+ `${dropletUuid} is not one of this company's droplets, or asking Fluid to publish is not turned on for this company.`,
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Ask Fluid to publish a draft or internal droplet. Asking again while a
73
+ * request waits returns that request.
74
+ */
75
+ export async function requestPublish(
76
+ client: DropletsClient,
77
+ dropletUuid: string,
78
+ ): Promise<{ requested: true; publish_request: PublishReview }> {
79
+ try {
80
+ const response = await client.post<{ publish_request: PublishReview }>(
81
+ publishRequestPath(dropletUuid),
82
+ );
83
+ return { requested: true, publish_request: response.publish_request };
84
+ } catch (error) {
85
+ if (apiStatus(error) === 404) throw notAvailable(dropletUuid);
86
+ throw error;
87
+ }
88
+ }
89
+
90
+ /** Withdraw the request to publish that is waiting. */
91
+ export async function withdrawPublish(
92
+ client: DropletsClient,
93
+ dropletUuid: string,
94
+ ): Promise<{ withdrawn: true; droplet_uuid: string }> {
95
+ try {
96
+ await client.delete<void>(publishRequestPath(dropletUuid));
97
+ return { withdrawn: true, droplet_uuid: dropletUuid };
98
+ } catch (error) {
99
+ if (apiStatus(error) === 404)
100
+ throw new DropletsCliError(
101
+ `${dropletUuid} has no request to publish waiting, or is not one of this company's droplets.`,
102
+ );
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Where a droplet stands: its status, whether this company can ask Fluid to
109
+ * publish it, and Fluid's latest decision (with the reason once rejected).
110
+ */
111
+ export async function publishStatus(
112
+ client: DropletsClient,
113
+ dropletUuid: string,
114
+ ): Promise<{
115
+ droplet_uuid: string;
116
+ status: string | undefined;
117
+ publish_review_available: boolean;
118
+ latest_publish_review: PublishReview | null;
119
+ }> {
120
+ const detail = await client.get<DropletDetail>(
121
+ `/api/company/v2026-10/droplets/${encodeURIComponent(dropletUuid)}`,
122
+ );
123
+ return {
124
+ droplet_uuid: dropletUuid,
125
+ status: detail.droplet?.status,
126
+ publish_review_available: detail.droplet?.publish_review_available === true,
127
+ latest_publish_review: detail.droplet?.latest_publish_review ?? null,
128
+ };
129
+ }
130
+
131
+ /** Stop paging after this many pages, so a cursor that never ends cannot
132
+ * loop forever. */
133
+ export const MAX_PAGES = 100;
134
+
135
+ /** Requests to publish in a state (pending when left out), oldest first,
136
+ * every page. Root admin only. */
137
+ export async function listPublishRequests(
138
+ client: DropletsClient,
139
+ options: { state?: string; droplet?: string } = {},
140
+ ): Promise<PublishRequestRow[]> {
141
+ const filter = {
142
+ state: options.state ?? "pending",
143
+ ...(options.droplet ? { droplet_uuid: options.droplet } : {}),
144
+ };
145
+ const rows: PublishRequestRow[] = [];
146
+ let cursor: string | null | undefined;
147
+ for (let page = 0; page < MAX_PAGES; page += 1) {
148
+ const response = await client.get<PublishRequestsPage>(
149
+ PUBLISH_REQUESTS_PATH,
150
+ { filter, ...(cursor ? { page: { cursor } } : {}) },
151
+ );
152
+ rows.push(...(response.publish_requests ?? []));
153
+ cursor = response.meta?.pagination?.next_cursor;
154
+ if (!cursor) return rows;
155
+ }
156
+ throw new DropletsCliError(
157
+ `Stopped after ${MAX_PAGES} pages of publish requests.`,
158
+ );
159
+ }
160
+
161
+ /** Approve a request, which publishes the droplet, or reject it with a
162
+ * reason its publisher is told. Root admin only. */
163
+ export async function decidePublishRequest(
164
+ client: DropletsClient,
165
+ requestId: string,
166
+ decision: "approve" | "reject",
167
+ reason?: string,
168
+ ): Promise<PublishRequestRow> {
169
+ if (decision === "reject" && !reason?.trim())
170
+ throw new DropletsCliError("Say why with --reason.");
171
+ const response = await client.post<{ publish_request: PublishRequestRow }>(
172
+ reviewPath(requestId, decision),
173
+ decision === "reject" ? { reason } : undefined,
174
+ );
175
+ return response.publish_request;
176
+ }