@getxflow/cli 0.6.5 → 0.6.6
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/api.js +28 -4
- package/dist/bin.js +9 -1
- package/dist/commands/connections.js +89 -2
- package/dist/commands/env.js +1 -0
- package/dist/help.js +348 -328
- package/dist/version.js +1 -1
- package/package.json +23 -23
- package/skills/xflow/SKILL.md +463 -451
package/dist/api.js
CHANGED
|
@@ -98,26 +98,50 @@ async function send(client, path, options = {}) {
|
|
|
98
98
|
assertProtocol(response);
|
|
99
99
|
return response;
|
|
100
100
|
}
|
|
101
|
-
|
|
101
|
+
/**
|
|
102
|
+
* The body is not an answer of the API: every one of those is JSON with content.
|
|
103
|
+
* A page in its place, or nothing at all, means the request never reached a
|
|
104
|
+
* route, so we say that instead of quoting what came back.
|
|
105
|
+
*
|
|
106
|
+
* Markup never goes into the hint. The reader is an agent, and 200 characters of
|
|
107
|
+
* HTML read like a bug in its own code rather than like a platform that has not
|
|
108
|
+
* deployed this endpoint yet. An empty body is worse still: it used to leave no
|
|
109
|
+
* hint at all.
|
|
110
|
+
*
|
|
111
|
+
* Plain text is kept: some proxies say something useful in one line.
|
|
112
|
+
*/
|
|
113
|
+
function notAnAnswer(status, apiUrl, body) {
|
|
114
|
+
const text = body.trim();
|
|
115
|
+
if (text.length > 0 && !text.startsWith('<')) {
|
|
116
|
+
return new ApiError(`Request rejected (${status})`, 'unknown', status, text.slice(0, 200));
|
|
117
|
+
}
|
|
118
|
+
// 404 is a missing route, 405 a route without this method. Both mean the same
|
|
119
|
+
// thing to the caller: this platform does not serve the endpoint yet.
|
|
120
|
+
if (status === 404 || status === 405) {
|
|
121
|
+
return new ApiError(`The platform does not serve that endpoint (${status})`, 'not_deployed', status, `This CLI is ${version_1.CLI_VERSION} and ${apiUrl} is older than it. Wait for the platform to deploy, or check XFLOW_API_URL`);
|
|
122
|
+
}
|
|
123
|
+
return new ApiError(`The platform is not answering (${status})`, 'unavailable', status, `Something on the way to ${apiUrl} answered with a page instead of an answer. Retry in a minute`);
|
|
124
|
+
}
|
|
125
|
+
async function readError(response, client) {
|
|
102
126
|
const text = await response.text().catch(() => '');
|
|
103
127
|
try {
|
|
104
128
|
const parsed = JSON.parse(text);
|
|
105
129
|
return new ApiError(parsed.error || `Request rejected (${response.status})`, parsed.code || 'unknown', response.status, parsed.hint, parsed.issues, parsed.limit, parsed.removing);
|
|
106
130
|
}
|
|
107
131
|
catch {
|
|
108
|
-
return
|
|
132
|
+
return notAnAnswer(response.status, client.apiUrl, text);
|
|
109
133
|
}
|
|
110
134
|
}
|
|
111
135
|
async function apiJson(client, path, options = {}) {
|
|
112
136
|
const response = await send(client, path, options);
|
|
113
137
|
if (!response.ok)
|
|
114
|
-
throw await readError(response);
|
|
138
|
+
throw await readError(response, client);
|
|
115
139
|
return (await response.json());
|
|
116
140
|
}
|
|
117
141
|
async function apiBinary(client, path) {
|
|
118
142
|
const response = await send(client, path, { timeoutMs: TRANSFER_TIMEOUT_MS });
|
|
119
143
|
if (!response.ok)
|
|
120
|
-
throw await readError(response);
|
|
144
|
+
throw await readError(response, client);
|
|
121
145
|
return Buffer.from(await response.arrayBuffer());
|
|
122
146
|
}
|
|
123
147
|
async function apiUpload(client, path, body, headers = {}) {
|
package/dist/bin.js
CHANGED
|
@@ -107,11 +107,19 @@ async function run(args) {
|
|
|
107
107
|
}
|
|
108
108
|
throw new errors_1.CliError(`Unknown command: env ${second}`, 'Available: list, check, set and rm');
|
|
109
109
|
case 'connections':
|
|
110
|
+
if (second === 'link') {
|
|
111
|
+
await (0, connections_1.connectionsLink)(rest);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (second === 'unlink') {
|
|
115
|
+
await (0, connections_1.connectionsUnlink)(rest);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
110
118
|
if (second === undefined || second === 'list') {
|
|
111
119
|
await (0, connections_1.connectionsList)();
|
|
112
120
|
return;
|
|
113
121
|
}
|
|
114
|
-
throw new errors_1.CliError(`Unknown command: connections ${second}`, 'Available: list');
|
|
122
|
+
throw new errors_1.CliError(`Unknown command: connections ${second}`, 'Available: list, link and unlink');
|
|
115
123
|
case 'schedules':
|
|
116
124
|
if (second === 'set') {
|
|
117
125
|
await (0, schedules_1.schedulesSet)(rest);
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.connectionsList = connectionsList;
|
|
4
|
+
exports.connectionsLink = connectionsLink;
|
|
5
|
+
exports.connectionsUnlink = connectionsUnlink;
|
|
4
6
|
const api_1 = require("../api");
|
|
7
|
+
const args_1 = require("../args");
|
|
5
8
|
const config_1 = require("../config");
|
|
9
|
+
const errors_1 = require("../errors");
|
|
6
10
|
const session_1 = require("../session");
|
|
7
11
|
const ui_1 = require("../ui");
|
|
12
|
+
const env_1 = require("./env");
|
|
8
13
|
// An expiring token is renewed by any build, a revoked one is not renewed by
|
|
9
14
|
// anything until a human reconnects the account. Telling them apart saves a
|
|
10
15
|
// pointless rebuild, so every case gets its own line.
|
|
@@ -26,10 +31,35 @@ function state(row) {
|
|
|
26
31
|
return 'linked';
|
|
27
32
|
}
|
|
28
33
|
}
|
|
34
|
+
function endpoint(projectId) {
|
|
35
|
+
return `/api/v1/projects/${projectId}/connections`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Which connection the argument means. Identifiers are what the platform knows,
|
|
39
|
+
* names are what the list shows and what a human said out loud; both are
|
|
40
|
+
* accepted, so nobody has to copy a UUID from somewhere the command never
|
|
41
|
+
* printed it. Names repeat across accounts of the same service, so an ambiguous
|
|
42
|
+
* one is refused with the identifiers rather than resolved by luck.
|
|
43
|
+
*/
|
|
44
|
+
function resolve(connections, target) {
|
|
45
|
+
const byId = connections.find((row) => row.id === target);
|
|
46
|
+
if (byId)
|
|
47
|
+
return byId;
|
|
48
|
+
const wanted = target.toLowerCase();
|
|
49
|
+
const named = connections.filter((row) => row.label.toLowerCase() === wanted);
|
|
50
|
+
if (named.length === 1)
|
|
51
|
+
return named[0];
|
|
52
|
+
if (named.length > 1) {
|
|
53
|
+
(0, ui_1.fail)(`The organization has ${named.length} connections named ${target}:`);
|
|
54
|
+
(0, ui_1.table)(named.map((row) => [row.id, row.connector_name ?? row.connector_key ?? '-']));
|
|
55
|
+
throw new errors_1.CliError('The name is ambiguous', 'Repeat with one of the identifiers above');
|
|
56
|
+
}
|
|
57
|
+
throw new errors_1.CliError(`No connection ${target}`, 'What this project can use: xflow connections');
|
|
58
|
+
}
|
|
29
59
|
async function connectionsList() {
|
|
30
60
|
const { config } = (0, config_1.requireProject)();
|
|
31
61
|
const client = (0, session_1.connect)(config);
|
|
32
|
-
const data = await (0, api_1.apiJson)(client,
|
|
62
|
+
const data = await (0, api_1.apiJson)(client, endpoint(config.projectId));
|
|
33
63
|
if (data.connections.length === 0) {
|
|
34
64
|
(0, ui_1.note)('No connections you can use in this organization');
|
|
35
65
|
(0, ui_1.note)((0, ui_1.dim)(' Somebody connects an account first: platform settings, Connectors'));
|
|
@@ -47,6 +77,63 @@ async function connectionsList() {
|
|
|
47
77
|
(0, ui_1.note)((0, ui_1.dim)(' A change reaches a function on its next deploy: xflow deploy'));
|
|
48
78
|
}
|
|
49
79
|
if (data.connections.some((row) => !row.alias)) {
|
|
50
|
-
(0, ui_1.note)((0, ui_1.dim)(' To link one:
|
|
80
|
+
(0, ui_1.note)((0, ui_1.dim)(' To link one: xflow connections link <name> --as ALIAS'));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function connectionsLink(args) {
|
|
84
|
+
const target = args.words[1];
|
|
85
|
+
if (!target) {
|
|
86
|
+
throw new errors_1.CliError('A connection is required', 'Its name is the first column of xflow connections');
|
|
87
|
+
}
|
|
88
|
+
// The alias is asked for, never guessed: it becomes the prefix of the
|
|
89
|
+
// variables, it ends up in the code of the functions, and renaming it later
|
|
90
|
+
// means editing that code.
|
|
91
|
+
const alias = (0, args_1.flagString)(args, 'as');
|
|
92
|
+
if (!alias) {
|
|
93
|
+
throw new errors_1.CliError('An alias is required', 'For example: xflow connections link "Яндекс Метрика" --as YANDEX_METRIKA');
|
|
94
|
+
}
|
|
95
|
+
const { config } = (0, config_1.requireProject)();
|
|
96
|
+
const client = (0, session_1.connect)(config);
|
|
97
|
+
const data = await (0, api_1.apiJson)(client, endpoint(config.projectId));
|
|
98
|
+
const connectionId = resolve(data.connections, target).id;
|
|
99
|
+
const result = await (0, api_1.apiJson)(client, endpoint(config.projectId), { method: 'POST', body: { connection_id: connectionId, alias } });
|
|
100
|
+
(0, ui_1.ok)(`${(0, ui_1.bold)(result.label)} linked as ${result.alias}`);
|
|
101
|
+
if (result.env.length > 0)
|
|
102
|
+
(0, ui_1.note)((0, ui_1.dim)(` The functions will get: ${result.env.join(', ')}`));
|
|
103
|
+
(0, ui_1.note)((0, ui_1.dim)(' The values arrive on the next deploy: xflow deploy'));
|
|
104
|
+
}
|
|
105
|
+
async function connectionsUnlink(args) {
|
|
106
|
+
const target = args.words[1];
|
|
107
|
+
if (!target) {
|
|
108
|
+
throw new errors_1.CliError('A connection is required', 'For example: xflow connections unlink YANDEX_METRIKA');
|
|
109
|
+
}
|
|
110
|
+
const { root, config } = (0, config_1.requireProject)();
|
|
111
|
+
const client = (0, session_1.connect)(config);
|
|
112
|
+
// The alias is what the code of the functions knows, and it wins: it is the
|
|
113
|
+
// most precise of the three, since it names the link and not just the account.
|
|
114
|
+
const data = await (0, api_1.apiJson)(client, endpoint(config.projectId));
|
|
115
|
+
const byAlias = data.connections.find((item) => item.alias !== null && item.alias === target.toUpperCase());
|
|
116
|
+
const row = byAlias ?? resolve(data.connections, target);
|
|
117
|
+
if (!row.alias) {
|
|
118
|
+
(0, ui_1.note)(`${row.label} is not linked to this project`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// The platform does not see the sources, so this check lives here. Without it
|
|
122
|
+
// the unlink is silent and the breakage shows up one deploy later.
|
|
123
|
+
const needed = (0, env_1.referencedByFunctions)(root).needed;
|
|
124
|
+
const inUse = row.env
|
|
125
|
+
.map((name) => ({ name, users: needed.get(name) ?? [] }))
|
|
126
|
+
.filter((entry) => entry.users.length > 0);
|
|
127
|
+
if (inUse.length > 0 && !(0, args_1.flagBool)(args, 'force')) {
|
|
128
|
+
(0, ui_1.fail)(`The functions of this project read the variables of ${row.alias}:`);
|
|
129
|
+
(0, ui_1.table)(inUse.map((entry) => [entry.name, entry.users.join(', ')]));
|
|
130
|
+
throw new errors_1.CliError('They would lose these variables on the next deploy', 'Repeat with --force if that is intended');
|
|
131
|
+
}
|
|
132
|
+
const result = await (0, api_1.apiJson)(client, `${endpoint(config.projectId)}?connection_id=${encodeURIComponent(row.id)}`, { method: 'DELETE' });
|
|
133
|
+
if (!result.removed) {
|
|
134
|
+
(0, ui_1.note)(`${row.label} is not linked to this project`);
|
|
135
|
+
return;
|
|
51
136
|
}
|
|
137
|
+
(0, ui_1.ok)(`${(0, ui_1.bold)(row.label)} unlinked, ${result.alias} is gone`);
|
|
138
|
+
(0, ui_1.note)((0, ui_1.dim)(' Functions already deployed keep the values until their next deploy'));
|
|
52
139
|
}
|