@getxflow/cli 0.6.4 → 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
@@ -13,6 +13,7 @@ const zip_1 = require("./zip");
13
13
  const version_1 = require("./version");
14
14
  const auth_1 = require("./commands/auth");
15
15
  const projects_1 = require("./commands/projects");
16
+ const connections_1 = require("./commands/connections");
16
17
  const db_1 = require("./commands/db");
17
18
  const env_1 = require("./commands/env");
18
19
  const functions_1 = require("./commands/functions");
@@ -105,6 +106,20 @@ async function run(args) {
105
106
  return;
106
107
  }
107
108
  throw new errors_1.CliError(`Unknown command: env ${second}`, 'Available: list, check, set and rm');
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
+ }
118
+ if (second === undefined || second === 'list') {
119
+ await (0, connections_1.connectionsList)();
120
+ return;
121
+ }
122
+ throw new errors_1.CliError(`Unknown command: connections ${second}`, 'Available: list, link and unlink');
108
123
  case 'schedules':
109
124
  if (second === 'set') {
110
125
  await (0, schedules_1.schedulesSet)(rest);
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.connectionsList = connectionsList;
4
+ exports.connectionsLink = connectionsLink;
5
+ exports.connectionsUnlink = connectionsUnlink;
6
+ const api_1 = require("../api");
7
+ const args_1 = require("../args");
8
+ const config_1 = require("../config");
9
+ const errors_1 = require("../errors");
10
+ const session_1 = require("../session");
11
+ const ui_1 = require("../ui");
12
+ const env_1 = require("./env");
13
+ // An expiring token is renewed by any build, a revoked one is not renewed by
14
+ // anything until a human reconnects the account. Telling them apart saves a
15
+ // pointless rebuild, so every case gets its own line.
16
+ function state(row) {
17
+ if (!row.alias)
18
+ return 'available, not linked';
19
+ if (row.disabled)
20
+ return 'linked but switched off, the functions get nothing';
21
+ switch (row.health) {
22
+ case 'expiring':
23
+ return `linked, token expires in ${row.days_left} days, any build renews it`;
24
+ case 'expired':
25
+ return 'linked, access has expired, reconnect the account';
26
+ case 'revoked':
27
+ return 'linked, access was revoked by the provider, a rebuild will not help';
28
+ case 'ok':
29
+ return row.days_left === null ? 'linked' : `linked, token valid ${row.days_left} days`;
30
+ default:
31
+ return 'linked';
32
+ }
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
+ }
59
+ async function connectionsList() {
60
+ const { config } = (0, config_1.requireProject)();
61
+ const client = (0, session_1.connect)(config);
62
+ const data = await (0, api_1.apiJson)(client, endpoint(config.projectId));
63
+ if (data.connections.length === 0) {
64
+ (0, ui_1.note)('No connections you can use in this organization');
65
+ (0, ui_1.note)((0, ui_1.dim)(' Somebody connects an account first: platform settings, Connectors'));
66
+ return;
67
+ }
68
+ (0, ui_1.table)(data.connections.map((row) => [
69
+ row.label,
70
+ row.connector_key ?? '-',
71
+ row.alias ?? '-',
72
+ state(row),
73
+ ]));
74
+ const variables = data.connections.flatMap((row) => row.env);
75
+ if (variables.length > 0) {
76
+ (0, ui_1.note)((0, ui_1.dim)(` The functions get: ${variables.join(', ')}`));
77
+ (0, ui_1.note)((0, ui_1.dim)(' A change reaches a function on its next deploy: xflow deploy'));
78
+ }
79
+ if (data.connections.some((row) => !row.alias)) {
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;
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'));
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;
package/dist/help.js CHANGED
@@ -43,6 +43,11 @@ ${(0, ui_1.bold)('Functions')}
43
43
  xflow env [check] function variables: what is stored, what is missing
44
44
  xflow env set NAME=value store a variable
45
45
  xflow env rm NAME delete a variable
46
+ xflow connections connected accounts the project can use, and their variables
47
+ xflow connections link <name> --as ALIAS
48
+ give the functions the credentials of an account
49
+ xflow connections unlink <ALIAS> [--force]
50
+ take them away again
46
51
 
47
52
  ${(0, ui_1.bold)('Database')}
48
53
  xflow db status which migrations are applied and which are waiting
@@ -166,6 +171,46 @@ The value reaches the function on deploy, not at the moment it is stored: after
166
171
  ${(0, ui_1.bold)('env set')} run ${(0, ui_1.bold)('xflow deploy')}. The build ships a function whose code did not
167
172
  change but whose variables did, so nothing is left holding an old value. The same after a
168
173
  delete: a function already deployed keeps the old value until its next deploy.`,
174
+ connections: `${(0, ui_1.bold)('xflow connections')}: accounts connected to the organization
175
+
176
+ Somebody signs in to Yandex Metrika, Bitrix or a mail service once, in the platform,
177
+ and that account becomes a connection of the organization. Linked to a project, it
178
+ hands its credentials to the cloud functions as environment variables. Nobody has to
179
+ paste a token into the repository, and nobody sees the value: it goes straight from
180
+ the platform into the function.
181
+
182
+ xflow connections what this project can use, and what it already uses
183
+ xflow connections link <name> --as ALIAS
184
+ hand its credentials to the functions
185
+ xflow connections unlink <ALIAS> take them away again
186
+
187
+ Every row says whether the connection is linked to this project (its alias) and what
188
+ state the access is in. ${(0, ui_1.bold)('available, not linked')} is the useful one: the account
189
+ exists in the organization, but this project gets nothing from it yet: link it.
190
+
191
+ Both commands take the name of the connection, the first column of the list, and both
192
+ also take its identifier. ${(0, ui_1.bold)('unlink')} takes the alias as well, and prefers it: the
193
+ alias is the name your own code already uses. One organization can hold several accounts
194
+ of the same service under one name; then the command prints their identifiers and asks
195
+ for one of those instead of guessing.
196
+
197
+ ${(0, ui_1.bold)('unlink')} refuses while a function still reads one of the variables and names
198
+ those functions; ${(0, ui_1.bold)('--force')} goes through anyway.
199
+
200
+ Variables are named after the alias: an OAuth connection called ${(0, ui_1.bold)('YANDEX_METRIKA')}
201
+ gives ${(0, ui_1.bold)('YANDEX_METRIKA_TOKEN')}, a key-based one gives a variable per field. They
202
+ arrive at a function on its next deploy, like every other variable, so after a change
203
+ run ${(0, ui_1.bold)('xflow deploy')}. A token close to expiry is renewed by any build on the way;
204
+ a revoked one is not renewed by anything until a human reconnects the account.
205
+
206
+ Linking needs a right on the key, ${(0, ui_1.bold)('connections:link')}. Keys are issued with it,
207
+ and a person can take it away in the platform settings under Developers; a key cannot give
208
+ it back to itself. Losing it is what the refusal says, and the answer is to ask a person,
209
+ not to look for another route. Either way only the accounts granted to you personally can
210
+ be linked at all: that rule holds whatever the key is allowed to do.
211
+
212
+ Connecting a new account and switching one off stay with a person, in the platform
213
+ interface. There is no command for either.`,
169
214
  schedules: `${(0, ui_1.bold)('xflow schedules')}: running functions on a timer
170
215
 
171
216
  A schedule is a Yandex timer trigger: it calls the function itself, with no
package/dist/version.js CHANGED
@@ -2,6 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEFAULT_API_URL = exports.CLI_VERSION = void 0;
4
4
  /** Keep in sync with cli/package.json. */
5
- exports.CLI_VERSION = '0.6.4';
5
+ exports.CLI_VERSION = '0.6.6';
6
6
  /** Overridden by XFLOW_API_URL or the `api` field in xflow.json. */
7
7
  exports.DEFAULT_API_URL = 'https://app.getxflow.com';
package/package.json CHANGED
@@ -1,23 +1,23 @@
1
- {
2
- "name": "@getxflow/cli",
3
- "version": "0.6.4",
4
- "description": "CLI for the XFlow platform: source sync, deployment and publishing of applications",
5
- "license": "UNLICENSED",
6
- "engines": {
7
- "node": ">=20"
8
- },
9
- "bin": {
10
- "xflow": "dist/bin.js"
11
- },
12
- "files": [
13
- "dist",
14
- "skills"
15
- ],
16
- "scripts": {
17
- "build": "tsc -p tsconfig.json",
18
- "prepublishOnly": "npm run build"
19
- },
20
- "publishConfig": {
21
- "access": "public"
22
- }
23
- }
1
+ {
2
+ "name": "@getxflow/cli",
3
+ "version": "0.6.6",
4
+ "description": "CLI for the XFlow platform: source sync, deployment and publishing of applications",
5
+ "license": "UNLICENSED",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "bin": {
10
+ "xflow": "dist/bin.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "skills"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.json",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ }
23
+ }
@@ -261,6 +261,25 @@ and refuses. If a call to that service starts failing with an authorisation erro
261
261
  account was disconnected on the provider's side, which only a human can fix by reconnecting
262
262
  it in the platform settings.
263
263
 
264
+ `xflow connections` lists those accounts: the ones already linked to this project, with the
265
+ alias and the state of the access, and the ones the organization has but this project does
266
+ not use yet, marked `available, not linked`. Check it before telling anyone a service is
267
+ unavailable: the account you need is often connected already, one link away.
268
+
269
+ `xflow connections link "Яндекс Метрика" --as YANDEX_METRIKA` is that link, and
270
+ `xflow connections unlink YANDEX_METRIKA` undoes it. Name the connection the way the list
271
+ does, in its first column, or by its identifier; unlink also takes the alias, which your
272
+ own code already knows. If two accounts of the same service share a name, the command
273
+ prints their identifiers instead of guessing. Unlink refuses while a function still reads
274
+ one of the variables and names those functions, so read that list before reaching for
275
+ `--force`.
276
+
277
+ Linking needs the `connections:link` right, which keys are issued with. If it was taken
278
+ away, say so and ask the person to turn it back on in the platform settings under
279
+ Developers: a key cannot grant it to itself. Only accounts granted to the owner of the key
280
+ personally can be linked at all. Connecting a new account and switching one off stay with a
281
+ person too.
282
+
264
283
  To run a function on a timer: `xflow schedules set report "0 3 ? * * *"` (daily at 03:00).
265
284
  Six fields, UTC, and exactly one of day-of-month / day-of-week must be `?` — that is
266
285
  how Yandex wants it. A scheduled run reaches the handler as a POST with no headers.