biaws 0.1.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/LICENSE +201 -0
- package/README.md +229 -0
- package/bin/biaws.js +3 -0
- package/docs/command-taxonomy.md +36 -0
- package/docs/instance-lifecycle.md +82 -0
- package/docs/releasing.md +105 -0
- package/package.json +63 -0
- package/shared/skillPackage.js +100 -0
- package/src/apiClient.js +89 -0
- package/src/baseCommands.js +122 -0
- package/src/commands/agent/configure.js +3 -0
- package/src/commands/agent/doctor.js +3 -0
- package/src/commands/agent.js +400 -0
- package/src/commands/api.js +17 -0
- package/src/commands/applications/get.js +17 -0
- package/src/commands/applications/list.js +32 -0
- package/src/commands/applications.js +7 -0
- package/src/commands/configure/claude.js +25 -0
- package/src/commands/configure/codex.js +25 -0
- package/src/commands/configure/doctor.js +49 -0
- package/src/commands/configure/skills.js +74 -0
- package/src/commands/configure.js +19 -0
- package/src/commands/demands/complete-task.js +6 -0
- package/src/commands/demands/get.js +16 -0
- package/src/commands/demands/list.js +48 -0
- package/src/commands/demands/task-status.js +49 -0
- package/src/commands/demands/tasks.js +44 -0
- package/src/commands/demands.js +7 -0
- package/src/commands/instance/backup.js +74 -0
- package/src/commands/instance/list.js +39 -0
- package/src/commands/instance/remove.js +72 -0
- package/src/commands/instance/restore.js +96 -0
- package/src/commands/instance/setup.js +349 -0
- package/src/commands/instance/show.js +31 -0
- package/src/commands/instance/start.js +32 -0
- package/src/commands/instance/status.js +42 -0
- package/src/commands/instance/stop.js +30 -0
- package/src/commands/instance.js +18 -0
- package/src/commands/issues/get.js +16 -0
- package/src/commands/issues/list.js +51 -0
- package/src/commands/issues/transition.js +33 -0
- package/src/commands/issues.js +7 -0
- package/src/commands/monitoring/describe.js +13 -0
- package/src/commands/monitoring/signal.js +25 -0
- package/src/commands/monitoring/signals.js +17 -0
- package/src/commands/monitoring/validate.js +14 -0
- package/src/commands/monitoring.js +163 -0
- package/src/commands/skills/install-all.js +13 -0
- package/src/commands/skills/install.js +16 -0
- package/src/commands/skills/list.js +5 -0
- package/src/commands/skills/publish-all.js +14 -0
- package/src/commands/skills/publish.js +11 -0
- package/src/commands/skills/status.js +7 -0
- package/src/commands/skills/update.js +12 -0
- package/src/commands/skills.js +303 -0
- package/src/commands/workspaces/get.js +15 -0
- package/src/commands/workspaces/list.js +20 -0
- package/src/commands/workspaces.js +7 -0
- package/src/compatibilityCommands.js +107 -0
- package/src/configure/command.js +50 -0
- package/src/core/context.js +162 -0
- package/src/core/errors.js +25 -0
- package/src/core/processRunner.js +119 -0
- package/src/core/prompts.js +138 -0
- package/src/core/redaction.js +29 -0
- package/src/core/terminal.js +72 -0
- package/src/core/wizard.js +279 -0
- package/src/domain/readCommand.js +65 -0
- package/src/domain/readService.js +126 -0
- package/src/domain/writeCommand.js +91 -0
- package/src/domain/writeService.js +122 -0
- package/src/index.js +5 -0
- package/src/instance/command.js +37 -0
- package/src/instance/service.js +515 -0
- package/src/localSkills.js +143 -0
- package/src/skillPackage.js +4 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Args, Flags } from "@oclif/core";
|
|
2
|
+
|
|
3
|
+
export const instanceArgument = Args.string({
|
|
4
|
+
description:
|
|
5
|
+
"nome da instância; usa BIAWS_INSTANCE ou a única disponível quando omitido",
|
|
6
|
+
required: false,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const contextFlags = Object.freeze({
|
|
10
|
+
"instances-dir": Flags.string({
|
|
11
|
+
description: "diretório que contém as instâncias",
|
|
12
|
+
}),
|
|
13
|
+
root: Flags.string({ description: "raiz do checkout BIAWS" }),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export function contextInput(flags, instance) {
|
|
17
|
+
return {
|
|
18
|
+
instance,
|
|
19
|
+
instancesDirectory: flags["instances-dir"],
|
|
20
|
+
root: flags.root,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function writeResult(command, value, human, json = false) {
|
|
25
|
+
command.output({ json }).result(json ? value : human(value));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function instanceSummary(instance) {
|
|
29
|
+
return [
|
|
30
|
+
`Instância: ${instance.name}`,
|
|
31
|
+
`Diretório: ${instance.directory}`,
|
|
32
|
+
`MongoDB: mongodb://127.0.0.1:${instance.mongoPort}/biaws`,
|
|
33
|
+
`API: http://127.0.0.1:${instance.apiPort}`,
|
|
34
|
+
`UI: ${instance.publicUrl}`,
|
|
35
|
+
`Storage: ${instance.storage}`,
|
|
36
|
+
].join("\n");
|
|
37
|
+
}
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
|
|
4
|
+
import { CliError } from "../core/errors.js";
|
|
5
|
+
import { parseEnv } from "../core/context.js";
|
|
6
|
+
|
|
7
|
+
const PORT_FIELDS = Object.freeze({
|
|
8
|
+
mongoPort: "MONGO_PORT",
|
|
9
|
+
apiPort: "ISSUE_API_PORT",
|
|
10
|
+
uiPort: "ISSUE_UI_PORT",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const STORAGE_FIELDS = Object.freeze({
|
|
14
|
+
mongoPath: "BIAWS_MONGO_DATA_PATH",
|
|
15
|
+
issuePath: "BIAWS_ISSUE_FILES_PATH",
|
|
16
|
+
requestPath: "BIAWS_REQUEST_FILES_PATH",
|
|
17
|
+
documentPath: "BIAWS_DOCUMENT_FILES_PATH",
|
|
18
|
+
secretPath: "BIAWS_SECRET_FILES_PATH",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function usageError(message, code, details) {
|
|
22
|
+
throw new CliError(message, { code, details, exitCode: 2 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function integer(value, label) {
|
|
26
|
+
const parsed = Number(value);
|
|
27
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
28
|
+
usageError(`${label} deve ser um inteiro positivo.`, "INVALID_INTEGER");
|
|
29
|
+
}
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function validateInstanceName(value) {
|
|
34
|
+
const name = String(value || "").trim();
|
|
35
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/u.test(name)) {
|
|
36
|
+
usageError(
|
|
37
|
+
"Nome de instância inválido: use letras minúsculas, números e hífens.",
|
|
38
|
+
"INVALID_INSTANCE_NAME",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return name;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function validatePort(value, label = "Porta") {
|
|
45
|
+
const port = integer(value, label);
|
|
46
|
+
if (port > 65_535) usageError(`${label} inválida.`, "INVALID_PORT");
|
|
47
|
+
return port;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function validatePublicUrl(value) {
|
|
51
|
+
let url;
|
|
52
|
+
try {
|
|
53
|
+
url = new URL(String(value));
|
|
54
|
+
} catch {
|
|
55
|
+
usageError("URL pública inválida.", "INVALID_PUBLIC_URL");
|
|
56
|
+
}
|
|
57
|
+
if (
|
|
58
|
+
!["http:", "https:"].includes(url.protocol) ||
|
|
59
|
+
url.username ||
|
|
60
|
+
url.password ||
|
|
61
|
+
url.pathname !== "/" ||
|
|
62
|
+
url.search ||
|
|
63
|
+
url.hash
|
|
64
|
+
) {
|
|
65
|
+
usageError(
|
|
66
|
+
"A URL pública deve ser uma origem HTTP(S), sem credenciais, caminho, query ou fragmento.",
|
|
67
|
+
"INVALID_PUBLIC_URL",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return url.origin;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function validateStoragePath(value, label) {
|
|
74
|
+
if (!value) return "";
|
|
75
|
+
const resolved = path.resolve(String(value));
|
|
76
|
+
if (
|
|
77
|
+
!path.isAbsolute(String(value)) ||
|
|
78
|
+
resolved === path.parse(resolved).root
|
|
79
|
+
) {
|
|
80
|
+
usageError(
|
|
81
|
+
`${label} deve ser absoluto e não pode ser a raiz.`,
|
|
82
|
+
"UNSAFE_STORAGE_PATH",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (/[#$:'"\\]/u.test(String(value))) {
|
|
86
|
+
usageError(
|
|
87
|
+
`${label} contém caractere incompatível com Compose.`,
|
|
88
|
+
"UNSAFE_STORAGE_PATH",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return resolved;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validateDistinctPaths(paths) {
|
|
95
|
+
const populated = paths.filter(Boolean).map((value) => path.resolve(value));
|
|
96
|
+
for (let index = 0; index < populated.length; index += 1) {
|
|
97
|
+
for (let nested = index + 1; nested < populated.length; nested += 1) {
|
|
98
|
+
const left = `${populated[index]}${path.sep}`;
|
|
99
|
+
const right = `${populated[nested]}${path.sep}`;
|
|
100
|
+
if (left.startsWith(right) || right.startsWith(left)) {
|
|
101
|
+
usageError(
|
|
102
|
+
"Diretórios persistentes não podem ser iguais nem aninhados.",
|
|
103
|
+
"OVERLAPPING_STORAGE_PATHS",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function readOptional(filesystem, filePath) {
|
|
111
|
+
try {
|
|
112
|
+
return await filesystem.readFile(filePath, "utf8");
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error?.code === "ENOENT") return null;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function listInstances(context, filesystem) {
|
|
120
|
+
let entries;
|
|
121
|
+
try {
|
|
122
|
+
entries = await filesystem.readdir(context.instancesDirectory, {
|
|
123
|
+
withFileTypes: true,
|
|
124
|
+
});
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error?.code === "ENOENT") return [];
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
const instances = [];
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
if (!entry.isDirectory()) continue;
|
|
132
|
+
const directory = path.join(context.instancesDirectory, entry.name);
|
|
133
|
+
const contents = await readOptional(
|
|
134
|
+
filesystem,
|
|
135
|
+
path.join(directory, ".env"),
|
|
136
|
+
);
|
|
137
|
+
if (contents === null) continue;
|
|
138
|
+
const env = parseEnv(contents);
|
|
139
|
+
instances.push({
|
|
140
|
+
name: entry.name,
|
|
141
|
+
directory,
|
|
142
|
+
envFile: path.join(directory, ".env"),
|
|
143
|
+
mongoPort: Number(env.MONGO_PORT || 27_017),
|
|
144
|
+
apiPort: Number(env.ISSUE_API_PORT || 3_100),
|
|
145
|
+
uiPort: Number(env.ISSUE_UI_PORT || 4_400),
|
|
146
|
+
publicUrl:
|
|
147
|
+
env.BIAWS_PUBLIC_URL ||
|
|
148
|
+
`http://localhost:${env.ISSUE_UI_PORT || 4_400}`,
|
|
149
|
+
storage: Object.values(STORAGE_FIELDS).some((key) => env[key])
|
|
150
|
+
? "directories"
|
|
151
|
+
: "volumes",
|
|
152
|
+
env,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return instances.sort((left, right) => left.name.localeCompare(right.name));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function getInstance(context, filesystem, name) {
|
|
159
|
+
const instances = await listInstances(context, filesystem);
|
|
160
|
+
const selected = String(name || context.instanceName || "").trim();
|
|
161
|
+
if (!selected && instances.length === 1) return instances[0];
|
|
162
|
+
if (!selected) {
|
|
163
|
+
usageError(
|
|
164
|
+
"Informe a instância ou defina BIAWS_INSTANCE.",
|
|
165
|
+
"INSTANCE_REQUIRED",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
const normalized = validateInstanceName(selected);
|
|
169
|
+
const instance = instances.find((item) => item.name === normalized);
|
|
170
|
+
if (!instance) {
|
|
171
|
+
throw new CliError(`Instância não encontrada: ${normalized}.`, {
|
|
172
|
+
code: "INSTANCE_NOT_FOUND",
|
|
173
|
+
exitCode: 3,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return instance;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function resolveStorage(values, existing) {
|
|
180
|
+
if (values.storage === "volumes") {
|
|
181
|
+
if (Object.keys(STORAGE_FIELDS).some((field) => values[field])) {
|
|
182
|
+
usageError(
|
|
183
|
+
"Storage em volumes não pode ser combinado com caminhos do host.",
|
|
184
|
+
"STORAGE_MODE_CONFLICT",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return Object.fromEntries(
|
|
188
|
+
Object.keys(STORAGE_FIELDS).map((key) => [key, ""]),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const root = validateStoragePath(values.storageRoot, "Raiz de storage");
|
|
192
|
+
const storage = {};
|
|
193
|
+
const defaults = {
|
|
194
|
+
mongoPath: "mongo",
|
|
195
|
+
issuePath: "issues",
|
|
196
|
+
requestPath: "requests",
|
|
197
|
+
documentPath: "documents",
|
|
198
|
+
secretPath: "secrets",
|
|
199
|
+
};
|
|
200
|
+
for (const [name, envName] of Object.entries(STORAGE_FIELDS)) {
|
|
201
|
+
storage[name] = validateStoragePath(
|
|
202
|
+
values[name] ||
|
|
203
|
+
(root ? path.join(root, defaults[name]) : existing?.env[envName]),
|
|
204
|
+
name,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (Object.values(storage).some((value) => !value)) {
|
|
208
|
+
usageError(
|
|
209
|
+
"Informe --storage-root ou todos os caminhos para storage em diretórios.",
|
|
210
|
+
"INCOMPLETE_STORAGE_PATHS",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
validateDistinctPaths(Object.values(storage));
|
|
214
|
+
return storage;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function buildSetupConfiguration(values, context, filesystem) {
|
|
218
|
+
const name = validateInstanceName(values.name);
|
|
219
|
+
const instances = await listInstances(context, filesystem);
|
|
220
|
+
const existing = instances.find((item) => item.name === name);
|
|
221
|
+
const ports = {
|
|
222
|
+
mongoPort: validatePort(
|
|
223
|
+
values.mongoPort ?? existing?.mongoPort ?? 27_017,
|
|
224
|
+
"Porta MongoDB",
|
|
225
|
+
),
|
|
226
|
+
apiPort: validatePort(
|
|
227
|
+
values.apiPort ?? existing?.apiPort ?? 3_100,
|
|
228
|
+
"Porta API",
|
|
229
|
+
),
|
|
230
|
+
uiPort: validatePort(
|
|
231
|
+
values.uiPort ?? existing?.uiPort ?? 4_400,
|
|
232
|
+
"Porta UI",
|
|
233
|
+
),
|
|
234
|
+
};
|
|
235
|
+
if (new Set(Object.values(ports)).size !== 3) {
|
|
236
|
+
usageError(
|
|
237
|
+
"MongoDB, API e UI devem usar portas distintas.",
|
|
238
|
+
"PORT_COLLISION",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
for (const instance of instances) {
|
|
242
|
+
if (instance.name === name) continue;
|
|
243
|
+
const reserved = new Set([
|
|
244
|
+
instance.mongoPort,
|
|
245
|
+
instance.apiPort,
|
|
246
|
+
instance.uiPort,
|
|
247
|
+
]);
|
|
248
|
+
for (const port of Object.values(ports)) {
|
|
249
|
+
if (reserved.has(port)) {
|
|
250
|
+
usageError(
|
|
251
|
+
`A porta ${port} já pertence à instância ${instance.name}.`,
|
|
252
|
+
"PORT_COLLISION",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const storage = resolveStorage(values, existing);
|
|
258
|
+
const previousStorage = existing
|
|
259
|
+
? Object.fromEntries(
|
|
260
|
+
Object.entries(STORAGE_FIELDS).map(([key, envName]) => [
|
|
261
|
+
key,
|
|
262
|
+
existing.env[envName] || "",
|
|
263
|
+
]),
|
|
264
|
+
)
|
|
265
|
+
: null;
|
|
266
|
+
const storageChanged = Boolean(
|
|
267
|
+
previousStorage &&
|
|
268
|
+
Object.keys(STORAGE_FIELDS).some(
|
|
269
|
+
(key) => previousStorage[key] !== storage[key],
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
return Object.freeze({
|
|
273
|
+
name,
|
|
274
|
+
...ports,
|
|
275
|
+
...storage,
|
|
276
|
+
publicUrl: validatePublicUrl(
|
|
277
|
+
values.publicUrl || `http://localhost:${ports.uiPort}`,
|
|
278
|
+
),
|
|
279
|
+
storage: values.storage,
|
|
280
|
+
storageChanged,
|
|
281
|
+
adminEmail: String(values.adminEmail).trim(),
|
|
282
|
+
adminName: String(values.adminName).trim(),
|
|
283
|
+
adminPassword: values.adminPassword,
|
|
284
|
+
demoSeed: Boolean(values.demoSeed),
|
|
285
|
+
disableRateLimit: Boolean(values.disableRateLimit),
|
|
286
|
+
apiRateLimitMax: integer(values.apiRateLimitMax, "Rate limit da API"),
|
|
287
|
+
apiRateLimitWindow: integer(values.apiRateLimitWindow, "Janela da API"),
|
|
288
|
+
authRateLimitMax: integer(
|
|
289
|
+
values.authRateLimitMax,
|
|
290
|
+
"Rate limit de autenticação",
|
|
291
|
+
),
|
|
292
|
+
authRateLimitWindow: integer(
|
|
293
|
+
values.authRateLimitWindow,
|
|
294
|
+
"Janela de autenticação",
|
|
295
|
+
),
|
|
296
|
+
apiKeyRateLimitMax: integer(
|
|
297
|
+
values.apiKeyRateLimitMax,
|
|
298
|
+
"Rate limit da API key",
|
|
299
|
+
),
|
|
300
|
+
apiKeyRateLimitWindow: integer(
|
|
301
|
+
values.apiKeyRateLimitWindow,
|
|
302
|
+
"Janela da API key",
|
|
303
|
+
),
|
|
304
|
+
existing: Boolean(existing),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function setupArguments(configuration, context) {
|
|
309
|
+
const args = [
|
|
310
|
+
path.join(context.repositoryRoot, "scripts", "setup-server.sh"),
|
|
311
|
+
"--instance",
|
|
312
|
+
configuration.name,
|
|
313
|
+
"--instances-dir",
|
|
314
|
+
context.instancesDirectory,
|
|
315
|
+
"--public-url",
|
|
316
|
+
configuration.publicUrl,
|
|
317
|
+
"--mongo-port",
|
|
318
|
+
String(configuration.mongoPort),
|
|
319
|
+
"--api-port",
|
|
320
|
+
String(configuration.apiPort),
|
|
321
|
+
"--ui-port",
|
|
322
|
+
String(configuration.uiPort),
|
|
323
|
+
"--api-rate-limit-max",
|
|
324
|
+
String(configuration.apiRateLimitMax),
|
|
325
|
+
"--api-rate-limit-window-seconds",
|
|
326
|
+
String(configuration.apiRateLimitWindow),
|
|
327
|
+
"--auth-rate-limit-max",
|
|
328
|
+
String(configuration.authRateLimitMax),
|
|
329
|
+
"--auth-rate-limit-window-seconds",
|
|
330
|
+
String(configuration.authRateLimitWindow),
|
|
331
|
+
"--api-key-rate-limit-max",
|
|
332
|
+
String(configuration.apiKeyRateLimitMax),
|
|
333
|
+
"--api-key-rate-limit-window-seconds",
|
|
334
|
+
String(configuration.apiKeyRateLimitWindow),
|
|
335
|
+
];
|
|
336
|
+
if (configuration.disableRateLimit) args.push("--disable-rate-limit");
|
|
337
|
+
if (configuration.storage === "volumes") args.push("--use-docker-volumes");
|
|
338
|
+
else {
|
|
339
|
+
for (const [name, flag] of [
|
|
340
|
+
["mongoPath", "--mongo-data-path"],
|
|
341
|
+
["issuePath", "--issue-files-path"],
|
|
342
|
+
["requestPath", "--request-files-path"],
|
|
343
|
+
["documentPath", "--document-files-path"],
|
|
344
|
+
["secretPath", "--secret-files-path"],
|
|
345
|
+
])
|
|
346
|
+
args.push(flag, configuration[name]);
|
|
347
|
+
}
|
|
348
|
+
return args;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function executeSetup(
|
|
352
|
+
configuration,
|
|
353
|
+
context,
|
|
354
|
+
processRunner,
|
|
355
|
+
environment,
|
|
356
|
+
options = {},
|
|
357
|
+
) {
|
|
358
|
+
const args = setupArguments(configuration, context);
|
|
359
|
+
await processRunner.run("bash", args, {
|
|
360
|
+
cwd: context.repositoryRoot,
|
|
361
|
+
env: {
|
|
362
|
+
...environment,
|
|
363
|
+
BIAWS_BOOTSTRAP_ADMIN_EMAIL: configuration.adminEmail,
|
|
364
|
+
BIAWS_BOOTSTRAP_ADMIN_NAME: configuration.adminName,
|
|
365
|
+
BIAWS_BOOTSTRAP_ADMIN_PASSWORD: configuration.adminPassword,
|
|
366
|
+
BIAWS_SKIP_DEMO_SEED: configuration.demoSeed ? "0" : "1",
|
|
367
|
+
},
|
|
368
|
+
secrets: [configuration.adminPassword],
|
|
369
|
+
silent: Boolean(options.silent),
|
|
370
|
+
});
|
|
371
|
+
return {
|
|
372
|
+
name: configuration.name,
|
|
373
|
+
ui: configuration.publicUrl,
|
|
374
|
+
api: `http://127.0.0.1:${configuration.apiPort}`,
|
|
375
|
+
mongo: `mongodb://127.0.0.1:${configuration.mongoPort}/biaws`,
|
|
376
|
+
storage: configuration.storage,
|
|
377
|
+
storageChanged: configuration.storageChanged,
|
|
378
|
+
commands: {
|
|
379
|
+
status: `biaws instance status ${configuration.name}`,
|
|
380
|
+
start: `biaws instance start ${configuration.name}`,
|
|
381
|
+
stop: `biaws instance stop ${configuration.name}`,
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function composeArguments(instance, context, operation) {
|
|
387
|
+
const base = [
|
|
388
|
+
"compose",
|
|
389
|
+
"--project-directory",
|
|
390
|
+
context.repositoryRoot,
|
|
391
|
+
"--file",
|
|
392
|
+
path.join(context.repositoryRoot, "compose.yaml"),
|
|
393
|
+
"--env-file",
|
|
394
|
+
instance.envFile,
|
|
395
|
+
"--project-name",
|
|
396
|
+
`biaws-${instance.name}`,
|
|
397
|
+
];
|
|
398
|
+
if (operation === "start") return [...base, "up", "-d", "--wait"];
|
|
399
|
+
if (operation === "stop") return [...base, "stop"];
|
|
400
|
+
return [...base, "ps", "--format", "json"];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export async function operateInstance(
|
|
404
|
+
instance,
|
|
405
|
+
context,
|
|
406
|
+
processRunner,
|
|
407
|
+
operation,
|
|
408
|
+
) {
|
|
409
|
+
const result = await processRunner.run(
|
|
410
|
+
"docker",
|
|
411
|
+
composeArguments(instance, context, operation),
|
|
412
|
+
{ cwd: context.repositoryRoot, silent: true },
|
|
413
|
+
);
|
|
414
|
+
return { instance: instance.name, operation, output: result.stdout.trim() };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function validateArchivePath(value) {
|
|
418
|
+
const archive = path.resolve(String(value || ""));
|
|
419
|
+
if (!value || archive === path.parse(archive).root) {
|
|
420
|
+
usageError("Informe um arquivo de backup válido.", "INVALID_ARCHIVE_PATH");
|
|
421
|
+
}
|
|
422
|
+
return archive;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function backupArguments(instance, context, options = {}) {
|
|
426
|
+
const args = [
|
|
427
|
+
path.join(context.repositoryRoot, "scripts", "backup-instance.sh"),
|
|
428
|
+
"--instance",
|
|
429
|
+
instance.name,
|
|
430
|
+
"--instances-dir",
|
|
431
|
+
context.instancesDirectory,
|
|
432
|
+
];
|
|
433
|
+
if (options.output)
|
|
434
|
+
args.push("--output", validateArchivePath(options.output));
|
|
435
|
+
if (options.passwordFile)
|
|
436
|
+
args.push("--password-file", path.resolve(options.passwordFile));
|
|
437
|
+
return args;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function restoreArguments(instance, context, options) {
|
|
441
|
+
const args = [
|
|
442
|
+
path.join(context.repositoryRoot, "scripts", "restore-instance.sh"),
|
|
443
|
+
"--instance",
|
|
444
|
+
instance.name,
|
|
445
|
+
"--instances-dir",
|
|
446
|
+
context.instancesDirectory,
|
|
447
|
+
"--archive",
|
|
448
|
+
validateArchivePath(options.archive),
|
|
449
|
+
"--yes",
|
|
450
|
+
];
|
|
451
|
+
if (options.passwordFile)
|
|
452
|
+
args.push("--password-file", path.resolve(options.passwordFile));
|
|
453
|
+
return args;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function removeArguments(instance, context, options = {}) {
|
|
457
|
+
const args = [
|
|
458
|
+
path.join(context.repositoryRoot, "scripts", "remove-instance.sh"),
|
|
459
|
+
"--instance",
|
|
460
|
+
instance.name,
|
|
461
|
+
"--instances-dir",
|
|
462
|
+
context.instancesDirectory,
|
|
463
|
+
"--yes",
|
|
464
|
+
];
|
|
465
|
+
if (options.deleteExternalData) args.push("--delete-external-data");
|
|
466
|
+
return args;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export async function withPasswordFile(
|
|
470
|
+
filesystem,
|
|
471
|
+
password,
|
|
472
|
+
suppliedFile,
|
|
473
|
+
operation,
|
|
474
|
+
) {
|
|
475
|
+
if (suppliedFile) return operation(path.resolve(suppliedFile));
|
|
476
|
+
if (!password) {
|
|
477
|
+
usageError(
|
|
478
|
+
"Informe --password-file ou forneça a senha em modo interativo.",
|
|
479
|
+
"BACKUP_PASSWORD_REQUIRED",
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
if (String(password).length < 12) {
|
|
483
|
+
usageError(
|
|
484
|
+
"A senha do backup deve ter pelo menos 12 caracteres.",
|
|
485
|
+
"WEAK_BACKUP_PASSWORD",
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
const directory = await filesystem.mkdtemp(
|
|
489
|
+
path.join(os.tmpdir(), "biaws-password-"),
|
|
490
|
+
);
|
|
491
|
+
const passwordFile = path.join(directory, "password");
|
|
492
|
+
try {
|
|
493
|
+
await filesystem.writeFile(passwordFile, `${password}\n`, { mode: 0o600 });
|
|
494
|
+
await filesystem.chmod(passwordFile, 0o600);
|
|
495
|
+
return await operation(passwordFile);
|
|
496
|
+
} finally {
|
|
497
|
+
await filesystem.rm(directory, { recursive: true, force: true });
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export async function executeInstanceScript(
|
|
502
|
+
args,
|
|
503
|
+
context,
|
|
504
|
+
processRunner,
|
|
505
|
+
options = {},
|
|
506
|
+
) {
|
|
507
|
+
const result = await processRunner.run("bash", args, {
|
|
508
|
+
cwd: context.repositoryRoot,
|
|
509
|
+
secrets: options.secrets || [],
|
|
510
|
+
silent: Boolean(options.silent),
|
|
511
|
+
});
|
|
512
|
+
return result.stdout.trim();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export { PORT_FIELDS, STORAGE_FIELDS };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
lstat,
|
|
4
|
+
mkdir,
|
|
5
|
+
readdir,
|
|
6
|
+
readFile,
|
|
7
|
+
rename,
|
|
8
|
+
rm,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
function lockPath(targetDirectory) {
|
|
14
|
+
return path.join(path.dirname(targetDirectory), "biaws-skills.lock.json");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function exists(filePath) {
|
|
18
|
+
try {
|
|
19
|
+
await lstat(filePath);
|
|
20
|
+
return true;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error.code === "ENOENT") return false;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function readLock(targetDirectory) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(await readFile(lockPath(targetDirectory), "utf8"));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error.code === "ENOENT")
|
|
32
|
+
return { format: "biaws-skills-lock/v1", skills: {} };
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function collectInstalledFiles(root, current = root, result = []) {
|
|
38
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
39
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
const absolutePath = path.join(current, entry.name);
|
|
42
|
+
if (entry.isSymbolicLink())
|
|
43
|
+
throw new Error(`Link simbólico inesperado: ${absolutePath}`);
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
await collectInstalledFiles(root, absolutePath, result);
|
|
46
|
+
} else if (entry.isFile()) {
|
|
47
|
+
result.push({
|
|
48
|
+
path: path.relative(root, absolutePath).split(path.sep).join("/"),
|
|
49
|
+
absolutePath,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function checksumInstalledSkill(skillDirectory) {
|
|
57
|
+
if (!(await exists(skillDirectory))) return null;
|
|
58
|
+
const files = await collectInstalledFiles(skillDirectory);
|
|
59
|
+
const hash = crypto.createHash("sha256");
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
const content = await readFile(file.absolutePath);
|
|
62
|
+
const fileHash = crypto.createHash("sha256").update(content).digest("hex");
|
|
63
|
+
hash.update(file.path);
|
|
64
|
+
hash.update("\0");
|
|
65
|
+
hash.update(fileHash);
|
|
66
|
+
hash.update("\0");
|
|
67
|
+
}
|
|
68
|
+
return hash.digest("hex");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function writeLock(targetDirectory, lock) {
|
|
72
|
+
await mkdir(path.dirname(targetDirectory), { recursive: true });
|
|
73
|
+
await writeFile(
|
|
74
|
+
lockPath(targetDirectory),
|
|
75
|
+
`${JSON.stringify(lock, null, 2)}\n`,
|
|
76
|
+
"utf8",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function safeDestination(root, relativePath) {
|
|
81
|
+
const normalized = String(relativePath || "").replaceAll("\\", "/");
|
|
82
|
+
if (
|
|
83
|
+
normalized.startsWith("/") ||
|
|
84
|
+
normalized.split("/").some((part) => !part || part === "." || part === "..")
|
|
85
|
+
) {
|
|
86
|
+
throw new Error(`Caminho inválido no pacote: ${relativePath}`);
|
|
87
|
+
}
|
|
88
|
+
const destination = path.resolve(root, normalized);
|
|
89
|
+
if (!destination.startsWith(`${path.resolve(root)}${path.sep}`)) {
|
|
90
|
+
throw new Error(`Caminho fora do diretório da skill: ${relativePath}`);
|
|
91
|
+
}
|
|
92
|
+
return destination;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function installSkillPackage(
|
|
96
|
+
skillPackage,
|
|
97
|
+
targetDirectory,
|
|
98
|
+
options = {},
|
|
99
|
+
) {
|
|
100
|
+
if (
|
|
101
|
+
skillPackage?.format !== "biaws-skill-package/v1" ||
|
|
102
|
+
!skillPackage.skill
|
|
103
|
+
) {
|
|
104
|
+
throw new Error("Pacote de skill inválido ou incompatível");
|
|
105
|
+
}
|
|
106
|
+
const skill = skillPackage.skill;
|
|
107
|
+
const finalDirectory = path.join(
|
|
108
|
+
path.resolve(targetDirectory),
|
|
109
|
+
skill.skillId,
|
|
110
|
+
);
|
|
111
|
+
const temporaryDirectory = `${finalDirectory}.install-${crypto.randomUUID()}`;
|
|
112
|
+
await mkdir(temporaryDirectory, { recursive: true });
|
|
113
|
+
try {
|
|
114
|
+
for (const file of skill.files || []) {
|
|
115
|
+
const destination = safeDestination(temporaryDirectory, file.path);
|
|
116
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
117
|
+
await writeFile(destination, Buffer.from(file.contentBase64, "base64"));
|
|
118
|
+
}
|
|
119
|
+
if (await exists(finalDirectory)) {
|
|
120
|
+
if (!options.force) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`A skill já existe em ${finalDirectory}; use --force para substituí-la`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const backupDirectory = `${finalDirectory}.backup-${new Date().toISOString().replace(/[:.]/gu, "-")}`;
|
|
126
|
+
await rename(finalDirectory, backupDirectory);
|
|
127
|
+
}
|
|
128
|
+
await mkdir(path.dirname(finalDirectory), { recursive: true });
|
|
129
|
+
await rename(temporaryDirectory, finalDirectory);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const lock = await readLock(targetDirectory);
|
|
136
|
+
lock.skills[skill.skillId] = {
|
|
137
|
+
version: skill.version,
|
|
138
|
+
packageSha256: skill.packageSha256,
|
|
139
|
+
installedAt: new Date().toISOString(),
|
|
140
|
+
};
|
|
141
|
+
await writeLock(targetDirectory, lock);
|
|
142
|
+
return { directory: finalDirectory, skill: lock.skills[skill.skillId] };
|
|
143
|
+
}
|