@getxflow/cli 0.10.2 → 0.10.4

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/args.js CHANGED
@@ -1,22 +1,22 @@
1
1
  "use strict";
2
2
  /** Argument parsing, dependency-free. */
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.BOOLEAN_FLAGS = void 0;
4
5
  exports.parseArgs = parseArgs;
5
6
  exports.flagString = flagString;
6
7
  exports.flagBool = flagBool;
7
8
  exports.flagNumber = flagNumber;
8
9
  /** Flags that never take a value, so `--force ./dir` keeps the path as a word. */
9
- const BOOLEAN_FLAGS = new Set([
10
+ exports.BOOLEAN_FLAGS = new Set([
10
11
  'force',
11
12
  'yes',
12
13
  'json',
14
+ 'replace',
13
15
  'help',
14
16
  'version',
15
17
  'global',
16
18
  'refresh',
17
- 'live',
18
19
  'no-push',
19
- 'skip-build',
20
20
  'all',
21
21
  'dry-run',
22
22
  'allow-destructive',
@@ -40,7 +40,7 @@ function parseArgs(argv) {
40
40
  continue;
41
41
  }
42
42
  const next = argv[i + 1];
43
- if (BOOLEAN_FLAGS.has(body) || next === undefined || next.startsWith('-')) {
43
+ if (exports.BOOLEAN_FLAGS.has(body) || next === undefined || next.startsWith('-')) {
44
44
  flags[body] = true;
45
45
  }
46
46
  else {
package/dist/bin.js CHANGED
@@ -6,6 +6,7 @@ const args_1 = require("./args");
6
6
  const config_1 = require("./config");
7
7
  const credentials_1 = require("./credentials");
8
8
  const errors_1 = require("./errors");
9
+ const flags_1 = require("./flags");
9
10
  const help_1 = require("./help");
10
11
  const limits_1 = require("./limits");
11
12
  const state_1 = require("./state");
@@ -51,11 +52,15 @@ async function run(args) {
51
52
  (0, org_1.orgSwitch)({ ...args, words: args.words.slice(2) });
52
53
  return;
53
54
  }
55
+ if (second === 'members') {
56
+ await (0, org_1.orgMembers)();
57
+ return;
58
+ }
54
59
  if (second === undefined || second === 'list') {
55
60
  (0, org_1.orgList)();
56
61
  return;
57
62
  }
58
- throw new errors_1.CliError(`Unknown command: org ${second}`, 'Available: list and switch');
63
+ throw new errors_1.CliError(`Unknown command: org ${second}`, 'Available: list, members and switch');
59
64
  case 'templates':
60
65
  await (0, projects_1.templates)();
61
66
  return;
@@ -94,7 +99,7 @@ async function run(args) {
94
99
  return;
95
100
  }
96
101
  if (second === undefined || second === 'list') {
97
- await (0, functions_1.functionsList)();
102
+ await (0, functions_1.functionsList)(rest);
98
103
  return;
99
104
  }
100
105
  throw new errors_1.CliError(`Unknown command: functions ${second}`, 'Available: list, invoke and logs');
@@ -115,7 +120,7 @@ async function run(args) {
115
120
  return;
116
121
  }
117
122
  if (second === undefined || second === 'list') {
118
- await (0, env_1.envList)();
123
+ await (0, env_1.envList)(rest);
119
124
  return;
120
125
  }
121
126
  throw new errors_1.CliError(`Unknown command: env ${second}`, 'Available: list, check, set and rm');
@@ -129,7 +134,7 @@ async function run(args) {
129
134
  return;
130
135
  }
131
136
  if (second === undefined || second === 'list') {
132
- await (0, connections_1.connectionsList)();
137
+ await (0, connections_1.connectionsList)(rest);
133
138
  return;
134
139
  }
135
140
  throw new errors_1.CliError(`Unknown command: connections ${second}`, 'Available: list, link and unlink');
@@ -143,7 +148,7 @@ async function run(args) {
143
148
  return;
144
149
  }
145
150
  if (second === undefined || second === 'list') {
146
- await (0, schedules_1.schedulesList)();
151
+ await (0, schedules_1.schedulesList)(rest);
147
152
  return;
148
153
  }
149
154
  throw new errors_1.CliError(`Unknown command: schedules ${second}`, 'Available: list, set and rm');
@@ -152,6 +157,10 @@ async function run(args) {
152
157
  await (0, db_1.dbMigrate)(rest);
153
158
  return;
154
159
  }
160
+ if (second === 'list') {
161
+ await (0, db_1.dbList)();
162
+ return;
163
+ }
155
164
  if (second === 'schema') {
156
165
  await (0, db_1.dbSchema)({ ...args, words: args.words.slice(2) });
157
166
  return;
@@ -164,7 +173,7 @@ async function run(args) {
164
173
  await (0, db_1.dbStatus)();
165
174
  return;
166
175
  }
167
- throw new errors_1.CliError(`Unknown command: db ${second}`, 'Available: status, schema, query and migrate');
176
+ throw new errors_1.CliError(`Unknown command: db ${second}`, 'Available: status, list, schema, query and migrate');
168
177
  case 'storage':
169
178
  if (second === 'push') {
170
179
  await (0, storage_1.storagePush)(rest);
@@ -189,13 +198,13 @@ async function run(args) {
189
198
  await (0, deploy_1.deploy)(rest);
190
199
  return;
191
200
  case 'publish':
192
- await (0, deploy_1.publish)();
201
+ await (0, deploy_1.publish)(rest);
193
202
  return;
194
203
  case 'rollback':
195
204
  await (0, deploy_1.rollback)(rest);
196
205
  return;
197
206
  case 'deployments':
198
- await (0, deploy_1.deployments)();
207
+ await (0, deploy_1.deployments)(rest);
199
208
  return;
200
209
  case 'update':
201
210
  (0, update_1.update)();
@@ -255,6 +264,7 @@ async function main() {
255
264
  // carries the same advice in its hint.
256
265
  let quiet = args.words[0] === 'update';
257
266
  try {
267
+ (0, flags_1.checkFlags)(args);
258
268
  await run(args);
259
269
  return 0;
260
270
  }
@@ -265,8 +275,15 @@ async function main() {
265
275
  (0, ui_1.note)((0, ui_1.dim)(` ${(0, limits_1.limitLine)(e.limit)}`));
266
276
  if (e.hint)
267
277
  (0, ui_1.note)((0, ui_1.dim)(` ${e.hint}`));
268
- if (e.code === 'not_found' && (0, credentials_1.anyMultipleOrgs)()) {
269
- (0, ui_1.note)((0, ui_1.dim)(' Several organizations are signed in: the answer may live in another one, see xflow org'));
278
+ if (e.code === 'not_found') {
279
+ // XFLOW_TOKEN is taken as given and no organization is looked for, so a
280
+ // project of another one answers "not found" with nothing else to say.
281
+ if (process.env.XFLOW_TOKEN?.trim()) {
282
+ (0, ui_1.note)((0, ui_1.dim)(' XFLOW_TOKEN is set: it is the key of one organization and is used as it comes, so a project of another one is not found'));
283
+ }
284
+ else if ((0, credentials_1.anyMultipleOrgs)()) {
285
+ (0, ui_1.note)((0, ui_1.dim)(' Several organizations are signed in: the answer may live in another one, see xflow org'));
286
+ }
270
287
  }
271
288
  quiet = quiet || e.code === 'outdated_cli';
272
289
  return 1;
@@ -56,10 +56,9 @@ function resolve(connections, target) {
56
56
  }
57
57
  throw new errors_1.CliError(`No connection ${target}`, 'What this project can use: xflow connections');
58
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));
59
+ async function connectionsList(args) {
60
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
61
+ const data = await (0, api_1.apiJson)(client, endpoint(projectId));
63
62
  if (data.connections.length === 0) {
64
63
  (0, ui_1.note)('No connections you can use in this organization');
65
64
  (0, ui_1.note)((0, ui_1.dim)(' Somebody connects an account first: platform settings, Connectors'));
@@ -92,11 +91,10 @@ async function connectionsLink(args) {
92
91
  if (!alias) {
93
92
  throw new errors_1.CliError('An alias is required', 'For example: xflow connections link "Яндекс Метрика" --as YANDEX_METRIKA');
94
93
  }
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));
94
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
95
+ const data = await (0, api_1.apiJson)(client, endpoint(projectId));
98
96
  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 } });
97
+ const result = await (0, api_1.apiJson)(client, endpoint(projectId), { method: 'POST', body: { connection_id: connectionId, alias } });
100
98
  (0, ui_1.ok)(`${(0, ui_1.bold)(result.label)} linked as ${result.alias}`);
101
99
  if (result.env.length > 0)
102
100
  (0, ui_1.note)((0, ui_1.dim)(` The functions will get: ${result.env.join(', ')}`));
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.dbStatus = dbStatus;
4
+ exports.dbList = dbList;
4
5
  exports.dbSchema = dbSchema;
5
6
  exports.dbQuery = dbQuery;
6
7
  exports.dbMigrate = dbMigrate;
@@ -76,6 +77,33 @@ async function dbStatus() {
76
77
  (0, ui_1.warn)('A changed file will not be applied again: write a new migration');
77
78
  }
78
79
  }
80
+ /**
81
+ * Logical databases of the organization, and who is attached to them.
82
+ *
83
+ * The identifier is what `xflow init --database <id>` asks for, and the list of
84
+ * projects is not decoration: a database is shared, so a migration written for
85
+ * one application lands in the others too.
86
+ */
87
+ async function dbList() {
88
+ const client = (0, session_1.connect)((0, config_1.localConfig)());
89
+ const data = await (0, api_1.apiJson)(client, '/api/v1/org/databases');
90
+ if (data.databases.length === 0) {
91
+ (0, ui_1.note)('The organization has no databases');
92
+ (0, ui_1.note)((0, ui_1.dim)(' One is created with the first project: xflow init'));
93
+ return;
94
+ }
95
+ (0, ui_1.table)(data.databases.map((database) => [
96
+ database.name,
97
+ database.id,
98
+ database.schema ?? '-',
99
+ database.projects.length === 0
100
+ ? 'no projects'
101
+ : database.projects.map((project) => project.name).join(', '),
102
+ ]));
103
+ if (data.truncated)
104
+ (0, ui_1.note)((0, ui_1.dim)(` Shown ${data.databases.length} of ${data.total}`));
105
+ (0, ui_1.note)((0, ui_1.dim)(' One database is shared across projects: a migration affects all of them'));
106
+ }
79
107
  /** Long values would tear the columns apart; the cut is marked, never silent. */
80
108
  const CELL_LIMIT = 60;
81
109
  function cell(value) {
@@ -92,10 +120,9 @@ function cell(value) {
92
120
  * in there".
93
121
  */
94
122
  async function dbSchema(args) {
95
- const { config } = (0, config_1.requireProject)();
96
- const client = (0, session_1.connect)(config);
123
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
97
124
  const wanted = args.words[0];
98
- const path = `/api/v1/projects/${config.projectId}/db/schema${wanted ? `?table=${encodeURIComponent(wanted)}` : ''}`;
125
+ const path = `/api/v1/projects/${projectId}/db/schema${wanted ? `?table=${encodeURIComponent(wanted)}` : ''}`;
99
126
  if (wanted) {
100
127
  const data = await (0, api_1.apiJson)(client, path);
101
128
  (0, ui_1.out)(`${(0, ui_1.bold)(`${data.schema}.${data.table}`)}`);
@@ -134,13 +161,12 @@ async function dbSchema(args) {
134
161
  * database itself rather than by us guessing at the text of the SQL.
135
162
  */
136
163
  async function dbQuery(args) {
137
- const { config } = (0, config_1.requireProject)();
138
164
  const sql = args.words.join(' ').trim();
139
165
  if (!sql) {
140
166
  throw new errors_1.CliError('A query is required', 'For example: xflow db query "select * from orders order by created_at desc limit 20"');
141
167
  }
142
- const client = (0, session_1.connect)(config);
143
- const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/db/query`, {
168
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
169
+ const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/db/query`, {
144
170
  method: 'POST',
145
171
  body: { sql, limit: (0, args_1.flagNumber)(args, 'limit') },
146
172
  timeoutMs: 60_000,
@@ -122,7 +122,7 @@ async function assertBuildAllowed(client, projectId) {
122
122
  }
123
123
  async function deploy(args) {
124
124
  const { root, config } = (0, config_1.requireProject)();
125
- const client = await (0, session_1.connectProject)(root, config);
125
+ const client = await (0, session_1.connectProject)(config.projectId, root, config);
126
126
  let revision;
127
127
  if ((0, args_1.flagBool)(args, 'no-push')) {
128
128
  const server = await (0, sources_1.latestRevision)(client, config.projectId);
@@ -170,11 +170,10 @@ async function deploy(args) {
170
170
  (0, ui_1.note)((0, ui_1.dim)(' and are not held back by publishing'));
171
171
  (0, ui_1.note)((0, ui_1.dim)(' Show the new pages to visitors: xflow publish'));
172
172
  }
173
- async function publish() {
174
- const { root, config } = (0, config_1.requireProject)();
175
- const client = await (0, session_1.connectProject)(root, config);
173
+ async function publish(args) {
174
+ const { projectId, client } = await (0, session_1.projectTarget)(args, { probe: true });
176
175
  (0, ui_1.step)('Publishing the current dev version');
177
- const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/publish`, { method: 'POST', timeoutMs: 120_000 });
176
+ const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/publish`, { method: 'POST', timeoutMs: 120_000 });
178
177
  if (result.already_published) {
179
178
  (0, ui_1.ok)('This version is already published');
180
179
  }
@@ -186,13 +185,12 @@ async function publish() {
186
185
  (0, ui_1.note)((0, ui_1.dim)(' have been running the code of the last xflow deploy since it finished'));
187
186
  }
188
187
  async function rollback(args) {
189
- const { root, config } = (0, config_1.requireProject)();
190
188
  const deployId = args.words[0];
191
189
  if (!deployId) {
192
190
  throw new errors_1.CliError('A version number is required', 'To see the versions: xflow deployments. For example: xflow rollback 481203');
193
191
  }
194
- const client = await (0, session_1.connectProject)(root, config);
195
- const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/rollback`, { method: 'POST', body: { deploy_id: deployId } });
192
+ const { projectId, client } = await (0, session_1.projectTarget)(args, { probe: true });
193
+ const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/rollback`, { method: 'POST', body: { deploy_id: deployId } });
196
194
  (0, ui_1.ok)(`The project now serves the pages of build ${result.deploy_id}`);
197
195
  (0, ui_1.out)(result.project_url);
198
196
  (0, ui_1.note)((0, ui_1.dim)(' Only the pages came back. Cloud functions and the database are one per project:'));
@@ -203,10 +201,9 @@ async function rollback(args) {
203
201
  }
204
202
  (0, ui_1.note)((0, ui_1.dim)(' Visitors see these pages only after xflow publish'));
205
203
  }
206
- async function deployments() {
207
- const { root, config } = (0, config_1.requireProject)();
208
- const client = await (0, session_1.connectProject)(root, config);
209
- const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/deployments`);
204
+ async function deployments(args) {
205
+ const { projectId, client } = await (0, session_1.projectTarget)(args, { probe: true });
206
+ const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/deployments`);
210
207
  if (data.deployments.length === 0) {
211
208
  (0, ui_1.note)('No versions yet. To build and release: xflow deploy');
212
209
  return;
@@ -82,14 +82,17 @@ function referencedByFunctions(root) {
82
82
  }
83
83
  return { needed, provided, opaque };
84
84
  }
85
- async function fetchVariables() {
86
- const { root, config } = (0, config_1.requireProject)();
87
- const client = (0, session_1.connect)(config);
88
- const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/env`);
89
- return { names: new Set(data.variables.map((row) => row.name)), rows: data.variables, root };
85
+ /**
86
+ * The stored variables. The project is resolved by the caller: list takes
87
+ * --project, check reads the sources and so needs the folder it is standing in.
88
+ */
89
+ async function fetchVariables(client, projectId) {
90
+ const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/env`);
91
+ return { names: new Set(data.variables.map((row) => row.name)), rows: data.variables };
90
92
  }
91
- async function envList() {
92
- const { rows } = await fetchVariables();
93
+ async function envList(args) {
94
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
95
+ const { rows } = await fetchVariables(client, projectId);
93
96
  if (rows.length === 0) {
94
97
  (0, ui_1.note)('No variables');
95
98
  (0, ui_1.note)((0, ui_1.dim)(' To store one: xflow env set SMTP_PASSWORD=secret'));
@@ -100,7 +103,8 @@ async function envList() {
100
103
  }
101
104
  /** Check that every referenced variable is stored. */
102
105
  async function envCheck() {
103
- const { names, root } = await fetchVariables();
106
+ const { root, config } = (0, config_1.requireProject)();
107
+ const { names } = await fetchVariables((0, session_1.connect)(config), config.projectId);
104
108
  const { needed, provided, opaque } = referencedByFunctions(root);
105
109
  if (needed.size === 0 && provided.size === 0 && opaque.size === 0) {
106
110
  (0, ui_1.note)('The functions of this project read no environment variables');
@@ -164,7 +168,10 @@ async function envSet(args) {
164
168
  const value = pair.slice(pair.indexOf('=') + 1);
165
169
  const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/env`, {
166
170
  method: 'POST',
167
- body: { name, value, scope: (0, args_1.flagString)(args, 'scope') === 'project' ? 'project' : 'organization' },
171
+ // The word goes to the platform as typed: it knows the two scopes and
172
+ // refuses a third. Guessing here would have made --scope proj mean the
173
+ // whole organization, which is the opposite of what such a typo means.
174
+ body: { name, value, scope: (0, args_1.flagString)(args, 'scope') },
168
175
  });
169
176
  (0, ui_1.ok)(`${(0, ui_1.bold)(result.name)} stored (${result.scope === 'project' ? 'this project only' : 'the whole organization'})`);
170
177
  // Values reach a function on its next deploy.
@@ -174,12 +181,11 @@ async function envSet(args) {
174
181
  }
175
182
  }
176
183
  async function envRemove(args) {
177
- const { config } = (0, config_1.requireProject)();
178
- const client = (0, session_1.connect)(config);
179
184
  const name = args.words[1];
180
185
  if (!name)
181
186
  throw new errors_1.CliError('A variable name is required', 'What is stored: xflow env');
182
- const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/env?name=${encodeURIComponent(name)}`, { method: 'DELETE' });
187
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
188
+ const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/env?name=${encodeURIComponent(name)}`, { method: 'DELETE' });
183
189
  if (!result.removed) {
184
190
  (0, ui_1.note)(`There is no variable ${name}`);
185
191
  return;
@@ -4,8 +4,8 @@ exports.functionsList = functionsList;
4
4
  exports.functionsInvoke = functionsInvoke;
5
5
  const api_1 = require("../api");
6
6
  const args_1 = require("../args");
7
- const config_1 = require("../config");
8
7
  const errors_1 = require("../errors");
8
+ const json_arg_1 = require("../json-arg");
9
9
  const session_1 = require("../session");
10
10
  const ui_1 = require("../ui");
11
11
  /** Who can call the function: inside the app only, or an outside service holding a key. */
@@ -15,10 +15,9 @@ function accessLabel(fn) {
15
15
  return `external (${fn.external_keys} key${fn.external_keys > 1 ? 's' : ''})`;
16
16
  }
17
17
  const FUNCTIONS_DIR = 'functions';
18
- async function functionsList() {
19
- const { config } = (0, config_1.requireProject)();
20
- const client = (0, session_1.connect)(config);
21
- const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/functions`);
18
+ async function functionsList(args) {
19
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
20
+ const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/functions`);
22
21
  if (data.functions.length === 0) {
23
22
  (0, ui_1.note)(`No functions. Put the code in ${FUNCTIONS_DIR}/<name>/index.ts and run xflow deploy`);
24
23
  return;
@@ -41,27 +40,28 @@ function prettyBody(text) {
41
40
  }
42
41
  /** Call a function the same way the application does. */
43
42
  async function functionsInvoke(args) {
44
- const { config } = (0, config_1.requireProject)();
45
- const client = (0, session_1.connect)(config);
43
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
46
44
  const name = args.words[1];
47
45
  if (!name)
48
46
  throw new errors_1.CliError('A function name is required', 'What is deployed: xflow functions list');
49
- const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}`);
47
+ // The body is judged before anything goes over the network: a broken one is the
48
+ // user's own line, and it costs nothing to say so at once.
49
+ const data = (0, json_arg_1.jsonArg)(args, 'data');
50
+ const method = ((0, args_1.flagString)(args, 'method') ?? (data ? 'POST' : 'GET')).toUpperCase();
51
+ const sendsBody = method !== 'GET' && method !== 'HEAD';
52
+ const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}`);
50
53
  const fn = card.functions.find((item) => item.name === name);
51
54
  if (!fn || !fn.invoke_url) {
52
55
  throw new errors_1.CliError(`Function ${name} is not deployed`, card.functions.length > 0
53
56
  ? `Deployed: ${card.functions.map((item) => item.name).join(', ')}`
54
57
  : 'Functions ship with the build: xflow deploy');
55
58
  }
56
- const data = (0, args_1.flagString)(args, 'data');
57
- const method = ((0, args_1.flagString)(args, 'method') ?? (data ? 'POST' : 'GET')).toUpperCase();
58
- const sendsBody = method !== 'GET' && method !== 'HEAD';
59
59
  // A pass for the person who owns the key: the project token alone is not an
60
60
  // identity, and a project built with the current template refuses without one.
61
61
  // A read-only key cannot get a pass, so the call still goes out without it.
62
62
  let pass = '';
63
63
  try {
64
- const issued = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/pass`, { method: 'POST' });
64
+ const issued = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/pass`, { method: 'POST' });
65
65
  pass = issued.pass;
66
66
  }
67
67
  catch {
@@ -4,7 +4,6 @@ exports.logs = logs;
4
4
  exports.functionsLogs = functionsLogs;
5
5
  const api_1 = require("../api");
6
6
  const args_1 = require("../args");
7
- const config_1 = require("../config");
8
7
  const session_1 = require("../session");
9
8
  const ui_1 = require("../ui");
10
9
  const DEFAULT_LIMIT = 10;
@@ -15,12 +14,11 @@ function stamp(iso) {
15
14
  return `${pad(date.getDate())}.${pad(date.getMonth() + 1)} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
16
15
  }
17
16
  async function fetchLogs(source, name, args) {
18
- const { config } = (0, config_1.requireProject)();
19
- const client = (0, session_1.connect)(config);
17
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
20
18
  const query = new URLSearchParams({ source, limit: String((0, args_1.flagNumber)(args, 'limit') ?? DEFAULT_LIMIT) });
21
19
  if (name)
22
20
  query.set('name', name);
23
- const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/logs?${query.toString()}`);
21
+ const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/logs?${query.toString()}`);
24
22
  return data.logs;
25
23
  }
26
24
  /** Newest at the bottom. */
@@ -1,10 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.orgList = orgList;
4
+ exports.orgMembers = orgMembers;
4
5
  exports.orgSwitch = orgSwitch;
6
+ const api_1 = require("../api");
5
7
  const config_1 = require("../config");
6
8
  const credentials_1 = require("../credentials");
7
9
  const errors_1 = require("../errors");
10
+ const session_1 = require("../session");
8
11
  const ui_1 = require("../ui");
9
12
  const mcp_1 = require("./mcp");
10
13
  function label(org) {
@@ -35,6 +38,22 @@ function orgList() {
35
38
  (0, ui_1.note)((0, ui_1.dim)(' Switch: xflow org switch <name or id>'));
36
39
  (0, ui_1.note)((0, ui_1.dim)(' To add an organization, sign in to it: xflow login'));
37
40
  }
41
+ /**
42
+ * Who is in the organization this key belongs to, and with which role. Reading
43
+ * only: people and money are the owner's, in the platform interface.
44
+ */
45
+ async function orgMembers() {
46
+ const client = (0, session_1.connect)((0, config_1.localConfig)());
47
+ const data = await (0, api_1.apiJson)(client, '/api/v1/org/members');
48
+ if (data.members.length === 0) {
49
+ (0, ui_1.note)('The organization has no members');
50
+ return;
51
+ }
52
+ (0, ui_1.table)(data.members.map((member) => [member.name ?? '(unnamed)', member.email ?? '-', member.role]));
53
+ const org = (0, credentials_1.listOrgs)(client.apiUrl).find((entry) => entry.organizationId === client.organizationId);
54
+ const count = data.truncated ? `${data.members.length} of ${data.total}` : String(data.total);
55
+ (0, ui_1.note)((0, ui_1.dim)(` ${count} in "${org ? label(org) : 'the organization'}". Inviting and removing people happens in the platform interface`));
56
+ }
38
57
  /** Local switch of the active organization: a pointer move, no browser. */
39
58
  function orgSwitch(args) {
40
59
  const wanted = args.words[0];
@@ -4,15 +4,13 @@ exports.schedulesList = schedulesList;
4
4
  exports.schedulesSet = schedulesSet;
5
5
  exports.schedulesRemove = schedulesRemove;
6
6
  const api_1 = require("../api");
7
- const args_1 = require("../args");
8
- const config_1 = require("../config");
9
7
  const errors_1 = require("../errors");
8
+ const json_arg_1 = require("../json-arg");
10
9
  const session_1 = require("../session");
11
10
  const ui_1 = require("../ui");
12
- async function schedulesList() {
13
- const { config } = (0, config_1.requireProject)();
14
- const client = (0, session_1.connect)(config);
15
- const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/schedules`);
11
+ async function schedulesList(args) {
12
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
13
+ const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/schedules`);
16
14
  if (data.schedules.length === 0) {
17
15
  (0, ui_1.note)('No schedules');
18
16
  (0, ui_1.note)((0, ui_1.dim)(' To run a function on a timer: xflow schedules set <function> "0 3 ? * * *"'));
@@ -26,23 +24,21 @@ async function schedulesList() {
26
24
  ]));
27
25
  }
28
26
  async function schedulesSet(args) {
29
- const { config } = (0, config_1.requireProject)();
30
- const client = (0, session_1.connect)(config);
31
27
  const functionName = args.words[1];
32
28
  const cron = args.words[2];
33
29
  if (!functionName || !cron) {
34
30
  throw new errors_1.CliError('A function name and a schedule are required', 'For example: xflow schedules set nightly-report "0 3 ? * * *" runs every day at 03:00 UTC');
35
31
  }
36
- const row = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/schedules`, {
32
+ const payload = (0, json_arg_1.jsonArg)(args, 'payload') ?? null;
33
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
34
+ const row = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/schedules`, {
37
35
  method: 'POST',
38
- body: { function: functionName, cron, payload: (0, args_1.flagString)(args, 'payload') ?? null },
36
+ body: { function: functionName, cron, payload },
39
37
  });
40
38
  (0, ui_1.ok)(`${(0, ui_1.bold)(row.function)} runs ${row.description}`);
41
39
  (0, ui_1.note)((0, ui_1.dim)(` The time is UTC. To check by hand: xflow functions invoke ${row.function}`));
42
40
  }
43
41
  async function schedulesRemove(args) {
44
- const { config } = (0, config_1.requireProject)();
45
- const client = (0, session_1.connect)(config);
46
42
  const functionName = args.words[1];
47
43
  if (!functionName) {
48
44
  throw new errors_1.CliError('A function name is required', 'What runs on a timer: xflow schedules list');
@@ -51,7 +47,8 @@ async function schedulesRemove(args) {
51
47
  const query = new URLSearchParams({ function: functionName });
52
48
  if (cron)
53
49
  query.set('cron', cron);
54
- const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/schedules?${query.toString()}`, { method: 'DELETE' });
50
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
51
+ const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/schedules?${query.toString()}`, { method: 'DELETE' });
55
52
  if (result.removed === 0) {
56
53
  (0, ui_1.note)(`Function ${functionName} has no such schedule`);
57
54
  return;
@@ -154,7 +154,7 @@ async function pull(args) {
154
154
  const { root, config } = (0, config_1.requireProject)();
155
155
  const into = (0, args_1.flagString)(args, 'into');
156
156
  const target = into ? (0, node_path_1.resolve)(into) : root;
157
- const client = await (0, session_1.connectProject)(root, config);
157
+ const client = await (0, session_1.connectProject)(config.projectId, root, config);
158
158
  if (hasContent(target) && !(0, args_1.flagBool)(args, 'force')) {
159
159
  throw new errors_1.CliError(`The directory ${target} is not empty`, 'The platform cannot merge changes, that is git work. Fetch the server copy alongside: ' +
160
160
  'xflow pull --into ./server-copy, or overwrite the folder completely: xflow pull --force');
@@ -172,7 +172,7 @@ async function pull(args) {
172
172
  }
173
173
  async function status() {
174
174
  const { root, config } = (0, config_1.requireProject)();
175
- const client = await (0, session_1.connectProject)(root, config);
175
+ const client = await (0, session_1.connectProject)(config.projectId, root, config);
176
176
  const tree = prepareTree(root, config);
177
177
  const state = (0, config_1.readState)(root);
178
178
  const [card, server, history] = await Promise.all([
@@ -11,7 +11,6 @@ const node_path_1 = require("node:path");
11
11
  const node_stream_1 = require("node:stream");
12
12
  const api_1 = require("../api");
13
13
  const args_1 = require("../args");
14
- const config_1 = require("../config");
15
14
  const errors_1 = require("../errors");
16
15
  const session_1 = require("../session");
17
16
  const ui_1 = require("../ui");
@@ -158,10 +157,9 @@ async function fetchAll(client, projectId, folder) {
158
157
  return files;
159
158
  }
160
159
  async function storageList(args) {
161
- const { config } = (0, config_1.requireProject)();
162
- const client = (0, session_1.connect)(config);
160
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
163
161
  const folder = args.words[1] ?? '';
164
- const files = await fetchAll(client, config.projectId, folder);
162
+ const files = await fetchAll(client, projectId, folder);
165
163
  if ((0, args_1.flagBool)(args, 'json')) {
166
164
  (0, ui_1.out)(JSON.stringify({ files }, null, 2));
167
165
  return;
@@ -210,8 +208,6 @@ async function inParallel(items, limit, task) {
210
208
  return results;
211
209
  }
212
210
  async function storagePush(args) {
213
- const { config } = (0, config_1.requireProject)();
214
- const client = (0, session_1.connect)(config);
215
211
  const source = args.words[1];
216
212
  if (!source) {
217
213
  throw new errors_1.CliError('A folder or a file is required', 'For example: xflow storage push ./media --to media');
@@ -239,9 +235,10 @@ async function storagePush(args) {
239
235
  }
240
236
  const replace = (0, args_1.flagBool)(args, 'replace');
241
237
  const asJson = (0, args_1.flagBool)(args, 'json');
238
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
242
239
  // What is already there decides what to send: the same run repeated has to be
243
240
  // cheap and quiet, not a pile of conflicts.
244
- const known = new Map((await fetchAll(client, config.projectId, target)).map((f) => [f.path, f]));
241
+ const known = new Map((await fetchAll(client, projectId, target)).map((f) => [f.path, f]));
245
242
  const outcome = { uploaded: [], skipped: [], failed: [] };
246
243
  const pending = [];
247
244
  for (const file of files) {
@@ -270,7 +267,7 @@ async function storagePush(args) {
270
267
  (0, ui_1.step)(`Uploading ${pending.length} file(s), ${(0, ui_1.formatBytes)(bytes)}`);
271
268
  }
272
269
  for (const batch of batches) {
273
- const { files: tickets } = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/storage/upload-url`, {
270
+ const { files: tickets } = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/storage/upload-url`, {
274
271
  method: 'POST',
275
272
  body: {
276
273
  files: batch.map((file) => ({
@@ -306,7 +303,7 @@ async function storagePush(args) {
306
303
  // Confirm right after the batch, not at the end: an object that is in the
307
304
  // bucket without a record is invisible to the project and still takes up
308
305
  // the quota.
309
- const confirmed = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/storage/confirm`, {
306
+ const confirmed = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/storage/confirm`, {
310
307
  method: 'POST',
311
308
  body: { files: done.map(({ ticket }) => ({ s3_key: ticket.s3_key })) },
312
309
  });
@@ -343,8 +340,6 @@ function report(outcome, asJson) {
343
340
  }
344
341
  }
345
342
  async function storageRemove(args) {
346
- const { config } = (0, config_1.requireProject)();
347
- const client = (0, session_1.connect)(config);
348
343
  const folder = (0, args_1.flagString)(args, 'folder');
349
344
  const url = args.words[1];
350
345
  if ((!url && !folder) || (url && folder)) {
@@ -353,9 +348,10 @@ async function storageRemove(args) {
353
348
  const query = new URLSearchParams(url ? { url } : { folder: folder });
354
349
  if ((0, args_1.flagBool)(args, 'yes'))
355
350
  query.set('confirm', 'true');
351
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
356
352
  let result;
357
353
  try {
358
- result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/storage?${query.toString()}`, { method: 'DELETE' });
354
+ result = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/storage?${query.toString()}`, { method: 'DELETE' });
359
355
  }
360
356
  catch (e) {
361
357
  // The platform counts what a folder holds and refuses until the answer is
package/dist/flags.js ADDED
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.checkFlags = checkFlags;
4
+ const args_1 = require("./args");
5
+ const errors_1 = require("./errors");
6
+ const help_1 = require("./help");
7
+ /**
8
+ * Which flags each command answers to.
9
+ *
10
+ * An unknown flag used to pass in silence, and one written before a word ate that
11
+ * word: `functions logs --nosuch flows` answered for every function instead of the
12
+ * one asked for, and the answer looked truthful. A mistyped switch is worse still:
13
+ * `db migrate --dryrun` applied the migrations for real.
14
+ *
15
+ * Keys are the command as it is typed. A command that is not here is not checked,
16
+ * so a new one keeps working until it is added, and the list also drives the hint:
17
+ * whoever is at the keyboard gets the flags of this very command back.
18
+ *
19
+ * `--project` is a flag like any other here, and it is written against a command
20
+ * only when that command does not read the working copy. The ones that do (deploy,
21
+ * pull, status, db migrate, db status, env check, env set, connections unlink) have
22
+ * a folder to stand in, and naming a project they cannot read would be a lie.
23
+ */
24
+ const KNOWN = {
25
+ help: [],
26
+ login: [],
27
+ logout: ['all'],
28
+ whoami: [],
29
+ org: [],
30
+ 'org list': [],
31
+ 'org members': [],
32
+ 'org switch': [],
33
+ templates: [],
34
+ init: ['name', 'template', 'database'],
35
+ link: [],
36
+ projects: [],
37
+ 'projects list': [],
38
+ 'projects get': [],
39
+ skills: ['refresh', 'global', 'agent', 'yes'],
40
+ mcp: ['show-token'],
41
+ 'mcp install': ['show-token'],
42
+ functions: ['project'],
43
+ 'functions list': ['project'],
44
+ 'functions invoke': ['data', 'data-file', 'method', 'project'],
45
+ 'functions logs': ['limit', 'project'],
46
+ logs: ['limit', 'project'],
47
+ env: ['project'],
48
+ 'env list': ['project'],
49
+ 'env set': ['scope'],
50
+ 'env rm': ['project'],
51
+ 'env remove': ['project'],
52
+ 'env check': [],
53
+ connections: ['project'],
54
+ 'connections list': ['project'],
55
+ 'connections link': ['as', 'project'],
56
+ 'connections unlink': ['force'],
57
+ schedules: ['project'],
58
+ 'schedules list': ['project'],
59
+ 'schedules set': ['payload', 'payload-file', 'project'],
60
+ 'schedules rm': ['project'],
61
+ 'schedules remove': ['project'],
62
+ db: [],
63
+ 'db status': [],
64
+ 'db list': [],
65
+ 'db schema': ['project'],
66
+ 'db query': ['limit', 'project'],
67
+ 'db migrate': ['dry-run', 'allow-destructive'],
68
+ storage: ['json', 'project'],
69
+ 'storage ls': ['json', 'project'],
70
+ 'storage list': ['json', 'project'],
71
+ 'storage push': ['to', 'replace', 'json', 'project'],
72
+ 'storage rm': ['folder', 'yes', 'project'],
73
+ 'storage remove': ['folder', 'yes', 'project'],
74
+ status: [],
75
+ pull: ['into', 'force', 'revision'],
76
+ deploy: ['no-push', 'force', 'allow-removals'],
77
+ publish: ['project'],
78
+ rollback: ['project'],
79
+ deployments: ['project'],
80
+ update: [],
81
+ };
82
+ /** Answered everywhere: both are handled before the command runs. */
83
+ const GLOBAL = new Set(['help', 'version']);
84
+ /**
85
+ * Commands whose second word is a subcommand rather than a value. An unknown one
86
+ * belongs to the dispatcher, which names it and lists the real ones: complaining
87
+ * about a flag of a command that does not exist would send the reader the wrong way.
88
+ */
89
+ const NAMESPACES = new Set([
90
+ 'org',
91
+ 'projects',
92
+ 'functions',
93
+ 'env',
94
+ 'connections',
95
+ 'schedules',
96
+ 'db',
97
+ 'storage',
98
+ 'mcp',
99
+ ]);
100
+ /** The command these words name, as far as the table knows it. */
101
+ function commandOf(words) {
102
+ const [first, second] = words;
103
+ if (!first)
104
+ return null;
105
+ if (second !== undefined) {
106
+ if (Object.hasOwn(KNOWN, `${first} ${second}`))
107
+ return `${first} ${second}`;
108
+ if (NAMESPACES.has(first))
109
+ return null;
110
+ }
111
+ return Object.hasOwn(KNOWN, first) ? first : null;
112
+ }
113
+ function checkFlags(args) {
114
+ const command = commandOf(args.words);
115
+ if (command === null)
116
+ return;
117
+ const allowed = KNOWN[command];
118
+ const topic = command.split(' ')[0];
119
+ // Not every command has a page, and sending the reader to "No page for login"
120
+ // would be a second dead end in the same breath as the first.
121
+ const page = (0, help_1.hasHelpPage)(topic) ? `xflow help ${topic}` : 'xflow help';
122
+ for (const [name, value] of Object.entries(args.flags)) {
123
+ if (GLOBAL.has(name))
124
+ continue;
125
+ if (!allowed.includes(name)) {
126
+ throw new errors_1.CliError(`xflow ${command} has no flag --${name}`, allowed.length > 0
127
+ ? `It takes ${allowed.map((flag) => `--${flag}`).join(', ')}. What each one does: ${page}`
128
+ : `It takes no flags. What it does: ${page}`);
129
+ }
130
+ // The value went missing, so the flag arrived as a bare switch. Left alone it
131
+ // would mean the default, which is never what the person typing it wanted.
132
+ if (value === true && !args_1.BOOLEAN_FLAGS.has(name)) {
133
+ throw new errors_1.CliError(`--${name} needs a value`, `Put it after the flag, or write --${name}=value when the value starts with a dash`);
134
+ }
135
+ }
136
+ }
package/dist/help.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hasHelpPage = hasHelpPage;
3
4
  exports.help = help;
4
5
  const ui_1 = require("./ui");
5
6
  const version_1 = require("./version");
@@ -14,6 +15,10 @@ const ALIASES = {
14
15
  deployments: 'rollback',
15
16
  };
16
17
  /** Help text. For agents this is the reference documentation. */
18
+ /** Whether `xflow help <topic>` has a page: a hint that sends nowhere is a second dead end. */
19
+ function hasHelpPage(topic) {
20
+ return Object.hasOwn(TOPICS, ALIASES[topic] ?? topic);
21
+ }
17
22
  function help(topic) {
18
23
  if (topic) {
19
24
  const page = TOPICS[ALIASES[topic] ?? topic];
@@ -77,6 +82,7 @@ ${(0, ui_1.bold)('Functions')}
77
82
 
78
83
  ${(0, ui_1.bold)('Database')}
79
84
  xflow db status which migrations are applied and which are waiting
85
+ xflow db list databases of the organization and the projects on them
80
86
  xflow db schema [table] tables of the project, or the columns of one
81
87
  xflow db query "select ..." read data, in a read-only transaction
82
88
  xflow db migrate [--dry-run] [--allow-destructive]
@@ -87,12 +93,17 @@ ${(0, ui_1.bold)('Reference')}
87
93
  xflow projects list projects of the organization
88
94
  xflow projects get [id] project card
89
95
  xflow org organizations with a stored key, the active one marked
96
+ xflow org members who is in the organization, and with which role
90
97
  xflow org switch <name|id> make another organization the active one
91
98
  xflow whoami whose key this is and what it can do
92
99
  xflow logout [--all] forget the key of the active organization (--all: every one)
93
100
  xflow update update the CLI itself, and the skill that ships with it
94
101
  xflow --version which version is installed
95
102
 
103
+ ${(0, ui_1.bold)('Outside a project folder')}
104
+ --project <id> name the project by id, for every command that does
105
+ not read the local files (xflow help project)
106
+
96
107
  ${(0, ui_1.bold)('Environment')}
97
108
  XFLOW_TOKEN access key (for CI, instead of xflow login)
98
109
  XFLOW_API_URL platform address when it is not app.getxflow.com
@@ -112,10 +123,14 @@ Which key a command then uses, in order:
112
123
  3. the active organization
113
124
 
114
125
  xflow org the stored organizations, the active one marked
126
+ xflow org members who is in this one, and with which role
115
127
  xflow org switch <name|id> make another one active, no browser involved
116
128
  xflow logout forget the key of the active organization
117
129
  xflow logout --all forget every key of this platform address
118
130
 
131
+ ${(0, ui_1.bold)('members')} reads only. Inviting people, changing a role and removing somebody stay
132
+ in the platform interface, with the owner: there is no command for any of them.
133
+
119
134
  The switch is local: it changes which stored key is used and nothing happens on
120
135
  the platform. A folder bound to an organization is not affected, its commands
121
136
  stay in its own organization: two projects of two organizations in two terminals
@@ -129,6 +144,37 @@ A project id is unique across the whole platform, so "project not found" under
129
144
  the wrong organization can never touch somebody else's project. When that error
130
145
  names a project you know exists, the key is simply from another organization:
131
146
  check ${(0, ui_1.bold)('xflow org')}.`,
147
+ project: `${(0, ui_1.bold)('--project <id>')}: a project without its folder
148
+
149
+ Every command that only talks to the platform takes it, so a service project needs
150
+ no working copy of its own:
151
+
152
+ xflow db query --project 3f2a… "select id, title from tasks where done = false"
153
+ xflow functions invoke kanban --project 3f2a… --data-file card.json
154
+
155
+ Which commands: ${(0, ui_1.bold)('db query')}, ${(0, ui_1.bold)('db schema')}, ${(0, ui_1.bold)('functions list')},
156
+ ${(0, ui_1.bold)('functions invoke')}, ${(0, ui_1.bold)('functions logs')}, ${(0, ui_1.bold)('logs')}, ${(0, ui_1.bold)('env')}, ${(0, ui_1.bold)('env rm')},
157
+ ${(0, ui_1.bold)('connections')}, ${(0, ui_1.bold)('connections link')}, ${(0, ui_1.bold)('schedules')}, ${(0, ui_1.bold)('schedules set')},
158
+ ${(0, ui_1.bold)('schedules rm')}, ${(0, ui_1.bold)('storage ls')}, ${(0, ui_1.bold)('storage push')}, ${(0, ui_1.bold)('storage rm')},
159
+ ${(0, ui_1.bold)('deployments')}, ${(0, ui_1.bold)('publish')}, ${(0, ui_1.bold)('rollback')}.
160
+
161
+ The rest read the working copy and have nothing to do with an id instead:
162
+ ${(0, ui_1.bold)('deploy')}, ${(0, ui_1.bold)('pull')}, ${(0, ui_1.bold)('status')}, ${(0, ui_1.bold)('db migrate')}, ${(0, ui_1.bold)('db status')},
163
+ ${(0, ui_1.bold)('env check')}, ${(0, ui_1.bold)('env set')}, ${(0, ui_1.bold)('connections unlink')}. They answer about the files
164
+ next to them, so they keep asking for a folder.
165
+
166
+ The folder is then ignored whole, and that includes its ${(0, ui_1.bold)('xflow.json')}: the address
167
+ is app.getxflow.com, or ${(0, ui_1.bold)('XFLOW_API_URL')} when it is set, and never the ${(0, ui_1.bold)('api')} field
168
+ of a config that happens to be lying around. Standing in the folder of a self-hosted
169
+ project, the flag therefore goes somewhere else than the folder does.
170
+
171
+ The id is echoed back on every run. It is the one place a project is named by hand,
172
+ and a wrong id answers "not found" on a read but works on a write.
173
+
174
+ With several organizations signed in, the one holding the project is looked for on
175
+ every call: there is no folder to write the answer into, and a second place to
176
+ remember it would be a second thing to keep true. One ${(0, ui_1.bold)('xflow org switch')} to the
177
+ right organization spares that search.`,
132
178
  update: `${(0, ui_1.bold)('xflow update')}: bring the CLI up to date
133
179
 
134
180
  Installs the published version and, if anything changed, rewrites the platform
@@ -155,10 +201,15 @@ will work.`,
155
201
  db: `${(0, ui_1.bold)('xflow db')}: schema, data, migrations
156
202
 
157
203
  xflow db status what is applied and what is waiting
204
+ xflow db list databases of the organization, and who is on them
158
205
  xflow db schema [table] tables of the project, or the columns of one
159
206
  xflow db query "select ..." read data (--limit N)
160
207
  xflow db migrate apply migrations/*.sql
161
208
 
209
+ ${(0, ui_1.bold)('list')} is about the organization, not about this project: it names the logical
210
+ databases, the identifier ${(0, ui_1.bold)('xflow init --database <id>')} asks for, and the projects
211
+ attached to each one. That last column is the one to read before a migration.
212
+
162
213
  ${(0, ui_1.bold)('schema')} answers a different question than the ${(0, ui_1.bold)('migrations/')} directory: one logical
163
214
  database is shared by several projects, so the files say what you did, and the schema
164
215
  says what is actually in there. ${(0, ui_1.bold)('query')} runs inside a READ ONLY transaction, so
@@ -259,7 +310,11 @@ the function. So keep your own copy wherever you got it from.
259
310
  xflow env set SMTP_PASSWORD=… store
260
311
  xflow env rm SMTP_PASSWORD delete
261
312
 
262
- --scope project visible to this project only, not the whole organization
313
+ --scope project visible to this project only
314
+ --scope organization visible to every project of the organization (the default)
315
+
316
+ Those two words are the whole list: a third is refused rather than read as the default,
317
+ because a value meant for one project would have gone out to all of them.
263
318
 
264
319
  ${(0, ui_1.bold)('check')} reads the sources in ${(0, ui_1.bold)('functions/')} and looks for ${(0, ui_1.bold)('process.env.NAME')}
265
320
  references. The same rule applies on deploy: a function receives only the variables it
@@ -326,6 +381,7 @@ the day-of-month and day-of-week fields has to be ${(0, ui_1.bold)('?')}, which
326
381
  how Yandex works. ${(0, ui_1.bold)('The time is always UTC')}, local time is not understood.
327
382
 
328
383
  --payload '{"mode":"full"}' the body the function will receive
384
+ --payload-file body.json the same body from a file
329
385
 
330
386
  A scheduled run arrives at the handler as a POST with no headers, and the project
331
387
  token is not checked on it: such an event cannot be forged from outside. The function
@@ -339,8 +395,13 @@ Calls it exactly the way the application does: the project token plus a visitor
339
395
  for the person who owns the key, so the function sees a real caller and its role.
340
396
  Both are taken from the platform, no local .env is needed.
341
397
 
342
- --data '{"a":1}' request body (the method becomes POST by default)
343
- --method GET a different method
398
+ --data '{"a":1}' request body (the method becomes POST by default)
399
+ --data-file body.json the same body from a file
400
+ --method GET a different method
401
+
402
+ The body has to be JSON, and it is checked before the call goes out: PowerShell
403
+ strips double quotes from a native call, so ${(0, ui_1.bold)('--data')} needs them escaped there
404
+ (${(0, ui_1.bold)('--data \'{\\"a\\":1}\'')}) and a file needs nothing.
344
405
 
345
406
  Prints the status, the response time and the body. A non-zero exit code on 4xx and
346
407
  5xx: in CI such a call has to fail the step. The cause of a crash is shown by
@@ -460,7 +521,7 @@ is not a formality: your own palette on top of them looks foreign inside the pla
460
521
 
461
522
  --name <name> project name (the folder name by default)
462
523
  --template <id> a different template (the list: xflow templates)
463
- --database <id> attach a logical database of the organization
524
+ --database <id> attach a logical database of the organization (the list: xflow db list)
464
525
 
465
526
  The order: the project is created on the platform first, because without it there is
466
527
  nowhere to get the token for .env. If writing the files fails, the project stays empty
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.jsonProblem = jsonProblem;
4
+ exports.jsonHint = jsonHint;
5
+ exports.jsonArg = jsonArg;
6
+ const node_fs_1 = require("node:fs");
7
+ const args_1 = require("./args");
8
+ const errors_1 = require("./errors");
9
+ /**
10
+ * A JSON body typed on the command line: --data for a call, --payload for a
11
+ * schedule. Checked here, before it leaves: PowerShell strips the double quotes
12
+ * out of a native call, and an unchecked body arrives at the function as garbage,
13
+ * so a shell problem looks like a crash in the project's own code.
14
+ */
15
+ /** No quote left where JSON needs them: a shell ate them, this is not a typo. */
16
+ function quotesStripped(raw) {
17
+ return !raw.includes('"') && /^[{[]/.test(raw);
18
+ }
19
+ /** What the parser says about the text, or null when it is JSON. */
20
+ function jsonProblem(raw) {
21
+ const text = raw.trim();
22
+ if (!text)
23
+ return 'the value is empty';
24
+ try {
25
+ JSON.parse(text);
26
+ return null;
27
+ }
28
+ catch (e) {
29
+ return e instanceof Error ? e.message : String(e);
30
+ }
31
+ }
32
+ /** Where to look, and on a stripped value the shell is the first suspect. */
33
+ function jsonHint(flag, raw) {
34
+ const file = `--${flag}-file body.json`;
35
+ if (quotesStripped(raw.trim())) {
36
+ return (`Not one double quote survived: the shell removed them, PowerShell does this to ` +
37
+ `native calls. Escape them: --${flag} '{\\"key\\":\\"value\\"}', or keep the body ` +
38
+ `in a file: ${file}`);
39
+ }
40
+ return `A single JSON value is expected, or keep the body in a file: ${file}`;
41
+ }
42
+ /** The body from --<flag> or --<flag>-file, already known to be JSON. */
43
+ function jsonArg(args, flag) {
44
+ const fileFlag = `${flag}-file`;
45
+ const inline = args.flags[flag];
46
+ const path = (0, args_1.flagString)(args, fileFlag);
47
+ if (inline !== undefined && path !== undefined) {
48
+ throw new errors_1.CliError(`--${flag} and --${fileFlag} cannot be used together`, 'Give the body once');
49
+ }
50
+ if (path !== undefined) {
51
+ if (!(0, node_fs_1.existsSync)(path)) {
52
+ throw new errors_1.CliError(`No file ${path}`, `--${fileFlag} takes the path to a file with one JSON value`);
53
+ }
54
+ const text = (0, node_fs_1.readFileSync)(path, 'utf-8').trim();
55
+ const problem = jsonProblem(text);
56
+ if (problem)
57
+ throw new errors_1.CliError(`${path} is not valid JSON: ${problem}`, 'The file has to hold one JSON value');
58
+ return text;
59
+ }
60
+ if (inline === undefined)
61
+ return undefined;
62
+ if (inline === true)
63
+ throw new errors_1.CliError(`--${flag} needs a value`, `For example: --${flag} '{"mode":"full"}'`);
64
+ const raw = String(inline).trim();
65
+ const problem = jsonProblem(raw);
66
+ if (problem)
67
+ throw new errors_1.CliError(`--${flag} is not valid JSON: ${raw}`, jsonHint(flag, raw));
68
+ return raw;
69
+ }
package/dist/session.js CHANGED
@@ -4,15 +4,24 @@ exports.connect = connect;
4
4
  exports.anonymous = anonymous;
5
5
  exports.findProjectOrg = findProjectOrg;
6
6
  exports.connectProject = connectProject;
7
+ exports.isProjectId = isProjectId;
8
+ exports.projectTarget = projectTarget;
7
9
  const api_1 = require("./api");
10
+ const args_1 = require("./args");
8
11
  const config_1 = require("./config");
9
12
  const credentials_1 = require("./credentials");
10
13
  const errors_1 = require("./errors");
11
14
  const ui_1 = require("./ui");
12
- /** Platform connection: address plus key. */
13
- function connect(config) {
15
+ /**
16
+ * Platform connection: address plus key.
17
+ *
18
+ * `ignoreFolder` is what --project needs: the folder around it belongs to another
19
+ * project, so its organization must not decide the key. Taking it would hand the
20
+ * flag the key of the wrong organization, and the answer would be a bare 404.
21
+ */
22
+ function connect(config, options) {
14
23
  const apiUrl = (0, config_1.apiUrlFor)(config);
15
- const root = (0, config_1.findProjectRoot)();
24
+ const root = options?.ignoreFolder === true ? null : (0, config_1.findProjectRoot)();
16
25
  const picked = (0, credentials_1.pickKey)({
17
26
  env: process.env.XFLOW_TOKEN?.trim() || null,
18
27
  // XFLOW_TOKEN never follows an address taken from the repo's xflow.json:
@@ -63,23 +72,76 @@ async function findProjectOrg(apiUrl, projectId) {
63
72
  * gets probed once: the organization that serves the project is found and
64
73
  * written into .xflow/state.json, so every later command resolves the key
65
74
  * synchronously. With one stored key there is nothing to probe.
75
+ *
76
+ * Without a folder (--project) there is nowhere to write the answer, so the
77
+ * probe repeats on every command. Kept that way on purpose: a second store of
78
+ * "which organization holds what" would be a second truth to keep in step, and
79
+ * the price is paid only by whoever did not switch the active organization.
66
80
  */
67
- async function connectProject(root, config) {
68
- const session = connect(config);
81
+ async function connectProject(projectId, root, config) {
82
+ const session = connect(config, { ignoreFolder: root === null });
69
83
  if (session.source !== 'active' || session.organizationId === null)
70
84
  return session;
71
85
  const orgs = (0, credentials_1.listOrgs)(session.apiUrl);
72
86
  if (orgs.length <= 1)
73
87
  return session;
74
- const { hit, dead } = await findProjectOrg(session.apiUrl, config.projectId);
88
+ const { hit, dead } = await findProjectOrg(session.apiUrl, projectId);
75
89
  if (!hit) {
76
- throw new errors_1.CliError(`No signed-in organization holds the project ${config.projectId}`, dead.length > 0
90
+ throw new errors_1.CliError(`No signed-in organization holds the project ${projectId}`, dead.length > 0
77
91
  ? `The key of ${dead.map((d) => d.name ?? d.organizationId).join(', ')} is not working (sign in again: xflow login); the project may live there`
78
- : 'Check projectId in xflow.json, or sign in to the organization that owns it: xflow login');
92
+ : root === null
93
+ ? 'Check the id, or sign in to the organization that owns it: xflow login'
94
+ : 'Check projectId in xflow.json, or sign in to the organization that owns it: xflow login');
95
+ }
96
+ if (root !== null) {
97
+ (0, config_1.writeState)(root, { organizationId: hit.organizationId });
98
+ if (hit.organizationId !== session.organizationId) {
99
+ (0, ui_1.note)((0, ui_1.dim)(` The project belongs to "${hit.name ?? hit.organizationId}", not to the active organization: the folder is bound to it`));
100
+ }
101
+ }
102
+ else if (hit.organizationId !== session.organizationId) {
103
+ // Nothing was written down, so the next command pays for the same search.
104
+ (0, ui_1.note)((0, ui_1.dim)(` The project belongs to "${hit.name ?? hit.organizationId}": xflow org switch spares the next command this search`));
79
105
  }
80
- (0, config_1.writeState)(root, { organizationId: hit.organizationId });
81
- if (hit.organizationId !== session.organizationId) {
82
- (0, ui_1.note)((0, ui_1.dim)(` The project belongs to "${hit.name ?? hit.organizationId}", not to the active organization: the folder is bound to it`));
106
+ return {
107
+ apiUrl: session.apiUrl,
108
+ token: hit.token,
109
+ source: root === null ? 'probe' : 'folder',
110
+ organizationId: hit.organizationId,
111
+ };
112
+ }
113
+ /**
114
+ * A project id, and nothing that could be mistaken for one. A name passed where
115
+ * the id belongs would answer a bare 404, and that reads as "no such project"
116
+ * rather than "wrong argument".
117
+ */
118
+ function isProjectId(value) {
119
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
120
+ }
121
+ /**
122
+ * The project a command works on: the --project flag, or the folder it runs in.
123
+ *
124
+ * The flag ignores the folder whole, its xflow.json included, so the address is
125
+ * the default one or XFLOW_API_URL and nothing else: a config lying around must
126
+ * not be able to redirect a call made by id.
127
+ *
128
+ * `probe` is the difference the folder path already has and keeps: publish,
129
+ * rollback and deployments look for the organization of an unbound folder,
130
+ * everything else takes the key it resolves to. Named by id there is no binding
131
+ * to lean on, so the search always happens.
132
+ */
133
+ async function projectTarget(args, options) {
134
+ const named = (0, args_1.flagString)(args, 'project');
135
+ if (named !== undefined) {
136
+ if (!isProjectId(named)) {
137
+ throw new errors_1.CliError(`--project takes a project id, got "${named}"`, 'The ids: xflow projects list');
138
+ }
139
+ // Unconditional: the flag is the only place the id is typed by hand, and a
140
+ // wrong one answers 404 on a read and works on a write.
141
+ (0, ui_1.note)((0, ui_1.dim)(` Project ${named} (--project)`));
142
+ return { projectId: named, client: await connectProject(named, null) };
83
143
  }
84
- return { apiUrl: session.apiUrl, token: hit.token, source: 'folder', organizationId: hit.organizationId };
144
+ const { root, config } = (0, config_1.requireProject)();
145
+ const client = options?.probe === true ? await connectProject(config.projectId, root, config) : connect(config);
146
+ return { projectId: config.projectId, client };
85
147
  }
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.10.2';
5
+ exports.CLI_VERSION = '0.10.4';
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@getxflow/cli",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "description": "CLI for the XFlow platform: source sync, deployment and publishing of applications",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
@@ -24,7 +24,7 @@ contains the fix.
24
24
 
25
25
  ## Keeping these instructions current
26
26
 
27
- These instructions ship with xflow CLI 0.10.2. They travel inside the package, so the copy
27
+ These instructions ship with xflow CLI 0.10.4. They travel inside the package, so the copy
28
28
  you are reading can be older than the CLI answering your commands, and nothing about that
29
29
  is visible in the text itself.
30
30
 
@@ -234,10 +234,15 @@ you to delete. Put the directories back instead, and if the removal really is in
234
234
  which functions are about to go and let the user answer.
235
235
 
236
236
  Debugging a deployed function is two commands: `xflow functions invoke <name>` calls it
237
- the way the app does and prints status, timing and body (`--data '{"a":1}'` sends a body),
238
- and `xflow functions logs <name>` shows the failures, each with its stack and the console
239
- output of that call. Only failed calls are logged, so an empty output means the function
240
- never crashed, not that logging is broken.
237
+ the way the app does and prints status, timing and body (`--data '{"a":1}'` sends a body,
238
+ `--data-file body.json` sends the same from a file), and `xflow functions logs <name>` shows
239
+ the failures, each with its stack and the console output of that call. Only failed calls are
240
+ logged, so an empty output means the function never crashed, not that logging is broken.
241
+
242
+ The body has to be JSON and the CLI checks it before calling. In PowerShell the double
243
+ quotes never reach the CLI: `--data '{"a":1}'` arrives as `{a:1}`, so escape them
244
+ (`--data '{\"a\":1}'`) or pass a file. This is a shell habit, not a bug in the function, and
245
+ it bites `--payload` of a schedule the same way.
241
246
 
242
247
  From the app, call a function through `src/lib/xflow.ts`:
243
248
  `await xflow.functions.invoke('send-mail', { body: { to } })`. It carries the credentials
@@ -303,6 +308,11 @@ writes one, `xflow env` lists the names, `xflow env check` tells you which varia
303
308
  functions read but the platform does not have. Values never come back out — the only place
304
309
  they exist is inside the running function.
305
310
 
311
+ A variable goes to the whole organization unless you say otherwise: `--scope project` keeps it
312
+ to this project alone, `--scope organization` is the default, and a third word is refused
313
+ rather than read as the default. Pick the narrow one for anything that belongs to one
314
+ application.
315
+
306
316
  These commands see only what this project can see: variables shared across the organization
307
317
  and the ones bound to this project. A variable bound to another project is invisible here, so
308
318
  `env rm` reports it as missing even though names are unique within the organization.
@@ -342,7 +352,9 @@ grant it to itself. Connecting a new account and switching one off stay with a p
342
352
  `xflow schedules set report "0 3 ? * * *"` runs a function daily at 03:00. Six fields, UTC,
343
353
  and exactly one of day-of-month / day-of-week must be `?` — that is how Yandex wants it.
344
354
  A scheduled run reaches the handler as a POST with no headers, and `--payload '{"mode":"full"}'`
345
- is how it gets a body. How often a schedule may run is a plan limit, see **Plan limits**.
355
+ (or `--payload-file body.json`) is how it gets a body. The payload has to be JSON: a broken one
356
+ is refused when the schedule is set, not at three in the morning with nobody watching. How often
357
+ a schedule may run is a plan limit, see **Plan limits**.
346
358
 
347
359
  The pieces line up in one pass. From a new function to a verified schedule:
348
360
 
@@ -386,7 +398,9 @@ place the affected tables are dumped first and kept for 7 days. Check with `--dr
386
398
 
387
399
  One logical database can be shared by several projects, so your migration can break an app
388
400
  you do not see, and `xflow db status` lists applied migrations that have no file in your
389
- repository: that is somebody else's project. For the same reason `migrations/` is not the
401
+ repository: that is somebody else's project. `xflow db list` names the databases of the
402
+ organization and the projects on each, which is where you find out who else is on yours.
403
+ For the same reason `migrations/` is not the
390
404
  schema. It says what you did; `xflow db schema [table]` says what is in the database right
391
405
  now, and `xflow db query "select ..."` reads it inside a READ ONLY transaction. Look before
392
406
  you write a migration against a shared database.
@@ -482,7 +496,9 @@ One key per organization, stored side by side rather than replacing each other:
482
496
 
483
497
  Inside a project folder there is nothing to switch: commands follow the organization the
484
498
  folder is bound to, whatever the active one is. That is what lets two projects of two
485
- organizations work side by side.
499
+ organizations work side by side. The exception is `--project <id>`, which every command
500
+ that does not read the working copy takes (`xflow help project` lists them): it ignores the
501
+ folder whole, so a second project needs no second checkout.
486
502
 
487
503
  A "not found" on a project you know exists usually means the key belongs to another
488
504
  organization, not that the project is gone: ids are unique across the platform, so a