@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 +28 -4
- package/dist/bin.js +15 -0
- package/dist/commands/connections.js +139 -0
- package/dist/commands/env.js +1 -0
- package/dist/help.js +45 -0
- package/dist/version.js +1 -1
- package/package.json +23 -23
- package/skills/xflow/SKILL.md +19 -0
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
|
@@ -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
|
+
}
|
package/dist/commands/env.js
CHANGED
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.
|
|
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
|
-
"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
|
+
}
|
package/skills/xflow/SKILL.md
CHANGED
|
@@ -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.
|