@fluid-app/fluid-cli-droplets 0.1.103 → 0.1.104

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
@@ -1,5 +1,202 @@
1
1
  import { createDomainCommand } from "@fluid-app/fluid-cli";
2
+ import { createInterface } from "node:readline/promises";
2
3
  import "commander";
4
+ //#region src/install/api.ts
5
+ /** A failure the CLI reports as-is, with a non-zero exit. */
6
+ var DropletsCliError = class extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "DropletsCliError";
10
+ }
11
+ };
12
+ const INSTALLATIONS_PATH = "/api/company/v2026-10/droplet-installations";
13
+ /** Installations of a droplet are created under the droplet. */
14
+ function dropletInstallationsPath(dropletUuid) {
15
+ return `/api/company/v2026-10/droplets/${encodeURIComponent(dropletUuid)}/installations`;
16
+ }
17
+ function installationPath(installationUuid) {
18
+ return `${INSTALLATIONS_PATH}/${encodeURIComponent(installationUuid)}`;
19
+ }
20
+ function apiStatus(error) {
21
+ const status = error.status;
22
+ return typeof status === "number" ? status : void 0;
23
+ }
24
+ /**
25
+ * Install a droplet. An installation that already exists answers 409, which
26
+ * is reported rather than raised: the company has what it asked for.
27
+ */
28
+ async function installDroplet(client, dropletUuid, roleUuid) {
29
+ const installation = roleUuid ? { role_uuid: roleUuid } : {};
30
+ try {
31
+ return {
32
+ installed: true,
33
+ alreadyInstalled: false,
34
+ installation: (await client.post(dropletInstallationsPath(dropletUuid), { installation })).droplet_installation
35
+ };
36
+ } catch (error) {
37
+ if (apiStatus(error) === 409) return {
38
+ installed: true,
39
+ alreadyInstalled: true,
40
+ dropletUuid
41
+ };
42
+ throw error;
43
+ }
44
+ }
45
+ /**
46
+ * The company's installations. Without `full`, the navigation list (uuid,
47
+ * droplet uuid, title and logo of every active installation). With `full`,
48
+ * every installation not yet uninstalled, as full rows, newest first,
49
+ * following `meta.pagination.next_cursor` to the last page.
50
+ */
51
+ async function listInstallations(client, options = {}) {
52
+ if (!options.full) return (await client.get("/api/company/v2026-10/droplet-installations")).droplet_installations ?? [];
53
+ const rows = [];
54
+ let cursor;
55
+ for (let page = 0; page < 100; page += 1) {
56
+ const response = await client.get(INSTALLATIONS_PATH, {
57
+ view: "full",
58
+ ...options.category ? { filter: { category: options.category } } : {},
59
+ page: {
60
+ limit: 100,
61
+ ...cursor ? { cursor } : {}
62
+ }
63
+ });
64
+ rows.push(...response.droplet_installations ?? []);
65
+ cursor = response.meta?.pagination?.next_cursor;
66
+ if (!cursor) return rows;
67
+ }
68
+ throw new DropletsCliError(`Stopped after 100 pages of installations.`);
69
+ }
70
+ /**
71
+ * The installation an uninstall names: an installation uuid as given, or the
72
+ * company's installation of a droplet uuid, preferring an active one over
73
+ * one already being removed. Null when the droplet is not installed.
74
+ */
75
+ async function resolveInstallation(client, uuid) {
76
+ if (uuid.startsWith("dri_")) return uuid;
77
+ try {
78
+ return (await findInstallation(client, uuid))?.uuid ?? null;
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.`);
81
+ throw error;
82
+ }
83
+ }
84
+ /**
85
+ * The installation an install would duplicate, read before asking. Undefined
86
+ * when the list cannot be read (403, no droplets.view): the install then goes
87
+ * straight to the API, which answers 409 for one that exists.
88
+ */
89
+ async function preflightInstallation(client, dropletUuid) {
90
+ try {
91
+ return await findInstallation(client, dropletUuid);
92
+ } catch (error) {
93
+ if (apiStatus(error) === 403) return void 0;
94
+ throw error;
95
+ }
96
+ }
97
+ /**
98
+ * The company's installation of a droplet, preferring an active one over one
99
+ * already being removed, or null when it has none.
100
+ */
101
+ async function findInstallation(client, dropletUuid) {
102
+ const rows = (await listInstallations(client, { full: true })).filter((row) => (row.droplet_uuid ?? row.droplet?.uuid) === dropletUuid);
103
+ return rows.find((row) => row.status === "active") ?? rows[0] ?? null;
104
+ }
105
+ /**
106
+ * Uninstall: Fluid revokes the installation's credentials and sends the
107
+ * droplet its uninstall webhook. The installation reads `pending_uninstall`
108
+ * until the droplet's cleanup finishes.
109
+ */
110
+ async function uninstallDroplet(client, installationUuid) {
111
+ try {
112
+ return {
113
+ uninstalled: true,
114
+ installation: (await client.delete(installationPath(installationUuid))).droplet_installation
115
+ };
116
+ } catch (error) {
117
+ if (apiStatus(error) === 404) throw notInstalled(installationUuid);
118
+ throw error;
119
+ }
120
+ }
121
+ function notInstalled(uuid) {
122
+ return new DropletsCliError(`${uuid} is not installed for this company. List what is installed with \`fluid droplets installed\`.`);
123
+ }
124
+ //#endregion
125
+ //#region src/commands/install.ts
126
+ async function askOnTerminal(question) {
127
+ const readline = createInterface({
128
+ input: process.stdin,
129
+ output: process.stderr
130
+ });
131
+ try {
132
+ const answer = await readline.question(`${question} [y/N] `);
133
+ return /^y(es)?$/i.test(answer.trim());
134
+ } finally {
135
+ readline.close();
136
+ }
137
+ }
138
+ /**
139
+ * Changes to what a company has installed need an explicit yes, as `fluid
140
+ * connect install` does. Scripts and agents pass `--yes`; an interactive
141
+ * terminal is asked. With neither, nothing runs.
142
+ */
143
+ async function confirmChange(question, yes, interactive = Boolean(process.stdin.isTTY), ask = askOnTerminal) {
144
+ if (yes) return;
145
+ if (!interactive) throw new DropletsCliError(`${question} Pass --yes to confirm.`);
146
+ if (!await ask(question)) throw new DropletsCliError("Cancelled.");
147
+ }
148
+ /** Print a failure as JSON on stderr and exit non-zero, as `fluid connect`
149
+ * does. The API's `error.message` and `error.details` are passed on. */
150
+ function fail(err) {
151
+ const status = err.status;
152
+ const apiError = err.body?.error;
153
+ const message = typeof apiError === "string" ? apiError : typeof apiError?.message === "string" ? apiError.message : err instanceof Error ? err.message : String(err);
154
+ const details = typeof apiError === "object" ? apiError?.details : void 0;
155
+ process.stderr.write(JSON.stringify({
156
+ error: message,
157
+ ...typeof status === "number" ? { status } : {},
158
+ ...details && typeof details === "object" && Object.keys(details).length > 0 ? { details } : {}
159
+ }) + "\n");
160
+ process.exitCode = 1;
161
+ }
162
+ function run(action) {
163
+ return action().catch(fail);
164
+ }
165
+ async function dropletsClient(ctx) {
166
+ return await ctx.getClient();
167
+ }
168
+ /**
169
+ * `fluid droplets install|uninstall|installed`: friendly commands over the
170
+ * v2026-10 company API, next to the generated `installations` commands.
171
+ */
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);
175
+ const existing = await preflightInstallation(client, dropletUuid);
176
+ if (existing) {
177
+ ctx.output({
178
+ installed: true,
179
+ alreadyInstalled: true,
180
+ installation: existing
181
+ });
182
+ return;
183
+ }
184
+ await confirmChange(`Install droplet ${dropletUuid} for this company?`, opts.yes);
185
+ ctx.output(await installDroplet(client, dropletUuid, opts.role));
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);
189
+ const installationUuid = await resolveInstallation(client, uuid);
190
+ if (!installationUuid) throw notInstalled(uuid);
191
+ await confirmChange(`Uninstall ${installationUuid} from this company?`, opts.yes);
192
+ ctx.output(await uninstallDroplet(client, installationUuid));
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 () => {
195
+ if (opts.category && !opts.full) throw new DropletsCliError("--category needs --full.");
196
+ ctx.output(await listInstallations(await dropletsClient(ctx), opts));
197
+ }));
198
+ }
199
+ //#endregion
3
200
  //#region src/generated/categories.ts
