@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 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$1(error) {
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$1(error) === 409) return {
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$1(error) === 403) throw new DropletsCliError(`Finding the installation of ${uuid} needs the droplets.view permission. Pass the installation's uuid (dri_…) instead.`);
80
+ if (apiStatus$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$1(error) === 403) return void 0;
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$1(error) === 404) throw notInstalled(installationUuid);
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 the
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fluid-app/fluid-cli-droplets",
3
- "version": "0.1.125",
3
+ "version": "0.1.127",
4
4
  "description": "Fluid CLI commands for droplet management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -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
+ });
@@ -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 the
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 `installed`, over the v2026-10
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);