@fluid-app/fluid-cli-droplets 0.1.123 → 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 +144 -13
- package/package.json +1 -1
- package/src/__tests__/publish-api.test.ts +222 -0
- package/src/commands/publish.ts +135 -0
- package/src/index.ts +4 -1
- package/src/publish/api.ts +176 -0
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
|
@@ -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
|
+
}
|