4
201
  function registerCategories(parent, ctx) {
5
202
  parent.command("categories").description("categories").command("list").description("Returns a list of all droplet categories").action(async () => {
@@ -132,7 +329,10 @@ const plugin = {
132
329
  name: "@fluid-app/fluid-cli-droplets",
133
330
  version: "0.1.0",
134
331
  register(ctx) {
135
- ctx.program.addCommand(createDomainCommand("droplets", "Manage Fluid droplets and installations", registerAllCommands));
332
+ ctx.program.addCommand(createDomainCommand("droplets", "Manage Fluid droplets and installations", (parent, cmdCtx) => {
333
+ registerAllCommands(parent, cmdCtx);
334
+ registerInstallCommands(parent, cmdCtx);
335
+ }));
136
336
  }
137
337
  };
138
338
  //#endregion
@@ -0,0 +1,13 @@
1
+ module.exports = {
2
+ testEnvironment: "node",
3
+ testMatch: ["**/src/**/__tests__/**/*.test.ts"],
4
+ transform: {
5
+ "^.+\\.(t|j)sx?$": ["@swc/jest", { module: { type: "commonjs" } }],
6
+ },
7
+ moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
8
+ moduleNameMapper: {
9
+ // The real package only ships ESM, which Jest can't resolve in CJS mode.
10
+ "^@fluid-app/fluid-cli$": "<rootDir>/jest.mocks/fluid-cli.ts",
11
+ "^(\\.{1,2}/.*)\\.js$": "$1",
12
+ },
13
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Jest mock for @fluid-app/fluid-cli, which only ships ESM. The droplets
3
+ * plugin's tests exercise its hand-written modules, so the command factory
4
+ * is inert.
5
+ */
6
+ export function createDomainCommand(): never {
7
+ throw new Error(
8
+ "createDomainCommand is not available in unit tests (droplets)",
9
+ );
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fluid-app/fluid-cli-droplets",
3
- "version": "0.1.103",
3
+ "version": "0.1.104",
4
4
  "description": "Fluid CLI commands for droplet management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -17,13 +17,17 @@
17
17
  "access": "public"
18
18
  },
19
19
  "dependencies": {
20
- "@fluid-app/fluid-cli": "0.1.102",
20
+ "@fluid-app/fluid-cli": "0.1.103",
21
21
  "commander": "^12.0.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@fluid-app/api-client-scripts": "0.0.0",
25
25
  "@fluid-app/typescript-config": "0.0.0",
26
+ "@swc/core": "^1.15.18",
27
+ "@swc/jest": "^0.2.39",
28
+ "@types/jest": "^29.5.14",
26
29
  "@types/node": "24.10.12",
30
+ "jest": "^29.7.0",
27
31
  "tsdown": "^0.21.0",
28
32
  "tsx": "^4.21.0",
29
33
  "typescript": "^5"
@@ -46,6 +50,16 @@
46
50
  }
47
51
  ]
48
52
  },
53
+ "test": {
54
+ "dependsOn": [
55
+ {
56
+ "target": "build",
57
+ "projects": [
58
+ "@fluid-app/fluid-cli"
59
+ ]
60
+ }
61
+ ]
62
+ },
49
63
  "build": {
50
64
  "dependsOn": [
51
65
  "typecheck",
@@ -65,6 +79,7 @@
65
79
  "generate": "tsx scripts/generate.ts",
66
80
  "lint": "oxlint",
67
81
  "lint:fix": "oxlint --fix",
68
- "typecheck": "tsgo --noEmit"
82
+ "typecheck": "tsgo --noEmit",
83
+ "test": "jest"
69
84
  }
70
85
  }
@@ -0,0 +1,283 @@
1
+ import {
2
+ dropletInstallationsPath,
3
+ DropletsCliError,
4
+ findInstallation,
5
+ installDroplet,
6
+ INSTALLATIONS_PATH,
7
+ installationPath,
8
+ listInstallations,
9
+ preflightInstallation,
10
+ resolveInstallation,
11
+ uninstallDroplet,
12
+ type DropletsClient,
13
+ } from "../install/api";
14
+
15
+ type Call = {
16
+ method: string;
17
+ endpoint: string;
18
+ body?: unknown;
19
+ params?: unknown;
20
+ };
21
+
22
+ /** Answers each request from a queue per `METHOD endpoint`; an Error is thrown. */
23
+ function fakeClient(responses: Record<string, unknown[]> = {}) {
24
+ const calls: Call[] = [];
25
+ const respond = (method: string, endpoint: string) => {
26
+ const queue = responses[`${method} ${endpoint}`];
27
+ const value = queue && queue.length > 0 ? queue.shift() : {};
28
+ if (value instanceof Error) throw value;
29
+ return value;
30
+ };
31
+ const client: DropletsClient = {
32
+ get: async <T>(endpoint: string, params?: Record<string, unknown>) => {
33
+ calls.push({ method: "GET", endpoint, params });
34
+ return respond("GET", endpoint) as T;
35
+ },
36
+ post: async <T>(endpoint: string, body?: unknown) => {
37
+ calls.push({ method: "POST", endpoint, body });
38
+ return respond("POST", endpoint) as T;
39
+ },
40
+ delete: async <T>(endpoint: string) => {
41
+ calls.push({ method: "DELETE", endpoint });
42
+ return respond("DELETE", endpoint) as T;
43
+ },
44
+ };
45
+ return { client, calls };
46
+ }
47
+
48
+ function apiError(status: number, message = "failed"): Error {
49
+ return Object.assign(new Error(message), {
50
+ status,
51
+ body: { error: { message } },
52
+ });
53
+ }
54
+
55
+ const row = (uuid: string, dropletUuid: string, status = "active") => ({
56
+ uuid,
57
+ droplet_uuid: dropletUuid,
58
+ status,
59
+ });
60
+
61
+ describe("installDroplet", () => {
62
+ it("posts the role by uuid and returns the installation", async () => {
63
+ const path = dropletInstallationsPath("drp_order_sync");
64
+ const { client, calls } = fakeClient({
65
+ [`POST ${path}`]: [
66
+ { droplet_installation: row("dri_1", "drp_order_sync") },
67
+ ],
68
+ });
69
+
70
+ const outcome = await installDroplet(
71
+ client,
72
+ "drp_order_sync",
73
+ "0190c2d3-role",
74
+ );
75
+
76
+ expect(calls).toEqual([
77
+ {
78
+ method: "POST",
79
+ endpoint: path,
80
+ body: { installation: { role_uuid: "0190c2d3-role" } },
81
+ },
82
+ ]);
83
+ expect(outcome).toEqual({
84
+ installed: true,
85
+ alreadyInstalled: false,
86
+ installation: row("dri_1", "drp_order_sync"),
87
+ });
88
+ });
89
+
90
+ it("sends an empty installation without a role", async () => {
91
+ const { client, calls } = fakeClient();
92
+
93
+ await installDroplet(client, "drp_order_sync");
94
+
95
+ expect(calls[0]?.body).toEqual({ installation: {} });
96
+ });
97
+
98
+ it("reports a 409 as already installed", async () => {
99
+ const path = dropletInstallationsPath("drp_order_sync");
100
+ const { client } = fakeClient({
101
+ [`POST ${path}`]: [apiError(409, "Droplet already installed")],
102
+ });
103
+
104
+ await expect(installDroplet(client, "drp_order_sync")).resolves.toEqual({
105
+ installed: true,
106
+ alreadyInstalled: true,
107
+ dropletUuid: "drp_order_sync",
108
+ });
109
+ });
110
+
111
+ it("raises any other failure", async () => {
112
+ const path = dropletInstallationsPath("drp_secret");
113
+ const { client } = fakeClient({
114
+ [`POST ${path}`]: [apiError(404, "Droplet not found")],
115
+ });
116
+
117
+ await expect(installDroplet(client, "drp_secret")).rejects.toMatchObject({
118
+ status: 404,
119
+ });
120
+ });
121
+ });
122
+
123
+ describe("listInstallations", () => {
124
+ it("reads the navigation list by default", async () => {
125
+ const { client, calls } = fakeClient({
126
+ [`GET ${INSTALLATIONS_PATH}`]: [
127
+ {
128
+ droplet_installations: [
129
+ { uuid: "dri_1", droplet: { uuid: "drp_a" } },
130
+ ],
131
+ },
132
+ ],
133
+ });
134
+
135
+ const rows = await listInstallations(client);
136
+
137
+ expect(calls).toEqual([
138
+ { method: "GET", endpoint: INSTALLATIONS_PATH, params: undefined },
139
+ ]);
140
+ expect(rows).toEqual([{ uuid: "dri_1", droplet: { uuid: "drp_a" } }]);
141
+ });
142
+
143
+ it("follows every page of the full view, in the category asked for", async () => {
144
+ const { client, calls } = fakeClient({
145
+ [`GET ${INSTALLATIONS_PATH}`]: [
146
+ {
147
+ droplet_installations: [row("dri_2", "drp_b")],
148
+ meta: { pagination: { next_cursor: "c2" } },
149
+ },
150
+ {
151
+ droplet_installations: [row("dri_1", "drp_a")],
152
+ meta: { pagination: { next_cursor: null } },
153
+ },
154
+ ],
155
+ });
156
+
157
+ const rows = await listInstallations(client, {
158
+ full: true,
159
+ category: "payments",
160
+ });
161
+
162
+ expect(rows.map((r) => r.uuid)).toEqual(["dri_2", "dri_1"]);
163
+ expect(calls.map((c) => c.params)).toEqual([
164
+ { view: "full", filter: { category: "payments" }, page: { limit: 100 } },
165
+ {
166
+ view: "full",
167
+ filter: { category: "payments" },
168
+ page: { limit: 100, cursor: "c2" },
169
+ },
170
+ ]);
171
+ });
172
+ });
173
+
174
+ describe("resolveInstallation", () => {
175
+ it("takes an installation uuid as given", async () => {
176
+ const { client, calls } = fakeClient();
177
+
178
+ await expect(resolveInstallation(client, "dri_1")).resolves.toBe("dri_1");
179
+ expect(calls).toEqual([]);
180
+ });
181
+
182
+ it("finds a droplet's installation, preferring the active one", async () => {
183
+ const { client } = fakeClient({
184
+ [`GET ${INSTALLATIONS_PATH}`]: [
185
+ {
186
+ droplet_installations: [
187
+ row("dri_old", "drp_a", "pending_uninstall"),
188
+ row("dri_new", "drp_a"),
189
+ row("dri_x", "drp_b"),
190
+ ],
191
+ },
192
+ ],
193
+ });
194
+
195
+ await expect(resolveInstallation(client, "drp_a")).resolves.toBe("dri_new");
196
+ });
197
+
198
+ it("is null for a droplet that is not installed", async () => {
199
+ const { client } = fakeClient({
200
+ [`GET ${INSTALLATIONS_PATH}`]: [
201
+ { droplet_installations: [row("dri_x", "drp_b")] },
202
+ ],
203
+ });
204
+
205
+ await expect(findInstallation(client, "drp_a")).resolves.toBeNull();
206
+ });
207
+ });
208
+
209
+ describe("uninstallDroplet", () => {
210
+ it("deletes the installation", async () => {
211
+ const path = installationPath("dri_1");
212
+ const { client, calls } = fakeClient({
213
+ [`DELETE ${path}`]: [
214
+ { droplet_installation: row("dri_1", "drp_a", "pending_uninstall") },
215
+ ],
216
+ });
217
+
218
+ const outcome = await uninstallDroplet(client, "dri_1");
219
+
220
+ expect(calls).toEqual([{ method: "DELETE", endpoint: path }]);
221
+ expect(outcome).toEqual({
222
+ uninstalled: true,
223
+ installation: row("dri_1", "drp_a", "pending_uninstall"),
224
+ });
225
+ });
226
+
227
+ it("says plainly when the installation is not the company's", async () => {
228
+ const path = installationPath("dri_gone");
229
+ const { client } = fakeClient({
230
+ [`DELETE ${path}`]: [apiError(404, "Droplet installation not found")],
231
+ });
232
+
233
+ await expect(uninstallDroplet(client, "dri_gone")).rejects.toBeInstanceOf(
234
+ DropletsCliError,
235
+ );
236
+ });
237
+ });
238
+
239
+ describe("without droplets.view", () => {
240
+ const refused = () => ({
241
+ [`GET ${INSTALLATIONS_PATH}`]: [apiError(403, "Not authorized.")],
242
+ });
243
+
244
+ it("lets install go straight to the API, which answers 409 for an existing one", async () => {
245
+ const path = dropletInstallationsPath("drp_order_sync");
246
+ const { client } = fakeClient({
247
+ ...refused(),
248
+ [`POST ${path}`]: [apiError(409, "Droplet already installed")],
249
+ });
250
+
251
+ await expect(
252
+ preflightInstallation(client, "drp_order_sync"),
253
+ ).resolves.toBeUndefined();
254
+ await expect(
255
+ installDroplet(client, "drp_order_sync"),
256
+ ).resolves.toMatchObject({ alreadyInstalled: true });
257
+ });
258
+
259
+ it("asks for the installation uuid when a droplet uuid cannot be resolved", async () => {
260
+ const { client } = fakeClient(refused());
261
+
262
+ await expect(resolveInstallation(client, "drp_order_sync")).rejects.toThrow(
263
+ /Pass the installation's uuid \(dri_…\) instead/,
264
+ );
265
+ });
266
+
267
+ it("still resolves an installation uuid without reading the list", async () => {
268
+ const { client, calls } = fakeClient(refused());
269
+
270
+ await expect(resolveInstallation(client, "dri_1")).resolves.toBe("dri_1");
271
+ expect(calls).toEqual([]);
272
+ });
273
+
274
+ it("raises other list failures", async () => {
275
+ const { client } = fakeClient({
276
+ [`GET ${INSTALLATIONS_PATH}`]: [apiError(500)],
277
+ });
278
+
279
+ await expect(
280
+ preflightInstallation(client, "drp_order_sync"),
281
+ ).rejects.toMatchObject({ status: 500 });
282
+ });
283
+ });
@@ -0,0 +1,59 @@
1
+ import { confirmChange, fail } from "../commands/install";
2
+ import { DropletsCliError } from "../install/api";
3
+
4
+ describe("confirmChange", () => {
5
+ it("runs at once with --yes", async () => {
6
+ const ask = jest.fn();
7
+
8
+ await expect(
9
+ confirmChange("Install?", true, false, ask),
10
+ ).resolves.toBeUndefined();
11
+ expect(ask).not.toHaveBeenCalled();
12
+ });
13
+
14
+ it("refuses without --yes when nobody can be asked", async () => {
15
+ await expect(confirmChange("Install?", false, false)).rejects.toThrow(
16
+ "Install? Pass --yes to confirm.",
17
+ );
18
+ });
19
+
20
+ it("asks an interactive terminal and stops on no", async () => {
21
+ await expect(
22
+ confirmChange("Install?", false, true, async () => true),
23
+ ).resolves.toBeUndefined();
24
+ await expect(
25
+ confirmChange("Install?", false, true, async () => false),
26
+ ).rejects.toBeInstanceOf(DropletsCliError);
27
+ });
28
+ });
29
+
30
+ describe("fail", () => {
31
+ afterEach(() => {
32
+ process.exitCode = undefined;
33
+ jest.restoreAllMocks();
34
+ });
35
+
36
+ it("prints the API's message and details on stderr and exits non-zero", () => {
37
+ const write = jest
38
+ .spyOn(process.stderr, "write")
39
+ .mockImplementation(() => true);
40
+ const error = Object.assign(new Error("Request failed"), {
41
+ status: 422,
42
+ body: {
43
+ error: {
44
+ message: "Role not found",
45
+ details: { role_uuid: ["not found"] },
46
+ },
47
+ },
48
+ });
49
+
50
+ fail(error);
51
+
52
+ expect(JSON.parse(String(write.mock.calls[0]?.[0]))).toEqual({
53
+ error: "Role not found",
54
+ status: 422,
55
+ details: { role_uuid: ["not found"] },
56
+ });
57
+ expect(process.exitCode).toBe(1);
58
+ });
59
+ });
@@ -0,0 +1,167 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import type { Command } from "commander";
3
+ import type { CommandContext } from "@fluid-app/fluid-cli";
4
+
5
+ import {
6
+ DropletsCliError,
7
+ installDroplet,
8
+ listInstallations,
9
+ notInstalled,
10
+ preflightInstallation,
11
+ resolveInstallation,
12
+ uninstallDroplet,
13
+ type DropletsClient,
14
+ } from "../install/api.js";
15
+
16
+ /** Asks a yes/no question; true means yes. */
17
+ export type Ask = (question: string) => Promise<boolean>;
18
+
19
+ async function askOnTerminal(question: string): Promise<boolean> {
20
+ const readline = createInterface({
21
+ input: process.stdin,
22
+ output: process.stderr,
23
+ });
24
+ try {
25
+ const answer = await readline.question(`${question} [y/N] `);
26
+ return /^y(es)?$/i.test(answer.trim());
27
+ } finally {
28
+ readline.close();
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Changes to what a company has installed need an explicit yes, as `fluid
34
+ * connect install` does. Scripts and agents pass `--yes`; an interactive
35
+ * terminal is asked. With neither, nothing runs.
36
+ */
37
+ export async function confirmChange(
38
+ question: string,
39
+ yes: boolean,
40
+ interactive: boolean = Boolean(process.stdin.isTTY),
41
+ ask: Ask = askOnTerminal,
42
+ ): Promise<void> {
43
+ if (yes) return;
44
+ if (!interactive)
45
+ throw new DropletsCliError(`${question} Pass --yes to confirm.`);
46
+ if (!(await ask(question))) throw new DropletsCliError("Cancelled.");
47
+ }
48
+
49
+ /** Print a failure as JSON on stderr and exit non-zero, as `fluid connect`
50
+ * does. The API's `error.message` and `error.details` are passed on. */
51
+ export function fail(err: unknown): void {
52
+ const status = (err as { status?: unknown }).status;
53
+ const body = (err as { body?: { error?: unknown } | null }).body;
54
+ const apiError = body?.error as
55
+ | { message?: unknown; details?: unknown }
56
+ | string
57
+ | undefined;
58
+ const message =
59
+ typeof apiError === "string"
60
+ ? apiError
61
+ : typeof apiError?.message === "string"
62
+ ? apiError.message
63
+ : err instanceof Error
64
+ ? err.message
65
+ : String(err);
66
+ const details = typeof apiError === "object" ? apiError?.details : undefined;
67
+ process.stderr.write(
68
+ JSON.stringify({
69
+ error: message,
70
+ ...(typeof status === "number" ? { status } : {}),
71
+ ...(details &&
72
+ typeof details === "object" &&
73
+ Object.keys(details).length > 0
74
+ ? { details }
75
+ : {}),
76
+ }) + "\n",
77
+ );
78
+ process.exitCode = 1;
79
+ }
80
+
81
+ function run(action: () => Promise<void>): Promise<void> {
82
+ return action().catch(fail);
83
+ }
84
+
85
+ async function dropletsClient(ctx: CommandContext): Promise<DropletsClient> {
86
+ return (await ctx.getClient()) as unknown as DropletsClient;
87
+ }
88
+
89
+ /**
90
+ * `fluid droplets install|uninstall|installed`: friendly commands over the
91
+ * v2026-10 company API, next to the generated `installations` commands.
92
+ */
93
+ export function registerInstallCommands(
94
+ parent: Command,
95
+ ctx: CommandContext,
96
+ ): void {
97
+ parent
98
+ .command("install <droplet_uuid>")
99
+ .description(
100
+ "Install a droplet for this company; it receives the scopes it requests (changes live config)",
101
+ )
102
+ .option(
103
+ "--role <role_uuid>",
104
+ "The role the droplet acts with, by its uuid; a new role is made when left out",
105
+ )
106
+ .option("--yes", "Confirm without a prompt", false)
107
+ .action((dropletUuid: string, opts: { role?: string; yes: boolean }) =>
108
+ run(async () => {
109
+ const client = await dropletsClient(ctx);
110
+ // Undefined when the list is refused; the install then relies on 409.
111
+ const existing = await preflightInstallation(client, dropletUuid);
112
+ if (existing) {
113
+ ctx.output({
114
+ installed: true,
115
+ alreadyInstalled: true,
116
+ installation: existing,
117
+ });
118
+ return;
119
+ }
120
+ await confirmChange(
121
+ `Install droplet ${dropletUuid} for this company?`,
122
+ opts.yes,
123
+ );
124
+ // A 409 here means another request installed it meanwhile.
125
+ ctx.output(await installDroplet(client, dropletUuid, opts.role));
126
+ }),
127
+ );
128
+
129
+ parent
130
+ .command("uninstall <uuid>")
131
+ .description(
132
+ "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)",
133
+ )
134
+ .option("--yes", "Confirm without a prompt", false)
135
+ .action((uuid: string, opts: { yes: boolean }) =>
136
+ run(async () => {
137
+ const client = await dropletsClient(ctx);
138
+ const installationUuid = await resolveInstallation(client, uuid);
139
+ if (!installationUuid) throw notInstalled(uuid);
140
+
141
+ await confirmChange(
142
+ `Uninstall ${installationUuid} from this company?`,
143
+ opts.yes,
144
+ );
145
+ ctx.output(await uninstallDroplet(client, installationUuid));
146
+ }),
147
+ );
148
+
149
+ parent
150
+ .command("installed")
151
+ .description(
152
+ "List the droplets this company has installed; --full for full installation rows, every page",
153
+ )
154
+ .option(
155
+ "--full",
156
+ "Full rows (status, scopes, role), including installations being removed",
157
+ false,
158
+ )
159
+ .option("--category <slug>", "With --full, only droplets in this category")
160
+ .action((opts: { full: boolean; category?: string }) =>
161
+ run(async () => {
162
+ if (opts.category && !opts.full)
163
+ throw new DropletsCliError("--category needs --full.");
164
+ ctx.output(await listInstallations(await dropletsClient(ctx), opts));
165
+ }),
166
+ );
167
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { FluidPlugin } from "@fluid-app/fluid-cli";
2
2
  import { createDomainCommand } from "@fluid-app/fluid-cli";
3
3
 
4
+ import { registerInstallCommands } from "./commands/install.js";
4
5
  import { registerAllCommands } from "./generated/index.js";
5
6
 
6
7
  const plugin: FluidPlugin = {
@@ -11,7 +12,13 @@ const plugin: FluidPlugin = {
11
12
  createDomainCommand(
12
13
  "droplets",
13
14
  "Manage Fluid droplets and installations",
14
- registerAllCommands,
15
+ (parent, cmdCtx) => {
16
+ registerAllCommands(parent, cmdCtx);
17
+ // Hand-written commands on top of the generated OpenAPI surface:
18
+ // `install`, `uninstall` and `installed`, over the v2026-10
19
+ // company API.
20
+ registerInstallCommands(parent, cmdCtx);
21
+ },
15
22
  ),
16
23
  );
17
24
  },
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Installing, listing and uninstalling droplets for the signed-in company,
3
+ * through the v2026-10 company droplets API
4
+ * (apps/rails/docs/openapi/v2026-10/company-droplets.yaml).
5
+ */
6
+
7
+ /** The slice of the CLI's API client these calls use. */
8
+ export interface DropletsClient {
9
+ get<T>(endpoint: string, params?: Record<string, unknown>): Promise<T>;
10
+ post<T>(endpoint: string, body?: unknown): Promise<T>;
11
+ delete<T>(endpoint: string): Promise<T>;
12
+ }
13
+
14
+ /** A failure the CLI reports as-is, with a non-zero exit. */
15
+ export class DropletsCliError extends Error {
16
+ constructor(message: string) {
17
+ super(message);
18
+ this.name = "DropletsCliError";
19
+ }
20
+ }
21
+
22
+ export const INSTALLATIONS_PATH = "/api/company/v2026-10/droplet-installations";
23
+
24
+ /** Installations of a droplet are created under the droplet. */
25
+ export function dropletInstallationsPath(dropletUuid: string): string {
26
+ return `/api/company/v2026-10/droplets/${encodeURIComponent(dropletUuid)}/installations`;
27
+ }
28
+
29
+ export function installationPath(installationUuid: string): string {
30
+ return `${INSTALLATIONS_PATH}/${encodeURIComponent(installationUuid)}`;
31
+ }
32
+
33
+ /** An installation's uuid starts with this; anything else is read as a
34
+ * droplet's uuid (most are `drp_…`, some older ones are plain slugs). */
35
+ export const INSTALLATION_UUID_PREFIX = "dri_";
36
+
37
+ /** The most rows one page of `view=full` returns. */
38
+ export const FULL_PAGE_LIMIT = 100;
39
+
40
+ /** Stop paging after this many pages, so a cursor that never ends cannot
41
+ * loop forever. 100 pages of 100 is far more than a company installs. */
42
+ export const MAX_PAGES = 100;
43
+
44
+ /** A full installation row (`view=full`, and every write's response). */
45
+ export interface InstallationRow {
46
+ uuid: string;
47
+ droplet_uuid?: string;
48
+ droplet?: { uuid: string; title: string | null; logo_url: string | null };
49
+ status?: string;
50
+ role?: { uuid: string; name: string | null } | null;
51
+ scopes?: string[];
52
+ effective_scopes?: string[];
53
+ created_at?: string;
54
+ updated_at?: string;
55
+ }
56
+
57
+ interface InstallationsPage {
58
+ droplet_installations?: InstallationRow[];
59
+ meta?: { pagination?: { next_cursor?: string | null } };
60
+ }
61
+
62
+ function apiStatus(error: unknown): number | undefined {
63
+ const status = (error as { status?: unknown }).status;
64
+ return typeof status === "number" ? status : undefined;
65
+ }
66
+
67
+ export type InstallOutcome =
68
+ | { installed: true; alreadyInstalled: false; installation: InstallationRow }
69
+ | { installed: true; alreadyInstalled: true; dropletUuid: string };
70
+
71
+ /**
72
+ * Install a droplet. An installation that already exists answers 409, which
73
+ * is reported rather than raised: the company has what it asked for.
74
+ */
75
+ export async function installDroplet(
76
+ client: DropletsClient,
77
+ dropletUuid: string,
78
+ roleUuid?: string,
79
+ ): Promise<InstallOutcome> {
80
+ const installation = roleUuid ? { role_uuid: roleUuid } : {};
81
+ try {
82
+ const created = await client.post<{
83
+ droplet_installation: InstallationRow;
84
+ }>(dropletInstallationsPath(dropletUuid), { installation });
85
+ return {
86
+ installed: true,
87
+ alreadyInstalled: false,
88
+ installation: created.droplet_installation,
89
+ };
90
+ } catch (error) {
91
+ if (apiStatus(error) === 409)
92
+ return { installed: true, alreadyInstalled: true, dropletUuid };
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * The company's installations. Without `full`, the navigation list (uuid,
99
+ * droplet uuid, title and logo of every active installation). With `full`,
100
+ * every installation not yet uninstalled, as full rows, newest first,
101
+ * following `meta.pagination.next_cursor` to the last page.
102
+ */
103
+ export async function listInstallations(
104
+ client: DropletsClient,
105
+ options: { full?: boolean; category?: string } = {},
106
+ ): Promise<InstallationRow[]> {
107
+ if (!options.full) {
108
+ const list = await client.get<InstallationsPage>(INSTALLATIONS_PATH);
109
+ return list.droplet_installations ?? [];
110
+ }
111
+
112
+ const rows: InstallationRow[] = [];
113
+ let cursor: string | null | undefined;
114
+ for (let page = 0; page < MAX_PAGES; page += 1) {
115
+ const response = await client.get<InstallationsPage>(INSTALLATIONS_PATH, {
116
+ view: "full",
117
+ ...(options.category ? { filter: { category: options.category } } : {}),
118
+ page: { limit: FULL_PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
119
+ });
120
+ rows.push(...(response.droplet_installations ?? []));
121
+ cursor = response.meta?.pagination?.next_cursor;
122
+ if (!cursor) return rows;
123
+ }
124
+ throw new DropletsCliError(
125
+ `Stopped after ${MAX_PAGES} pages of installations.`,
126
+ );
127
+ }
128
+
129
+ /**
130
+ * The installation an uninstall names: an installation uuid as given, or the
131
+ * company's installation of a droplet uuid, preferring an active one over
132
+ * one already being removed. Null when the droplet is not installed.
133
+ */
134
+ export async function resolveInstallation(
135
+ client: DropletsClient,
136
+ uuid: string,
137
+ ): Promise<string | null> {
138
+ if (uuid.startsWith(INSTALLATION_UUID_PREFIX)) return uuid;
139
+
140
+ try {
141
+ return (await findInstallation(client, uuid))?.uuid ?? null;
142
+ } catch (error) {
143
+ // Reading the list needs droplets.view; deleting by installation uuid
144
+ // needs only droplets.update.
145
+ if (apiStatus(error) === 403) {
146
+ throw new DropletsCliError(
147
+ `Finding the installation of ${uuid} needs the droplets.view permission. Pass the installation's uuid (dri_…) instead.`,
148
+ );
149
+ }
150
+ throw error;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * The installation an install would duplicate, read before asking. Undefined
156
+ * when the list cannot be read (403, no droplets.view): the install then goes
157
+ * straight to the API, which answers 409 for one that exists.
158
+ */
159
+ export async function preflightInstallation(
160
+ client: DropletsClient,
161
+ dropletUuid: string,
162
+ ): Promise<InstallationRow | null | undefined> {
163
+ try {
164
+ return await findInstallation(client, dropletUuid);
165
+ } catch (error) {
166
+ if (apiStatus(error) === 403) return undefined;
167
+ throw error;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * The company's installation of a droplet, preferring an active one over one
173
+ * already being removed, or null when it has none.
174
+ */
175
+ export async function findInstallation(
176
+ client: DropletsClient,
177
+ dropletUuid: string,
178
+ ): Promise<InstallationRow | null> {
179
+ const rows = (await listInstallations(client, { full: true })).filter(
180
+ (row) => (row.droplet_uuid ?? row.droplet?.uuid) === dropletUuid,
181
+ );
182
+ return rows.find((row) => row.status === "active") ?? rows[0] ?? null;
183
+ }
184
+
185
+ /**
186
+ * Uninstall: Fluid revokes the installation's credentials and sends the
187
+ * droplet its uninstall webhook. The installation reads `pending_uninstall`
188
+ * until the droplet's cleanup finishes.
189
+ */
190
+ export async function uninstallDroplet(
191
+ client: DropletsClient,
192
+ installationUuid: string,
193
+ ): Promise<{ uninstalled: true; installation: InstallationRow }> {
194
+ try {
195
+ const removed = await client.delete<{
196
+ droplet_installation: InstallationRow;
197
+ }>(installationPath(installationUuid));
198
+ return { uninstalled: true, installation: removed.droplet_installation };
199
+ } catch (error) {
200
+ if (apiStatus(error) === 404) throw notInstalled(installationUuid);
201
+ throw error;
202
+ }
203
+ }
204
+
205
+ export function notInstalled(uuid: string): DropletsCliError {
206
+ return new DropletsCliError(
207
+ `${uuid} is not installed for this company. List what is installed with \`fluid droplets installed\`.`,
208
+ );
209
+ }