@fluid-app/fluid-cli-droplets 0.1.257 → 0.1.258
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 +29 -4
- package/package.json +2 -2
- package/src/__tests__/status-api.test.ts +127 -0
- package/src/commands/install.ts +24 -1
- package/src/health/api.ts +17 -9
- package/src/status/api.ts +57 -0
package/dist/index.mjs
CHANGED
|
@@ -621,6 +621,13 @@ function apiStatus$1(error) {
|
|
|
621
621
|
const status = error.status;
|
|
622
622
|
return typeof status === "number" ? status : void 0;
|
|
623
623
|
}
|
|
624
|
+
/** Turn a 404 or 403 from a droplet read (`what` is "health" or "status"; `notFound` replaces the 404 text)
|
|
625
|
+
* into a plain message; throws any other failure as it came. */
|
|
626
|
+
function rethrowDropletReadError(error, dropletUuid, what, notFound = `${dropletUuid} is not a droplet this company publishes or has installed. List what is installed with \`fluid droplets installed\`.`) {
|
|
627
|
+
if (apiStatus$1(error) === 404) throw new DropletsCliError(notFound);
|
|
628
|
+
if (apiStatus$1(error) === 403) throw new DropletsCliError(`Reading droplet ${what} needs the developer.view permission.`);
|
|
629
|
+
throw error;
|
|
630
|
+
}
|
|
624
631
|
/** Read the window from `--window`, refusing one the API would. */
|
|
625
632
|
function parseWindowHours(value) {
|
|
626
633
|
if (value === void 0) return void 0;
|
|
@@ -636,9 +643,23 @@ async function getDropletHealth(client, dropletUuid, windowHours) {
|
|
|
636
643
|
try {
|
|
637
644
|
return (await client.get(healthPath(dropletUuid), windowHours === void 0 ? void 0 : { window_hours: windowHours })).droplet_health;
|
|
638
645
|
} catch (error) {
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
646
|
+
return rethrowDropletReadError(error, dropletUuid, "health");
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
//#endregion
|
|
650
|
+
//#region src/status/api.ts
|
|
651
|
+
function statusPath(dropletUuid) {
|
|
652
|
+
return `/api/company/v2026-10/droplets/${encodeURIComponent(dropletUuid)}/status`;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* The droplet's status for this company over the window (the API's default,
|
|
656
|
+
* 24 hours, when left out).
|
|
657
|
+
*/
|
|
658
|
+
async function getDropletStatus(client, dropletUuid, windowHours) {
|
|
659
|
+
try {
|
|
660
|
+
return (await client.get(statusPath(dropletUuid), windowHours === void 0 ? void 0 : { window_hours: windowHours })).droplet_status;
|
|
661
|
+
} catch (error) {
|
|
662
|
+
return rethrowDropletReadError(error, dropletUuid, "status", `${dropletUuid} has no installation for this company, or is not a droplet it can see. List what is installed with \`fluid droplets installed\`.`);
|
|
642
663
|
}
|
|
643
664
|
}
|
|
644
665
|
//#endregion
|
|
@@ -686,7 +707,7 @@ async function dropletsClient$1(ctx) {
|
|
|
686
707
|
return await ctx.getClient();
|
|
687
708
|
}
|
|
688
709
|
/**
|
|
689
|
-
* `fluid droplets install|uninstall|installed|health`: friendly commands over
|
|
710
|
+
* `fluid droplets install|uninstall|installed|health|status`: friendly commands over
|
|
690
711
|
* the v2026-10 company API, next to the generated `installations` commands.
|
|
691
712
|
*/
|
|
692
713
|
function registerInstallCommands(parent, ctx) {
|
|
@@ -715,6 +736,10 @@ function registerInstallCommands(parent, ctx) {
|
|
|
715
736
|
const windowHours = parseWindowHours(opts.window);
|
|
716
737
|
ctx.output(await getDropletHealth(await dropletsClient$1(ctx), dropletUuid, windowHours));
|
|
717
738
|
}));
|
|
739
|
+
parent.command("status <droplet_uuid>").description("A droplet's operating status for this company's installation: deployment, install, lifecycle, health, metrics and webhook health, each section marked available or not (read-only)").option("--window <hours>", "Trailing window in hours, 1 to 168 (default 24)").action((dropletUuid, opts) => run$1(async () => {
|
|
740
|
+
const windowHours = parseWindowHours(opts.window);
|
|
741
|
+
ctx.output(await getDropletStatus(await dropletsClient$1(ctx), dropletUuid, windowHours));
|
|
742
|
+
}));
|
|
718
743
|
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 () => {
|
|
719
744
|
if (opts.category && !opts.full) throw new DropletsCliError("--category needs --full.");
|
|
720
745
|
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.
|
|
3
|
+
"version": "0.1.258",
|
|
4
4
|
"description": "Fluid CLI commands for droplet management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"access": "public"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@fluid-app/fluid-cli": "0.1.
|
|
20
|
+
"@fluid-app/fluid-cli": "0.1.257",
|
|
21
21
|
"ajv": "8.17.1",
|
|
22
22
|
"commander": "^12.0.0"
|
|
23
23
|
},
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import type { CommandContext } from "@fluid-app/fluid-cli";
|
|
3
|
+
|
|
4
|
+
import { registerInstallCommands } from "../commands/install";
|
|
5
|
+
import { type DropletsClient } from "../install/api";
|
|
6
|
+
import { getDropletStatus, statusPath } from "../status/api";
|
|
7
|
+
|
|
8
|
+
type Call = { endpoint: string; params?: unknown };
|
|
9
|
+
|
|
10
|
+
function fakeClient(answer: unknown) {
|
|
11
|
+
const calls: Call[] = [];
|
|
12
|
+
const client: DropletsClient = {
|
|
13
|
+
get: async <T>(endpoint: string, params?: Record<string, unknown>) => {
|
|
14
|
+
calls.push({ endpoint, params });
|
|
15
|
+
if (answer instanceof Error) throw answer;
|
|
16
|
+
return answer as T;
|
|
17
|
+
},
|
|
18
|
+
post: async () => {
|
|
19
|
+
throw new Error("not used");
|
|
20
|
+
},
|
|
21
|
+
delete: async () => {
|
|
22
|
+
throw new Error("not used");
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
return { client, calls };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const apiError = (status: number) =>
|
|
29
|
+
Object.assign(new Error(`HTTP ${status}`), { status });
|
|
30
|
+
|
|
31
|
+
const unavailable = {
|
|
32
|
+
available: false,
|
|
33
|
+
error: { type: "unavailable", description: "Try again later." },
|
|
34
|
+
data: null,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const status = {
|
|
38
|
+
generated_at: "2026-10-22T10:00:00Z",
|
|
39
|
+
window_hours: 24,
|
|
40
|
+
installation: null,
|
|
41
|
+
history: [],
|
|
42
|
+
sections: {
|
|
43
|
+
health: { available: true, error: null, data: { verdict: "brand_new" } },
|
|
44
|
+
metrics: unavailable,
|
|
45
|
+
reconcile: unavailable,
|
|
46
|
+
connect: unavailable,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe("getDropletStatus", () => {
|
|
51
|
+
it("returns the status as sent, unavailable sections and unknown verdicts included", async () => {
|
|
52
|
+
const { client, calls } = fakeClient({ droplet_status: status });
|
|
53
|
+
|
|
54
|
+
await expect(getDropletStatus(client, "drp_1")).resolves.toEqual(status);
|
|
55
|
+
expect(calls).toEqual([
|
|
56
|
+
{ endpoint: statusPath("drp_1"), params: undefined },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("asks for the window given", async () => {
|
|
61
|
+
const { client, calls } = fakeClient({ droplet_status: status });
|
|
62
|
+
|
|
63
|
+
await getDropletStatus(client, "drp_1", 168);
|
|
64
|
+
|
|
65
|
+
expect(calls[0]?.params).toEqual({ window_hours: 168 });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("explains a 404 and a 403", async () => {
|
|
69
|
+
await expect(
|
|
70
|
+
getDropletStatus(fakeClient(apiError(404)).client, "drp_1"),
|
|
71
|
+
).rejects.toThrow(/has no installation for this company/);
|
|
72
|
+
await expect(
|
|
73
|
+
getDropletStatus(fakeClient(apiError(403)).client, "drp_1"),
|
|
74
|
+
).rejects.toThrow(/developer.view/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("passes other failures on", async () => {
|
|
78
|
+
await expect(
|
|
79
|
+
getDropletStatus(fakeClient(apiError(500)).client, "drp_1"),
|
|
80
|
+
).rejects.toMatchObject({ status: 500 });
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("fluid droplets status", () => {
|
|
85
|
+
function setup(answer: unknown) {
|
|
86
|
+
const { client, calls } = fakeClient(answer);
|
|
87
|
+
const output = jest.fn();
|
|
88
|
+
const ctx = {
|
|
89
|
+
getClient: async () => client,
|
|
90
|
+
output,
|
|
91
|
+
} as unknown as CommandContext;
|
|
92
|
+
const program = new Command();
|
|
93
|
+
program.exitOverride();
|
|
94
|
+
registerInstallCommands(program, ctx);
|
|
95
|
+
return { program, calls, output };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
it("prints the droplet_status object for the window given", async () => {
|
|
99
|
+
const { program, calls, output } = setup({ droplet_status: status });
|
|
100
|
+
|
|
101
|
+
await program.parseAsync(["status", "drp_1", "--window", "6"], {
|
|
102
|
+
from: "user",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(calls).toEqual([
|
|
106
|
+
{ endpoint: statusPath("drp_1"), params: { window_hours: 6 } },
|
|
107
|
+
]);
|
|
108
|
+
expect(output).toHaveBeenCalledWith(status);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("refuses a window outside the API's bounds before calling it", async () => {
|
|
112
|
+
const { program, calls, output } = setup({ droplet_status: status });
|
|
113
|
+
const stderr = jest
|
|
114
|
+
.spyOn(process.stderr, "write")
|
|
115
|
+
.mockImplementation(() => true);
|
|
116
|
+
|
|
117
|
+
await program.parseAsync(["status", "drp_1", "--window", "169"], {
|
|
118
|
+
from: "user",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(calls).toEqual([]);
|
|
122
|
+
expect(output).not.toHaveBeenCalled();
|
|
123
|
+
expect(String(stderr.mock.calls[0]?.[0])).toMatch(/--window must be/);
|
|
124
|
+
stderr.mockRestore();
|
|
125
|
+
process.exitCode = undefined;
|
|
126
|
+
});
|
|
127
|
+
});
|
package/src/commands/install.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type DropletsClient,
|
|
14
14
|
} from "../install/api.js";
|
|
15
15
|
import { getDropletHealth, parseWindowHours } from "../health/api.js";
|
|
16
|
+
import { getDropletStatus } from "../status/api.js";
|
|
16
17
|
|
|
17
18
|
/** Asks a yes/no question; true means yes. */
|
|
18
19
|
export type Ask = (question: string) => Promise<boolean>;
|
|
@@ -88,7 +89,7 @@ async function dropletsClient(ctx: CommandContext): Promise<DropletsClient> {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
/**
|
|
91
|
-
* `fluid droplets install|uninstall|installed|health`: friendly commands over
|
|
92
|
+
* `fluid droplets install|uninstall|installed|health|status`: friendly commands over
|
|
92
93
|
* the v2026-10 company API, next to the generated `installations` commands.
|
|
93
94
|
*/
|
|
94
95
|
export function registerInstallCommands(
|
|
@@ -169,6 +170,28 @@ export function registerInstallCommands(
|
|
|
169
170
|
}),
|
|
170
171
|
);
|
|
171
172
|
|
|
173
|
+
parent
|
|
174
|
+
.command("status <droplet_uuid>")
|
|
175
|
+
.description(
|
|
176
|
+
"A droplet's operating status for this company's installation: deployment, install, lifecycle, health, metrics and webhook health, each section marked available or not (read-only)",
|
|
177
|
+
)
|
|
178
|
+
.option(
|
|
179
|
+
"--window <hours>",
|
|
180
|
+
"Trailing window in hours, 1 to 168 (default 24)",
|
|
181
|
+
)
|
|
182
|
+
.action((dropletUuid: string, opts: { window?: string }) =>
|
|
183
|
+
run(async () => {
|
|
184
|
+
const windowHours = parseWindowHours(opts.window);
|
|
185
|
+
ctx.output(
|
|
186
|
+
await getDropletStatus(
|
|
187
|
+
await dropletsClient(ctx),
|
|
188
|
+
dropletUuid,
|
|
189
|
+
windowHours,
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
|
|
172
195
|
parent
|
|
173
196
|
.command("installed")
|
|
174
197
|
.description(
|
package/src/health/api.ts
CHANGED
|
@@ -33,6 +33,22 @@ function apiStatus(error: unknown): number | undefined {
|
|
|
33
33
|
return typeof status === "number" ? status : undefined;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** Turn a 404 or 403 from a droplet read (`what` is "health" or "status"; `notFound` replaces the 404 text)
|
|
37
|
+
* into a plain message; throws any other failure as it came. */
|
|
38
|
+
export function rethrowDropletReadError(
|
|
39
|
+
error: unknown,
|
|
40
|
+
dropletUuid: string,
|
|
41
|
+
what: string,
|
|
42
|
+
notFound = `${dropletUuid} is not a droplet this company publishes or has installed. List what is installed with \`fluid droplets installed\`.`,
|
|
43
|
+
): never {
|
|
44
|
+
if (apiStatus(error) === 404) throw new DropletsCliError(notFound);
|
|
45
|
+
if (apiStatus(error) === 403)
|
|
46
|
+
throw new DropletsCliError(
|
|
47
|
+
`Reading droplet ${what} needs the developer.view permission.`,
|
|
48
|
+
);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
|
|
36
52
|
/** Read the window from `--window`, refusing one the API would. */
|
|
37
53
|
export function parseWindowHours(
|
|
38
54
|
value: string | undefined,
|
|
@@ -67,14 +83,6 @@ export async function getDropletHealth(
|
|
|
67
83
|
);
|
|
68
84
|
return response.droplet_health;
|
|
69
85
|
} catch (error) {
|
|
70
|
-
|
|
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;
|
|
86
|
+
return rethrowDropletReadError(error, dropletUuid, "health");
|
|
79
87
|
}
|
|
80
88
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A droplet's operating status 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
|
+
* `CompanyDropletStatus`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type DropletsClient } from "../install/api.js";
|
|
9
|
+
import { rethrowDropletReadError } from "../health/api.js";
|
|
10
|
+
|
|
11
|
+
/** One part of the status. Where `available` is false, `data` is null and
|
|
12
|
+
* `error` says why; a verdict string inside `data` may be one this client
|
|
13
|
+
* does not know. */
|
|
14
|
+
export interface DropletStatusSection {
|
|
15
|
+
available: boolean;
|
|
16
|
+
error: { type: string; description: string } | null;
|
|
17
|
+
data: Record<string, unknown> | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The status as the API returns it. `reconcile` and `connect` are always
|
|
21
|
+
* unavailable for now. */
|
|
22
|
+
export interface DropletStatus {
|
|
23
|
+
generated_at: string;
|
|
24
|
+
window_hours: number;
|
|
25
|
+
installation: Record<string, unknown> | null;
|
|
26
|
+
history: unknown;
|
|
27
|
+
sections: Record<string, DropletStatusSection>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function statusPath(dropletUuid: string): string {
|
|
31
|
+
return `/api/company/v2026-10/droplets/${encodeURIComponent(dropletUuid)}/status`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The droplet's status for this company over the window (the API's default,
|
|
36
|
+
* 24 hours, when left out).
|
|
37
|
+
*/
|
|
38
|
+
export async function getDropletStatus(
|
|
39
|
+
client: DropletsClient,
|
|
40
|
+
dropletUuid: string,
|
|
41
|
+
windowHours?: number,
|
|
42
|
+
): Promise<DropletStatus> {
|
|
43
|
+
try {
|
|
44
|
+
const response = await client.get<{ droplet_status: DropletStatus }>(
|
|
45
|
+
statusPath(dropletUuid),
|
|
46
|
+
windowHours === undefined ? undefined : { window_hours: windowHours },
|
|
47
|
+
);
|
|
48
|
+
return response.droplet_status;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return rethrowDropletReadError(
|
|
51
|
+
error,
|
|
52
|
+
dropletUuid,
|
|
53
|
+
"status",
|
|
54
|
+
`${dropletUuid} has no installation for this company, or is not a droplet it can see. List what is installed with \`fluid droplets installed\`.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|