@getxflow/cli 0.34.2 → 1.0.0
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/README.md +1 -1
- package/dist/api.js +21 -6
- package/dist/args.js +1 -1
- package/dist/bin.js +10 -3
- package/dist/commands/connections.js +4 -4
- package/dist/commands/db.js +26 -63
- package/dist/commands/deploy.js +170 -216
- package/dist/commands/dev.js +15 -29
- package/dist/commands/env.js +6 -3
- package/dist/commands/functions.js +27 -119
- package/dist/commands/logs.js +19 -15
- package/dist/commands/pages.js +1 -0
- package/dist/commands/projects.js +134 -190
- package/dist/commands/sources.js +218 -120
- package/dist/commands/storage.js +2 -2
- package/dist/config.js +82 -26
- package/dist/flags.js +19 -12
- package/dist/help.js +210 -209
- package/dist/limits.js +6 -6
- package/dist/session.js +167 -52
- package/dist/template.js +29 -125
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/skills/xflow/SKILL.md +682 -740
package/dist/limits.js
CHANGED
|
@@ -4,8 +4,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
exports.limitLine = limitLine;
|
|
5
5
|
exports.quotaRows = quotaRows;
|
|
6
6
|
const DENIAL_LABELS = {
|
|
7
|
-
limit_projects: 'projects',
|
|
8
|
-
|
|
7
|
+
limit_projects: 'projects, the platform cap',
|
|
8
|
+
limit_apps: 'apps',
|
|
9
|
+
plan_app_access: 'access levels of apps',
|
|
10
|
+
functions_stopped: 'function minutes, the functions of the organization are stopped',
|
|
9
11
|
limit_connections: 'active connector connections',
|
|
10
12
|
limit_pages: 'public pages',
|
|
11
13
|
limit_functions: 'cloud functions',
|
|
@@ -32,8 +34,7 @@ function limitLine(detail) {
|
|
|
32
34
|
return `Plan limit: ${label}${numbers}. Repeating the same command will not help.`;
|
|
33
35
|
}
|
|
34
36
|
const QUOTA_ORDER = [
|
|
35
|
-
'
|
|
36
|
-
'databases',
|
|
37
|
+
'apps',
|
|
37
38
|
'connections',
|
|
38
39
|
'pages',
|
|
39
40
|
'functions',
|
|
@@ -45,8 +46,7 @@ const QUOTA_ORDER = [
|
|
|
45
46
|
'builds',
|
|
46
47
|
];
|
|
47
48
|
const QUOTA_LABELS = {
|
|
48
|
-
|
|
49
|
-
databases: 'Databases',
|
|
49
|
+
apps: 'Apps',
|
|
50
50
|
connections: 'Active connections',
|
|
51
51
|
pages: 'Public pages',
|
|
52
52
|
functions: 'Cloud functions',
|
package/dist/session.js
CHANGED
|
@@ -6,6 +6,13 @@ exports.findProjectOrg = findProjectOrg;
|
|
|
6
6
|
exports.connectProject = connectProject;
|
|
7
7
|
exports.isProjectId = isProjectId;
|
|
8
8
|
exports.projectTarget = projectTarget;
|
|
9
|
+
exports.parseTargetWord = parseTargetWord;
|
|
10
|
+
exports.onlyApp = onlyApp;
|
|
11
|
+
exports.folderTarget = folderTarget;
|
|
12
|
+
exports.appTarget = appTarget;
|
|
13
|
+
exports.streamTarget = streamTarget;
|
|
14
|
+
const node_fs_1 = require("node:fs");
|
|
15
|
+
const node_path_1 = require("node:path");
|
|
9
16
|
const api_1 = require("./api");
|
|
10
17
|
const args_1 = require("./args");
|
|
11
18
|
const config_1 = require("./config");
|
|
@@ -15,9 +22,8 @@ const ui_1 = require("./ui");
|
|
|
15
22
|
/**
|
|
16
23
|
* Platform connection: address plus key.
|
|
17
24
|
*
|
|
18
|
-
* `ignoreFolder` is what --project
|
|
19
|
-
* project, so its organization must not decide the key.
|
|
20
|
-
* flag the key of the wrong organization, and the answer would be a bare 404.
|
|
25
|
+
* `ignoreFolder` is what --project and --app need: the folder around it belongs to
|
|
26
|
+
* another project, so its organization must not decide the key.
|
|
21
27
|
*/
|
|
22
28
|
function connect(config, options) {
|
|
23
29
|
const apiUrl = (0, config_1.apiUrlFor)(config);
|
|
@@ -31,11 +37,7 @@ function connect(config, options) {
|
|
|
31
37
|
...(0, credentials_1.storedKeyInputs)(apiUrl),
|
|
32
38
|
});
|
|
33
39
|
if (!picked) {
|
|
34
|
-
throw new errors_1.CliError(`No access key for ${apiUrl}`,
|
|
35
|
-
// Who does it, not where the key comes from: the key is issued by the sign-in
|
|
36
|
-
// itself and by nothing else, so an agent reading this has to hand the step over
|
|
37
|
-
// instead of hunting for a value to paste.
|
|
38
|
-
'Sign in: xflow login (needs a person with a browser). In CI pass the key in XFLOW_TOKEN');
|
|
40
|
+
throw new errors_1.CliError(`No access key for ${apiUrl}`, 'Sign in: xflow login (needs a person with a browser). In CI pass the key in XFLOW_TOKEN');
|
|
39
41
|
}
|
|
40
42
|
if ('missing' in picked) {
|
|
41
43
|
throw new errors_1.CliError(`This folder is bound to the organization ${picked.organizationId}, and there is no stored key for it`, 'Sign in to that organization: xflow login. The stored ones: xflow org');
|
|
@@ -47,16 +49,15 @@ function anonymous(config) {
|
|
|
47
49
|
return { apiUrl: (0, config_1.apiUrlFor)(config), token: '' };
|
|
48
50
|
}
|
|
49
51
|
/**
|
|
50
|
-
* Which signed-in organization
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* sit behind the next one.
|
|
52
|
+
* Which signed-in organization answers this path. Ids are unique across the
|
|
53
|
+
* platform, so a 404 means "not this one" and exactly one organization can answer.
|
|
54
|
+
* A dead key must not end the search: the answer may sit behind the next one.
|
|
54
55
|
*/
|
|
55
|
-
async function
|
|
56
|
+
async function findOrgFor(apiUrl, path) {
|
|
56
57
|
const dead = [];
|
|
57
58
|
for (const candidate of (0, credentials_1.listOrgs)(apiUrl)) {
|
|
58
59
|
try {
|
|
59
|
-
await (0, api_1.apiJson)({ apiUrl, token: candidate.token },
|
|
60
|
+
await (0, api_1.apiJson)({ apiUrl, token: candidate.token }, path);
|
|
60
61
|
return { hit: candidate, dead };
|
|
61
62
|
}
|
|
62
63
|
catch (e) {
|
|
@@ -71,28 +72,26 @@ async function findProjectOrg(apiUrl, projectId) {
|
|
|
71
72
|
}
|
|
72
73
|
return { hit: null, dead };
|
|
73
74
|
}
|
|
75
|
+
function findProjectOrg(apiUrl, projectId) {
|
|
76
|
+
return findOrgFor(apiUrl, `/api/v1/projects/${projectId}`);
|
|
77
|
+
}
|
|
74
78
|
/**
|
|
75
|
-
* Connection for project
|
|
76
|
-
* gets probed once
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* Without a folder (--project) there is nowhere to write the answer, so the
|
|
81
|
-
* probe repeats on every command. Kept that way on purpose: a second store of
|
|
82
|
-
* "which organization holds what" would be a second truth to keep in step, and
|
|
83
|
-
* the price is paid only by whoever did not switch the active organization.
|
|
79
|
+
* Connection for a project or an app. A folder not yet bound to an organization
|
|
80
|
+
* gets probed once and the answer written into .xflow/state.json of the project
|
|
81
|
+
* root. Named by id there is nowhere to write it, so the probe repeats: a second
|
|
82
|
+
* store of "which organization holds what" would be a second truth to keep true.
|
|
84
83
|
*/
|
|
85
|
-
async function
|
|
84
|
+
async function connectFor(path, what, root, config) {
|
|
86
85
|
const session = connect(config, { ignoreFolder: root === null });
|
|
87
86
|
if (session.source !== 'active')
|
|
88
87
|
return session;
|
|
89
88
|
const orgs = (0, credentials_1.listOrgs)(session.apiUrl);
|
|
90
89
|
if (orgs.length <= 1)
|
|
91
90
|
return session;
|
|
92
|
-
const { hit, dead } = await
|
|
91
|
+
const { hit, dead } = await findOrgFor(session.apiUrl, path);
|
|
93
92
|
if (!hit) {
|
|
94
|
-
throw new errors_1.CliError(`No signed-in organization holds the
|
|
95
|
-
? `The key of ${dead.map((d) => d.name ?? d.organizationId).join(', ')} is not working (sign in again: xflow login);
|
|
93
|
+
throw new errors_1.CliError(`No signed-in organization holds the ${what}`, dead.length > 0
|
|
94
|
+
? `The key of ${dead.map((d) => d.name ?? d.organizationId).join(', ')} is not working (sign in again: xflow login); it may live there`
|
|
96
95
|
: root === null
|
|
97
96
|
? 'Check the id, or sign in to the organization that owns it: xflow login'
|
|
98
97
|
: 'Check projectId in xflow.json, or sign in to the organization that owns it: xflow login');
|
|
@@ -103,35 +102,31 @@ async function connectProject(projectId, root, config) {
|
|
|
103
102
|
(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`));
|
|
104
103
|
}
|
|
105
104
|
}
|
|
106
|
-
return {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
organizationId: hit.organizationId,
|
|
111
|
-
};
|
|
105
|
+
return { apiUrl: session.apiUrl, token: hit.token, source: root === null ? 'probe' : 'folder', organizationId: hit.organizationId };
|
|
106
|
+
}
|
|
107
|
+
function connectProject(projectId, root, config) {
|
|
108
|
+
return connectFor(`/api/v1/projects/${projectId}`, `project ${projectId}`, root, config);
|
|
112
109
|
}
|
|
113
110
|
/**
|
|
114
|
-
* A project id, and nothing that could be mistaken for one. A name passed
|
|
115
|
-
* the id belongs would answer a bare 404,
|
|
111
|
+
* A project or app id, and nothing that could be mistaken for one. A name passed
|
|
112
|
+
* where the id belongs would answer a bare 404, which reads as "no such project"
|
|
116
113
|
* rather than "wrong argument".
|
|
117
114
|
*/
|
|
118
115
|
function isProjectId(value) {
|
|
119
116
|
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
117
|
}
|
|
118
|
+
/** Echoed on every human run: an id typed by hand answers 404 on a read and works on a write. */
|
|
119
|
+
function echoNamed(client, line) {
|
|
120
|
+
const org = (0, credentials_1.listOrgs)(client.apiUrl).find((entry) => entry.organizationId === client.organizationId);
|
|
121
|
+
(0, ui_1.note)((0, ui_1.dim)(` ${line}${org ? ` of "${org.name ?? org.organizationId}"` : ''}`));
|
|
122
|
+
}
|
|
121
123
|
/**
|
|
122
|
-
* The project a command works on: the --project flag, or the
|
|
124
|
+
* The project a command works on: the --project flag, or the project root of the
|
|
125
|
+
* folder it runs in (any folder of the project, apps included).
|
|
123
126
|
*
|
|
124
127
|
* The flag ignores the folder whole, its xflow.json included, so the address is
|
|
125
|
-
* the default one or XFLOW_API_URL
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* Either way the key comes from connectProject: an unbound folder gets the
|
|
129
|
-
* organization of its project looked up once and written down, an id gets it
|
|
130
|
-
* looked up on every call.
|
|
131
|
-
*
|
|
132
|
-
* `root` is that folder, and null when the id was named: a command that reads the
|
|
133
|
-
* sources for a check of its own then knows the check is not available rather than
|
|
134
|
-
* running it against whatever folder the caller happened to stand in.
|
|
128
|
+
* the default one or XFLOW_API_URL: a config lying around must not redirect a call
|
|
129
|
+
* made by id. `root` is null then.
|
|
135
130
|
*/
|
|
136
131
|
async function projectTarget(args) {
|
|
137
132
|
const named = (0, args_1.flagString)(args, 'project');
|
|
@@ -140,15 +135,135 @@ async function projectTarget(args) {
|
|
|
140
135
|
throw new errors_1.CliError(`--project takes a project id, got "${named}"`, 'The ids: xflow projects list');
|
|
141
136
|
}
|
|
142
137
|
const client = await connectProject(named, null);
|
|
143
|
-
|
|
144
|
-
// hand, and a wrong one answers 404 on a read and works on a write. The
|
|
145
|
-
// organization is named too, since it may not be the active one. Under --json
|
|
146
|
-
// the line is silenced with the rest, where the payload is the whole answer.
|
|
147
|
-
const org = (0, credentials_1.listOrgs)(client.apiUrl).find((entry) => entry.organizationId === client.organizationId);
|
|
148
|
-
(0, ui_1.note)((0, ui_1.dim)(` Project ${named}${org ? ` of "${org.name ?? org.organizationId}"` : ''} (--project)`));
|
|
138
|
+
echoNamed(client, `Project ${named} (--project)`);
|
|
149
139
|
return { projectId: named, client, root: null };
|
|
150
140
|
}
|
|
151
141
|
const { root, config } = (0, config_1.requireProject)();
|
|
152
142
|
const client = await connectProject(config.projectId, root, config);
|
|
153
143
|
return { projectId: config.projectId, client, root };
|
|
154
144
|
}
|
|
145
|
+
/** The word after a command: `functions` is the project stream, anything else a path to an app folder. */
|
|
146
|
+
function parseTargetWord(word) {
|
|
147
|
+
if (word === undefined)
|
|
148
|
+
return null;
|
|
149
|
+
if (word === 'functions')
|
|
150
|
+
return { kind: 'functions' };
|
|
151
|
+
return { kind: 'path', path: word.replace(/\\/g, '/').replace(/\/+$/, '') || '.' };
|
|
152
|
+
}
|
|
153
|
+
/** The app of a project that names none: the only one, or a refusal listing them. */
|
|
154
|
+
function onlyApp(apps, local) {
|
|
155
|
+
if (apps.length === 1)
|
|
156
|
+
return apps[0];
|
|
157
|
+
if (apps.length === 0)
|
|
158
|
+
throw new errors_1.CliError('The project has no apps', 'Create one: xflow apps add <folder>');
|
|
159
|
+
const names = apps.map((app) => {
|
|
160
|
+
const folder = local.find((item) => item.appId === app.id)?.folder;
|
|
161
|
+
return folder ? `apps/${folder}` : `--app ${app.id} (${app.name})`;
|
|
162
|
+
});
|
|
163
|
+
throw new errors_1.CliError('The project has several apps: name the one', `Stand in its folder or name it: ${names.join(', ')}`);
|
|
164
|
+
}
|
|
165
|
+
function appsHint(projectRoot) {
|
|
166
|
+
const apps = projectRoot ? (0, config_1.localApps)(projectRoot) : [];
|
|
167
|
+
return apps.length > 0
|
|
168
|
+
? `The app folders here: ${apps.map((app) => `apps/${app.folder}`).join(', ')}`
|
|
169
|
+
: 'Apps are created with xflow apps add <folder>';
|
|
170
|
+
}
|
|
171
|
+
/** The app folder a typed path names: relative to where you stand, then to the project root. */
|
|
172
|
+
function appFolderFor(path, projectRoot) {
|
|
173
|
+
for (const base of [process.cwd(), projectRoot]) {
|
|
174
|
+
if (!base)
|
|
175
|
+
continue;
|
|
176
|
+
const dir = (0, node_path_1.resolve)(base, path);
|
|
177
|
+
const file = (0, node_path_1.join)(dir, config_1.CONFIG_FILE);
|
|
178
|
+
if ((0, node_fs_1.existsSync)(file) && (0, config_1.configKind)(safeRead(file)) === 'app')
|
|
179
|
+
return dir;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
function safeRead(file) {
|
|
184
|
+
try {
|
|
185
|
+
return JSON.parse((0, node_fs_1.readFileSync)(file, 'utf-8'));
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async function appInFolder(folder) {
|
|
192
|
+
const roots = (0, config_1.findRoots)(folder);
|
|
193
|
+
if (!roots)
|
|
194
|
+
throw new errors_1.CliError(`${folder} is not inside a project`);
|
|
195
|
+
const config = (0, config_1.readConfig)(roots.projectRoot);
|
|
196
|
+
const { appId } = (0, config_1.readAppConfig)(folder);
|
|
197
|
+
const client = await connectProject(config.projectId, roots.projectRoot, config);
|
|
198
|
+
return { kind: 'app', appId, projectId: config.projectId, client, root: folder, projectRoot: roots.projectRoot };
|
|
199
|
+
}
|
|
200
|
+
/** An app named by id: its project comes from the platform. */
|
|
201
|
+
async function appById(appId) {
|
|
202
|
+
if (!isProjectId(appId)) {
|
|
203
|
+
throw new errors_1.CliError(`--app takes an app id, got "${appId}"`, 'The apps of a project: xflow apps --project <id>');
|
|
204
|
+
}
|
|
205
|
+
const client = await connectFor(`/api/v1/apps/${appId}`, `app ${appId}`, null);
|
|
206
|
+
const card = await (0, api_1.apiJson)(client, `/api/v1/apps/${appId}`);
|
|
207
|
+
echoNamed(client, `App ${card.name} ${appId} (--app)`);
|
|
208
|
+
return { kind: 'app', appId, projectId: card.project_id, client, root: null, projectRoot: null };
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The target of a command that stands in the folder: the word first, then the folder.
|
|
212
|
+
* `appCommand` commands (publish, deployments of an app, logs) have no project stream
|
|
213
|
+
* of their own, so in the project root they take the only app or refuse.
|
|
214
|
+
*/
|
|
215
|
+
async function folderTarget(word, appCommand) {
|
|
216
|
+
const parsed = parseTargetWord(word);
|
|
217
|
+
if (parsed?.kind === 'path') {
|
|
218
|
+
const projectRoot = (0, config_1.findProjectRoot)();
|
|
219
|
+
const folder = appFolderFor(parsed.path, projectRoot);
|
|
220
|
+
if (!folder) {
|
|
221
|
+
throw new errors_1.CliError(`${word} is not an app folder: there is no ${config_1.CONFIG_FILE} with appId in it`, appsHint(projectRoot));
|
|
222
|
+
}
|
|
223
|
+
return appInFolder(folder);
|
|
224
|
+
}
|
|
225
|
+
const { root, config, appRoot } = (0, config_1.requireProject)();
|
|
226
|
+
if (appRoot && parsed === null)
|
|
227
|
+
return appInFolder(appRoot);
|
|
228
|
+
const client = await connectProject(config.projectId, root, config);
|
|
229
|
+
if (!appCommand || parsed?.kind === 'functions')
|
|
230
|
+
return { kind: 'project', projectId: config.projectId, client, root };
|
|
231
|
+
const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}`);
|
|
232
|
+
const local = (0, config_1.localApps)(root);
|
|
233
|
+
const app = onlyApp(card.apps, local);
|
|
234
|
+
return {
|
|
235
|
+
kind: 'app',
|
|
236
|
+
appId: app.id,
|
|
237
|
+
projectId: config.projectId,
|
|
238
|
+
client,
|
|
239
|
+
root: local.find((item) => item.appId === app.id)?.root ?? null,
|
|
240
|
+
projectRoot: root,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/** An app by --app, or by the folder (in the project root: the only app). */
|
|
244
|
+
async function appTarget(args) {
|
|
245
|
+
const byApp = (0, args_1.flagString)(args, 'app');
|
|
246
|
+
const target = byApp !== undefined ? await appById(byApp) : await folderTarget(undefined, true);
|
|
247
|
+
if (target.kind !== 'app')
|
|
248
|
+
throw new errors_1.CliError('Only an app has versions to publish', 'Functions go live with xflow deploy functions');
|
|
249
|
+
return target;
|
|
250
|
+
}
|
|
251
|
+
/** The same, with --project (the project stream) and --app (an app) naming the target without a folder. */
|
|
252
|
+
async function streamTarget(args, word, appCommand) {
|
|
253
|
+
const byApp = (0, args_1.flagString)(args, 'app');
|
|
254
|
+
const byProject = (0, args_1.flagString)(args, 'project');
|
|
255
|
+
if (byApp !== undefined) {
|
|
256
|
+
if (byProject !== undefined)
|
|
257
|
+
throw new errors_1.CliError('--app names the app and its project at once', 'Keep --app alone');
|
|
258
|
+
if (word !== undefined)
|
|
259
|
+
throw new errors_1.CliError(`--app and "${word}" both name a target`, 'Keep one of them');
|
|
260
|
+
return appById(byApp);
|
|
261
|
+
}
|
|
262
|
+
if (byProject !== undefined) {
|
|
263
|
+
if (parseTargetWord(word)?.kind === 'path') {
|
|
264
|
+
throw new errors_1.CliError(`--project and "${word}" both name a target`, 'An app without its folder: --app <id>');
|
|
265
|
+
}
|
|
266
|
+
return { kind: 'project', ...(await projectTarget(args)) };
|
|
267
|
+
}
|
|
268
|
+
return folderTarget(word, appCommand);
|
|
269
|
+
}
|
package/dist/template.js
CHANGED
|
@@ -1,141 +1,45 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
/** Files the CLI lays into a new project root: the app itself comes from the platform template. */
|
|
2
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const node_path_1 = require("node:path");
|
|
10
|
-
const GITIGNORE = `node_modules/
|
|
11
|
-
dist/
|
|
12
|
-
.xflow/
|
|
13
|
-
.env
|
|
14
|
-
.env.*
|
|
4
|
+
exports.projectScaffold = projectScaffold;
|
|
5
|
+
const GITIGNORE = `node_modules/
|
|
6
|
+
dist/
|
|
7
|
+
.xflow/
|
|
8
|
+
.env
|
|
9
|
+
.env.*
|
|
15
10
|
`;
|
|
16
|
-
const README = `# %NAME%
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
\`\`\`
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
11
|
+
const README = `# %NAME%
|
|
12
|
+
|
|
13
|
+
A project on XFlow: one database, cloud functions and the apps that use them.
|
|
14
|
+
|
|
15
|
+
\`\`\`
|
|
16
|
+
xflow.json projectId, and which apps and pages may call each function
|
|
17
|
+
package.json dependencies of the functions
|
|
18
|
+
functions/<name>/ cloud functions, shared by every app
|
|
19
|
+
migrations/ the database of the project
|
|
20
|
+
apps/<folder>/ an app: its own code, versions and address
|
|
21
|
+
\`\`\`
|
|
22
|
+
|
|
23
|
+
\`\`\`bash
|
|
24
|
+
xflow deploy functions -m "..." # ship the functions and their callers
|
|
25
|
+
xflow deploy apps/<folder> -m "..." # build one app
|
|
26
|
+
xflow publish # inside an app folder: show the newest build
|
|
27
|
+
xflow status # what is on the server
|
|
28
|
+
\`\`\`
|
|
33
29
|
`;
|
|
34
|
-
|
|
35
|
-
const FUNCTIONS_ENV_KEY = 'VITE_XFLOW_FUNCTIONS';
|
|
36
|
-
function functionsEnvValue(functions) {
|
|
37
|
-
const map = {};
|
|
38
|
-
for (const fn of functions) {
|
|
39
|
-
if (fn.invoke_url)
|
|
40
|
-
map[fn.name] = fn.invoke_url;
|
|
41
|
-
}
|
|
42
|
-
return JSON.stringify(map);
|
|
43
|
-
}
|
|
44
|
-
/** The platform address the way .env holds it, and the way every template reads it. */
|
|
45
|
-
function envApiUrl(apiUrl) {
|
|
46
|
-
return apiUrl.replace(/^https?:\/\//, '');
|
|
47
|
-
}
|
|
48
|
-
/** The variables that point a local run at the project: written to .env, handed to xflow dev. */
|
|
49
|
-
function platformEnv(projectToken, apiUrl, functions = [],
|
|
50
|
-
// The map the platform assembled: it also holds the standalone functions of the
|
|
51
|
-
// organization. Own functions alone are the fallback for a platform without it.
|
|
52
|
-
map) {
|
|
53
|
-
return {
|
|
54
|
-
VITE_XFLOW_PROJECT_TOKEN: projectToken ?? '',
|
|
55
|
-
VITE_XFLOW_API_URL: envApiUrl(apiUrl),
|
|
56
|
-
[FUNCTIONS_ENV_KEY]: map ? JSON.stringify(map) : functionsEnvValue(functions),
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Point .env at the current project: written on every link and deploy, so that `npm run dev`
|
|
61
|
-
* never keeps talking to the previous one. Variables of your own are left alone.
|
|
62
|
-
*/
|
|
63
|
-
function writePlatformEnv(root, projectToken, apiUrl, functions = [], map) {
|
|
64
|
-
for (const [key, value] of Object.entries(platformEnv(projectToken, apiUrl, functions, map))) {
|
|
65
|
-
writeEnvValue(root, key, value);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
/** Update one variable in .env without touching the rest of the file. */
|
|
69
|
-
function writeEnvValue(root, key, value) {
|
|
70
|
-
const path = (0, node_path_1.join)(root, '.env');
|
|
71
|
-
const line = `${key}=${value}`;
|
|
72
|
-
if (!(0, node_fs_1.existsSync)(path)) {
|
|
73
|
-
(0, node_fs_1.writeFileSync)(path, `${line}\n`, 'utf-8');
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
const text = (0, node_fs_1.readFileSync)(path, 'utf-8');
|
|
77
|
-
const existing = new RegExp(`^${key}=.*$`, 'm');
|
|
78
|
-
const next = existing.test(text) ? text.replace(existing, line) : `${text.replace(/\n*$/, '\n')}${line}\n`;
|
|
79
|
-
if (next !== text)
|
|
80
|
-
(0, node_fs_1.writeFileSync)(path, next, 'utf-8');
|
|
81
|
-
}
|
|
82
|
-
function scaffoldFiles(projectName) {
|
|
83
|
-
return [
|
|
84
|
-
{ path: '.gitignore', content: GITIGNORE },
|
|
85
|
-
{ path: 'README.md', content: README.replace('%NAME%', projectName.replace(/"/g, '\\"')) },
|
|
86
|
-
];
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* A standalone function has no site, so it gets no Vite tree: one handler, and
|
|
90
|
-
* esbuild, which the sandbox takes from the project to bundle the function.
|
|
91
|
-
*/
|
|
92
|
-
const FUNCTION_HANDLER = `/**
|
|
93
|
-
* Cloud function of the %NAME% project.
|
|
94
|
-
*
|
|
95
|
-
* XFlow calls it over HTTP and, if a schedule is set, on a timer. The answer is
|
|
96
|
-
* { statusCode, body }, where body is a string.
|
|
97
|
-
*/
|
|
98
|
-
export async function handler(event: {
|
|
99
|
-
httpMethod: string;
|
|
100
|
-
body: string | null;
|
|
101
|
-
queryStringParameters: Record<string, string> | null;
|
|
102
|
-
}) {
|
|
103
|
-
const input = event.body ? JSON.parse(event.body) : {};
|
|
104
|
-
|
|
105
|
-
return {
|
|
106
|
-
statusCode: 200,
|
|
107
|
-
body: JSON.stringify({ ok: true, method: event.httpMethod, input }),
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
`;
|
|
111
|
-
const FUNCTION_README = `# %NAME%
|
|
112
|
-
|
|
113
|
-
A standalone function on XFlow: a shared backend for the applications of the
|
|
114
|
-
organization, without a site of its own.
|
|
115
|
-
|
|
116
|
-
\`\`\`bash
|
|
117
|
-
npm install # dependencies
|
|
118
|
-
xflow deploy # send the code, build and release the function
|
|
119
|
-
xflow functions # its address and status
|
|
120
|
-
\`\`\`
|
|
121
|
-
|
|
122
|
-
The code lives in \`functions/%FUNCTION%/index.ts\`. A schedule, keys and secrets
|
|
123
|
-
are set in the platform, in the "Functions" section.
|
|
124
|
-
`;
|
|
125
|
-
function functionScaffoldFiles(projectName, functionName) {
|
|
126
|
-
const fill = (text) => text.replace(/%NAME%/g, projectName.replace(/"/g, '\\"')).replace(/%FUNCTION%/g, functionName);
|
|
30
|
+
function projectScaffold(projectName) {
|
|
127
31
|
return [
|
|
128
32
|
{ path: '.gitignore', content: GITIGNORE },
|
|
129
|
-
{ path: 'README.md', content:
|
|
33
|
+
{ path: 'README.md', content: README.replace('%NAME%', projectName) },
|
|
130
34
|
{
|
|
131
35
|
path: 'package.json',
|
|
132
36
|
content: `${JSON.stringify({
|
|
133
|
-
name:
|
|
37
|
+
name: 'functions',
|
|
134
38
|
private: true,
|
|
135
39
|
type: 'module',
|
|
40
|
+
// The sandbox bundles every function with the esbuild of the project.
|
|
136
41
|
devDependencies: { esbuild: '0.28.2' },
|
|
137
42
|
}, null, 2)}\n`,
|
|
138
43
|
},
|
|
139
|
-
{ path: `functions/${functionName}/index.ts`, content: fill(FUNCTION_HANDLER) },
|
|
140
44
|
];
|
|
141
45
|
}
|
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.
|
|
5
|
+
exports.CLI_VERSION = '1.0.0';
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getxflow/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI for the XFlow platform: source sync, deployment and publishing
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI for the XFlow platform: projects with apps, functions and a database; source sync, deployment and publishing",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20"
|