@getxflow/cli 0.3.2 → 0.4.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/dist/bin.js CHANGED
@@ -60,7 +60,7 @@ async function run(args) {
60
60
  }
61
61
  throw new errors_1.CliError(`Unknown command: projects ${second}`, 'Available: list and get');
62
62
  case 'skills':
63
- (0, skills_1.skills)(rest);
63
+ await (0, skills_1.skills)(rest);
64
64
  return;
65
65
  case 'mcp':
66
66
  if (second === undefined || second === 'install') {
@@ -7,18 +7,22 @@ const node_os_1 = require("node:os");
7
7
  const node_path_1 = require("node:path");
8
8
  const args_1 = require("../args");
9
9
  const errors_1 = require("../errors");
10
+ const prompt_1 = require("../prompt");
10
11
  const ui_1 = require("../ui");
11
- /**
12
- * The SKILL.md format is shared across tools (agentskills.io); each agent reads
13
- * its own folder. Cursor reads .cursor/rules/*.mdc instead, so it gets the same
14
- * text under its own header.
15
- */
16
- const SKILL_DIRS = [
17
- ['.claude', 'skills'],
18
- ['.agents', 'skills'],
12
+ const AGENTS = [
13
+ { id: 'claude', label: 'Claude Code', project: ['.claude', 'skills'], global: ['.claude', 'skills'] },
14
+ { id: 'cursor', label: 'Cursor', project: ['.agents', 'skills'], global: ['.cursor', 'skills'], rule: true },
15
+ { id: 'codex', label: 'Codex', project: ['.agents', 'skills'], global: ['.codex', 'skills'] },
16
+ { id: 'copilot', label: 'GitHub Copilot', project: ['.agents', 'skills'], global: ['.copilot', 'skills'] },
17
+ { id: 'gemini', label: 'Gemini CLI', project: ['.agents', 'skills'], global: ['.gemini', 'skills'] },
18
+ { id: 'opencode', label: 'OpenCode', project: ['.agents', 'skills'], global: ['.config', 'opencode', 'skills'] },
19
+ { id: 'windsurf', label: 'Windsurf', project: ['.windsurf', 'skills'], global: ['.codeium', 'windsurf', 'skills'] },
20
+ { id: 'openclaw', label: 'OpenClaw', project: ['skills'], global: ['.openclaw', 'skills'] },
21
+ { id: 'universal', label: 'Other agents (Cline, Warp, Zed, Amp)', project: ['.agents', 'skills'], global: ['.agents', 'skills'] },
19
22
  ];
20
- /** Codex reads its global skills from .codex/skills, not from .agents. */
21
- const GLOBAL_SKILL_DIRS = [...SKILL_DIRS, ['.codex', 'skills']];
23
+ /** What init and link lay down, and what a run without questions refreshes. */
24
+ const DEFAULT_PROJECT = ['claude', 'cursor', 'codex'];
25
+ const DEFAULT_GLOBAL = ['claude', 'codex', 'universal'];
22
26
  const CURSOR_RULE = ['.cursor', 'rules', 'xflow.mdc'];
23
27
  /** AGENTS.md is always read, unlike the lazily loaded skill: a short pointer lives there. */
24
28
  const POINTER_MARKER = '<!-- xflow-skill -->';
@@ -44,14 +48,20 @@ function cursorRule(skill) {
44
48
  const body = skill.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
45
49
  return `---\ndescription: ${description}\nalwaysApply: false\n---\n\n${body}`;
46
50
  }
47
- /** Writes only when the content differs. Returns the touched path. */
51
+ function skillPath(base, agent, global) {
52
+ return (0, node_path_1.join)(global ? (0, node_os_1.homedir)() : base, ...(global ? agent.global : agent.project), 'xflow', 'SKILL.md');
53
+ }
54
+ function installed(base, agent, global) {
55
+ return (0, node_fs_1.existsSync)(skillPath(base, agent, global));
56
+ }
57
+ /** Writes only when the content differs. */
48
58
  function writeIfChanged(path, content) {
49
59
  const before = (0, node_fs_1.existsSync)(path) ? (0, node_fs_1.readFileSync)(path, 'utf-8') : null;
50
60
  if (before === content)
51
61
  return null;
52
62
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
53
63
  (0, node_fs_1.writeFileSync)(path, content, 'utf-8');
54
- return path;
64
+ return before === null ? 'new' : 'updated';
55
65
  }
56
66
  /** Append once, by marker: the file belongs to the user. */
57
67
  function appendPointer(base) {
@@ -60,31 +70,48 @@ function appendPointer(base) {
60
70
  if (before?.includes(POINTER_MARKER))
61
71
  return null;
62
72
  (0, node_fs_1.writeFileSync)(path, before ? `${before.replace(/\s*$/, '')}\n\n${POINTER}` : POINTER, 'utf-8');
63
- return path;
73
+ return { path, state: before === null ? 'new' : 'updated' };
64
74
  }
65
- function install(base, global) {
75
+ function install(base, ids, global) {
66
76
  const skill = skillSource();
67
- const touched = [];
68
- for (const dir of global ? GLOBAL_SKILL_DIRS : SKILL_DIRS) {
69
- const path = writeIfChanged((0, node_path_1.join)(base, ...dir, 'xflow', 'SKILL.md'), skill);
70
- if (path)
71
- touched.push(path);
77
+ const changes = [];
78
+ const seen = new Set();
79
+ const put = (path, content) => {
80
+ if (seen.has(path))
81
+ return;
82
+ seen.add(path);
83
+ const state = writeIfChanged(path, content);
84
+ if (state)
85
+ changes.push({ path, state });
86
+ };
87
+ for (const agent of AGENTS) {
88
+ if (!ids.includes(agent.id))
89
+ continue;
90
+ put(skillPath(base, agent, global), skill);
91
+ if (!global && agent.rule === true)
92
+ put((0, node_path_1.join)(base, ...CURSOR_RULE), cursorRule(skill));
72
93
  }
73
94
  // Project-level only: these have no global equivalents.
74
95
  if (!global) {
75
- const rule = writeIfChanged((0, node_path_1.join)(base, ...CURSOR_RULE), cursorRule(skill));
76
- if (rule)
77
- touched.push(rule);
78
96
  const pointer = appendPointer(base);
79
97
  if (pointer)
80
- touched.push(pointer);
98
+ changes.push(pointer);
81
99
  }
82
- return touched;
100
+ return changes;
101
+ }
102
+ /** Defaults plus whatever is already installed, so an update run refreshes everything. */
103
+ function defaultIds(base, global) {
104
+ const ids = new Set(global ? DEFAULT_GLOBAL : DEFAULT_PROJECT);
105
+ for (const agent of AGENTS) {
106
+ if (installed(base, agent, global))
107
+ ids.add(agent.id);
108
+ }
109
+ return [...ids];
83
110
  }
84
111
  /** Best-effort install from init and link: a failure must not break the linking. */
85
112
  function installSkillQuietly(base) {
86
113
  try {
87
- if (install(base, false).length > 0) {
114
+ if (install(base, defaultIds(base, false), false).length > 0) {
88
115
  (0, ui_1.note)((0, ui_1.dim)(' The platform instructions are laid out for the AI agent'));
89
116
  }
90
117
  }
@@ -92,12 +119,86 @@ function installSkillQuietly(base) {
92
119
  (0, ui_1.note)((0, ui_1.dim)(' Could not lay out the AI agent instructions: xflow skills'));
93
120
  }
94
121
  }
95
- function skills(args) {
96
- const global = (0, args_1.flagBool)(args, 'global');
97
- const touched = install(global ? (0, node_os_1.homedir)() : process.cwd(), global);
98
- for (const path of touched)
99
- (0, ui_1.out)(path);
100
- if (touched.length === 0)
122
+ function pathLabel(agent, global) {
123
+ const dir = global ? agent.global : agent.project;
124
+ return (global ? ['~', ...dir] : dir).join('/');
125
+ }
126
+ function listAgents(base) {
127
+ (0, ui_1.table)(AGENTS.map((agent) => [
128
+ agent.id,
129
+ agent.label,
130
+ pathLabel(agent, false) + (installed(base, agent, false) ? ' *' : ''),
131
+ pathLabel(agent, true) + (installed(base, agent, true) ? ' *' : ''),
132
+ ]));
133
+ (0, ui_1.note)((0, ui_1.dim)(' * installed. Columns: id, agent, in the project, in the home folder'));
134
+ (0, ui_1.note)((0, ui_1.dim)(' Install without questions: xflow skills --agent <id,id> [--global]'));
135
+ }
136
+ async function pick(globalFlag) {
137
+ let global = globalFlag;
138
+ if (!globalFlag) {
139
+ const scope = await (0, prompt_1.select)('Where to install the xflow skill', [
140
+ { label: 'This project', hint: process.cwd() },
141
+ { label: 'Globally', hint: 'home folder, visible to every project' },
142
+ ]);
143
+ if (scope === null)
144
+ return null;
145
+ global = scope === 1;
146
+ }
147
+ const base = process.cwd();
148
+ const present = AGENTS.map((agent) => installed(base, agent, global));
149
+ const anyPresent = present.includes(true);
150
+ const defaults = global ? DEFAULT_GLOBAL : DEFAULT_PROJECT;
151
+ const picked = await (0, prompt_1.multiSelect)('Which agents get the skill', AGENTS.map((agent, i) => ({
152
+ label: agent.label,
153
+ hint: pathLabel(agent, global) + (present[i] ? ', installed' : ''),
154
+ checked: anyPresent ? present[i] : defaults.includes(agent.id),
155
+ })));
156
+ if (picked === null || picked.length === 0)
157
+ return null;
158
+ return { ids: picked.map((i) => AGENTS[i].id), global };
159
+ }
160
+ async function skills(args) {
161
+ const sub = args.words[0];
162
+ if (sub === 'list') {
163
+ listAgents(process.cwd());
164
+ return;
165
+ }
166
+ if (sub !== undefined) {
167
+ throw new errors_1.CliError(`Unknown command: skills ${sub}`, 'Available: xflow skills [list]');
168
+ }
169
+ let global = (0, args_1.flagBool)(args, 'global');
170
+ const agentFlag = (0, args_1.flagString)(args, 'agent');
171
+ if (args.flags.agent !== undefined && agentFlag === undefined) {
172
+ throw new errors_1.CliError('The --agent flag needs a value', 'For example: --agent claude,cursor. The ids: xflow skills list');
173
+ }
174
+ let ids;
175
+ if (agentFlag !== undefined) {
176
+ ids = agentFlag
177
+ .split(',')
178
+ .map((id) => id.trim())
179
+ .filter((id) => id.length > 0);
180
+ const known = new Set(AGENTS.map((agent) => agent.id));
181
+ const unknown = ids.filter((id) => !known.has(id));
182
+ if (unknown.length > 0 || ids.length === 0) {
183
+ throw new errors_1.CliError(unknown.length > 0 ? `Unknown agent: ${unknown.join(', ')}` : 'No agents named', 'The ids: xflow skills list');
184
+ }
185
+ }
186
+ else if (process.stdin.isTTY === true && process.stderr.isTTY === true && !(0, args_1.flagBool)(args, 'yes')) {
187
+ const picked = await pick(global);
188
+ if (picked === null) {
189
+ (0, ui_1.note)((0, ui_1.dim)(' nothing chosen, nothing changed'));
190
+ return;
191
+ }
192
+ ids = picked.ids;
193
+ global = picked.global;
194
+ }
195
+ else {
196
+ ids = defaultIds(process.cwd(), global);
197
+ }
198
+ const changes = install(process.cwd(), ids, global);
199
+ for (const change of changes)
200
+ (0, ui_1.out)(`${change.state === 'new' ? '+' : '~'} ${change.path}`);
201
+ if (changes.length === 0)
101
202
  (0, ui_1.note)((0, ui_1.dim)(' everything is already up to date'));
102
203
  (0, ui_1.ok)(global ? 'The xflow skill is available in every project' : 'The xflow skill is in place');
103
204
  (0, ui_1.note)((0, ui_1.dim)(' After a CLI update run the command again: the skill ships with it'));
package/dist/help.js CHANGED
@@ -16,7 +16,7 @@ ${(0, ui_1.bold)('Getting started')}
16
16
  xflow init [dir] new project from the platform template
17
17
  xflow templates which templates are available
18
18
  xflow link <id> link this folder to a project (id: xflow projects list)
19
- xflow skills [--global] refresh the platform instructions for an AI agent
19
+ xflow skills pick the agents that get the platform instructions
20
20
  xflow mcp install give the agent platform access without a terminal
21
21
 
22
22
  ${(0, ui_1.bold)('Code')}
@@ -183,19 +183,23 @@ project from the ${(0, ui_1.bold)('project_id')} argument, not from your folder,
183
183
  An agent has no way to know about XFlow: the platform is not in its training. The
184
184
  instructions explain how to release and publish, where the design system components
185
185
  come from, and where to look for production errors. ${(0, ui_1.bold)('init')} and ${(0, ui_1.bold)('link')} lay them
186
- down on their own, so this command is for refreshing them after a CLI update: the two
187
- ship as one version.
188
-
189
- The format is shared (agentskills.io) but the folders differ per tool, so we write all:
190
-
191
- .claude/skills/xflow/SKILL.md Claude Code
192
- .agents/skills/xflow/SKILL.md Codex, OpenClaw
193
- .cursor/rules/xflow.mdc Cursor: it does not read the shared format
194
-
195
- --global the same in the home folder, which makes the skill visible in every
196
- project. There ~/.codex/skills is added as well, because Codex looks in
197
- its shared catalog only inside a repository. The Cursor rule and
198
- AGENTS.md stay per project, they have no global counterpart
186
+ down on their own for the common agents, so this command exists to pick tools
187
+ precisely and to refresh after a CLI update: the skill and the CLI ship as one version.
188
+
189
+ Run in a terminal, it asks two questions: where (this project, or the home folder,
190
+ which makes the skill visible in every project) and for which agents. Copies that are
191
+ already installed come pre-selected and are updated in place. Without a terminal, in
192
+ CI or when an agent runs the command, there are no questions: the defaults plus every
193
+ already installed copy are refreshed.
194
+
195
+ xflow skills list the agents, their folders and what is installed
196
+ --agent claude,cursor exact agents, no questions asked
197
+ --global the home folder instead of the project
198
+ --yes no questions: defaults plus what is installed
199
+
200
+ The format is shared (agentskills.io) and inside a project most tools read the shared
201
+ .agents/skills folder, so the paths differ mostly in the home directory. Cursor also
202
+ gets .cursor/rules/xflow.mdc: its older versions do not read the shared format.
199
203
 
200
204
  Plus a few pointer lines in ${(0, ui_1.bold)('AGENTS.md')}. A skill is picked up lazily, only when its
201
205
  description matches the task, and "add a customers table" will not trigger it. AGENTS.md
package/dist/prompt.js ADDED
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.multiSelect = multiSelect;
4
+ exports.select = select;
5
+ const node_readline_1 = require("node:readline");
6
+ const errors_1 = require("./errors");
7
+ const ui_1 = require("./ui");
8
+ function render(lines, first) {
9
+ const up = first ? '' : `[${lines.length}A`;
10
+ process.stderr.write(up + lines.map((line) => `${line}\n`).join(''));
11
+ }
12
+ function listen(onKey) {
13
+ if (process.stdin.isTTY !== true) {
14
+ throw new errors_1.CliError('An interactive prompt needs a terminal', 'Pass the choice through flags instead');
15
+ }
16
+ (0, node_readline_1.emitKeypressEvents)(process.stdin);
17
+ const wasRaw = process.stdin.isRaw === true;
18
+ process.stdin.setRawMode(true);
19
+ process.stdin.resume();
20
+ const handler = (_, key) => {
21
+ if (key)
22
+ onKey(key);
23
+ };
24
+ process.stdin.on('keypress', handler);
25
+ return () => {
26
+ process.stdin.off('keypress', handler);
27
+ process.stdin.setRawMode(wasRaw);
28
+ process.stdin.pause();
29
+ };
30
+ }
31
+ const isConfirm = (key) => key.name === 'return' || key.name === 'enter';
32
+ const isCancel = (key) => key.name === 'escape' || (key.ctrl === true && key.name === 'c');
33
+ /** Checkbox list. Resolves with the picked indexes, or null when cancelled. */
34
+ function multiSelect(title, choices) {
35
+ return new Promise((resolve) => {
36
+ let cursor = 0;
37
+ let first = true;
38
+ const checked = choices.map((choice) => choice.checked === true);
39
+ const draw = () => {
40
+ render([
41
+ (0, ui_1.bold)(title),
42
+ ...choices.map((choice, i) => {
43
+ const hint = choice.hint ? ` ${(0, ui_1.dim)(choice.hint)}` : '';
44
+ return `${i === cursor ? '>' : ' '} ${checked[i] ? '[x]' : '[ ]'} ${choice.label}${hint}`;
45
+ }),
46
+ (0, ui_1.dim)(' up/down move, space toggle, enter confirm, esc cancel'),
47
+ ], first);
48
+ first = false;
49
+ };
50
+ const stop = listen((key) => {
51
+ if (key.name === 'up')
52
+ cursor = (cursor + choices.length - 1) % choices.length;
53
+ else if (key.name === 'down')
54
+ cursor = (cursor + 1) % choices.length;
55
+ else if (key.name === 'space')
56
+ checked[cursor] = !checked[cursor];
57
+ else if (isConfirm(key)) {
58
+ stop();
59
+ resolve(checked.flatMap((on, i) => (on ? [i] : [])));
60
+ return;
61
+ }
62
+ else if (isCancel(key)) {
63
+ stop();
64
+ resolve(null);
65
+ return;
66
+ }
67
+ else
68
+ return;
69
+ draw();
70
+ });
71
+ draw();
72
+ });
73
+ }
74
+ /** Radio list. Resolves with the picked index, or null when cancelled. */
75
+ function select(title, choices) {
76
+ return new Promise((resolve) => {
77
+ let cursor = 0;
78
+ let first = true;
79
+ const draw = () => {
80
+ render([
81
+ (0, ui_1.bold)(title),
82
+ ...choices.map((choice, i) => {
83
+ const hint = choice.hint ? ` ${(0, ui_1.dim)(choice.hint)}` : '';
84
+ return `${i === cursor ? '>' : ' '} ${choice.label}${hint}`;
85
+ }),
86
+ (0, ui_1.dim)(' up/down move, enter confirm, esc cancel'),
87
+ ], first);
88
+ first = false;
89
+ };
90
+ const stop = listen((key) => {
91
+ if (key.name === 'up')
92
+ cursor = (cursor + choices.length - 1) % choices.length;
93
+ else if (key.name === 'down')
94
+ cursor = (cursor + 1) % choices.length;
95
+ else if (isConfirm(key)) {
96
+ stop();
97
+ resolve(cursor);
98
+ return;
99
+ }
100
+ else if (isCancel(key)) {
101
+ stop();
102
+ resolve(null);
103
+ return;
104
+ }
105
+ else
106
+ return;
107
+ draw();
108
+ });
109
+ draw();
110
+ });
111
+ }
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.3.1';
5
+ exports.CLI_VERSION = '0.4.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@getxflow/cli",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "CLI for the XFlow platform: source sync, deployment and publishing of applications",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
@@ -6,8 +6,9 @@ description: Build, deploy and publish apps on the XFlow platform with the xflow
6
6
  # XFlow
7
7
 
8
8
  Hosting for web apps. The code lives in an ordinary repository on the developer
9
- machine and is built locally; the platform takes the finished build and serves it.
10
- A platform project is recognized by the `xflow.json` file in its root.
9
+ machine, `xflow deploy` sends the sources, and the platform builds them in a clean
10
+ sandbox and serves the result as static files. A platform project is recognized by
11
+ the `xflow.json` file in its root.
11
12
 
12
13
  ## First rule
13
14
 
@@ -16,6 +17,26 @@ memory. If a command is not in the help output, it does not exist: guessing flag
16
17
  pointless. The CLI prints a hint with almost every error, read it in full, it usually
17
18
  contains the fix.
18
19
 
20
+ ## Hard rules
21
+
22
+ These mistakes cost the most because nothing fails at the moment they are made, or
23
+ the error points away from the cause. The sections below carry the details.
24
+
25
+ 1. Read environment variables literally: `process.env.API_KEY`. Destructuring
26
+ (`const { API_KEY } = process.env`) reads as no mention of the variable at all,
27
+ and it arrives empty.
28
+ 2. A value written with `xflow env set` reaches the functions on the next
29
+ `xflow deploy`, not at the moment it is written.
30
+ 3. Never delete a `functions/<name>/` directory unless the user asked for that
31
+ function to go. The next deploy removes it from the cloud together with its
32
+ schedules, and a function created again later gets a different address.
33
+ 4. An already applied migration is never re-run, so editing its file changes
34
+ nothing. A schema change is always a new file.
35
+ 5. When inserts start failing with `db_write_locked`, the database is over its plan
36
+ size. The fix is a migration that deletes data, never a rewrite of the failing SQL.
37
+ 6. In file storage, call `confirm` only after the PUT has finished, and send the same
38
+ `Content-Type` in both calls. Both mistakes answer 200 and break later.
39
+
19
40
  ## Plan limits
20
41
 
21
42
  The organization runs on a plan with finite limits: projects, cloud functions, developer
@@ -134,6 +155,12 @@ separate deploy command: `xflow deploy` ships the functions and then builds the
134
155
  in that order. List what is live with `xflow functions list`. The handler returns
135
156
  `{ statusCode, body }` where `body` is a JSON string.
136
157
 
158
+ Keep one shape inside that string across the whole project: `{ success: true, data }`
159
+ when it worked, `{ success: false, error: { message, code } }` when it did not. Nothing
160
+ enforces this, but a project where every function answers its own way costs an adapter
161
+ on every call. Branch the frontend on `error.code`, never on `error.message`: wording
162
+ gets rewritten on any edit, a code does not.
163
+
137
164
  The sources are the whole truth about which functions exist. Delete the directory and the
138
165
  next deploy deletes the function from the cloud, schedules included, and that cannot be
139
166
  undone: a function created again later gets a different address. So never remove a function
@@ -215,6 +242,17 @@ To run a function on a timer: `xflow schedules set report "0 3 ? * * *"` (daily
215
242
  Six fields, UTC, and exactly one of day-of-month / day-of-week must be `?` — that is
216
243
  how Yandex wants it. A scheduled run reaches the handler as a POST with no headers.
217
244
 
245
+ The pieces line up in one pass. From a new function to a verified schedule:
246
+
247
+ ```
248
+ xflow env set SMTP_PASSWORD=... # secrets first: values ride the next deploy
249
+ # write functions/report/index.ts, reading process.env.SMTP_PASSWORD literally
250
+ xflow deploy # ships the function, then builds the app
251
+ xflow schedules set report "0 3 ? * * *" # after the deploy: a schedule needs a deployed function
252
+ xflow functions invoke report # run it once, the way the app would
253
+ xflow functions logs report # empty output means it never crashed
254
+ ```
255
+
218
256
  ## Database
219
257
 
220
258
  Schema changes are files: `migrations/0001_init.sql`, `migrations/0002_orders.sql`, applied
@@ -291,6 +329,10 @@ answers you get:
291
329
  and takes its real size from there, not from what you declared, so an early `confirm`
292
330
  answers that the file is not there.
293
331
 
332
+ A refusal comes back as `{ error, code }`. Branch on `code` (`invalid_name`, `file_too_large`,
333
+ `quota_exceeded`, `not_uploaded`, `duplicate_name`, `not_found`, …) and never on the text:
334
+ the wording is free to change, the code is not.
335
+
294
336
  What the app may do with files is decided inside that function, because the page in the
295
337
  browser can be edited by whoever opened it. Never write the key into the sources and never
296
338
  send it to the frontend: the build gate stops on a key found in the application code, and a