@fluid-app/fluid-cli-droplets 0.1.103 → 0.1.105
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 +201 -1
- package/jest.config.cjs +13 -0
- package/jest.mocks/fluid-cli.ts +10 -0
- package/package.json +18 -3
- package/src/__tests__/install-api.test.ts +283 -0
- package/src/__tests__/install-commands.test.ts +59 -0
- package/src/commands/install.ts +167 -0
- package/src/index.ts +8 -1
- package/src/install/api.ts +209 -0
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",
|
|
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
|
package/jest.config.cjs
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.1.105",
|
|
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.
|
|
20
|
+
"@fluid-app/fluid-cli": "0.1.104",
|
|
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
|
-
|
|
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
|
+
}
|