@fluid-app/fluid-cli-droplets 0.1.125 → 0.1.127
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 +38 -7
- package/package.json +1 -1
- package/src/__tests__/health-api.test.ts +82 -0
- package/src/commands/install.ts +25 -2
- package/src/health/api.ts +80 -0
- package/src/index.ts +1 -1
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$
|
|
20
|
+
function apiStatus$2(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$
|
|
37
|
+
if (apiStatus$2(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$
|
|
80
|
+
if (apiStatus$2(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$
|
|
93
|
+
if (apiStatus$2(error) === 403) return void 0;
|
|
94
94
|
throw error;
|
|
95
95
|
}
|
|
96
96
|
}
|
|
@@ -114,13 +114,40 @@ async function uninstallDroplet(client, installationUuid) {
|
|
|
114
114
|
installation: (await client.delete(installationPath(installationUuid))).droplet_installation
|
|
115
115
|
};
|
|
116
116
|
} catch (error) {
|
|
117
|
-
if (apiStatus$
|
|
117
|
+
if (apiStatus$2(error) === 404) throw notInstalled(installationUuid);
|
|
118
118
|
throw error;
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
function notInstalled(uuid) {
|
|
122
122
|
return new DropletsCliError(`${uuid} is not installed for this company. List what is installed with \`fluid droplets installed\`.`);
|
|
123
123
|
}
|
|
124
|
+
function healthPath(dropletUuid) {
|
|
125
|
+
return `/api/company/v2026-10/droplets/${encodeURIComponent(dropletUuid)}/health`;
|
|
126
|
+
}
|
|
127
|
+
function apiStatus$1(error) {
|
|
128
|
+
const status = error.status;
|
|
129
|
+
return typeof status === "number" ? status : void 0;
|
|
130
|
+
}
|
|
131
|
+
/** Read the window from `--window`, refusing one the API would. */
|
|
132
|
+
function parseWindowHours(value) {
|
|
133
|
+
if (value === void 0) return void 0;
|
|
134
|
+
const hours = Number(value);
|
|
135
|
+
if (!Number.isInteger(hours) || hours < 1 || hours > 168) throw new DropletsCliError(`--window must be a whole number of hours from 1 to 168.`);
|
|
136
|
+
return hours;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* How the droplet's callbacks have behaved for this company over the window
|
|
140
|
+
* (the API's default, 24 hours, when left out).
|
|
141
|
+
*/
|
|
142
|
+
async function getDropletHealth(client, dropletUuid, windowHours) {
|
|
143
|
+
try {
|
|
144
|
+
return (await client.get(healthPath(dropletUuid), windowHours === void 0 ? void 0 : { window_hours: windowHours })).droplet_health;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (apiStatus$1(error) === 404) throw new DropletsCliError(`${dropletUuid} is not a droplet this company publishes or has installed. List what is installed with \`fluid droplets installed\`.`);
|
|
147
|
+
if (apiStatus$1(error) === 403) throw new DropletsCliError("Reading droplet health needs the developer.view permission.");
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
124
151
|
//#endregion
|
|
125
152
|
//#region src/commands/install.ts
|
|
126
153
|
async function askOnTerminal(question) {
|
|
@@ -166,8 +193,8 @@ async function dropletsClient$1(ctx) {
|
|
|
166
193
|
return await ctx.getClient();
|
|
167
194
|
}
|
|
168
195
|
/**
|
|
169
|
-
* `fluid droplets install|uninstall|installed`: friendly commands over
|
|
170
|
-
* v2026-10 company API, next to the generated `installations` commands.
|
|
196
|
+
* `fluid droplets install|uninstall|installed|health`: friendly commands over
|
|
197
|
+
* the v2026-10 company API, next to the generated `installations` commands.
|
|
171
198
|
*/
|
|
172
199
|
function registerInstallCommands(parent, ctx) {
|
|
173
200
|
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 () => {
|
|
@@ -191,6 +218,10 @@ function registerInstallCommands(parent, ctx) {
|
|
|
191
218
|
await confirmChange(`Uninstall ${installationUuid} from this company?`, opts.yes);
|
|
192
219
|
ctx.output(await uninstallDroplet(client, installationUuid));
|
|
193
220
|
}));
|
|
221
|
+
parent.command("health <droplet_uuid>").description("How a droplet's callbacks have behaved for this company's installation: calls, failures, timeouts and latency per callback, and whether a checkout waits on it (read-only)").option("--window <hours>", "Trailing window in hours, 1 to 168 (default 24)").action((dropletUuid, opts) => run$1(async () => {
|
|
222
|
+
const windowHours = parseWindowHours(opts.window);
|
|
223
|
+
ctx.output(await getDropletHealth(await dropletsClient$1(ctx), dropletUuid, windowHours));
|
|
224
|
+
}));
|
|
194
225
|
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
226
|
if (opts.category && !opts.full) throw new DropletsCliError("--category needs --full.");
|
|
196
227
|
ctx.output(await listInstallations(await dropletsClient$1(ctx), opts));
|
package/package.json
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { DropletsCliError, type DropletsClient } from "../install/api";
|
|
2
|
+
import { getDropletHealth, healthPath, parseWindowHours } from "../health/api";
|
|
3
|
+
|
|
4
|
+
type Call = { endpoint: string; params?: unknown };
|
|
5
|
+
|
|
6
|
+
function fakeClient(answer: unknown) {
|
|
7
|
+
const calls: Call[] = [];
|
|
8
|
+
const client: DropletsClient = {
|
|
9
|
+
get: async <T>(endpoint: string, params?: Record<string, unknown>) => {
|
|
10
|
+
calls.push({ endpoint, params });
|
|
11
|
+
if (answer instanceof Error) throw answer;
|
|
12
|
+
return answer as T;
|
|
13
|
+
},
|
|
14
|
+
post: async () => {
|
|
15
|
+
throw new Error("not used");
|
|
16
|
+
},
|
|
17
|
+
delete: async () => {
|
|
18
|
+
throw new Error("not used");
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
return { client, calls };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const apiError = (status: number) =>
|
|
25
|
+
Object.assign(new Error(`HTTP ${status}`), { status });
|
|
26
|
+
|
|
27
|
+
const report = {
|
|
28
|
+
window_hours: 24,
|
|
29
|
+
generated_at: "2026-10-22T10:00:00Z",
|
|
30
|
+
metrics_available: false,
|
|
31
|
+
metrics_error: { type: "unavailable", description: "Try again later." },
|
|
32
|
+
droplet: { uuid: "drp_1", name: "Tax Sync", status: "published" },
|
|
33
|
+
installation: null,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe("getDropletHealth", () => {
|
|
37
|
+
it("returns the report, leaving the window to the API by default", async () => {
|
|
38
|
+
const { client, calls } = fakeClient({ droplet_health: report });
|
|
39
|
+
|
|
40
|
+
await expect(getDropletHealth(client, "drp_1")).resolves.toEqual(report);
|
|
41
|
+
expect(calls).toEqual([
|
|
42
|
+
{ endpoint: healthPath("drp_1"), params: undefined },
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("asks for the window given", async () => {
|
|
47
|
+
const { client, calls } = fakeClient({ droplet_health: report });
|
|
48
|
+
|
|
49
|
+
await getDropletHealth(client, "drp_1", 168);
|
|
50
|
+
|
|
51
|
+
expect(calls[0]?.params).toEqual({ window_hours: 168 });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("explains a 404 and a 403", async () => {
|
|
55
|
+
await expect(
|
|
56
|
+
getDropletHealth(fakeClient(apiError(404)).client, "drp_1"),
|
|
57
|
+
).rejects.toThrow(/not a droplet this company publishes or has installed/);
|
|
58
|
+
await expect(
|
|
59
|
+
getDropletHealth(fakeClient(apiError(403)).client, "drp_1"),
|
|
60
|
+
).rejects.toThrow(/developer.view/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("passes other failures on", async () => {
|
|
64
|
+
await expect(
|
|
65
|
+
getDropletHealth(fakeClient(apiError(500)).client, "drp_1"),
|
|
66
|
+
).rejects.toMatchObject({ status: 500 });
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("parseWindowHours", () => {
|
|
71
|
+
it("accepts whole hours from 1 to 168 and nothing", () => {
|
|
72
|
+
expect(parseWindowHours(undefined)).toBeUndefined();
|
|
73
|
+
expect(parseWindowHours("1")).toBe(1);
|
|
74
|
+
expect(parseWindowHours("168")).toBe(168);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("refuses anything the API would", () => {
|
|
78
|
+
for (const value of ["0", "169", "1.5", "day"]) {
|
|
79
|
+
expect(() => parseWindowHours(value)).toThrow(DropletsCliError);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/commands/install.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
uninstallDroplet,
|
|
13
13
|
type DropletsClient,
|
|
14
14
|
} from "../install/api.js";
|
|
15
|
+
import { getDropletHealth, parseWindowHours } from "../health/api.js";
|
|
15
16
|
|
|
16
17
|
/** Asks a yes/no question; true means yes. */
|
|
17
18
|
export type Ask = (question: string) => Promise<boolean>;
|
|
@@ -87,8 +88,8 @@ async function dropletsClient(ctx: CommandContext): Promise<DropletsClient> {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
/**
|
|
90
|
-
* `fluid droplets install|uninstall|installed`: friendly commands over
|
|
91
|
-
* v2026-10 company API, next to the generated `installations` commands.
|
|
91
|
+
* `fluid droplets install|uninstall|installed|health`: friendly commands over
|
|
92
|
+
* the v2026-10 company API, next to the generated `installations` commands.
|
|
92
93
|
*/
|
|
93
94
|
export function registerInstallCommands(
|
|
94
95
|
parent: Command,
|
|
@@ -146,6 +147,28 @@ export function registerInstallCommands(
|
|
|
146
147
|
}),
|
|
147
148
|
);
|
|
148
149
|
|
|
150
|
+
parent
|
|
151
|
+
.command("health <droplet_uuid>")
|
|
152
|
+
.description(
|
|
153
|
+
"How a droplet's callbacks have behaved for this company's installation: calls, failures, timeouts and latency per callback, and whether a checkout waits on it (read-only)",
|
|
154
|
+
)
|
|
155
|
+
.option(
|
|
156
|
+
"--window <hours>",
|
|
157
|
+
"Trailing window in hours, 1 to 168 (default 24)",
|
|
158
|
+
)
|
|
159
|
+
.action((dropletUuid: string, opts: { window?: string }) =>
|
|
160
|
+
run(async () => {
|
|
161
|
+
const windowHours = parseWindowHours(opts.window);
|
|
162
|
+
ctx.output(
|
|
163
|
+
await getDropletHealth(
|
|
164
|
+
await dropletsClient(ctx),
|
|
165
|
+
dropletUuid,
|
|
166
|
+
windowHours,
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
|
|
149
172
|
parent
|
|
150
173
|
.command("installed")
|
|
151
174
|
.description(
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A droplet's callback health for the signed-in company's own installation,
|
|
3
|
+
* through the v2026-10 company droplets API
|
|
4
|
+
* (apps/rails/docs/openapi/v2026-10/company-droplets.yaml,
|
|
5
|
+
* `companyDropletHealth`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { DropletsCliError, type DropletsClient } from "../install/api.js";
|
|
9
|
+
|
|
10
|
+
/** The trailing window the API accepts, in hours. */
|
|
11
|
+
export const MIN_WINDOW_HOURS = 1;
|
|
12
|
+
export const MAX_WINDOW_HOURS = 168;
|
|
13
|
+
|
|
14
|
+
/** The health report as the API returns it. `metrics` is null wherever the
|
|
15
|
+
* figures could not be read (`metrics_available` false); a zero count is a
|
|
16
|
+
* real reading. `installation` is null when the company publishes the
|
|
17
|
+
* droplet but has not installed it. */
|
|
18
|
+
export interface DropletHealth {
|
|
19
|
+
window_hours: number;
|
|
20
|
+
generated_at: string;
|
|
21
|
+
metrics_available: boolean;
|
|
22
|
+
metrics_error: { type: string; description: string } | null;
|
|
23
|
+
droplet: { uuid: string; name: string | null; status: string };
|
|
24
|
+
installation: Record<string, unknown> | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function healthPath(dropletUuid: string): string {
|
|
28
|
+
return `/api/company/v2026-10/droplets/${encodeURIComponent(dropletUuid)}/health`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function apiStatus(error: unknown): number | undefined {
|
|
32
|
+
const status = (error as { status?: unknown }).status;
|
|
33
|
+
return typeof status === "number" ? status : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Read the window from `--window`, refusing one the API would. */
|
|
37
|
+
export function parseWindowHours(
|
|
38
|
+
value: string | undefined,
|
|
39
|
+
): number | undefined {
|
|
40
|
+
if (value === undefined) return undefined;
|
|
41
|
+
const hours = Number(value);
|
|
42
|
+
if (
|
|
43
|
+
!Number.isInteger(hours) ||
|
|
44
|
+
hours < MIN_WINDOW_HOURS ||
|
|
45
|
+
hours > MAX_WINDOW_HOURS
|
|
46
|
+
) {
|
|
47
|
+
throw new DropletsCliError(
|
|
48
|
+
`--window must be a whole number of hours from ${MIN_WINDOW_HOURS} to ${MAX_WINDOW_HOURS}.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return hours;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* How the droplet's callbacks have behaved for this company over the window
|
|
56
|
+
* (the API's default, 24 hours, when left out).
|
|
57
|
+
*/
|
|
58
|
+
export async function getDropletHealth(
|
|
59
|
+
client: DropletsClient,
|
|
60
|
+
dropletUuid: string,
|
|
61
|
+
windowHours?: number,
|
|
62
|
+
): Promise<DropletHealth> {
|
|
63
|
+
try {
|
|
64
|
+
const response = await client.get<{ droplet_health: DropletHealth }>(
|
|
65
|
+
healthPath(dropletUuid),
|
|
66
|
+
windowHours === undefined ? undefined : { window_hours: windowHours },
|
|
67
|
+
);
|
|
68
|
+
return response.droplet_health;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (apiStatus(error) === 404)
|
|
71
|
+
throw new DropletsCliError(
|
|
72
|
+
`${dropletUuid} is not a droplet this company publishes or has installed. List what is installed with \`fluid droplets installed\`.`,
|
|
73
|
+
);
|
|
74
|
+
if (apiStatus(error) === 403)
|
|
75
|
+
throw new DropletsCliError(
|
|
76
|
+
"Reading droplet health needs the developer.view permission.",
|
|
77
|
+
);
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@ const plugin: FluidPlugin = {
|
|
|
16
16
|
(parent, cmdCtx) => {
|
|
17
17
|
registerAllCommands(parent, cmdCtx);
|
|
18
18
|
// Hand-written commands on top of the generated OpenAPI surface:
|
|
19
|
-
// `install`, `uninstall` and `
|
|
19
|
+
// `install`, `uninstall`, `installed` and `health`, over the v2026-10
|
|
20
20
|
// company API, and asking Fluid to publish a droplet (with the
|
|
21
21
|
// root admin's review of those requests).
|
|
22
22
|
registerInstallCommands(parent, cmdCtx);
|