@getformation/cloud-cli 1.0.0
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/LICENSE +21 -0
- package/README.md +39 -0
- package/bin/formation-cloud.mjs +4 -0
- package/connector/SKILL.md +42 -0
- package/package.json +28 -0
- package/src/cli.mjs +122 -0
- package/src/client.mjs +203 -0
- package/src/connector.mjs +408 -0
- package/src/errors.mjs +29 -0
- package/src/installer.mjs +520 -0
- package/src/manifest.mjs +296 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eduardo Muth Martinez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Formation Cloud CLI
|
|
2
|
+
|
|
3
|
+
`formation-cloud` calls the Formation v3 operation front door. It also installs the Formation connector and reviewed expert Skills into exact local targets.
|
|
4
|
+
|
|
5
|
+
The package needs Node.js 22 or later. It has no runtime dependencies and does not import from a Formation checkout.
|
|
6
|
+
|
|
7
|
+
Install the public package from npm:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @getformation/cloud-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Install the bundled Formation connector. The first command is a dry run. The second command writes the connector.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
formation-cloud connector install --target /absolute/path/to/formation
|
|
17
|
+
formation-cloud connector install --target /absolute/path/to/formation --apply
|
|
18
|
+
formation-cloud connector check --target /absolute/path/to/formation
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Set `FORMATION_CLOUD_URL` to the exact HTTPS Formation origin. Set `FORMATION_AGENT_KEY` only when an operation needs an authenticated machine scope.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
formation-cloud discover
|
|
25
|
+
formation-cloud connector
|
|
26
|
+
formation-cloud channel_search '{"query":"Formation"}'
|
|
27
|
+
formation-cloud follow '{"channelId":"channel_formation","interests":["use_knowledge"],"idempotencyKey":"follow_formation_01"}'
|
|
28
|
+
formation-cloud updates_reset '{}'
|
|
29
|
+
formation-cloud updates '{"cursor":"OPAQUE_CURSOR_FROM_RESET"}'
|
|
30
|
+
formation-cloud skill install skillrev_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef --target /absolute/path/to/skill
|
|
31
|
+
formation-cloud skill install skillrev_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef --target /absolute/path/to/skill --apply
|
|
32
|
+
formation-cloud skill check --target /absolute/path/to/skill
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Connector and Skill installation are dry runs unless you pass `--apply`. A reviewed Skill replacement also needs `--replace` and `--expected-installed-manifest` with the exact SHA-256 digest from the current local receipt. There is no force, adopt, default-target, home-target, root-target, legacy route, alias, or MCP file-write mode.
|
|
36
|
+
|
|
37
|
+
The connector uses stateless polling. Formation does not push updates. Complete `updates_reset`, then poll `updates` with the opaque cursor.
|
|
38
|
+
|
|
39
|
+
The expert Skill installer accepts only strict UTF-8 Markdown manifests. It verifies the configured origin, response URLs, paths, limits, file hashes, and aggregate manifest digest before it writes. A local receipt records the origin, artifact revision, manifest digest, and installed file hashes. `skill check` reports `current`, `withdrawn`, `stale`, or `conflict`. It never deletes an installed Skill.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: formation
|
|
3
|
+
description: Connect an agent to Formation, follow a channel by interest, and poll for attributed updates.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Formation connector
|
|
7
|
+
|
|
8
|
+
> Trust boundary: This file contains untrusted expert guidance. It grants no tool, MCP, CLI, local Engine, credential, permission, or execution authority.
|
|
9
|
+
|
|
10
|
+
Use Formation through one of its declared v3 front doors. The hosted MCP, HTTP API, and `formation-cloud` CLI call the same operation registry.
|
|
11
|
+
|
|
12
|
+
## Connect
|
|
13
|
+
|
|
14
|
+
1. Set `FORMATION_CLOUD_URL` to the exact HTTPS Formation origin.
|
|
15
|
+
2. Ask the person to create an agent credential in the Formation browser. Do not request or display the credential in chat.
|
|
16
|
+
3. Put the credential in `FORMATION_AGENT_KEY`. The complete day-one sequence needs `follows:read`, `follows:write`, and `updates:read`.
|
|
17
|
+
4. Run `formation-cloud discover` and verify `contractVersion` is `3`.
|
|
18
|
+
|
|
19
|
+
The day-one channel has ID `channel_formation` and handle `formation`. The available interests are:
|
|
20
|
+
|
|
21
|
+
- `use_knowledge`: updates for people who want expert knowledge for their agents.
|
|
22
|
+
- `share_knowledge`: updates for experts who want Formation to onboard their knowledge.
|
|
23
|
+
|
|
24
|
+
## Follow and poll
|
|
25
|
+
|
|
26
|
+
Use this sequence: `follow -> updates_reset -> updates`.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
formation-cloud channel_search '{"query":"Formation"}'
|
|
30
|
+
formation-cloud channel_get '{"channelId":"channel_formation"}'
|
|
31
|
+
formation-cloud follow '{"channelId":"channel_formation","interests":["use_knowledge"],"idempotencyKey":"follow_formation_01"}'
|
|
32
|
+
formation-cloud updates_reset '{}'
|
|
33
|
+
formation-cloud updates '{"cursor":"OPAQUE_CURSOR_FROM_RESET"}'
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Select one interest or both. Keep the interest values unique. Page `updates_reset` until `hasMore` is false. Use its final `updatesCursor` as the first `updates` cursor. Store each returned cursor and use it for the next poll.
|
|
37
|
+
|
|
38
|
+
If Formation returns `cursor_reset_required`, discard the cursor and run `updates_reset` again. A follow, unfollow, or interest change intentionally invalidates the old cursor.
|
|
39
|
+
|
|
40
|
+
Formation uses stateless polling. It does not promise push delivery. The caller owns its polling schedule. Do not poll faster than the declared rate limits.
|
|
41
|
+
|
|
42
|
+
Following records interest only. It does not grant access to expert knowledge or local execution authority.
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@getformation/cloud-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Formation Cloud operation client and explicit Skill installer",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"formation-cloud": "bin/formation-cloud.mjs"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/formation-cloud.mjs",
|
|
14
|
+
"src/cli.mjs",
|
|
15
|
+
"src/client.mjs",
|
|
16
|
+
"src/connector.mjs",
|
|
17
|
+
"src/errors.mjs",
|
|
18
|
+
"src/installer.mjs",
|
|
19
|
+
"src/manifest.mjs",
|
|
20
|
+
"connector/SKILL.md",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test test/*.test.mjs"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT"
|
|
28
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { clientFromEnvironment, isMachineOperationName } from "./client.mjs";
|
|
2
|
+
import { checkConnector, installConnector } from "./connector.mjs";
|
|
3
|
+
import { CloudCliError, fail, safeFailure } from "./errors.mjs";
|
|
4
|
+
import { checkSkill, installSkill } from "./installer.mjs";
|
|
5
|
+
|
|
6
|
+
function parseJson(value) {
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(value);
|
|
9
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
|
|
10
|
+
return parsed;
|
|
11
|
+
} catch {
|
|
12
|
+
fail("invalid_operation_input", "Provide one valid JSON input object.", "Pass JSON such as '{}'.");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseOptions(args, allowed) {
|
|
17
|
+
const options = {};
|
|
18
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
19
|
+
const flag = args[index];
|
|
20
|
+
if (!allowed.has(flag) || Object.hasOwn(options, flag)) {
|
|
21
|
+
fail("invalid_command", `Unsupported or repeated option: ${flag}`, "Run formation-cloud help for the exact target commands.");
|
|
22
|
+
}
|
|
23
|
+
if (["--apply", "--replace"].includes(flag)) options[flag] = true;
|
|
24
|
+
else {
|
|
25
|
+
const value = args[index + 1];
|
|
26
|
+
if (!value || value.startsWith("--")) fail("invalid_command", `Option ${flag} requires one value.`, "Run formation-cloud help.");
|
|
27
|
+
options[flag] = value;
|
|
28
|
+
index += 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return options;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function help() {
|
|
35
|
+
return {
|
|
36
|
+
package: "@getformation/cloud-cli",
|
|
37
|
+
version: "1.0.0",
|
|
38
|
+
usage: [
|
|
39
|
+
"formation-cloud discover",
|
|
40
|
+
"formation-cloud connector",
|
|
41
|
+
"formation-cloud connector install --target /absolute/path [--apply]",
|
|
42
|
+
"formation-cloud connector check --target /absolute/path",
|
|
43
|
+
"formation-cloud <machine_operation> '[JSON input object]'",
|
|
44
|
+
"formation-cloud skill install <artifact-revision-id> --target /absolute/path [--apply]",
|
|
45
|
+
"formation-cloud skill install <artifact-revision-id> --target /absolute/path --apply --replace --expected-installed-manifest <sha256>",
|
|
46
|
+
"formation-cloud skill check --target /absolute/path",
|
|
47
|
+
],
|
|
48
|
+
endpointVariable: "FORMATION_CLOUD_URL",
|
|
49
|
+
credentialVariable: "FORMATION_AGENT_KEY",
|
|
50
|
+
installation: "Skill install is read-only unless --apply is present. Replacement requires the exact installed manifest digest.",
|
|
51
|
+
trust: "untrusted",
|
|
52
|
+
grantsAuthority: false,
|
|
53
|
+
executionMode: "guidance_only",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function execute(argv, environment = process.env) {
|
|
58
|
+
const [first = "help", ...rest] = argv;
|
|
59
|
+
if (["help", "--help", "-h"].includes(first)) {
|
|
60
|
+
if (rest.length) fail("invalid_command", "Help does not accept extra arguments.", "Run formation-cloud help.");
|
|
61
|
+
return help();
|
|
62
|
+
}
|
|
63
|
+
if (first === "discover") {
|
|
64
|
+
if (rest.length) fail("invalid_command", "Discovery does not accept input.", "Run formation-cloud discover.");
|
|
65
|
+
return clientFromEnvironment(environment).discover();
|
|
66
|
+
}
|
|
67
|
+
if (first === "connector") {
|
|
68
|
+
if (!rest.length) return clientFromEnvironment(environment).operate("connector_get", {});
|
|
69
|
+
const [command, ...connectorArgs] = rest;
|
|
70
|
+
if (command === "install") {
|
|
71
|
+
const options = parseOptions(connectorArgs, new Set(["--target", "--apply"]));
|
|
72
|
+
if (!options["--target"]) fail("invalid_command", "Connector install requires --target with one absolute path.", "Choose one dedicated exact target.");
|
|
73
|
+
return installConnector({ target: options["--target"], apply: options["--apply"] === true });
|
|
74
|
+
}
|
|
75
|
+
if (command === "check") {
|
|
76
|
+
const options = parseOptions(connectorArgs, new Set(["--target"]));
|
|
77
|
+
if (!options["--target"]) fail("invalid_command", "Connector check requires --target with one absolute path.", "Pass the exact installed connector target.");
|
|
78
|
+
return checkConnector({ target: options["--target"] });
|
|
79
|
+
}
|
|
80
|
+
fail("invalid_command", "Connector supports retrieval, install, and check.", "Run formation-cloud help.");
|
|
81
|
+
}
|
|
82
|
+
const client = clientFromEnvironment(environment);
|
|
83
|
+
if (first === "skill") {
|
|
84
|
+
const [command, ...skillArgs] = rest;
|
|
85
|
+
if (command === "install") {
|
|
86
|
+
const [artifactRevisionId, ...optionArgs] = skillArgs;
|
|
87
|
+
if (!artifactRevisionId || artifactRevisionId.startsWith("--")) fail("invalid_command", "Skill install requires one artifact revision ID.", "Pass the exact revision returned by skill_search.");
|
|
88
|
+
const options = parseOptions(optionArgs, new Set(["--target", "--apply", "--replace", "--expected-installed-manifest"]));
|
|
89
|
+
if (!options["--target"]) fail("invalid_command", "Skill install requires --target with one absolute path.", "Choose one dedicated exact target.");
|
|
90
|
+
return installSkill({ client, artifactRevisionId, target: options["--target"], apply: options["--apply"] === true,
|
|
91
|
+
replace: options["--replace"] === true, expectedInstalledManifest: options["--expected-installed-manifest"] ?? null });
|
|
92
|
+
}
|
|
93
|
+
if (command === "check") {
|
|
94
|
+
const options = parseOptions(skillArgs, new Set(["--target"]));
|
|
95
|
+
if (!options["--target"]) fail("invalid_command", "Skill check requires --target with one absolute path.", "Pass the exact installed Skill target.");
|
|
96
|
+
return checkSkill({ client, target: options["--target"] });
|
|
97
|
+
}
|
|
98
|
+
fail("invalid_command", "Skill supports only install and check.", "Run formation-cloud help.");
|
|
99
|
+
}
|
|
100
|
+
if (!isMachineOperationName(first)) fail("invalid_command", "The target operation name is invalid.", "Run formation-cloud discover.");
|
|
101
|
+
if (rest.length > 1) fail("invalid_command", "A machine operation accepts one optional JSON input object.", "Run formation-cloud discover.");
|
|
102
|
+
return client.operate(first, rest.length ? parseJson(rest[0]) : {});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function redactedJson(value, credential) {
|
|
106
|
+
const serialized = JSON.stringify(value);
|
|
107
|
+
return credential ? serialized.replaceAll(credential, "[redacted]") : serialized;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function runCli({ argv = process.argv.slice(2), environment = process.env, stdout = process.stdout } = {}) {
|
|
111
|
+
try {
|
|
112
|
+
const result = await execute(argv, environment);
|
|
113
|
+
stdout.write(`${redactedJson(result, environment.FORMATION_AGENT_KEY ?? "")}\n`);
|
|
114
|
+
if (result?.status === "conflict") process.exitCode = 1;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const safe = safeFailure(error);
|
|
117
|
+
stdout.write(`${redactedJson(safe, environment.FORMATION_AGENT_KEY ?? "")}\n`);
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export { CloudCliError };
|
package/src/client.mjs
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { CloudCliError, fail } from "./errors.mjs";
|
|
2
|
+
|
|
3
|
+
export const CLOUD_API_PATH = "/api/v3";
|
|
4
|
+
export const MAX_GENERIC_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
5
|
+
export const MAX_MANIFEST_RESPONSE_BYTES = 512 * 1024;
|
|
6
|
+
export const MAX_FILE_RESPONSE_BYTES = 512 * 1024;
|
|
7
|
+
const MAX_REQUEST_BYTES = 278_528;
|
|
8
|
+
const OPERATION_NAME = /^[a-z][a-z_]{0,63}$/u;
|
|
9
|
+
const AGENT_KEY = /^fka_[A-Za-z0-9_-]{43}$/u;
|
|
10
|
+
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$/u;
|
|
11
|
+
|
|
12
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
13
|
+
const exactKeys = (value, keys) => object(value)
|
|
14
|
+
&& Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
15
|
+
|
|
16
|
+
function validTimestamp(value) {
|
|
17
|
+
return typeof value === "string" && value.length <= 32 && Number.isFinite(Date.parse(value));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function validateDiscovery(value) {
|
|
21
|
+
if (!object(value) || value.contractVersion !== 3 || !Array.isArray(value.operations)) {
|
|
22
|
+
fail("invalid_response", "The server returned an invalid v3 discovery descriptor.",
|
|
23
|
+
"Use the target v3 Formation Cloud endpoint.");
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validateOperationEnvelope(value, operation) {
|
|
29
|
+
const audit = value?.audit;
|
|
30
|
+
if (!exactKeys(value, ["contractVersion", "requestId", "operation", "result", "audit"])
|
|
31
|
+
|| value.contractVersion !== 3 || value.operation !== operation
|
|
32
|
+
|| typeof value.requestId !== "string" || !IDENTIFIER.test(value.requestId)
|
|
33
|
+
|| !object(value.result) || !exactKeys(audit, ["recordId", "createdAt"])
|
|
34
|
+
|| !(audit.recordId === null || typeof audit.recordId === "string" && IDENTIFIER.test(audit.recordId))
|
|
35
|
+
|| !(audit.createdAt === null || validTimestamp(audit.createdAt))) {
|
|
36
|
+
fail("invalid_response", "The server returned an invalid v3 operation envelope.",
|
|
37
|
+
"Use the target v3 Formation Cloud endpoint and retry the exact operation.");
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isMachineOperationName(value) {
|
|
43
|
+
return OPERATION_NAME.test(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function exactOrigin(rawOrigin, allowHttpLoopbackForTests) {
|
|
47
|
+
let url;
|
|
48
|
+
try { url = new URL(rawOrigin); }
|
|
49
|
+
catch {
|
|
50
|
+
fail("invalid_endpoint", "Set FORMATION_CLOUD_URL to the service's exact HTTPS origin.",
|
|
51
|
+
"Use an origin such as https://formation.example without a path, credentials, query, or fragment.");
|
|
52
|
+
}
|
|
53
|
+
if (!rawOrigin || url.username || url.password || url.origin !== rawOrigin) {
|
|
54
|
+
fail("invalid_endpoint", "The endpoint must be an exact origin without a path, credentials, query, or fragment.",
|
|
55
|
+
"Set FORMATION_CLOUD_URL to the exact configured Cloud origin.");
|
|
56
|
+
}
|
|
57
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
58
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback && allowHttpLoopbackForTests)) {
|
|
59
|
+
fail("https_required", "Formation Cloud requires HTTPS.",
|
|
60
|
+
"Use HTTPS. Tests may opt in to HTTP only for an exact loopback origin.");
|
|
61
|
+
}
|
|
62
|
+
return url;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readBoundedJson(response, maximumBytes) {
|
|
66
|
+
const reader = response.body?.getReader();
|
|
67
|
+
if (!reader) {
|
|
68
|
+
fail("invalid_response", "The server returned no JSON response body.", "Retry against the configured Formation Cloud origin.");
|
|
69
|
+
}
|
|
70
|
+
const chunks = [];
|
|
71
|
+
let total = 0;
|
|
72
|
+
try {
|
|
73
|
+
while (true) {
|
|
74
|
+
const { value, done } = await reader.read();
|
|
75
|
+
if (done) break;
|
|
76
|
+
total += value.byteLength;
|
|
77
|
+
if (total > maximumBytes) {
|
|
78
|
+
await reader.cancel().catch(() => {});
|
|
79
|
+
fail("response_too_large", "The server response exceeded the client byte limit.",
|
|
80
|
+
"Do not retry until the server returns a bounded target response.");
|
|
81
|
+
}
|
|
82
|
+
chunks.push(Buffer.from(value));
|
|
83
|
+
}
|
|
84
|
+
} finally {
|
|
85
|
+
reader.releaseLock();
|
|
86
|
+
}
|
|
87
|
+
let value;
|
|
88
|
+
try { value = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
|
|
89
|
+
catch {
|
|
90
|
+
fail("invalid_response", "The server did not return valid bounded JSON.", "Retry against a healthy target endpoint.");
|
|
91
|
+
}
|
|
92
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
93
|
+
fail("invalid_response", "The server returned an invalid operation envelope.", "Use the target v3 Formation Cloud endpoint.");
|
|
94
|
+
}
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function safeRemoteText(value, fallback) {
|
|
99
|
+
return typeof value === "string" && value.length > 0 && value.length <= 8192 ? value : fallback;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class CloudClient {
|
|
103
|
+
constructor({
|
|
104
|
+
origin,
|
|
105
|
+
credential = "",
|
|
106
|
+
allowHttpLoopbackForTests = false,
|
|
107
|
+
fetchImpl = globalThis.fetch,
|
|
108
|
+
timeoutMs = 15_000,
|
|
109
|
+
}) {
|
|
110
|
+
this.origin = exactOrigin(origin, allowHttpLoopbackForTests);
|
|
111
|
+
if (credential && !AGENT_KEY.test(credential)) {
|
|
112
|
+
fail("invalid_agent_credential", "FORMATION_AGENT_KEY has an invalid target credential format.",
|
|
113
|
+
"Issue a current bounded credential in the signed-in browser.");
|
|
114
|
+
}
|
|
115
|
+
if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be a function");
|
|
116
|
+
this.credential = credential;
|
|
117
|
+
this.fetchImpl = fetchImpl;
|
|
118
|
+
this.timeoutMs = timeoutMs;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async #request({ path, method, input, maximumBytes }) {
|
|
122
|
+
const expected = new URL(path, this.origin);
|
|
123
|
+
const body = input === undefined ? undefined : JSON.stringify(input);
|
|
124
|
+
if (body !== undefined && Buffer.byteLength(body, "utf8") > MAX_REQUEST_BYTES) {
|
|
125
|
+
fail("body_too_large", "The operation input exceeded the client byte limit.", "Send a bounded target operation input.");
|
|
126
|
+
}
|
|
127
|
+
let response;
|
|
128
|
+
try {
|
|
129
|
+
response = await this.fetchImpl(expected, {
|
|
130
|
+
method,
|
|
131
|
+
redirect: "error",
|
|
132
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
133
|
+
headers: {
|
|
134
|
+
accept: "application/json",
|
|
135
|
+
...(this.credential ? { authorization: `Bearer ${this.credential}` } : {}),
|
|
136
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
137
|
+
},
|
|
138
|
+
...(body === undefined ? {} : { body }),
|
|
139
|
+
});
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error instanceof CloudCliError) throw error;
|
|
142
|
+
fail("connection_failed", "The target endpoint could not be reached safely. Redirects are not followed.",
|
|
143
|
+
"Check the exact configured origin and its TLS endpoint.");
|
|
144
|
+
}
|
|
145
|
+
let responseUrl;
|
|
146
|
+
try { responseUrl = new URL(response.url); }
|
|
147
|
+
catch {
|
|
148
|
+
fail("response_origin_mismatch", "The response did not identify its exact target URL.",
|
|
149
|
+
"Use a direct target operation endpoint without a proxy rewrite or redirect.");
|
|
150
|
+
}
|
|
151
|
+
if (responseUrl.href !== expected.href || responseUrl.origin !== this.origin.origin) {
|
|
152
|
+
fail("response_origin_mismatch", "The final response URL did not match the exact requested operation URL.",
|
|
153
|
+
"Use the configured Formation Cloud origin directly. Redirected responses are refused.");
|
|
154
|
+
}
|
|
155
|
+
const value = await readBoundedJson(response, maximumBytes);
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
throw new CloudCliError(
|
|
158
|
+
safeRemoteText(value.code, "operation_failed"),
|
|
159
|
+
safeRemoteText(value.error ?? value.message, "The operation was refused."),
|
|
160
|
+
safeRemoteText(value.recovery, "Read target discovery and current authority before retrying."),
|
|
161
|
+
value.requestId && typeof value.requestId === "string" ? { requestId: value.requestId } : undefined,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async discover() {
|
|
168
|
+
return validateDiscovery(await this.#request({
|
|
169
|
+
path: `${CLOUD_API_PATH}/discovery`, method: "GET", maximumBytes: MAX_GENERIC_RESPONSE_BYTES,
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async operate(operation, input = {}, { maximumBytes = MAX_GENERIC_RESPONSE_BYTES } = {}) {
|
|
174
|
+
if (!isMachineOperationName(operation)) {
|
|
175
|
+
fail("invalid_operation", "The operation name is invalid.", "Use a machine operation name from target discovery.");
|
|
176
|
+
}
|
|
177
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
178
|
+
fail("invalid_operation_input", "The operation input must be one JSON object.", "Pass one target operation input object.");
|
|
179
|
+
}
|
|
180
|
+
return validateOperationEnvelope(await this.#request({
|
|
181
|
+
path: `${CLOUD_API_PATH}/operations/${operation}`,
|
|
182
|
+
method: "POST",
|
|
183
|
+
input,
|
|
184
|
+
maximumBytes,
|
|
185
|
+
}), operation);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
manifest(artifactRevisionId) {
|
|
189
|
+
return this.operate("skill_manifest_get", { artifactRevisionId }, { maximumBytes: MAX_MANIFEST_RESPONSE_BYTES });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
file(artifactRevisionId, filePath) {
|
|
193
|
+
return this.operate("skill_file_get", { artifactRevisionId, path: filePath }, { maximumBytes: MAX_FILE_RESPONSE_BYTES });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function clientFromEnvironment(environment = process.env) {
|
|
198
|
+
return new CloudClient({
|
|
199
|
+
origin: environment.FORMATION_CLOUD_URL ?? "",
|
|
200
|
+
credential: environment.FORMATION_AGENT_KEY ?? "",
|
|
201
|
+
allowHttpLoopbackForTests: environment.FORMATION_CLOUD_ALLOW_HTTP_LOOPBACK_FOR_TESTS === "true",
|
|
202
|
+
});
|
|
203
|
+
}
|