layero 0.9.5 → 0.10.1
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/LICENSE +21 -0
- package/README.md +203 -167
- package/dist/agent.js +130 -2
- package/dist/api.js +125 -0
- package/dist/bin/layero.js +92 -33
- package/dist/commands/claim.js +141 -0
- package/dist/commands/data-api.js +578 -0
- package/dist/commands/data-probe.js +541 -0
- package/dist/commands/data.js +4 -1
- package/dist/commands/db.js +2 -2
- package/dist/commands/deploy.js +80 -10
- package/dist/commands/envs.js +44 -0
- package/dist/commands/hooks.js +29 -32
- package/dist/commands/init.js +63 -91
- package/dist/commands/link.js +12 -25
- package/dist/commands/logout.js +2 -2
- package/dist/commands/orgs.js +11 -15
- package/dist/commands/projects.js +179 -12
- package/dist/commands/sources.js +127 -0
- package/dist/commands/whoami.js +9 -10
- package/dist/config.js +1 -0
- package/dist/exit-codes.js +102 -0
- package/dist/project-config.js +2 -0
- package/dist/project-ref.js +20 -0
- package/package.json +10 -5
|
@@ -1,21 +1,188 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { ApiClient } from "../api.js";
|
|
1
|
+
import readline from "node:readline/promises";
|
|
2
|
+
import { ApiClient, ApiError } from "../api.js";
|
|
3
3
|
import { loadConfig } from "../config.js";
|
|
4
|
-
|
|
4
|
+
import { LayeroError, detectMode, emit } from "../agent.js";
|
|
5
|
+
import { orgOf } from "./db.js";
|
|
6
|
+
async function makeClient() {
|
|
5
7
|
const cfg = await loadConfig();
|
|
6
8
|
if (!cfg.token) {
|
|
7
|
-
|
|
8
|
-
process.exitCode = 1;
|
|
9
|
-
return;
|
|
9
|
+
throw new LayeroError("auth_required", "вход не выполнен", "выполните `layero login` или задайте LAYERO_TOKEN");
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
return new ApiClient(cfg);
|
|
12
|
+
}
|
|
13
|
+
export async function projectsListCmd() {
|
|
14
|
+
const api = await makeClient();
|
|
12
15
|
const list = await api.listProjects();
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
emit({
|
|
17
|
+
event: "projects",
|
|
18
|
+
projects: list.map((p) => ({
|
|
19
|
+
id: p.id,
|
|
20
|
+
slug: p.slug,
|
|
21
|
+
name: p.name,
|
|
22
|
+
organization: p.organization.slug,
|
|
23
|
+
url: `https://${p.apex_hostname}`,
|
|
24
|
+
source_type: p.source_type,
|
|
25
|
+
repo: p.repo_full_name ?? null,
|
|
26
|
+
status: p.status,
|
|
27
|
+
})),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* `<provider>:<owner/repo>` → провайдер и путь. Путь у GitLab бывает
|
|
32
|
+
* вложенным (`group/sub/project`) — режем только по ПЕРВОМУ двоеточию.
|
|
33
|
+
*/
|
|
34
|
+
export function parseRepoRef(raw) {
|
|
35
|
+
const idx = raw.indexOf(":");
|
|
36
|
+
const provider = idx > 0 ? raw.slice(0, idx).trim().toLowerCase() : "";
|
|
37
|
+
const path = (idx > 0 ? raw.slice(idx + 1) : raw).trim().replace(/^\/+|\/+$/g, "");
|
|
38
|
+
if (!provider || !path.includes("/")) {
|
|
39
|
+
throw new LayeroError("repo_format", `не разобрать «${raw}»`, "формат: --repo <provider>:<owner/repo>, например --repo github:acme/site или --repo gitverse:acme/site; провайдеры — `layero sources list`");
|
|
40
|
+
}
|
|
41
|
+
return { provider, path };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* `layero projects create --repo <provider>:<owner/repo>` — проект из
|
|
45
|
+
* репозитория без панели (этап 6 AX-аудита). Раньше путь (a) «есть
|
|
46
|
+
* репозиторий» начинался с кнопки «Импорт из репозитория», и агент, у
|
|
47
|
+
* которого панели нет, был вынужден выбирать путь (b) — заливать папку.
|
|
48
|
+
*
|
|
49
|
+
* GitHub — через ключ аккаунта установки App: сервер заводит проект и
|
|
50
|
+
* вебхук одним вызовом, иного режима у App нет. Остальные провайдеры — в два
|
|
51
|
+
* шага: проект, затем `connect-source`, потому что только он возвращает
|
|
52
|
+
* судьбу вебхука, а без вебхука push не собирается, и молчать об этом нельзя.
|
|
53
|
+
*/
|
|
54
|
+
export async function projectsCreateCmd(opts) {
|
|
55
|
+
if (!opts.repo) {
|
|
56
|
+
throw new LayeroError("repo_format", "не указан репозиторий", "`layero projects create --repo <provider>:<owner/repo>`; папку без репозитория выкладывает `layero deploy`");
|
|
57
|
+
}
|
|
58
|
+
const { provider, path } = parseRepoRef(opts.repo);
|
|
59
|
+
const api = await makeClient();
|
|
60
|
+
const org = await orgOf(api, opts);
|
|
61
|
+
const accounts = await api.listImportAccounts(org);
|
|
62
|
+
const candidates = accounts.filter((a) => a.provider === provider);
|
|
63
|
+
if (candidates.length === 0) {
|
|
64
|
+
const known = [...new Set(accounts.map((a) => a.provider))];
|
|
65
|
+
throw new LayeroError("account_not_found", `в организации «${org}» нет подключения к ${provider}`, known.length
|
|
66
|
+
? `подключены: ${known.join(", ")}; добавить — \`layero sources connect ${provider} --token-stdin\``
|
|
67
|
+
: `добавьте подключение: \`layero sources connect ${provider} --token-stdin\` (GitHub — установкой App в панели)`);
|
|
68
|
+
}
|
|
69
|
+
const account = candidates.find((a) => a.status === "active" && a.can_import !== false) ?? candidates[0];
|
|
70
|
+
if (account.status !== "active" || account.can_import === false) {
|
|
71
|
+
throw new LayeroError("account_not_found", `подключение ${provider} (${account.login}) в состоянии ${account.status}${account.status_note ? `: ${account.status_note}` : ""}`, account.configure_url ?? "переподключите провайдер: `layero sources connect …`");
|
|
72
|
+
}
|
|
73
|
+
// Репозиторий сверяем по списку аккаунта: опечатка в пути даёт понятный
|
|
74
|
+
// отказ здесь, а не 502 от провайдера после создания проекта.
|
|
75
|
+
const repos = await api.listImportRepos(org, account.key);
|
|
76
|
+
const repo = repos.find((r) => r.path.toLowerCase() === path.toLowerCase());
|
|
77
|
+
if (!repo) {
|
|
78
|
+
const sample = repos.slice(0, 8).map((r) => r.path).join(", ");
|
|
79
|
+
throw new LayeroError("repo_not_found", `репозиторий «${path}» не виден подключению ${provider} (${account.login})`, sample ? `доступны: ${sample}${repos.length > 8 ? ", …" : ""}` : "у подключения нет ни одного репозитория");
|
|
80
|
+
}
|
|
81
|
+
if (repo.imported_project_ids.length > 0) {
|
|
82
|
+
throw new LayeroError("repo_already_imported", `репозиторий «${repo.path}» уже привязан к проекту ${repo.imported_project_ids.join(", ")}`, "`layero link <id>` — привязать папку к нему; второй проект из того же репозитория заводится в панели");
|
|
83
|
+
}
|
|
84
|
+
const branch = opts.branch ?? repo.default_branch ?? "main";
|
|
85
|
+
const name = opts.name ?? repo.name;
|
|
86
|
+
let project;
|
|
87
|
+
if (account.key.startsWith("github:")) {
|
|
88
|
+
project = await api.createProjectFromAccount({
|
|
89
|
+
name,
|
|
90
|
+
organization_slug: org,
|
|
91
|
+
source_account_key: account.key,
|
|
92
|
+
repo_path: repo.path,
|
|
93
|
+
default_branch: branch,
|
|
94
|
+
});
|
|
95
|
+
emitCreated(project, repo.path, branch);
|
|
96
|
+
emit({ event: "source_connected", org, connection_id: account.key, provider, account: account.login });
|
|
97
|
+
// У GitHub App вебхук — часть установки: без него App не существует.
|
|
98
|
+
emit({ event: "webhook_installed", project: project.slug, url: "" });
|
|
15
99
|
return;
|
|
16
100
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
101
|
+
const connectionId = account.key.replace(/^connection:/, "");
|
|
102
|
+
project = await api.createCliProject({ name, organization_slug: org });
|
|
103
|
+
let connected;
|
|
104
|
+
try {
|
|
105
|
+
connected = await api.connectSource(project.id, {
|
|
106
|
+
connection_id: connectionId,
|
|
107
|
+
repo_path: repo.path,
|
|
108
|
+
branch,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// Проект без источника бесполезен и занимает адрес — убираем. Удаление
|
|
113
|
+
// требует scope admin; без него проект останется, и мы это скажем.
|
|
114
|
+
let cleaned = false;
|
|
115
|
+
try {
|
|
116
|
+
await api.deleteProject(project.id);
|
|
117
|
+
cleaned = true;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
/* нет прав или сеть — скажем словами ниже */
|
|
121
|
+
}
|
|
122
|
+
const reason = err instanceof ApiError ? err.body.slice(0, 300) : String(err);
|
|
123
|
+
throw new LayeroError("source_connect_failed", `репозиторий не привязался: ${reason}`, cleaned
|
|
124
|
+
? "проект удалён; проверьте токен подключения (`layero sources list`) и повторите"
|
|
125
|
+
: `проект ${project.slug} создан без репозитория — привяжите в панели или удалите: \`layero projects delete ${project.slug} --yes\``);
|
|
126
|
+
}
|
|
127
|
+
emitCreated(connected.project, repo.path, branch);
|
|
128
|
+
emit({ event: "source_connected", org, connection_id: connectionId, provider, account: account.login });
|
|
129
|
+
if (connected.webhook_registered) {
|
|
130
|
+
emit({ event: "webhook_installed", project: connected.project.slug, url: connected.webhook_url });
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
emit({
|
|
134
|
+
event: "webhook_unavailable",
|
|
135
|
+
project: connected.project.slug,
|
|
136
|
+
url: connected.webhook_url,
|
|
137
|
+
hint: connected.webhook_hint ??
|
|
138
|
+
"провайдер не дал создать вебхук этим токеном — заведите его в настройках репозитория вручную",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function emitCreated(project, repo, branch) {
|
|
143
|
+
emit({
|
|
144
|
+
event: "project_created",
|
|
145
|
+
project_id: project.id,
|
|
146
|
+
slug: project.slug,
|
|
147
|
+
organization: project.organization.slug,
|
|
148
|
+
url: `https://${project.apex_hostname}`,
|
|
149
|
+
repo,
|
|
150
|
+
branch,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* `layero projects delete <slug> --yes` — необратимо. Маршрут требует у
|
|
155
|
+
* токена scope `admin`: токен по умолчанию (`read`+`deploy`) получит
|
|
156
|
+
* `forbidden`, и это правильно — агент с деплой-токеном не должен уметь
|
|
157
|
+
* снести проект.
|
|
158
|
+
*/
|
|
159
|
+
export async function projectsDeleteCmd(ref, opts) {
|
|
160
|
+
const api = await makeClient();
|
|
161
|
+
const project = await api.resolveProject(ref);
|
|
162
|
+
const mode = detectMode();
|
|
163
|
+
if (!opts.yes) {
|
|
164
|
+
if (!mode.interactive) {
|
|
165
|
+
throw new LayeroError("confirmation_required", `удаление проекта ${project.slug} (${project.apex_hostname}) необратимо, а подтвердить его здесь некому`, `покажите это человеку и повторите с --yes: \`layero projects delete ${project.slug} --yes\``);
|
|
166
|
+
}
|
|
167
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
168
|
+
try {
|
|
169
|
+
const answer = (await rl.question(`Удалить проект ${project.slug} и сайт https://${project.apex_hostname}? Необратимо. Введите слаг для подтверждения: `)).trim();
|
|
170
|
+
if (answer !== project.slug) {
|
|
171
|
+
throw new LayeroError("confirmation_required", "удаление отменено", "введите слаг проекта точно, как показан");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
rl.close();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
await api.deleteProject(project.id);
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
if (err instanceof ApiError && err.status === 403) {
|
|
183
|
+
throw new LayeroError("forbidden", "удаление проекта требует токена со scope admin", "выпустите токен: `layero token create <имя> --scope admin` — или удалите проект в панели");
|
|
184
|
+
}
|
|
185
|
+
throw err;
|
|
20
186
|
}
|
|
187
|
+
emit({ event: "project_deleted", project_id: project.id, slug: project.slug });
|
|
21
188
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { ApiClient, ApiError } from "../api.js";
|
|
2
|
+
import { loadConfig } from "../config.js";
|
|
3
|
+
import { LayeroError, emit } from "../agent.js";
|
|
4
|
+
import { orgOf } from "./db.js";
|
|
5
|
+
async function makeClient() {
|
|
6
|
+
const cfg = await loadConfig();
|
|
7
|
+
if (!cfg.token) {
|
|
8
|
+
throw new LayeroError("auth_required", "вход не выполнен", "выполните `layero login` или задайте LAYERO_TOKEN");
|
|
9
|
+
}
|
|
10
|
+
return new ApiClient(cfg);
|
|
11
|
+
}
|
|
12
|
+
/** `layero sources list` — провайдеры, которые платформа умеет, и подключения организации. */
|
|
13
|
+
export async function sourcesListCmd(opts) {
|
|
14
|
+
const api = await makeClient();
|
|
15
|
+
const org = await orgOf(api, opts);
|
|
16
|
+
const [providers, connections] = await Promise.all([
|
|
17
|
+
api.listSourceProviders(org),
|
|
18
|
+
api.listSourceConnections(org),
|
|
19
|
+
]);
|
|
20
|
+
emit({
|
|
21
|
+
event: "sources",
|
|
22
|
+
org,
|
|
23
|
+
providers: providers.map((p) => ({
|
|
24
|
+
id: p.id,
|
|
25
|
+
title: p.title,
|
|
26
|
+
self_hosted: p.self_hosted,
|
|
27
|
+
webhook_supported: p.webhook_supported,
|
|
28
|
+
token_hint: p.token_hint ?? null,
|
|
29
|
+
})),
|
|
30
|
+
connections: connections.map((c) => ({
|
|
31
|
+
id: c.id,
|
|
32
|
+
provider: c.provider_id,
|
|
33
|
+
account: c.external_account,
|
|
34
|
+
status: c.status,
|
|
35
|
+
projects_count: c.projects_count,
|
|
36
|
+
token_expiry_state: c.token_expiry_state,
|
|
37
|
+
last_error: c.last_error,
|
|
38
|
+
})),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** Читает stdin целиком — для `--token-stdin`. */
|
|
42
|
+
async function readStdin() {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
for await (const chunk of process.stdin) {
|
|
45
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
46
|
+
}
|
|
47
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* `layero sources connect <provider> --token-stdin` — подключить провайдера
|
|
51
|
+
* по PAT. Токен принимается флагом `--token` (для скриптов, где история не
|
|
52
|
+
* пишется) и через stdin (`--token-stdin`): в истории shell и в транскрипте
|
|
53
|
+
* агента `--token ghp_…` остаётся навсегда, а `echo $PAT | … --token-stdin`
|
|
54
|
+
* — нет. Сервер проверяет токен до записи и наружу его не возвращает.
|
|
55
|
+
*
|
|
56
|
+
* Список провайдеров — `/organizations/{slug}/source-providers`: сверяем до
|
|
57
|
+
* запроса, чтобы опечатка дала список, а не 400.
|
|
58
|
+
*/
|
|
59
|
+
export async function sourcesConnectCmd(provider, opts) {
|
|
60
|
+
const api = await makeClient();
|
|
61
|
+
const org = await orgOf(api, opts);
|
|
62
|
+
const providers = await api.listSourceProviders(org);
|
|
63
|
+
const spec = providers.find((p) => p.id === provider.trim().toLowerCase());
|
|
64
|
+
if (!spec) {
|
|
65
|
+
throw new LayeroError("provider_unknown", `провайдера «${provider}» нет`, `доступны: ${providers.map((p) => p.id).join(", ")}; GitHub подключается установкой App в панели`);
|
|
66
|
+
}
|
|
67
|
+
if (opts.baseUrl && !spec.self_hosted) {
|
|
68
|
+
throw new LayeroError("bad_format", `${spec.title} не поддерживает собственный инстанс`, "уберите --base-url");
|
|
69
|
+
}
|
|
70
|
+
let token = (opts.token ?? "").trim();
|
|
71
|
+
if (opts.tokenStdin) {
|
|
72
|
+
token = (await readStdin()).trim();
|
|
73
|
+
}
|
|
74
|
+
if (!token) {
|
|
75
|
+
throw new LayeroError("token_missing", "не передан токен провайдера", `${spec.token_hint ? spec.token_hint + ". " : ""}Передайте его через stdin: \`echo "$PAT" | layero sources connect ${spec.id} --token-stdin\``);
|
|
76
|
+
}
|
|
77
|
+
let created;
|
|
78
|
+
try {
|
|
79
|
+
created = await api.createSourceConnection(org, {
|
|
80
|
+
provider_id: spec.id,
|
|
81
|
+
token,
|
|
82
|
+
display_name: opts.name ?? null,
|
|
83
|
+
base_url: opts.baseUrl ?? null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (err instanceof ApiError && err.status === 502) {
|
|
88
|
+
throw new LayeroError("source_rejected", `${spec.title} не принял токен: ${err.body.slice(0, 300)}`, spec.token_hint ?? "проверьте токен и его права");
|
|
89
|
+
}
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
emit({
|
|
93
|
+
event: "source_connected",
|
|
94
|
+
org,
|
|
95
|
+
connection_id: created.id,
|
|
96
|
+
provider: created.provider_id,
|
|
97
|
+
account: created.external_account,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/** `layero sources repos <connection_id>` — репозитории, видимые токену подключения. */
|
|
101
|
+
export async function sourcesReposCmd(connectionId, opts) {
|
|
102
|
+
const api = await makeClient();
|
|
103
|
+
const org = await orgOf(api, opts);
|
|
104
|
+
let repos;
|
|
105
|
+
try {
|
|
106
|
+
repos = await api.listSourceRepos(org, connectionId);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
110
|
+
throw new LayeroError("connection_not_found", `в организации «${org}» нет подключения ${connectionId}`, "`layero sources list`");
|
|
111
|
+
}
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
emit({
|
|
115
|
+
event: "source_repos",
|
|
116
|
+
org,
|
|
117
|
+
connection_id: connectionId,
|
|
118
|
+
repos: repos.map((r) => ({
|
|
119
|
+
path: r.path,
|
|
120
|
+
name: r.name,
|
|
121
|
+
default_branch: r.default_branch,
|
|
122
|
+
private: r.private,
|
|
123
|
+
can_admin: r.can_admin,
|
|
124
|
+
updated_at: r.updated_at,
|
|
125
|
+
})),
|
|
126
|
+
});
|
|
127
|
+
}
|
package/dist/commands/whoami.js
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
import chalk from "chalk";
|
|
2
1
|
import { ApiClient } from "../api.js";
|
|
3
2
|
import { loadConfig } from "../config.js";
|
|
3
|
+
import { LayeroError, emit } from "../agent.js";
|
|
4
4
|
export async function whoamiCmd() {
|
|
5
5
|
const cfg = await loadConfig();
|
|
6
6
|
if (!cfg.token) {
|
|
7
|
-
|
|
8
|
-
process.exitCode = 1;
|
|
9
|
-
return;
|
|
7
|
+
throw new LayeroError("auth_required", "вход не выполнен", "выполните `layero login` или задайте LAYERO_TOKEN");
|
|
10
8
|
}
|
|
11
9
|
const api = new ApiClient(cfg);
|
|
12
10
|
const me = await api.me();
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
emit({
|
|
12
|
+
event: "me",
|
|
13
|
+
id: me.id,
|
|
14
|
+
username: me.username ?? null,
|
|
15
|
+
email: me.email ?? null,
|
|
16
|
+
github_login: me.github_login ?? null,
|
|
17
|
+
});
|
|
19
18
|
}
|
package/dist/config.js
CHANGED
|
@@ -26,6 +26,7 @@ export async function loadConfig() {
|
|
|
26
26
|
// окружения: файл мог остаться от другого аккаунта, и подпись в
|
|
27
27
|
// выводе врала бы. Кто мы — узнаем у API.
|
|
28
28
|
user: ENV_TOKEN ? undefined : parsed.user,
|
|
29
|
+
claim_tokens: parsed.claim_tokens,
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
catch (err) {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Коды выхода CLI по классам ошибок (AX-аудит 17.09.2026, этап 6).
|
|
3
|
+
*
|
|
4
|
+
* До этого любой отказ заканчивался `exit 1`, и скрипт, вызвавший `layero`,
|
|
5
|
+
* не мог отличить «нет токена» от «сборка упала»: приходилось разбирать
|
|
6
|
+
* JSON-событие `error`, а в человеческом режиме — текст. Классы взяты
|
|
7
|
+
* из аудита и совпадают с тем, как их делят Vercel и `gh`:
|
|
8
|
+
*
|
|
9
|
+
* 0 — успех;
|
|
10
|
+
* 1 — прочий отказ (лимит тарифа, нет прав, нужно подтверждение,
|
|
11
|
+
* повторяющаяся ошибка сборки — всё, что не попало в классы ниже);
|
|
12
|
+
* 2 — нужен вход: нет токена, токен протух, вход не подтверждён;
|
|
13
|
+
* 3 — не найдено: проект, организация, база, домен, хук, окружение;
|
|
14
|
+
* 4 — неверный ввод: флаг, аргумент, формат, неподдерживаемая операция;
|
|
15
|
+
* 5 — удалённая ошибка: сборка упала или отменена, 5xx платформы,
|
|
16
|
+
* непредвиденное исключение CLI.
|
|
17
|
+
*
|
|
18
|
+
* Отнесение ПО КОДУ ОШИБКИ, а не по месту выброса: код — контракт (см.
|
|
19
|
+
* `check-error-codes.py`), и класс выхода становится его частью. Новый код,
|
|
20
|
+
* не попавший ни в один список, даёт 1 — честно «прочее», а не наугад.
|
|
21
|
+
*/
|
|
22
|
+
const AUTH = new Set(["auth_required", "auth_expired", "auth_timeout"]);
|
|
23
|
+
const NOT_FOUND = new Set([
|
|
24
|
+
"project_unknown",
|
|
25
|
+
"project_not_found",
|
|
26
|
+
"org_unknown",
|
|
27
|
+
"database_unknown",
|
|
28
|
+
"env_not_found",
|
|
29
|
+
"domain_not_found",
|
|
30
|
+
"hook_not_found",
|
|
31
|
+
"connection_not_found",
|
|
32
|
+
"account_not_found",
|
|
33
|
+
"repo_not_found",
|
|
34
|
+
"claim_unknown",
|
|
35
|
+
"branch_without_env",
|
|
36
|
+
"no_deploy",
|
|
37
|
+
"no_deploys",
|
|
38
|
+
"no_runs",
|
|
39
|
+
"data_key_unknown",
|
|
40
|
+
]);
|
|
41
|
+
const INVALID_INPUT = new Set([
|
|
42
|
+
"invalid_type",
|
|
43
|
+
"invalid_choice",
|
|
44
|
+
"prebuilt_no_dir",
|
|
45
|
+
"prebuilt_no_index",
|
|
46
|
+
"bad_format",
|
|
47
|
+
"nothing_to_set",
|
|
48
|
+
"sql_missing",
|
|
49
|
+
"branch_unsupported",
|
|
50
|
+
"provider_unknown",
|
|
51
|
+
"repo_format",
|
|
52
|
+
"token_missing",
|
|
53
|
+
"username_rejected",
|
|
54
|
+
"gb_not_supported",
|
|
55
|
+
"dedicated_needs_panel",
|
|
56
|
+
"data_key_kind",
|
|
57
|
+
"data_key_expiry",
|
|
58
|
+
"data_key_ambiguous",
|
|
59
|
+
"data_levels_missing",
|
|
60
|
+
"data_level_unknown",
|
|
61
|
+
"data_probe_method",
|
|
62
|
+
"data_probe_path",
|
|
63
|
+
"data_probe_query",
|
|
64
|
+
"data_probe_body",
|
|
65
|
+
"data_probe_as",
|
|
66
|
+
"data_probe_user_required",
|
|
67
|
+
"data_probe_user_invalid",
|
|
68
|
+
"data_probe_schema",
|
|
69
|
+
"data_probe_expect",
|
|
70
|
+
]);
|
|
71
|
+
const REMOTE = new Set([
|
|
72
|
+
"deploy_failed",
|
|
73
|
+
"deploy_cancelled",
|
|
74
|
+
"deploy_not_started",
|
|
75
|
+
"internal",
|
|
76
|
+
"oauth_unavailable",
|
|
77
|
+
"claimable_unavailable",
|
|
78
|
+
"data_probe_gateway_failed",
|
|
79
|
+
]);
|
|
80
|
+
export const EXIT_OK = 0;
|
|
81
|
+
export const EXIT_OTHER = 1;
|
|
82
|
+
export const EXIT_AUTH = 2;
|
|
83
|
+
export const EXIT_NOT_FOUND = 3;
|
|
84
|
+
export const EXIT_INVALID_INPUT = 4;
|
|
85
|
+
export const EXIT_REMOTE = 5;
|
|
86
|
+
export function exitCodeFor(code) {
|
|
87
|
+
if (AUTH.has(code))
|
|
88
|
+
return EXIT_AUTH;
|
|
89
|
+
if (NOT_FOUND.has(code))
|
|
90
|
+
return EXIT_NOT_FOUND;
|
|
91
|
+
if (INVALID_INPUT.has(code))
|
|
92
|
+
return EXIT_INVALID_INPUT;
|
|
93
|
+
if (REMOTE.has(code))
|
|
94
|
+
return EXIT_REMOTE;
|
|
95
|
+
// `deploy_<status>` собирается динамически; `http_5xx` — отказ платформы,
|
|
96
|
+
// который не разобрали в понятный код.
|
|
97
|
+
if (code.startsWith("deploy_"))
|
|
98
|
+
return EXIT_REMOTE;
|
|
99
|
+
if (/^http_5\d\d$/.test(code))
|
|
100
|
+
return EXIT_REMOTE;
|
|
101
|
+
return EXIT_OTHER;
|
|
102
|
+
}
|
package/dist/project-config.js
CHANGED
|
@@ -67,6 +67,8 @@ export async function persistProjectLinking(cwd, linking, fallbackHint) {
|
|
|
67
67
|
};
|
|
68
68
|
if (linking.api_url !== undefined)
|
|
69
69
|
merged.api_url = linking.api_url;
|
|
70
|
+
if (linking.claim !== undefined)
|
|
71
|
+
merged.claim = linking.claim;
|
|
70
72
|
// Only seed framework_hint if the user hasn't set one yet — once it's in
|
|
71
73
|
// the file (manually or from a prior --type), leave it alone.
|
|
72
74
|
if (fallbackHint != null &&
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ссылка на проект: идентификатор или слаг, и как одно отличить от другого.
|
|
3
|
+
*
|
|
4
|
+
* 🚨 ОТДЕЛЬНЫЙ МОДУЛЬ РАДИ ОДНОЙ СТРОКИ — намеренно. Форма UUID нужна и
|
|
5
|
+
* клиенту API, и команде деплоя; жила она копией в `commands/deploy.ts`, и
|
|
6
|
+
* второй копией разошлась бы молча. Положить её в `api.ts` не вышло: тесты
|
|
7
|
+
* подменяют этот модуль целиком (`vi.mock`), и константа исчезала бы вместе с
|
|
8
|
+
* ним — то есть общий модуль стал бы способом уронить чужой тест.
|
|
9
|
+
*/
|
|
10
|
+
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
/**
|
|
12
|
+
* Что нам дали — идентификатор или слаг.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ Отличаем по ФОРМЕ, а не по успеху запроса: UUID, ушедший в поиск по
|
|
15
|
+
* слагу, просто не найдётся, и человек получит «нет такого проекта» на
|
|
16
|
+
* совершенно правильный идентификатор.
|
|
17
|
+
*/
|
|
18
|
+
export function looksLikeId(ref) {
|
|
19
|
+
return UUID_RE.test(ref);
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "layero",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Layero CLI
|
|
3
|
+
"version": "0.10.1",
|
|
4
|
+
"description": "Layero CLI — deploy a directory or a repo to hosting with build servers in Russia. JSON events and exit codes for AI agents and CI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"homepage": "https://layero.ru",
|
|
7
|
+
"homepage": "https://docs.layero.ru/cli/",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/LayeroInfra/cli.git"
|
|
11
|
+
},
|
|
8
12
|
"bugs": {
|
|
9
|
-
"url": "https://
|
|
13
|
+
"url": "https://github.com/LayeroInfra/cli/issues"
|
|
10
14
|
},
|
|
11
15
|
"keywords": [
|
|
12
16
|
"layero",
|
|
@@ -39,7 +43,8 @@
|
|
|
39
43
|
"files": [
|
|
40
44
|
"dist",
|
|
41
45
|
"scripts/postinstall.cjs",
|
|
42
|
-
"README.md"
|
|
46
|
+
"README.md",
|
|
47
|
+
"LICENSE"
|
|
43
48
|
],
|
|
44
49
|
"scripts": {
|
|
45
50
|
"build": "tsc -p tsconfig.json",
|