@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 CHANGED
@@ -98,26 +98,50 @@ async function send(client, path, options = {}) {
98
98
  assertProtocol(response);
99
99
  return response;
100
100
  }
101
- async function readError(response) {
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 new ApiError(`Request rejected (${response.status})`, 'unknown', response.status, text.slice(0, 200) || undefined);
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, `/api/v1/projects/${config.projectId}/connections`);
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: project settings, Connectors. Needs the developer role on the project'));
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
  }
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.referencedByFunctions = referencedByFunctions;
3
4
  exports.envList = envList;
4
5
  exports.envCheck = envCheck;
5
6
  exports.envSet = envSet;