@microteia/console 2.41.0 → 2.43.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/CONSOLE-VERSIONS.md +22 -0
- package/README.md +1 -1
- package/lib/console/pluginWorkspace.js +93 -27
- package/lib/index.js +1 -1
- package/lib/kernel/manifest.js +0 -14
- package/lib/shared/paths.d.ts +1 -1
- package/lib/shared/paths.js +1 -1
- package/lib/vendor/installationDocs.js +1 -1
- package/lib/vendor/pluginWorkspaceTemplates.d.ts +18 -1
- package/lib/vendor/pluginWorkspaceTemplates.js +30 -9
- package/package.json +1 -1
package/CONSOLE-VERSIONS.md
CHANGED
|
@@ -22,6 +22,28 @@
|
|
|
22
22
|
- fix(command): what was broken, and what it did to whoever hit it.
|
|
23
23
|
-->
|
|
24
24
|
|
|
25
|
+
#### [2.43.0] — 2026-11-10
|
|
26
|
+
|
|
27
|
+
- feat(plugin-init): with a terminal, asks the publisher and the prefix right after the namespace, each
|
|
28
|
+
with a short explanation, and records them in the seed `package.json` under `microteia.publisher` and
|
|
29
|
+
`microteia.prefix` — the keys `microteia-plugin create` reads. The publisher suggests the office name
|
|
30
|
+
(`project.name` in `microteia.yaml`); the prefix suggests the one `create` would (the namespace's last
|
|
31
|
+
segment when it fits, `com.acme` → `acme`); Enter accepts either. Until now both were asked by the first
|
|
32
|
+
`create`, so a value that never changes was chosen by whoever ran the first extension — often an agent.
|
|
33
|
+
- feat(plugin-init): `--publisher <name>` and `--prefix <acme>` answer without a terminal; an invalid
|
|
34
|
+
prefix is refused before anything is written. Without a terminal and without the flags, both are left
|
|
35
|
+
out and `create` asks for them once, as before. `--force` reuses the three values already recorded.
|
|
36
|
+
|
|
37
|
+
#### [2.42.0] — 2026-11-08
|
|
38
|
+
|
|
39
|
+
- feat(kernel/manifest)!: the optional prerequisite `self-service-tenants` and the backend parameter
|
|
40
|
+
`SELF_SERVICE_TENANTS` are gone. Whether a new account gets an organization of its own is now decided
|
|
41
|
+
by SYSTEM in Access Management › Users (stored in `system/registration`), and takes effect without a
|
|
42
|
+
deploy. A `SELF_SERVICE_TENANTS` line left in `.env.<projectId>` is ignored: an installation that had it
|
|
43
|
+
set to `false` must tick "Block automatic organizations" there once, after upgrading the kernel to
|
|
44
|
+
9.214.0.
|
|
45
|
+
- docs(installation): `MICROTEIA.md` no longer cites `SELF_SERVICE_TENANTS` as a platform setting.
|
|
46
|
+
|
|
25
47
|
#### [2.41.0] — 2026-11-07
|
|
26
48
|
|
|
27
49
|
- feat(cli/publish): after the catalog is written, the source repository (the one `gh repo view` names
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ import { blank, bullets, heading, info, success, warn } from '../shared/ui.js';
|
|
|
6
6
|
import { run } from '../shared/run.js';
|
|
7
7
|
import { ENVIRONMENT_FILE, readEnvironment } from './environment.js';
|
|
8
8
|
import { WORKSPACE_DIR } from './workspace.js';
|
|
9
|
-
import { SDK_TARBALL, isValidNamespace, workspacePackageJson } from '../vendor/pluginWorkspaceTemplates.js';
|
|
9
|
+
import { SDK_TARBALL, isValidNamespace, isValidPrefix, suggestPrefix, workspacePackageJson, } from '../vendor/pluginWorkspaceTemplates.js';
|
|
10
10
|
/**
|
|
11
11
|
* `microteia plugin-init` — prepara `extensions/`, onde o escritório escreve extensão em TypeScript.
|
|
12
12
|
*
|
|
@@ -108,40 +108,100 @@ export const refreshAuthoringWorkspace = async (root, officeName) => {
|
|
|
108
108
|
return false;
|
|
109
109
|
}
|
|
110
110
|
};
|
|
111
|
-
/**
|
|
112
|
-
|
|
111
|
+
/**
|
|
112
|
+
* O que um `--force` anterior deixou gravado: reescrever a semente não é motivo para perguntar de novo —
|
|
113
|
+
* e o prefixo, uma vez usado, não pode mudar.
|
|
114
|
+
*/
|
|
115
|
+
const existingIdentity = async (directory) => {
|
|
113
116
|
try {
|
|
114
117
|
const pkg = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8'));
|
|
115
|
-
const
|
|
116
|
-
return
|
|
118
|
+
const { namespace, publisher, prefix } = pkg.microteia ?? {};
|
|
119
|
+
return {
|
|
120
|
+
...(typeof namespace === 'string' && isValidNamespace(namespace) ? { namespace } : {}),
|
|
121
|
+
...(typeof publisher === 'string' && publisher.trim() !== '' ? { publisher: publisher.trim() } : {}),
|
|
122
|
+
...(typeof prefix === 'string' && isValidPrefix(prefix) ? { prefix } : {}),
|
|
123
|
+
};
|
|
117
124
|
}
|
|
118
125
|
catch {
|
|
119
|
-
return
|
|
126
|
+
return {};
|
|
120
127
|
}
|
|
121
128
|
};
|
|
129
|
+
const invalidNamespace = (namespace) => new Error(`"${namespace}" is not a valid namespace. Use reverse-DNS, lowercase, from a domain your organization`
|
|
130
|
+
+ ' controls — for example: --namespace com.acme');
|
|
131
|
+
const invalidPrefix = (prefix) => new Error(`"${prefix}" is not a valid prefix. Use 2 to 8 lowercase letters or digits, starting with a letter`
|
|
132
|
+
+ ' — for example: --prefix acme');
|
|
133
|
+
/** Com sugestão, Enter a aceita — é o que torna a sugestão uma resposta, e não só um exemplo. */
|
|
134
|
+
const asker = (rl) => async (label, suggestion) => {
|
|
135
|
+
const answer = (await rl.question(suggestion === undefined ? `${label}: ` : `${label} [${suggestion}]: `)).trim();
|
|
136
|
+
return answer === '' && suggestion !== undefined ? suggestion : answer;
|
|
137
|
+
};
|
|
122
138
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
139
|
+
* Namespace, publisher e prefix — os três valem para TODA extensão do diretório, e por isso são
|
|
140
|
+
* perguntados aqui, uma vez, e não no primeiro `create`: quem chegava a ele de um agente via o prefixo
|
|
141
|
+
* ser escolhido por quem não podia escolhê-lo.
|
|
142
|
+
*
|
|
143
|
+
* O namespace é OBRIGATÓRIO e não tem padrão: qualquer padrão seria um prefixo que não é do escritório,
|
|
144
|
+
* e o id que nasce dele é permanente. Sem terminal, só a flag o responde. Publisher e prefix, sem
|
|
145
|
+
* terminal e sem flag, ficam para o `create` — que já sabe perguntá-los.
|
|
125
146
|
*/
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
147
|
+
const resolveIdentity = async (args, directory, officeName) => {
|
|
148
|
+
const stored = await existingIdentity(directory);
|
|
149
|
+
const given = {
|
|
150
|
+
namespace: flagValue(args, 'namespace')?.trim() ?? stored.namespace,
|
|
151
|
+
publisher: flagValue(args, 'publisher')?.trim() ?? stored.publisher,
|
|
152
|
+
prefix: flagValue(args, 'prefix')?.trim() ?? stored.prefix,
|
|
153
|
+
};
|
|
154
|
+
if (given.namespace !== undefined && !isValidNamespace(given.namespace))
|
|
155
|
+
throw invalidNamespace(given.namespace);
|
|
156
|
+
if (given.prefix !== undefined && !isValidPrefix(given.prefix))
|
|
157
|
+
throw invalidPrefix(given.prefix);
|
|
158
|
+
if (given.publisher === '')
|
|
159
|
+
throw new Error('The publisher cannot be empty — for example: --publisher "Acme Legal"');
|
|
136
160
|
if (!process.stdin.isTTY) {
|
|
137
|
-
|
|
138
|
-
|
|
161
|
+
if (given.namespace === undefined) {
|
|
162
|
+
throw new Error('A namespace is required: every extension written in this directory is named below it, and its ids\n'
|
|
163
|
+
+ 'are permanent. Pass it with --namespace, for example: microteia plugin-init --namespace com.acme');
|
|
164
|
+
}
|
|
165
|
+
return { namespace: given.namespace, publisher: given.publisher, prefix: given.prefix };
|
|
166
|
+
}
|
|
167
|
+
if (given.namespace !== undefined && given.publisher !== undefined && given.prefix !== undefined) {
|
|
168
|
+
return { namespace: given.namespace, publisher: given.publisher, prefix: given.prefix };
|
|
139
169
|
}
|
|
140
|
-
info('Every extension written here is named below a namespace — the reverse-DNS of a domain your');
|
|
141
|
-
info('organization controls, like com.acme. Its ids are permanent, so choose one that is yours.');
|
|
142
170
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
171
|
+
const ask = asker(rl);
|
|
143
172
|
try {
|
|
144
|
-
|
|
173
|
+
let namespace = given.namespace;
|
|
174
|
+
if (namespace === undefined) {
|
|
175
|
+
blank();
|
|
176
|
+
info('NAMESPACE — every extension written here is named below it: "billing" becomes com.acme.billing.');
|
|
177
|
+
info('Use the reverse-DNS of a domain your organization controls. The ids are permanent, so choose one');
|
|
178
|
+
info('that is yours.');
|
|
179
|
+
namespace = await ask('namespace');
|
|
180
|
+
if (!isValidNamespace(namespace))
|
|
181
|
+
throw invalidNamespace(namespace);
|
|
182
|
+
}
|
|
183
|
+
let publisher = given.publisher;
|
|
184
|
+
if (publisher === undefined) {
|
|
185
|
+
blank();
|
|
186
|
+
info('PUBLISHER — who answers for these extensions. It is shown as the author of each one on the');
|
|
187
|
+
info('Extensions screen of every office that installs it. A name, not an id.');
|
|
188
|
+
publisher = await ask('publisher', officeName);
|
|
189
|
+
if (publisher === '')
|
|
190
|
+
throw new Error('The publisher cannot be empty.');
|
|
191
|
+
}
|
|
192
|
+
let prefix = given.prefix;
|
|
193
|
+
if (prefix === undefined) {
|
|
194
|
+
blank();
|
|
195
|
+
info('PREFIX — starts every name your extensions create: acme_clients for a collection, acme-notify for');
|
|
196
|
+
info('an automation. It keeps two publishers who both pick "clients" from colliding in the same office.');
|
|
197
|
+
info('2 to 8 lowercase letters or digits. It never changes: a new prefix would rename the collections');
|
|
198
|
+
info('and leave their records behind. The suggestion comes from the namespace.');
|
|
199
|
+
prefix = await ask('prefix', suggestPrefix(namespace));
|
|
200
|
+
if (!isValidPrefix(prefix))
|
|
201
|
+
throw invalidPrefix(prefix);
|
|
202
|
+
}
|
|
203
|
+
blank();
|
|
204
|
+
return { namespace, publisher, prefix };
|
|
145
205
|
}
|
|
146
206
|
finally {
|
|
147
207
|
rl.close();
|
|
@@ -160,19 +220,25 @@ export const pluginInit = async (args) => {
|
|
|
160
220
|
}
|
|
161
221
|
const sdk = await locateSdk(root);
|
|
162
222
|
const installDir = join(root, WORKSPACE_DIR, 'install');
|
|
163
|
-
const namespace = await resolveNamespace(args, directory);
|
|
164
223
|
heading(`Extension authoring for "${environment.project.name}"`);
|
|
224
|
+
const identity = await resolveIdentity(args, directory, environment.project.name);
|
|
225
|
+
const { namespace } = identity;
|
|
165
226
|
await mkdir(directory, { recursive: true });
|
|
166
227
|
// O caminho do tarball é relativo, para que o diretório continue válido se a instalação inteira for
|
|
167
228
|
// movida — o que acontece assim que alguém a versiona num repositório do escritório.
|
|
168
|
-
await writeFile(join(directory, 'package.json'), workspacePackageJson(environment.environment.id, relative(directory, sdk.path),
|
|
169
|
-
success(`wrote the seed package.json (namespace ${namespace}
|
|
229
|
+
await writeFile(join(directory, 'package.json'), workspacePackageJson(environment.environment.id, relative(directory, sdk.path), identity));
|
|
230
|
+
success(`wrote the seed package.json (namespace ${namespace}`
|
|
231
|
+
+ `${identity.publisher === undefined ? '' : `, publisher "${identity.publisher}"`}`
|
|
232
|
+
+ `${identity.prefix === undefined ? '' : `, prefix ${identity.prefix}`})`);
|
|
233
|
+
if (identity.publisher === undefined || identity.prefix === undefined) {
|
|
234
|
+
info(' publisher and prefix not given — "npm run create" asks for them once, and records them there.');
|
|
235
|
+
}
|
|
170
236
|
blank();
|
|
171
237
|
info('Installing @microteia/sdk and writing the guides...');
|
|
172
238
|
await syncAuthoringWorkspace(directory, sdk.path, installDir, environment.project.name);
|
|
173
239
|
blank();
|
|
174
240
|
info(`Extension authoring ready in ${relative(root, directory)}/ (kernel contract ${sdk.kernelVersion}).`);
|
|
175
|
-
info(`Every extension created there is ${namespace}.<name> — recorded in package.json, under microteia
|
|
241
|
+
info(`Every extension created there is ${namespace}.<name> — recorded in package.json, under microteia.`);
|
|
176
242
|
bullets([
|
|
177
243
|
`cd ${relative(process.cwd(), directory) || '.'}`,
|
|
178
244
|
'npm run create',
|
package/lib/index.js
CHANGED
|
@@ -67,7 +67,7 @@ const COMMANDS = [
|
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
name: 'plugin-init',
|
|
70
|
-
usage: 'microteia plugin-init --namespace <com.acme> [--dir <path>] [--force]',
|
|
70
|
+
usage: 'microteia plugin-init --namespace <com.acme> [--publisher <name>] [--prefix <acme>] [--dir <path>] [--force]',
|
|
71
71
|
summary: 'Prepare "extensions/", to author extensions in TypeScript in your editor.',
|
|
72
72
|
audience: 'office',
|
|
73
73
|
execute: pluginInit,
|
package/lib/kernel/manifest.js
CHANGED
|
@@ -141,20 +141,6 @@ export const MANUAL_PREREQUISITES = [
|
|
|
141
141
|
+ 'changes for anyone.',
|
|
142
142
|
optional: true,
|
|
143
143
|
},
|
|
144
|
-
{
|
|
145
|
-
id: 'self-service-tenants',
|
|
146
|
-
title: 'Stop sign-up from opening an organization, if this installation is shared',
|
|
147
|
-
detail: 'By default every new account gets an organization of its own, with that person as the owner. In an '
|
|
148
|
-
+ 'installation shared by several organizations — each one created on purpose — that lets anyone who '
|
|
149
|
-
+ 'can create an account open one. Write SELF_SERVICE_TENANTS=false in .env.<projectId> and run '
|
|
150
|
-
+ 'microteia deploy --only functions: from then on a new account waits to be added to an organization, and only the '
|
|
151
|
-
+ 'installation administrator (SYSTEM) opens organizations, in Access management > Organizations, '
|
|
152
|
-
+ 'naming an owner who already has an account. The first account of the installation gets one '
|
|
153
|
-
+ 'anyway. Any value other than empty, true or false is read as false. Two-step verification is '
|
|
154
|
-
+ 'required per organization and does not reach the installation administrator: protect SYSTEM '
|
|
155
|
-
+ 'accounts with a sign-in provider that enforces its own second factor.',
|
|
156
|
-
optional: true,
|
|
157
|
-
},
|
|
158
144
|
];
|
|
159
145
|
/**
|
|
160
146
|
* Os nomes das Cloud Functions saem de `functions/src/index.ts`, a mesma fonte e o mesmo parse que
|
package/lib/shared/paths.d.ts
CHANGED
|
@@ -87,4 +87,4 @@ export declare const consoleRoot: string;
|
|
|
87
87
|
* de código nunca chegou a quem instala. Este bloco fica sendo o que ele já era antes de crescer: a
|
|
88
88
|
* política de semver da ferramenta, mais o registro do que motivou as versões até a `2.5.0`.
|
|
89
89
|
*/
|
|
90
|
-
export declare const CLI_VERSION = "2.
|
|
90
|
+
export declare const CLI_VERSION = "2.43.0";
|
package/lib/shared/paths.js
CHANGED
|
@@ -94,4 +94,4 @@ export const consoleRoot = resolve(here, '..', '..');
|
|
|
94
94
|
* de código nunca chegou a quem instala. Este bloco fica sendo o que ele já era antes de crescer: a
|
|
95
95
|
* política de semver da ferramenta, mais o registro do que motivou as versões até a `2.5.0`.
|
|
96
96
|
*/
|
|
97
|
-
export const CLI_VERSION = '2.
|
|
97
|
+
export const CLI_VERSION = '2.43.0';
|
|
@@ -197,7 +197,7 @@ setting:
|
|
|
197
197
|
|
|
198
198
|
- **A setting of an extension, or of your own \`functions/\`** — run \`microteia provision\`. It notices the
|
|
199
199
|
change (by a salted digest in \`microteia.yaml\`) and requeues exactly the functions that carry it.
|
|
200
|
-
- **A setting of the platform itself** (\`
|
|
200
|
+
- **A setting of the platform itself** (\`AUDIT_ARCHIVE_BUCKET\`, …) — run
|
|
201
201
|
\`microteia deploy --only functions\`. \`provision\` does not track them; the deploy compares each
|
|
202
202
|
function's stamp, which includes its environment, and redeploys the ones whose value changed.
|
|
203
203
|
|
|
@@ -16,5 +16,22 @@ export declare const SDK_TARBALL = "microteia-sdk.tgz";
|
|
|
16
16
|
* duplicá-los aqui era exatamente o problema. O `sync` os acrescenta logo depois do `npm install`, e o
|
|
17
17
|
* segundo `npm install` os instala.
|
|
18
18
|
*/
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* O que só quem roda o `plugin-init` pode decidir, e que vale para TODA extensão do diretório. O
|
|
21
|
+
* `publisher` e o `prefix` são opcionais: sem terminal e sem flag, quem os pergunta é o `create` do SDK,
|
|
22
|
+
* uma vez — o mesmo que faz num diretório anterior a eles.
|
|
23
|
+
*/
|
|
24
|
+
export interface WorkspaceIdentity {
|
|
25
|
+
namespace: string;
|
|
26
|
+
publisher?: string;
|
|
27
|
+
prefix?: string;
|
|
28
|
+
}
|
|
29
|
+
export declare const workspacePackageJson: (officeName: string, sdkPath: string, identity: WorkspaceIdentity) => string;
|
|
20
30
|
export declare const isValidNamespace: (value: string) => boolean;
|
|
31
|
+
export declare const isValidPrefix: (value: string) => boolean;
|
|
32
|
+
/**
|
|
33
|
+
* A sugestão do `create` do SDK (`suggestedPrefix`), repetida aqui para que o `plugin-init` ofereça o
|
|
34
|
+
* MESMO prefixo que o `create` ofereceria: o último segmento do namespace, se ele já serve
|
|
35
|
+
* (`com.acme` → `acme`), senão os primeiros caracteres que servem, senão quatro letras sorteadas.
|
|
36
|
+
*/
|
|
37
|
+
export declare const suggestPrefix: (namespace: string, random?: () => number) => string;
|
|
@@ -11,12 +11,7 @@
|
|
|
11
11
|
* escritório. É a semente — mínima de propósito, porque quem a completa é o `sync`.
|
|
12
12
|
*/
|
|
13
13
|
export const SDK_TARBALL = 'microteia-sdk.tgz';
|
|
14
|
-
|
|
15
|
-
* A semente do `package.json`. Não traz scripts nem devDependencies: os nomes de comando são do SDK, e
|
|
16
|
-
* duplicá-los aqui era exatamente o problema. O `sync` os acrescenta logo depois do `npm install`, e o
|
|
17
|
-
* segundo `npm install` os instala.
|
|
18
|
-
*/
|
|
19
|
-
export const workspacePackageJson = (officeName, sdkPath, namespace) => JSON.stringify({
|
|
14
|
+
export const workspacePackageJson = (officeName, sdkPath, identity) => JSON.stringify({
|
|
20
15
|
name: `${officeName}-extensions`,
|
|
21
16
|
version: '0.0.0',
|
|
22
17
|
private: true,
|
|
@@ -26,9 +21,14 @@ export const workspacePackageJson = (officeName, sdkPath, namespace) => JSON.str
|
|
|
26
21
|
// traz o SDK novo é `npm run sdk:refresh` — ou o `microteia unpack`, que o faz sozinho.
|
|
27
22
|
'@microteia/sdk': `file:${sdkPath}`,
|
|
28
23
|
},
|
|
29
|
-
//
|
|
30
|
-
// o
|
|
31
|
-
|
|
24
|
+
// É o SDK que lê estas chaves (`microteia-plugin create`, o MCP); o Console só as recebe de quem roda
|
|
25
|
+
// o `plugin-init`, que é a única pessoa que pode decidi-las. Chave sem valor fica de fora, e não
|
|
26
|
+
// vazia: o `create` só pergunta o que não acha.
|
|
27
|
+
microteia: {
|
|
28
|
+
namespace: identity.namespace,
|
|
29
|
+
...(identity.publisher === undefined ? {} : { publisher: identity.publisher }),
|
|
30
|
+
...(identity.prefix === undefined ? {} : { prefix: identity.prefix }),
|
|
31
|
+
},
|
|
32
32
|
}, null, 2) + '\n';
|
|
33
33
|
/**
|
|
34
34
|
* O formato de id de extensão (`isValidPluginId`, no domínio) — repetido porque o Console não importa
|
|
@@ -37,3 +37,24 @@ export const workspacePackageJson = (officeName, sdkPath, namespace) => JSON.str
|
|
|
37
37
|
*/
|
|
38
38
|
const NAMESPACE_PATTERN = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$/;
|
|
39
39
|
export const isValidNamespace = (value) => NAMESPACE_PATTERN.test(value);
|
|
40
|
+
/**
|
|
41
|
+
* O formato do prefixo de artefato (`ARTIFACT_PREFIX_PATTERN`, no domínio) — repetido pela mesma razão do
|
|
42
|
+
* namespace, e preso a ele pelo mesmo teste.
|
|
43
|
+
*/
|
|
44
|
+
const PREFIX_PATTERN = /^[a-z][a-z0-9]{1,7}$/;
|
|
45
|
+
const PREFIX_MAX_LENGTH = 8;
|
|
46
|
+
const GENERATED_PREFIX_LENGTH = 4;
|
|
47
|
+
const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
|
|
48
|
+
export const isValidPrefix = (value) => PREFIX_PATTERN.test(value);
|
|
49
|
+
/**
|
|
50
|
+
* A sugestão do `create` do SDK (`suggestedPrefix`), repetida aqui para que o `plugin-init` ofereça o
|
|
51
|
+
* MESMO prefixo que o `create` ofereceria: o último segmento do namespace, se ele já serve
|
|
52
|
+
* (`com.acme` → `acme`), senão os primeiros caracteres que servem, senão quatro letras sorteadas.
|
|
53
|
+
*/
|
|
54
|
+
export const suggestPrefix = (namespace, random = Math.random) => {
|
|
55
|
+
const last = (namespace.split('.').pop() ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
56
|
+
const candidate = last.replace(/^[0-9]+/, '').slice(0, PREFIX_MAX_LENGTH);
|
|
57
|
+
if (isValidPrefix(candidate))
|
|
58
|
+
return candidate;
|
|
59
|
+
return Array.from({ length: GENERATED_PREFIX_LENGTH }, () => LETTERS[Math.floor(random() * LETTERS.length) % LETTERS.length]).join('');
|
|
60
|
+
};
|